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.
httpx
ratatoskr.sessions
ratatoskr.provider.memory_store
ratatoskr.provider.affect_store
bifrost
ratatoskr.cli
ratatoskr.tui
ratatoskr.web.server
python
medium
260
0.78
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).
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).
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
path
reason
5
src/ratatoskr/sessions.py
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
@dataclass(frozen=True)classBifrostBinding:"""Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160).
endpoint_url is the WORLDTREE-VISIBLE base URL of one provider plane."""endpoint_url:strscope:str|None=Noneasyncdefcreate_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."""defendpoint_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."""
# Provider-side observe feed (ratatoskr.provider.opfeed) — dispatch-layer.@dataclass(frozen=True)classOpEvent:ts:str# ISO 8601 UTC, capture timeplane:str# "memory" | "affect"op:str# verb: search / upsert_many / emit / get / delete / handshakesession_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 itstatus:str# "ok" | "error"req_summary:dict# per-verb, scope-only (see "Op-feed summary shapes"); no record bodiesresp_summary:dict# per-verb counts + ids/scores; never verbatim contentturn_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)definstrument_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
classBifrostHandshakeFailed(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|NoneclassBifrostConsumerKeyMissing(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
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-*
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
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:
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.