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