From c89b4aa4a5762779cef0311cda0eacf030f6028e Mon Sep 17 00:00:00 2001
From: tegwick
Date: Thu, 30 Jul 2026 15:43:16 +0200
Subject: [PATCH] Implement WP-0009-T04: Control Plane interactive UI on
whynot-design
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Builds the Control Plane's browser UI (login, dashboard, Phase
registration, Development Credit entry/proposal/review, credential
admin, audit log) as a FastAPI + Jinja2 app over the already-finished
T03 backend, rather than from scratch — whynot-design's Lit web
components are vendored as static assets (source commit 4b62cffc,
v0.4.1), with lit itself resolved via an esm.sh CDN import map.
Session auth re-checks the credential token against the database on
every request rather than trusting the session cookie's cached rights,
so a mid-session revocation takes effect immediately.
9 new Docker-gated HTTP-level tests via FastAPI's TestClient (no
browser-automation tool available, so real rendering of the
components was never visually verified). All four WP-0009 tasks are
now done; workplan marked finished.
Co-Authored-By: Claude Sonnet 5
---
README.md | 2 +-
pyproject.toml | 3 +
src/target_revenue/registry.py | 16 +
.../service/control_plane_app.py | 395 ++++++++++++
.../admin_credentials.html | 38 ++
.../control_plane_templates/audit.html | 27 +
.../service/control_plane_templates/base.html | 54 ++
.../control_plane_templates/dashboard.html | 29 +
.../control_plane_templates/login.html | 14 +
.../control_plane_templates/phase_detail.html | 64 ++
.../control_plane_templates/phase_new.html | 45 ++
.../control_plane_templates/proposals.html | 33 +
.../service/static/whynot-design/VENDORED.md | 27 +
.../static/whynot-design/elements/_styles.js | 604 ++++++++++++++++++
.../static/whynot-design/elements/atoms.js | 164 +++++
.../static/whynot-design/elements/chrome.js | 206 ++++++
.../static/whynot-design/elements/form.js | 205 ++++++
.../static/whynot-design/elements/icons.js | 45 ++
.../static/whynot-design/elements/layout.js | 287 +++++++++
.../service/static/whynot-design/index.js | 35 +
.../whynot-design/styles/colors_and_type.css | 270 ++++++++
.../whynot-design/styles/components.css | 590 +++++++++++++++++
tests/test_control_plane_app.py | 289 +++++++++
...EV-WP-0009-target-revenue-control-plane.md | 47 +-
24 files changed, 3486 insertions(+), 3 deletions(-)
create mode 100644 src/target_revenue/service/control_plane_app.py
create mode 100644 src/target_revenue/service/control_plane_templates/admin_credentials.html
create mode 100644 src/target_revenue/service/control_plane_templates/audit.html
create mode 100644 src/target_revenue/service/control_plane_templates/base.html
create mode 100644 src/target_revenue/service/control_plane_templates/dashboard.html
create mode 100644 src/target_revenue/service/control_plane_templates/login.html
create mode 100644 src/target_revenue/service/control_plane_templates/phase_detail.html
create mode 100644 src/target_revenue/service/control_plane_templates/phase_new.html
create mode 100644 src/target_revenue/service/control_plane_templates/proposals.html
create mode 100644 src/target_revenue/service/static/whynot-design/VENDORED.md
create mode 100644 src/target_revenue/service/static/whynot-design/elements/_styles.js
create mode 100644 src/target_revenue/service/static/whynot-design/elements/atoms.js
create mode 100644 src/target_revenue/service/static/whynot-design/elements/chrome.js
create mode 100644 src/target_revenue/service/static/whynot-design/elements/form.js
create mode 100644 src/target_revenue/service/static/whynot-design/elements/icons.js
create mode 100644 src/target_revenue/service/static/whynot-design/elements/layout.js
create mode 100644 src/target_revenue/service/static/whynot-design/index.js
create mode 100644 src/target_revenue/service/static/whynot-design/styles/colors_and_type.css
create mode 100644 src/target_revenue/service/static/whynot-design/styles/components.css
create mode 100644 tests/test_control_plane_app.py
diff --git a/README.md b/README.md
index b96feb1..c1668ab 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule**
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — **finished**, all 9 tasks done (Postgres-backed registries/ledger/metrics/attestation/breach-record, ADR-0002 accepted, onboarding CLI, hosted conformance suite) |
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — **finished**, all 4 tasks done. `trsl:policy:linear-longstop-v0` confirmed 2026-07-29 as the v1 norm for the first pilot cohort; `progress-paused-longstop-v1` named as the next iteration, not yet adopted |
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout — active; T01–T04 done. `info-tech-canon` dry-run onboarding routine exercised end-to-end 2026-07-29. **Org-wide TRSL license adoption executed 2026-07-30** across ~90 `coulomb`-org repos (`history/260730-TRSL-OrgWideLicenseRollout.md`) — a license-text adoption, not a Phase declaration. T05 (real Phase go-live gate) remains `todo` by design; no Phase exists yet for any repo |
-| [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — active; T01, T02, and **T03 (backend rights enforcement + audit log + propose/review workflow, `src/target_revenue/control_plane.py`) done**; T04 (interactive UI) next |
+| [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — **finished**, all 4 tasks done. **T04 (interactive UI, `src/target_revenue/service/control_plane_app.py`) built on vendored `whynot-design` web components** rather than from scratch, per an explicit feasibility check |
| [TREV-WP-0010](workplans/TREV-WP-0010-development-effort-calculator.md) | Development Effort Calculator — **finished**, all 3 tasks done. Applied to the three real pilot candidates (`history/260730-EffortCalculator-CandidateApplication.md`) — every calculator-derived Initial Target came out materially lower than the earlier hand-picked placeholders, two of three carrying explicit warnings recommending manual review |
Hub index: [`WORK-RECORDS.md`](WORK-RECORDS.md) · brief: [`.custodian-brief.md`](.custodian-brief.md)
diff --git a/pyproject.toml b/pyproject.toml
index b6c4c58..f66d396 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,9 @@ service = [
"psycopg-pool>=3.1",
"fastapi>=0.110",
"uvicorn>=0.27",
+ "jinja2>=3.1",
+ "itsdangerous>=2.1",
+ "python-multipart>=0.0.9",
]
service-dev = [
"target-revenue[service,dev]",
diff --git a/src/target_revenue/registry.py b/src/target_revenue/registry.py
index b90a1a0..1a2010b 100644
--- a/src/target_revenue/registry.py
+++ b/src/target_revenue/registry.py
@@ -218,6 +218,22 @@ def get_phase_manifest(conn: Connection, phase_id: str) -> dict[str, Any] | None
return row[0] if row else None
+def list_phase_manifests_for_licensor(conn: Connection, licensor_id: str) -> list[dict[str, Any]]:
+ """All Phases registered by one Licensor tenant, most recently
+ registered first — needed by any UI that wants to show "my Phases"
+ (WP-0009-T04's Control Plane dashboard) rather than requiring a
+ caller to already know every `phase_id` in advance."""
+ rows = conn.execute(
+ """
+ SELECT manifest FROM phase_manifests
+ WHERE licensor_id = %s
+ ORDER BY registered_at DESC
+ """,
+ (licensor_id,),
+ ).fetchall()
+ return [row[0] for row in rows]
+
+
def register_extension(
conn: Connection, licensor: Licensor, extension: dict[str, Any]
) -> None:
diff --git a/src/target_revenue/service/control_plane_app.py b/src/target_revenue/service/control_plane_app.py
new file mode 100644
index 0000000..398f2b2
--- /dev/null
+++ b/src/target_revenue/service/control_plane_app.py
@@ -0,0 +1,395 @@
+"""Target Revenue Control Plane — interactive UI (WP-0009-T04).
+
+An HTML front end over `target_revenue.control_plane`, itself a client
+layer over the hosted Trust Service (`registry.py`, `ledger.py`) — this
+module adds no new rights-enforcement or audit logic of its own, it only
+frames `control_plane.py`'s functions as browser-usable forms and pages
+(session-based sign-in, flash messages, whynot-design-styled templates).
+
+Session model: the browser session stores only the raw credential token
+(`itsdangerous`-signed cookie, via Starlette's `SessionMiddleware` — never
+the resolved rights tier, which is re-checked against the database on
+every request so a mid-session revocation takes effect immediately).
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+from fastapi import Depends, FastAPI, Form, HTTPException, Request
+from fastapi.responses import RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from psycopg import Connection
+from psycopg_pool import ConnectionPool
+from starlette.middleware.sessions import SessionMiddleware
+
+from .. import control_plane, ledger, metrics, registry
+from . import keys
+
+_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
+_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "control_plane_templates")
+
+_DATABASE_URL_ENV = "TRF_DATABASE_URL"
+_SECRET_KEY_ENV = "TRF_CONTROL_PLANE_SECRET_KEY"
+
+app = FastAPI(title="Target Revenue Control Plane", version="0.1.0")
+app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
+templates = Jinja2Templates(directory=_TEMPLATES_DIR)
+
+_secret_key = os.environ.get(_SECRET_KEY_ENV)
+if not _secret_key:
+ import secrets
+ import warnings
+
+ warnings.warn(
+ f"{_SECRET_KEY_ENV} not set — generating an ephemeral session secret. "
+ "Every restart invalidates existing sessions; do not use this in any "
+ "real deployment.",
+ stacklevel=1,
+ )
+ _secret_key = secrets.token_hex(32)
+app.add_middleware(SessionMiddleware, secret_key=_secret_key)
+
+
+def get_pool() -> ConnectionPool:
+ if not hasattr(app.state, "pool"):
+ dsn = os.environ.get(_DATABASE_URL_ENV)
+ if not dsn:
+ raise RuntimeError(f"{_DATABASE_URL_ENV} is not set")
+ app.state.pool = ConnectionPool(dsn, min_size=1, max_size=5, open=True)
+ return app.state.pool
+
+
+def get_signing_key():
+ if not hasattr(app.state, "signing_key"):
+ app.state.signing_key = keys.load_signing_key()
+ return app.state.signing_key
+
+
+def get_connection():
+ pool = get_pool()
+ with pool.connection() as conn:
+ yield conn
+
+
+def get_session_licensor(
+ request: Request, conn: Connection = Depends(get_connection)
+) -> registry.Licensor | None:
+ """The signed-in credential, re-authenticated against the database on
+ every request (not trusted from the session cookie alone) so a
+ revocation between requests takes effect immediately."""
+ token = request.session.get("token")
+ if not token:
+ return None
+ try:
+ return registry.authenticate(conn, token)
+ except registry.RegistrationError:
+ request.session.clear()
+ return None
+
+
+class _NotAuthenticated(Exception):
+ """Raised by `require_login` when there is no valid session — handled
+ below by a dedicated exception handler so it never collides with (or
+ overrides the default handling of) ordinary HTTPExceptions like a
+ 404 for an unknown Phase id."""
+
+
+def require_login(
+ licensor: registry.Licensor | None = Depends(get_session_licensor),
+) -> registry.Licensor:
+ if licensor is None:
+ raise _NotAuthenticated()
+ return licensor
+
+
+def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]:
+ context = {
+ "request": request,
+ "session_credential_label": licensor.credential_label if licensor else None,
+ "session_rights": licensor.rights if licensor else None,
+ "session_licensor_id": licensor.licensor_id if licensor else None,
+ "flash": request.session.pop("flash", None),
+ "flash_variant": request.session.pop("flash_variant", None),
+ }
+ context.update(extra)
+ return context
+
+
+def _redirect(url: str, request: Request, flash: str | None = None, variant: str = "info") -> RedirectResponse:
+ if flash:
+ request.session["flash"] = flash
+ request.session["flash_variant"] = variant
+ return RedirectResponse(url=url, status_code=303)
+
+
+@app.exception_handler(_NotAuthenticated)
+async def _redirect_to_login(request: Request, exc: _NotAuthenticated):
+ return RedirectResponse(url="/login", status_code=303)
+
+
+# --- Auth --------------------------------------------------------------
+
+
+@app.get("/login")
+def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)):
+ if licensor is not None:
+ return RedirectResponse(url="/", status_code=303)
+ return templates.TemplateResponse(request, "login.html", _template_context(request, None))
+
+
+@app.post("/login")
+def login_submit(
+ request: Request,
+ token: str = Form(...),
+ conn: Connection = Depends(get_connection),
+):
+ try:
+ registry.authenticate(conn, token)
+ except registry.RegistrationError:
+ request.session["flash"] = "Invalid or revoked credential token."
+ request.session["flash_variant"] = "danger"
+ return RedirectResponse(url="/login", status_code=303)
+ request.session["token"] = token
+ return RedirectResponse(url="/", status_code=303)
+
+
+@app.post("/logout")
+def logout(request: Request):
+ request.session.clear()
+ return RedirectResponse(url="/login", status_code=303)
+
+
+# --- Dashboard -----------------------------------------------------------
+
+
+@app.get("/")
+def dashboard(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+):
+ phases = registry.list_phase_manifests_for_licensor(conn, licensor.licensor_id)
+ return templates.TemplateResponse(
+ request, "dashboard.html", _template_context(request, licensor, phases=phases)
+ )
+
+
+# --- Phases ----------------------------------------------------------------
+
+
+@app.get("/phases/new")
+def phase_new_form(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+):
+ if not registry.has_right(licensor.rights, "operator"):
+ return _redirect("/", request, "Operator rights required to register a Phase.", "danger")
+ return templates.TemplateResponse(request, "phase_new.html", _template_context(request, licensor))
+
+
+@app.post("/phases/new")
+def phase_new_submit(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ phase_id: str = Form(...),
+ milestone_release_name: str = Form(...),
+ source_revision: str = Form(...),
+ initial_target_amount: float = Form(...),
+ currency: str = Form(...),
+ future_license: str = Form(...),
+ degeneration_policy: str = Form(...),
+ longstop_at: str = Form(...),
+ ledger_uri: str = Form(..., alias="ledger"),
+):
+ manifest = {
+ "framework": "TRF-0.1",
+ "license": "TRSL-0.1",
+ "phase": {
+ "id": phase_id,
+ "milestone_release": {"name": milestone_release_name, "source_revision": source_revision},
+ "initial_target": {"amount": initial_target_amount, "currency": currency},
+ "future_license": future_license,
+ "degeneration_policy": degeneration_policy,
+ "longstop_at": longstop_at,
+ "ledger": ledger_uri,
+ },
+ }
+ try:
+ control_plane.register_phase(conn, licensor, manifest)
+ except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
+ return _redirect("/phases/new", request, str(exc), "danger")
+ return _redirect(f"/phases/{phase_id}", request, f"Phase {phase_id} registered.", "success")
+
+
+@app.get("/phases/{phase_id}")
+def phase_detail(
+ phase_id: str,
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+):
+ manifest = registry.get_phase_manifest(conn, phase_id)
+ if manifest is None:
+ raise HTTPException(status_code=404, detail="phase not found")
+ entries = ledger.get_ledger(conn, phase_id)
+ computed_metrics = metrics.compute_metrics(manifest, entries, metrics.utcnow())
+ return templates.TemplateResponse(
+ request,
+ "phase_detail.html",
+ _template_context(request, licensor, manifest=manifest, ledger=entries, metrics=computed_metrics),
+ )
+
+
+@app.post("/phases/{phase_id}/ledger")
+def phase_ledger_submit(
+ phase_id: str,
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ signing_key=Depends(get_signing_key),
+ entry_id: str = Form(...),
+ amount: float = Form(...),
+ currency: str = Form(...),
+ recognized_at: str = Form(...),
+ evidence_reference: str = Form(...),
+ extension_id: str = Form(...),
+ extension_version: str = Form(...),
+):
+ entry_input = {
+ "id": entry_id,
+ "phase": phase_id,
+ "type": "development-credit",
+ "amount": amount,
+ "currency": currency,
+ "recognized_at": recognized_at,
+ "evidence_reference": evidence_reference,
+ "extension": {"id": extension_id, "version": extension_version},
+ }
+ try:
+ if registry.has_right(licensor.rights, "operator"):
+ control_plane.append_development_credit(conn, licensor, phase_id, entry_input, signing_key)
+ flash = f"Entry {entry_id} appended."
+ else:
+ control_plane.propose_ledger_entry(conn, licensor, phase_id, entry_input)
+ flash = f"Entry {entry_id} submitted for review."
+ except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
+ return _redirect(f"/phases/{phase_id}", request, str(exc), "danger")
+ return _redirect(f"/phases/{phase_id}", request, flash, "success")
+
+
+# --- Proposals (Operator+) --------------------------------------------------
+
+
+@app.get("/proposals")
+def proposals_list(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+):
+ if not registry.has_right(licensor.rights, "operator"):
+ return _redirect("/", request, "Operator rights required to review proposals.", "danger")
+ proposals = control_plane.list_proposed_entries(conn)
+ return templates.TemplateResponse(
+ request, "proposals.html", _template_context(request, licensor, proposals=proposals)
+ )
+
+
+@app.post("/proposals/{proposal_id}/approve")
+def proposal_approve(
+ proposal_id: int,
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ signing_key=Depends(get_signing_key),
+):
+ try:
+ control_plane.approve_proposed_entry(conn, licensor, proposal_id, signing_key)
+ except control_plane.ControlPlaneError as exc:
+ return _redirect("/proposals", request, str(exc), "danger")
+ return _redirect("/proposals", request, f"Proposal {proposal_id} approved.", "success")
+
+
+@app.post("/proposals/{proposal_id}/reject")
+def proposal_reject(
+ proposal_id: int,
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+):
+ try:
+ control_plane.reject_proposed_entry(conn, licensor, proposal_id)
+ except control_plane.ControlPlaneError as exc:
+ return _redirect("/proposals", request, str(exc), "danger")
+ return _redirect("/proposals", request, f"Proposal {proposal_id} rejected.", "success")
+
+
+# --- Credentials (Admin) ----------------------------------------------------
+
+
+@app.get("/admin/credentials")
+def admin_credentials_form(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+):
+ if not registry.has_right(licensor.rights, "admin"):
+ return _redirect("/", request, "Admin rights required.", "danger")
+ return templates.TemplateResponse(request, "admin_credentials.html", _template_context(request, licensor))
+
+
+@app.post("/admin/credentials")
+def admin_credentials_issue(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ credential_label: str = Form(...),
+ rights: str = Form(...),
+):
+ if not registry.has_right(licensor.rights, "admin"):
+ return _redirect("/", request, "Admin rights required.", "danger")
+ try:
+ new_credential = control_plane.issue_user_credential(
+ conn, licensor, licensor.licensor_id, credential_label, rights
+ )
+ except (control_plane.ControlPlaneError, registry.RegistrationError) as exc:
+ return _redirect("/admin/credentials", request, str(exc), "danger")
+ return templates.TemplateResponse(
+ request,
+ "admin_credentials.html",
+ _template_context(
+ request, licensor,
+ new_token=new_credential.token, new_token_label=new_credential.credential_label,
+ ),
+ )
+
+
+@app.post("/admin/credentials/revoke")
+def admin_credentials_revoke(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+ token: str = Form(...),
+):
+ if not registry.has_right(licensor.rights, "admin"):
+ return _redirect("/", request, "Admin rights required.", "danger")
+ try:
+ control_plane.revoke_user_credential(conn, licensor, token)
+ except control_plane.ControlPlaneError as exc:
+ return _redirect("/admin/credentials", request, str(exc), "danger")
+ return _redirect("/admin/credentials", request, "Credential revoked.", "success")
+
+
+# --- Audit log ---------------------------------------------------------
+
+
+@app.get("/audit")
+def audit_log(
+ request: Request,
+ licensor: registry.Licensor = Depends(require_login),
+ conn: Connection = Depends(get_connection),
+):
+ log = control_plane.get_audit_log(conn)
+ return templates.TemplateResponse(request, "audit.html", _template_context(request, licensor, log=log))
diff --git a/src/target_revenue/service/control_plane_templates/admin_credentials.html b/src/target_revenue/service/control_plane_templates/admin_credentials.html
new file mode 100644
index 0000000..6669383
--- /dev/null
+++ b/src/target_revenue/service/control_plane_templates/admin_credentials.html
@@ -0,0 +1,38 @@
+{% extends "base.html" %}
+{% block title %}Credentials — Target Revenue Control Plane{% endblock %}
+{% block content %}
+
+ Manage credentials (Admin)
+
+
+{% if new_token %}
+
+ New credential issued for {{ new_token_label }}. Copy it now —
+ it will not be shown again: {{ new_token }}
+
+{% endif %}
+
+
Issue a new credential
+
+
+
Revoke a credential
+
+{% endblock %}
diff --git a/src/target_revenue/service/control_plane_templates/audit.html b/src/target_revenue/service/control_plane_templates/audit.html
new file mode 100644
index 0000000..8b7245e
--- /dev/null
+++ b/src/target_revenue/service/control_plane_templates/audit.html
@@ -0,0 +1,27 @@
+{% extends "base.html" %}
+{% block title %}Audit log — Target Revenue Control Plane{% endblock %}
+{% block content %}
+
+ Control Plane audit log
+
+
+ This is the Control Plane's own record of who did what — independent of
+ the Trust Service's signed records, which only ever attest that the
+ {{ session_licensor_id }} tenant acted, never which
+ individual human (see specs/TargetRevenueControlPlaneConcept.md §5).
+
+
+
when
who
action
Phase
Trust Service record
+
+ {% for row in log %}
+
+
{{ row.created_at }}
+
{{ row.actor_credential_label }}
+
{{ row.action }}
+
{{ row.phase_id }}
+
{{ row.trust_service_record_id }}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/src/target_revenue/service/control_plane_templates/base.html b/src/target_revenue/service/control_plane_templates/base.html
new file mode 100644
index 0000000..d2919fb
--- /dev/null
+++ b/src/target_revenue/service/control_plane_templates/base.html
@@ -0,0 +1,54 @@
+
+
+
+
+{% block title %}Target Revenue Control Plane{% endblock %}
+
+
+
+
+
+
+
+
+ Target Revenue Control Plane
+
+
+{% if session_credential_label %}
+
+ Signed in as {{ session_credential_label }}
+ ({{ session_rights }}) for {{ session_licensor_id }}
+ · Dashboard
+ {% if session_rights in ("operator", "admin") %}· Proposals{% endif %}
+ {% if session_rights == "admin" %}· Credentials{% endif %}
+ · Audit log
+ ·
+ This is a dry-run/pilot tool. No real Phase is authorized to go live via
+ this interface — see workplans/TREV-WP-0008-governance-and-pilot-rollout.md T05.
+ Visual language vendored from whynot-design — see
+ static/whynot-design/VENDORED.md.
+
+
+
diff --git a/src/target_revenue/service/control_plane_templates/dashboard.html b/src/target_revenue/service/control_plane_templates/dashboard.html
new file mode 100644
index 0000000..cb16a20
--- /dev/null
+++ b/src/target_revenue/service/control_plane_templates/dashboard.html
@@ -0,0 +1,29 @@
+{% extends "base.html" %}
+{% block title %}Dashboard — Target Revenue Control Plane{% endblock %}
+{% block content %}
+
+ Phases for {{ session_licensor_id }}
+
+
+{% if session_rights in ("operator", "admin") %}
+
+{% else %}
+No ledger entries yet.
+{% endif %}
+
+{% if session_rights in ("contributor", "operator", "admin") %}
+
+ {% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
+
+
+{% endif %}
+{% endblock %}
diff --git a/src/target_revenue/service/control_plane_templates/phase_new.html b/src/target_revenue/service/control_plane_templates/phase_new.html
new file mode 100644
index 0000000..5175406
--- /dev/null
+++ b/src/target_revenue/service/control_plane_templates/phase_new.html
@@ -0,0 +1,45 @@
+{% extends "base.html" %}
+{% block title %}Register a Phase — Target Revenue Control Plane{% endblock %}
+{% block content %}
+
+ Register a new Phase (Operator+)
+
+
+ This registers a real Phase Manifest against the hosted Trust Service.
+ It does not itself authorize going live for a repo — see
+ workplans/TREV-WP-0008-governance-and-pilot-rollout.md T05.
+
+
+{% endblock %}
diff --git a/src/target_revenue/service/control_plane_templates/proposals.html b/src/target_revenue/service/control_plane_templates/proposals.html
new file mode 100644
index 0000000..233038e
--- /dev/null
+++ b/src/target_revenue/service/control_plane_templates/proposals.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+{% block title %}Proposals — Target Revenue Control Plane{% endblock %}
+{% block content %}
+
+ Pending Development Credit proposals (Operator+)
+
+{% if proposals %}
+