1use 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#[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 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 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 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 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 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 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}