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.
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. |
|
|
python | medium | 130 | 0.82 |
|
|
|
|
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-entityvalence[](familiarity/regard/interaction_count),emitted_at. It MUST NOT synthesize Tier-1persona_statefields (dominant_emotion,baseline_pad,mood_drift,emotions_active) — those are platform concepts Tier-3 structurally lacks (ADR-0009); a fabricated emptymood_driftreads as a bug, not an absence. - INV-002 (end_user_id is server-supplied, never browser). The web proxy reads
end_user_idfromapp.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 zeroedpad:{0,0,0}that looks like real PAD. A missing/unsetRATATOSKR_END_USER_IDis 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 SAMEaffect.db— one shared helper, not a composite-only feature. - INV-008 (colon-id round-trip). A Tier-3
agent_idcontaining:(ratatoskr:sindra) round-trips browser → web (encodeURIComponent) → provider (web re-encodes viaquote(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
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):
- provider read route: seeded store → 200 with the exact snapshot; unseeded (agent,user) → 404
no_affect_snapshot; missingend_user_id→ 400. - provider app:
/bifrost/handshake+/bifrost/affect-callstill reachable after the read route is composed in (INV-004 / POST-002). - web proxy: supplies server
end_user_id(browser-supplied one is ignored); colon-idratatoskr:sindraround-trips into the provider path (INV-008); provider-unreachable → 502; unconfigured → 400. - op-feed: a request to
/affect/state/...produces NO OpEvent (INV-004). - busy_timeout:
open_affect_storeconnection reportsbusy_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_apprequires a real memory_store AND affect_store; bifrost'sbuild_combined_appraisesValueErrorif either is None (single-plane consumers usebuild_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
memoryandaffectby the presence of each advertising store (memory needsdescribe_store; affect needsaffect_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-prerequisiteaffect.fetchalready 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 sharedadd_affect_read_routehelper — NOT a composite-only reimplementation (fulfils the D2 INV-007 promise). The pane reads it throughRATATOSKR_AFFECT_READ_URLregardless of whether the bound endpoint is:8390or: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. Withplane='combined'it derives the OpEvent plane fromscope['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).
:8392is a NEW endpoint alongside:8390/:8391;build_affect_provider_app/build_memory_provider_appand their serve entrypoints are untouched. The composite + a standalone may open the SAMEaffect.db(two processes) — hence the affect store'sbusy_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):
build_combined_provider_app→ app with all four routes; handshake grants both caps.- a memory
search+ an affectemitboth dispatch through the one app (INV-013). - the affect read route works on the composite for a colon-id (INV-011).
affect_store=None→ ValueError (INV-009).- op-feed
plane='combined': a memory-call stampsplane='memory', an affect-call stampsplane='affect', a handshake stampsplane='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_appshipped + FR-1 resolved). - WT #289 mediated affect-read (
affect.fetchover 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.