use serde::{Deserialize, Serialize};
use crate::API_VERSION_V1;
use crate::action_plan::ActionPlan;
use crate::action_result::{ActionResult, FinalStatus};
use crate::discovery_policy::{DiscoveryAspectPolicy, DiscoveryAspectPolicySet};
use crate::enrollment::HostProfile;
pub const DISPATCH_ACTION_PLAN_KIND: &str = "dispatch_action_plan";
pub const ACTION_PLAN_ACK_KIND: &str = "action_plan_ack";
pub const REPORT_ACTION_RESULT_KIND: &str = "report_action_result";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentWorkState {
Paused,
Resumed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentWorkStateChange {
pub input_id: String,
pub state: AgentWorkState,
pub reason: String,
pub at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
kind = "message",
role = "command",
domain = "Reporting",
module = "Reporting.Protocol"
)]
#[serde(deny_unknown_fields)]
pub struct AgentStatusReport {
pub agent_id: String,
pub instance_id: String,
pub version: String,
#[serde(default)]
pub memory_bytes: Option<u64>,
#[serde(default)]
pub cpu_percent: Option<f64>,
#[serde(default)]
pub cpu_cores: Option<u32>,
#[serde(default)]
pub admin_latency_ms: Option<u64>,
#[serde(default)]
pub work_state_changes: Option<Vec<AgentWorkStateChange>>,
#[serde(default)]
pub discovery_policy_version: Option<i64>,
#[serde(default)]
pub local_work: Option<crate::local_work::AgentLocalWork>,
#[serde(default)]
pub uplink_state: Option<crate::agent_uplink::AgentUplinkState>,
#[serde(default)]
pub certificate_status: Option<AgentCertificateStatus>,
#[serde(default)]
pub machine_profile: Option<HostProfile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentCertificateStatus {
pub not_after: String,
pub remaining_seconds: i64,
pub state: String,
#[serde(default)]
pub last_renewal: Option<AgentCredentialRenewal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentCredentialRenewal {
pub outcome: String,
pub checked_at: String,
pub detail: String,
pub not_after: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
kind = "message",
role = "command",
domain = "Reporting",
module = "Reporting.Protocol"
)]
#[serde(deny_unknown_fields)]
pub struct DispatchActionPlan {
pub api_version: String,
pub kind: String,
pub dispatch_id: String,
pub plan: ActionPlan,
}
impl DispatchActionPlan {
pub fn new(dispatch_id: String, plan: ActionPlan) -> Self {
Self {
api_version: API_VERSION_V1.to_string(),
kind: DISPATCH_ACTION_PLAN_KIND.to_string(),
dispatch_id,
plan,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "event", domain = "Reporting", module = "Reporting.Protocol")]
#[serde(deny_unknown_fields)]
pub struct ActionPlanAck {
pub api_version: String,
pub kind: String,
pub dispatch_id: String,
pub action_id: String,
pub plan_digest: String,
pub agent_id: String,
pub instance_id: String,
pub execution_id: Option<String>,
pub ack_status: AckStatus,
pub reason_code: Option<String>,
pub reason_message: Option<String>,
pub queue_position: Option<u64>,
pub received_at: String,
pub acknowledged_at: String,
}
impl ActionPlanAck {
pub fn builder(
dispatch_id: String,
action_id: String,
ack_status: AckStatus,
) -> ActionPlanAckBuilder {
ActionPlanAckBuilder {
dispatch_id,
action_id,
plan_digest: String::new(),
agent_id: String::new(),
instance_id: String::new(),
execution_id: None,
ack_status,
reason_code: None,
reason_message: None,
queue_position: None,
received_at: String::new(),
acknowledged_at: String::new(),
}
}
#[allow(clippy::too_many_arguments)]
pub fn new(
dispatch_id: String,
action_id: String,
plan_digest: String,
agent_id: String,
instance_id: String,
execution_id: Option<String>,
ack_status: AckStatus,
received_at: String,
acknowledged_at: String,
) -> Self {
Self::builder(dispatch_id, action_id, ack_status)
.plan_digest(plan_digest)
.agent_id(agent_id)
.instance_id(instance_id)
.execution_id(execution_id)
.received_at(received_at)
.acknowledged_at(acknowledged_at)
.build()
}
}
#[derive(Debug, Clone, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Protocol")]
pub struct ActionPlanAckBuilder {
dispatch_id: String,
action_id: String,
plan_digest: String,
agent_id: String,
instance_id: String,
execution_id: Option<String>,
ack_status: AckStatus,
reason_code: Option<String>,
reason_message: Option<String>,
queue_position: Option<u64>,
received_at: String,
acknowledged_at: String,
}
impl ActionPlanAckBuilder {
pub fn plan_digest(mut self, plan_digest: String) -> Self {
self.plan_digest = plan_digest;
self
}
pub fn agent_id(mut self, agent_id: String) -> Self {
self.agent_id = agent_id;
self
}
pub fn instance_id(mut self, instance_id: String) -> Self {
self.instance_id = instance_id;
self
}
pub fn execution_id(mut self, execution_id: Option<String>) -> Self {
self.execution_id = execution_id;
self
}
pub fn reason_code(mut self, reason_code: Option<String>) -> Self {
self.reason_code = reason_code;
self
}
pub fn reason_message(mut self, reason_message: Option<String>) -> Self {
self.reason_message = reason_message;
self
}
pub fn queue_position(mut self, queue_position: Option<u64>) -> Self {
self.queue_position = queue_position;
self
}
pub fn received_at(mut self, received_at: String) -> Self {
self.received_at = received_at;
self
}
pub fn acknowledged_at(mut self, acknowledged_at: String) -> Self {
self.acknowledged_at = acknowledged_at;
self
}
pub fn build(self) -> ActionPlanAck {
ActionPlanAck {
api_version: API_VERSION_V1.to_string(),
kind: ACTION_PLAN_ACK_KIND.to_string(),
dispatch_id: self.dispatch_id,
action_id: self.action_id,
plan_digest: self.plan_digest,
agent_id: self.agent_id,
instance_id: self.instance_id,
execution_id: self.execution_id,
ack_status: self.ack_status,
reason_code: self.reason_code,
reason_message: self.reason_message,
queue_position: self.queue_position,
received_at: self.received_at,
acknowledged_at: self.acknowledged_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
kind = "message",
role = "command",
domain = "Reporting",
module = "Reporting.Protocol"
)]
#[serde(deny_unknown_fields)]
pub struct ReportActionResult {
pub api_version: String,
pub report_id: String,
pub kind: String,
pub dispatch_id: Option<String>,
pub action_id: String,
pub report_attempt: u32,
pub final_status: FinalStatus,
pub execution_id: String,
pub plan_digest: String,
pub agent_id: String,
pub instance_id: String,
pub result_attestation: ResultAttestation,
pub reported_at: String,
pub result: ActionResult,
}
impl ReportActionResult {
#[allow(clippy::too_many_arguments)]
pub fn new(
report_id: String,
action_id: String,
report_attempt: u32,
final_status: FinalStatus,
execution_id: String,
plan_digest: String,
agent_id: String,
instance_id: String,
result_attestation: ResultAttestation,
reported_at: String,
result: ActionResult,
) -> Self {
Self {
api_version: API_VERSION_V1.to_string(),
report_id,
kind: REPORT_ACTION_RESULT_KIND.to_string(),
dispatch_id: None,
action_id,
report_attempt,
final_status,
execution_id,
plan_digest,
agent_id,
instance_id,
result_attestation,
reported_at,
result,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(kind = "struct", domain = "Reporting", module = "Reporting.Protocol")]
#[serde(deny_unknown_fields)]
pub struct ResultAttestation {
pub result_digest: String,
pub signature: String,
pub issued_by: String,
pub attested_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AckStatus {
#[serde(rename = "accepted")]
Accepted,
#[serde(rename = "rejected")]
Rejected,
#[serde(rename = "queued")]
Queued,
#[serde(rename = "duplicate")]
Duplicate,
#[serde(rename = "stale")]
Stale,
#[serde(rename = "busy")]
Busy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionResultAck {
pub report_id: String,
pub agent_id: String,
pub acknowledged_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentStatusAck {
pub agent_id: String,
pub instance_id: String,
pub acknowledged_at: String,
}
pub const REPORT_AGENT_FACT_SUMMARY_KIND: &str = "report_agent_fact_summary";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactSummaryAckStatus {
Accepted,
Duplicate,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
kind = "message",
role = "command",
domain = "Reporting",
module = "Reporting.Protocol"
)]
#[serde(deny_unknown_fields)]
pub struct ReportAgentFactSummary {
pub api_version: String,
pub kind: String,
pub report_id: String,
pub agent_id: String,
pub instance_id: String,
pub content_digest: String,
pub revision: i64,
pub observed_at: String,
pub os: String,
pub arch: String,
pub process_count: i64,
pub process_executables: Vec<String>,
pub packages: Vec<String>,
pub listen_ports: Vec<String>,
#[serde(default)]
pub host_id: String,
#[serde(default)]
pub host_name: String,
#[serde(default)]
pub network_addresses: Vec<String>,
pub reported_at: String,
}
impl ReportAgentFactSummary {
#[allow(clippy::too_many_arguments)]
pub fn new_agent_facts(
report_id: String,
agent_id: String,
instance_id: String,
content_digest: String,
revision: i64,
observed_at: String,
os: String,
arch: String,
process_count: i64,
process_executables: Vec<String>,
packages: Vec<String>,
listen_ports: Vec<String>,
reported_at: String,
) -> Self {
Self {
api_version: API_VERSION_V1.to_string(),
kind: REPORT_AGENT_FACT_SUMMARY_KIND.to_string(),
report_id,
agent_id,
instance_id,
content_digest,
revision,
observed_at,
os,
arch,
process_count,
process_executables,
packages,
listen_ports,
host_id: String::new(),
host_name: String::new(),
network_addresses: Vec::new(),
reported_at,
}
}
pub fn with_display(
mut self,
host_id: String,
host_name: String,
network_addresses: Vec<String>,
) -> Self {
self.host_id = host_id;
self.host_name = host_name;
self.network_addresses = network_addresses;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FactSummaryAccepted {
pub report_id: String,
pub agent_id: String,
pub content_digest: String,
pub ack_status: FactSummaryAckStatus,
pub suggestion_id: Option<String>,
pub received_at: String,
}
pub const POLL_DISCOVERY_POLICIES_KIND: &str = "poll_discovery_policies";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
kind = "message",
role = "command",
domain = "Reporting",
module = "Reporting.Protocol"
)]
#[serde(deny_unknown_fields)]
pub struct PollDiscoveryPolicies {
pub api_version: String,
pub kind: String,
pub agent_id: String,
pub instance_id: String,
pub requested_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ::jumo_derive::Jumo)]
#[jumo(
kind = "message",
role = "response",
domain = "Reporting",
module = "Reporting.Protocol"
)]
#[serde(deny_unknown_fields)]
pub struct DiscoveryPoliciesReturned {
pub policy_version: i64,
pub published_at: String,
pub policies: Vec<DiscoveryAspectPolicy>,
pub returned_at: String,
}
impl DiscoveryPoliciesReturned {
pub fn from_set(set: &DiscoveryAspectPolicySet, returned_at: String) -> Self {
Self {
policy_version: set.policy_version,
published_at: set.published_at.clone(),
policies: set.policies.clone(),
returned_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plan() -> ActionPlan {
serde_json::from_str(
r#"{"api_version":"v1","kind":"action_plan",
"meta":{"action_id":"act-1","request_id":"req-1","template_id":null,
"tenant_id":"t","environment_id":"e","plan_version":1,
"compiled_at":"2026-09-27T00:00:00Z","expires_at":"2026-09-28T00:00:00Z"},
"target":{"agent_id":"agent-1","instance_id":null,"node_id":"n","host_name":null,
"platform":"macos","arch":"arm64","selectors":{}},
"constraints":{"risk_level":"R1","approval_ref":null,"approval_mode":"not_required",
"requested_by":"admin","reason":null,"max_total_duration_ms":1000,
"step_timeout_default_ms":500,"execution_profile":"default",
"required_capabilities":[]},
"program":{"entry":"s1","steps":[{"id":"s1","kind":"invoke","op":"shell"}]}}"#,
)
.expect("plan")
}
fn attestation() -> ResultAttestation {
ResultAttestation {
result_digest: "sha256:abc".to_string(),
signature: "dev-placeholder:sig".to_string(),
issued_by: "dev-placeholder:agent-1".to_string(),
attested_at: "2026-09-27T00:00:02Z".to_string(),
}
}
#[test]
fn dispatch_action_plan_new_stamps_the_envelope() {
let dispatch = DispatchActionPlan::new("disp-1".to_string(), plan());
assert_eq!(dispatch.api_version, API_VERSION_V1);
assert_eq!(dispatch.kind, DISPATCH_ACTION_PLAN_KIND);
let json = serde_json::to_string(&dispatch).expect("encode");
let back: DispatchActionPlan = serde_json::from_str(&json).expect("decode");
assert_eq!(back, dispatch);
}
#[test]
fn action_plan_ack_builder_and_new_stamp_the_envelope() {
let built =
ActionPlanAck::builder("disp-1".to_string(), "act-1".to_string(), AckStatus::Queued)
.plan_digest("sha256:plan".to_string())
.agent_id("agent-1".to_string())
.instance_id("inst-1".to_string())
.queue_position(Some(3))
.received_at("2026-09-27T00:00:00Z".to_string())
.acknowledged_at("2026-09-27T00:00:01Z".to_string())
.build();
assert_eq!(built.api_version, API_VERSION_V1);
assert_eq!(built.kind, ACTION_PLAN_ACK_KIND);
assert_eq!(built.queue_position, Some(3));
assert_eq!(built.reason_code, None);
let constructed = ActionPlanAck::new(
"disp-1".to_string(),
"act-1".to_string(),
"sha256:plan".to_string(),
"agent-1".to_string(),
"inst-1".to_string(),
None,
AckStatus::Accepted,
"2026-09-27T00:00:00Z".to_string(),
"2026-09-27T00:00:01Z".to_string(),
);
assert_eq!(constructed.kind, ACTION_PLAN_ACK_KIND);
let json = serde_json::to_string(&constructed).expect("encode");
let back: ActionPlanAck = serde_json::from_str(&json).expect("decode");
assert_eq!(back, constructed);
}
#[test]
fn ack_status_uses_the_wire_names_and_rejects_unknown_variants() {
for (status, name) in [
(AckStatus::Accepted, "accepted"),
(AckStatus::Rejected, "rejected"),
(AckStatus::Queued, "queued"),
(AckStatus::Duplicate, "duplicate"),
(AckStatus::Stale, "stale"),
(AckStatus::Busy, "busy"),
] {
assert_eq!(
serde_json::to_string(&status).unwrap(),
format!("\"{name}\"")
);
}
assert!(serde_json::from_str::<AckStatus>("\"unknown\"").is_err());
}
#[test]
fn report_action_result_new_sets_kind_and_leaves_dispatch_absent() {
let result = ActionResult::new(
"act-1".to_string(),
"exec-1".to_string(),
FinalStatus::Succeeded,
);
let report = ReportActionResult::new(
"rep-1".to_string(),
"act-1".to_string(),
1,
FinalStatus::Succeeded,
"exec-1".to_string(),
"sha256:plan".to_string(),
"agent-1".to_string(),
"inst-1".to_string(),
attestation(),
"2026-09-27T00:00:02Z".to_string(),
result,
);
assert_eq!(report.api_version, API_VERSION_V1);
assert_eq!(report.kind, REPORT_ACTION_RESULT_KIND);
assert_eq!(report.dispatch_id, None);
let json = serde_json::to_string(&report).expect("encode");
let back: ReportActionResult = serde_json::from_str(&json).expect("decode");
assert_eq!(back, report);
}
#[test]
fn agent_work_state_uses_snake_case_on_the_wire() {
assert_eq!(
serde_json::to_string(&AgentWorkState::Paused).unwrap(),
"\"paused\""
);
assert_eq!(
serde_json::to_string(&AgentWorkState::Resumed).unwrap(),
"\"resumed\""
);
let change = AgentWorkStateChange {
input_id: "app".to_string(),
state: AgentWorkState::Paused,
reason: "spool_over_limit".to_string(),
at: "2026-09-27T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&change).expect("encode");
let back: AgentWorkStateChange = serde_json::from_str(&json).expect("decode");
assert_eq!(back, change);
let mutated = json.replacen('{', "{\"extra\":1,", 1);
assert!(serde_json::from_str::<AgentWorkStateChange>(&mutated).is_err());
}
#[test]
fn a_minimal_agent_status_report_decodes_and_extra_keys_fail() {
let json = r#"{"agent_id":"a","instance_id":"i","version":"0.1.5"}"#;
let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
assert_eq!(report.memory_bytes, None);
assert_eq!(report.cpu_percent, None);
assert_eq!(report.work_state_changes, None);
assert_eq!(report.discovery_policy_version, None);
assert!(report.local_work.is_none());
assert!(report.uplink_state.is_none());
assert!(report.certificate_status.is_none());
assert!(report.machine_profile.is_none());
assert!(
serde_json::from_str::<AgentStatusReport>(
r#"{"agent_id":"a","instance_id":"i","version":"v","nope":1}"#
)
.is_err()
);
}
#[test]
fn an_agent_status_report_carries_the_machine_profile() {
let json = r#"{
"agent_id":"a","instance_id":"i","version":"0.1.24",
"machine_profile":{
"node_id":"node-1","hostname":"host-1","os":"linux","arch":"x86_64",
"machine_id":"mid-1","cloud_instance_id":null,"k8s_node_uid":null,
"ip_addresses":["en0 192.168.1.5/24","10.8.0.2"]
}
}"#;
let report: AgentStatusReport = serde_json::from_str(json).expect("decode");
let profile = report.machine_profile.expect("machine_profile present");
assert_eq!(profile.hostname, "host-1");
assert_eq!(profile.node_id, "node-1");
assert_eq!(profile.machine_id, "mid-1");
assert_eq!(
profile.ip_addresses,
vec!["en0 192.168.1.5/24".to_string(), "10.8.0.2".to_string()]
);
}
#[test]
fn certificate_status_last_renewal_is_optional_and_round_trips() {
let legacy =
r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid"}"#;
let status: AgentCertificateStatus = serde_json::from_str(legacy).expect("decode");
assert!(status.last_renewal.is_none());
let json = r#"{"not_after":"2026-11-04T00:00:00Z","remaining_seconds":100,"state":"valid",
"last_renewal":{"outcome":"renewed","checked_at":"2026-10-08T00:00:00Z",
"detail":"credential renewed","not_after":"2026-11-04T00:00:00Z"}}"#;
let status: AgentCertificateStatus = serde_json::from_str(json).expect("decode");
assert_eq!(
status.last_renewal.as_ref().expect("renewal").outcome,
"renewed"
);
let encoded = serde_json::to_string(&status).expect("encode");
let back: AgentCertificateStatus = serde_json::from_str(&encoded).expect("decode");
assert_eq!(back, status);
let drifted = r#"{"not_after":"x","remaining_seconds":0,"state":"valid",
"last_renewal":{"outcome":"renewed","checked_at":"t","detail":"","not_after":"",
"extra":1}}"#;
assert!(serde_json::from_str::<AgentCertificateStatus>(drifted).is_err());
}
#[test]
fn fact_summary_ack_status_uses_snake_case_and_rejects_unknown() {
assert_eq!(
serde_json::to_string(&FactSummaryAckStatus::Duplicate).unwrap(),
"\"duplicate\""
);
assert!(serde_json::from_str::<FactSummaryAckStatus>("\"nope\"").is_err());
}
#[test]
fn new_agent_facts_defaults_display_fields_then_with_display_fills_them() {
let summary = ReportAgentFactSummary::new_agent_facts(
"fact_1".to_string(),
"agent-1".to_string(),
"inst-1".to_string(),
"fact-v1:sha256:abc".to_string(),
7,
"2026-09-27T00:00:00Z".to_string(),
"macos".to_string(),
"arm64".to_string(),
3,
vec!["/usr/bin/a".to_string()],
Vec::new(),
vec!["443".to_string()],
"2026-09-27T00:00:01Z".to_string(),
);
assert_eq!(summary.kind, REPORT_AGENT_FACT_SUMMARY_KIND);
assert!(summary.host_id.is_empty());
assert!(summary.network_addresses.is_empty());
let with_display = summary.with_display(
"host-id".to_string(),
"host-name".to_string(),
vec!["en0 10.0.0.1/24".to_string()],
);
assert_eq!(with_display.host_id, "host-id");
assert_eq!(with_display.host_name, "host-name");
let json = serde_json::to_string(&with_display).expect("encode");
let back: ReportAgentFactSummary = serde_json::from_str(&json).expect("decode");
assert_eq!(back, with_display);
}
#[test]
fn discovery_policies_returned_from_set_copies_the_versioned_table() {
let set = DiscoveryAspectPolicySet::new(
4,
"2026-09-27T00:00:00Z".to_string(),
vec![DiscoveryAspectPolicy {
aspect: "host".to_string(),
default_interval_seconds: 900,
min_interval_seconds: 60,
max_interval_seconds: 3600,
baseline: true,
enabled_by_default: true,
platforms: vec!["macos".to_string(), "linux".to_string()],
yields: "os/arch".to_string(),
}],
);
let returned =
DiscoveryPoliciesReturned::from_set(&set, "2026-09-27T00:00:01Z".to_string());
assert_eq!(returned.policy_version, 4);
assert_eq!(returned.policies, set.policies);
assert_eq!(returned.returned_at, "2026-09-27T00:00:01Z");
let json = serde_json::to_string(&returned).expect("encode");
let back: DiscoveryPoliciesReturned = serde_json::from_str(&json).expect("decode");
assert_eq!(back, returned);
}
#[test]
fn poll_discovery_policies_round_trips_and_rejects_unknown_fields() {
let poll = PollDiscoveryPolicies {
api_version: API_VERSION_V1.to_string(),
kind: POLL_DISCOVERY_POLICIES_KIND.to_string(),
agent_id: "agent-1".to_string(),
instance_id: "inst-1".to_string(),
requested_at: "2026-09-27T00:00:00Z".to_string(),
};
let json = serde_json::to_string(&poll).expect("encode");
let back: PollDiscoveryPolicies = serde_json::from_str(&json).expect("decode");
assert_eq!(back, poll);
let mutated = json.replacen('{', "{\"extra\":1,", 1);
assert!(serde_json::from_str::<PollDiscoveryPolicies>(&mutated).is_err());
}
}