diff --git a/docs/architecture/mcp-ha-rolling-restart.md b/docs/architecture/mcp-ha-rolling-restart.md
new file mode 100644
index 0000000..fc82f99
--- /dev/null
+++ b/docs/architecture/mcp-ha-rolling-restart.md
@@ -0,0 +1,167 @@
+# ADR: High-availability and rolling-restart architecture for Gitea MCP control plane
+
+- **Status:** Proposed (Design ADR under [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668))
+- **Date:** 2026-07-25
+- **Tracking Issue:** [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668)
+- **Policy Version:** `mcp-ha-rolling-restart/v1`
+- **Related:**
+ - Parent: [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655) — Governed MCP restart coordination and zero-disruption recovery
+ - Governance Policy: [#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656) / `docs/architecture/mcp-restart-governance.md`
+ - Control-Plane DB Substrate: [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) / `docs/architecture/control-plane-db-substrate.md`
+ - Runtime Policy: [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615) / `docs/architecture/mcp-stable-control-runtime-policy-adr.md`
+ - Product Vision: [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) (Phase 5 Maturity)
+ - Delivery Roadmap: [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653)
+
+---
+
+## 1. Context & Problem Statement
+
+The Gitea MCP server operates as the authoritative **control plane** for managing issues, Pull Requests, code mutations, formal reviews, and workflow reconciliations. Under single-process governance ([#656](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/656)), process restarts are strictly controlled using pre-flight checks, drain phases, and operator approvals.
+
+However, a single-instance control plane inherently presents fundamental constraints:
+
+1. **Downtime during updates:** Even a perfectly executed single-process drain requires a window where incoming client requests must be paused or rejected while the server binary or python environment reloads.
+2. **Single point of failure:** Infrastructure issues, process crashes, or unhandled host-level terminations immediately disconnect active LLM sessions and leave transient workflows incomplete.
+3. **Multi-agent concurrency bottlenecks:** High volumes of concurrent multi-LLM tasks put all lock management, lease allocation, and Gitea API interactions through a single process event loop.
+
+To achieve true zero-disruption operation and seamless rolling deployments without stopping active work, the system requires a high-availability (HA), multi-instance MCP architecture.
+
+---
+
+## 2. Architectural Principles & Non-Goals
+
+### 2.1 Core Architectural Principles
+* **Gitea as Canonical Work SoT:** Gitea remains the ultimate System of Record (SoT) for issue states, pull requests, labels, and audit comments. The MCP control plane does not duplicate domain entities.
+* **Control-Plane DB as Multi-Instance State Substrate:** The control-plane SQLite/durable database ([#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613)) acts as the single source of truth for workflow leases, session tokens, assignment records, and lock fences across all MCP nodes.
+* **Stateless Worker Nodes:** MCP role server processes (`gitea-author`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, `gitea-controller`) maintain no unique in-memory state; any node can handle any request given a valid session resume token.
+* **Fail-Closed Split-Brain Defense:** In any network partition or quorum loss scenario, nodes must fail closed rather than risk double-mutations or conflicting Gitea states.
+
+### 2.2 Non-Goals
+* **Replacing Gitea:** We do not replace Gitea issue/PR tracking with an independent database.
+* **Immediate Multi-Node Cluster Execution in v1:** This ADR defines the target architecture and phased roadmap; immediate implementation occurs incrementally post-[#655] v1.
+
+---
+
+## 3. High-Availability & Rolling-Restart Architecture
+
+### 3.1 Architecture Overview
+
+```
+ +----------------------------+
+ | LLM Clients / IDE Sessions |
+ +--------------+-------------+
+ |
+ v
+ +----------------------------+
+ | HA Proxy / Router |
+ | (Health-based & Affinity) |
+ +------+--------------+------+
+ | |
+ +--------------+ +--------------+
+ v v
+ +--------------------+ +--------------------+
+ | MCP Instance Node A| | MCP Instance Node B|
+ | (Version N) | | (Version N+1) |
+ +---------+----------+ +---------+----------+
+ | |
+ +----------------------+----------------------+
+ |
+ v
+ +----------------------------+
+ | Control-Plane DB Substrate|
+ | (Shared Lease & Locks) |
+ +--------------+-------------+
+ |
+ v
+ +----------------------------+
+ | Gitea API |
+ +----------------------------+
+```
+
+---
+
+### 3.2 Key System Components
+
+#### A. Multiple MCP Instance Cohorts
+* The control plane runs across $N \ge 2$ redundant process nodes.
+* Dual-namespace deployment allows running the old version (Node A) alongside a updated version (Node B) during rolling upgrades.
+
+#### B. Shared Durable Session Storage & Resume Tokens
+* Session context, preflight verification proofs, and capability resolution states are stored in the shared control-plane database.
+* Client requests carry an explicit `session_id` and `resume_token`. If an MCP instance restarts or a request routes to a different instance, the target node validates the token against the database without requiring full session re-initialization.
+
+#### C. Shared Lease Authority & Fencing Counters
+* Workflow leases (`gitea_allocate_next_work`, `gitea_adopt_workflow_lease`) use monotonic fencing tokens (`lease_generation_id`).
+* When Node B acquires or renews a lease, it increments the generation counter. Any delayed or out-of-order write attempt from Node A using an older generation token is rejected by database constraints.
+
+#### D. Leader Election & Coordinated Drain
+* Node clusters elect a primary coordinator node for administrative background tasks (such as stale lease cleanup or incident Watchdogs).
+* During a rolling deployment:
+ 1. Node B (new version) is launched and registers as healthy.
+ 2. Router directs new session creations to Node B.
+ 3. Node A enters `MAINTENANCE_DRAIN` status ([#659](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/659)), completing in-flight mutations while refusing new tasks.
+ 4. Once all active sessions migrate or complete, Node A shuts down cleanly.
+
+#### E. Idempotent Mutations & Failover Safety
+* All state-changing tool executions (PR creation, review submission, merge operations, label changes) carry a deterministic `idempotency_key`.
+* If a network connection flaps or a node fails mid-mutation, the re-issued request with the same `idempotency_key` is recognized by the control-plane substrate, returning the existing recorded result without repeating side effects on Gitea.
+
+#### F. Schema Version Compatibility
+* Database migrations follow non-breaking additive patterns.
+* During rolling upgrades where Node A (Version $N$) and Node B (Version $N+1$) run concurrently, both versions operate against the shared schema without structural conflicts.
+
+---
+
+## 4. Split-Brain & Failure Behavior
+
+### 4.1 Split-Brain Risk Scenarios & Mitigation
+
+| Scenario | Risk | Mitigation Strategy |
+|---|---|---|
+| **Network Partition between Nodes** | Both Node A and Node B attempt to process operations for the same issue/PR. | **Generation Fencing:** Lease renewal requires updating the DB generation counter. The node isolated from the DB fails closed immediately. |
+| **Stale Node Recovery** | Node A recovers after a long pause and executes a queued mutation. | **Lease Expiry & TTL Fencing:** Transactions verify that `expires_at > NOW()` within the atomic SQLite transaction boundaries. |
+| **Database Connection Loss** | Node loses access to shared control-plane DB substrate. | **Strict Fail-Closed:** The node immediately marks all task capabilities as `blocked` and rejects mutation tools until DB connectivity is re-established. |
+
+---
+
+## 5. Phased Implementation Milestones
+
+```mermaid
+flowchart TD
+ M1[Milestone 1: Shared Control-Plane DB Schema & Resume Tokens] --> M2[Milestone 2: Idempotent Mutation Layer]
+ M2 --> M3[Milestone 3: Health Routing & Standby Failover]
+ M3 --> M4[Milestone 4: Active-Active Rolling Deployment & Auto-Drain]
+```
+
+### Milestone 1: Shared Control-Plane DB Schema & Resume Tokens (Post-#655)
+* Extend [#613] Control-Plane DB schema to store multi-instance node heartbeat records and session resume tokens.
+* Enable session lookup across instances via `session_id`.
+
+### Milestone 2: Idempotent Mutation Layer & Lease Fencing
+* Add mandatory `idempotency_key` tracking to all Gitea mutation tools.
+* Implement monotonic lease fencing counters in `gitea_allocate_next_work` and `gitea_adopt_workflow_lease`.
+
+### Milestone 3: Health-Based Routing & Active-Passive Standby
+* Introduce lightweight proxy/router capable of checking node health endpoints.
+* Implement active-standby failover where standby node automatically assumes work if active node fails health checks.
+
+### Milestone 4: Active-Active Horizontal Deployment & Rolling Upgrade Automation
+* Enable true active-active multi-instance execution.
+* Integrate automated zero-downtime rolling upgrades coordinated with `gitea_request_mcp_restart` maintenance drain.
+
+---
+
+## 6. Observability & Audit Requirements
+
+High-availability control plane operations must expose clear telemetry and audit trails:
+
+* **Node Registry Telemetry:** Active nodes, version numbers, uptime, and heartbeat timestamps reported via `gitea_get_runtime_context`.
+* **Lease Fencing Metrics:** Tracking lease acquire latency, fence rejection counts, and lease handoff durations.
+* **Failover & Re-route Audit Logs:** Durable logging of session migrations between nodes, drain initiation, and process retirement events.
+
+---
+
+## 7. Tradeoffs & Accepted Risks
+
+* **Increased Architectural Complexity:** Moving from a single process to a multi-instance control plane requires robust DB locking, proxy routing, and migration governance.
+* **Database Dependency:** The control-plane database substrate becomes a critical shared dependency for multi-node deployments. High availability for the underlying SQLite file system / DB must be guaranteed.
diff --git a/docs/webui-restart-console.md b/docs/webui-restart-console.md
new file mode 100644
index 0000000..12dd427
--- /dev/null
+++ b/docs/webui-restart-console.md
@@ -0,0 +1,102 @@
+# Web Console: restart status, impact preview, and approval state (#667)
+
+Phase 1 of the console restart surface. It consumes the #655 coordinator
+substrate and displays it. It performs no restart, reload, drain, approval, or
+process action, and it registers no write endpoint.
+
+Issue #667's rollout is explicit — *status views first, write approval after the
+backend gates are green* — and this change delivers only the status half.
+
+## Surfaces
+
+| Path | Method | Purpose |
+|------|--------|---------|
+| `/runtime/restart` | GET | Restart status page |
+| `/api/v1/system/restart/status` | GET | Same snapshot as JSON |
+
+Both accept an optional `restart_class` query parameter (default
+`full_mcp_restart`). An unrecognised class is not an error: the coordinator
+resolves it as unknown and fails closed, and the page shows the resulting deny.
+
+Neither path accepts `POST`; a write attempt returns `405`, and a test asserts
+it.
+
+## What it shows
+
+* **Impact preview (#658)** — verdict, blast radius, affected sessions, leases,
+ critical sections, mutations, and the counts behind them, evaluated
+ `dry_run=True` against live control-plane state.
+* **Drain proof (#661)** — verification of a supplied proof: valid, clean,
+ expired, tampered, and the reasons behind a refusal.
+* **Post-restart reconcile (#662)** — the most recent completion proof, its
+ overall status, and which dimensions still require follow-up.
+* **Restart classes (#663)** — the least-privilege matrix, with *you may
+ request* and *you may execute* computed for the viewing role rather than for a
+ generic operator.
+* **Approval controls (#633)** — the authorization state of
+ `system.restart_namespace` and `system.reload_namespace`.
+* **Break-glass (#664)** — declared and marked unavailable; see below.
+
+## Three rules this surface holds itself to
+
+A status page that is wrong is worse than one that is missing, because an
+operator acts on it. Three properties are enforced by tests, and each was
+verified by reverting the guard and watching a test fail.
+
+### An unreadable source reports unavailable, never green
+
+Every source carries its own `SourceStatus`. Nothing substitutes a default,
+placeholder, or self-comparison for a reading that failed. An unreadable
+control-plane database yields `inventory_complete: false`, which the coordinator
+itself turns into a fail-closed verdict, and the page says the blast radius is
+unknown rather than showing an empty affected-sessions table.
+
+An absent drain proof is reported as absent — not as a pass. The #661 gate
+authorizes a restart only against a valid, unexpired, clean proof, so no proof
+is precisely the state that gate denies on.
+
+### Authorization is asked the way execution would ask it
+
+Every probe passes `for_execution=True`.
+
+Asked without it, an admin is `allowed` for `system.restart_namespace`. On a
+control surface that reads as a live button. Asked the way an execution attempt
+would ask, the same principal is refused `phase_not_active`, because the console
+is in Phase 1 and the action is Phase 2. This surface reports the second answer.
+
+`execution_enabled` is therefore `false` for every action and every role today,
+and a test asserts that across the whole role matrix.
+
+### The control-plane database is opened read-only
+
+`ControlPlaneDB()` creates directories and runs migrations on construction — a
+write. This surface never constructs one. It opens the sqlite file with
+`mode=ro`, exactly as `webui/inventory.py` does, and treats a missing file as
+missing authority rather than as an empty inventory.
+
+The test that protects this points at a path inside a directory that already
+exists, so a read-write `connect` would really create the file. A nested
+missing-directory path would have passed for the wrong reason.
+
+## Break-glass is declared, not offered
+
+The break-glass workflow (#664) is not available on this branch's base. The
+panel is rendered to operator-class roles as **unavailable**, naming the issue
+that tracks it. It is not silently omitted, because an operator who has been
+told a governance path exists needs to see that it is not wired here; and it is
+not rendered as a control, because there is nothing behind it.
+
+Unprivileged viewers see only a note that the surface is operator-class.
+
+## Redaction and escaping
+
+Every interpolated value passes through `_esc` (`html.escape(..., quote=True)`).
+Free-form text and anything that can carry a filesystem path additionally passes
+through `webui.inventory.scrub_text`, which redacts credential-shaped tokens
+inside a string rather than only at its start. The impact payload is passed
+through `webui.inventory.scrub` before rendering.
+
+## Linkage
+
+Parent #655 · extends #642 · consumes #658, #661, #662, #663 · RBAC #633 ·
+console #631 · vision #652 · roadmap #653 · break-glass #664.
diff --git a/tests/test_webui_restart_console.py b/tests/test_webui_restart_console.py
new file mode 100644
index 0000000..439bba5
--- /dev/null
+++ b/tests/test_webui_restart_console.py
@@ -0,0 +1,452 @@
+"""Read-only restart console: views, gates, and honesty rules (#667).
+
+The console consumes the #655 substrate. These tests hold it to the three
+properties that make a status surface trustworthy:
+
+* an unreadable source is reported unavailable, never rendered as green;
+* authorization is probed the way execution would probe it, so an allow is
+ never shown for something that could not run;
+* the surface performs no mutation, including no write to the control-plane DB.
+"""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+import sys
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from starlette.testclient import TestClient # noqa: E402
+
+import restart_coordinator # noqa: E402
+from webui import console_authz, restart_console, restart_views # noqa: E402
+from webui.app import create_app # noqa: E402
+
+NOW = datetime(2026, 7, 25, 21, 0, 0, tzinfo=timezone.utc)
+
+
+def _principal(role: str) -> console_authz.Principal:
+ return console_authz.Principal(
+ subject="operator@example.com",
+ role=role,
+ identity_source=console_authz.IDENTITY_LOCAL_DEV,
+ authenticated=True,
+ )
+
+
+def _inventory(*, complete: bool = True, sessions=(), leases=()):
+ def _read(**_kwargs):
+ return {
+ "sessions": list(sessions),
+ "leases": list(leases),
+ "terminal_lock": None,
+ "prior_recovery_attempts": [],
+ "inventory_complete": complete,
+ "incomplete_reasons": (
+ [] if complete else ["fixture: inventory withheld"]
+ ),
+ }
+
+ return _read
+
+
+def _live_session(session_id: str = "prgs-author-1234-abcd") -> dict:
+ return {
+ "session_id": session_id,
+ "role": "author",
+ "profile": "prgs-author",
+ "pid": os.getpid(),
+ "status": "active",
+ "last_heartbeat_at": (NOW - timedelta(seconds=30)).isoformat(),
+ }
+
+
+def drain_proof_fixture() -> dict:
+ """A structurally complete but unsigned drain proof."""
+ return {
+ "version": "drain-proof/v1",
+ "proof_id": "deadbeef" * 8,
+ "clean": True,
+ "issued_at": (NOW - timedelta(minutes=1)).isoformat(),
+ "expires_at": (NOW + timedelta(minutes=5)).isoformat(),
+ "requesting_session_id": "s-live",
+ "impact_fingerprint": "f" * 64,
+ "checks": [],
+ "failed_checks": [],
+ }
+
+
+class RestartClassMatrixTest(unittest.TestCase):
+ def test_every_policy_class_is_rendered(self) -> None:
+ views = restart_console.build_restart_class_views("operator")
+ self.assertEqual(len(views), len(restart_coordinator.RESTART_CLASS_POLICIES))
+
+ def test_viewer_capability_is_role_scoped_not_generic(self) -> None:
+ """A worker role must not be shown as able to request a full restart."""
+ author = {
+ v.restart_class: v
+ for v in restart_console.build_restart_class_views("author")
+ }
+ operator = {
+ v.restart_class: v
+ for v in restart_console.build_restart_class_views("operator")
+ }
+ full = restart_coordinator.RestartClass.FULL_MCP_RESTART.value
+
+ self.assertFalse(author[full].viewer_may_request)
+ self.assertFalse(author[full].viewer_may_execute)
+ self.assertTrue(operator[full].viewer_may_request)
+ self.assertTrue(operator[full].viewer_may_execute)
+
+ def test_unknown_role_may_do_nothing(self) -> None:
+ views = restart_console.build_restart_class_views("not-a-role")
+ self.assertTrue(all(not v.viewer_may_request for v in views))
+ self.assertTrue(all(not v.viewer_may_execute for v in views))
+
+
+class AuthorizationProbeTest(unittest.TestCase):
+ def test_probe_asks_for_execution_so_phase_gate_is_reported(self) -> None:
+ """An admin clears the role bar and still cannot execute in Phase 1.
+
+ This is the case that distinguishes the two probes. Asked without
+ ``for_execution`` an admin is *allowed* for ``system.restart_namespace``,
+ which on a control surface reads as a live button. Asked the way
+ execution asks, the same principal is refused ``phase_not_active``. The
+ console must report the second answer.
+ """
+ by_id = {
+ a.action_id: a
+ for a in restart_console.build_action_authorizations(
+ _principal(console_authz.ADMIN)
+ )
+ }
+ restart = by_id["system.restart_namespace"]
+
+ self.assertFalse(restart.execution_enabled)
+ self.assertEqual(restart.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE)
+
+ permissive = console_authz.authorize(
+ "system.restart_namespace", _principal(console_authz.ADMIN)
+ )
+ self.assertTrue(
+ permissive.allowed,
+ "guard precondition: without for_execution an admin is allowed, "
+ "which is exactly why the console must not probe that way",
+ )
+
+ def test_operator_is_refused_the_admin_only_restart_action(self) -> None:
+ """Role refusal precedes the phase gate and is reported as such."""
+ by_id = {
+ a.action_id: a
+ for a in restart_console.build_action_authorizations(
+ _principal(console_authz.OPERATOR)
+ )
+ }
+ self.assertEqual(
+ by_id["system.restart_namespace"].reason_code,
+ console_authz.DENY_INSUFFICIENT_ROLE,
+ )
+
+ def test_anonymous_is_denied_unauthenticated(self) -> None:
+ by_id = {
+ a.action_id: a for a in restart_console.build_action_authorizations(None)
+ }
+ self.assertEqual(
+ by_id["system.restart_namespace"].reason_code,
+ console_authz.DENY_UNAUTHENTICATED,
+ )
+
+ def test_no_authorization_ever_reports_execution_enabled(self) -> None:
+ for role in (
+ console_authz.VIEWER,
+ console_authz.OPERATOR,
+ console_authz.CONTROLLER,
+ console_authz.ADMIN,
+ ):
+ for auth in restart_console.build_action_authorizations(_principal(role)):
+ self.assertFalse(
+ auth.execution_enabled,
+ f"{role} reported execution_enabled for {auth.action_id}",
+ )
+
+
+class ImpactPreviewTest(unittest.TestCase):
+ def test_impact_renders_from_coordinator_dto(self) -> None:
+ impact, source = restart_console.load_impact_report(
+ principal=_principal(console_authz.OPERATOR),
+ read_inventory=_inventory(sessions=[_live_session()]),
+ now=NOW,
+ )
+ self.assertTrue(source.available)
+ self.assertIsNotNone(impact)
+ self.assertEqual(
+ impact["restart_class"],
+ restart_coordinator.RestartClass.FULL_MCP_RESTART.value,
+ )
+ self.assertIn("verdict", impact)
+ self.assertFalse(impact["restart_performed"])
+ self.assertTrue(impact["dry_run"])
+
+ def test_incomplete_inventory_is_surfaced_and_denies(self) -> None:
+ impact, source = restart_console.load_impact_report(
+ principal=_principal(console_authz.OPERATOR),
+ read_inventory=_inventory(complete=False),
+ now=NOW,
+ )
+ self.assertFalse(impact["inventory_complete"])
+ self.assertFalse(impact["allow_restart"])
+ self.assertTrue(source.detail, "incomplete inventory must explain itself")
+
+ def test_inventory_reader_failure_is_unavailable_not_empty(self) -> None:
+ """A reader that raises must not be rendered as 'no sessions affected'."""
+
+ def _boom(**_kwargs):
+ raise RuntimeError("control-plane unreachable")
+
+ impact, source = restart_console.load_impact_report(
+ principal=_principal(console_authz.OPERATOR),
+ read_inventory=_boom,
+ now=NOW,
+ )
+ self.assertIsNone(impact)
+ self.assertFalse(source.available)
+ self.assertIn("control-plane unreachable", source.detail)
+
+
+class ControlPlaneReadTest(unittest.TestCase):
+ def test_missing_database_is_incomplete_not_empty(self) -> None:
+ inventory = restart_console.read_control_plane_inventory(
+ db_path="/nonexistent/control-plane.sqlite3"
+ )
+ self.assertFalse(inventory["inventory_complete"])
+ self.assertEqual(inventory["sessions"], [])
+ self.assertTrue(inventory["incomplete_reasons"])
+
+ def test_reader_never_creates_the_database(self) -> None:
+ """Reading status must not bring a control-plane DB into existence.
+
+ The path deliberately sits in a directory that already exists: a
+ read-write ``sqlite3.connect`` would happily create the file there, so
+ this fails if the reader ever stops opening the database ``mode=ro``.
+ A nested-missing-directory path would pass for the wrong reason,
+ because sqlite cannot create the parent directory either way.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "control_plane.sqlite3")
+ self.assertTrue(os.path.isdir(os.path.dirname(path)))
+
+ inventory = restart_console.read_control_plane_inventory(db_path=path)
+
+ self.assertFalse(
+ os.path.exists(path),
+ "reading restart status created a control-plane database",
+ )
+ self.assertFalse(inventory["inventory_complete"])
+
+ def test_reads_active_sessions_from_a_real_database(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "cp.sqlite3")
+ conn = sqlite3.connect(path)
+ conn.execute(
+ "CREATE TABLE sessions (session_id TEXT, role TEXT, profile TEXT,"
+ " pid INTEGER, status TEXT, last_heartbeat_at TEXT)"
+ )
+ conn.execute(
+ "CREATE TABLE work_items (work_item_id INTEGER, kind TEXT,"
+ " number INTEGER)"
+ )
+ conn.execute(
+ "CREATE TABLE leases (lease_id TEXT, session_id TEXT, role TEXT,"
+ " phase TEXT, status TEXT, worktree_path TEXT,"
+ " work_item_id INTEGER, expires_at TEXT)"
+ )
+ conn.execute(
+ "INSERT INTO sessions VALUES (?,?,?,?,?,?)",
+ ("s-live", "author", "prgs-author", 4242, "active", NOW.isoformat()),
+ )
+ conn.execute(
+ "INSERT INTO sessions VALUES (?,?,?,?,?,?)",
+ ("s-done", "author", "prgs-author", 11, "closed", NOW.isoformat()),
+ )
+ conn.execute("INSERT INTO work_items VALUES (1, 'issue', 667)")
+ conn.execute(
+ "INSERT INTO leases VALUES (?,?,?,?,?,?,?,?)",
+ (
+ "l-1",
+ "s-live",
+ "author",
+ "allocated",
+ "active",
+ None,
+ 1,
+ NOW.isoformat(),
+ ),
+ )
+ conn.commit()
+ conn.close()
+
+ inventory = restart_console.read_control_plane_inventory(db_path=path)
+
+ self.assertTrue(inventory["inventory_complete"])
+ self.assertEqual([s["session_id"] for s in inventory["sessions"]], ["s-live"])
+ self.assertEqual(inventory["leases"][0]["work_number"], 667)
+
+
+class DrainAndReconcileTest(unittest.TestCase):
+ def test_absent_drain_proof_is_not_a_pass(self) -> None:
+ drain, source = restart_console.load_drain_status(proof=None, now=NOW)
+ self.assertIsNone(drain)
+ self.assertFalse(source.available)
+ self.assertIn("denies", source.detail)
+
+ def test_tampered_drain_proof_is_reported_invalid(self) -> None:
+ proof = drain_proof_fixture()
+ proof["clean"] = True
+ proof["proof_id"] = "0" * 64
+ drain, source = restart_console.load_drain_status(proof=proof, now=NOW)
+ self.assertTrue(source.available)
+ self.assertFalse(drain["valid"])
+
+ def test_absent_reconcile_proof_is_unavailable(self) -> None:
+ reconcile, source = restart_console.load_reconcile_status(load_proof=None)
+ self.assertIsNone(reconcile)
+ self.assertFalse(source.available)
+
+ def test_reconcile_proof_is_rendered_when_supplied(self) -> None:
+ payload = {
+ "overall_status": "degraded",
+ "mode": "log_only",
+ "resolved_count": 3,
+ "unresolved_count": 2,
+ "items": [
+ {
+ "dimension": "leases",
+ "status": "unresolved",
+ "summary": "2 orphaned leases",
+ "follow_up_required": True,
+ }
+ ],
+ }
+ reconcile, source = restart_console.load_reconcile_status(
+ load_proof=lambda: payload
+ )
+ self.assertTrue(source.available)
+ self.assertEqual(reconcile["unresolved_count"], 2)
+
+
+class RenderingTest(unittest.TestCase):
+ def _snapshot(self, **kwargs):
+ params = {
+ "principal": _principal(console_authz.OPERATOR),
+ "read_inventory": _inventory(sessions=[_live_session()]),
+ "now": NOW,
+ }
+ params.update(kwargs)
+ return restart_console.load_restart_console_snapshot(**params)
+
+ def test_page_renders_every_section(self) -> None:
+ html = restart_views.render_restart_console_page(self._snapshot())
+ for heading in (
+ "Impact preview",
+ "Drain proof",
+ "Post-restart reconcile",
+ "Restart classes",
+ "Approval controls",
+ "Break-glass",
+ ):
+ self.assertIn(heading, html)
+
+ def test_hostile_session_id_is_escaped(self) -> None:
+ hostile = ""
+ html = restart_views.render_restart_console_page(
+ self._snapshot(read_inventory=_inventory(sessions=[_live_session(hostile)]))
+ )
+ self.assertNotIn("