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

Open
jcwalker3 wants to merge 3 commits from fix/issue-709-decision-lock-cross-profile into master
5 changed files with 1453 additions and 27 deletions
Showing only changes of commit ec5cf67771 - Show all commits
+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
+143 -1
View File
@@ -36,7 +36,21 @@ KIND_REVIEW_DRAFT = "review_draft"
# other session proofs; a contaminated session fails closed on gated mutations
# until a reconciler audits and clears it.
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
# #709: archive prior terminal decision ledgers instead of silent overwrite.
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
# #709: post-merge cleanup/audit reconciliation-required durable record.
KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery"
# #709: truthful record when historical terminal evidence is irrecoverably gone.
KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
# Kinds that must survive the default session-state TTL (forensic / recovery).
RECOVERY_CRITICAL_KINDS = frozenset(
{
KIND_DECISION_LOCK_ARCHIVE,
KIND_POST_MERGE_DECISION_RECOVERY,
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
}
)
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
@@ -270,7 +284,11 @@ def identity_match_reasons(
reasons.append("session state missing recorded_at timestamp (fail closed)")
else:
age = _now_utc() - recorded_at
if age > timedelta(hours=ttl_hours()):
kind = (record.get("kind") or "").strip()
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
record.get("recovery_critical")
)
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
reasons.append(
f"session state expired after {ttl_hours():g}h (fail closed)"
)
@@ -447,3 +465,127 @@ def clear_state(
profile_identity=profile_identity,
state_dir=state_dir,
)
def list_decision_lock_profile_identities(
state_dir: str | None = None,
) -> list[str]:
"""Return profile identities that have a durable review_decision_lock file (#709).
Filename form: ``review_decision_lock-<profile>.json`` (see ``state_key``).
Does not validate TTL or identity — callers must load via ``load_state``.
"""
root = (state_dir or default_state_dir()).strip()
if not root or not os.path.isdir(root):
return []
prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-"
suffix = ".json"
found: list[str] = []
try:
names = os.listdir(root)
except OSError:
return []
for name in names:
if not name.startswith(prefix) or not name.endswith(suffix):
continue
if name.endswith(".lock"):
continue
mid = name[len(prefix) : -len(suffix)]
if mid:
found.append(mid)
return sorted(set(found))
def load_state_for_profile(
*,
kind: str,
profile_identity: str,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
state_dir: str | None = None,
skip_identity_match: bool = False,
) -> dict[str, Any] | None:
"""Load durable state for an explicit profile identity (#709 cross-profile).
When *skip_identity_match* is True, still requires the file's recorded
profile_identity to equal the requested profile (anti-stomp), but does not
require the *active* session identity to match — needed so a merger can
inspect a reviewer lock after merge.
"""
profile = current_profile_identity(profile_identity=profile_identity)
root = _ensure_state_dir(state_dir)
path = state_file_path(
kind=kind,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
state_dir=root,
)
lock_path = f"{path}.lock"
with _exclusive_file_lock(lock_path):
envelope = _read_json(path)
if not envelope:
return None
payload = envelope.get("payload")
if not isinstance(payload, dict):
return None
merged = dict(payload)
for key in (
"kind",
"remote",
"org",
"repo",
"profile_identity",
"session_profile_lock",
"recorded_at",
"updated_at",
"writer_pid",
):
if key in envelope and key not in merged:
merged[key] = envelope[key]
stored = (merged.get("profile_identity") or "").strip()
if stored and stored != profile:
return None
if skip_identity_match:
# Still enforce TTL / future-dated so dead records do not authorize cleanup.
reasons = identity_match_reasons(
merged,
remote=remote or merged.get("remote"),
org=org or merged.get("org"),
repo=repo or merged.get("repo"),
profile_identity=stored or profile,
)
# Drop active-session-only mismatches; keep expiry / spoof reasons.
filtered = [
r
for r in reasons
if "profile identity mismatch" not in r
or (stored and stored != profile)
]
# Re-run only expiry/future/missing checks via identity when profile matches
expiry_reasons = [
r
for r in identity_match_reasons(
merged,
remote=None,
org=None,
repo=None,
profile_identity=stored or profile,
)
if any(x in r for x in ("expired", "future", "missing recorded_at"))
]
if expiry_reasons:
return None
return merged
reasons = identity_match_reasons(
merged,
remote=remote,
org=org,
repo=repo,
profile_identity=profile,
)
if reasons:
return None
return merged
+213
View File
@@ -438,3 +438,216 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
"This path only clears a lock when the referenced PR is merged/closed.",
]
return "\n".join(lines)
# ---------------------------------------------------------------------------
# #709 cross-profile cleanup, overwrite protection, recovery provenance
# ---------------------------------------------------------------------------
def has_unresolved_terminal_evidence(lock: dict | None) -> bool:
"""True when *lock* still carries a terminal live mutation ledger."""
return last_terminal_mutation(lock) is not None
def assess_init_overwrite(
existing_lock: dict | None,
*,
force: bool = False,
) -> dict[str, Any]:
"""Whether init_review_decision_lock may replace an existing durable lock (#709 AC2).
Unresolved terminal evidence must never be silently replaced by an empty
initialized lock. *force* does not authorize destruction of terminal
ledgers — only explicit cleanup / archive transitions may remove them.
"""
result: dict[str, Any] = {
"overwrite_allowed": True,
"has_existing": existing_lock is not None,
"has_unresolved_terminal": False,
"reasons": [],
"last_terminal_pr": None,
"last_terminal_action": None,
"existing_profile_identity": None,
}
if existing_lock is None:
result["reasons"].append("no existing lock; empty init allowed")
return result
result["existing_profile_identity"] = (
existing_lock.get("profile_identity")
or existing_lock.get("session_profile_lock")
or existing_lock.get("session_profile")
)
last = last_terminal_mutation(existing_lock)
if last is None:
result["reasons"].append(
"existing lock has no terminal mutations; re-init allowed"
)
return result
result["has_unresolved_terminal"] = True
result["overwrite_allowed"] = False
result["last_terminal_pr"] = last.get("pr_number")
result["last_terminal_action"] = last.get("action")
result["reasons"].append(
"refuse empty re-init: unresolved terminal decision-lock evidence "
f"present ({last.get('action')} on PR #{last.get('pr_number')}); "
"use gitea_cleanup_stale_review_decision_lock when moot, or archive "
f"via sanctioned recovery — force={force!r} does not authorize overwrite "
"(#709 AC2)"
)
return result
def lock_targets_merged_pr_approval(
lock: dict | None,
*,
pr_number: int,
expected_head_sha: str | None = None,
) -> bool:
"""True when *lock*'s last terminal mutation is approve of *pr_number*."""
last = last_terminal_mutation(lock)
if last is None:
return False
if last.get("action") != "approve":
return False
if last.get("pr_number") != pr_number:
return False
if expected_head_sha:
locked = mutation_head_sha(last, lock)
if locked and not heads_equal(locked, expected_head_sha):
return False
return True
def build_post_merge_recovery_record(
*,
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,
actor_username: str | None,
profile_name: str | None,
) -> dict[str, Any]:
"""Durable recovery-required payload after irreversible merge (#709 AC3)."""
return {
"event": "post_merge_decision_lock_recovery_required",
"status": "recovery_required",
"issue_ref": "#709",
"recovery_critical": True,
"applied": False, # never claim historical cleanup succeeded
"timestamp": datetime.now(timezone.utc).isoformat(),
"pr_number": pr_number,
"head_sha": normalize_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_username,
"profile_name": profile_name,
"required_recovery_action": (
"retry cross-profile decision-lock cleanup and audit publication "
"for the merged PR; if terminal evidence is gone, use "
"gitea_record_irrecoverable_decision_lock_provenance"
),
}
def build_irrecoverable_provenance_record(
*,
pr_number: int,
head_sha: str | None,
remote: str | None,
org: str | None,
repo: str | None,
actor_username: str | None,
profile_name: str | None,
reason: str,
incident_ref: str | None,
operator_authorized: bool,
) -> dict[str, Any]:
"""Truthful absence-of-proof record (#709 AC5). Never sets applied=True."""
return {
"event": "irrecoverable_decision_lock_provenance",
"status": "provenance_irrecoverable",
"operator_recovery_required": True,
"issue_ref": "#709",
"recovery_critical": True,
"applied": False,
"historical_cleanup_proven": False,
"timestamp": datetime.now(timezone.utc).isoformat(),
"pr_number": pr_number,
"head_sha": normalize_head_sha(head_sha),
"remote": remote,
"org": org,
"repo": repo,
"actor_username": actor_username,
"profile_name": profile_name,
"reason": reason,
"incident_ref": incident_ref,
"operator_authorized": bool(operator_authorized),
"merger_may_accept": bool(operator_authorized),
"acceptance_rule": (
"Merger may accept this record only when operator_authorized=true, "
"repository/PR/head match the live target, the record is durable "
"and read back, and no conflicting terminal lock remains for a "
"different PR/head. This does not prove historical cleanup."
),
}
def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str:
"""Markdown body for irrecoverable provenance audit (no applied=true claim)."""
lines = [
"## Irrecoverable decision-lock provenance (#709)",
"",
"Status: **PROVENANCE_IRRECOVERABLE** (not applied cleanup)",
"",
f"- actor: `{record.get('actor_username')}`",
f"- profile: `{record.get('profile_name')}`",
f"- timestamp: `{record.get('timestamp')}`",
f"- PR: `#{record.get('pr_number')}`",
f"- head_sha: `{record.get('head_sha')}`",
f"- incident_ref: `{record.get('incident_ref')}`",
f"- operator_authorized: `{record.get('operator_authorized')}`",
f"- historical_cleanup_proven: `{record.get('historical_cleanup_proven')}`",
f"- applied: `{record.get('applied')}` (must remain false)",
"",
f"Reason: {record.get('reason')}",
"",
"This record documents **absence of proof**, not successful cleanup.",
"It must not be reused for a different PR or head (#709 AC6).",
]
return "\n".join(lines)
def format_post_merge_recovery_comment(record: dict[str, Any]) -> str:
"""Markdown body for post-merge recovery-required audit."""
lines = [
"## Post-merge decision-lock recovery required (#709)",
"",
"Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)",
"",
f"- actor: `{record.get('actor_username')}`",
f"- profile: `{record.get('profile_name')}`",
f"- timestamp: `{record.get('timestamp')}`",
f"- PR: `#{record.get('pr_number')}`",
f"- head_sha: `{record.get('head_sha')}`",
f"- merge_commit_sha: `{record.get('merge_commit_sha')}`",
f"- target_profile_identity: `{record.get('target_profile_identity')}`",
f"- failed_step: `{record.get('failed_step')}`",
f"- error: `{record.get('error')}`",
"",
f"Required action: {record.get('required_recovery_action')}",
"",
"applied=false — do not treat this as successful cleanup evidence.",
]
return "\n".join(lines)
+9
View File
@@ -127,6 +127,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.review",
"role": "reviewer",
},
# #709: truthful absence-of-proof recovery record (not applied cleanup).
"record_irrecoverable_decision_lock_provenance": {
"permission": "gitea.issue.comment",
"role": "reconciler",
},
"gitea_record_irrecoverable_decision_lock_provenance": {
"permission": "gitea.issue.comment",
"role": "reconciler",
},
"delete_branch": {
"permission": "gitea.branch.delete",
"role": "author",
@@ -0,0 +1,523 @@
"""#709: cross-profile decision-lock cleanup, overwrite protection, recovery.
Covers AC1AC8 regression scenarios without fabricating historical PR #696
provenance or special-casing live PR numbers in production code.
"""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import mcp_session_state as ss
import stale_review_decision_lock as srdl
def _lock(
mutations=None,
*,
profile="prgs-reviewer",
remote="prgs",
head=None,
):
muts = []
for m in mutations or []:
row = dict(m)
if head and "head_sha" not in row:
row["head_sha"] = head
muts.append(row)
return {
"task": "review_pr",
"remote": remote,
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"session_pid": os.getpid(),
"session_profile": profile,
"session_profile_lock": profile,
"profile_identity": profile,
"final_review_decision_ready": False,
"ready_pr_number": None,
"ready_action": None,
"ready_expected_head_sha": None,
"live_mutations": muts,
"correction_authorized": False,
"correction_reason": None,
"kind": ss.KIND_DECISION_LOCK,
}
APPROVE = {"pr_number": 100, "action": "approve", "review_id": 9}
APPROVE_OTHER = {"pr_number": 200, "action": "approve", "review_id": 10}
HEAD_A = "a" * 40
HEAD_B = "b" * 40
class TestAC2InitOverwrite(unittest.TestCase):
def test_empty_lock_allows_reinit(self):
a = srdl.assess_init_overwrite(_lock([]), force=True)
self.assertTrue(a["overwrite_allowed"])
def test_terminal_lock_blocks_force_reinit(self):
a = srdl.assess_init_overwrite(_lock([APPROVE]), force=True)
self.assertFalse(a["overwrite_allowed"])
self.assertTrue(a["has_unresolved_terminal"])
self.assertEqual(a["last_terminal_pr"], 100)
def test_none_lock_allows_init(self):
a = srdl.assess_init_overwrite(None)
self.assertTrue(a["overwrite_allowed"])
class TestAC1TargetApproval(unittest.TestCase):
def test_targets_matching_approve(self):
self.assertTrue(
srdl.lock_targets_merged_pr_approval(
_lock([APPROVE], head=HEAD_A),
pr_number=100,
expected_head_sha=HEAD_A,
)
)
def test_rejects_other_pr(self):
self.assertFalse(
srdl.lock_targets_merged_pr_approval(
_lock([APPROVE_OTHER]), pr_number=100
)
)
def test_rejects_head_mismatch(self):
self.assertFalse(
srdl.lock_targets_merged_pr_approval(
_lock([APPROVE], head=HEAD_A),
pr_number=100,
expected_head_sha=HEAD_B,
)
)
class TestAC5IrrecoverableRecord(unittest.TestCase):
def test_never_sets_applied_true(self):
rec = srdl.build_irrecoverable_provenance_record(
pr_number=42,
head_sha=HEAD_A,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
actor_username="sysadmin",
profile_name="prgs-reconciler",
reason="evidence destroyed",
incident_ref="#700 comment 1",
operator_authorized=True,
)
self.assertFalse(rec["applied"])
self.assertFalse(rec["historical_cleanup_proven"])
self.assertEqual(rec["status"], "provenance_irrecoverable")
self.assertTrue(rec["merger_may_accept"])
body = srdl.format_irrecoverable_audit_comment(rec)
self.assertIn("applied: `False`", body)
self.assertIn("must remain false", body)
def test_unauthorized_not_merger_acceptable(self):
rec = srdl.build_irrecoverable_provenance_record(
pr_number=42,
head_sha=HEAD_A,
remote="prgs",
org=None,
repo=None,
actor_username="x",
profile_name="y",
reason="r",
incident_ref=None,
operator_authorized=False,
)
self.assertFalse(rec["merger_may_accept"])
class TestAC3PostMergeRecoveryRecord(unittest.TestCase):
def test_recovery_record_is_not_applied_cleanup(self):
rec = srdl.build_post_merge_recovery_record(
pr_number=10,
head_sha=HEAD_A,
merge_commit_sha="m" * 40,
target_profile_identity="prgs-reviewer",
failed_step="audit_comment_publish",
error="timeout",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
actor_username="sysadmin",
profile_name="prgs-merger",
)
self.assertEqual(rec["status"], "recovery_required")
self.assertFalse(rec["applied"])
self.assertTrue(rec["recovery_critical"])
class TestSessionStateCrossProfile(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.state_dir = self._tmp.name
os.chmod(self.state_dir, 0o700)
def tearDown(self):
self._tmp.cleanup()
def test_list_and_load_foreign_profile_lock(self):
ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=_lock([APPROVE], profile="prgs-reviewer"),
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
profile_identity="prgs-reviewer",
state_dir=self.state_dir,
)
# Merger-local empty lock
ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=_lock([], profile="prgs-merger"),
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
profile_identity="prgs-merger",
state_dir=self.state_dir,
)
ids = ss.list_decision_lock_profile_identities(state_dir=self.state_dir)
self.assertIn("prgs-reviewer", ids)
self.assertIn("prgs-merger", ids)
foreign = ss.load_state_for_profile(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
state_dir=self.state_dir,
skip_identity_match=True,
)
self.assertIsNotNone(foreign)
self.assertTrue(
srdl.lock_targets_merged_pr_approval(foreign, pr_number=100)
)
empty = ss.load_state_for_profile(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-merger",
state_dir=self.state_dir,
skip_identity_match=True,
)
self.assertIsNotNone(empty)
self.assertFalse(
srdl.lock_targets_merged_pr_approval(empty, pr_number=100)
)
def test_clear_reviewer_not_merger_empty(self):
ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=_lock([APPROVE], profile="prgs-reviewer"),
profile_identity="prgs-reviewer",
state_dir=self.state_dir,
)
ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=_lock([], profile="prgs-merger"),
profile_identity="prgs-merger",
state_dir=self.state_dir,
)
ss.clear_state(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
state_dir=self.state_dir,
)
self.assertIsNone(
ss.load_state_for_profile(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
state_dir=self.state_dir,
skip_identity_match=True,
)
)
# Merger empty lock remains
self.assertIsNotNone(
ss.load_state_for_profile(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-merger",
state_dir=self.state_dir,
skip_identity_match=True,
)
)
def test_recovery_critical_kinds_ttl_exempt(self):
rec = srdl.build_irrecoverable_provenance_record(
pr_number=1,
head_sha=HEAD_A,
remote="prgs",
org=None,
repo=None,
actor_username="a",
profile_name="p",
reason="gone",
incident_ref=None,
operator_authorized=True,
)
rec["kind"] = ss.KIND_IRRECOVERABLE_DECISION_PROVENANCE
# Force old recorded_at
rec["recorded_at"] = "2000-01-01T00:00:00Z"
rec["updated_at"] = rec["recorded_at"]
rec["profile_identity"] = "prgs-reconciler"
rec["session_profile_lock"] = "prgs-reconciler"
reasons = ss.identity_match_reasons(
rec, profile_identity="prgs-reconciler"
)
self.assertFalse(
any("expired" in r for r in reasons),
msg=reasons,
)
class TestInitReviewDecisionLockIntegration(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.env = patch.dict(
os.environ,
{
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
"GITEA_PROFILE_NAME": "prgs-reviewer",
},
clear=False,
)
self.env.start()
import mcp_server
self.mcp = mcp_server
self.mcp._REVIEW_DECISION_LOCK = None
def tearDown(self):
self.mcp._REVIEW_DECISION_LOCK = None
self.env.stop()
self._tmp.cleanup()
def test_init_does_not_wipe_terminal_ledger(self):
self.mcp._save_review_decision_lock(_lock([APPROVE], profile="prgs-reviewer"))
# force=True would previously wipe
self.mcp.init_review_decision_lock("prgs", "review_pr", force=True)
loaded = self.mcp._load_review_decision_lock()
self.assertIsNotNone(loaded)
last = srdl.last_terminal_mutation(loaded)
self.assertIsNotNone(last)
self.assertEqual(last.get("pr_number"), 100)
def test_init_creates_empty_when_no_terminal(self):
self.mcp._save_review_decision_lock(None)
self.mcp.init_review_decision_lock("prgs", "review_pr", force=True)
loaded = self.mcp._load_review_decision_lock()
self.assertIsNotNone(loaded)
self.assertEqual(loaded.get("live_mutations"), [])
class TestIrrecoverableTool(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.env = patch.dict(
os.environ,
{
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
"GITEA_SESSION_PROFILE_LOCK": "prgs-reconciler",
"GITEA_PROFILE_NAME": "prgs-reconciler",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment,gitea.pr.comment",
},
clear=False,
)
self.env.start()
import mcp_server
self.mcp = mcp_server
def tearDown(self):
self.env.stop()
self._tmp.cleanup()
def test_requires_confirmation_and_operator(self):
with patch.object(
self.mcp,
"get_profile",
return_value={
"profile_name": "prgs-reconciler",
"allowed_operations": [
"gitea.read",
"gitea.issue.comment",
"gitea.pr.comment",
],
"forbidden_operations": [],
},
):
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
pr_number=50,
reason="lost",
confirmation="",
operator_authorized=False,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
post_audit_comment=False,
)
self.assertFalse(r["success"])
self.assertFalse(r["applied"])
def test_records_without_applied_true(self):
with patch.object(
self.mcp,
"get_profile",
return_value={
"profile_name": "prgs-reconciler",
"allowed_operations": [
"gitea.read",
"gitea.issue.comment",
"gitea.pr.comment",
],
"forbidden_operations": [],
},
), patch.object(
self.mcp, "_authenticated_username", return_value="sysadmin"
), patch.object(
self.mcp, "_profile_operation_gate", return_value=None
), patch.object(
self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools")
):
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
pr_number=50,
reason="terminal evidence overwritten",
confirmation="IRRECOVERABLE DECISION PROVENANCE PR 50",
operator_authorized=True,
expected_head_sha=HEAD_A,
incident_ref="issue-700-comment-11489",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
post_audit_comment=False,
)
self.assertTrue(r["success"])
self.assertFalse(r["applied"])
self.assertFalse(r["historical_cleanup_proven"])
self.assertEqual(r["record"]["status"], "provenance_irrecoverable")
# Idempotent replay
with patch.object(
self.mcp,
"get_profile",
return_value={
"profile_name": "prgs-reconciler",
"allowed_operations": [
"gitea.read",
"gitea.issue.comment",
"gitea.pr.comment",
],
"forbidden_operations": [],
},
), patch.object(
self.mcp, "_authenticated_username", return_value="sysadmin"
), patch.object(
self.mcp, "_profile_operation_gate", return_value=None
), patch.object(
self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools")
):
r2 = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
pr_number=50,
reason="terminal evidence overwritten",
confirmation="IRRECOVERABLE DECISION PROVENANCE PR 50",
operator_authorized=True,
expected_head_sha=HEAD_A,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
post_audit_comment=False,
)
self.assertTrue(r2["success"])
self.assertFalse(r2["performed"]) # idempotent hit
def test_wrong_confirmation_cannot_unblock_other_pr(self):
with patch.object(
self.mcp,
"get_profile",
return_value={
"profile_name": "prgs-reconciler",
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
"forbidden_operations": [],
},
), patch.object(
self.mcp, "_authenticated_username", return_value="sysadmin"
), patch.object(
self.mcp, "_profile_operation_gate", return_value=None
), patch.object(
self.mcp, "_resolve", return_value=("h", "o", "r")
):
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
pr_number=50,
reason="x",
confirmation="IRRECOVERABLE DECISION PROVENANCE PR 51",
operator_authorized=True,
remote="prgs",
post_audit_comment=False,
)
self.assertFalse(r["success"])
class TestClearProfileHelper(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.env = patch.dict(
os.environ,
{
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
"GITEA_SESSION_PROFILE_LOCK": "prgs-merger",
},
clear=False,
)
self.env.start()
import mcp_server
self.mcp = mcp_server
self.mcp._REVIEW_DECISION_LOCK = None
def tearDown(self):
self.mcp._REVIEW_DECISION_LOCK = None
self.env.stop()
self._tmp.cleanup()
def test_clear_only_matching_reviewer_approve(self):
ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A),
profile_identity="prgs-reviewer",
state_dir=self._tmp.name,
)
ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=_lock([], profile="prgs-merger"),
profile_identity="prgs-merger",
state_dir=self._tmp.name,
)
out = self.mcp._clear_decision_lock_for_profile(
profile_identity="prgs-reviewer",
pr_number=100,
expected_head_sha=HEAD_A,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertTrue(out["cleared"])
skip = self.mcp._clear_decision_lock_for_profile(
profile_identity="prgs-merger",
pr_number=100,
expected_head_sha=HEAD_A,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertFalse(skip["cleared"])
if __name__ == "__main__":
unittest.main()