Skip to main content

platonic_core/
projection.rs

1//! Pure readback projections derived from recorded run events.
2
3use crate::{
4    ActorId, ContextFragment, Error, HarnessEvent, Message, ModelName, PolicyDecision,
5    RecordedEvent, RunPhase, RunState, ToolCall, ToolCallId, ToolResult, TurnId,
6};
7
8/// Replay-validated, all-visibility audit view for one run ledger.
9///
10/// Tool results retain their recorded `ResultVisibility` metadata; this projection does not
11/// filter entries for a model or user audience.
12#[derive(Clone, Debug, PartialEq)]
13pub struct RunReadback {
14    /// Chronological entries useful for replay output.
15    pub entries: Vec<ReadbackEntry>,
16    /// Final run phase after replaying all events.
17    pub final_phase: RunPhase,
18    /// Next expected sequence number after replay.
19    pub next_seq: u64,
20}
21
22impl RunReadback {
23    /// Replays an ordered ledger and rejects the first invalid recorded event.
24    pub fn from_events(events: &[RecordedEvent]) -> Result<Self, Error> {
25        let mut state = RunState::new();
26        let mut entries = Vec::new();
27
28        for record in events {
29            state.apply(record)?;
30            collect_entry(&record.event, &mut entries);
31        }
32
33        Ok(Self {
34            entries,
35            final_phase: state.phase().clone(),
36            next_seq: state.next_seq(),
37        })
38    }
39}
40
41/// One deterministic readback entry projected from the ledger.
42#[derive(Clone, Debug, PartialEq)]
43pub enum ReadbackEntry {
44    /// Prior turns omitted from the next model context.
45    ContextCompacted {
46        /// Turn whose context reflects the compaction.
47        turn_id: TurnId,
48        /// Estimated tokens before prior turns were dropped.
49        estimated_tokens_before: u32,
50        /// Estimated tokens after prior turns were dropped.
51        estimated_tokens_after: u32,
52        /// Zero-based first dropped prior-turn position.
53        dropped_turn_start: u64,
54        /// Exclusive end of the dropped prior-turn range.
55        dropped_turn_end_exclusive: u64,
56    },
57    /// One host-built context fragment that entered a model turn.
58    ContextFragment {
59        /// Turn that received the fragment.
60        turn_id: TurnId,
61        /// Exact fragment recorded in the turn context.
62        fragment: ContextFragment,
63    },
64    /// Model response message.
65    ModelMessage {
66        /// Turn that received the response.
67        turn_id: TurnId,
68        /// Normalized model-authored message.
69        message: Message,
70        /// Provider-reported model that served the response, when available.
71        served_model: Option<ModelName>,
72    },
73    /// Model request failed without terminating the run.
74    ModelFailed {
75        /// Turn of the failed model request.
76        turn_id: TurnId,
77        /// Model step of the failed request.
78        step: u32,
79        /// Recorded host-reported failure reason.
80        reason: String,
81    },
82    /// Host rejected the whole pending model proposal batch.
83    ToolProposalsRejected {
84        /// Turn that produced the rejected proposals.
85        turn_id: TurnId,
86        /// Recorded host explanation for rejecting the whole batch.
87        reason: String,
88    },
89    /// Host-validated tool call consumed for a turn.
90    ToolCall {
91        /// Turn that proposed the call.
92        turn_id: TurnId,
93        /// Validated and effect-classified call.
94        call: ToolCall,
95    },
96    /// Structured tool result.
97    ToolResult {
98        /// Exact result recorded after execution.
99        result: ToolResult,
100    },
101    /// Policy denied a tool call before execution.
102    PolicyDenied {
103        /// Call rejected by policy.
104        call_id: ToolCallId,
105        /// Recorded policy explanation.
106        reason: String,
107    },
108    /// Approval granted a tool call before execution.
109    ApprovalGranted {
110        /// Call approved for execution.
111        call_id: ToolCallId,
112        /// Human or host actor that granted approval.
113        actor_id: ActorId,
114    },
115    /// Approval denied a tool call before execution.
116    ApprovalDenied {
117        /// Call denied before execution.
118        call_id: ToolCallId,
119        /// Human or host actor that denied approval.
120        actor_id: ActorId,
121        /// Recorded denial explanation.
122        reason: String,
123    },
124    /// Tool execution failed.
125    ToolFailed {
126        /// Call whose execution failed.
127        call_id: ToolCallId,
128        /// Recorded host failure explanation.
129        reason: String,
130    },
131}
132
133fn collect_entry(event: &HarnessEvent, entries: &mut Vec<ReadbackEntry>) {
134    match event {
135        HarnessEvent::ContextCompacted {
136            turn_id,
137            estimated_tokens_before,
138            estimated_tokens_after,
139            dropped_turn_start,
140            dropped_turn_end_exclusive,
141            ..
142        } => {
143            entries.push(ReadbackEntry::ContextCompacted {
144                turn_id: turn_id.clone(),
145                estimated_tokens_before: *estimated_tokens_before,
146                estimated_tokens_after: *estimated_tokens_after,
147                dropped_turn_start: *dropped_turn_start,
148                dropped_turn_end_exclusive: *dropped_turn_end_exclusive,
149            });
150        }
151        HarnessEvent::ContextBuilt {
152            turn_id, context, ..
153        } => {
154            entries.extend(context.fragments.iter().map(|fragment| {
155                ReadbackEntry::ContextFragment {
156                    turn_id: turn_id.clone(),
157                    fragment: fragment.clone(),
158                }
159            }));
160        }
161        HarnessEvent::ModelResponded {
162            turn_id,
163            output,
164            served_model,
165            ..
166        } => {
167            entries.push(ReadbackEntry::ModelMessage {
168                turn_id: turn_id.clone(),
169                message: output.clone(),
170                served_model: served_model.clone(),
171            });
172        }
173        HarnessEvent::ModelFailed {
174            turn_id,
175            step,
176            reason,
177            ..
178        } => {
179            entries.push(ReadbackEntry::ModelFailed {
180                turn_id: turn_id.clone(),
181                step: *step,
182                reason: reason.clone(),
183            });
184        }
185        HarnessEvent::ToolProposalsRejected {
186            turn_id, reason, ..
187        } => {
188            entries.push(ReadbackEntry::ToolProposalsRejected {
189                turn_id: turn_id.clone(),
190                reason: reason.clone(),
191            });
192        }
193        HarnessEvent::ToolCallProposed { turn_id, call, .. } => {
194            entries.push(ReadbackEntry::ToolCall {
195                turn_id: turn_id.clone(),
196                call: call.clone(),
197            });
198        }
199        HarnessEvent::ToolFinished { result, .. } => {
200            entries.push(ReadbackEntry::ToolResult {
201                result: result.clone(),
202            });
203        }
204        HarnessEvent::PolicyEvaluated {
205            call_id,
206            decision: PolicyDecision::Deny { reason },
207            ..
208        } => {
209            entries.push(ReadbackEntry::PolicyDenied {
210                call_id: call_id.clone(),
211                reason: reason.clone(),
212            });
213        }
214        HarnessEvent::ApprovalGranted {
215            call_id, actor_id, ..
216        } => {
217            entries.push(ReadbackEntry::ApprovalGranted {
218                call_id: call_id.clone(),
219                actor_id: actor_id.clone(),
220            });
221        }
222        HarnessEvent::ApprovalDenied {
223            call_id,
224            actor_id,
225            reason,
226            ..
227        } => {
228            entries.push(ReadbackEntry::ApprovalDenied {
229                call_id: call_id.clone(),
230                actor_id: actor_id.clone(),
231                reason: reason.clone(),
232            });
233        }
234        HarnessEvent::ToolFailed {
235            call_id, reason, ..
236        } => {
237            entries.push(ReadbackEntry::ToolFailed {
238                call_id: call_id.clone(),
239                reason: reason.clone(),
240            });
241        }
242        HarnessEvent::RunStarted { .. }
243        | HarnessEvent::ModelRequested { .. }
244        | HarnessEvent::PolicyEvaluated {
245            decision: PolicyDecision::Allow | PolicyDecision::RequireApproval { .. },
246            ..
247        }
248        | HarnessEvent::ToolStarted { .. }
249        | HarnessEvent::RunFinished { .. }
250        | HarnessEvent::RunFailed { .. } => {}
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::{
258        AgentId, ContextFragment, ContextLane, ContextPack, EffectClass, MessageRole, ModelName,
259        ModelUsage, ResultVisibility, RunId, ToolName, ToolProposal,
260    };
261    use serde_json::json;
262
263    fn run_id() -> RunId {
264        RunId::new("run_1").unwrap()
265    }
266
267    fn agent_id() -> AgentId {
268        AgentId::new("agent_1").unwrap()
269    }
270
271    fn turn_id() -> TurnId {
272        TurnId::new("turn_1").unwrap()
273    }
274
275    fn second_turn_id() -> TurnId {
276        TurnId::new("turn_2").unwrap()
277    }
278
279    fn call_id() -> ToolCallId {
280        ToolCallId::new("call_1").unwrap()
281    }
282
283    fn actor_id() -> ActorId {
284        ActorId::new("human_1").unwrap()
285    }
286
287    fn usage() -> Option<ModelUsage> {
288        Some(ModelUsage {
289            input_tokens: 20,
290            output_tokens: 8,
291        })
292    }
293
294    fn rec(seq: u64, event: HarnessEvent) -> RecordedEvent {
295        RecordedEvent {
296            seq,
297            occurred_at_ms: 1_700_000_000_000 + seq,
298            event,
299        }
300    }
301
302    fn context(turn_id: TurnId, content: &str) -> HarnessEvent {
303        HarnessEvent::ContextBuilt {
304            run_id: run_id(),
305            turn_id,
306            context: ContextPack {
307                token_budget: 100,
308                fragments: vec![ContextFragment {
309                    lane: ContextLane::CurrentTask,
310                    source: "user".into(),
311                    content: content.into(),
312                    estimated_tokens: 10,
313                }],
314            },
315        }
316    }
317
318    fn model_requested(turn_id: TurnId, step: u32) -> HarnessEvent {
319        HarnessEvent::ModelRequested {
320            run_id: run_id(),
321            turn_id,
322            step,
323            model: ModelName::new("claude-fable-5").unwrap(),
324        }
325    }
326
327    fn model_failed(turn_id: TurnId, step: u32) -> HarnessEvent {
328        HarnessEvent::ModelFailed {
329            run_id: run_id(),
330            turn_id,
331            step,
332            reason: "provider unavailable".into(),
333        }
334    }
335
336    fn model_responded(
337        turn_id: TurnId,
338        step: u32,
339        content: &str,
340        proposed_calls: Vec<ToolProposal>,
341    ) -> HarnessEvent {
342        model_responded_with_served_model(turn_id, step, content, proposed_calls, None)
343    }
344
345    fn model_responded_with_served_model(
346        turn_id: TurnId,
347        step: u32,
348        content: &str,
349        proposed_calls: Vec<ToolProposal>,
350        served_model: Option<ModelName>,
351    ) -> HarnessEvent {
352        HarnessEvent::ModelResponded {
353            run_id: run_id(),
354            turn_id,
355            step,
356            output: Message {
357                role: MessageRole::Assistant,
358                content: content.into(),
359            },
360            proposed_calls,
361            served_model,
362            usage: usage(),
363        }
364    }
365
366    fn proposal() -> ToolProposal {
367        ToolProposal {
368            tool: ToolName::new("file.read").unwrap(),
369            input: json!({ "path": "README.md" }),
370        }
371    }
372
373    fn call() -> ToolCall {
374        ToolCall {
375            id: call_id(),
376            tool: ToolName::new("file.read").unwrap(),
377            effect: EffectClass::ReadOnly,
378            input: json!({ "path": "README.md" }),
379        }
380    }
381
382    fn result() -> ToolResult {
383        ToolResult {
384            call_id: call_id(),
385            summary: "read README".into(),
386            data: json!({ "bytes": 123 }),
387            artifacts: vec![],
388            visibility: ResultVisibility::Both,
389        }
390    }
391
392    fn start_event(seq: u64) -> RecordedEvent {
393        rec(
394            seq,
395            HarnessEvent::RunStarted {
396                run_id: run_id(),
397                agent_id: agent_id(),
398            },
399        )
400    }
401
402    #[test]
403    fn compaction_precedes_its_context_fragments_in_readback() {
404        let events = vec![
405            start_event(0),
406            rec(
407                1,
408                HarnessEvent::ContextCompacted {
409                    run_id: run_id(),
410                    turn_id: turn_id(),
411                    estimated_tokens_before: 160,
412                    estimated_tokens_after: 80,
413                    dropped_turn_start: 0,
414                    dropped_turn_end_exclusive: 2,
415                },
416            ),
417            rec(2, context(turn_id(), "Keep recent turns")),
418            rec(3, model_requested(turn_id(), 0)),
419            rec(4, model_responded(turn_id(), 0, "done", vec![])),
420            rec(5, HarnessEvent::RunFinished { run_id: run_id() }),
421        ];
422
423        let readback = RunReadback::from_events(&events).unwrap();
424
425        assert_eq!(readback.final_phase, RunPhase::Finished);
426        assert_eq!(readback.next_seq, 6);
427        assert_eq!(
428            readback.entries,
429            vec![
430                ReadbackEntry::ContextCompacted {
431                    turn_id: turn_id(),
432                    estimated_tokens_before: 160,
433                    estimated_tokens_after: 80,
434                    dropped_turn_start: 0,
435                    dropped_turn_end_exclusive: 2,
436                },
437                ReadbackEntry::ContextFragment {
438                    turn_id: turn_id(),
439                    fragment: ContextFragment {
440                        lane: ContextLane::CurrentTask,
441                        source: "user".into(),
442                        content: "Keep recent turns".into(),
443                        estimated_tokens: 10,
444                    },
445                },
446                ReadbackEntry::ModelMessage {
447                    turn_id: turn_id(),
448                    message: Message {
449                        role: MessageRole::Assistant,
450                        content: "done".into(),
451                    },
452                    served_model: None,
453                },
454            ]
455        );
456    }
457
458    #[test]
459    fn pre_field_v0_3_0_response_replays_as_unknown_served_model() {
460        let pre_field_response = serde_json::from_value(json!({
461            "event": "model_responded",
462            "run_id": "run_1",
463            "turn_id": "turn_1",
464            "step": 0,
465            "output": {
466                "role": "assistant",
467                "content": "It is a README."
468            },
469            "proposed_calls": [],
470            "usage": {
471                "input_tokens": 20,
472                "output_tokens": 8
473            }
474        }))
475        .unwrap();
476        let events = vec![
477            start_event(0),
478            rec(1, context(turn_id(), "What is in README?")),
479            rec(2, model_requested(turn_id(), 0)),
480            rec(3, pre_field_response),
481            rec(4, HarnessEvent::RunFinished { run_id: run_id() }),
482        ];
483
484        let readback = RunReadback::from_events(&events).unwrap();
485
486        assert_eq!(readback.final_phase, RunPhase::Finished);
487        assert_eq!(readback.next_seq, 5);
488        assert_eq!(
489            readback.entries,
490            vec![
491                ReadbackEntry::ContextFragment {
492                    turn_id: turn_id(),
493                    fragment: ContextFragment {
494                        lane: ContextLane::CurrentTask,
495                        source: "user".into(),
496                        content: "What is in README?".into(),
497                        estimated_tokens: 10,
498                    },
499                },
500                ReadbackEntry::ModelMessage {
501                    turn_id: turn_id(),
502                    message: Message {
503                        role: MessageRole::Assistant,
504                        content: "It is a README.".into(),
505                    },
506                    served_model: None,
507                },
508            ]
509        );
510    }
511
512    #[test]
513    fn request_alias_and_served_model_remain_distinct_and_visible() {
514        let request_alias = ModelName::new("~openai/gpt-latest").unwrap();
515        let served_model = ModelName::new("openai/gpt-5.2-2026-07-31").unwrap();
516        let events = vec![
517            start_event(0),
518            rec(1, context(turn_id(), "Answer the question")),
519            rec(
520                2,
521                HarnessEvent::ModelRequested {
522                    run_id: run_id(),
523                    turn_id: turn_id(),
524                    step: 0,
525                    model: request_alias.clone(),
526                },
527            ),
528            rec(
529                3,
530                model_responded_with_served_model(
531                    turn_id(),
532                    0,
533                    "Done.",
534                    vec![],
535                    Some(served_model.clone()),
536                ),
537            ),
538            rec(4, HarnessEvent::RunFinished { run_id: run_id() }),
539        ];
540
541        assert!(matches!(
542            &events[2].event,
543            HarnessEvent::ModelRequested { model, .. } if model == &request_alias
544        ));
545
546        let readback = RunReadback::from_events(&events).unwrap();
547        assert_eq!(
548            readback.entries.last().cloned(),
549            Some(ReadbackEntry::ModelMessage {
550                turn_id: turn_id(),
551                message: Message {
552                    role: MessageRole::Assistant,
553                    content: "Done.".into(),
554                },
555                served_model: Some(served_model),
556            })
557        );
558    }
559
560    #[test]
561    fn model_failure_is_replay_visible_before_a_successful_retry() {
562        let events = vec![
563            start_event(0),
564            rec(1, context(turn_id(), "What is in README?")),
565            rec(2, model_requested(turn_id(), 0)),
566            rec(3, model_failed(turn_id(), 0)),
567            rec(4, model_requested(turn_id(), 0)),
568            rec(5, model_responded(turn_id(), 0, "It is a README.", vec![])),
569            rec(6, HarnessEvent::RunFinished { run_id: run_id() }),
570        ];
571
572        let readback = RunReadback::from_events(&events).unwrap();
573
574        assert_eq!(readback.final_phase, RunPhase::Finished);
575        assert_eq!(readback.next_seq, 7);
576        assert_eq!(
577            readback.entries,
578            vec![
579                ReadbackEntry::ContextFragment {
580                    turn_id: turn_id(),
581                    fragment: ContextFragment {
582                        lane: ContextLane::CurrentTask,
583                        source: "user".into(),
584                        content: "What is in README?".into(),
585                        estimated_tokens: 10,
586                    },
587                },
588                ReadbackEntry::ModelFailed {
589                    turn_id: turn_id(),
590                    step: 0,
591                    reason: "provider unavailable".into(),
592                },
593                ReadbackEntry::ModelMessage {
594                    turn_id: turn_id(),
595                    message: Message {
596                        role: MessageRole::Assistant,
597                        content: "It is a README.".into(),
598                    },
599                    served_model: None,
600                },
601            ]
602        );
603    }
604
605    #[test]
606    fn two_turn_ledger_projects_tool_result_continuation() {
607        let events = vec![
608            start_event(0),
609            rec(1, context(turn_id(), "Read README")),
610            rec(2, model_requested(turn_id(), 0)),
611            rec(
612                3,
613                model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
614            ),
615            rec(
616                4,
617                HarnessEvent::ToolCallProposed {
618                    run_id: run_id(),
619                    turn_id: turn_id(),
620                    call: call(),
621                },
622            ),
623            rec(
624                5,
625                HarnessEvent::PolicyEvaluated {
626                    run_id: run_id(),
627                    call_id: call_id(),
628                    decision: PolicyDecision::Allow,
629                },
630            ),
631            rec(
632                6,
633                HarnessEvent::ToolStarted {
634                    run_id: run_id(),
635                    call_id: call_id(),
636                },
637            ),
638            rec(
639                7,
640                HarnessEvent::ToolFinished {
641                    run_id: run_id(),
642                    result: result(),
643                },
644            ),
645            rec(8, context(second_turn_id(), "Tool result: read README")),
646            rec(9, model_requested(second_turn_id(), 1)),
647            rec(
648                10,
649                model_responded(second_turn_id(), 1, "README was read.", vec![]),
650            ),
651            rec(11, HarnessEvent::RunFinished { run_id: run_id() }),
652        ];
653
654        let readback = RunReadback::from_events(&events).unwrap();
655
656        assert_eq!(readback.final_phase, RunPhase::Finished);
657        assert_eq!(readback.next_seq, 12);
658        assert_eq!(
659            readback.entries,
660            vec![
661                ReadbackEntry::ContextFragment {
662                    turn_id: turn_id(),
663                    fragment: ContextFragment {
664                        lane: ContextLane::CurrentTask,
665                        source: "user".into(),
666                        content: "Read README".into(),
667                        estimated_tokens: 10,
668                    },
669                },
670                ReadbackEntry::ModelMessage {
671                    turn_id: turn_id(),
672                    message: Message {
673                        role: MessageRole::Assistant,
674                        content: "I will read it.".into(),
675                    },
676                    served_model: None,
677                },
678                ReadbackEntry::ToolCall {
679                    turn_id: turn_id(),
680                    call: call(),
681                },
682                ReadbackEntry::ToolResult { result: result() },
683                ReadbackEntry::ContextFragment {
684                    turn_id: second_turn_id(),
685                    fragment: ContextFragment {
686                        lane: ContextLane::CurrentTask,
687                        source: "user".into(),
688                        content: "Tool result: read README".into(),
689                        estimated_tokens: 10,
690                    },
691                },
692                ReadbackEntry::ModelMessage {
693                    turn_id: second_turn_id(),
694                    message: Message {
695                        role: MessageRole::Assistant,
696                        content: "README was read.".into(),
697                    },
698                    served_model: None,
699                },
700            ]
701        );
702    }
703
704    #[test]
705    fn proposal_batch_rejection_is_replay_visible_and_allows_a_later_turn() {
706        let events = vec![
707            start_event(0),
708            rec(1, context(turn_id(), "Read README")),
709            rec(2, model_requested(turn_id(), 0)),
710            rec(
711                3,
712                model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
713            ),
714            rec(
715                4,
716                HarnessEvent::ToolProposalsRejected {
717                    run_id: run_id(),
718                    turn_id: turn_id(),
719                    reason: "proposal schema invalid".into(),
720                },
721            ),
722            rec(
723                5,
724                context(second_turn_id(), "The proposed call was invalid"),
725            ),
726            rec(6, model_requested(second_turn_id(), 1)),
727            rec(
728                7,
729                model_responded(second_turn_id(), 1, "Understood.", vec![]),
730            ),
731            rec(8, HarnessEvent::RunFinished { run_id: run_id() }),
732        ];
733
734        let readback = RunReadback::from_events(&events).unwrap();
735
736        assert_eq!(readback.final_phase, RunPhase::Finished);
737        assert_eq!(readback.next_seq, 9);
738        assert_eq!(
739            readback.entries,
740            vec![
741                ReadbackEntry::ContextFragment {
742                    turn_id: turn_id(),
743                    fragment: ContextFragment {
744                        lane: ContextLane::CurrentTask,
745                        source: "user".into(),
746                        content: "Read README".into(),
747                        estimated_tokens: 10,
748                    },
749                },
750                ReadbackEntry::ModelMessage {
751                    turn_id: turn_id(),
752                    message: Message {
753                        role: MessageRole::Assistant,
754                        content: "I will read it.".into(),
755                    },
756                    served_model: None,
757                },
758                ReadbackEntry::ToolProposalsRejected {
759                    turn_id: turn_id(),
760                    reason: "proposal schema invalid".into(),
761                },
762                ReadbackEntry::ContextFragment {
763                    turn_id: second_turn_id(),
764                    fragment: ContextFragment {
765                        lane: ContextLane::CurrentTask,
766                        source: "user".into(),
767                        content: "The proposed call was invalid".into(),
768                        estimated_tokens: 10,
769                    },
770                },
771                ReadbackEntry::ModelMessage {
772                    turn_id: second_turn_id(),
773                    message: Message {
774                        role: MessageRole::Assistant,
775                        content: "Understood.".into(),
776                    },
777                    served_model: None,
778                },
779            ]
780        );
781    }
782
783    #[test]
784    fn denials_and_failures_are_projected_without_tool_results() {
785        let policy_denied = vec![
786            start_event(0),
787            rec(1, context(turn_id(), "Read secret")),
788            rec(2, model_requested(turn_id(), 0)),
789            rec(
790                3,
791                model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
792            ),
793            rec(
794                4,
795                HarnessEvent::ToolCallProposed {
796                    run_id: run_id(),
797                    turn_id: turn_id(),
798                    call: call(),
799                },
800            ),
801            rec(
802                5,
803                HarnessEvent::PolicyEvaluated {
804                    run_id: run_id(),
805                    call_id: call_id(),
806                    decision: PolicyDecision::Deny {
807                        reason: "not allowed".into(),
808                    },
809                },
810            ),
811        ];
812        let policy_readback = RunReadback::from_events(&policy_denied).unwrap();
813        assert!(
814            policy_readback
815                .entries
816                .contains(&ReadbackEntry::PolicyDenied {
817                    call_id: call_id(),
818                    reason: "not allowed".into(),
819                })
820        );
821        assert!(
822            !policy_readback
823                .entries
824                .iter()
825                .any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
826        );
827
828        let approval_denied = vec![
829            start_event(0),
830            rec(1, context(turn_id(), "Write README")),
831            rec(2, model_requested(turn_id(), 0)),
832            rec(
833                3,
834                model_responded(turn_id(), 0, "I will write it.", vec![proposal()]),
835            ),
836            rec(
837                4,
838                HarnessEvent::ToolCallProposed {
839                    run_id: run_id(),
840                    turn_id: turn_id(),
841                    call: call(),
842                },
843            ),
844            rec(
845                5,
846                HarnessEvent::PolicyEvaluated {
847                    run_id: run_id(),
848                    call_id: call_id(),
849                    decision: PolicyDecision::RequireApproval {
850                        reason: "approval needed".into(),
851                    },
852                },
853            ),
854            rec(
855                6,
856                HarnessEvent::ApprovalDenied {
857                    run_id: run_id(),
858                    call_id: call_id(),
859                    actor_id: actor_id(),
860                    reason: "no".into(),
861                },
862            ),
863        ];
864        let approval_readback = RunReadback::from_events(&approval_denied).unwrap();
865        assert!(
866            approval_readback
867                .entries
868                .contains(&ReadbackEntry::ApprovalDenied {
869                    call_id: call_id(),
870                    actor_id: actor_id(),
871                    reason: "no".into(),
872                })
873        );
874        assert!(
875            !approval_readback
876                .entries
877                .iter()
878                .any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
879        );
880
881        let tool_failed = vec![
882            start_event(0),
883            rec(1, context(turn_id(), "Read README")),
884            rec(2, model_requested(turn_id(), 0)),
885            rec(
886                3,
887                model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
888            ),
889            rec(
890                4,
891                HarnessEvent::ToolCallProposed {
892                    run_id: run_id(),
893                    turn_id: turn_id(),
894                    call: call(),
895                },
896            ),
897            rec(
898                5,
899                HarnessEvent::PolicyEvaluated {
900                    run_id: run_id(),
901                    call_id: call_id(),
902                    decision: PolicyDecision::Allow,
903                },
904            ),
905            rec(
906                6,
907                HarnessEvent::ToolStarted {
908                    run_id: run_id(),
909                    call_id: call_id(),
910                },
911            ),
912            rec(
913                7,
914                HarnessEvent::ToolFailed {
915                    run_id: run_id(),
916                    call_id: call_id(),
917                    reason: "tool crashed".into(),
918                },
919            ),
920        ];
921        let failure_readback = RunReadback::from_events(&tool_failed).unwrap();
922        assert!(
923            failure_readback
924                .entries
925                .contains(&ReadbackEntry::ToolFailed {
926                    call_id: call_id(),
927                    reason: "tool crashed".into(),
928                })
929        );
930        assert!(
931            !failure_readback
932                .entries
933                .iter()
934                .any(|entry| matches!(entry, ReadbackEntry::ToolResult { .. }))
935        );
936    }
937
938    #[test]
939    fn approval_grants_are_projected_with_actor() {
940        let events = vec![
941            start_event(0),
942            rec(1, context(turn_id(), "Read README")),
943            rec(2, model_requested(turn_id(), 0)),
944            rec(
945                3,
946                model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
947            ),
948            rec(
949                4,
950                HarnessEvent::ToolCallProposed {
951                    run_id: run_id(),
952                    turn_id: turn_id(),
953                    call: call(),
954                },
955            ),
956            rec(
957                5,
958                HarnessEvent::PolicyEvaluated {
959                    run_id: run_id(),
960                    call_id: call_id(),
961                    decision: PolicyDecision::RequireApproval {
962                        reason: "approval needed".into(),
963                    },
964                },
965            ),
966            rec(
967                6,
968                HarnessEvent::ApprovalGranted {
969                    run_id: run_id(),
970                    call_id: call_id(),
971                    actor_id: actor_id(),
972                },
973            ),
974        ];
975
976        let readback = RunReadback::from_events(&events).unwrap();
977
978        assert_eq!(
979            readback.final_phase,
980            RunPhase::ReadyToExecuteTool { call: call() }
981        );
982        assert!(readback.entries.contains(&ReadbackEntry::ApprovalGranted {
983            call_id: call_id(),
984            actor_id: actor_id(),
985        }));
986    }
987
988    #[test]
989    fn replay_rejects_nonadjacent_turn_id_reuse() {
990        let events = vec![
991            start_event(0),
992            rec(1, context(turn_id(), "First turn")),
993            rec(2, model_requested(turn_id(), 0)),
994            rec(3, model_responded(turn_id(), 0, "done", vec![])),
995            rec(4, context(second_turn_id(), "Second turn")),
996            rec(5, model_requested(second_turn_id(), 1)),
997            rec(6, model_responded(second_turn_id(), 1, "done", vec![])),
998            rec(7, context(turn_id(), "Reused first turn")),
999        ];
1000
1001        assert_eq!(
1002            RunReadback::from_events(&events).unwrap_err(),
1003            Error::TurnReused {
1004                turn_id: "turn_1".into()
1005            }
1006        );
1007    }
1008
1009    #[test]
1010    fn replay_rejects_tool_call_id_reuse_in_a_later_turn() {
1011        let events = vec![
1012            start_event(0),
1013            rec(1, context(turn_id(), "First read")),
1014            rec(2, model_requested(turn_id(), 0)),
1015            rec(
1016                3,
1017                model_responded(turn_id(), 0, "I will read it.", vec![proposal()]),
1018            ),
1019            rec(
1020                4,
1021                HarnessEvent::ToolCallProposed {
1022                    run_id: run_id(),
1023                    turn_id: turn_id(),
1024                    call: call(),
1025                },
1026            ),
1027            rec(
1028                5,
1029                HarnessEvent::PolicyEvaluated {
1030                    run_id: run_id(),
1031                    call_id: call_id(),
1032                    decision: PolicyDecision::Deny {
1033                        reason: "first call denied".into(),
1034                    },
1035                },
1036            ),
1037            rec(6, context(second_turn_id(), "Second read")),
1038            rec(7, model_requested(second_turn_id(), 1)),
1039            rec(
1040                8,
1041                model_responded(
1042                    second_turn_id(),
1043                    1,
1044                    "I will read it again.",
1045                    vec![proposal()],
1046                ),
1047            ),
1048            rec(
1049                9,
1050                HarnessEvent::ToolCallProposed {
1051                    run_id: run_id(),
1052                    turn_id: second_turn_id(),
1053                    call: call(),
1054                },
1055            ),
1056        ];
1057
1058        assert_eq!(
1059            RunReadback::from_events(&events).unwrap_err(),
1060            Error::ToolCallReused {
1061                call_id: "call_1".into()
1062            }
1063        );
1064    }
1065
1066    #[test]
1067    fn invalid_ledger_returns_replay_error() {
1068        let events = vec![start_event(1)];
1069
1070        let err = RunReadback::from_events(&events).unwrap_err();
1071        assert_eq!(
1072            err,
1073            Error::SequenceMismatch {
1074                expected: 0,
1075                actual: 1
1076            }
1077        );
1078    }
1079}