Compare commits

..

5 Commits

Author SHA1 Message Date
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
13 changed files with 954 additions and 276 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.**
@@ -10,7 +10,7 @@ complexity: "medium"
estimated_loc: 180 estimated_loc: 180
confidence: 0.85 confidence: 0.85
assumptions: 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." - "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)." - "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)." - "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 - source: ~/development/bifrost/docs/contracts/affect.contract.md
invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane
- source: ~/development/bifrost/bifrost/reference_server/affect.py - 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: revisions:
- version: "1.1" - version: "1.1"
at: 2026-06-14 at: 2026-06-14
@@ -39,6 +41,22 @@ revisions:
- "basic_emit wording — semantic round-trip (was: byte-identical)" - "basic_emit wording — semantic round-trip (was: byte-identical)"
REMOVED: REMOVED:
- "the 'same idempotency_key + different content hash -> LWW overwrite' clause (it was backwards: bifrost treats that as a conflict)" - "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 ## 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; conflict cache: `digest` is a content fingerprint of the snapshot;
`expires_at` records the short-retry deadline for a future pruning pass `expires_at` records the short-retry deadline for a future pruning pass
(TTL eviction deferred — see INV-009). (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). `{"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 **Async surface:** `emit` and `fetch` are `async def` (the bifrost consumer
it); `open_affect_store` and `get` are sync (no I/O await — `get` is a read-back Protocol awaits them); `open_affect_store` and `get` are sync (no I/O await —
seam). The `FN` lines below omit the `async` keyword only because the contract `get` is the read-back seam `fetch` wraps). The `FN` lines below omit the
grammar's `FN <name>` form has no async marker. `async` keyword only because the contract grammar's `FN <name>` form has no
async marker.
## Invariants ## Invariants
@@ -113,7 +137,7 @@ grammar's `FN <name>` form has no async marker.
`stored` specifically). `stored` specifically).
- **INV-005** [hard]: The store advertises `affect_supported = True`; it is the - **INV-005** [hard]: The store advertises `affect_supported = True`; it is the
REQUIRED store — `build_affect_app(store=None, ...)` raises (no silent 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 - **INV-006** [hard]: Authorization identity/scope — and the **idempotency
actor** — are taken from `ctx` (DispatchContext), never from the snapshot or actor** — are taken from `ctx` (DispatchContext), never from the snapshot or
other call arguments. The snapshot addressing keys are used ONLY as the 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 grows unbounded until a follow-up pruning patch. Wire-observable behavior is
unaffected (replay/conflict still resolve correctly); only cache size 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)`. `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 ## Concurrency
@@ -186,8 +217,10 @@ already have rejected a malformed envelope.
Protocol are a later contract. Protocol are a later contract.
- **The combined two-plane server** (guide §7): one handshake negotiating both - **The combined two-plane server** (guide §7): one handshake negotiating both
memory + affect is deferred; `build_affect_provider_app` mounts affect alone. memory + affect is deferred; `build_affect_provider_app` mounts affect alone.
- **`affect.fetch` / `affect:read` / persona-baseline rehydrate**: RESERVED in - **`affect:read` scope enforcement / persona-baseline rehydrate shaping**: the
v1; only `emit` + the test-only `get()` exist. 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 - **`idempotency_class`**: accepted and ignored (affect.* uses a single
short-retry class). short-retry class).
- **WAL/concurrency hardening, deployment DB path, auth-key provisioning**: - **WAL/concurrency hardening, deployment DB path, auth-key provisioning**:
@@ -254,7 +287,7 @@ TESTS:
```contract ```contract
FN get(self, agent_id: str, end_user_id: str) -> dict | None 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) POST: [POST-001 return_value] returns the verbatim snapshot for the key, or None if absent -- (INV-003)
STEPS: STEPS:
1. [sequential] SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ? 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 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 ```contract
FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette 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. BRIEF: Wire the JWT verifier + registration and hand the store to bifrost's build_affect_app.
+249
View File
@@ -0,0 +1,249 @@
---
contract_version: "2.1"
target_module: "ratatoskr.provider.affect_store + ratatoskr.web (server + static/index.html)"
scope: "Issue #18 DELIVERABLE 2 ONLY — the PAD read-endpoint so the web pane renders live PAD/valence for a Tier-3 agent from OUR :8390 affect store. Three pieces: (1) a NON-bifrost read route on the affect-store-owning app — GET /affect/state/{agent_id}?end_user_id=… → store.get(agent_id, end_user_id); (2) a web proxy GET /api/affect/{agent_id} that supplies end_user_id SERVER-SIDE (RATATOSKR_END_USER_ID), never from the browser, and proxies to a CONFIGURED affect-read base URL (RATATOSKR_AFFECT_READ_URL) so the pane is decoupled from the bind target; (3) a NEW pane render path for the affect-emit snapshot shape (pad + per-entity valence + emitted_at) — NOT a reuse of renderPersonaPane (the Tier-1 persona_state shape we never receive for Tier-3). DELIVERABLE 1 (composite :8392 endpoint) is DEFERRED — bifrost-blocked on a public bifrost.consumer.build_combined_app (bifrost-dev confirmed, ~v0.9.0, design locked) AND gated on the open FR-1 Worldtree-dispatch question (worldtree-dev consult in flight). When build_combined_app lands and FR-1 resolves, this contract is AMENDED to add Deliverable 1. Direct in-session TDD (the #17 pattern). The panel framing-consult (Heid, 3 arms) pressure-tested this design; its triaged findings are folded in as INV/POST clauses below."
depends_on:
- "httpx"
- "starlette"
- "ratatoskr.provider.affect_store"
- "ratatoskr.web.server"
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.
## Out of scope / DEFERRED (anti-creep)
- **Deliverable 1 — composite :8392 endpoint** — bifrost-blocked (public `build_combined_app`,
~v0.9.0, design locked) + FR-1 (Worldtree dual-plane dispatch, worldtree-dev consult in
flight). Added by amendment when both resolve. This is the SAME issue, not a new one.
- 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.
+100 -253
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr # Persistent memory — ratatoskr
_Last updated: 2026-06-16_ _Last updated: 2026-06-18_
This file captures durable intent and supporting evidence (goals, decisions, This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -25,248 +25,109 @@ handshake state, admin lifecycle events, optional raw server log.
Named after the squirrel that runs up and down Yggdrasil carrying messages Named after the squirrel that runs up and down Yggdrasil carrying messages
between layers. On-the-nose Worldtree resonance (Yggdrasil = the World Tree). between layers. On-the-nose Worldtree resonance (Yggdrasil = the World Tree).
Origin: althing ask from worldtree-dev (thread `01KS3R34XD3N6HMK91VXESHGW7`, **Second identity (since 2026-06-14): the v1 Bifrost Tier-3 consumer/provider**
2026-05-20) for the shape of a TUI Conversation API consumer. brokkr-smithy the durable persistence Worldtree writes Tier-3 agent affect (PAD/persona, `:8390`)
ran the shape pass; operator's reframe routed it as a new repo with a + memory (`:8391`) into. Lives in `src/ratatoskr/provider/`, depends on `bifrost`
separate dev team rather than an in-tree Worldtree tool. (`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` **v0.15.0+ sibling browser surface** (`ratatoskr.web`, `ratatoskr-web` console
console script). Same five-pane debug surface (transcript / Tools / Debug / script): same five-pane debug surface over the same SSE wire, LAN-viewable.
Thinking / Persona) consuming the same Worldtree SSE wire, viewable from any Internal-LAN trust model — `0.0.0.0`, no auth/TLS/CORS (operator direction).
device on the operator's LAN. Sibling viewport, NOT a TUI replacement; the Disciplined regardless: transcript HTML-escapes assistant content (INV-004);
TUI is canonical. Internal-LAN trust model — bound to `0.0.0.0`, no auth, upstream API key stays server-side (INV-003).
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).
## Current state / in-flight ## Current state / in-flight
_As of 2026-06-18:_ _As of 2026-06-19:_
**LATEST (2026-06-18) — TIER-3 MEMORY PROVEN END-TO-END LIVE; #296 FIX SHIPPED (Stage 1 + Stage 2).** **#18 DELIVERABLE 2 SHIPPED + PUSHED — the persona-telemetry gap is CLOSED.** The web
The session's opening goal — "how far from Tier-3 agents we host with their own memories?" — is pane now renders live PAD/valence for Tier-3 agents from OUR `:8390` affect store
ANSWERED: a hosted agent recalled a user fact across a COLD (history-free) session, live. Full chain: (`v0.17.14`, `39eebd1`, suite **482 green**, **pushed to origin**). Three pieces:
seed → promotion → our v0.17.6 `:8391` store (upsert) → cold session → WT `scope_any` recall (1 hit @ provider read route `GET /affect/state/{agent_id}` (non-bifrost, added to the affect app
cosine 0.6994) → model answered "Dark chocolate with sea salt." Proven with `ratatoskr:terse-probe` via `app.add_route` — keeps `/bifrost/*` top-level + op-feed-skipped); web proxy
(plain persona, end_user `terse-proof`) vs personal WT v0.35.19. `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.
The whole #296 arc closed this session: **#18 DELIVERABLE 1 (composite `:8392` endpoint) — PARKED on bifrost** (tracked Gitea #18).
- **Stage 1** (v0.35.19, recallability admission gate + real `_EXTRACTOR_SYSTEM`) VALIDATED LIVE for Routed to bifrost-dev for a public `build_combined_app` rather than hand-rolled from
normal-volume turns — clean first-person distillate (`distiller_model_id="identity"`), recalls @0.70. bifrost privates (debug-surface-uses-canonical principle). bifrost-dev confirmed it: clean
- **Bisect verdict:** residual = verbose-persona VOLUME crowd-out (symptom 1). terse-probe promotes the additive minor (~`v0.9.0`), design locked (advertise-by-presence handshake, per-route
fact cleanly; sindra's theatrical turn drowns it → v0.35.16 emits 2 meta-descriptions (a3466fab confirmed), call-time isolation), slotted AFTER WT #289. FR-1 RESOLVED — composite is bifrost-only,
v0.35.19 gate catches + hard-rejects (emit-then-reject, fail-closed). ZERO Worldtree change (single-endpoint caps-routed, worldtree-dev code-verified). NEXT:
- **Stage 2** (v0.36.0, MERGED on demo) = user-only one-call-per-turn extraction, the STRUCTURAL fix for when bifrost ships `build_combined_app`, **repin + reimplement D1 against it** (per-plane
the verbose drop; hard-linguistic layer at #305. Live-validated eval fixture pair handed to worldtree-dev failure status + op-feed plane-per-request derivation already specced in the issue).
(terse-proof POSITIVE chunk + choc-v2/sindra NEGATIVE). Nothing blocks on our side.
**FULL COVERAGE PROVEN (the OPEN item, now DONE):** re-smoked the VERBOSE persona — `ratatoskr:sindra-probe` **OPERATOR SESSION STATE — running shells are PRE-#18 code (foot-gun).** web `:8765` +
(sindra's actual theatrical prompt, end_user `verbose-v2`, FRESH agent to dodge the WT promotion-dedup) against affect `:8390` + memory `:8391` are the prior session's background shells running OLD code
`:8081` **v0.36.0**: the high-volume theatrical turn promoted the USER FACT cleanly (not a meta-description) and a (no read route; web has no `RATATOSKR_AFFECT_READ_URL`). To see D2 live in the operator's
COLD history-free session recalled it @ 0.694 — "Dark chocolate. With sea salt." So `:8081` is on v0.36.0 and own session, RESTART `:8390` (affect provider, new code → gains the read route) + `:8765`
verbose characters remember too. All session commits + tags PUSHED to origin (v0.17.6, spec repin v0.35.16, (web, new code + `RATATOSKR_AFFECT_READ_URL=http://127.0.0.1:8390` + `RATATOSKR_END_USER_ID`).
snapshots; tags v0.17.4/.5/.6). 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).
**NOW (operator session):** `:8391` store WIPED clean; `ratatoskr-web` UP on `0.0.0.0:8765` **Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
(LAN `http://10.100.10.50:8765`, consumer key — owner-matches sindra, end_user `vuong`, `ratatoskr:sindra` in the cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
picker). Sindra switched to the **`thoughtful-character` role → `mistral-small-4-reasoning`** (via DELETE+redefine; `sindra-probe` too under #296 Stage 2 (v0.36.0). The #296 extraction-quality arc closed
she chats in full persona on it). **"No persona" 404 diagnosed = Worldtree limitation, NOT ours:** `persona_state` (Stage 1 v0.35.19 gate + Stage 2 v0.36.0 user-only extraction at worldtree-codex; hard-
hard-404s every Tier-3 (colon-id) agent by design (`api.py:1220` "Phase 2.0 has no Tier 3 persona", still on linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
v0.36.0) — a persona block in the define does NOT help (404 fires before the row read). Pinged worldtree-dev re:
the Tier-3 persona_state roadmap (thread `01KVCR6P…`). De-uglied our web persona pane (`index.html` `loadPersona`)
to render a clear Tier-3 message instead of a bare "HTTP 404". **CAVEAT still stands: the web client does NOT bind
to `:8391`/`:8390` (#17 unbuilt) → web chat = persona + debug only; no memory persistence, no affect telemetry.**
**NEXT (fresh context): #17 — bifrost-binding the chat client.** The single unblock for BOTH (a) memory persistence **Sindra:** `ratatoskr:sindra`, `thoughtful-character` role → `mistral-small-4-reasoning`
in the web/TUI/CLI chat (Sindra remembering in a real session, not just scripts) AND (b) feeding the persona pane (DELETE+redefined on v0.35.16; `memory:{}` block trips the promotion gate). Owner-scoped
from OUR `:8390` affect store — `affect.emit` PAD/valence, ours to render per ADR-0009 (NOT a WT persona_state to (separate `consumer_agents` table) — invisible to `GET /agents`; check via
wait for; see the reframe in Recent decisions). Contract `docs/contracts/issues/17.contract.md` is WRITTEN + `GET /agents/<owner>:<name>` with the owner key.
Heid-reviewed + drift-clean; **TDD is the next step** — slice 1 = the `create_session` bifrost-binding primitive
(consumer-key per-request bearer + missing-key precondition + 502→`BifrostHandshakeFailed`, respx-mocked), then
`endpoint_for_plane` → dispatch-layer op-feed → CLI/TUI/web triggers → live smoke. althing monitor armed.
**(2026-06-16 PM) — BIFROST REPINNED 0.7.0→0.8.0 (wire v0.5→v0.6).** The **Standing:** Worldtree spec pin v0.35.16 (`f1b59f8`); bifrost 0.8.0 / wire v0.6
memory `search` scope filter was split into `scope_all` (AND/intersection) + (`scope_all`+`scope_any`); WT handshake now advertises `bifrost_version 0.6.0` (worldtree-dev
`scope_any` (OR/union over a LIST of conjunctive scopes) — bifrost #11, the canonical honesty-fix FYI `858ba58` — we don't pin/assert it, no-op our side). Heimdall key env-only
fix for the #295/#297 silent-zero AND foot-gun. Our store + contract (v1.2) + tests at `~/.config/ratatoskr/provider.env` (mode 600); rotate via infra-ops. `graphify-out/`
reimplemented to parity with the v0.6 reference `_matches_scope`/`_validate_scope` runs dirty (auto-regen, not chased). Open issues: #10 (subject migration), #11 (AdminEvents
(no-compat: `scope_filter` REMOVED). 433 tests green incl. the new `scope_any` union pane) — deferred; **#18** (D2 PAD-read SHIPPED `v0.17.14`; D1 composite PARKED on bifrost
test + the parity-vs-reference test through the real 0.8.0 `dispatch_memory_call`. `build_combined_app`). Codex-first pilot dormant.
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). Shipped as v0.17.6 (`96d61a4`, tag v0.17.6); the #17
contract's stale `scope_filter`/`_scope_matches`-AND references were synced in the same
commit. 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). Cold-recall
status SUPERSEDED by the 2026-06-17 block above (now CLOSED end-to-end).
**Ratatoskr now has a SECOND identity: the v1 Bifrost Tier-3 consumer** — the Branch: `main` (== `origin/main` @ `39eebd1`). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
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.
**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.
**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).
**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.)
**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.
**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 now v0.35.16 (`f1b59f8`) for the
conversation-API/TUI surface (bumped 2026-06-17 from v0.29.0/`562001a`). 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`.
## Recent decisions ## Recent decisions
Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per
decision. Captures rationale that won't be obvious from code alone. 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]` **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]` **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-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]` **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-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, 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]` **#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).** 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]` **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]` **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]` **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]` **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]` **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]` **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. _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
- `[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.**
- `[2026-06-17]` **Worldtree spec pin bumped v0.29.0→v0.35.16 (`562001a`→`f1b59f8`); cold recall CLOSED end-to-end.** Worldtree shipped #297 (client-side per-scope-value union recall) + #298/#299 (adopt the bifrost v0.6 `scope_any`/`scope_all` wire) — it now emits `scope_any` on recall, the upstream half that pairs with our v0.17.6 provider. Re-vendored `conversation-api-spec.md` + `conversation_api.contract.md`; diff-reviewed the 285-commit catch-up — no client-breaking changes (#211 agent-slug rename saga→echo/actor→mask is slugs-only; #245 end_user_id+memory-scope; #187/#188/#219 Tier-3 define/PATCH additive; error codes stable). Shipped as a `pin:` commit, NO package bump (docs/pin-only, no ratatoskr code; per SemVer SKIP for docs-only). **Remaining proof: a live cold-recall re-smoke against a personal instance on v0.35.16** — the smoke target ran v0.35.2/.3, needs infra-ops to update it.
- `[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 (WT emits `scope_any`), write path (promote→upsert→persist), and cold read (scope_any returns hits) ALL proven. Lone gap = upstream #296 extraction quality. Refines the spec-repin entry's "cold recall closes end-to-end": the WIRE closed; actual fact-recall is #296-blocked. (triage `01KVBBH0…`)
- `[2026-06-17]` **DELETE+redefine `ratatoskr:sindra` (operator-authorized; pre-v1 debug surface).** She SURVIVED the rebuild but was STALE (dead model `artemis-31b-v1i` + no memory block from Phase-2.0 tooling); 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`). Reasoning bind needs a `character`-role bind reorder (worldtree-dev flagged to operator). Our `tier3.py` define is Phase-2.0-stale (no role/memory/persona layers) — 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. The seed promoted at ~4min (per-turn salience/turn-scan, before the idle floor).
- `[2026-06-17]` **#296 triage sent to worldtree-dev** (`01KVBBH0QRGT34GDJQEX5ACTT1`): extraction SUBJECT-INVERSION (promotes assistant prose, drops the user's fact) + META-DESCRIPTION-not-content; verbose-persona aggravator. The WAD-vs-bug cut 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 → "Dark chocolate with sea salt"). The session's headline; closes the opening "how far from Tier-3 memory" question for normal agents. (proof in this session's transcript)
- `[2026-06-18]` **#296 Stage 1 (v0.35.19) validated live + bisect localized the residual to VERBOSE-PERSONA VOLUME.** Stage 1's recallability admission gate works for normal turns (clean distillate, `distiller_model_id="identity"`); the terse-probe-promotes / sindra-drowns bisect pinned the remaining failure to symptom 1 (assistant-prose volume crowding out the user fact at extraction).
- `[2026-06-18]` **#296 Stage 2 (v0.36.0) MERGED at worldtree-codex** — user-only one-call-per-turn extraction, the STRUCTURAL fix for the subject-inversion drop sindra exposed. Staged per operator: core fix now (validated by our terse-probe PASS + WT test suite); hard-linguistic layer (larger-model escalation, marker-less-fact regex-demotion) tracked at Worldtree #305. We handed worldtree-dev a live-validated eval fixture PAIR for #305 (terse-proof POSITIVE chunk + choc-v2/sindra NEGATIVE-drop).
- `[2026-06-18]` **Full root-cause chain traced + confirmed** (a3466fab ledger pull via infra-ops): v0.35.16 verbose-sindra EMITS 2 meta-descriptions → v0.35.19 gate hard-rejects them (emit-then-reject) → v0.36.0 user-only pass emits the fact. The 2 v0.35.16 chunks ("Assistant's detailed flirtatious response…" / "…head tilt, eye locking, freckles…") are textbook subject-inversion; banked as #305 negative-fixture examples.
- `[2026-06-18]` **FULL-COVERAGE proof — verbose-persona memory works under Stage 2 (v0.36.0).** Re-smoked `ratatoskr:sindra-probe` (sindra's theatrical prompt; FRESH agent + end_user `verbose-v2` to dodge the WT promotion-dedup): the high-volume turn promoted the user fact CLEANLY (not a meta-description) and cold-recalled @ 0.694. Confirms `:8081` is on v0.36.0 and closes the verbose-persona caveat end-to-end. Then started `ratatoskr-web` (`:8765`, consumer key, `ratatoskr:sindra` in the picker) for the operator's Sindra session — **persona + debug only; the web client does NOT bind to `:8391` (#17 unbuilt), so no memory persistence in the web chat** (memory-enabled chat is the #17 build, or a manual bound session).
- `[2026-06-18]` **Tier-3 persona_state is a Worldtree limitation (not ours); sindra → thoughtful-character.** The web Persona-pane 404 ("persona not available") = `persona_state` hard-404s ALL Tier-3 (colon-id) agents by design (`api.py:1220`); a persona block in the define doesn't help (endpoint short-circuits before the row). Pinged worldtree-dev on the Tier-3 persona_state roadmap (thread `01KVCR6P…`) + de-uglied our web pane (`index.html loadPersona`) to render a clear message (v0.17.7). Separately switched `ratatoskr:sindra` to the `thoughtful-character` role (→ `mistral-small-4-reasoning`) via DELETE+redefine (kept her prompt; added an inert persona block). **REFRAME (worldtree-dev `01KVCRK9…`):** persona_state GET is **Tier-1-only by ADR-0009**, the colon-id 404 is correct-by-design (NOT a stub to wait for). Tier-3 affect is **CLIENT-persisted** — we ALREADY hold the PAD `{pleasure,arousal,dominance}` + valence at `:8390` from the `affect.emit` payload. So the Persona-pane fix is **OUR side**: #17 affect-binding → WT dispatches `affect.emit``:8390` → the web pane renders PAD/valence from our own store. WT #289 (`affect.fetch`/`affect:read`) = optional mediated-read (their intelligence over our raw store), NOT a prerequisite. WT #300 = their forthcoming v1 client-impl guide (who-owns-what), folding in this exact case.
_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._ _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._
@@ -275,38 +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 Log of approaches that were tried and rejected, with rationale. Future-self
defense against re-attempting the same cul-de-sac. 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-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-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-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-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]` **"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]` **"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]` **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]` **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` 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 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; 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]` **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, 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]` **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, and same-session "recall" can be plain session history. Don't call cross-session recall proven from a clean wire.
- `[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.** 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-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` before running the drift-checker here.
- `[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`, 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]` **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]` **#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]` **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).** 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-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-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]` **"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 whether a Tier-3 agent exists, `GET /agents/<owner>:<name>` with the OWNER key. (Extends the 2026-06-15 "Sindra registration under-verified" lesson — even the roster query can't see consumer agents; only the owner-scoped point-GET does.) - `[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-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 (assistant-prose promoted, user-fact dropped), not non-firing. "No upsert while a session is live and `<10min` idle" is WAD, not a miss. (Extends the 2026-06-16 "checked too early" entry with the multi-trigger timing + the fired-but-bad-content distinction.) - `[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]` **Wiping our `:8391` store does NOT reset Worldtree's promotion-side dedup** — the v0.35.19 sindra re-smoke (same agent + fresh end_user `choc-v2`) returned `reason_code=noop_duplicate` / `candidate_count=0`: the extractor NEVER RE-RAN, dedup short-circuited against the earlier `sindra-proof` promotion. So a "0 chunks / empty store" result can be a DEDUP artifact, not Stage 1 fail-closing — I mis-read it as fail-closed, worldtree-dev caught it from the ledger. **For a clean promotion smoke, use a BRAND-NEW agent + end_user (never-used names)** — exactly why terse-probe was valid. (Also: `llm_calls_used=0` is NOT the "did the extractor run" tell — `noop_duplicate` is; a3466fab promoted 2 candidates with `llm=0`.) - `[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._
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.13" version = "0.17.15"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -30,7 +30,7 @@ web = [
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md. # from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
provider = [ provider = [
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack "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 "jsonschema>=4", # bifrost runtime dep — envelope validation
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table) "sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
] ]
+64 -3
View File
@@ -20,6 +20,8 @@ from typing import Any
from bifrost.affect import AffectIdempotencyConflict, AffectInvalidArguments from bifrost.affect import AffectIdempotencyConflict, AffectInvalidArguments
from bifrost.consumer import ConsumerRegistration, build_affect_app from bifrost.consumer import ConsumerRegistration, build_affect_app
from bifrost.reference_server import JwtVerifier from bifrost.reference_server import JwtVerifier
from starlette.requests import Request
from starlette.responses import JSONResponse
_SHORT_RETRY_TTL_SECONDS = 300 _SHORT_RETRY_TTL_SECONDS = 300
@@ -100,17 +102,50 @@ class RatatoskrAffectStore:
return {"stored": True} return {"stored": True}
def get(self, agent_id: str, end_user_id: str) -> dict | None: 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( row = self._conn.execute(
"SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?", "SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?",
(agent_id, end_user_id), (agent_id, end_user_id),
).fetchone() ).fetchone()
return json.loads(row[0]) if row is not None else None 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: def open_affect_store(db_path: str) -> RatatoskrAffectStore:
"""Open the SQLite-backed affect store, creating the schema on first use.""" """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:": if db_path != ":memory:":
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute( conn.execute(
@@ -143,4 +178,30 @@ def build_affect_provider_app(
raise ValueError("heimdall_key must be non-empty bytes") raise ValueError("heimdall_key must be non-empty bytes")
verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key) verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
registration = ConsumerRegistration(consumer_id=consumer_id) 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): a NON-bifrost PAD read route added as a top-level
# sibling of the bifrost routes (add_route, not Mount — keeps /bifrost/* top-level
# so the op-feed's path check still matches them and skips this one). Internal-LAN
# trust model: no JWT on the read.
async def _affect_state_route(request: Request) -> JSONResponse:
agent_id = request.path_params["agent_id"]
end_user_id = request.query_params.get("end_user_id")
if not end_user_id: # PRE-001: never look up against a None/empty partition
return JSONResponse(
{"error_code": "missing_end_user_id"}, status_code=400
)
snap = store.get(agent_id, end_user_id)
if snap is None: # INV-003: explicit no-data, never a fabricated zeroed PAD
return JSONResponse(
{
"error_code": "no_affect_snapshot",
"agent_id": agent_id,
"end_user_id": end_user_id,
},
status_code=404,
)
return JSONResponse(snap)
app.add_route("/affect/state/{agent_id}", _affect_state_route, methods=["GET"])
return app
+6
View File
@@ -63,6 +63,11 @@ def main(argv: list[str] | None = None) -> int:
# selects the plane; the consumer key + visible host live server-side only. # selects the plane; the consumer key + visible host live server-side only.
bifrost_consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") bifrost_consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY")
bifrost_visible_host = os.environ.get("RATATOSKR_PROVIDER_VISIBLE_HOST") 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 # INV-001: lazy import. Users without [web] extras get a clean hint
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL # instead of a raw ImportError. Scoped narrowly to the OPTIONAL
@@ -102,6 +107,7 @@ def main(argv: list[str] | None = None) -> int:
end_user_id=end_user_id, end_user_id=end_user_id,
bifrost_consumer_key=bifrost_consumer_key, bifrost_consumer_key=bifrost_consumer_key,
bifrost_visible_host=bifrost_visible_host, bifrost_visible_host=bifrost_visible_host,
affect_read_url=affect_read_url,
) )
# Boot banner to stderr (so stdout stays clean for piping). # Boot banner to stderr (so stdout stays clean for piping).
+28
View File
@@ -393,12 +393,36 @@ async def _persona_state_endpoint(request: Request) -> JSONResponse:
return JSONResponse(snap, status_code=200) 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( def create_app(
client_factory: Callable[[], httpx.AsyncClient], client_factory: Callable[[], httpx.AsyncClient],
*, *,
end_user_id: str | None = None, end_user_id: str | None = None,
bifrost_consumer_key: str | None = None, bifrost_consumer_key: str | None = None,
bifrost_visible_host: str | None = None, bifrost_visible_host: str | None = None,
affect_read_url: str | None = None,
) -> Starlette: ) -> Starlette:
"""Construct the Starlette app — wire routes + state per FN create_app. """Construct the Starlette app — wire routes + state per FN create_app.
@@ -458,6 +482,7 @@ def create_app(
Route("/api/agents", _agents_endpoint), Route("/api/agents", _agents_endpoint),
Route("/api/sessions", _create_session_endpoint, methods=["POST"]), Route("/api/sessions", _create_session_endpoint, methods=["POST"]),
Route("/api/agents/{agent_id}/persona_state", _persona_state_endpoint), 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}", _submit_turn_endpoint, methods=["POST"]),
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint), Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]), Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
@@ -470,6 +495,9 @@ def create_app(
# selects only the PLANE; the server constructs the bound session (INV-008). # selects only the PLANE; the server constructs the bound session (INV-008).
app.state.bifrost_consumer_key = bifrost_consumer_key app.state.bifrost_consumer_key = bifrost_consumer_key
app.state.bifrost_visible_host = bifrost_visible_host 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) # INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
app.state.turn_registry = {} app.state.turn_registry = {}
return app return app
+67 -1
View File
@@ -679,8 +679,12 @@ function setPersonaStrip(snap) {
bars += `<div class="pad"><span class="k">${k}</span>` bars += `<div class="pad"><span class="k">${k}</span>`
+ `<span class="track"><span class="fill" style="left:${left}%;width:${width}%"></span></span></div>`; + `<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 = 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>`; + `<span class="pad-bars">${bars}</span>`;
strip.classList.add("show"); strip.classList.add("show");
} }
@@ -705,6 +709,10 @@ function renderPersonaPane(snap) {
} }
async function loadPersona(agentId) { 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 { try {
const r = await fetch("/api/agents/" + encodeURIComponent(agentId) + "/persona_state"); const r = await fetch("/api/agents/" + encodeURIComponent(agentId) + "/persona_state");
if (r.status === 200) { if (r.status === 200) {
@@ -732,6 +740,59 @@ async function loadPersona(agentId) {
} }
} }
// 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 ---- // ---- session lifecycle ----
async function startSession() { async function startSession() {
const agentId = $("agent-picker").value; const agentId = $("agent-picker").value;
@@ -943,6 +1004,11 @@ async function submitPrompt() {
state.eventSource = null; state.turnId = null; state.eventSource = null; state.turnId = null;
$("composer").classList.remove("streaming"); $("composer").classList.remove("streaming");
setConn(cls === "error" ? "error" : "idle", cls === "error" ? "error" : "connected"); 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(); $("prompt-input").focus();
} }
es.addEventListener("done", (e) => terminal("done", "done", e)); es.addEventListener("done", (e) => terminal("done", "done", e));
+141
View File
@@ -46,6 +46,14 @@ def test_open_advertises_capability_and_schema():
store._conn.execute("SELECT * FROM affect_idempotency") 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): def test_reopen_existing_file_is_idempotent(tmp_path):
db = str(tmp_path / "affect.db") db = str(tmp_path / "affect.db")
open_affect_store(db) # first open creates schema 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 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 --- # --- build_affect_provider_app ---
def test_build_app_exposes_handshake_and_affect_routes(): 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/handshake" in routes
assert "/bifrost/affect-call" in routes assert "/bifrost/affect-call" in routes
assert "POST" in routes["/bifrost/affect-call"].methods # POST-001: the verb, not just the path 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(): 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( assert await dispatch_affect_call(_env(other), ctx, ref) == await dispatch_affect_call(
_env(other), ctx, mine _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"
+35
View File
@@ -277,6 +277,41 @@ class TestOpFeedAffect:
assert ev.req_summary == {} # conduit-opaque assert ev.req_summary == {} # conduit-opaque
assert ev.resp_summary == {"stored": True} 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: class TestInstrumentGuards:
def test_unknown_plane_raises(self) -> None: def test_unknown_plane_raises(self) -> None:
+117 -1
View File
@@ -616,13 +616,15 @@ class TestCreateAppShape:
"""create_app FN — route registration + state wiring (contract TESTS).""" """create_app FN — route registration + state wiring (contract TESTS)."""
def test_routes_registered(self) -> None: 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 from ratatoskr.web.server import create_app
app = create_app(_mock_client_factory()) app = create_app(_mock_client_factory())
paths = {getattr(r, "path", None) for r in app.routes} paths = {getattr(r, "path", None) for r in app.routes}
for expected in ( for expected in (
"/", "/version", "/api/agents", "/api/sessions", "/", "/version", "/api/agents", "/api/sessions",
"/api/agents/{agent_id}/persona_state", "/api/agents/{agent_id}/persona_state",
"/api/affect/{agent_id}",
"/api/turns/{session_id}", "/api/turns/{session_id}/stream", "/api/turns/{session_id}", "/api/turns/{session_id}/stream",
"/api/turns/{session_id}/cancel", "/api/turns/{session_id}/cancel",
): ):
@@ -902,3 +904,117 @@ class TestWebBifrostBind:
resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"}) resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"})
assert resp.status_code == 201 assert resp.status_code == 201
assert "bifrost" not in resp.json() 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]] [[package]]
name = "bifrost" name = "bifrost"
version = "0.8.0" version = "0.10.0"
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" } source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
dependencies = [ dependencies = [
{ name = "jsonschema" }, { 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 = [ 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]] [[package]]
@@ -1052,7 +1052,7 @@ wheels = [
[[package]] [[package]]
name = "ratatoskr" name = "ratatoskr"
version = "0.17.13" version = "0.17.15"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -1086,7 +1086,7 @@ web = [
[package.metadata] [package.metadata]
requires-dist = [ 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", specifier = ">=0.27" },
{ name = "httpx-sse", specifier = ">=0.4" }, { name = "httpx-sse", specifier = ">=0.4" },
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" }, { name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },