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).
This commit is contained in:
2026-06-20 16:47:03 -07:00
parent c5c8ecf9d5
commit 719e4d605b
8 changed files with 74 additions and 20 deletions
+7 -7
View File
@@ -1,7 +1,7 @@
---
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) + 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 is PARKED — vNext); 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."
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"
@@ -92,8 +92,8 @@ async def create_session(
def endpoint_for_plane(plane: str, base_host: str) -> str:
"""'memory'->:8391, 'affect'->:8390 → f'http://{base_host}:{port}'. The
Worldtree-visible base URL. See FN endpoint_for_plane."""
"""'memory'->:8391, 'affect'->:8390, 'combined'->:8392 (#18 composite) →
f'http://{base_host}:{port}'. The Worldtree-visible base URL. See FN endpoint_for_plane."""
```
```python
@@ -218,12 +218,12 @@ STEPS:
```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).
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"} -- else ValueError
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) -- assert
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 = 8391 if plane == "memory" else 8390
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
```
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.18.0"
version = "0.18.1"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+12 -10
View File
@@ -227,19 +227,21 @@ async def list_sessions(
def endpoint_for_plane(plane: str, base_host: str) -> str:
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
loopback — Worldtree must reach the provider over the network. `http://` is
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
is supplied directly, bypassing this helper.
Issue #17 dev helper: `memory` → :8391, `affect` → :8390, `combined` → :8392
(the #18 composite both-plane endpoint). Returns the Worldtree-visible base
(e.g. `http://10.100.10.50:8391`), NOT the client's loopback — Worldtree must
reach the provider over the network. `http://` is deliberate: the HTTPS
relaxation is allowlist-side (Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS), not a
URL concern. A production HTTPS endpoint is supplied directly, bypassing this
helper.
"""
if plane not in ("memory", "affect"):
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
if plane not in ports:
raise ValueError(
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
f"unknown plane: {plane!r} "
"(expected 'memory', 'affect', or 'combined')"
)
port = 8391 if plane == "memory" else 8390
return f"http://{base_host}:{port}"
return f"http://{base_host}:{ports[plane]}"
def _bifrost_error_from(resp: httpx.Response) -> str | None:
+1 -1
View File
@@ -138,7 +138,7 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
consumer_key = request.app.state.bifrost_consumer_key
visible_host = request.app.state.bifrost_visible_host
if bifrost_plane:
if bifrost_plane not in ("memory", "affect"):
if bifrost_plane not in ("memory", "affect", "combined"):
return JSONResponse(
{"error_code": "invalid_bifrost_plane"}, status_code=400
)
+1
View File
@@ -513,6 +513,7 @@ body {
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
<div class="select-wrap">
<select id="bifrost-plane">
<option value="combined" selected>combined (:8392) — PAD + memory in one session</option>
<option value="">none — observe only</option>
<option value="memory">memory (:8391) — durable recall</option>
<option value="affect">affect (:8390) — persona / PAD</option>
+7
View File
@@ -384,6 +384,13 @@ class TestEndpointForPlane:
== "http://10.100.10.50:8390"
)
def test_combined_plane_maps_to_8392(self) -> None:
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
assert (
endpoint_for_plane("combined", "10.100.10.50")
== "http://10.100.10.50:8392"
)
def test_unknown_plane_raises_value_error(self) -> None:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
with pytest.raises(ValueError):
+44
View File
@@ -837,6 +837,50 @@ class TestWebBifrostBind:
}
assert upstream.headers["Authorization"] == "Bearer server-ck"
@respx.mock
def test_combined_plane_binds_to_8392(self) -> None:
"""combined [#18 composite]: a 'combined' plane from the browser → the server
binds the :8392 both-plane endpoint; bound-state echoes plane='combined'."""
import json as _json
from ratatoskr.web.server import create_app
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="server-ck",
bifrost_visible_host="10.100.10.50",
)
resp = TestClient(app).post(
"/api/sessions",
json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"},
)
assert resp.status_code == 201
assert resp.json()["bifrost"] == {
"plane": "combined",
"endpoint": "http://10.100.10.50:8392",
"status": "bound",
}
upstream = route.calls[0].request
body = _json.loads(upstream.content)
assert body["bifrost"] == {
"endpoint_url": "http://10.100.10.50:8392",
"scope": None,
}
def test_dropdown_offers_combined_as_default(self) -> None:
"""(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option,
it is the DEFAULT-selected one, and single-plane memory/affect remain."""
from pathlib import Path
import ratatoskr.web as web_pkg
html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text()
assert '<option value="combined" selected>' in html
assert 'value="memory"' in html and 'value="affect"' in html
@respx.mock
def test_plane_without_server_config_is_400(self) -> None:
"""A plane requested but no server-held key/host → bifrost_not_configured."""
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.18.0"
version = "0.18.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },