create_session could only mint foundational sessions; an ephemeral template
(agent_id="echo") returned 422 ephemeral_requires_config because ratatoskr never
sent the required config block — Echo was uncreatable, surfacing as an opaque
session_api_failed at the CLI. Thread an opaque, role/model-agnostic config
passthrough through the create path so Echo sessions are creatable.
- sessions.py: create_session(config=...) verbatim passthrough (PRE-004 Mapping /
PRE-005 config-xor-bifrost guards); SessionInfo gains kind + config, captured
defensively (.get) on both create and list.
- cli.py: --system-prompt flag builds config={"system_prompt": ...} (validation:
non-empty, requires --new+--agent, xor bifrost); _amain surfaces kind=; the
--whoami renderer now reads allowed_roles/default_role (was reading the dead
allowed_models/default_model) and tolerates a malformed capabilities shape.
- contract #2 amended (Amendment 2026-07-18); Heid-panel contract-reviewed +
diff-scoped bug-hunted (one whoami null-join gap found + fixed).
Canonical grounding: config.role, never config.model (worldtree-dev althing
01KXT976NN91DRBZBPXNZ2BVZR; ADR-0012 role cutover). Verified end-to-end against
the live v0.16.2 target. TDD across create + CLI; full suite green (534).
Closes#19.
Live Worldtree b125 changed POST /agents/define: the request field is
now 'role' (a model-role like 'thoughtful-character'), replacing 'model';
the response still echoes it as 'model'. Update define_agent/patch_agent
request bodies + CLI (--model -> --role); response parse + LocalAgentEntry
unchanged. Verified end-to-end against live (delete->define round-trip);
26 tier3 tests green. Full b22->b125 spec-pin bump remains a follow-up.
The textual TUI (tui.py) is superseded by the web console (ratatoskr-web)
and is removed per the no-backwards-compat rule. The `ratatoskr` command
stays as a headless client: --send / --whoami / --characters /
--set-persona-pad / --seed-first-message still work; invoking it with no
--send now returns a usage error (rc 10) instead of launching the TUI.
Removed: src/ratatoskr/tui.py, tests/test_tui.py, the textual + textual-dev
deps, and cli.py's run_tui launch path. cli.py's shared exports (USER_AGENT,
ParsedArgs, formatters) stay — web/entrypoint.py and tier3.py depend on them.
BREAKING CHANGE: the interactive `ratatoskr --agent X` TUI is gone; use the
web console (ratatoskr-web) for interactive debugging, or --send for scripted.
Verified: full suite 520 passed; ratatoskr --help exit 0; no-send -> rc 10;
web/provider/tier3 import clean; textual absent from the lockfile.
Implement `mark_superseded(ids, *, superseded_by=None, reason=None)` — the SOLE
supersession verb Worldtree #364's promotion-hygiene reconciliation calls to retire
contradicted facts (wire shape confirmed by worldtree-dev, bifrost_memory_store.py:293).
Live re-verify (2026-07-16) proved our provider 500-crashed on this call (unimplemented)
→ #364's retirement couldn't land + a retry-storm bloated the store; the readout only
passed via transient recency-eviction.
- `mark_superseded` mirrors the reference `_mark_lifecycle`: sets top-level
`superseded=True` (+ `superseded_by`/`superseded_reason` when non-None), increments
revision, NON-destructive (get still returns; recoverable). Unknown ids skipped.
- `_is_live` (INV-011) now short-circuits on `superseded is True`, so a retired chunk is
excluded from `scan` (person-prime) — durable retirement, not just recency-eviction.
search is unfiltered (matches reference; WT re-checks liveness client-side).
- Contract: un-defer mark_superseded (+ FN spec, INV-011); TDD 5/5 (retires-from-scan
tracer, non-destructive-get, unknown-id no-op, non-None-fields-only, parity #195).
- bifrost 1.1.1→1.1.4: hasattr-gate backstop for the maintenance verbs (unimplemented
verb → unsupported_capability 400, never AttributeError/500/retry-storm — the gap we
surfaced) + the 1.1.3 scan/cursor conformance harness. Full suite 644 green.
DEPLOY-BREAKER caught by driving the live bind (unit tests + worldtree-dev's
name-only parser + heid-bug-hunt all missed it). bifrost handshake_response
`SortableChunkField` requires BOTH `name` and `type` (additionalProperties:false).
We advertised `[{"name":"updated_at"}]` (no `type`), so the handshake_response
failed wire-schema validation → `bifrost.schema_validation_failed` → the ENTIRE
Bifrost bind (memory + affect) broke, not just the sort. Advertise
`{"name":"updated_at","type":"timestamp"}` (matches the reference; `type` is
advisory-only). Regression guard added to the caps test (asserts required name+type,
no extra keys). Full suite 639 green.
heid-bug-hunt panel (Gróa + Hulda, confirmed-from-code) caught that a truthy
non-dict `sort` (e.g. sort="updated_at" or sort=["updated_at"]) reached
`(sort or {}).get(...)` and crashed with AttributeError instead of the
InvalidArguments PRE-003 promises for malformed caller-controlled input. Add an
isinstance guard before field extraction. Test covers str/list/int sort values.
Implement the memory-store `scan` verb — a query-LESS, LIVE-only, globally
ordered top-N-by-recency read — and advertise `sortable_chunk_fields=
[{updated_at}]` at the Bifrost handshake. Advertising the cap is what lights
up Worldtree's #349 person-prime turn-1 durable-fact injection (Branch-A
`"updated_at" in caps.sort_fields_supported`); the fix is ZERO Worldtree
change — the running provider announcing the cap is the trigger.
scan is:
- LIVE-only server-side (INV-009): superseded/tombstoned excluded — a dead
fact can never inject; person-prime's `lifecycle_state=live` does not ride
the scan wire, so server-side is authoritative.
- Globally ordered before pagination (INV-010): the full scope-filtered live
set is ordered by (sort.field, direction) globally; missing value LAST,
chunk_id tiebreak. Backed by an expression index on
json_extract(record_json,'$.updated_at') to stay in the 500ms budget.
- Cursor = offset into the global order; emits a next cursor only when a
further match exists (no empty trailing page — matches the reference).
Sort is dispatch-gated: an unadvertised sort.field raises InvalidArguments,
never a silent unsorted fallback.
Contract amended: un-defers scan, adds the FN spec + INV-009/INV-010 +
sortable_chunk_fields to INV-006. TDD 7/7 green (scan_recency tracer,
live_only, scope_isolation, unadvertised_sort, person_prime_record_shape,
cursor pagination, parity_vs_reference vs InMemoryMemoryStore #195). Full
suite 638 green.
The #347 auto-seed worked (the greeting was in the session ledger at seq-0),
but the web UI never showed it: there was no GET /api/sessions/{id}/messages
route and startSession() went straight from create to persona/tools/admin
hydration, so the transcript only filled from the live turn stream + user
echoes — a seeded turn-0 was invisible.
- server: new proxy route GET /api/sessions/{id}/messages -> get_session_messages
(mirrors the tools/bifrost proxies; status-preserving envelope).
- SPA: loadTranscript(sessionId) fetches it on open and renders existing turns
(assistant -> .response .md-body via markdownSafe escape-first; user ->
.prompt-echo via textContent), called after the workspace opens. Best-effort.
web_debug_surface contract amended (endpoint + loadTranscript). 2 web route
tests, suite 617 green. Playwright DOM check proved the render end-to-end
(drive the real UI -> Sindra's greeting bubble appears).
Codifies 'give an agent a first message' (Worldtree #347): new module
ratatoskr.first_message (FIRST_MESSAGE_PRESETS + seed_preset_first_message)
seeds a preset agent's opening as a #347 authored turn-0 on every new session,
wired into all three create paths — cli._amain (--send --new), tui._resolve_then_run
(bare --new), web._create_session_endpoint (POST /api/sessions).
seed_preset_first_message is strictly best-effort (INV-001): it soft-guards its
inputs (return None, never assert), bounds the write with asyncio.wait_for so a
stalled /history can't block create (the CLI/TUI clients disable read timeout for
SSE), and swallows every exception except asyncio.CancelledError (which
propagates) — so it can NEVER raise into or block the session-create path it is
wired into. Per-content idempotency key → idempotent replay, no dup.
Seeded with ratatoskr:sindra, whose opening greeting moved out of her card:
her live system_prompt was PATCHed (non-destructive) to drop the Startup
workaround the #347 first-message now replaces.
Quality gate (both cross-frontier panels): heid-code-review returned zero
implementation drift (2 test-only fixups applied); heid-bug-hunt caught the
gap the conformance lens can't see — code matched the contract's narrow
ERROR_ROUTING but INV-001's 'never raises' is broader — driving the broad-except
+ soft-guard + wait_for hardening above.
Contract docs/contracts/first_message.contract.md (module-scoped, validated).
TDD: 12 unit + 1 web wire-in; the 3 existing sindra bind tests gained a
history-endpoint mock (creating a preset agent now auto-seeds). Suite 615 green,
ruff+mypy clean. Auto-seed live-proven generation-free against personal :8081.
worldtree-dev landed the Tier-3 persona/memory/persona_state prose docs
(c9e59ec) — shapes that serialize as freeform Any in the OpenAPI, so the
prose markdown is their source of truth. Re-vendored docs/conversation-api-spec.md
(tolerate_drift markdown pin; worldtree-spec-rev 879cefe→c9e59ec).
Consumer alignment: --set-persona-pad / _set_persona_probe was building
{pad:[list]}, but the canonical POST /sessions/{id}/persona_state body (#317)
is {pad:{pleasure,arousal,dominance}} (named dict). Aligned the probe to the
named dict + a len!=3 guard; updated contract #2's note, the set_persona_state
docstring, and the tests. The set_persona_state wrapper was already correct
(freeform pass-through) — only the CLI probe's body construction drifted.
Suite 602 green. (Also this session: heid-code-review on the #347 slice
returned unanimous zero drift across all three panel arms.)
Consumer side of Worldtree's #347 authored-history-write (the SillyTavern
first-message primitive), shipped via direct in-session TDD:
- write_authored_history (POST /sessions/{id}/history): v1 author=assistant,
effects=none, per-session idempotency; body server-pinned (AuthoredWriteRequest
extra=forbid) so null effects/claimed_original_at are omitted; 200 replay /
201 fresh both return the AuthoredTurnResponse dict.
- AuthoredHistoryUnavailable: the hide-existence 404 (feature-absent / ungranted
/ session-absent, indistinguishable by design — INV-347-1) raised DISTINCT from
SessionApiFailed so callers branch feature-absent and never capability-probe.
- get_session_messages (GET /sessions/{id}/messages): un-deferred as the seed
read-back — confirms a seed renders as a normal role=assistant turn
(model-invisible provenance).
- --seed-first-message probe: create session -> seed -> read-back; a 404 reports
a benign feature-absent result (exit 0), never a capability-probe.
Contract #2 amended (2 FNs, validated OK). 19 new tests (12 wrapper + 7 cli),
suite 601 green. Coverage-map re-converged: REST 19/41 (the #347 route + the
messages read-back close the one gap the 2.3.0 re-vendor opened).
Live-proof pending the session.history.write grant (requested infra-ops).
Cross-frontier panel (Gróa/Hulda/Regin) on the v0.19.2 web surface, triaged:
- FIX (Gróa #1, drift): the turn EventSource `onerror` (raw transport drop)
now calls hideThinkingNote() — a drop mid-reasoning no longer leaves the
"<Agent> is pondering…" line + its setInterval running (INV-LIFECYCLE).
- FIX (Gróa #4 + Hulda #1, convergent drift): openAdminEvents now closes the
EventSource + clears state.adminES on `stream_error` (server signalled end)
and on a PERMANENT onerror (readyState CLOSED) — native EventSource no longer
auto-reconnects into a retry loop; transient CONNECTING drops still reconnect.
- TEST (Gróa #2 + Hulda #3): test_routes_registered asserts the 3 new routes;
test_state_attached asserts app.state.admin_key (create_app POST-001/002).
- TEST (Gróa #3 + Regin #3): AdminEvents stream_error-on-connect-failure test —
upstream non-200 -> exactly one `stream_error` frame, then ends (POST-003).
- CONTRACT (Hulda #2 + Regin #2, accepted): clarified the Tools inventory
renders NAMES only by design (descriptions live in the BifrostState pane);
code unchanged. Also lands the web_debug_surface contract as the trail.
Accepted-no-op: 403-bifrost / non-404-tools tests (identical code path to the
tested 404). Panel found ZERO functional server-side drift; INV-004 escaping
confirmed clean across the new panes. 60 web tests pass; JS + ruff clean.
Bring the browser surface to TUI parity as the primary debug surface:
- Tools inventory (GET /sessions/{id}/tools) folded into the tools pane —
what the LLM has at turn-fire, above the live tool events.
- BifrostState pane (GET /admin/sessions/{id}/bifrost) — admin-scoped
dispatch state; the admin key stays server-side (app.state.admin_key),
never reaches the browser (INV-003 precedent).
- AdminEvents pane (GET /admin/events SSE) — admin lifecycle, session-
filtered SERVER-side (heartbeats + other-session events dropped); one
fixed "admin_event" browser event so every type renders (no drops).
- PAD refresh: poll a window (1.5/3.5/6.5/10.5s) instead of a single 2s
shot that raced the post-turn-async affect.emit (issue #18 foot-gun).
- Reasoning indicator: ephemeral "<Agent> is pondering…" in the transcript
on `thinking` deltas, cleared when text begins — clearly non-engine.
Admin key wired through entrypoint -> create_app. 9 new respx/route tests
(admin-bearer override, filter unit, SSE stream-filter); 59 web tests pass.
Live-proven against ratatoskr:sindra (bifrost connected, both caps; 253
thinking events -> indicator fires; affect emit lands -> PAD poll catches it).
v1 coverage-audit slice b2. The audit found list_sessions had no
caller — the startup session picker (design-brief §4) was never built;
bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors
AgentPickerApp) and resolve bare mode in _resolve_then_run.
- Bare TUI mode (no --session/--new) now valid → session picker.
Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per
§4 "no in-app creation, --new only"); exactly 1 -> auto-resume
(§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick
(Esc/Ctrl-D -> exit 0).
- cli._parse: bare TUI valid; --send still requires one flag; --agent
forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive.
- Contract #6 amended (SessionPickerApp + bare-mode resolution) +
validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli
validation tests. Suite 528 green; touched code ruff-clean.
Design note: bare + 0 sessions errors (honors §4's no-in-app-creation
clause); the friendlier auto-fall-through-to-new is deferred pending
operator preference.
v1 coverage-audit slice b1. The audit found reconnect_turn had no
caller — every presenter dropped the stream on disconnect instead of
resuming, leaving the "reference SSE-resume implementation" (design-
brief §3/§8d) unreachable. Add stream_turn_resilient as the single
shared resume surface (design-brief §8b "share the consumer, branch
the presenter") and route cli --send through it.
- stream_turn_resilient wraps stream_turn + reconnect_turn: on
SseConnectionDropped (mid-stream drop or clean EOF before terminal),
resume from the last-seen sse_id via reconnect_turn (Last-Event-ID),
up to max_reconnects (default 5). last_seen persists across attempts.
- Non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed)
propagate unchanged, per contract #1's "surface, not recover".
- cli.py: --send consumer now drives stream_turn_resilient (transparent
reconnect). tui/web still consume bare stream_turn (follow-up).
- Contract #1 amended (FN stream_turn_resilient) + validated; 8 TDD
cases (happy, resume-after-1/2-drops, clean-EOF resume, unresumable
zero-event, max-reconnects-exhausted, zero-budget, buffer-expired-
propagates). Suite 518 green; ruff + mypy clean on touched code.
worldtree-dev finalized the #331 503 turn-launch error_code as `not_ready`
(re-pinned from internal_error; retryable, matching the /readyz 503 sense)
and re-froze the OpenAPI at 2.2.0 documenting the 409/503 statuses our
v0.18.3 mapping already handles. Tighten our fallback default from the
placeholder `turn_launch_unavailable` to the canonical `not_ready` (the
default only fires when the body omits error_code — the real code is
surfaced verbatim regardless). +1 test, suite 510 green.
NOTE: a full conversation-api-spec.md re-vendor to the b2 era is a separate,
larger pin-refresh (ratatoskr vendors the markdown spec, not the OpenAPI
JSON) — deferred, to bundle with the v1 coverage-audit / when personal is on b2.
Worldtree v1.0.0b1 (#331) decoupled turn execution from the SSE connection,
so turn-launch failures now arrive EAGERLY as an HTTP status before any
stream: 409 agent_not_available (pre-b1 was a 200 + in-stream error event)
and 503 (retryable turn-launch / infra failure). stream_turn previously
funneled both into a generic SseConnectFailed.
Map them to typed SseConnectFailed subclasses — AgentNotAvailable (409) and
TurnLaunchUnavailable (503, retryable=True) — carrying the parsed
error_code/message from the {detail:{error_code,message}} envelope.
Subclassing keeps existing `except SseConnectFailed` handlers working with
zero changes (POST-003 preserved — no synthetic event yielded; raise mirrors
reconnect_turn's 400/410/412 pattern).
worldtree-dev confirmed 409/503 are real runtime statuses; the OpenAPI 2.1.0
gap (not enumerating them) is theirs to fix (doc-completeness, not a wire
break). The 503 error_code is being re-pinned upstream (today internal_error
-> likely not_ready); our handling keys on STATUS so it's robust to the final
code — tighten the 503 default once they confirm.
Body shape live-confirmed against demo b1's 404/401 responses. Suite 509 green.
Contract docs/contracts/issues/1.contract.md updated.
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.
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.
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
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.
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).
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).
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).
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.
Repin bifrost 0.7.0→0.8.0 and reimplement the memory store's search scope
filter to the v0.6 split (#11): scope_all (AND/intersection) + scope_any
(OR/union over a list of conjunctive scopes), at parity with the v0.6
reference _matches_scope / _validate_scope. No-compat: scope_filter removed.
scope_any is the union-visibility primitive that resolves the #295/#297
silent-zero AND foot-gun — a subset-scoped chunk now recalls via an OR
member. End-to-end cold recall now gated only on Worldtree emitting
scope_any on its recall path (#297, upstream).
- store: search(scope_all, scope_any); _scope_subset + _matches_scope + _validate_scope
- contract v1.2: search FN sig, INV-005 recomposed, PRE-003 both fields, scope_any_union test
- tests: scope_any union, scope_all∧scope_any compose, both-empty match-all; parity vs real 0.8.0 dispatch (433 green)
- #17 contract: sync stale scope_filter/_scope_matches-AND refs to scope_all/scope_any
- runbook + persistent-memory updated; provider bounced onto 0.8.0 (fresh empty db)
v0.17.6
bifrost 0.7.0 (wire v0.5) makes agent_self canonical: the scope lattice is
now {end_user, group, tenant, agent_self}. Our store was MORE permissive than
bifrost's reference (no _validate_scope_filter), which silently 0-zeroed the
#295 cold recall instead of a loud 400. Now matched: search rejects an
out-of-lattice axis with InvalidFilter (-> memory.invalid_filter 400), agent_self
admitted. Purely additive — everything that validated before still validates.
Closes the parity gap our own foot-gun flag opened (bifrost-dev shipped the
lattice add #10 off it). Pin bumped bifrost>=0.6.1 -> >=0.7.0. Contract
search PRE-003 + lattice_axes test; 2 new store tests; full suite 429 green.
The second plane of ratatoskr's Tier-3 Bifrost consumer: a durable memory
store Worldtree writes agent memory chunks into (upsert_many) and recalls
by vector similarity (search), with point reads + deletes. Implements
bifrost's own MemoryDataStore Protocol; conformance is #195 parity vs
InMemoryMemoryStore through the real dispatch_memory_call.
Store (memory_store.py): open_memory_store, describe_store, upsert_many
(replay/conflict idempotency, optimistic locking, injection rule, atomic
batch), search (cosine over sqlite-vec vec0, scope isolation INV-005,
over-fetch-then-filter so top_k counts in-scope), get/get_many,
delete_many, build_memory_provider_app. Dev shell (serve_memory.py):
ratatoskr-memory-provider entrypoint, port 8391.
TDD + heid-code-review (panel Groa/Hulda/Regin, zero true drift). Adopted
fixups: scope_filter dict guard, top_k<=0 -> [], stronger scope-isolation
+ delete-hit-search + handshake-POST tests. Partial-map optimistic-lock
semantics pinned against the reference via a new expected_revisions
parity test.
26 memory + 4 serve tests; #195 parity (upsert/search/expected_revisions)
green; ruff clean. Deps: +sqlite-vec.
Stand-up glue for the live affect negotiation smoke vs Worldtree v0.35.2.
- ratatoskr.provider.serve: build_app_from_env (tested env->app seam) + main
(uvicorn shell). Config from env: RATATOSKR_HEIMDALL_KEY (required),
RATATOSKR_AFFECT_DB, RATATOSKR_CONSUMER_ID, RATATOSKR_PROVIDER_HOST/PORT.
- console-script ratatoskr-provider = ratatoskr.provider.serve:main.
- Heimdall key utf-8-encoded into HS256 key_bytes; revisit if Worldtree signs
with base64/raw bytes (confirm format at wire-up).
The first slice of ratatoskr-as-Tier-3-Bifrost-consumer: a SQLite-backed,
conduit-opaque affect store Worldtree emits Tier-3 persona/affect snapshots
into, plus the thin build_affect_provider_app wiring. The bifrost library
owns the wire; this owns only the store + glue.
- ratatoskr.provider.affect_store: open_affect_store / emit / get /
build_affect_provider_app. Two-table SQLite schema (snapshots + idempotency).
- Conduit-opaque (INV-001): reads only the two addressing keys; whole-blob
hash only. LWW-by-arrival across distinct keys; replay-or-conflict
idempotency (actor-scoped from ctx), raising bifrost's typed exceptions.
- Conformance: #195 parity vs InMemoryAffectStore through the real
dispatch_affect_call engine. 17 provider tests; 395 full suite.
- Contract v1.1: idempotency model corrected to bifrost's actual semantics
(caught by real-lib TDD; the artifact-only review structurally could not).
Heid-panel reviewed (contract + code); idempotency-cache TTL pruning,
memory.* plane, and the combined two-plane server deferred (see Out of scope).
Second Heid panel pass (thread 01KSPBMFRRQE) on the v0.16.0 tree:
Gróa returned zero findings; Hulda surfaced two minor tightening
items, both closed here.
1. test-gap — TestStreamFullEventVocab drove only 8 of 11 Event types
through the stream endpoint (omitted Error, Cancelled, AffectUpdate).
Serialization for all 11 was already covered by the presentation-
contract fixture tests; this was a stream-integration coverage gap.
- Added AffectUpdate to the vocab stream (non-terminal, coexists
with done).
- Added dedicated test_error_terminal_event + test_cancelled_terminal_event
(terminal events are mutually exclusive with done, so they can't
share one stream).
2. precision — the disconnect-cancel path caught bare `except Exception:
pass`, silently swallowing real CancelFailed / transport errors. The
contract intent is to swallow only the cooperative race
(CancelAlreadyCompleted). Narrowed: swallow CancelAlreadyCompleted /
CancelTurnNotFound as the no-op race; log unexpected cancel failures
as a structured stderr line for diagnosability. Never re-raises (we're
unwinding the cancelled generator and must not mask CancelledError).
Tests: +2 (376 → 378). Patch per SemVer discipline — coverage +
diagnosability tightening, no behavior change observable to callers.
Heid panel review (Gróa + Hulda, thread 01KSP5P6CSJH) on v0.15.0/
v0.15.1 surfaced one load-bearing bug + several precision items. This
pass closes them.
Load-bearing fix — cancel paths targeted the wrong turn_id:
- `_TURN_COUNTER` allocates browser-local ids (1, 2, 3…); the real
upstream Worldtree turn_id (e.g. 799) only arrives in the first SSE
event. The v0.15.x cancel/disconnect/shutdown paths posted to
/sessions/{sid}/turns/{LOCAL_ID}/cancel — wrong URL upstream.
- TurnHandle.upstream_response (dead field) → upstream_turn_id: int|None.
Captured from the first event's sse_id.turn_id in the stream
generator. All cancel paths now target it. Cancel before the upstream
stream starts (upstream_turn_id None) is a no-op
({"cancelled": false, "reason": "not_started"}).
- The old cancel tests mocked the local-id URL, so they encoded the bug;
rewritten to assert the UPSTREAM id is targeted.
Behavior change (minor-bump driver) — server-side end_user_id:
- create_app gains end_user_id kwarg; entrypoint reads
RATATOSKR_END_USER_ID and threads it in. POST /api/sessions uses
app.state.end_user_id, IGNORING any browser-supplied value (a client
can't impersonate an arbitrary end-user partition). JS no longer
sends end_user_id.
Precision fixes:
- Entrypoint missing-extras ImportError catch scoped to starlette/
uvicorn ONLY; baseline-dep / first-party import failures now
propagate as real tracebacks instead of masking as exit-12.
- Lifespan shutdown logs per-pending session_id + upstream_turn_id
(was a single aggregate count).
Tests (+18; 376 total):
- disconnect_triggers_upstream_cancel (INV-005 load-bearing — drives
the stream generator directly + cancels the consuming task; would
have caught the turn_id bug)
- cancel_targets_upstream_turn_id, cancel_before_started_is_noop,
cancel_failed_500
- server-side end_user_id: uses / ignores-body / omits-when-unset
- create_app: routes_registered / state_attached / factory_stored
- entrypoint: default_host / port_zero / happy_argv / open / no-open
- real_import_bug_propagates (precision guard)
- full_event_vocab at the stream-endpoint layer
Contract #16 amended: v0.16.0 amendment banner + INV-005/006 reworded
for upstream_turn_id + FN sketches corrected (server-side end_user_id,
upstream_response→upstream_turn_id, manual client lifecycle vs the
non-executable async-with sketch, not-started cancel branch).
Live smoke against personal:8081 during the v0.15.0 web-companion
verification surfaced that real Worldtree returns persona_state
errors in the FastAPI default envelope shape:
{"detail": {"error_code": "auth_scope_denied", "message": "..."}}
The v0.12.0 `get_persona_state` parser only inspected the top-level
`error_code` key. When the field was nested under `detail`, the
typed exception (AuthScopeDenied / PersonaNotConfigured /
AgentNotAvailable) wasn't raised; the call fell through to
SessionApiFailed, which then surfaced through the web companion as
an opaque HTTP 500 on /api/agents/{id}/persona_state.
The original test_sessions.py mocks used the flat-shape envelope, so
the bug was invisible in unit tests until the real-wire smoke.
Fix: extract error_code from either `err.get("error_code")` (flat)
OR `err.get("detail", {}).get("error_code")` (FastAPI default).
Patch per SemVer discipline — bug fix to v0.12.0 surface, no public
signature change, no new behavior. Callers that were getting the
wrong exception now get the right one; callers that were already
getting the right exception (flat-shape paths) are unchanged.
Tests: 2 new regression cases in TestGetPersonaState — one each for
the detail-envelope shape of 403 auth_scope_denied and 404
persona_not_configured. Suite: 358 passing.
Spec pin moved da93ca7 (v0.28.0) → 562001a (v0.29.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted.
The only material delta is Worldtree #201's awaiting_llm_first_token
SSE heartbeat — a top-level event (NOT a worker_phase extension, per
INV-053's three-field stability) that fires at a configurable interval
(default 5s) during the BuildingPrompt → CallingLLM gap.
Wire layer (sse_client.py):
- New `AwaitingLlmFirstToken` dataclass: sse_id / turn_id /
elapsed_ms_since_building_prompt (server-authoritative monotonic)
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any slow-first-token turn
from a v0.29.0 server (unknown SSE event type → ValueError)
TUI layer (tui.py):
- Audit pipeline: per-event debug-pane line with elapsed in seconds
- Live transcript indicator: first heartbeat mounts a Static
("awaiting first token · 5.0s"); subsequent heartbeats update it
in place; any non-heartbeat event removes it (the gap closed)
- Turn-summary line now carries heartbeat count
- Indicator demoted via .awaiting-label CSS so it reads as ambient
progress, not content
Tests: 2 wire-layer (single + monotonic sequence) + 3 presenter
(audit line shape, single-mount semantic, indicator removal on gap
close). Suite: 318 passing.
Step 3 of the Worldtree #204 integration: visible persona-state UX.
Pairs with v0.11.0's AffectUpdate SSE event + v0.12.0's
get_persona_state HTTP client — together those gave the data; this
bump surfaces it.
Two surfaces (Option C: both):
Sticky persona-header (top of screen, dock=top, height=1):
- Shape: `agent_id · dominant_emotion · pad(P, A, D) · N emotions
active` — concise enough for at-a-glance scan above the chat
- Starts hidden via `.empty` CSS class; height collapses to 0 when
the agent has no persona surface
- Refreshes on AffectUpdate(status="current") snapshots
Persona TabPane (Ctrl+4):
- Full snapshot detail: dominant emotion, PAD axes with baseline +
delta, mood drift, active emotions with intensity + decay
(minutes-rounded), last_updated_at footer
- Replaced (not appended) on each new snapshot — snapshots are
absolute state, not incremental
Lifecycle:
- on_mount spawns a Textual worker that calls get_persona_state to
hydrate header + pane before turn 1
- PersonaNotConfigured (domari, muninn, Tier 3) → pane carries an
italic placeholder, header stays empty
- AgentNotAvailable / AuthScopeDenied / network error → italic
failure placeholder; audit-logged; never crashes
- Presenter's render() takes an optional `on_persona_snapshot`
callback so AffectUpdate(current) refreshes both surfaces during
a live turn (no widget coupling — App owns the callback)
Tests: 10 new (4 formatters, 3 presenter callback, 4 layout/binding/
hydration). Full suite: 313 passing.
Adds the read-side half of Worldtree #204's persona-state observability
surface. Pairs with v0.11.0's AffectUpdate SSE event — together they
let a consumer hydrate a persona pane on session-open (this GET) and
keep it live as turns fire (the SSE event).
Public surface:
- `get_persona_state(client, agent_id) -> dict[str, Any]` — GET
/agents/{agent_id}/persona_state, returns the same `snapshot` dict
shape as AffectUpdate.snapshot
- New exception types mapped from the spec's documented 4xx error_codes:
- `PersonaNotConfigured` (404 persona_not_configured) — agent has
no persona surface (domari, muninn, all Tier 3 in Phase 2.0)
- `AgentNotAvailable` (404 agent_not_available) — unknown agent_id
- `AuthScopeDenied` (403 auth_scope_denied) — key lacks the
requested scope (persona.read here; reusable for future scoped
endpoints)
- Other non-2xx falls through to the existing SessionApiFailed
precedent so novel failure modes aren't silently absorbed
Tests: 6 new cases covering happy snapshot return, each typed 4xx
sub-code, unknown 404 fall-through, and 5xx SessionApiFailed parity.
Not yet consumed: TUI persona surface (Persona TabPane / sticky
header line). UX shape pending operator direction — step 3.
Spec pin moved 55101e9 (v0.19.0) → da93ca7 (v0.28.0); vendored
conversation-api-spec.md + conversation_api.contract.md re-snapshotted
from Worldtree at the new SHA. The only material delta consumed in
this bump is Worldtree #204's affect_update SSE event surface.
Wire layer (sse_client.py):
- New AffectUpdate dataclass: sse_id / status / turn_id / snapshot
(snapshot is None for status="scheduled")
- Added to Event union + _envelope_for_type dispatch branch
- Without this, ratatoskr would crash on any persona-enabled turn
from a v0.28.0 server (unknown SSE event type → ValueError)
TUI layer (tui.py):
- AffectUpdate routes through the v0.10.0 audit pipeline only — one
debug-pane line per arrival with dominant_emotion + PAD for
status="current", lightweight status+turn_id for status="scheduled"
- No transcript / tools / thinking pane writes — the persona UX shape
(Persona TabPane vs sticky header line) is deferred to a separate
bump pending operator direction
Tests: 2 new wire-layer tests for current+scheduled parsing + 2 new
presenter audit tests for routing and audit-line shape.
Not yet consumed: GET /agents/{id}/persona_state endpoint (step 2 of
the integration plan).
Replaces v0.8.2's drop-Markdown patch with proper in-place Markdown
rendering. The transcript becomes a VerticalScroll container; each
turn's response body lives as a single Static widget whose content
is updated as Text deltas arrive — Markdown is re-rendered in place
rather than re-printed on Done. Eliminates the v0.8.x double-print
without sacrificing rich formatting.
- transcript: RichLog → VerticalScroll (#transcript-scroll)
- Text deltas: mount Static(Markdown(buffer)) on first delta;
Static.update(Markdown(buffer)) on subsequent deltas
- --raw mode: bypass Markdown, mount Static(plain_str) for the same
in-place update semantics
- Terminal events (Done/Error/Cancelled) mount styled label Statics
- _cancel_via_sse: write → mount Static on the new container
- _write_turn_headers: transcript gets a styled RichText Static
("── turn N ──"); other panes still receive Rule renderables
- Test suite reshape: bulk rename `log` → `transcript` for the
presenter contract, `_mounted_renderables` helper extracts
Static.content for assertion, `_spy_writes` captures both
RichLog.write and VerticalScroll.mount
Operator: "first turn double prints agent's turn."
Root cause: v0.8.1 wrote both the streamed Text lines AND the post-
Done `Markdown(event.response)` body into the transcript. Same
content rendered twice — once as plain streaming, once as a full
markdown re-render. The v0.8.1 commit message documented this as
"some duplication is acceptable" but the live UX read as a bug.
## Fix
Drop the post-Done `Rule + Markdown(response)` writes in non-raw
mode. The streamed text IS the response; whatever the model emitted
flows into the transcript line-by-line via coalesce-on-newline.
Markdown formatting (bold, lists, code blocks) renders as plain
text — a known regression from v0.8.1's polished output but the
right tradeoff vs the duplication bug.
## What this loses temporarily
Pre-v0.8.2 (after Done):
[done] turn_id=... ───
─── (Rule separator) ───
**Bold text** rendered bold, `code` highlighted, lists as bullets, etc.
v0.8.2 (after Done):
[done] turn_id=... ───
**Bold text** as plain asterisks, `code` as backticks, lists as plain dashes
## v0.9.0 plan
Restore markdown rendering via LIVE rendering during the stream
(not post-Done re-render). Replace `RichLog#transcript` with a
`VerticalScroll` container that mounts a fresh `Markdown` widget
per turn; Text deltas update the widget; markdown renders as
content arrives. No duplication, no snap, full formatting.
Operator-confirmed direction (2026-05-25 AskUserQuestion).
## Tests
287/287 GREEN; ruff clean. Two tests updated for the new shape:
- test_done_renders_markdown_after_label → renamed
test_done_flushes_tail_and_writes_label; asserts NO Markdown, NO
Rule (post-Done) in the writes.
- test_happy_text_done_renders_markdown → renamed
test_happy_text_done_no_double_print; asserts NO Markdown in the
spy.
Patch bump (v0.8.1 → v0.8.2): bug fix; no public API change.
Two related fixes for the same user-reported bug pattern from a
running session against ratatoskr:sindra (qwen3.6-35-a3b-heretic):
## 1. Streaming text overlapping the transcript
Operator: "new text comes at the bottom and overwrites the existing
pane information instead of pushing it up naturally."
Root cause: the v0.6.0 `#current-text` Static was `dock: bottom`
with `height: auto`, sitting between the transcript RichLog (1fr)
and the prompt Input (dock: bottom). As text streamed, the Static
grew UPWARD but Textual didn't dynamically resize the 1fr transcript
to accommodate — the growing Static visually OVERLAPPED the
transcript's bottom rows. On Done, `current_text.update("")` snapped
it to height 0 and the transcript re-laid-out — "boom, everything
updates."
Fix: remove `#current-text` Static entirely. Apply the same
coalesce-on-newline pattern v0.7.1 used for thinking — Text deltas
accumulate in `TuiPresenterState.text_chunk_buffer`, flushing whole
lines (each `\n` boundary) directly to `log` (transcript). On Done:
flush remaining tail, then [done] label + Rule + Markdown body.
Trade-off accepted: streamed lines + post-Done Markdown body are
both in the transcript (some content duplication). The Markdown
body re-renders the same content with proper formatting (lists,
bold, code blocks). Acceptable — operator gets both the live-progress
streaming AND the canonical rendered version.
## 2. MalformedSseId raw='' crashing every turn
Operator: "current session is erroring on every turn with
[malformed_sse_id] raw=''"
Worldtree's qwen3.6-35-a3b-heretic provider emits some events
without `id:` lines (observed 2026-05-25 mid-stream). When the FIRST
such event arrives before any prior id has been seen, httpx_sse's
`ServerSentEvent.id` is `""`. `_parse_sse_id('')` raised ValueError
→ MalformedSseId → turn worker bailed → operator saw the label
every turn.
Per SSE RFC, events without `id:` are legitimate (they just don't
update Last-Event-ID). Issue #7 already covered the empty-DATA
keepalive case with skip-silently semantics. Empty-id is the same
shape of wire weirdness; same fix shape:
if sse.id == "":
continue # treat as keepalive
Ordered AFTER the empty-data branch so an empty-data + empty-id
event still gets skipped on the data check.
## Tests + smoke
287/287 GREEN (was 286, +1 for empty-id skip; +1 net Text-flow test
adjustments). Ruff clean.
Verified Worldtree alive when the user hit the empty-id bug
(/healthz returned ok in 18ms) — not a server-down issue, just
wire-format mid-stream.
## Caveats
The fix doesn't recover content from the dropped empty-id event.
If the event happened to carry meaningful data (not a true
keepalive), we silently lose it. Acceptable trade-off: pre-v0.8.1
EVERY turn died on the offending agent; post-v0.8.1 the turn
continues and any single dropped frame is recoverable from logs if
debugging. Worldtree-side fix (always emit ids) is the right
upstream answer; ratatoskr just stops panicking on wire weirdness.
Patch bump (v0.8.0 → v0.8.1) — both fixes are bug fixes; no public
API change. The `TuiPresenterState.render` signature loses the
`current_text` parameter (was added v0.6.0), but presenter is an
internal contract; no external callers.