feat(#18): affect.fetch — adopt bifrost 0.10.0 mandatory fetch (D1 prerequisite)

bifrost 0.10.0's _supports_affect_plane (bifrost/affect.py:75-80) now requires a
callable fetch for the affect capability to advertise/dispatch at all (INV-012
strong-or-absent), so an emit-only store 400s on EVERY affect op — repinning past
the affect.fetch release (#12/#13) breaks our shipped affect plane until fetch
exists. Implement affect.fetch as a thin async wrapper over the existing get()
read seam, conformed verbatim to the reference InMemoryAffectStore.fetch:
{"found": False} or {"found": True, "snapshot": <verbatim>}, AffectInvalidArguments
on empty ids, opaque (INV-001 — never reads pad/valence).

This is the forced prerequisite for the #18 D1 composite (build_combined_app),
and a new Worldtree I/O point consumed (affect read-back over bifrost).

- Repin bifrost>=0.8.0 -> >=0.10.0 (uv lock: 0.8.0 -> 0.10.0)
- affect_store.py: add async fetch() over get()
- contract bifrost_affect_provider v1.2: fetch FN block + INV-010 (cap = supported+emit+fetch)
- tests: 3 fetch unit + parity_vs_reference_fetch through dispatch_affect_call
- suite 482 -> 486 green
This commit is contained in:
vh
2026-06-19 22:59:59 -07:00
parent a0c6c73ab9
commit ca6af6bdaa
5 changed files with 158 additions and 19 deletions
@@ -10,7 +10,7 @@ complexity: "medium"
estimated_loc: 180 estimated_loc: 180
confidence: 0.85 confidence: 0.85
assumptions: assumptions:
- "bifrost>=0.6.1 is installed and exposes build_affect_app, dispatch_affect_call, JwtVerifier, ConsumerRegistration, AffectInvalidArguments, AffectIdempotencyConflict per bifrost/docs/implementing-a-consumer.md @ 8df54ed and bifrost/reference_server/affect.py." - "bifrost>=0.10.0 is installed and exposes build_affect_app, build_combined_app, dispatch_affect_call, JwtVerifier, ConsumerRegistration, AffectInvalidArguments, AffectIdempotencyConflict, and REQUIRES a callable affect-store fetch for the affect capability (_supports_affect_plane, bifrost/affect.py:75-80, strong-or-absent) per bifrost/reference_server/affect.py."
- "The affect snapshot dict always carries string addressing keys 'agent_id' and 'end_user_id'; the bifrost wire validates the envelope before the store is called." - "The affect snapshot dict always carries string addressing keys 'agent_id' and 'end_user_id'; the bifrost wire validates the envelope before the store is called."
- "A Heimdall HS256 key for consumer_id='ratatoskr' is provisioned (deploy-time, brokered via infra-ops); the store itself never sees raw auth — the library verifies per-dispatch JWTs and hands a DispatchContext (ctx)." - "A Heimdall HS256 key for consumer_id='ratatoskr' is provisioned (deploy-time, brokered via infra-ops); the store itself never sees raw auth — the library verifies per-dispatch JWTs and hands a DispatchContext (ctx)."
- "The idempotency actor is derivable from ctx (mirrors bifrost's reference `_ctx_actor(ctx)` — the dispatch subject/actor identity)." - "The idempotency actor is derivable from ctx (mirrors bifrost's reference `_ctx_actor(ctx)` — the dispatch subject/actor identity)."
@@ -21,7 +21,9 @@ external_invariants:
- source: ~/development/bifrost/docs/contracts/affect.contract.md - source: ~/development/bifrost/docs/contracts/affect.contract.md
invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane
- source: ~/development/bifrost/bifrost/reference_server/affect.py - source: ~/development/bifrost/bifrost/reference_server/affect.py
invariant_id: "InMemoryAffectStore.emit" # the executable reference for the wire semantics we parity-prove against invariant_id: "InMemoryAffectStore.emit" # the executable reference for the emit wire semantics we parity-prove against
- source: ~/development/bifrost/bifrost/reference_server/affect.py
invariant_id: "InMemoryAffectStore.fetch" # the executable reference for the affect.fetch read shape ({found, snapshot})
revisions: revisions:
- version: "1.1" - version: "1.1"
at: 2026-06-14 at: 2026-06-14
@@ -39,6 +41,22 @@ revisions:
- "basic_emit wording — semantic round-trip (was: byte-identical)" - "basic_emit wording — semantic round-trip (was: byte-identical)"
REMOVED: REMOVED:
- "the 'same idempotency_key + different content hash -> LWW overwrite' clause (it was backwards: bifrost treats that as a conflict)" - "the 'same idempotency_key + different content hash -> LWW overwrite' clause (it was backwards: bifrost treats that as a conflict)"
- version: "1.2"
at: 2026-06-19
summary: "Adopt bifrost 0.10.0's mandatory affect.fetch (strong-or-absent, INV-012): _supports_affect_plane now requires a callable fetch for the affect cap to advertise/dispatch at all, so an emit-only store 400s on EVERY affect op. Promote the sync get() read seam to an async wire fetch() returning bifrost's {found, snapshot} shape; conform to the reference InMemoryAffectStore.fetch. affect.fetch leaves 'reserved'. Forced prerequisite of the #18 D1 composite (build_combined_app)."
delta:
ADDED:
- "fetch() function block (async wire verb; mirrors reference InMemoryAffectStore.fetch)"
- "INV-010 (affect cap = affect_supported + emit + fetch, strong-or-absent)"
- "parity_vs_reference_fetch test"
- "InMemoryAffectStore.fetch external invariant"
MODIFIED:
- "INV-005 — cross-refs INV-010 (the affect cap now requires fetch present too)"
- "assumptions — bifrost pin >=0.10.0 (build_combined_app + mandatory affect.fetch)"
- "get() BRIEF — the sync read seam fetch() wraps (no longer 'affect.fetch RESERVED')"
- "Data flow — add the fetch read-back path"
REMOVED:
- "the 'affect.fetch / affect:read RESERVED in v1' out-of-scope line"
--- ---
## Context ## Context
@@ -75,13 +93,19 @@ affect; we only persist and round-trip it.** We run no affect logic.
conflict cache: `digest` is a content fingerprint of the snapshot; conflict cache: `digest` is a content fingerprint of the snapshot;
`expires_at` records the short-retry deadline for a future pruning pass `expires_at` records the short-retry deadline for a future pruning pass
(TTL eviction deferred — see INV-009). (TTL eviction deferred — see INV-009).
- **Out:** `{"stored": True}` ack (the library wraps it with the transport - **Out (emit):** `{"stored": True}` ack (the library wraps it with the transport
`{"success": True}` envelope). `{"success": True}` envelope).
- **Fetch (read-back):** Worldtree's `affect.fetch` → `POST /bifrost/affect-call`
→ `store.fetch(agent_id=..., end_user_id=...)` → `{"found": False}` or
`{"found": True, "snapshot": <verbatim snapshot>}` (the library wraps it via
`affect_result(**fetched)`). The snapshot is returned opaque/verbatim — `fetch`
never reads `pad` / `valence` / `persona_baselines` / `emitted_at` (INV-001).
**Async surface:** `emit` is `async def` (the bifrost consumer Protocol awaits **Async surface:** `emit` and `fetch` are `async def` (the bifrost consumer
it); `open_affect_store` and `get` are sync (no I/O await — `get` is a read-back Protocol awaits them); `open_affect_store` and `get` are sync (no I/O await —
seam). The `FN` lines below omit the `async` keyword only because the contract `get` is the read-back seam `fetch` wraps). The `FN` lines below omit the
grammar's `FN <name>` form has no async marker. `async` keyword only because the contract grammar's `FN <name>` form has no
async marker.
## Invariants ## Invariants
@@ -113,7 +137,7 @@ grammar's `FN <name>` form has no async marker.
`stored` specifically). `stored` specifically).
- **INV-005** [hard]: The store advertises `affect_supported = True`; it is the - **INV-005** [hard]: The store advertises `affect_supported = True`; it is the
REQUIRED store — `build_affect_app(store=None, ...)` raises (no silent REQUIRED store — `build_affect_app(store=None, ...)` raises (no silent
in-memory default). in-memory default). See INV-010 for the full affect-capability surface.
- **INV-006** [hard]: Authorization identity/scope — and the **idempotency - **INV-006** [hard]: Authorization identity/scope — and the **idempotency
actor** — are taken from `ctx` (DispatchContext), never from the snapshot or actor** — are taken from `ctx` (DispatchContext), never from the snapshot or
other call arguments. The snapshot addressing keys are used ONLY as the other call arguments. The snapshot addressing keys are used ONLY as the
@@ -137,6 +161,13 @@ grammar's `FN <name>` form has no async marker.
grows unbounded until a follow-up pruning patch. Wire-observable behavior is grows unbounded until a follow-up pruning patch. Wire-observable behavior is
unaffected (replay/conflict still resolve correctly); only cache size is. unaffected (replay/conflict still resolve correctly); only cache size is.
`affect_snapshots` is already bounded to one row per `(agent_id, end_user_id)`. `affect_snapshots` is already bounded to one row per `(agent_id, end_user_id)`.
- **INV-010** [hard]: **The affect capability is `affect_supported` + `emit` +
`fetch`, strong-or-absent** (bifrost ≥0.10.0 `_supports_affect_plane`,
`bifrost/affect.py:75-80`; the INV-012 no-degraded-path rule). bifrost gates
EVERY affect op (emit included) on all three being present, so a store missing
a callable `fetch` is rejected with `affect.unsupported_capability` and the
handshake never advertises `affect`. We therefore implement `fetch` fully (not
a stub) — the canonical surface admits no emit-only affect store.
## Concurrency ## Concurrency
@@ -186,8 +217,10 @@ already have rejected a malformed envelope.
Protocol are a later contract. Protocol are a later contract.
- **The combined two-plane server** (guide §7): one handshake negotiating both - **The combined two-plane server** (guide §7): one handshake negotiating both
memory + affect is deferred; `build_affect_provider_app` mounts affect alone. memory + affect is deferred; `build_affect_provider_app` mounts affect alone.
- **`affect.fetch` / `affect:read` / persona-baseline rehydrate**: RESERVED in - **`affect:read` scope enforcement / persona-baseline rehydrate shaping**: the
v1; only `emit` + the test-only `get()` exist. library owns scope auth (`affect:read` for fetch); `fetch` returns the stored
blob verbatim — any richer rehydrate shaping beyond a snapshot round-trip is
Worldtree's concern, not the store's.
- **`idempotency_class`**: accepted and ignored (affect.* uses a single - **`idempotency_class`**: accepted and ignored (affect.* uses a single
short-retry class). short-retry class).
- **WAL/concurrency hardening, deployment DB path, auth-key provisioning**: - **WAL/concurrency hardening, deployment DB path, auth-key provisioning**:
@@ -254,7 +287,7 @@ TESTS:
```contract ```contract
FN get(self, agent_id: str, end_user_id: str) -> dict | None FN get(self, agent_id: str, end_user_id: str) -> dict | None
BRIEF: Read-back of the stored snapshot (tests / future rehydrate-seed). NOT a wire verb — affect.fetch is RESERVED in v1. BRIEF: Sync read-back seam returning the verbatim stored snapshot (or None). The async wire verb fetch() wraps this; tests / the D2 read route / rehydrate-seed also use it directly.
POST: [POST-001 return_value] returns the verbatim snapshot for the key, or None if absent -- (INV-003) POST: [POST-001 return_value] returns the verbatim snapshot for the key, or None if absent -- (INV-003)
STEPS: STEPS:
1. [sequential] SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ? 1. [sequential] SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?
@@ -264,6 +297,30 @@ TESTS:
get_after_emit [happy]: returns the emitted snapshot, deserialized equal get_after_emit [happy]: returns the emitted snapshot, deserialized equal
``` ```
```contract
FN fetch(self, agent_id: str, end_user_id: str) -> dict
BRIEF: Wire affect.fetch read handler — return the stored snapshot in bifrost's {found, snapshot} shape, conduit-opaque. Mirrors the reference InMemoryAffectStore.fetch verbatim (INV-010 strong-or-absent: this method MUST exist for the affect cap to advertise/dispatch).
PRE: [PRE-001 hard] agent_id and end_user_id are non-empty strings -- else raise AffectInvalidArguments (mirrors reference; the wire validates the envelope first, this is belt-and-suspenders)
POST: [POST-001 return_value] returns {"found": False} when no snapshot for the key -- (the library wraps via affect_result(**fetched))
POST: [POST-002 return_value] returns {"found": True, "snapshot": <verbatim snapshot>} when present; snapshot deserializes equal to the emitted snapshot -- (INV-003)
POST: [POST-003 return_value] never reads pad/valence/persona_baselines/emitted_at — returns the whole blob opaque -- (INV-001)
ERROR_ROUTING:
AffectInvalidArguments:
local_handling: raise on missing/empty agent_id or end_user_id
flow_control: abort
state_recovery: none (read-only; no state touched)
STEPS:
1. [setup, flexibility=prescriptive] IF agent_id/end_user_id missing or not non-empty str: RAISE AffectInvalidArguments
2. [sequential] SET snap = self.get(agent_id, end_user_id) -- the existing sync read seam; whole-blob json.loads, no field reads (INV-001)
3. [branch] IF snap is None: RETURN {"found": False}
4. [cleanup] RETURN {"found": True, "snapshot": snap}
TESTS:
fetch_absent [boundary]: no row for key → {"found": False}
fetch_after_emit [happy,tracer]: emit then fetch → {"found": True, "snapshot": equals the emitted snapshot}
fetch_missing_key [adversarial]: empty/missing agent_id or end_user_id → raises AffectInvalidArguments
parity_vs_reference_fetch [scenario]: drive identical affect.fetch envelopes (found + not-found) through dispatch_affect_call against InMemoryAffectStore and RatatoskrAffectStore → (status, body) tuples agree (#195)
```
```contract ```contract
FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
BRIEF: Wire the JWT verifier + registration and hand the store to bifrost's build_affect_app. BRIEF: Wire the JWT verifier + registration and hand the store to bifrost's build_affect_app.
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.14" version = "0.17.15"
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.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) "bifrost>=0.10.0", # consumer engines + library (0.10.0: build_combined_app (#18) + mandatory affect.fetch, strong-or-absent; 0.8.0/wire-v0.6: scope_all/scope_any split (#11); 0.7.0/v0.5: agent_self)
"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)
] ]
+27 -1
View File
@@ -102,13 +102,39 @@ class RatatoskrAffectStore:
return {"stored": True} return {"stored": True}
def get(self, agent_id: str, end_user_id: str) -> dict | None: def get(self, agent_id: str, end_user_id: str) -> dict | None:
"""Read-back of the stored snapshot (tests / future rehydrate-seed).""" """Sync read-back seam returning the verbatim stored snapshot (or None).
The async wire verb `fetch` wraps this; tests, the D2 read route, and
rehydrate-seed also call it directly.
"""
row = self._conn.execute( row = self._conn.execute(
"SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?", "SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?",
(agent_id, end_user_id), (agent_id, end_user_id),
).fetchone() ).fetchone()
return json.loads(row[0]) if row is not None else None return json.loads(row[0]) if row is not None else None
async def fetch(self, agent_id: str, end_user_id: str) -> dict:
"""Async affect.fetch handler — return the stored snapshot in bifrost's
{found, snapshot} shape, conduit-opaque.
INV-010 (strong-or-absent): bifrost >=0.10.0 gates EVERY affect op on the
store advertising affect_supported + emit + fetch (`_supports_affect_plane`),
so this method MUST exist for the affect capability to dispatch at all —
an emit-only store 400s. Mirrors the reference InMemoryAffectStore.fetch;
returns the whole blob opaque (INV-001 — never reads pad/valence).
"""
if not (
isinstance(agent_id, str)
and agent_id
and isinstance(end_user_id, str)
and end_user_id
):
raise AffectInvalidArguments("fetch missing agent_id / end_user_id")
snap = self.get(agent_id, end_user_id)
if snap is None:
return {"found": False}
return {"found": True, "snapshot": snap}
def open_affect_store(db_path: str) -> RatatoskrAffectStore: def open_affect_store(db_path: str) -> RatatoskrAffectStore:
"""Open the SQLite-backed affect store, creating the schema on first use.""" """Open the SQLite-backed affect store, creating the schema on first use."""
+56
View File
@@ -162,6 +162,32 @@ async def test_get_after_emit_returns_equal():
assert store.get("a1", "u1") == snap assert store.get("a1", "u1") == snap
# --- fetch (affect.fetch wire verb — bifrost >=0.10.0, INV-010 strong-or-absent) ---
async def test_fetch_absent_returns_found_false():
"""fetch_absent: no row for the key → {"found": False} (mirrors reference)."""
store = open_affect_store(":memory:")
assert await store.fetch("nope", "nope") == {"found": False}
async def test_fetch_after_emit_returns_snapshot():
"""fetch_after_emit [tracer]: emit then fetch → {"found": True, "snapshot": <verbatim>}."""
store = open_affect_store(":memory:")
snap = _snapshot()
await store.emit(snap, idempotency_key="k1", ctx=_ctx())
assert await store.fetch("a1", "u1") == {"found": True, "snapshot": snap}
async def test_fetch_missing_key_raises():
"""fetch_missing_key: empty/missing addressing key → AffectInvalidArguments
(PRE-001; symmetric across both keys, belt-and-suspenders behind the wire)."""
store = open_affect_store(":memory:")
with pytest.raises(AffectInvalidArguments):
await store.fetch("", "u1")
with pytest.raises(AffectInvalidArguments):
await store.fetch("a1", "")
# --- build_affect_provider_app --- # --- build_affect_provider_app ---
def test_build_app_exposes_handshake_and_affect_routes(): def test_build_app_exposes_handshake_and_affect_routes():
@@ -243,6 +269,36 @@ async def test_parity_vs_reference_store_through_dispatch():
) )
def _fetch_env(agent_id: str = "agent-1", end_user_id: str = "user-1") -> dict:
return {"operation": "affect.fetch", "args": {"agent_id": agent_id, "end_user_id": end_user_id}}
async def test_parity_vs_reference_fetch_through_dispatch():
"""#195 parity for affect.fetch: cold (not-found) + warm (found) read envelopes
yield identical (status, body) through the real engine against the reference store
and ours. Conforms to bifrost's InMemoryAffectStore.fetch ({found, snapshot})."""
from bifrost.affect import dispatch_affect_call
from bifrost.consumer.testing import InMemoryAffectStore
ref = InMemoryAffectStore()
mine = open_affect_store(":memory:")
write_ctx = _dispatch_ctx("affect:write")
read_ctx = _dispatch_ctx("affect:read")
# cold fetch (nothing persisted): both -> {found: false}
assert await dispatch_affect_call(_fetch_env(), read_ctx, ref) == await dispatch_affect_call(
_fetch_env(), read_ctx, mine
)
# seed both via emit, then fetch -> both {found: true, snapshot: <verbatim>}
snap = _ref_shaped_snapshot()
await dispatch_affect_call(_env(snap), write_ctx, ref)
await dispatch_affect_call(_env(snap), write_ctx, mine)
assert await dispatch_affect_call(_fetch_env(), read_ctx, ref) == await dispatch_affect_call(
_fetch_env(), read_ctx, mine
)
# --- PAD read route (issue #18 Deliverable 2) --- # --- PAD read route (issue #18 Deliverable 2) ---
# Non-bifrost GET /affect/state/{agent_id}?end_user_id=… → store.get snapshot. # Non-bifrost GET /affect/state/{agent_id}?end_user_id=… → store.get snapshot.
Generated
+5 -5
View File
@@ -190,14 +190,14 @@ wheels = [
[[package]] [[package]]
name = "bifrost" name = "bifrost"
version = "0.8.0" version = "0.10.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.8.0/bifrost-0.8.0.tar.gz", hash = "sha256:28194877c81a056a0803b052e86902c092e965d4ce63a5623d7a31240cedb645" } sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0.tar.gz", hash = "sha256:aba1869dba68d921f2e0be8fb560277073da09ec2ad5f410e226cacd5e84fe1a" }
wheels = [ wheels = [
{ 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" }, { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0-py3-none-any.whl", hash = "sha256:88adbce23fa8840a14f9493e4f0cf6f9320f4950845f7a6080387defe574a5cc" },
] ]
[[package]] [[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.14" version = "0.17.15"
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.8.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }, { name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.10.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" },