Compare commits

...

4 Commits

Author SHA1 Message Date
vh 96d61a4bb1 feat(provider): split memory search scope_filter → scope_all + scope_any (bifrost 0.8.0/wire v0.6)
Repin bifrost 0.7.0→0.8.0 and reimplement the memory store's search scope
filter to the v0.6 split (#11): scope_all (AND/intersection) + scope_any
(OR/union over a list of conjunctive scopes), at parity with the v0.6
reference _matches_scope / _validate_scope. No-compat: scope_filter removed.

scope_any is the union-visibility primitive that resolves the #295/#297
silent-zero AND foot-gun — a subset-scoped chunk now recalls via an OR
member. End-to-end cold recall now gated only on Worldtree emitting
scope_any on its recall path (#297, upstream).

- store: search(scope_all, scope_any); _scope_subset + _matches_scope + _validate_scope
- contract v1.2: search FN sig, INV-005 recomposed, PRE-003 both fields, scope_any_union test
- tests: scope_any union, scope_all∧scope_any compose, both-empty match-all; parity vs real 0.8.0 dispatch (433 green)
- #17 contract: sync stale scope_filter/_scope_matches-AND refs to scope_all/scope_any
- runbook + persistent-memory updated; provider bounced onto 0.8.0 (fresh empty db)

v0.17.6
2026-06-16 23:09:31 -07:00
vh 43f2e148ad memory: snapshot — observe brick + self-drive proven; #295 root-caused (upstream); agent_self canonical shipped both sides + 4-axis parity (v0.17.5); #17 contract reviewed, TDD next 2026-06-16 18:26:36 -07:00
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
8 changed files with 758 additions and 60 deletions
+173
View File
@@ -0,0 +1,173 @@
# 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_all={...} scope_any=[...] 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_all`/`scope_any` vs the `search RESPONSE` hit count is
the whole diagnosis surface:
- **0 hits** → the composed v0.6 filter matched no stored chunk. `scope_all` axes
are AND-matched — an extra axis the chunks don't carry (e.g. `agent_self`) zeroes
the result even when `end_user` matches. `scope_any` is the OR/union escape hatch:
a subset-scoped chunk recalls if its scope ⊇ **any one** element. So 0 hits with a
populated store now means Worldtree sent an over-specified `scope_all` instead of a
`scope_any` union — a **scope-build** question (Worldtree-side, post-v0.6).
- **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..."
```
(That capture is the **pre-v0.6 wire** — a single AND-only `scope_filter`.)
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.
**Resolution (bifrost 0.8.0 / wire v0.6, #11):** the single `scope_filter` is split
into `scope_all` (AND) + `scope_any` (OR/union). Worldtree can now send the visible
scopes as a `scope_any` union (e.g. `[{end_user: smoke-user}, {end_user: smoke-user,
agent_self: ...}]`), so the subset-scoped chunk recalls via the matching OR member.
Our store implements this at parity with the v0.6 reference; **end-to-end cold recall
now waits only on Worldtree emitting `scope_any`** on the recall path (#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.
@@ -22,6 +22,16 @@ external_invariants:
- source: ~/development/bifrost/docs/implementing-a-consumer.md
invariant_id: "§5 memory plane"
revisions:
- version: "1.2"
at: 2026-06-16
summary: "Repin bifrost 0.7.0→0.8.0 (wire v0.5→v0.6, #11): search `scope_filter` split into `scope_all` (AND/intersection) + `scope_any` (OR/union over a list of conjunctive scopes). No-compat: `scope_filter` removed. Adds union-visibility recall in one call — the fix for the #295/#297 AND silent-zero foot-gun. Store at parity with the v0.6 reference `_matches_scope` / `_validate_scope`."
delta:
MODIFIED:
- "search signature: scope_filter -> scope_all + scope_any"
- "INV-005 scope isolation -> composed v0.6 (scope_all AND ∧ scope_any OR-union)"
- "PRE-003 validates axes in BOTH fields; STEP 1 = _validate_scope (shape + lattice)"
ADDED:
- "scope_any_union test (#295/#297 union capability); both-fields-empty match-all"
- version: "1.1"
at: 2026-06-15
summary: "Heid-contract-review fixup: semantic-not-byte-equal round-trip; reconcile idempotency 4-tuple; search returns top_k IN-SCOPE; define recalled_view + scope_filter + named field keys inline; clarify metadata_filter-v1 + transaction-term + delete atomicity + get_many + revision-on-replay; drop scan from INV-005."
@@ -98,9 +108,13 @@ interpreted.
- **INV-004** [hard]: **Atomic batch.** `upsert_many` applies all records + their
vec rows + the idempotency record in one transaction; on any error nothing is
persisted (no partial batch, no orphaned vec rows).
- **INV-005** [hard]: **Scope isolation.** `search` results are filtered to records
whose `record["scope"]` matches every axis in `scope_filter`; a search never
returns another scope's chunk.
- **INV-005** [hard]: **Scope isolation (wire v0.6, #11).** `search` filters by two
explicit fields: `scope_all` (AND/intersection — record ⊇ every named axis) and
`scope_any` (OR/union over a LIST of conjunctive scope dicts — record ⊇ ≥1 element,
each element AND-matched as a whole). They compose by AND; both empty → no scope
constraint. A search never returns a chunk outside the composed filter. Byte-faithful
to the reference `_matches_scope`. (`scope_any` is the union-visibility primitive that
resolves the #295/#297 silent-zero — a subset-scoped chunk now recalls via an OR member.)
- **INV-006** [hard]: **Capabilities match implementation** (advertise-⇒-implement).
`describe_store` advertises ONLY what v1 implements: `relational_edges_supported=False`,
`atomic_supersede_supported=False`, `transaction_supported=False`,
@@ -205,20 +219,23 @@ TESTS:
```
```contract
FN search(self, vector: list[float], *, top_k: int, scope_filter: dict | None = None, metadata_filter: dict | None = None, include: dict | None = None, fidelity_target=None) -> list[dict]
BRIEF: Vector (cosine) recall over sqlite-vec, scoped, returning the top_k IN-SCOPE chunks.
FN search(self, vector: list[float], *, top_k: int, scope_all: dict | None = None, scope_any: list | None = None, metadata_filter: dict | None = None, include: dict | None = None, fidelity_target=None) -> list[dict]
BRIEF: Vector (cosine) recall over sqlite-vec, scoped by the v0.6 scope_all/scope_any filter, returning the top_k IN-SCOPE chunks.
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
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
PRE: [PRE-003 hard] scope_all is a flat dict and scope_any a list of flat dicts (else InvalidArguments); every axis in BOTH ∈ {end_user, group, tenant, agent_self} -- else InvalidFilter (memory.invalid_filter 400); the bifrost wire-v0.6 lattice, matching the reference _validate_scope (#10 agent_self canonical, #11 scope split)
POST: [POST-001 return_value] returns the top_k highest-cosine records passing the composed v0.6 filter — `(scope_all empty OR record ⊇ scope_all) AND (scope_any empty OR record ⊇ ≥1 element)`; at most top_k, 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:
1. [setup] validate scope_filter is a flat {axis: value} dict (matched against record["scope"][axis])
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)
1. [setup] scope_all ← scope_all or {}; scope_any ← scope_any or []; validate via _validate_scope (flat-dict / list-of-dicts shape + every axis ∈ the v0.6 lattice, else InvalidArguments / InvalidFilter)
2. [sequential, flexibility=indicative] rank candidates by cosine over record["embedding"]; KEEP only records passing _matches_scope(scope_all, scope_any) (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)
TESTS:
basic_search [happy,tracer]: upsert 3 scoped chunks, search → ranked by cosine, ≤ top_k, recalled_view present
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 → []
scope_isolation [adversarial]: two scopes, scope_all 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_any_union [scenario]: scope_any=[{end_user:u},{agent_self:a}] recalls BOTH a subject-scoped and a self-scoped chunk in one call (#295/#297 union capability); scope_all+scope_any compose by AND
empty [boundary]: search empty store → []; both fields empty → match all
metadata_filter_rejected [adversarial]: non-empty metadata_filter → InvalidArguments
lattice_axes [adversarial]: out-of-lattice axis in scope_all OR scope_any → InvalidFilter; non-list scope_any → InvalidArguments; agent_self admitted (wire v0.5, #10)
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. `_matches_scope` stays the v0.6 composed filter — `scope_all` (AND/intersection) ∧ `scope_any` (OR/union over conjunctive scopes) — byte-faithful to bifrost reference `reference_server/memory.py` (wire v0.6, #11); the 4-axis lattice validation (`_validate_scope`) is in place and at parity. Scope semantics are settled (the v0.6 scope split shipped, bifrost 0.8.0) and OUT OF SCOPE for #17 — observe is read-only over them."
- "The existing `create_session(client, agent_id, *, end_user_id=None)` (sessions.py:179) is extended, not replaced (pre-v1, no compat shim). The httpx client carries the canary key as its default Authorization; the bound create overrides the bearer per-request with the consumer key."
- "Tests use `respx` for the bind unit tests (mirroring tests/test_sessions.py) + the in-process op-feed; the live-smoke acceptance is manual (per the repo's load-bearing-smoke posture), captured as a documented runbook step, not a unit test. `docs/bifrost-self-test.md` is the manual procedure this feature productizes."
- "v1 ships the CLI + TUI + web trigger surfaces in lockstep (the repo's BOTH-presenters-in-lockstep rule); the op-feed is read by the debug surface as structured lines for now (pane-correlated rendering is PARKED)."
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 `_matches_scope`, the v0.6
`scope_all`/`scope_any` semantics, or any store behavior. The op-feed reports the
effective scope used per op; it never rewrites scope client-side.
- **INV-005 (correlation key at the dispatch layer).** session_id is captured from
the JWT ctx at the dispatch/ASGI layer — present for ALL JWT-carrying verbs,
INCLUDING search/get/delete (bifrost withholds ctx from those STORE methods, but
the dispatch layer still verifies + reads the JWT). `session_id=None` ONLY if the
JWT genuinely omits it (a claims-dump open question, not the store-method gap).
`OpEvent` carries a literal `turn_id: str | None = None` field — the reservation
is a real field defaulted to None in v1, not a future schema change.
- **INV-006 (data hygiene).** Bound debug sessions write to DURABLE stores. The
smoke procedure uses an explicit test scope (`end_user:smoke-user`) and a
documented cleanup path; the contract's acceptance asserts the fixture
before/after so a debug run's promotions are visible, never silent. (Promotion
of a turn is expected behavior, not a bug — but it must be observable.)
- **INV-007 (observe captures failures + late ops).** The op-feed records non-2xx
/ error ops (status="error"), never hides or double-counts them. The `OpSink` is
a CONTINUOUS append-only feed — NOT per-session-scoped, no per-session teardown in
v1; late ops that land AFTER the SSE turn-end simply append with their timestamp,
so a consumer can apply a post-turn grace window (the PARKED pane-UI's concern; v1
just must not drop late ops). A sink write that FAILS is swallowed from the
dispatch path (instrument_provider_app POST-003) BUT logged to stderr — an observe
gap is never silent.
- **INV-008 (both presenters in lockstep).** The bind trigger + bound-state
indicator land in CLI, TUI, and web together (the repo's add-to-all-presenters
rule). Web creates the bound session SERVER-SIDE; the consumer key never reaches
the browser.
- **INV-009 (secret hygiene).** Both keys are redacted in all UI/logs. The
consumer key is PRIVILEGED (it is the handshake identity), not equivalent to
read-only conversation access; config that stores it uses no weaker file
permissions than the canary key (cf. provider.env mode 600).
## Data flow
BIND (client → Worldtree): CLI/TUI/web resolve `plane` + the consumer key →
`endpoint_for_plane(plane, worldtree_visible_host)``BifrostBinding`
`create_session(..., bifrost=binding, consumer_key=...)` → POST /sessions with
bearer = consumer key → Worldtree handshakes synchronously to the provider
endpoint → 201 (bound) or 502 (`BifrostHandshakeFailed`).
OBSERVE (Worldtree → provider → feed): a bound turn makes Worldtree dispatch
affect/memory bifrost-calls to the provider → `instrument_provider_app` wraps the
dispatch layer → one `OpEvent` per call (session_id from JWT ctx when present) →
`OpSink` (JSONL) → the debug surface reads structured lines. The store path is
untouched (INV-004); observe is read-only over dispatch.
## Function contracts
```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_all/scope_any/top_k for search; record-count+scopes for upsert) + resp (hit-count+ids/scores | upserted+replayed | error code) — NEVER verbatim content
4. emit to sink; swallow sink errors
```
## ERROR_ROUTING
| Wire | Exception | Surfaced |
|---|---|---|
| 502 `bifrost_handshake_failed` | `BifrostHandshakeFailed(bifrost_error)` | create path, names the bifrost_error; TUI pre-alt-screen |
| (pre-HTTP) bifrost w/o consumer_key | `BifrostConsumerKeyMissing` | config/CLI validation, before any request |
| 422 `ephemeral_does_not_accept_bifrost` | `SessionApiFailed` | create path |
| 401 at provider handshake (manifests as 502 to client) | `BifrostHandshakeFailed('bifrost.auth_rejected')` | error text: "bound create requires the consumer key, not WORLDTREE_API_KEY" |
## Acceptance — the ordered live-smoke gate (load-bearing)
The repo's smoke-is-load-bearing posture: this gate IS acceptance, run manually
against personal Worldtree, mirroring `docs/bifrost-self-test.md`.
```
1. providers up → memory :8391 serving + op-feed sink attached (affect :8390 is symmetric, OPTIONAL for the memory-plane gate)
2. consumer key set → RATATOSKR_BIFROST_CONSUMER_KEY present; canary key separate
3. allowlist OK → endpoint_for_plane host on Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS
4. bind 201 → ratatoskr --bifrost-plane memory → bound session, handshake 200
5. negative: canary → binding with the canary key → BifrostHandshakeFailed, the auth_rejected message names the consumer-key mismatch
6. turn → one turn into the bound session
7. assert op-feed → OpEvent captured with session_id == the BOUND session's id (not merely "some session_id"); {plane, op, req/resp summary, status}
8. assert hygiene → fixture before/after asserted (per docs/bifrost-self-test.md); promotions visible not silent
```
Unit tests (respx) cover: bind body shape, consumer-key override, the
missing-key precondition, 502→BifrostHandshakeFailed mapping, 422 ephemeral, and
the op-feed emitting one OpEvent per dispatched call incl. error + late op.
## v1 clarifications (paraphrase-gate fixups)
Pinned in response to the `/heid-contract-review` panel — closing under-specs that
let an implementer comply while violating intent.
- **Op-feed summary shapes (per verb).** `req_summary` / `resp_summary` are
scope-only, never verbatim content:
- `search` → req `{scope_all, scope_any, top_k}`; resp `{hit_count, [{chunk_id, score}]}`
- `upsert_many` → req `{record_count, [scope]}`; resp `{upserted, replayed}`
- `get` / `get_many` → req `{ids}`; resp `{found_count}`
- `delete_many` → req `{ids}`; resp `{deleted}`
- `emit` (affect) → req `{actor-scope}`; resp `{status}` (affect stays conduit-opaque)
- `handshake` → req `{caps_requested}`; resp `{caps_granted, ok}`
- **`BifrostBinding.scope` is an opaque pass-through** (Worldtree spec: ≤256 chars,
copied into the JWT payload unchanged). ratatoskr does NOT interpret it; v1 sends
`null`. A non-null value is operator-supplied and meaningful only to Worldtree.
- **Web bind split (INV-008 sharpened).** The web UI selects the PLANE; the consumer
key is SERVER-HELD (env/config), never sent from the browser; the server
constructs the bound session. The browser never sees the consumer key.
- **Bound-state indicator** shows at least `plane + endpoint + bound|failed status`,
not a bare boolean (so the operator can see WHICH identity/endpoint bound).
- **401-handshake message scoping.** The "use the consumer key, not WORLDTREE_API_KEY"
text is keyed on `bifrost_error == "bifrost.auth_rejected"`; other 502 handshake
failures surface the generic `BifrostHandshakeFailed` with their own `bifrost_error`.
- **`endpoint_for_plane` is the DEV helper** (returns `http://`, allowlist-relaxed).
A production HTTPS endpoint is supplied directly via `--bifrost-url`, bypassing the
plane shortcut — HTTPS is not constructed by `endpoint_for_plane`.
- **422 `ephemeral_does_not_accept_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 v0.6 scope split — RESOLVED (shipped: bifrost 0.8.0/wire v0.6, `_validate_scope` 4-axis + `scope_all`/`scope_any`); no longer a #17 concern.
+86 -22
View File
@@ -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,13 +41,29 @@ 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:_
**LATEST (2026-06-16 PM) — BIFROST REPINNED 0.7.0→0.8.0 (wire v0.5→v0.6).** The
memory `search` scope filter was split into `scope_all` (AND/intersection) +
`scope_any` (OR/union over a LIST of conjunctive scopes) — bifrost #11, the canonical
fix for the #295/#297 silent-zero AND foot-gun. Our store + contract (v1.2) + tests
reimplemented to parity with the v0.6 reference `_matches_scope`/`_validate_scope`
(no-compat: `scope_filter` REMOVED). 433 tests green incl. the new `scope_any` union
test + the parity-vs-reference test through the real 0.8.0 `dispatch_memory_call`.
Memory provider BOUNCED onto 0.8.0 (`:8391`, fresh empty `memory.db` — the prior
5-chunk #296 corpus was WIPED, operator confirmed "nothing of value", SUPERSEDES the
"KEEP PINNED" note below). **End-to-end cold recall now waits only on Worldtree
EMITTING `scope_any` on its recall path (#297, upstream).** Affect plane untouched
(split is memory-only); affect provider still on its 0.7.0-loaded process (bounce
optional — affect wire unchanged at 0.8.0). NOT yet committed; patch bump v0.17.6
pending operator commit approval. **#17 contract carries stale `scope_filter` /
`_scope_matches`-AND references — fix when #17 TDD starts.**
**Ratatoskr now has a SECOND identity: the v1 Bifrost Tier-3 consumer** — the
durable persistence provider Worldtree writes Tier-3 agent affect/persona +
memory into — alongside the original debug-observability TUI/web. The
Bifrost-consumer work lives in `src/ratatoskr/provider/` and depends on
`bifrost>=0.6.1` (a `provider` optional-extra from the gitea PyPI index),
`bifrost>=0.7.0` (a `provider` optional-extra from the gitea PyPI index),
SEPARATE from the Worldtree conversation-API spec pin.
**AFFECT plane: SHIPPED + LIVE-PROVEN** (v0.17.2). Running now as a dev
@@ -56,21 +72,53 @@ 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 5 durable chunks (all scope `{end_user:smoke-user}`): choc-fact
`498ed752`, name `8241e569`, promoted-question `c863bb6b`, + 2 LATE async promotions
from the cold-recall probe (probe-question `acc3d49`, model NON-ANSWER `4773704`
salience promoted a "I don't have memory" refusal). All are #296 corpus, KEEP PINNED.
**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 → research issue **#296** (keyword-regex
salience suspected fundamentally flawed; corpus = `c863bb6b` + the 2 late-promotions).
F1 → research issue **#297** (Worldtree-local fix = per-visible-scope single-axis
search unioned client-side). **The agent_self lattice question is RESOLVED:**
agent_self is now canonical in bifrost 0.7.0 / wire v0.5 (our foot-gun flag drove it;
worldtree-dev shipped it both sides — Worldtree v0.35.11) → #297 union build UNBLOCKED.
Our memory provider now runs **bifrost 0.7.0 + validates the 4-axis lattice**
`{end_user,group,tenant,agent_self}` (v0.17.5; restarted on it; out-of-lattice axis →
InvalidFilter, reference-parity restored).
**OBSERVE BRICK SHIPPED** (`memory_store.py`, committed v0.17.4 `2fef6e3`):
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. (NOTE: this is the debug SHIM at the STORE method; #17's
real observe feed instruments the DISPATCH layer — see the contract INV-005.)
**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 slice 1 = the `create_session` bind primitive** (BifrostBinding
dataclass + consumer-key per-request bearer override + missing-key precondition +
502→BifrostHandshakeFailed; respx-mocked), then endpoint_for_plane → dispatch-layer
op-feed → CLI/TUI/web → live smoke.
**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 +134,11 @@ 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):** observe brick (logger, v0.17.4 `2fef6e3`),
`docs/bifrost-self-test.md` + #17 contract (`ca02c70`), 4-axis-validation parity
(v0.17.5, tag `v0.17.5`), + this snapshot. Tags `v0.17.4`/`v0.17.5`. 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 +200,17 @@ 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.
- `[2026-06-16]` **agent_self lattice SHIPPED both sides → our axis-validation gap CLOSED (v0.17.5).** bifrost 0.7.0 / wire v0.5 adds agent_self to the scope lattice `{end_user,group,tenant,agent_self}` (#10, driven by our foot-gun flag via bifrost-dev); Worldtree pinned 0.7.0 + canonical-synced the v0.5 spec (v0.35.11, `c860fb0`). **SUPERSEDES the prior "HELD, do NOT add axis-validation" note** — we DID add `_validate_scope_filter` (4-axis) to match the reference (bifrost-dev's recommendation, purely additive; out-of-lattice → InvalidFilter). #297 union build unblocked. Memory provider restarted on 0.7.0.
- `[2026-06-16]` **#17 contract reviewed + the debug-assist arc fully closed.** `/heid` design consult + `/heid-contract-review` panel both run on `docs/contracts/issues/17.contract.md` (validates OK). The #295 debug-assist that opened the session is closed end-to-end: root cause (scope-axis asymmetry) → #296/#297 research issues + corpus → a shipped bifrost protocol change (agent_self canonical) → our store at parity. NEXT durable step: #17 TDD (tracked: Gitea #17 + the contract).
- `[2026-06-16]` **Repinned bifrost 0.7.0→0.8.0 + reimplemented memory `search` to the v0.6 scope split (operator-directed).** `scope_filter``scope_all` (AND) + `scope_any` (OR/union over a list of conjunctive scopes), bifrost #11 — the canonical resolution of the #295/#297 silent-zero. **SUPERSEDES the "Provider stores byte-faithful to AND reference / Do NOT flip `_scope_matches` to OR" entry above**: the reference itself now does OR via `scope_any` (a NEW field — `scope_all` keeps the old AND semantics; this is an additive split, not a flip of the AND predicate). Store / contract (v1.2) / tests at parity with the v0.6 reference; provider bounced onto 0.8.0 with a wiped DB (operator: "nothing of value"). Cold recall now gated only on Worldtree emitting `scope_any` (#297). **#17's contract has stale `scope_filter`/`_scope_matches`-AND references (its `assumptions`, INV-004, and the op-feed `search → req {scope_filter}` summary shape) — update those to `scope_all`/`scope_any` when #17 TDD starts; INV-004's intent (observe must not alter scope semantics) still holds.**
_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 +246,7 @@ 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.
- `[2026-06-16]` **"No promotion" was checked TOO EARLY — Tier-3 promotion is ASYNC (lands AFTER the SSE turn-end).** The cold-recall probe's immediate post-turn fixture check showed 3 chunks (no promotion); a later check (during the v0.17.5 provider restart) found 5 — the probe HAD promoted 2 chunks (its question `acc3d49` + the model's non-answer `4773704`), just late. Don't trust an immediate post-turn fixture snapshot to judge promotion; it lands after the turn completes. (Same family as the "wire-200 ≠ recall, prove it in a cold session" lesson, extended to promotion timing — and the reason #17's contract pins a post-turn grace window + fixture before/after assertion.)
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.17.4"
version = "0.17.6"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
@@ -30,7 +30,7 @@ web = [
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
provider = [
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
"bifrost>=0.6.1", # consumer engines + library (0.6.0 yanked: circular import)
"bifrost>=0.8.0", # consumer engines + library (0.8.0/wire-v0.6: scope_filter split into scope_all (AND) + scope_any (OR/union, #11); 0.7.0/v0.5 added agent_self)
"jsonschema>=4", # bifrost runtime dep — envelope validation
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
]
+44 -10
View File
@@ -25,6 +25,7 @@ from bifrost.consumer import ConsumerRegistration, build_memory_app
from bifrost.memory import (
IdempotencyConflict,
InvalidArguments,
InvalidFilter,
RevisionMismatch,
StoreCapabilities,
)
@@ -33,6 +34,11 @@ from bifrost.reference_server import JwtVerifier
_SHORT_RETRY_TTL_SECONDS = 300
_DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60
# bifrost wire v0.6 scope lattice: three subject axes + agent_self (the #248
# agent-identity axis, canonical since #10/v0.5). An axis outside it is InvalidFilter
# (-> memory.invalid_filter 400), matching bifrost's reference _validate_scope.
_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.
@@ -81,11 +87,37 @@ def _record_vector(record: dict) -> list[float]:
return [float(v) for v in value] if isinstance(value, list) else []
def _scope_matches(record_scope: Any, scope_filter: dict) -> bool:
"""INV-005: record is in-scope iff every scope_filter axis matches record["scope"]."""
if not isinstance(record_scope, dict):
def _scope_subset(record_scope: dict, filter_dict: dict) -> bool:
"""True iff record_scope has EVERY axis of filter_dict (AND). Empty filter = match."""
return all(record_scope.get(axis) == value for axis, value in filter_dict.items())
def _matches_scope(record_scope: Any, scope_all: dict, scope_any: list) -> bool:
"""INV-005 (wire v0.6, #11): a record passes iff
`(scope_all empty OR record ⊇ scope_all) AND (scope_any empty OR it matches ≥1 element)`.
scope_any is OR/union over a LIST of conjunctive scope dicts (each AND-matched as a whole),
never single axes flattened together (the Worldtree #297 over-broadening foot-gun).
Byte-faithful to bifrost reference `reference_server/memory.py:_matches_scope`.
"""
scope = record_scope if isinstance(record_scope, dict) else {}
if not _scope_subset(scope, scope_all):
return False
return all(record_scope.get(axis) == value for axis, value in scope_filter.items())
if scope_any and not any(_scope_subset(scope, element) for element in scope_any):
return False
return True
def _validate_scope(scope_all: dict, scope_any: list) -> None:
"""STEP 1: scope_all is a flat dict, scope_any a list of flat dicts; every axis in BOTH
fields ∈ the v0.6 lattice {end_user, group, tenant, agent_self} (else InvalidFilter),
matching the reference `_validate_scope`."""
if not isinstance(scope_all, dict):
raise InvalidArguments("scope_all must be a flat {axis: value} dict")
if not isinstance(scope_any, list) or any(not isinstance(e, dict) for e in scope_any):
raise InvalidArguments("scope_any must be a list of {axis: value} dicts")
for scope in (scope_all, *scope_any):
if any(axis not in _SCOPE_LATTICE for axis in scope):
raise InvalidFilter("scope_filter contains unsupported axis")
def _validate_injection(record: dict) -> None:
@@ -194,7 +226,8 @@ class RatatoskrMemoryStore:
vector: list[float],
*,
top_k: int,
scope_filter: dict | None = None,
scope_all: dict | None = None,
scope_any: list | None = None,
metadata_filter: dict | None = None,
include: dict | None = None,
fidelity_target: Any = None,
@@ -203,11 +236,12 @@ class RatatoskrMemoryStore:
raise InvalidArguments(f"vector length {len(vector)} != embedding_dim {self._dim}")
if metadata_filter: # PRE-002: v1 advertises no filterable metadata fields
raise InvalidArguments("metadata_filter is unsupported in v1")
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")
scope_all = scope_all or {}
scope_any = scope_any or []
_validate_scope(scope_all, scope_any) # STEP 1 (raises InvalidArguments / InvalidFilter)
_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),
"memory-call search REQUEST: scope_all=%r scope_any=%r top_k=%s vec_dim=%d",
scope_all, scope_any, top_k, len(vector),
)
def _emit(rs: list[dict]) -> list[dict]:
@@ -241,7 +275,7 @@ class RatatoskrMemoryStore:
results: list[dict] = []
for chunk_id, distance, record_json, revision in rows:
record = json.loads(record_json)
if scope_filter and not _scope_matches(record.get("scope"), scope_filter):
if not _matches_scope(record.get("scope"), scope_all, scope_any):
continue
results.append(
{
+109 -11
View File
@@ -10,7 +10,12 @@ from __future__ import annotations
import types
import pytest
from bifrost.memory import IdempotencyConflict, InvalidArguments, RevisionMismatch
from bifrost.memory import (
IdempotencyConflict,
InvalidArguments,
InvalidFilter,
RevisionMismatch,
)
from ratatoskr.provider.memory_store import (
build_memory_provider_app,
@@ -171,7 +176,7 @@ async def test_basic_search_ranks_by_cosine_with_recalled_view():
idempotency_key="k1",
ctx=_ctx(),
)
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_filter=scope)
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_all=scope)
assert [r["chunk_id"] for r in results] == ["c1", "c3"] # nearest to [1,0] by cosine
top = results[0]
assert top["chunk"] == c1 # verbatim chunk, no revision attached
@@ -191,7 +196,7 @@ async def test_scope_isolation_excludes_other_scope_even_if_closer():
idempotency_key="k1",
ctx=_ctx(),
)
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_filter={"end_user": "u1"})
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_all={"end_user": "u1"})
assert [r["chunk_id"] for r in results] == ["u1-far"] # u2-near excluded despite ranking first
@@ -214,18 +219,109 @@ async def test_search_wrong_vector_dim_rejected():
await store.search([1.0, 0.0], top_k=5)
async def test_search_non_dict_scope_filter_rejected():
# search STEP 1: scope_filter must be a flat {axis: value} dict
async def test_search_non_dict_scope_all_rejected():
# search STEP 1: scope_all must be a flat {axis: value} dict
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
with pytest.raises(InvalidArguments):
await store.search(_vec(1.0), top_k=5, scope_filter="u1")
await store.search(_vec(1.0), top_k=5, scope_all="u1")
async def test_search_non_list_scope_any_rejected():
# search STEP 1: scope_any must be a LIST of {axis: value} dicts (#11)
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
with pytest.raises(InvalidArguments):
await store.search(_vec(1.0), top_k=5, scope_any={"end_user": "u1"})
async def test_search_out_of_lattice_scope_axis_rejected():
# v0.6 scope lattice = {end_user, group, tenant, agent_self}; an axis outside
# it is InvalidFilter (-> memory.invalid_filter 400) in EITHER field, matching the reference.
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
with pytest.raises(InvalidFilter):
await store.search(_vec(1.0), top_k=5, scope_all={"bogus_axis": "x"})
with pytest.raises(InvalidFilter):
await store.search(_vec(1.0), top_k=5, scope_any=[{"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_all={"agent_self": "ratatoskr:smoke"}
)
assert [r["chunk_id"] for r in results] == ["a1"]
async def test_search_top_k_zero_returns_empty():
# POST-001: at most top_k — zero means zero
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
await store.upsert_many([_chunk("c1")], idempotency_key="k1", ctx=_ctx())
assert await store.search(_vec(1.0), top_k=0, scope_filter={"end_user": "u1"}) == []
assert await store.search(_vec(1.0), top_k=0, scope_all={"end_user": "u1"}) == []
async def test_search_no_scope_matches_all():
# v0.6: both fields empty -> no scope constraint (match all, within top_k).
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
await store.upsert_many(
[
_chunk("u1", scope={"end_user": "u1"}),
_chunk("u2", scope={"end_user": "u2"}),
],
idempotency_key="k1",
ctx=_ctx(),
)
results = await store.search(_vec(1.0), top_k=10)
assert {r["chunk_id"] for r in results} == {"u1", "u2"}
async def test_search_scope_any_unions_across_scopes():
# v0.6 (#11): scope_any is OR/union over a LIST of conjunctive scopes. A {end_user:u1}
# chunk AND an {agent_self:a} chunk are BOTH recalled in ONE call — the capability
# that resolves the #295/#297 silent-zero AND foot-gun (subset-scoped chunks now recall).
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
await store.upsert_many(
[
_chunk("subj", embedding=_vec(1.0, 0.0), scope={"end_user": "u1"}),
_chunk("self", embedding=_vec(0.9, 0.1), scope={"agent_self": "ratatoskr:sindra"}),
_chunk("other", embedding=_vec(0.8, 0.2), scope={"end_user": "u9"}),
],
idempotency_key="k1",
ctx=_ctx(),
)
results = await store.search(
_vec(1.0, 0.0),
top_k=10,
scope_any=[{"end_user": "u1"}, {"agent_self": "ratatoskr:sindra"}],
)
assert {r["chunk_id"] for r in results} == {"subj", "self"} # union; u9 excluded
async def test_search_scope_all_and_scope_any_compose_by_and():
# v0.6: a record passes iff (record ⊇ scope_all) AND (matches ≥1 scope_any element).
store = open_memory_store(":memory:", embedding_dim=EMBEDDING_DIM)
await store.upsert_many(
[
# tenant t1 AND (end_user u1 OR u2) — only these pass
_chunk("t1u1", embedding=_vec(1.0, 0.0), scope={"tenant": "t1", "end_user": "u1"}),
_chunk("t1u2", embedding=_vec(0.9, 0.1), scope={"tenant": "t1", "end_user": "u2"}),
_chunk("t1u9", embedding=_vec(0.8, 0.2), scope={"tenant": "t1", "end_user": "u9"}),
_chunk("t2u1", embedding=_vec(0.7, 0.3), scope={"tenant": "t2", "end_user": "u1"}),
],
idempotency_key="k1",
ctx=_ctx(),
)
results = await store.search(
_vec(1.0, 0.0),
top_k=10,
scope_all={"tenant": "t1"},
scope_any=[{"end_user": "u1"}, {"end_user": "u2"}],
)
assert {r["chunk_id"] for r in results} == {"t1u1", "t1u2"} # t1u9 fails any; t2u1 fails all
async def test_scope_isolation_fills_top_k_from_in_scope_past_higher_out_of_scope():
@@ -242,7 +338,7 @@ async def test_scope_isolation_fills_top_k_from_in_scope_past_higher_out_of_scop
idempotency_key="k1",
ctx=_ctx(),
)
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_filter={"end_user": "u1"})
results = await store.search(_vec(1.0, 0.0), top_k=2, scope_all={"end_user": "u1"})
# exactly top_k in-scope (the 2 nearest u1 chunks); the higher-ranked u2 chunk is excluded
assert [r["chunk_id"] for r in results] == ["u1-near", "u1-mid"]
@@ -271,7 +367,7 @@ async def test_delete_hit_removes_chunk_and_vec_row():
assert _row_count(store, "memory_chunks") == 1
assert _row_count(store, "memory_vec") == 1 # c1's vec row gone too (no orphan)
# delete_hit: search no longer surfaces it (vec/chunk coupling held)
hits = await store.search(_vec(1.0), top_k=5, scope_filter={"end_user": "u1"})
hits = await store.search(_vec(1.0), top_k=5, scope_all={"end_user": "u1"})
assert all(r["chunk_id"] != "c1" for r in hits)
@@ -361,7 +457,7 @@ async def test_parity_search_ranked_ids_vs_reference_through_dispatch():
await dispatch_memory_call(up, wctx, mine)
search_env = {
"operation": "search",
"args": {"vector": [1.0, 0.0], "top_k": 2, "scope_filter": {"end_user": "u1"}},
"args": {"vector": [1.0, 0.0], "top_k": 2, "scope_all": {"end_user": "u1"}},
}
rstatus, rbody = await dispatch_memory_call(search_env, rctx, ref)
mstatus, mbody = await dispatch_memory_call(search_env, rctx, mine)
@@ -388,7 +484,9 @@ async def test_parity_expected_revisions_vs_reference_through_dispatch():
"args": {"records": [_ref_record("a", [1.0, 0.0]), _ref_record("b", [0.0, 1.0])]},
"idempotency_key": "seed",
}
assert await dispatch_memory_call(seed, wctx, ref) == await dispatch_memory_call(seed, wctx, mine)
ref_seed = await dispatch_memory_call(seed, wctx, ref)
mine_seed = await dispatch_memory_call(seed, wctx, mine)
assert ref_seed == mine_seed
# partial map: only "a" is locked (revision 1); "b" is omitted from expected_revisions
partial = {
Generated
+5 -5
View File
@@ -190,14 +190,14 @@ wheels = [
[[package]]
name = "bifrost"
version = "0.6.1"
version = "0.8.0"
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
dependencies = [
{ 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.8.0/bifrost-0.8.0.tar.gz", hash = "sha256:28194877c81a056a0803b052e86902c092e965d4ce63a5623d7a31240cedb645" }
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.8.0/bifrost-0.8.0-py3-none-any.whl", hash = "sha256:2aac5e4a7828d718389748a78dae6baeb5e9ee4a801a10c427c06c5cc7ed6597" },
]
[[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.17.4"
version = "0.17.6"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -1086,7 +1086,7 @@ web = [
[package.metadata]
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.8.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx-sse", specifier = ">=0.4" },
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },