| 2.1 |
ratatoskr.web |
v0.19.2 web debug-surface parity: 3 admin/debug panes (Tools inventory, BifrostState, AdminEvents SSE) proxied server-side with the admin key server-held, plus a reliable PAD-refresh poll and a non-engine reasoning indicator in the transcript. |
ratatoskr.web (server.py routes + entrypoint.py + static/index.html) |
v0.19.2 web debug-surface parity — bring the browser surface (now the PRIMARY debug surface) to TUI parity. THREE new admin/debug panes proxied server-side + TWO transcript affordances. (1) Tools inventory: GET /api/sessions/{id}/tools proxies owner-scoped get_session_tools into the tools pane (what the LLM HAS at turn-fire), above the live tool events. (2) BifrostState pane: GET /api/sessions/{id}/bifrost proxies admin-scoped get_session_bifrost; the admin key is SERVER-HELD (app.state.admin_key from RATATOSKR_ADMIN_API_KEY), never sent to the browser. (3) AdminEvents pane: GET /api/admin/events is an SSE proxy of stream_admin_events, session-filtered SERVER-side (heartbeats + other-session events dropped), re-emitted under a fixed 'admin_event' name so every dotted type renders with one browser listener. (4) PAD refresh: the persona/affect pane polls a bounded window instead of a single 2s shot that raced the post-turn-async affect.emit. (5) Reasoning indicator: an ephemeral, clearly-non-engine transcript line on `thinking` deltas, cleared when text begins. Direct in-session TDD (the #17/#18 pattern); this contract is authored post-implementation to anchor the heid code review (the client wrappers get_session_tools/get_session_bifrost/stream_admin_events are already contracted in the sessions/sse_client specs — this contract governs the WEB proxy + presenter surface only. v0.20.0 REDESIGN (Claude Design 'Ratatoskr Console' import): the tabbed telemetry column is replaced by a 3-column command-console — a left engine-ticker rail (the DEBUG + ADMIN + tool/turn-lifecycle feeds MERGED into one timeline via tickerAdd, plus a tools-armed chip list + a full-detail Bifrost rail pane) · a center conversation (per-turn INLINE chain-of-thought, replacing the separate Think pane) · a right resizable affect console (dominant/canonical-mood centerpiece + bipolar PAD faders each carrying a turn-to-turn Δ+sparkline + a P×A mood orbit + relations metric rows + canonical directive). ALL SERVER ROUTES UNCHANGED. Single-file/no-CDN/vanilla preserved; adds a light/dark theme toggle (dark default) + an inlined data-URI favicon. Presenter FN renames tracked below (renderBifrostState→renderBifrost; renderAffectPane→renderConsole; setPersonaStrip removed; tickerAdd/setFader/setFaderTrend/renderOrbit/renderDominant/renderDerived/renderRelations/renderDirective added). INV-001/INV-004 held.). |
| httpx |
| starlette |
| ratatoskr.sessions |
| ratatoskr.sse_client |
|
|
python + vanilla JS (single-file SPA, no build) |
medium |
290 |
0.8 |
| The three client wrappers exist and are already contracted: get_session_tools(client, session_id)->dict (owner-scoped, consumer bearer; non-200 -> SessionApiFailed), get_session_bifrost(client, session_id, *, admin_key)->dict (OVERRIDES Authorization with admin_key; non-200 -> SessionApiFailed), stream_admin_events(client, *, admin_key)->AsyncIterator[AdminEvent] (non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped). The web routes are thin proxies over them; they add NO new upstream semantics. |
| AdminEvent = {id:int, type:str, timestamp:str|None, data:dict}. data MOST carry session_id (INV-049). type is a dotted namespace (session.*/turn.*/key.*/system.*). |
| The web SPA is a single static/index.html served per-request via FileResponse (edits land on browser refresh; server code changes need a restart). Model/tool/admin content is UNTRUSTED text (INV-004) — every render path escapes first (esc() via textContent, or JSON.stringify wrapped in esc()). |
| The internal-LAN trust model (0.0.0.0, no auth/TLS/CORS) is deliberate operator direction. Admin-scoped DATA becoming LAN-visible is accepted under that model; the admin KEY must nonetheless never cross to the browser. |
| Tests: respx mocks the upstream endpoints (absolute w.example URLs) driven through the TestClient; the AdminEvents SSE proxy is tested with a finite mocked SSE byte-stream asserting the filter + fixed event name. Live-proven against ratatoskr:sindra on personal :8081. |
|
| name |
signature |
description |
preconditions |
postconditions |
steps |
flexibility |
| _session_tools_endpoint |
async _session_tools_endpoint(request: Request) -> JSONResponse |
GET /api/sessions/{session_id}/tools — proxy owner-scoped tool inventory. |
| session_id in path_params. |
|
| POST-001: 200 with the upstream inventory dict verbatim on success. |
| POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_tools_unavailable', status}, status_code=status) — status-preserving. |
|
Open client_factory() client; await get_session_tools(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope. |
prescriptive |
|
| name |
signature |
description |
preconditions |
postconditions |
steps |
flexibility |
| _session_messages_endpoint |
async _session_messages_endpoint(request: Request) -> JSONResponse |
GET /api/sessions/{session_id}/messages — proxy the session's message history so the SPA renders existing turns on open (notably a #347 authored first-message seeded at create-time; without it a seeded session's transcript is blank until the user speaks). |
| session_id in path_params. |
|
| POST-001: 200 with the upstream {session_id, items, next_cursor} dict verbatim on success. |
| POST-002: on SessionApiFailed(status) -> JSONResponse({error_code:'session_messages_unavailable', status}, status_code=status) — status-preserving. |
|
Open client_factory() client; await get_session_messages(client, session_id); return 200. Except SessionApiFailed -> status-preserving envelope. |
prescriptive |
|
| name |
signature |
description |
preconditions |
postconditions |
steps |
flexibility |
| _session_bifrost_endpoint |
async _session_bifrost_endpoint(request: Request) -> JSONResponse |
GET /api/sessions/{session_id}/bifrost — proxy admin-scoped Bifrost dispatch state. |
| session_id in path_params. |
| PRE-001 (fail-visible): app.state.admin_key must be truthy; else 400 admin_key_not_configured with NO upstream call. |
|
| POST-001: the admin key is read from app.state.admin_key ONLY; it is passed to get_session_bifrost(admin_key=...) and NEVER placed in a response body or surfaced to the browser. |
| POST-002: 200 with the upstream state dict verbatim on success. |
| POST-003: on SessionApiFailed(status) -> {error_code:'bifrost_state_unavailable', status} at status_code=status (notably 404 not-bound, 403 scope-denied). |
|
If not admin_key -> 400. Open client; await get_session_bifrost(client, session_id, admin_key=admin_key); 200. Except SessionApiFailed -> status-preserving envelope. |
prescriptive |
|
| name |
signature |
description |
postconditions |
flexibility |
| _admin_event_matches_web |
_admin_event_matches_web(ev: AdminEvent, session_id: str | None) -> bool |
AdminEvents session-filter (mirrors the TUI _admin_event_matches, design-brief §6). |
| POST-001: ev.type == 'system.heartbeat' -> False (keepalive noise dropped). |
| POST-002: ev.type.startswith('system.') (non-heartbeat) -> True (stream-integrity signals always pass). |
| POST-003: otherwise -> True IFF session_id is not None AND ev.data.get('session_id') == session_id (per-session scoping; a None session_id forwards NO non-system event). |
|
prescriptive |
|
| name |
signature |
description |
preconditions |
postconditions |
steps |
flexibility |
| _admin_events_endpoint |
async _admin_events_endpoint(request: Request) -> Response |
GET /api/admin/events?session_id=... — SSE proxy of stream_admin_events, session-filtered server-side. |
| PRE-001 (fail-visible): app.state.admin_key truthy; else 400 admin_key_not_configured with NO stream opened. |
|
| POST-001: returns StreamingResponse(media_type='text/event-stream'); the admin key never crosses to the browser. |
| POST-002: ONLY events passing _admin_event_matches_web(ev, session_id) are forwarded; each is re-emitted under the FIXED SSE event name 'admin_event' with {id,type,timestamp,data} in the payload (the real dotted type rides in the payload, so one browser listener renders every type — nothing silently dropped by name). |
| POST-003: SseConnectFailed/SseConnectionDropped/MalformedSseId/MalformedSseData -> a single 'stream_error' SSE frame, then the stream ends (best-effort; never raises to the browser). |
| POST-004: asyncio.CancelledError (browser disconnect) re-raises to unwind the generator; the upstream client is aclose()'d in finally on every exit path. |
|
If not admin_key -> 400. gen(): open client; async-for ev in stream_admin_events(admin_key); skip unless _admin_event_matches_web; yield _format_sse('admin_event', {...}). Except SSE errors -> yield stream_error. Except CancelledError -> raise. Finally aclose(). Return StreamingResponse(gen()). |
prescriptive |
|
| name |
signature |
description |
postconditions |
flexibility |
| create_app (amendment) |
create_app(client_factory, *, end_user_id=None, bifrost_consumer_key=None, bifrost_visible_host=None, affect_read_url=None, memory_read_url=None, admin_key=None) -> Starlette |
New optional admin_key param stored at app.state.admin_key; entrypoint passes RATATOSKR_ADMIN_API_KEY. v0.20.7 adds memory_read_url (app.state.memory_read_url, from RATATOSKR_MEMORY_READ_URL) for the memory viewer. Four new routes registered across the arc. |
| POST-001: app.state.admin_key = admin_key (default None -> the two admin routes fail-visible per their PRE-001). |
| POST-002: routes /api/sessions/{session_id}/tools, /api/sessions/{session_id}/bifrost, /api/admin/events added; existing routes unchanged. |
| POST-003 (v0.20.7): app.state.memory_read_url = memory_read_url; route /api/memory/chunks added (the memory-viewer proxy). |
|
closed |
|
| name |
signature |
description |
postconditions |
flexibility |
| reasoning indicator (index.html: showThinkingNote / hideThinkingNote) |
showThinkingNote() ; hideThinkingNote() // called from the turn SSE loop |
Ephemeral transcript affordance signalling reasoning inference — clearly NOT engine output. |
| POST-001: on the first `thinking` delta, an italic '<Agent> <phrase>' line (✦ glyph, rotating phrase) is shown; it supersedes any live 'awaiting first token' heartbeat. |
| POST-002: the agent display name is derived from state.agentId and rendered via textContent (NEVER innerHTML) — INV-004 holds even for an adversarial agent_id. |
| POST-003: it is removed the instant the first `text` delta arrives, and on any terminal (done/error/cancelled); the rotation interval is cleared on removal (no leaked setInterval). |
|
prescriptive |
|
| name |
signature |
description |
postconditions |
flexibility |
| PAD refresh poll (index.html: terminal() done-branch) |
on Done: poll loadPersona over [1500,3500,6500,10500]ms |
Catch the post-turn-async affect.emit without racing it (replaces the single 2s shot). |
| POST-001: loadAffect sets state.lastAffectAt = snap.emitted_at; the poll captures beforeAt and stops (settled) once state.lastAffectAt !== beforeAt. |
| POST-002: a scheduled poll no-ops if a NEW turn has started (state.turnId truthy) or already settled — no refresh of a stale agent, no unbounded polling. |
|
open |
|
| name |
signature |
description |
postconditions |
flexibility |
| loadTranscript (index.html) |
async loadTranscript(sessionId) -> void |
On session open, GET /api/sessions/{id}/messages and render each EXISTING turn into #transcript — notably a #347 authored first-message seeded at create-time (which lives in the ledger, not the live turn stream, so without this the transcript is blank until the user speaks). |
| POST-001: assistant items render as a .response .md-body bubble via markdownSafe(content) (escape-first whitelist, same path as appendResponse); user items render as a .prompt-echo via textContent — no upstream content reaches innerHTML unescaped (INV-004). |
| POST-002: any non-200, fetch error, or parse error is swallowed (best-effort) — a blank transcript is acceptable; opening the workspace is never blocked. |
|
prescriptive |
|
| name |
signature |
description |
postconditions |
flexibility |
| web pane renderers (v0.20.0: index.html: renderToolsInventory / renderBifrost / openAdminEvents + tickerAdd) |
renderToolsInventory(inv) ; renderBifrost(b) ; openAdminEvents(sessionId) ; tickerAdd(kind, msg, dim) |
Render the debug/admin surfaces into the 3-column console; all content escaped (INV-004). v0.20.0: BifrostState is now a full-detail LEFT-RAIL pane (renderBifrost, renamed from renderBifrostState); AdminEvents + the raw debug/op log + tool_start/result + turn lifecycle are MERGED into one engine-ticker timeline via tickerAdd (openAdminEvents routes admin_event → tickerAdd; the turn SSE handlers route worker_phase/tool/text_boundary/affect_update → tickerAdd); Tools inventory is a rail chip list (renderToolsInventory). |
| POST-001: every dynamic value (agent_id, tool names/descriptions, endpoint, caps, consumer_id, admin event type + data, ticker msg/dim) is passed through esc() or esc(JSON.stringify(...)); no upstream string reaches innerHTML unescaped. |
| POST-002: openAdminEvents closes a prior EventSource before opening a new one (state.adminES) and, on stream_error, closes so native EventSource does NOT retry-loop; admin events render into the engine ticker via tickerAdd. |
| POST-003: renderToolsInventory renders builtin + bifrost tool NAMES as rail chips (a compact 'what does the LLM have' glance); renderBifrost renders endpoint + connected + consumer_id + capabilities_granted chips + per-tool name/description rows (the full detail, admin-gated; the admin key stays server-held). tickerAdd bounds the feed to the last 400 rows (a tail, not an archive). |
| POST-004: tool NAME-vs-DESCRIPTION split preserved — the rail chip list shows names only; per-tool descriptions live in the Bifrost pane's tools list. The engine-ticker spine (.ticker-inner::before) lives on the content-height wrapper so it stays visible when auto-scrolled to the newest entry. |
|
open |
|
| name |
signature |
description |
postconditions |
flexibility |
| renderConsole + trend (v0.20.0 — unified persona/affect console; supersedes renderAffectPane/renderPersonaPane/setPersonaStrip) |
renderConsole(snap) ; setFader(axis,v) ; setFaderTrend(axis) ; renderOrbit() ; renderDominant(snap) ; renderDerived(snap) ; renderRelations(snap) ; renderDirective(snap) ; pushAffectHistory(snap) ; sparkPointsH(vals,w,h,endX) ; padDeltas(vals) ; deltaStrip(deltas) ; orbitFrame(H,head,ts) ; orbitProj/orbitShadowY/orbitWallPt/orbitAxisPt ; startOrbitAnim() ; trendDelta(vals) |
ONE render path for BOTH the Tier-1 persona_state snapshot and the Tier-3 affect snapshot (renderConsole), feeding the right affect console: dominant/canonical-mood centerpiece, bipolar PAD faders (each with a turn-to-turn Δ + sparkline), a P×A mood orbit from PAD history, an affect-derived grid, relations metric rows, and the canonical directive. Replaces the v0.19.x split of renderPersonaPane (Tier-1 pane) + renderAffectPane (Tier-3 pane) + setPersonaStrip (top-bar strip, removed — PAD now lives in the console faders). |
| POST-001: reads snap.relations (relation_edge/1: target_entity + trust_ability/benevolence/integrity + warmth as {value,confidence,evidence_count} + agency + relation_context) — the CURRENT Worldtree emit shape; falls back to the legacy flat snap.valence for an older emitter. Tier-1 fields (baseline_pad, mood_drift, dominant_emotion, emotions_active) render WHEN PRESENT, '—' when absent (Tier-3 lacks them). |
| POST-002: SVG sparklines + affect visuals (v0.20.4, adapted from the design prototype; v0.20.7 = design iteration-3). Each relation metric shows a HORIZONTAL SVG sparkline (`sparkPointsH`, 56×13, auto-scaled to its OWN range, sparkFade gradient + end dot), now BACKED by a subtle grid (`<pattern id=sparkGrid>` + a bg `<rect>` behind the polyline). Each PAD fader shows current value + Δ-vs-previous (▲/▼) + a per-turn Δ STRIP: v0.20.7 REPLACES the vertical polyline strip (removed `stripPoints`) with a column of 12 diverging HTML bars (`padDeltas`→`deltaStrip`, newest at bottom, each bar offset L/R of a center line by that turn's Δ, magnitude→width, age→opacity, zero-Δ→faint center dot). renderOrbit is now a DIMETRIC OPEN BOX (azimuth 35° / elevation 25°, D→right / A→left-back / P→up; removed the isometric `proj3` for `orbitProj/orbitShadowY/orbitWallPt/orbitAxisPt`) — a ghost A×P wall (carrying the P readout) + a D×A floor, JS-DRIVEN animated replay (`orbitFrame` rebuilt per rAF frame by a singleton `startOrbitAnim` loop reading live `ORBIT_HIST`; no SMIL/CSS-keyframes; reduced-motion → static final-state render). All drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived); coords are computed numerics (no upstream strings → INV-004 trivially held). Gradients/patterns live in one hidden `<defs>` svg in the console. v0.20.9 (R32-1B prep): the fader fill (`padFillFrac`) + orbit projections (`_padNorm`) AUTO-SCALE to the session's own max |PAD| (`padScale`, floor 1.0) instead of hard-clamping to [-1,1] — so an unbounded-z PAD (Worldtree R32-1B) renders at FULL range and never pegs/escapes the frame, while today's [-1,1] values are unchanged (scale==1); the exact value is always shown numerically (unclamped). This scaling is PURELY debug-display — ratatoskr is a downstream observer; it never touches the agent's real affect or any write path (the `--set-persona-pad` seed carries values unclamped). |
| POST-003: pushAffectHistory dedupes by emitted_at||last_updated_at so the ~4x/turn post-turn PAD poll contributes ONE sample/turn; history is CLIENT-side only (lost on reload — durable cross-session history via a provider-side snapshot log is a deferred follow-up, NOT built here). |
| POST-004: INV-001 honesty — no fabricated Tier-1 fields. The dominant-emotion centerpiece shows a real OCC dominant_emotion (Tier-1) OR the CANONICAL mood word from canonMood(pad) (Tier-3, dimmed) OR '—'; NEVER a synthesized emotion. The affect-derived grid drops non-emitted metrics (intensity/decay-τ) and shows only real/client-derived cells (baseline/drift real for Tier-1, client-derived samples/volatility). INV-004 — every dynamic value passes through esc(); numerics go through toFixed, never innerHTML-raw. |
|
open |
|
| name |
signature |
description |
postconditions |
flexibility |
| canonical affect-NL + context-injection reconstruction (v0.19.5 canons; v0.20.2 full context-injection panel) |
canonMood(pad) ; canonDirective(rel) ; canonPadFallback(pad) ; canonEmotionDirective(type) ; renderDirective(snap) ; loadPersonaCanon() |
Reconstruct + render the HIDDEN affect-context block Worldtree assembles into the agent's system prompt (never on any wire) — byte-exact to Worldtree's own describe_pad + render_d2_canonical + derive_directive + _pad_band_fallback. The v0.20.2 'context injection' panel shows the full block: mood descriptor + mood directive + relationship directive. Reference: docs/vendor/worldtree-persona-canon/affect-egress-consumer-reference.md (pinned). |
| POST-001: DETERMINISTIC, no LLM. canonMood mirrors describe_pad (valence×arousal grid + strict ±0.3 bands + dominance clause); canonDirective mirrors render_d2_canonical; canonPadFallback mirrors renderer._pad_band_fallback BYTE-EXACT (P×A quadrant: hi/lo/mid arousal band × p>0.3/<-0.3/neutral, with the negative_low_dominance (d<-0.3) special case + neutral_high_a + default); canonEmotionDirective is the occ_directives[type].directive lookup (+ tier / full_only flag). |
| POST-002: the canon DATA is VENDORED (docs/vendor/worldtree-persona-canon/{d2-mood-render-canon-v1,d2-render-canon-v1}.json), pinned drift-gated in .corviduo-canonicals.toml; the flat browser form (static/persona_render_canon.json, served /static) is regenerated by scripts/build_persona_canon.py via Worldtree's OWN authoritative loader — v0.20.2 extended it to emit mood_directive {occ_directives, pad_band_fallback, salience, pad_band_cutoff, full_only}. The affect-egress consumer reference is pinned tolerate_drift (worldtree-affect-egress-consumer-reference-v1; worldtree-dev co-signs + pings on change). |
| POST-003: fail-open — canon absent (fetch fails) → the reconstructed lines OMIT, the structured console still renders. Every canon-derived string is esc()'d before the DOM (INV-004). |
| POST-004: HONEST-PARTIAL provenance (affect-egress-reference §3). The mood descriptor + relationship directive are EXACT (tagged 'exact'); the mood DIRECTIVE is a CANDIDATE pair (tagged 'candidate') — the OCC emotion directive for the delivered dominant_emotion type AND the PAD-band fallback — because affect.emit is type-only (no intensity) so the salience gate (≥0.2) can't be evaluated; BOTH are shown with the 'injected if intensity ≥ salience' caveat, never asserting which fires. When dominant_emotion is absent the fallback alone is EXACT. The panel is labeled reconstructed + hidden-from-consumers + dev-only (the reference-impl's sanctioned understand/reconstruct use, NOT end-user display per the reference's caveat). WATCH: a pending Worldtree render_d2_canonical change conditionally drops the trailing 'avoid premature we-framing' clause under a 3-gate combo — canonDirective holds as-is until worldtree-dev pings with the exact conditional + a canon bump. |
|
open |
|
| name |
signature |
description |
postconditions |
flexibility |
| memory viewer (v0.20.7 — provider debug read → web proxy → console pane) |
server: _memory_chunks_endpoint(request) [GET /api/memory/chunks] ; provider: add_memory_read_route(app, store) [GET /memory/chunks] + RatatoskrMemoryStore.list_chunks(*, agent_id, end_user_id) + .count_chunks() ; index.html: loadMemory(agentId) ; renderMemory(data) ; setMemHead(count, total) |
Durable memory chunks Worldtree promoted into OUR store, surfaced as a live-polling MEMORY console pane (content·scope·origin·revision per chunk). Mirrors the #18-D2 affect read pattern: a NON-bifrost debug read on OUR own store (bifrost's memory protocol has no list-all verb) → a web proxy supplying end_user_id server-side → the pane. Polled on session open + the post-turn window (promotion is async, like affect.emit). |
| POST-001 (provider read): GET /memory/chunks?agent_id=&end_user_id= returns {chunks:[{chunk_id,content,scope,origin,revision}], count, total}. end_user_id REQUIRED (400 missing_end_user_id) — the partition boundary. Filter: end_user STRICT (scope.end_user==end_user_id), agent_id LENIENT (excluded only if the chunk CARRIES an agent_self axis that differs — so an {end_user}-only chunk, the real WT promotion shape, is not hidden). An empty match is a 200 empty list (0-chunks is a visible answer, never a 404). `total` = unfiltered store-wide count (distinguishes empty-store from scope-mismatch). content = best-effort text field / distillate summary / compact JSON-minus-embedding — a DEBUG read; bifrost verbs stay index/conduit-faithful. |
| POST-002 (web proxy): GET /api/memory/chunks supplies end_user_id from app.state.end_user_id (NEVER the browser), forwards the browser-named agent_id, proxies to app.state.memory_read_url (the combined :8392 provider serves both read routes). 400 memory_not_configured when unset; 502 memory_provider_unreachable on network error; status passthrough otherwise. Mirrors _affect_state_endpoint (#18 D2 INV-002). |
| POST-003 (pane): renderMemory shows count(matched)/total(store-wide) in the head + one .mem-chunk per chunk (scope axes + origin + revision + content, ALL esc()'d — INV-004). Empty states are honest + diagnostic: total 0 → 'no memory chunks yet — promotion needs a bound memory/combined session + ~6 turns (or idle); if 0/0 the bind wasn't memory-granted or closed pre-promotion'; total>0 → 'scope mismatch, not an empty store'. |
|
open |
|
| name |
signature |
description |
postconditions |
flexibility |
| markdownSafe pass-2 (v0.20.6 RP coloring + v0.20.7 tables / nested lists / streaming) |
markdownSafe(raw) ; mdTable(lines, i) ; mdInline(s) |
The escape-first whitelist Markdown renderer, extended pass-2: GFM pipe tables, indentation-nested lists, ordered-list start numbering, and streaming-partial robustness. Pass-1 (RP speech/action coloring + CommonMark paragraph reflow) shipped v0.20.6. |
| POST-001: GFM pipe tables (`mdTable`) — a pipe row + an alignment/delimiter row (`|---|:--:|`) → <table class=md-table> with per-column text-align from the delimiter colons; body rows parsed until a non-pipe line. |
| POST-002: indentation-nested lists — leading-space depth builds a stack of <ul>/<ol> with each child list INSIDE the open parent <li> (valid nested HTML); same-level items are siblings; ul↔ol switches close+reopen. Ordered lists honor the first item's number (<ol start=N> when != 1). |
| POST-003: streaming robustness — an unterminated code fence renders as a partial code block; a table header without its delimiter yet falls through to a paragraph (becomes a table once the delimiter streams in); parsing never throws on a partial. INV-004 held — esc() runs FIRST on the whole input, so table cells / list items / code all carry escaped content. |
|
open |
|
|
| INV-004 (untrusted-render): ALL model / tool / admin / agent-supplied text is escaped before entering the DOM (esc via textContent, or esc(JSON.stringify)). No new render path introduces an innerHTML sink for upstream content. This is the highest-value review target — the new JS render paths are NOT unit-tested. v0.20.7: the memory pane (chunk content/scope/origin), the delta-strip bars, and markdownSafe table cells / list items all pass through esc() (esc runs FIRST on the whole markdown input). |
| INV-ADMIN-KEY: the admin key exists ONLY at app.state.admin_key (from RATATOSKR_ADMIN_API_KEY). It is never serialized into any response, never sent to the browser, never logged. The browser receives only the session-filtered RESULT of admin-scoped reads. |
| INV-FILTER: AdminEvents filtering happens SERVER-side (_admin_event_matches_web) — the browser never receives the cross-session admin firehose; only active-session events + non-heartbeat system.* cross the wire. |
| INV-FAIL-VISIBLE: both admin routes return 400 admin_key_not_configured when the key is absent — never a silent empty pane, never an upstream call with an empty bearer. |
| INV-LIFECYCLE: SSE generators and EventSources are cleaned up on every exit path (upstream client aclose() in finally; setInterval cleared in hideThinkingNote; prior EventSource closed before re-open) — no leaked connections, tasks, or timers. |
| INV-ADDITIVE: existing routes, panes, and the turn-stream path are unchanged; the 3 new routes + 2 new tabs are purely additive (59 web tests incl. all prior ones stay green). |
|