Files
Gitea-Tools/docs/sanctioned-recovery-playbooks.md
T
sysadminandClaude Opus 4.8 4a2fae8495 fix(webui): arm the recovery gates and make the playbooks reach the process (#644)
Reviewer REQUEST_CHANGES on PR #903 at head 1c88b87 raised five blockers, all
reproduced by executing that head. The shared shape: a write path that declared
itself gated, audited, and verified, but never armed the gate, mutated a copy of
the state it claimed to fix, and then verified against that same copy.

B1 - the apply path never asked the execution gate.
execute_recovery_playbook called console_authz.authorize with the default
for_execution=False, and the phase branch only fires when it is True. ACTIVE_PHASE
is 1 and every new action is phase 2, so an operator executed a phase-2 write
through POST /api/v1/system/recovery/apply while build_recovery_preview reported
execution_enabled false. The call now passes for_execution=True and surfaces the
phase_not_active refusal. Preview reports the same decision under
execution_authorization / execution_blocked_reason instead of a hardcoded False it
could not explain.

B2 - both env playbooks mutated a discarded copy and verified against it.
source_env = dict(os.environ) meant clear_stale_binding and rebind_session_worktree
never touched the running process, and verify_post_recovery(env=source_env)
re-diagnosed the same copy, confirming a change that had not happened. Mutations
now target the live mapping (apply_recovery's sanctioned env=None -> os.environ
path, #702 AC2) and verification re-reads state rather than the mutated input.
binding_before / binding_after / binding_changed are returned, and a playbook that
changed nothing reports performed: false. verify_post_recovery no longer reads an
unverified_inherited binding as clean, because unproven is not clean.

B3 - the reconcile playbook called a function that does not exist.
merged_cleanup_reconcile.reconcile_merged_cleanups is absent from that module and a
bare except turned the AttributeError into a generic failure, so the playbook could
never succeed. It now calls gitea_mcp_server.gitea_reconcile_merged_cleanups, the
real orchestrator, imported lazily; failures carry error_type. task_capability_map
declared gitea.pr.close for reconcile_cleanups while the entry point gates on
gitea.read; the two authority statements are reconciled to the one that is enforced.

B4 - the #630 contamination integration could not block.
assess_contamination_gate was fed marker=None, which short-circuits to block: False
on its first statement; the task passed was a console action id outside
CONTAMINATION_GATED_TASKS; and the result was read through a "contaminated" key the
gate never returns, making STATUS_BLOCKED_CONTAMINATION unreachable. The live marker
now comes from the #641 session inventory reader, the gated task key
console_recovery_apply is added to CONTAMINATION_GATED_TASKS, every read uses the
"block" key the gate actually returns, and the marker is forwarded to
sanctioned_restart.execute_restart so a restart cannot launder a contaminated
runtime. The reconciler cleanup playbook stays exempt as the designated remedy.

B5 - the parity baseline was captured from the head it was compared against.
capture_startup_parity(root, head=checkout_head) stores the head verbatim, so
in_parity was structurally incapable of being false, and live_remote_head was never
passed. The baseline is now the daemon start head that assess_stale_runtime already
returns, and the #610 live-remote dimension is restored.

Also: _recovery_card was the one renderer in system_health_views.py interpolating
without _esc(), and it is where a marker's operator-supplied command_summary lands
once B4 is wired; it now escapes, including the except branch. Docs no longer claim
apply enforces master parity or that verify asserts clean: true, and the absolute
file:///Users/... links are relative.

Tests: the two that asserted the defects as intended are inverted -
test_api_recovery_apply_with_dev_auth asserted the phase-gate bypass, and the rebind
test asserted the input echoed back. Added coverage per blocker, including a no-op
detection test that fails when a playbook reports success without changing anything,
the previously untested reconcile playbook, contamination block and remedy-exemption
tests, and parity baseline/live-remote tests. Both new guards were mutation-verified:
disarming for_execution fails 2 tests, restoring the env copy fails 2 tests.

Validation: WEBUI_TEST_OFFLINE=1 ../../venv/bin/python -m pytest tests/ -q from
branches/feat-issue-644 gives 27 failed / 5291 passed / 6 skipped / 953 subtests;
the same command from branches/baseline-master-76f293e at 76f293eb28 gives
28 failed / 5262 passed / 6 skipped / 926 subtests. Suites run one at a time.
comm of the sorted FAILED lines shows no new signature at the head. The single
absent signature, test_workspace_guard_alignment.py::
TestRuntimeContextGuardAlignment::test_declared_branches_worktree_passes_when_mcp_root_differs,
is suite-order dependent: that file passes 9/9 in isolation at both revisions.

Closes #644

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 17:42:49 -04:00

5.1 KiB

Sanctioned Recovery Playbooks & Controls (Phase 2 #644)

Overview

Stale runtimes, worktree binding mismatches, and un-reconciled merged branches previously required expert manual shell recovery. Manual process kills (pkill -f mcp_server.py) are strictly forbidden and classified as runtime contamination (#630).

Phase 2 introduces sanctioned recovery playbooks and controls into the Web Console:

  • Diagnose: Surface stale runtimes, worktree binding errors, contamination markers, and worktree anomalies via health & inventory APIs.
  • Preview: Render mutation ledgers and exact confirmation phrases for recovery playbooks.
  • Confirm & Apply: Execute sanctioned recovery actions through gated, audited paths.
  • Verify: Revalidate control-plane state post-recovery before claiming clean status.

Recovery Playbook Taxonomy

Playbook ID Action ID Minimum Role Target / Scope Description
clear_stale_binding system.clear_stale_binding Operator Active worktree binding Clear provably missing or superseded GITEA_ACTIVE_WORKTREE binding (#702).
rebind_session_worktree system.rebind_session_worktree Operator Session worktree Rebind or synchronize session worktree to verified lease worktree (#864).
reconcile_cleanups system.reconcile_cleanups Controller Worktree hygiene Execute reconciler cleanup preview and apply for merged/superseded PR branches.
sanctioned_restart system.restart_namespace Admin MCP Namespace Restart MCP daemon gracefully via host supervisor (#642).

Wizard Workflow (Diagnose → Preview → Confirm → Verify)

1. Diagnose (GET /api/v1/system/recovery/diagnose)

Runs control-plane diagnostics:

  • Stale Runtime: Mismatch between running daemon HEAD, local checkout HEAD, and remote-tracking HEAD.
  • Worktree Binding: Missing path (provably_stale_missing_path), unverified inherited binding (unverified_inherited), or superseded binding (superseded_by_session_lease).
  • Contamination: Checks for live contamination markers from unmanaged process kills.
  • Worktree Anomalies: Scans branches/ directory for un-reconciled cleanups or missing preserved worktrees.

Returns RecoveryDiagnosis with eligible playbooks.

2. Preview (POST /api/v1/system/recovery/preview)

Takes playbook_id and optional target/params. Returns:

  • Mutation Ledger: Step-by-step sequence of actions.
  • Confirmation Phrase: Exact phrase required to authorize execution (e.g., confirm clear_stale_binding).
  • Authorization Decision: RBAC check against the operator's principal.

3. Apply (POST /api/v1/system/recovery/apply)

Requires playbook_id and matching confirmation phrase. Gates run in this order, and each fails closed before anything is mutated:

  1. RBAC and execution phase (console_authz.authorize(..., for_execution=True)). The phase branch only applies when for_execution is set. While ACTIVE_PHASE is 1, every phase-2 recovery action is refused with phase_not_active, so no recovery playbook writes yet. Preview reports the same decision under execution_authorization / execution_blocked_reason.
  2. Confirmation phrase (confirmation_matches).
  3. Contamination rules (#630): the live marker is read from the session inventory and assessed under the gated task key console_recovery_apply. A contaminated runtime must be cleared through the reconciler cleanup playbook, which is the one playbook exempted from this gate because it is the designated remedy. The marker is also forwarded to sanctioned_restart.execute_restart, so a restart cannot launder a contaminated runtime.

Apply then executes the sanctioned recovery logic against the live process environment — not a copy — and records an audit entry in console_audit. A playbook that leaves the binding unchanged reports performed: false; binding_before, binding_after, and binding_changed are returned so a no-op cannot read as success.

Apply does not enforce master parity. Parity is reported by Diagnose (#610) as evidence for the operator; it is not a precondition of this endpoint.

4. Verify (POST /api/v1/system/recovery/verify)

Re-evaluates control-plane diagnostics post-recovery and reports clean, stale_runtime_clean, binding_clean, binding_classification, and contamination_clean. It reports; it does not assert or block. State is read fresh rather than from the mapping a mutation just wrote. An unverified_inherited binding is reported as not clean, because unproven is not clean.


Safety & Governance Principles

  1. No Manual pkill: Direct process killing remains forbidden and is recorded as contamination.
  2. Auditability: Every recovery preview and execution is logged in the console audit trail.
  3. Master Parity & Dual Control: High-privilege recovery actions require controller/admin roles and explicit confirmation phrases.