From ca02c70b7c91072f2d23021729023c147ed340cb Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 16 Jun 2026 01:15:28 -0700 Subject: [PATCH] docs(#17): self-drive+observe contract, bifrost self-test runbook + snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/contracts/issues/17.contract.md — issue-scoped v2.1 contract for #17 (Bifrost-binding the chat client). v1 scope = single-plane bind + dispatch-layer op-feed (composite endpoint + turn-pane UI parked). Design consulted via /heid, paraphrase-gated via /heid-contract-review panel; two internal inconsistencies fixed (OpEvent turn_id reservation made literal; session_id-for-all-verbs correction). Validates OK, prd drift-clean. - docs/bifrost-self-test.md — reusable runbook for driving + observing the full Bifrost round-trip against our own provider (the manual form of #17; pins the consumer-key-as-bearer tripwire). - persistent-memory.md — snapshot: observe brick shipped, self-drive proven, #295 root-caused (upstream, scope-axis asymmetry) -> #296/#297, agent_self -> canonical decided. --- docs/bifrost-self-test.md | 163 ++++++++++++++ docs/contracts/issues/17.contract.md | 312 +++++++++++++++++++++++++++ persistent-memory.md | 73 +++++-- 3 files changed, 527 insertions(+), 21 deletions(-) create mode 100644 docs/bifrost-self-test.md create mode 100644 docs/contracts/issues/17.contract.md diff --git a/docs/bifrost-self-test.md b/docs/bifrost-self-test.md new file mode 100644 index 0000000..7c25d09 --- /dev/null +++ b/docs/bifrost-self-test.md @@ -0,0 +1,163 @@ +# Bifrost round-trip self-test + +How to drive **and** observe a full Tier-3 Bifrost round-trip against +ratatoskr's *own* provider — bind a Worldtree session to our affect/memory +store, fire a turn, and read exactly what Worldtree dispatched to us, +correlated with the turn that triggered it. + +This is the **manual form of issue #17** (self-drive + correlated-log +affect/memory ops). Until #17 ships that capability inside the TUI/web/CLI, +this runbook is the reproducible loop — and it is the substrate worldtree-dev's +#296 (salience-algorithm research) and #297 (recall scope/assembly research) +diagnose against. + +> First proven by hand 2026-06-16 while isolating #295's recall leg. + +## The load-bearing tripwire: which key signs the bind + +A bound session-create authenticates as the **Bifrost consumer**, not the +canary client. Worldtree signs the Bifrost handshake JWT with the +**session-create bearer token** (HS256 shared-secret model); our provider +verifies it against `RATATOSKR_HEIMDALL_KEY`. + +So the bearer on `POST /sessions` **must be the consumer Heimdall key** +(`RATATOSKR_HEIMDALL_KEY`, in `~/.config/ratatoskr/provider.env`) — **not** +`WORLDTREE_API_KEY` (the mimir/foundational TUI key in `env.sh`). They are two +different keys for two identities of ratatoskr. + +| Bearer used on `POST /sessions` | Handshake at our provider | +|---|---| +| `WORLDTREE_API_KEY` (mimir/TUI key) | **401** `bifrost.auth_rejected` → 502 to client | +| `RATATOSKR_HEIMDALL_KEY` (consumer key) | **200 OK** → session bound | + +ratatoskr is two identities: the conversation-API **canary client** +(`WORLDTREE_API_KEY`) and the Bifrost **consumer/provider** +(`RATATOSKR_HEIMDALL_KEY`). Self-driving a bound session crosses into the +consumer identity, so it uses the consumer key. #17's Bind half has to carry +this distinction. + +## Prereqs + +- Provider(s) running on this box (nh3-dev, `10.100.10.50`): + - memory → `ratatoskr-memory-provider` on `:8391` + - affect → `ratatoskr-provider` on `:8390` + - Each is a dev background shell, env-sourced from `provider.env`. The memory + provider's stdout carries the inbound observe log (`[memory-provider]` lines + added in `memory_store.py`). +- The provider endpoint is reachable + allowlisted from Worldtree + (`10.250.50.152`): `http://10.100.10.50:8391`. The allowlist + (`BIFROST_CLIENT_ALLOWED_HOSTS`) is **Worldtree-side, infra-ops-owned** — if a + bind 502s with a route/allowlist error, that entry is the thing to check. +- A memory-enabled Tier-3 agent defined on the instance. `ratatoskr:smoke` + (scope `end_user:smoke-user`) is the standing fixture; it is hidden from + `GET /agents` (Tier-3 agents are not in the public roster) but resolves on + session-create. + +## Steps + +```bash +cd ~/development/ratatoskr +set -a && . ~/.config/ratatoskr/provider.env && set +a # RATATOSKR_HEIMDALL_KEY etc. +URL=http://10.250.50.152:8081 # personal Worldtree +HK="$RATATOSKR_HEIMDALL_KEY" # the CONSUMER key — the tripwire +``` + +**1 — Bind a fresh (cold) session to our provider.** `bifrost.endpoint_url` +points at the plane you want (`:8391` memory, `:8390` affect); caps are +negotiated by the handshake, not declared here (`BifrostBindingRequest` is +`{endpoint_url, scope}` only, `additionalProperties:false`). A 201 means the +handshake verified. + +```bash +curl -sS -X POST "$URL/sessions" -H "Authorization: Bearer $HK" \ + -H "Content-Type: application/json" \ + -d '{"agent_id":"ratatoskr:smoke","end_user_id":"smoke-user", + "bifrost":{"endpoint_url":"http://10.100.10.50:8391","scope":null}}' +# -> 201 {"session_id":"...", "kind":"consumer_defined", ...} +``` + +**2 — Snapshot the fixture** (to detect any promotion the turn writes): + +```bash +sqlite3 -readonly memory.db \ + "SELECT chunk_id, json_extract(record_json,'\$.verbatim.text') FROM memory_chunks;" +``` + +**3 — Fire ONE turn** into the bound session, reusing ratatoskr's own SSE +client (handles composite ids + no-read-timeout). Bearer = the consumer key: + +```bash +RATATOSKR_HEIMDALL_KEY="$HK" uv run python - <<'PY' +import asyncio, os, httpx +from ratatoskr.sse_client import stream_turn, Text, Done, Error, Cancelled +SESSION="" +URL="http://10.250.50.152:8081"; KEY=os.environ["RATATOSKR_HEIMDALL_KEY"] +async def main(): + async with httpx.AsyncClient(base_url=URL, + headers={"Authorization":f"Bearer {KEY}","User-Agent":"ratatoskr-selftest"}, + timeout=httpx.Timeout(connect=10.0,read=None,write=10.0,pool=10.0)) as c: + async for ev in stream_turn(c, SESSION, "What kind of chocolate do I like?"): + if isinstance(ev, Done): print("ANSWER:", ev.response); return + if isinstance(ev, Error): print("ERROR:", ev.error_code, ev.message); return +asyncio.run(asyncio.wait_for(main(), 150)) +PY +``` + +**4 — Read the inbound pair** from the provider's stdout (the observe brick). +For a background-shell provider, that is the task output file; tail it: + +``` +[memory-provider] memory-call search REQUEST: scope_filter={...} top_k=... vec_dim=1024 +[memory-provider] memory-call search RESPONSE: N hit(s) [{'chunk_id':..., 'score':..., 'scope':...}] +``` + +**5 — Re-snapshot the fixture** (step 2's query). A new row = the turn was +promoted (a salience-algorithm event; relevant to #296). Audit, don't blindly +delete — promoted failure-surfaces may be wanted corpus. + +## Reading the result + +The `search REQUEST` `scope_filter` vs the `search RESPONSE` hit count is the +whole diagnosis surface: + +- **0 hits** → the filter didn't match any stored chunk. Compare the filter's + axes against the stored `scope`. INV-005 requires **every** filter axis to + match, so an extra axis on the filter that the chunks don't carry (e.g. + `agent_self`) zeroes the result even when `end_user` matches. That's a + **scope-build / persist-recall-symmetry** question (Worldtree-side). +- **Hit present but the model says "no memory"** → we returned it; Worldtree + dropped it downstream of search → **recall-assembly / injection** + (Worldtree-side). + +Either way our store + search are provable from this surface; the recall +*efficacy* must be judged at the model's answer in a **cold (history-free)** +session, never from a wire 200 (a `search` returns 200 whether or not its hits +are injected). + +### Worked example (2026-06-16, #295 → #297) + +Cold turn "What kind of chocolate do I like?" against the `smoke-user` fixture: + +``` +REQUEST: scope_filter={'end_user': 'smoke-user', 'agent_self': 'ratatoskr:smoke'} top_k=128 +RESPONSE: 0 hit(s) +ANSWER: "I don't have access to your past preferences..." +``` + +Root cause: the recall filter carried `agent_self` but the stored chunks are +`{end_user: smoke-user}` only → the `agent_self` axis excluded all of them. +Branch (a), scope asymmetry — fed to #297. + +## Notes / foot-guns + +- **HTTP, not HTTPS.** The spec requires `endpoint_url` be HTTPS; dev is relaxed + via the Worldtree-side allowlist. Don't "fix" our provider to HTTPS to make a + bind work — check the allowlist entry first. +- **Cold means cold.** Reuse of a session with history can satisfy a "recall" + from plain conversation history. Always bind a *fresh* session for a recall + probe. +- **Stray sessions** created by probes are harmless empty rows on the dev + instance; no cleanup required. +- This loop is the thing #17 productizes into the chat surfaces; when #17 lands, + the bind+observe steps move inside the TUI/web/CLI and this runbook becomes the + underlying contract check. diff --git a/docs/contracts/issues/17.contract.md b/docs/contracts/issues/17.contract.md new file mode 100644 index 0000000..bfb46f7 --- /dev/null +++ b/docs/contracts/issues/17.contract.md @@ -0,0 +1,312 @@ +--- +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." +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. `_scope_matches` stays AND (byte-faithful to bifrost reference `reference_server/memory.py:398`); the missing axis-lattice validation (my store admits a wider axis set than the reference) is a SEPARATE parity item, GATED on the `agent_self`-canonicity resolution, and OUT OF SCOPE for #17." + - "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 → 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 `_scope_matches`, the + AND-parity, 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). + +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 +STEPS: + 1. port = 8391 if plane == "memory" else 8390 + 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_filter/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_filter, 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 agent_self parity item) — gated on the `agent_self`-canonicity cross-repo resolution; tracked separately, NOT #17. diff --git a/persistent-memory.md b/persistent-memory.md index 4ce5202..8aed14b 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -1,6 +1,6 @@ # Persistent memory — ratatoskr -_Last updated: 2026-06-15_ +_Last updated: 2026-06-16_ This file captures durable intent and supporting evidence (goals, decisions, foot-gun warnings, in-flight state) across context resets. Read it at session @@ -41,7 +41,7 @@ model output is untrusted); upstream API key stays server-side (INV-003). ## Current state / in-flight -_As of 2026-06-15:_ +_As of 2026-06-16:_ **Ratatoskr now has a SECOND identity: the v1 Bifrost Tier-3 consumer** — the durable persistence provider Worldtree writes Tier-3 agent affect/persona + @@ -56,21 +56,43 @@ background shell (`ratatoskr-provider`, `0.0.0.0:8390`, env-sourced from Worldtree **v0.35.2** (`10.250.50.152`): handshake 200 + `affect.emit` 200 → durable row persisted, opacity held. -**MEMORY plane: PROVIDER LIVE-PROVEN** (v0.17.3, commit `cd12951`; provider -running on `0.0.0.0:8391` as a dev bg shell). Store + dev shell shipped (7 -contract blocks, 26+4 tests, #195 parity, heid-reviewed zero-drift) AND the -live round-trip ran against personal **v0.35.3**: handshake + `upsert_many` -(persist) + `search` (recall-dispatch) all green; `memory.db` holds 3 durable -chunks (2 facts + 1 promoted question, scope `end_user:smoke-user`, 1024-d -vec-indexed). **Proven: persist + dispatch + our search-correctness** — direct -probe (embed the recall query via gateway `qwen3-embedding`, search our live -store) recalls the right fact at cosine 0.60, correctly ranked. **NOT proven, -and upstream (not ours): end-to-end recall-INJECTION** — Worldtree's -recall-assembly doesn't surface our hits into the prompt (cold-session probe: -search dispatched 200 but the model said "no memory"). worldtree-dev looped in -(thread `01KV7JH8…`; #295 held open). Provider stays up with the live fixture. -So NO minor bump / "all-green" announce until worldtree-dev closes the injection -+ question-promotion gaps. +**MEMORY plane: PROVIDER LIVE-PROVEN + recall-miss ROOT-CAUSED (upstream).** +Store + dev shell shipped (v0.17.3, `cd12951`; running on `0.0.0.0:8391`). +`memory.db` holds 3 durable chunks (choc-fact `498ed752` sal 0.9, name `8241e569` +sal 0.8, promoted-question `c863bb6b` sal 0.9 — all scope `{end_user:smoke-user}`). +**The #295 cold-recall miss is now ROOT-CAUSED and it's UPSTREAM, not ours** +(2026-06-16 debug-assist with worldtree-dev): a self-driven bound cold-recall +probe captured the inbound pair via our new observe log — Worldtree's recall sends +`scope_filter={end_user:smoke-user, agent_self:ratatoskr:smoke}` (TWO axes) but our +chunks carry `{end_user}` ONLY; our AND `_scope_matches` (byte-faithful to bifrost +reference `reference_server/memory.py:398`) drops everything on the unmatched +`agent_self` axis → 0 hits → the model says "no memory". So **our store + search +are SOUND**; the fix is Worldtree-side. F2 (question-promotion) → research issue +**#296** (keyword-regex salience suspected fundamentally flawed; `c863bb6b` is a +corpus fixture, KEEP PINNED). F1 (recall-miss) → research issue **#297** +(Worldtree-local fix = per-visible-scope single-axis search unioned client-side; +HELD until the agent_self lattice question lands — see Recent decisions). + +**OBSERVE BRICK SHIPPED** (`memory_store.py`, uncommitted at snapshot time): +structured `[memory-provider]` request/response logging on the memory-call path — +the first concrete brick of #17's observe half, and the lens that caught #295's +root cause. Live-verified. + +**SELF-DRIVE PROVEN BY HAND** (2026-06-16): ratatoskr's own client drove a +Bifrost-bound cold-recall end-to-end (bind → handshake 200 → turn → captured the +recall pair). Load-bearing finding: a bound session-create must use the CONSUMER +Heimdall key (`RATATOSKR_HEIMDALL_KEY`) as the bearer, NOT the canary +`WORLDTREE_API_KEY` — Worldtree signs the Bifrost handshake JWT with the +session-create bearer (canary → 401; consumer → 200). Runbook: +`docs/bifrost-self-test.md`. This is #17's substrate, proven before the contract. + +**ISSUE #17 (self-drive + observe) — CONTRACT WRITTEN + HEID-REVIEWED + FIXED, +TDD NEXT.** `docs/contracts/issues/17.contract.md` (validates OK, drift-clean). +v1 scope operator-locked: single-plane bind (composite endpoint PARKED) + +dispatch-layer op-feed with session-level correlation (turn-pane UI PARKED). +`/heid-contract-review` panel caught + fixed two real internal inconsistencies +(OpEvent `turn_id` reservation made literal; the `session_id`-for-all-verbs +correction). NEXT: TDD tracer-bullet (bind affect plane → turn → assert OpEvent). **Sindra:** a REGISTERED Tier-3 agent (`ratatoskr:sindra`, was model `artemis-31b-v1i`) — registration is REQUIRED to use a Tier-3 character (a @@ -86,10 +108,10 @@ registration. `~/.config/ratatoskr/provider.env` (mode 600, nh3-dev) — `consumer="ratatoskr"`, HS256 = the API-key STRING utf-8-encoded; rotate via infra-ops. -**Pushed:** `main` is in sync with `origin` (pushed through `e57b054`); tags -`v0.17.3` + a straggler `v0.8.2` pushed. Only `graphify-out/GRAPH_REPORT.md` -runs dirty (auto-regenerated by the commit hook — generated artifact, not -chased). +**Committed (2026-06-16, NOT yet pushed):** the observe brick (logger, patch +bump), `docs/bifrost-self-test.md`, the #17 contract, + this snapshot. Push is +the operator's call. `graphify-out/GRAPH_REPORT.md` still runs dirty +(auto-regenerated artifact, not chased). **Still standing from before:** Worldtree spec pin v0.29.0 (`562001a`) for the conversation-API/TUI surface (untouched by the Bifrost work). Codex-first pilot @@ -151,6 +173,12 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-06-15]` **Providers run as dev-box BACKGROUND SHELLS, not infra-ops/systemd** (operator call — it's a dev box). `ratatoskr-provider` (affect) + a future `ratatoskr-memory-provider` as background processes; no productionization track. - `[2026-06-15]` **Affect plane shipped (v0.17.2) + LIVE-PROVEN end-to-end against real Worldtree v0.35.2.** Personal handshake 200 + `affect.emit` 200 from `10.250.50.152` → durable row persisted (opacity held). HS256 key = the consumer's Heimdall API-key STRING utf-8-encoded (NOT base64/raw — the tripwire); cross-subnet route + `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist all held (infra-ops-owned). worldtree-dev confirmed ADR-0009 holding as designed. +- `[2026-06-16]` **#295 cold-recall miss root-caused — UPSTREAM, branch (a) scope-axis asymmetry.** A self-driven bound cold-recall probe (our own client, consumer-key bearer) captured the inbound pair via the new observe log: Worldtree's recall filter carries `{end_user, agent_self}`; our chunks are `{end_user}`-only; AND-matching drops everything on `agent_self` → 0 hits. Our store + search are SOUND; the fix is Worldtree-side. F2 (question-promotion) → **#296** research; F1 (recall-miss) → **#297** research (worldtree-dev's Worldtree-local per-scope-union fix, HELD pending the lattice question). +- `[2026-06-16]` **agent_self → make it CANONICAL (operator decided A).** The cross-repo "is agent_self a valid bifrost scope axis?" question: bifrost's reference lattice is `{end_user, group, tenant}` only (agent_self → `invalid_filter` 400); Worldtree emits agent_self (`bifrost_memory_store.py:479` #248 agent-self primitive). Operator chose canonical-not-re-expressed; worldtree-dev filed the lattice-addition with bifrost-dev (thread `01KV7PXF…`). **Implication: our store's permissive axis-acceptance becomes CORRECT once bifrost adds agent_self — so do NOT add axis-validation; our missing `_validate_scope_filter` is HELD, not a bug to fix.** #297 union build held until the axis lands. +- `[2026-06-16]` **Self-drive auth identity: bound session-create uses the CONSUMER Heimdall key as bearer, NOT `WORLDTREE_API_KEY`.** Worldtree signs the Bifrost handshake JWT with the session-create bearer (canary key → handshake 401; consumer key → 200). Two keys, two identities. Proven by hand; documented in `docs/bifrost-self-test.md`; load-bearing for #17's Bind half. +- `[2026-06-16]` **Issue #17 v1 scope locked (operator 1A/2A): single-plane bind + dispatch-layer op-feed.** `BifrostBindingRequest` is one `endpoint_url` (one plane per session); composite-both-planes endpoint PARKED. Observe = structured op-feed instrumented at the DISPATCH layer (bifrost passes ctx to upsert_many but NOT search/get/delete — `memory.py:244`), session-level correlation; turn-correlated pane UI PARKED (needs turn_id, TBD). Direct in-session TDD (live-smoke load-bearing). Contract `docs/contracts/issues/17.contract.md` written, `/heid`-design-consulted + `/heid-contract-review`-panel'd + fixed (validates OK). NEXT: TDD. +- `[2026-06-16]` **Provider stores confirmed byte-faithful to bifrost's AND reference** (`reference_server/memory.py:398` `_matches_scope` = `all(...)`, identical to ours). OR-union was considered + rejected (ecosystem-wide change); flagged the silent-zero foot-gun to bifrost-dev (docs-only landed, bifrost stays 0.6.4). Do NOT flip `_scope_matches` to OR. + _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ ## Tried and abandoned @@ -186,3 +214,6 @@ defense against re-attempting the same cul-de-sac. - `[2026-06-15]` **Diagnostic: our recall-search is SOUND — the cross-session recall gap is UPSTREAM, not the store — and it caught an upstream bug.** Embedded the recall query via gateway `qwen3-embedding` + searched our live store directly → the dark-chocolate fact recalls at cosine 0.60, correctly ranked above the unrelated name fact (0.16). So the cold-session recall failure is Worldtree's recall-assembly/injection (hits not reaching the prompt), NOT our search. ALSO found a latent UPSTREAM bug: a recall QUESTION got promoted as a durable chunk and ranks **#1 (0.70 > the fact's 0.60)**, polluting recall. Relayed to worldtree-dev (thread `01KV7JH8…`). **This is exactly #17's thesis — ratatoskr-as-provider caught an upstream bug invisible from the chat side.** - `[2026-06-15]` **"Wire 200 ≠ recall works" — prove recall efficacy at the model's answer in a COLD (history-free) session, not on the wire.** A `search`/memory-call returns 200 whether or not its results are injected into the prompt, and same-session "recall" can be plain session history. infra-ops' cold cross-session probe caught my premature "all-green" (search dispatched 200, model had no memory). Don't call cross-session recall proven from a clean wire. - `[2026-06-15]` **Issue #17 filed — Bifrost-binding for the chat client (self-drive + correlated-log affect/memory ops).** REVERSES design-brief §6's "no Bifrost-binding consumer support" — that negative clause predates ratatoskr's provider identity (2026-06-14), so the canary now owns both ends but its client can't drive its own provider (`create_session` sends only `{agent_id, end_user_id}`; no Bifrost `endpoint_url`). Today's smoke proved the substrate (bind→dispatch→persist); only the observe/log channel design (open question #5) remains. The recall-injection caveat is upstream and doesn't block #17. NEXT on #17: `/heid` consult on the now-grounded framing → contract → TDD. (tracked: Gitea #17, labels enhancement/observability/tui) +- `[2026-06-16]` **`scripts/contract_drift_check.py` defaults `GITEA_REPO` to "Worldtree"** (line 74), so a bare run in ratatoskr false-positives DRIFT by hashing Worldtree's same-numbered issue. Always `export GITEA_REPO=ratatoskr GITEA_OWNER=vh` (env.sh leaves the GITEA vars commented out) before running the drift-checker here. +- `[2026-06-16]` **My #295 coupling hypothesis (the promoted question crowds out the fact at small top_k) was REFUTED** — worldtree-dev's recall over-fetches `top_k=128` (`injector.py:203`/`_store_helpers.py:101`), so the question can't crowd the fact out at search level. Reasonable cross-frontier hypothesis, correctly framed as a hypothesis not a conclusion; the real cause was the scope-axis asymmetry. Lesson: offer provider-side hypotheses, let the upstream owner check them against their code. +- `[2026-06-16]` **Contract drifted from its own design in two spots, caught only by `/heid-contract-review` (not same-author paraphrase):** the `OpEvent` dataclass omitted the `turn_id` that INV-005 promised; the `session_id` comment said "None for search/get/delete" contradicting the dispatch-layer design (the JWT carries session_id for all verbs at dispatch). Cross-model paraphrase is load-bearing for catching an author's own contract-vs-intent drift.