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]>
This commit is contained in:
@@ -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}"
|
||||
Reference in New Issue
Block a user