Compare commits

...

9 Commits

Author SHA1 Message Date
vh af67ad995c chore(deps): repin bifrost==1.0.0 (first stable; wire v0.6 frozen)
Bifrost shipped 1.0.0 — first stable release, freezing wire v0.6 (the
surface ratatoskr's combined-builder consumer #18 already adopted).
Non-breaking: byte-identical on the wire to the prior >=0.10.0 pin.
Switched floor pin -> exact pin per the stable-substrate posture.
Suite 506 green against bifrost 1.0.0.
2026-06-29 11:10:01 -07:00
vh 719e4d605b feat: web SPA bind — add 'combined' (:8392) both-plane option as default
The bind dropdown offered only memory/affect single-plane binds; #18's
composite endpoint (:8392, both planes in one session) was never reachable
from the SPA. Add 'combined' as the default-selected option, keeping
memory-only / affect-only for single-plane isolation diagnostics.

- endpoint_for_plane: combined -> :8392 (sessions.py)
- web server: accept bifrost_plane="combined" (server.py)
- dropdown: combined (:8392) default-selected, single-plane retained (index.html)
- #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined
- tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default

Suite 506 green. Live-verified on :8765 (current code).
2026-06-20 16:47:03 -07:00
vh c5c8ecf9d5 memory: snapshot — #17 CLOSED + #18 composite final leg PROVEN end-to-end
The Worldtree-driven composite :8392 smoke ran and is proven + persisted:
one bound session drove the full both-plane lifecycle through one endpoint
(handshake both caps -> affect.fetch + memory.search -> affect.emit stored:true
-> memory.upsert_many upserted:1), both writes verified in our SQLite stores.
infra-ops allowlisted :8392 (01KVHWJGTT); #17 closed in the tracker. No open
legs remain on the composite; repo at a converged checkpoint.
2026-06-20 12:59:50 -07:00
vh 4f16ba588d memory: snapshot — #18 CLOSED end-to-end + v0.18.0 (composite both-plane binding)
#18 D1 SHIPPED: build_combined_provider_app on :8392 wraps bifrost 0.10.0's public
build_combined_app over both stores + the shared affect read route; one bound WT session
drives memory.* AND affect.* through one endpoint; op-feed plane='combined' per-path.
Shipped v0.17.15 (affect.fetch, the strong-or-absent prerequisite) -> v0.17.16 (composite)
-> v0.17.17 (#17 op-feed field-name fix) -> v0.18.0 (publishing minor). Suite 503 green.

Live-smoke PROVEN at wire+dispatch (real stores + bifrost 0.10.0 on a running :8392):
handshake grants both caps, PAD read route serves real sindra PAD, both planes dispatch at
one bound session_id. WT-driven turn gated on infra-ops adding :8392 to WT's
BIFROST_CLIENT_ALLOWED_HOSTS (requested).

New decisions: reference-impl-adopt-canonical (operator); v1-derived-from-WT-I/O-coverage
(operator). New foot-guns: memory-store check_same_thread bug (same as affect D2, exposed by
the contract-mandated search test via TestClient); :8392 infra-allowlist gate; heid-review
test-fidelity nudge cascaded into 2 latent-bug fixes.
2026-06-19 23:57:01 -07:00
vh 359dbb1436 release: v0.18.0 — composite both-plane Bifrost binding (#18 closed)
Publishing-minor for the #18 arc: ratatoskr now exposes a COMPOSITE Bifrost
endpoint (build_combined_app, :8392) so one bound Worldtree session drives BOTH
the memory.* and affect.* planes through a single endpoint — completing the
Tier-3 consumer round-trip (durable memory + live PAD from one binding).

Shipped as patches v0.17.15 (affect.fetch prerequisite) → v0.17.16 (composite) →
v0.17.17 (#17 op-feed fix); this minor publishes the milestone.

Live-smoke (against real stores + bifrost 0.10.0 on a running :8392): handshake
grants BOTH caps by store presence; the PAD read route serves real sindra PAD;
both planes dispatch through the one endpoint at a single bound session_id with
the op-feed deriving plane per path. The remaining WT-driven turn is gated on
infra-ops adding :8392 to Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS (requested).
2026-06-19 23:48:33 -07:00
vh d60b77d4f1 fix(#17): op-feed handshake reads the real capabilities field, not capabilities_requested
The dispatch-layer op-feed's handshake req-summary read req.get("capabilities_requested"),
a field that never exists on the wire — bifrost's handshake handler reads
request_body["capabilities"] (reference_server/_protocol.py:181). So the op-feed's
caps_requested was silently ALWAYS None on every handshake. Read the real field.

Surfaced by the heid-code-review panel (Regin) during the #18 D1 review — a latent
#17 observability bug, not D1 drift. Regression test asserts caps_requested is
populated from a handshake body's capabilities.

Suite 502 -> 503 green.
2026-06-19 23:34:19 -07:00
vh 7f4ceaab2b feat(#18): composite Bifrost endpoint — build_combined_app (Deliverable 1)
One ASGI app fronting BOTH the memory.* and affect.* planes (:8392), so a single
bound Worldtree session both remembers AND shows live PAD. Closes #18 end-to-end
(D2 PAD read-endpoint shipped v0.17.14; D1 was bifrost-blocked, now unparked by
bifrost 0.10.0's public build_combined_app + FR-1 resolved — zero Worldtree change).

- provider/combined.py: build_combined_provider_app wraps bifrost.consumer.build_combined_app
  over both stores + mounts the shared affect read route. Advertises both caps by store
  presence; per-route call-time isolation is bifrost's (INV-013).
- affect_store.py: extract add_affect_read_route shared helper (the D2 INV-007 promise —
  composite + standalone mount the SAME read route over the same affect.db, INV-011).
- opfeed.py: plane='combined' derives the OpEvent plane per request path
  (memory-call->memory, affect-call->affect, handshake->combined; INV-012).
- serve_combined.py + ratatoskr-combined-provider console script on :8392 (additive —
  standalone :8390/:8391 untouched, INV-014).
- contract: 18.contract.md § Deliverable 1 (INV-009..INV-014); D1 un-deferred.

Latent bug fixed (exposed by the contract-mandated memory `search` dispatch test running
through TestClient = a worker thread): open_memory_store lacked check_same_thread=False —
the SAME sqlite thread-safety bug already fixed in the affect store (D2). The composite
serves the memory plane over HTTP, so a memory-call on uvicorn's threadpool would trip it.
Fix: check_same_thread=False + PRAGMA busy_timeout=5000 (memory contract Concurrency note).

heid-code-review panel (Groa/Hulda/Regin): ZERO drift findings; the implementation matches
INV-009..INV-014 at function-block level. Folded the genuine test-fidelity fix (memory leg
describe_store -> search per the contract TEST) + added the PRE-001/PRE-002 guard tests.
Suite 486 -> 502 green.
2026-06-19 23:32:47 -07:00
vh ca6af6bdaa 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
2026-06-19 22:59:59 -07:00
vh a0c6c73ab9 memory: snapshot — #18 D2 SHIPPED+PUSHED (v0.17.14, 39eebd1): web pane renders live PAD/valence from our :8390 store, persona-telemetry gap closed; full #17+#18 arc now on origin. D1 (composite :8392) PARKED on bifrost build_combined_app (~v0.9.0, design locked, after WT #289). FR-1 RESOLVED — composite is bifrost-only, ZERO WT change (single-endpoint caps-routed, worldtree-dev code-verified). New decisions: #18 split + Option-C canonical-surface routing; D2 TDD + heid-code-review (1 INV-001 drift + 4 test-gaps fixed). Foot-guns: rationalized-away a known INV-001 deviation that only the post-impl cross-model review caught; latent sqlite check_same_thread bug exposed by the HTTP read route. FOOT-GUN: running :8390/:8765 are PRE-#18 code — restart with new code + RATATOSKR_AFFECT_READ_URL to see D2 live. 2026-06-19 22:10:57 -07:00
21 changed files with 956 additions and 106 deletions
@@ -10,7 +10,7 @@ complexity: "medium"
estimated_loc: 180
confidence: 0.85
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."
- "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)."
@@ -21,7 +21,9 @@ 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/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:
- version: "1.1"
at: 2026-06-14
@@ -39,6 +41,22 @@ revisions:
- "basic_emit wording — semantic round-trip (was: byte-identical)"
REMOVED:
- "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
@@ -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;
`expires_at` records the short-retry deadline for a future pruning pass
(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).
- **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
it); `open_affect_store` and `get` are sync (no I/O await — `get` is a read-back
seam). The `FN` lines below omit the `async` keyword only because the contract
grammar's `FN <name>` form has no async marker.
**Async surface:** `emit` and `fetch` are `async def` (the bifrost consumer
Protocol awaits them); `open_affect_store` and `get` are sync (no I/O await —
`get` is the read-back seam `fetch` wraps). The `FN` lines below omit the
`async` keyword only because the contract grammar's `FN <name>` form has no
async marker.
## Invariants
@@ -113,7 +137,7 @@ grammar's `FN <name>` form has no async marker.
`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).
in-memory default). See INV-010 for the full affect-capability surface.
- **INV-006** [hard]: Authorization identity/scope — and the **idempotency
actor** — are taken from `ctx` (DispatchContext), never from the snapshot or
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
unaffected (replay/conflict still resolve correctly); only cache size is.
`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
@@ -186,8 +217,10 @@ already have rejected a malformed envelope.
Protocol are a later contract.
- **The combined two-plane server** (guide §7): one handshake negotiating both
memory + affect is deferred; `build_affect_provider_app` mounts affect alone.
- **`affect.fetch` / `affect:read` / persona-baseline rehydrate**: RESERVED in
v1; only `emit` + the test-only `get()` exist.
- **`affect:read` scope enforcement / persona-baseline rehydrate shaping**: the
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
short-retry class).
- **WAL/concurrency hardening, deployment DB path, auth-key provisioning**:
@@ -254,7 +287,7 @@ TESTS:
```contract
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)
STEPS:
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
```
```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
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.
@@ -132,7 +132,13 @@ interpreted.
SQLite WAL (concurrent readers, single writer). `upsert_many`/`delete_many`
serialize on the writer; `search`/`get` are concurrent reads. sqlite-vec index
writes ride inside the upsert/delete transaction.
writes ride inside the upsert/delete transaction. The connection is opened
`check_same_thread=False` with `PRAGMA busy_timeout=5000` (mirrors the affect store):
the provider is an ASGI app, so uvicorn/Starlette (and TestClient always) may run a
handler off the connection's creating thread — the event loop serializes the sync
sqlite calls, so this is safe; busy_timeout preps the composite/standalone two-process
topology over the same db. (Surfaced by a TestClient-driven memory `search` through the
#18 D1 combined provider — the direct-store tests structurally could not.)
## Division of labor (library vs store)
+7 -7
View File
@@ -1,7 +1,7 @@
---
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."
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; `combined`→:8392 added post-#17 — the #18 composite, the web default) + 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 was PARKED at #17 — later shipped as #18 and surfaced in the web bind as the `combined` plane); 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"
@@ -92,8 +92,8 @@ async def 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."""
"""'memory'->:8391, 'affect'->:8390, 'combined'->:8392 (#18 composite) →
f'http://{base_host}:{port}'. The Worldtree-visible base URL. See FN endpoint_for_plane."""
```
```python
@@ -218,12 +218,12 @@ STEPS:
```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).
BRIEF: Map a plane name to the Worldtree-visible provider base URL (memory->:8391, affect->:8390, combined->:8392 — the #18 composite both-plane endpoint, surfaced post-#17).
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
PRE: [PRE-001 hard] plane in {"memory", "affect", "combined"} -- else ValueError
POST: [POST-001 return_value] returns f"http://{base_host}:{port}", port 8391 (memory) / 8390 (affect) / 8392 (combined) -- assert
STEPS:
1. port = 8391 if plane == "memory" else 8390
1. port = {"memory": 8391, "affect": 8390, "combined": 8392}[plane]
2. return the Worldtree-VISIBLE base URL (not client loopback); HTTPS relaxation is allowlist-side, not a URL concern
```
+135 -4
View File
@@ -1,12 +1,15 @@
---
contract_version: "2.1"
target_module: "ratatoskr.provider.affect_store + ratatoskr.web (server + static/index.html)"
scope: "Issue #18 DELIVERABLE 2 ONLY — the PAD read-endpoint so the web pane renders live PAD/valence for a Tier-3 agent from OUR :8390 affect store. Three pieces: (1) a NON-bifrost read route on the affect-store-owning app — GET /affect/state/{agent_id}?end_user_id=… → store.get(agent_id, end_user_id); (2) a web proxy GET /api/affect/{agent_id} that supplies end_user_id SERVER-SIDE (RATATOSKR_END_USER_ID), never from the browser, and proxies to a CONFIGURED affect-read base URL (RATATOSKR_AFFECT_READ_URL) so the pane is decoupled from the bind target; (3) a NEW pane render path for the affect-emit snapshot shape (pad + per-entity valence + emitted_at) — NOT a reuse of renderPersonaPane (the Tier-1 persona_state shape we never receive for Tier-3). DELIVERABLE 1 (composite :8392 endpoint) is DEFERRED — bifrost-blocked on a public bifrost.consumer.build_combined_app (bifrost-dev confirmed, ~v0.9.0, design locked) AND gated on the open FR-1 Worldtree-dispatch question (worldtree-dev consult in flight). When build_combined_app lands and FR-1 resolves, this contract is AMENDED to add Deliverable 1. Direct in-session TDD (the #17 pattern). The panel framing-consult (Heid, 3 arms) pressure-tested this design; its triaged findings are folded in as INV/POST clauses below."
scope: "Issue #18 — BOTH deliverables. DELIVERABLE 2 (SHIPPED v0.17.14): the PAD read-endpoint so the web pane renders live PAD/valence for a Tier-3 agent from OUR :8390 affect store (1) a NON-bifrost read route on the affect-store-owning app — GET /affect/state/{agent_id}?end_user_id=… → store.get; (2) a web proxy GET /api/affect/{agent_id} that supplies end_user_id SERVER-SIDE; (3) a NEW pane render path for the affect-emit snapshot shape. DELIVERABLE 1 (composite endpoint, NOW IN SCOPE — amended 2026-06-19): bifrost 0.10.0 shipped the public bifrost.consumer.build_combined_app and FR-1 RESOLVED (worldtree-dev verified one BifrostClient per session, caps_granted parsed INDEPENDENTLY into memory+affect sets, both stores attach off the SAME endpoint iff their cap was granted — ZERO Worldtree change). D1 = build_combined_provider_app fronting BOTH planes on :8392, advertising both caps by store PRESENCE, mounting the SAME affect read route (INV-007), with the op-feed deriving plane PER request path (plane='combined'); per-plane failure isolation is bifrost's (per-route call-time dispatch isolation in one ASGI process). Direct in-session TDD (the #17 pattern). The panel framing-consult (Heid, 3 arms) pressure-tested this design; its triaged findings are folded in as INV/POST clauses below."
depends_on:
- "httpx"
- "starlette"
- "ratatoskr.provider.affect_store"
- "ratatoskr.provider.memory_store" # D1: the composite fronts the memory plane too
- "ratatoskr.provider.opfeed" # D1: op-feed plane='combined' (per-path derivation)
- "ratatoskr.web.server"
- "bifrost.consumer" # D1: build_combined_app (bifrost >=0.10.0)
used_by:
- "ratatoskr.provider.serve"
- "ratatoskr.web.entrypoint"
@@ -236,11 +239,139 @@ prior emit for `ratatoskr:sindra` / the configured end_user, open the web pane o
→ the pane renders live PAD + valence + `emitted_at` from OUR store (no "telemetry isn't
exposed"); on a fresh (agent,user) with no emit → the explicit empty-state, not a zeroed PAD.
## Deliverable 1 — composite endpoint (`build_combined_app`)
### Context
One bound Worldtree session that both remembers (memory.*) AND shows live PAD
(affect.*). bifrost 0.10.0 ships `bifrost.consumer.build_combined_app(memory_store,
affect_store, verifier, registration, maintenance_store=None) -> ASGIApp`: ONE app
exposing handshake + `/bifrost/memory-call` + `/bifrost/affect-call` (no legacy
`/bifrost/tool-call`), advertising BOTH caps by store PRESENCE. FR-1 is resolved:
Worldtree runs one `BifrostClient` per session off a single `_endpoint_url`, parses
`capabilities_granted` independently into memory+affect sets, and attaches each store
iff its cap was granted — so a single `:8392` endpoint advertising both caps drives
both planes with ZERO Worldtree change. D1 is bifrost-only on our side: compose the
combined app + mount our existing affect read route + derive the op-feed plane per
path. It is ADDITIVE — the standalone `:8390`/`:8391` apps are unchanged.
### Public surface (D1)
```python
# ratatoskr.provider.combined — a NEW module (the composite spans both planes, so it
# belongs in neither store module).
def build_combined_provider_app(
memory_store: RatatoskrMemoryStore,
affect_store: RatatoskrAffectStore,
heimdall_key: bytes,
consumer_id: str = "ratatoskr",
):
"""Wire the JWT verifier + registration, hand BOTH stores to
bifrost.consumer.build_combined_app, then mount the SAME non-bifrost affect read
route (the shared helper) as a top-level sibling. Returns a Starlette app exposing
/bifrost/handshake + /bifrost/memory-call + /bifrost/affect-call + GET
/affect/state/{agent_id}. See FN build_combined_provider_app."""
# ratatoskr.provider.affect_store — the read route is extracted into a shared helper
# so both build_affect_provider_app and build_combined_provider_app mount the SAME one.
def add_affect_read_route(app, store: RatatoskrAffectStore) -> None: ...
# ratatoskr.provider.serve_combined — `ratatoskr-combined-provider` console script,
# :8392. Opens BOTH affect.db + memory.db stores; wires the op-feed with plane='combined'.
```
### Invariants (D1)
- **INV-009 (both stores REQUIRED).** `build_combined_provider_app` requires a real
memory_store AND affect_store; bifrost's `build_combined_app` raises `ValueError`
if either is None (single-plane consumers use `build_affect_app`/`build_memory_app`).
We pass our real SQLite-backed stores; no in-memory default.
- **INV-010 (advertise BOTH caps by store PRESENCE).** The combined handshake grants
`memory` and `affect` by the presence of each advertising store (memory needs
`describe_store`; affect needs `affect_supported` + `emit` + `fetch`, strong-or-absent
— see the affect-provider contract INV-010) — NOT a runtime health probe. The affect
cap therefore depends on Deliverable-prerequisite `affect.fetch` already shipped.
- **INV-011 (SAME affect read route, shared helper).** The composite mounts the
identical `GET /affect/state/{agent_id}` route over the SAME affect store, via the
shared `add_affect_read_route` helper — NOT a composite-only reimplementation
(fulfils the D2 INV-007 promise). The pane reads it through `RATATOSKR_AFFECT_READ_URL`
regardless of whether the bound endpoint is `:8390` or `:8392`.
- **INV-012 (op-feed plane derived PER request path).** On the composite, the op-feed
cannot use a fixed `plane` — both planes share one app. With `plane='combined'` it
derives the OpEvent plane from `scope['path']`: `/bifrost/memory-call``memory`,
`/bifrost/affect-call``affect`, `/bifrost/handshake``combined`. The per-verb
summary logic already keys on path, so memory/affect summaries stay correct; this is
purely the plane STAMP. The non-bifrost read route stays outside `_BIFROST_PATHS`
(no OpEvent), unchanged.
- **INV-013 (per-plane failure isolation is bifrost's, honest).** Failure isolation is
per-route CALL-TIME dispatch isolation within ONE shared ASGI process — a memory-call
failure does not corrupt an affect-call and vice-versa. Bind-time + process-crash are
SHARED domains (one process), not independent services; the contract does not claim
otherwise. We add no isolation layer of our own.
- **INV-014 (additive — standalones unchanged).** `:8392` is a NEW endpoint alongside
`:8390`/`:8391`; `build_affect_provider_app`/`build_memory_provider_app` and their
serve entrypoints are untouched. The composite + a standalone may open the SAME
`affect.db` (two processes) — hence the affect store's `busy_timeout` (D2 INV-006).
### Function contracts (D1)
```contract
FN add_affect_read_route(app, store: RatatoskrAffectStore) -> None
BRIEF: Mount the non-bifrost GET /affect/state/{agent_id} read route on `app` (shared by the affect-only and combined apps). Extracted from build_affect_provider_app verbatim (INV-011 / D2 INV-007).
POST: [POST-001 side_effect] app gains a top-level GET /affect/state/{agent_id} route reading store.get -- assert route present
POST: [POST-002 side_effect] /bifrost/* routes remain top-level (the helper only adds; never Mounts) so the op-feed path-check still matches them (D2 INV-004) -- assert
STEPS:
1. define _affect_state_route closing over store (PRE: end_user_id present → else 400 missing_end_user_id; store.get None → 404 no_affect_snapshot; else 200 snap verbatim)
2. app.add_route('/affect/state/{agent_id}', _affect_state_route, methods=['GET'])
```
```contract
FN build_combined_provider_app(memory_store: RatatoskrMemoryStore, affect_store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> ASGIApp
BRIEF: Compose bifrost.consumer.build_combined_app over BOTH stores + mount the shared affect read route — one app fronting both planes plus the PAD read.
PRE: [PRE-001 hard] affect_store.affect_supported is True -- else ValueError (INV-010)
PRE: [PRE-002 hard] heimdall_key is non-empty bytes -- else ValueError
POST: [POST-001 return_value] returns a Starlette app exposing /bifrost/handshake + /bifrost/memory-call + /bifrost/affect-call + GET /affect/state/{agent_id} -- assert routes present
POST: [POST-002 return_value] a combined handshake requesting [memory, affect] is granted BOTH caps (store presence, INV-010) -- assert
POST: [POST-003 return_value] both a memory-call and an affect-call dispatch through the one app (parity vs the standalone apps' behavior) -- assert
STEPS:
1. guard PRE-001/002; SET verifier = JwtVerifier(HS256, heimdall_key); SET registration = ConsumerRegistration(consumer_id)
2. SET app = bifrost.consumer.build_combined_app(memory_store, affect_store, verifier, registration)
3. add_affect_read_route(app, affect_store); RETURN app
TESTS:
builds_both_planes [happy,tracer]: valid stores + key → app with handshake + memory-call + affect-call + /affect/state routes
handshake_grants_both [scenario]: handshake requesting [memory, affect] → capabilities_granted contains BOTH (INV-010)
memory_and_affect_dispatch [scenario]: a memory search + an affect emit both succeed through the one app via dispatch JWTs (INV-013)
affect_read_route_on_composite [happy]: seeded affect store → GET /affect/state/{colon-id} returns the snapshot (INV-011)
missing_affect_store [adversarial]: affect_store=None → ValueError (bifrost INV-001)
```
```contract
FN serve_combined.main() -> None
BRIEF: `ratatoskr-combined-provider` entrypoint — open both stores, build the combined app, wire the op-feed (plane='combined'), serve on :8392.
STEPS:
1. open_affect_store(RATATOSKR_AFFECT_DB) + open_memory_store(RATATOSKR_MEMORY_DB)
2. app = build_combined_provider_app(memory_store, affect_store, heimdall_key, consumer_id)
3. app = maybe_instrument_from_env(app, env, plane='combined') -- op-feed derives plane per path (INV-012)
4. uvicorn.run(app, host, port=8392)
TESTS:
(serve wiring is exercised by the unit tests for build_combined_provider_app + the op-feed plane='combined' tests; the uvicorn.run line is a thin shell, smoke-only)
```
### Acceptance (D1)
Unit (in-process, dispatch JWTs via `bifrost.core.dispatch_jwt.mint_dispatch_jwt` — the #17 posture):
1. `build_combined_provider_app` → app with all four routes; handshake grants both caps.
2. a memory `search` + an affect `emit` both dispatch through the one app (INV-013).
3. the affect read route works on the composite for a colon-id (INV-011).
4. `affect_store=None` → ValueError (INV-009).
5. op-feed `plane='combined'`: a memory-call stamps `plane='memory'`, an affect-call stamps `plane='affect'`, a handshake stamps `plane='combined'` (INV-012); the read route emits NO OpEvent.
Live-smoke (manual, the repo's posture): start `:8392`, bind a Tier-3 session to it, drive a turn → the op-feed shows BOTH a memory op and an affect emit at the bound session_id; the web pane (pointed at `:8392` via `RATATOSKR_AFFECT_READ_URL`) renders live PAD. Then ping bifrost-dev that the composite landed.
## Out of scope / DEFERRED (anti-creep)
- **Deliverable 1 — composite :8392 endpoint** — bifrost-blocked (public `build_combined_app`,
~v0.9.0, design locked) + FR-1 (Worldtree dual-plane dispatch, worldtree-dev consult in
flight). Added by amendment when both resolve. This is the SAME issue, not a new one.
- **Deliverable 1 — composite :8392 endpoint** — RESOLVED: now in scope, see
§ *Deliverable 1* above (bifrost 0.10.0 `build_combined_app` shipped + FR-1 resolved).
- WT #289 mediated affect-read (`affect.fetch` over bifrost) — we own the store, read it
directly; no Worldtree dependency.
- Production hardening (TLS/RS256 on the read route; auth on /affect/state) — internal-LAN
+63 -36
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-06-18_
_Last updated: 2026-06-20_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -39,37 +39,41 @@ upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-06-18 (PM):_
_As of 2026-06-20:_
**#17 SHIPPED END-TO-END + LIVE-SMOKE PROVEN.** Bifrost-binding the chat client
(self-drive + observe) is DONE across CLI/TUI/web — 6 commits `v0.17.8``v0.17.13`,
full suite **470 green**, **NOT pushed** (operator's call). Slices: (1) `create_session`
bind primitive (`7be162e`); (2) dispatch-layer op-feed `ratatoskr.provider.opfeed`
(`8ebe227`); (3a) CLI `--bifrost-plane`/`--bifrost-url` (`0bebad7`); (3b) TUI
pre-alt-screen (`016defc`); (3c) web server-side bind + UI plane selector
(`2806aba`+`179a8df`). **Live smoke:** a bound CLI→sindra session vs personal `:8081`
→ handshake 200 → op-feed captured 2 recall searches with the EXACT bound session_id
(`2c0c7482`) carrying `scope_any=[{end_user},{agent_self:ratatoskr:sindra}]` @ top_k=128
(the #297/#298 union recall, observed provider-side). #17's whole thesis validated:
ratatoskr owns both ends → sees the round-trip.
**#17 and #18 BOTH CLOSED — the composite both-plane binding is fully proven.** #18 shipped
`v0.18.0` (`359dbb1`): D2 (PAD read-endpoint, `v0.17.14`) renders live PAD in the web pane from our
`:8390` store; D1 (composite endpoint, `v0.17.16` `7f4ceaa`) — `build_combined_provider_app`
(`provider/combined.py`) on `:8392` wraps bifrost's public `build_combined_app` over BOTH stores +
the shared affect read route; one bound session drives memory.* AND affect.* through ONE endpoint,
op-feed deriving plane per path. Suite **503 green**. **#17 closed in the tracker 2026-06-20**
(shipped `v0.17.8``.13` + the `v0.17.17` op-feed field fix).
**OPERATOR SESSION STATE — background shells UP:** web `:8765` bind-configured
(consumer key + `RATATOSKR_PROVIDER_VISIBLE_HOST=10.100.10.50`, sindra + plane
selector live); memory provider `:8391` + affect provider `:8390` running WITH the
op-feed (`/tmp/opfeed-{memory,affect}.jsonl`); althing light-monitor armed (not signed
off). Consumer/owner key = `wt_live_d81b…`. Providers: SQLite + sqlite-vec, separate
DB per plane (`memory.db` / `affect.db` at repo root).
**#18's final leg — the Worldtree-DRIVEN composite turn — RAN and is PROVEN end-to-end + persisted
(2026-06-20).** infra-ops added `10.100.10.50:8392` to the personal WT's (`:8081`)
`BIFROST_CLIENT_ALLOWED_HOSTS` (thread `01KVHWJGTT…`), unblocking the smoke. A real WT turn through
`:8392` (session `b83a66b6`, agent `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) drove the
FULL both-plane lifecycle on ONE endpoint, caps-routed by path: `handshake`
(`caps_granted=[memory, affect]`) → `affect.fetch` + `memory.search` (reads) → `affect.emit`
(`stored:true`, PAD row in `affect_snapshots`) → `memory.upsert_many` (`upserted:1`, chunk
`2df1b79de761b948` in `memory_chunks`). Both writes verified directly in our SQLite. The
model-backend outage that blocked the first attempt (both agents' models `model_unavailable`) was
operator-fixed mid-session, then the resmoke completed clean. **No open legs remain on the composite.**
**PERSONA TELEMETRY GAP → #18 (the live ask).** The affect bind WORKS — PAD persists
to our `:8390` store (vuong session: pleasure +0.146, familiarity climbing 0.18→0.59
over 8 turns). But the web persona pane shows "telemetry isn't exposed" because it
reads Worldtree `persona_state` (`loadPersona` index.html:707), which 404s for Tier-3
(ADR-0009 Tier-1-only), AND a Tier-3 turn emits **zero `affect_update` SSE** (wire-
verified). Both Worldtree-side sources are dead for consumer agents; the pane was never
wired to render PAD from OUR store. **#18 filed** (composite endpoint + PAD read-endpoint).
NEXT proposed: fast-track #18's small PAD-display half (provider read-endpoint → pane
renders our store) so telemetry shows now — **awaiting operator go**; composite-endpoint
half stays contract-first.
**bifrost repinned 0.8.0 → 0.10.0** (floor, `provider` extra). 0.10.0 made `affect.fetch`
MANDATORY (strong-or-absent: `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch`,
gating EVERY affect op incl. emit) — so the repin FORCED `affect.fetch` (`v0.17.15`, conformed
to bifrost's reference `InMemoryAffectStore.fetch``{found, snapshot?}`) or our shipped affect
plane would 400. The composite's affect cap depends on it.
**OPERATOR SESSION STATE — `:8390`/`:8391`/`:8765` shells are PRE-#18 code (foot-gun).** web `:8765`
+ affect `:8390` + memory `:8391` are prior-session background shells on OLD code. The **`:8392`
composite provider is RUNNING on NEW code** (`ratatoskr-combined-provider`, pid started Jun19,
`RATATOSKR_OPFEED_PATH=/tmp/ratatoskr-combined-opfeed.jsonl`, shared `affect.db`/`memory.db`) — now
`:8392`-allowlisted and WT-turn-proven. To see the full web stack on new code, RESTART `:8390`/`:8765`
from current code (D2 web needs `RATATOSKR_AFFECT_READ_URL`). Consumer/owner key = `wt_live_d81b…`
(`~/.config/ratatoskr/provider.env`, mode 600, rotate via infra-ops); providers SQLite + sqlite-vec,
`memory.db`/`affect.db` at repo root (live sindra PAD: vuong + the `resmoke-choco-1` smoke fixture).
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
@@ -82,14 +86,17 @@ linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
`GET /agents/<owner>:<name>` with the owner key.
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); bifrost 0.8.0 / wire v0.6
(`scope_all`+`scope_any`); WT handshake now advertises `bifrost_version 0.6.0` (worldtree-dev
honesty-fix FYI `858ba58` — we don't pin/assert it, no-op our side). Heimdall key env-only
at `~/.config/ratatoskr/provider.env` (mode 600); rotate via infra-ops. `graphify-out/`
runs dirty (auto-regen, not chased). Open issues: #10 (subject migration), #11 (AdminEvents
pane), **#18** (composite + PAD-read) — all deferred. Codex-first pilot dormant.
**Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); **bifrost 0.10.0 / wire v0.6**
(`scope_all`+`scope_any`). Heimdall key env-only at `~/.config/ratatoskr/provider.env` (mode
600); rotate via infra-ops. `graphify-out/` runs dirty (auto-regen, not chased). **Open issues:
#11** (AdminEvents pane — the next-reachable Worldtree-I/O coverage gap, blocked on an
`admin.events.read` scope request) and **#10** (subject-migration watch on Worldtree #196) — both
deferred. **#17 + #18 CLOSED.** Codex-first pilot dormant. No in-flight implementation work — repo
is at a converged checkpoint; v1 advances when Worldtree does (v1 = full Worldtree I/O coverage).
Branch: `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
Branch: `main` (tag `v0.18.0`, `359dbb1`) — **in sync with `origin/main`** (the full #17+#18 arc is
pushed). This `/snapshot` commit will sit one ahead of origin until pushed (push is the operator's
call). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
## Recent decisions
@@ -119,6 +126,18 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-06-18]` **#17 live-smoke PROVEN — the whole thesis validated.** A self-driven bound CLI session showed, from the PROVIDER side, exactly which memory ops a turn produced (2 recall searches, exact bound session_id, real union-recall scopes). Negative (canary→auth_rejected) NOT live-constructible (Tier-1 agents aren't memory-bindable; a wrong key for an owner-scoped agent fails at agent-auth before the handshake) — covered by the unit test + prior hand-proof.
- `[2026-06-18]` **Fixed a pre-existing test-isolation bug exposed by the #17 CLI tests** (`0bebad7`): `test_no_textual_import` did a live `importlib.reload(ratatoskr.cli)` that mutated the shared module in place, breaking class identity (`isinstance`/`pytest.raises`) for every test ordered after it. The real check is the static source-grep; the reload was vestigial → removed. Lesson: never `importlib.reload` a shared module in a test without restoring it.
- `[2026-06-18]` **#18 filed (composite endpoint + PAD read-endpoint) — DEFERRED, tracked at Gitea #18.** Two pieces: (1) a composite Bifrost facade (new port e.g. `:8392`) fronting BOTH `:8390`+`:8391` advertising both caps at handshake → one session binds both planes (un-parks the #17 open-q; bifrost reference_server already mounts both planes in one app → thin combined builder; needs per-plane failure-status + the op-feed deriving plane PER-REQUEST from the path instead of its fixed `plane` param). (2) a non-bifrost PAD read-endpoint on the affect provider (recommended over web-reads-`affect.db`-directly) → web persona pane renders PAD/valence from OUR `:8390` store. **Composite half APPROVED by operator ("A is correct"); contract-first next.** **Persona-telemetry diagnosis (verified):** affect bind persists PAD (vuong: pleasure +0.146, familiarity 0.18→0.59 over 8 turns) but the pane reads Tier-3-404 `persona_state` AND Tier-3 emits ZERO `affect_update` SSE (wire-verified) — both WT sources dead, so #18's PAD-display half is the only path. `affect.fetch` over bifrost is RESERVED/blocked but irrelevant (we own the store). Proposed: fast-track the PAD-display half now (awaiting operator go), keep composite contract-first.
- `[2026-06-18]` **#18 SPLIT; Deliverable 1 (composite) routed to bifrost — Option C (operator).** D2 (PAD read-endpoint, our-side only) fast-tracked; D1 (composite `:8392` endpoint) routed to bifrost-dev to add a PUBLIC `build_combined_app` rather than hand-roll one from bifrost privates — because ratatoskr is a debug surface that must exercise the CANONICAL surface ("don't go off the reservation"). The Heid framing-panel had unanimously recommended hand-rolling (Option B) — DISCARDED as wrong-grounded (the panel lacked the canonical-surface principle; their own finding that B reaches external/underscore-private names actually vindicated C). bifrost-dev confirmed: clean additive minor (~`v0.9.0`), design locked (advertise-by-store-PRESENCE handshake — no health probe; per-route call-time isolation within a shared ASGI process), slotted after WT #289. [principle → auto-memory `feedback-debug-surface-uses-canonical-surface-only`]
- `[2026-06-18]` **FR-1 RESOLVED — the composite premise was unverified, now wire-proven: single-endpoint, caps-routed.** The Heid panel's sharpest catch (Regin): "advertise both caps → Worldtree dispatches both planes to one endpoint" was an ASSUMPTION about WT dispatch, stated as fact. worldtree-dev verified IN CODE: one `BifrostClient` per session (single `_endpoint_url`), handshake `capabilities_granted` parsed INDEPENDENTLY into memory+affect sets, both stores attach off the SAME endpoint iff their cap was granted (`service.py:2597/2703-2713/2745-2751`, `bifrost_client.py ~357-369`; tests `test_tier3_bifrost_{memory,affect}_routing.py`). So D1 is **bifrost-only, ZERO Worldtree change**#18's "no WT change needed" assumption was correct.
- `[2026-06-18]` **#18 D2 implemented via direct in-session TDD (suite 470→482).** Provider read route `GET /affect/state/{agent_id}` added via `app.add_route` (NOT an outer `Mount` — keeps `/bifrost/*` top-level so the existing route test + the op-feed path-check stay valid); web `GET /api/affect/{agent_id}` proxy (server-supplied `end_user_id`, colon-id `quote()`'d, `RATATOSKR_AFFECT_READ_URL`); pane renders the affect-emit shape honestly. Contract `docs/contracts/issues/18.contract.md` (D2-scoped; D1 deferred). **heid-code-review panel (Gróa 5 / Hulda 3 / Regin 0): 1 real INV-001 drift + 4 test-gaps, all fixed.** No contract amendments (code was wrong, contract was right).
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin.** Live-smoke PROVEN against real data (throwaway `:8393`/`:8766` vs the real `affect.db` → real sindra/vuong PAD through the full web→provider chain; Playwright DOM check confirmed the pane render + the F1 fix — no fabricated "neutral"). The push carried 9 previously-held commits incl. the deliberately-unpushed #17 (`v0.17.8``v0.17.13`); origin/main now == `39eebd1`, tag `v0.17.14`.
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent).** 0.10.0's `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch` and gates EVERY affect op — an emit-only store 400s. Implemented `affect.fetch` (`v0.17.15`, `ca6af6b`) conformed to bifrost's reference `InMemoryAffectStore.fetch` (`{found, snapshot?}`): the forced D1 prerequisite + a new Worldtree I/O point consumed. Flagged the now-stale consumer-guide line to bifrost-dev (fixed `a2e6d62`).
- `[2026-06-19]` **#18 D1 SHIPPED — composite `build_combined_app` on `:8392` (`v0.17.16`, `7f4ceaa`); #18 CLOSED; published `v0.18.0` (`359dbb1`).** `build_combined_provider_app` wraps bifrost's public builder over both stores + the shared read route; op-feed `plane='combined'` per-path. Direct in-session TDD; heid-code-review panel (Gróa/Hulda/Regin) returned ZERO drift. Live-proven at wire+dispatch; WT-turn gated on infra-ops `:8392` allowlist.
- `[2026-06-19]` **op-feed handshake field-name fix (#17, `v0.17.17` `d60b77d`): `capabilities_requested`→`capabilities`.** The summary read a field that never exists on the wire (bifrost reads `capabilities`, `_protocol.py:181`) → caps_requested was always null. Surfaced by the heid panel (Regin) during the D1 review — a latent #17 bug, not D1 drift.
- `[2026-06-19]` **Ratatoskr is a REFERENCE implementation of the Worldtree/Bifrost standard (operator).** Adopt the dep's canonical way (even if ours works); INFORM of drift/gaps; ADVISE a different approach only when ours is genuinely better (dep owner decides), never unilaterally fork. [auto-memory `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
- `[2026-06-19]` **Ratatoskr v1 is DERIVED from Worldtree I/O coverage (operator) — no self-defined feature ROADMAP.** v1 = consume all of Worldtree's I/O points, reached when Worldtree hits 1.0; the convergence target is a coverage map, not a 37 capability list. [auto-memory `project-ratatoskr-v1-derived-from-worldtree-io-coverage`]
- `[2026-06-20]` **#18's final leg PROVEN — composite `:8392` WT-driven smoke ran end-to-end + persisted.** infra-ops allowlisted `10.100.10.50:8392` on the personal WT (`01KVHWJGTT…`); a real WT turn (session `b83a66b6`, `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) dispatched the full both-plane lifecycle through ONE endpoint — handshake (both caps) → `affect.fetch` + `memory.search``affect.emit` (`stored:true`) → `memory.upsert_many` (`upserted:1`) — both writes verified in our SQLite (`affect_snapshots` PAD row + `memory_chunks` chunk `2df1b79…`). First attempt blocked by a `model_unavailable` outage on the personal WT (both agents' models down), operator-fixed mid-session, then clean. The composite has no open legs.
- `[2026-06-20]` **#17 CLOSED in the tracker.** Shipped end-to-end (`v0.17.8``.13` + op-feed field fix `v0.17.17`); the 2026-06-20 composite smoke re-exercised its op-feed live. Closing comment captures the full both-plane proof. Open issues now just #11 (scope-blocked) + #10 (watch).
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
@@ -146,5 +165,13 @@ defense against re-attempting the same cul-de-sac.
- `[2026-06-17]` **"Promotion didn't fire → #296" was PREMATURE — twice over.** (1) Polled the op-feed only ~2min, but the upsert landed at ~4min — promotion is async + multi-trigger; watch a longer window. (2) It DID fire; the real bug is extraction QUALITY, not non-firing. "No upsert while a session is live and `<10min` idle" is WAD.
- `[2026-06-18]` **Wiping our `:8391` store does NOT reset Worldtree's promotion-side dedup** — a same-agent re-smoke returned `reason_code=noop_duplicate` / `candidate_count=0`: the extractor NEVER RE-RAN, dedup short-circuited against an earlier promotion. **For a clean promotion smoke, use a BRAND-NEW agent + end_user (never-used names).** (Also: `llm_calls_used=0` is NOT the "did the extractor run" tell — `noop_duplicate` is.)
- `[2026-06-18]` **`affect.emit` is POST-TURN ASYNC — checking the op-feed immediately after a turn MISSES it.** The Tier-3 affect appraise→emit→rehydrate loop runs AFTER the SSE `[done]`; the emit lands in our `:8390` store seconds later (op-feed grep right after `[done]` showed only the handshake; the `emit stored:true` appeared on a later read). Same family as the async-promotion timing trap. Watch a few-second window post-turn before concluding "no affect emitted." Also wire-verified the same turn: Tier-3 sindra emits ZERO `affect_update` SSE (the persona-strip SSE path never populates for consumer agents) — see the #18 PAD-display decision.
- `[2026-06-18]` **Rationalized away a KNOWN contract-invariant deviation during TDD — only the cross-model code-review caught it.** #18 D2's `loadAffect` called `setPersonaStrip(snap)`, which renders `dominant_emotion || "neutral"`; the affect snapshot has no `dominant_emotion`, so it fabricated a "neutral" emotion — violating the very INV-001 ("no synthesized Tier-1 fields") I had WRITTEN. I knew the strip did this and talked myself into it as acceptable. Neither the design panel nor TDD caught it (unit tests don't exercise the JS render); the post-implementation `/heid-code-review` did (Gróa + Hulda both). **Lesson: a known deviation from a contract invariant is drift even when you've rationalized it — flag it, don't argue yourself past it; the post-implementation cross-model review is the backstop for author-rationalized drift, distinct from the design-stage panel.**
- `[2026-06-18]` **Latent SQLite thread-safety bug in the affect store, surfaced ONLY by the new HTTP read route.** `open_affect_store` created the connection without `check_same_thread=False`; the bifrost emit path never tripped it (uvicorn's loop ran on the connection's creating thread), but the `TestClient`-driven read route runs handlers off a worker thread → `sqlite3.ProgrammingError`. Fix: `check_same_thread=False` (safe — the event loop serializes access) + explicit `PRAGMA busy_timeout=5000` (don't rely on sqlite3's `timeout=5.0` default). **Lesson: a sqlite-backed ASGI app needs `check_same_thread=False`; the HTTP-layer test exposed what the direct-store-method tests structurally couldn't.**
- `[2026-06-19]` **The SAME `check_same_thread` sqlite bug recurred in the MEMORY store — exposed by the contract-mandated `search` dispatch test (TestClient = worker thread).** Heid's test-fidelity finding (the D1 dispatch test used `describe_store` where the contract says `search`) → fixing it to `search` tripped `sqlite3.ProgrammingError` because `open_memory_store` also lacked `check_same_thread=False`. Fixed (mirrors affect INV-006). **Lesson: this bug is PER-STORE — every sqlite-backed ASGI store needs `check_same_thread=False`; an HTTP-layer (TestClient) test exposes what direct-store tests can't, and the composite serving memory over HTTP makes it bite.**
- `[2026-06-19]` **Full WT-driven `:8392` live-smoke is infra-gated — `:8392` not in WT's `BIFROST_CLIENT_ALLOWED_HOSTS` (bind 422s).** New provider ports are NOT auto-allowlisted (only `:8390/:8391` are). Self-driven dispatch (minted consumer-key JWTs → `:8392`) is the wire-proof; the WT-turn needs infra-ops to add `:8392` (requested `01KVHWJGTT…`).
- `[2026-06-19]` **heid-code-review pulled MORE weight than its own "marginal" self-assessment.** The panel returned zero drift, but its single test-fidelity finding CASCADED into 2 real latent-bug fixes when applied (the memory `check_same_thread` bug + Regin's op-feed field-name bug). **Lesson: a contract-fidelity nudge can transitively expose bugs the test never reached — don't dismiss a "marginal" finding by its count.**
- `[2026-06-20]` **The post-turn-async timing trap bit AGAIN — even a 35s post-`[done]` read missed the promotion `upsert_many` by ~2s** (it landed `19:48:58`; the read was ~`19:48:56`). A 15s-interval background poll caught it on the first tick. Same family as the affect.emit / async-promotion traps already logged — re-confirmed that "wait once then read" is fragile for post-turn writes; **poll a window, don't snapshot once.** (The affect.emit write, by contrast, DID land inside the 35s window — promotion is the slower of the two post-turn writes.)
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
+3 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.17.14"
version = "0.18.2"
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.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==1.0.0", # consumer engines + library. 1.0.0 = first STABLE release, wire v0.6 FROZEN (non-breaking repin from >=0.10.0; build_combined_app #18 + mandatory affect.fetch; 0.8.0/v0.6 scope_all/scope_any #11; 0.7.0/v0.5 agent_self)
"jsonschema>=4", # bifrost runtime dep — envelope validation
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
]
@@ -50,6 +50,7 @@ ratatoskr = "ratatoskr.cli:main"
ratatoskr-web = "ratatoskr.web.entrypoint:main"
ratatoskr-provider = "ratatoskr.provider.serve:main"
ratatoskr-memory-provider = "ratatoskr.provider.serve_memory:main"
ratatoskr-combined-provider = "ratatoskr.provider.serve_combined:main"
[project.urls]
Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
+58 -25
View File
@@ -102,13 +102,39 @@ class RatatoskrAffectStore:
return {"stored": True}
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(
"SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?",
(agent_id, end_user_id),
).fetchone()
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:
"""Open the SQLite-backed affect store, creating the schema on first use."""
@@ -136,6 +162,33 @@ def open_affect_store(db_path: str) -> RatatoskrAffectStore:
return RatatoskrAffectStore(conn)
def add_affect_read_route(app, store: RatatoskrAffectStore) -> None:
"""Mount the non-bifrost PAD read route GET /affect/state/{agent_id} on `app`,
reading store.get. SHARED by build_affect_provider_app and the combined provider
(#18 INV-011 / D2 INV-007) — add_route (NOT Mount) keeps /bifrost/* top-level so
the op-feed path check still matches them and passes this route through untouched.
No JWT (internal-LAN trust model).
"""
async def _affect_state_route(request: Request) -> JSONResponse:
agent_id = request.path_params["agent_id"]
end_user_id = request.query_params.get("end_user_id")
if not end_user_id: # PRE-001: never look up against a None/empty partition
return JSONResponse({"error_code": "missing_end_user_id"}, status_code=400)
snap = store.get(agent_id, end_user_id)
if snap is None: # INV-003: explicit no-data, never a fabricated zeroed PAD
return JSONResponse(
{
"error_code": "no_affect_snapshot",
"agent_id": agent_id,
"end_user_id": end_user_id,
},
status_code=404,
)
return JSONResponse(snap)
app.add_route("/affect/state/{agent_id}", _affect_state_route, methods=["GET"])
def build_affect_provider_app(
store: RatatoskrAffectStore,
heimdall_key: bytes,
@@ -154,28 +207,8 @@ def build_affect_provider_app(
registration = ConsumerRegistration(consumer_id=consumer_id)
app = build_affect_app(store=store, verifier=verifier, registration=registration)
# Issue #18 (Deliverable 2): a NON-bifrost PAD read route added as a top-level
# sibling of the bifrost routes (add_route, not Mount — keeps /bifrost/* top-level
# so the op-feed's path check still matches them and skips this one). Internal-LAN
# trust model: no JWT on the read.
async def _affect_state_route(request: Request) -> JSONResponse:
agent_id = request.path_params["agent_id"]
end_user_id = request.query_params.get("end_user_id")
if not end_user_id: # PRE-001: never look up against a None/empty partition
return JSONResponse(
{"error_code": "missing_end_user_id"}, status_code=400
)
snap = store.get(agent_id, end_user_id)
if snap is None: # INV-003: explicit no-data, never a fabricated zeroed PAD
return JSONResponse(
{
"error_code": "no_affect_snapshot",
"agent_id": agent_id,
"end_user_id": end_user_id,
},
status_code=404,
)
return JSONResponse(snap)
app.add_route("/affect/state/{agent_id}", _affect_state_route, methods=["GET"])
# Issue #18 (Deliverable 2): mount the non-bifrost PAD read route. Extracted into
# add_affect_read_route so the combined provider mounts the SAME one (Deliverable 1,
# INV-011) over the same affect.db.
add_affect_read_route(app, store)
return app
+48
View File
@@ -0,0 +1,48 @@
"""Combined Bifrost provider (issue #18 Deliverable 1): ONE ASGI app fronting BOTH
the memory.* and affect.* planes, so a single bound Worldtree session both remembers
AND shows live PAD.
Contract: docs/contracts/issues/18.contract.md (§ Deliverable 1)
Wraps `bifrost.consumer.build_combined_app` (bifrost >=0.10.0) over our real
SQLite-backed stores and mounts the SAME non-bifrost affect read route as the
standalone affect provider (the shared `add_affect_read_route` helper, INV-011). The
composite advertises both caps by store PRESENCE at the handshake; per-plane failure
isolation is bifrost's per-route call-time dispatch isolation (INV-013). It is
ADDITIVE the standalone :8390/:8391 apps are unchanged (INV-014).
"""
from __future__ import annotations
from bifrost.consumer import ConsumerRegistration, build_combined_app
from bifrost.reference_server import JwtVerifier
from ratatoskr.provider.affect_store import RatatoskrAffectStore, add_affect_read_route
from ratatoskr.provider.memory_store import RatatoskrMemoryStore
def build_combined_provider_app(
memory_store: RatatoskrMemoryStore,
affect_store: RatatoskrAffectStore,
heimdall_key: bytes,
consumer_id: str = "ratatoskr",
):
"""Compose `build_combined_app` over BOTH stores + mount the shared affect read
route. Returns a Starlette app exposing POST /bifrost/handshake +
/bifrost/memory-call + /bifrost/affect-call + GET /affect/state/{agent_id}.
Both stores are REQUIRED (INV-009): bifrost's build_combined_app raises if either
is None. The affect cap depends on the affect store advertising affect_supported +
emit + fetch (strong-or-absent, INV-010) guarded here at build time so a
misconfigured store fails fast rather than silently withholding the cap.
"""
if getattr(affect_store, "affect_supported", False) is not True: # PRE-001 / INV-010
raise ValueError("affect_store must advertise affect_supported=True")
if not (isinstance(heimdall_key, bytes) and heimdall_key): # PRE-002
raise ValueError("heimdall_key must be non-empty bytes")
verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
registration = ConsumerRegistration(consumer_id=consumer_id)
# build_combined_app validates memory_store/affect_store presence (INV-009, raises
# ValueError on None) and mounts handshake + memory-call + affect-call (no tool-call).
app = build_combined_app(memory_store, affect_store, verifier, registration)
add_affect_read_route(app, affect_store) # INV-011: the SAME read route, same db
return app
+7 -1
View File
@@ -330,10 +330,16 @@ def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemorySto
"""Open the SQLite+sqlite-vec memory store, creating schema + the vec index on first use."""
if not (isinstance(embedding_dim, int) and embedding_dim > 0): # PRE-002
raise ValueError("embedding_dim must be a positive int")
conn = sqlite3.connect(db_path)
# check_same_thread=False: the memory provider is an ASGI app; uvicorn/Starlette
# (and TestClient always) may run a handler off the connection's creating thread.
# The event loop serializes the sync sqlite calls, so this is safe. Mirrors the
# affect store (bifrost_affect_provider INV-006); surfaced by a TestClient-driven
# memory-call search through the combined provider (#18 D1).
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.enable_load_extension(True)
sqlite_vec.load(conn)
conn.enable_load_extension(False)
conn.execute("PRAGMA busy_timeout=5000") # wait up to 5s, don't fail SQLITE_BUSY at once
if db_path != ":memory:":
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
+26 -3
View File
@@ -70,6 +70,22 @@ _BIFROST_PATHS = (
"/bifrost/affect-call",
)
_PLANE_BY_PATH = {
"/bifrost/memory-call": "memory",
"/bifrost/affect-call": "affect",
}
def _resolve_plane(configured: str, path: str) -> str:
"""For the combined provider (plane='combined', #18 D1) the OpEvent plane is
derived from the request PATH memory-callmemory, affect-callaffect,
handshakecombined. A fixed plane ('memory'/'affect', the single-plane apps) is
returned unchanged. The per-verb summary logic already keys on path, so only the
plane STAMP changes."""
if configured != "combined":
return configured
return _PLANE_BY_PATH.get(path, "combined")
def _b64url_decode(seg: str) -> bytes:
return base64.urlsafe_b64decode(seg + "=" * (-len(seg) % 4))
@@ -119,7 +135,11 @@ def _ids_summary(args: dict[str, Any]) -> list[Any]:
def _req_summary(plane: str, path: str, op: str, req: dict[str, Any]) -> dict[str, Any]:
"""Scope-only request summary — NEVER record bodies / PAD content."""
if path == "/bifrost/handshake":
return {"caps_requested": req.get("capabilities_requested")}
# The handshake REQUEST field is `capabilities` (bifrost reference_server
# _protocol.py:181 reads request_body["capabilities"]) — NOT the transposed
# `capabilities_requested`, which never existed on the wire (caps_requested
# was silently always None). Fixed per the heid-code-review #17 catch.
return {"caps_requested": req.get("capabilities")}
if plane == "affect":
return {} # affect stays conduit-opaque — no PAD content surfaced
args = req.get("args") or {}
@@ -174,6 +194,7 @@ def _resp_summary(
def _build_event(
plane: str, path: str, scope: dict[str, Any], req_body: bytes, captured: dict[str, Any]
) -> OpEvent:
plane = _resolve_plane(plane, path) # 'combined' → per-path; fixed plane unchanged
headers = dict(scope.get("headers") or [])
session_id = _session_id_from_auth(headers.get(b"authorization"))
status = "ok" if 200 <= int(captured["status"]) < 300 else "error"
@@ -208,8 +229,10 @@ def instrument_provider_app(app: Any, *, plane: str, sink: OpSink) -> Any:
(INV-004). A sink/summary failure never propagates into the dispatch path
(POST-003 / INV-007) it is swallowed and logged to stderr.
"""
if plane not in ("memory", "affect"):
raise ValueError(f"plane must be 'memory' or 'affect', got {plane!r}")
if plane not in ("memory", "affect", "combined"):
raise ValueError(
f"plane must be 'memory', 'affect', or 'combined', got {plane!r}"
)
async def wrapped(scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope.get("type") != "http" or scope.get("path") not in _BIFROST_PATHS:
+73
View File
@@ -0,0 +1,73 @@
"""Runnable entrypoint: serve the COMBINED provider (memory + affect) as one ASGI app.
Issue #18 Deliverable 1 — a single endpoint a Worldtree session binds to drive BOTH
planes. Additive: the standalone affect (:8390) + memory (:8391) entrypoints are
unchanged. Config from env:
- RATATOSKR_HEIMDALL_KEY (required): HS256 shared key for the consumer, utf-8.
- RATATOSKR_MEMORY_EMBEDDING_DIM (required): the pinned embedder dim (no default
a wrong value silently breaks search).
- RATATOSKR_AFFECT_DB (default "affect.db") + RATATOSKR_MEMORY_DB (default "memory.db"):
the two SQLite paths (one per plane, per the v1 contract).
- RATATOSKR_CONSUMER_ID (default "ratatoskr").
- RATATOSKR_PROVIDER_HOST (default "0.0.0.0"),
RATATOSKR_COMBINED_PROVIDER_PORT (default 8392 distinct from :8390/:8391 so the
composite runs side-by-side with the standalones).
- RATATOSKR_OPFEED_PATH (optional): op-feed JSONL path; plane is derived PER request
path (memory-callmemory, affect-callaffect, handshakecombined).
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from ratatoskr.provider.affect_store import open_affect_store
from ratatoskr.provider.combined import build_combined_provider_app
from ratatoskr.provider.memory_store import open_memory_store
from ratatoskr.provider.opfeed import maybe_instrument_from_env
def build_combined_app_from_env(env: Mapping[str, str] | None = None):
"""Build the combined ASGI app from environment config (testable seam)."""
env = os.environ if env is None else env
key = env.get("RATATOSKR_HEIMDALL_KEY")
if not key:
raise RuntimeError(
"RATATOSKR_HEIMDALL_KEY is required to serve the combined provider"
)
raw_dim = env.get("RATATOSKR_MEMORY_EMBEDDING_DIM")
if not raw_dim:
raise RuntimeError(
"RATATOSKR_MEMORY_EMBEDDING_DIM is required (Worldtree's PINNED_EMBEDDER_DIM)"
)
try:
embedding_dim = int(raw_dim)
except ValueError as exc:
raise RuntimeError(
f"RATATOSKR_MEMORY_EMBEDDING_DIM must be an int, got {raw_dim!r}"
) from exc
if embedding_dim <= 0:
raise RuntimeError("RATATOSKR_MEMORY_EMBEDDING_DIM must be a positive int")
affect_store = open_affect_store(env.get("RATATOSKR_AFFECT_DB", "affect.db"))
memory_store = open_memory_store(
env.get("RATATOSKR_MEMORY_DB", "memory.db"), embedding_dim=embedding_dim
)
app = build_combined_provider_app(
memory_store,
affect_store,
heimdall_key=key.encode(),
consumer_id=env.get("RATATOSKR_CONSUMER_ID", "ratatoskr"),
)
# Issue #17 (Observe): opt-in dispatch-layer op-feed; plane='combined' derives the
# OpEvent plane per request path (INV-012).
return maybe_instrument_from_env(app, env, plane="combined")
def main() -> None:
import uvicorn
uvicorn.run(
build_combined_app_from_env(),
host=os.environ.get("RATATOSKR_PROVIDER_HOST", "0.0.0.0"),
port=int(os.environ.get("RATATOSKR_COMBINED_PROVIDER_PORT", "8392")),
)
+12 -10
View File
@@ -227,19 +227,21 @@ async def list_sessions(
def endpoint_for_plane(plane: str, base_host: str) -> str:
"""Map a provider plane name to its Worldtree-VISIBLE base URL.
Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the
Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's
loopback Worldtree must reach the provider over the network. `http://` is
deliberate: the HTTPS relaxation is allowlist-side (Worldtree's
BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint
is supplied directly, bypassing this helper.
Issue #17 dev helper: `memory` → :8391, `affect` → :8390, `combined` → :8392
(the #18 composite both-plane endpoint). Returns the Worldtree-visible base
(e.g. `http://10.100.10.50:8391`), NOT the client's loopback — Worldtree must
reach the provider over the network. `http://` is deliberate: the HTTPS
relaxation is allowlist-side (Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS), not a
URL concern. A production HTTPS endpoint is supplied directly, bypassing this
helper.
"""
if plane not in ("memory", "affect"):
ports = {"memory": 8391, "affect": 8390, "combined": 8392}
if plane not in ports:
raise ValueError(
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
f"unknown plane: {plane!r} "
"(expected 'memory', 'affect', or 'combined')"
)
port = 8391 if plane == "memory" else 8390
return f"http://{base_host}:{port}"
return f"http://{base_host}:{ports[plane]}"
def _bifrost_error_from(resp: httpx.Response) -> str | None:
+1 -1
View File
@@ -138,7 +138,7 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
consumer_key = request.app.state.bifrost_consumer_key
visible_host = request.app.state.bifrost_visible_host
if bifrost_plane:
if bifrost_plane not in ("memory", "affect"):
if bifrost_plane not in ("memory", "affect", "combined"):
return JSONResponse(
{"error_code": "invalid_bifrost_plane"}, status_code=400
)
+1
View File
@@ -513,6 +513,7 @@ body {
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
<div class="select-wrap">
<select id="bifrost-plane">
<option value="combined" selected>combined (:8392) — PAD + memory in one session</option>
<option value="">none — observe only</option>
<option value="memory">memory (:8391) — durable recall</option>
<option value="affect">affect (:8390) — persona / PAD</option>
+56
View File
@@ -162,6 +162,32 @@ async def test_get_after_emit_returns_equal():
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 ---
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) ---
# Non-bifrost GET /affect/state/{agent_id}?end_user_id=… → store.get snapshot.
+274
View File
@@ -0,0 +1,274 @@
"""Tests for the combined Bifrost provider (ratatoskr.provider.combined) — issue #18
Deliverable 1.
ONE app fronting BOTH planes (memory.* + affect.*) + the shared affect read route.
Mirrors bifrost's tests/consumer/test_build_combined_app.py shapes (handshake +
dispatch) and ratatoskr's op-feed test style (mint_dispatch_jwt, RecordingSink), so
the envelopes and JWTs are the real wire shapes, not hand-mocked guesses ("test
against the shipped lib").
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
import httpx
import pytest
from bifrost.core.dispatch_jwt import mint_dispatch_jwt
from starlette.testclient import TestClient
from ratatoskr.provider.affect_store import open_affect_store
from ratatoskr.provider.combined import build_combined_provider_app
from ratatoskr.provider.memory_store import open_memory_store
from ratatoskr.provider.opfeed import instrument_provider_app
_KEY = b"deterministic-test-heimdall-key-32-bytes!"
_CONSUMER = "ratatoskr"
_DIM = 8
def _combined_app():
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
affect_store = open_affect_store(":memory:")
app = build_combined_provider_app(
memory_store, affect_store, heimdall_key=_KEY, consumer_id=_CONSUMER
)
return app, memory_store, affect_store
def _dispatch_headers(*scopes: str, session_id: str = "sess-1") -> dict:
token = mint_dispatch_jwt(
session_id=session_id,
consumer_id=_CONSUMER,
issuer="worldtree",
scope=list(scopes),
secret_or_key=_KEY,
algorithm="HS256",
)
return {"Authorization": f"Bearer {token}"}
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _handshake_jwt(session_id: str = "sess-1") -> str:
"""Replicate bifrost's consumer conftest jwt_factory (HS256 handshake JWT)."""
header = {"alg": "HS256", "typ": "JWT"}
now = time.time()
payload = {
"session_id": session_id,
"consumer_id": _CONSUMER,
"issued_at": now,
"expires_at": now + 3600,
}
h = _b64url(json.dumps(header, separators=(",", ":")).encode())
p = _b64url(json.dumps(payload, separators=(",", ":")).encode())
sig = hmac.new(_KEY, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
return f"{h}.{p}.{_b64url(sig)}"
def _handshake_body(session_id: str = "sess-1") -> dict:
return {
"bifrost_version": "0.4.0",
"mcp_version": "0.4.0",
"session_id": session_id,
"consumer_id": _CONSUMER,
"auth": {"scheme": "Bearer", "token": _handshake_jwt(session_id)},
"capabilities": ["memory", "affect"],
}
def _snapshot(agent: str = "ratatoskr:sindra", user: str = "vuong") -> dict:
return {
"agent_id": agent,
"end_user_id": user,
"pad": {"pleasure": 0.5, "arousal": 0.2, "dominance": -0.1},
"valence": [{"entity_id": "e1", "regard": 0.7, "familiarity": 0.3}],
"emitted_at": "2026-06-14T12:00:00Z",
}
def _emit_envelope(snap: dict) -> dict:
return {
"operation": "affect.emit",
"idempotency_key": "sess-1:1:affect",
"idempotency_class": "short-retry",
"args": snap,
}
# --- build_combined_provider_app ---
def test_builds_both_planes_and_read_route():
"""builds_both_planes [tracer]: the composite exposes handshake + memory-call +
affect-call + the non-bifrost /affect/state read route (INV-011)."""
app, _m, _a = _combined_app()
paths = {getattr(r, "path", None) for r in app.routes}
assert "/bifrost/handshake" in paths
assert "/bifrost/memory-call" in paths
assert "/bifrost/affect-call" in paths
assert "/affect/state/{agent_id}" in paths
def test_handshake_grants_both_caps():
"""handshake_grants_both [scenario]: a handshake requesting [memory, affect] is
granted BOTH by store PRESENCE (INV-010) my wiring doesn't break it."""
app, _m, _a = _combined_app()
resp = TestClient(app).post("/bifrost/handshake", json=_handshake_body())
assert resp.status_code == 200
granted = resp.json()["capabilities_granted"]
assert "memory" in granted
assert "affect" in granted
def test_memory_and_affect_dispatch_through_one_app():
"""memory_and_affect_dispatch [scenario]: a memory SEARCH AND an affect emit each
round-trip through the SINGLE combined app (INV-013; contract TEST + Acceptance §2
name a memory `search`)."""
app, _m, _a = _combined_app()
client = TestClient(app)
mem = client.post(
"/bifrost/memory-call",
json={
"operation": "search",
"args": {"vector": [0.0] * _DIM, "top_k": 1, "scope_all": {}},
},
headers=_dispatch_headers("memory:read"),
)
assert mem.status_code == 200
assert mem.json()["success"] is True
aff = client.post(
"/bifrost/affect-call",
json=_emit_envelope(_snapshot()),
headers=_dispatch_headers("affect:write"),
)
assert aff.status_code == 200
assert aff.json()["success"] is True
assert aff.json()["stored"] is True
def test_affect_read_route_on_composite_colon_id():
"""affect_read_route_on_composite [happy]: after an emit, GET /affect/state for a
colon-id agent returns the snapshot verbatim from the SAME store (INV-011 / INV-008)."""
app, _m, _a = _combined_app()
client = TestClient(app)
snap = _snapshot()
client.post(
"/bifrost/affect-call",
json=_emit_envelope(snap),
headers=_dispatch_headers("affect:write"),
)
r = client.get("/affect/state/ratatoskr:sindra", params={"end_user_id": "vuong"})
assert r.status_code == 200
assert r.json() == snap
def test_missing_affect_store_raises():
"""missing_affect_store [adversarial]: affect_store=None → ValueError (INV-009)."""
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
with pytest.raises(ValueError):
build_combined_provider_app(memory_store, None, heimdall_key=_KEY)
def test_missing_memory_store_raises():
"""INV-009 (other half): memory_store=None → ValueError (bifrost build_combined_app)."""
affect_store = open_affect_store(":memory:")
with pytest.raises(ValueError):
build_combined_provider_app(None, affect_store, heimdall_key=_KEY)
def test_empty_heimdall_key_raises():
"""PRE-002: empty heimdall_key → ValueError (combined-level guard)."""
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
affect_store = open_affect_store(":memory:")
with pytest.raises(ValueError):
build_combined_provider_app(memory_store, affect_store, heimdall_key=b"")
def test_non_advertising_affect_store_raises():
"""PRE-001 / INV-010: affect_store with affect_supported=False → ValueError."""
memory_store = open_memory_store(":memory:", embedding_dim=_DIM)
affect_store = open_affect_store(":memory:")
affect_store.affect_supported = False
with pytest.raises(ValueError):
build_combined_provider_app(memory_store, affect_store, heimdall_key=_KEY)
# --- op-feed plane='combined' (per-path derivation, INV-012) ---
class _RecordingSink:
def __init__(self) -> None:
self.events: list = []
def emit(self, event) -> None:
self.events.append(event)
async def _post(app, path: str, body: dict, headers: dict | None = None) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://provider") as client:
return await client.post(path, json=body, headers=headers or {})
async def test_opfeed_combined_memory_call_stamps_memory():
sink = _RecordingSink()
app, _m, _a = _combined_app()
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
resp = await _post(
wrapped,
"/bifrost/memory-call",
{"operation": "search", "args": {"vector": [0.0] * _DIM, "top_k": 1, "scope_all": {}}},
_dispatch_headers("memory:read"),
)
assert resp.status_code == 200
assert len(sink.events) == 1
assert sink.events[0].plane == "memory" # derived from path (INV-012)
assert sink.events[0].op == "search"
async def test_opfeed_combined_affect_call_stamps_affect():
sink = _RecordingSink()
app, _m, _a = _combined_app()
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
resp = await _post(
wrapped,
"/bifrost/affect-call",
_emit_envelope(_snapshot()),
_dispatch_headers("affect:write"),
)
assert resp.status_code == 200
assert len(sink.events) == 1
assert sink.events[0].plane == "affect" # derived from path (INV-012)
assert sink.events[0].op == "emit" # affect. prefix stripped
async def test_opfeed_combined_handshake_stamps_combined():
"""handshake isn't plane-specific → stamp plane='combined' (INV-012). A bad-version
handshake is cleanly rejected but still emits exactly one OpEvent."""
sink = _RecordingSink()
app, _m, _a = _combined_app()
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
resp = await _post(
wrapped, "/bifrost/handshake", {"bifrost_version": "99.0.0", "mcp_version": "0.4.0"}
)
assert resp.status_code != 200 # major-version mismatch, cleanly rejected
assert len(sink.events) == 1
assert sink.events[0].plane == "combined"
assert sink.events[0].op == "handshake"
async def test_opfeed_combined_read_route_emits_no_event():
"""INV-012/INV-004: the non-bifrost read route is outside _BIFROST_PATHS → NO OpEvent."""
sink = _RecordingSink()
app, _m, _a = _combined_app()
wrapped = instrument_provider_app(app, plane="combined", sink=sink)
transport = httpx.ASGITransport(app=wrapped)
async with httpx.AsyncClient(transport=transport, base_url="http://provider") as client:
await client.get("/affect/state/ratatoskr:sindra", params={"end_user_id": "vuong"})
assert sink.events == []
+17
View File
@@ -231,6 +231,23 @@ class TestOpFeedMemory:
assert len(sink.events) == 1
assert sink.events[0].op == "handshake"
async def test_handshake_req_summary_reads_real_capabilities_field(self) -> None:
"""The handshake req-summary reads the REAL wire field `capabilities` (bifrost
_protocol.py:181), not the transposed `capabilities_requested` so caps_requested
is actually populated (heid-code-review #17 catch). A bad-version handshake still
emits the OpEvent carrying the requested caps from the request body."""
sink = _RecordingSink()
app, _store = _wrapped_memory_app(sink)
resp = await _post(
app,
"/bifrost/handshake",
{"bifrost_version": "99.0.0", "mcp_version": "0.4.0", "capabilities": ["memory"]},
None,
)
assert resp.status_code != 200
assert len(sink.events) == 1
assert sink.events[0].req_summary == {"caps_requested": ["memory"]}
async def test_sink_failure_never_breaks_dispatch(self) -> None:
"""sink_swallow [adversarial]: a raising sink must NOT break the dispatch
path the search still returns 200 (POST-003 / INV-007)."""
+44
View File
@@ -0,0 +1,44 @@
"""Tests for the combined-provider serve entrypoint (ratatoskr.provider.serve_combined).
Only the env -> app seam is unit-tested; uvicorn.run is the untestable shell.
"""
from __future__ import annotations
import pytest
from ratatoskr.provider.serve_combined import build_combined_app_from_env
_ENV = {
"RATATOSKR_HEIMDALL_KEY": "shared-secret",
"RATATOSKR_MEMORY_EMBEDDING_DIM": "8",
"RATATOSKR_AFFECT_DB": ":memory:",
"RATATOSKR_MEMORY_DB": ":memory:",
}
def test_requires_heimdall_key():
env = {k: v for k, v in _ENV.items() if k != "RATATOSKR_HEIMDALL_KEY"}
with pytest.raises(RuntimeError):
build_combined_app_from_env(env)
def test_requires_embedding_dim():
env = {k: v for k, v in _ENV.items() if k != "RATATOSKR_MEMORY_EMBEDDING_DIM"}
with pytest.raises(RuntimeError):
build_combined_app_from_env(env)
def test_builds_app_with_all_routes():
app = build_combined_app_from_env(dict(_ENV))
paths = {getattr(r, "path", None) for r in app.routes}
assert "/bifrost/handshake" in paths
assert "/bifrost/memory-call" in paths
assert "/bifrost/affect-call" in paths
assert "/affect/state/{agent_id}" in paths
def test_opfeed_path_wraps_app(tmp_path):
env = dict(_ENV)
env["RATATOSKR_OPFEED_PATH"] = str(tmp_path / "ops.jsonl")
app = build_combined_app_from_env(env)
assert not hasattr(app, "routes") # wrapped: a bare ASGI callable (plane='combined')
+7
View File
@@ -384,6 +384,13 @@ class TestEndpointForPlane:
== "http://10.100.10.50:8390"
)
def test_combined_plane_maps_to_8392(self) -> None:
"""combined [#18 composite]: 'combined' → http://<host>:8392 (POST-001)."""
assert (
endpoint_for_plane("combined", "10.100.10.50")
== "http://10.100.10.50:8392"
)
def test_unknown_plane_raises_value_error(self) -> None:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
with pytest.raises(ValueError):
+44
View File
@@ -837,6 +837,50 @@ class TestWebBifrostBind:
}
assert upstream.headers["Authorization"] == "Bearer server-ck"
@respx.mock
def test_combined_plane_binds_to_8392(self) -> None:
"""combined [#18 composite]: a 'combined' plane from the browser → the server
binds the :8392 both-plane endpoint; bound-state echoes plane='combined'."""
import json as _json
from ratatoskr.web.server import create_app
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="server-ck",
bifrost_visible_host="10.100.10.50",
)
resp = TestClient(app).post(
"/api/sessions",
json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "combined"},
)
assert resp.status_code == 201
assert resp.json()["bifrost"] == {
"plane": "combined",
"endpoint": "http://10.100.10.50:8392",
"status": "bound",
}
upstream = route.calls[0].request
body = _json.loads(upstream.content)
assert body["bifrost"] == {
"endpoint_url": "http://10.100.10.50:8392",
"scope": None,
}
def test_dropdown_offers_combined_as_default(self) -> None:
"""(a)+default: the SPA plane dropdown offers a 'combined' (:8392) option,
it is the DEFAULT-selected one, and single-plane memory/affect remain."""
from pathlib import Path
import ratatoskr.web as web_pkg
html = (Path(web_pkg.__file__).parent / "static" / "index.html").read_text()
assert '<option value="combined" selected>' in html
assert 'value="memory"' in html and 'value="affect"' in html
@respx.mock
def test_plane_without_server_config_is_400(self) -> None:
"""A plane requested but no server-held key/host → bifrost_not_configured."""
Generated
+5 -5
View File
@@ -190,14 +190,14 @@ wheels = [
[[package]]
name = "bifrost"
version = "0.8.0"
version = "1.0.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.8.0/bifrost-0.8.0.tar.gz", hash = "sha256:28194877c81a056a0803b052e86902c092e965d4ce63a5623d7a31240cedb645" }
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/1.0.0/bifrost-1.0.0.tar.gz", hash = "sha256:93130d68dfd9868580a4514277996ba176837972b9e42129eda8bb03ad3b18b9" }
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/1.0.0/bifrost-1.0.0-py3-none-any.whl", hash = "sha256:1a53baa2b0596b7c418e2d82e3eeee0f13054604d78b592609ee1aff90dccac2" },
]
[[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.17.14"
version = "0.18.2"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -1086,7 +1086,7 @@ web = [
[package.metadata]
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 = "==1.0.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" },