---
contract_version: "2.1"
issue: 16
target_module: "ratatoskr.web"
scope: "New module `ratatoskr.web` exposing a browser-based debug companion to the Ratatoskr TUI. Reuses `ratatoskr.sse_client`, `ratatoskr.sessions`, `ratatoskr.tier3`, `ratatoskr.local_agents`, `ratatoskr.cli` unchanged. Adds a Starlette web server (`ratatoskr.web.server`), a lazy-import console-script entrypoint (`ratatoskr.web.entrypoint`), and a single-page static UI at `ratatoskr/web/static/index.html`. Optional-deps group `[web]` carries `starlette>=0.40` and `uvicorn[standard]>=0.30`. Surface: 9 HTTP endpoints (1 root, 1 static, 1 version, 5 API proxies, 1 SSE stream). Bound to `0.0.0.0` by default for LAN consumption — internal-LAN debug surface, no auth, no CORS guard (deliberate operator direction). The five Worldtree SSE surfaces (transcript, thinking, tools, debug, persona) render in the browser via the same routing rules as the TUI, with client-side JS re-implementing the presentation discipline (no shared abstraction extracted at v0.15.0). Goal: operators have a sharable / inspectable second viewport on the same Worldtree SSE stream, reachable from any device on the LAN."
depends_on:
  - "ratatoskr.sse_client"
  - "ratatoskr.sessions"
  - "ratatoskr.tier3"
  - "ratatoskr.local_agents"
  - "ratatoskr.cli"
  - "starlette"
  - "uvicorn"
  - "httpx"
used_by: []
language: "python"
complexity: "medium"
estimated_loc: 600
confidence: 0.85
assumptions:
  - "**Browser-native EventSource is GET-only.** The SSE stream endpoint is `GET /api/turns/{sid}/stream?turn_id=<id>`; the prompt-submit is a separate `POST /api/turns/{sid}` that returns `{turn_id}`. The two calls share a small in-memory turn registry keyed on `(session_id, turn_id)` so the cancel and disconnect-cleanup paths can find the in-flight upstream request. This split is a load-bearing correction from the Heid panel review (Hulda) on scope v1."
  - "**Trust model is internal LAN.** Binds `0.0.0.0:8765` by default; `--host 127.0.0.1` available for localhost-only. No auth, no TLS, no CORS guard. The operator has explicitly accepted this: anyone routable to the host's port can reach the interface. What stays disciplined regardless of network trust: (1) transcript HTML-escapes assistant content (model output is untrusted text — adversarial HTML in responses must not execute in the browser); (2) upstream API key never reaches the browser DOM or any client-visible response field."
  - "**Optional-deps lazy-import discipline.** `ratatoskr.web` deps (`starlette`, `uvicorn`) are an optional-extras group `[web]`. The console-script entrypoint `ratatoskr.web.entrypoint:main` parses CLI flags BEFORE importing `ratatoskr.web.server` so users without the extras installed get a clean `pip install ratatoskr[web]` message instead of a naked `ImportError: starlette`. Both Heid panel arms (Gróa + Hulda) converged on this. `ratatoskr.web.__init__` is bare; no module-level imports of starlette/uvicorn anywhere on the cli import path."
  - "**Starlette over FastAPI.** Both Heid panel arms converged: five thin proxy endpoints don't need FastAPI's Pydantic / OpenAPI / dependency-injection machinery. Use Starlette + manual `Response` / `StreamingResponse` / `JSONResponse` construction."
  - "**Static asset packaging.** `src/ratatoskr/web/static/index.html` ships in the wheel via `[tool.hatch.build.targets.wheel]` include rules. Located at runtime via `importlib.resources.files('ratatoskr.web') / 'static' / 'index.html'`. Test asserts this resolution works in the installed package."
  - "**Presentation contract pinning.** A JSON fixture at `tests/fixtures/presentation_contract.json` enumerates the expected browser-facing event payload for each Event type (one entry each for WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate, AwaitingLlmFirstToken). Server-side proxy serialization is unit-tested against this fixture. JS-side rendering treats the fixture as the contract. Drift detection between TUI and JS presenter without forcing a shared abstraction (Hulda)."
  - "**Browser-disconnect → upstream cancel.** When the browser closes the EventSource (tab close, navigation, explicit disconnect), the server's stream handler catches the `asyncio.CancelledError` raised by Starlette's BackgroundTask cleanup and triggers an upstream cancel on the matching `(session_id, turn_id)` via `ratatoskr.sse_client.cancel_turn`. Both Heid arms convergent. Test simulates the disconnect via `respx` + `httpx.AsyncClient` test-client and verifies the upstream cancel call lands."
  - "**Mid-stream Ctrl-C safety.** Server uses Starlette's `lifespan` shutdown hook to issue upstream cancels for every entry in the turn registry within a 5-second cleanup budget. Entries that don't ack in time are abandoned (structured-logged). No half-written state on the Worldtree side under cooperative cleanup."
  - "**Markdown rendering is escape-first.** v0.15.0 ships HTML-escaped plain-text rendering for the transcript pane only. Markdown rendering with a vendored safe-subset renderer is deferred to v0.16.x. This is a deliberate first-cut safety call (Hulda) — hand-rolled Markdown is easy to get wrong around HTML escaping when model output is untrusted."
  - "**Server-side structured JSON logging.** One JSON line per HTTP request (method/path/status/duration_ms/client) + one line per SSE open/close (with events_forwarded + reason). Lets the operator diagnose problems when the browser viewport is the only one running (Gróa)."
  - "**Per-pane copy + version footer affordances.** Each pane (Tools / Debug / Thinking / Persona) has a copy button that surfaces the pane's plain-text content for paste-into-issue / paste-into-bug-report flows. Footer carries the running `ratatoskr` package version for version-correlation when comparing browser to TUI (both Gróa-flagged)."
  - "**Resume punted at v0.15.0.** No cross-reload session resume via `Last-Event-ID`; reload starts fresh. `/api/sessions` (GET) endpoint dropped from v0.15.0 — only `POST /api/sessions` (create) is shipped. Resume moves to v0.16.x."
  - "**Tier 3 lifecycle stays CLI-only.** The web UI is read-only for Tier 3 surface — define / patch / delete remain in the `ratatoskr.tier3` CLI. Web surface lists Tier 3 agents (via the same `local_agents.json` merge that the TUI does) but doesn't expose mutation. Mutation UI deferred to v0.16.x."
  - "**Tests use Starlette's TestClient + respx for upstream.** Same `respx` pattern as `tests/test_sse_client.py` / `tests/test_sessions.py`. New test files: `tests/test_web_server.py`, `tests/test_web_presentation_contract.py`, `tests/test_web_packaging.py`. No live network; the live smoke-test is part of the post-merge ship verification, not the unit tests."
open_questions:
  - "Should `--open` auto-open default to True or False? Draft: False — the LAN use case often runs the server on one device and connects from another, so auto-opening on the host is wrong by default. Operator passes `--open` when running locally and wants the convenience."
  - "Should the turn registry's cleanup-budget timeout (5s) be CLI-configurable? Draft: no for v0.15.0 — 5s is a reasonable default and adding a flag invites bikeshedding. Revisit if real outage telemetry suggests otherwise."
  - "Should the static `index.html` carry a build-time hash for browser cache-busting? Draft: no for v0.15.0 — the use case is short-lived debug sessions; operators reload manually. Vendored renderer + Markdown rendering in v0.16.x is the right time to introduce cache-busting if needed."
prd:
  issue: 16
  issue_url: https://gitea.phasefinal.com/vh/ratatoskr/issues/16
  body_sha256_16: "ae32cee38fd35761"
  lock_in_comment_id: null
  lock_in_sha256_16: null
  lock_in_at: null
  pinned_at: "2026-05-28T01:43:24+00:00"
---

# Web companion — in-browser debug surface

## Context

Ratatoskr is a debug TUI for the Worldtree Conversation API. The wire-layer modules (`sse_client`, `sessions`, `tier3`, `local_agents`) are well-factored and reusable. This issue adds a sibling presentation surface: a browser-based debug companion that consumes the same SSE wire and renders the same five panes (transcript, thinking, tools, debug, persona). Reachable from any device on the operator's LAN — "show someone what I'm seeing" — without replacing the TUI as the canonical debug interface.

The work is wire-layer-zero (no changes to `sse_client` / `sessions` / `tier3` / `local_agents`) plus a new top-level module `ratatoskr.web` with a Starlette app, a console-script entrypoint, and a single-page static UI. Optional dependencies (`starlette`, `uvicorn`) ship as an `[web]` extras group so users who only want the TUI don't pay the install cost.

## Public surface

### Console script

```
ratatoskr-web [--host HOST] [--port PORT] [--open]

  --host HOST   Bind address. Default: 0.0.0.0 (LAN-accessible).
                Use 127.0.0.1 to restrict to localhost.
  --port PORT   Listen port. Default: 8765. Use 0 for random free.
  --open        Auto-open the URL in the system browser.
```

### Server endpoint surface

```
GET   /                                  → serve index.html (200)
GET   /static/<path>                     → serve static asset (200) or 404
GET   /version                           → {"ratatoskr": "<version>"}  (200)

GET   /api/agents                        → 200 with [AgentInfo + tier3 local merge]
POST  /api/sessions                      → 201 with SessionInfo
GET   /api/agents/{agent_id}/persona_state
                                         → 200 with PersonaSnapshot, or 404 / 403

POST  /api/turns/{session_id}            → 200 with {"turn_id": <int>}
GET   /api/turns/{session_id}/stream
        ?turn_id=<int>                   → 200 SSE stream (text/event-stream)
POST  /api/turns/{session_id}/cancel
        ?turn_id=<int>                   → 200 ok / 404 / 409 / 500
```

### Module shape

```
src/ratatoskr/web/
  __init__.py            # bare — no module-level imports of starlette/uvicorn
  entrypoint.py          # console-script: argparse, lazy import of server
  server.py              # Starlette app factory + endpoint handlers + turn registry
  static/
    index.html           # single-page UI (vanilla HTML/CSS/JS, no build step)
```

### Public functions

```python
def create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette: ...
def main(argv: list[str] | None = None) -> int: ...  # entrypoint.main
```

`create_app` is the factory — takes a callable that produces a configured `httpx.AsyncClient` (bearer auth, base_url from env, User-Agent set per `ratatoskr.cli.USER_AGENT`) and returns a Starlette app with routes wired. Decoupling via factory keeps tests simple (the test client passes a respx-mocked `AsyncClient`).

`entrypoint.main` is the console-script target — parses flags, builds the client factory from env, calls `create_app`, runs uvicorn. The lazy-import discipline lives here: `import starlette` does NOT happen at module top — it lands inside `main()` after arg parsing, with an `ImportError` catch that prints the `pip install ratatoskr[web]` hint and exits non-zero.

## v0.16.0 amendment (post-Heid-code-review)

Heid panel review (Gróa + Hulda, thread `01KSP5P6CSJH`) on the
v0.15.0/v0.15.1 implementation surfaced three contract-text issues
now corrected below:

1. **Upstream vs local turn_id.** Cancel paths (explicit cancel,
   browser-disconnect, lifespan shutdown) MUST target the *upstream*
   (Worldtree-assigned) turn_id captured from the first SSE event's
   `sse_id.turn_id`, NOT the browser-local `_TURN_COUNTER` value (which
   is only a registry key). The `TurnHandle.upstream_response` field is
   replaced by `upstream_turn_id: int | None`. Cancel before the
   upstream stream starts (upstream_turn_id is None) is a no-op
   (`{"cancelled": false, "reason": "not_started"}`).
2. **`RATATOSKR_END_USER_ID` is server-configured.** `FN main` reads it
   from env and threads it into `create_app(..., end_user_id=...)`; the
   `POST /api/sessions` endpoint uses `app.state.end_user_id` server-
   side. The browser NEVER supplies end_user_id — a client cannot
   impersonate an arbitrary end-user partition.
3. **Stream client lifecycle.** The `async with client_factory() as
   client:` sketch in `FN stream_turn_endpoint` is not executable for a
   long-lived async generator that must outlive the handler frame; the
   implementation uses manual `client = ...; try: ... finally: await
   client.aclose()`. Sketch corrected below.

## Invariants

- **INV-001**: `ratatoskr.web.__init__` and `ratatoskr.web.entrypoint` MUST NOT import `starlette` or `uvicorn` at module top. Import is inside `main()` after flag parsing. The missing-extras `ImportError` catch is scoped to the OPTIONAL extras (`starlette` / `uvicorn`) ONLY — baseline-dep / first-party import failures propagate as real tracebacks rather than masking as exit-12.
- **INV-002**: `ratatoskr.web.server.create_app` MUST accept a `client_factory` callable. The app MUST NOT construct `httpx.AsyncClient` at module top or in route handlers; it MUST call the factory.
- **INV-003**: Upstream API key MUST never appear in any browser-visible response. Server proxies upstream calls using the client factory; only the upstream's JSON / SSE payload is forwarded. No header echo.
- **INV-004**: Transcript content from upstream `text` SSE events MUST be HTML-escaped before reaching the browser DOM (escape on the wire in the SSE proxy serialization OR escape in the JS rendering — both are acceptable; pick one and stick to it).
- **INV-005**: Browser disconnect mid-stream (`asyncio.CancelledError` in the SSE handler) MUST trigger an upstream cancel via `sse_client.cancel_turn` on the captured `upstream_turn_id` (v0.16.0 — NOT the browser-local turn_id). If the turn already completed, the cancel is a best-effort no-op (`CancelAlreadyCompleted` swallowed). If `upstream_turn_id` is still None (upstream stream never started), the disconnect cancel is skipped — nothing to cancel.
- **INV-006**: Server shutdown (Ctrl-C / SIGTERM) MUST issue upstream cancels (on `upstream_turn_id`) for every in-flight registry entry within a 5-second cleanup budget. Handles whose `upstream_turn_id` is None are skipped. Entries that don't ack in time are abandoned with a per-entry structured log line carrying `session_id` + `upstream_turn_id`.
- **INV-007**: The turn registry MUST be in-process memory only — no persistence, no shared state across server restarts. Process exit drops the registry.
- **INV-008**: Each SSE event serialized to the browser MUST follow the contract enumerated in `tests/fixtures/presentation_contract.json` — one entry per Event type, with the exact JSON shape the browser presenter renders against.
- **INV-009**: All wire-layer modules (`sse_client`, `sessions`, `tier3`, `local_agents`) MUST be used unchanged. Any required change to those modules is out of scope for this issue and gets its own ticket.

## Constraints

- **[security]** Upstream API key never reaches the browser. Lives in `WORLDTREE_API_KEY` env, passed to upstream via `Authorization: Bearer …` header in the client factory.
- **[security]** Model-output text is HTML-escaped in the transcript pane. No script injection from adversarial assistant responses.
- **[security]** No CORS guard, no auth — internal-LAN debug surface per operator direction.
- **[testability]** Server is testable via `starlette.testclient.TestClient` + `respx` upstream mocks. No live network in unit tests.
- **[packaging]** Static asset `index.html` ships in the wheel; resolvable via `importlib.resources` post-install.
- **[performance]** Server is stateless across browser tabs; one in-memory registry entry per in-flight turn. Cleanup on browser disconnect / server shutdown.

## Tests (overview)

All test files live under `tests/`. New test files added by this issue:

- `tests/test_web_server.py` — endpoint contract tests via TestClient + respx
- `tests/test_web_presentation_contract.py` — SSE proxy serialization vs fixture
- `tests/test_web_packaging.py` — static asset resolution + lazy-import discipline

Existing test files remain unchanged.

## Function blocks

```contract
FN main(argv: list[str] | None) -> int
BRIEF: Console-script entry point — parses flags, lazy-imports server, runs uvicorn.
PRE: [PRE-001 hard] argv parsing succeeds -- argparse raises SystemExit on bad args (exit 2)
PRE: [PRE-002 soft] WORLDTREE_API_KEY env var present -- if missing, exit 11 [auth_error]
PRE: [PRE-003 hard] starlette + uvicorn importable -- catch ImportError, print install hint, exit 12 [missing_extras]
POST: [POST-001 side_effect] uvicorn serves until SIGINT/SIGTERM -- blocking call returns on shutdown
POST: [POST-002 side_effect] boot banner printed to stderr -- URL + connect-instructions visible
ERRORS:
  ImportError -> print "Install ratatoskr[web]" hint, return 12
  KeyError -> print missing-env error, return 11
STEPS:
  1. [parse] argparse: --host (default 0.0.0.0), --port (default 8765, 0 = random), --open (default False)
  2. [validate] read WORLDTREE_API_URL, WORLDTREE_API_KEY, RATATOSKR_END_USER_ID from env
     IF WORLDTREE_API_KEY missing:
       - write [auth_error] to stderr, return 11
  3. [import] try: from ratatoskr.web.server import create_app
     EXCEPT ImportError:
       - write "ratatoskr-web requires the [web] extras..." hint to stderr, return 12
  4. [factory] build client_factory closure capturing url + key + user-agent
  5. [app] app = create_app(client_factory)
  6. [banner] print boot banner to stderr (version, host:port, connect URLs)
  7. [open] IF --open: webbrowser.open(f"http://localhost:{port}/")
  8. [serve] uvicorn.run(app, host=host, port=port, log_config=None)
  9. [return] return 0 on clean shutdown
TESTS:
  happy_argv [tracer]: argv=["--port", "0"] with env set → returns 0 after serve loop mocked
  missing_extras [error]: starlette unimportable → stderr has install hint, returns 12
  missing_api_key [error]: WORLDTREE_API_KEY unset → stderr has [auth_error], returns 11
  default_host_is_zero [trace]: argv=[] → parsed host == "0.0.0.0"
  port_zero_supported [trace]: argv=["--port", "0"] → parsed port == 0
  open_flag_calls_webbrowser [trace]: argv=["--open"] with mocked webbrowser → webbrowser.open called
  no_open_default [trace]: argv=[] → webbrowser.open not called
```

```contract
FN create_app(client_factory: Callable[[], httpx.AsyncClient]) -> Starlette
BRIEF: Construct the Starlette app — wire routes, register lifespan, build turn registry.
PRE: [PRE-001 hard] client_factory is callable -- assert callable(client_factory)
POST: [POST-001 return_value] returns Starlette instance with all routes registered -- inspect app.routes
POST: [POST-002 state_change] app.state.turn_registry initialized as dict -- app.state.turn_registry == {}
STEPS:
  1. [setup] turn_registry: dict[tuple[str, int], TurnHandle] = {}
  2. [routes] register routes for: /, /static/{path}, /version, /api/agents, /api/sessions,
     /api/agents/{id}/persona_state, /api/turns/{sid} (POST), /api/turns/{sid}/stream (GET),
     /api/turns/{sid}/cancel (POST)
  3. [lifespan] register lifespan handler that drains turn_registry on shutdown
     within 5s cleanup budget per INV-006
  4. [state] attach client_factory and turn_registry to app.state
  5. [return] return Starlette(routes=routes, lifespan=lifespan)
TESTS:
  routes_registered [tracer]: factory=mock → app.routes contains all 9 path patterns
  state_attached [trace]: factory=mock → app.state.turn_registry is empty dict
  factory_stored [trace]: factory=mock → app.state.client_factory is the same callable
```

```contract
FN version_endpoint(request: Request) -> JSONResponse
BRIEF: Return the ratatoskr package version as JSON.
POST: [POST-001 return_value] response is JSON {"ratatoskr": <version>} status 200
STEPS:
  1. [lookup] version = importlib.metadata.version("ratatoskr")
  2. [return] JSONResponse({"ratatoskr": version}, status_code=200)
TESTS:
  happy [tracer]: GET /version → 200, body == {"ratatoskr": "<current-version>"}
```

```contract
FN agents_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy GET /agents from upstream; merge with local Tier 3 index.
POST: [POST-001 return_value] 200 with list of agent dicts (upstream + local tier3 merged)
POST: [POST-002 exception] upstream error → JSONResponse with upstream's error_code envelope
STEPS:
  1. [proxy] async with app.state.client_factory() as client: agents = await list_agents(client)
  2. [local] local = local_agents.load_local_agents()
  3. [merge] merged = [as_dict(a) for a in agents] + [as_dict(le) for le in local if le.agent_id not in {a.agent_id for a in agents}]
  4. [return] JSONResponse(merged, status_code=200)
ERRORS:
  SessionApiFailed -> JSONResponse({"error_code": "session_api_failed", "status": exc.status}, exc.status)
  httpx.RequestError -> JSONResponse({"error_code": "network_error", "message": str(exc)}, 502)
TESTS:
  happy [tracer]: respx mock /agents 200 → response merges upstream + local index
  upstream_500 [error]: respx mock 500 → 500 with error_code envelope
  network_error [error]: respx connection refused → 502 with network_error envelope
  local_dedup [scenario]: local entry with same agent_id as upstream → no duplicate in merge
```

```contract
FN create_session_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy POST /sessions to upstream.
PRE: [PRE-001 hard] request body has "agent_id" key -- 400 if missing
POST: [POST-001 return_value] 201 with SessionInfo on upstream success
STEPS:
  1. [parse] body = await request.json(); agent_id = body["agent_id"] (400 if missing)
  2. [server-side] end_user_id = request.app.state.end_user_id  # v0.16.0: server-configured, NOT from body
  3. [proxy] async with client_factory() as client: info = await create_session(client, agent_id, end_user_id=end_user_id)
  4. [return] JSONResponse(as_dict(info), status_code=201)
ERRORS:
  AgentNotFound -> JSONResponse({"error_code": "agent_not_found"}, 404)
  SessionApiFailed -> JSONResponse({"error_code": "session_api_failed", "status": exc.status}, exc.status)
TESTS:
  happy [tracer]: respx mock 201 → endpoint returns 201 with session JSON
  unknown_agent [error]: respx mock 404 → 404 with agent_not_found envelope
  missing_agent_id [adversarial]: body without agent_id → 400
  server_side_end_user_id [v0.16.0]: create_app(end_user_id="X") → upstream body carries end_user_id="X"
  ignores_body_end_user_id [v0.16.0,security]: body end_user_id is overridden by server value
```

```contract
FN persona_state_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy GET /agents/{id}/persona_state to upstream.
POST: [POST-001 return_value] 200 with PersonaSnapshot on upstream success
STEPS:
  1. [parse] agent_id = request.path_params["agent_id"]
  2. [proxy] async with client_factory() as client: snap = await get_persona_state(client, agent_id)
  3. [return] JSONResponse(snap, status_code=200)
ERRORS:
  PersonaNotConfigured -> JSONResponse({"error_code": "persona_not_configured"}, 404)
  AgentNotAvailable -> JSONResponse({"error_code": "agent_not_available"}, 404)
  AuthScopeDenied -> JSONResponse({"error_code": "auth_scope_denied"}, 403)
TESTS:
  happy [tracer]: respx mock 200 → endpoint returns 200 with snapshot
  persona_not_configured [error]: respx mock 404 + persona_not_configured → 404 envelope
  agent_not_available [error]: respx mock 404 + agent_not_available → 404 envelope
  auth_scope_denied [error]: respx mock 403 + auth_scope_denied → 403 envelope
```

```contract
FN submit_turn_endpoint(request: Request) -> JSONResponse
BRIEF: Accept a prompt-submit; allocate a turn_id in the registry; return it. NO upstream call yet — the stream endpoint opens that.
PRE: [PRE-001 hard] request body has "content" key -- 400 if missing
POST: [POST-001 return_value] 200 with {"turn_id": <int>}
POST: [POST-002 state_change] app.state.turn_registry has entry for (sid, turn_id) with content + status "queued"
STEPS:
  1. [parse] session_id = path_params["session_id"]; body = await request.json(); content = body["content"]
  2. [allocate] turn_id = next_turn_id()  # process-local monotonic counter
  3. [register] turn_registry[(session_id, turn_id)] = TurnHandle(content=content, status="queued", upstream_turn_id=None)  # v0.16.0: was upstream_response
  4. [return] JSONResponse({"turn_id": turn_id}, status_code=200)
TESTS:
  happy [tracer]: POST {"content": "hi"} → 200 with turn_id; registry populated
  missing_content [adversarial]: body without content → 400
  monotonic_turn_ids [trace]: two submits → second turn_id > first turn_id
```

```contract
FN stream_turn_endpoint(request: Request) -> StreamingResponse
BRIEF: Open SSE stream to browser — proxy upstream stream_turn() events, forward as SSE.
PRE: [PRE-001 hard] (session_id, turn_id) in registry -- 404 if absent
POST: [POST-001 side_effect] each upstream event serialized to browser as SSE event with type+data per fixture
POST: [POST-002 state_change] on completion/disconnect, registry entry removed; upstream cancel if turn still in flight
STEPS:
  1. [validate] sid, tid = path/query params; handle = registry.get((sid, tid)); 404 if None
  2. [open] client = client_factory()  # v0.16.0: manual lifecycle, NOT `async with` — the generator outlives this frame; closed in finally
     - handle.status = "streaming"
  3. [forward] async for event in stream_turn(client, sid, handle.content):
     - IF handle.upstream_turn_id is None: handle.upstream_turn_id = event.sse_id.turn_id  # v0.16.0: capture upstream turn id
     - serialize per fixture: {"type": <ssetype>, "data": <json>}
     - yield as `event: <type>\\ndata: <json>\\n\\n` bytes
  4. [terminal] on Done/Error/Cancelled: yield final SSE, mark handle.status, break
  5. [cleanup] finally:
     - IF asyncio.CancelledError caught AND status=="streaming" AND upstream_turn_id is not None: cancel_turn(client, sid, handle.upstream_turn_id)  # v0.16.0: upstream id, not tid
     - remove (sid, tid) from registry; await client.aclose()
ERRORS:
  KeyError -> 404 turn_not_found
  asyncio.CancelledError -> upstream cancel, propagate
  SseConnectFailed -> yield synthetic error event, close stream
  SseConnectionDropped -> yield synthetic error event, close stream
TESTS:
  happy [tracer]: respx mock one text+done → SSE stream yields text event + done event
  unknown_turn [error]: GET with turn_id not in registry → 404
  upstream_error [error]: respx 500 on /sessions/{sid}/messages → synthetic error SSE event
  disconnect_triggers_cancel [scenario]: browser disconnect mid-stream → cancel_turn called on upstream
  full_event_vocab [scenario]: respx with one of each Event type → fixture-shaped JSON for each
```

```contract
FN cancel_turn_endpoint(request: Request) -> JSONResponse
BRIEF: Proxy upstream cancel for a registered turn.
PRE: [PRE-001 hard] (session_id, turn_id) in registry -- 404 if absent
POST: [POST-001 side_effect] upstream cancel call lands; registry entry removed
POST: [POST-002 return_value] 200 with {"cancelled": true} or 200 with status reflecting upstream race
STEPS:
  1. [validate] sid, tid = params; handle = registry.get((sid, tid)); 404 if None
  2. [not-started] IF handle.upstream_turn_id is None: del registry[(sid,tid)]; return 200 {"cancelled": false, "reason": "not_started"}  # v0.16.0: upstream never opened
  3. [cancel] async with client_factory() as client:
     - try: await cancel_turn(client, sid, handle.upstream_turn_id)  # v0.16.0: upstream id, not tid
     - return 200 {"cancelled": true}
  4. [race] EXCEPT CancelAlreadyCompleted / CancelTurnNotFound:
     - return 200 {"cancelled": false, "reason": "race_or_completed"}
  5. [cleanup] del registry[(sid, tid)]
TESTS:
  happy [tracer]: registered turn (upstream_turn_id set) → POST cancel → 200, upstream cancel at the upstream id
  unknown_turn [error]: not in registry → 404
  cancel_before_started [v0.16.0]: upstream_turn_id None → 200 {cancelled:false, reason:not_started}, no upstream call
  cancel_targets_upstream_turn_id [v0.16.0]: local tid != upstream id → cancel URL uses upstream id
  already_completed [race]: respx cancel returns 409 → 200 with reason=race_or_completed
  cancel_failed [error]: respx returns 500 → 500 with cancel_failed envelope
```

```contract
FN root_endpoint(request: Request) -> FileResponse
BRIEF: Serve the static index.html.
POST: [POST-001 return_value] FileResponse for ratatoskr/web/static/index.html, status 200, content-type text/html
STEPS:
  1. [resolve] path = importlib.resources.files("ratatoskr.web") / "static" / "index.html"
  2. [return] FileResponse(path, media_type="text/html")
TESTS:
  happy [tracer]: GET / → 200, content-type text/html, body contains "<html"
```

```contract
FN lifespan_shutdown(app: Starlette) -> None
BRIEF: On Ctrl-C / SIGTERM, drain the turn registry within 5s budget per INV-006.
POST: [POST-001 side_effect] every in-flight upstream turn gets a cancel attempt within budget
POST: [POST-002 side_effect] entries that don't ack in budget logged + abandoned
STEPS:
  1. [collect] in_flight = [h for h in registry.values() if h.status == "streaming" and h.upstream_turn_id is not None]  # v0.16.0: skip not-yet-started
  2. [cancel] async with client_factory() as client:
     - task_to_handle = {create_task(cancel_turn(client, h.session_id, h.upstream_turn_id)): h for h in in_flight}  # v0.16.0: upstream id
     - done, pending = await asyncio.wait(task_to_handle, timeout=5.0)
  3. [log] for each pending: cancel task + log {"kind": "shutdown", "event": "cleanup_timeout", "session_id": h.session_id, "upstream_turn_id": h.upstream_turn_id}
  4. [clear] registry.clear()
TESTS:
  happy [tracer]: 2 in-flight turns + shutdown → both upstream cancels called, registry empty
  timeout [scenario]: 1 hanging cancel + 1 normal → normal succeeds, hanging logged as cleanup_timeout
```
