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

27 KiB

contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
contract_version target_module scope depends_on used_by language complexity estimated_loc confidence assumptions open_questions prd dependencies
2.1 ratatoskr.provider.affect_store + ratatoskr.web (server + static/index.html) 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.
httpx
starlette
ratatoskr.provider.affect_store
ratatoskr.provider.memory_store
ratatoskr.provider.opfeed
ratatoskr.web.server
bifrost.consumer
ratatoskr.provider.serve
ratatoskr.web.entrypoint
python medium 130 0.82
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).
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.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
18 #18 92be262865f38c0e null null null 2026-06-18T18:00:00+00:00
issue path reason
17 src/ratatoskr/web/server.py 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 path reason
17 src/ratatoskr/provider/opfeed.py 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

# 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."""
# 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: ...
// 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 loadAffectGET /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

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

# 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-callmemory, /bifrost/affect-callaffect, /bifrost/handshakecombined. 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)

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'])
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)
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.