---
contract_version: "2.1"
target_module: "ratatoskr.provider.affect_store + ratatoskr.web (server + static/index.html)"
scope: "Issue #18 — BOTH deliverables. DELIVERABLE 2 (SHIPPED v0.17.14): the PAD read-endpoint so the web pane renders live PAD/valence for a Tier-3 agent from OUR :8390 affect store — (1) a NON-bifrost read route on the affect-store-owning app — GET /affect/state/{agent_id}?end_user_id=… → store.get; (2) a web proxy GET /api/affect/{agent_id} that supplies end_user_id SERVER-SIDE; (3) a NEW pane render path for the affect-emit snapshot shape. DELIVERABLE 1 (composite endpoint, NOW IN SCOPE — amended 2026-06-19): bifrost 0.10.0 shipped the public bifrost.consumer.build_combined_app and FR-1 RESOLVED (worldtree-dev verified one BifrostClient per session, caps_granted parsed INDEPENDENTLY into memory+affect sets, both stores attach off the SAME endpoint iff their cap was granted — ZERO Worldtree change). D1 = build_combined_provider_app fronting BOTH planes on :8392, advertising both caps by store PRESENCE, mounting the SAME affect read route (INV-007), with the op-feed deriving plane PER request path (plane='combined'); per-plane failure isolation is bifrost's (per-route call-time dispatch isolation in one ASGI process). Direct in-session TDD (the #17 pattern). The panel framing-consult (Heid, 3 arms) pressure-tested this design; its triaged findings are folded in as INV/POST clauses below."
depends_on:
  - "httpx"
  - "starlette"
  - "ratatoskr.provider.affect_store"
  - "ratatoskr.provider.memory_store"        # D1: the composite fronts the memory plane too
  - "ratatoskr.provider.opfeed"              # D1: op-feed plane='combined' (per-path derivation)
  - "ratatoskr.web.server"
  - "bifrost.consumer"                        # D1: build_combined_app (bifrost >=0.10.0)
used_by:
  - "ratatoskr.provider.serve"
  - "ratatoskr.web.entrypoint"
language: "python"
complexity: "medium"
estimated_loc: 130
confidence: 0.82
assumptions:
  - "VERIFIED (live affect.db this session): the stored affect.emit snapshot shape is {agent_id, end_user_id, pad:{pleasure,arousal,dominance}, valence:[{entity_id,entity_type,familiarity,interaction_count,regard}], emitted_at}. It overlaps the Worldtree Tier-1 persona_state shape ONLY on agent_id + pad; it has NO dominant_emotion/baseline_pad/mood_drift/emotions_active/last_updated_at, and it HAS valence[] + emitted_at the persona shape lacks. So the pane CANNOT reuse renderPersonaPane — a new affect render path is required (Heid panel Q4: render honestly, do not fabricate Tier-1 fields)."
  - "VERIFIED (wire, prior session): a Tier-3 turn emits ZERO affect_update SSE and Worldtree persona_state 404s for every Tier-3 colon-id agent (ADR-0009 Tier-1-only). Both Worldtree-side persona sources are dead for consumer agents, so reading OUR store is the only path. The pane therefore POLLS the read endpoint (on session-start + after each turn-end); there is no SSE affect channel to subscribe to."
  - "The affect store already exposes get(agent_id, end_user_id) -> dict | None (affect_store.py:102). The read route is a thin wrapper over it; the store's conduit-opacity is unaffected (the route returns the stored blob verbatim)."
  - "RatatoskrAffectStore holds ONE sqlite3.Connection shared across emit + the new read in a single process; the event loop serializes the sync sqlite calls (no threadpool), so same-process read+write needs no extra locking. busy_timeout matters for the FUTURE cross-process case (composite :8392 + standalone :8390 opening the same affect.db); setting it now is correct prep, not a same-process fix."
  - "build_affect_provider_app currently returns build_affect_app(...) directly. It now adds the read route to that app via app.add_route('/affect/state/{agent_id}', ...) — keeping /bifrost/handshake + /bifrost/affect-call as TOP-LEVEL routes (so the existing route-introspection test stays green AND the op-feed's scope['path'] check in opfeed.py _BIFROST_PATHS still matches the bifrost calls and passes the read route through untouched, INV-004). add_route is preferred over an outer Mount precisely because Mount would push the bifrost paths under the mount and break top-level introspection — add_route is the surgical composition."
  - "The web affect-read hop is SERVER-TO-PROVIDER (same dev box), distinct from the Worldtree-visible host used for binding. So RATATOSKR_AFFECT_READ_URL is its own config (default http://127.0.0.1:8390), NOT derived from RATATOSKR_PROVIDER_VISIBLE_HOST (which is the WT-visible host for handshake)."
  - "Tests: respx mocks the provider read URL for the web-proxy unit tests; the provider read route is tested in-process against a seeded RatatoskrAffectStore (mirroring the existing affect_store tests). A colon-id (ratatoskr:sindra) round-trips browser->web->provider and is asserted end-to-end (Heid panel FM-7)."
open_questions:
  - "DELIVERABLE 1 / FR-1 (does NOT block Deliverable 2): does Worldtree dispatch BOTH memory-call AND affect-call to ONE bound endpoint that advertised both caps, or is the binding effectively single-plane? Worldtree-dev consult in flight (msg 01KVDXQMJF…). If single-plane, Deliverable 1 needs a Worldtree-side change too. Resolution gates the Deliverable-1 amendment, not this contract."
  - "Valence display cap: the snapshot's valence[] is unbounded in principle. v1 caps the rendered list (scroll/limit) so the pane layout can't blow out (Heid panel Groa-FM4). Exact cap is a UI detail settled in implementation; the INVARIANT is 'bounded render', not a specific number."
prd:
  issue: 18
  issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/18"
  body_sha256_16: "92be262865f38c0e"
  lock_in_comment_id: null
  lock_in_sha256_16: null
  lock_in_at: null
  pinned_at: "2026-06-18T18:00:00+00:00"
dependencies:
  - issue: 17
    path: "src/ratatoskr/web/server.py"
    reason: "INV-006 of #17 — end_user_id is SERVER-configured (app.state.end_user_id from RATATOSKR_END_USER_ID), never read from the browser. The affect-read proxy follows the same posture: the browser names the agent (already in the picker); the server supplies end_user_id."
  - issue: 17
    path: "src/ratatoskr/provider/opfeed.py"
    reason: "The op-feed instruments only _BIFROST_PATHS; the new non-bifrost read route is outside that set and is passed through untouched. Deliverable 2 must NOT alter op-feed behavior (INV-004)."
---

# Issue #18 (Deliverable 2) — PAD read-endpoint → persona pane renders OUR store

## Context

Ratatoskr binds a Tier-3 session to its own affect provider (:8390) and Worldtree
persists the agent's PAD/valence there (live-proven: vuong session pleasure +0.146,
familiarity 0.18→0.59 over 8 turns). But the web pane shows "telemetry isn't exposed":
it reads Worldtree `persona_state` (`loadPersona`, index.html:707), which 404s for every
Tier-3 colon-id agent (ADR-0009, Tier-1-only), AND a Tier-3 turn emits zero
`affect_update` SSE. Both Worldtree-side persona sources are dead for consumer agents.
The pane was never wired to render PAD from OUR store — Deliverable 2 closes that.

**Scope is the PAD-display half ONLY.** Deliverable 1 (the composite :8392 endpoint that
lets one session bind both planes) is bifrost-blocked: bifrost-dev has confirmed a public
`bifrost.consumer.build_combined_app` (clean additive minor, ~v0.9.0, design locked) and is
standing by on the open Worldtree-dispatch question (FR-1). This contract is amended to add
Deliverable 1 once that lands. Deliverable 2 has zero bifrost or Worldtree dependency — it
reads our own `affect.db` — so it ships now, independently.

## Public surface

```python
# Provider side (ratatoskr.provider.affect_store) — a NON-bifrost read route on the
# affect-store-owning app, composed alongside the bifrost app.
def build_affect_provider_app(
    store: RatatoskrAffectStore,
    heimdall_key: bytes,
    consumer_id: str = "ratatoskr",
):
    """Builds the bifrost affect app, then app.add_route('/affect/state/{agent_id}',
    …) to add the PAD read route (reading store.get) as a top-level sibling of the
    bifrost handshake + affect-call routes. The read route is non-bifrost (no JWT)
    under the internal-LAN trust model. See FN build_affect_provider_app."""


def open_affect_store(db_path: str) -> RatatoskrAffectStore:
    """Unchanged surface; additionally sets PRAGMA busy_timeout=5000 on the connection
    (INV-006) so a contended write waits rather than failing SQLITE_BUSY immediately —
    WAL alone does not serialize concurrent writers."""
```

```python
# Web side (ratatoskr.web.server) — a server-side proxy to the configured affect-read URL.
async def _affect_state_endpoint(request: Request) -> JSONResponse:
    """GET /api/affect/{agent_id}. Supplies end_user_id from app.state.end_user_id
    (NEVER the browser); proxies to app.state.affect_read_url, re-encoding agent_id into
    the provider path (colon-id safe). See FN affect_state_endpoint."""


def create_app(
    client_factory,
    *,
    end_user_id: str | None = None,
    bifrost_consumer_key: str | None = None,
    bifrost_visible_host: str | None = None,
    affect_read_url: str | None = None,   # NEW: provider affect-read base URL
) -> Starlette: ...
```

```javascript
// Pane side (static/index.html) — a NEW render path for the affect-emit shape.
function renderAffectPane(snap) { /* pad + per-entity valence + emitted_at; header "affect snapshot" */ }
async function loadAffect(agentId) { /* GET /api/affect/{id}; honest render or explicit empty-state */ }
// loadPersona dispatches: colon-id agent -> loadAffect; else -> existing persona_state path.
```

## Exception classes / error codes

No new Python exception types. Error states are JSON `{error_code}` bodies:

| Surface | error_code | Status | Meaning |
|---|---|---|---|
| provider read route | `missing_end_user_id` | 400 | `?end_user_id` absent |
| provider read route | `no_affect_snapshot` | 404 | `store.get` returned None — no emit yet for (agent, user) |
| web proxy | `affect_not_configured` | 400 | `affect_read_url` or server `end_user_id` unset |
| web proxy | `affect_provider_unreachable` | 502 | network error reaching the provider read route |
| web proxy | (passthrough) | provider status | provider 404/400 surfaced to the browser verbatim |

## Invariants

- **INV-001 (honest shape, no fabrication).** The pane renders ONLY fields the
  affect.emit snapshot actually carries — `pad{pleasure,arousal,dominance}`, per-entity
  `valence[]` (familiarity/regard/interaction_count), `emitted_at`. It MUST NOT
  synthesize Tier-1 `persona_state` fields (`dominant_emotion`, `baseline_pad`,
  `mood_drift`, `emotions_active`) — those are platform concepts Tier-3 structurally
  lacks (ADR-0009); a fabricated empty `mood_drift` reads as a bug, not an absence.
- **INV-002 (end_user_id is server-supplied, never browser).** The web proxy reads
  `end_user_id` from `app.state.end_user_id` (RATATOSKR_END_USER_ID); it MUST NOT accept
  one from the browser body/query. Mirrors #17 INV-006 — a client cannot read an
  arbitrary end-user's affect partition.
- **INV-003 (empty/missing is fail-visible).** No emit yet for (agent, user) → an
  EXPLICIT no-data state (provider 404 `no_affect_snapshot`; pane shows "no affect
  emitted yet for this agent / user"). NEVER a zeroed `pad:{0,0,0}` that looks like real
  PAD. A missing/unset `RATATOSKR_END_USER_ID` is a visible config error, not a silent
  empty result.
- **INV-004 (op-feed + scope semantics untouched).** The read route is non-bifrost and
  sits OUTSIDE `opfeed._BIFROST_PATHS`, so the op-feed passes it through and emits no
  OpEvent for it. Deliverable 2 changes neither op-feed behavior nor store scope/affect
  semantics; the store stays conduit-opaque (it returns the stored blob verbatim).
- **INV-005 (label honesty).** When the pane renders affect-store data it is labelled
  "affect" (e.g. "affect snapshot"), NOT "persona" — the data is affect, not Worldtree
  persona_state, and the label must not imply otherwise.
- **INV-006 (SQLite busy_timeout).** Every affect-store connection sets
  `busy_timeout >= 5000ms`. WAL permits one writer + many readers but the default
  busy_timeout is 0 (a contended write returns SQLITE_BUSY immediately). This is prep
  for the future composite/standalone two-process topology; harmless single-process.
- **INV-007 (read route on the store owner; pane decoupled via configured URL).** The
  read route is mounted by whatever app owns the affect store; the pane proxies to
  `RATATOSKR_AFFECT_READ_URL`, so it renders regardless of which endpoint a session is
  bound to. The deferred composite (Deliverable 1) will mount the SAME read route over
  the SAME `affect.db` — one shared helper, not a composite-only feature.
- **INV-008 (colon-id round-trip).** A Tier-3 `agent_id` containing `:`
  (`ratatoskr:sindra`) round-trips browser → web (`encodeURIComponent`) → provider (web
  re-encodes via `quote(agent_id, safe='')` into the provider path) → `store.get`. Both
  hops are asserted end-to-end with a colon-id (Heid panel FM-7).

## Data flow

PANE LOAD (poll, no SSE): on session-start and after each turn-end, the pane calls
`loadPersona(agentId)`. For a colon-id (Tier-3) agent it dispatches to `loadAffect` →
`GET /api/affect/{agent_id}` → the web server supplies `end_user_id` server-side →
proxies to `GET {affect_read_url}/affect/state/{quote(agent_id)}?end_user_id=…` → the
provider reads `store.get(agent_id, end_user_id)` → snapshot JSON (200) or
`no_affect_snapshot` (404) → `renderAffectPane` or the explicit empty-state. For a
non-colon (Tier-1) agent, the existing `persona_state` path is unchanged.

## Function contracts

```contract
FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> ASGIApp
BRIEF: Compose the PAD read route + the bifrost affect app into one Starlette app, so the affect provider also serves the non-bifrost read.

PRE: [PRE-001 hard] store.affect_supported is True -- else ValueError (unchanged)
PRE: [PRE-002 hard] heimdall_key is non-empty bytes -- else ValueError (unchanged)
POST: [POST-001 return_value] returns the bifrost affect app with an added top-level GET /affect/state/{agent_id} route -- assert
POST: [POST-002 return_value] /bifrost/handshake + /bifrost/affect-call remain top-level routes so the op-feed still matches them (INV-004) -- assert
STEPS:
  1. app = build_affect_app(store, verifier, registration) as today (after the existing PRE guards)
  2. define _affect_state_route closing over store (see FN affect_state_route)
  3. app.add_route('/affect/state/{agent_id}', _affect_state_route, methods=['GET']); return app
```

```contract
FN affect_state_route(request) -> JSONResponse   # provider-side, closes over store
BRIEF: Read store.get(agent_id, end_user_id) and return the snapshot or an explicit no-data 404.

PRE: [PRE-001 hard] end_user_id query param present and non-empty -- else 400 missing_end_user_id (INV-003)
POST: [POST-001 return_value] store.get returns a snapshot → 200 with the snapshot JSON verbatim (conduit-opaque) -- assert
POST: [POST-002 return_value] store.get returns None → 404 {error_code:"no_affect_snapshot", agent_id, end_user_id} (INV-003) -- assert
STEPS:
  1. agent_id = path_params['agent_id']; end_user_id = query_params.get('end_user_id')
  2. guard end_user_id (PRE-001); snap = store.get(agent_id, end_user_id)
  3. snap is None → 404 no_affect_snapshot; else 200 snap
```

```contract
FN affect_state_endpoint(request) -> JSONResponse   # web-side proxy
BRIEF: Proxy GET /api/affect/{agent_id} to the configured provider read URL, supplying end_user_id server-side, colon-id safe.

PRE: [PRE-001 hard] app.state.affect_read_url and app.state.end_user_id are set -- else 400 affect_not_configured (INV-002/003)
POST: [POST-001 state_change] the upstream request carries end_user_id from app.state, NEVER from the browser (INV-002) -- assert
POST: [POST-002 return_value] provider 200 → 200 with the snapshot; provider 404/400 → same status passthrough -- assert
POST: [POST-003 exception] httpx.RequestError reaching the provider → 502 affect_provider_unreachable -- assert
POST: [POST-004 side_effect] agent_id is quote()'d into the provider path so a colon-id round-trips (INV-008) -- assert
STEPS:
  1. agent_id = path_params['agent_id']; read affect_read_url + end_user_id from app.state; guard (PRE-001)
  2. url = f"{affect_read_url}/affect/state/{quote(agent_id, safe='')}"
  3. GET url with params {end_user_id}; on RequestError → 502; else passthrough (status, json)
```

## ERROR_ROUTING

| Wire (provider) | Web proxy → browser | Pane render |
|---|---|---|
| 200 snapshot | 200 snapshot | `renderAffectPane` (pad + valence + emitted_at) |
| 404 `no_affect_snapshot` | 404 `no_affect_snapshot` | "no affect emitted yet for this agent / user" (INV-003) |
| 400 `missing_end_user_id` | (server always supplies it) | n/a — config bug surfaced as `affect_not_configured` |
| (provider unreachable) | 502 `affect_provider_unreachable` | "affect provider unavailable" |
| (server misconfig) | 400 `affect_not_configured` | "affect telemetry not configured" |

## Acceptance

Unit (respx + in-process store):
1. provider read route: seeded store → 200 with the exact snapshot; unseeded (agent,user) → 404 `no_affect_snapshot`; missing `end_user_id` → 400.
2. provider app: `/bifrost/handshake` + `/bifrost/affect-call` still reachable after the read route is composed in (INV-004 / POST-002).
3. web proxy: supplies server `end_user_id` (browser-supplied one is ignored); colon-id `ratatoskr:sindra` round-trips into the provider path (INV-008); provider-unreachable → 502; unconfigured → 400.
4. op-feed: a request to `/affect/state/...` produces NO OpEvent (INV-004).
5. busy_timeout: `open_affect_store` connection reports `busy_timeout == 5000` (INV-006).

Live-smoke (load-bearing, manual — the repo's posture): with the affect provider up and a
prior emit for `ratatoskr:sindra` / the configured end_user, open the web pane on that agent
→ the pane renders live PAD + valence + `emitted_at` from OUR store (no "telemetry isn't
exposed"); on a fresh (agent,user) with no emit → the explicit empty-state, not a zeroed PAD.

## Deliverable 1 — composite endpoint (`build_combined_app`)

### Context

One bound Worldtree session that both remembers (memory.*) AND shows live PAD
(affect.*). bifrost 0.10.0 ships `bifrost.consumer.build_combined_app(memory_store,
affect_store, verifier, registration, maintenance_store=None) -> ASGIApp`: ONE app
exposing handshake + `/bifrost/memory-call` + `/bifrost/affect-call` (no legacy
`/bifrost/tool-call`), advertising BOTH caps by store PRESENCE. FR-1 is resolved:
Worldtree runs one `BifrostClient` per session off a single `_endpoint_url`, parses
`capabilities_granted` independently into memory+affect sets, and attaches each store
iff its cap was granted — so a single `:8392` endpoint advertising both caps drives
both planes with ZERO Worldtree change. D1 is bifrost-only on our side: compose the
combined app + mount our existing affect read route + derive the op-feed plane per
path. It is ADDITIVE — the standalone `:8390`/`:8391` apps are unchanged.

### Public surface (D1)

```python
# ratatoskr.provider.combined — a NEW module (the composite spans both planes, so it
# belongs in neither store module).
def build_combined_provider_app(
    memory_store: RatatoskrMemoryStore,
    affect_store: RatatoskrAffectStore,
    heimdall_key: bytes,
    consumer_id: str = "ratatoskr",
):
    """Wire the JWT verifier + registration, hand BOTH stores to
    bifrost.consumer.build_combined_app, then mount the SAME non-bifrost affect read
    route (the shared helper) as a top-level sibling. Returns a Starlette app exposing
    /bifrost/handshake + /bifrost/memory-call + /bifrost/affect-call + GET
    /affect/state/{agent_id}. See FN build_combined_provider_app."""

# ratatoskr.provider.affect_store — the read route is extracted into a shared helper
# so both build_affect_provider_app and build_combined_provider_app mount the SAME one.
def add_affect_read_route(app, store: RatatoskrAffectStore) -> None: ...

# ratatoskr.provider.serve_combined — `ratatoskr-combined-provider` console script,
# :8392. Opens BOTH affect.db + memory.db stores; wires the op-feed with plane='combined'.
```

### Invariants (D1)

- **INV-009 (both stores REQUIRED).** `build_combined_provider_app` requires a real
  memory_store AND affect_store; bifrost's `build_combined_app` raises `ValueError`
  if either is None (single-plane consumers use `build_affect_app`/`build_memory_app`).
  We pass our real SQLite-backed stores; no in-memory default.
- **INV-010 (advertise BOTH caps by store PRESENCE).** The combined handshake grants
  `memory` and `affect` by the presence of each advertising store (memory needs
  `describe_store`; affect needs `affect_supported` + `emit` + `fetch`, strong-or-absent
  — see the affect-provider contract INV-010) — NOT a runtime health probe. The affect
  cap therefore depends on Deliverable-prerequisite `affect.fetch` already shipped.
- **INV-011 (SAME affect read route, shared helper).** The composite mounts the
  identical `GET /affect/state/{agent_id}` route over the SAME affect store, via the
  shared `add_affect_read_route` helper — NOT a composite-only reimplementation
  (fulfils the D2 INV-007 promise). The pane reads it through `RATATOSKR_AFFECT_READ_URL`
  regardless of whether the bound endpoint is `:8390` or `:8392`.
- **INV-012 (op-feed plane derived PER request path).** On the composite, the op-feed
  cannot use a fixed `plane` — both planes share one app. With `plane='combined'` it
  derives the OpEvent plane from `scope['path']`: `/bifrost/memory-call`→`memory`,
  `/bifrost/affect-call`→`affect`, `/bifrost/handshake`→`combined`. The per-verb
  summary logic already keys on path, so memory/affect summaries stay correct; this is
  purely the plane STAMP. The non-bifrost read route stays outside `_BIFROST_PATHS`
  (no OpEvent), unchanged.
- **INV-013 (per-plane failure isolation is bifrost's, honest).** Failure isolation is
  per-route CALL-TIME dispatch isolation within ONE shared ASGI process — a memory-call
  failure does not corrupt an affect-call and vice-versa. Bind-time + process-crash are
  SHARED domains (one process), not independent services; the contract does not claim
  otherwise. We add no isolation layer of our own.
- **INV-014 (additive — standalones unchanged).** `:8392` is a NEW endpoint alongside
  `:8390`/`:8391`; `build_affect_provider_app`/`build_memory_provider_app` and their
  serve entrypoints are untouched. The composite + a standalone may open the SAME
  `affect.db` (two processes) — hence the affect store's `busy_timeout` (D2 INV-006).

### Function contracts (D1)

```contract
FN add_affect_read_route(app, store: RatatoskrAffectStore) -> None
BRIEF: Mount the non-bifrost GET /affect/state/{agent_id} read route on `app` (shared by the affect-only and combined apps). Extracted from build_affect_provider_app verbatim (INV-011 / D2 INV-007).
POST: [POST-001 side_effect] app gains a top-level GET /affect/state/{agent_id} route reading store.get -- assert route present
POST: [POST-002 side_effect] /bifrost/* routes remain top-level (the helper only adds; never Mounts) so the op-feed path-check still matches them (D2 INV-004) -- assert
STEPS:
  1. define _affect_state_route closing over store (PRE: end_user_id present → else 400 missing_end_user_id; store.get None → 404 no_affect_snapshot; else 200 snap verbatim)
  2. app.add_route('/affect/state/{agent_id}', _affect_state_route, methods=['GET'])
```

```contract
FN build_combined_provider_app(memory_store: RatatoskrMemoryStore, affect_store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> ASGIApp
BRIEF: Compose bifrost.consumer.build_combined_app over BOTH stores + mount the shared affect read route — one app fronting both planes plus the PAD read.
PRE: [PRE-001 hard] affect_store.affect_supported is True -- else ValueError (INV-010)
PRE: [PRE-002 hard] heimdall_key is non-empty bytes -- else ValueError
POST: [POST-001 return_value] returns a Starlette app exposing /bifrost/handshake + /bifrost/memory-call + /bifrost/affect-call + GET /affect/state/{agent_id} -- assert routes present
POST: [POST-002 return_value] a combined handshake requesting [memory, affect] is granted BOTH caps (store presence, INV-010) -- assert
POST: [POST-003 return_value] both a memory-call and an affect-call dispatch through the one app (parity vs the standalone apps' behavior) -- assert
STEPS:
  1. guard PRE-001/002; SET verifier = JwtVerifier(HS256, heimdall_key); SET registration = ConsumerRegistration(consumer_id)
  2. SET app = bifrost.consumer.build_combined_app(memory_store, affect_store, verifier, registration)
  3. add_affect_read_route(app, affect_store); RETURN app
TESTS:
  builds_both_planes [happy,tracer]: valid stores + key → app with handshake + memory-call + affect-call + /affect/state routes
  handshake_grants_both [scenario]: handshake requesting [memory, affect] → capabilities_granted contains BOTH (INV-010)
  memory_and_affect_dispatch [scenario]: a memory search + an affect emit both succeed through the one app via dispatch JWTs (INV-013)
  affect_read_route_on_composite [happy]: seeded affect store → GET /affect/state/{colon-id} returns the snapshot (INV-011)
  missing_affect_store [adversarial]: affect_store=None → ValueError (bifrost INV-001)
```

```contract
FN serve_combined.main() -> None
BRIEF: `ratatoskr-combined-provider` entrypoint — open both stores, build the combined app, wire the op-feed (plane='combined'), serve on :8392.
STEPS:
  1. open_affect_store(RATATOSKR_AFFECT_DB) + open_memory_store(RATATOSKR_MEMORY_DB)
  2. app = build_combined_provider_app(memory_store, affect_store, heimdall_key, consumer_id)
  3. app = maybe_instrument_from_env(app, env, plane='combined')  -- op-feed derives plane per path (INV-012)
  4. uvicorn.run(app, host, port=8392)
TESTS:
  (serve wiring is exercised by the unit tests for build_combined_provider_app + the op-feed plane='combined' tests; the uvicorn.run line is a thin shell, smoke-only)
```

### Acceptance (D1)

Unit (in-process, dispatch JWTs via `bifrost.core.dispatch_jwt.mint_dispatch_jwt` — the #17 posture):
1. `build_combined_provider_app` → app with all four routes; handshake grants both caps.
2. a memory `search` + an affect `emit` both dispatch through the one app (INV-013).
3. the affect read route works on the composite for a colon-id (INV-011).
4. `affect_store=None` → ValueError (INV-009).
5. op-feed `plane='combined'`: a memory-call stamps `plane='memory'`, an affect-call stamps `plane='affect'`, a handshake stamps `plane='combined'` (INV-012); the read route emits NO OpEvent.

Live-smoke (manual, the repo's posture): start `:8392`, bind a Tier-3 session to it, drive a turn → the op-feed shows BOTH a memory op and an affect emit at the bound session_id; the web pane (pointed at `:8392` via `RATATOSKR_AFFECT_READ_URL`) renders live PAD. Then ping bifrost-dev that the composite landed.

## Out of scope / DEFERRED (anti-creep)

- **Deliverable 1 — composite :8392 endpoint** — RESOLVED: now in scope, see
  § *Deliverable 1* above (bifrost 0.10.0 `build_combined_app` shipped + FR-1 resolved).
- WT #289 mediated affect-read (`affect.fetch` over bifrost) — we own the store, read it
  directly; no Worldtree dependency.
- Production hardening (TLS/RS256 on the read route; auth on /affect/state) — internal-LAN
  trust model, consistent with the rest of ratatoskr.web.
- Real-time push of PAD into the pane — Tier-3 emits no affect SSE; v1 polls. A push channel
  would need a Worldtree-side affect SSE, out of scope.
