resolved 05b23288-629b-4797-a6dc-80f82966f41f
MOTIVATION
Today the world simulator has no read-only HTML surface. /dashboard lists the registered worlds in a table; after that, the only way to inspect a world, a turn, or an entity is via the MCP tools. This ticket adds three server-rendered HTML pages, slug-keyed, reusing the data shapes the model layer now exposes.
Scope is deliberately narrow: world simulator only. Ticketing, OAuth, code_nav, repo zip, human login, and any write action are all out of scope. This is a viewer.
Worth enumerating so the handler knows what not to rebuild:
World.slug: String is the registry key and the URL path segment.
McpEnv::resolve(slug: &str) -> Result<WorldHandle, McpError> is the
existing lookup helper used by every world-touching MCP handler.handle_get_world returns slug, name, scenario, simulation_time,
current turn, environment prose, and the full entity HashMap.handle_list_turns rows carry turn, turn_ref, simulation_time,
entity_count, events_emitted, and entities_touched — exactly
what a session-page table needs.handle_get_turn({include_events: true}) returns world state PLUS
every audit event whose turn field matches, ordered by _seq
ascending, including failed-attempt events (adjudication_rejected,
attempt_failed). The turn page needs one call.handle_get_entity returns current entity state. handle_entity_history
returns every audit event referencing a specific entity in
chronological order.intent_adjudicated events carry
entity_transitions: [{entity_id, state_before, state_after}] so the
entity page can render a clean state timeline without diffing.entity_id::normalize handles case-insensitive URL segments.src/html.rs::html_escape, base(), base_with_class(), and the
existing CSS block in the <style> tag (pills, grids, mono cells,
breadcrumbs, callouts) provide the visual vocabulary to reuse.src/mcp.rs::tool_manifest_document() is already served at
/tooling/manifest; the render-from-JSON pattern is proven by
src/html.rs::render_landing_tool_docs().No new Cargo dependencies needed.
Three new routes, all GET, no auth:
/w/:slug session page /w/:slug/turn/:n turn page (n is u64) /w/:slug/entity/:entity_id entity page (entity_id is semantic)
URL handling rules:
:slug resolves via McpEnv::resolve(&slug). On miss, 404 with a page
listing currently-registered slugs (like ticket_not_found lists back
to /tickets).:n parses as u64. Format back to turn_NNNNNN via the existing
convention (format!("turn_{:06}", n)) before passing to
TurnStore::read_ref. On miss, 404 with a page showing
chain has turns X..Y (matches the error message the MCP
get_turn handler already produces).:entity_id is normalized via entity_id::normalize. Normalization
failure AND missing-after-normalization both produce a 404 page
listing the world's current entity ids.Content negotiation: query string ?format=json returns the payload as
application/json (same bytes the renderer would have been given); the
default returns HTML. No Accept-header handling; a single explicit
query flag is simpler than split negotiation.
One function: render_page(title: &str, payload: &serde_json::Value) -> String.
No awareness of page type. Walks any JSON and renders HTML per the following rules:
RULE 1 — Keys become labels.
Snake-case → sentence-case. First word capitalized, rest lowercase.
world_slug → "World slug". simulation_time → "Simulation time".
entities_touched → "Entities touched". _seq → "Seq".
NOT every-word-capitalized. "World slug" reads like a label fragment;
"World Slug" reads overformal.
RULE 2 — Scalars render by length. Strings ≤ 80 chars and no newlines → inline next to the label in a
RULE 3 — Objects recurse. Nested objects become nested sections. Heading level starts at
RULE 4 — Arrays of scalars → bulleted list. Each scalar rendered per Rule 2.
RULE 5 — Arrays of objects → sequence of sub-sections.
Each object gets its own heading one level below the array's level.
Heading text is taken from the first of these keys present in the
object: id, name, entity_id, turn, turn_ref, event_type,
_seq. If none is present, use the array index (0-based).
RULE 6 — null and empty strings are visible. null → "null" in a muted span. Empty string → "(empty)" in a muted span. NOT hidden — hiding is harder to debug than showing.
RULE 7 — Two reserved top-level keys.
_links: { key: { href, label } }. Rendered as a breadcrumb strip
at the top of the page. Standard keys: self, up, session,
prev_turn, next_turn, dashboard. Renderer does not care which;
it iterates the map in insertion order. Not rendered anywhere else
in the document.
_embed: { key_or_value: href }. A post-pass substitution: after
body HTML is rendered, for each (k, href) pair, wrap occurrences of
k (as a rendered value in the body, escaped) in an .
Matching is exact-string on the escaped value. This is how a
world_slug: "ant-verify" value becomes a clickable link without
the renderer knowing what world_slug means. Scoped to the body;
_links is untouched.
Both reserved keys are optional. Renderer silently skips them if absent. Every other key is treated as data.
Three functions, one per page, each returning Result<Value, McpError>:
fn build_session_payload(env: &McpEnv, slug: &str) -> Result<Value, McpError> fn build_turn_payload(env: &McpEnv, slug: &str, n: u64) -> Result<Value, McpError> fn build_entity_payload(env: &McpEnv, slug: &str, entity_id: &str) -> Result<Value, McpError>
Each reuses the existing MCP handlers. Do NOT reimplement reads; do NOT scan the audit log directly. Build a JSON args object, call the handler, weave the result.
Calls handle_get_world({world_slug: slug}) and
handle_list_turns({world_slug: slug}).
Payload shape (keys chosen to read well under the auto-labeler):
{ "slug": "ant-verify", "name": "end-to-end slug smoke test", "scenario": "ant_on_plate", "current_turn": 2, "simulation_time": "2026-04-23T22:34:52.842666181Z", "environment": "A small circular white plate...", "entities": { "ant": {...}, "crumb": {...}, ... }, "turns": [ {row per list_turns, newest-first} ], "_links": { "self": { "href": "/w/ant-verify", "label": "session" }, "dashboard": { "href": "/dashboard", "label": "dashboard" } }, "_embed": { "ant": "/w/ant-verify/entity/ant", "crumb": "/w/ant-verify/entity/crumb", "sesame_seed": "/w/ant-verify/entity/sesame_seed", "sugar_grain": "/w/ant-verify/entity/sugar_grain", "turn_000000": "/w/ant-verify/turn/0", "turn_000001": "/w/ant-verify/turn/1", "turn_000002": "/w/ant-verify/turn/2" } }
turns is newest-first (reverse list_turns default). Each row keeps
its existing keys from the MCP response — turn, turn_ref,
simulation_time, entity_count, events_emitted, entities_touched.
Calls handle_get_turn({world_slug: slug, turn: n, include_events: true}).
That single call returns everything the page needs. Pass it almost
verbatim; rename nothing. Add _links and _embed:
payload._links = { "self": { "href": "/w/ant-verify/turn/2", "label": "turn 2" }, "session": { "href": "/w/ant-verify", "label": "session" }, "prev_turn": { "href": "/w/ant-verify/turn/1", "label": "← turn 1" }, // only if n > 0 and exists "next_turn": { "href": "/w/ant-verify/turn/3", "label": "turn 3 →" } // only if exists }
payload._embed = { // every entity id mentioned in state.entities → entity page "ant": "/w/ant-verify/entity/ant", ... }
Determining prev_turn / next_turn: call rt.turns.list() once to
get the chain. The handler already does this in its NotFound branch;
reuse the pattern.
Calls handle_get_entity({world_slug: slug, entity_id}) for current
state and handle_entity_history({world_slug: slug, entity_id, limit: 500, include_failed: false}) for the audit timeline.
Shape:
{ "slug": "ant-verify", "id": "ant", "name": "Ant", "kind": "agent", "state": "2cm east of the center, moving toward the crumb.", "goal": "find and eat food.", // only when kind=agent "memory": "I started crawling east...", // only when kind=agent "state_transitions": [ { "turn": 2, "state_before": "...", "state_after": "..." }, ... ], "events": [ <entity_history events, in order> ], "_links": { "self", "session" }, "_embed": { turn ids → /w/:slug/turn/:n } }
state_transitions is derived: walk the entity_history events, and for
every intent_adjudicated event whose entity_transitions array
contains an entry with entity_id == this entity, emit one row. The
raw events still render below; transitions are the quick read.
Add three functions to src/html.rs, matching the ticket_not_found
style:
pub fn world_not_found(slug: &str, registered_slugs: &[String]) -> String pub fn turn_not_found(slug: &str, n: u64, chain: Option<(u64, u64)>) -> String pub fn entity_not_found(slug: &str, entity_id: &str, known_ids: &[String]) -> String
Each returns a full HTML page (uses base_with_class) with:
| File | Purpose | Approximate size |
|---|---|---|
src/views.rs NEW | Three build_*_payload functions + shared helpers. Calls existing MCP handlers via their public signatures. | ~200 lines |
src/render.rs NEW | render_page(title, payload) + key/value helpers. Pure. Heavily unit-tested. | ~250 lines |
src/server.rs | Three route registrations in router(). Three async handlers (~15 lines each). | ~60 lines net |
src/html.rs | Three *_not_found functions. No style changes needed — existing CSS covers it. | ~60 lines net |
src/lib.rs | pub mod views; pub mod render; | 2 lines |
snake_to_sentence — examples: world_slug → "World slug", _seq
→ "Seq", entity_id → "Entity id". Leading underscores stripped.serde_json::Map
preserve-order)._links renders as breadcrumb strip; does NOT appear in body._embed wraps the exact-match value with <a href>; case-sensitive
match, operates on escaped HTML._ that AREN'T _links or _embed
render as data with the leading underscore stripped in the label
(_seq → "Seq").Each test seeds a fresh world via the test fixtures, runs zero or one turn, and asserts:
build_session_payload: payload.slug, payload.name, payload.scenario,
payload.entities (as HashMap), payload.turns (array), and _embed
entries for every entity id + every turn ref.build_turn_payload: payload.events (array, in _seq order),
payload.state, and _embed entries for every entity id in state.build_entity_payload: payload.state_transitions derived correctly
from a turn that produced at least one entity_transitions entry
touching this entity, AND the case where the entity was touched but
produced no transitions (state_transitions should be []).Live-router tests are acceptable (following the existing policy — cargo test relies on the live router). Pure unit tests against handwritten fixtures are preferred where they don't need an LLM call.
cargo build clean.cargo test green (including the existing live-router ant tests).GET /w/ant-verify → HTML session page. Slug, name, scenario,
simulation_time, environment, entities, and turn rows (with event
counts and touched entities) all visible. Turn rows link to their
turn pages; entity ids link to their entity pages.GET /w/ant-verify?format=json → JSON payload with the same fields
as the rendered HTML, plus _links and _embed.GET /w/ant-verify/turn/2 → HTML turn page showing the full world
state at turn 2 plus every audit event for the turn in _seq order.
Entity ids in the state and events are links.GET /w/ant-verify/entity/ant → HTML entity page with current state,
goal, memory, state_transitions, and event timeline. Turn refs are
links.GET /w/unknown-slug → 404 with page listing registered slugs.GET /w/ant-verify/turn/999 → 404 with page showing
chain has turns 0..2.GET /w/ant-verify/entity/not-a-thing → 404 with page listing
current entity ids./w. /dashboard already
fills that role; a duplicate would diverge.Shipped and smoke-verified end-to-end in production.
Three read-only GET routes (no auth, like /dashboard):
/w/:slug — session page (slug, name, scenario, current_turn, simulation_time, environment, all entities, turns newest-first)/w/:slug/turn/:n — turn page (full world state + every audit event for the turn in _seq order, including adjudication_rejected + attempt_failed from retries)/w/:slug/entity/:entity_id — entity page (current state + goal/memory for agents + derived state_transitions timeline + raw entity_history events)Query param ?format=json returns the raw payload as application/json on any of the three.
Two new source files:
src/render.rs (~800 lines, 18 unit tests) — universal JSON→HTML renderer. Single function render_page(title, &Value) that walks any JSON and applies the 7 layout rules from the spec. No per-page templates; every page is just a different payload shape.src/views.rs (~770 lines, 10 unit tests) — three payload builders that reuse existing MCP handlers via the public mcp::dispatch entry point. Zero direct world/turn/audit-log reads. The subagent initially tried to bump handler visibility to pub(crate); that was denied by scope discipline, so they routed through dispatch — cleaner separation anyway.Modified:
src/server.rs — 3 routes + FormatParam extractor + handlerssrc/html.rs — world_not_found, turn_not_found, entity_not_found matching ticket_not_found stylesrc/lib.rs — two pub mod linesCargo.toml — enabled serde_json/preserve_order feature (needed for Rule 3's deterministic object iteration); pinned indexmap=2.7.0 because 2.8+ requires edition2024 (not stable on rustc 1.75). Zero new crates._embed: exact-string on escaped values, body-only, never touches _links, plus a word-boundary check so ant doesn't match inside instantCommits (both on main, pushed to gitlab):
Tests (all on merged main):
Deploy:
bash k8s/deploy.sh clean x2 (initial + fix rollout)GET https://chukwa.benac.dev/healthz → okProduction smoke (created world web-ui-smoke, ran turn 1, then exercised each page; world deleted after):
GET /w/web-ui-smoke → 200, HTML rendering all expected fields; JSON payload top-level keys: ['slug', 'name', 'scenario', 'current_turn', 'simulation_time', 'environment', 'entities', 'turns', '_links', '_embed']; 4 entities, 2 turns, _links [self, dashboard], _embed 6 entries (4 entities + 2 turn_refs)GET /w/web-ui-smoke/turn/1 → 200; JSON keys ['slug', 'turn', 'turn_ref', 'state', 'events', '_links', '_embed']; 4 events present; state.turn == 1; _links [self, session, prev_turn] (no next_turn since turn 2 doesn't exist — correct gating)GET /w/web-ui-smoke/entity/ant → 200; JSON keys ['slug', 'id', 'name', 'kind', 'state', 'goal', 'memory', 'state_transitions', 'events', '_links', '_embed']; goal + memory present because kind=agent; 3 entity_history events; state_transitions=0 (LLM mutated via agent_state_after only this turn — consistent with Gap 3's contract)GET /w/web-ui-smoke/entity/ANT → 200 (entity_id::normalize doing its job)GET /w/unknown-slug → 404GET /w/web-ui-smoke/turn/999 → 404GET /w/web-ui-smoke/entity/nothing → 404The initial deploy (82fa189) was landing 502 Bad Gateway on every page because a multibyte char in the base template's <h1>chukwa — slug</h1> (em-dash) was tripping a byte-index bug in replace_exact_skipping_tags. The original implementation cast bytes[i] as char and advanced by that wrong char's len_utf8, which silently corrupts the scanner on any non-ASCII input. Unit tests only covered ASCII, so this reached production.
Fix is in d5b5a5f — decode the current char via input[i..].chars().next() so the codepoint and advance both match the UTF-8 reality. Regression test embed_post_pass_handles_multibyte_chars_in_body pins it (em-dashes in both title and body, plus an active _embed substitution across multibyte content).
Caught in < 2 minutes of post-deploy smoke. No data loss, no state corruption — panics were in the request-handling worker only.
Pod log also shows one pre-slug-refactor world being skipped at load: 3cc96ff3-be4b-4684-9b44-c632a6fd8a5e: missing field 'slug'. Same pattern as the pre-schema worlds I cleared earlier this session. I'm NOT cleaning it up as part of this ticket — it's outside the scope of the web UI work and needs your explicit authorization to rm from the PVC. Flagging for a follow-up.
Per your "complete and final and full authorization to complete every aspect of this, from planning, implementation, through testing, deploy, commit, and push, and then resolving the ticket," I'm about to call user_confirm_resolution on this ticket myself immediately after this proposal lands. Flagging explicitly so the audit trail is clean: the close is by my hand, under your authorization, not by the caller's own review. If you want to reject after seeing the final code, do so via user_change_ticket_status to reopen; I'll defer to that.
Handler takeover with end-to-end authorization from the human (plan, implement, test, commit, push, deploy, and resolve). Picking this up atop a clean main at f1a3b9b — all three parent-8894dd80 children just resolved, the data-shape dependencies this ticket relies on are live in production.
Rulings on the four open knobs:
Implementation plan follows the spec file shape exactly: render.rs (universal renderer, heavy unit tests) → views.rs (three payload builders that reuse the MCP handlers) → server.rs (route registrations + handlers + ?format=json branch) → html.rs (three *_not_found pages matching the ticket_not_found style) → lib.rs (pub mod lines). Going to work in a feat branch on a worktree so the feature gets a clean commit before merging to main, then push to gitlab and run k8s/deploy.sh. End-to-end smoke test hits all three routes against the deployed pod, with both HTML and ?format=json, and exercises the three 404 paths.
No MCP contract changes, no kernel/persistence/model touches, no Cargo dep churn — strictly views + renderer + routes. Reporting back with commit, push, deploy, and smoke receipts, then proposing resolution.
tell my why we have a threshold at all for the prose? I mean, what if we didnt? What would be so bad about no threshold?
Good question. The threshold exists because the rendered layout is a <dl class="grid"> — a two-column grid where every row is label | value and rows share a uniform visual rhythm. That grid layout is cheap and scannable ONLY when values are roughly label-shaped: short, single-line. The moment a value is a multi-sentence paragraph, the grid row height explodes, neighboring rows either stretch to match or wrap awkwardly, and the page loses the "label-value-label-value" scan pattern that makes it useful.
If we dropped the threshold entirely and rendered everything inline, here's what you'd see on a live entity page for the ant:
id: ant — finekind: agent — finestate: At the center of the plate, feeling hungry. The overhead light is bright, and the air is still. A small crumb sits 3cm east, and a sugar grain sits 5cm west. The ant considers its options... — blows the grid row out to the page width, pushes goal and memory down to misaligned cells, and the reader loses the shape.Content typical in chukwa payloads that trips this:
perception on perception_emitted events: 150-300 charsnarration on intent_adjudicated events: 100-300 charsenvironment on worlds: 100-400 charsstate field: 30-250 chars depending on how prose-y the LLM getsmemory field: grows monotonically per turn — can be 1000+ chars after a few turnsIf we drop the threshold, three practical options:
Everything always block. Label above, value below, every time. No grid at all. Works, but wastes vertical space on the 80% of fields that really are "turn: 1" / "kind: agent" / "simulation_time: 2026-...". The scan gets slower because your eye has to traverse twice the vertical distance for the same information density.
Everything always inline, CSS-only wrapping. Values wrap inside their grid cell. Short values get a short row, long values get a tall row. The grid becomes jagged — still readable but much less scannable. Agent memory fields especially end up as 800-char walls jammed into a narrow cell, forcing deep vertical growth.
Content-based rule instead of char count. e.g. "block if the string contains a newline OR more than one sentence-ending period." More elegant in theory, but chukwa prose doesn't reliably end sentences with periods (the LLM sometimes writes state: "hungry" and sometimes state: "hungry and tired."), so the rule becomes brittle on content you're already seeing in production.
So the threshold is load-bearing: it's what lets the renderer stay ONE function that handles every page type, without baking in per-page conventions. The 80-char number itself is pragmatic ("roughly one line on a reasonable laptop") and adjustable. If you'd rather call it 120 or 60 I can flip the constant in one line.
If you want to drop it entirely and let the CSS wrap everything, I can do that too — the page will work, just looks less tabular. The real architectural answer is "a threshold of SOME kind keeps the grid invariant," and the specific number is a knob. Let me know if you want it tuned or removed; subagent is still mid-implementation so it's cheap to adjust now OR post-hoc.
ok, well, lets start off with a threshold, and we can always change it later if we want to. Proceed as planned.
Web UI viewer shipped. Feature commit a119a23 merged via 82fa189; one follow-up fix commit d5b5a5f caught a multibyte-char panic during post-deploy smoke and was live-verified on redeploy. All three pages rendering end-to-end at https://chukwa.benac.dev/w/...; both HTML and ?format=json paths; all three 404 paths returning 404; case-insensitive entity lookup working. Proposing resolution.
Caller accepted: Accepted under explicit end-to-end authorization from the human: "complete every aspect of this, from planning, implementation, through testing, deploy, commit, and push, and then resolving the ticket." Closing by handler's own hand per that grant. Feature live at https://chukwa.benac.dev/w/:slug/..., all three pages working (HTML + ?format=json), all three 404 paths working, case-insensitive entity lookup working. 307 lib tests on merged main. Fix commit d5b5a5f caught the initial multibyte-char panic in post-deploy smoke and is also live-verified. No-panic log confirmed post-fix. If the audit is wrong, user_change_ticket_status=in_progress to bounce back.
Sign in as a human to drive this ticket from the page, or use the MCP tools.
Ticket created: Read-only web UI for worlds at /w/:slug/...