Skip to main content

gatehouse_runtime_protocol/
intent.rs

1//! Pure transitions for a single removal intent. No filesystem, dispatch,
2//! namespace discovery or lease migration. A durable coordinator must commit
3//! the returned state under its generation lock BEFORE the corresponding effect.
4use crate::{require_absence, Invalid, Label, Operation, Request, Response};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case", try_from = "String")]
9pub enum Phase {
10    Prepared,
11    Unknown,
12    Completed,
13}
14
15impl TryFrom<String> for Phase {
16    type Error = Invalid;
17    fn try_from(value: String) -> Result<Self, Self::Error> {
18        match value.as_str() {
19            "prepared" => Ok(Self::Prepared),
20            "unknown" => Ok(Self::Unknown),
21            "completed" => Ok(Self::Completed),
22            _ => Err(Invalid("unsupported intent phase")),
23        }
24    }
25}
26
27/// Fields are private so ordinary callers cannot skip ownership validation.
28/// Restore accepts only an operator-protected state record with independent
29/// expected request pins; it does not authenticate disk bytes or prevent rollback.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct Intent {
32    schema: String,
33    request: Request,
34    phase: Phase,
35}
36
37#[derive(Deserialize)]
38#[serde(deny_unknown_fields)]
39struct Record {
40    schema: String,
41    request: Request,
42    phase: Phase,
43}
44
45const SCHEMA: &str = "gate-runtime-intent/v1";
46
47fn removal(request: &Request) -> Result<(), Invalid> {
48    request.validate()?;
49    if !matches!(request.operation, Operation::Remove { .. }) {
50        return Err(Invalid("intent requires exact-ID removal"));
51    }
52    Ok(())
53}
54
55fn inspection_request(removal: &Request) -> Request {
56    let Operation::Remove { object_id } = &removal.operation else {
57        unreachable!("private helper called only after removal validation");
58    };
59    Request {
60        binding: removal.binding.clone(),
61        operation: Operation::Inspect {
62            object_id: object_id.clone(),
63        },
64    }
65}
66
67impl Intent {
68    /// The coordinator must independently authenticate the inspection producer
69    /// and pin required labels from its lease, never from the adapter response.
70    pub fn prepare(
71        request: &Request,
72        inspection: &Response,
73        required_labels: &[Label],
74    ) -> Result<Self, Invalid> {
75        removal(request)?;
76        inspection.require_ownership(&inspection_request(request), required_labels)?;
77        Ok(Self {
78            schema: SCHEMA.into(),
79            request: request.clone(),
80            phase: Phase::Prepared,
81        })
82    }
83
84    pub fn phase(&self) -> Phase {
85        self.phase
86    }
87
88    pub fn request(&self) -> &Request {
89        &self.request
90    }
91
92    /// Commit this Unknown state before dispatch. A crash between commit and
93    /// dispatch is conservative: recovery must inspect, never blindly retry.
94    pub fn begin_dispatch(&self, expected: &Request) -> Result<Self, Invalid> {
95        self.request.require_same_intent(expected)?;
96        if self.phase != Phase::Prepared {
97            return Err(Invalid("intent is not prepared for dispatch"));
98        }
99        let mut next = self.clone();
100        next.phase = Phase::Unknown;
101        Ok(next)
102    }
103
104    /// None represents transport loss/cancellation/error. Every valid mutation
105    /// response, including completed/rejected-before-effect, leaves Unknown.
106    /// Invalid responses return an error without modifying the original state.
107    pub fn observe_dispatch(
108        &self,
109        expected: &Request,
110        response: Option<&Response>,
111    ) -> Result<Self, Invalid> {
112        self.request.require_same_intent(expected)?;
113        if self.phase != Phase::Unknown {
114            return Err(Invalid("intent has no unresolved dispatch"));
115        }
116        if let Some(response) = response {
117            response.validate(&self.request)?;
118        }
119        Ok(self.clone())
120    }
121
122    /// Only independently authenticated exact-ID absence can finish Unknown.
123    /// This pure operation does not remove a marker or grant cleanup authority.
124    pub fn reconcile(&self, expected: &Request, inspection: &Response) -> Result<Self, Invalid> {
125        self.request.require_same_intent(expected)?;
126        if self.phase != Phase::Unknown {
127            return Err(Invalid("intent is not awaiting reconciliation"));
128        }
129        require_absence(&self.request, inspection)?;
130        let mut next = self.clone();
131        next.phase = Phase::Completed;
132        Ok(next)
133    }
134
135    /// Read only trusted persisted state. Legacy lease v2, missing pins, duplicate
136    /// fields and unknown schema/phase reject; no ambient values are consulted.
137    pub fn restore(bytes: &[u8], expected: &Request) -> Result<Self, Invalid> {
138        let record: Record = crate::parse(bytes)?;
139        if record.schema != SCHEMA {
140            return Err(Invalid("unsupported intent schema"));
141        }
142        removal(&record.request)?;
143        record.request.require_same_intent(expected)?;
144        Ok(Self {
145            schema: record.schema,
146            request: record.request,
147            phase: record.phase,
148        })
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    // Pure transition model only. These tests do not establish durable commit,
155    // persistence, locking, rollback protection or dispatch behavior.
156    use super::*;
157    use crate::{Binding, Engine, Namespace, Object, Outcome, Protocol};
158    use serde_json::json;
159
160    fn id() -> String {
161        "a".repeat(64)
162    }
163
164    fn binding() -> Binding {
165        Binding {
166            protocol: Protocol::V1,
167            adapter_sha256: "b".repeat(64),
168            adapter_version: "1.0.0".into(),
169            invocation: "inv-1".into(),
170            operation_key: "op-1".into(),
171            generation: "gen-1".into(),
172            namespace: Namespace {
173                engine: Engine::Docker,
174                endpoint: "unix:///run/docker.sock".into(),
175                context: "ctx-1".into(),
176                principal: "uid:1000".into(),
177                rootless: true,
178                scope: "scope-1".into(),
179            },
180        }
181    }
182
183    fn removal() -> Request {
184        Request {
185            binding: binding(),
186            operation: Operation::Remove { object_id: id() },
187        }
188    }
189
190    fn labels() -> Vec<Label> {
191        vec![Label {
192            key: "harness-gate.owner".into(),
193            value: "harness-gate".into(),
194        }]
195    }
196
197    fn present() -> Response {
198        Response {
199            binding: binding(),
200            operation: Operation::Inspect { object_id: id() },
201            outcome: Outcome::Present {
202                object: Object {
203                    object_id: id(),
204                    labels: labels(),
205                },
206            },
207        }
208    }
209
210    fn absent() -> Response {
211        Response {
212            binding: binding(),
213            operation: Operation::Inspect { object_id: id() },
214            outcome: Outcome::Absent,
215        }
216    }
217
218    fn prepared() -> Intent {
219        Intent::prepare(&removal(), &present(), &labels()).unwrap()
220    }
221
222    fn unknown() -> Intent {
223        prepared().begin_dispatch(&removal()).unwrap()
224    }
225
226    fn binding_mutations() -> Vec<Binding> {
227        let mut values = Vec::new();
228        let mutations: [fn(&mut Binding); 11] = [
229            |b| b.adapter_sha256 = "c".repeat(64),
230            |b| b.adapter_version = "2".into(),
231            |b| b.invocation = "inv-2".into(),
232            |b| b.operation_key = "op-2".into(),
233            |b| b.generation = "gen-2".into(),
234            |b| b.namespace.engine = Engine::Podman,
235            |b| b.namespace.endpoint = "unix:///other".into(),
236            |b| b.namespace.context = "ctx-2".into(),
237            |b| b.namespace.principal = "uid:1001".into(),
238            |b| b.namespace.rootless = false,
239            |b| b.namespace.scope = "scope-2".into(),
240        ];
241        for mutate in mutations {
242            let mut value = binding();
243            mutate(&mut value);
244            values.push(value);
245        }
246        values
247    }
248
249    #[test]
250    fn prepare_requires_present_exact_identity_and_trusted_labels() {
251        assert_eq!(prepared().phase(), Phase::Prepared);
252        assert_eq!(prepared().request(), &removal());
253        for response in [
254            Response {
255                binding: binding(),
256                operation: Operation::Inspect {
257                    object_id: "b".repeat(64),
258                },
259                outcome: Outcome::Present {
260                    object: Object {
261                        object_id: "c".repeat(64),
262                        labels: labels(),
263                    },
264                },
265            },
266            absent(),
267            Response {
268                binding: binding(),
269                operation: Operation::Inspect { object_id: id() },
270                outcome: Outcome::Unavailable,
271            },
272        ] {
273            assert!(Intent::prepare(&removal(), &response, &labels()).is_err());
274        }
275        let mut wrong_binding = present();
276        wrong_binding.binding.invocation = "inv-2".into();
277        assert!(Intent::prepare(&removal(), &wrong_binding, &labels()).is_err());
278        let missing = Label {
279            key: "missing".into(),
280            value: "v".into(),
281        };
282        assert!(Intent::prepare(&removal(), &present(), &[missing]).is_err());
283        assert!(Intent::prepare(&removal(), &present(), &[]).is_err());
284        assert!(Intent::prepare(
285            &removal(),
286            &present(),
287            &[labels()[0].clone(), labels()[0].clone()]
288        )
289        .is_err());
290        let mut wrong_value = labels();
291        wrong_value[0].value = "other".into();
292        assert!(Intent::prepare(&removal(), &present(), &wrong_value).is_err());
293        let mut extended = present();
294        if let Outcome::Present { object } = &mut extended.outcome {
295            object.labels.push(Label {
296                key: "extra".into(),
297                value: "allowed".into(),
298            });
299        }
300        assert!(Intent::prepare(&removal(), &extended, &labels()).is_ok());
301    }
302
303    #[test]
304    fn prepare_rejects_non_removal_requests() {
305        let inspect = Request {
306            binding: binding(),
307            operation: Operation::Inspect { object_id: id() },
308        };
309        assert!(Intent::prepare(&inspect, &present(), &labels()).is_err());
310        let capabilities = Request {
311            binding: binding(),
312            operation: Operation::Capabilities,
313        };
314        assert!(Intent::prepare(&capabilities, &present(), &labels()).is_err());
315    }
316
317    #[test]
318    fn binding_conflicts_reject_every_transition_and_restore() {
319        let prepared = prepared();
320        let unknown = unknown();
321        let completed = unknown.reconcile(&removal(), &absent()).unwrap();
322        for changed in binding_mutations() {
323            let expected = Request {
324                binding: changed,
325                operation: removal().operation,
326            };
327            assert!(prepared.begin_dispatch(&expected).is_err());
328            assert!(unknown.observe_dispatch(&expected, None).is_err());
329            assert!(unknown.reconcile(&expected, &absent()).is_err());
330            let bytes = serde_json::to_vec(&completed).unwrap();
331            assert!(Intent::restore(&bytes, &expected).is_err());
332        }
333        for object_id in ["b".repeat(64), "c".repeat(64)] {
334            let expected = Request {
335                binding: binding(),
336                operation: Operation::Remove { object_id },
337            };
338            assert!(prepared.begin_dispatch(&expected).is_err());
339            assert!(unknown.observe_dispatch(&expected, None).is_err());
340            assert!(unknown.reconcile(&expected, &absent()).is_err());
341            let bytes = serde_json::to_vec(&unknown).unwrap();
342            assert!(Intent::restore(&bytes, &expected).is_err());
343        }
344    }
345
346    #[test]
347    fn phase_matrix_rejects_illegal_transitions() {
348        let prepared = prepared();
349        let unknown = unknown();
350        let completed = unknown.reconcile(&removal(), &absent()).unwrap();
351        assert!(prepared.begin_dispatch(&removal()).is_ok());
352        assert!(prepared.observe_dispatch(&removal(), None).is_err());
353        assert!(prepared.reconcile(&removal(), &absent()).is_err());
354        assert!(unknown.begin_dispatch(&removal()).is_err());
355        assert_eq!(
356            unknown.observe_dispatch(&removal(), None).unwrap().phase(),
357            Phase::Unknown
358        );
359        assert_eq!(
360            unknown.reconcile(&removal(), &absent()).unwrap().phase(),
361            Phase::Completed
362        );
363        assert!(completed.begin_dispatch(&removal()).is_err());
364        assert!(completed.observe_dispatch(&removal(), None).is_err());
365        assert!(completed.reconcile(&removal(), &absent()).is_err());
366    }
367
368    #[test]
369    fn unknown_keeps_unknown_for_none_and_all_mutation_outcomes() {
370        let unknown = unknown();
371        assert_eq!(
372            unknown.observe_dispatch(&removal(), None).unwrap().phase(),
373            Phase::Unknown
374        );
375        for outcome in [
376            Outcome::Completed,
377            Outcome::RejectedBeforeEffect,
378            Outcome::Unknown,
379        ] {
380            let response = Response {
381                binding: binding(),
382                operation: removal().operation.clone(),
383                outcome,
384            };
385            let next = unknown
386                .observe_dispatch(&removal(), Some(&response))
387                .unwrap();
388            assert_eq!(next.phase(), Phase::Unknown);
389            assert_eq!(next.request(), unknown.request());
390            assert_eq!(unknown.phase(), Phase::Unknown);
391            assert_eq!(unknown.request(), &removal());
392        }
393    }
394
395    #[test]
396    fn unknown_rejects_invalid_response_without_change() {
397        let unknown = unknown();
398        let invalid = Response {
399            binding: binding(),
400            operation: Operation::Inspect { object_id: id() },
401            outcome: Outcome::Absent,
402        };
403        assert!(unknown
404            .observe_dispatch(&removal(), Some(&invalid))
405            .is_err());
406        assert_eq!(unknown.phase(), Phase::Unknown);
407        assert_eq!(unknown.request(), &removal());
408    }
409
410    #[test]
411    fn completed_requires_exact_id_absence_and_is_terminal() {
412        let unknown = unknown();
413        for response in [
414            present(),
415            Response {
416                binding: binding(),
417                operation: Operation::Inspect { object_id: id() },
418                outcome: Outcome::Unavailable,
419            },
420            Response {
421                binding: binding(),
422                operation: Operation::Inspect {
423                    object_id: "b".repeat(64),
424                },
425                outcome: Outcome::Absent,
426            },
427        ] {
428            assert!(unknown.reconcile(&removal(), &response).is_err());
429        }
430        let completed = unknown.reconcile(&removal(), &absent()).unwrap();
431        assert_eq!(completed.phase(), Phase::Completed);
432        assert!(completed.begin_dispatch(&removal()).is_err());
433        assert!(completed.observe_dispatch(&removal(), None).is_err());
434        assert!(completed.reconcile(&removal(), &absent()).is_err());
435    }
436
437    #[test]
438    fn restore_roundtrips_and_enforces_schema_and_phase() {
439        for intent in [
440            prepared(),
441            unknown(),
442            unknown().reconcile(&removal(), &absent()).unwrap(),
443        ] {
444            let bytes = serde_json::to_vec(&intent).unwrap();
445            let restored = Intent::restore(&bytes, &removal()).unwrap();
446            assert_eq!(restored, intent);
447        }
448        let mut value = serde_json::to_value(prepared()).unwrap();
449        value["schema"] = json!("other/v1");
450        assert!(Intent::restore(&serde_json::to_vec(&value).unwrap(), &removal()).is_err());
451        value["schema"] = json!("gate-runtime-intent/v1");
452        value["phase"] = json!("bogus");
453        assert!(Intent::restore(&serde_json::to_vec(&value).unwrap(), &removal()).is_err());
454        value["phase"] = json!({"prepared": null});
455        assert!(Intent::restore(&serde_json::to_vec(&value).unwrap(), &removal()).is_err());
456        value["extra"] = json!(1);
457        assert!(Intent::restore(&serde_json::to_vec(&value).unwrap(), &removal()).is_err());
458        let legacy = json!({
459            "owner_marker": "harness-gate",
460            "schema_version": 2,
461            "runtime": "docker",
462            "runtime_object_id": id()
463        });
464        assert!(Intent::restore(&serde_json::to_vec(&legacy).unwrap(), &removal()).is_err());
465    }
466
467    #[test]
468    fn restore_rejects_duplicates_missing_pins_and_bad_bytes() {
469        let mut value = serde_json::to_value(prepared()).unwrap();
470        value["request"]["binding"]
471            .as_object_mut()
472            .unwrap()
473            .remove("generation");
474        assert!(Intent::restore(&serde_json::to_vec(&value).unwrap(), &removal()).is_err());
475        let original = serde_json::to_string(&prepared()).unwrap();
476        let duplicate = original.replacen(
477            r#""phase":"prepared""#,
478            r#""phase":"prepared","phase":"prepared""#,
479            1,
480        );
481        assert!(Intent::restore(duplicate.as_bytes(), &removal()).is_err());
482        let mut padded = serde_json::to_vec(&prepared()).unwrap();
483        padded.extend(std::iter::repeat_n(b' ', crate::MAX_MESSAGE_BYTES));
484        assert!(Intent::restore(&padded, &removal()).is_err());
485        let mut invalid = serde_json::to_value(prepared()).unwrap();
486        invalid["request"]["binding"]["invocation"] = json!("SECRET-ECHO\n");
487        let bytes = serde_json::to_vec(&invalid).unwrap();
488        let error = Intent::restore(&bytes, &removal()).unwrap_err();
489        assert!(!error.to_string().contains("SECRET-ECHO"));
490        let before = bytes.clone();
491        let _ = Intent::restore(&bytes, &removal());
492        assert_eq!(bytes, before);
493    }
494}