Skip to main content

salvor_engine/
fork.rs

1//! Fork planning: the pure computation behind fork-from-node.
2//!
3//! A fork is a NEW run whose log opens with the origin's prefix — every event
4//! below the [`Event::NodeEntered`] the fork restarts from — rewritten under the
5//! fork's own run id, with the seq-0 [`Event::GraphRunStarted`] carrying a
6//! [`ForkOrigin`] as the durable record of that descent. The origin is never
7//! touched: [`plan_fork`] only reads it. Everything in this module is pure — no
8//! store, no clock, no randomness — so the server and the CLI share one honest
9//! story for where the fork boundary sits, which writes the re-walked segment
10//! would re-fire, and what bytes the child's prefix holds.
11//!
12//! # Node boundaries are the only legal fork points
13//!
14//! The boundary is the position `k` of the [`Event::NodeEntered`] for the
15//! requested node. The prefix is every event with `seq < k`; the child restarts
16//! by re-walking from that node. A node the origin never entered is refused
17//! ([`ForkError::NodeNeverEntered`]) rather than guessed at — a fork must land on
18//! a real node boundary the run actually reached, so the prefix is always a
19//! faithful sub-log the child replays byte for byte before continuing live.
20//!
21//! # Refuse-then-record lives one layer up
22//!
23//! This module surfaces the write hazard set ([`ForkPlan::hazards`]): the
24//! [`Effect::Write`] tool intents in the segment the fork will re-walk. Deciding
25//! whether the operator acknowledged them, and recording that acknowledgement
26//! into [`ForkOrigin::acknowledged_writes`], is the caller's policy (the server's
27//! `409 write_replay_hazard`, the CLI's `--acknowledge-writes`). This module
28//! computes the set and copies the acknowledgement into the child; it does not
29//! judge it.
30
31use salvor_core::{Effect, Event, EventEnvelope, ForkOrigin, RunId, SequenceNumber};
32use serde_json::Value;
33use thiserror::Error;
34use time::OffsetDateTime;
35
36/// A recorded [`Effect::Write`] tool intent that a fork's re-walked segment would
37/// re-execute: the evidence the refuse-then-record policy shows the operator.
38///
39/// `seq` is the intent's position in the ORIGIN log — the number the operator
40/// acknowledges and the number recorded into [`ForkOrigin::acknowledged_writes`].
41#[derive(Clone, Debug)]
42pub struct WriteHazard {
43    /// The intent's origin log position.
44    pub seq: u64,
45    /// The tool the intent names.
46    pub tool: String,
47    /// The typed input the intent recorded.
48    pub input: Value,
49    /// The intent's idempotency key, if the tool declared one (a `Write`
50    /// normally carries none).
51    pub idempotency_key: Option<String>,
52    /// When the intent was recorded, carried so the caller can render it.
53    pub recorded_at: OffsetDateTime,
54}
55
56/// Why a fork cannot be planned at all, independent of write acknowledgement.
57///
58/// These are the structural refusals: the run is not a graph run, or the fork
59/// point is not a node the origin entered. The acknowledgement refusal is not
60/// here — it is the caller's policy over [`ForkPlan::hazards`].
61#[derive(Debug, Error)]
62pub enum ForkError {
63    /// The origin's log does not open with [`Event::GraphRunStarted`], so it is
64    /// an ordinary agent run and has no node boundaries to fork from.
65    #[error("run is not a graph run: its log does not open with GraphRunStarted")]
66    NotAGraphRun,
67    /// The origin never entered the requested node (it does not exist in the
68    /// graph, or the walk routed past it). A fork point must be a node the run
69    /// actually reached.
70    #[error(
71        "the origin never entered node `{node}`; a fork point must be a node boundary the run reached"
72    )]
73    NodeNeverEntered {
74        /// The requested node id.
75        node: String,
76    },
77}
78
79/// A planned fork of an origin run: the boundary, the prefix to copy, and the
80/// write hazard set in the segment the fork will re-walk.
81///
82/// Produced by [`plan_fork`] from a read-only view of the origin log. It borrows
83/// nothing from the origin afterward: the prefix envelopes are cloned, so the
84/// caller can build the child ([`build_child_prefix`](Self::build_child_prefix))
85/// without holding the origin log open.
86#[derive(Debug)]
87pub struct ForkPlan {
88    origin_run: RunId,
89    graph_hash: String,
90    from_node: String,
91    // The position of the NodeEntered the fork restarts from. The prefix is every
92    // origin event with seq strictly below this.
93    boundary: SequenceNumber,
94    prefix: Vec<EventEnvelope>,
95    hazards: Vec<WriteHazard>,
96}
97
98impl ForkPlan {
99    /// The run this fork descends from.
100    #[must_use]
101    pub fn origin_run(&self) -> RunId {
102        self.origin_run
103    }
104
105    /// The origin's recorded graph hash, which the fork reuses unchanged.
106    #[must_use]
107    pub fn graph_hash(&self) -> &str {
108        &self.graph_hash
109    }
110
111    /// The node the fork restarts execution from.
112    #[must_use]
113    pub fn from_node(&self) -> &str {
114        &self.from_node
115    }
116
117    /// The boundary the prefix was taken through: the highest `seq` the prefix
118    /// carries, one below the restart node's [`Event::NodeEntered`]. Recorded in
119    /// [`ForkOrigin::through_seq`]. The restart node's entry is always at least
120    /// `seq` 1 (`seq` 0 is the head), so this never underflows.
121    #[must_use]
122    pub fn through_seq(&self) -> SequenceNumber {
123        SequenceNumber::new(self.boundary.get() - 1)
124    }
125
126    /// How many events the child's prefix holds (the origin events with
127    /// `seq < boundary`).
128    #[must_use]
129    pub fn prefix_len(&self) -> usize {
130        self.prefix.len()
131    }
132
133    /// The [`Effect::Write`] intents the fork's re-walked segment would
134    /// re-execute, in origin log order. Empty when the fork boundary sat before
135    /// any write intent, which is the no-acknowledgement-needed case.
136    #[must_use]
137    pub fn hazards(&self) -> &[WriteHazard] {
138        &self.hazards
139    }
140
141    /// The origin log positions of every hazard write, sorted ascending: the set
142    /// an acknowledgement must cover, and the value recorded into
143    /// [`ForkOrigin::acknowledged_writes`] when the fork proceeds.
144    #[must_use]
145    pub fn hazard_seqs(&self) -> Vec<u64> {
146        let mut seqs: Vec<u64> = self.hazards.iter().map(|h| h.seq).collect();
147        seqs.sort_unstable();
148        seqs
149    }
150
151    /// Builds the child's log prefix: the origin's `[0, boundary)` events
152    /// rewritten under `child`, with the seq-0 [`Event::GraphRunStarted`]
153    /// carrying `forked_from`. Every other field of every envelope — `seq`,
154    /// `schema_version`, `recorded_at`, and the payload — is copied verbatim, so
155    /// the child's prefix is byte-identical to the origin's modulo the run id and
156    /// that one seq-0 field. `acknowledged_writes` is recorded verbatim into the
157    /// [`ForkOrigin`].
158    #[must_use]
159    pub fn build_child_prefix(
160        &self,
161        child: RunId,
162        acknowledged_writes: Vec<u64>,
163    ) -> Vec<EventEnvelope> {
164        let origin = ForkOrigin {
165            run_id: self.origin_run,
166            through_seq: self.through_seq(),
167            from_node: self.from_node.clone(),
168            graph_hash: self.graph_hash.clone(),
169            acknowledged_writes,
170        };
171        self.prefix
172            .iter()
173            .enumerate()
174            .map(|(index, envelope)| {
175                let mut event = envelope.event.clone();
176                if index == 0
177                    && let Event::GraphRunStarted { forked_from, .. } = &mut event
178                {
179                    *forked_from = Some(origin.clone());
180                }
181                EventEnvelope {
182                    run_id: child,
183                    seq: envelope.seq,
184                    schema_version: envelope.schema_version,
185                    recorded_at: envelope.recorded_at,
186                    event,
187                }
188            })
189            .collect()
190    }
191}
192
193/// Plans a fork of `origin_log` restarting from `from_node`.
194///
195/// Reads the origin log only. Finds the [`Event::NodeEntered`] for `from_node`,
196/// takes the prefix below it, and scans the segment at and after it for
197/// [`Effect::Write`] intents. Deciding whether those hazards were acknowledged is
198/// the caller's policy; see the module docs.
199///
200/// # Errors
201///
202/// [`ForkError::NotAGraphRun`] when the log does not open with
203/// [`Event::GraphRunStarted`]; [`ForkError::NodeNeverEntered`] when no
204/// [`Event::NodeEntered`] names `from_node`.
205pub fn plan_fork(origin_log: &[EventEnvelope], from_node: &str) -> Result<ForkPlan, ForkError> {
206    let graph_hash = match origin_log.first().map(|envelope| &envelope.event) {
207        Some(Event::GraphRunStarted { graph_hash, .. }) => graph_hash.clone(),
208        _ => return Err(ForkError::NotAGraphRun),
209    };
210
211    let boundary = origin_log
212        .iter()
213        .find_map(|envelope| match &envelope.event {
214            Event::NodeEntered { node } if node == from_node => Some(envelope.seq),
215            _ => None,
216        })
217        .ok_or_else(|| ForkError::NodeNeverEntered {
218            node: from_node.to_owned(),
219        })?;
220
221    let prefix: Vec<EventEnvelope> = origin_log
222        .iter()
223        .filter(|envelope| envelope.seq < boundary)
224        .cloned()
225        .collect();
226
227    // The re-walked segment is every origin event at or beyond the boundary. The
228    // boundary event itself is the restart node's NodeEntered (never a write), so
229    // scanning from it is equivalent to scanning strictly past it; `>=` is used
230    // for a precise, self-evident "the segment the fork re-walks" definition.
231    let hazards: Vec<WriteHazard> = origin_log
232        .iter()
233        .filter(|envelope| envelope.seq >= boundary)
234        .filter_map(|envelope| match &envelope.event {
235            Event::ToolCallRequested {
236                tool,
237                input,
238                effect: Effect::Write,
239                idempotency_key,
240                ..
241            } => Some(WriteHazard {
242                seq: envelope.seq.get(),
243                tool: tool.clone(),
244                input: input.clone(),
245                idempotency_key: idempotency_key.clone(),
246                recorded_at: envelope.recorded_at,
247            }),
248            _ => None,
249        })
250        .collect();
251
252    Ok(ForkPlan {
253        origin_run: origin_log[0].run_id,
254        graph_hash,
255        from_node: from_node.to_owned(),
256        boundary,
257        prefix,
258        hazards,
259    })
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use serde_json::json;
266    use time::macros::datetime;
267    use uuid::Uuid;
268
269    fn run(tag: u8) -> RunId {
270        let mut bytes = [0u8; 16];
271        bytes[15] = tag;
272        bytes[6] = 0x40;
273        bytes[8] = 0x80;
274        RunId::from_uuid(Uuid::from_bytes(bytes))
275    }
276
277    fn envelope(run_id: RunId, seq: u64, event: Event) -> EventEnvelope {
278        EventEnvelope::new(
279            run_id,
280            SequenceNumber::new(seq),
281            datetime!(2026-07-14 12:00:00 UTC),
282            event,
283        )
284    }
285
286    // research (agent) -> publish (write tool). The log: head, research
287    // entered/exited, publish entered + a Write intent + completion + exited,
288    // terminal. Forking from `publish` re-walks the write.
289    fn origin_log() -> Vec<EventEnvelope> {
290        let r = run(1);
291        vec![
292            envelope(
293                r,
294                0,
295                Event::GraphRunStarted {
296                    graph_hash: "sha256:graph".into(),
297                    input: json!({"topic": "otters"}),
298                    labels: None,
299                    forked_from: None,
300                },
301            ),
302            envelope(
303                r,
304                1,
305                Event::NodeEntered {
306                    node: "research".into(),
307                },
308            ),
309            envelope(
310                r,
311                2,
312                Event::NodeExited {
313                    node: "research".into(),
314                },
315            ),
316            envelope(
317                r,
318                3,
319                Event::NodeEntered {
320                    node: "publish".into(),
321                },
322            ),
323            envelope(
324                r,
325                4,
326                Event::ToolCallRequested {
327                    seq: SequenceNumber::new(4),
328                    tool: "http_post".into(),
329                    input: json!({"body": "draft"}),
330                    effect: Effect::Write,
331                    idempotency_key: None,
332                },
333            ),
334            envelope(
335                r,
336                5,
337                Event::ToolCallCompleted {
338                    seq: SequenceNumber::new(4),
339                    output: json!({"ok": true}),
340                },
341            ),
342            envelope(
343                r,
344                6,
345                Event::NodeExited {
346                    node: "publish".into(),
347                },
348            ),
349            envelope(
350                r,
351                7,
352                Event::RunCompleted {
353                    output: json!(null),
354                },
355            ),
356        ]
357    }
358
359    #[test]
360    fn a_non_graph_run_is_refused() {
361        let log = vec![envelope(
362            run(1),
363            0,
364            Event::RunStarted {
365                agent_def_hash: "sha256:a".into(),
366                input: json!(null),
367                labels: None,
368            },
369        )];
370        assert!(matches!(
371            plan_fork(&log, "anything"),
372            Err(ForkError::NotAGraphRun)
373        ));
374    }
375
376    #[test]
377    fn a_node_the_run_never_entered_is_refused() {
378        let error = plan_fork(&origin_log(), "ghost").expect_err("ghost was never entered");
379        match error {
380            ForkError::NodeNeverEntered { node } => assert_eq!(node, "ghost"),
381            other => panic!("expected NodeNeverEntered, got {other:?}"),
382        }
383    }
384
385    #[test]
386    fn forking_before_the_write_finds_no_hazard() {
387        // Fork from research: the prefix is [head, research-entered] and the
388        // re-walked segment (research-exited onward) still contains the write,
389        // so research's fork DOES see the downstream write as a hazard.
390        let plan = plan_fork(&origin_log(), "research").expect("research was entered");
391        assert_eq!(plan.through_seq().get(), 0, "prefix is just the head");
392        assert_eq!(plan.prefix_len(), 1);
393        assert_eq!(
394            plan.hazard_seqs(),
395            vec![4],
396            "the downstream write is still in the re-walked segment"
397        );
398    }
399
400    #[test]
401    fn forking_at_the_write_node_lists_the_write() {
402        let plan = plan_fork(&origin_log(), "publish").expect("publish was entered");
403        assert_eq!(
404            plan.through_seq().get(),
405            2,
406            "prefix is through research-exited"
407        );
408        assert_eq!(plan.prefix_len(), 3, "head + research entered + exited");
409        let hazards = plan.hazards();
410        assert_eq!(hazards.len(), 1);
411        assert_eq!(hazards[0].seq, 4);
412        assert_eq!(hazards[0].tool, "http_post");
413        assert_eq!(hazards[0].input, json!({"body": "draft"}));
414    }
415
416    #[test]
417    fn the_child_prefix_is_byte_identical_modulo_run_id_and_forked_from() {
418        let origin = origin_log();
419        let plan = plan_fork(&origin, "publish").expect("publish was entered");
420        let child = run(2);
421        let child_prefix = plan.build_child_prefix(child, plan.hazard_seqs());
422
423        // Every child envelope carries the child run id and the origin's seq.
424        for (index, env) in child_prefix.iter().enumerate() {
425            assert_eq!(env.run_id, child);
426            assert_eq!(env.seq, origin[index].seq);
427            assert_eq!(env.recorded_at, origin[index].recorded_at);
428        }
429
430        // The seq-0 head carries the fork origin; nothing else does.
431        match &child_prefix[0].event {
432            Event::GraphRunStarted { forked_from, .. } => {
433                let origin_link = forked_from.as_ref().expect("head carries the fork origin");
434                assert_eq!(origin_link.run_id, run(1));
435                assert_eq!(origin_link.through_seq.get(), 2);
436                assert_eq!(origin_link.from_node, "publish");
437                assert_eq!(origin_link.graph_hash, "sha256:graph");
438                assert_eq!(origin_link.acknowledged_writes, vec![4]);
439            }
440            other => panic!("head is not GraphRunStarted: {other:?}"),
441        }
442
443        // The byte-identical proof, constructed explicitly: rewrite the parent
444        // prefix under the child id, blank the seq-0 forked_from, and compare
445        // serialized bytes to the child prefix with its forked_from blanked.
446        let blank = |envelopes: &[EventEnvelope]| -> String {
447            let mut copy: Vec<EventEnvelope> = envelopes.to_vec();
448            for env in &mut copy {
449                env.run_id = child;
450            }
451            if let Event::GraphRunStarted { forked_from, .. } = &mut copy[0].event {
452                *forked_from = None;
453            }
454            serde_json::to_string(&copy).unwrap()
455        };
456        let parent_prefix: Vec<EventEnvelope> = origin[..3].to_vec();
457        let mut child_blanked = child_prefix.clone();
458        if let Event::GraphRunStarted { forked_from, .. } = &mut child_blanked[0].event {
459            *forked_from = None;
460        }
461        assert_eq!(
462            blank(&parent_prefix),
463            serde_json::to_string(&child_blanked).unwrap(),
464            "child prefix is byte-identical to the parent's modulo run id and forked_from"
465        );
466    }
467}