Files
ratatoskr/docs/contracts/issues/17.contract.md
T
vh 719e4d605b feat: web SPA bind — add 'combined' (:8392) both-plane option as default
The bind dropdown offered only memory/affect single-plane binds; #18's
composite endpoint (:8392, both planes in one session) was never reachable
from the SPA. Add 'combined' as the default-selected option, keeping
memory-only / affect-only for single-plane isolation diagnostics.

- endpoint_for_plane: combined -> :8392 (sessions.py)
- web server: accept bifrost_plane="combined" (server.py)
- dropdown: combined (:8392) default-selected, single-plane retained (index.html)
- #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined
- tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default

Suite 506 green. Live-verified on :8765 (current code).
2026-06-20 16:47:03 -07:00

23 KiB

contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
contract_version target_module scope depends_on used_by language complexity estimated_loc confidence assumptions open_questions prd dependencies
2.1 ratatoskr.sessions + ratatoskr.provider (+ cli/tui/web trigger surfaces) 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).
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
17 #17 58a420956e6226fb null null null 2026-06-16T07:45:00+00:00
issue path reason
2 src/ratatoskr/sessions.py 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)
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."""
# 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

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)BifrostBindingcreate_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:
    • 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_bifrostSessionApiFailed 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.