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:
@@ -132,7 +132,13 @@ interpreted.
|
||||
|
||||
SQLite WAL (concurrent readers, single writer). `upsert_many`/`delete_many`
|
||||
serialize on the writer; `search`/`get` are concurrent reads. sqlite-vec index
|
||||
writes ride inside the upsert/delete transaction.
|
||||
writes ride inside the upsert/delete transaction. The connection is opened
|
||||
`check_same_thread=False` with `PRAGMA busy_timeout=5000` (mirrors the affect store):
|
||||
the provider is an ASGI app, so uvicorn/Starlette (and TestClient always) may run a
|
||||
handler off the connection's creating thread — the event loop serializes the sync
|
||||
sqlite calls, so this is safe; busy_timeout preps the composite/standalone two-process
|
||||
topology over the same db. (Surfaced by a TestClient-driven memory `search` through the
|
||||
#18 D1 combined provider — the direct-store tests structurally could not.)
|
||||
|
||||
## Division of labor (library vs store)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.17.15"
|
||||
version = "0.17.16"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -50,6 +50,7 @@ ratatoskr = "ratatoskr.cli:main"
|
||||
ratatoskr-web = "ratatoskr.web.entrypoint:main"
|
||||
ratatoskr-provider = "ratatoskr.provider.serve:main"
|
||||
ratatoskr-memory-provider = "ratatoskr.provider.serve_memory:main"
|
||||
ratatoskr-combined-provider = "ratatoskr.provider.serve_combined:main"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
|
||||
|
||||
@@ -162,6 +162,33 @@ def open_affect_store(db_path: str) -> RatatoskrAffectStore:
|
||||
return RatatoskrAffectStore(conn)
|
||||
|
||||
|
||||
def add_affect_read_route(app, store: RatatoskrAffectStore) -> None:
|
||||
"""Mount the non-bifrost PAD read route GET /affect/state/{agent_id} on `app`,
|
||||
reading store.get. SHARED by build_affect_provider_app and the combined provider
|
||||
(#18 INV-011 / D2 INV-007) — add_route (NOT Mount) keeps /bifrost/* top-level so
|
||||
the op-feed path check still matches them and passes this route through untouched.
|
||||
No JWT (internal-LAN trust model).
|
||||
"""
|
||||
async def _affect_state_route(request: Request) -> JSONResponse:
|
||||
agent_id = request.path_params["agent_id"]
|
||||
end_user_id = request.query_params.get("end_user_id")
|
||||
if not end_user_id: # PRE-001: never look up against a None/empty partition
|
||||
return JSONResponse({"error_code": "missing_end_user_id"}, status_code=400)
|
||||
snap = store.get(agent_id, end_user_id)
|
||||
if snap is None: # INV-003: explicit no-data, never a fabricated zeroed PAD
|
||||
return JSONResponse(
|
||||
{
|
||||
"error_code": "no_affect_snapshot",
|
||||
"agent_id": agent_id,
|
||||
"end_user_id": end_user_id,
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
return JSONResponse(snap)
|
||||
|
||||
app.add_route("/affect/state/{agent_id}", _affect_state_route, methods=["GET"])
|
||||
|
||||
|
||||
def build_affect_provider_app(
|
||||
store: RatatoskrAffectStore,
|
||||
heimdall_key: bytes,
|
||||
@@ -180,28 +207,8 @@ def build_affect_provider_app(
|
||||
registration = ConsumerRegistration(consumer_id=consumer_id)
|
||||
app = build_affect_app(store=store, verifier=verifier, registration=registration)
|
||||
|
||||
# Issue #18 (Deliverable 2): a NON-bifrost PAD read route added as a top-level
|
||||
# sibling of the bifrost routes (add_route, not Mount — keeps /bifrost/* top-level
|
||||
# so the op-feed's path check still matches them and skips this one). Internal-LAN
|
||||
# trust model: no JWT on the read.
|
||||
async def _affect_state_route(request: Request) -> JSONResponse:
|
||||
agent_id = request.path_params["agent_id"]
|
||||
end_user_id = request.query_params.get("end_user_id")
|
||||
if not end_user_id: # PRE-001: never look up against a None/empty partition
|
||||
return JSONResponse(
|
||||
{"error_code": "missing_end_user_id"}, status_code=400
|
||||
)
|
||||
snap = store.get(agent_id, end_user_id)
|
||||
if snap is None: # INV-003: explicit no-data, never a fabricated zeroed PAD
|
||||
return JSONResponse(
|
||||
{
|
||||
"error_code": "no_affect_snapshot",
|
||||
"agent_id": agent_id,
|
||||
"end_user_id": end_user_id,
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
return JSONResponse(snap)
|
||||
|
||||
app.add_route("/affect/state/{agent_id}", _affect_state_route, methods=["GET"])
|
||||
# Issue #18 (Deliverable 2): mount the non-bifrost PAD read route. Extracted into
|
||||
# add_affect_read_route so the combined provider mounts the SAME one (Deliverable 1,
|
||||
# INV-011) over the same affect.db.
|
||||
add_affect_read_route(app, store)
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Combined Bifrost provider (issue #18 Deliverable 1): ONE ASGI app fronting BOTH
|
||||
the memory.* and affect.* planes, so a single bound Worldtree session both remembers
|
||||
AND shows live PAD.
|
||||
|
||||
Contract: docs/contracts/issues/18.contract.md (§ Deliverable 1)
|
||||
|
||||
Wraps `bifrost.consumer.build_combined_app` (bifrost >=0.10.0) over our real
|
||||
SQLite-backed stores and mounts the SAME non-bifrost affect read route as the
|
||||
standalone affect provider (the shared `add_affect_read_route` helper, INV-011). The
|
||||
composite advertises both caps by store PRESENCE at the handshake; per-plane failure
|
||||
isolation is bifrost's per-route call-time dispatch isolation (INV-013). It is
|
||||
ADDITIVE — the standalone :8390/:8391 apps are unchanged (INV-014).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from bifrost.consumer import ConsumerRegistration, build_combined_app
|
||||
from bifrost.reference_server import JwtVerifier
|
||||
|
||||
from ratatoskr.provider.affect_store import RatatoskrAffectStore, add_affect_read_route
|
||||
from ratatoskr.provider.memory_store import RatatoskrMemoryStore
|
||||
|
||||
|
||||
def build_combined_provider_app(
|
||||
memory_store: RatatoskrMemoryStore,
|
||||
affect_store: RatatoskrAffectStore,
|
||||
heimdall_key: bytes,
|
||||
consumer_id: str = "ratatoskr",
|
||||
):
|
||||
"""Compose `build_combined_app` over BOTH stores + mount the shared affect read
|
||||
route. Returns a Starlette app exposing POST /bifrost/handshake +
|
||||
/bifrost/memory-call + /bifrost/affect-call + GET /affect/state/{agent_id}.
|
||||
|
||||
Both stores are REQUIRED (INV-009): bifrost's build_combined_app raises if either
|
||||
is None. The affect cap depends on the affect store advertising affect_supported +
|
||||
emit + fetch (strong-or-absent, INV-010) — guarded here at build time so a
|
||||
misconfigured store fails fast rather than silently withholding the cap.
|
||||
"""
|
||||
if getattr(affect_store, "affect_supported", False) is not True: # PRE-001 / INV-010
|
||||
raise ValueError("affect_store must advertise affect_supported=True")
|
||||
if not (isinstance(heimdall_key, bytes) and heimdall_key): # PRE-002
|
||||
raise ValueError("heimdall_key must be non-empty bytes")
|
||||
verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
|
||||
registration = ConsumerRegistration(consumer_id=consumer_id)
|
||||
# build_combined_app validates memory_store/affect_store presence (INV-009, raises
|
||||
# ValueError on None) and mounts handshake + memory-call + affect-call (no tool-call).
|
||||
app = build_combined_app(memory_store, affect_store, verifier, registration)
|
||||
add_affect_read_route(app, affect_store) # INV-011: the SAME read route, same db
|
||||
return app
|
||||
@@ -330,10 +330,16 @@ def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemorySto
|
||||
"""Open the SQLite+sqlite-vec memory store, creating schema + the vec index on first use."""
|
||||
if not (isinstance(embedding_dim, int) and embedding_dim > 0): # PRE-002
|
||||
raise ValueError("embedding_dim must be a positive int")
|
||||
conn = sqlite3.connect(db_path)
|
||||
# check_same_thread=False: the memory provider is an ASGI app; uvicorn/Starlette
|
||||
# (and TestClient always) may run a handler off the connection's creating thread.
|
||||
# The event loop serializes the sync sqlite calls, so this is safe. Mirrors the
|
||||
# affect store (bifrost_affect_provider INV-006); surfaced by a TestClient-driven
|
||||
# memory-call search through the combined provider (#18 D1).
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
conn.enable_load_extension(True)
|
||||
sqlite_vec.load(conn)
|
||||
conn.enable_load_extension(False)
|
||||
conn.execute("PRAGMA busy_timeout=5000") # wait up to 5s, don't fail SQLITE_BUSY at once
|
||||
if db_path != ":memory:":
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
|
||||
@@ -70,6 +70,22 @@ _BIFROST_PATHS = (
|
||||
"/bifrost/affect-call",
|
||||
)
|
||||
|
||||
_PLANE_BY_PATH = {
|
||||
"/bifrost/memory-call": "memory",
|
||||
"/bifrost/affect-call": "affect",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_plane(configured: str, path: str) -> str:
|
||||
"""For the combined provider (plane='combined', #18 D1) the OpEvent plane is
|
||||
derived from the request PATH — memory-call→memory, affect-call→affect,
|
||||
handshake→combined. A fixed plane ('memory'/'affect', the single-plane apps) is
|
||||
returned unchanged. The per-verb summary logic already keys on path, so only the
|
||||
plane STAMP changes."""
|
||||
if configured != "combined":
|
||||
return configured
|
||||
return _PLANE_BY_PATH.get(path, "combined")
|
||||
|
||||
|
||||
def _b64url_decode(seg: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))
|
||||
@@ -174,6 +190,7 @@ def _resp_summary(
|
||||
def _build_event(
|
||||
plane: str, path: str, scope: dict[str, Any], req_body: bytes, captured: dict[str, Any]
|
||||
) -> OpEvent:
|
||||
plane = _resolve_plane(plane, path) # 'combined' → per-path; fixed plane unchanged
|
||||
headers = dict(scope.get("headers") or [])
|
||||
session_id = _session_id_from_auth(headers.get(b"authorization"))
|
||||
status = "ok" if 200 <= int(captured["status"]) < 300 else "error"
|
||||
@@ -208,8 +225,10 @@ def instrument_provider_app(app: Any, *, plane: str, sink: OpSink) -> Any:
|
||||
(INV-004). A sink/summary failure never propagates into the dispatch path
|
||||
(POST-003 / INV-007) — it is swallowed and logged to stderr.
|
||||
"""
|
||||
if plane not in ("memory", "affect"):
|
||||
raise ValueError(f"plane must be 'memory' or 'affect', got {plane!r}")
|
||||
if plane not in ("memory", "affect", "combined"):
|
||||
raise ValueError(
|
||||
f"plane must be 'memory', 'affect', or 'combined', got {plane!r}"
|
||||
)
|
||||
|
||||
async def wrapped(scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http" or scope.get("path") not in _BIFROST_PATHS:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Runnable entrypoint: serve the COMBINED provider (memory + affect) as one ASGI app.
|
||||
|
||||
Issue #18 Deliverable 1 — a single endpoint a Worldtree session binds to drive BOTH
|
||||
planes. Additive: the standalone affect (:8390) + memory (:8391) entrypoints are
|
||||
unchanged. Config from env:
|
||||
- RATATOSKR_HEIMDALL_KEY (required): HS256 shared key for the consumer, utf-8.
|
||||
- RATATOSKR_MEMORY_EMBEDDING_DIM (required): the pinned embedder dim (no default —
|
||||
a wrong value silently breaks search).
|
||||
- RATATOSKR_AFFECT_DB (default "affect.db") + RATATOSKR_MEMORY_DB (default "memory.db"):
|
||||
the two SQLite paths (one per plane, per the v1 contract).
|
||||
- RATATOSKR_CONSUMER_ID (default "ratatoskr").
|
||||
- RATATOSKR_PROVIDER_HOST (default "0.0.0.0"),
|
||||
RATATOSKR_COMBINED_PROVIDER_PORT (default 8392 — distinct from :8390/:8391 so the
|
||||
composite runs side-by-side with the standalones).
|
||||
- RATATOSKR_OPFEED_PATH (optional): op-feed JSONL path; plane is derived PER request
|
||||
path (memory-call→memory, affect-call→affect, handshake→combined).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ratatoskr.provider.affect_store import open_affect_store
|
||||
from ratatoskr.provider.combined import build_combined_provider_app
|
||||
from ratatoskr.provider.memory_store import open_memory_store
|
||||
from ratatoskr.provider.opfeed import maybe_instrument_from_env
|
||||
|
||||
|
||||
def build_combined_app_from_env(env: Mapping[str, str] | None = None):
|
||||
"""Build the combined ASGI app from environment config (testable seam)."""
|
||||
env = os.environ if env is None else env
|
||||
key = env.get("RATATOSKR_HEIMDALL_KEY")
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
"RATATOSKR_HEIMDALL_KEY is required to serve the combined provider"
|
||||
)
|
||||
raw_dim = env.get("RATATOSKR_MEMORY_EMBEDDING_DIM")
|
||||
if not raw_dim:
|
||||
raise RuntimeError(
|
||||
"RATATOSKR_MEMORY_EMBEDDING_DIM is required (Worldtree's PINNED_EMBEDDER_DIM)"
|
||||
)
|
||||
try:
|
||||
embedding_dim = int(raw_dim)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"RATATOSKR_MEMORY_EMBEDDING_DIM must be an int, got {raw_dim!r}"
|
||||
) from exc
|
||||
if embedding_dim <= 0:
|
||||
raise RuntimeError("RATATOSKR_MEMORY_EMBEDDING_DIM must be a positive int")
|
||||
|
||||
affect_store = open_affect_store(env.get("RATATOSKR_AFFECT_DB", "affect.db"))
|
||||
memory_store = open_memory_store(
|
||||
env.get("RATATOSKR_MEMORY_DB", "memory.db"), embedding_dim=embedding_dim
|
||||
)
|
||||
app = build_combined_provider_app(
|
||||
memory_store,
|
||||
affect_store,
|
||||
heimdall_key=key.encode(),
|
||||
consumer_id=env.get("RATATOSKR_CONSUMER_ID", "ratatoskr"),
|
||||
)
|
||||
# Issue #17 (Observe): opt-in dispatch-layer op-feed; plane='combined' derives the
|
||||
# OpEvent plane per request path (INV-012).
|
||||
return maybe_instrument_from_env(app, env, plane="combined")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
build_combined_app_from_env(),
|
||||
host=os.environ.get("RATATOSKR_PROVIDER_HOST", "0.0.0.0"),
|
||||
port=int(os.environ.get("RATATOSKR_COMBINED_PROVIDER_PORT", "8392")),
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for the combined Bifrost provider (ratatoskr.provider.combined) — issue #18
|
||||
Deliverable 1.
|
||||
|
||||
ONE app fronting BOTH planes (memory.* + affect.*) + the shared affect read route.
|
||||
Mirrors bifrost's tests/consumer/test_build_combined_app.py shapes (handshake +
|
||||
dispatch) and ratatoskr's op-feed test style (mint_dispatch_jwt, RecordingSink), so
|
||||
the envelopes and JWTs are the real wire shapes, not hand-mocked guesses ("test
|
||||
against the shipped lib").
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from bifrost.core.dispatch_jwt import mint_dispatch_jwt
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from ratatoskr.provider.affect_store import open_affect_store
|
||||
from ratatoskr.provider.combined import build_combined_provider_app
|
||||
from ratatoskr.provider.memory_store import open_memory_store
|
||||
from ratatoskr.provider.opfeed import instrument_provider_app
|
||||
|
||||
_KEY = b"deterministic-test-heimdall-key-32-bytes!"
|
||||
_CONSUMER = "ratatoskr"
|
||||
_DIM = 8
|
||||
|
||||
|
||||
def _combined_app():
|
||||
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
|
||||
affect_store = open_affect_store(":memory:")
|
||||
app = build_combined_provider_app(
|
||||
memory_store, affect_store, heimdall_key=_KEY, consumer_id=_CONSUMER
|
||||
)
|
||||
return app, memory_store, affect_store
|
||||
|
||||
|
||||
def _dispatch_headers(*scopes: str, session_id: str = "sess-1") -> dict:
|
||||
token = mint_dispatch_jwt(
|
||||
session_id=session_id,
|
||||
consumer_id=_CONSUMER,
|
||||
issuer="worldtree",
|
||||
scope=list(scopes),
|
||||
secret_or_key=_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _handshake_jwt(session_id: str = "sess-1") -> str:
|
||||
"""Replicate bifrost's consumer conftest jwt_factory (HS256 handshake JWT)."""
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
now = time.time()
|
||||
payload = {
|
||||
"session_id": session_id,
|
||||
"consumer_id": _CONSUMER,
|
||||
"issued_at": now,
|
||||
"expires_at": now + 3600,
|
||||
}
|
||||
h = _b64url(json.dumps(header, separators=(",", ":")).encode())
|
||||
p = _b64url(json.dumps(payload, separators=(",", ":")).encode())
|
||||
sig = hmac.new(_KEY, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
|
||||
return f"{h}.{p}.{_b64url(sig)}"
|
||||
|
||||
|
||||
def _handshake_body(session_id: str = "sess-1") -> dict:
|
||||
return {
|
||||
"bifrost_version": "0.4.0",
|
||||
"mcp_version": "0.4.0",
|
||||
"session_id": session_id,
|
||||
"consumer_id": _CONSUMER,
|
||||
"auth": {"scheme": "Bearer", "token": _handshake_jwt(session_id)},
|
||||
"capabilities": ["memory", "affect"],
|
||||
}
|
||||
|
||||
|
||||
def _snapshot(agent: str = "ratatoskr:sindra", user: str = "vuong") -> dict:
|
||||
return {
|
||||
"agent_id": agent,
|
||||
"end_user_id": user,
|
||||
"pad": {"pleasure": 0.5, "arousal": 0.2, "dominance": -0.1},
|
||||
"valence": [{"entity_id": "e1", "regard": 0.7, "familiarity": 0.3}],
|
||||
"emitted_at": "2026-06-14T12:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def _emit_envelope(snap: dict) -> dict:
|
||||
return {
|
||||
"operation": "affect.emit",
|
||||
"idempotency_key": "sess-1:1:affect",
|
||||
"idempotency_class": "short-retry",
|
||||
"args": snap,
|
||||
}
|
||||
|
||||
|
||||
# --- build_combined_provider_app ---
|
||||
|
||||
def test_builds_both_planes_and_read_route():
|
||||
"""builds_both_planes [tracer]: the composite exposes handshake + memory-call +
|
||||
affect-call + the non-bifrost /affect/state read route (INV-011)."""
|
||||
app, _m, _a = _combined_app()
|
||||
paths = {getattr(r, "path", None) for r in app.routes}
|
||||
assert "/bifrost/handshake" in paths
|
||||
assert "/bifrost/memory-call" in paths
|
||||
assert "/bifrost/affect-call" in paths
|
||||
assert "/affect/state/{agent_id}" in paths
|
||||
|
||||
|
||||
def test_handshake_grants_both_caps():
|
||||
"""handshake_grants_both [scenario]: a handshake requesting [memory, affect] is
|
||||
granted BOTH by store PRESENCE (INV-010) — my wiring doesn't break it."""
|
||||
app, _m, _a = _combined_app()
|
||||
resp = TestClient(app).post("/bifrost/handshake", json=_handshake_body())
|
||||
assert resp.status_code == 200
|
||||
granted = resp.json()["capabilities_granted"]
|
||||
assert "memory" in granted
|
||||
assert "affect" in granted
|
||||
|
||||
|
||||
def test_memory_and_affect_dispatch_through_one_app():
|
||||
"""memory_and_affect_dispatch [scenario]: a memory SEARCH AND an affect emit each
|
||||
round-trip through the SINGLE combined app (INV-013; contract TEST + Acceptance §2
|
||||
name a memory `search`)."""
|
||||
app, _m, _a = _combined_app()
|
||||
client = TestClient(app)
|
||||
|
||||
mem = client.post(
|
||||
"/bifrost/memory-call",
|
||||
json={
|
||||
"operation": "search",
|
||||
"args": {"vector": [0.0] * _DIM, "top_k": 1, "scope_all": {}},
|
||||
},
|
||||
headers=_dispatch_headers("memory:read"),
|
||||
)
|
||||
assert mem.status_code == 200
|
||||
assert mem.json()["success"] is True
|
||||
|
||||
aff = client.post(
|
||||
"/bifrost/affect-call",
|
||||
json=_emit_envelope(_snapshot()),
|
||||
headers=_dispatch_headers("affect:write"),
|
||||
)
|
||||
assert aff.status_code == 200
|
||||
assert aff.json()["success"] is True
|
||||
assert aff.json()["stored"] is True
|
||||
|
||||
|
||||
def test_affect_read_route_on_composite_colon_id():
|
||||
"""affect_read_route_on_composite [happy]: after an emit, GET /affect/state for a
|
||||
colon-id agent returns the snapshot verbatim from the SAME store (INV-011 / INV-008)."""
|
||||
app, _m, _a = _combined_app()
|
||||
client = TestClient(app)
|
||||
snap = _snapshot()
|
||||
client.post(
|
||||
"/bifrost/affect-call",
|
||||
json=_emit_envelope(snap),
|
||||
headers=_dispatch_headers("affect:write"),
|
||||
)
|
||||
r = client.get("/affect/state/ratatoskr:sindra", params={"end_user_id": "vuong"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == snap
|
||||
|
||||
|
||||
def test_missing_affect_store_raises():
|
||||
"""missing_affect_store [adversarial]: affect_store=None → ValueError (INV-009)."""
|
||||
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
|
||||
with pytest.raises(ValueError):
|
||||
build_combined_provider_app(memory_store, None, heimdall_key=_KEY)
|
||||
|
||||
|
||||
def test_missing_memory_store_raises():
|
||||
"""INV-009 (other half): memory_store=None → ValueError (bifrost build_combined_app)."""
|
||||
affect_store = open_affect_store(":memory:")
|
||||
with pytest.raises(ValueError):
|
||||
build_combined_provider_app(None, affect_store, heimdall_key=_KEY)
|
||||
|
||||
|
||||
def test_empty_heimdall_key_raises():
|
||||
"""PRE-002: empty heimdall_key → ValueError (combined-level guard)."""
|
||||
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
|
||||
affect_store = open_affect_store(":memory:")
|
||||
with pytest.raises(ValueError):
|
||||
build_combined_provider_app(memory_store, affect_store, heimdall_key=b"")
|
||||
|
||||
|
||||
def test_non_advertising_affect_store_raises():
|
||||
"""PRE-001 / INV-010: affect_store with affect_supported=False → ValueError."""
|
||||
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
|
||||
affect_store = open_affect_store(":memory:")
|
||||
affect_store.affect_supported = False
|
||||
with pytest.raises(ValueError):
|
||||
build_combined_provider_app(memory_store, affect_store, heimdall_key=_KEY)
|
||||
|
||||
|
||||
# --- op-feed plane='combined' (per-path derivation, INV-012) ---
|
||||
|
||||
class _RecordingSink:
|
||||
def __init__(self) -> None:
|
||||
self.events: list = []
|
||||
|
||||
def emit(self, event) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
async def _post(app, path: str, body: dict, headers: dict | None = None) -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://provider") as client:
|
||||
return await client.post(path, json=body, headers=headers or {})
|
||||
|
||||
|
||||
async def test_opfeed_combined_memory_call_stamps_memory():
|
||||
sink = _RecordingSink()
|
||||
app, _m, _a = _combined_app()
|
||||
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
|
||||
resp = await _post(
|
||||
wrapped,
|
||||
"/bifrost/memory-call",
|
||||
{"operation": "search", "args": {"vector": [0.0] * _DIM, "top_k": 1, "scope_all": {}}},
|
||||
_dispatch_headers("memory:read"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(sink.events) == 1
|
||||
assert sink.events[0].plane == "memory" # derived from path (INV-012)
|
||||
assert sink.events[0].op == "search"
|
||||
|
||||
|
||||
async def test_opfeed_combined_affect_call_stamps_affect():
|
||||
sink = _RecordingSink()
|
||||
app, _m, _a = _combined_app()
|
||||
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
|
||||
resp = await _post(
|
||||
wrapped,
|
||||
"/bifrost/affect-call",
|
||||
_emit_envelope(_snapshot()),
|
||||
_dispatch_headers("affect:write"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(sink.events) == 1
|
||||
assert sink.events[0].plane == "affect" # derived from path (INV-012)
|
||||
assert sink.events[0].op == "emit" # affect. prefix stripped
|
||||
|
||||
|
||||
async def test_opfeed_combined_handshake_stamps_combined():
|
||||
"""handshake isn't plane-specific → stamp plane='combined' (INV-012). A bad-version
|
||||
handshake is cleanly rejected but still emits exactly one OpEvent."""
|
||||
sink = _RecordingSink()
|
||||
app, _m, _a = _combined_app()
|
||||
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
|
||||
resp = await _post(
|
||||
wrapped, "/bifrost/handshake", {"bifrost_version": "99.0.0", "mcp_version": "0.4.0"}
|
||||
)
|
||||
assert resp.status_code != 200 # major-version mismatch, cleanly rejected
|
||||
assert len(sink.events) == 1
|
||||
assert sink.events[0].plane == "combined"
|
||||
assert sink.events[0].op == "handshake"
|
||||
|
||||
|
||||
async def test_opfeed_combined_read_route_emits_no_event():
|
||||
"""INV-012/INV-004: the non-bifrost read route is outside _BIFROST_PATHS → NO OpEvent."""
|
||||
sink = _RecordingSink()
|
||||
app, _m, _a = _combined_app()
|
||||
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
|
||||
transport = httpx.ASGITransport(app=wrapped)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://provider") as client:
|
||||
await client.get("/affect/state/ratatoskr:sindra", params={"end_user_id": "vuong"})
|
||||
assert sink.events == []
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for the combined-provider serve entrypoint (ratatoskr.provider.serve_combined).
|
||||
|
||||
Only the env -> app seam is unit-tested; uvicorn.run is the untestable shell.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ratatoskr.provider.serve_combined import build_combined_app_from_env
|
||||
|
||||
_ENV = {
|
||||
"RATATOSKR_HEIMDALL_KEY": "shared-secret",
|
||||
"RATATOSKR_MEMORY_EMBEDDING_DIM": "8",
|
||||
"RATATOSKR_AFFECT_DB": ":memory:",
|
||||
"RATATOSKR_MEMORY_DB": ":memory:",
|
||||
}
|
||||
|
||||
|
||||
def test_requires_heimdall_key():
|
||||
env = {k: v for k, v in _ENV.items() if k != "RATATOSKR_HEIMDALL_KEY"}
|
||||
with pytest.raises(RuntimeError):
|
||||
build_combined_app_from_env(env)
|
||||
|
||||
|
||||
def test_requires_embedding_dim():
|
||||
env = {k: v for k, v in _ENV.items() if k != "RATATOSKR_MEMORY_EMBEDDING_DIM"}
|
||||
with pytest.raises(RuntimeError):
|
||||
build_combined_app_from_env(env)
|
||||
|
||||
|
||||
def test_builds_app_with_all_routes():
|
||||
app = build_combined_app_from_env(dict(_ENV))
|
||||
paths = {getattr(r, "path", None) for r in app.routes}
|
||||
assert "/bifrost/handshake" in paths
|
||||
assert "/bifrost/memory-call" in paths
|
||||
assert "/bifrost/affect-call" in paths
|
||||
assert "/affect/state/{agent_id}" in paths
|
||||
|
||||
|
||||
def test_opfeed_path_wraps_app(tmp_path):
|
||||
env = dict(_ENV)
|
||||
env["RATATOSKR_OPFEED_PATH"] = str(tmp_path / "ops.jsonl")
|
||||
app = build_combined_app_from_env(env)
|
||||
assert not hasattr(app, "routes") # wrapped: a bare ASGI callable (plane='combined')
|
||||
Reference in New Issue
Block a user