Files
Gitea-Tools/docs/webui-local-dev.md
T
sysadminandClaude Opus 4.8 211890f361 feat(webui): Gitea issue and PR linkage console (Closes #645)
Add a Phase 3 read-only console that resolves issue↔PR linkage with
evidence (closes keyword, branch marker, body mention), surfaces the
latest canonical handoff for a focused thread, and deep-links to Gitea
only under the admin reveal opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 16:40:07 -04:00

31 KiB

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):

./scripts/run-webui

Or directly:

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 for internal-only serving, Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).

See 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 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):

{
  "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

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):

./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:

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:

{
  "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 eventswork_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

pytest tests/test_webui_timeline.py -q

Tests

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