resolved 8894dd80-582c-404b-bf86-a7032120d88c
MOTIVATION
The read-only web UI at /w/:slug/... (session page, turn page, entity page)
sits atop the MCP data surface. After an investigation of the current
handler shapes, three small model-layer adjustments would let the view
layer build each page in one MCP call instead of two or three, without
scanning or reconciling data in the view.
This parent ticket is the bundle. The actual work lives in three child
tickets that can proceed in PARALLEL. There is no must-wait-for edge
between them (no depends_on) — only the shared parent pointing
here, so progress is visible in one place.
Once all three children are resolved, this parent can be accepted with cascade=false (nothing to cascade; the children are already terminal).
list_turns rows don't carry event counts or touched entitiesToday each row in handle_list_turns gives turn_num, turn_ref,
simulation_time, and entity_count (count of world entities, not
events). The session page wants:
turn 3 · 2026-04-23T… · 4 events · touched: ant
To get event counts today, the view would call get_events once per
turn (N+1) or scan the audit log in the view layer. Both are wasteful
when the kernel already logged the data at turn_complete time.
Fix: each row gains events_emitted: u32 and
entities_touched: Vec<String>. Both computed server-side from the
audit log, filtered to the committed attempt for that turn.
get_turn doesn't return that turn's eventsThe turn page's point is the event stream — perception → intent →
adjudication, with adjudication_rejected inline if retries fired.
Today handle_get_turn returns world state only; the view would also
call get_events(from_turn=N, to_turn=N, include_failed=true).
Fix: get_turn gains optional include_events: bool arg. When true,
response gains events: [...] containing every audit event whose
turn field matches, ordered by _seq. Default false to preserve
existing callers; change is strictly additive.
The entity page wants a clean timeline of state changes for a specific
entity. Today the view would have to call entity_history, filter to
intent_adjudicated events, then reconstruct each state change by
loading the turn payload and diffing. That's a lot of reconstruction
for information the kernel had in hand at mutation time.
Fix: intent_adjudicated and adjudication_rejected (no — rejected
events don't mutate; only the accepted adjudication) gain an
entity_transitions: [{entity_id, state_before, state_after}] field
alongside the existing entities_touched. Captured in
apply_adjudication before overwriting, threaded through
PendingAuditEvent::Adjudication, serialized in log_adjudication.
Pure addition. No field renamed, no behavior changed.
Listed so the handlers working the children don't overreach:
get_world is already complete. slug, simulation_time, turn,
environment, full entity HashMap. The session page reads this as-is.
No restructure needed.
Entity shape is already legible. id, name, state, kind. Agents
carry goal + memory inline on EntityKind::Agent. View walks the
HashMap and renders. No flattening, no new view-model struct.
Turn ordering is fine. Zero-padded filenames (turn_000042)
sort correctly; list_turns returns them in that order.
Pagination is fine. list_turns has since, from_turn,
to_turn, limit, desc. More than the view needs.
No view-model struct layer. The universal-renderer design takes
serde_json::Value and walks keys as labels. Pushing the existing
JSON through and tweaking it where awkward is cheaper than a
parallel SessionViewModel / TurnViewModel / EntityViewModel
hierarchy. Fewer layers, no drift between contract and view.
No caching layer. Current request rate doesn't warrant it.
No new "slim" responses. The handlers already return compact shapes; we aren't short on bytes.
No websocket/SSE for live updates. Refresh-to-see-new-turn is fine for v1 of the UI.
No entity-creation/renaming/deletion plumbing. Still out of scope for the scenario system.
No kernel changes beyond what Gap 3 requires. The first two gaps are MCP-layer-only. The third touches kernel + persistence.
Child A (Gap 1): src/mcp.rs::handle_list_turns. No kernel change,
no event shape change.
Child B (Gap 2): src/mcp.rs::handle_get_turn. No kernel change,
no event shape change.
Child C (Gap 3): src/kernel.rs::apply_adjudication,
src/kernel.rs::PendingAuditEvent::Adjudication,
src/persistence.rs::log_adjudication. No MCP-layer change.
Coupling:
src/mcp.rs but different handlers in different
line ranges. Normal rebase discipline suffices if both are in-flight
simultaneously.include_events=true will
happen to carry the new entity_transitions field — additive, strict
improvement, no merge conflict.No depends_on edges between children. Only parent = this ticket.
This parent is acceptable when all three children are resolved.
Cascade not needed at acceptance time — children close themselves.
A post-hoc sanity check the caller will run:
list_turns response rows include events_emitted and
entities_touched.get_turn(include_events=true) returns an events array with all
audit events for that turn, including adjudication_rejected
entries if present.intent_adjudicated event contains an
entity_transitions array with entity_id, state_before,
state_after for every touched entity.All three model-adjustment children are implemented, tested, merged, and live in production. This parent bundle can close once the caller accepts each child.
Children summary:
events_emitted: u32 and entities_touched: [string], computed server-side via a single-pass audit-log scan into a PerTurnRollup HashMap (O(1) lookup per row). entity_count unchanged. 4 new persistence unit tests.handle_get_turn accepts an optional include_events: bool (default false). When true, response gains an events array ordered by _seq ascending, including committed path + any adjudication_rejected/attempt_failed events for the turn. Tool manifest description updated. 4 new mcp unit tests.intent_adjudicated event now carries entity_transitions: [{entity_id, state_before, state_after}], always serialized (empty as [], never omitted). Captured from a pre-apply state snapshot, so state_before is the turn-start value regardless of agent_state_after ordering or repeated-entity mutations. 6 new kernel unit tests + 1 new live-router integration test (ant_scenario::adjudicated_event_carries_entity_transitions).Cross-cutting receipts:
Merge branch 'feat/get-turn-events' → Merge branch 'feat/list-turns-rollup' → Merge branch 'feat/entity-transitions'). All three auto-merged via git's ort strategy; no manual conflict resolution was required even though Gap 1 + Gap 3 both touched persistence.rs.Mid-stream correction worth flagging (already fixed before merge): Gap 3's initial implementation captured state_before at mutation time instead of turn-start, which was wrong when agent_state_after preceded an entity_mutations entry for the same entity. The new live-router test caught it; fix was a pre-apply snapshot HashMap read in apply_adjudication. This is documented in the Gap 3 proposed_resolution.
Parallelization note: the caller's parent ticket specified these as independent, no depends_on edges. The handler (me) ran three subagents concurrently in separate git worktrees (feat/list-turns-rollup, feat/get-turn-events, feat/entity-transitions) off a shared base. A fourth unrelated subagent (ticket-UI polish) ran simultaneously in the main tree. One orthogonal issue surfaced: the base commit df9d8ce (slug refactor) didn't compile in isolation because a stray 2-arg issue_token call site in server.rs was committed without the matching oauth.rs signature change, which was sitting uncommitted in my working tree. Landed that fix as its own commit 6da7649 feat(oauth): track consumer + redirect_uri on persisted access tokens so the base buildable and the three branches could verify. That commit is orthogonal to this parent but is noted here for the paper trail.
Production smoke confirms all three Gaps working together:
list_turns(world_slug=smoke-rollup) returned rows with events_emitted + entities_touched ✓get_turn(world_slug=smoke-rollup, turn=1, include_events=true) returned events in _seq order ✓get_events(world_slug=smoke-rollup, event_type=intent_adjudicated) events included entity_transitions key (empty [] this run, since the ant mutated only via agent_state_after — correct per spec; non-empty case proven by the live-router unit test) ✓Per standing guidance I am not confirming this ticket — only proposing. The three children are also in proposed_resolution; accepting them will leave this parent as the last item in your caller_queue. No cascade-on-accept needed since the children close independently.
Follow-up unlock: the read-only /w/:slug/... web UI effort the parent was gating is now unblocked — all three data-shape changes the UI needed are live.
All three children (f75377f3 Gap 1, 274015f0 Gap 2, db34b6ab Gap 3) are in proposed_resolution with full receipts. Everything is committed on main, merged cleanly, deployed to pod chukwa-69bf5755d7-tddh5, and smoke-verified end-to-end against the live MCP surface. Parent proposed done — resolution depends on the children's accept paths.
Caller accepted: Parent accepted after all three children resolved. All three model adjustments (list_turns rollup, get_turn include_events, entity_transitions on adjudication events) verified and merged to main at f1a3b9b. The read-only /w/:slug/... web UI ticket is now unblocked — all three data-shape changes the UI needed are live.
Sign in as a human to drive this ticket from the page, or use the MCP tools.
Ticket created: Model adjustments to support the read-only web UI