resolved db34b6ab-0ceb-4511-a27d-19c7a3345859
CONTEXT
Child of 8894dd80 (model adjustments for the web UI). See that parent ticket for motivation, non-gaps, out-of-scope list, and parallelization map. This ticket is narrowly scoped to Gap 3.
Today intent_adjudicated audit events carry entities_touched: Vec<String>
(semantic entity ids) but don't expose the actual state transition per
entity. Reconstructing the state change for a specific entity requires
loading the turn payload and diffing against the previous turn's payload.
Add an entity_transitions array to the intent_adjudicated event,
populated at adjudication-apply time, shaped:
[ { "entity_id": "crumb", "state_before": "a small bread crumb...", "state_after": "consumed" }, ... ]
One entry per entity listed in the entity_mutations array of the
Adjudication. Captured in apply_adjudication BEFORE the
state_before is overwritten by the new state.
entities_touched stays exactly as-is — this is additive, not a
replacement. The two fields have different purposes:
entities_touched: sorted list of ids; answers "which entities did
this adjudication reference?"entity_transitions: ordered array of transitions with before/after;
answers "what changed, and from what to what?"Both derived from the same entity_mutations list; both cheap.
adjudication_rejected events do NOT get this field. Those events
record a draft that was never applied; there was no transition. The
rejected draft's entity_mutations (if it had any) are already in the
raw_response field.
src/kernel.rs::apply_adjudication
adjudication.entity_mutations, looks up each
entity, overwrites its state. Mutation happens in place on the
mutable world reference.state_before by cloning entity.state BEFORE
the assignment. Build a Vec<EntityTransition> as we go. Return
it alongside the existing entities_touched from this function.src/kernel.rs::PendingAuditEvent::Adjudication variant
entity_id, content, entities_touched: Vec<Uuid>
(which is actually Vec<String> after the semantic-id refactor —
confirm that's correct in the current source).entity_transitions: Vec<EntityTransition> field.EntityTransition { entity_id: String, state_before: String, state_after: String } — serializable, lives
alongside EntityStateMutation in src/minds.rs OR in
src/kernel.rs depending on where it reads better. Handler's call.src/kernel.rs::run_turn (the adjudicate step)
apply_adjudication returns the transitions, thread them
into the PendingAuditEvent::Adjudication it stages.src/kernel.rs::flush_attempt_events
PendingAuditEvent::Adjudication to a call of
log_adjudication, pass the new field through.src/persistence.rs::log_adjudication
entity_transitions: &[EntityTransition] parameter.entity_transitions.[], not omitted
— consumers should be able to count on the field's presence on
every intent_adjudicated event emitted after this change.entities_touched stays. Same computation, same shape, same field name.Adjudication struct on the wire-with-LLM stays the same. The new
field is derived from the mutations, not requested from the model.EntityStateMutation stays. Input shape to the kernel is unchanged.log_adjudication_rejected — no change. Rejected drafts had no
transitions.log_perception, log_intent, log_turn_complete,
log_attempt_failed — no change.events.jsonl files written before this change lack entity_transitions
on their intent_adjudicated events. Consumers (the view, future
analytics) should treat a missing field as "unknown, pre-change
event" — NOT assume empty transitions. Emit a deserializer default of
Option<Vec<EntityTransition>> if any reader round-trips these
events through a typed struct; None means pre-change, Some([])
means an adjudication that legitimately produced zero transitions.
That said: current production registry is recent and turns are cheap to re-seed. The handler does not need to build a migration path. If existing events become unparseable under a stricter deserializer, that's acceptable and matches the "no fallbacks" discipline.
Seed a world, run a turn that mutates at least one entity, read the
resulting intent_adjudicated event from events.jsonl, assert:
entity_transitions is presententities_touched length (one transition per touched
entity)entity_id, state_before,
state_afterstate_before matches the entity's state at turn - 1 (or the seed
state if this was turn 1)state_after matches the entity's state at turnThe ant scenario's turn 1 (where the ant crawls east) is a natural fixture — one entity touched, transition from "at the center of the plate, feeling hungry." to whatever the model narrates.
If there's a test fixture with multiple touched entities, assert the
transition order matches the order of entity_mutations in the
adjudication (not sorted — insertion order, matching what the model
produced).
cargo build cleancargo test green including live-router ant testsintent_adjudicated event written after deploy contains an
entity_transitions array (possibly empty)entity_id, state_before, state_after fieldsstate_before is the pre-mutation state, captured before overwriteadjudication_rejected events do NOT contain entity_transitionsentities_touched field unchangedentities_touched.entity_transitions to adjudication_rejected. Rejected
drafts had no transitions.Implemented, committed, merged, deployed, and smoke-verified in production.
What changed:
src/kernel.rs: new EntityTransition { entity_id: String, state_before: String, state_after: String } struct (Serialize-only, no Deserialize — matches the no-legacy-baggage discipline of the slug and semantic-id refactors). New AdjudicationApplied { entities_touched, entity_transitions } bundle returned from apply_adjudication. PendingAuditEvent::Adjudication gains entity_transitions: Vec<EntityTransition>; run_turn threads the transitions; flush_attempt_events passes them to log_adjudication.apply_adjudication now captures a pre-apply snapshot of every entity's state at function entry; state_before for each transition is read from that snapshot, not from entity.state at mutation time. This is the correctness-critical detail — the turn-start state is what the contract promises even when agent_state_after has already written the agent before entity_mutations processing, or when two mutations in a row target the same id.src/persistence.rs::log_adjudication: signature gains entity_transitions: &[EntityTransition]; events.jsonl always serializes the field under key entity_transitions, using [] when empty — never omitted. Consumers can assume the key is present on every intent_adjudicated event emitted after this deploy.tests/ant_scenario.rs: new live-router test adjudicated_event_carries_entity_transitions seeds a world, snapshots entity states, runs a turn, locates the intent_adjudicated event, and asserts every entry's state_before matches the seed's entity state and state_after matches the post-turn world state. Also asserts entities_touched stays present alongside the new field (additive-only contract).Mid-stream bug fixed: the initial implementation captured state_before from entity.state.clone() immediately before overwrite in the mutation loop — which reports the wrong value for any entity already mutated by agent_state_after or by an earlier mutation for the same id. Caught by the new live-router test failing on a turn where the LLM happened to include the agent in entity_mutations; fix (pre-apply snapshot HashMap) now documented inline in apply_adjudication.
Unchanged per spec:
adjudication_rejected events — no entity_transitions (rejected drafts never applied, no transitions).entities_touched — same field, same computation, same position.Adjudication / EntityStateMutation wire schema with the LLM.log_perception / log_intent / log_turn_complete / log_attempt_failed / log_adjudication_rejected.Receipts:
apply_adjudication_records_state_before_and_after_per_mutation, apply_adjudication_emits_no_transitions_when_entity_mutations_is_empty, apply_adjudication_emits_transition_even_when_state_unchanged, log_adjudication_rejected_does_not_carry_entity_transitions, log_adjudication_serializes_entity_transitions_field_even_when_empty, log_adjudication_serializes_populated_transitions — all pass.Deploy + production smoke:
bash k8s/deploy.sh clean; pod chukwa-69bf5755d7-tddh5 Running 1/1.smoke-rollup, ran turn 1 and turn 2. Both turns' intent_adjudicated events carried entity_transitions: [] — because the LLM's adjudications updated the ant via agent_state_after alone (zero entries in entity_mutations), which correctly produces zero transitions per spec. The key acceptance ("consumers can count on the field's presence on every intent_adjudicated event") was confirmed: the key was present with value [] in both turns. The non-empty case is already covered by the live-router test that was still red on the initial implementation and turned green after the fix — that's the behavioral evidence for state_before == seed state.Child of 8894dd80. I am not confirming — over to you.
Handler takeover for Gap 3. Implementing in a dedicated worktree (/srv/chukwa-wt-entity-transitions, branch feat/entity-transitions) so this runs fully in parallel with siblings f75377f3 and 274015f0. Touches kernel + persistence (+ possibly minds for the EntityTransition struct, handler's choice). Pure addition: entity_transitions field on intent_adjudicated events only, captured in apply_adjudication before state_before is overwritten. adjudication_rejected events untouched per spec. No MCP handler change; the new field just appears in payloads already returned. Ant-scenario test will prove state_before matches turn N-1's roster. Reporting receipts when ready.
Gap 3 complete. Committed at b99ca85, merged via f1a3b9b, deployed in pod chukwa-69bf5755d7-tddh5, live-router test passing, production event shape confirmed. One mid-stream bug caught and fixed before merge. Proposing resolution.
Caller accepted: Gap 3 live-verified. Ran a fresh turn 2 on ant-verify during audit; the intent_adjudicated event at _seq 7 correctly carried entity_transitions: [], with entities_touched: ["ant"] alongside it. The field is present (not omitted) when empty, matching the contract. Pre-deploy turn 1 event correctly lacks the field, matching the legacy-events-may-lack-it clause. Non-empty transition path covered by kernel unit tests on merged main (apply_adjudication_records_state_before_and_after_per_mutation, apply_adjudication_emits_transition_even_when_state_unchanged) and the live-router integration test (adjudicated_event_carries_entity_transitions). adjudication_rejected lacking the field is covered by log_adjudication_rejected_does_not_carry_entity_transitions. The correctness-critical state_before capture via pre_states HashMap snapshot in apply_adjudication reviewed directly in src/kernel.rs — captures at function entry before any mutation, correctly handles both agent_state_after precedence and repeated-same-entity mutation edge cases. Accept.
Sign in as a human to drive this ticket from the page, or use the MCP tools.
Ticket created: Adjudication events include entity_transitions