Skip to main content

wist_api/
gateway.rs

1//! Gateway envelope contract types.
2
3use serde::{Deserialize, Serialize};
4
5use wist_contracts::API_VERSION_V1;
6
7/// 报文引用的领域类型(被多条 seam 复用),仍留在 `wist-contracts`;这里 re-export,
8/// 调用方可以从 `wist_api::gateway` 一处取齐。
9pub 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    /// Development placeholder until real signing and verifier plumbing is implemented.
240    pub result_digest: String,
241    /// Development placeholder signature. Consumers must not treat this as production attestation.
242    pub signature: String,
243    /// Development placeholder issuer identity, prefixed as `dev-placeholder:...`.
244    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/// Gateway 对动作结果上报的确认响应。
265#[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/// Gateway 对 Agent 状态上报的确认响应。
274#[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/// 事实上报的确认状态。
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum FactSummaryAckStatus {
288    /// 已入库。
289    Accepted,
290    /// 内容未变(网关按**自算**摘要判定):只刷留痕,未改内容、未重复计分。
291    Duplicate,
292    /// envelope 或身份非法。
293    Rejected,
294}
295
296/// agentd → 网关的事实**摘要**上报(控制面)。
297///
298/// 与数据面上的原文快照(`ReportDiscoverySnapshot`)分工不同,**不是同一条路**:
299/// 摘要只服务用途推断(网关侧按规则表算),去重后 10~30 KB,走已认证的控制面;
300/// 原文快照一台几百 KB,走数据面给中心做资产整理。所以网关只接摘要。
301///
302/// 幂等键是内容摘要,不是 `revision`(后者每轮 refresh 无条件 +1)。
303///
304/// agentd **无条件周期全量**上报,判重归网关:网关用
305/// `wist_contracts::fact_summary::FactContent::content_digest` 从收到的内容**自己算**摘要,
306/// 以此判重。`content_digest` 字段因此只是 agent 的**声明**:
307/// 与网关算出来的不一致时会记 `FactDigestMismatch` 告警(可能只是版本偏差,**不拒收**)。
308#[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    /// agent 侧声明的内容摘要。**不是**判重键:网关从下列内容字段自算,此值只作版本偏差的金丝雀。
317    pub content_digest: String,
318    /// 仅留痕:快照 revision 每轮 refresh 无条件 +1,网关不据它判重。
319    pub revision: i64,
320    /// 仅留痕:观察到的事实属于哪一刻(快照生成时间)。
321    pub observed_at: String,
322    pub os: String,
323    pub arch: String,
324    /// 仅留痕:去重前的进程条数(去重会毁掉基数,留一个原始计数备查),不进摘要。
325    pub process_count: i64,
326    /// 去重后的进程可执行标识。注意两边不同源:
327    /// macOS 是 `ps -axo comm=` 给的完整路径,Linux 是 `/proc/{pid}/comm`(只有 basename)。
328    pub process_executables: Vec<String>,
329    /// 已装包名(仅 linux;macOS 侧待定)。
330    pub packages: Vec<String>,
331    pub listen_ports: Vec<String>,
332    // ── 以下三个是**留痕/展示**字段:**不进内容摘要**,也不参与判重 ──
333    //
334    // 为什么不进摘要:摘要回答的是「内容变了没有」(幂等键与用途判据的输入)。
335    // 机器名、IP 会因 DHCP/改名而变,但它们不影响「这台机器是干什么用的」——
336    // 放进摘要会让每次换网就触发一次重报与重算。所以它们只用于展示与追溯。
337    // 也正因如此,`fact-v1` 的字段集**没变**,不需要 bump 版本、不需要强制重报。
338    /// 主机标识(发现里 `host` 方向的 `host.id`)。
339    #[serde(default)]
340    pub host_id: String,
341    /// 主机名(`host.name`)。
342    #[serde(default)]
343    pub host_name: String,
344    /// 网卡地址(每块网卡一条,形如 `en0 192.168.1.5/24`)。
345    #[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    /// 补上**留痕/展示**字段(不参与内容摘要与判重)。
390    ///
391    /// 为什么另开一个方法而不是给构造函数再加三个参数:那个函数已经有 13 个位置参数,
392    /// 再加就是 16 个 —— 调用方只需错一次顺序,就会把主机名传成 os、把端口传成包名,
393    /// 而这类错**不会报错**(都是 String/Vec<String>),只会静默写错数据。
394    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/// Gateway 对事实上报的确认响应(对应模型 `FactSummaryAccepted`)。
408#[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    /// 幂等命中(`duplicate`)时仍回带已存的建议,Agent 侧不必再问一次。
416    pub suggestion_id: Option<String>,
417    pub received_at: String,
418}
419
420pub const POLL_DISCOVERY_POLICIES_KIND: &str = "poll_discovery_policies";
421
422/// agentd → 网关:拉取**发现方向策略表**(控制面,复用 agent 凭据)。
423///
424/// 为什么是「拉」而不是网关推:agentd 没有入站监听(那要开端口、要证书、要处理公网可达),
425/// 而策略是**幂等内容**(声明式、可重复拉取,与 PollWork 同类)—— 拉一次就够,不必重放。
426///
427/// 为什么不需要 `wait_ms`(PollControlCommands 有):那是长轮询指令流;策略表按版本变化,
428/// 轮询周期由 agentd 自己控(它知道自己能承受多密)。
429#[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/// 网关返回的策略表(对应模型 `Discovery.Probe.DiscoveryAspectPolicySet`)。
440///
441/// 带 `policy_version`:agentd 用它判断「这份与我手上的是不是同一版」,
442/// 从而在版本未变时跳过重算与日志(而不是每次都重新应用一遍)。
443#[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    /// 从策略表组响应。
454    ///
455    /// 不回带 `agent_id`/`instance_id`(其它 ack 会带):这不是「确认某次上报」,
456    /// 而是「把当前版本的内容交给你」—— 身份由凭证本身表达,重复一份只会多一个会失配的字段。
457    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}