Compare commits

...
Author SHA1 Message Date
jcwalker3andClaude Opus 4.8 e33b8d3712 docs(webui): add MCP Control Plane Web Console architecture ADR (Closes #632)
Adds the durable architecture record epic #631 lacked: layer contract,
authority boundaries, the redaction boundary, /api/v1 versioning with a
compatibility rule for the existing unversioned MVP exports, a target page
map, per-child component ownership for all twenty children (#632-#651),
phase gates, and explicit forbidden paths.

Documentation only. No UI, API, or deployment change.

- docs/architecture/webui-control-plane-console-architecture-adr.md (new)
- docs/webui-local-dev.md: cross-link to the ADR
- tests/test_webui_architecture_docs.py: doc gate for the acceptance
  criteria and the cross-link (12 cases)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 04:52:21 -05:00
sysadmin 620ed6e9a9 Merge pull request 'fix(lock): allow exact-owner renewal of expired author issue locks (Closes #760)' (#791) from fix/issue-760-exact-owner-renewal into master 2026-07-22 01:02:39 -05:00
3 changed files with 356 additions and 0 deletions
@@ -0,0 +1,201 @@
# ADR: MCP Control Plane Web Console architecture and information architecture
- **Status:** Proposed (documentation only; blocks no code, gates every #631 child)
- **Date:** 2026-07-22
- **Tracking issue:** [#632](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/632) — architecture and information architecture (Phase 1)
- **Parent epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631) — MCP Control Plane Web Console
- **Foundation (closed, extend — do not recreate):** #425 tracker and children #426 skeleton, #427 projects, #428 prompts, #429 queue, #430 runtime, #431 audit paste, #432 worktrees, #433 leases, #434 gated actions, #435 auth/deployment boundary, #436 tests/CI
- **Related:** `mcp-allocator-control-plane-observability-adr.md`, `mcp-stable-control-runtime-policy-adr.md`, `control-plane-db-substrate.md`, `../safety-model.md`, `../tool-boundaries.md`, `../credential-isolation.md`, `../webui-local-dev.md`, `../webui-deployment.md`
## 1. Context
The MVP web UI shipped under `webui/` as a read-only Starlette application with ten operator routes and a JSON export beside most of them. It is a working foundation, not the console product described by epic #631, and it carries no durable architecture record: no layer contract, no authority boundary, no API versioning rule, no page map, and no statement of which phase may open a write path.
Twenty children (#632#651) hang off #631. Without one architecture document each implementer re-derives boundaries, and the most likely failure is not a bad view — it is a privileged action wired into the browser before the authorization and audit model of #633 exists.
This ADR is the single retrievable design source for the console. It decides structure only. It implements no UI, no API, and no change to deployment topology.
## 2. Decision summary (core)
| Layer | Owns | Must not |
|-------|------|----------|
| **Browser UI** | Rendering, navigation, operator affordances | Hold tokens, call Gitea/providers directly, or execute an action the server did not gate |
| **HTTP route layer** (`webui/app.py`) | Versioned routing, authentication, authorization, redaction boundary, audit emission | Contain domain logic or reach past a loader to a raw credential |
| **Domain loaders** (`webui/*_loader.py`, `*_scanner.py`, `runtime_health.py`, `project_registry.py`) | Assembling read models from authoritative sources | Mutate anything, or emit unredacted secrets across the boundary |
| **Gitea** | Durable work record: issues, PRs, comments, reviews, labels, merges | Be the concurrency lock under multi-session load |
| **Control-plane DB** | Sessions, assignment, leases, heartbeats, events | Replace Gitea history |
| **MCP tools / capability gates** | Mutation authorization | Be re-implemented, mirrored, or bypassed by console code |
| **External providers** (Sentry/GlitchTip, AI providers) | Incident and usage data | Assign work or mutate Gitea outside the #612 bridge |
**One-liner:** **Gitea records. The DB coordinates. MCP tools authorize. The console projects state and executes only capability-checked, audited actions. Providers observe.**
## 3. Console surface today versus target
`webui/app.py` currently registers these routes (see `../webui-local-dev.md` for the operator-facing table): `/`, `/health`, `/queue`, `/projects`, `/projects/{id}`, `/prompts`, `/runtime`, `/audit`, `/worktrees`, `/leases`, `/actions`, and the unversioned exports `/api/queue`, `/api/projects`, `/api/prompts`, `/api/runtime`, `/api/audit`, `/api/worktrees`, `/api/leases`, `/api/actions`, `/api/actions/{id}/preview`, `/api/actions/{id}/attempt`.
Every one of these is **retained and evolved**. No child issue may recreate a route from scratch; each states in its PR which MVP surface it extends and what it changes.
## 4. Authority boundaries
### 4.1 Gitea (durable record)
Authoritative for issue and PR identity and state, comments, reviews and verdicts, labels, merges, and branch refs. When the console and Gitea disagree about durable state, Gitea wins and the console view is refreshed — never the reverse.
### 4.2 Control-plane DB (coordination)
Authoritative for live coordination: which session holds which assignment or lease, heartbeat freshness, expiry, and the allocation event log. The console reads it; only allocator and lease tools write it.
### 4.3 MCP capability gates (authorization)
`task_capability_map.py` and `gitea_resolve_task_capability` remain the only authority that decides whether a mutation may run. The console asks; it never answers. A console action that cannot name the MCP tool it delegates to is not an action — it is a defect.
### 4.4 Filesystem and git (local state)
Issue lock files, `branches/` worktrees, and registered git worktrees are read through existing scanners. The console never deletes, rebinds, or force-clears local state outside a Phase 2 gated action.
### 4.5 Providers (observe only)
Sentry/GlitchTip and AI providers are read surfaces. The #612 incident bridge is the only path that turns an observation into Gitea work.
## 5. Request flow and the redaction boundary
```text
browser ──HTTP──> route layer ──> domain loader ──> Gitea REST
│ ├──> control-plane DB
│ ├──> filesystem / git
│ └──> providers
[redaction boundary]
audit event
```
| Stage | May hold credentials | Emits |
|-------|----------------------|-------|
| Loader → route layer | yes (server-side, via `gitea_auth`) | domain objects |
| Route layer → browser | **no** | redacted DTOs, HTML |
Two invariants govern the boundary and are non-negotiable for every child:
1. **No secrets to the browser.** Tokens, keychain identifiers, Authorization headers, raw provider endpoints, and credential-bearing URLs are redacted by default, consistent with `../safety-model.md` §3 and `../credential-isolation.md`. Serializers redact; templates do not sanitize after the fact.
2. **No ungated mutations.** A write reaches an authoritative system only by delegating to an MCP tool that passed its own capability gate. HTML forms and JSON endpoints are transport, never authority.
## 6. API naming and versioning
**Decision:** all console APIs added from Phase 1 onward are served under `/api/v1/...`.
- Nouns are plural and hierarchical: `/api/v1/inventory/leases`, `/api/v1/system/health`.
- Read endpoints are `GET` and side-effect free.
- Phase 2 action endpoints are `POST /api/v1/actions/{action_id}/preview` and `POST /api/v1/actions/{action_id}/execute`; `preview` stays side-effect free and returns a mutation ledger.
- The existing unversioned MVP exports remain as **compatibility aliases** for the whole of Phase 1 so the current operator flow never breaks. They may be retired no earlier than Phase 2, and only after the replacing `v1` route ships and `../webui-local-dev.md` records the swap.
- A breaking change to a `v1` payload requires `/api/v2/...`, not an in-place edit.
- Every JSON payload carries enough provenance for an auditor to tell where the data came from — at minimum the source system and whether the inventory was complete, matching the pagination-proof habit the MVP queue export already established.
## 7. Page map
| Page | Purpose | Owning child | Evolves |
|------|---------|--------------|---------|
| `/` | Console shell, navigation, next-safe-action summary | #638 | MVP `/` (#426) |
| `/system` | System-health dashboard | #639 | new, backed by #634 |
| `/traffic` | Workflow traffic control, queues, blockers | #640 | MVP `/queue` (#429) |
| `/runtime` | Runtime and session view | #641 | MVP `/runtime` (#430) |
| `/projects`, `/projects/{id}` | Project registry and onboarding | #635 | MVP `/projects` (#427) |
| `/inventory` | Sessions, leases, locks, worktrees in one surface | #636 | MVP `/leases` (#433) + `/worktrees` (#432) |
| `/timeline` | Workflow events and conversation timeline | #637 | new |
| `/actions` | Gated action registry, preview, execution | #642, #643, #644 | MVP `/actions` (#434) |
| `/gitea` | Issue and PR linkage console | #645 | new |
| `/policy` | Guardrail visibility, then versioned editing | #646, #647 | new |
| `/notifications` | Human-attention routing | #648 | new |
| `/observability` | Sentry/GlitchTip correlation and durable issue creation | #649 | new |
| `/providers` | AI-provider connections and insights | #650 | new |
| `/analytics` | Usage, token cost, latency, workflow performance | #651 | new |
| `/audit` | Final-report validator preview and audit log | #431 foundation, extended by #633 | MVP `/audit` (#431) |
| `/prompts`, `/prompts/{id}` | Canonical prompt library | #638 | MVP `/prompts` (#428) |
| `/health` | Liveness and deployment metadata | #634 | MVP `/health` (#435) |
## 8. Component ownership for every epic child
Each #631 child maps to at least one architectural component defined above.
| Child | Capability area | Primary component | Phase |
|-------|-----------------|-------------------|-------|
| #632 | Architecture and information architecture | this ADR | 1 |
| #633 | Authorization, RBAC, secret redaction, audit and retention | route layer + redaction boundary (§5) | 1 |
| #634 | Read-only system-health API | `/api/v1/system/health` + health loader | 1 |
| #635 | Project registry API evolution | `/api/v1/projects` + `project_registry.py` | 1 |
| #636 | Session, lease, lock, worktree inventory API | `/api/v1/inventory/*` + `lease_loader.py`, `worktree_scanner.py` | 1 |
| #637 | Workflow-event and conversation timeline model | `/api/v1/events` + control-plane DB event log | 1 |
| #638 | Application shell evolution | browser UI layer + `layout.py` | 1 |
| #639 | System-health dashboard | `/system` page over #634 | 1 |
| #640 | Workflow traffic-control view | `/traffic` page over the queue loader | 1 |
| #641 | Runtime and session view | `/runtime` page over `runtime_health.py` | 1 |
| #642 | Sanctioned restart and graceful reload controls | gated action framework, restart class | 2 |
| #643 | Requests, intent preview, authorization, workflow initiation | `/api/v1/actions/*` execute path | 2 |
| #644 | Stale-runtime recovery, worktree rebinding, reconciliation controls | gated actions over filesystem/git authority | 2 |
| #645 | Gitea issue and PR linkage console | `/gitea` page over Gitea authority | 3 |
| #646 | Workflow policy and guardrail visibility | `/policy` read view over the capability map | 3 |
| #647 | Versioned policy editing, validation, simulation, approval, rollback | `/policy` write path, gated | 3 |
| #648 | Notifications and human-attention routing | notification component over the event model | 3 |
| #649 | Sentry/GlitchTip connections, correlation, durable issue creation | provider layer + #612 incident bridge | 4 |
| #650 | AI-provider connections and operational insights | provider layer | 4 |
| #651 | Model usage, token cost, latency, workflow analytics | analytics component over the event model | 4 |
Related but **outside** this epic: #667 (restart status, impact preview, and approval controls) belongs to the #655 restart-governance umbrella and must reuse the #642 action class rather than adding a second restart surface.
## 9. Phase gates
| Phase | May ship | Entry condition |
|-------|----------|-----------------|
| **1 — read-only visibility** | `GET` pages and `GET /api/v1/...` | this ADR accepted |
| **2 — controlled actions** | gated `POST` action execution | #633 authorization, RBAC, and audit model landed |
| **3 — orchestration and policy** | linkage, policy visibility, versioned policy editing | Phase 1 inventory plus the Phase 2 action framework |
| **4 — insights** | provider correlation, analytics | evidence-backed sources from Phases 13 |
Phase 1 must not open a mutation endpoint, and the read-only guard that returns `405 read-only-mvp` stays in force until the Phase 2 entry condition is met. A phase is not entered by exception; if a control is urgent, the entry condition is what gets prioritized.
## 10. Security and workflow safety
- **Fail closed** on unknown authentication, missing RBAC mapping, or ambiguous lease ownership. An unknown state renders as blocked, never as permitted.
- **Redact by default**, per §5.
- **Every privileged action** requires a resolved capability, an explicit operator confirmation, and a durable audit event naming actor, action, target, and outcome.
- **Contamination surfaces.** Session contamination — including a manually killed MCP daemon (#630) — must be shown and must block clean claims rather than being silently repaired.
- **Deployment boundary unchanged.** Loopback by default, with the existing refusal of public binds (#435). This ADR documents that target; it does not widen it.
## 11. Forbidden paths
These are rejected designs, not preferences:
1. **Raw provider incidents as work.** The allocator never receives an unclassified Sentry/GlitchTip incident; only the #612 bridge turns an observation into a Gitea issue.
2. **Browser-held tokens.** No credential, keychain identifier, or Authorization header is ever sent to the browser or embedded in a client bundle.
3. **Process-kill recovery.** The console must not expose `pkill`, process-identifier termination, or any host process kill as a recovery affordance (#630). Restart is the sanctioned, operator-owned path of #642 and the #655 umbrella.
4. **Ungated browser mutations.** No review, approval, merge, close, or comment may originate from the browser without passing an MCP capability gate.
5. **Policy invented in the console.** The console projects policy from the capability map and canonical workflows; it never encodes a second copy.
6. **Recreating MVP scope.** Re-implementing a #426#436 surface without an explicit evolve-or-extend statement is out of bounds.
## 12. Approval checklist (readable without chat history)
A controller can accept or reject this ADR against these six points alone:
1. Layers and their owners are defined (§2) and each authority is named (§4).
2. The redaction boundary and the two invariants are stated (§5).
3. API versioning is decided, including what happens to the existing unversioned routes (§6).
4. A page map exists and names an owning child for every page (§7).
5. Every #631 child maps to at least one component and one phase (§8).
6. Phase gates and forbidden paths are explicit (§9, §11).
## 13. Open questions and follow-ups
Unresolved choices are recorded here rather than settled by implication. Each needs its own durable issue before the phase that depends on it:
- **Authentication mechanism.** Whether the console authenticates via an access proxy (Cloudflare Access or equivalent) or an application-level session is deferred to #633. This ADR requires only that it fail closed.
- **Event model substrate.** Whether the #637 timeline reads the control-plane event log directly or through a projection is deferred to #637.
- **CI path filter coverage.** `webui/ci_paths.py` triggers the web UI suite on `webui/`, `tests/test_webui_*`, and `docs/webui*`. This ADR lives under `docs/architecture/`, so editing it alone does not trigger that gate; the accompanying `tests/test_webui_architecture_docs.py` does run in the full suite. Widening the filter is a small follow-up, deliberately not bundled into a documentation-only change.
- **Retention.** Audit-event retention duration is owned by #633.
## 14. Acceptance
Accepting this ADR means:
- Phase 1 children may proceed against the layers, page map, and API rules above.
- Phase 2 children may not open a write path until #633 lands.
- Any deviation is recorded as an amendment to this file with its own issue reference, not as an undocumented divergence in code.
+6
View File
@@ -37,6 +37,12 @@ Optional environment variables:
See [webui-deployment.md](webui-deployment.md) for internal-only serving, See [webui-deployment.md](webui-deployment.md) for internal-only serving,
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435). Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
See
[architecture/webui-control-plane-console-architecture-adr.md](architecture/webui-control-plane-console-architecture-adr.md)
for the console architecture: layer and authority boundaries, the redaction
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
that govern when a write path may open (#632, epic #631).
## Routes (MVP) ## Routes (MVP)
| Path | Description | | Path | Description |
+149
View File
@@ -0,0 +1,149 @@
"""Documentation acceptance for the web console architecture ADR (#632 / epic #631).
Enforces the acceptance criteria of issue #632:
* AC1 — the ADR exists and covers layers, authority, phases, API versioning,
and a page map.
* AC2 — every #631 child (#632#651) maps to at least one architectural
component.
* AC3 — the closed MVP (#425#436) is stated as foundation, not recreated.
* AC4 — forbidden paths are explicit: raw provider incidents as work,
browser-held tokens, process-kill recovery.
* AC5 — a controller can approve the document without reading chat history.
Plus the linkage requirement: ``docs/webui-local-dev.md`` cross-links the ADR.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
ADR = (
REPO_ROOT
/ "docs"
/ "architecture"
/ "webui-control-plane-console-architecture-adr.md"
)
ADR_BASENAME = "webui-control-plane-console-architecture-adr.md"
LOCAL_DEV = REPO_ROOT / "docs" / "webui-local-dev.md"
# Epic #631 children, phases 1-4 (twenty capability areas).
EPIC_CHILDREN = tuple(f"#{number}" for number in range(632, 652))
def _read(path: Path) -> str:
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
return path.read_text(encoding="utf-8")
def test_ac1_adr_exists_with_required_sections():
text = _read(ADR)
lower = text.lower()
assert text.lstrip().startswith("#"), "ADR lacks a title"
assert "#631" in text and "#632" in text
for heading in (
"## 2. Decision summary",
"## 4. Authority boundaries",
"## 5. Request flow and the redaction boundary",
"## 6. API naming and versioning",
"## 7. Page map",
"## 8. Component ownership",
"## 9. Phase gates",
"## 11. Forbidden paths",
):
assert heading in text, f"ADR must contain section {heading!r}"
assert "browser ui" in lower and "domain loader" in lower
assert "control-plane db" in lower and "capability gate" in lower
def test_ac1_api_versioning_is_decided_including_legacy_routes():
text = _read(ADR)
assert "/api/v1/" in text, "ADR must decide the versioned API prefix"
assert "/api/v2/" in text, "ADR must state how breaking changes are handled"
lower = text.lower()
assert "compatibility alias" in lower, (
"ADR must say what happens to the existing unversioned MVP exports"
)
def test_ac1_page_map_covers_mvp_routes():
text = _read(ADR)
for route in ("`/`", "`/health`", "`/projects`", "`/prompts`", "`/runtime`",
"`/audit`", "`/actions`"):
assert route in text, f"page map must account for MVP route {route}"
def test_ac2_every_epic_child_maps_to_a_component():
text = _read(ADR)
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
missing = [child for child in EPIC_CHILDREN if child not in ownership]
assert not missing, (
f"epic #631 children without an architectural component: {missing}"
)
def test_ac2_every_child_row_declares_a_phase():
text = _read(ADR)
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
for child in EPIC_CHILDREN:
row = next(
(line for line in ownership.splitlines() if line.startswith(f"| {child} ")),
None,
)
assert row is not None, f"no ownership row for {child}"
assert row.rstrip().endswith(("| 1 |", "| 2 |", "| 3 |", "| 4 |")), (
f"ownership row for {child} must end with its phase: {row!r}"
)
def test_ac3_mvp_is_foundation_not_recreated():
text = _read(ADR)
assert "#425" in text and "#436" in text
lower = text.lower()
assert "do not recreate" in lower or "recreating mvp scope" in lower
assert "retained and evolved" in lower
def test_ac4_forbidden_paths_are_explicit():
text = _read(ADR)
forbidden = text.split("## 11. Forbidden paths", 1)[-1].split("## 12.", 1)[0]
lower = forbidden.lower()
assert "raw provider incidents" in lower and "#612" in forbidden
assert "browser-held tokens" in lower
assert "process-kill recovery" in lower and "#630" in forbidden
assert "ungated browser mutations" in lower
def test_ac5_approval_checklist_is_self_contained():
text = _read(ADR)
assert "## 12. Approval checklist" in text
checklist = text.split("## 12. Approval checklist", 1)[-1].split("## 13.", 1)[0]
for marker in ("1.", "2.", "3.", "4.", "5.", "6."):
assert marker in checklist, f"approval checklist missing item {marker}"
def test_adr_states_the_two_boundary_invariants():
text = _read(ADR)
lower = text.lower()
assert "no secrets to the browser" in lower
assert "no ungated mutations" in lower
def test_open_questions_are_recorded_not_implied():
text = _read(ADR)
assert "## 13. Open questions and follow-ups" in text
section = text.split("## 13. Open questions and follow-ups", 1)[-1]
assert "#633" in section, "deferred authorization work must name its issue"
def test_local_dev_doc_cross_links_the_adr():
text = _read(LOCAL_DEV)
assert ADR_BASENAME in text, (
"docs/webui-local-dev.md must cross-link the console architecture ADR "
"(issue #632 scope)"
)
def test_docs_do_not_embed_secrets():
for path in (ADR, LOCAL_DEV):
text = _read(path)
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
assert marker not in text, f"{path.name} contains {marker!r}"