1use serde::{Deserialize, Serialize};
4
5use wist_contracts::API_VERSION_V1;
6
7pub use wist_contracts::action_plan::ActionPlan;
10pub use wist_contracts::action_result::{ActionResult, FinalStatus};
11pub use wist_contracts::discovery_policy::{DiscoveryAspectPolicy, DiscoveryAspectPolicySet};
12
13pub const DISPATCH_ACTION_PLAN_KIND: &str = "dispatch_action_plan";
14pub const ACTION_PLAN_ACK_KIND: &str = "action_plan_ack";
15pub const REPORT_ACTION_RESULT_KIND: &str = "report_action_result";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct DispatchActionPlan {
20 pub api_version: String,
21 pub kind: String,
22 pub dispatch_id: String,
23 pub plan: ActionPlan,
24}
25
26impl DispatchActionPlan {
27 pub fn new(dispatch_id: String, plan: ActionPlan) -> Self {
28 Self {
29 api_version: API_VERSION_V1.to_string(),
30 kind: DISPATCH_ACTION_PLAN_KIND.to_string(),
31 dispatch_id,
32 plan,
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct ActionPlanAck {
40 pub api_version: String,
41 pub kind: String,
42 pub dispatch_id: String,
43 pub action_id: String,
44 pub plan_digest: String,
45 pub agent_id: String,
46 pub instance_id: String,
47 pub execution_id: Option<String>,
48 pub ack_status: AckStatus,
49 pub reason_code: Option<String>,
50 pub reason_message: Option<String>,
51 pub queue_position: Option<u64>,
52 pub received_at: String,
53 pub acknowledged_at: String,
54}
55
56impl ActionPlanAck {
57 pub fn builder(
58 dispatch_id: String,
59 action_id: String,
60 ack_status: AckStatus,
61 ) -> ActionPlanAckBuilder {
62 ActionPlanAckBuilder {
63 dispatch_id,
64 action_id,
65 plan_digest: String::new(),
66 agent_id: String::new(),
67 instance_id: String::new(),
68 execution_id: None,
69 ack_status,
70 reason_code: None,
71 reason_message: None,
72 queue_position: None,
73 received_at: String::new(),
74 acknowledged_at: String::new(),
75 }
76 }
77
78 #[allow(clippy::too_many_arguments)]
79 pub fn new(
80 dispatch_id: String,
81 action_id: String,
82 plan_digest: String,
83 agent_id: String,
84 instance_id: String,
85 execution_id: Option<String>,
86 ack_status: AckStatus,
87 received_at: String,
88 acknowledged_at: String,
89 ) -> Self {
90 Self::builder(dispatch_id, action_id, ack_status)
91 .plan_digest(plan_digest)
92 .agent_id(agent_id)
93 .instance_id(instance_id)
94 .execution_id(execution_id)
95 .received_at(received_at)
96 .acknowledged_at(acknowledged_at)
97 .build()
98 }
99}
100
101#[derive(Debug, Clone)]
102pub struct ActionPlanAckBuilder {
103 dispatch_id: String,
104 action_id: String,
105 plan_digest: String,
106 agent_id: String,
107 instance_id: String,
108 execution_id: Option<String>,
109 ack_status: AckStatus,
110 reason_code: Option<String>,
111 reason_message: Option<String>,
112 queue_position: Option<u64>,
113 received_at: String,
114 acknowledged_at: String,
115}
116
117impl ActionPlanAckBuilder {
118 pub fn plan_digest(mut self, plan_digest: String) -> Self {
119 self.plan_digest = plan_digest;
120 self
121 }
122
123 pub fn agent_id(mut self, agent_id: String) -> Self {
124 self.agent_id = agent_id;
125 self
126 }
127
128 pub fn instance_id(mut self, instance_id: String) -> Self {
129 self.instance_id = instance_id;
130 self
131 }
132
133 pub fn execution_id(mut self, execution_id: Option<String>) -> Self {
134 self.execution_id = execution_id;
135 self
136 }
137
138 pub fn reason_code(mut self, reason_code: Option<String>) -> Self {
139 self.reason_code = reason_code;
140 self
141 }
142
143 pub fn reason_message(mut self, reason_message: Option<String>) -> Self {
144 self.reason_message = reason_message;
145 self
146 }
147
148 pub fn queue_position(mut self, queue_position: Option<u64>) -> Self {
149 self.queue_position = queue_position;
150 self
151 }
152
153 pub fn received_at(mut self, received_at: String) -> Self {
154 self.received_at = received_at;
155 self
156 }
157
158 pub fn acknowledged_at(mut self, acknowledged_at: String) -> Self {
159 self.acknowledged_at = acknowledged_at;
160 self
161 }
162
163 pub fn build(self) -> ActionPlanAck {
164 ActionPlanAck {
165 api_version: API_VERSION_V1.to_string(),
166 kind: ACTION_PLAN_ACK_KIND.to_string(),
167 dispatch_id: self.dispatch_id,
168 action_id: self.action_id,
169 plan_digest: self.plan_digest,
170 agent_id: self.agent_id,
171 instance_id: self.instance_id,
172 execution_id: self.execution_id,
173 ack_status: self.ack_status,
174 reason_code: self.reason_code,
175 reason_message: self.reason_message,
176 queue_position: self.queue_position,
177 received_at: self.received_at,
178 acknowledged_at: self.acknowledged_at,
179 }
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct ReportActionResult {
186 pub api_version: String,
187 pub report_id: String,
188 pub kind: String,
189 pub dispatch_id: Option<String>,
190 pub action_id: String,
191 pub report_attempt: u32,
192 pub final_status: FinalStatus,
193 pub execution_id: String,
194 pub plan_digest: String,
195 pub agent_id: String,
196 pub instance_id: String,
197 pub result_attestation: ResultAttestation,
198 pub reported_at: String,
199 pub result: ActionResult,
200}
201
202impl ReportActionResult {
203 #[allow(clippy::too_many_arguments)]
204 pub fn new(
205 report_id: String,
206 action_id: String,
207 report_attempt: u32,
208 final_status: FinalStatus,
209 execution_id: String,
210 plan_digest: String,
211 agent_id: String,
212 instance_id: String,
213 result_attestation: ResultAttestation,
214 reported_at: String,
215 result: ActionResult,
216 ) -> Self {
217 Self {
218 api_version: API_VERSION_V1.to_string(),
219 report_id,
220 kind: REPORT_ACTION_RESULT_KIND.to_string(),
221 dispatch_id: None,
222 action_id,
223 report_attempt,
224 final_status,
225 execution_id,
226 plan_digest,
227 agent_id,
228 instance_id,
229 result_attestation,
230 reported_at,
231 result,
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct ResultAttestation {
239 pub result_digest: String,
241 pub signature: String,
243 pub issued_by: String,
245 pub attested_at: String,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249pub enum AckStatus {
250 #[serde(rename = "accepted")]
251 Accepted,
252 #[serde(rename = "rejected")]
253 Rejected,
254 #[serde(rename = "queued")]
255 Queued,
256 #[serde(rename = "duplicate")]
257 Duplicate,
258 #[serde(rename = "stale")]
259 Stale,
260 #[serde(rename = "busy")]
261 Busy,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct ActionResultAck {
268 pub report_id: String,
269 pub agent_id: String,
270 pub acknowledged_at: String,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct AgentStatusAck {
277 pub agent_id: String,
278 pub instance_id: String,
279 pub acknowledged_at: String,
280}
281
282pub const REPORT_AGENT_FACT_SUMMARY_KIND: &str = "report_agent_fact_summary";
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum FactSummaryAckStatus {
288 Accepted,
290 Duplicate,
292 Rejected,
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
309#[serde(deny_unknown_fields)]
310pub struct ReportAgentFactSummary {
311 pub api_version: String,
312 pub kind: String,
313 pub report_id: String,
314 pub agent_id: String,
315 pub instance_id: String,
316 pub content_digest: String,
318 pub revision: i64,
320 pub observed_at: String,
322 pub os: String,
323 pub arch: String,
324 pub process_count: i64,
326 pub process_executables: Vec<String>,
329 pub packages: Vec<String>,
331 pub listen_ports: Vec<String>,
332 #[serde(default)]
340 pub host_id: String,
341 #[serde(default)]
343 pub host_name: String,
344 #[serde(default)]
346 pub network_addresses: Vec<String>,
347 pub reported_at: String,
348}
349
350impl ReportAgentFactSummary {
351 #[allow(clippy::too_many_arguments)]
352 pub fn new_agent_facts(
353 report_id: String,
354 agent_id: String,
355 instance_id: String,
356 content_digest: String,
357 revision: i64,
358 observed_at: String,
359 os: String,
360 arch: String,
361 process_count: i64,
362 process_executables: Vec<String>,
363 packages: Vec<String>,
364 listen_ports: Vec<String>,
365 reported_at: String,
366 ) -> Self {
367 Self {
368 api_version: API_VERSION_V1.to_string(),
369 kind: REPORT_AGENT_FACT_SUMMARY_KIND.to_string(),
370 report_id,
371 agent_id,
372 instance_id,
373 content_digest,
374 revision,
375 observed_at,
376 os,
377 arch,
378 process_count,
379 process_executables,
380 packages,
381 listen_ports,
382 host_id: String::new(),
383 host_name: String::new(),
384 network_addresses: Vec::new(),
385 reported_at,
386 }
387 }
388
389 pub fn with_display(
395 mut self,
396 host_id: String,
397 host_name: String,
398 network_addresses: Vec<String>,
399 ) -> Self {
400 self.host_id = host_id;
401 self.host_name = host_name;
402 self.network_addresses = network_addresses;
403 self
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct FactSummaryAccepted {
411 pub report_id: String,
412 pub agent_id: String,
413 pub content_digest: String,
414 pub ack_status: FactSummaryAckStatus,
415 pub suggestion_id: Option<String>,
417 pub received_at: String,
418}
419
420pub const POLL_DISCOVERY_POLICIES_KIND: &str = "poll_discovery_policies";
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(deny_unknown_fields)]
431pub struct PollDiscoveryPolicies {
432 pub api_version: String,
433 pub kind: String,
434 pub agent_id: String,
435 pub instance_id: String,
436 pub requested_at: String,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(deny_unknown_fields)]
445pub struct DiscoveryPoliciesReturned {
446 pub policy_version: i64,
447 pub published_at: String,
448 pub policies: Vec<DiscoveryAspectPolicy>,
449 pub returned_at: String,
450}
451
452impl DiscoveryPoliciesReturned {
453 pub fn from_set(set: &DiscoveryAspectPolicySet, returned_at: String) -> Self {
458 Self {
459 policy_version: set.policy_version,
460 published_at: set.published_at.clone(),
461 policies: set.policies.clone(),
462 returned_at,
463 }
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 fn plan() -> ActionPlan {
472 serde_json::from_str(
473 r#"{"api_version":"v1","kind":"action_plan",
474 "meta":{"action_id":"act-1","request_id":"req-1","template_id":null,
475 "tenant_id":"t","environment_id":"e","plan_version":1,
476 "compiled_at":"2026-09-27T00:00:00Z","expires_at":"2026-09-28T00:00:00Z"},
477 "target":{"agent_id":"agent-1","instance_id":null,"node_id":"n","host_name":null,
478 "platform":"macos","arch":"arm64","selectors":{}},
479 "constraints":{"risk_level":"R1","approval_ref":null,"approval_mode":"not_required",
480 "requested_by":"admin","reason":null,"max_total_duration_ms":1000,
481 "step_timeout_default_ms":500,"execution_profile":"default",
482 "required_capabilities":[]},
483 "program":{"entry":"s1","steps":[{"id":"s1","kind":"invoke","op":"shell"}]}}"#,
484 )
485 .expect("plan")
486 }
487
488 fn attestation() -> ResultAttestation {
489 ResultAttestation {
490 result_digest: "sha256:abc".to_string(),
491 signature: "dev-placeholder:sig".to_string(),
492 issued_by: "dev-placeholder:agent-1".to_string(),
493 attested_at: "2026-09-27T00:00:02Z".to_string(),
494 }
495 }
496
497 #[test]
498 fn dispatch_action_plan_new_stamps_the_envelope() {
499 let dispatch = DispatchActionPlan::new("disp-1".to_string(), plan());
500 assert_eq!(dispatch.api_version, API_VERSION_V1);
501 assert_eq!(dispatch.kind, DISPATCH_ACTION_PLAN_KIND);
502
503 let json = serde_json::to_string(&dispatch).expect("encode");
504 let back: DispatchActionPlan = serde_json::from_str(&json).expect("decode");
505 assert_eq!(back, dispatch);
506 }
507
508 #[test]
509 fn action_plan_ack_builder_and_new_stamp_the_envelope() {
510 let built =
511 ActionPlanAck::builder("disp-1".to_string(), "act-1".to_string(), AckStatus::Queued)
512 .plan_digest("sha256:plan".to_string())
513 .agent_id("agent-1".to_string())
514 .instance_id("inst-1".to_string())
515 .queue_position(Some(3))
516 .received_at("2026-09-27T00:00:00Z".to_string())
517 .acknowledged_at("2026-09-27T00:00:01Z".to_string())
518 .build();
519 assert_eq!(built.api_version, API_VERSION_V1);
520 assert_eq!(built.kind, ACTION_PLAN_ACK_KIND);
521 assert_eq!(built.queue_position, Some(3));
522 assert_eq!(built.reason_code, None);
523
524 let constructed = ActionPlanAck::new(
525 "disp-1".to_string(),
526 "act-1".to_string(),
527 "sha256:plan".to_string(),
528 "agent-1".to_string(),
529 "inst-1".to_string(),
530 None,
531 AckStatus::Accepted,
532 "2026-09-27T00:00:00Z".to_string(),
533 "2026-09-27T00:00:01Z".to_string(),
534 );
535 assert_eq!(constructed.kind, ACTION_PLAN_ACK_KIND);
536
537 let json = serde_json::to_string(&constructed).expect("encode");
538 let back: ActionPlanAck = serde_json::from_str(&json).expect("decode");
539 assert_eq!(back, constructed);
540 }
541
542 #[test]
543 fn ack_status_uses_the_wire_names_and_rejects_unknown_variants() {
544 for (status, name) in [
545 (AckStatus::Accepted, "accepted"),
546 (AckStatus::Rejected, "rejected"),
547 (AckStatus::Queued, "queued"),
548 (AckStatus::Duplicate, "duplicate"),
549 (AckStatus::Stale, "stale"),
550 (AckStatus::Busy, "busy"),
551 ] {
552 assert_eq!(
553 serde_json::to_string(&status).unwrap(),
554 format!("\"{name}\"")
555 );
556 }
557 assert!(serde_json::from_str::<AckStatus>("\"unknown\"").is_err());
558 }
559
560 #[test]
561 fn report_action_result_new_sets_kind_and_leaves_dispatch_absent() {
562 let result = ActionResult::new(
563 "act-1".to_string(),
564 "exec-1".to_string(),
565 FinalStatus::Succeeded,
566 );
567 let report = ReportActionResult::new(
568 "rep-1".to_string(),
569 "act-1".to_string(),
570 1,
571 FinalStatus::Succeeded,
572 "exec-1".to_string(),
573 "sha256:plan".to_string(),
574 "agent-1".to_string(),
575 "inst-1".to_string(),
576 attestation(),
577 "2026-09-27T00:00:02Z".to_string(),
578 result,
579 );
580 assert_eq!(report.api_version, API_VERSION_V1);
581 assert_eq!(report.kind, REPORT_ACTION_RESULT_KIND);
582 assert_eq!(report.dispatch_id, None);
583
584 let json = serde_json::to_string(&report).expect("encode");
585 let back: ReportActionResult = serde_json::from_str(&json).expect("decode");
586 assert_eq!(back, report);
587 }
588
589 #[test]
590 fn fact_summary_ack_status_uses_snake_case_and_rejects_unknown() {
591 assert_eq!(
592 serde_json::to_string(&FactSummaryAckStatus::Duplicate).unwrap(),
593 "\"duplicate\""
594 );
595 assert!(serde_json::from_str::<FactSummaryAckStatus>("\"nope\"").is_err());
596 }
597
598 #[test]
599 fn new_agent_facts_defaults_display_fields_then_with_display_fills_them() {
600 let summary = ReportAgentFactSummary::new_agent_facts(
601 "fact_1".to_string(),
602 "agent-1".to_string(),
603 "inst-1".to_string(),
604 "fact-v1:sha256:abc".to_string(),
605 7,
606 "2026-09-27T00:00:00Z".to_string(),
607 "macos".to_string(),
608 "arm64".to_string(),
609 3,
610 vec!["/usr/bin/a".to_string()],
611 Vec::new(),
612 vec!["443".to_string()],
613 "2026-09-27T00:00:01Z".to_string(),
614 );
615 assert_eq!(summary.kind, REPORT_AGENT_FACT_SUMMARY_KIND);
616 assert!(summary.host_id.is_empty());
617 assert!(summary.network_addresses.is_empty());
618
619 let with_display = summary.with_display(
620 "host-id".to_string(),
621 "host-name".to_string(),
622 vec!["en0 10.0.0.1/24".to_string()],
623 );
624 assert_eq!(with_display.host_id, "host-id");
625 assert_eq!(with_display.host_name, "host-name");
626
627 let json = serde_json::to_string(&with_display).expect("encode");
628 let back: ReportAgentFactSummary = serde_json::from_str(&json).expect("decode");
629 assert_eq!(back, with_display);
630 }
631
632 #[test]
633 fn discovery_policies_returned_from_set_copies_the_versioned_table() {
634 let set = DiscoveryAspectPolicySet::new(
635 4,
636 "2026-09-27T00:00:00Z".to_string(),
637 vec![DiscoveryAspectPolicy {
638 aspect: "host".to_string(),
639 default_interval_seconds: 900,
640 min_interval_seconds: 60,
641 max_interval_seconds: 3600,
642 baseline: true,
643 enabled_by_default: true,
644 platforms: vec!["macos".to_string(), "linux".to_string()],
645 yields: "os/arch".to_string(),
646 }],
647 );
648 let returned =
649 DiscoveryPoliciesReturned::from_set(&set, "2026-09-27T00:00:01Z".to_string());
650 assert_eq!(returned.policy_version, 4);
651 assert_eq!(returned.policies, set.policies);
652 assert_eq!(returned.returned_at, "2026-09-27T00:00:01Z");
653
654 let json = serde_json::to_string(&returned).expect("encode");
655 let back: DiscoveryPoliciesReturned = serde_json::from_str(&json).expect("decode");
656 assert_eq!(back, returned);
657 }
658
659 #[test]
660 fn poll_discovery_policies_round_trips_and_rejects_unknown_fields() {
661 let poll = PollDiscoveryPolicies {
662 api_version: API_VERSION_V1.to_string(),
663 kind: POLL_DISCOVERY_POLICIES_KIND.to_string(),
664 agent_id: "agent-1".to_string(),
665 instance_id: "inst-1".to_string(),
666 requested_at: "2026-09-27T00:00:00Z".to_string(),
667 };
668 let json = serde_json::to_string(&poll).expect("encode");
669 let back: PollDiscoveryPolicies = serde_json::from_str(&json).expect("decode");
670 assert_eq!(back, poll);
671
672 let mutated = json.replacen('{', "{\"extra\":1,", 1);
673 assert!(serde_json::from_str::<PollDiscoveryPolicies>(&mutated).is_err());
674 }
675}