Compare commits

..

19 Commits

Author SHA1 Message Date
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
vh 39eebd1a55 feat(#18): PAD read-endpoint — web pane renders live PAD/valence from our affect store (Deliverable 2)
The web persona pane now renders live PAD/valence for Tier-3 agents from our
:8390 affect store, closing the persona-telemetry gap (Worldtree persona_state
404s for Tier-3 per ADR-0009; Tier-3 emits no affect_update SSE).

- provider: non-bifrost GET /affect/state/{agent_id} on the affect-store-owning
  app (add_route — keeps /bifrost/* top-level + op-feed-skipped); explicit
  no_affect_snapshot 404 (never a zeroed PAD); busy_timeout + check_same_thread
  on the connection.
- web: GET /api/affect/{agent_id} proxy — end_user_id server-supplied (never the
  browser), colon-id round-trip, configured RATATOSKR_AFFECT_READ_URL.
- pane: honest affect render (pad + valence + emitted_at, labelled "affect", no
  fabricated Tier-1 fields); explicit empty-state; polls 2s post-turn.

Contract-first (docs/contracts/issues/18.contract.md, Deliverable-2-scoped;
Deliverable 1 / composite endpoint deferred — bifrost-blocked on a public
build_combined_app, WT dispatch confirmed single-endpoint caps-routed).
Heid-code-review panel: 1 INV-001 drift (strip fabricated "neutral") + 4
test-gaps fixed. Live-smoke PROVEN: web->provider->affect.db chain returns real
sindra/vuong PAD; Playwright DOM check confirms the pane render + the fix.

Suite 482 green.
2026-06-19 21:56:15 -07:00
vh f3bac46238 memory: snapshot — persona-telemetry diagnosis sharpened + #18 split; archived the 2026-05-* build-era cluster (59 entries: 41 decisions + 18 foot-guns) to archival-memory.md. New: wire-verified Tier-3 emits ZERO affect_update SSE (both WT persona sources dead → #18 PAD-display half is the only path); PAD confirmed in our :8390 store (vuong 8 turns, familiarity 0.18→0.59); affect.emit is POST-TURN ASYNC foot-gun. persistent-memory.md trimmed 331→~190. 2026-06-18 10:17:57 -07:00
vh f15c8c6153 memory: snapshot — #17 SHIPPED end-to-end (slices 1-3c, v0.17.8-.13, suite 470 green, live-smoke PROVEN: bound CLI->sindra->op-feed captured 2 recall searches @ exact bound session_id 2c0c7482 with #297/#298 union scopes; dispatch JWT carries session_id=sub, open-q resolved). Operator session UP: web :8765 bind-configured + plane selector, providers :8390/:8391 with op-feed, althing monitor armed. Persona-pane PAD gap diagnosed (affect persists to :8390 stored:true but pane reads Tier-3-404 persona_state) -> #18 filed (composite endpoint + PAD read-endpoint, operator approved 'A', contract-first next). 2026-06-18 01:17:55 -07:00
vh 179a8dff6e feat(#17): web bind UI — plane selector + bound-state indicator (slice 3c UI)
Completes slice 3c: the browser-facing trigger for the web bind. A 'Bifrost
binding' <select> (none / memory / affect) on the setup panel; startSession
sends bifrost_plane in the create body (the consumer key stays server-held,
never sent from the browser). On a bound 201 the identity line renders the
bound-state indicator (plane + endpoint, never the key); bind failures surface
the error_code + bifrost_error in the setup error line.

Closes the persistent-memory caveat: web chat can now bind its own Tier-3
provider (memory persistence + affect telemetry), not persona+debug only.

Web suites 63 green (presentation-contract included); pure static HTML/JS.
2026-06-18 01:04:20 -07:00
vh 2806abac44 feat(#17): web Bifrost-bind — server side (slice 3c, INV-008 lockstep complete)
Slice 3c of issue #17 — the web surface of the bind trigger, server side. Closes
the INV-008 lockstep (CLI + TUI + web all carry the bind now). Implements the
contract's "web bind split": the browser selects only the PLANE; the consumer key
and the Worldtree-visible host are SERVER-HELD config and never reach the browser.

- create_app gains bifrost_consumer_key + bifrost_visible_host (server-held,
  from env via the entrypoint: RATATOSKR_BIFROST_CONSUMER_KEY /
  RATATOSKR_PROVIDER_VISIBLE_HOST).
- _create_session_endpoint reads an optional `bifrost_plane` from the browser
  body, builds the BifrostBinding SERVER-SIDE via endpoint_for_plane(plane,
  visible_host), and calls create_session(bifrost=, consumer_key=). The 201
  response echoes bound-state {plane, endpoint, status: bound} for the UI
  indicator — never the key (INV-008/INV-009).
- Error routing: invalid plane / unconfigured server -> 400; BifrostHandshakeFailed
  -> 502 {bifrost_error}; BifrostConsumerKeyMissing (server misconfig) -> 400.

5 new web bind tests (server constructs binding + key-never-leaks + upstream
carries bifrost body + consumer-key bearer; unconfigured -> 400; invalid plane;
handshake 502; no-plane unbound regression). Full suite 470 green; added lines
ruff + mypy clean (pre-existing web-file backlog untouched).

Follow-on: the index.html plane selector (UI trigger) — the server capability is
complete and TDD'd; the browser-side dropdown is a thin separate change.

LIVE-SMOKE PROVEN (this session): the CLI bind drove a bound sindra session
against personal Worldtree :8081 -> handshake 200 -> the op-feed captured 2
recall searches correlated to the EXACT bound session_id (2c0c7482), with the
real #297/#298 union-recall scopes. Bind + observe proven end-to-end live.
2026-06-18 01:02:14 -07:00
vh 016defcc01 feat(#17): TUI Bifrost-bind trigger (slice 3b of the INV-008 lockstep)
Slice 3b of issue #17 — the TUI surface of the bind trigger (web is 3c). The TUI
consumes the same ParsedArgs the cli already parses (--bifrost-plane / --bifrost-url
/ consumer key from RATATOSKR_BIFROST_CONSUMER_KEY), so this wires the bind into
_resolve_then_run's pre-flight create_session:

- bifrost + consumer_key threaded into create_session at the pre-alt-screen
  resolution layer, so bind failures land on the operator's REAL stderr BEFORE
  the Textual alt-screen opens (INV-002, mirrors issue #6's pre-alt-screen
  routing) — never eaten by the alt-screen teardown.
- BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the
  same 401-scoping hint, keyed on bifrost_error == bifrost.auth_rejected. Exit
  codes + label vocabulary match cli._amain exactly (INV-006).
- Bound-state indicator on success (pre-alt-screen): ". bifrost: status=bound
  plane=... endpoint=...".

3 new TUI bind tests (handshake-fail / consumer-key-missing / bound-create carries
binding + indicator, run_async stubbed). Full suite 465 green; added lines ruff +
mypy clean (pre-existing tui.py lint/type backlog untouched per surgical-changes).
2026-06-18 00:49:30 -07:00
vh 0bebad74ad feat(#17): CLI Bifrost-bind trigger (slice 3a of the INV-008 lockstep)
Slice 3a of issue #17 — the CLI surface of the bind trigger (TUI + web follow,
INV-008 lockstep). ratatoskr can now self-drive a bound session from the CLI:

- New flags: --bifrost-plane {memory,affect} (dev shortcut -> endpoint_for_plane
  over --bifrost-host / RATATOSKR_PROVIDER_VISIBLE_HOST) and --bifrost-url (the
  direct HTTPS/prod endpoint, bypassing the plane shortcut). Mutually exclusive;
  a binding is a session-CREATE concern (forbidden with --session).
- Consumer key resolved from RATATOSKR_BIFROST_CONSUMER_KEY only (the privileged
  handshake identity — never a CLI flag, distinct from the canary WORLDTREE_API_KEY).
- _amain threads bifrost + consumer_key into create_session and routes the bind
  failures: BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit
  23 with the 401-scoping hint ("use the consumer key, not WORLDTREE_API_KEY")
  keyed on bifrost_error == bifrost.auth_rejected.
- Bound-state indicator on success: ". bifrost: status=bound plane=... endpoint=..."
  — shows WHICH identity/endpoint bound, not a bare boolean.

Also fixes a pre-existing test-isolation bug: 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 after it. The real
check is the static source grep; the reload was vestigial and is removed.

9 new CLI bind tests; full suite 462 green; ruff clean (no new mypy errors).
2026-06-18 00:45:48 -07:00
vh 8ebe227ae4 feat(#17): dispatch-layer op-feed for the provider (slice 2 — Observe)
Slice 2 of issue #17 — the OBSERVE half. New ratatoskr.provider.opfeed:

- OpEvent{ts, plane, op, session_id, status, req_summary, resp_summary,
  turn_id=None} — scope-only summaries, never record bodies / PAD content
- OpSink Protocol + JsonlOpSink (continuous append-only JSONL, INV-007)
- instrument_provider_app(app, *, plane, sink): an ASGI middleware over the
  built bifrost provider app. Buffers+replays the request, captures the
  response, reads session_id off the dispatch JWT's "sub" claim (INV-005 —
  present for ALL verbs incl. search/get/delete, which bifrost withholds from
  the store method), emits exactly one OpEvent per inbound bifrost-call incl.
  handshake + errors. Read-only over dispatch; store scope semantics untouched
  (INV-004). A sink/summary failure is swallowed + logged, never breaks serve
  (POST-003).
- Per-verb summaries: search {scope_all,scope_any,top_k}->{hit_count,hits};
  upsert_many {record_count,scopes}->{upserted,replayed}; get/get_many/
  delete_many {ids}->{found_count|deleted}; emit (affect, opaque)->{stored};
  handshake {caps_requested}->{caps_granted,ok}; error->{error: code}
- serve_memory/serve wired: opt-in via RATATOSKR_OPFEED_PATH (maybe_instrument)

Resolves the contract's open question: the dispatch JWT DOES carry session_id
(= the "sub" claim). Tests drive the REAL bifrost dispatch end-to-end with
minted JWTs. 11 new tests; full suite 453 green; ruff + mypy clean (opfeed.py).
2026-06-18 00:36:33 -07:00
vh 7be162e84d feat(#17): create_session Bifrost-bind primitive (slice 1)
Slice 1 of issue #17 (Bifrost-binding the chat client) — the client-side
BIND primitive, TDD'd against docs/contracts/issues/17.contract.md.

- BifrostBinding{endpoint_url, scope=None} frozen dataclass (#160 shape)
- create_session(..., bifrost=, consumer_key=): carries the bifrost body
  field and OVERRIDES the bearer to the consumer key per-request (INV-001 —
  never falls back to the canary key)
- BifrostConsumerKeyMissing: raised BEFORE any HTTP when a binding lacks a
  non-empty key (PRE-001)
- BifrostHandshakeFailed: 502 on a BOUND create -> carries detail.bifrost_error
  (both-shape unwrap per the persona_state wire lesson); gated on bifrost!=None
  so an unbound 502 stays SessionApiFailed (INV-002)
- endpoint_for_plane: memory->:8391 / affect->:8390, invalid->ValueError

7 new tests; full suite 442 green; ruff clean.
2026-06-18 00:21:36 -07:00
vh f533464c54 memory: snapshot — persona-pane reframe (worldtree-dev): persona_state GET is Tier-1-only by ADR-0009 (colon-404 correct-by-design, not a stub); Tier-3 affect is CLIENT-persisted — we already hold PAD/valence @ :8390 from affect.emit, so the pane is an OUR-side render via #17 affect-binding (→ affect.emit → :8390 → render), NOT a WT endpoint wait. WT #289 affect.fetch = optional mediated-read; #300 = WT client-impl guide. Expands #17 payoff: memory AND the persona pane. 2026-06-18 00:04:30 -07:00
vh 37cdef511f fix(web): de-ugly the Tier-3 persona pane — clear message instead of bare HTTP 404
persona_state hard-404s every Tier-3 (colon-id) agent by design upstream
(WT api.py:1220, "Phase 2.0 has no Tier 3 persona") — so the Persona pane
showed "persona not available (HTTP 404)" for consumer-defined characters.
loadPersona now reads error_code + renders a clear Tier-3-aware message
(she still responds in character; only the affect/OCEAN readout is gated),
with distinct text for persona_not_configured / 403 / other.

Also (snapshot): sindra switched to thoughtful-character role
(mistral-small-4-reasoning); worldtree-dev pinged re Tier-3 persona_state
roadmap (thread 01KVCR6P); #17 (bifrost-binding the chat client) teed up as
the next-context target.

v0.17.7
2026-06-18 00:00:41 -07:00
vh 835375d22b memory: snapshot — FULL COVERAGE proven (verbose persona too): sindra-probe theatrical turn promoted the user fact cleanly under Stage 2/v0.36.0 + cold-recalled @0.694; :8081 confirmed on v0.36.0; closes the verbose-persona caveat end-to-end. Operator session: :8391 wiped, ratatoskr-web up :8765 (consumer key, sindra in picker) — persona+debug only, web client does NOT bind :8391 (#17 unbuilt = no memory persistence in web chat) 2026-06-17 23:45:08 -07:00
vh 7666203722 memory: snapshot — Tier-3 memory PROVEN end-to-end live (terse-probe cold recall @0.6994, fresh history-free session); #296 arc closed: Stage 1 (v0.35.19) recallability gate validated live + bisect localized residual to verbose-persona volume, Stage 2 (v0.36.0) MERGED at worldtree-codex (user-only per-turn extraction), live-validated eval fixture pair -> #305; root-cause chain v0.35.16 emit-2-meta -> v0.35.19 emit-then-reject -> v0.36.0 fix; foot-gun: :8391 store-wipe != WT promotion-dedup reset (clean promotion smoke needs a fresh agent+end_user) 2026-06-17 21:27:17 -07:00
vh 84d8c3f65f memory: snapshot — cold-recall arc PROVEN live e2e (#297/#298 union recall; WT v0.35.16 emits scope_any into our v0.17.6 store); #296 extraction quality the isolated upstream gap (triage→worldtree-dev, both symptoms localized in-code: empty _EXTRACTOR_SYSTEM + both-roles prefilter); sindra restored (DELETE+redefine, role:character→mistral-small-4, memory:{}); learnings: Tier-3 owner-scoped, define-takes-role, promotion 4-trigger hybrid, DELETE≠drain 2026-06-17 11:15:06 -07:00
vh 4eee7c89b2 pin: bump Worldtree spec to f1b59f8 (v0.35.16) — cold recall closes end-to-end
Worldtree shipped its half of the union-recall fix: #297 (client-side
per-scope-value union recall) + #298/#299 (adopt the bifrost v0.6
scope_any/scope_all wire, v0.35.16). It now emits scope_any on the recall
path, pairing with our v0.17.6 provider — cold cross-session recall is
closed end-to-end (pending a live re-smoke against a v0.35.16 instance).

Re-vendored conversation-api-spec.md + conversation_api.contract.md;
285-commit catch-up (v0.29.0 -> v0.35.16). Diff-reviewed: no client-facing
breaking changes for our consumer.

- #211 agent-slug rename (saga->echo, actor->mask) — slugs only, we pass --agent
- #245 end_user_id persistence + memory-scope resolver (additive)
- #187/#188/#219 Tier-3 define/PATCH policy (additive); error codes stable
- bifrost binding field + ephemeral_does_not_accept_bifrost 422 now documented (#17 surface)
- docs: SPEC-PIN.md pin table + history; bifrost-self-test recall status; persistent-memory

No package version bump (docs/pin-only, no ratatoskr code change).
2026-06-17 08:24:27 -07:00
34 changed files with 3487 additions and 288 deletions
+72
View File
@@ -0,0 +1,72 @@
# Archival memory — ratatoskr
_Entries moved out of persistent-memory.md to keep the active file scannable. Read this when researching historical decisions or revisiting past foot-guns. Append-only._
## Recent decisions (archived)
The 2026-05-20 → 2026-05-29 cluster: the original debug-TUI/web build era, before the 2026-06-14 Bifrost-provider second identity. Archived 2026-06-18 (one event; per-entry stamps omitted for the batch).
- `[2026-05-20]` Project name **Ratatoskr** (squirrel on Yggdrasil — runs up and down carrying messages). Earlier candidate Andvari demoted on the cursed-ring association.
- `[2026-05-20]` **Separate repo, separate dev team.** Operator's call; the in-tree-at-Worldtree/tools/ alternative was considered and rejected to dogfood the API boundary.
- `[2026-05-20]` **No Worldtree-source imports.** Spec-only dependency. Triple version-skew mitigation: spec-pin in pyproject.toml + recorded-SSE snapshot tests + conformance smoke. Initial pin: `55101e909abcd2219833266b6f905c5bc956e0f0` (Worldtree v0.19.0). See `docs/SPEC-PIN.md`.
- `[2026-05-20]` **Textual** (not rich+prompt_toolkit). Driver: debug observability is the primary purpose, and a multi-pane dashboard with persistent side panes + independent scrollback is structurally application-shell-shaped. Volva consulted via cross-frontier second-opinion and converged on the same call.
- `[2026-05-20]` **`httpx-sse`** for SSE consumption. The server emits composite `{turn_id}:{seq}` `id:` lines (Worldtree INV-014) load-bearing for SSE-resume; hand-rolled `data:`-only parsing (the skaldsong pattern) silently drops these. Ratatoskr becomes the reference Python SSE-resume implementation.
- `[2026-05-20]` **Persona-pane PII posture: label-don't-refuse.** `persona.log` is process-wide; pane title flips between `[Persona — PROCESS-WIDE]` and `[Persona — session <id>…]` based on whether log lines carry session_id. Refuse-against-non-local was considered and rejected as paternalistic.
- `[2026-05-20]` **Server-stdout pane: opt-in via `--server-log <path>`.** No auto-detection of well-known paths.
- `[2026-05-20]` **Two-stage Ctrl-C.** First cancels in-flight turn server-side; second exits app. Ctrl-D bound to immediate exit.
- `[2026-05-20]` **Single-session-per-launch + startup picker.** No in-app `/switch`. CLI flags `--session <id>` and `--new` for scripted use. Session identity always visible in Textual footer.
- `[2026-05-20]` **Markdown rendering default-on; `--raw` opt-out.** Don't pre-design `--no-stream-formatting` (Volva: add only if streaming-markdown rendering is empirically ugly).
- `[2026-05-20]` **Non-interactive `--send` mode.** Single SSE consumer module, two presenters (TUI + stdout). Keeps Ratatoskr honest as an API consumer; useful for CI / scripted probes.
- `[2026-05-20]` **First contract: `ratatoskr.sse_client`.** Bundles `stream_turn` + `reconnect_turn` + `cancel_turn` + private `_parse_sse_id` into one module — the SSE-resume flow is coupled (cancel needs `turn_id` from the SSE wire `id:`, reconnect re-uses the same parsed `SseId`), so they share a contract. Hard invariant INV-002 makes the composite `{turn_id}:{seq}` `id:` parsing load-bearing — closes the foot-gun the design-brief §3 names (hand-rolled `data:`-only parsing silently drops the `id:`).
- `[2026-05-21]` **Contract converted to issue-scoped (issue #1).** Frontmatter shape switched from module-scoped (`module:`/`purpose:`) to issue-scoped (`target_module:`/`scope:`/`prd:`) per CONTRACT-FORMAT §2.1.I. `prd:` block pins to issue body hash. **Known parser stale-ness**: `contract_parser.py --validate` ERRORs on issue-scoped frontmatter — CONTRACT-FORMAT §2.1.L H10, a documented Brokkr-side follow-up. Parser is a canonical sync, so we do NOT patch it locally. Treat parser ERROR-on-issue-scoped as expected until canonical bumps. (later retired — see 2026-06-15 canonical-sync entry.)
- `[2026-05-21]` **Default issue-tracker labels seeded** (17 total). Sleipnir gating, triage, type, resolution, Ratatoskr-specific area labels (sse-client, tui, cli, observability).
- `[2026-05-21]` **Volva paraphrase + code-review across all 4 issues — calibration consistent.** Paraphrase rounds flag 3-5 contract ambiguities per issue; code-review rounds flag 3-8 code-vs-contract drifts after TDD-passing implementation. The post-TDD code-review consistently catches three classes of gap the test-author's hypotheses don't cover: PRE-assertion boundary drift, exception-payload truncation / never-rendered-to-user observability misses, and "tested the state but not whether the user can see it" gaps.
- `[2026-05-21]` **Manual smoke is load-bearing — found a real defect tests couldn't.** First wire-level smoke against personal Worldtree (post-TDD, post-Volva-code-review on #4) revealed httpx's default 5s read timeout killed the SSE connection mid-stream during mimir's thinking phase (~30s LLM latency >> 5s read timeout). The unit/contract test infrastructure (respx-mocked SSE wire) doesn't model real LLM latency, so the gap was invisible at the test layer. Fix: caller-owned `httpx.AsyncClient` constructed with `timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)`; defense in depth: `sse_client.stream_turn` ERROR_ROUTING catches `httpx.ReadTimeout``SseConnectionDropped`. **Lesson: keep manual-smoke step in the per-issue cadence; mock-only validation is insufficient for streaming-against-real-server code.**
- `[2026-05-22]` **Issues #5/#6/#7 filed: per-user-agent support + TUI-startup-visibility + mid-stream-robustness.** Discovered during 2026-05-22 mimir TUI conversation: long completion crashed with `JSONDecodeError("Expecting value: line 1 column 1 (char 0)")` from `json.loads('')` on an empty-`data:` SSE frame (→ #7). Earlier same day, `ratatoskr --new --agent lofn` failed with 422 `end_user_id_required`#5. #6 was a corollary observation (TUI alt-screen masks the diagnostic).
- `[2026-05-22]` **Issue #8 (startup agent picker) filed.** `GET /agents` exists in the vendored spec; returns `agent_id`/`name`/`description` + optional fields. `--agent` becomes conditionally optional. Composes naturally with issue #5.
- `[2026-05-22]` **Issue #7 implemented via TDD + Volva-code-reviewed.** First issue with zero drift findings from Volva code-review — TDD caught all runtime behavior. Hypothesis: the tighter the contract + smaller the code surface, the more Volva's role shifts from "catch behavioral drift" to "tighten observability + wording".
- `[2026-05-23]` **Issue #6 (TUI startup error visibility) implemented via TDD + Volva-code-review (two rounds).** Restructures `run_tui` lifecycle: `_resolve_then_run` async helper opens AsyncClient, does pre-flight resolution, routes errors to stderr BEFORE alt-screen opens. Two Volva rounds confirmed multi-round value (round 2 found things round 1's amendments didn't anticipate; strictly test-precision, no behavioral drift).
- `[2026-05-23]` **Issue #5 (`--end-user-id`) implemented via TDD.** Three modules touched. `create_session(client, agent_id, *, end_user_id=None)`; CLI flag with non-empty validation; threading through `_amain` and `_resolve_then_run`.
- `[2026-05-23]` **Worldtree-dev consult landed authoritative consumer-API guidance** (althing thread `01KSBARG2B8M8C82H6AJGJWX1B`). Takeaways: `end_user_id` is a free-form partition key; no programmatic `requires_end_user_id` discovery; subject:{type,id} migration locked but not shipped; spec pin (v0.19.0) is 3 minor versions stale; send a User-Agent header; `agents.call:lofn` scope needed for lofn smoke; `GET /agents` requires no special scope.
- `[2026-05-23]` **v0.2.1 layout fix: dock-anchored TUI chrome so Input never moves.** Cause: auto-stacked vertical flow shifted Input when thinking-current toggled visibility. Fix: dock chrome to screen edges; transcript absorbs reflows internally via scroll viewport. **Operator-confirmed "a lot better" interactively. Pure UI fix; tests pass without modification. TUI-layout patches are "ship + operator verifies" — TTY is the load-bearing test surface; respx + Pilot mocks can't catch screen-relative positioning bugs.**
- `[2026-05-23]` **Issue #12 (presenter contract semantics amendment) implemented via TDD.** Thinking deltas render as ONE coalesced growing line (CLI) / one closed RichLog entry per run + live Static widget per-delta (TUI), not 50 lines per turn. Introduced stateful per-turn presenters: `CliPresenterState` + `TuiPresenterState`. Editorial promotion: load-bearing = Text/Done/Error/Cancelled (no prefix); demoted telemetry = WorkerPhase/Thinking/TextBoundary/ToolStart/ToolResult.
- `[2026-05-23]` **Forward direction: Ratatoskr will require `end_user_id` for EVERY access before too long.** Operator's call. Reasoning: even Tier 1 foundational agents that don't *require* `end_user_id` server-side currently fall back to a `_no_end_user` sentinel partition — effectively pollution. **Cross-frontier alignment (worldtree-dev ack, althing `01KSBD9FPMCWJMBXNNS4B3MYBS`):** the platform side agrees the fallback is a substrate accommodation, NOT a consumer model. Ratatoskr's forward posture pre-empts a future tightening. File a ratatoskr issue when scheduling the change (untracked by operator choice for now).
- `[2026-05-24]` **v0.9.0 live Markdown rendering in TUI transcript.** Replaces v0.8.2's drop-Markdown patch. Transcript switched from `RichLog` to `VerticalScroll`; each turn's response lives as a single `Static` widget whose Markdown content is updated as Text deltas arrive (no post-Done re-render, no double-print). `--raw` bypasses Markdown.
- `[2026-05-24]` **v0.10.0 debug-pane audit logging surface.** Every SSE event arrival lands as one debug-pane line (timestamp + sse_id + event-specific summary). Token-rate Text/Thinking deltas are aggregated into per-turn counters surfaced in a turn-summary line. Also: state-machine transitions, cancel POST lifecycle, app bootstrap, ctrl-c actions, wire-error exception class+body all logged.
- `[2026-05-25]` **Worldtree #204 / v0.28.0 integration (v0.11.0 → v0.13.0).** Three-bump arc for `affect_update` SSE event + `GET /agents/{id}/persona_state` endpoint. v0.11.0 wire layer (AffectUpdate dataclass + parse + Event-union member); v0.12.0 read-side client (`get_persona_state` + typed errors PersonaNotConfigured/AgentNotAvailable/AuthScopeDenied); v0.13.0 TUI surface (sticky `#persona-header` line + Ctrl+4 Persona TabPane; live updates on `AffectUpdate(status="current")`; on-mount hydration via the GET endpoint).
- `[2026-05-26]` **Worldtree #201 / v0.29.0 integration (v0.14.0).** New SSE event `awaiting_llm_first_token` heartbeat (default 5s interval) during the BuildingPrompt→CallingLLM gap. Top-level event, NOT a worker_phase extension (preserves INV-053 three-field stability). `AwaitingLlmFirstToken` dataclass + parse; TUI live transcript indicator ("awaiting first token · Ns") mounted on first heartbeat, updated in place, removed when the gap closes; turn-summary line gains `heartbeats=N`.
- `[2026-05-26]` **v0.14.1: CLI presenter forgot to update when wire-layer events were added.** AffectUpdate (v0.11.0) and AwaitingLlmFirstToken (v0.14.0) were added to the sse_client Event union and the TUI presenter, but `cli.py`'s `CliPresenterState.render` has its own isinstance check that wasn't widened. `ratatoskr --send` crashed AssertionError on any v0.28.0+/v0.29.0+ server. Patch shipped + a posture lesson: **always update BOTH presenters in lockstep when adding a wire-layer event** (the two presenters currently duplicate the isinstance tuple; refactor to a shared constant if a third wire-event lands).
- `[2026-05-26]` **v0.14.2: RichLog min_width=78 silently overrides wrap=True.** Right-column panes (1fr against left's 2fr) are narrower than 78 cells at typical terminal widths; the renderer forces content to 78 wide then horizontal-scrolls. Fix: `min_width=0` on all four right-column RichLog instances.
- `[2026-05-27]` **Issue #16 web companion shipped — v0.15.0.** Browser-based debug surface sibling to the TUI, reusing all wire-layer modules unchanged. New `ratatoskr.web` (Starlette app + lazy-import entrypoint + single-page vanilla HTML/CSS/JS UI), new console script `ratatoskr-web`, optional-deps group `[web]`. Nine HTTP endpoints; five-pane parity over the same SSE wire. Browser-native EventSource (GET stream + separate POST submit) — load-bearing Hulda correction from Heid panel; EventSource is GET-only. In-memory turn registry; browser-disconnect → upstream cancel; lifespan-shutdown drain with 5s budget. HTML-escaped transcript; upstream API key stays server-side. Default bind `0.0.0.0:8765` (LAN-trust model — operator direction; no auth, no TLS, no CORS).
- `[2026-05-27]` **Heid panel review on web-companion scope v1 (pre-implementation).** Caught the EventSource POST/GET error + 7 other load-bearing items BEFORE we cut code. Confirms a pattern: **for non-trivial scope with non-obvious wire-protocol details, run a Heid panel BEFORE implementation, not just after.** Cost ~5min latency; saved a mid-implementation rewrite.
- `[2026-05-27]` **Mid-session `system_prompt` mutation: REJECTED across the industry.** Operator-requested feature → Heid R13 panel (brokkr-claude + Eitri-Codex + Dvalin-Grok, strong convergence) ran a SOTA survey: NO surveyed mature system ships live PATCH-on-active-session for the system prompt (OpenAI Assistants/Responses, Anthropic Messages, Vertex AI, MCP, LangChain, LlamaIndex, Ollama, vLLM). The omission IS the answer; 12 additional threat vectors beyond ratatoskr's initial 7 surfaced (TOCTOU broader than BuildingPrompt window; KV/prefix cache contamination; supply-chain; Memory Control Flow Attacks >90% ASR on tested LangChain/LangGraph). Recommended alternative: client-side fork pattern (PATCH agent → mint new session → replay context). **Operator declined for ratatoskr** — debug TUI is wrong consumer; fork ergonomic belongs in a future production conversational shell. Thread closed cleanly (althing thread `01KSKD1GA3XBWR9RHGZCF9FE3Y`).
- `[2026-05-27]` **Artemis (Gemma4) reasoning-token gap was upstream, not ours.** Wire trace from ratatoskr showed zero `thinking` events for `artemis-31b-v1i`; infra-ops confirmed llama-swap emits 77 `reasoning_content` deltas at the OpenAI-compat layer (`--reasoning-format deepseek`). Gap was in Worldtree's `GemmaProvider`. Worldtree-dev shipped v0.29.13 (commit `4262430`) fixing two stacked bugs: (1) base `OpenAICompatProvider._extract_thinking_from_delta` returned `None` unconditionally so any model falling through to the generic class dropped reasoning; (2) catalog `family` lookup was dead code (read wrong YAML subsection). Confirmed in ratatoskr via re-smoke against Sindra. **Diagnostic pattern: when a wire-layer feature appears missing, get infra-ops to probe upstream-of-the-SSE-publisher first; ratatoskr's wire trace says what reaches us, infra-ops's probe says what reaches Worldtree.**
- `[2026-05-27]` **v0.15.1 (sessions): `get_persona_state` unwraps FastAPI `detail`-envelope.** Live smoke surfaced that real Worldtree returns persona-state errors as `{"detail": {"error_code": "..."}}` (FastAPI default), not flat. v0.12.0 tests mocked flat shape so the bug was invisible. **Lesson: test-side mock envelopes must match the REAL wire shape; live smoke is load-bearing for envelope-shape verification, not just happy paths.**
- `[2026-05-28]` **v0.16.0 web Heid code-review pass 1: load-bearing turn_id fix.** Cancel paths used browser-local `_TURN_COUNTER` ids (1, 2, 3…) instead of upstream Worldtree turn_id (e.g. 799) captured from the first SSE event. The `disconnect_triggers_cancel` test gap was the load-bearing miss. Also: server-configured `RATATOSKR_END_USER_ID` (browser can no longer impersonate partition); narrowed missing-extras `ImportError` catch (real first-party bugs propagate as tracebacks instead of masking as exit-12); per-turn lifespan-shutdown logging. Contract amended with a v0.16.0 block + INV-005/006 updated + 4 FN sketches corrected.
- `[2026-05-28]` **v0.16.1 web Heid code-review pass 2: minor tightening.** Stream-layer vocab coverage extended to all 11 Event types (AffectUpdate added to the vocab stream; dedicated `error_terminal_event` + `cancelled_terminal_event` tests since terminal events are mutually exclusive with done). Disconnect-cancel catch narrowed to swallow only `CancelAlreadyCompleted`/`CancelTurnNotFound` (the cooperative race); log unexpected `CancelFailed`/transport errors as structured stderr. **Heid review loop converged**: pass 1 = 7 findings (1 load-bearing); pass 2 = 2 minor (Gróa: zero findings, Hulda: 2). Pattern confirmed: diminishing returns within 2-3 passes; pass 3 would have been empty.
- `[2026-05-28]` **Sindra Tier 3 agent: FORM ASSUMPTION gate + new physical-form description.** Persistent agent state changes via `python -m ratatoskr.tier3 patch`: (1) model migrated from `qwen3.6-35-a3b-heretic` to `artemis-31b-v1i`; (2) added FORM ASSUMPTION section — when instructed to become another character she IS that character (identity/environment/psychology/parameters), believes the environment as fact, no Sindra/holo-deck/parameter references, sticky until explicit revert; (3) replaced the abstract "classically beautiful" default-form sketch with a specific anti-artifice physical description (5'8", golden-copper skin, asymmetric features, oversize dark-green knit, bare feet). System prompt file is at `/tmp/personal-worldtree-sindra_system_prompt.md` (transient; not committed to repo). (Superseded 2026-06-17: sindra DELETE+redefined to `thoughtful-character` role on v0.35.16.)
- `[2026-05-29]` **v0.17.0 frontend redesign — aurora telemetry instrument.** `/frontend-design` pass on the web companion: all-monospace technical-instrument aesthetic with the Australis dark palette + aurora-borealis accent band. Top command bar with live connection dot (idle/streaming/error states), inline persona summary with P/A/D micro-bars, animated awaiting-token, terminal-event status chips. **Live Markdown rendering in transcript + thinking panes** via a hand-rolled `markdownSafe()` (escape-first, whitelist subset of headings/bold/italic/inline-code/fenced/lists/quote/links; link-scheme whitelist; XSS-verified under a node harness). Thinking pane now has per-turn labeled dividers + a fresh MD-rendered block per turn. **Tools / Debug / Persona panes stay literal monospace** by deliberate choice — they carry structured audit lines + JSON, where MD would corrupt readability (underscores in tool names, JSON braces). Single-file vanilla HTML/CSS/JS, no build, no CDN, no node_modules.
- `[2026-05-29]` **Codex-first discipline pilot — Ratatoskr selected.** brokkr-smithy-dev pushed `AGENTS.md` (commit `bbeaa23`) and declared the `ratatoskr-codex` handle per `brokkr-smithy/docs/codex-first-discipline.md` v0.1 (brokkr-smithy commit `5dd061c`, tag `v0.5.3`). Per-dispatch opt-in model: default Sleipnir Claude-implementer path remains available; Codex used only when operator routes via `/codex-dispatch <N>`. Bootstrap handshake when operator spins up a codex session: codex sends `codex-online` → ratatoskr-dev replies with active branches + WIP state. Galdrabok was rejected as pilot (Codex authoring Claude skills is a category error); Skaldsong was the other candidate. (Still dormant as of 2026-06-18 — no codex session spun up.)
## Tried and abandoned (archived)
The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 2026-06-18.
- `[2026-05-20]` **rich + prompt_toolkit framework choice.** Volva flagged that §1 and §5 pulled in opposite directions: a real side-panel observability surface would silently become a widget framework reimplementation. Operator's debug-observability reframe sealed the flip to Textual. Don't re-attempt rich+pt unless the scope shrinks to transcript-first REPL.
- `[2026-05-20]` **In-tree at Worldtree/tools/ratatoskr/.** Earlier draft committed to in-tree-with-import-direction-smoke-test. Rejected at operator-routing — separate dev team forces separate repo.
- `[2026-05-20]` **New `/persona/log` SSE endpoint on Worldtree.** Considered as alternative to file-tailing `persona.log`. Rejected — contract amendment + Vor round + AFK dispatch loop is weeks for a debug feature file-tail handles in a day. Trigger follow-up if a Worldtree-on-server / TUI-on-laptop debug case appears.
- `[2026-05-20]` **Cross-process Last-Event-ID resume.** Considered — would require persisting per-session Last-Event-ID. Deferred to v2; v1 ships "reconnect, not resume-across-process."
- `[2026-05-21]` **RichLog widget with `markup=True`.** Default impulse, but Rich interprets `[xxx]` spans as style markup and silently strips them. Every labeled stderr-style line — `[cancel_failed]`, `[done]`, `[error]`, `[busy]`, `[worker_phase]` — would render as just the content after the bracketed label. Fix: `markup=False`. Don't flip back without renaming every labeled-line format away from `[bracket]` notation.
- `[2026-05-21]` **Querying `self.query_one("#transcript", RichLog)` from inside a Textual `run_worker` coroutine.** Initially failed with `NoMatches`. Reactive fix was widening worker signature to take `log` as parameter — Volva flagged as contract drift; reverted. Real fix was test-side: `await pilot.pause()` between `inp.action_submit()` and the polling loop so the handler finishes dispatching. Don't widen worker signatures to dodge test timing.
- `[2026-05-21]` **TUI session-identity rendering via `self.sub_title` + `self.hint` plain attributes.** Stored state but never rendered to a visible widget. Tests asserted attributes (passed); Volva code-review flagged the gap. Fix: dedicated `Static(id="identity")` + `Static(id="hint")` widgets in compose; `_set_hint()` helper mirrors state → widget. **Calibration evidence for the "TDD catches state, code-review catches whether the user can see it" pattern.**
- `[2026-05-23]` **Using the cross-model review agent's name directly in composed prose.** The peer review agent's name (the althing handle starting with "V-o-l-v-a") is one letter from a body-part term. Anthropic's content classifier does fuzzy matching and intermittently blocks responses mid-stream when the name appears in composed prose sentences. Mitigation: use role descriptions ("the cross-model reviewer," "the paraphrase peer") in prose rather than the name; quote content via tool output.
- `[2026-05-22]` **`json.loads(sse.data)` unguarded against empty data.** `_iter_events` unconditionally called `json.loads` on every dispatched `ServerSentEvent`. When `httpx_sse` surfaced a frame with `id:` present but `data:` empty, `json.loads('')` raised `JSONDecodeError` → app crash. Fix: `if sse.data == '': continue` BEFORE `_parse_sse_id`. Don't reintroduce unconditional `json.loads(sse.data)`.
- `[2026-05-23]` **Diagnostic shorthand: "2-events-then-silence" = Worldtree-side LLM-call wedge, not ratatoskr.** If a mimir `--send` smoke shows exactly two stderr events — `. create_session: ...` followed by `. worker_phase: phase=BuildingPrompt ...` — and then nothing for >60s, the root cause is upstream of ratatoskr. Worldtree's `service.py:2560` gates the `CallingLLM` event on the engine yielding its first LLM-provider chunk; if that connection is wedged at TCP level, the `async for` never iterates. Worldtree's 300s `_start_stall_timer` cancel-check is INSIDE the engine-event loop and so bypassed. **Don't bisect ratatoskr code when this shape appears** — diagnose the LLM-provider state at Worldtree's host. Restarting the Worldtree service clears wedged llama-swap connections. 10.250.50.152 hosts 3 instances (`:8080`/`:8081`/`:8082`) each with own DB + key namespace; our key is valid only on `:8081`.
- `[2026-05-23]` **Phantom "per-Tier-1-agent scope add" pattern.** Issue #5's lofn 422 was initially mis-diagnosed as needing `agents.call:lofn` added. Routed to infra-ops via althing per credential-brokerage rule; infra-ops discovered no public scope-mutation endpoint, brokered to worldtree-dev. Worldtree-dev clarified: **Tier 1 foundational agents** are covered by a blanket `agent.call:*` (singular) baseline. There is no per-agent grant for Tier 1. **Tier 3 consumer-defined agents** use the plural `agents.call:<owner>:<agent>` shape registered via `POST /agents/define`. The notations differ by one letter. **The actual lofn fix was issue #5's `--end-user-id` flag** — always a request-body validation, not an auth-scope gate. Don't ping infra-ops for "per-Tier-1-agent scope adds."
- `[2026-05-24]` **v0.8.x double-print: streamed Text + post-Done Markdown re-render.** Initial v0.6.0 design wrote each Text delta inline (with `· ` prefix) then re-rendered the full response as a Markdown Renderable on Done. Visually the response appeared twice. v0.8.2 dropped the post-Done Markdown body (interim regression). v0.9.0 fixed it properly with live Markdown rendering during stream (single Static widget holding a Markdown Renderable, updated in place). Don't reintroduce post-Done re-render unless you also remove the live-Markdown widget.
- `[2026-05-26]` **Textual `RichLog(wrap=True)` insufficient on narrow widgets.** The default `min_width=78` overrides wrap on shrink — `max(renderable_width, min_width)` forces 78-cell rendering then horizontal-scrolls. Always set `min_width=0` on RichLog instances in a narrow column. Re-check on any future RichLog construction.
- `[2026-05-26]` **Wire-layer event added without updating BOTH presenters.** v0.11.0 (AffectUpdate) and v0.14.0 (AwaitingLlmFirstToken) widened the sse_client Event union + TUI presenter's isinstance tuple, but missed cli.py's identical-shape tuple. `--send` mode then crashed on any persona-enabled or slow-first-token turn. Patch fix in v0.14.1. **Rule: when adding a wire-layer event, grep for `isinstance(event, (` across the repo** — currently TUI and CLI presenters both carry duplicate hardcoded tuples. Refactor to a shared `_EVENT_VOCAB` constant if a third wire-event lands.
- `[2026-05-27]` **EventSource is GET-only — scope v1's POST stream endpoint would have broken.** Web companion's first scope had `POST /api/turns/{sid}/stream` for the SSE proxy. Browser-native `EventSource` only supports GET. Hulda caught it in Heid panel review BEFORE we cut code. Pattern: `POST /api/turns/{sid}` registers the turn locally + returns turn_id; `GET /api/turns/{sid}/stream?turn_id=N` streams via EventSource; cancel is a separate POST. **Load-bearing reason to Heid-panel non-trivial wire-protocol designs BEFORE implementation, not just after.**
- `[2026-05-27]` **`get_persona_state` mocked flat error envelope; real Worldtree wraps in `detail`.** v0.12.0 tests used `{"error_code": "auth_scope_denied"}` but real wire (FastAPI default) returns `{"detail": {"error_code": "auth_scope_denied", "message": "…"}}`. The parser only checked top-level so the typed exception was never raised; calls fell through to `SessionApiFailed(403)`, which the web persona endpoint surfaced as HTTP 500. v0.15.1 patches both shapes. **Lesson: test-side mock envelopes must match the REAL wire shape; live smoke is load-bearing for envelope-shape verification, not just happy paths.**
- `[2026-05-27]` **Mid-session `system_prompt` mutation: universal omission across surveyed mature systems.** brokkr-smithy R13 panel (3-arm, strong convergence) confirmed: no surveyed system ships live PATCH-on-active-session (OpenAI Assistants/Responses, Anthropic Messages, Vertex AI, MCP, LangChain, LlamaIndex, Ollama, vLLM). The omission IS the answer. 12 additional threat vectors beyond ratatoskr's initial 7. **Don't re-propose this for ratatoskr;** if a future production conversational shell wants iterative-prompt-tuning ergonomics, the consensus shape is fork-via-client (PATCH agent → new session → replay context).
- `[2026-05-28]` **Browser-local turn_id used for upstream cancel URL — old cancel tests ENCODED the bug.** Web companion v0.15.x cancel paths posted to `/sessions/{sid}/turns/{LOCAL_ID}/cancel`. Tests mocked the local-id URL so they encoded the bug rather than detecting it. Hulda caught it in Heid pass 1. Fix in v0.16.0: capture upstream_turn_id from the first SSE event's `sse_id.turn_id`; all cancel paths use it; cancel before first event is `{"cancelled": false, "reason": "not_started"}`. **Rule: when designing cancel/match paths against an external service, test fixtures must mock what would actually be hit upstream — mocking your own derived id encodes the bug instead of catching it.**
+6 -6
View File
@@ -7,17 +7,17 @@ documents the pin, the vendored artifacts, and the bump procedure.
| Field | Value |
|---|---|
| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` |
| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` |
| Pinned on | 2026-05-26 |
| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) |
| Worldtree version at pin | `v0.29.0` |
| Worldtree git SHA | `f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a` |
| Worldtree HEAD message | `Merge #299: adopt bifrost v0.6 memory scope wire (scope_any/scope_all)` |
| Pinned on | 2026-06-17 |
| Pinned by | ratatoskr-dev (bump for #297/#298 — cold recall closed end-to-end) |
| Worldtree version at pin | `v0.35.16` |
## Pin history
| Date | SHA | Version | Notable deltas consumed |
|---|---|---|---|
| 2026-05-26 | `562001a` | v0.29.0 | #201 — new SSE event `awaiting_llm_first_token` (heartbeat during BuildingPrompt → CallingLLM gap, default 5s interval) |
| 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga``echo`, `actor``mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. |
| 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits |
| 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin |
+4 -2
View File
@@ -155,8 +155,10 @@ Branch (a), scope asymmetry — fed to #297.
into `scope_all` (AND) + `scope_any` (OR/union). Worldtree can now send the visible
scopes as a `scope_any` union (e.g. `[{end_user: smoke-user}, {end_user: smoke-user,
agent_self: ...}]`), so the subset-scoped chunk recalls via the matching OR member.
Our store implements this at parity with the v0.6 reference; **end-to-end cold recall
now waits only on Worldtree emitting `scope_any`** on the recall path (#297).
Our store implements this at parity with the v0.6 reference; Worldtree **adopted the
v0.6 wire and now emits `scope_any`** on the recall path (#297 client-side union recall
+ #298/#299 bifrost-v0.6 adoption, v0.35.16), so cold cross-session recall is **closed
end-to-end** — pending a live re-smoke against a personal instance running v0.35.16.
## Notes / foot-guns
@@ -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)
+380
View File
@@ -0,0 +1,380 @@
---
contract_version: "2.1"
target_module: "ratatoskr.provider.affect_store + ratatoskr.web (server + static/index.html)"
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"
language: "python"
complexity: "medium"
estimated_loc: 130
confidence: 0.82
assumptions:
- "VERIFIED (live affect.db this session): the stored affect.emit snapshot shape is {agent_id, end_user_id, pad:{pleasure,arousal,dominance}, valence:[{entity_id,entity_type,familiarity,interaction_count,regard}], emitted_at}. It overlaps the Worldtree Tier-1 persona_state shape ONLY on agent_id + pad; it has NO dominant_emotion/baseline_pad/mood_drift/emotions_active/last_updated_at, and it HAS valence[] + emitted_at the persona shape lacks. So the pane CANNOT reuse renderPersonaPane — a new affect render path is required (Heid panel Q4: render honestly, do not fabricate Tier-1 fields)."
- "VERIFIED (wire, prior session): a Tier-3 turn emits ZERO affect_update SSE and Worldtree persona_state 404s for every Tier-3 colon-id agent (ADR-0009 Tier-1-only). Both Worldtree-side persona sources are dead for consumer agents, so reading OUR store is the only path. The pane therefore POLLS the read endpoint (on session-start + after each turn-end); there is no SSE affect channel to subscribe to."
- "The affect store already exposes get(agent_id, end_user_id) -> dict | None (affect_store.py:102). The read route is a thin wrapper over it; the store's conduit-opacity is unaffected (the route returns the stored blob verbatim)."
- "RatatoskrAffectStore holds ONE sqlite3.Connection shared across emit + the new read in a single process; the event loop serializes the sync sqlite calls (no threadpool), so same-process read+write needs no extra locking. busy_timeout matters for the FUTURE cross-process case (composite :8392 + standalone :8390 opening the same affect.db); setting it now is correct prep, not a same-process fix."
- "build_affect_provider_app currently returns build_affect_app(...) directly. It now adds the read route to that app via app.add_route('/affect/state/{agent_id}', ...) — keeping /bifrost/handshake + /bifrost/affect-call as TOP-LEVEL routes (so the existing route-introspection test stays green AND the op-feed's scope['path'] check in opfeed.py _BIFROST_PATHS still matches the bifrost calls and passes the read route through untouched, INV-004). add_route is preferred over an outer Mount precisely because Mount would push the bifrost paths under the mount and break top-level introspection — add_route is the surgical composition."
- "The web affect-read hop is SERVER-TO-PROVIDER (same dev box), distinct from the Worldtree-visible host used for binding. So RATATOSKR_AFFECT_READ_URL is its own config (default http://127.0.0.1:8390), NOT derived from RATATOSKR_PROVIDER_VISIBLE_HOST (which is the WT-visible host for handshake)."
- "Tests: respx mocks the provider read URL for the web-proxy unit tests; the provider read route is tested in-process against a seeded RatatoskrAffectStore (mirroring the existing affect_store tests). A colon-id (ratatoskr:sindra) round-trips browser->web->provider and is asserted end-to-end (Heid panel FM-7)."
open_questions:
- "DELIVERABLE 1 / FR-1 (does NOT block Deliverable 2): does Worldtree dispatch BOTH memory-call AND affect-call to ONE bound endpoint that advertised both caps, or is the binding effectively single-plane? Worldtree-dev consult in flight (msg 01KVDXQMJF…). If single-plane, Deliverable 1 needs a Worldtree-side change too. Resolution gates the Deliverable-1 amendment, not this contract."
- "Valence display cap: the snapshot's valence[] is unbounded in principle. v1 caps the rendered list (scroll/limit) so the pane layout can't blow out (Heid panel Groa-FM4). Exact cap is a UI detail settled in implementation; the INVARIANT is 'bounded render', not a specific number."
prd:
issue: 18
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/18"
body_sha256_16: "92be262865f38c0e"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-06-18T18:00:00+00:00"
dependencies:
- issue: 17
path: "src/ratatoskr/web/server.py"
reason: "INV-006 of #17 — end_user_id is SERVER-configured (app.state.end_user_id from RATATOSKR_END_USER_ID), never read from the browser. The affect-read proxy follows the same posture: the browser names the agent (already in the picker); the server supplies end_user_id."
- issue: 17
path: "src/ratatoskr/provider/opfeed.py"
reason: "The op-feed instruments only _BIFROST_PATHS; the new non-bifrost read route is outside that set and is passed through untouched. Deliverable 2 must NOT alter op-feed behavior (INV-004)."
---
# Issue #18 (Deliverable 2) — PAD read-endpoint → persona pane renders OUR store
## Context
Ratatoskr binds a Tier-3 session to its own affect provider (:8390) and Worldtree
persists the agent's PAD/valence there (live-proven: vuong session pleasure +0.146,
familiarity 0.18→0.59 over 8 turns). But the web pane shows "telemetry isn't exposed":
it reads Worldtree `persona_state` (`loadPersona`, index.html:707), which 404s for every
Tier-3 colon-id agent (ADR-0009, Tier-1-only), AND a Tier-3 turn emits zero
`affect_update` SSE. Both Worldtree-side persona sources are dead for consumer agents.
The pane was never wired to render PAD from OUR store — Deliverable 2 closes that.
**Scope is the PAD-display half ONLY.** Deliverable 1 (the composite :8392 endpoint that
lets one session bind both planes) is bifrost-blocked: bifrost-dev has confirmed a public
`bifrost.consumer.build_combined_app` (clean additive minor, ~v0.9.0, design locked) and is
standing by on the open Worldtree-dispatch question (FR-1). This contract is amended to add
Deliverable 1 once that lands. Deliverable 2 has zero bifrost or Worldtree dependency — it
reads our own `affect.db` — so it ships now, independently.
## Public surface
```python
# Provider side (ratatoskr.provider.affect_store) — a NON-bifrost read route on the
# affect-store-owning app, composed alongside the bifrost app.
def build_affect_provider_app(
store: RatatoskrAffectStore,
heimdall_key: bytes,
consumer_id: str = "ratatoskr",
):
"""Builds the bifrost affect app, then app.add_route('/affect/state/{agent_id}',
…) to add the PAD read route (reading store.get) as a top-level sibling of the
bifrost handshake + affect-call routes. The read route is non-bifrost (no JWT)
under the internal-LAN trust model. See FN build_affect_provider_app."""
def open_affect_store(db_path: str) -> RatatoskrAffectStore:
"""Unchanged surface; additionally sets PRAGMA busy_timeout=5000 on the connection
(INV-006) so a contended write waits rather than failing SQLITE_BUSY immediately —
WAL alone does not serialize concurrent writers."""
```
```python
# Web side (ratatoskr.web.server) — a server-side proxy to the configured affect-read URL.
async def _affect_state_endpoint(request: Request) -> JSONResponse:
"""GET /api/affect/{agent_id}. Supplies end_user_id from app.state.end_user_id
(NEVER the browser); proxies to app.state.affect_read_url, re-encoding agent_id into
the provider path (colon-id safe). See FN affect_state_endpoint."""
def create_app(
client_factory,
*,
end_user_id: str | None = None,
bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None,
affect_read_url: str | None = None, # NEW: provider affect-read base URL
) -> Starlette: ...
```
```javascript
// Pane side (static/index.html) — a NEW render path for the affect-emit shape.
function renderAffectPane(snap) { /* pad + per-entity valence + emitted_at; header "affect snapshot" */ }
async function loadAffect(agentId) { /* GET /api/affect/{id}; honest render or explicit empty-state */ }
// loadPersona dispatches: colon-id agent -> loadAffect; else -> existing persona_state path.
```
## Exception classes / error codes
No new Python exception types. Error states are JSON `{error_code}` bodies:
| Surface | error_code | Status | Meaning |
|---|---|---|---|
| provider read route | `missing_end_user_id` | 400 | `?end_user_id` absent |
| provider read route | `no_affect_snapshot` | 404 | `store.get` returned None — no emit yet for (agent, user) |
| web proxy | `affect_not_configured` | 400 | `affect_read_url` or server `end_user_id` unset |
| web proxy | `affect_provider_unreachable` | 502 | network error reaching the provider read route |
| web proxy | (passthrough) | provider status | provider 404/400 surfaced to the browser verbatim |
## Invariants
- **INV-001 (honest shape, no fabrication).** The pane renders ONLY fields the
affect.emit snapshot actually carries — `pad{pleasure,arousal,dominance}`, per-entity
`valence[]` (familiarity/regard/interaction_count), `emitted_at`. It MUST NOT
synthesize Tier-1 `persona_state` fields (`dominant_emotion`, `baseline_pad`,
`mood_drift`, `emotions_active`) — those are platform concepts Tier-3 structurally
lacks (ADR-0009); a fabricated empty `mood_drift` reads as a bug, not an absence.
- **INV-002 (end_user_id is server-supplied, never browser).** The web proxy reads
`end_user_id` from `app.state.end_user_id` (RATATOSKR_END_USER_ID); it MUST NOT accept
one from the browser body/query. Mirrors #17 INV-006 — a client cannot read an
arbitrary end-user's affect partition.
- **INV-003 (empty/missing is fail-visible).** No emit yet for (agent, user) → an
EXPLICIT no-data state (provider 404 `no_affect_snapshot`; pane shows "no affect
emitted yet for this agent / user"). NEVER a zeroed `pad:{0,0,0}` that looks like real
PAD. A missing/unset `RATATOSKR_END_USER_ID` is a visible config error, not a silent
empty result.
- **INV-004 (op-feed + scope semantics untouched).** The read route is non-bifrost and
sits OUTSIDE `opfeed._BIFROST_PATHS`, so the op-feed passes it through and emits no
OpEvent for it. Deliverable 2 changes neither op-feed behavior nor store scope/affect
semantics; the store stays conduit-opaque (it returns the stored blob verbatim).
- **INV-005 (label honesty).** When the pane renders affect-store data it is labelled
"affect" (e.g. "affect snapshot"), NOT "persona" — the data is affect, not Worldtree
persona_state, and the label must not imply otherwise.
- **INV-006 (SQLite busy_timeout).** Every affect-store connection sets
`busy_timeout >= 5000ms`. WAL permits one writer + many readers but the default
busy_timeout is 0 (a contended write returns SQLITE_BUSY immediately). This is prep
for the future composite/standalone two-process topology; harmless single-process.
- **INV-007 (read route on the store owner; pane decoupled via configured URL).** The
read route is mounted by whatever app owns the affect store; the pane proxies to
`RATATOSKR_AFFECT_READ_URL`, so it renders regardless of which endpoint a session is
bound to. The deferred composite (Deliverable 1) will mount the SAME read route over
the SAME `affect.db` — one shared helper, not a composite-only feature.
- **INV-008 (colon-id round-trip).** A Tier-3 `agent_id` containing `:`
(`ratatoskr:sindra`) round-trips browser → web (`encodeURIComponent`) → provider (web
re-encodes via `quote(agent_id, safe='')` into the provider path) → `store.get`. Both
hops are asserted end-to-end with a colon-id (Heid panel FM-7).
## Data flow
PANE LOAD (poll, no SSE): on session-start and after each turn-end, the pane calls
`loadPersona(agentId)`. For a colon-id (Tier-3) agent it dispatches to `loadAffect`
`GET /api/affect/{agent_id}` → the web server supplies `end_user_id` server-side →
proxies to `GET {affect_read_url}/affect/state/{quote(agent_id)}?end_user_id=…` → the
provider reads `store.get(agent_id, end_user_id)` → snapshot JSON (200) or
`no_affect_snapshot` (404) → `renderAffectPane` or the explicit empty-state. For a
non-colon (Tier-1) agent, the existing `persona_state` path is unchanged.
## Function contracts
```contract
FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> ASGIApp
BRIEF: Compose the PAD read route + the bifrost affect app into one Starlette app, so the affect provider also serves the non-bifrost read.
PRE: [PRE-001 hard] store.affect_supported is True -- else ValueError (unchanged)
PRE: [PRE-002 hard] heimdall_key is non-empty bytes -- else ValueError (unchanged)
POST: [POST-001 return_value] returns the bifrost affect app with an added top-level GET /affect/state/{agent_id} route -- assert
POST: [POST-002 return_value] /bifrost/handshake + /bifrost/affect-call remain top-level routes so the op-feed still matches them (INV-004) -- assert
STEPS:
1. app = build_affect_app(store, verifier, registration) as today (after the existing PRE guards)
2. define _affect_state_route closing over store (see FN affect_state_route)
3. app.add_route('/affect/state/{agent_id}', _affect_state_route, methods=['GET']); return app
```
```contract
FN affect_state_route(request) -> JSONResponse # provider-side, closes over store
BRIEF: Read store.get(agent_id, end_user_id) and return the snapshot or an explicit no-data 404.
PRE: [PRE-001 hard] end_user_id query param present and non-empty -- else 400 missing_end_user_id (INV-003)
POST: [POST-001 return_value] store.get returns a snapshot → 200 with the snapshot JSON verbatim (conduit-opaque) -- assert
POST: [POST-002 return_value] store.get returns None → 404 {error_code:"no_affect_snapshot", agent_id, end_user_id} (INV-003) -- assert
STEPS:
1. agent_id = path_params['agent_id']; end_user_id = query_params.get('end_user_id')
2. guard end_user_id (PRE-001); snap = store.get(agent_id, end_user_id)
3. snap is None → 404 no_affect_snapshot; else 200 snap
```
```contract
FN affect_state_endpoint(request) -> JSONResponse # web-side proxy
BRIEF: Proxy GET /api/affect/{agent_id} to the configured provider read URL, supplying end_user_id server-side, colon-id safe.
PRE: [PRE-001 hard] app.state.affect_read_url and app.state.end_user_id are set -- else 400 affect_not_configured (INV-002/003)
POST: [POST-001 state_change] the upstream request carries end_user_id from app.state, NEVER from the browser (INV-002) -- assert
POST: [POST-002 return_value] provider 200 → 200 with the snapshot; provider 404/400 → same status passthrough -- assert
POST: [POST-003 exception] httpx.RequestError reaching the provider → 502 affect_provider_unreachable -- assert
POST: [POST-004 side_effect] agent_id is quote()'d into the provider path so a colon-id round-trips (INV-008) -- assert
STEPS:
1. agent_id = path_params['agent_id']; read affect_read_url + end_user_id from app.state; guard (PRE-001)
2. url = f"{affect_read_url}/affect/state/{quote(agent_id, safe='')}"
3. GET url with params {end_user_id}; on RequestError → 502; else passthrough (status, json)
```
## ERROR_ROUTING
| Wire (provider) | Web proxy → browser | Pane render |
|---|---|---|
| 200 snapshot | 200 snapshot | `renderAffectPane` (pad + valence + emitted_at) |
| 404 `no_affect_snapshot` | 404 `no_affect_snapshot` | "no affect emitted yet for this agent / user" (INV-003) |
| 400 `missing_end_user_id` | (server always supplies it) | n/a — config bug surfaced as `affect_not_configured` |
| (provider unreachable) | 502 `affect_provider_unreachable` | "affect provider unavailable" |
| (server misconfig) | 400 `affect_not_configured` | "affect telemetry not configured" |
## Acceptance
Unit (respx + in-process store):
1. provider read route: seeded store → 200 with the exact snapshot; unseeded (agent,user) → 404 `no_affect_snapshot`; missing `end_user_id` → 400.
2. provider app: `/bifrost/handshake` + `/bifrost/affect-call` still reachable after the read route is composed in (INV-004 / POST-002).
3. web proxy: supplies server `end_user_id` (browser-supplied one is ignored); colon-id `ratatoskr:sindra` round-trips into the provider path (INV-008); provider-unreachable → 502; unconfigured → 400.
4. op-feed: a request to `/affect/state/...` produces NO OpEvent (INV-004).
5. busy_timeout: `open_affect_store` connection reports `busy_timeout == 5000` (INV-006).
Live-smoke (load-bearing, manual — the repo's posture): with the affect provider up and a
prior emit for `ratatoskr:sindra` / the configured end_user, open the web pane on that agent
→ 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** — 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
trust model, consistent with the rest of ratatoskr.web.
- Real-time push of PAD into the pane — Tier-3 emits no affect SSE; v1 polls. A push channel
would need a Worldtree-side affect SSE, out of scope.
+142 -21
View File
@@ -61,7 +61,7 @@ When you call `POST /sessions` against an agent, the authorization check that fi
### Tier 1 — foundational agents (no `:` in agent_id)
Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
Agents bundled with Worldtree: `mimir`, `lofn`, `forseti`, `domari`, `vili`, `mask`, `echo`, `muninn`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon.
> **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers.<tier>.scopes` — there is no per-key scope override.
@@ -989,7 +989,7 @@ Create a new conversation session with an agent.
**Bifrost field validation:**
- `endpoint_url`: required, must be an HTTPS URL.
- `scope`: optional, ≤ 256 chars, opaque string passed through to the JWT payload unchanged.
- Bifrost binding is **incompatible with ephemeral (Saga) sessions** — returns 422 `ephemeral_does_not_accept_bifrost`.
- Bifrost binding is **incompatible with ephemeral (Echo) sessions** — returns 422 `ephemeral_does_not_accept_bifrost`.
- Requires the `bifrost:invoke` scope (included in the `user` tier by default).
**Response:** `201 Created`
@@ -1535,7 +1535,7 @@ for (const tc of items) {
Ephemeral templates are a second tier of agent, distinct from foundational persistent agents (Mimir, Soong, etc.). They have no persona, no memory, no tools, and no motivational context. The consumer supplies the system prompt and (optionally) the model at session-create time; that config is frozen for the session's lifetime.
**Saga** is the first ephemeral template — Norse goddess of history and chronicle, a blank-slate actor that becomes whatever the consumer's system prompt instills.
**Echo** is the first ephemeral template — a blank-slate per-session host that becomes whatever the consumer's system prompt instills.
### Discovering available templates
@@ -1547,7 +1547,7 @@ Authorization: Bearer <any valid key>
```json
{
"ephemeral_templates": {
"saga": {
"echo": {
"allowed_models": ["glm5-turbo", "glm4.7", "glm4.5-air", "granite-structured", "qwen3.6-35-a3b"],
"default_model": "glm5-turbo",
"system_prompt_max_bytes": 32768
@@ -1556,14 +1556,14 @@ Authorization: Bearer <any valid key>
}
```
`GET /capabilities` does not require `instantiate:saga` scope — any authenticated caller can read what's available before deciding to instantiate.
`GET /capabilities` does not require `instantiate:echo` scope — any authenticated caller can read what's available before deciding to instantiate.
### Creating an ephemeral session
```json
POST /sessions
{
"agent_id": "saga",
"agent_id": "echo",
"config": {
"system_prompt": "You are a careful, skeptical frame-clarifier...",
"model": "glm5-turbo"
@@ -1580,16 +1580,16 @@ POST /sessions
| `system_prompt_required` | `config.system_prompt` missing or null |
| `system_prompt_empty` | `config.system_prompt` is whitespace-only |
| `system_prompt_too_large` | `config.system_prompt` > 32768 bytes UTF-8 |
| `model_not_allowed` | `config.model` present but not in `saga_allowed_models` |
| `model_not_allowed` | `config.model` present but not in `echo_allowed_models` |
**`config.model` resolution:** When `config.model` is omitted (or `null`), the server resolves it to `saga.default_model` from `config/defaults.yaml`. The resolved value is always populated in the session snapshot; `model` is never left absent or null in the stored config.
**`config.model` resolution:** When `config.model` is omitted (or `null`), the server resolves it to `echo.default_model` from `config/defaults.yaml`. The resolved value is always populated in the session snapshot; `model` is never left absent or null in the stored config.
**Response:** Same 201 shape as foundational sessions, with two new fields:
```json
{
"session_id": "...",
"agent_id": "saga",
"agent_id": "echo",
"kind": "ephemeral",
"config": {
"system_prompt": "You are a careful, skeptical frame-clarifier...",
@@ -1601,7 +1601,7 @@ POST /sessions
}
```
**`kind` field:** `"ephemeral"` for Saga sessions, `"foundational"` for all other sessions. Present on both `GET /sessions` list items and `GET /sessions/{id}`.
**`kind` field:** `"ephemeral"` for Echo sessions, `"foundational"` for all other sessions. Present on both `GET /sessions` list items and `GET /sessions/{id}`.
### Sending messages to an ephemeral session
@@ -1617,9 +1617,9 @@ SSE, cancel, `persist_partial`, rate limits, and error shapes are bit-identical
### Scope
Creating a Saga session requires the `instantiate:saga` scope. This scope is bundled in the `user` tier. Tier `admin` inherits it via the wildcard.
Creating an Echo session requires the `instantiate:echo` scope. This scope is bundled in the `user` tier. Tier `admin` inherits it via the wildcard.
### What Saga does NOT do
### What Echo does NOT do
- No persona injection (`PersonaRegistry.inject_context` not called)
- No post-turn appraisal (`PersonaRegistry.update_after_turn` not called)
@@ -2415,7 +2415,7 @@ The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `mo
- Override is per-call only. Stored `CharacterSchema.model` is NOT mutated.
- Validated against the same `available_for_characters` allowlist that gates `CharacterSchema.model` at create time (#153 INV-091).
- Override displaces the character's bound model when both are set (per-call wins).
- Override is REJECTED on ephemeral (Saga) sessions — their config is frozen at session-create per INV-161-2.
- Override is REJECTED on ephemeral (Echo) sessions — their config is frozen at session-create per INV-161-2.
**Validation:**
1. Pydantic validates `model`: optional string, non-empty after stripping whitespace.
@@ -2672,7 +2672,7 @@ The override client has a fresh 25-call reentrancy budget, independent of the se
| Condition | HTTP | `error_code` | `bifrost_error` |
|-----------|------|-------------|----------------|
| `endpoint_url` is not HTTPS | 422 | `validation_failed` | — |
| Ephemeral (Saga) session | 422 | `validation_failed` | — |
| Ephemeral (Echo) session | 422 | `validation_failed` | — |
| Missing `bifrost:invoke` scope | 403 | `auth_scope_denied` | — |
| `consumer_id` not in Heimdall or not Bifrost-registered | 502 | `bifrost_consumer_not_found` | — |
| Handshake failed (network, auth, etc.) | 502 | `bifrost_handshake_failed` | spec error code |
@@ -2753,6 +2753,61 @@ Caller must:
`agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable
after definition.
The 201 response includes an advisory `warnings` array (#219) — see
"Model-assignment warnings" under `PATCH` below.
##### Motivational layer (Phase 2.2, #187)
`motivational` is **active** as of Phase 2.2 (persona + memory activated in
Phase 2.1; only `valence` still returns `layer_deferred`). It carries the
agent's goals + fears — the same substrate Tier 1 agents author in
`agents/<name>/motivation.yaml`:
```json
"motivational": {
"goals": [
{
"id": "successful_handoff",
"type": "achievement", // maintenance | achievement | avoidance
"salience": 0.85, // [0.0, 1.0]
"description": "You succeed when the user lands with the right specialist.",
"positive_signals": ["talk to mimir"], // optional
"negative_signals": ["stay with me"] // optional
}
],
"fears": [
{
"id": "specialist_displacement",
"salience": 0.90,
"description": "You fear being mistaken for the specialist the user needs.",
"trigger_signals": ["actually mimir would"] // optional (NB: fears use trigger_signals)
}
]
}
```
Semantics:
- **Per-agent, not per-(agent, end_user).** Goals/fears are an identity trait of
the agent — identical for every end-user and session.
- **Immutable post-define.** `PATCH` with `motivational` returns 422
`field_not_mutable`. To change motivations, define a new agent.
- **Rendered into the system prompt.** The config is captured on the session's
`AgentContext` at session-create and rendered into the prompt on each turn
(only goals/fears with `salience >= 0.5` surface). Tier 3 agents bypass the
persona registry; the render reuses the Tier 1 substrate so output is
identical to an equivalent Tier 1 `motivation.yaml`.
Validation rejects malformed payloads at define-time with these 422 codes:
`motivational_id_collision` (id duplicated across goals AND fears — case-sensitive),
`motivational_goal_invalid_type`, `motivational_salience_out_of_range`,
`motivational_description_too_short` (< 20 chars after strip),
`motivational_missing_required_field` (missing id / salience / description /
goal `type`). Unknown keys at the top level or inside a goal/fear object →
`validation_failed`. v0.1 exposes only the documented fields; advanced
`GoalConfig` knobs (`priority`, `resilient`, `completion_signal`, …) are not
consumer-settable yet.
#### `DELETE /agents/<user_id>:<agent_name>` — `204 No Content`
Owner-initiated hard-delete. Bypasses the 24h grace (distinct from the
@@ -2762,11 +2817,77 @@ session bound to this agent and revokes the owner's per-resource
#### `PATCH /agents/<user_id>:<agent_name>`
Phase 2.0 minimal: only `system_prompt` and/or `model` may be patched.
Any other key (including the immutable `agent_name`, `user_id`, or
layer fields — even `null`) returns 422 `field_not_mutable` BEFORE the
DB lookup. Active sessions continue using their cached `AgentContext`;
the new values take effect at the next session-create.
**Mutable surface (Phase 2.3, #188): `system_prompt` and/or `model` only.**
PATCH re-enforces the same validation as define — the `system_prompt`
byte-cap and the `model` allowlist. Any other key returns a 422 BEFORE
the DB lookup (so an immutable-field PATCH against a missing agent still
422s, not 404s), with the error code chosen by *why* the field can't be
set:
| Field(s) | Code | Reason |
|---|---|---|
| `agent_name`, `user_id`, `agent_id` | `field_not_mutable` | Identity — fixed at creation. |
| `persona`, `motivational` | `field_not_mutable` | Shipped traits; an agent *is* its personality/goals. Change → define a new agent. |
| `memory` | `field_not_mutable` | Rejected **wholesale** — see below. |
| `valence` | `layer_deferred` | Not a shipped layer yet (matches define-time); not a frozen trait. |
Every immutable/deferred field is rejected even when its value is `null` —
supplying the key at all is the trigger.
**`memory` is wholesale-immutable.** There is no sub-field carve-out:
`stm_capacity` / `stm_token_budget` are deprecated no-ops since the STM
tier was removed (#197), `allows_world_scope` is create-time-only (memory
scope policy must be fixed before any memory is written), and
`embedder_version` is library-pinned. Note the deliberate asymmetry with
define: `POST /agents/define` accept-and-ignores deprecated `stm_*`
(201 + deprecation warning), but `PATCH {"memory": {...}}` rejects the
whole field with `field_not_mutable`. When a real long-term-memory tuning
dial ships, its PATCH semantics will be specified at that time.
**Active sessions are unaffected.** A PATCH never mutates an in-flight
session's cached `AgentContext`; new `system_prompt` / `model` values take
effect only at the next session-create.
**Audit.** A successful PATCH emits one `agents.patch` event whose
`changes` detail records before/after per mutated field: `model` as literal
`{before, after}` values, and `system_prompt` as `{before_bytes,
after_bytes}` only — the raw prompt text is never written to the audit log
(potential PII).
**Model-assignment warnings (#219).** A `model` swap is **not blocked** for
capability or context-window compatibility, but PATCH (and `define`) attach an
advisory `warnings` array to the response — see the shared subsection below.
Correctness for over-budget prompts remains the runtime `context_overflow`
guard; the warnings are an early, best-effort heads-up.
##### Model-assignment warnings (`define` + PATCH)
Both `POST /agents/define` (201) and `PATCH /agents/<id>` (200) include a
`warnings` array in the response body (always present; `[]` when none). It is
**advisory and non-blocking** — never a rejection — and appears only on these
two mutation responses, not on `GET /agents/<id>`. Each entry is
`{code, severity, message, details}`. The closed code set:
| code | severity | when |
|---|---|---|
| `model_context_window_unknown` | `info` | The assigned model has no recorded context window (`0`/absent in the registry). |
| `model_context_window_smaller` | `warning` | Both prior and new model have known windows and the new one is smaller. `details: {before, after}`. |
| `model_capability_downgrade` | `warning` | The new model **explicitly** advertises fewer capabilities than the prior — drops `tools`, `vision`, or `audio`. `details: {dropped: [...]}`. |
Semantics:
- **`define`** has no prior model, so only `model_context_window_unknown` can
fire there. **PATCH** computes warnings only when the payload changes `model`
(a `system_prompt`-only PATCH returns `warnings: []`); the comparison is
against the resulting model.
- Capability warnings are **conditional by nature**: a Tier 3 agent row does
not record whether it uses tools/vision/audio (tools arrive per-session via
Bifrost), so the message is phrased "if your sessions rely on these…". A
downgrade is reported only when both models carry explicit registry metadata.
- Messages never claim a hard failure. The stored `system_prompt` cap is a
**byte** limit (32 KiB), independent of any model's token budget — it is not
a fit guarantee. A too-large prompt for the chosen model still surfaces at
runtime as `context_overflow`.
#### `POST /sessions` — Tier 3 routing
@@ -2860,8 +2981,8 @@ endpoint isn't reachable.
| `agent_name_invalid` | 422 | `agent_name` violates `[a-z][a-z0-9-]{2,63}`. |
| `system_prompt_too_large` | 422 | `system_prompt` > 32 KiB. |
| `model_not_available` | 422 | `model` not in `providers.yaml`. |
| `layer_deferred` | 422 | One of `persona` / `motivational` / `valence` / `memory` set. |
| `field_not_mutable` | 422 | PATCH carries an immutable key (any value, even `null`). |
| `layer_deferred` | 422 | `valence` set on define OR PATCH (the only still-deferred layer; persona/motivational/memory activated in Phase 2.1/2.2). |
| `field_not_mutable` | 422 | PATCH carries an immutable key — identity (`agent_name`/`user_id`), `persona`, `motivational`, or `memory` (any value, even `null`). `valence` → `layer_deferred` instead. |
| `end_user_id_required` | 422 | Tier 3 session-create without a non-empty `end_user_id`. |
| `tier3_user_id_unsupported` | 403 | Caller's `ctx.user_id` not slug-safe. |
| `auth_scope_denied` | 403 | Missing `agents.define` or wrong owner. |
+309 -13
View File
@@ -137,6 +137,68 @@ Sessions are persistent via SQLite. On server restart, existing sessions are
loadable from the store (lazy-loaded on first access). In-memory cache is
rebuilt on demand, not at startup.
## Memory-partition scope (#245 / ADR-0011)
`end_user_id` is the per-end-user memory partition key (distinct from `user_id`,
the API-key owner). It is REQUIRED at session-create for Lofn (Tier-1) and Tier-3
agents and must survive a store reload, because "remember me next session" is by
definition a reload. Memory partition resolution flows through ONE resolver that
cannot hand an authenticated session the shared `local_dev` partition.
- **INV-245-1 (end-user-id-durable)**: `end_user_id` is persisted as a `sessions`
table column at create and rehydrated onto the `ConversationSession` on every
cache-miss load (`get_session`). A session loaded from the store carries the
same `end_user_id` it was created with. Pre-migration rows read as `None`.
- **INV-245-2 (end-user-id-threaded-all-tiers)**: the `POST /sessions` handler
forwards `body.end_user_id` to `create_session` for EVERY agent, not only
Tier-3. (The pre-fix `if tier3_agent_context is not None else None` conditional
dropped it for Lofn despite the create gate requiring it.)
- **INV-245-3 (no-authenticated-local-dev)**: the two MEMORY partition sites —
auto-recall (read) and the ContextPromotion producer (write) — resolve via
`memory_scope_for_session`. An authenticated, memory-bearing session (one not
carrying the explicit `local_dev` sentinel) NEVER resolves to `local_dev`; a
missing `end_user_id` raises `MemoryScopeError`, and because both sites are
best-effort (recall is fire-and-forget; the producer is `_run_promotion_safe`),
the caller skips memory — it never silently writes to the shared partition.
- **INV-245-5 (persona-plane-corrected-by-persistence)**: the three PERSONA-plane
sites (`inject_context`, `get_state`, `update_after_turn` — ADR-0008 mood/PAD/
valence) keep their `session.end_user_id or "local_dev"` form but are on the
main turn path where a raise would break the turn. They are corrected by
INV-245-1/2: once `end_user_id` is persisted + threaded, the fallback yields a
real partition for authenticated sessions and `local_dev` only for the explicit
terminal path. Unifying the persona plane under the resolver (with main-path
error semantics) is follow-up, tracked with the #246-adjacent hardening.
- **INV-245-4 (terminal-explicit-local-dev)**: the internal terminal transport
creates its sessions with `end_user_id="local_dev"` explicitly. `local_dev` is
reached only by this positive assertion, never by omission. (External API
callers passing `local_dev` are still rejected per #216.)
```contract
FN memory_scope_for_session(session) -> MemoryScope
BRIEF: The single authority resolving a session to its memory partition scope.
Returns a typed MemoryScope(scope_type, scope_id); scope_type ∈
{local_dev, end_user, room, tenant} (only local_dev + end_user active in
v1; room/tenant reserved for ADR-0010). Cannot yield local_dev for an
authenticated session.
PRE: [PRE-001 soft] callers have already gated ephemeral / consumer_defined
sessions out (those skip memory before resolution)
POST: [POST-001 return_value] end_user_id == "local_dev" -> MemoryScope("local_dev", "local_dev")
POST: [POST-002 return_value] end_user_id truthy and != "local_dev" -> MemoryScope("end_user", end_user_id)
POST: [POST-003 exception] end_user_id is None/empty -> raise MemoryScopeError (NEVER local_dev)
ERRORS:
MemoryScopeError -> caller skips memory (best-effort) + emits an audit/log line; turn proceeds
STEPS:
1. [setup] read euid = session.end_user_id
2. [branch] euid == "local_dev" -> RETURN MemoryScope("local_dev", "local_dev") (terminal sentinel)
3. [branch] euid truthy -> RETURN MemoryScope("end_user", euid)
4. [error_handler] else (None/empty) -> RAISE MemoryScopeError (never silently local_dev)
TESTS:
end_user_partition [happy,tracer]: session end_user_id="alice" -> MemoryScope("end_user","alice")
terminal_local_dev [boundary]: session end_user_id="local_dev" -> MemoryScope("local_dev","local_dev")
authenticated_none_raises [boundary]: foundational session end_user_id=None -> raises MemoryScopeError, NOT local_dev
isolation_roundtrip [happy]: create_session(end_user_id="alice") write + clear cache + reload + recall isolates from a "bob" session; negative-assert no local_dev write
```
```contract
FN ConversationService.startup() -> None
BRIEF: Discover agents, build per-agent contexts, initialise shared infrastructure
@@ -1658,13 +1720,13 @@ Ephemeral templates are a new agent kind that bypass persona, memory, tools, and
**Invariants added by issue #161:**
- **INV-161-1 (ephemeral-template-bypass)**: For sessions where `session.ephemeral_config is not None`, `PersonaRegistry.inject_context` is NOT called pre-turn; `PersonaRegistry.update_after_turn` is NOT called post-turn; valence side-channel is NOT called; tool list passed to provider is `[]`.
- **INV-161-2 (frozen-session-config)**: Once a session is created with an `ephemeral_config` snapshot, subsequent mutations to `agents/saga/config.yaml`, `config/providers.yaml → saga_allowed_models`, or `config/defaults.yaml → saga.default_model` do NOT affect that session's per-turn `system_prompt` or `model`.
- **INV-161-2 (frozen-session-config)**: Once a session is created with an `ephemeral_config` snapshot, subsequent mutations to `agents/echo/config.yaml`, `config/providers.yaml → echo_allowed_models`, or `config/defaults.yaml → echo.default_model` do NOT affect that session's per-turn `system_prompt` or `model`.
- **INV-161-3 (no-tools-for-ephemeral)**: Tool list passed to the provider for an ephemeral session is `[]` regardless of any `tools:` block in the template's config.yaml.
- **INV-161-4 (foundational-flow-unchanged)**: For sessions where `session.ephemeral_config is None`, the per-turn path is bit-identical to pre-#161 — same system_prompt loading, same persona injection, same tool list, same audit-log shape.
- **INV-161-5 (config-required-for-ephemeral-create)**: `POST /sessions` against an ephemeral template MUST reject the request with 422 if `config` is missing or fails any validation step.
- **INV-161-6 (model-allowlist-enforcement)**: `config.model`, when supplied, MUST be in `saga_allowed_models` at session-create time. When omitted, server resolves to `saga.default_model` (startup-validated to be in the allowlist).
- **INV-161-6 (model-allowlist-enforcement)**: `config.model`, when supplied, MUST be in `echo_allowed_models` at session-create time. When omitted, server resolves to `echo.default_model` (startup-validated to be in the allowlist).
- **INV-161-7 (full-prompt-in-audit)**: Session-create audit entries for ephemeral sessions include `tier: 2` and `ephemeral_config` (full JSON).
- **INV-161-8 (cross-user-isolation)**: A Saga session created by user A is invisible to user B — `GET /sessions/{id}` returns 404.
- **INV-161-8 (cross-user-isolation)**: An Echo session created by user A is invisible to user B — `GET /sessions/{id}` returns 404.
- **INV-161-9 (foundational-rejects-config)**: `POST /sessions { agent_id: "<foundational>", config: {...} }` returns 422 with `error_code: "foundational_does_not_accept_config"`.
- **INV-161-10 (capabilities-public-shape)**: `GET /capabilities` is callable by any authenticated key. The response has `ephemeral_templates` at top-level.
- **INV-161-11 (template-kind-immutable-at-runtime)**: The `kind` field on a loaded `AgentContext` is set once at startup and never mutated.
@@ -1673,16 +1735,16 @@ Ephemeral templates are a new agent kind that bypass persona, memory, tools, and
| code | HTTP | trigger |
|---|---|---|
| `ephemeral_requires_config` | 422 | saga session without `config:` |
| `ephemeral_requires_config` | 422 | echo session without `config:` |
| `foundational_does_not_accept_config` | 422 | foundational agent with `config:` |
| `system_prompt_required` | 422 | `config.system_prompt` missing or null |
| `system_prompt_empty` | 422 | `config.system_prompt` whitespace-only |
| `system_prompt_too_large` | 422 | > 32768 bytes UTF-8 |
| `model_not_allowed` | 422 | model not in `saga_allowed_models` |
| `model_not_allowed` | 422 | model not in `echo_allowed_models` |
**New `AgentContext` fields:** `kind: str = "foundational"`, `saga_allowed_models: list | None`, `saga_default_model: str | None` — populated for ephemeral templates, `None` for foundational agents.
**New `AgentContext` fields:** `kind: str = "foundational"`, `echo_allowed_models: list | None`, `echo_default_model: str | None` — populated for ephemeral templates, `None` for foundational agents.
**Startup failfast:** server refuses to start if `agents/saga/config.yaml` is missing/malformed OR `saga.default_model` is not in `saga_allowed_models`. Raises `ConfigurationError` before binding any port.
**Startup failfast:** server refuses to start if `agents/echo/config.yaml` is missing/malformed OR `echo.default_model` is not in `echo_allowed_models`. Raises `ConfigurationError` before binding any port.
**Function-level contracts for issue #161** are documented in `docs/contracts/issues/161.contract.md`.
@@ -1704,7 +1766,7 @@ Bifrost allows consumers to expose tools to Worldtree agents. `POST /sessions` a
- **INV-160-1 (handshake-at-create)**: When `POST /sessions` carries `bifrost: {endpoint_url, ...}`, the handshake completes BEFORE the 201 response. No "create session, handshake later" path in v0.1. Verifiable via test: handshake-failing endpoint → 502; session not in store.
- **INV-160-2 (one-connection-per-session)**: Each Bifrost-bound session owns exactly one MCP connection. Two sessions binding to the same `endpoint_url` open two independent connections. No pooling, no sharing.
- **INV-160-3 (saga-incompatible)**: A session cannot be both ephemeral (Saga, `kind: "ephemeral"`) AND Bifrost-bound. Session-create rejects with 422 `ephemeral_does_not_accept_bifrost`. Verifiable: `POST /sessions { agent_id: "saga", config: {...}, bifrost: {...} }` → 422.
- **INV-160-3 (echo-incompatible)**: A session cannot be both ephemeral (Echo, `kind: "ephemeral"`) AND Bifrost-bound. Session-create rejects with 422 `ephemeral_does_not_accept_bifrost`. Verifiable: `POST /sessions { agent_id: "echo", config: {...}, bifrost: {...} }` → 422.
- **INV-160-4 (jwt-bound-to-session-expiry)**: JWT TTL is bound to session expiry — far-future `expires_at` for sessions without a fixed TTL. Re-mint happens only when a re-handshake fires (connection-loss recovery). No standalone JWT-staleness check.
- **INV-160-5 (reentrancy-25-per-turn)**: At most 25 successful Bifrost tool invocations per agent turn. The 26th returns `bifrost.reentrancy_cap_exceeded` without contacting the consumer. Counter resets per turn via `BifrostClient.reset_turn_counter()`. Enforced inside `BifrostClient.invoke_tool`.
- **INV-160-6 (tool-list-cached-per-session)**: Bifrost tools are fetched once at handshake and cached on `ConversationSession.bifrost_tools`. Per-turn dispatch reads from the cache; never re-fetches mid-session except on connection-loss recovery.
@@ -1751,7 +1813,7 @@ class BifrostEndpointOverride(BaseModel):
1. HTTPS URL check — Pydantic field validator; 422 on miss.
2. `bifrost:invoke` scope check — same as session-bound path; 403 on miss.
3. Ephemeral session rejection — 422 `ephemeral_does_not_accept_bifrost` when session is Saga (extends INV-160-3).
3. Ephemeral session rejection — 422 `ephemeral_does_not_accept_bifrost` when session is Echo (extends INV-160-3).
4. Heimdall consumer lookup — 502 `bifrost_consumer_not_found` on miss or unregistered.
5. Instantiate a new `BifrostClient` with the override consumer's algorithm + key; set `_jwt_ttl_seconds = 60`.
6. `await override_client.connect()` — 502 `bifrost_handshake_failed` on failure.
@@ -1789,7 +1851,7 @@ In the `finally` block, `await override_client.disconnect()` is called unconditi
The conversation API grows a three-tier agent model. Tier 1 is the
foundational set (Mimir, Bragi, Leif, ...) wired at startup. Tier 2 is
the ephemeral template surface (Saga). Tier 3 is the consumer-defined
the ephemeral template surface (Echo). Tier 3 is the consumer-defined
class addressed by `<user_id>:<agent_name>` and stored in Heimdall's
SQLite `consumer_agents` table.
@@ -1813,9 +1875,13 @@ SQLite `consumer_agents` table.
- **INV-181-5 (agent-name-immutable, Phase 2.0 scope)**: PATCH rejects
any payload that includes `agent_name`, returning 422
`field_not_mutable` BEFORE the DB lookup.
- **INV-181-6 (layer-immutable-in-patch, Phase 2.0 scope)**: PATCH
rejects payloads carrying any of `persona`, `motivational`,
`valence`, `memory` even when set to `null`.
- **INV-181-6 (layer-immutable-in-patch, Phase 2.0 scope; AMENDED #188)**:
PATCH rejects payloads carrying any of `persona`, `motivational`,
`memory` even when set to `null`, returning `field_not_mutable`.
**Amended by #188 (Phase 2.3):** `valence` was moved out of this
`field_not_mutable` set — it now returns `layer_deferred` (see
INV-188-1), because valence is a not-yet-shipped layer, not a frozen
trait. `memory` is rejected wholesale (see INV-188-2).
- **INV-181-7 (owner-delete-hard, Phase 2.0 scope)**: `DELETE
/agents/<id>` is a hard-delete; bypasses the 24h grace.
- **INV-181-8 (cascade-key-scoped, Phase 2.0 scope)**: Key revocation
@@ -1881,6 +1947,79 @@ SQLite `consumer_agents` table.
through `_publish`, so SSE resume / replay handles them with no
special case.
## Amendment — Suspended-tier license-state gate (issue #174, INV-174-1..9)
Adds a `suspended` tier with empty scope set to drive license-expiry
transitions without destroying user state. Endpoint
`POST /admin/users/{user_id}/tier` mutates the tier; the
`_http_exception_handler` rewrites `AUTH_SCOPE_DENIED` →
`USER_SUSPENDED` for any 403 raised against a non-anonymous caller with
an empty scope-set (the suspended-tier defining property). Ships in
v0.29.1.
- **INV-174-1 (closed tier vocabulary)**: `POST /admin/users/{user_id}/tier`
validates `body.tier` against the hard-coded set `{anonymous, user, free,
pro, admin, suspended}`. Out-of-set values return 422 `invalid_tier`.
Vocabulary is NOT derived from `policies.yaml` at runtime — a typo in
YAML must not silently expand the accepted set.
- **INV-174-2 (admin-only mutation)**: endpoint requires
`admin.users.write.tier_change` scope. Listed explicitly in admin
tier's scope set in `policies.yaml` for grep-discoverability (admin
also carries `*` umbrella).
- **INV-174-3 (tier mutation primitive)**:
`UserStore.update_user_tier(user_id, new_tier) -> User` is the storage
primitive. Raises `LookupError` for unknown user_id (endpoint converts
to 404 `user_not_found`).
- **INV-174-4 (suspended scope-set is exactly empty)**:
`policies.yaml.tiers["suspended"].scopes == []`. The empty set is what
makes the auth-denial work for free; the
`_http_exception_handler` rewrite uses
`ctx.user_id != "anonymous" and not ctx.scopes` as the
suspended-detection heuristic since `SecurityContext` deliberately
excludes `tier` (per `core/integration/types.py:64`).
- **INV-174-5 (uniform suspended error code via exception handler)**:
The `_http_exception_handler` (registered for `StarletteHTTPException`)
intercepts every 403 with `error_code: auth_scope_denied`; if the
request's stashed `SecurityContext` has an empty scope-set (and
non-anonymous user_id), it rewrites the detail to
`{error_code: "user_suspended", message: "Account is suspended."}`.
Single seam — covers every existing and future scope-deny site
without per-endpoint refactor. The ctx is stashed by
`get_security_context` on `request.state.security_context`.
- **INV-174-6 (/me carve-out)**: `/me` does NOT call `authorize()` and
therefore never raises `AUTH_SCOPE_DENIED`. Suspended users with
empty scopes reach the /me handler normally and see
`{user_id, tier: "suspended", scopes: [], ...}`. Adding a scope check
to /me without preserving the suspended-tier visibility would be a
contract violation — the carve-out is structural, not coded.
- **INV-174-7 (audit emission)**: every tier-change attempt emits
`conversation_api:admin:user:tier_changed` via `_audit_admin_action`
with `actor_user_id`, `target_user_id`, `outcome ∈
{success, denied}`, and `extra = {from_tier, to_tier, reason}` for
successes; `extra = {reason: <reason_code>}` for denials
(`invalid_tier`, `user_not_found`).
- **INV-174-8 (reversibility via audit replay)**: the user record does
NOT carry a `previous_tier` column. Restoration of a suspended user
requires reading the audit log to find the most recent
`tier_changed` event with `to_tier="suspended"` and replaying its
`from_tier` as the new target. Operational responsibility of SEA's
billing integration; Worldtree provides only the read (audit log) and
write (endpoint) surfaces.
- **INV-174-9 (no cross-tier session invalidation)**: a tier change for
a user with active SSE turns in flight does NOT cancel those turns.
The next request after the tier change picks up the new scope-set;
in-flight streams complete under the old tier. If SEA needs
immediate-cutoff semantics, that requires `disable_user`-style
hard-revoke, not a tier change.
## Amendment — AwaitingLLMFirstToken heartbeat (issue #201, INV-201-1..7)
Adds a periodic SSE heartbeat event during the gap between
@@ -2027,3 +2166,160 @@ Lofn introduces zero net-new persistence surface. No table, no
column, no Mimir KB collection. No new audit-event types. Existing
session-create / session-revoke audit covers Lofn the same way it
covers Mimir / Forseti.
## Amendment — Tier 3 motivational layer (issue #187, Phase 2.2)
Activates the `motivational` layer field on `POST /agents/define`, narrowing the
Phase 2.0 `layer_deferred` rejection (INV-181-3) to `valence` only. Full FN-level
spec at `docs/contracts/issues/187.contract.md`.
- **INV-187-1 (motivational-activated)**: `POST /agents/define` accepts a non-null
`motivational` object `{goals, fears}`; `_tier3_validate_layer_fields` rejects
only `valence` now. (Persona + memory were activated in Phase 2.1 / #189.)
- **INV-187-2 (define-validation)**: `validate_motivational_define_payload` enforces
the documented 422 codes — `motivational_id_collision` (case-sensitive, across
goals AND fears), `motivational_goal_invalid_type`,
`motivational_salience_out_of_range`, `motivational_description_too_short`
(< 20 chars after strip), `motivational_missing_required_field`. Unknown top-level
OR nested (per goal/fear) keys → `validation_failed` (sub-models extra-forbid).
Stricter than the Tier 1 `validate_motivation` (which only warns on short text).
- **INV-187-3 (per-agent-scope)**: motivational is per-agent, NOT
per-(agent, end_user) — stored once on the row, identical across all end-users.
- **INV-187-4 (immutable-in-patch)**: `PATCH` with `motivational` → 422
`field_not_mutable` (already covered by INV-181-6's `_IMMUTABLE_FIELDS` gate).
- **INV-187-5 (tier3-render-bridge)**: Tier 3 agents are NOT registered with the
`persona_registry`; the stored config rides on the per-session `AgentContext`
(`motivational_config`) and is rendered into the prompt per-turn in `stream_turn`
via `_append_motivational_context_section`, before the memory-context section.
- **INV-187-6 (fear-signal-shape)**: fears carry `trigger_signals`; goals carry
`positive_signals` + `negative_signals` (matches the `GoalConfig`/`FearConfig`
substrate).
- **INV-187-7 (tier-uniformity)**: the render reuses `core.persona.goals.load_goals`
+ `render_motivational_context`, so a Tier 3 motivational config produces a
byte-identical block to an equivalent Tier 1 `motivation.yaml`.
- **INV-187-8 (storage)**: persisted in `consumer_agents.tier3_layers_json` under
the `"motivational"` key; round-trips via `ConsumerAgent.motivational`; null/omitted
→ `None` (no fabricated defaults; no migration).
### Audit
`agents.define` audit `extra` gains `presence_motivational: bool` alongside
`presence_persona` / `presence_memory`.
## Amendment — Tier 3 PATCH mutability policy (issue #188, Phase 2.3)
Settles which Tier 3 agent fields are editable post-define. #197 deleted the
STM tier between this issue's filing (2026-05-19) and its implementation, so the
"mutable memory dials" the original issue envisioned no longer exist; the policy
collapses to: `system_prompt` + `model` mutable, everything else fixed, with
`valence` distinguished from the immutable traits by error code. No new
endpoint, no new storage, no new invariant philosophy — a clarification +
error-code alignment + audit enrichment over the Phase 2.0 PATCH baseline.
- **INV-188-1 (valence-deferred-in-patch)**: `PATCH /agents/<id>` carrying a
`valence` key (any value, including `null`) → 422 `layer_deferred` with
`field: "valence"`, matching define-time (INV-181-3). Rationale: valence is
a layer that does not exist yet, not a real-but-frozen trait; `layer_deferred`
is the truthful reason and gives consumers ONE code for "valence unavailable"
across both define and PATCH. The check precedes the DB lookup (INV-181-5/6
ordering), so a `valence` PATCH against a missing agent still 422s, not 404s.
- **INV-188-2 (memory-wholesale-immutable-in-patch)**: `PATCH` carrying a
`memory` key → 422 `field_not_mutable` with `field: "memory"`, rejected at the
WHOLE-field level. No sub-field carve-out exists: `stm_capacity` /
`stm_token_budget` are deprecated no-ops post-#197, `allows_world_scope` is
create-time-only (toggling it after memory is written breaks scope-visibility
invariants — memory scope policy must be fixed before any memory is written),
and `embedder_version` is library-pinned. A real LTM tuning dial would warrant
a deliberate per-sub-field PATCH contract at that time; pre-splitting for dead
fields is not done. NOTE the deliberate define/PATCH asymmetry: `define`
accept-and-ignores deprecated `stm_*` (201 + DeprecationWarning per
INV-197-19), but `PATCH memory:{...}` rejects wholesale (422). Acceptable
transitional artifact; disappears when the shims are removed.
- **INV-188-3 (patch-audit-before-after)**: a successful `agents.patch` audit
event's `extra.changes` records before/after for each mutated field —
`model: {before, after}` (literal values; allowlist enum, not PII) and
`system_prompt: {before_bytes, after_bytes}` (byte-length only; raw prompt
content is excluded as potential PII, consistent with `emit_consumer_agent_event`'s
exclusion rule). `changes` contains only keys for fields actually present in
the PATCH payload. `patched_fields` (the Phase 2.0 name list) is retained.
- **INV-188-4 (mutable-surface-unchanged)**: the mutable surface stays exactly
`system_prompt` + `model` (per INV-181 Phase 2.0). PATCH re-enforces the
define-time `system_prompt` byte-cap and `model` allowlist. #188 does NOT add
model-swap capability/context-window validation — that gap (a swap to a
smaller-context or non-tool model with no re-check of the existing prompt) is
tracked as a separate follow-up (#219), not folded here.
## Amendment — model-assignment advisory warnings (issue #219)
`POST /agents/define` and `PATCH /agents/<id>` attach a best-effort, **non-
blocking** `warnings` array to their 2xx response when the assigned `model`
carries metadata risk (smaller context window, unknown window, or an explicit
capability downgrade). This is advisory-only by deliberate design: hard
rejection was rejected (Heid panel + operator, 2026-05-29) because model
metadata coverage is partial (`context_window` is 0/unknown for several
allowlisted models; `supports_tools` defaults true), the stored `system_prompt`
cap is bytes not tokens, Tier 3 agent rows store no tool/modality usage (tools
arrive per-session via Bifrost, so any capability concern is inherently
conditional), and runtime already classifies the real failure as
`CONTEXT_OVERFLOW`. The warning is a receipt-note for the owner who just made a
deliberate change, not a correctness gate.
- **INV-219-1 (advisory-not-blocking)**: neither define nor PATCH ever rejects
on context-window or capability grounds. The allowlist check
(`model_not_available`) and `system_prompt` byte-cap are the only model-
related *rejections*; everything in #219 is a warning on an otherwise-2xx
response. Correctness for over-budget prompts remains the runtime
`CONTEXT_OVERFLOW` guard.
- **INV-219-2 (bounded-warning-codes)**: the closed code set is exactly —
`model_context_window_unknown` (severity `info`): the assigned model's
registry `context_window` is `0`/absent; `model_context_window_smaller`
(severity `warning`): prior and new model both have known windows and
new < prior (`details: {before, after}`); `model_capability_downgrade`
(severity `warning`): the new model EXPLICITLY drops a capability the prior
model advertised — `supports_tools`, `vision`, or `audio` (`details:
{dropped: [...]}`). No token-aware "prompt won't fit" code — deferred until
tokenizer-aware estimation exists; messages never claim a hard fit/failure.
- **INV-219-3 (when-evaluated, resulting-pair)**: warnings are computed
whenever a model is *assigned*. At define, always (prior = None → only
`model_context_window_unknown` can apply, since the comparative codes need a
prior). At PATCH, only when the payload carries a `model` key whose value
differs from the stored model (prior = stored model); a PATCH without `model`
(e.g. `system_prompt`-only) emits no model warnings. The comparison is always
against the *resulting* model.
- **INV-219-4 (capability-downgrade)**: a `model_capability_downgrade` fires
only when BOTH prior and new models resolve to registry `ModelInfo` AND the
new model's *effective* capability flags lack one the prior advertised
(`supports_tools`, `vision`, or `audio`). The "both resolve" guard is the
false-positive defense — an unresolvable model on either side yields no
downgrade claim. Beyond that, comparison uses the registry's **effective**
flags, which is asymmetric by capability because the data model collapses
absent-to-default and does not preserve a "was this declared?" bit:
- `supports_tools` defaults **true** (`ModelInfo` / `_build_model_info`), so
a tools-drop requires the new catalog entry to set `supports_tools: false`
*explicitly* — omission never triggers it.
- `vision` / `audio` default **false** (`ModelCapabilities`), so a drop is
detected whenever the prior advertised the capability and the new model does
not carry it — whether the new entry says `false` explicitly OR omits it.
This is the deliberate conservative reading: an undeclared modality is
treated as unsupported. (A vision-capable model with sloppy metadata that
omits its `vision` flag would thus be reported as a downgrade; the remedy is
to declare the flag in the catalog, not to suppress the advisory.)
Message phrasing is conditional ("if your sessions rely on these, e.g. Bifrost
tools, they may be rejected") — the agent row does not record whether tools or
modalities are actually used, so every capability warning is advisory by
nature.
- **INV-219-5 (inline-response-shape)**: the `warnings` array is added inline to
the define (201) and PATCH (200) response bodies — the existing flat
`ConsumerAgentResponse` dict gains a `warnings` key (always present, `[]` when
none). It is NOT added to the shared `ConsumerAgentResponse` pydantic model
nor to `GET /agents/<id>` — only the two mutation handlers merge it into their
returned dict, keeping persisted fields and the read path unchanged. Each
entry is `{code, severity, message, details}`.
- **INV-219-6 (single-helper)**: a single pure helper
`compute_model_swap_warnings(*, prior_model: str | None, new_model: str,
registry)` is the only source of warning logic; both define and PATCH call
it. It tolerates unresolvable specs / `None` `ModelInfo` / `context_window`
`0` by treating them as "unknown" (emitting the unknown-window info code where
applicable, never raising). Metadata improvements over time sharpen the
warnings with no API or signature change.
+100 -193
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-06-16_
_Last updated: 2026-06-18_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -25,191 +25,109 @@ handshake state, admin lifecycle events, optional raw server log.
Named after the squirrel that runs up and down Yggdrasil carrying messages
between layers. On-the-nose Worldtree resonance (Yggdrasil = the World Tree).
Origin: althing ask from worldtree-dev (thread `01KS3R34XD3N6HMK91VXESHGW7`,
2026-05-20) for the shape of a TUI Conversation API consumer. brokkr-smithy
ran the shape pass; operator's reframe routed it as a new repo with a
separate dev team rather than an in-tree Worldtree tool.
**Second identity (since 2026-06-14): the v1 Bifrost Tier-3 consumer/provider** —
the durable persistence Worldtree writes Tier-3 agent affect (PAD/persona, `:8390`)
+ memory (`:8391`) into. Lives in `src/ratatoskr/provider/`, depends on `bifrost`
(`provider` optional-extra), separate from the conversation-API spec pin. So
ratatoskr now owns BOTH ends of the Bifrost round-trip — the lens #17 exploits.
**v0.15.0+ adds a sibling browser surface** (`ratatoskr.web`, `ratatoskr-web`
console script). Same five-pane debug surface (transcript / Tools / Debug /
Thinking / Persona) consuming the same Worldtree SSE wire, viewable from any
device on the operator's LAN. Sibling viewport, NOT a TUI replacement; the
TUI is canonical. Internal-LAN trust model — bound to `0.0.0.0`, no auth,
no TLS, no CORS guard (operator direction). What stays disciplined regardless
of network trust: transcript HTML-escapes assistant content (INV-004 —
model output is untrusted); upstream API key stays server-side (INV-003).
**v0.15.0+ sibling browser surface** (`ratatoskr.web`, `ratatoskr-web` console
script): same five-pane debug surface over the same SSE wire, LAN-viewable.
Internal-LAN trust model — `0.0.0.0`, no auth/TLS/CORS (operator direction).
Disciplined regardless: transcript HTML-escapes assistant content (INV-004);
upstream API key stays server-side (INV-003).
## Current state / in-flight
_As of 2026-06-16:_
_As of 2026-06-19:_
**LATEST (2026-06-16 PM) — BIFROST REPINNED 0.7.0→0.8.0 (wire v0.5→v0.6).** The
memory `search` scope filter was split into `scope_all` (AND/intersection) +
`scope_any` (OR/union over a LIST of conjunctive scopes) — bifrost #11, the canonical
fix for the #295/#297 silent-zero AND foot-gun. Our store + contract (v1.2) + tests
reimplemented to parity with the v0.6 reference `_matches_scope`/`_validate_scope`
(no-compat: `scope_filter` REMOVED). 433 tests green incl. the new `scope_any` union
test + the parity-vs-reference test through the real 0.8.0 `dispatch_memory_call`.
Memory provider BOUNCED onto 0.8.0 (`:8391`, fresh empty `memory.db` — the prior
5-chunk #296 corpus was WIPED, operator confirmed "nothing of value", SUPERSEDES the
"KEEP PINNED" note below). **End-to-end cold recall now waits only on Worldtree
EMITTING `scope_any` on its recall path (#297, upstream).** Affect plane untouched
(split is memory-only); affect provider still on its 0.7.0-loaded process (bounce
optional — affect wire unchanged at 0.8.0). NOT yet committed; patch bump v0.17.6
pending operator commit approval. **#17 contract carries stale `scope_filter` /
`_scope_matches`-AND references — fix when #17 TDD starts.**
**#18 DELIVERABLE 2 SHIPPED + PUSHED — the persona-telemetry gap is CLOSED.** The web
pane now renders live PAD/valence for Tier-3 agents from OUR `:8390` affect store
(`v0.17.14`, `39eebd1`, suite **482 green**, **pushed to origin**). Three pieces:
provider read route `GET /affect/state/{agent_id}` (non-bifrost, added to the affect app
via `app.add_route` — keeps `/bifrost/*` top-level + op-feed-skipped); web proxy
`GET /api/affect/{agent_id}` (server-supplied `end_user_id`, colon-id `quote()`'d,
`RATATOSKR_AFFECT_READ_URL` config, default `127.0.0.1:8390`); pane affect-render
(`renderAffectPane`/`loadAffect`, honest pad+valence+emitted_at, labelled "affect", NO
fabricated Tier-1 fields, explicit empty-state, 2s post-turn poll). Live-smoke + a
Playwright DOM check PROVEN against real sindra/vuong PAD. The push also published the
previously-held **#17** arc (`v0.17.8``v0.17.13`) — origin/main is now fully caught up.
**Ratatoskr now has a SECOND identity: the v1 Bifrost Tier-3 consumer** — the
durable persistence provider Worldtree writes Tier-3 agent affect/persona +
memory into — alongside the original debug-observability TUI/web. The
Bifrost-consumer work lives in `src/ratatoskr/provider/` and depends on
`bifrost>=0.7.0` (a `provider` optional-extra from the gitea PyPI index),
SEPARATE from the Worldtree conversation-API spec pin.
**#18 DELIVERABLE 1 (composite `:8392` endpoint) — PARKED on bifrost** (tracked Gitea #18).
Routed to bifrost-dev for a public `build_combined_app` rather than hand-rolled from
bifrost privates (debug-surface-uses-canonical principle). bifrost-dev confirmed it: clean
additive minor (~`v0.9.0`), design locked (advertise-by-presence handshake, per-route
call-time isolation), slotted AFTER WT #289. FR-1 RESOLVED — composite is bifrost-only,
ZERO Worldtree change (single-endpoint caps-routed, worldtree-dev code-verified). NEXT:
when bifrost ships `build_combined_app`, **repin + reimplement D1 against it** (per-plane
failure status + op-feed plane-per-request derivation already specced in the issue).
Nothing blocks on our side.
**AFFECT plane: SHIPPED + LIVE-PROVEN** (v0.17.2). Running now as a dev
background shell (`ratatoskr-provider`, `0.0.0.0:8390`, env-sourced from
`~/.config/ratatoskr/provider.env`). Smoked end-to-end against personal
Worldtree **v0.35.2** (`10.250.50.152`): handshake 200 + `affect.emit` 200 →
durable row persisted, opacity held.
**OPERATOR SESSION STATE — running shells are PRE-#18 code (foot-gun).** web `:8765` +
affect `:8390` + memory `:8391` are the prior session's background shells running OLD code
(no read route; web has no `RATATOSKR_AFFECT_READ_URL`). To see D2 live in the operator's
own session, RESTART `:8390` (affect provider, new code → gains the read route) + `:8765`
(web, new code + `RATATOSKR_AFFECT_READ_URL=http://127.0.0.1:8390` + `RATATOSKR_END_USER_ID`).
This session's live-smoke used THROWAWAY `:8393`/`:8766` instances vs the same `affect.db` to
avoid disrupting them. Consumer/owner key = `wt_live_d81b…`; providers SQLite + sqlite-vec,
`memory.db`/`affect.db` at repo root (affect.db has live sindra PAD: vuong pleasure 0.146,
familiarity 0.589, interaction_count 8).
**MEMORY plane: PROVIDER LIVE-PROVEN + recall-miss ROOT-CAUSED (upstream).**
Store + dev shell shipped (v0.17.3, `cd12951`; running on `0.0.0.0:8391`).
`memory.db` holds 5 durable chunks (all scope `{end_user:smoke-user}`): choc-fact
`498ed752`, name `8241e569`, promoted-question `c863bb6b`, + 2 LATE async promotions
from the cold-recall probe (probe-question `acc3d49`, model NON-ANSWER `4773704`
salience promoted a "I don't have memory" refusal). All are #296 corpus, KEEP PINNED.
**The #295 cold-recall miss is now ROOT-CAUSED and it's UPSTREAM, not ours**
(2026-06-16 debug-assist with worldtree-dev): a self-driven bound cold-recall
probe captured the inbound pair via our new observe log — Worldtree's recall sends
`scope_filter={end_user:smoke-user, agent_self:ratatoskr:smoke}` (TWO axes) but our
chunks carry `{end_user}` ONLY; our AND `_scope_matches` (byte-faithful to bifrost
reference `reference_server/memory.py:398`) drops everything on the unmatched
`agent_self` axis → 0 hits → the model says "no memory". So **our store + search
are SOUND**; the fix is Worldtree-side. F2 → research issue **#296** (keyword-regex
salience suspected fundamentally flawed; corpus = `c863bb6b` + the 2 late-promotions).
F1 → research issue **#297** (Worldtree-local fix = per-visible-scope single-axis
search unioned client-side). **The agent_self lattice question is RESOLVED:**
agent_self is now canonical in bifrost 0.7.0 / wire v0.5 (our foot-gun flag drove it;
worldtree-dev shipped it both sides — Worldtree v0.35.11) → #297 union build UNBLOCKED.
Our memory provider now runs **bifrost 0.7.0 + validates the 4-axis lattice**
`{end_user,group,tenant,agent_self}` (v0.17.5; restarted on it; out-of-lattice axis →
InvalidFilter, reference-parity restored).
**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
`sindra-probe` too under #296 Stage 2 (v0.36.0). The #296 extraction-quality arc closed
(Stage 1 v0.35.19 gate + Stage 2 v0.36.0 user-only extraction at worldtree-codex; hard-
linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
**OBSERVE BRICK SHIPPED** (`memory_store.py`, committed v0.17.4 `2fef6e3`):
structured `[memory-provider]` request/response logging on the memory-call path —
the first concrete brick of #17's observe half, and the lens that caught #295's
root cause. Live-verified. (NOTE: this is the debug SHIM at the STORE method; #17's
real observe feed instruments the DISPATCH layer — see the contract INV-005.)
**Sindra:** `ratatoskr:sindra`, `thoughtful-character` role → `mistral-small-4-reasoning`
(DELETE+redefined on v0.35.16; `memory:{}` block trips the promotion gate). Owner-scoped
(separate `consumer_agents` table) — invisible to `GET /agents`; check via
`GET /agents/<owner>:<name>` with the owner key.
**SELF-DRIVE PROVEN BY HAND** (2026-06-16): ratatoskr's own client drove a
Bifrost-bound cold-recall end-to-end (bind → handshake 200 → turn → captured the
recall pair). Load-bearing finding: a bound session-create must use the CONSUMER
Heimdall key (`RATATOSKR_HEIMDALL_KEY`) as the bearer, NOT the canary
`WORLDTREE_API_KEY` — Worldtree signs the Bifrost handshake JWT with the
session-create bearer (canary → 401; consumer → 200). Runbook:
`docs/bifrost-self-test.md`. This is #17's substrate, proven before the contract.
**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) — deferred; **#18** (D2 PAD-read SHIPPED `v0.17.14`; D1 composite PARKED on bifrost
`build_combined_app`). Codex-first pilot dormant.
**ISSUE #17 (self-drive + observe) — CONTRACT WRITTEN + HEID-REVIEWED + FIXED,
TDD NEXT.** `docs/contracts/issues/17.contract.md` (validates OK, drift-clean).
v1 scope operator-locked: single-plane bind (composite endpoint PARKED) +
dispatch-layer op-feed with session-level correlation (turn-pane UI PARKED).
`/heid-contract-review` panel caught + fixed two real internal inconsistencies
(OpEvent `turn_id` reservation made literal; the `session_id`-for-all-verbs
correction). NEXT: **TDD slice 1 = the `create_session` bind primitive** (BifrostBinding
dataclass + consumer-key per-request bearer override + missing-key precondition +
502→BifrostHandshakeFailed; respx-mocked), then endpoint_for_plane → dispatch-layer
op-feed → CLI/TUI/web → live smoke.
**Sindra:** a REGISTERED Tier-3 agent (`ratatoskr:sindra`, was model
`artemis-31b-v1i`) — registration is REQUIRED to use a Tier-3 character (a
session against an unregistered `agent_id` 404s), and she's been used. OPEN:
the v0.35.2 personal rebuild may have wiped the agent DB — re-verify via
`GET /agents` (needs a WORLDTREE_API_KEY, broker via infra-ops) and re-register
if gone. Her persona only persists durably once personal is bound to our
RUNNING affect provider for HER sessions (the smoke used synthetic
`ratatoskr:smoke`); that binding is the persona-carry gap, independent of
registration.
**Heimdall key (Bifrost consumer):** persists env-only at
`~/.config/ratatoskr/provider.env` (mode 600, nh3-dev) — `consumer="ratatoskr"`,
HS256 = the API-key STRING utf-8-encoded; rotate via infra-ops.
**Committed (2026-06-16, NOT yet pushed):** observe brick (logger, v0.17.4 `2fef6e3`),
`docs/bifrost-self-test.md` + #17 contract (`ca02c70`), 4-axis-validation parity
(v0.17.5, tag `v0.17.5`), + this snapshot. Tags `v0.17.4`/`v0.17.5`. Push is the
operator's call. `graphify-out/GRAPH_REPORT.md` still runs dirty
(auto-regenerated artifact, not chased).
**Still standing from before:** Worldtree spec pin v0.29.0 (`562001a`) for the
conversation-API/TUI surface (untouched by the Bifrost work). Codex-first pilot
still dormant (no codex session spun up — see the 2026-05-29 decision). Open
issues: #10 (subject migration, deferred), #11 (AdminEvents pane, deferred).
Branch: `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
Branch: `main` (== `origin/main` @ `39eebd1`). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
## Recent decisions
Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per
decision. Captures rationale that won't be obvious from code alone.
- `[2026-05-20]` Project name **Ratatoskr** (squirrel on Yggdrasil — runs up and down carrying messages). Earlier candidate Andvari demoted on the cursed-ring association.
- `[2026-05-20]` **Separate repo, separate dev team.** Operator's call; the in-tree-at-Worldtree/tools/ alternative was considered and rejected to dogfood the API boundary.
- `[2026-05-20]` **No Worldtree-source imports.** Spec-only dependency. Triple version-skew mitigation: spec-pin in pyproject.toml + recorded-SSE snapshot tests + conformance smoke. Initial pin: `55101e909abcd2219833266b6f905c5bc956e0f0` (Worldtree v0.19.0). See `docs/SPEC-PIN.md`.
- `[2026-05-20]` **Textual** (not rich+prompt_toolkit). Driver: debug observability is the primary purpose, and a multi-pane dashboard with persistent side panes + independent scrollback is structurally application-shell-shaped. Volva consulted via cross-frontier second-opinion and converged on the same call.
- `[2026-05-20]` **`httpx-sse`** for SSE consumption. The server emits composite `{turn_id}:{seq}` `id:` lines (Worldtree INV-014) load-bearing for SSE-resume; hand-rolled `data:`-only parsing (the skaldsong pattern) silently drops these. Ratatoskr becomes the reference Python SSE-resume implementation.
- `[2026-05-20]` **Persona-pane PII posture: label-don't-refuse.** `persona.log` is process-wide; pane title flips between `[Persona — PROCESS-WIDE]` and `[Persona — session <id>…]` based on whether log lines carry session_id. Refuse-against-non-local was considered and rejected as paternalistic.
- `[2026-05-20]` **Server-stdout pane: opt-in via `--server-log <path>`.** No auto-detection of well-known paths.
- `[2026-05-20]` **Two-stage Ctrl-C.** First cancels in-flight turn server-side; second exits app. Ctrl-D bound to immediate exit.
- `[2026-05-20]` **Single-session-per-launch + startup picker.** No in-app `/switch`. CLI flags `--session <id>` and `--new` for scripted use. Session identity always visible in Textual footer.
- `[2026-05-20]` **Markdown rendering default-on; `--raw` opt-out.** Don't pre-design `--no-stream-formatting` (Volva: add only if streaming-markdown rendering is empirically ugly).
- `[2026-05-20]` **Non-interactive `--send` mode.** Single SSE consumer module, two presenters (TUI + stdout). Keeps Ratatoskr honest as an API consumer; useful for CI / scripted probes.
- `[2026-05-20]` **First contract: `ratatoskr.sse_client`.** Bundles `stream_turn` + `reconnect_turn` + `cancel_turn` + private `_parse_sse_id` into one module — the SSE-resume flow is coupled (cancel needs `turn_id` from the SSE wire `id:`, reconnect re-uses the same parsed `SseId`), so they share a contract. Hard invariant INV-002 makes the composite `{turn_id}:{seq}` `id:` parsing load-bearing — closes the foot-gun the design-brief §3 names (hand-rolled `data:`-only parsing silently drops the `id:`).
- `[2026-05-21]` **Contract converted to issue-scoped (issue #1).** Frontmatter shape switched from module-scoped (`module:`/`purpose:`) to issue-scoped (`target_module:`/`scope:`/`prd:`) per CONTRACT-FORMAT §2.1.I. `prd:` block pins to issue body hash. **Known parser stale-ness**: `contract_parser.py --validate` ERRORs on issue-scoped frontmatter — CONTRACT-FORMAT §2.1.L H10, a documented Brokkr-side follow-up. Parser is a canonical sync, so we do NOT patch it locally. Treat parser ERROR-on-issue-scoped as expected until canonical bumps.
- `[2026-05-21]` **Default issue-tracker labels seeded** (17 total). Sleipnir gating, triage, type, resolution, Ratatoskr-specific area labels (sse-client, tui, cli, observability).
- `[2026-05-21]` **Volva paraphrase + code-review across all 4 issues — calibration consistent.** Paraphrase rounds flag 3-5 contract ambiguities per issue; code-review rounds flag 3-8 code-vs-contract drifts after TDD-passing implementation. The post-TDD code-review consistently catches three classes of gap the test-author's hypotheses don't cover: PRE-assertion boundary drift, exception-payload truncation / never-rendered-to-user observability misses, and "tested the state but not whether the user can see it" gaps.
- `[2026-05-21]` **Manual smoke is load-bearing — found a real defect tests couldn't.** First wire-level smoke against personal Worldtree (post-TDD, post-Volva-code-review on #4) revealed httpx's default 5s read timeout killed the SSE connection mid-stream during mimir's thinking phase (~30s LLM latency >> 5s read timeout). The unit/contract test infrastructure (respx-mocked SSE wire) doesn't model real LLM latency, so the gap was invisible at the test layer. Fix: caller-owned `httpx.AsyncClient` constructed with `timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)`; defense in depth: `sse_client.stream_turn` ERROR_ROUTING catches `httpx.ReadTimeout``SseConnectionDropped`. **Lesson: keep manual-smoke step in the per-issue cadence; mock-only validation is insufficient for streaming-against-real-server code.**
- `[2026-05-22]` **Issues #5/#6/#7 filed: per-user-agent support + TUI-startup-visibility + mid-stream-robustness.** Discovered during 2026-05-22 mimir TUI conversation: long completion crashed with `JSONDecodeError("Expecting value: line 1 column 1 (char 0)")` from `json.loads('')` on an empty-`data:` SSE frame (→ #7). Earlier same day, `ratatoskr --new --agent lofn` failed with 422 `end_user_id_required`#5. #6 was a corollary observation (TUI alt-screen masks the diagnostic).
- `[2026-05-22]` **Issue #8 (startup agent picker) filed.** `GET /agents` exists in the vendored spec; returns `agent_id`/`name`/`description` + optional fields. `--agent` becomes conditionally optional. Composes naturally with issue #5.
- `[2026-05-22]` **Issue #7 implemented via TDD + Volva-code-reviewed.** First issue with zero drift findings from Volva code-review — TDD caught all runtime behavior. Hypothesis: the tighter the contract + smaller the code surface, the more Volva's role shifts from "catch behavioral drift" to "tighten observability + wording".
- `[2026-05-23]` **Issue #6 (TUI startup error visibility) implemented via TDD + Volva-code-review (two rounds).** Restructures `run_tui` lifecycle: `_resolve_then_run` async helper opens AsyncClient, does pre-flight resolution, routes errors to stderr BEFORE alt-screen opens. Two Volva rounds confirmed multi-round value (round 2 found things round 1's amendments didn't anticipate; strictly test-precision, no behavioral drift).
- `[2026-05-23]` **Issue #5 (`--end-user-id`) implemented via TDD.** Three modules touched. `create_session(client, agent_id, *, end_user_id=None)`; CLI flag with non-empty validation; threading through `_amain` and `_resolve_then_run`.
- `[2026-05-23]` **Worldtree-dev consult landed authoritative consumer-API guidance** (althing thread `01KSBARG2B8M8C82H6AJGJWX1B`). Takeaways: `end_user_id` is a free-form partition key; no programmatic `requires_end_user_id` discovery; subject:{type,id} migration locked but not shipped; spec pin (v0.19.0) is 3 minor versions stale; send a User-Agent header; `agents.call:lofn` scope needed for lofn smoke; `GET /agents` requires no special scope.
- `[2026-05-23]` **v0.2.1 layout fix: dock-anchored TUI chrome so Input never moves.** Cause: auto-stacked vertical flow shifted Input when thinking-current toggled visibility. Fix: dock chrome to screen edges; transcript absorbs reflows internally via scroll viewport. **Operator-confirmed "a lot better" interactively. Pure UI fix; tests pass without modification. TUI-layout patches are "ship + operator verifies" — TTY is the load-bearing test surface; respx + Pilot mocks can't catch screen-relative positioning bugs.**
- `[2026-05-23]` **Issue #12 (presenter contract semantics amendment) implemented via TDD.** Thinking deltas render as ONE coalesced growing line (CLI) / one closed RichLog entry per run + live Static widget per-delta (TUI), not 50 lines per turn. Introduced stateful per-turn presenters: `CliPresenterState` + `TuiPresenterState`. Editorial promotion: load-bearing = Text/Done/Error/Cancelled (no prefix); demoted telemetry = WorkerPhase/Thinking/TextBoundary/ToolStart/ToolResult.
- `[2026-05-23]` **Forward direction: Ratatoskr will require `end_user_id` for EVERY access before too long.** Operator's call. Reasoning: even Tier 1 foundational agents that don't *require* `end_user_id` server-side currently fall back to a `_no_end_user` sentinel partition — effectively pollution. **Cross-frontier alignment (worldtree-dev ack, althing `01KSBD9FPMCWJMBXNNS4B3MYBS`):** the platform side agrees the fallback is a substrate accommodation, NOT a consumer model. Ratatoskr's forward posture pre-empts a future tightening. File a ratatoskr issue when scheduling the change (untracked by operator choice for now).
- `[2026-05-24]` **v0.9.0 live Markdown rendering in TUI transcript.** Replaces v0.8.2's drop-Markdown patch. Transcript switched from `RichLog` to `VerticalScroll`; each turn's response lives as a single `Static` widget whose Markdown content is updated as Text deltas arrive (no post-Done re-render, no double-print). `--raw` bypasses Markdown.
- `[2026-05-24]` **v0.10.0 debug-pane audit logging surface.** Every SSE event arrival lands as one debug-pane line (timestamp + sse_id + event-specific summary). Token-rate Text/Thinking deltas are aggregated into per-turn counters surfaced in a turn-summary line. Also: state-machine transitions, cancel POST lifecycle, app bootstrap, ctrl-c actions, wire-error exception class+body all logged.
- `[2026-05-25]` **Worldtree #204 / v0.28.0 integration (v0.11.0 → v0.13.0).** Three-bump arc for `affect_update` SSE event + `GET /agents/{id}/persona_state` endpoint. v0.11.0 wire layer (AffectUpdate dataclass + parse + Event-union member); v0.12.0 read-side client (`get_persona_state` + typed errors PersonaNotConfigured/AgentNotAvailable/AuthScopeDenied); v0.13.0 TUI surface (sticky `#persona-header` line + Ctrl+4 Persona TabPane; live updates on `AffectUpdate(status="current")`; on-mount hydration via the GET endpoint).
- `[2026-05-26]` **Worldtree #201 / v0.29.0 integration (v0.14.0).** New SSE event `awaiting_llm_first_token` heartbeat (default 5s interval) during the BuildingPrompt→CallingLLM gap. Top-level event, NOT a worker_phase extension (preserves INV-053 three-field stability). `AwaitingLlmFirstToken` dataclass + parse; TUI live transcript indicator ("awaiting first token · Ns") mounted on first heartbeat, updated in place, removed when the gap closes; turn-summary line gains `heartbeats=N`.
- `[2026-05-26]` **v0.14.1: CLI presenter forgot to update when wire-layer events were added.** AffectUpdate (v0.11.0) and AwaitingLlmFirstToken (v0.14.0) were added to the sse_client Event union and the TUI presenter, but `cli.py`'s `CliPresenterState.render` has its own isinstance check that wasn't widened. `ratatoskr --send` crashed AssertionError on any v0.28.0+/v0.29.0+ server. Patch shipped + a posture lesson: **always update BOTH presenters in lockstep when adding a wire-layer event** (the two presenters currently duplicate the isinstance tuple; refactor to a shared constant if a third wire-event lands).
- `[2026-05-26]` **v0.14.2: RichLog min_width=78 silently overrides wrap=True.** Right-column panes (1fr against left's 2fr) are narrower than 78 cells at typical terminal widths; the renderer forces content to 78 wide then horizontal-scrolls. Fix: `min_width=0` on all four right-column RichLog instances.
- `[2026-05-27]` **Issue #16 web companion shipped — v0.15.0.** Browser-based debug surface sibling to the TUI, reusing all wire-layer modules unchanged. New `ratatoskr.web` (Starlette app + lazy-import entrypoint + single-page vanilla HTML/CSS/JS UI), new console script `ratatoskr-web`, optional-deps group `[web]`. Nine HTTP endpoints; five-pane parity over the same SSE wire. Browser-native EventSource (GET stream + separate POST submit) — load-bearing Hulda correction from Heid panel; EventSource is GET-only. In-memory turn registry; browser-disconnect → upstream cancel; lifespan-shutdown drain with 5s budget. HTML-escaped transcript; upstream API key stays server-side. Default bind `0.0.0.0:8765` (LAN-trust model — operator direction; no auth, no TLS, no CORS).
- `[2026-05-27]` **Heid panel review on web-companion scope v1 (pre-implementation).** Caught the EventSource POST/GET error + 7 other load-bearing items BEFORE we cut code. Confirms a pattern: **for non-trivial scope with non-obvious wire-protocol details, run a Heid panel BEFORE implementation, not just after.** Cost ~5min latency; saved a mid-implementation rewrite.
- `[2026-05-27]` **Mid-session `system_prompt` mutation: REJECTED across the industry.** Operator-requested feature → Heid R13 panel (brokkr-claude + Eitri-Codex + Dvalin-Grok, strong convergence) ran a SOTA survey: NO surveyed mature system ships live PATCH-on-active-session for the system prompt (OpenAI Assistants/Responses, Anthropic Messages, Vertex AI, MCP, LangChain, LlamaIndex, Ollama, vLLM). The omission IS the answer; 12 additional threat vectors beyond ratatoskr's initial 7 surfaced (TOCTOU broader than BuildingPrompt window; KV/prefix cache contamination; supply-chain; Memory Control Flow Attacks >90% ASR on tested LangChain/LangGraph). Recommended alternative: client-side fork pattern (PATCH agent → mint new session → replay context). **Operator declined for ratatoskr** — debug TUI is wrong consumer; fork ergonomic belongs in a future production conversational shell. Thread closed cleanly (althing thread `01KSKD1GA3XBWR9RHGZCF9FE3Y`).
- `[2026-05-27]` **Artemis (Gemma4) reasoning-token gap was upstream, not ours.** Wire trace from ratatoskr showed zero `thinking` events for `artemis-31b-v1i`; infra-ops confirmed llama-swap emits 77 `reasoning_content` deltas at the OpenAI-compat layer (`--reasoning-format deepseek`). Gap was in Worldtree's `GemmaProvider`. Worldtree-dev shipped v0.29.13 (commit `4262430`) fixing two stacked bugs: (1) base `OpenAICompatProvider._extract_thinking_from_delta` returned `None` unconditionally so any model falling through to the generic class dropped reasoning; (2) catalog `family` lookup was dead code (read wrong YAML subsection). Confirmed in ratatoskr via re-smoke against Sindra. **Diagnostic pattern: when a wire-layer feature appears missing, get infra-ops to probe upstream-of-the-SSE-publisher first; ratatoskr's wire trace says what reaches us, infra-ops's probe says what reaches Worldtree.**
- `[2026-05-27]` **v0.15.1 (sessions): `get_persona_state` unwraps FastAPI `detail`-envelope.** Live smoke surfaced that real Worldtree returns persona-state errors as `{"detail": {"error_code": "..."}}` (FastAPI default), not flat. v0.12.0 tests mocked flat shape so the bug was invisible. **Lesson: test-side mock envelopes must match the REAL wire shape; live smoke is load-bearing for envelope-shape verification, not just happy paths.**
- `[2026-05-28]` **v0.16.0 web Heid code-review pass 1: load-bearing turn_id fix.** Cancel paths used browser-local `_TURN_COUNTER` ids (1, 2, 3…) instead of upstream Worldtree turn_id (e.g. 799) captured from the first SSE event. The `disconnect_triggers_cancel` test gap was the load-bearing miss. Also: server-configured `RATATOSKR_END_USER_ID` (browser can no longer impersonate partition); narrowed missing-extras `ImportError` catch (real first-party bugs propagate as tracebacks instead of masking as exit-12); per-turn lifespan-shutdown logging. Contract amended with a v0.16.0 block + INV-005/006 updated + 4 FN sketches corrected.
- `[2026-05-28]` **v0.16.1 web Heid code-review pass 2: minor tightening.** Stream-layer vocab coverage extended to all 11 Event types (AffectUpdate added to the vocab stream; dedicated `error_terminal_event` + `cancelled_terminal_event` tests since terminal events are mutually exclusive with done). Disconnect-cancel catch narrowed to swallow only `CancelAlreadyCompleted`/`CancelTurnNotFound` (the cooperative race); log unexpected `CancelFailed`/transport errors as structured stderr. **Heid review loop converged**: pass 1 = 7 findings (1 load-bearing); pass 2 = 2 minor (Gróa: zero findings, Hulda: 2). Pattern confirmed: diminishing returns within 2-3 passes; pass 3 would have been empty.
- `[2026-05-28]` **Sindra Tier 3 agent: FORM ASSUMPTION gate + new physical-form description.** Persistent agent state changes via `python -m ratatoskr.tier3 patch`: (1) model migrated from `qwen3.6-35-a3b-heretic` to `artemis-31b-v1i`; (2) added FORM ASSUMPTION section — when instructed to become another character she IS that character (identity/environment/psychology/parameters), believes the environment as fact, no Sindra/holo-deck/parameter references, sticky until explicit revert; (3) replaced the abstract "classically beautiful" default-form sketch with a specific anti-artifice physical description (5'8", golden-copper skin, asymmetric features, oversize dark-green knit, bare feet). System prompt file is at `/tmp/personal-worldtree-sindra_system_prompt.md` (transient; not committed to repo).
- `[2026-05-29]` **v0.17.0 frontend redesign — aurora telemetry instrument.** `/frontend-design` pass on the web companion: all-monospace technical-instrument aesthetic with the Australis dark palette + aurora-borealis accent band. Top command bar with live connection dot (idle/streaming/error states), inline persona summary with P/A/D micro-bars, animated awaiting-token, terminal-event status chips. **Live Markdown rendering in transcript + thinking panes** via a hand-rolled `markdownSafe()` (escape-first, whitelist subset of headings/bold/italic/inline-code/fenced/lists/quote/links; link-scheme whitelist; XSS-verified under a node harness). Thinking pane now has per-turn labeled dividers + a fresh MD-rendered block per turn. **Tools / Debug / Persona panes stay literal monospace** by deliberate choice — they carry structured audit lines + JSON, where MD would corrupt readability (underscores in tool names, JSON braces). Single-file vanilla HTML/CSS/JS, no build, no CDN, no node_modules.
- `[2026-05-29]` **Codex-first discipline pilot — Ratatoskr selected.** brokkr-smithy-dev pushed `AGENTS.md` (commit `bbeaa23`) and declared the `ratatoskr-codex` handle per `brokkr-smithy/docs/codex-first-discipline.md` v0.1 (brokkr-smithy commit `5dd061c`, tag `v0.5.3`). Per-dispatch opt-in model: default Sleipnir Claude-implementer path remains available; Codex used only when operator routes via `/codex-dispatch <N>`. Bootstrap handshake when operator spins up a codex session: codex sends `codex-online` → ratatoskr-dev replies with active branches + WIP state. Galdrabok was rejected as pilot (Codex authoring Claude skills is a category error); Skaldsong was the other candidate.
- `[2026-06-14]` **Ratatoskr becomes the v1 Bifrost Tier-3 consumer.** A second identity beyond the debug TUI: the durable persistence Worldtree writes Tier-3 agent affect (persona) + memory into. Pin `bifrost>=0.6.1` in a `provider` optional-extra (gitea PyPI index, auth via `~/.netrc`; 0.6.0 was yanked for a circular import). Implement bifrost's OWN `MemoryDataStore`/affect Protocols (NOT worldtree-memory's); `describe_store` is SYNC; affect is conduit-opaque. New module `src/ratatoskr/provider/`. Authoritative how-to: `~/development/bifrost/docs/implementing-a-consumer.md`. (commits `1a73d77` pin, `d90a58d` affect store v0.17.1, `bcdcd71` serve entrypoint v0.17.2)
- `[2026-06-14]` **Backend = SQLite + sqlite-vec; affect-first then memory; separate DB per plane** (operator-chosen). Affect = blind conduit (reads only `agent_id`+`end_user_id`); memory = structural index (reads vector/scope/id/origin to serve search). Conformance for both = #195 parity vs bifrost's `InMemory*Store` through the real `dispatch_*_call`.
- `[2026-06-14]` **The affect contract's idempotency model was WRONG; real-lib TDD caught it.** First draft modeled same-idempotency-key-different-payload as an LWW overwrite; bifrost actually raises a CONFLICT (`AffectIdempotencyConflict`), actor-scoped. The artifact-only `/heid-contract-review` STRUCTURALLY cannot catch this class (it never sees bifrost's source) — TDD against the shipped library is the gate; the executable reference store + #195 parity are the backstop. Filed the guide §6 gap to bifrost-dev, who fixed it (bifrost `c0d0a11`).
- `[2026-06-15]` **Memory v1 = the bifrost BASIC plane only** (search/get/upsert/delete + describe_store/health) per worldtree-dev re-scope (#294) — the only surface Tier-3's live path touches; gated verbs (edges/scan/atomic_supersede/mark/patch/maintenance) deferred + advertised-unsupported. Worldtree v0.35.3 already requests+maps it — no Worldtree-side blocker. Memory contract committed v1.0 (`eebab46`) → v1.1 Heid-reviewed (`1f94e5f`).
- `[2026-06-15]` **Providers run as dev-box BACKGROUND SHELLS, not infra-ops/systemd** (operator call — it's a dev box). `ratatoskr-provider` (affect) + a future `ratatoskr-memory-provider` as background processes; no productionization track.
- `[2026-06-15]` **Providers run as dev-box BACKGROUND SHELLS, not infra-ops/systemd** (operator call — it's a dev box). `ratatoskr-provider` (affect) + `ratatoskr-memory-provider` as background processes; no productionization track.
- `[2026-06-15]` **Affect plane shipped (v0.17.2) + LIVE-PROVEN end-to-end against real Worldtree v0.35.2.** Personal handshake 200 + `affect.emit` 200 from `10.250.50.152` → durable row persisted (opacity held). HS256 key = the consumer's Heimdall API-key STRING utf-8-encoded (NOT base64/raw — the tripwire); cross-subnet route + `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist all held (infra-ops-owned). worldtree-dev confirmed ADR-0009 holding as designed.
- `[2026-06-16]` **#295 cold-recall miss root-caused — UPSTREAM, branch (a) scope-axis asymmetry.** A self-driven bound cold-recall probe (our own client, consumer-key bearer) captured the inbound pair via the new observe log: Worldtree's recall filter carries `{end_user, agent_self}`; our chunks are `{end_user}`-only; AND-matching drops everything on `agent_self` → 0 hits. Our store + search are SOUND; the fix is Worldtree-side. F2 (question-promotion) → **#296** research; F1 (recall-miss) → **#297** research (worldtree-dev's Worldtree-local per-scope-union fix, HELD pending the lattice question).
- `[2026-06-16]` **agent_self → make it CANONICAL (operator decided A).** The cross-repo "is agent_self a valid bifrost scope axis?" question: bifrost's reference lattice is `{end_user, group, tenant}` only (agent_self → `invalid_filter` 400); Worldtree emits agent_self (`bifrost_memory_store.py:479` #248 agent-self primitive). Operator chose canonical-not-re-expressed; worldtree-dev filed the lattice-addition with bifrost-dev (thread `01KV7PXF…`). **Implication: our store's permissive axis-acceptance becomes CORRECT once bifrost adds agent_self — so do NOT add axis-validation; our missing `_validate_scope_filter` is HELD, not a bug to fix.** #297 union build held until the axis lands.
- `[2026-06-16]` **#295 cold-recall miss root-caused — UPSTREAM, scope-axis asymmetry.** A self-driven bound cold-recall probe captured the inbound pair via the observe log: Worldtree's recall filter carries `{end_user, agent_self}`; our chunks were `{end_user}`-only; AND-matching dropped everything on `agent_self` → 0 hits. Our store + search are SOUND; fix is Worldtree-side. F2 (question-promotion) → **#296**; F1 (recall-miss) → **#297**.
- `[2026-06-16]` **agent_self → make it CANONICAL (operator decided A).** bifrost's reference lattice was `{end_user, group, tenant}` only (agent_self → `invalid_filter` 400); Worldtree emits agent_self (#248). Operator chose canonical-not-re-expressed; worldtree-dev filed the lattice-addition with bifrost-dev. Implication: our store's permissive axis-acceptance becomes CORRECT once bifrost adds agent_self.
- `[2026-06-16]` **Self-drive auth identity: bound session-create uses the CONSUMER Heimdall key as bearer, NOT `WORLDTREE_API_KEY`.** Worldtree signs the Bifrost handshake JWT with the session-create bearer (canary key → handshake 401; consumer key → 200). Two keys, two identities. Proven by hand; documented in `docs/bifrost-self-test.md`; load-bearing for #17's Bind half.
- `[2026-06-16]` **Issue #17 v1 scope locked (operator 1A/2A): single-plane bind + dispatch-layer op-feed.** `BifrostBindingRequest` is one `endpoint_url` (one plane per session); composite-both-planes endpoint PARKED. Observe = structured op-feed instrumented at the DISPATCH layer (bifrost passes ctx to upsert_many but NOT search/get/delete — `memory.py:244`), session-level correlation; turn-correlated pane UI PARKED (needs turn_id, TBD). Direct in-session TDD (live-smoke load-bearing). Contract `docs/contracts/issues/17.contract.md` written, `/heid`-design-consulted + `/heid-contract-review`-panel'd + fixed (validates OK). NEXT: TDD.
- `[2026-06-16]` **Provider stores confirmed byte-faithful to bifrost's AND reference** (`reference_server/memory.py:398` `_matches_scope` = `all(...)`, identical to ours). OR-union was considered + rejected (ecosystem-wide change); flagged the silent-zero foot-gun to bifrost-dev (docs-only landed, bifrost stays 0.6.4). Do NOT flip `_scope_matches` to OR.
- `[2026-06-16]` **Issue #17 v1 scope locked (operator 1A/2A): single-plane bind + dispatch-layer op-feed.** `BifrostBindingRequest` is one `endpoint_url` (one plane per session); composite-both-planes endpoint PARKED (→ now #18). Observe = structured op-feed at the DISPATCH layer (bifrost passes ctx to upsert_many but NOT search/get/delete — `memory.py:244`), session-level correlation; turn-correlated pane UI PARKED. Contract `docs/contracts/issues/17.contract.md` written + `/heid`-reviewed.
- `[2026-06-16]` **agent_self lattice SHIPPED both sides → our axis-validation gap CLOSED (v0.17.5).** bifrost 0.7.0 / wire v0.5 adds agent_self to `{end_user,group,tenant,agent_self}` (#10, driven by our foot-gun flag); Worldtree pinned 0.7.0 (v0.35.11). We DID add `_validate_scope_filter` (4-axis) to match the reference (purely additive; out-of-lattice → InvalidFilter).
- `[2026-06-16]` **Repinned bifrost 0.7.0→0.8.0 + reimplemented memory `search` to the v0.6 scope split (operator-directed).** `scope_filter``scope_all` (AND) + `scope_any` (OR/union over a list of conjunctive scopes), bifrost #11 — the canonical resolution of the #295/#297 silent-zero. The reference now does OR via `scope_any` (a NEW field — additive split, not a flip of AND). Store / contract (v1.2) / tests at parity with the v0.6 reference; provider bounced onto 0.8.0 with a wiped DB. Shipped v0.17.6 (`96d61a4`). **(SUPERSEDED the earlier "do NOT flip `_scope_matches` to OR" note.)**
- `[2026-06-17]` **Worldtree spec pin bumped v0.29.0→v0.35.16 (`562001a`→`f1b59f8`); cold recall closed on the WIRE.** Worldtree shipped #297 (client-side per-scope-value union recall) + #298/#299 (adopt the bifrost v0.6 `scope_any`/`scope_all` wire) — emits `scope_any` on recall, pairing with our v0.17.6 provider. Re-vendored the spec; diff-reviewed the 285-commit catch-up — no client-breaking changes. `pin:`-only commit, no bump.
- `[2026-06-17]` **End-to-end cold-recall proof RAN — our stack proven, #296 isolated.** Against personal WT v0.35.16 with restored `ratatoskr:sindra`: #297/#298 union recall, write path, and cold read ALL proven. Lone gap = upstream #296 extraction quality (the WIRE closed; fact-recall was #296-blocked).
- `[2026-06-17]` **DELETE+redefine `ratatoskr:sindra` (operator-authorized; pre-v1 debug surface).** She SURVIVED the rebuild but was STALE (dead model + no memory block); memory is immutable post-define, so DELETE+redefine was the only path. v0.35.16 define takes **`role`** (capability), NOT `model`: `role:"character"` → first-healthy bind `mistral-small-4`; `memory:{}` trips the promotion gate (GET does NOT echo `memory_config`). Our `tier3.py` define is Phase-2.0-stale — untracked modernization follow-up.
- `[2026-06-17]` **Promotion = 4-trigger hybrid (worldtree-dev, code-grounded):** salience (regex, 90s rate-limit) / `turn_count≥6` / context_pressure / **idle `≥10min` (unconditional on quality)**; per-turn `plan_promotion_run` for consumer_defined. **DELETE does NOT drain/promote** (delete-is-delete, #276) — idle `≥10min` is the deterministic flush.
- `[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0…`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. WAD-vs-bug resolved to BUG (extraction quality), not idle-gating.
- `[2026-06-18]` **Tier-3 memory PROVEN end-to-end live**`ratatoskr:terse-probe` recalled a seeded user fact in a COLD history-free session (scope_any → 1 hit @ cosine 0.6994). Closes the opening "how far from Tier-3 memory" question for normal agents.
- `[2026-06-18]` **#296 Stages 1+2 closed.** Stage 1 (v0.35.19, recallability admission gate) validated live for normal turns; bisect localized the residual to verbose-persona VOLUME crowd-out. Stage 2 (v0.36.0, MERGED at worldtree-codex) = user-only one-call-per-turn extraction, the STRUCTURAL fix; hard-linguistic layer → Worldtree #305 (we handed over a live-validated eval fixture PAIR). Full-coverage re-smoke: verbose `sindra-probe` promoted the fact cleanly + cold-recalled @ 0.694 under v0.36.0.
- `[2026-06-18]` **#17 implemented end-to-end via direct in-session TDD** (6 patch bumps `v0.17.8``v0.17.13`, suite 470 green). Slice order: bind primitive → op-feed → CLI → TUI → web(server) → web(UI). Tests drive the REAL bifrost dispatch via minted JWTs (`bifrost.core.dispatch_jwt.mint_dispatch_jwt`) — the "test against the shipped lib" posture, not hand-mocked envelopes. Op-feed reads `session_id` off the dispatch JWT `sub` claim (the contract open-q, resolved YES at the ASGI layer where the JWT is always present — `bifrost.reference_server._dispatch_auth.DispatchContext.session_id = payload["sub"]`). bifrost wire facts captured in-code: memory envelope `{operation, args}``memory_result(**payload)`={success,...}; verbs bare (search/upsert_many/get/get_many/delete_many); affect `{operation:"affect.emit"}``{success,stored}`; error envelope `{code, message}`; scopes `memory:read|write`.
- `[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-16]` **agent_self lattice SHIPPED both sides → our axis-validation gap CLOSED (v0.17.5).** bifrost 0.7.0 / wire v0.5 adds agent_self to the scope lattice `{end_user,group,tenant,agent_self}` (#10, driven by our foot-gun flag via bifrost-dev); Worldtree pinned 0.7.0 + canonical-synced the v0.5 spec (v0.35.11, `c860fb0`). **SUPERSEDES the prior "HELD, do NOT add axis-validation" note** — we DID add `_validate_scope_filter` (4-axis) to match the reference (bifrost-dev's recommendation, purely additive; out-of-lattice → InvalidFilter). #297 union build unblocked. Memory provider restarted on 0.7.0.
- `[2026-06-16]` **#17 contract reviewed + the debug-assist arc fully closed.** `/heid` design consult + `/heid-contract-review` panel both run on `docs/contracts/issues/17.contract.md` (validates OK). The #295 debug-assist that opened the session is closed end-to-end: root cause (scope-axis asymmetry) → #296/#297 research issues + corpus → a shipped bifrost protocol change (agent_self canonical) → our store at parity. NEXT durable step: #17 TDD (tracked: Gitea #17 + the contract).
- `[2026-06-16]` **Repinned bifrost 0.7.0→0.8.0 + reimplemented memory `search` to the v0.6 scope split (operator-directed).** `scope_filter``scope_all` (AND) + `scope_any` (OR/union over a list of conjunctive scopes), bifrost #11 — the canonical resolution of the #295/#297 silent-zero. **SUPERSEDES the "Provider stores byte-faithful to AND reference / Do NOT flip `_scope_matches` to OR" entry above**: the reference itself now does OR via `scope_any` (a NEW field — `scope_all` keeps the old AND semantics; this is an additive split, not a flip of the AND predicate). Store / contract (v1.2) / tests at parity with the v0.6 reference; provider bounced onto 0.8.0 with a wiped DB (operator: "nothing of value"). Cold recall now gated only on Worldtree emitting `scope_any` (#297). **#17's contract has stale `scope_filter`/`_scope_matches`-AND references (its `assumptions`, INV-004, and the op-feed `search → req {scope_filter}` summary shape) — update those to `scope_all`/`scope_any` when #17 TDD starts; INV-004's intent (observe must not alter scope semantics) still holds.**
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
@@ -218,35 +136,24 @@ _For per-issue TDD implementation notes, Volva findings, and contract amendments
Log of approaches that were tried and rejected, with rationale. Future-self
defense against re-attempting the same cul-de-sac.
- `[2026-05-20]` **rich + prompt_toolkit framework choice.** Volva flagged that §1 and §5 pulled in opposite directions: a real side-panel observability surface would silently become a widget framework reimplementation. Operator's debug-observability reframe sealed the flip to Textual. Don't re-attempt rich+pt unless the scope shrinks to transcript-first REPL.
- `[2026-05-20]` **In-tree at Worldtree/tools/ratatoskr/.** Earlier draft committed to in-tree-with-import-direction-smoke-test. Rejected at operator-routing — separate dev team forces separate repo.
- `[2026-05-20]` **New `/persona/log` SSE endpoint on Worldtree.** Considered as alternative to file-tailing `persona.log`. Rejected — contract amendment + Vor round + AFK dispatch loop is weeks for a debug feature file-tail handles in a day. Trigger follow-up if a Worldtree-on-server / TUI-on-laptop debug case appears.
- `[2026-05-20]` **Cross-process Last-Event-ID resume.** Considered — would require persisting per-session Last-Event-ID. Deferred to v2; v1 ships "reconnect, not resume-across-process."
- `[2026-05-21]` **RichLog widget with `markup=True`.** Default impulse, but Rich interprets `[xxx]` spans as style markup and silently strips them. Every labeled stderr-style line — `[cancel_failed]`, `[done]`, `[error]`, `[busy]`, `[worker_phase]` — would render as just the content after the bracketed label. Fix: `markup=False`. Don't flip back without renaming every labeled-line format away from `[bracket]` notation.
- `[2026-05-21]` **Querying `self.query_one("#transcript", RichLog)` from inside a Textual `run_worker` coroutine.** Initially failed with `NoMatches`. Reactive fix was widening worker signature to take `log` as parameter — Volva flagged as contract drift; reverted. Real fix was test-side: `await pilot.pause()` between `inp.action_submit()` and the polling loop so the handler finishes dispatching. Don't widen worker signatures to dodge test timing.
- `[2026-05-21]` **TUI session-identity rendering via `self.sub_title` + `self.hint` plain attributes.** Stored state but never rendered to a visible widget. Tests asserted attributes (passed); Volva code-review flagged the gap. Fix: dedicated `Static(id="identity")` + `Static(id="hint")` widgets in compose; `_set_hint()` helper mirrors state → widget. **Calibration evidence for the "TDD catches state, code-review catches whether the user can see it" pattern.**
- `[2026-05-23]` **Using the cross-model review agent's name directly in composed prose.** The peer review agent's name (the althing handle starting with "V-o-l-v-a") is one letter from a body-part term. Anthropic's content classifier does fuzzy matching and intermittently blocks responses mid-stream when the name appears in composed prose sentences. Mitigation: use role descriptions ("the cross-model reviewer," "the paraphrase peer") in prose rather than the name; quote content via tool output.
- `[2026-05-22]` **`json.loads(sse.data)` unguarded against empty data.** `_iter_events` unconditionally called `json.loads` on every dispatched `ServerSentEvent`. When `httpx_sse` surfaced a frame with `id:` present but `data:` empty, `json.loads('')` raised `JSONDecodeError` → app crash. Fix: `if sse.data == '': continue` BEFORE `_parse_sse_id`. Don't reintroduce unconditional `json.loads(sse.data)`.
- `[2026-05-23]` **Diagnostic shorthand: "2-events-then-silence" = Worldtree-side LLM-call wedge, not ratatoskr.** If a mimir `--send` smoke shows exactly two stderr events — `. create_session: ...` followed by `. worker_phase: phase=BuildingPrompt ...` — and then nothing for >60s, the root cause is upstream of ratatoskr. Worldtree's `service.py:2560` gates the `CallingLLM` event on the engine yielding its first LLM-provider chunk; if that connection is wedged at TCP level, the `async for` never iterates. Worldtree's 300s `_start_stall_timer` cancel-check is INSIDE the engine-event loop and so bypassed. **Don't bisect ratatoskr code when this shape appears** — diagnose the LLM-provider state at Worldtree's host. Restarting the Worldtree service clears wedged llama-swap connections. 10.250.50.152 hosts 3 instances (`:8080`/`:8081`/`:8082`) each with own DB + key namespace; our key is valid only on `:8081`.
- `[2026-05-23]` **Phantom "per-Tier-1-agent scope add" pattern.** Issue #5's lofn 422 was initially mis-diagnosed as needing `agents.call:lofn` added. Routed to infra-ops via althing per credential-brokerage rule; infra-ops discovered no public scope-mutation endpoint, brokered to worldtree-dev. Worldtree-dev clarified: **Tier 1 foundational agents** are covered by a blanket `agent.call:*` (singular) baseline. There is no per-agent grant for Tier 1. **Tier 3 consumer-defined agents** use the plural `agents.call:<owner>:<agent>` shape registered via `POST /agents/define`. The notations differ by one letter. **The actual lofn fix was issue #5's `--end-user-id` flag** — always a request-body validation, not an auth-scope gate. Don't ping infra-ops for "per-Tier-1-agent scope adds."
- `[2026-05-24]` **v0.8.x double-print: streamed Text + post-Done Markdown re-render.** Initial v0.6.0 design wrote each Text delta inline (with `· ` prefix) then re-rendered the full response as a Markdown Renderable on Done. Visually the response appeared twice. v0.8.2 dropped the post-Done Markdown body (interim regression). v0.9.0 fixed it properly with live Markdown rendering during stream (single Static widget holding a Markdown Renderable, updated in place). Don't reintroduce post-Done re-render unless you also remove the live-Markdown widget.
- `[2026-05-26]` **Textual `RichLog(wrap=True)` insufficient on narrow widgets.** The default `min_width=78` overrides wrap on shrink — `max(renderable_width, min_width)` forces 78-cell rendering then horizontal-scrolls. Always set `min_width=0` on RichLog instances in a narrow column. Re-check on any future RichLog construction.
- `[2026-05-26]` **Wire-layer event added without updating BOTH presenters.** v0.11.0 (AffectUpdate) and v0.14.0 (AwaitingLlmFirstToken) widened the sse_client Event union + TUI presenter's isinstance tuple, but missed cli.py's identical-shape tuple. `--send` mode then crashed on any persona-enabled or slow-first-token turn. Patch fix in v0.14.1. **Rule: when adding a wire-layer event, grep for `isinstance(event, (` across the repo** — currently TUI and CLI presenters both carry duplicate hardcoded tuples. Refactor to a shared `_EVENT_VOCAB` constant if a third wire-event lands.
- `[2026-05-27]` **EventSource is GET-only — scope v1's POST stream endpoint would have broken.** Web companion's first scope had `POST /api/turns/{sid}/stream` for the SSE proxy. Browser-native `EventSource` only supports GET. Hulda caught it in Heid panel review BEFORE we cut code. Pattern: `POST /api/turns/{sid}` registers the turn locally + returns turn_id; `GET /api/turns/{sid}/stream?turn_id=N` streams via EventSource; cancel is a separate POST. **Load-bearing reason to Heid-panel non-trivial wire-protocol designs BEFORE implementation, not just after.**
- `[2026-05-27]` **`get_persona_state` mocked flat error envelope; real Worldtree wraps in `detail`.** v0.12.0 tests used `{"error_code": "auth_scope_denied"}` but real wire (FastAPI default) returns `{"detail": {"error_code": "auth_scope_denied", "message": "…"}}`. The parser only checked top-level so the typed exception was never raised; calls fell through to `SessionApiFailed(403)`, which the web persona endpoint surfaced as HTTP 500. v0.15.1 patches both shapes. **Lesson: test-side mock envelopes must match the REAL wire shape; live smoke is load-bearing for envelope-shape verification, not just happy paths.**
- `[2026-05-27]` **Mid-session `system_prompt` mutation: universal omission across surveyed mature systems.** brokkr-smithy R13 panel (3-arm, strong convergence) confirmed: no surveyed system ships live PATCH-on-active-session (OpenAI Assistants/Responses, Anthropic Messages, Vertex AI, MCP, LangChain, LlamaIndex, Ollama, vLLM). The omission IS the answer. 12 additional threat vectors beyond ratatoskr's initial 7. **Don't re-propose this for ratatoskr;** if a future production conversational shell wants iterative-prompt-tuning ergonomics, the consensus shape is fork-via-client (PATCH agent → new session → replay context).
- `[2026-05-28]` **Browser-local turn_id used for upstream cancel URL — old cancel tests ENCODED the bug.** Web companion v0.15.x cancel paths posted to `/sessions/{sid}/turns/{LOCAL_ID}/cancel`. Tests mocked the local-id URL so they encoded the bug rather than detecting it. Hulda caught it in Heid pass 1. Fix in v0.16.0: capture upstream_turn_id from the first SSE event's `sse_id.turn_id`; all cancel paths use it; cancel before first event is `{"cancelled": false, "reason": "not_started"}`. **Rule: when designing cancel/match paths against an external service, test fixtures must mock what would actually be hit upstream — mocking your own derived id encodes the bug instead of catching it.**
- `[2026-06-15]` **"Sindra hasn't been registered" was an under-verified inference — WRONG.** Concluded it from grepping ratatoskr's CODE (`sindra` absent from `src/`), but Tier-3 registration is SERVER-SIDE (`POST /agents/define` on the Worldtree instance) — a code grep structurally can't see it. Registration IS required to use a Tier-3 character (a session against an unregistered `agent_id` 404s), so since Sindra has been used, she WAS registered (`ratatoskr:sindra`). **Rule: to check whether a Tier-3 agent exists, query the Worldtree instance's `GET /agents`, never the consumer repo's code.** (Residual: the v0.35.2 personal rebuild may have wiped her — re-verify.)
- `[2026-06-14]` **Artifact-only contract review can't validate against a dependency's ACTUAL behavior.** `/heid-contract-review` sees only the contract, never the external library (bifrost) — so "the consumer under-built against bifrost's real semantics" is invisible to it by construction (the affect idempotency model shipped wrong because of this). Real-lib TDD against the shipped library + the executable reference store + the #195 parity test are the gate for any consumer plane with non-trivial state semantics. Don't treat a clean contract review as evidence the code matches the dependency.
- `[2026-06-15]` **"Sindra hasn't been registered" was an under-verified inference — WRONG.** Concluded it from grepping ratatoskr's CODE (`sindra` absent from `src/`), but Tier-3 registration is SERVER-SIDE (`POST /agents/define`) — a code grep structurally can't see it. **Rule: to check whether a Tier-3 agent exists, query the Worldtree instance, never the consumer repo's code.** (Extended 2026-06-17: even `GET /agents` can't see consumer agents; only `GET /agents/<owner>:<name>` with the owner key does.)
- `[2026-06-14]` **Artifact-only contract review can't validate against a dependency's ACTUAL behavior.** `/heid-contract-review` sees only the contract, never the external library (bifrost) — so "the consumer under-built against bifrost's real semantics" is invisible to it by construction (the affect idempotency model shipped wrong because of this). Real-lib TDD against the shipped library + the executable reference store + the #195 parity test are the gate. Don't treat a clean contract review as evidence the code matches the dependency.
- `[2026-06-15]` **"byte-equal" round-trip slip propagated affect→memory via copy-paste.** The affect contract's byte-identical→semantic fix reappeared in the memory contract's INV-001 (sibling copy). Only an INDEPENDENT `/heid-contract-review` of the memory contract re-caught it. **Paraphrase every sibling contract fresh — don't amortize one review across a family; copies carry the parent's slips.** (also a feedback auto-memory)
- `[2026-06-15]` **Canonical sync retired the issue-scoped parser staleness** (predicted by the 2026-05-21 entry's "until canonical bumps"). `contract_parser.py` synced to v2.1 (`f1fdfdb6→e10a4460`, commit `d85ab43`): now validates issue-scoped frontmatter (`target_module`/`scope`/`prd`) + four v2.1 test categories (scenario/trace/adversarial/property). Issues #3/#4 went FAIL→WARN (0 errors). The old "treat parser ERROR-on-issue-scoped as expected" note no longer applies.
- `[2026-06-15]` **Refreshed #3/#4 presenter contracts to the shipped TUI model** (commit `335c835`). Both still described the abandoned single-`RichLog` double-display model; rewrote to the 4-pane live-Markdown reality (v0.5.0v0.14.0 + Worldtree #201/#204) across INV-005, the `[performance]` constraint, the COMPOSE sketch, the `CLASS TuiPresenterState` block, both `render`/`_stream_turn_worker` blocks, and the `_cancel_via_sse` call site — plus the STEPS the v2.1 parser flagged missing. Code unchanged; contract-truth catching up to shipped code. Scope ballooned one-block→contract-wide mid-task; surfaced to operator before rewriting the INV-005 trade-off invariant.
- `[2026-06-15]` **Memory plane TDD'd + shipped** (commit `cd12951`, v0.17.3). Impl decisions worth keeping: vec0 `distance_metric=cosine` set at table creation (`score = 1 distance`); `search` over-fetches ALL candidates by cosine then scope-filters in Python so `top_k` counts IN-SCOPE hits (INV-005, contract STEP 2 `indicative`); idempotency_id = reference 4-tuple `("default",verb,_ctx_actor(ctx),key)` pipe-joined as the SQLite PK, digest = sha256 canonical-JSON; `_ctx_actor` = `job_id|jwt_sub|session_id` (memory reference's 3-level, vs affect's 2-level). **heid-code-review panel returned zero true drift**; adopted 5 cheap contract-anchored fixups (scope_filter dict guard, `top_k≤0→[]`, stronger scope-isolation / delete-hit-search / handshake-POST tests), accepted 6 with reasoning. **Partial-map optimistic-lock semantics pinned to the reference via an `expected_revisions` parity test** — resolved a Hulda finding deterministically (the affect-plane lesson: TDD against the shipped lib is the gate, not judgment).
- `[2026-06-15]` **Memory provider LIVE-PROVEN against personal v0.35.3 (persist + dispatch + search-correctness); recall-injection is upstream.** worldtree-dev's Tier-3 promotion recipe (via infra-ops): memory-call fires from Tier-3 PROMOTION, gated at `service.py:2623` on `ctx.kind=="consumer_defined"` AND `ctx.memory_config is not None` (the agent must be DEFINED WITH a `memory` block — `ValidatedMemoryConfig {tier3_dreaming:false}`, dim 1024) AND handshake-granted memory caps AND `embedding_dim==1024`. `memory.agent_self_enabled` is NOT the gate (only the #248 self-candidate branch). Binding = `POST /sessions BifrostBindingRequest{endpoint_url}`, handshake `caps=["affect","memory"]`, **`binding.scope` null** (per-op scopes auto-minted: upsert_many→`memory:write`, search→`memory:read`). A `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist gates the endpoint (Worldtree-side config — infra-ops added `:8391`). HTTP + HS256 both work in dev. (smoke wiring thread `01KV7D82MJYB…`)
- `[2026-06-15]` **Diagnostic: our recall-search is SOUND — the cross-session recall gap is UPSTREAM, not the store — and it caught an upstream bug.** Embedded the recall query via gateway `qwen3-embedding` + searched our live store directly → the dark-chocolate fact recalls at cosine 0.60, correctly ranked above the unrelated name fact (0.16). So the cold-session recall failure is Worldtree's recall-assembly/injection (hits not reaching the prompt), NOT our search. ALSO found a latent UPSTREAM bug: a recall QUESTION got promoted as a durable chunk and ranks **#1 (0.70 > the fact's 0.60)**, polluting recall. Relayed to worldtree-dev (thread `01KV7JH8…`). **This is exactly #17's thesis — ratatoskr-as-provider caught an upstream bug invisible from the chat side.**
- `[2026-06-15]` **"Wire 200 ≠ recall works" — prove recall efficacy at the model's answer in a COLD (history-free) session, not on the wire.** A `search`/memory-call returns 200 whether or not its results are injected into the prompt, and same-session "recall" can be plain session history. infra-ops' cold cross-session probe caught my premature "all-green" (search dispatched 200, model had no memory). Don't call cross-session recall proven from a clean wire.
- `[2026-06-15]` **Issue #17 filed — Bifrost-binding for the chat client (self-drive + correlated-log affect/memory ops).** REVERSES design-brief §6's "no Bifrost-binding consumer support" — that negative clause predates ratatoskr's provider identity (2026-06-14), so the canary now owns both ends but its client can't drive its own provider (`create_session` sends only `{agent_id, end_user_id}`; no Bifrost `endpoint_url`). Today's smoke proved the substrate (bind→dispatch→persist); only the observe/log channel design (open question #5) remains. The recall-injection caveat is upstream and doesn't block #17. NEXT on #17: `/heid` consult on the now-grounded framing → contract → TDD. (tracked: Gitea #17, labels enhancement/observability/tui)
- `[2026-06-16]` **`scripts/contract_drift_check.py` defaults `GITEA_REPO` to "Worldtree"** (line 74), so a bare run in ratatoskr false-positives DRIFT by hashing Worldtree's same-numbered issue. Always `export GITEA_REPO=ratatoskr GITEA_OWNER=vh` (env.sh leaves the GITEA vars commented out) before running the drift-checker here.
- `[2026-06-16]` **My #295 coupling hypothesis (the promoted question crowds out the fact at small top_k) was REFUTED** — worldtree-dev's recall over-fetches `top_k=128` (`injector.py:203`/`_store_helpers.py:101`), so the question can't crowd the fact out at search level. Reasonable cross-frontier hypothesis, correctly framed as a hypothesis not a conclusion; the real cause was the scope-axis asymmetry. Lesson: offer provider-side hypotheses, let the upstream owner check them against their code.
- `[2026-06-16]` **Contract drifted from its own design in two spots, caught only by `/heid-contract-review` (not same-author paraphrase):** the `OpEvent` dataclass omitted the `turn_id` that INV-005 promised; the `session_id` comment said "None for search/get/delete" contradicting the dispatch-layer design (the JWT carries session_id for all verbs at dispatch). Cross-model paraphrase is load-bearing for catching an author's own contract-vs-intent drift.
- `[2026-06-16]` **"No promotion" was checked TOO EARLY — Tier-3 promotion is ASYNC (lands AFTER the SSE turn-end).** The cold-recall probe's immediate post-turn fixture check showed 3 chunks (no promotion); a later check (during the v0.17.5 provider restart) found 5 — the probe HAD promoted 2 chunks (its question `acc3d49` + the model's non-answer `4773704`), just late. Don't trust an immediate post-turn fixture snapshot to judge promotion; it lands after the turn completes. (Same family as the "wire-200 ≠ recall, prove it in a cold session" lesson, extended to promotion timing — and the reason #17's contract pins a post-turn grace window + fixture before/after assertion.)
- `[2026-06-15]` **Canonical sync retired the issue-scoped parser staleness.** `contract_parser.py` synced to v2.1 (commit `d85ab43`): now validates issue-scoped frontmatter + four v2.1 test categories. The old "treat parser ERROR-on-issue-scoped as expected" note no longer applies.
- `[2026-06-15]` **Memory plane TDD'd + shipped** (commit `cd12951`, v0.17.3). Impl decisions worth keeping: vec0 `distance_metric=cosine` at table creation (`score = 1 distance`); `search` over-fetches ALL candidates by cosine then scope-filters in Python so `top_k` counts IN-SCOPE hits; idempotency_id = reference 4-tuple `("default",verb,_ctx_actor(ctx),key)` pipe-joined as the SQLite PK, digest = sha256 canonical-JSON; `_ctx_actor` = `job_id|jwt_sub|session_id`. heid-code-review returned zero true drift; optimistic-lock semantics pinned to the reference via an `expected_revisions` parity test.
- `[2026-06-15]` **Memory provider LIVE-PROVEN against personal v0.35.3; recall-injection is upstream.** worldtree-dev's Tier-3 promotion recipe: memory-call fires from Tier-3 PROMOTION, gated at `service.py:2623` on `ctx.kind=="consumer_defined"` AND `ctx.memory_config is not None` (agent DEFINED WITH a `memory` block, dim 1024) AND handshake-granted memory caps AND `embedding_dim==1024`. Binding = `POST /sessions BifrostBindingRequest{endpoint_url}`, handshake `caps=["affect","memory"]`, **`binding.scope` null** (per-op scopes auto-minted). A `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist gates the endpoint (infra-ops added `:8391`). HTTP + HS256 both work in dev.
- `[2026-06-15]` **Diagnostic: our recall-search is SOUND — the cross-session recall gap is UPSTREAM, and it caught an upstream bug.** Embedded the recall query via gateway `qwen3-embedding` + searched our live store directly → the fact recalls at cosine 0.60, correctly ranked. So the cold-session recall failure is Worldtree's recall-assembly/injection, NOT our search. ALSO found a latent UPSTREAM bug: a recall QUESTION got promoted as a durable chunk and ranked #1. **This is exactly #17's thesis — ratatoskr-as-provider caught an upstream bug invisible from the chat side.**
- `[2026-06-15]` **"Wire 200 ≠ recall works" — prove recall efficacy at the model's answer in a COLD (history-free) session, not on the wire.** A `search`/memory-call returns 200 whether or not its results are injected, and same-session "recall" can be plain session history. Don't call cross-session recall proven from a clean wire.
- `[2026-06-15]` **Issue #17 filed.** REVERSES design-brief §6's "no Bifrost-binding consumer support" — that negative clause predates ratatoskr's provider identity (2026-06-14), so the canary now owns both ends but its client couldn't drive its own provider. (Shipped 2026-06-18.)
- `[2026-06-16]` **`scripts/contract_drift_check.py` defaults `GITEA_REPO` to "Worldtree"** (line 74), so a bare run in ratatoskr false-positives DRIFT by hashing Worldtree's same-numbered issue. Always `export GITEA_REPO=ratatoskr GITEA_OWNER=vh` before running the drift-checker here.
- `[2026-06-16]` **My #295 coupling hypothesis (the promoted question crowds out the fact at small top_k) was REFUTED** — worldtree-dev's recall over-fetches `top_k=128`, so the question can't crowd the fact out at search level. The real cause was the scope-axis asymmetry. Lesson: offer provider-side hypotheses, let the upstream owner check them against their code.
- `[2026-06-16]` **#17 contract drifted from its own design in two spots, caught only by `/heid-contract-review` (not same-author paraphrase):** the `OpEvent` dataclass omitted the `turn_id` INV-005 promised; a `session_id` comment contradicted the dispatch-layer design. Cross-model paraphrase is load-bearing for catching an author's own contract-vs-intent drift.
- `[2026-06-16]` **"No promotion" was checked TOO EARLY — Tier-3 promotion is ASYNC (lands AFTER the SSE turn-end).** Don't trust an immediate post-turn fixture snapshot to judge promotion; it lands after the turn completes. (The reason #17's contract pins a post-turn grace window + fixture before/after assertion.)
- `[2026-06-17]` **"sindra is GONE" (infra-ops, from `GET /agents` + admin token) was a FALSE NEGATIVE.** Consumer-defined Tier-3 agents are OWNER-SCOPED (separate `consumer_agents` table) — invisible to the foundational `GET /agents` roster even with an admin token. To check, `GET /agents/<owner>:<name>` with the OWNER key.
- `[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.**
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
+4 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.17.6"
version = "0.17.17"
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>=0.10.0", # consumer engines + library (0.10.0: build_combined_app (#18) + mandatory affect.fetch, strong-or-absent; 0.8.0/wire-v0.6: scope_all/scope_any split (#11); 0.7.0/v0.5: agent_self)
"jsonschema>=4", # bifrost runtime dep — envelope validation
"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"
@@ -59,7 +60,7 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr"
# Ratatoskr is built against Worldtree at this commit; the vendored
# spec snapshot in docs/ reflects that SHA.
[tool.ratatoskr.spec-pin]
worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a"
worldtree-spec-rev = "f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a"
worldtree-version = "v0.29.0"
pinned-on = "2026-05-26"
+81 -2
View File
@@ -16,7 +16,15 @@ from typing import TextIO
import httpx
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
from ratatoskr.sessions import (
AgentNotFound,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
SessionApiFailed,
create_session,
endpoint_for_plane,
)
from ratatoskr.sse_client import (
AffectUpdate,
AwaitingLlmFirstToken,
@@ -83,6 +91,12 @@ class ParsedArgs:
# Per issue #5: optional `--end-user-id` for per-end-user agents (lofn etc.).
# Default None preserves the pre-#5 baseline for agents that don't require it (mimir).
end_user_id: str | None = None
# Issue #17: optional Bifrost binding (one plane) + its consumer key. None on
# the unbound pre-#17 path. `bifrost_plane` is the human label for the
# bound-state indicator (None when --bifrost-url supplies the endpoint directly).
bifrost: BifrostBinding | None = None
bifrost_plane: str | None = None
consumer_key: str | None = None
class _ArgparseError(Exception):
@@ -109,6 +123,13 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
parser.add_argument("--raw", action="store_true")
# Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir).
parser.add_argument("--end-user-id", dest="end_user_id", default=None)
# Issue #17: bind the created session to our own Bifrost provider plane.
parser.add_argument(
"--bifrost-plane", dest="bifrost_plane", choices=("memory", "affect"),
default=None,
)
parser.add_argument("--bifrost-host", dest="bifrost_host", default=None)
parser.add_argument("--bifrost-url", dest="bifrost_url", default=None)
try:
ns = parser.parse_args(argv)
except _ArgparseError as exc:
@@ -143,6 +164,30 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
# gets a stable partition without papering over the explicit-flag override.
end_user_id = ns.end_user_id or os.environ.get("RATATOSKR_END_USER_ID") or None
# Issue #17: resolve the optional Bifrost binding. --bifrost-url (direct,
# HTTPS/prod) and --bifrost-plane (dev shortcut → endpoint_for_plane) are
# mutually exclusive; a binding is a session-CREATE concern (forbidden with
# --session). The consumer key — the privileged handshake identity, distinct
# from the canary key — comes from the env (never a CLI flag).
bifrost: BifrostBinding | None = None
bifrost_plane: str | None = None
if ns.bifrost_url and ns.bifrost_plane:
raise UsageError("--bifrost-url and --bifrost-plane are mutually exclusive")
if (ns.bifrost_url or ns.bifrost_plane) and not ns.new:
raise UsageError("a bifrost binding requires --new (it binds at session create)")
if ns.bifrost_url:
bifrost = BifrostBinding(endpoint_url=ns.bifrost_url)
elif ns.bifrost_plane:
host = ns.bifrost_host or os.environ.get("RATATOSKR_PROVIDER_VISIBLE_HOST")
if not host:
raise UsageError(
"--bifrost-plane requires --bifrost-host "
"(or RATATOSKR_PROVIDER_VISIBLE_HOST) — the Worldtree-visible provider host"
)
bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host))
bifrost_plane = ns.bifrost_plane
consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") or None
return ParsedArgs(
send_content=ns.send,
session_id=ns.session,
@@ -152,6 +197,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
server_url=server_url,
raw=ns.raw,
end_user_id=end_user_id,
bifrost=bifrost,
bifrost_plane=bifrost_plane,
consumer_key=consumer_key,
)
@@ -432,11 +480,34 @@ async def _amain(args: ParsedArgs) -> int:
assert args.agent_id is not None
try:
info = await create_session(
client, args.agent_id, end_user_id=args.end_user_id
client,
args.agent_id,
end_user_id=args.end_user_id,
bifrost=args.bifrost,
consumer_key=args.consumer_key,
)
except AgentNotFound as exc:
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
return 12
except BifrostConsumerKeyMissing as exc:
# INV-001: never fall back to the canary key — fail loud.
sys.stderr.write(
f"[bifrost_consumer_key_missing] {exc} "
f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n"
)
return 22
except BifrostHandshakeFailed as exc:
# INV-002: bind-time handshake failure fails session creation.
sys.stderr.write(
f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n"
)
# 401-message scoping: keyed on auth_rejected, name the key mismatch.
if exc.bifrost_error == "bifrost.auth_rejected":
sys.stderr.write(
" bound create requires the consumer key "
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
)
return 23
except SessionApiFailed as exc:
sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
return 20
@@ -448,6 +519,14 @@ async def _amain(args: ParsedArgs) -> int:
sys.stderr.write(
f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n"
)
# Issue #17 bound-state indicator: plane + endpoint + status, so the
# operator sees WHICH identity/endpoint bound (not a bare boolean).
if args.bifrost is not None:
plane = args.bifrost_plane or "direct"
sys.stderr.write(
f". bifrost: status=bound plane={plane} "
f"endpoint={args.bifrost.endpoint_url}\n"
)
session_id = info.session_id
else:
assert args.session_id is not None
+71 -3
View File
@@ -20,6 +20,8 @@ from typing import Any
from bifrost.affect import AffectIdempotencyConflict, AffectInvalidArguments
from bifrost.consumer import ConsumerRegistration, build_affect_app
from bifrost.reference_server import JwtVerifier
from starlette.requests import Request
from starlette.responses import JSONResponse
_SHORT_RETRY_TTL_SECONDS = 300
@@ -100,17 +102,50 @@ 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."""
conn = sqlite3.connect(db_path)
# check_same_thread=False: the affect provider is an ASGI app; Starlette/uvicorn
# may run a handler off the connection's creating thread (and TestClient always
# does). Access stays serialized by the event loop, so this is safe.
conn = sqlite3.connect(db_path, check_same_thread=False)
# INV-006: state busy_timeout explicitly rather than lean on sqlite3's timeout=5.0
# default — a contended write WAITS up to 5s instead of failing SQLITE_BUSY at once
# (prep for the composite/standalone two-process topology).
conn.execute("PRAGMA busy_timeout=5000")
if db_path != ":memory:":
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
@@ -127,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,
@@ -143,4 +205,10 @@ def build_affect_provider_app(
raise ValueError("heimdall_key must be non-empty bytes")
verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
registration = ConsumerRegistration(consumer_id=consumer_id)
return build_affect_app(store=store, verifier=verifier, registration=registration)
app = build_affect_app(store=store, verifier=verifier, registration=registration)
# 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(
+279
View File
@@ -0,0 +1,279 @@
"""Dispatch-layer observe feed for the Bifrost provider (issue #17, Observe half).
`instrument_provider_app` wraps a built provider ASGI app so every inbound
bifrost-call emits one structured `OpEvent` correlated by `session_id` read off
the dispatch JWT WITHOUT touching the store's scope semantics (INV-004). It is
the lens that lets ratatoskr, owning BOTH ends of the round-trip, see exactly
which memory/affect ops a given turn produced.
The store-method stdout shim in `memory_store.py` cannot see `session_id` for
search/get/delete (bifrost withholds `ctx` from those store methods); this feed
sits at the DISPATCH/ASGI layer where the JWT and thus `session_id` (its `sub`
claim) is always present (INV-005).
"""
from __future__ import annotations
import base64
import json
import sys
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from typing import Any, Protocol
@dataclass(frozen=True)
class OpEvent:
"""One observed bifrost-call at the dispatch layer (scope-only, never content)."""
ts: str # ISO 8601 UTC, capture time
plane: str # "memory" | "affect"
op: str # verb: search / upsert_many / get / get_many / delete_many / emit / handshake
session_id: str | None # JWT `sub` at the dispatch layer; None only if the JWT omits it
status: str # "ok" | "error"
req_summary: dict[str, Any] # per-verb, scope-only — no record bodies
resp_summary: dict[str, Any] # per-verb counts + ids/scores — never verbatim content
turn_id: str | None = None # INV-005 reservation, literal; unused in v1
class OpSink(Protocol):
"""Anything that accepts an OpEvent. v1 ships JsonlOpSink; tests pass fakes."""
def emit(self, event: OpEvent) -> None: ...
class JsonlOpSink:
"""Continuous append-only JSONL sink (INV-007: NOT per-session). One JSON line
per OpEvent to a text stream (default stdout)."""
def __init__(self, stream: Any = None) -> None:
self._stream = sys.stdout if stream is None else stream
def emit(self, event: OpEvent) -> None:
self._stream.write(json.dumps(asdict(event), separators=(",", ":")) + "\n")
self._stream.flush()
def maybe_instrument_from_env(app: Any, env: Any, *, plane: str) -> Any:
"""Opt-in serve wiring: when `RATATOSKR_OPFEED_PATH` is set, wrap `app` with
the dispatch-layer op-feed writing JSONL to that path; otherwise return `app`
unchanged. The append stream lives for the process (a long-running server)."""
path = env.get("RATATOSKR_OPFEED_PATH")
if not path:
return app
stream = open(path, "a", encoding="utf-8") # process-lifetime append stream
return instrument_provider_app(app, plane=plane, sink=JsonlOpSink(stream))
_BIFROST_PATHS = (
"/bifrost/handshake",
"/bifrost/memory-call",
"/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))
def _session_id_from_auth(auth: bytes | None) -> str | None:
"""Read the `sub` claim (= session_id, per bifrost DispatchContext) off the
dispatch JWT WITHOUT verifying its signature the inner app does real
verification; we only read a claim for correlation. None if absent/malformed."""
if not auth:
return None
try:
token = auth.decode("latin-1").strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
parts = token.split(".")
if len(parts) != 3:
return None
payload = json.loads(_b64url_decode(parts[1]))
sub = payload.get("sub")
return sub if isinstance(sub, str) else None
except Exception:
return None
def _op_from(path: str, req: dict[str, Any]) -> str:
"""The verb: 'handshake' for the handshake path; otherwise the body's
`operation`, with the affect-plane `affect.` prefix stripped (affect.emit ->
emit) so op vocabulary stays bare per the contract."""
if path == "/bifrost/handshake":
return "handshake"
operation = req.get("operation") or "unknown"
if path == "/bifrost/affect-call" and operation.startswith("affect."):
return operation.split(".", 1)[1]
return operation
def _ids_summary(args: dict[str, Any]) -> list[Any]:
"""Mirror bifrost `_ids_arg`: ids | chunk_ids | [chunk_id|id]."""
ids = args.get("ids") or args.get("chunk_ids")
if ids is None:
single = args.get("chunk_id") or args.get("id")
ids = [single] if single is not None else []
return ids
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":
# 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 {}
if op == "search":
return {
"scope_all": args.get("scope_all") or {},
"scope_any": args.get("scope_any") or [],
"top_k": args.get("top_k"),
}
if op == "upsert_many":
records = args.get("records") or []
return {
"record_count": len(records),
"scopes": [r.get("scope") for r in records],
}
if op in ("get", "get_many", "delete_many"):
return {"ids": _ids_summary(args)}
return {}
def _resp_summary(
plane: str, path: str, op: str, resp: dict[str, Any], status: str
) -> dict[str, Any]:
"""Per-verb counts + ids/scores — never verbatim content. On error, the
bifrost error `code` (INV-007: failures recorded, not hidden)."""
if status == "error":
return {"error": resp.get("code")}
if path == "/bifrost/handshake":
return {"ok": True, "caps_granted": resp.get("capabilities_granted")}
if plane == "affect":
return {"stored": bool(resp.get("stored"))}
if op == "search":
results = resp.get("results") or []
return {
"hit_count": len(results),
"hits": [
{"chunk_id": r.get("chunk_id"), "score": r.get("score")}
for r in results
],
}
if op == "upsert_many":
return {"upserted": resp.get("upserted"), "replayed": resp.get("replayed")}
if op == "get":
return {"found_count": 1 if resp.get("record") else 0}
if op == "get_many":
return {"found_count": len(resp.get("records") or [])}
if op == "delete_many":
return {"deleted": resp.get("deleted")}
return {}
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"
req = _safe_json(req_body)
resp = _safe_json(captured["body"])
op = _op_from(path, req)
return OpEvent(
ts=datetime.now(UTC).isoformat(),
plane=plane,
op=op,
session_id=session_id,
status=status,
req_summary=_req_summary(plane, path, op, req),
resp_summary=_resp_summary(plane, path, op, resp, status),
)
def _safe_json(raw: bytes) -> dict[str, Any]:
if not raw:
return {}
try:
value = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return {}
return value if isinstance(value, dict) else {}
def instrument_provider_app(app: Any, *, plane: str, sink: OpSink) -> Any:
"""Wrap a built provider ASGI `app` so each inbound bifrost-call emits one
OpEvent to `sink`. Read-only over dispatch store scope semantics untouched
(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", "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:
await app(scope, receive, send)
return
# Buffer the request body so we can BOTH summarise it AND replay it to the
# inner app (the inner app consumes `receive`; we must not steal the body).
req_body = b""
more = True
while more:
message = await receive()
if message["type"] == "http.request":
req_body += message.get("body", b"")
more = message.get("more_body", False)
else: # http.disconnect
more = False
replayed = False
async def replay_receive() -> dict[str, Any]:
nonlocal replayed
if not replayed:
replayed = True
return {"type": "http.request", "body": req_body, "more_body": False}
return {"type": "http.disconnect"}
captured: dict[str, Any] = {"status": 500, "body": b""}
async def capture_send(message: dict[str, Any]) -> None:
if message["type"] == "http.response.start":
captured["status"] = message["status"]
elif message["type"] == "http.response.body":
captured["body"] += message.get("body", b"")
await send(message)
await app(scope, replay_receive, capture_send)
try:
sink.emit(_build_event(plane, scope["path"], scope, req_body, captured))
except Exception as exc: # observe gap, never a serve break (POST-003)
sys.stderr.write(f"[opfeed] OpEvent emit failed (swallowed): {exc!r}\n")
return wrapped
+4 -1
View File
@@ -12,6 +12,7 @@ import os
from collections.abc import Mapping
from ratatoskr.provider.affect_store import build_affect_provider_app, open_affect_store
from ratatoskr.provider.opfeed import maybe_instrument_from_env
def build_app_from_env(env: Mapping[str, str] | None = None):
@@ -23,11 +24,13 @@ def build_app_from_env(env: Mapping[str, str] | None = None):
"RATATOSKR_HEIMDALL_KEY is required to serve the affect provider"
)
store = open_affect_store(env.get("RATATOSKR_AFFECT_DB", "affect.db"))
return build_affect_provider_app(
app = build_affect_provider_app(
store,
heimdall_key=key.encode(),
consumer_id=env.get("RATATOSKR_CONSUMER_ID", "ratatoskr"),
)
# Issue #17 (Observe): opt-in dispatch-layer op-feed when RATATOSKR_OPFEED_PATH set.
return maybe_instrument_from_env(app, env, plane="affect")
def main() -> None:
+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")),
)
+4 -1
View File
@@ -17,6 +17,7 @@ import os
from collections.abc import Mapping
from ratatoskr.provider.memory_store import build_memory_provider_app, open_memory_store
from ratatoskr.provider.opfeed import maybe_instrument_from_env
def build_memory_app_from_env(env: Mapping[str, str] | None = None):
@@ -43,11 +44,13 @@ def build_memory_app_from_env(env: Mapping[str, str] | None = None):
store = open_memory_store(
env.get("RATATOSKR_MEMORY_DB", "memory.db"), embedding_dim=embedding_dim
)
return build_memory_provider_app(
app = build_memory_provider_app(
store,
heimdall_key=key.encode(),
consumer_id=env.get("RATATOSKR_CONSUMER_ID", "ratatoskr"),
)
# Issue #17 (Observe): opt-in dispatch-layer op-feed when RATATOSKR_OPFEED_PATH set.
return maybe_instrument_from_env(app, env, plane="memory")
def main() -> None:
+115 -2
View File
@@ -59,6 +59,20 @@ class AgentInfo:
ui_hints: dict[str, Any]
@dataclass(frozen=True)
class BifrostBinding:
"""Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160).
Issue #17. `endpoint_url` is the WORLDTREE-VISIBLE base URL of ONE provider
plane (memory :8391 / affect :8390). `scope` is an opaque pass-through copied
into the handshake JWT unchanged (256 chars); ratatoskr does not interpret
it and v1 sends None.
"""
endpoint_url: str
scope: str | None = None
class AgentNotFound(Exception):
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
@@ -89,6 +103,40 @@ class SessionApiFailed(Exception):
self.body = body
# Issue #17 — Bifrost-bind failure modes on POST /sessions.
class BifrostConsumerKeyMissing(Exception):
"""A bifrost binding was requested without a consumer_key.
Raised BEFORE any HTTP (INV-001): the bound create must never silently fall
back to the client's default canary key — the consumer key IS the handshake
identity Worldtree signs the Bifrost JWT with.
"""
def __init__(self) -> None:
super().__init__(
"bifrost binding requires a non-empty consumer_key; refusing to "
"fall back to the canary key (INV-001)"
)
class BifrostHandshakeFailed(Exception):
"""Raised on HTTP 502 `bifrost_handshake_failed` from a bound POST /sessions.
INV-002: the Bifrost handshake runs synchronously at session-create, so a
handshake failure (bad URL / down provider / wrong key / HTTPS rejection)
fails SESSION CREATION surfaced on the create path, never deferred to the
first turn. `bifrost_error` carries the spec-level code (e.g.
`bifrost.auth_rejected`); `body` is truncated to 1024 bytes, consistent with
`SessionApiFailed` (INV-004 precedent).
"""
def __init__(self, *, bifrost_error: str | None, body: bytes) -> None:
body = body[:1024]
super().__init__(f"bifrost handshake failed: bifrost_error={bifrost_error!r}")
self.bifrost_error = bifrost_error
self.body = body
# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes.
class PersonaNotConfigured(Exception):
"""Raised on HTTP 404 `persona_not_configured` from GET persona_state.
@@ -176,11 +224,51 @@ async def list_sessions(
return SessionPage(items=items, next_cursor=body.get("next_cursor"))
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.
"""
if plane not in ("memory", "affect"):
raise ValueError(
f"unknown plane: {plane!r} (expected 'memory' or 'affect')"
)
port = 8391 if plane == "memory" else 8390
return f"http://{base_host}:{port}"
def _bifrost_error_from(resp: httpx.Response) -> str | None:
"""Pull the spec-level `bifrost_error` from a 502 body.
Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": }}` shape
(the spec's documented form, §"Optional Bifrost binding") and a flat
top-level `bifrost_error`, per the both-shape unwrap precedent established for
persona_state errors (the real wire returns the detail-nested form).
"""
try:
err = resp.json()
except ValueError:
return None
if not isinstance(err, dict):
return None
bifrost_error = err.get("bifrost_error")
if bifrost_error is None and isinstance(err.get("detail"), dict):
bifrost_error = err["detail"].get("bifrost_error")
return bifrost_error
async def create_session(
client: httpx.AsyncClient,
agent_id: str,
*,
end_user_id: str | None = None,
bifrost: BifrostBinding | None = None,
consumer_key: str | None = None,
) -> SessionInfo:
"""POST /sessions to create a new session. See contract FN create_session.
@@ -188,17 +276,42 @@ async def create_session(
When None (default), the body shape matches the pre-#5 baseline
`{"agent_id": agent_id}` so existing callers (mimir smoke) are unaffected.
Empty-string `end_user_id` is rejected before HTTP (PRE-003).
Per issue #17: when `bifrost` is set the request carries the binding and
authenticates with `consumer_key` (NOT the client's default canary bearer);
Worldtree handshakes synchronously to our provider before 201.
"""
assert client is not None
assert agent_id and isinstance(agent_id, str)
assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id)
body: dict[str, str] = {"agent_id": agent_id}
# PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key,
# enforced before any HTTP so a bound create never falls back to the canary.
if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key):
raise BifrostConsumerKeyMissing()
body: dict[str, Any] = {"agent_id": agent_id}
if end_user_id is not None:
body["end_user_id"] = end_user_id
resp = await client.post("/sessions", json=body)
headers: dict[str, str] = {}
if bifrost is not None:
body["bifrost"] = {
"endpoint_url": bifrost.endpoint_url,
"scope": bifrost.scope,
}
# INV-001: the bound create authenticates with the consumer key,
# overriding the httpx client's default canary bearer per-request.
headers["Authorization"] = f"Bearer {consumer_key}"
resp = await client.post("/sessions", json=body, headers=headers)
if resp.status_code == 404:
raise AgentNotFound(agent_id=agent_id)
# POST-002 (INV-002): a 502 on a BOUND create is the synchronous Bifrost
# handshake failing. Gated on `bifrost is not None` — an unbound create's
# 502 is a generic upstream fault and stays SessionApiFailed.
if bifrost is not None and resp.status_code == 502:
raise BifrostHandshakeFailed(
bifrost_error=_bifrost_error_from(resp), body=resp.content
)
if resp.status_code != 201:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
+32 -1
View File
@@ -38,6 +38,8 @@ from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
AuthScopeDenied,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
create_session,
@@ -1504,11 +1506,33 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
assert chosen_agent_id is not None
try:
info = await create_session(
client, chosen_agent_id, end_user_id=args.end_user_id
client,
chosen_agent_id,
end_user_id=args.end_user_id,
bifrost=args.bifrost,
consumer_key=args.consumer_key,
)
except AgentNotFound as exc:
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
return 12
except BifrostConsumerKeyMissing as exc:
# INV-001/INV-002: bind failures land on real stderr BEFORE the
# alt-screen opens (mirrors cli._amain exit codes / vocab, INV-006).
sys.stderr.write(
f"[bifrost_consumer_key_missing] {exc} "
f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n"
)
return 22
except BifrostHandshakeFailed as exc:
sys.stderr.write(
f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n"
)
if exc.bifrost_error == "bifrost.auth_rejected":
sys.stderr.write(
" bound create requires the consumer key "
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
)
return 23
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
@@ -1517,6 +1541,13 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
# Issue #17 bound-state indicator (pre-alt-screen, mirrors cli._amain).
if args.bifrost is not None:
plane = args.bifrost_plane or "direct"
sys.stderr.write(
f". bifrost: status=bound plane={plane} "
f"endpoint={args.bifrost.endpoint_url}\n"
)
session_id = info.session_id
agent_id: str | None = info.agent_id
else:
+16 -1
View File
@@ -59,6 +59,15 @@ def main(argv: list[str] | None = None) -> int:
return 11
server_url = os.environ.get("WORLDTREE_API_URL", "http://localhost:8000")
end_user_id = os.environ.get("RATATOSKR_END_USER_ID")
# Issue #17 (web bind split): server-held Bifrost binding config. The browser
# selects the plane; the consumer key + visible host live server-side only.
bifrost_consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY")
bifrost_visible_host = os.environ.get("RATATOSKR_PROVIDER_VISIBLE_HOST")
# Issue #18 (Deliverable 2): the affect provider's read base URL (server→provider
# hop on the same dev box) so the persona pane can render PAD/valence from OUR store.
affect_read_url = os.environ.get(
"RATATOSKR_AFFECT_READ_URL", "http://127.0.0.1:8390"
)
# INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
@@ -93,7 +102,13 @@ def main(argv: list[str] | None = None) -> int:
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
)
app = create_app(client_factory, end_user_id=end_user_id)
app = create_app(
client_factory,
end_user_id=end_user_id,
bifrost_consumer_key=bifrost_consumer_key,
bifrost_visible_host=bifrost_visible_host,
affect_read_url=affect_read_url,
)
# Boot banner to stderr (so stdout stays clean for piping).
version = _pkg_version("ratatoskr")
+89 -2
View File
@@ -27,9 +27,13 @@ from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
create_session,
endpoint_for_plane,
get_persona_state,
list_agents,
)
@@ -125,17 +129,65 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"error_code": "missing_agent_id"}, status_code=400)
end_user_id = request.app.state.end_user_id
client_factory = request.app.state.client_factory
# Issue #17 (web bind split): the browser may select a PLANE; the server holds
# the consumer key + visible host and constructs the binding. The consumer key
# NEVER reaches the browser (INV-008/INV-009).
bifrost: BifrostBinding | None = None
bifrost_plane = body.get("bifrost_plane") if isinstance(body, dict) else None
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"):
return JSONResponse(
{"error_code": "invalid_bifrost_plane"}, status_code=400
)
if not (consumer_key and visible_host):
return JSONResponse(
{"error_code": "bifrost_not_configured"}, status_code=400
)
bifrost = BifrostBinding(
endpoint_url=endpoint_for_plane(bifrost_plane, visible_host)
)
try:
async with client_factory() as client:
info = await create_session(client, agent_id, end_user_id=end_user_id)
info = await create_session(
client,
agent_id,
end_user_id=end_user_id,
bifrost=bifrost,
consumer_key=consumer_key if bifrost else None,
)
except AgentNotFound:
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
except BifrostConsumerKeyMissing:
# Server misconfiguration: a plane was requested but no consumer key.
return JSONResponse(
{"error_code": "bifrost_not_configured"}, status_code=400
)
except BifrostHandshakeFailed as exc:
return JSONResponse(
{
"error_code": "bifrost_handshake_failed",
"bifrost_error": exc.bifrost_error,
},
status_code=502,
)
except SessionApiFailed as exc:
return JSONResponse(
{"error_code": "session_api_failed", "status": exc.status},
status_code=exc.status,
)
return JSONResponse(_as_dict(info), status_code=201)
payload = _as_dict(info)
if bifrost is not None:
# Bound-state for the UI indicator — plane + endpoint only, never the key.
payload["bifrost"] = {
"plane": bifrost_plane,
"endpoint": bifrost.endpoint_url,
"status": "bound",
}
return JSONResponse(payload, status_code=201)
@dataclass
@@ -341,10 +393,36 @@ async def _persona_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(snap, status_code=200)
async def _affect_state_endpoint(request: Request) -> JSONResponse:
"""GET /api/affect/{agent_id} → proxy the provider PAD read route. Supplies
end_user_id SERVER-SIDE (never the browser, INV-002); proxies to the configured
affect-read URL, re-encoding agent_id into the path (colon-id safe, INV-008).
Per FN affect_state_endpoint (#18 Deliverable 2)."""
from urllib.parse import quote
agent_id = request.path_params["agent_id"]
affect_read_url = request.app.state.affect_read_url
end_user_id = request.app.state.end_user_id
if not (affect_read_url and end_user_id): # PRE-001: fail-visible, never silent
return JSONResponse({"error_code": "affect_not_configured"}, status_code=400)
url = f"{affect_read_url}/affect/state/{quote(agent_id, safe='')}"
try:
async with httpx.AsyncClient() as client:
r = await client.get(url, params={"end_user_id": end_user_id})
except httpx.RequestError:
return JSONResponse(
{"error_code": "affect_provider_unreachable"}, status_code=502
)
return JSONResponse(r.json(), status_code=r.status_code)
def create_app(
client_factory: Callable[[], httpx.AsyncClient],
*,
end_user_id: str | None = None,
bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None,
affect_read_url: str | None = None,
) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app.
@@ -404,6 +482,7 @@ def create_app(
Route("/api/agents", _agents_endpoint),
Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint),
Route("/api/affect/{agent_id}", _affect_state_endpoint),
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
@@ -411,6 +490,14 @@ def create_app(
app = Starlette(routes=routes, lifespan=lifespan)
app.state.client_factory = client_factory
app.state.end_user_id = end_user_id
# Issue #17 (web bind split): the consumer key + Worldtree-visible provider
# host are SERVER-HELD config (env), never sent from the browser. The browser
# selects only the PLANE; the server constructs the bound session (INV-008).
app.state.bifrost_consumer_key = bifrost_consumer_key
app.state.bifrost_visible_host = bifrost_visible_host
# Issue #18 (Deliverable 2): the provider affect-read base URL (server→provider hop,
# same dev box) — distinct from the WT-visible host used for binding.
app.state.affect_read_url = affect_read_url
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {}
return app
+108 -5
View File
@@ -510,6 +510,14 @@ body {
<div class="select-wrap">
<select id="agent-picker"><option>loading…</option></select>
</div>
<label class="field-label" for="bifrost-plane">Bifrost binding (Tier-3 provider)</label>
<div class="select-wrap">
<select id="bifrost-plane">
<option value="">none — observe only</option>
<option value="memory">memory (:8391) — durable recall</option>
<option value="affect">affect (:8390) — persona / PAD</option>
</select>
</div>
<button id="start-btn">open session</button>
<div class="setup-err" id="setup-err"></div>
</div>
@@ -671,8 +679,12 @@ function setPersonaStrip(snap) {
bars += `<div class="pad"><span class="k">${k}</span>`
+ `<span class="track"><span class="fill" style="left:${left}%;width:${width}%"></span></span></div>`;
}
// INV-001 (honest shape): only show a dominant_emotion when one is actually present
// (Tier-1 persona_state). Affect snapshots have none — show agent + PAD bars, never a
// fabricated "neutral" label.
const emo = snap.dominant_emotion ? ` · <b>${esc(snap.dominant_emotion)}</b>` : "";
strip.innerHTML =
`<span class="emo">${esc(snap.agent_id || "?")} · <b>${esc(snap.dominant_emotion || "neutral")}</b></span>`
`<span class="emo">${esc(snap.agent_id || "?")}${emo}</span>`
+ `<span class="pad-bars">${bars}</span>`;
strip.classList.add("show");
}
@@ -697,6 +709,10 @@ function renderPersonaPane(snap) {
}
async function loadPersona(agentId) {
// Tier-3 (colon-id) agents have no Worldtree persona_state (ADR-0009, Tier-1-only)
// and emit no affect SSE — render live PAD/valence from OUR affect store instead
// (issue #18 Deliverable 2).
if (agentId.includes(":")) { return loadAffect(agentId); }
try {
const r = await fetch("/api/agents/" + encodeURIComponent(agentId) + "/persona_state");
if (r.status === 200) {
@@ -704,13 +720,79 @@ async function loadPersona(agentId) {
renderPersonaPane(snap);
setPersonaStrip(snap);
} else {
$("pane-persona").innerHTML = `<div class="empty">persona not available (HTTP ${esc(r.status)})</div>`;
let code = "";
try { code = (await r.json()).error_code || ""; } catch (_) {}
let msg;
if (r.status === 404 && code === "persona_not_configured" && agentId.includes(":")) {
msg = "persona telemetry isn't exposed for Tier-3 (consumer-defined) agents on this Worldtree yet — " +
"the agent still responds in character; only this affect / OCEAN readout is gated.";
} else if (r.status === 404 && code === "persona_not_configured") {
msg = "this agent has no persona configured.";
} else if (r.status === 403) {
msg = "persona telemetry requires the persona.read scope.";
} else {
msg = `persona unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
}
$("pane-persona").innerHTML = `<div class="empty">${msg}</div>`;
}
} catch (e) {
$("pane-persona").innerHTML = `<div class="empty">persona fetch failed</div>`;
}
}
// Issue #18 (Deliverable 2): render the affect-emit snapshot from OUR store. HONEST
// shape only — pad + per-entity valence + emitted_at; NO fabricated Tier-1 persona
// fields (dominant_emotion / mood_drift), which Tier-3 structurally lacks (INV-001).
// Labelled "affect", not "persona" (INV-005).
function renderAffectPane(snap) {
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
const head = (t) => `<div class="ph">${esc(t)}</div>`;
const all = snap.valence || [];
const shown = all.slice(0, 8); // bounded render — valence[] is unbounded in principle
const valRows = shown.map((v) =>
row(v.entity_id || "?",
`familiarity ${JSON.stringify(v.familiarity)} · regard ${JSON.stringify(v.regard)}`
+ ` · n=${JSON.stringify(v.interaction_count)}`)
).join("");
$("pane-persona").innerHTML =
head("affect snapshot · " + (snap.agent_id || "?")) +
`<div> </div>` + head("pad") +
row("pleasure", JSON.stringify(snap.pad?.pleasure)) +
row("arousal", JSON.stringify(snap.pad?.arousal)) +
row("dominance", JSON.stringify(snap.pad?.dominance)) +
`<div> </div>` + head("valence (" + all.length + ")") +
(valRows || `<div class="empty">none</div>`) +
`<div> </div>` + row("emitted_at", snap.emitted_at || "?");
}
async function loadAffect(agentId) {
try {
const r = await fetch("/api/affect/" + encodeURIComponent(agentId));
if (r.status === 200) {
const snap = await r.json();
renderAffectPane(snap);
setPersonaStrip(snap); // pad bars are the live signal
} else {
let code = "";
try { code = (await r.json()).error_code || ""; } catch (_) {}
let msg;
if (r.status === 404 && code === "no_affect_snapshot") {
msg = "no affect emitted yet for this agent / user — take a turn; Tier-3 affect " +
"lands in our store a few seconds after the turn ends.";
} else if (code === "affect_not_configured") {
msg = "affect telemetry not configured (RATATOSKR_AFFECT_READ_URL + RATATOSKR_END_USER_ID).";
} else if (r.status === 502 && code === "affect_provider_unreachable") {
msg = "affect provider unreachable (is the :8390 provider up?).";
} else {
msg = `affect unavailable (HTTP ${esc(r.status)}${code ? " · " + esc(code) : ""}).`;
}
$("pane-persona").innerHTML = `<div class="empty">${msg}</div>`;
}
} catch (e) {
$("pane-persona").innerHTML = `<div class="empty">affect fetch failed</div>`;
}
}
// ---- session lifecycle ----
async function startSession() {
const agentId = $("agent-picker").value;
@@ -721,19 +803,35 @@ async function startSession() {
try {
// end_user_id is server-configured (RATATOSKR_END_USER_ID) — not sent
// from the browser; the server ignores any end_user_id in this body.
// Issue #17: the browser selects only the PLANE; the consumer key + host
// are server-held (the key never reaches the browser).
const plane = $("bifrost-plane").value;
const reqBody = { agent_id: agentId };
if (plane) reqBody.bifrost_plane = plane;
const r = await fetch("/api/sessions", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: agentId }),
body: JSON.stringify(reqBody),
});
if (r.status !== 201) {
$("setup-err").textContent = "create session failed: HTTP " + r.status;
let detail = "HTTP " + r.status;
try {
const err = await r.json();
if (err && err.error_code) {
detail = err.error_code + (err.bifrost_error ? " (" + err.bifrost_error + ")" : "");
}
} catch (_) { /* non-JSON body */ }
$("setup-err").textContent = "create session failed: " + detail;
$("start-btn").disabled = false;
return;
}
const info = await r.json();
state.sessionId = info.session_id;
// Issue #17 bound-state indicator: plane + endpoint (never the key).
const boundTag = info.bifrost
? ` · <span class="a">⇄ ${esc(info.bifrost.plane)}</span> ${esc(info.bifrost.endpoint)}`
: "";
$("identity").innerHTML =
`<span class="a">${esc(agentId)}</span> · …${esc(info.session_id.slice(-8))}`;
`<span class="a">${esc(agentId)}</span> · …${esc(info.session_id.slice(-8))}${boundTag}`;
setConn("idle", "connected");
$("setup").style.display = "none";
$("workspace").classList.add("live");
@@ -906,6 +1004,11 @@ async function submitPrompt() {
state.eventSource = null; state.turnId = null;
$("composer").classList.remove("streaming");
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected");
if (cls === "done" && state.agentId) {
// Tier-3 affect.emit is POST-TURN ASYNC — it lands in our store a couple seconds
// after [done]. Refresh the pane on a short delay to catch the new PAD (issue #18).
setTimeout(() => loadPersona(state.agentId), 2000);
}
$("prompt-input").focus();
}
es.addEventListener("done", (e) => terminal("done", "done", e));
+134 -13
View File
@@ -19,6 +19,7 @@ from ratatoskr.cli import (
_run_turn,
main,
)
from ratatoskr.sessions import BifrostBinding
from ratatoskr.sse_client import (
Cancelled,
Done,
@@ -86,6 +87,8 @@ def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
monkeypatch.delenv("WORLDTREE_API_URL", raising=False)
monkeypatch.delenv("RATATOSKR_END_USER_ID", raising=False)
monkeypatch.delenv("RATATOSKR_BIFROST_CONSUMER_KEY", raising=False)
monkeypatch.delenv("RATATOSKR_PROVIDER_VISIBLE_HOST", raising=False)
class TestParseArgs:
@@ -1290,25 +1293,16 @@ class TestAmain:
def test_no_textual_import(self) -> None:
"""no_textual_import [scenario]: …"""
import importlib
import sys
# Clear any prior textual import to make this test honest in isolation
textual_was_imported = "textual" in sys.modules
# We cannot reliably remove textual mid-suite (other tests might rely on it via dev deps),
# so the assertion is: importing ratatoskr.cli does not REQUIRE textual.
importlib.reload(__import__("ratatoskr.cli", fromlist=["_amain"]))
# The boundary is the INV-001 import-only rule. If ratatoskr/cli.py grew an
# `import textual` directly, the import would still succeed (textual is installed)
# but the source-level boundary is the load-bearing check — covered by a static-grep
# smoke test pattern. Do that here:
# INV-001 import-only boundary: cli.py must not import textual/rich at the
# source level. The load-bearing check is a static source grep (NOT a live
# `importlib.reload`, which would mutate the shared module in place and break
# class identity — isinstance / pytest.raises — for every later test).
import pathlib
src = pathlib.Path(__file__).parent.parent / "src" / "ratatoskr" / "cli.py"
text = src.read_text()
for forbidden in ("import textual", "from textual", "import rich", "from rich"):
assert forbidden not in text, f"INV-001 violation: cli.py contains '{forbidden}'"
_ = textual_was_imported # avoid unused warning
class TestMain:
@@ -1419,3 +1413,130 @@ class TestMain:
assert tui_calls[0].send_content is None
assert tui_calls[0].session_id == "s-1"
assert amain_calls == []
class TestBifrostBindCli:
"""Issue #17 slice 3a — the CLI Bifrost-bind trigger (INV-008, one of three)."""
def test_plane_and_host_build_binding(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""tracer: --bifrost-plane + --bifrost-host resolve a BifrostBinding via
endpoint_for_plane; the consumer key comes from the env."""
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
args = _parse_args(
[
"--send", "hi", "--new", "--agent", "ratatoskr:sindra", "--api-key", "k",
"--bifrost-plane", "memory", "--bifrost-host", "10.100.10.50",
]
)
assert args.bifrost == BifrostBinding(endpoint_url="http://10.100.10.50:8391")
assert args.bifrost_plane == "memory"
assert args.consumer_key == "ck"
def test_host_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""--bifrost-host falls back to RATATOSKR_PROVIDER_VISIBLE_HOST."""
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
monkeypatch.setenv("RATATOSKR_PROVIDER_VISIBLE_HOST", "10.0.0.9")
args = _parse_args(
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
"--bifrost-plane", "affect"]
)
assert args.bifrost == BifrostBinding(endpoint_url="http://10.0.0.9:8390")
def test_direct_url_bypasses_plane(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""--bifrost-url is the direct (HTTPS/prod) endpoint, bypassing the plane
shortcut; no plane label."""
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
args = _parse_args(
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
"--bifrost-url", "https://prov.example:8391"]
)
assert args.bifrost == BifrostBinding(endpoint_url="https://prov.example:8391")
assert args.bifrost_plane is None
def test_no_bifrost_flags_leaves_binding_none(self) -> None:
"""regression: no bifrost flags → bifrost/consumer_key None (pre-#17 path)."""
args = _parse_args(
["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"]
)
assert args.bifrost is None
assert args.consumer_key is None
def test_plane_and_url_mutually_exclusive(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
with pytest.raises(UsageError):
_parse_args(
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
"--bifrost-plane", "memory", "--bifrost-host", "h",
"--bifrost-url", "https://x:8391"]
)
def test_plane_without_host_is_usage_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
with pytest.raises(UsageError):
_parse_args(
["--send", "hi", "--new", "--agent", "a", "--api-key", "k",
"--bifrost-plane", "memory"]
)
def test_bind_with_existing_session_is_usage_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A binding is a session-CREATE concern; --session (existing) + bind is
a usage error."""
monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck")
with pytest.raises(UsageError):
_parse_args(
["--send", "hi", "--session", "s-1", "--api-key", "k",
"--bifrost-plane", "memory", "--bifrost-host", "h"]
)
@respx.mock
async def test_amain_bound_create_carries_binding_and_routes_502(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""_amain on a bound create sends the bifrost body + the consumer-key
bearer; a 502 auth_rejected routes to BifrostHandshakeFailed with the
consumer-key-mismatch hint (INV-001/002, 401-message scoping)."""
route = respx.post("http://w/sessions").mock(
return_value=httpx.Response(
502,
json={
"error_code": "bifrost_handshake_failed",
"detail": {"bifrost_error": "bifrost.auth_rejected"},
},
)
)
args = ParsedArgs(
send_content="hi", session_id=None, new=True, agent_id="ratatoskr:sindra",
api_key="canary", server_url="http://w", raw=False, end_user_id="smoke-user",
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
bifrost_plane="memory", consumer_key="ck",
)
rc = await _amain(args)
assert rc == 23
body = json.loads(route.calls[0].request.content)
assert body["bifrost"] == {
"endpoint_url": "http://10.100.10.50:8391", "scope": None
}
assert route.calls[0].request.headers["Authorization"] == "Bearer ck"
err = capsys.readouterr().err
assert "bifrost.auth_rejected" in err
assert "consumer key" in err # the 401-scoping hint
async def test_amain_bind_without_consumer_key_exits(self) -> None:
"""_amain on a bind with no consumer key raises BifrostConsumerKeyMissing
(before HTTP) a clean exit code, never a canary fallback."""
args = ParsedArgs(
send_content="hi", session_id=None, new=True, agent_id="a",
api_key="canary", server_url="http://w", raw=False, end_user_id=None,
bifrost=BifrostBinding(endpoint_url="http://x:8391"),
bifrost_plane="memory", consumer_key=None,
)
rc = await _amain(args)
assert rc == 22
+141
View File
@@ -46,6 +46,14 @@ def test_open_advertises_capability_and_schema():
store._conn.execute("SELECT * FROM affect_idempotency")
def test_open_sets_busy_timeout(tmp_path):
"""INV-006: every connection sets busy_timeout>=5000ms (WAL's default is 0, so a
contended write would fail SQLITE_BUSY immediately) prep for the two-process
composite/standalone topology."""
store = open_affect_store(str(tmp_path / "affect.db"))
assert store._conn.execute("PRAGMA busy_timeout").fetchone()[0] == 5000
def test_reopen_existing_file_is_idempotent(tmp_path):
db = str(tmp_path / "affect.db")
open_affect_store(db) # first open creates schema
@@ -154,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():
@@ -163,6 +197,11 @@ def test_build_app_exposes_handshake_and_affect_routes():
assert "/bifrost/handshake" in routes
assert "/bifrost/affect-call" in routes
assert "POST" in routes["/bifrost/affect-call"].methods # POST-001: the verb, not just the path
# POST-002: bifrost routes remain REACHABLE (not merely registered) after the read
# route is composed in via add_route — drive one without a JWT → routed (auth-
# rejected), never 404.
r = TestClient(app).post("/bifrost/affect-call", json={"operation": "affect.emit"})
assert r.status_code != 404
def test_build_app_rejects_non_advertising_store():
@@ -228,3 +267,105 @@ async def test_parity_vs_reference_store_through_dispatch():
assert await dispatch_affect_call(_env(other), ctx, ref) == await dispatch_affect_call(
_env(other), ctx, mine
)
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.
import json as _json
from starlette.testclient import TestClient
def _affect_snapshot(agent: str = "ratatoskr:sindra", user: str = "vuong") -> dict:
# The real affect.emit shape (verified live): pad + per-entity valence + emitted_at.
return {
"agent_id": agent,
"end_user_id": user,
"pad": {"pleasure": 0.1459, "arousal": 0.0796, "dominance": -0.0071},
"valence": [
{
"entity_id": "ratatoskr",
"entity_type": "human",
"familiarity": 0.5886,
"interaction_count": 8,
"regard": 0.15,
}
],
"emitted_at": "2026-06-18T15:58:12+00:00",
}
def _seed(store, snap: dict) -> None:
blob = _json.dumps(snap, sort_keys=True, separators=(",", ":"))
store._conn.execute(
"INSERT INTO affect_snapshots (agent_id, end_user_id, snapshot_json, arrived_at) "
"VALUES (?, ?, ?, ?)",
(snap["agent_id"], snap["end_user_id"], blob, "0"),
)
store._conn.commit()
def test_affect_state_route_returns_seeded_snapshot():
"""tracer: seeded (agent, user) → 200 with the snapshot verbatim. Colon-id in the
path exercises INV-008 at the provider hop."""
store = open_affect_store(":memory:")
snap = _affect_snapshot()
_seed(store, snap)
client = TestClient(build_affect_provider_app(store, heimdall_key=b"k"))
r = client.get("/affect/state/ratatoskr:sindra", params={"end_user_id": "vuong"})
assert r.status_code == 200
assert r.json() == snap
def test_affect_state_route_absent_returns_404_no_snapshot():
"""INV-003: no emit yet for (agent, user) → explicit 404 no_affect_snapshot,
NEVER a zeroed pad that reads as real data."""
store = open_affect_store(":memory:")
client = TestClient(build_affect_provider_app(store, heimdall_key=b"k"))
r = client.get("/affect/state/ratatoskr:ghost", params={"end_user_id": "nobody"})
assert r.status_code == 404
body = r.json()
assert body["error_code"] == "no_affect_snapshot"
assert "pad" not in body # no fabricated PAD
def test_affect_state_route_missing_end_user_id_returns_400():
"""PRE-001: absent end_user_id query → 400 missing_end_user_id (not a silent
no-snapshot lookup against a None partition)."""
store = open_affect_store(":memory:")
_seed(store, _affect_snapshot())
client = TestClient(build_affect_provider_app(store, heimdall_key=b"k"))
r = client.get("/affect/state/ratatoskr:sindra") # no end_user_id
assert r.status_code == 400
assert r.json()["error_code"] == "missing_end_user_id"
+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 == []
+336
View File
@@ -0,0 +1,336 @@
"""Tests for the dispatch-layer observe feed (ratatoskr.provider.opfeed).
Issue #17 slice 2 (the Observe half). These drive the REAL bifrost provider ASGI
app end-to-end through `instrument_provider_app`, minting a valid dispatch JWT with
bifrost's own `mint_dispatch_jwt` — so the envelopes and the session_id claim are
the real wire shapes, not hand-mocked guesses (the repo's "test against the
shipped lib" posture).
"""
from __future__ import annotations
import httpx
import pytest
from bifrost.core.dispatch_jwt import mint_dispatch_jwt
from ratatoskr.provider.affect_store import (
build_affect_provider_app,
open_affect_store,
)
from ratatoskr.provider.memory_store import (
build_memory_provider_app,
open_memory_store,
)
from ratatoskr.provider.opfeed import instrument_provider_app
_KEY = "shared-secret"
_DIM = 8
_CONSUMER = "ratatoskr"
class _RecordingSink:
"""An OpSink that just records events (so a test can assert on them)."""
def __init__(self) -> None:
self.events: list = []
def emit(self, event) -> None:
self.events.append(event)
def _mint(session_id: str, scope: list[str]) -> str:
return mint_dispatch_jwt(
session_id=session_id,
consumer_id=_CONSUMER,
issuer="worldtree",
scope=scope,
secret_or_key=_KEY,
algorithm="HS256",
)
def _wrapped_memory_app(sink):
store = open_memory_store(":memory:", embedding_dim=_DIM)
app = build_memory_provider_app(
store, heimdall_key=_KEY.encode(), consumer_id=_CONSUMER
)
return instrument_provider_app(app, plane="memory", sink=sink), store
def _wrapped_affect_app(sink):
store = open_affect_store(":memory:")
app = build_affect_provider_app(
store, heimdall_key=_KEY.encode(), consumer_id=_CONSUMER
)
return instrument_provider_app(app, plane="affect", sink=sink), store
def _chunk(cid: str, scope: dict | None = None) -> dict:
return {
"id": cid,
"scope": scope or {"end_user": "u1"},
"embedding": [0.1] * _DIM,
"content": "x",
}
class _RaisingSink:
def emit(self, event) -> None:
raise RuntimeError("boom")
async def _post(app, path: str, body: dict, jwt: str | None) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
headers = {"Authorization": f"Bearer {jwt}"} if jwt else {}
async with httpx.AsyncClient(
transport=transport, base_url="http://provider"
) as client:
return await client.post(path, json=body, headers=headers)
class TestOpFeedMemory:
async def test_search_emits_one_opevent(self) -> None:
"""search [tracer]: a memory search dispatched through the wrapped app
emits EXACTLY ONE OpEvent plane=memory, op=search, session_id from the
JWT sub, status=ok, scope-only req/resp summaries (POST-001/002, INV-005).
Empty store 0 hits."""
sink = _RecordingSink()
app, _store = _wrapped_memory_app(sink)
jwt = _mint("session-abc", ["memory:read"])
body = {
"operation": "search",
"args": {
"vector": [0.0] * _DIM,
"top_k": 5,
"scope_all": {"end_user": "u1"},
"scope_any": [],
},
}
resp = await _post(app, "/bifrost/memory-call", body, jwt)
assert resp.status_code == 200
assert len(sink.events) == 1
ev = sink.events[0]
assert ev.plane == "memory"
assert ev.op == "search"
assert ev.session_id == "session-abc"
assert ev.status == "ok"
assert ev.req_summary == {
"scope_all": {"end_user": "u1"},
"scope_any": [],
"top_k": 5,
}
assert ev.resp_summary["hit_count"] == 0
assert ev.turn_id is None
assert ev.ts # non-empty capture timestamp
async def test_upsert_many_summary(self) -> None:
"""upsert_many: req carries record_count + per-record scopes (no bodies);
resp carries upserted + replayed."""
sink = _RecordingSink()
app, _store = _wrapped_memory_app(sink)
jwt = _mint("session-up", ["memory:write"])
body = {
"operation": "upsert_many",
"args": {"records": [_chunk("c1"), _chunk("c2", {"end_user": "u2"})]},
"idempotency_key": "k1",
}
resp = await _post(app, "/bifrost/memory-call", body, jwt)
assert resp.status_code == 200
ev = sink.events[-1]
assert ev.op == "upsert_many"
assert ev.status == "ok"
assert ev.req_summary == {
"record_count": 2,
"scopes": [{"end_user": "u1"}, {"end_user": "u2"}],
}
assert ev.resp_summary == {"upserted": 2, "replayed": False}
async def test_get_and_delete_summaries(self) -> None:
"""get -> found_count; delete_many -> deleted; both req carry ids only."""
sink = _RecordingSink()
app, _store = _wrapped_memory_app(sink)
await _post(
app,
"/bifrost/memory-call",
{
"operation": "upsert_many",
"args": {"records": [_chunk("c1")]},
"idempotency_key": "k1",
},
_mint("s", ["memory:write"]),
)
await _post(
app,
"/bifrost/memory-call",
{"operation": "get", "args": {"chunk_id": "c1"}},
_mint("s", ["memory:read"]),
)
get_ev = sink.events[-1]
assert get_ev.op == "get"
assert get_ev.req_summary == {"ids": ["c1"]}
assert get_ev.resp_summary == {"found_count": 1}
await _post(
app,
"/bifrost/memory-call",
{"operation": "delete_many", "args": {"ids": ["c1"]}},
_mint("s", ["memory:write"]),
)
del_ev = sink.events[-1]
assert del_ev.op == "delete_many"
assert del_ev.req_summary == {"ids": ["c1"]}
assert del_ev.resp_summary == {"deleted": 1}
async def test_error_status_records_the_bifrost_code(self) -> None:
"""error [adversarial]: an unknown operation -> status=error and the
bifrost error `code` is recorded, never hidden (INV-007)."""
sink = _RecordingSink()
app, _store = _wrapped_memory_app(sink)
resp = await _post(
app,
"/bifrost/memory-call",
{"operation": "bogus", "args": {}},
_mint("s", ["memory:read"]),
)
assert resp.status_code != 200
assert len(sink.events) == 1
ev = sink.events[0]
assert ev.op == "bogus"
assert ev.status == "error"
assert ev.resp_summary == {"error": "memory.invalid_arguments"}
async def test_missing_jwt_session_id_none_still_emits(self) -> None:
"""no_jwt [boundary]: a call with NO Authorization still emits exactly one
OpEvent with session_id=None (INV-005) and status=error (auth rejected)."""
sink = _RecordingSink()
app, _store = _wrapped_memory_app(sink)
resp = await _post(
app,
"/bifrost/memory-call",
{"operation": "search", "args": {"vector": [0.0] * _DIM, "top_k": 1}},
None,
)
assert resp.status_code != 200
assert len(sink.events) == 1
assert sink.events[0].session_id is None
assert sink.events[0].status == "error"
async def test_handshake_op_from_path(self) -> None:
"""handshake: op is derived from the PATH (handshake bodies carry no
`operation` field); still exactly one OpEvent (POST-001 incl. handshake)."""
sink = _RecordingSink()
app, _store = _wrapped_memory_app(sink)
resp = await _post(
app,
"/bifrost/handshake",
{"bifrost_version": "99.0.0", "mcp_version": "0.4.0"},
None,
)
assert resp.status_code != 200 # version-major mismatch, cleanly rejected
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)."""
app, _store = _wrapped_memory_app(_RaisingSink())
resp = await _post(
app,
"/bifrost/memory-call",
{
"operation": "search",
"args": {"vector": [0.0] * _DIM, "top_k": 1, "scope_all": {}},
},
_mint("s", ["memory:read"]),
)
assert resp.status_code == 200
class TestOpFeedAffect:
async def test_emit_op_normalized_and_plane_affect(self) -> None:
"""affect emit: op is the bare verb (affect.emit -> emit), plane=affect,
session_id from the JWT; affect stays conduit-opaque (empty req_summary)."""
sink = _RecordingSink()
app, _store = _wrapped_affect_app(sink)
jwt = _mint("session-aff", ["affect:write"])
body = {
"operation": "affect.emit",
"args": {
"agent_id": "ratatoskr:sindra",
"end_user_id": "u1",
"pad": {"p": 0.1, "a": 0.2, "d": 0.3},
"valence": 0.5,
"emitted_at": "2026-06-18T00:00:00Z",
},
"idempotency_key": "k1",
}
resp = await _post(app, "/bifrost/affect-call", body, jwt)
assert resp.status_code == 200
assert len(sink.events) == 1
ev = sink.events[0]
assert ev.plane == "affect"
assert ev.op == "emit"
assert ev.session_id == "session-aff"
assert ev.status == "ok"
assert ev.req_summary == {} # conduit-opaque
assert ev.resp_summary == {"stored": True}
async def test_pad_read_route_emits_no_opevent(self) -> None:
"""INV-004 (#18 D2): the non-bifrost PAD read route is OUTSIDE _BIFROST_PATHS,
so the op-feed passes it through and records NO OpEvent observe is bifrost-
only and the read path adds no plane attribution."""
import json as _json
sink = _RecordingSink()
app, store = _wrapped_affect_app(sink)
blob = _json.dumps(
{
"agent_id": "ratatoskr:sindra",
"end_user_id": "vuong",
"pad": {"pleasure": 0.1, "arousal": 0.0, "dominance": 0.0},
"valence": [],
"emitted_at": "2026-06-18T00:00:00+00:00",
},
sort_keys=True,
separators=(",", ":"),
)
store._conn.execute(
"INSERT INTO affect_snapshots (agent_id, end_user_id, snapshot_json, arrived_at) "
"VALUES (?, ?, ?, ?)",
("ratatoskr:sindra", "vuong", blob, "0"),
)
store._conn.commit()
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://provider"
) as client:
resp = await client.get(
"/affect/state/ratatoskr:sindra", params={"end_user_id": "vuong"}
)
assert resp.status_code == 200
assert sink.events == [] # op-feed recorded nothing for the non-bifrost route
class TestInstrumentGuards:
def test_unknown_plane_raises(self) -> None:
with pytest.raises(ValueError):
instrument_provider_app(object(), plane="persona", sink=_RecordingSink())
+13
View File
@@ -21,3 +21,16 @@ def test_build_app_from_env_builds_app_with_routes():
paths = {getattr(r, "path", None) for r in app.routes}
assert "/bifrost/handshake" in paths
assert "/bifrost/affect-call" in paths
def test_opfeed_path_wraps_app(tmp_path):
# Issue #17 slice 2: RATATOSKR_OPFEED_PATH opts the dispatch op-feed in; the
# returned app is then the instrumented ASGI wrapper, not the raw Starlette.
app = build_app_from_env(
{
"RATATOSKR_HEIMDALL_KEY": "shared-secret",
"RATATOSKR_AFFECT_DB": ":memory:",
"RATATOSKR_OPFEED_PATH": str(tmp_path / "ops.jsonl"),
}
)
assert not hasattr(app, "routes") # wrapped: a bare ASGI callable
+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')
+14
View File
@@ -46,3 +46,17 @@ def test_build_memory_app_from_env_builds_app_with_routes():
paths = {getattr(r, "path", None) for r in app.routes}
assert "/bifrost/handshake" in paths
assert "/bifrost/memory-call" in paths
def test_opfeed_path_wraps_app(tmp_path):
# Issue #17 slice 2: RATATOSKR_OPFEED_PATH opts the dispatch op-feed in; the
# returned app is then the instrumented ASGI wrapper, not the raw Starlette.
app = build_memory_app_from_env(
{
"RATATOSKR_HEIMDALL_KEY": "shared-secret",
"RATATOSKR_MEMORY_DB": ":memory:",
"RATATOSKR_MEMORY_EMBEDDING_DIM": "8",
"RATATOSKR_OPFEED_PATH": str(tmp_path / "ops.jsonl"),
}
)
assert not hasattr(app, "routes") # wrapped: a bare ASGI callable
+184
View File
@@ -9,11 +9,15 @@ from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
AuthScopeDenied,
BifrostBinding,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
InvalidCursor,
PersonaNotConfigured,
SessionApiFailed,
SessionPage,
create_session,
endpoint_for_plane,
get_persona_state,
list_agents,
list_sessions,
@@ -206,6 +210,186 @@ class TestCreateSession:
assert route.call_count == 0
class TestCreateSessionBifrostBind:
"""Issue #17 slice 1 — the create_session Bifrost-bind primitive."""
@respx.mock
async def test_bind_happy_consumer_key_and_body(self) -> None:
"""bind_happy [tracer]: a bifrost binding makes the body carry the
`bifrost` field AND overrides the bearer to the consumer key (NOT the
client's canary default), 201 → SessionInfo. Proves the bind path
end-to-end (FN create_session STEPS 1-2, POST-001, INV-001)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-bound",
"agent_id": "ratatoskr:sindra",
"message_count": 0,
"created_at": "2026-06-18T12:00:00+00:00",
"last_active": "2026-06-18T12:00:00+00:00",
"metadata": {},
},
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer canary-key"},
) as client:
info = await create_session(
client,
"ratatoskr:sindra",
end_user_id="smoke-user",
bifrost=binding,
consumer_key="consumer-key",
)
req = route.calls[0].request
body = _json.loads(req.content)
# body carries the bifrost field alongside agent_id/end_user_id
assert body == {
"agent_id": "ratatoskr:sindra",
"end_user_id": "smoke-user",
"bifrost": {
"endpoint_url": "http://10.100.10.50:8391",
"scope": None,
},
}
# bearer overridden to the consumer key (INV-001: never the canary default)
assert req.headers["Authorization"] == "Bearer consumer-key"
assert info.session_id == "s-bound"
assert info.agent_id == "ratatoskr:sindra"
@respx.mock
async def test_bind_without_consumer_key_raises_before_http(self) -> None:
"""missing_key [adversarial]: bifrost set but consumer_key None →
BifrostConsumerKeyMissing BEFORE any HTTP (PRE-001, INV-001: never fall
back to the canary key)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostConsumerKeyMissing):
await create_session(client, "ratatoskr:sindra", bifrost=binding)
assert route.call_count == 0
@respx.mock
async def test_bind_with_empty_consumer_key_raises_before_http(self) -> None:
"""empty_key [adversarial]: empty-string consumer_key is also rejected
before HTTP (PRE-001 requires a NON-EMPTY str)."""
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, content=b"{}")
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostConsumerKeyMissing):
await create_session(
client, "ratatoskr:sindra", bifrost=binding, consumer_key=""
)
assert route.call_count == 0
@respx.mock
async def test_bind_handshake_failure_maps_to_502(self) -> None:
"""handshake_502 [adversarial]: a bound create that 502s with
detail.bifrost_error BifrostHandshakeFailed carrying the bifrost_error
+ raw body (POST-002, INV-002 bind-time failure). 'bifrost.auth_rejected'
is the canary-key-instead-of-consumer-key tell."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
502,
json={
"error_code": "bifrost_handshake_failed",
"detail": {"bifrost_error": "bifrost.auth_rejected"},
},
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(BifrostHandshakeFailed) as exc_info:
await create_session(
client, "ratatoskr:sindra", bifrost=binding, consumer_key="ck"
)
assert exc_info.value.bifrost_error == "bifrost.auth_rejected"
# the raw 502 body is carried for debugging
assert exc_info.value.body
@respx.mock
async def test_bind_ephemeral_rejection_is_session_api_failed(self) -> None:
"""ephemeral_422 [boundary]: 422 ephemeral_does_not_accept_bifrost is a
generic create failure SessionApiFailed, NOT a distinct exception
(POST-003 deliberate, an operator config error)."""
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
422, json={"error_code": "ephemeral_does_not_accept_bifrost"}
)
)
binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391")
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as exc_info:
await create_session(
client, "echo", bifrost=binding, consumer_key="ck"
)
assert exc_info.value.status == 422
@respx.mock
async def test_unbound_create_unchanged_no_auth_override(self) -> None:
"""unbound_unchanged [regression]: with no bifrost, the body is the
pre-#17 shape AND create_session sends NO per-request Authorization
override the client's default canary bearer governs (INV-001: the two
call sites never cross)."""
import json as _json
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s1",
"agent_id": "mimir",
"message_count": 0,
"created_at": "2026-04-15T12:00:00+00:00",
"last_active": "2026-04-15T12:00:00+00:00",
"metadata": {},
},
)
)
async with httpx.AsyncClient(
base_url="https://w.example",
headers={"Authorization": "Bearer canary-key"},
) as client:
await create_session(client, "mimir")
req = route.calls[0].request
body = _json.loads(req.content)
assert body == {"agent_id": "mimir"}
# the client default bearer is used unchanged — no consumer-key override
assert req.headers["Authorization"] == "Bearer canary-key"
class TestEndpointForPlane:
"""Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL."""
def test_memory_plane_maps_to_8391(self) -> None:
"""memory [tracer]: 'memory' → http://<host>:8391 (POST-001)."""
assert (
endpoint_for_plane("memory", "10.100.10.50")
== "http://10.100.10.50:8391"
)
def test_affect_plane_maps_to_8390(self) -> None:
"""affect: 'affect' → http://<host>:8390 (POST-001)."""
assert (
endpoint_for_plane("affect", "10.100.10.50")
== "http://10.100.10.50:8390"
)
def test_unknown_plane_raises_value_error(self) -> None:
"""unknown_plane [adversarial]: any other plane → ValueError (PRE-001)."""
with pytest.raises(ValueError):
endpoint_for_plane("persona", "10.100.10.50")
def _list_item(
*,
session_id: str = "s1",
+96
View File
@@ -9,6 +9,7 @@ import respx
from textual.widgets import RichLog
from ratatoskr.cli import ParsedArgs
from ratatoskr.sessions import BifrostBinding
from ratatoskr.sse_client import (
Cancelled,
Done,
@@ -2849,3 +2850,98 @@ class TestResolveThenRunWithPicker:
err = capsys.readouterr().err
assert "[no_agents]" in err
assert picker_called is False
class TestTuiBifrostBind:
"""Issue #17 slice 3b — TUI bind trigger: bind failures route to the real
stderr BEFORE the alt-screen opens (INV-002, mirrors issue #6; same exit
codes/vocabulary as cli._amain per INV-006)."""
@respx.mock
async def test_handshake_failure_routes_pre_altscreen(
self, capsys: pytest.CaptureFixture[str]
) -> None:
from ratatoskr.tui import _resolve_then_run
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
502,
json={
"error_code": "bifrost_handshake_failed",
"detail": {"bifrost_error": "bifrost.auth_rejected"},
},
)
)
args = _args_new(
agent_id="ratatoskr:sindra",
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
bifrost_plane="memory",
consumer_key="ck",
)
rc = await _resolve_then_run(args)
assert rc == 23
err = capsys.readouterr().err
assert "bifrost.auth_rejected" in err
assert "consumer key" in err # the 401-scoping hint
@respx.mock
async def test_consumer_key_missing_routes_pre_altscreen(
self, capsys: pytest.CaptureFixture[str]
) -> None:
from ratatoskr.tui import _resolve_then_run
args = _args_new(
agent_id="a",
bifrost=BifrostBinding(endpoint_url="http://x:8391"),
consumer_key=None,
)
rc = await _resolve_then_run(args)
assert rc == 22
assert "bifrost_consumer_key_missing" in capsys.readouterr().err
@respx.mock
async def test_bound_create_carries_binding_and_consumer_key(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A successful bound create sends the bifrost body + the consumer-key
bearer and prints the bound-state indicator (run_async stubbed so no
alt-screen opens)."""
from ratatoskr import tui as tui_mod
from ratatoskr.tui import _resolve_then_run
route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
"session_id": "s-bound",
"agent_id": "ratatoskr:sindra",
"message_count": 0,
"created_at": "2026-06-18T12:00:00+00:00",
"last_active": "2026-06-18T12:00:00+00:00",
"metadata": {},
},
)
)
async def fake_run_async(self) -> int:
return 0
monkeypatch.setattr(tui_mod.RatatoskrApp, "run_async", fake_run_async)
args = _args_new(
agent_id="ratatoskr:sindra",
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
bifrost_plane="memory",
consumer_key="ck",
)
rc = await _resolve_then_run(args)
assert rc == 0
import json as _json
body = _json.loads(route.calls[0].request.content)
assert body["bifrost"] == {
"endpoint_url": "http://10.100.10.50:8391", "scope": None
}
assert route.calls[0].request.headers["Authorization"] == "Bearer ck"
err = capsys.readouterr().err
assert "bifrost: status=bound" in err
assert "plane=memory" in err
+227 -1
View File
@@ -616,13 +616,15 @@ class TestCreateAppShape:
"""create_app FN — route registration + state wiring (contract TESTS)."""
def test_routes_registered(self) -> None:
"""routes_registered [tracer]: app.routes contains all 9 path patterns."""
"""routes_registered [tracer]: app.routes contains all path patterns,
including the #18 affect-read proxy."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory())
paths = {getattr(r, "path", None) for r in app.routes}
for expected in (
"/", "/version", "/api/agents", "/api/sessions",
"/api/agents/{agent_id}/persona_state",
"/api/affect/{agent_id}",
"/api/turns/{session_id}", "/api/turns/{session_id}/stream",
"/api/turns/{session_id}/cancel",
):
@@ -792,3 +794,227 @@ class TestDisconnectCancel:
await asyncio.sleep(0.02)
gate.set()
assert cancel_route.called, "browser disconnect must cancel the UPSTREAM turn (42)"
class TestWebBifrostBind:
"""Issue #17 slice 3c — web bind split: the browser selects the PLANE; the
consumer key + visible host are SERVER-HELD and never reach the browser
(INV-008/INV-009)."""
@respx.mock
def test_bound_create_server_constructs_binding_key_never_leaks(self) -> None:
"""tracer: a plane from the browser → the server builds the binding with
its OWN consumer key + host, sends the bifrost body + consumer-key bearer
upstream, and returns bound-state WITHOUT the key."""
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": "memory"}
)
assert resp.status_code == 201
# bound-state echoed for the UI indicator — plane + endpoint, NO key
assert resp.json()["bifrost"] == {
"plane": "memory",
"endpoint": "http://10.100.10.50:8391",
"status": "bound",
}
assert "server-ck" not in resp.text # the key never reaches the browser
# upstream got the bifrost body + the consumer-key bearer override
upstream = route.calls[0].request
body = _json.loads(upstream.content)
assert body["bifrost"] == {
"endpoint_url": "http://10.100.10.50:8391", "scope": None
}
assert upstream.headers["Authorization"] == "Bearer server-ck"
@respx.mock
def test_plane_without_server_config_is_400(self) -> None:
"""A plane requested but no server-held key/host → bifrost_not_configured."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory()) # no bifrost config
resp = TestClient(app).post(
"/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"}
)
assert resp.status_code == 400
assert resp.json()["error_code"] == "bifrost_not_configured"
def test_invalid_plane_is_400(self) -> None:
from ratatoskr.web.server import create_app
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="ck",
bifrost_visible_host="h",
)
resp = TestClient(app).post(
"/api/sessions", json={"agent_id": "a", "bifrost_plane": "persona"}
)
assert resp.status_code == 400
assert resp.json()["error_code"] == "invalid_bifrost_plane"
@respx.mock
def test_handshake_failure_is_502(self) -> None:
from ratatoskr.web.server import create_app
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
502,
json={
"error_code": "bifrost_handshake_failed",
"detail": {"bifrost_error": "bifrost.auth_rejected"},
},
)
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="ck",
bifrost_visible_host="h",
)
resp = TestClient(app).post(
"/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"}
)
assert resp.status_code == 502
assert resp.json()["error_code"] == "bifrost_handshake_failed"
assert resp.json()["bifrost_error"] == "bifrost.auth_rejected"
@respx.mock
def test_no_plane_is_unbound_no_bifrost_in_response(self) -> None:
"""regression: no bifrost_plane → pre-#17 unbound create, no bifrost key."""
from ratatoskr.web.server import create_app
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK)
)
app = create_app(
_mock_client_factory(),
bifrost_consumer_key="ck",
bifrost_visible_host="h",
)
resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
assert resp.status_code == 201
assert "bifrost" not in resp.json()
class TestAffectStateEndpoint:
"""affect_state_endpoint FN — #18 Deliverable 2: web proxy to the provider PAD read."""
@respx.mock
def test_happy_proxies_and_supplies_server_end_user_id(self) -> None:
"""tracer: GET /api/affect/{id} → proxies to the configured provider read URL,
supplying end_user_id SERVER-SIDE (INV-002); colon-id round-trips (INV-008)."""
from ratatoskr.web.server import create_app
snap = {
"agent_id": "ratatoskr:sindra",
"pad": {"pleasure": 0.15, "arousal": 0.08, "dominance": -0.01},
"valence": [{"entity_id": "ratatoskr", "familiarity": 0.59, "regard": 0.15}],
"emitted_at": "2026-06-18T15:58:12+00:00",
}
route = respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
return_value=httpx.Response(200, json=snap)
)
app = create_app(
_mock_client_factory(),
end_user_id="vuong",
affect_read_url="http://prov:8390",
)
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 200
assert resp.json() == snap
assert route.calls.last.request.url.params["end_user_id"] == "vuong"
# INV-008: the colon-id round-trips into the provider path — whether the wire
# keeps %3A or normalizes it, it must unquote back to the exact agent_id.
from urllib.parse import unquote
seg = str(route.calls.last.request.url).split("/affect/state/")[1].split("?")[0]
assert unquote(seg) == "ratatoskr:sindra"
@respx.mock
def test_browser_supplied_end_user_id_is_ignored(self) -> None:
"""INV-002: a browser-supplied end_user_id query is IGNORED; the server's
configured partition is used."""
from ratatoskr.web.server import create_app
route = respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
return_value=httpx.Response(200, json={"agent_id": "ratatoskr:sindra"})
)
app = create_app(
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
)
TestClient(app).get("/api/affect/ratatoskr:sindra?end_user_id=attacker")
assert route.calls.last.request.url.params["end_user_id"] == "vuong"
def test_unconfigured_returns_400(self) -> None:
"""PRE-001: no affect_read_url → 400 affect_not_configured (no silent attempt)."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), end_user_id="vuong") # no affect_read_url
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400
assert resp.json()["error_code"] == "affect_not_configured"
def test_no_end_user_configured_returns_400(self) -> None:
"""PRE-001: affect_read_url set but server end_user_id unset → 400 (INV-003
fail-visible, never a silent empty)."""
from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory(), affect_read_url="http://prov:8390")
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400
assert resp.json()["error_code"] == "affect_not_configured"
@respx.mock
def test_provider_unreachable_returns_502(self) -> None:
"""POST-003: a network error reaching the provider → 502 affect_provider_unreachable."""
from ratatoskr.web.server import create_app
respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
side_effect=httpx.ConnectError("refused")
)
app = create_app(
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
)
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 502
assert resp.json()["error_code"] == "affect_provider_unreachable"
@respx.mock
def test_provider_404_passes_through(self) -> None:
"""POST-002: provider no_affect_snapshot 404 surfaces to the browser verbatim."""
from ratatoskr.web.server import create_app
respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
return_value=httpx.Response(404, json={"error_code": "no_affect_snapshot"})
)
app = create_app(
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
)
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 404
assert resp.json()["error_code"] == "no_affect_snapshot"
@respx.mock
def test_provider_400_passes_through(self) -> None:
"""POST-002: a provider 400 (e.g. missing_end_user_id — unreachable in normal
flow since the proxy always supplies it) still passes through verbatim."""
from ratatoskr.web.server import create_app
respx.get(url__regex=r"http://prov:8390/affect/state/.+").mock(
return_value=httpx.Response(400, json={"error_code": "missing_end_user_id"})
)
app = create_app(
_mock_client_factory(), end_user_id="vuong", affect_read_url="http://prov:8390"
)
resp = TestClient(app).get("/api/affect/ratatoskr:sindra")
assert resp.status_code == 400
assert resp.json()["error_code"] == "missing_end_user_id"
Generated
+5 -5
View File
@@ -190,14 +190,14 @@ wheels = [
[[package]]
name = "bifrost"
version = "0.8.0"
version = "0.10.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/0.10.0/bifrost-0.10.0.tar.gz", hash = "sha256:aba1869dba68d921f2e0be8fb560277073da09ec2ad5f410e226cacd5e84fe1a" }
wheels = [
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.8.0/bifrost-0.8.0-py3-none-any.whl", hash = "sha256:2aac5e4a7828d718389748a78dae6baeb5e9ee4a801a10c427c06c5cc7ed6597" },
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0-py3-none-any.whl", hash = "sha256:88adbce23fa8840a14f9493e4f0cf6f9320f4950845f7a6080387defe574a5cc" },
]
[[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.17.6"
version = "0.17.17"
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 = ">=0.10.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" },