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.
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 |
|
|
|
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 onlyagent_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 tableaffect_snapshots, holding the verbatim snapshot JSON + a content hash + the last idempotency key + anarrived_atreceive-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.mdINV-001 so this contract stands alone). The store's OWN logic references ONLYsnapshot["agent_id"]andsnapshot["end_user_id"]. It MUST NOT index, attribute-access, validate, compare, or branch onpad/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 whichemit's upsert transaction commits (serialized under SQLite's single-writer model).emitted_atis 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]:
emitreturns exactly{"stored": True}on every successful persist (Worldtree's emitter validatesstoredspecifically). - 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_keyAND 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 sameidempotency_keycarrying 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.InMemoryAffectStorebifrost.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
Hooksin a namespace OUTSIDEaffect.*(ADR-0005, INV-008). - [correctness] Do not compare
emitted_atanywhere (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