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