Files
ratatoskr/docs/contracts/issues/18.contract.md
T
vh 7f4ceaab2b feat(#18): composite Bifrost endpoint — build_combined_app (Deliverable 1)
One ASGI app fronting BOTH the memory.* and affect.* planes (:8392), so a single
bound Worldtree session both remembers AND shows live PAD. Closes #18 end-to-end
(D2 PAD read-endpoint shipped v0.17.14; D1 was bifrost-blocked, now unparked by
bifrost 0.10.0's public build_combined_app + FR-1 resolved — zero Worldtree change).

- provider/combined.py: build_combined_provider_app wraps bifrost.consumer.build_combined_app
  over both stores + mounts the shared affect read route. Advertises both caps by store
  presence; per-route call-time isolation is bifrost's (INV-013).
- affect_store.py: extract add_affect_read_route shared helper (the D2 INV-007 promise —
  composite + standalone mount the SAME read route over the same affect.db, INV-011).
- opfeed.py: plane='combined' derives the OpEvent plane per request path
  (memory-call->memory, affect-call->affect, handshake->combined; INV-012).
- serve_combined.py + ratatoskr-combined-provider console script on :8392 (additive —
  standalone :8390/:8391 untouched, INV-014).
- contract: 18.contract.md § Deliverable 1 (INV-009..INV-014); D1 un-deferred.

Latent bug fixed (exposed by the contract-mandated memory `search` dispatch test running
through TestClient = a worker thread): open_memory_store lacked check_same_thread=False —
the SAME sqlite thread-safety bug already fixed in the affect store (D2). The composite
serves the memory plane over HTTP, so a memory-call on uvicorn's threadpool would trip it.
Fix: check_same_thread=False + PRAGMA busy_timeout=5000 (memory contract Concurrency note).

heid-code-review panel (Groa/Hulda/Regin): ZERO drift findings; the implementation matches
INV-009..INV-014 at function-block level. Folded the genuine test-fidelity fix (memory leg
describe_store -> search per the contract TEST) + added the PRE-001/PRE-002 guard tests.
Suite 486 -> 502 green.
2026-06-19 23:32:47 -07:00

381 lines
27 KiB
Markdown

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