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(©).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}