chore(provider): pin bifrost>=0.6.1 + affect-provider contract

Onboard ratatoskr as the Tier-3 Bifrost consumer (durable memory +
persona/affect persistence Worldtree writes into). Lands the dependency
and the reviewed affect-plane spec; no production code yet — the patch
bump fires when the store lands at TDD-green.

- pyproject: bifrost>=0.6.1 in a `provider` optional-extra (+ starlette,
  jsonschema); gitea PyPI index wired, bifrost sourced from it.
- uv.lock: bifrost 0.6.1 + jsonschema resolved from the gitea registry.
- docs/contracts/bifrost_affect_provider.contract.md: SQLite-backed,
  conduit-opaque affect store (emit + ASGI wiring). Heid-panel-reviewed
  (Groa/Hulda/Regin), amended for 8 text-ambiguity findings.
This commit is contained in:
vh
2026-06-14 14:41:27 -07:00
parent 89c22db6f8
commit 1a73d777c9
3 changed files with 390 additions and 1 deletions
@@ -0,0 +1,200 @@
---
contract_version: "2.1"
module: "ratatoskr.provider.affect_store"
purpose: "Affect-plane Bifrost consumer (v1 tracer): a SQLite-backed, conduit-opaque affect store + the ASGI app wiring Worldtree emits Tier-3 persona/affect snapshots into."
language: "python"
complexity: "medium"
estimated_loc: 150
confidence: 0.8
assumptions:
- "bifrost>=0.6.1 is installed (pending the gitea-index credential via infra-ops) and exposes build_affect_app, dispatch_affect_call, JwtVerifier, ConsumerRegistration per bifrost/docs/implementing-a-consumer.md @ 8df54ed."
- "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."
open_questions:
- "SQLite file path + whether the affect plane shares one DB file with the memory plane or uses its own — deferred to the combined-server slice (guide §7) and the memory-plane contract."
- "idempotency_class is accepted and ignored in v1 (reserved); confirm Worldtree never relies on class-scoped affect idempotency."
external_invariants:
- source: ~/development/bifrost/docs/contracts/affect.contract.md
invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane
- source: ~/development/bifrost/docs/implementing-a-consumer.md
invariant_id: "§6 affect v1 floor"
---
## Context
ratatoskr is the v1 **Bifrost consumer** — the durable persistence Worldtree
writes Tier-3 agent state into. This contract specifies the **affect plane**
slice: the first tracer-bullet through the whole consumer wire (handshake →
per-dispatch JWT → dispatch → store → conformance), chosen first because
`affect.*` has exactly one verb over an opaque blob, so it proves the pipes with
minimal store complexity before the heavier `memory.*` plane. We implement
**bifrost's own** `affect`-store shape (not worldtree-memory's), hand it to
`bifrost.consumer.build_affect_app`, and mount the Starlette app.
The boundary is absolute (ADR-0001/0002/0009): **Worldtree appraises and decides
affect; we only persist and round-trip it.** We run no affect logic.
## Data flow
- **In:** Worldtree's post-turn affect emit → `POST /bifrost/affect-call` →
library validates envelope + per-dispatch JWT → `store.emit(snapshot, *,
idempotency_key, ctx, idempotency_class=None)`.
- **Snapshot shape:** `{agent_id, end_user_id, pad, valence, persona_baselines,
emitted_at}`. We read **only** `agent_id` + `end_user_id` (the addressing
keys); the rest is opaque payload.
- **At rest:** one row per `(agent_id, end_user_id)` in a SQLite table
`affect_snapshots`, holding the **verbatim** snapshot JSON + a content hash +
the last idempotency key + an `arrived_at` receive-timestamp that is
**audit/debug only** (never used for ordering — LWW is the upsert itself — and
never returned). Last-write-wins by arrival → the row is upserted.
- **Out:** `{"stored": True}` ack (the library wraps it with the transport
`{"success": True}` envelope).
## Invariants
- **INV-001** [hard]: **Conduit opacity** (inlined from bifrost
`affect.contract.md` INV-001 so this contract stands alone). The store's OWN
logic references ONLY `snapshot["agent_id"]` and `snapshot["end_user_id"]`. It
MUST NOT index, attribute-access, validate, compare, or branch on `pad` /
`valence` / `persona_baselines` / `emitted_at`. Mechanically serializing the
whole dict (`json.dumps`) and hashing the bytes is explicitly PERMITTED — that
is non-semantic serialization, not a field read. The distinction the
implementer must preserve: *serialize-the-whole-blob* = allowed;
*read-a-payload-field-and-act-on-it* = forbidden.
- **INV-002** [hard]: **Last-write-wins by ARRIVAL.** The most recently arrived
emit for a `(agent_id, end_user_id)` is the persisted state. **Arrival order =
the order in which `emit`'s upsert transaction commits** (serialized under
SQLite's single-writer model). `emitted_at` is NEVER compared — Worldtree
throttles + sequences emits, so arrival order at the conduit is the intended
semantics.
- **INV-003** [hard]: The snapshot is persisted **verbatim** in the sense of
**semantic round-trip**: the stored bytes are the store's canonical
serialization (`json.dumps(..., sort_keys=True)`), and a read deserializes to a
Python object EQUAL to the input snapshot (`snapshot_out == snapshot_in`).
"Verbatim" does NOT promise byte-equality with the caller's original wire bytes
— key order, whitespace, and numeric formatting may differ; only value-equality
of the decoded object is guaranteed.
- **INV-004** [hard]: `emit` returns exactly `{"stored": True}` on every
successful persist (Worldtree's emitter validates `stored` specifically).
- **INV-005** [hard]: The store advertises `affect_supported = True`; it is the
REQUIRED store — `build_affect_app(store=None, ...)` raises (no silent
in-memory default).
- **INV-006** [hard]: Authorization identity/scope is taken from `ctx`
(DispatchContext), never from call arguments; the snapshot addressing keys are
used ONLY as the persistence key, not as an auth claim.
- **INV-007** [hard]: The upsert is atomic (single transaction); no partial affect
row is ever observable.
- **INV-008** [soft, recovery_window=1]: A replayed emit (same `idempotency_key`
AND same content hash) **SHOULD** skip the redundant write as a best-effort
optimization, but **MUST** still return `{"stored": True}`. Re-writing identical
bytes is harmless under LWW, so the skip is an optimization, not a correctness
requirement (the hard guarantee is the ack, INV-004). A same `idempotency_key`
carrying a *different* content hash is **NOT** a replay — it is a normal arrival
and overwrites per INV-002.
## Concurrency
SQLite in WAL mode (concurrent readers, single writer). `emit` writes are
serialized by the per-`(agent_id, end_user_id)` primary key; last-write-wins is
the upsert itself. No cross-row coordination — affect rows are independent.
## Division of labor (library vs store)
A crisp line, since the responsibilities interleave: the **bifrost library** owns
the entire wire — envelope validation, per-dispatch JWT verification, scope
authorization, error mapping, capability negotiation, route exposure. **This
contract** owns ONLY the store (`emit` + the SQLite persistence) and the thin
`build_affect_provider_app` wiring. Any guarantee about the wire is the
library's; this contract neither re-implements nor overrides it. `emit`'s
defensive missing-key check (PRE-001) is belt-and-suspenders — the wire should
already have rejected a malformed envelope; the store raises defensively rather
than relying on it.
## Integration points
- `bifrost.consumer.build_affect_app(store, verifier, registration)` → Starlette ASGI app.
- `bifrost.reference_server.JwtVerifier(algorithm="HS256", key_bytes=...)`.
- `bifrost.consumer.ConsumerRegistration(consumer_id="ratatoskr")`.
- **Conformance (tests only):** `bifrost.consumer.testing.InMemoryAffectStore`
+ `bifrost.affect.dispatch_affect_call` — the #195 parity pattern.
## Constraints
- **[security]** Never read or log the affect payload (`pad`/`valence`/
`persona_baselines`); opacity is a security + correctness boundary, not just a
style choice.
- **[compatibility]** Implement bifrost's affect-store shape exactly; never fork
the wire/engine/auth. Custom behavior, if ever needed, goes through `Hooks` in
a namespace OUTSIDE `affect.*` (ADR-0005, INV-008).
- **[correctness]** Do not compare `emitted_at` anywhere (would both read the
payload and break arrival-order LWW).
```contract
FN open_affect_store(db_path: str) -> RatatoskrAffectStore
BRIEF: Open the SQLite-backed affect store, creating the schema on first use.
PRE: [PRE-001 hard] db_path is a writable path or ":memory:" -- guard clause
POST: [POST-001 return_value] returned store has affect_supported is True -- assert store.affect_supported is True
POST: [POST-002 state_change] table affect_snapshots exists -- assert schema present
STEPS:
1. [setup] CONNECT sqlite3 to db_path; SET journal_mode=WAL (skip for ":memory:")
2. [sequential, flexibility=prescriptive] CREATE TABLE IF NOT EXISTS affect_snapshots (
agent_id TEXT NOT NULL, end_user_id TEXT NOT NULL,
snapshot_json TEXT NOT NULL, content_hash TEXT NOT NULL,
idempotency_key TEXT, arrived_at TEXT,
PRIMARY KEY (agent_id, end_user_id))
3. [cleanup] RETURN RatatoskrAffectStore(conn)
TESTS:
fresh_db [happy,tracer]: open ":memory:" → store.affect_supported is True; affect_snapshots queryable
reopen [happy]: open existing file twice → no error, schema idempotent
```
```contract
FN emit(self, snapshot: dict, *, idempotency_key: str, ctx: DispatchContext, idempotency_class: str | None = None) -> dict
BRIEF: Persist a Worldtree affect snapshot verbatim, conduit-opaque, last-write-wins by arrival.
PRE: [PRE-001 hard] snapshot["agent_id"] and snapshot["end_user_id"] are non-empty strings -- read both; raise ValueError if absent (defensive; the wire should prevent)
PRE: [PRE-002 hard] idempotency_key is a non-empty string -- assert
POST: [POST-001 return_value] returns {"stored": True} -- assert result == {"stored": True} (INV-004)
POST: [POST-002 side_effect] exactly one row for (agent_id, end_user_id) holds the verbatim snapshot -- stored snapshot deserializes equal to input (INV-003)
POST: [POST-003 state_change] a replayed identical emit SHOULD skip the second write (best-effort, INV-008); the ack is unconditional -- replay test asserts one logical write + {"stored": True}
ERROR_ROUTING:
KeyError:
local_handling: raise ValueError("affect snapshot missing addressing key")
flow_control: abort
state_recovery: none
sqlite3.OperationalError:
local_handling: let propagate (library maps to transport error)
flow_control: abort
state_recovery: transaction rolled back — no partial row (INV-007)
STEPS:
1. [setup, flexibility=prescriptive] IF "agent_id" not in snapshot OR "end_user_id" not in snapshot: RAISE ValueError("affect snapshot missing addressing key") -- explicit presence-guard so PRE-001 surfaces ValueError, not a raw KeyError
ELSE READ agent_id = snapshot["agent_id"]; end_user_id = snapshot["end_user_id"] -- the ONLY two fields read (INV-001)
2. [sequential, flexibility=indicative] SET blob = json.dumps(snapshot, sort_keys=True, separators=(",", ":")); SET content_hash = sha256(blob).hexdigest() -- whole-blob hash is opacity-safe; no individual payload field is read
3. [branch] IF a row exists for (agent_id, end_user_id) with content_hash == this content_hash AND idempotency_key == this idempotency_key:
- RETURN {"stored": True} -- replay no-op (INV-008)
4. [sequential, flexibility=prescriptive] UPSERT (agent_id, end_user_id, snapshot_json=blob, content_hash, idempotency_key, arrived_at=<receive wall-clock>) in ONE transaction -- LWW by arrival, unconditional overwrite (INV-002, INV-007). Do NOT compare emitted_at.
5. [cleanup] RETURN {"stored": True} (INV-004)
TESTS:
basic_emit [happy,tracer]: valid snapshot → {"stored": True}; row round-trips byte-identical
opacity [adversarial]: snapshot carrying arbitrary extra/unknown payload fields → persists + round-trips verbatim + returns stored:True (store never validates or branches on payload); AND two snapshots for the same key differing ONLY in payload address the SAME row (behavioral opacity — does not rely on attribute-access booby-trapping, which dict __getitem__/json.dumps would not trigger)
lww_by_arrival [scenario]: emit A, then emit B (different payload, OLDER emitted_at) for same key → stored state is B; emitted_at never compared
replay_noop [happy]: identical emit twice (same key + hash) → single row, {"stored": True} both times, one write
missing_key [adversarial]: snapshot without "end_user_id" → raises ValueError; no row written
parity_vs_reference [scenario]: drive identical affect.emit envelopes through dispatch_affect_call against InMemoryAffectStore and RatatoskrAffectStore → {success,...}+{stored} wire bodies agree (#195)
```
```contract
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.
PRE: [PRE-001 hard] store.affect_supported is True -- assert getattr(store, "affect_supported", False) is True (INV-005)
PRE: [PRE-002 hard] heimdall_key is non-empty bytes -- assert
POST: [POST-001 return_value] returns a Starlette app exposing POST /bifrost/handshake and POST /bifrost/affect-call -- assert routes present
STEPS:
1. [setup] SET verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
2. [setup] SET registration = ConsumerRegistration(consumer_id=consumer_id)
3. [sequential, flexibility=prescriptive] SET app = build_affect_app(store=store, verifier=verifier, registration=registration)
4. [cleanup] RETURN app
TESTS:
builds_app [happy,tracer]: valid store + key → Starlette app with the two routes
non_advertising_store [adversarial]: store with affect_supported=False → rejected (PRE-001 or library raises affect.unsupported_capability)
bad_key [error]: empty heimdall_key → raises at construction
```