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.
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.provider.affect_store + ratatoskr.web (server + static/index.html)"
|
||||
scope: "Issue #18 DELIVERABLE 2 ONLY — the PAD read-endpoint so the web pane renders live PAD/valence for a Tier-3 agent from OUR :8390 affect store. Three pieces: (1) a NON-bifrost read route on the affect-store-owning app — GET /affect/state/{agent_id}?end_user_id=… → store.get(agent_id, end_user_id); (2) a web proxy GET /api/affect/{agent_id} that supplies end_user_id SERVER-SIDE (RATATOSKR_END_USER_ID), never from the browser, and proxies to a CONFIGURED affect-read base URL (RATATOSKR_AFFECT_READ_URL) so the pane is decoupled from the bind target; (3) a NEW pane render path for the affect-emit snapshot shape (pad + per-entity valence + emitted_at) — NOT a reuse of renderPersonaPane (the Tier-1 persona_state shape we never receive for Tier-3). DELIVERABLE 1 (composite :8392 endpoint) is DEFERRED — bifrost-blocked on a public bifrost.consumer.build_combined_app (bifrost-dev confirmed, ~v0.9.0, design locked) AND gated on the open FR-1 Worldtree-dispatch question (worldtree-dev consult in flight). When build_combined_app lands and FR-1 resolves, this contract is AMENDED to add Deliverable 1. 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."
|
||||
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"
|
||||
@@ -236,11 +239,139 @@ prior emit for `ratatoskr:sindra` / the configured end_user, open the web pane o
|
||||
→ 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** — bifrost-blocked (public `build_combined_app`,
|
||||
~v0.9.0, design locked) + FR-1 (Worldtree dual-plane dispatch, worldtree-dev consult in
|
||||
flight). Added by amendment when both resolve. This is the SAME issue, not a new one.
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user