--- contract_version: "2.1" target_module: "ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces)" scope: "Issue #17 v1 — make the canary chat client self-drive AND observe its own Bifrost provider. Two parts. (1) BIND: `create_session` gains an optional single-plane Bifrost binding (`BifrostBinding{endpoint_url, scope}`) authenticated with a DISTINCT consumer Heimdall key; Worldtree runs the handshake synchronously at POST /sessions, so handshake failure is a session-create failure (502), surfaced on the create path. A plane selector (`memory`→:8391 / `affect`→:8390; `combined`→:8392 added post-#17 — the #18 composite, the web default) + the consumer key thread through CLI / TUI / web; bound-state is visible. (2) OBSERVE: a structured op-feed in the provider, instrumented at the DISPATCH/ASGI layer (where the JWT ctx / session_id lives — bifrost passes ctx to upsert_many but NOT to search/get/delete, so the existing store-method stdout shim cannot see session_id), emitting JSONL {session_id, plane, op, req_summary, resp_summary, status, ts}. OPERATOR DECISIONS LOCKED: single-plane-per-session for v1 (composite endpoint fronting both planes was PARKED at #17 — later shipped as #18 and surfaced in the web bind as the `combined` plane); op-feed with session-level correlation for v1 (turn-correlated debug-pane UI is PARKED — needs turn_id, TBD). Provider store scope semantics MUST NOT change (AND-parity with bifrost's reference store is a hard constraint). Direct in-session TDD; live-smoke against personal Worldtree is the load-bearing acceptance gate." depends_on: - "httpx" - "ratatoskr.sessions" - "ratatoskr.provider.memory_store" - "ratatoskr.provider.affect_store" - "bifrost" used_by: - "ratatoskr.cli" - "ratatoskr.tui" - "ratatoskr.web.server" language: "python" complexity: "medium" estimated_loc: 260 confidence: 0.78 assumptions: - "PROVEN this session (manual end-to-end against personal Worldtree v0.35.3): `POST /sessions` with `bifrost={endpoint_url, scope:null}` runs the handshake synchronously and returns 201 when it verifies. `BifrostBindingRequest` is `{endpoint_url, scope}` ONLY (live OpenAPI, additionalProperties:false); capabilities are negotiated at the handshake, not declared in the bind request. So a session binds exactly ONE provider endpoint → ONE plane's dispatch flows." - "PROVEN: the session-create BEARER is the identity Worldtree signs the Bifrost handshake JWT with (HS256 shared-secret). Bearer = the canary key (WORLDTREE_API_KEY) → handshake 401 `bifrost.auth_rejected` → 502 to the client. Bearer = the consumer Heimdall key (== the provider's RATATOSKR_HEIMDALL_KEY string) → handshake 200. So a BOUND session-create MUST authenticate with the consumer key, NOT the canary key. These are two distinct ratatoskr identities." - "PROVEN: dev HTTP is accepted (spec wants HTTPS) because the provider host:port is on Worldtree's `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist — a Worldtree-side, infra-ops-owned config. The endpoint_url must be the WORLDTREE-VISIBLE base URL (e.g. `http://10.100.10.50:8391`), not the client's loopback. Provider routes live at `/bifrost/handshake` + `/bifrost/memory-call` (memory) and `/bifrost/affect-call` (affect) under that base." - "PROVEN (bifrost source, memory.py:244 vs 262): `dispatch_memory_call` passes `ctx` to `upsert_many` but NOT to `search`/`get`/`delete`. So the recall verb's store method has no session_id; correlation identity must be captured at the dispatch/ASGI layer (JWT ctx), not inside the store method. turn_id (finer than session_id) availability is UNVERIFIED — a contract-stage JWT-claims/envelope dump resolves it; design the op-feed to accept a turn_id later without a schema break." - "Provider stores MUST NOT change scope semantics. `_matches_scope` stays the v0.6 composed filter — `scope_all` (AND/intersection) ∧ `scope_any` (OR/union over conjunctive scopes) — byte-faithful to bifrost reference `reference_server/memory.py` (wire v0.6, #11); the 4-axis lattice validation (`_validate_scope`) is in place and at parity. Scope semantics are settled (the v0.6 scope split shipped, bifrost 0.8.0) and OUT OF SCOPE for #17 — observe is read-only over them." - "The existing `create_session(client, agent_id, *, end_user_id=None)` (sessions.py:179) is extended, not replaced (pre-v1, no compat shim). The httpx client carries the canary key as its default Authorization; the bound create overrides the bearer per-request with the consumer key." - "Tests use `respx` for the bind unit tests (mirroring tests/test_sessions.py) + the in-process op-feed; the live-smoke acceptance is manual (per the repo's load-bearing-smoke posture), captured as a documented runbook step, not a unit test. `docs/bifrost-self-test.md` is the manual procedure this feature productizes." - "v1 ships the CLI + TUI + web trigger surfaces in lockstep (the repo's BOTH-presenters-in-lockstep rule); the op-feed is read by the debug surface as structured lines for now (pane-correlated rendering is PARKED)." open_questions: - "turn_id on the wire: does Worldtree put a turn_id (or traceparent) in the Bifrost request JWT claims / envelope? If yes, the op-feed echoes it and turn-correlation becomes exact; if no, correlation is session_id + time-window (weaker). RESOLVE via a JWT-claims dump during the first TDD slice. Does NOT block v1 (session_id correlation is the v1 target); it gates the PARKED pane-UI." - "Composite endpoint (PARKED): a thin ASGI fronting both :8390/:8391 advertising both caps at handshake would let one session observe both planes. Deferred to vNext per operator. If pursued, it needs per-underlying-store parity checks + per-plane failure status (a facade routing bug is a new failure class) — NOT just `handshake lists both caps`." - "Key identity unification (PARKED — operator's call, crosses Heimdall): v1 assumes two keys. Do not derive one from the other." - "Auto-bind on Tier-3 agents (PARKED — operator's call): v1 is explicit opt-in only. Auto-bind hides the most important debug variable (which identity + endpoint the session bound to)." prd: issue: 17 issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/17" body_sha256_16: "58a420956e6226fb" lock_in_comment_id: null lock_in_sha256_16: null lock_in_at: null pinned_at: "2026-06-16T07:45:00+00:00" dependencies: - issue: 2 path: "src/ratatoskr/sessions.py" reason: "create_session is the bind site. Same posture: caller-owned httpx client, async-native, no Worldtree imports, frozen-dataclass parse, exception `.body` truncated to [:1024]." - issue: 5 path: "src/ratatoskr/sessions.py" reason: "end_user_id already threads into the POST /sessions body; the bifrost field is added alongside it with the same optional-when-None posture." --- # Issue #17 — Bifrost-binding the chat client: self-drive + observe ## Context Ratatoskr is two identities: the conversation-API **canary client** (TUI/web/CLI that runs turns and watches the SSE flow) and a Bifrost **Tier-3 provider** (durable affect store :8390 + memory store :8391, separate ASGI apps). Until now the canary couldn't drive its OWN provider — `create_session` never sent a Bifrost binding, so every affect/memory round-trip was driven externally. #17 closes that: the canary BINDS a session to its own provider and OBSERVES the resulting affect/memory dispatch, so an operator can hunt latent cross-layer bugs from one seat. The manual procedure proven this session lives at `docs/bifrost-self-test.md`; #17 productizes it. **v1 scope is deliberately narrow** (operator-locked): single-plane bind (composite endpoint PARKED), session-level op-feed (turn-correlated panes PARKED). The load- bearing risks are bind-time auth identity + capturing a correlation key the store method can't see — both resolved below. ## Public surface ```python @dataclass(frozen=True) class BifrostBinding: """Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160). endpoint_url is the WORLDTREE-VISIBLE base URL of one provider plane.""" endpoint_url: str scope: str | None = None async def create_session( client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None, bifrost: BifrostBinding | None = None, consumer_key: str | None = None, ) -> SessionInfo: """POST /sessions. When `bifrost` is set the request authenticates with `consumer_key` (NOT the client's default canary bearer) and carries the `bifrost` field; Worldtree handshakes synchronously before 201. See FN create_session.""" def endpoint_for_plane(plane: str, base_host: str) -> str: """'memory'->:8391, 'affect'->:8390, 'combined'->:8392 (#18 composite) → f'http://{base_host}:{port}'. The Worldtree-visible base URL. See FN endpoint_for_plane.""" ``` ```python # Provider-side observe feed (ratatoskr.provider.opfeed) — dispatch-layer. @dataclass(frozen=True) class OpEvent: ts: str # ISO 8601 UTC, capture time plane: str # "memory" | "affect" op: str # verb: search / upsert_many / emit / get / delete / handshake session_id: str | None # from the JWT ctx at the DISPATCH layer — present for ALL # JWT-carrying verbs (not just upsert_many; bifrost withholds # ctx from search/get/delete STORE methods, but dispatch sees # the JWT); None only if the JWT genuinely omits it status: str # "ok" | "error" req_summary: dict # per-verb, scope-only (see "Op-feed summary shapes"); no record bodies resp_summary: dict # per-verb counts + ids/scores; never verbatim content turn_id: str | None = None # INV-005 reservation made LITERAL: the field exists now, # unused in v1 (session-level correlation), populated when # Worldtree propagates a turn id (open question) def instrument_provider_app(app, *, plane: str, sink: OpSink): """Wrap the dispatch/ASGI layer so every inbound bifrost-call emits one OpEvent to `sink`, reading session_id off the JWT ctx where bifrost exposes it. Does NOT touch store scope semantics. See FN instrument_provider_app.""" ``` ## Exception classes ```python class BifrostHandshakeFailed(Exception): """502 bifrost_handshake_failed on bound session-create. Carries the spec-level `detail.bifrost_error` (e.g. 'bifrost.auth_rejected').""" def __init__(self, *, bifrost_error: str | None, body: bytes) -> None: ... bifrost_error: str | None class BifrostConsumerKeyMissing(Exception): """A bifrost binding was requested without a consumer_key. Raised BEFORE HTTP (the bind must never silently fall back to the canary key).""" ``` ## Invariants - **INV-001 (auth identity, never fall back).** A `bifrost` binding REQUIRES a non-empty `consumer_key`; absence raises `BifrostConsumerKeyMissing` before any HTTP. The bound POST /sessions authenticates with `consumer_key`; an unbound create authenticates with the client's default canary key. The two call sites never cross. On 401-rooted handshake failure the error names the mismatch. - **INV-002 (bind-time, not turn-time, failure).** The handshake runs synchronously on POST /sessions. A bad URL / down provider / wrong key / HTTPS rejection fails SESSION CREATION (502 → `BifrostHandshakeFailed`), surfaced on the create path BEFORE any turn / before alt-screen (TUI) — never deferred to first-turn. Mirrors issue #6's pre-alt-screen error routing. - **INV-003 (one plane per session).** A binding targets exactly one endpoint = one plane. v1 documents this limit explicitly; binding both planes for one turn is the PARKED composite-endpoint feature, not a v1 path. - **INV-004 (no scope-semantics change).** The observe instrumentation is READ-ONLY over the dispatch path; it MUST NOT alter `_matches_scope`, the v0.6 `scope_all`/`scope_any` semantics, or any store behavior. The op-feed reports the effective scope used per op; it never rewrites scope client-side. - **INV-005 (correlation key at the dispatch layer).** session_id is captured from the JWT ctx at the dispatch/ASGI layer — present for ALL JWT-carrying verbs, INCLUDING search/get/delete (bifrost withholds ctx from those STORE methods, but the dispatch layer still verifies + reads the JWT). `session_id=None` ONLY if the JWT genuinely omits it (a claims-dump open question, not the store-method gap). `OpEvent` carries a literal `turn_id: str | None = None` field — the reservation is a real field defaulted to None in v1, not a future schema change. - **INV-006 (data hygiene).** Bound debug sessions write to DURABLE stores. The smoke procedure uses an explicit test scope (`end_user:smoke-user`) and a documented cleanup path; the contract's acceptance asserts the fixture before/after so a debug run's promotions are visible, never silent. (Promotion of a turn is expected behavior, not a bug — but it must be observable.) - **INV-007 (observe captures failures + late ops).** The op-feed records non-2xx / error ops (status="error"), never hides or double-counts them. The `OpSink` is a CONTINUOUS append-only feed — NOT per-session-scoped, no per-session teardown in v1; late ops that land AFTER the SSE turn-end simply append with their timestamp, so a consumer can apply a post-turn grace window (the PARKED pane-UI's concern; v1 just must not drop late ops). A sink write that FAILS is swallowed from the dispatch path (instrument_provider_app POST-003) BUT logged to stderr — an observe gap is never silent. - **INV-008 (both presenters in lockstep).** The bind trigger + bound-state indicator land in CLI, TUI, and web together (the repo's add-to-all-presenters rule). Web creates the bound session SERVER-SIDE; the consumer key never reaches the browser. - **INV-009 (secret hygiene).** Both keys are redacted in all UI/logs. The consumer key is PRIVILEGED (it is the handshake identity), not equivalent to read-only conversation access; config that stores it uses no weaker file permissions than the canary key (cf. provider.env mode 600). ## Data flow BIND (client → Worldtree): CLI/TUI/web resolve `plane` + the consumer key → `endpoint_for_plane(plane, worldtree_visible_host)` → `BifrostBinding` → `create_session(..., bifrost=binding, consumer_key=...)` → POST /sessions with bearer = consumer key → Worldtree handshakes synchronously to the provider endpoint → 201 (bound) or 502 (`BifrostHandshakeFailed`). OBSERVE (Worldtree → provider → feed): a bound turn makes Worldtree dispatch affect/memory bifrost-calls to the provider → `instrument_provider_app` wraps the dispatch layer → one `OpEvent` per call (session_id from JWT ctx when present) → `OpSink` (JSONL) → the debug surface reads structured lines. The store path is untouched (INV-004); observe is read-only over dispatch. ## Function contracts ```contract FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None, bifrost: BifrostBinding | None = None, consumer_key: str | None = None) -> SessionInfo BRIEF: POST /sessions; when a bifrost binding is given, authenticate with the consumer key (not the canary key) and carry the binding so Worldtree handshakes synchronously to our provider. PRE: [PRE-001 hard] bifrost is not None ⇒ consumer_key is a non-empty str -- else BifrostConsumerKeyMissing, before any HTTP (INV-001) PRE: [PRE-002 soft] bifrost is None ⇒ request uses the client's default canary bearer -- unchanged pre-#17 path POST: [POST-001 return_value] 201 → SessionInfo (unchanged parse) -- assert POST: [POST-002 exception] 502 → BifrostHandshakeFailed(bifrost_error=detail.bifrost_error, body) (INV-002) -- assert POST: [POST-003 exception] 422 ephemeral_does_not_accept_bifrost → SessionApiFailed -- assert POST: [POST-004 exception] 404 → AgentNotFound; other non-201 → SessionApiFailed -- assert (unchanged) STEPS: 1. body = {"agent_id": agent_id}; if end_user_id: body["end_user_id"] = end_user_id 2. if bifrost: body["bifrost"] = {"endpoint_url": bifrost.endpoint_url, "scope": bifrost.scope}; headers = {"Authorization": f"Bearer {consumer_key}"} 3. else: headers = {} (httpx client default bearer = canary key) 4. resp = await client.post("/sessions", json=body, headers=headers); route status per POST-* ``` ```contract FN endpoint_for_plane(plane: str, base_host: str) -> str BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390, combined->:8392 — the #18 composite both-plane endpoint, surfaced post-#17). PRE: [PRE-001 hard] plane in {"memory", "affect", "combined"} -- else ValueError POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) / 8392 (combined) -- assert STEPS: 1. port = {"memory": 8391, "affect": 8390, "combined": 8392}[plane] 2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern ``` ```contract FN instrument_provider_app(app, *, plane: str, sink: OpSink) -> ASGIApp BRIEF: Wrap the provider's dispatch layer so each inbound bifrost-call emits one structured OpEvent (session_id from the JWT ctx) without touching store semantics. PRE: [PRE-001 hard] app is a built provider ASGI app; sink is an OpSink -- guard POST: [POST-001 side_effect] emits exactly one OpEvent per inbound bifrost-call, incl. handshake + error ops (INV-007) -- assert POST: [POST-002 state_change] OpEvent.session_id = JWT ctx session_id when present, else None (INV-005) -- assert POST: [POST-003 side_effect] a sink failure never propagates into the dispatch path — observe must not break serve -- assert POST: [POST-004 return_value] store scope semantics untouched; read-only over dispatch (INV-004) -- assert STEPS: 1. wrap the dispatch/ASGI layer so each inbound bifrost-call yields one OpEvent 2. read session_id off the JWT ctx if present; else None 3. summarise req (scope_all/scope_any/top_k for search; record-count+scopes for upsert) + resp (hit-count+ids/scores | upserted+replayed | error code) — NEVER verbatim content 4. emit to sink; swallow sink errors ``` ## ERROR_ROUTING | Wire | Exception | Surfaced | |---|---|---| | 502 `bifrost_handshake_failed` | `BifrostHandshakeFailed(bifrost_error)` | create path, names the bifrost_error; TUI pre-alt-screen | | (pre-HTTP) bifrost w/o consumer_key | `BifrostConsumerKeyMissing` | config/CLI validation, before any request | | 422 `ephemeral_does_not_accept_bifrost` | `SessionApiFailed` | create path | | 401 at provider handshake (manifests as 502 to client) | `BifrostHandshakeFailed('bifrost.auth_rejected')` | error text: "bound create requires the consumer key, not WORLDTREE_API_KEY" | ## Acceptance — the ordered live-smoke gate (load-bearing) The repo's smoke-is-load-bearing posture: this gate IS acceptance, run manually against personal Worldtree, mirroring `docs/bifrost-self-test.md`. ``` 1. providers up → memory :8391 serving + op-feed sink attached (affect :8390 is symmetric, OPTIONAL for the memory-plane gate) 2. consumer key set → RATATOSKR_BIFROST_CONSUMER_KEY present; canary key separate 3. allowlist OK → endpoint_for_plane host on Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS 4. bind 201 → ratatoskr --bifrost-plane memory → bound session, handshake 200 5. negative: canary → binding with the canary key → BifrostHandshakeFailed, the auth_rejected message names the consumer-key mismatch 6. turn → one turn into the bound session 7. assert op-feed → OpEvent captured with session_id == the BOUND session's id (not merely "some session_id"); {plane, op, req/resp summary, status} 8. assert hygiene → fixture before/after asserted (per docs/bifrost-self-test.md); promotions visible not silent ``` Unit tests (respx) cover: bind body shape, consumer-key override, the missing-key precondition, 502→BifrostHandshakeFailed mapping, 422 ephemeral, and the op-feed emitting one OpEvent per dispatched call incl. error + late op. ## v1 clarifications (paraphrase-gate fixups) Pinned in response to the `/heid-contract-review` panel — closing under-specs that let an implementer comply while violating intent. - **Op-feed summary shapes (per verb).** `req_summary` / `resp_summary` are scope-only, never verbatim content: - `search` → req `{scope_all, scope_any, top_k}`; resp `{hit_count, [{chunk_id, score}]}` - `upsert_many` → req `{record_count, [scope]}`; resp `{upserted, replayed}` - `get` / `get_many` → req `{ids}`; resp `{found_count}` - `delete_many` → req `{ids}`; resp `{deleted}` - `emit` (affect) → req `{actor-scope}`; resp `{status}` (affect stays conduit-opaque) - `handshake` → req `{caps_requested}`; resp `{caps_granted, ok}` - **`BifrostBinding.scope` is an opaque pass-through** (Worldtree spec: ≤256 chars, copied into the JWT payload unchanged). ratatoskr does NOT interpret it; v1 sends `null`. A non-null value is operator-supplied and meaningful only to Worldtree. - **Web bind split (INV-008 sharpened).** The web UI selects the PLANE; the consumer key is SERVER-HELD (env/config), never sent from the browser; the server constructs the bound session. The browser never sees the consumer key. - **Bound-state indicator** shows at least `plane + endpoint + bound|failed status`, not a bare boolean (so the operator can see WHICH identity/endpoint bound). - **401-handshake message scoping.** The "use the consumer key, not WORLDTREE_API_KEY" text is keyed on `bifrost_error == "bifrost.auth_rejected"`; other 502 handshake failures surface the generic `BifrostHandshakeFailed` with their own `bifrost_error`. - **`endpoint_for_plane` is the DEV helper** (returns `http://`, allowlist-relaxed). A production HTTPS endpoint is supplied directly via `--bifrost-url`, bypassing the plane shortcut — HTTPS is not constructed by `endpoint_for_plane`. - **422 `ephemeral_does_not_accept_bifrost` → `SessionApiFailed` is deliberate** (no distinct exception; it is an operator config error, surfaced as a generic create failure). Not an oversight in the ERROR_ROUTING overlap with POST-004. ## Out of scope / PARKED (anti-creep) - Composite endpoint (both planes, one session) — vNext; needs per-store parity + per-plane failure status. - Turn-correlated debug-pane UI — needs turn_id (open question) + grace-window buffering + client read channel. - Key identity unification (Heimdall) — operator's call, crosses service boundary. - Auto-bind on Tier-3 agents — operator's call; v1 is explicit opt-in only. - Provider axis-lattice validation + the v0.6 scope split — RESOLVED (shipped: bifrost 0.8.0/wire v0.6, `_validate_scope` 4-axis + `scope_all`/`scope_any`); no longer a #17 concern.