Skip to main content

runtime/
handoff.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Freezing and resume. A Handoff is the five-section
7//! crossing point between a frozen Run and its successor. It is never a
8//! new authority: on conflict, fresh reads and real execution win.
9
10use kernel::event::record::HandoffWritten;
11use kernel::{AxCode, AxError, Locator, Payload, RunId};
12
13/// Five sections, always present: must-read / overview / progress /
14/// context / next step. Prose quality is the handoff probe's business;
15/// the type enforces structure only.
16///
17/// It writes a [`HandoffWritten`], whose field names are the line's
18/// keys, and is never read back from one: [`Handoff::new`] is the only
19/// way to hold one, and a reader that built one from a line would be a
20/// second door past that constructor's refusal of an empty must-read
21/// list.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Handoff {
24    must_read: Vec<Locator>,
25    overview: String,
26    progress: String,
27    context: String,
28    next_step: String,
29}
30
31impl Handoff {
32    /// Sole constructor: the must-read list is non-empty and every entry
33    /// is an already-parsed Locator by type (the same move
34    /// as `Completion::Done(Evidence)`).
35    pub fn new(
36        must_read: Vec<Locator>,
37        overview: String,
38        progress: String,
39        context: String,
40        next_step: String,
41    ) -> Result<Handoff, AxError> {
42        if must_read.is_empty() {
43            return Err(AxError::failure(
44                AxCode::InvalidArgs,
45                "construct handoff",
46                "empty must-read list",
47            )
48            .with_recovery(
49                "list the locators the successor must read before working; \
50                 norms are machine-filled, add only this run's specifics",
51            ));
52        }
53        Ok(Handoff {
54            must_read,
55            overview,
56            progress,
57            context,
58            next_step,
59        })
60    }
61
62    pub fn must_read(&self) -> &[Locator] {
63        &self.must_read
64    }
65
66    pub fn overview(&self) -> &str {
67        &self.overview
68    }
69
70    pub fn progress(&self) -> &str {
71        &self.progress
72    }
73
74    pub fn context(&self) -> &str {
75        &self.context
76    }
77
78    pub fn next_step(&self) -> &str {
79        &self.next_step
80    }
81
82    /// The `handoff_written` payload: these five fields, encoded.
83    ///
84    /// # Errors
85    /// Propagates whatever `Payload::of` says about the encoding.
86    pub fn payload(&self) -> Result<Payload, AxError> {
87        Payload::of(&HandoffWritten {
88            must_read: self.must_read.clone(),
89            overview: self.overview.clone(),
90            progress: self.progress.clone(),
91            context: self.context.clone(),
92            next_step: self.next_step.clone(),
93        })
94    }
95}
96
97/// What a successor starts from. No method returns to the frozen Run:
98/// resume consumes a Handoff and a *new* identity supplied by the caller
99/// (kernel never generates randomness).
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ResumeSeed {
102    pub run: RunId,
103    pub must_read: Vec<Locator>,
104}
105
106/// Rebirth, not revival (meta-principle six): the frozen Run is not a
107/// parameter — there is no way to spell "wake the old Run up".
108pub fn resume(handoff: &Handoff, new_run: RunId) -> ResumeSeed {
109    ResumeSeed {
110        run: new_run,
111        must_read: handoff.must_read().to_vec(),
112    }
113}
114
115#[cfg(test)]
116#[allow(
117    clippy::unwrap_used,
118    clippy::expect_used,
119    clippy::panic,
120    clippy::indexing_slicing,
121    reason = "test code"
122)]
123mod tests {
124    use super::*;
125
126    fn locator() -> Locator {
127        Locator::parse(&format!("cas:b3-{}", "ab".repeat(32))).unwrap()
128    }
129
130    #[test]
131    fn empty_must_read_cannot_be_constructed() {
132        let err = Handoff::new(vec![], "o".into(), "p".into(), "c".into(), "n".into()).unwrap_err();
133        assert_eq!(err.code(), &AxCode::InvalidArgs);
134    }
135
136    #[test]
137    fn payload_carries_five_sections() {
138        let handoff = Handoff::new(
139            vec![locator()],
140            "overview".into(),
141            "progress".into(),
142            "context".into(),
143            "next".into(),
144        )
145        .unwrap();
146        let json = serde_json::to_value(handoff.payload().unwrap()).unwrap();
147        for key in ["must_read", "overview", "progress", "context", "next_step"] {
148            assert!(json.get(key).is_some(), "missing section {key}");
149        }
150        assert_eq!(json["must_read"].as_array().unwrap().len(), 1);
151    }
152
153    #[test]
154    fn resume_mints_the_supplied_identity_and_carries_must_read() {
155        let handoff = Handoff::new(
156            vec![locator()],
157            String::new(),
158            String::new(),
159            String::new(),
160            "user-specified next action".into(),
161        )
162        .unwrap();
163        let new_run = RunId::parse("0198f6a2-7c4a-7bbb-9d1e-000000000002").unwrap();
164        let seed = resume(&handoff, new_run);
165        assert_eq!(seed.run, new_run);
166        assert_eq!(seed.must_read.len(), 1);
167    }
168}