Files
ratatoskr/docs/contracts/bifrost_affect_provider.contract.md
T
vh 1a73d777c9 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.
2026-06-14 14:41:27 -07:00

13 KiB

contract_version, module, purpose, language, complexity, estimated_loc, confidence, assumptions, open_questions, external_invariants
contract_version module purpose language complexity estimated_loc confidence assumptions open_questions external_invariants
2.1 ratatoskr.provider.affect_store 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. python medium 150 0.8
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.
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.
source invariant_id
~/development/bifrost/docs/contracts/affect.contract.md INV-001
source invariant_id
~/development/bifrost/docs/implementing-a-consumer.md §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).
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
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)
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