Compare commits

...

5 Commits

Author SHA1 Message Date
vh aac4353933 feat(provider): validate scope_filter against the v0.5 4-axis lattice
bifrost 0.7.0 (wire v0.5) makes agent_self canonical: the scope lattice is
now {end_user, group, tenant, agent_self}. Our store was MORE permissive than
bifrost's reference (no _validate_scope_filter), which silently 0-zeroed the
#295 cold recall instead of a loud 400. Now matched: search rejects an
out-of-lattice axis with InvalidFilter (-> memory.invalid_filter 400), agent_self
admitted. Purely additive — everything that validated before still validates.

Closes the parity gap our own foot-gun flag opened (bifrost-dev shipped the
lattice add #10 off it). Pin bumped bifrost>=0.6.1 -> >=0.7.0. Contract
search PRE-003 + lattice_axes test; 2 new store tests; full suite 429 green.
2026-06-16 01:24:01 -07:00
vh ca02c70b7c docs(#17): self-drive+observe contract, bifrost self-test runbook + snapshot
- 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.
2026-06-16 01:15:28 -07:00
vh 2fef6e39f9 feat(provider): inbound memory-call observe log (#17 observe brick)
Structured [memory-provider] request/response logging on the memory-call
path: search REQUEST (scope_filter, top_k) + RESPONSE (chunk_ids, scores,
scope), plus concise entry lines on upsert_many/delete_many. Self-contained
stdout handler so the lines reach the provider stdout under uvicorn.

Additive observability only — no search-semantics change (AND-parity with
bifrost's reference store holds). This is the first concrete brick of #17's
observe half, and the lens that root-caused #295's cold-recall miss (the
persist/recall scope-axis asymmetry) from the provider side.
2026-06-16 01:15:16 -07:00
vh 2b47dcff5a memory: snapshot — memory provider live-proven (persist/dispatch/search); recall-injection upstream; #17 filed 2026-06-15 23:44:33 -07:00
vh e57b054054 memory: snapshot — memory plane shipped (v0.17.3), canonical sync + #3/#4 refresh 2026-06-15 21:46:07 -07:00
8 changed files with 622 additions and 24 deletions
+163
View File
@@ -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="<session_id from step 1>"
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.
@@ -209,9 +209,10 @@ FN search(self, vector: list[float], *, top_k: int, scope_filter: dict | None =
BRIEF: Vector (cosine) recall over sqlite-vec, scoped, returning the top_k IN-SCOPE chunks. BRIEF: Vector (cosine) recall over sqlite-vec, scoped, returning the top_k IN-SCOPE chunks.
PRE: [PRE-001 hard] len(vector) == embedding_dim -- else InvalidArguments PRE: [PRE-001 hard] len(vector) == embedding_dim -- else InvalidArguments
PRE: [PRE-002 hard] metadata_filter is empty/None -- v1 advertises no filterable fields; a non-empty filter → InvalidArguments PRE: [PRE-002 hard] metadata_filter is empty/None -- v1 advertises no filterable fields; a non-empty filter → InvalidArguments
PRE: [PRE-003 hard] every scope_filter axis ∈ {end_user, group, tenant, agent_self} -- else InvalidFilter (memory.invalid_filter 400); the bifrost wire-v0.5 lattice, matching the reference _validate_scope_filter (#10 made agent_self canonical)
POST: [POST-001 return_value] returns the top_k highest-cosine records WHOSE scope matches scope_filter — at most top_k, and never fewer than min(top_k, in-scope count) (INV-005). Each: {chunk (verbatim), chunk_id, score, recalled_view (= chunk["distillate"] or chunk), revision} -- assert POST: [POST-001 return_value] returns the top_k highest-cosine records WHOSE scope matches scope_filter — at most top_k, and never fewer than min(top_k, in-scope count) (INV-005). Each: {chunk (verbatim), chunk_id, score, recalled_view (= chunk["distillate"] or chunk), revision} -- assert
STEPS: STEPS:
1. [setup] validate scope_filter is a flat {axis: value} dict (matched against record["scope"][axis]) 1. [setup] validate scope_filter is a flat {axis: value} dict (matched against record["scope"][axis]) AND every axis ∈ the v0.5 lattice {end_user, group, tenant, agent_self} (else InvalidFilter)
2. [sequential, flexibility=indicative] rank candidates by cosine over record["embedding"]; KEEP only scope-matching records (INV-005); THEN take top_k — so top_k counts IN-SCOPE hits, not pre-filter hits (over-fetch from the vec index or post-filter rank as needed) 2. [sequential, flexibility=indicative] rank candidates by cosine over record["embedding"]; KEEP only scope-matching records (INV-005); THEN take top_k — so top_k counts IN-SCOPE hits, not pre-filter hits (over-fetch from the vec index or post-filter rank as needed)
3. [cleanup] RETURN result rows (chunk verbatim + score + recalled_view + revision) 3. [cleanup] RETURN result rows (chunk verbatim + score + recalled_view + revision)
TESTS: TESTS:
@@ -219,6 +220,7 @@ TESTS:
scope_isolation [adversarial]: two scopes, search one → never returns the other's chunk, and returns top_k of the IN-SCOPE set even if out-of-scope chunks score higher (INV-005) scope_isolation [adversarial]: two scopes, search one → never returns the other's chunk, and returns top_k of the IN-SCOPE set even if out-of-scope chunks score higher (INV-005)
empty [boundary]: search empty store → [] empty [boundary]: search empty store → []
metadata_filter_rejected [adversarial]: non-empty metadata_filter → InvalidArguments metadata_filter_rejected [adversarial]: non-empty metadata_filter → InvalidArguments
lattice_axes [adversarial]: out-of-lattice scope axis → InvalidFilter; agent_self admitted (wire v0.5, #10)
parity_vs_reference [scenario]: identical search envelopes vs InMemoryMemoryStore → same ranked chunk_ids/shape (#195) parity_vs_reference [scenario]: identical search envelopes vs InMemoryMemoryStore → same ranked chunk_ids/shape (#195)
``` ```
+312
View File
@@ -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.
+59 -12
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr # Persistent memory — ratatoskr
_Last updated: 2026-06-15_ _Last updated: 2026-06-16_
This file captures durable intent and supporting evidence (goals, decisions, This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session 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 ## 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 **Ratatoskr now has a SECOND identity: the v1 Bifrost Tier-3 consumer** — the
durable persistence provider Worldtree writes Tier-3 agent affect/persona + durable persistence provider Worldtree writes Tier-3 agent affect/persona +
@@ -56,13 +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 → Worldtree **v0.35.2** (`10.250.50.152`): handshake 200 + `affect.emit` 200 →
durable row persisted, opacity held. durable row persisted, opacity held.
**MEMORY plane: contract done, TDD next.** **MEMORY plane: PROVIDER LIVE-PROVEN + recall-miss ROOT-CAUSED (upstream).**
`docs/contracts/bifrost_memory_provider.contract.md` v1.1 (Heid-panel-reviewed, Store + dev shell shipped (v0.17.3, `cd12951`; running on `0.0.0.0:8391`).
committed `1f94e5f`). NEXT (fresh session): add `sqlite-vec` to the `provider` `memory.db` holds 3 durable chunks (choc-fact `498ed752` sal 0.9, name `8241e569`
extra → TDD (tracer `basic_upsert` → search/scope-isolation/optimistic-lock/ sal 0.8, promoted-question `c863bb6b` sal 0.9 — all scope `{end_user:smoke-user}`).
conflict → #195 parity vs `InMemoryMemoryStore`) → `/heid-code-review` → ship a **The #295 cold-recall miss is now ROOT-CAUSED and it's UPSTREAM, not ours**
`ratatoskr-memory-provider` dev shell. Worldtree v0.35.3 already negotiates the (2026-06-16 debug-assist with worldtree-dev): a self-driven bound cold-recall
basic memory plane — the memory server is the only missing piece. 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 **Sindra:** a REGISTERED Tier-3 agent (`ratatoskr:sindra`, was model
`artemis-31b-v1i`) — registration is REQUIRED to use a Tier-3 character (a `artemis-31b-v1i`) — registration is REQUIRED to use a Tier-3 character (a
@@ -78,9 +108,10 @@ registration.
`~/.config/ratatoskr/provider.env` (mode 600, nh3-dev) — `consumer="ratatoskr"`, `~/.config/ratatoskr/provider.env` (mode 600, nh3-dev) — `consumer="ratatoskr"`,
HS256 = the API-key STRING utf-8-encoded; rotate via infra-ops. HS256 = the API-key STRING utf-8-encoded; rotate via infra-ops.
**Unpushed:** `main` is 3 commits ahead of origin (origin at `d90a58d`/v0.17.1; **Committed (2026-06-16, NOT yet pushed):** the observe brick (logger, patch
local at `1f94e5f`) — `bcdcd71` (v0.17.2), `eebab46`, `1f94e5f` + the local bump), `docs/bifrost-self-test.md`, the #17 contract, + this snapshot. Push is
`v0.17.2` tag are unpushed. Push is the operator's call. 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 **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 conversation-API/TUI surface (untouched by the Bifrost work). Codex-first pilot
@@ -142,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]` **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-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._ _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 ## Tried and abandoned
@@ -170,3 +207,13 @@ defense against re-attempting the same cul-de-sac.
- `[2026-06-15]` **"Sindra hasn't been registered" was an under-verified inference — WRONG.** Concluded it from grepping ratatoskr's CODE (`sindra` absent from `src/`), but Tier-3 registration is SERVER-SIDE (`POST /agents/define` on the Worldtree instance) — a code grep structurally can't see it. Registration IS required to use a Tier-3 character (a session against an unregistered `agent_id` 404s), so since Sindra has been used, she WAS registered (`ratatoskr:sindra`). **Rule: to check whether a Tier-3 agent exists, query the Worldtree instance's `GET /agents`, never the consumer repo's code.** (Residual: the v0.35.2 personal rebuild may have wiped her — re-verify.) - `[2026-06-15]` **"Sindra hasn't been registered" was an under-verified inference — WRONG.** Concluded it from grepping ratatoskr's CODE (`sindra` absent from `src/`), but Tier-3 registration is SERVER-SIDE (`POST /agents/define` on the Worldtree instance) — a code grep structurally can't see it. Registration IS required to use a Tier-3 character (a session against an unregistered `agent_id` 404s), so since Sindra has been used, she WAS registered (`ratatoskr:sindra`). **Rule: to check whether a Tier-3 agent exists, query the Worldtree instance's `GET /agents`, never the consumer repo's code.** (Residual: the v0.35.2 personal rebuild may have wiped her — re-verify.)
- `[2026-06-14]` **Artifact-only contract review can't validate against a dependency's ACTUAL behavior.** `/heid-contract-review` sees only the contract, never the external library (bifrost) — so "the consumer under-built against bifrost's real semantics" is invisible to it by construction (the affect idempotency model shipped wrong because of this). Real-lib TDD against the shipped library + the executable reference store + the #195 parity test are the gate for any consumer plane with non-trivial state semantics. Don't treat a clean contract review as evidence the code matches the dependency. - `[2026-06-14]` **Artifact-only contract review can't validate against a dependency's ACTUAL behavior.** `/heid-contract-review` sees only the contract, never the external library (bifrost) — so "the consumer under-built against bifrost's real semantics" is invisible to it by construction (the affect idempotency model shipped wrong because of this). Real-lib TDD against the shipped library + the executable reference store + the #195 parity test are the gate for any consumer plane with non-trivial state semantics. Don't treat a clean contract review as evidence the code matches the dependency.
- `[2026-06-15]` **"byte-equal" round-trip slip propagated affect→memory via copy-paste.** The affect contract's byte-identical→semantic fix reappeared in the memory contract's INV-001 (sibling copy). Only an INDEPENDENT `/heid-contract-review` of the memory contract re-caught it. **Paraphrase every sibling contract fresh — don't amortize one review across a family; copies carry the parent's slips.** (also a feedback auto-memory) - `[2026-06-15]` **"byte-equal" round-trip slip propagated affect→memory via copy-paste.** The affect contract's byte-identical→semantic fix reappeared in the memory contract's INV-001 (sibling copy). Only an INDEPENDENT `/heid-contract-review` of the memory contract re-caught it. **Paraphrase every sibling contract fresh — don't amortize one review across a family; copies carry the parent's slips.** (also a feedback auto-memory)
- `[2026-06-15]` **Canonical sync retired the issue-scoped parser staleness** (predicted by the 2026-05-21 entry's "until canonical bumps"). `contract_parser.py` synced to v2.1 (`f1fdfdb6→e10a4460`, commit `d85ab43`): now validates issue-scoped frontmatter (`target_module`/`scope`/`prd`) + four v2.1 test categories (scenario/trace/adversarial/property). Issues #3/#4 went FAIL→WARN (0 errors). The old "treat parser ERROR-on-issue-scoped as expected" note no longer applies.
- `[2026-06-15]` **Refreshed #3/#4 presenter contracts to the shipped TUI model** (commit `335c835`). Both still described the abandoned single-`RichLog` double-display model; rewrote to the 4-pane live-Markdown reality (v0.5.0v0.14.0 + Worldtree #201/#204) across INV-005, the `[performance]` constraint, the COMPOSE sketch, the `CLASS TuiPresenterState` block, both `render`/`_stream_turn_worker` blocks, and the `_cancel_via_sse` call site — plus the STEPS the v2.1 parser flagged missing. Code unchanged; contract-truth catching up to shipped code. Scope ballooned one-block→contract-wide mid-task; surfaced to operator before rewriting the INV-005 trade-off invariant.
- `[2026-06-15]` **Memory plane TDD'd + shipped** (commit `cd12951`, v0.17.3). Impl decisions worth keeping: vec0 `distance_metric=cosine` set at table creation (`score = 1 distance`); `search` over-fetches ALL candidates by cosine then scope-filters in Python so `top_k` counts IN-SCOPE hits (INV-005, contract STEP 2 `indicative`); idempotency_id = reference 4-tuple `("default",verb,_ctx_actor(ctx),key)` pipe-joined as the SQLite PK, digest = sha256 canonical-JSON; `_ctx_actor` = `job_id|jwt_sub|session_id` (memory reference's 3-level, vs affect's 2-level). **heid-code-review panel returned zero true drift**; adopted 5 cheap contract-anchored fixups (scope_filter dict guard, `top_k≤0→[]`, stronger scope-isolation / delete-hit-search / handshake-POST tests), accepted 6 with reasoning. **Partial-map optimistic-lock semantics pinned to the reference via an `expected_revisions` parity test** — resolved a Hulda finding deterministically (the affect-plane lesson: TDD against the shipped lib is the gate, not judgment).
- `[2026-06-15]` **Memory provider LIVE-PROVEN against personal v0.35.3 (persist + dispatch + search-correctness); recall-injection is upstream.** worldtree-dev's Tier-3 promotion recipe (via infra-ops): memory-call fires from Tier-3 PROMOTION, gated at `service.py:2623` on `ctx.kind=="consumer_defined"` AND `ctx.memory_config is not None` (the agent must be DEFINED WITH a `memory` block — `ValidatedMemoryConfig {tier3_dreaming:false}`, dim 1024) AND handshake-granted memory caps AND `embedding_dim==1024`. `memory.agent_self_enabled` is NOT the gate (only the #248 self-candidate branch). Binding = `POST /sessions BifrostBindingRequest{endpoint_url}`, handshake `caps=["affect","memory"]`, **`binding.scope` null** (per-op scopes auto-minted: upsert_many→`memory:write`, search→`memory:read`). A `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist gates the endpoint (Worldtree-side config — infra-ops added `:8391`). HTTP + HS256 both work in dev. (smoke wiring thread `01KV7D82MJYB…`)
- `[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.
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.3" version = "0.17.5"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -30,7 +30,7 @@ web = [
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md. # from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
provider = [ provider = [
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack "ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
"bifrost>=0.6.1", # consumer engines + library (0.6.0 yanked: circular import) "bifrost>=0.7.0", # consumer engines + library (0.7.0/wire-v0.5: agent_self canonical in the scope lattice; 0.6.0 yanked: circular import)
"jsonschema>=4", # bifrost runtime dep — envelope validation "jsonschema>=4", # bifrost runtime dep — envelope validation
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table) "sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
] ]
+50 -3
View File
@@ -14,7 +14,9 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging
import sqlite3 import sqlite3
import sys
import time import time
from typing import Any from typing import Any
@@ -23,6 +25,7 @@ from bifrost.consumer import ConsumerRegistration, build_memory_app
from bifrost.memory import ( from bifrost.memory import (
IdempotencyConflict, IdempotencyConflict,
InvalidArguments, InvalidArguments,
InvalidFilter,
RevisionMismatch, RevisionMismatch,
StoreCapabilities, StoreCapabilities,
) )
@@ -31,6 +34,22 @@ from bifrost.reference_server import JwtVerifier
_SHORT_RETRY_TTL_SECONDS = 300 _SHORT_RETRY_TTL_SECONDS = 300
_DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60 _DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60
# bifrost wire v0.5 scope lattice: three subject axes + agent_self (the #248
# agent-identity axis, made canonical in #10). An axis outside it is InvalidFilter
# (-> memory.invalid_filter 400), matching bifrost's reference _validate_scope_filter.
_SCOPE_LATTICE = {"end_user", "group", "tenant", "agent_self"}
# Inbound memory-call observability (#17 observe brick). A self-contained
# stdout handler so the lines reliably reach the provider's stdout regardless
# of uvicorn's logging config. INFO-level, no propagation to root.
_log = logging.getLogger("ratatoskr.provider.memory")
if not _log.handlers:
_h = logging.StreamHandler(sys.stdout)
_h.setFormatter(logging.Formatter("%(asctime)s [memory-provider] %(message)s"))
_log.addHandler(_h)
_log.setLevel(logging.INFO)
_log.propagate = False
def _ctx_actor(ctx: Any) -> str: def _ctx_actor(ctx: Any) -> str:
"""Reference `_ctx_actor`: actor = job_id | jwt_sub | session_id (never the record).""" """Reference `_ctx_actor`: actor = job_id | jwt_sub | session_id (never the record)."""
@@ -113,6 +132,11 @@ class RatatoskrMemoryStore:
) -> dict: ) -> dict:
if not (isinstance(idempotency_key, str) and idempotency_key): # PRE-001 if not (isinstance(idempotency_key, str) and idempotency_key): # PRE-001
raise InvalidArguments("idempotency_key required") raise InvalidArguments("idempotency_key required")
_log.info(
"memory-call upsert_many REQUEST: %d record(s) idempotency_key=%s actor=%s scopes=%s",
len(records), idempotency_key, _ctx_actor(ctx),
[r.get("scope") for r in records],
)
# INV-002: idempotency_id = ("default", verb, actor-from-ctx, key); digest over payload. # INV-002: idempotency_id = ("default", verb, actor-from-ctx, key); digest over payload.
digest = _payload_digest({"records": records, "expected_revisions": expected_revisions}) digest = _payload_digest({"records": records, "expected_revisions": expected_revisions})
idempotency_id = "|".join(("default", "upsert_many", _ctx_actor(ctx), idempotency_key)) idempotency_id = "|".join(("default", "upsert_many", _ctx_actor(ctx), idempotency_key))
@@ -187,11 +211,33 @@ class RatatoskrMemoryStore:
raise InvalidArguments("metadata_filter is unsupported in v1") raise InvalidArguments("metadata_filter is unsupported in v1")
if scope_filter is not None and not isinstance(scope_filter, dict): # STEP 1 if scope_filter is not None and not isinstance(scope_filter, dict): # STEP 1
raise InvalidArguments("scope_filter must be a flat {axis: value} dict") raise InvalidArguments("scope_filter must be a flat {axis: value} dict")
if scope_filter and any(axis not in _SCOPE_LATTICE for axis in scope_filter):
raise InvalidFilter("scope_filter contains unsupported axis")
_log.info(
"memory-call search REQUEST: scope_filter=%r top_k=%s metadata_filter=%r vec_dim=%d",
scope_filter, top_k, metadata_filter, len(vector),
)
def _emit(rs: list[dict]) -> list[dict]:
_log.info(
"memory-call search RESPONSE: %d hit(s) %s",
len(rs),
[
{
"chunk_id": r["chunk_id"],
"score": round(r["score"], 4),
"scope": r["chunk"].get("scope"),
}
for r in rs
],
)
return rs
if top_k <= 0: # POST-001: at most top_k if top_k <= 0: # POST-001: at most top_k
return [] return _emit([])
total = self._conn.execute("SELECT COUNT(*) FROM memory_vec").fetchone()[0] total = self._conn.execute("SELECT COUNT(*) FROM memory_vec").fetchone()[0]
if total == 0: if total == 0:
return [] return _emit([])
# Over-fetch every candidate ranked by cosine distance, then scope-filter and # Over-fetch every candidate ranked by cosine distance, then scope-filter and
# take top_k — so top_k counts IN-SCOPE hits (INV-005), not pre-filter hits. # take top_k — so top_k counts IN-SCOPE hits (INV-005), not pre-filter hits.
rows = self._conn.execute( rows = self._conn.execute(
@@ -216,7 +262,7 @@ class RatatoskrMemoryStore:
) )
if len(results) >= top_k: if len(results) >= top_k:
break break
return results return _emit(results)
async def get(self, chunk_id: str) -> dict | None: async def get(self, chunk_id: str) -> dict | None:
# INV-001: verbatim round-trip + an attached revision key, or None. # INV-001: verbatim round-trip + an attached revision key, or None.
@@ -241,6 +287,7 @@ class RatatoskrMemoryStore:
async def delete_many(self, ids: list[str]) -> dict: async def delete_many(self, ids: list[str]) -> dict:
# One transaction: chunk row + its vec row leave together (no orphan vec rows). # One transaction: chunk row + its vec row leave together (no orphan vec rows).
_log.info("memory-call delete_many REQUEST: ids=%s", ids)
deleted = 0 deleted = 0
with self._conn: with self._conn:
for chunk_id in ids: for chunk_id in ids:
+28 -1
View File
@@ -10,7 +10,12 @@ from __future__ import annotations
import types import types
import pytest import pytest
from bifrost.memory import IdempotencyConflict, InvalidArguments, RevisionMismatch from bifrost.memory import (
IdempotencyConflict,
InvalidArguments,
InvalidFilter,
RevisionMismatch,
)
from ratatoskr.provider.memory_store import ( from ratatoskr.provider.memory_store import (
build_memory_provider_app, build_memory_provider_app,
@@ -221,6 +226,28 @@ async def test_search_non_dict_scope_filter_rejected():
await store.search(_vec(1.0), top_k=5, scope_filter="u1") await store.search(_vec(1.0), top_k=5, scope_filter="u1")
async def test_search_out_of_lattice_scope_axis_rejected():
# v0.5 scope lattice = {end_user, group, tenant, agent_self}; an axis outside
# it is InvalidFilter (-> memory.invalid_filter 400), matching bifrost's reference.
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
with pytest.raises(InvalidFilter):
await store.search(_vec(1.0), top_k=5, scope_filter={"bogus_axis": "x"})
async def test_search_agent_self_axis_accepted():
# agent_self became canonical at wire v0.5 (#10) — admitted, not rejected.
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
await store.upsert_many(
[_chunk("a1", scope={"agent_self": "ratatoskr:smoke"})],
idempotency_key="k1",
ctx=_ctx(),
)
results = await store.search(
_vec(1.0), top_k=5, scope_filter={"agent_self": "ratatoskr:smoke"}
)
assert [r["chunk_id"] for r in results] == ["a1"]
async def test_search_top_k_zero_returns_empty(): async def test_search_top_k_zero_returns_empty():
# POST-001: at most top_k — zero means zero # POST-001: at most top_k — zero means zero
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM) store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
Generated
+5 -5
View File
@@ -190,14 +190,14 @@ wheels = [
[[package]] [[package]]
name = "bifrost" name = "bifrost"
version = "0.6.1" version = "0.7.0"
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" } source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
dependencies = [ dependencies = [
{ name = "jsonschema" }, { name = "jsonschema" },
] ]
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.6.1/bifrost-0.6.1.tar.gz", hash = "sha256:2eaf93c6da91faa6faa80a4c9a8d0c66161f4a7cc31ff041b0ae64daf3c161ea" } sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.7.0/bifrost-0.7.0.tar.gz", hash = "sha256:27d5c2c3052ad48510a4a73973141f2177b9e1f66576ab3d8976d5c4deb5bfac" }
wheels = [ wheels = [
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.6.1/bifrost-0.6.1-py3-none-any.whl", hash = "sha256:ed505d2c08cf4cdd0a84c68ec42f8732b4c1baf7d72befe5eacf75d88381d5ce" }, { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.7.0/bifrost-0.7.0-py3-none-any.whl", hash = "sha256:9704c50430f350f6dde5428f79a8719e044021ce717a5eac60a13424b1ee4623" },
] ]
[[package]] [[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.3" version = "0.17.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -1086,7 +1086,7 @@ web = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.6.1", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }, { name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.7.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "httpx-sse", specifier = ">=0.4" }, { name = "httpx-sse", specifier = ">=0.4" },
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" }, { name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },