# Internal web UI — local development (#426)

Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
MCP capability gates, and `skills/llm-project-workflow/` remain the source of
truth; this UI only provides route stubs and layout.

## Prerequisites

- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
- No secrets in repo, config, or client bundle

## Start the server

From the repository root (or an issue worktree):

```bash
./scripts/run-webui
```

Or directly:

```bash
python3 -m webui
```

Optional environment variables:

| Variable | Default | Purpose |
|----------|---------|---------|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
| `WEBUI_PORT` | `8765` | Listen port |
| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root |
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path |
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) |
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) |

See [webui-deployment.md](webui-deployment.md) for internal-only serving,
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).

See [webui-project-registry-api.md](webui-project-registry-api.md) for the
versioned project registry contract: registry schema versions 1 and 2, project
status, onboarding checklist state, and the fail-closed error payloads (#635).

## Routes (MVP)

| Path | Description |
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`, `uptime_seconds`) |
| `/api/v1/system/health` | Structured read-only system health (#634) |
| `/system-health` | System-health dashboard — readiness, version/uptime, dependencies, MCP namespaces, stale-runtime parity (#639) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
| `/traffic` | Workflow traffic-control view — runnable, leased, blocked, needs-controller, terminal-complete (#640) |
| `/api/traffic` | JSON traffic-control export with state classifications and next safe role actions |
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/v1/projects` | Versioned JSON registry export (#635) |
| `/api/v1/projects/{id}` | Versioned JSON project detail (#635) |
| `/api/projects` | JSON registry export — unversioned Phase 1 alias of `/api/v1/projects` |
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | MCP runtime health and stale detection (#430) |
| `/api/runtime` | JSON runtime health export |
| `/audit` | Report audit paste + validator preview (#431) |
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
| `/worktrees` | Worktree hygiene dashboard (#432) |
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
| `/actions` | Gated write-action registry — all disabled in MVP (#434) |
| `/api/actions` | JSON action registry with capability metadata |
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
| `/leases` | Lease and collision visibility (#433) |
| `/api/leases` | JSON lease/collision export |
| `/sessions` | Runtime and session view (#641) — health + inventory sessions/namespaces/worktrees |
| `/api/sessions` | JSON export for the runtime/session view |
| `/api/v1/sessions` | Versioned alias of `/api/sessions` |
| `/gitea` | Gitea issue↔PR linkage console (#645) — both directions, with the evidence for each edge |
| `/api/v1/gitea/linkage` | JSON linkage export; `502` when the read could not be answered |
| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) |
| `/timeline` | Phase 1 shell stub — workflow event timeline |
| `/policy` | Phase 1 shell stub — capability/role policy placeholder |
| `/insights` | Phase 1 shell stub — operational insights placeholder |

Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
local validator preview only (no Gitea mutations, no server-side storage).

### Traffic-control state vocabulary (#640)

The traffic view classifies each open issue/PR into exactly one bucket:

| Bucket | Meaning | Operator implication |
|--------|---------|----------------------|
| **runnable** | No active lease, no block reason, safe for its expected role | Next role may start work |
| **leased** | Active author claim or reviewer PR lease | Do not stomp; wait or adopt via role tools |
| **blocked** | Dependency, missing head pin, conflict, or unmet dependency | Author remediation first |
| **needs_controller** | Contaminated, controller-only diagnosis, or `status:blocked` | Controller only |
| **terminal_complete** | Reconciler / terminal-lock territory | Reconciler cleanup path |

`status:blocked` items route to **needs_controller**, not **blocked**:
`expected_role_for_candidate` sends them to the controller, and the blocker
reason renders in either bucket.

**Live path contracts (do not invent):**

- PR head pins come from `QueueItem.signals["head_sha"]` (full SHA). Display
  `extra["head_sha"]` is truncated and must never be used for routing.
- Reviewer leases are keyed as `(pr, pr_number)` only — never via a linked
  `issue_number` on the same lease marker.
- Issue claims come from `claim_inventory["entries"]`
  (`issue_claim_heartbeat.build_claim_inventory`). There is no `active_claims`
  key.
- Queue display badges are only: `blocked`, `claimed`, `duplicate`, `stale`,
  `in-review`, `open`. Review verdicts (`request-changes`, `approved`) are
  **not** queue badges; traffic does not invent them from the queue loader.

## System health API (#634)

`GET /api/v1/system/health` is the structured, read-only health surface for
automated readiness checks. It is the first console API under the `/api/v1`
prefix; the unversioned MVP exports remain as compatibility aliases.

`/health` is unchanged for existing consumers — every MVP key is still present
— and now also carries `started_at`, `uptime_seconds`, and a
`system_health_api` pointer. It stays deliberately cheap and runs no dependency
probe, because answering readiness costs real work.

**Status codes.** `200` when ready, `503` when a required dependency failed or
was never probed. Automation can branch on the code without parsing the body.

**Query flags.** The Gitea check is a network call, so it is opt-in:
`GET /api/v1/system/health?deep=1` runs it and caches the result for
`WEBUI_HEALTH_PROBE_TTL_SECONDS` (default 15s) so dashboard polling does not
amplify into remote load. Without the flag that probe reports `skipped`.

**Dependencies.** `control_plane_db` and `repository` are required and drive
readiness. `gitea` is optional: when it fails the overall `status` degrades but
`readiness.ready` stays true, because local inventory is still serveable. Each
entry carries `status`, `detail`, `required`, and `latency_ms`.

Two honesty rules are worth knowing before reading the payload:

* `stale_runtime.mutation_safe` is true only when the runtime, checkout, and
  remote-tracking commits are all known and equal. An unfetched remote is
  reported as indeterminate, never as safe.
* `mcp_namespaces` entries are always `unproven`. A web process runs outside
  the IDE-managed MCP client and cannot invoke a namespace tool, so per #543
  only a `client_namespace` probe can prove that path.

Sample response (abridged, healthy):

```json
{
  "status": "ok",
  "service": "mcp-control-plane-webui",
  "mode": "read-only",
  "api": "/api/v1/system/health",
  "timestamp": "2026-07-22T11:04:18.512034+00:00",
  "readiness": { "ready": true, "complete": true, "reasons": [] },
  "version": {
    "git_sha": "620ed6e9a9550b8da2ceb82d9ab8744e8920490f",
    "git_describe": "v1.1.0-898-g620ed6e",
    "control_plane_schema_version": 4,
    "python_version": "3.14.5",
    "known": true
  },
  "process": { "started_at": "2026-07-22T10:58:02.114+00:00", "uptime_seconds": 376.4 },
  "deep_probes_requested": false,
  "dependencies": [
    {
      "name": "control_plane_db",
      "kind": "sqlite",
      "status": "ok",
      "detail": "schema v4 readable",
      "required": true,
      "healthy": true,
      "latency_ms": 1.482,
      "metadata": { "schema_version": 4, "active_leases": 3 }
    },
    { "name": "repository", "kind": "git", "status": "ok", "required": true, "healthy": true },
    { "name": "gitea", "kind": "http", "status": "skipped", "required": false, "healthy": false }
  ],
  "mcp_namespaces": [
    { "namespace": "gitea-author", "required_tool": "gitea_whoami", "status": "unproven" }
  ],
  "stale_runtime": { "stale": false, "determinable": true, "mutation_safe": true, "reasons": [] },
  "probe_errors": []
}
```

No restart, reload, or process-kill control is exposed here: those are Phase 2
at the earliest, and #630 forbids process-kill recovery outright. Every probe
opens its subject read-only — the control-plane database is opened through a
`mode=ro` URI so a health check can never create or migrate a schema.

## Report audit (#431)

Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
reuses `final_report_validator` and review schema checks to surface missing
proof fields, wrong validation vocabulary, mutation contradictions, and a
suggested next prompt or issue-comment draft. Task kind can be auto-detected
or selected explicitly.

## Project registry (#427)

Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).

Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
copy outside git. The registry stores repo identity, remotes, profile names,
workflow/schema path references, and onboarding checklist steps — never tokens
or credentials.

Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
`prgs-reviewer`, and `prgs-reconciler` profiles.

## Prompt library (#428)

Prompts are generated at load time from canonical workflow files under
`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
copy/paste starters; canonical workflow files remain the only full policy
source.

## Live queue dashboard (#429)

`/queue` loads open PRs and issues for the default registry project (seed:
**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
credentials. The UI surfaces pagination proof (returned count, pages fetched,
`has_more`, `inventory_complete`) and classification badges (`claimed`,
`blocked`, `in-review`, `duplicate`) when evidence exists.

If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed).

## Gated actions (#434)

`/actions` registers future write actions (claim, comment, review, merge,
delete branch, create PR/issue). Each entry declares the MCP tool, required
permission, and profile role from `task_capability_map.py` — aligned with
`gitea_resolve_task_capability`. Buttons are disabled; previews always render
a mutation ledger. Direct `attempt_action` calls fail closed without invoking
MCP tools.

## Worktree hygiene (#432)

`/worktrees` scans local `branches/` directories and registered git worktrees.
Each entry is classified (`active-pr`, `active-issue`, `dirty`, `stale-clean`,
`detached-review`, `unsafe-unknown`, `orphan`). Missing preserved worktrees
referenced by the issue lock file are flagged as anomalies (#404). The page
includes a copy/paste canonical cleanup prompt only — no deletion actions.

Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).

## Lease visibility (#433)

`/leases` surfaces read-only lease and collision state: local issue lock file,
in-progress claim inventory (#268), reviewer PR lease comments when present
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
and duplicate local branches per issue. Links to collision-history backend
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.

## Runtime health (#430)

`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
project: active profile and role kind, authenticated identity (when credentials
are available), config model/mode, local vs remote `master` SHA sync, shell
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
checkout is behind merged safety-gate changes. Restart guidance links to #420;
no tokens or MCP restart actions are exposed.

## Application shell — Phase 1 (#638)

The console shell (`webui/layout.py`) renders a grouped navigation driven by a
single nav-config module, `webui/nav.py`. Nav groups follow the epic #631
Phase 1 information architecture: **Health, Traffic, Runtime/Sessions,
Projects, Inventory, Timeline, Policy** (placeholder), and **Insights**
(placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one
place so the layout and the route table cannot drift.

The header carries two read-only status badges — an **environment** badge
(`local` for loopback binds, `remote` otherwise, derived from `WEBUI_HOST`) and
a **mode: read-only** badge — plus a **Docs** link to this document. No
privileged action controls are present in the Phase 1 shell.

Not-yet-implemented surfaces (`/inventory`, `/timeline`, `/policy`,
`/insights`) resolve to graceful read-only stub pages instead of 404s; their
backing views land in later child issues of #631 (the inventory surfaces are
backed by #636). Mutating methods on stub routes still fail closed with
`read-only-mvp`.

### Runtime and sessions (#641)

`/sessions` is a live Phase 1 read-only view that composes:

* runtime health from `#430` (profile, role, identity, master parity, stale warning)
* control-plane sessions / leases and filesystem locks / worktrees / namespaces from `#636`
* durable contamination markers when detectable (`#630` runtime recovery, `#671` stable-branch push)

It surfaces stale indicators (dead PID, expired lease) and never silences an
active contamination marker. Recovery links point only at sanctioned
reconnect/operator restart docs (`docs/mcp-namespace-eof-recovery.md`,
`docs/mcp-namespace-health.md`, `docs/mcp-restart-path-inventory.md`, this
document). The page does **not** restart, kill, or take over sessions; manual
`pkill` of MCP daemons is contamination, not recovery.

Honesty rules specific to this view:

* **Ownership columns never assert absence they cannot prove.** When the
  `leases` or `locks` section is degraded or unavailable, the Leases and
  Worktree-binding cells render `unknown (inventory <status>)` with an
  *authority unproven* badge instead of `none` / `unbound`, and a caveat names
  the unreadable sections. A worktree binding is correlated through lease work
  numbers, so it is unproven when *either* section fails to read.
  `/api/sessions` carries the same facts as `ownership_authority_complete`,
  `ownership_section_status`, and per-row `lease_authority` /
  `worktree_authority`, so a JSON consumer can tell "holds none" from "could
  not be read".
* **Contamination text is redacted at the display boundary.** Marker payloads
  (`command_summary`, `reason_class`, `session_id`, `role`) are
  operator-supplied free text that does not arrive through inventory scrubbing,
  so they pass through `webui.inventory.scrub_text`, which collapses `$HOME` and
  redacts credential-shaped tokens and URL userinfo *anywhere* in the string.
  The write-time redactor is a narrow denylist and is not relied on. The field
  itself is kept — it is the `#630` evidence naming which daemon was killed.

## Gitea issue/PR linkage (#645)

`/gitea` is the Phase 3 read-only linkage console: which PR carries which issue,
which issues are claimed by more than one PR, and what the latest Canonical
Thread Handoff on a thread said. Gitea remains the source of truth — this
surface reads it and never writes to it. There is no issue/PR editor, no review,
and no merge control.

Query parameters (all optional):

| Parameter | Meaning |
|-----------|---------|
| `project` | Registry project id to scope the read (default: first registry entry) |
| `state` | `open` (default) or `all`; `all` widens the window to merged/closed items, where a landed edge lives |
| `issue=N` / `pr=N` | Focus one thread and load *its* latest canonical handoff |

`GET /api/v1/gitea/linkage` returns the same model as JSON
(`schema_version: 1`). It answers `502` when the read could not be answered, so
an automated consumer cannot mistake a fail-closed payload for "no links exist".
The HTML page always answers `200` and renders the reason instead — an operator
view must show why a read failed rather than withhold the page.

### How an edge is found

Each edge carries the evidence that produced it, strongest first:

| Evidence | Meaning |
|----------|---------|
| `closes_keyword` | The PR title or body declares `closes/fixes/resolves #N`. Gitea itself acts on this keyword. |
| `branch_marker` | The PR head branch carries the canonical `(fix\|feat\|docs\|chore)/issue-N-…` marker minted by the issue lock. |
| `body_reference` | The PR body mentions `#N` with no closing keyword. A mention is not a claim to close. |

Only closing and branch-marker edges populate the **issue → PR** direction: a
bare mention is a cross-link, and counting it as ownership would invent
contested issues out of ordinary references. The mention stays visible on the
**PR → issue** side, labelled as such. A PR whose two strongest edges tie is
flagged `ambiguous`; an issue claimed by two PRs is flagged `contested`.

### Honesty rules specific to this view

* **A partial read never reads as an absence.** Linkage is a claim about the
  loaded window only. When pagination did not complete, every empty edge cell
  renders `none found (partial inventory)` rather than `none`, and the JSON
  carries `inventory_complete: false` plus per-row `links_authoritative: false`.
* **A failed read renders no table at all.** Missing credentials, an unknown
  project, or a fetch error produce `ok: false` with a reason. An empty linkage
  table would assert that no issue is linked to any PR, which such a read is not
  in a position to claim.
* **Handoffs are loaded, never assumed.** CTH comments are thread-scoped, so
  only the focused issue or PR has its comments fetched. Every other row reports
  `not_loaded` with the reason; a thread whose comments *were* loaded and carried
  no CTH says exactly that. A comment-source failure degrades the handoff alone —
  the linkage tables still render.
* **Unrecognised handoff headings are reported, not republished.** A `## CTH:`
  heading outside `CTH_TYPES` renders as `unrecognized`.
* **Redaction precedes display.** Titles, labels, handoff fields, and error
  reasons pass through `webui.console_redaction` before serialization, and the
  page HTML-escapes everything it renders.
* **Deep links are opt-in.** A link out to the Gitea web UI appears only when
  `GITEA_MCP_REVEAL_ENDPOINTS=1` is set server-side, matching how the MCP tools
  gate URL exposure. Item numbers stay usable without it.

## System-health dashboard (#639)

`/system-health` renders the same snapshot the `/api/v1/system/health` API
returns, so the page and the API can never disagree. Cards: overall readiness,
stale-runtime parity, version and uptime, dependency probes, MCP namespaces,
probe errors (only when present), and recovery pointers. `?deep=1` opts into
the network probe exactly as the API does; the plain page load stays cheap.

Field authority and honesty rules:

* `ready` and `readiness_complete` are shown separately. A snapshot whose
  required probes never ran is not the same as one that ran them and passed,
  and the page never collapses the two into an unproven green.
* A probe that did not run appears under **Not probed**, never as healthy.
* `stale_runtime.mutation_safe` is displayed verbatim from the API. When the
  runtime is stale, or when parity is indeterminate, the page warns and does
  not claim mutation safety.
* MCP namespaces are reported `unproven`: the web process runs outside the
  IDE-managed MCP client and cannot prove that path (#543).

Redaction is split by field kind. Free text — probe details, readiness and
parity reasons, probe errors — passes through `system_health.redact`.
Structured fields — commit SHAs, probe names, statuses, timestamps — are
HTML-escaped only, because `redact`'s opaque-token rule matches any run of 32
or more characters and would otherwise blank every 40-character git SHA, which
is precisely the evidence the parity view exists to show.

The dashboard is read-only: no restart, reload, or process-kill control. Those
arrive in Phase 2 (#642). Recovery guidance points at the sanctioned client
reconnect / operator restart path — never a manual daemon kill (#630).

## Deployment boundary (#435)

MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless
`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata.

## Tests

```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q

pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q

## Tests (#436)

Run the full hermetic web UI suite (all `test_webui_*.py` modules):

```bash
./scripts/test-webui
```

CI / Jenkins multibranch can call the path-filtered gate (runs only when the
diff touches `webui/`, `tests/test_webui_*`, or web UI docs/scripts):

```bash
./scripts/ci-webui-check
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check   # always run
```

`scripts/test-webui` sets `WEBUI_TEST_OFFLINE=1` by default. In that mode the
queue, lease, and runtime routes use empty offline snapshots instead of Gitea
credentials, so CI can run without MCP daemon credential access. Set
`WEBUI_TEST_OFFLINE=0` only when deliberately validating live fetch behavior.

Or invoke unittest directly:

```bash
python3 -m unittest discover -s tests -p 'test_webui_*.py' -q
```

## Lease visibility (#433)

`/leases` surfaces read-only lease and collision state: local issue lock file,
in-progress claim inventory (#268), reviewer PR lease comments when present
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
and duplicate local branches per issue. Links to collision-history backend
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.

## Runtime health (#430)

`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
project: active profile and role kind, authenticated identity (when credentials
are available), config model/mode, local vs remote `master` SHA sync, shell
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
checkout is behind merged safety-gate changes. Restart guidance links to #420;
no tokens or MCP restart actions are exposed.

## Inventory API (#636)

`GET /api/v1/inventory` returns one versioned, read-only snapshot that unifies
what the lease (#433), worktree (#432), and runtime (#430) MVP views each show
separately, so traffic-control and recovery consumers read the same source.
`GET /api/v1/inventory/{section}` returns a single section under the identical
schema (`sessions`, `leases`, `locks`, `worktrees`, `namespaces`); an unknown
section is a `404` with `error: unknown_section`. Both routes are `GET`-only.

Each section carries its own `status` (`ok` / `degraded` / `unavailable`), a
`reason` when not `ok`, and a `scan_ms`. A subsystem that cannot be read
degrades to a reasoned section; it never raises and never emits an empty list
that would read as "nothing is there".

### Field authority

Every section names where its rows came from; authorities are never blended.

| Section | Authority | Source |
|---|---|---|
| `sessions` | `control_plane_db` | #613 control-plane DB (`mode=ro`), authoritative for exclusive ownership (#600/#601) |
| `leases` | `control_plane_db` | #613 control-plane DB; degrades if the `work_items` table is absent |
| `locks` | `filesystem` | durable per-issue lock files (`issue_lock_store`) |
| `worktrees` | `filesystem` | registered git worktrees via the #432 hygiene scanner |
| `namespaces` | `filesystem` | the active profile serving this web process (others are not enumerable) |

The payload restates this map under `field_authority` for machine consumers.

### Ownership safety

`ownership_authority_complete` is true only when every ownership-bearing section
(`sessions`, `leases`, `locks`) read cleanly. While it is false, nothing is
reported as unowned and no collision is asserted from a degraded source —
absence of evidence is reported as absence of evidence, never as free work.

`collisions` surfaces detectable conflicts, each with a `kind` and `severity`:
`lock-without-worktree`, `duplicate-live-lock`, `live-lock-dead-owner` (unexpired
lease, dead pid — a #753 recovery candidate that would read as live to a naive
timestamp check), `stale-lock-dead-owner`, `expired-lock-live-owner` (the
#635/#760 daemon-pid deadlock), `concurrent-active-lease`, `active-lease-past-expiry`,
and `orphan-lease`. Collisions are emitted only from sections that read cleanly.

The control-plane DB is opened through a `mode=ro` URI so a read never creates
or migrates it; paths are collapsed against `$HOME`, URLs lose userinfo and
query strings, and credential-shaped values are redacted at the boundary. Lease
steal/release and worktree deletion are Phase 2+ and have no representation here.

## Workflow-event timeline (#637)

`GET /api/v1/timeline` is a read-only, versioned aggregation of workflow
events from every available source into one normalised, filterable stream. It
is the model layer for the Phase 1 timeline console view (a later child issue
of #631); this issue ships the schema, adapters, and read API only.

### Schema (versioned)

`webui/timeline.py` declares `TIMELINE_SCHEMA_VERSION` (currently `1`) and the
frozen `WorkflowEvent` record. Every response carries `schema_version` so a
consumer can branch on shape. One event:

```json
{
  "source": "control_plane",
  "event_type": "lease.renew",
  "event_key": "cp:1421",
  "timestamp": "2026-07-23T02:00:00Z",
  "actor": null,
  "role": null,
  "issue_number": 637,
  "pr_number": null,
  "session_id": null,
  "tool_name": null,
  "decision": null,
  "message": "lease renewed",
  "correlation_id": "issue#637",
  "evidence_refs": [],
  "sensitive": true
}
```

`event_key` is stable and unique per source (`cp:<event_id>`,
`cth:<kind>:<number>:<comment_id>`), so pagination and dedup are deterministic.

### Sources and field authority

| Source | Adapter | Authority |
|---|---|---|
| Control-plane `events` ⋈ `work_items` | `adapt_cp_events` | `event_type`, `message`, `timestamp`, issue/PR scope come from the CP database, read through a `mode=ro` URI (never creates the DB or runs migrations) |
| Gitea Canonical Thread Handoff comments | `adapt_cth_comments` | `actor`, `role` (next owner), `decision`, `evidence_refs`, `timestamp` come from the parsed CTH comment body (`canonical_thread_handoff`) |

Handoff comments are thread-scoped: they are only read when the request filters
by a single `issue` or `pr`. Otherwise the handoff source reports `not run`
with a reason — it is never rendered as empty-and-healthy. Each source degrades
independently: an unavailable control-plane DB or a failed comment fetch is a
`sources[]` entry with `ok:false` and a `reason`, never a dropped timeline.

### Query parameters

`issue`, `pr`, `session` (conjunctive filters); `limit` (default 50, max 500)
and `offset` for pagination; `remote`, `org`, `repo` to override the default
registry-project scope. Events sort ascending by
`(timestamp, source_rank, event_key)`; missing timestamps sort last.

### Filter authority, and refusing what cannot be answered

A filter dimension is only meaningful for a source whose records carry it.
Each source declares its own support in `_SOURCE_FILTER_SUPPORT` and reports it
per response as `supported_filters` / `unsupported_filters`:

| Source | issue | pr | session |
|---|---|---|---|
| `control_plane` | yes | yes | **no** — the `events` table is `(event_id, work_item_id, event_type, message, created_at)` and records no session |
| `gitea_handoff` | yes | yes | yes — a CTH comment declares its own `Session:` field |

`session_id` is read only from that declared CTH field. It is never inferred
from a work item, an actor, or message text, and a value that is
redaction-altering or bare-secret-shaped is dropped rather than emitted.

When **no source that ran** can carry a requested dimension, the request is
refused rather than answered: the response is `422` with `ok:false` and a
structured `error` naming `unsupported_filters` and the per-source reason. A
`200` with zero events would tell an operator that no such activity exists,
which is a stronger — and false — claim than "this cannot be answered here".
A source that *can* answer the dimension and simply matched nothing still
returns `200` with `ok:true` and an empty page.

### Redaction

Every free-text field (event messages, decision/proof text, roles, actors) is
passed through the console redaction policy (`webui.console_redaction`, backed
by `gitea_audit.redact`) before it leaves the module, failing closed to the
placeholder. No unredacted tool arguments or secrets are ever emitted, and a
generation error never drops raw data to a caller or a log.

Redaction also runs *before* any structured value is derived from free text.
`evidence_refs` are extracted from already-redacted proof/decision text, and a
commit reference is recognised only where the text declares one (`commit`,
`head`, `base`, `sha`, …). An undeclared 40-character hex run has the exact
shape of a Gitea access token, so it is never lifted out of prose into a
structured field. Every reference is then independently revalidated against an
allowed shape and a second redaction pass immediately before serialization;
anything unproven is dropped and the event is flagged `sensitive`.

### Tests

```bash
pytest tests/test_webui_timeline.py -q
```

## Tests

```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
```
