Skip to main content

uptrakit_wire/
payloads.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use time::UtcDateTime;
5use uuid::Uuid;
6
7use super::capabilities::{Capability, EnrollmentStatus};
8use super::shared_types::{DisconnectReason, UpdateFinalStatus};
9use crate::serde_helpers::{duration_seconds, option_duration_seconds, utc_datetime_millis};
10use uptrakit_shared_types::{
11    DiscoveredSoftware, OutputStreamType, PluginTypeId, ReleaseInfo, SecretString, UpdateCategory,
12};
13
14/// Payload for ping messages.
15///
16/// Heartbeat sent by the service; the controller responds with [`PongPayload`],
17/// echoing `service_ts` back so the service can calculate round-trip time.
18#[non_exhaustive]
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21pub struct PingPayload {
22    /// Timestamp when the service sent the ping.
23    pub service_ts: super::shared_types::Timestamp,
24}
25
26impl PingPayload {
27    /// Creates a new `PingPayload` with the given service timestamp.
28    pub fn new(service_ts: super::shared_types::Timestamp) -> Self {
29        Self { service_ts }
30    }
31}
32
33/// Payload for pong messages.
34///
35/// Heartbeat response sent by the controller. Echoes back the ping's
36/// `service_ts` alongside `controller_ts`, letting the service compute
37/// round-trip time as `now - service_ts` on receipt.
38#[non_exhaustive]
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct PongPayload {
42    /// Original timestamp from the service's ping.
43    pub service_ts: super::shared_types::Timestamp,
44    /// Timestamp when the controller processed the ping.
45    pub controller_ts: super::shared_types::Timestamp,
46}
47
48impl PongPayload {
49    /// Creates a new `PongPayload` with the given service and controller timestamps.
50    pub fn new(
51        service_ts: super::shared_types::Timestamp,
52        controller_ts: super::shared_types::Timestamp,
53    ) -> Self {
54        Self {
55            service_ts,
56            controller_ts,
57        }
58    }
59}
60
61/// Information about the host machine running the agent.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64pub struct HostInfo {
65    /// Persistent machine identifier (e.g. `/etc/machine-id` on Linux, `IOPlatformUUID` on macOS).
66    ///
67    /// Falls back to `"unknown"` if the identifier cannot be read.
68    pub machine_id: String,
69    /// Operating system type (e.g. "linux", "macos").
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub os_type: Option<String>,
72    /// Operating system version (e.g. "Ubuntu 24.04 LTS").
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub os_version: Option<String>,
75    /// CPU architecture (e.g. "x86_64", "aarch64").
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub architecture: Option<String>,
78    /// Hostname reported by the agent/host machine.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub hostname: Option<String>,
81    /// Network address of the host (SSH target address for SSH agent hosts).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub ip_address: Option<String>,
84    /// Agent-local UUID assigned to this host at bootstrap time.
85    ///
86    /// When present, the controller uses this as `hosts.id` when creating a
87    /// new row, ensuring agent and controller share the same UUID. This is
88    /// required for plugin FK operations (e.g. Proxmox host mapping) that
89    /// reference `hosts.id` before the controller has generated its own UUID.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub agent_host_id: Option<Uuid>,
92    /// Agent-probed host features (e.g. `["posix_shell", "privilege_escalation", "systemd"]`).
93    ///
94    /// `None` for legacy agents that predate feature reporting. Uses `Vec<String>`
95    /// (not `BTreeSet<HostFeature>`) on the wire for forward-compatibility: if a
96    /// newer agent reports a feature the controller doesn't know, it is stored
97    /// losslessly and ignored by `HostCapabilities` parsing.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub features: Option<Vec<String>>,
100}
101
102/// Payload for service enrollment request.
103///
104/// Used by both agents and MQTT services. Host information is reported
105/// separately via [`ReportHostsPayload`] after authentication.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108pub struct EnrollPayload {
109    pub hostname: String,
110    pub friendly_name: String,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
113    pub enrollment_token: Option<SecretString>,
114    /// Capabilities this service supports.
115    ///
116    /// The controller persists these in the `services.capabilities` column and
117    /// derives behavioral defaults from the resulting [`ServiceProfile`](crate::ServiceProfile).
118    pub capabilities: BTreeSet<Capability>,
119    /// The binary/crate name of the enrolling service (e.g., `"uptrakit-agent-ssh"`).
120    ///
121    /// Derived from `env!("CARGO_PKG_NAME")` at compile time. Used for UI
122    /// display, extension conflict detection, and distinguishing service binaries.
123    pub service_app_name: String,
124}
125
126/// Payload for requesting a client certificate after approval.
127///
128/// Sent after receiving `approved`. The service generates a fresh ECDSA P-256
129/// keypair, creates a CSR with CN=service_id, and submits it here; the controller
130/// responds with [`CertificatePayload`] (cert PEM only — the private key stays on
131/// the service).
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct RequestCertificatePayload {
135    /// PEM-encoded Certificate Signing Request.
136    pub csr_pem: String,
137}
138
139/// Payload for requesting certificate renewal (mTLS-authenticated services).
140///
141/// Generated with a fresh ECDSA P-256 keypair, sent either proactively before the
142/// current certificate expires or in response to a [`RequestCertRenewalPayload`]
143/// push (e.g. after CA rotation). The controller responds with [`CertificatePayload`].
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146pub struct RenewCertificatePayload {
147    /// PEM-encoded Certificate Signing Request with CN=service_id.
148    pub csr_pem: String,
149}
150
151/// Payload for reporting host information (sent by authenticated agents on connect).
152///
153/// Supports multiple hosts per message, enabling a single service instance
154/// (e.g. a future SSH-backed agent) to manage several remote hosts.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157pub struct ReportHostsPayload {
158    /// One or more host machines managed by this service.
159    pub hosts: Vec<HostInfo>,
160    /// Agent binary version (e.g., "0.0.1").
161    pub agent_version: String,
162    /// Capabilities advertised by this service.
163    ///
164    /// The controller computes the agreed set as the intersection of this set
165    /// with its own capabilities, considering only typed (known) variants.
166    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
167    pub capabilities: BTreeSet<Capability>,
168}
169
170/// Payload for enrollment confirmation.
171///
172/// Sent once by the controller in response to `enroll`. `enrollment_secret` must be
173/// persisted by the service: it is presented as an `Authorization: Bearer` header to
174/// reconnect and resume the enrollment session (e.g. after a restart) if the service
175/// has not yet obtained a client certificate. `status` reflects whether the service
176/// requires manual admin approval or was auto-approved.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct EnrolledPayload {
180    pub service_id: Uuid,
181    #[cfg_attr(feature = "schema", schemars(with = "String"))]
182    pub enrollment_secret: SecretString,
183    pub status: EnrollmentStatus,
184}
185
186/// Payload for approval notification.
187///
188/// Pushed by the controller as soon as an admin approves the service — the service
189/// does not poll for this; it simply waits on the still-open enrollment connection
190/// (or a reconnect made with the `enrollment_secret` bearer token). On receipt, the
191/// service proceeds to generate a keypair and send `request_certificate`.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194pub struct ApprovedPayload {
195    pub service_id: Uuid,
196}
197
198/// Payload for rejection notification.
199///
200/// Pushed by the controller when an admin rejects a pending enrollment. The service
201/// should disconnect and exit; it must not retry the enrollment flow automatically.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct RejectedPayload {
205    pub service_id: Uuid,
206}
207
208/// Payload for issued certificate.
209///
210/// Sent in response to [`RequestCertificatePayload`] or `RenewCertificatePayload`.
211/// The private key is never included — the service already holds it locally. The
212/// service should persist the certificate and reconnect using mTLS.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct CertificatePayload {
216    pub cert_pem: String,
217    /// Certificate "not valid after" timestamp.
218    #[serde(with = "utc_datetime_millis")]
219    #[cfg_attr(feature = "schema", schemars(with = "i64"))]
220    pub not_after: UtcDateTime,
221}
222
223/// Payload for service runtime settings pushed by the controller.
224///
225/// Used for both agents and MQTT services. `shutdown_timeout` is
226/// present for agents and `None` for MQTT services.
227#[non_exhaustive]
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
230pub struct ServiceSettingsPayload {
231    pub renewal_window_hours: u16,
232    #[serde(default)]
233    pub ca_bundle_hash: String,
234    /// Capabilities advertised by the controller.
235    ///
236    /// The service computes the agreed set as the intersection of this set
237    /// with its own capabilities, considering only typed (known) variants.
238    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
239    pub capabilities: BTreeSet<Capability>,
240    /// Per-page item-count limits for paginated service-to-controller reports.
241    ///
242    /// Services must honor these limits when splitting large `report_hosts`,
243    /// `discovery_results`, `version_check_results`, and
244    /// `batch_update_result` payloads across pages.
245    #[serde(default, skip_serializing_if = "ReportPageLimits::is_default")]
246    pub report_page_limits: ReportPageLimits,
247    /// Maximum time to wait for in-flight operations during shutdown.
248    /// Present for agents, absent for MQTT services.
249    ///
250    /// Wire field name: `shutdown_timeout_seconds` (kept for backward compatibility).
251    #[serde(
252        default,
253        skip_serializing_if = "Option::is_none",
254        with = "option_duration_seconds",
255        rename = "shutdown_timeout_seconds"
256    )]
257    #[cfg_attr(feature = "schema", schemars(with = "Option<u32>"))]
258    pub shutdown_timeout: Option<std::time::Duration>,
259    /// How often the service should send ping messages.
260    /// Controller-managed; derived from per-service DB override or service-type default.
261    #[serde(with = "duration_seconds")]
262    #[cfg_attr(feature = "schema", schemars(with = "u32"))]
263    pub ping_interval: std::time::Duration,
264    /// Tenant UUID that this service belongs to.
265    ///
266    /// `None` for system services (MQTT, scheduler) which are not
267    /// tenant-scoped. Present for tenant-scoped agents so they can
268    /// include the tenant identity in external provisioning operations
269    /// (e.g. PVE API credential naming).
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub tenant_id: Option<Uuid>,
272    /// SPIFFE trust domain for Service identity URIs.
273    ///
274    /// Empty string when the Controller has no trust domain configured.
275    /// Agent falls back to the dialed hostname for SPIFFE SAN generation.
276    #[serde(default, skip_serializing_if = "String::is_empty")]
277    pub trust_domain: String,
278}
279
280impl ServiceSettingsPayload {
281    /// Creates a new [`ServiceSettingsPayload`] with the required fields.
282    ///
283    /// Optional fields default to: `ca_bundle_hash` = empty, `capabilities` = empty,
284    /// `report_page_limits` = default, `shutdown_timeout` = `None`,
285    /// `tenant_id` = `None`, `trust_domain` = empty.
286    pub fn new(renewal_window_hours: u16, ping_interval: std::time::Duration) -> Self {
287        Self {
288            renewal_window_hours,
289            ca_bundle_hash: String::new(),
290            capabilities: std::collections::BTreeSet::new(),
291            report_page_limits: ReportPageLimits::default(),
292            shutdown_timeout: None,
293            ping_interval,
294            tenant_id: None,
295            trust_domain: String::new(),
296        }
297    }
298
299    /// Sets the CA bundle hash.
300    #[must_use]
301    pub fn with_ca_bundle_hash(mut self, ca_bundle_hash: String) -> Self {
302        self.ca_bundle_hash = ca_bundle_hash;
303        self
304    }
305
306    /// Sets the controller capability set.
307    #[must_use]
308    pub fn with_capabilities(mut self, capabilities: impl IntoIterator<Item = Capability>) -> Self {
309        self.capabilities = capabilities.into_iter().collect();
310        self
311    }
312
313    /// Sets the report page limits.
314    #[must_use]
315    pub fn with_report_page_limits(mut self, report_page_limits: ReportPageLimits) -> Self {
316        self.report_page_limits = report_page_limits;
317        self
318    }
319
320    /// Sets the graceful-shutdown timeout for agent services.
321    #[must_use]
322    pub fn with_shutdown_timeout(mut self, shutdown_timeout: std::time::Duration) -> Self {
323        self.shutdown_timeout = Some(shutdown_timeout);
324        self
325    }
326
327    /// Sets the tenant UUID for tenant-scoped services.
328    #[must_use]
329    pub fn with_tenant_id(mut self, tenant_id: Uuid) -> Self {
330        self.tenant_id = Some(tenant_id);
331        self
332    }
333
334    /// Sets the SPIFFE trust domain advertised to connecting services.
335    #[must_use]
336    pub fn with_trust_domain(mut self, trust_domain: String) -> Self {
337        self.trust_domain = trust_domain;
338        self
339    }
340}
341
342/// Per-page item-count limits for paginated report payloads.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
345pub struct ReportPageLimits {
346    /// Maximum `hosts` items per `report_hosts` page.
347    pub report_hosts: u32,
348    /// Maximum `results` items per `version_check_results` page.
349    pub version_check_results: u32,
350    /// Maximum `results` items per `discovery_results` page.
351    pub discovery_results: u32,
352    /// Maximum `results` items per `batch_update_result` page.
353    pub batch_update_results: u32,
354}
355
356impl ReportPageLimits {
357    /// Returns `true` when all fields match the default wire limits.
358    pub fn is_default(&self) -> bool {
359        self == &Self::default()
360    }
361}
362
363impl Default for ReportPageLimits {
364    fn default() -> Self {
365        Self {
366            report_hosts: crate::limits::MAX_REPORT_HOSTS as u32,
367            version_check_results: crate::limits::MAX_VERSION_CHECK_RESULTS as u32,
368            discovery_results: crate::limits::MAX_DISCOVERY_PLUGIN_RESULTS as u32,
369            batch_update_results: crate::limits::MAX_BATCH_UPDATE_RESULTS as u32,
370        }
371    }
372}
373
374/// Payload for CA bundle update notification.
375///
376/// Pushed by the controller when the CA certificate bundle changes (e.g. after CA
377/// rotation). The service should update its trust store with the new bundle PEM
378/// and prepare for certificate renewal (see [`RequestCertRenewalPayload`]).
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
381pub struct CaBundleUpdatedPayload {
382    pub ca_bundle_pem: String,
383}
384
385/// Payload for requesting immediate certificate renewal from services.
386///
387/// Sent by the controller after CA rotation or backend URL change to prompt
388/// all connected services to renew their certificates with the new CA.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
391pub struct RequestCertRenewalPayload {
392    /// Human-readable reason for the renewal request.
393    pub reason: String,
394}
395
396/// Payload for server restarting notification.
397///
398/// Sent by the controller during graceful shutdown to notify connected services
399/// that the server is restarting. Services should expect the connection to close
400/// and reconnect automatically.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
403pub struct ServerRestartingPayload {
404    /// Human-readable reason for the restart.
405    pub reason: String,
406}
407
408/// Payload for requesting version checks from the agent.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
411pub struct CheckVersionsPayload {
412    /// The machine_id of the host to check versions on.
413    ///
414    /// For the regular agent (one service = one host), the agent validates that
415    /// this matches its own machine_id as a defensive sanity check.
416    /// For the SSH agent (one service = N remote hosts), the agent uses this
417    /// field to look up the correct SSH credentials and route the operation to
418    /// the right remote host.
419    pub host_machine_id: String,
420    /// List of software items to check.
421    pub assignments: Vec<VersionCheckAssignment>,
422}
423
424/// A plugin assignment for a specific role in a version check or update.
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
427pub struct PluginAssignment {
428    /// The plugin type (e.g. github_releases, apt, homebrew).
429    pub plugin_type: PluginTypeId,
430    /// Package identifier for this role's plugin.
431    pub package_identifier: String,
432    /// Merged plugin config (base + override).
433    pub config: serde_json::Value,
434}
435
436/// A single software item to check for installed version and/or latest version.
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
439pub struct VersionCheckAssignment {
440    /// Software item ID.
441    pub software_item_id: Uuid,
442    /// Human-readable name for logging.
443    pub name: String,
444    /// Plugin for the detect_version role.
445    /// None if no detect_version plugin is configured for this host-software pair.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub detect_version: Option<PluginAssignment>,
448    /// Plugin for the fetch_releases role — only included for agent-side plugins
449    /// (i.e., plugins without ControllerSideFetchReleases or with execution_site = agent).
450    /// Controller-side fetch_releases is handled by the scheduler, not sent to the agent.
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub fetch_releases: Option<PluginAssignment>,
453    /// Host software item ID for routing results to the host_software_items table.
454    /// When set, this assignment is for a host-managed software item rather than
455    /// a targeted software item.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub host_software_item_id: Option<Uuid>,
458}
459
460/// Payload for version check results from the agent.
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
463pub struct VersionCheckResultsPayload {
464    /// Results for each checked software item.
465    pub results: Vec<VersionCheckResult>,
466}
467
468/// Result of a single version check.
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
471pub struct VersionCheckResult {
472    /// Software item ID.
473    pub software_item_id: Uuid,
474    /// Detected installed version, if any.
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub installed_version: Option<String>,
477    /// Latest available version from the package index, if resolved locally
478    /// by the agent (e.g., Homebrew). Absent for plugins whose latest
479    /// version is resolved on the controller side.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub latest_version: Option<String>,
482    /// Error message if detection failed.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub error: Option<String>,
485    /// Classification of the available update (e.g. security, bugfix).
486    /// Defaults to `Unknown` when the plugin cannot classify the update.
487    #[serde(default)]
488    pub update_category: UpdateCategory,
489    /// Host software item ID for routing results to the host_software_items table.
490    /// Mirrors the value from the corresponding [`VersionCheckAssignment`].
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub host_software_item_id: Option<Uuid>,
493    /// Human-readable installed version for display when `installed_version`
494    /// is opaque (e.g. a Docker SHA256 digest → the image publish date).
495    /// Set by the agent from `BatchDetectResult.display_version`.
496    /// `None` when the plugin does not provide a display version.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub installed_display_version: Option<String>,
499    /// When `true`, the agent is not yet ready to report a meaningful version
500    /// for this item (e.g. a self-update is in progress and the binary has not
501    /// restarted yet). The controller should treat this as "check again later"
502    /// rather than clearing the installed version.
503    ///
504    /// `None` / absent means "ready" for wire backward compatibility.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub not_ready: Option<bool>,
507}
508
509// --- Update execution messages ---
510
511/// Controller -> Agent: Trigger an update.
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
514pub struct ExecuteUpdatePayload {
515    /// The machine_id of the host to run the update on.
516    ///
517    /// For the regular agent (one service = one host), the agent validates that
518    /// this matches its own machine_id as a defensive sanity check.
519    /// For the SSH agent (one service = N remote hosts), the agent uses this
520    /// field to look up the correct SSH credentials and route the operation to
521    /// the right remote host.
522    pub host_machine_id: String,
523    pub update_history_id: Uuid,
524    pub software_item_id: Uuid,
525    pub software_item_name: String,
526    pub to_version: String,
527    /// Plugin for the detect_version role (for before/after installed-version detection).
528    /// Absent when no detect_version plugin is configured for this assignment.
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub detect_version_plugin: Option<PluginAssignment>,
531    /// Plugin for the execute_update role.
532    pub execute_update_plugin: PluginAssignment,
533    /// Pre-update hook plugins to execute before the update, ordered by priority.
534    #[serde(default, skip_serializing_if = "Vec::is_empty")]
535    pub pre_update_hook_plugins: Vec<PluginAssignment>,
536    /// Post-update hook plugins to execute after the update, ordered by priority.
537    #[serde(default, skip_serializing_if = "Vec::is_empty")]
538    pub post_update_hook_plugins: Vec<PluginAssignment>,
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub release_info: Option<ReleaseInfo>,
541    /// Timeout for the update execution.
542    ///
543    /// Wire field name: `timeout_seconds` (kept for backward compatibility).
544    #[serde(
545        with = "duration_seconds",
546        rename = "timeout_seconds",
547        default = "super::shared_types::default_update_timeout"
548    )]
549    #[cfg_attr(feature = "schema", schemars(with = "u32"))]
550    pub timeout: std::time::Duration,
551    /// When `true`, the agent allocates a PTY and keeps stdin open for forwarding.
552    ///
553    /// Requires the agent to advertise the `InteractiveUpdates` capability.
554    /// Defaults to `false` for backward compatibility with older agents.
555    #[serde(default)]
556    pub interactive: bool,
557}
558
559/// Agent -> Controller: Update is starting.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
562pub struct UpdateStartedPayload {
563    pub update_history_id: Uuid,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub from_version: Option<String>,
566    /// Whether this update was dispatched in interactive mode and the agent's
567    /// executor supports PTY allocation (dispatch intent). The PTY itself is
568    /// allocated when the update command starts; on reconnect replay the agent
569    /// reports live reality instead (channels resolved). Old agents that do
570    /// not send this field will deserialize as `false`.
571    #[serde(default)]
572    pub interactive: bool,
573}
574
575/// Agent -> Controller: Streaming output line.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
578pub struct UpdateOutputPayload {
579    pub update_history_id: Uuid,
580    pub output: String,
581    #[serde(default)]
582    pub stream: OutputStreamType,
583}
584
585/// Agent -> Controller: Final result of update execution.
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
588pub struct UpdateResultPayload {
589    pub update_history_id: Uuid,
590    pub status: UpdateFinalStatus,
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub from_version: Option<String>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub to_version: Option<String>,
595    pub output: String,
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub error: Option<String>,
598    /// When `true`, the agent signals that this update can be resumed after
599    /// a restart (e.g. the update script supports idempotent re-entry or the
600    /// agent is mid-self-update and will re-attach on reconnect).
601    ///
602    /// `None` / absent means "not resumable" for wire backward compatibility.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub resumable: Option<bool>,
605}
606
607// --- Batch update messages ---
608
609/// Controller → Agent: execute a batch update of software items.
610///
611/// Groups multiple items under a single plugin type so the agent can
612/// run a single bulk command (e.g., `apt-get upgrade`, `brew upgrade`).
613#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
615pub struct ExecuteBatchUpdatePayload {
616    /// The machine_id of the host to run the batch update on.
617    ///
618    /// For the regular agent (one service = one host) this is validated against
619    /// its own machine_id. For the SSH agent this routes to the correct remote host.
620    pub host_machine_id: String,
621    /// Unique identifier for this batch operation.
622    pub batch_id: Uuid,
623    /// Plugin type for all items in this batch.
624    pub plugin_type: PluginTypeId,
625    /// Merged plugin configuration.
626    pub plugin_config: serde_json::Value,
627    /// Individual items to update.
628    pub updates: Vec<BatchUpdateItem>,
629    /// Pre-update hook plugins to execute before the batch, ordered by priority.
630    #[serde(default, skip_serializing_if = "Vec::is_empty")]
631    pub pre_update_hook_plugins: Vec<PluginAssignment>,
632    /// Post-update hook plugins to execute after the batch, ordered by priority.
633    #[serde(default, skip_serializing_if = "Vec::is_empty")]
634    pub post_update_hook_plugins: Vec<PluginAssignment>,
635    /// Timeout for the entire batch operation.
636    ///
637    /// Wire field name: `timeout_seconds` (kept for backward compatibility).
638    #[serde(
639        with = "duration_seconds",
640        rename = "timeout_seconds",
641        default = "super::shared_types::default_update_timeout"
642    )]
643    #[cfg_attr(feature = "schema", schemars(with = "u32"))]
644    pub timeout: std::time::Duration,
645    /// When `true`, the agent allocates a PTY and keeps stdin open for forwarding.
646    ///
647    /// Requires the agent to advertise the `InteractiveUpdates` capability.
648    /// Defaults to `false` for backward compatibility with older agents.
649    #[serde(default)]
650    pub interactive: bool,
651}
652
653/// A single software item within a batch update request.
654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
655#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
656pub struct BatchUpdateItem {
657    /// Host software item entity ID.
658    pub host_software_item_id: Uuid,
659    /// Update history record ID (pre-created by the controller).
660    pub update_history_id: Uuid,
661    /// Plugin-specific package identifier (e.g., APT package name).
662    pub package_identifier: String,
663    /// Target version to install.
664    pub to_version: String,
665    /// Optional release metadata from the upstream source.
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub release_info: Option<ReleaseInfo>,
668}
669
670/// Agent → Controller: result of a batch update.
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
672#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
673pub struct BatchUpdateResultPayload {
674    /// Batch ID matching the request.
675    pub batch_id: Uuid,
676    /// Per-item results.
677    pub results: Vec<BatchUpdateItemResult>,
678}
679
680/// Result of updating a single item within a batch operation.
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
683pub struct BatchUpdateItemResult {
684    /// Host software item entity ID.
685    pub host_software_item_id: Uuid,
686    /// Update history record ID.
687    pub update_history_id: Uuid,
688    /// Final status of this item's update.
689    pub status: UpdateFinalStatus,
690    /// Accumulated output from the update.
691    pub output: String,
692    /// Detected installed version after the update (if detection succeeded).
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub installed_version: Option<String>,
695    /// Error message if the update failed.
696    #[serde(default, skip_serializing_if = "Option::is_none")]
697    pub error: Option<String>,
698}
699
700// --- Remote update freeze ---
701
702/// Controller → Agent: enable or disable the update freeze.
703///
704/// When `enabled` is `true`, the agent creates its freeze file, which blocks
705/// `ExecuteUpdate` and `ExecuteBatchUpdate` messages until the file
706/// is removed (either via a subsequent `SetUpdateFreeze { enabled: false }`
707/// message, or manually on the host via `rm <freeze-file>`).
708///
709/// This message is safe for NATS publication — it contains no credentials.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
712pub struct SetUpdateFreezePayload {
713    /// Whether to enable (`true`) or disable (`false`) the freeze.
714    pub enabled: bool,
715    /// Optional human-readable reason for the freeze (audit trail).
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub reason: Option<String>,
718}
719
720// --- Graceful shutdown messages ---
721
722/// Service -> Controller: Notification before disconnecting.
723#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
725pub struct DisconnectingPayload {
726    pub reason: DisconnectReason,
727}
728
729impl DisconnectingPayload {
730    /// Create a `DisconnectingPayload` with the given reason.
731    pub fn new(reason: DisconnectReason) -> Self {
732        Self { reason }
733    }
734}
735
736// =============================================================================
737// Capability Management Payloads
738// =============================================================================
739
740/// Payload sent by every service on connect to declare its capabilities.
741///
742/// Sent from `on_connected` before any other messages. The controller uses
743/// this as the authoritative source for capability detection on the current
744/// session and persists the capability set to the DB.
745#[non_exhaustive]
746#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
747#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
748pub struct RegisterPayload {
749    /// Capabilities declared by this service instance.
750    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
751    pub capabilities: BTreeSet<Capability>,
752    /// Runtime instance identity for restart-vs-reconnect detection.
753    ///
754    /// Optional for mixed-version compatibility: legacy services omit this
755    /// field and are treated as service-scoped (not instance-scoped).
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub runtime_instance_id: Option<Uuid>,
758}
759
760impl RegisterPayload {
761    /// Create a new [`RegisterPayload`] with the given capability set.
762    ///
763    /// # Example
764    ///
765    /// ```
766    /// use std::collections::BTreeSet;
767    /// use uptrakit_wire::{Capability, RegisterPayload};
768    ///
769    /// let payload = RegisterPayload::new([Capability::SoftwareDiscovery, Capability::UpdateHooks]);
770    /// assert!(payload.capabilities.contains(&Capability::SoftwareDiscovery));
771    /// ```
772    pub fn new(capabilities: impl IntoIterator<Item = Capability>) -> Self {
773        Self {
774            capabilities: capabilities.into_iter().collect(),
775            runtime_instance_id: None,
776        }
777    }
778
779    /// Set a runtime instance id on this register payload.
780    #[must_use]
781    pub fn with_runtime_instance_id(mut self, runtime_instance_id: Uuid) -> Self {
782        self.runtime_instance_id = Some(runtime_instance_id);
783        self
784    }
785}
786
787// =============================================================================
788// Service Config Store Payloads
789// =============================================================================
790
791/// A single stored service config entry, delivered to the service.
792///
793/// Sensitive values are already decrypted by the controller before delivery.
794#[non_exhaustive]
795#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
796#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
797pub struct ServiceConfigEntry {
798    /// Tenant this entry belongs to, or `None` for global entries.
799    #[serde(default, skip_serializing_if = "Option::is_none")]
800    pub tenant_id: Option<Uuid>,
801    /// Entry key (e.g. `"clients.{uuid}"`).
802    pub key: String,
803    /// Entry value (plaintext JSON; controller decrypts before delivery).
804    pub value: serde_json::Value,
805}
806
807impl ServiceConfigEntry {
808    /// Create a new `ServiceConfigEntry`.
809    pub fn new(tenant_id: Option<Uuid>, key: String, value: serde_json::Value) -> Self {
810        Self {
811            tenant_id,
812            key,
813            value,
814        }
815    }
816}
817
818/// Identifies a service config entry by scope and key (used in delete notifications).
819#[non_exhaustive]
820#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
821#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
822pub struct ServiceConfigKey {
823    /// Tenant this entry belongs to, or `None` for global entries.
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub tenant_id: Option<Uuid>,
826    /// Entry key.
827    pub key: String,
828}
829
830impl ServiceConfigKey {
831    /// Create a new `ServiceConfigKey`.
832    pub fn new(tenant_id: Option<Uuid>, key: String) -> Self {
833        Self { tenant_id, key }
834    }
835}
836
837/// Service → Controller: write or update a config entry.
838///
839/// The controller upserts the entry in `tenant_service_config` (when
840/// `tenant_id` is set) or `global_service_config` (when `None`), encrypts
841/// the value if `sensitive` is `true`, ACKs the operation, and broadcasts
842/// `ServiceConfigUpdated` to all other connected instances of the same
843/// `service_app_name`.
844#[non_exhaustive]
845#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
846#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
847pub struct StoreServiceConfigPayload {
848    /// Correlation ID for the `ServiceConfigAck` response.
849    pub request_id: String,
850    /// Tenant scope. `None` = global scope.
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub tenant_id: Option<Uuid>,
853    /// Config key (e.g. `"clients.{uuid}"`).
854    pub key: String,
855    /// Config value (plaintext JSON; controller encrypts at rest if `sensitive`).
856    pub value: serde_json::Value,
857    /// When `true`, the controller stores the value using `EncryptedString`.
858    #[serde(default)]
859    pub sensitive: bool,
860}
861
862impl StoreServiceConfigPayload {
863    /// Create a new `StoreServiceConfigPayload`.
864    pub fn new(
865        request_id: String,
866        tenant_id: Option<Uuid>,
867        key: String,
868        value: serde_json::Value,
869        sensitive: bool,
870    ) -> Self {
871        Self {
872            request_id,
873            tenant_id,
874            key,
875            value,
876            sensitive,
877        }
878    }
879}
880
881/// Service → Controller: delete a config entry.
882///
883/// The controller deletes the entry, ACKs, and broadcasts `ServiceConfigUpdated`
884/// to all other connected instances of the same `service_app_name`.
885#[non_exhaustive]
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
888pub struct DeleteServiceConfigPayload {
889    /// Correlation ID for the `ServiceConfigAck` response.
890    pub request_id: String,
891    /// Tenant scope. `None` = global scope.
892    #[serde(default, skip_serializing_if = "Option::is_none")]
893    pub tenant_id: Option<Uuid>,
894    /// Config key to delete.
895    pub key: String,
896}
897
898impl DeleteServiceConfigPayload {
899    /// Create a new `DeleteServiceConfigPayload`.
900    pub fn new(request_id: String, tenant_id: Option<Uuid>, key: String) -> Self {
901        Self {
902            request_id,
903            tenant_id,
904            key,
905        }
906    }
907}
908
909/// Controller → Service: acknowledgment of a `StoreServiceConfig` or
910/// `DeleteServiceConfig` operation.
911#[non_exhaustive]
912#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
913#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
914pub struct ServiceConfigAckPayload {
915    /// Correlation ID matching the request.
916    pub request_id: String,
917    /// `true` if the operation succeeded.
918    pub success: bool,
919    /// Error message when `success` is `false`.
920    #[serde(default, skip_serializing_if = "Option::is_none")]
921    pub error: Option<String>,
922}
923
924impl ServiceConfigAckPayload {
925    /// Create a new success ACK.
926    pub fn success(request_id: String) -> Self {
927        Self {
928            request_id,
929            success: true,
930            error: None,
931        }
932    }
933
934    /// Create a new error ACK.
935    pub fn error(request_id: String, error: String) -> Self {
936        Self {
937            request_id,
938            success: false,
939            error: Some(error),
940        }
941    }
942}
943
944/// Controller → Service: initial delivery of all stored config entries.
945///
946/// Sent once after the service authenticates (after credential delivery).
947/// The service should use this as its authoritative in-memory state.
948#[non_exhaustive]
949#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
951pub struct ServiceConfigDeliveryPayload {
952    /// All config entries stored for this `service_app_name`.
953    pub entries: Vec<ServiceConfigEntry>,
954}
955
956impl ServiceConfigDeliveryPayload {
957    /// Create a new `ServiceConfigDeliveryPayload`.
958    pub fn new(entries: Vec<ServiceConfigEntry>) -> Self {
959        Self { entries }
960    }
961}
962
963/// Controller → Service: incremental config update notification.
964///
965/// Pushed to all connected instances of the same `service_app_name` when
966/// any instance stores or deletes a config entry. Services should apply
967/// these changes to their in-memory state atomically.
968#[non_exhaustive]
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
971pub struct ServiceConfigUpdatedPayload {
972    /// Entries that were inserted or updated (with decrypted values).
973    #[serde(default, skip_serializing_if = "Vec::is_empty")]
974    pub changed: Vec<ServiceConfigEntry>,
975    /// Keys that were deleted.
976    #[serde(default, skip_serializing_if = "Vec::is_empty")]
977    pub deleted: Vec<ServiceConfigKey>,
978}
979
980impl ServiceConfigUpdatedPayload {
981    /// Create a new `ServiceConfigUpdatedPayload`.
982    pub fn new(changed: Vec<ServiceConfigEntry>, deleted: Vec<ServiceConfigKey>) -> Self {
983        Self { changed, deleted }
984    }
985}
986
987// =============================================================================
988// Infrastructure Credential Payloads
989// =============================================================================
990
991/// Infrastructure credentials for services that advertise credential capabilities.
992///
993/// Fields are populated based on the service's capability set:
994///   - `database_access` → `db_url` is set
995///   - `nats_access` → `nats_url` is set (if controller has NATS)
996///   - `master_key_access` → `master_key_hex` is set (if encryption enabled)
997///
998/// **Security**: This payload contains highly sensitive credentials. It must
999/// NEVER be published to NATS or any external transport. It is delivered
1000/// exclusively over the authenticated WebSocket connection.
1001#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1002#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1003pub struct ServiceCredentialsPayload {
1004    /// Database connection URL. Present when the service has `database_access`.
1005    #[serde(skip_serializing_if = "Option::is_none")]
1006    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
1007    pub db_url: Option<SecretString>,
1008    /// Master encryption key as 64-char hex. Present when the service has
1009    /// `master_key_access` and encryption is enabled on the controller.
1010    #[serde(skip_serializing_if = "Option::is_none")]
1011    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
1012    pub master_key_hex: Option<SecretString>,
1013    /// NATS server URL. Present when the service has `nats_access` and
1014    /// NATS is configured on the controller.
1015    #[serde(skip_serializing_if = "Option::is_none")]
1016    pub nats_url: Option<String>,
1017}
1018
1019/// Request from an external component (e.g. scheduler) for the controller to
1020/// perform CA certificate rotation.
1021///
1022/// Published via NATS to `uptrakit.events.controller` subject. Handled by
1023/// triggering `ca_rotation_trigger.notify_one()` on the receiving controller.
1024#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1025#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1026pub struct RequestCaRotationPayload {
1027    /// Human-readable reason for the rotation request.
1028    pub reason: String,
1029}
1030
1031/// Request all controller instances to rebuild the CRL immediately.
1032///
1033/// Published via NATS to the `uptrakit.events.controller` subject by any
1034/// controller that revokes a certificate or by the `CrlRenewal` scheduled
1035/// task.  Receiving controllers fire `revocation_notify.notify_one()`.
1036#[non_exhaustive]
1037#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1038#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1039pub struct RequestCrlRenewalPayload {}
1040
1041/// Signal that software states have changed for a tenant and need to be
1042/// re-loaded and pushed to update-tracking services.
1043///
1044/// Published to the `controller` NATS subject by the external scheduler after
1045/// a version-check run completes. The receiving controller loads the states
1046/// from the database and pushes them to all connected update-tracking services.
1047///
1048/// This is a lightweight signal — it carries only the tenant ID, not the state
1049/// data itself. This decouples the scheduler from the state-loading logic.
1050#[non_exhaustive]
1051#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1052#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1053pub struct SoftwareStatesChangedPayload {
1054    pub tenant_id: uuid::Uuid,
1055}
1056
1057impl SoftwareStatesChangedPayload {
1058    pub fn new(tenant_id: uuid::Uuid) -> Self {
1059        Self { tenant_id }
1060    }
1061}
1062
1063/// Cross-controller token revocation event.
1064///
1065/// Published to the `controller` NATS subject by the controller that wrote
1066/// the revocation to the DB. Receiving controllers apply the revocation to
1067/// their in-memory denylist only — they do **not** write to DB (the
1068/// originating controller already did that).
1069///
1070/// A message may carry a JTI-level revocation, a user-level revocation, or
1071/// both. Fields not relevant to the revocation type are `None`.
1072#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1073#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1074pub struct TokenRevokedPayload {
1075    /// JWT ID to deny (`exp` must also be set for JTI-level revocations).
1076    #[serde(skip_serializing_if = "Option::is_none")]
1077    pub jti: Option<String>,
1078    /// Token expiry unix timestamp (seconds). Required when `jti` is set.
1079    #[serde(skip_serializing_if = "Option::is_none")]
1080    pub exp: Option<i64>,
1081    /// User UUID for user-level revocations.
1082    #[serde(skip_serializing_if = "Option::is_none")]
1083    pub user_id: Option<Uuid>,
1084    /// Deny tokens with `iat < iat_cutoff`. Required when `user_id` is set.
1085    #[serde(skip_serializing_if = "Option::is_none")]
1086    pub iat_cutoff: Option<i64>,
1087    /// Remove the user entry after this unix timestamp.
1088    #[serde(skip_serializing_if = "Option::is_none")]
1089    pub purge_after: Option<i64>,
1090}
1091
1092/// Cross-controller access-cache invalidation event.
1093///
1094/// Published to the `controller` NATS subject by the controller that mutated
1095/// access grants or role assignments (mutation sites land in M1.6a).
1096/// Receivers flush their **entire** access-authority cache — the subject
1097/// lists are observability and forward-compat detail, not a promise of
1098/// granular invalidation. There is deliberately no `tenant_id` field: a
1099/// global (NULL-tenant) user grant surfaces in every tenant's cache entry
1100/// for that user, so receivers must always flush across tenants. Both lists
1101/// may be empty.
1102#[non_exhaustive]
1103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1105pub struct AccessInvalidatedPayload {
1106    /// Users whose grant rows or role assignments changed.
1107    pub user_ids: Vec<Uuid>,
1108    /// Roles whose grant rows changed.
1109    pub role_ids: Vec<Uuid>,
1110}
1111
1112impl AccessInvalidatedPayload {
1113    pub fn new(user_ids: Vec<Uuid>, role_ids: Vec<Uuid>) -> Self {
1114        Self { user_ids, role_ids }
1115    }
1116}
1117
1118/// Per-host metadata published to MQTT for MQTT-browser visibility and Home Assistant.
1119///
1120/// Included in [`SoftwareStatesPayload`]. All fields are sourced exclusively
1121/// from the shared DB — safe for multi-controller deployments.
1122///
1123/// Intentionally excludes `ip_address` (network topology risk) and `agent_online`
1124/// (must come from the event-driven [`HostConnectivityUpdatedPayload`]).
1125#[non_exhaustive]
1126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1128pub struct HostStateMetadata {
1129    /// Host UUID.
1130    pub host_id: Uuid,
1131    /// Hostname as reported by the agent.
1132    pub hostname: String,
1133    /// User-defined display name.
1134    pub friendly_name: String,
1135    /// Operating system type (e.g. `"linux"`, `"macos"`). `null` when unknown.
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub os_type: Option<String>,
1138    /// Operating system version (e.g. `"Ubuntu 24.04 LTS"`). `null` when unknown.
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub os_version: Option<String>,
1141    /// CPU architecture (e.g. `"x86_64"`, `"aarch64"`). `null` when unknown.
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub architecture: Option<String>,
1144    /// Organisational tag names assigned to this host (e.g. `["production", "web-server"]`).
1145    #[serde(default)]
1146    pub tags: Vec<String>,
1147    /// Agent binary version string (e.g. `"0.2.1"`). `null` when never connected.
1148    ///
1149    /// Sourced from `services.client_version` for the newest approved, non-deactivated
1150    /// agent linked to this host.
1151    #[serde(default, skip_serializing_if = "Option::is_none")]
1152    pub agent_version: Option<String>,
1153    /// ISO 8601 timestamp of when the agent last sent a message.
1154    ///
1155    /// Sourced from `services.last_seen_at`. `null` when never seen.
1156    #[serde(default, skip_serializing_if = "Option::is_none")]
1157    pub agent_last_seen_at: Option<String>,
1158}
1159
1160impl HostStateMetadata {
1161    /// Creates a new `HostStateMetadata` with required fields.
1162    pub fn new(host_id: Uuid, hostname: String, friendly_name: String) -> Self {
1163        Self {
1164            host_id,
1165            hostname,
1166            friendly_name,
1167            os_type: None,
1168            os_version: None,
1169            architecture: None,
1170            tags: Vec::new(),
1171            agent_version: None,
1172            agent_last_seen_at: None,
1173        }
1174    }
1175}
1176
1177/// Connectivity status for a single host, used in [`HostConnectivityUpdatedPayload`].
1178#[non_exhaustive]
1179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1181pub struct HostConnectivityUpdate {
1182    /// Host UUID.
1183    pub host_id: Uuid,
1184    /// Whether the agent is currently connected (`true` = online, `false` = offline).
1185    pub online: bool,
1186    /// Timestamp of last agent activity (ISO 8601). `null` when unavailable.
1187    #[serde(default, skip_serializing_if = "Option::is_none")]
1188    pub last_seen_at: Option<String>,
1189    /// Agent binary version. Present on connect; `null` on disconnect.
1190    #[serde(default, skip_serializing_if = "Option::is_none")]
1191    pub agent_version: Option<String>,
1192}
1193
1194impl HostConnectivityUpdate {
1195    /// Creates an online update.
1196    pub fn online(
1197        host_id: Uuid,
1198        last_seen_at: Option<String>,
1199        agent_version: Option<String>,
1200    ) -> Self {
1201        Self {
1202            host_id,
1203            online: true,
1204            last_seen_at,
1205            agent_version,
1206        }
1207    }
1208
1209    /// Creates an offline update.
1210    pub fn offline(host_id: Uuid, last_seen_at: Option<String>) -> Self {
1211        Self {
1212            host_id,
1213            online: false,
1214            last_seen_at,
1215            agent_version: None,
1216        }
1217    }
1218}
1219
1220/// Controller → MQTT service: agent connectivity changed for one or more hosts.
1221///
1222/// Published to NATS with `target_capability = "update_tracking"` so that the MQTT
1223/// service on whichever controller the agent is connected to broadcasts the
1224/// connectivity state to **all** MQTT services across the cluster. This is the
1225/// canonical source of truth for `{prefix}/hosts/{h}/connectivity/state`.
1226///
1227/// Multi-controller safety: published by the controller that owns the agent
1228/// WebSocket connection (the only one with authoritative live state). All other
1229/// controllers receive this via NATS and update their caches.
1230#[non_exhaustive]
1231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1233pub struct HostConnectivityUpdatedPayload {
1234    /// Tenant this update belongs to.
1235    pub tenant_id: Uuid,
1236    /// One entry per host whose connectivity changed.
1237    pub updates: Vec<HostConnectivityUpdate>,
1238}
1239
1240impl HostConnectivityUpdatedPayload {
1241    /// Creates a new payload.
1242    pub fn new(tenant_id: Uuid, updates: Vec<HostConnectivityUpdate>) -> Self {
1243        Self { tenant_id, updates }
1244    }
1245}
1246
1247/// Pagination metadata for a [`SoftwareStatesPayload`] message.
1248///
1249/// All payloads carry a `page` field. For single-page delivery use
1250/// `{ page_index: 0, total_pages: 1 }`. Multi-page delivery uses
1251/// `page_index` 0…N-1; the last page satisfies `page_index + 1 == total_pages`.
1252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1253#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1254pub struct SoftwareStatesPage {
1255    /// Zero-based index of this page.
1256    pub page_index: u32,
1257    /// Total number of pages in this delivery batch.
1258    pub total_pages: u32,
1259}
1260
1261impl SoftwareStatesPage {
1262    /// Creates a single-page marker (the only page in a single-page delivery).
1263    pub fn single() -> Self {
1264        Self {
1265            page_index: 0,
1266            total_pages: 1,
1267        }
1268    }
1269}
1270
1271/// Controller -> MQTT service: current software version state for a tenant.
1272///
1273/// Sent after tenant assignment and after any version check or update result.
1274/// Safe to write to the outbox (contains no credentials).
1275///
1276/// Large tenants use multi-page delivery. The `page` field indicates which
1277/// page this payload represents. Receivers must accumulate all pages before
1278/// applying the full state update (see `page_index + 1 == total_pages` for
1279/// the last-page signal).
1280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1281#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1282pub struct SoftwareStatesPayload {
1283    /// Tenant this state belongs to.
1284    pub tenant_id: Uuid,
1285    /// All active software items for the tenant with per-host version data.
1286    pub items: Vec<SoftwareStateItem>,
1287    /// Per-host aggregate summary of unpinned (unfeatured) software items.
1288    ///
1289    /// Each entry summarises all enabled, non-deactivated unfeatured items for
1290    /// one host. Only hosts with at least one such item are included.
1291    /// Defaults to an empty list on deserialization for backward compatibility
1292    /// with older MQTT services.
1293    #[serde(default)]
1294    pub host_summaries: Vec<HostPackageSummary>,
1295    /// Per-host metadata for all hosts referenced in `items` or `host_summaries`.
1296    ///
1297    /// Includes OS info, tags, and agent last-seen data. Sourced exclusively from DB.
1298    /// Defaults to an empty list for backward compatibility with older MQTT services.
1299    #[serde(default)]
1300    pub hosts: Vec<HostStateMetadata>,
1301    /// Pagination metadata indicating which page this payload represents.
1302    pub page: SoftwareStatesPage,
1303}
1304
1305/// A single software item entry in [`SoftwareStatesPayload`].
1306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1307#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1308pub struct SoftwareStateItem {
1309    /// Software item UUID.
1310    pub software_item_id: Uuid,
1311    /// Human-readable software item name.
1312    pub name: String,
1313    /// Optional HTTPS URL to an icon/logo image.
1314    ///
1315    /// When present, the MQTT service includes this as `entity_picture` in the
1316    /// Home Assistant discovery config so HA displays it as the entity thumbnail.
1317    /// Limited to [`crate::limits::MAX_ICON_URL_LEN`] characters.
1318    #[serde(default, skip_serializing_if = "Option::is_none")]
1319    pub icon_url: Option<String>,
1320    /// Per-host version data for this software item.
1321    pub hosts: Vec<SoftwareStateHostEntry>,
1322}
1323
1324/// Per-host version data for a software item.
1325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1326#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1327pub struct SoftwareStateHostEntry {
1328    /// Host UUID.
1329    pub host_id: Uuid,
1330    /// Human-readable hostname.
1331    pub hostname: String,
1332    /// User-defined display name for the host.
1333    pub friendly_name: String,
1334    /// Currently installed version, if known.
1335    #[serde(default, skip_serializing_if = "Option::is_none")]
1336    pub installed_version: Option<String>,
1337    /// Latest available version, if known.
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub latest_version: Option<String>,
1340    /// Whether an update is available (`latest_version > installed_version`).
1341    pub update_available: bool,
1342    /// Whether an update is currently pending or in progress for this host-item pair.
1343    ///
1344    /// Set to `true` when an `update_history` record exists with status
1345    /// `Pending` or `InProgress`. Cleared to `false` once the update
1346    /// completes or fails. Defaults to `false` when absent (older controller).
1347    #[serde(default)]
1348    pub update_in_progress: bool,
1349    /// URL to the upstream release page (e.g. GitHub release), if available.
1350    #[serde(default, skip_serializing_if = "Option::is_none")]
1351    pub release_url: Option<String>,
1352    /// Release notes or changelog text, if available.
1353    #[serde(default, skip_serializing_if = "Option::is_none")]
1354    pub release_notes: Option<String>,
1355    /// Classification of the update (e.g. `"security"`, `"bugfix"`, `"feature"`, `"unknown"`).
1356    ///
1357    /// Sourced from `host_software_item.update_category`. Defaults to `"unknown"` when absent.
1358    #[serde(default, skip_serializing_if = "Option::is_none")]
1359    pub update_category: Option<String>,
1360    /// Date when the latest release was published (ISO 8601 date string, e.g. `"2025-01-15"`).
1361    ///
1362    /// Extracted from `latest_release_metadata.published_at`. `null` when metadata is absent.
1363    #[serde(default, skip_serializing_if = "Option::is_none")]
1364    pub release_date: Option<String>,
1365    /// Timestamp when the installed version was last detected (ISO 8601).
1366    ///
1367    /// Sourced from `host_software_item.installed_version_detected_at`. `null` when never checked.
1368    #[serde(default, skip_serializing_if = "Option::is_none")]
1369    pub last_checked_at: Option<String>,
1370}
1371
1372/// Service -> Controller: request to trigger a software update.
1373///
1374/// Sent when a Home Assistant user presses "Install" on an update entity.
1375/// The controller validates and dispatches the update to the appropriate agent.
1376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1377#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1378pub struct ServiceUpdateTriggerPayload {
1379    /// Tenant UUID (for validation).
1380    pub tenant_id: Uuid,
1381    /// Software item to update.
1382    pub software_item_id: Uuid,
1383    /// Host to update on.
1384    pub host_id: Uuid,
1385    /// Target version to install.
1386    pub to_version: String,
1387    /// Service instance UUID that initiated the trigger (used as actor_id).
1388    pub actor_service_id: Uuid,
1389}
1390
1391/// Per-host aggregate summary of unpinned (unfeatured) software items.
1392///
1393/// Included in [`SoftwareStatesPayload`] to surface overall update
1394/// status per host to Home Assistant via a single `update` entity per host.
1395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1396#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1397pub struct HostPackageSummary {
1398    /// Host UUID.
1399    pub host_id: Uuid,
1400    /// Human-readable hostname.
1401    pub hostname: String,
1402    /// User-defined display name for the host.
1403    #[serde(default)]
1404    pub friendly_name: String,
1405    /// Count of items where `installed_version != latest_version` (both known).
1406    pub pending_count: u32,
1407    /// Count of items where `update_category = "security"` AND versions differ.
1408    ///
1409    /// Used to drive the per-host security updates entity in Home Assistant.
1410    pub security_pending_count: u32,
1411    /// Total count of enabled, non-deactivated unfeatured items for this host.
1412    pub total_count: u32,
1413    /// Whether a batch update is currently pending or in progress for this host.
1414    pub update_in_progress: bool,
1415    /// Count of pending packages where `update_category = "bugfix"`.
1416    ///
1417    /// Defaults to `0` when absent (older controller that does not compute this field).
1418    #[serde(default)]
1419    pub bugfix_count: u32,
1420    /// Count of pending packages where `update_category = "feature"`.
1421    ///
1422    /// Defaults to `0` when absent (older controller that does not compute this field).
1423    #[serde(default)]
1424    pub feature_count: u32,
1425}
1426
1427/// Service → Controller: trigger a batch update of all outdated software items on a host.
1428///
1429/// Sent when a Home Assistant user presses "Install" on a host update
1430/// entity. The controller resolves the latest versions for all outdated items
1431/// at trigger time and dispatches a `ExecuteBatchUpdate` to the agent.
1432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1433#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1434pub struct ServiceHostBatchUpdateTriggerPayload {
1435    /// Tenant UUID (for validation).
1436    pub tenant_id: Uuid,
1437    /// Host whose items should be updated.
1438    pub host_id: Uuid,
1439    /// Service instance UUID that initiated the trigger (used as actor_id).
1440    pub actor_service_id: Uuid,
1441    /// When `true`, only items with `update_category = "security"` are updated.
1442    #[serde(default)]
1443    pub security_only: bool,
1444}
1445
1446/// Service -> Controller: forwarded semantic audit event.
1447///
1448/// The wire payload intentionally keeps semantic fields as strings so the
1449/// controller can re-validate them against its canonical audit contract before
1450/// persisting or exporting the event.
1451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1452#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1453pub struct AuditEventPayload {
1454    /// Semantic action identifier, such as `service.certificate.issue`.
1455    pub action_type: String,
1456    /// Tenant UUID as a string when the event is tenant-scoped.
1457    #[serde(default, skip_serializing_if = "Option::is_none")]
1458    pub tenant_id: Option<String>,
1459    /// Optional semantic target type.
1460    #[serde(default, skip_serializing_if = "Option::is_none")]
1461    pub target_type: Option<String>,
1462    /// Optional semantic target identifier.
1463    #[serde(default, skip_serializing_if = "Option::is_none")]
1464    pub target_id: Option<String>,
1465    /// Optional human-readable target display value.
1466    #[serde(default, skip_serializing_if = "Option::is_none")]
1467    pub target_display: Option<String>,
1468    /// Semantic outcome, such as `success` or `denied`.
1469    pub outcome: String,
1470    /// Optional JSON-encoded details payload.
1471    #[serde(default, skip_serializing_if = "Option::is_none")]
1472    pub details_json: Option<String>,
1473    /// Optional correlation identifier.
1474    #[serde(default, skip_serializing_if = "Option::is_none")]
1475    pub request_id: Option<String>,
1476    /// Optional correlation identifier linking events in a workflow chain.
1477    #[serde(default, skip_serializing_if = "Option::is_none")]
1478    pub correlation_id: Option<uuid::Uuid>,
1479}
1480
1481// =============================================================================
1482// Software Autodiscovery Payloads
1483// =============================================================================
1484
1485/// Controller -> Agent: Run software discovery on the given host.
1486///
1487/// The `plugins` list contains one entry per plugin that should be used.
1488/// When `plugin_config_id` is `None`, the assignment uses a default (empty)
1489/// config — the controller will auto-create a `PluginConfig` record once
1490/// results arrive.
1491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1492#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1493pub struct DiscoverSoftwarePayload {
1494    /// Machine ID of the host to discover software on.
1495    ///
1496    /// For the regular agent this is validated to match its own machine_id.
1497    /// For the SSH agent it identifies which remote host to connect to.
1498    pub host_machine_id: String,
1499    /// Per-plugin discovery assignments.
1500    pub plugins: Vec<DiscoveryPluginAssignment>,
1501}
1502
1503/// A single plugin assignment inside a [`DiscoverSoftwarePayload`].
1504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1505#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1506pub struct DiscoveryPluginAssignment {
1507    /// Pre-existing plugin config ID, or `None` for a default/auto run.
1508    #[serde(default, skip_serializing_if = "Option::is_none")]
1509    pub plugin_config_id: Option<Uuid>,
1510    /// Plugin type to use for discovery.
1511    pub plugin_type: PluginTypeId,
1512    /// Plugin-specific configuration (`{}` for default assignments).
1513    pub config: serde_json::Value,
1514}
1515
1516/// Agent -> Controller: Results of a software discovery run.
1517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1518#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1519pub struct DiscoveryResultsPayload {
1520    /// Machine ID of the host that was scanned (echoed from the assignment).
1521    pub host_machine_id: String,
1522    /// Per-plugin results.
1523    pub results: Vec<DiscoveryPluginResult>,
1524}
1525
1526/// Result for a single plugin inside a [`DiscoveryResultsPayload`].
1527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1528#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1529pub struct DiscoveryPluginResult {
1530    /// Echoed from [`DiscoveryPluginAssignment`] so the controller can route
1531    /// results to the correct `PluginConfig` row.
1532    #[serde(default, skip_serializing_if = "Option::is_none")]
1533    pub plugin_config_id: Option<Uuid>,
1534    /// Plugin type that produced these results.
1535    pub plugin_type: PluginTypeId,
1536    /// Discovered software items (empty on error).
1537    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1538    pub discoveries: Vec<DiscoveredSoftware>,
1539    /// Plugin-level error message, if discovery failed.
1540    #[serde(default, skip_serializing_if = "Option::is_none")]
1541    pub error: Option<String>,
1542}
1543
1544// =============================================================================
1545// Plugin config reporting
1546// =============================================================================
1547
1548/// Payload for `ServiceMessage::ReportPluginConfig`.
1549///
1550/// Sent by agents that detect infrastructure (e.g. PVE nodes) during bootstrap
1551/// and want the controller to create or retrieve a plugin configuration.
1552#[non_exhaustive]
1553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1554#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1555pub struct ReportPluginConfigPayload {
1556    /// Unique request identifier for correlating the response.
1557    pub request_id: String,
1558    /// Plugin type string (e.g. `"infrastructure.proxmox"`).
1559    pub plugin_type: String,
1560    /// Human-readable name for the config (e.g. `"pve.local"`).
1561    pub name: String,
1562    /// Plugin-specific configuration JSON.
1563    pub config: serde_json::Value,
1564}
1565
1566/// Payload for `ControllerMessage::ReportPluginConfigResponse`.
1567///
1568/// Returned to a service in response to `ReportPluginConfig`. Idempotent:
1569/// if a config with the same `(tenant_id, plugin_type, name)` already exists,
1570/// the existing ID is returned without creating a duplicate.
1571#[non_exhaustive]
1572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1573#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1574pub struct ReportPluginConfigResponsePayload {
1575    /// The request ID from the original `ReportPluginConfig` message.
1576    pub request_id: String,
1577    /// Whether the operation succeeded.
1578    pub success: bool,
1579    /// The plugin config ID (set on success).
1580    #[serde(default, skip_serializing_if = "Option::is_none")]
1581    pub plugin_config_id: Option<Uuid>,
1582    /// Error message (set on failure).
1583    #[serde(default, skip_serializing_if = "Option::is_none")]
1584    pub error: Option<String>,
1585}
1586
1587// =============================================================================
1588// Interactive Update Payloads
1589// =============================================================================
1590
1591/// Controller → Agent: forward stdin data or a signal to a running interactive update.
1592///
1593/// The `data` field contains raw bytes encoded as base64 to support binary
1594/// control sequences (e.g., `\x03` for Ctrl+C). When `signal` is set, the
1595/// agent delivers the signal to the process group instead of writing stdin.
1596#[non_exhaustive]
1597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1598#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1599pub struct UpdateStdinDataPayload {
1600    /// The update history record this stdin data belongs to.
1601    pub update_history_id: Uuid,
1602    /// Raw bytes encoded as base64 (supports binary: Ctrl+C = \x03, etc.).
1603    pub data: String,
1604    /// When set, send this signal to the process group instead of writing stdin.
1605    /// Values: 2 = SIGINT, 15 = SIGTERM.
1606    #[serde(default, skip_serializing_if = "Option::is_none")]
1607    pub signal: Option<i32>,
1608}
1609
1610impl UpdateStdinDataPayload {
1611    /// Create a new stdin data payload.
1612    pub fn new(update_history_id: Uuid, data: String) -> Self {
1613        Self {
1614            update_history_id,
1615            data,
1616            signal: None,
1617        }
1618    }
1619
1620    /// Create a new signal payload.
1621    pub fn with_signal(update_history_id: Uuid, signal: i32) -> Self {
1622        Self {
1623            update_history_id,
1624            data: String::new(),
1625            signal: Some(signal),
1626        }
1627    }
1628}
1629
1630/// Agent → Controller: the update process appears to be waiting for stdin input.
1631///
1632/// Sent when the agent detects sustained silence from the process (no output for
1633/// ~10 seconds while still running). The controller broadcasts this to interactive
1634/// session subscribers and may trigger notifications.
1635#[non_exhaustive]
1636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1638pub struct StdinAttentionPayload {
1639    /// The update history record that needs attention.
1640    pub update_history_id: Uuid,
1641    /// Optional hint about what the process might be waiting for.
1642    #[serde(default, skip_serializing_if = "Option::is_none")]
1643    pub hint: Option<String>,
1644}
1645
1646impl StdinAttentionPayload {
1647    /// Create a new attention payload.
1648    pub fn new(update_history_id: Uuid) -> Self {
1649        Self {
1650            update_history_id,
1651            hint: None,
1652        }
1653    }
1654
1655    /// Create a new attention payload with a hint.
1656    pub fn with_hint(update_history_id: Uuid, hint: String) -> Self {
1657        Self {
1658            update_history_id,
1659            hint: Some(hint),
1660        }
1661    }
1662}
1663
1664// --- Cross-controller admin event broadcast ---
1665
1666/// Cross-controller admin event broadcast payload.
1667///
1668/// Published via NATS to the `controller` subject by any controller instance
1669/// when it emits an [`AdminEvent`](crate::admin_events::AdminEvent)
1670/// to local SSE subscribers. Receiving controller instances decode the payload
1671/// and re-broadcast to their own local SSE subscribers without re-publishing
1672/// to NATS (to avoid infinite loops).
1673///
1674/// `tenant_id = None` means the event targets all tenants (system-wide).
1675///
1676/// **Safe to publish via NATS** — contains no credential material.
1677#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1678#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1679pub struct BroadcastAdminEventPayload {
1680    /// Target tenant, or `None` for system-wide events.
1681    pub tenant_id: Option<Uuid>,
1682    /// JSON-serialised `AdminEvent`.
1683    pub event_json: String,
1684}
1685
1686// =============================================================================
1687// Workload Claim Protocol Payloads
1688// =============================================================================
1689
1690/// Service → Controller: request exclusive ownership of config keys.
1691///
1692/// Each key in `claims` is a config key (e.g. `"clients.{uuid}"`) and the
1693/// value is the `tenant_id` that config belongs to. The controller grants
1694/// unclaimed keys and rejects keys already claimed by another service.
1695///
1696/// Uses **full replacement semantics**: each `WorkloadClaim` sends the
1697/// complete desired config key set. The controller diffs against current
1698/// grants to determine what to claim/release.
1699#[non_exhaustive]
1700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1702pub struct WorkloadClaimPayload {
1703    /// Map of `config_key → tenant_id` representing the full desired set.
1704    pub claims: BTreeMap<String, Uuid>,
1705}
1706
1707impl WorkloadClaimPayload {
1708    /// Create a new `WorkloadClaimPayload`.
1709    pub fn new(claims: BTreeMap<String, Uuid>) -> Self {
1710        Self { claims }
1711    }
1712}
1713
1714/// Controller → Service: grant/reject response for a workload claim request.
1715///
1716/// Sent in response to `WorkloadClaim`, or unsolicited when the controller
1717/// proactively re-grants previously rejected keys that became available
1718/// (e.g. after another service disconnected), or when revoking keys due
1719/// to cross-controller conflict resolution.
1720#[non_exhaustive]
1721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1722#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1723pub struct WorkloadClaimResultPayload {
1724    /// Config keys that were granted (exclusive ownership).
1725    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1726    pub granted: BTreeSet<String>,
1727    /// Config keys that were rejected (already claimed by another service).
1728    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1729    pub rejected: BTreeSet<String>,
1730}
1731
1732impl WorkloadClaimResultPayload {
1733    /// Create a new `WorkloadClaimResultPayload`.
1734    pub fn new(granted: BTreeSet<String>, rejected: BTreeSet<String>) -> Self {
1735        Self { granted, rejected }
1736    }
1737}
1738
1739/// Service → Controller: voluntarily release config keys.
1740///
1741/// Sent when a service no longer wants to serve certain configs (e.g. after
1742/// a config deletion). The controller releases the keys and makes them
1743/// available for other services.
1744#[non_exhaustive]
1745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1746#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1747pub struct WorkloadReleasePayload {
1748    /// Config keys to release.
1749    pub keys: BTreeSet<String>,
1750}
1751
1752impl WorkloadReleasePayload {
1753    /// Create a new `WorkloadReleasePayload`.
1754    pub fn new(keys: BTreeSet<String>) -> Self {
1755        Self { keys }
1756    }
1757}
1758
1759/// Controller → NATS: announce claim state changes for cross-controller sync.
1760///
1761/// Published to the `controller` NATS subject after granting or releasing
1762/// claims. Other controllers update their global claim registry from this.
1763///
1764/// **Safe to publish via NATS** — contains no credential material.
1765#[non_exhaustive]
1766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1767#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1768pub struct WorkloadClaimAnnouncementPayload {
1769    /// The service that owns these claims.
1770    pub service_id: Uuid,
1771    /// The controller that granted these claims.
1772    pub controller_id: Uuid,
1773    /// Newly claimed keys: `config_key → tenant_id`.
1774    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1775    pub claimed: BTreeMap<String, Uuid>,
1776    /// Keys that were released.
1777    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1778    pub released: BTreeSet<String>,
1779    /// ISO 8601 timestamp when the claims were granted (for conflict resolution).
1780    pub claimed_at: String,
1781}
1782
1783impl WorkloadClaimAnnouncementPayload {
1784    /// Create a new `WorkloadClaimAnnouncementPayload`.
1785    pub fn new(
1786        service_id: Uuid,
1787        controller_id: Uuid,
1788        claimed: BTreeMap<String, Uuid>,
1789        released: BTreeSet<String>,
1790        claimed_at: String,
1791    ) -> Self {
1792        Self {
1793            service_id,
1794            controller_id,
1795            claimed,
1796            released,
1797            claimed_at,
1798        }
1799    }
1800}
1801
1802/// Controller → NATS: request full claim state from all active controllers.
1803///
1804/// Published on controller startup to the `controller` NATS subject.
1805/// Each active controller responds with `WorkloadClaimSyncResponse`.
1806///
1807/// **NATS-only** (controller-to-controller), not service-facing.
1808#[non_exhaustive]
1809#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1810#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1811pub struct WorkloadClaimSyncRequestPayload {
1812    /// The requesting controller's ID.
1813    pub controller_id: Uuid,
1814}
1815
1816impl WorkloadClaimSyncRequestPayload {
1817    /// Create a new `WorkloadClaimSyncRequestPayload`.
1818    pub fn new(controller_id: Uuid) -> Self {
1819        Self { controller_id }
1820    }
1821}
1822
1823/// Controller → NATS: respond with full local claim state.
1824///
1825/// Sent in response to `WorkloadClaimSyncRequest`. Contains the responding
1826/// controller's complete local claim map for merging into the requester's
1827/// global registry.
1828///
1829/// **NATS-only** (controller-to-controller), not service-facing.
1830#[non_exhaustive]
1831#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1832#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1833pub struct WorkloadClaimSyncResponsePayload {
1834    /// The responding controller's ID.
1835    pub controller_id: Uuid,
1836    /// Full local claim state: `config_key → (service_id, tenant_id)`.
1837    pub claims: BTreeMap<String, WorkloadClaimSyncEntry>,
1838}
1839
1840impl WorkloadClaimSyncResponsePayload {
1841    /// Create a new `WorkloadClaimSyncResponsePayload`.
1842    pub fn new(controller_id: Uuid, claims: BTreeMap<String, WorkloadClaimSyncEntry>) -> Self {
1843        Self {
1844            controller_id,
1845            claims,
1846        }
1847    }
1848}
1849
1850/// A single entry in a `WorkloadClaimSyncResponse`.
1851#[non_exhaustive]
1852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1853#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1854pub struct WorkloadClaimSyncEntry {
1855    /// The service that owns this claim.
1856    pub service_id: Uuid,
1857    /// The tenant this config key belongs to.
1858    pub tenant_id: Uuid,
1859    /// ISO 8601 timestamp when the claim was granted.
1860    pub claimed_at: String,
1861}
1862
1863impl WorkloadClaimSyncEntry {
1864    /// Create a new `WorkloadClaimSyncEntry`.
1865    pub fn new(service_id: Uuid, tenant_id: Uuid, claimed_at: String) -> Self {
1866        Self {
1867            service_id,
1868            tenant_id,
1869            claimed_at,
1870        }
1871    }
1872}
1873
1874// ── Config test payloads ─────────────────────────────────────────────────────
1875
1876// Must be `pub use`, not bare `use` — wire's lib.rs does `pub use payloads::*`,
1877// which only re-exports *pub* items. A private import here would silently drop
1878// ConfigTestKind from the wire public API and break all 21 non-plugin dependents.
1879pub use uptrakit_shared_types::ConfigTestKind;
1880
1881/// Payload for a plugin configuration test request (controller -> agent).
1882#[non_exhaustive]
1883#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1884#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1885pub struct TestPluginConfigPayload {
1886    /// Unique request ID for correlation (UUID v7).
1887    pub request_id: String,
1888    /// Target host machine ID on the agent.
1889    pub host_machine_id: String,
1890    /// What to test.
1891    pub test_kind: ConfigTestKind,
1892    /// The plugin type to test.
1893    pub plugin_type: String,
1894    /// The plugin configuration JSON to test.
1895    pub config: serde_json::Value,
1896    /// Package identifier for testing (required for version detection).
1897    #[serde(default, skip_serializing_if = "Option::is_none")]
1898    pub package_identifier: Option<String>,
1899}
1900
1901impl TestPluginConfigPayload {
1902    /// Creates a new test plugin config payload.
1903    pub fn new(
1904        request_id: String,
1905        host_machine_id: String,
1906        test_kind: ConfigTestKind,
1907        plugin_type: String,
1908        config: serde_json::Value,
1909    ) -> Self {
1910        Self {
1911            request_id,
1912            host_machine_id,
1913            test_kind,
1914            plugin_type,
1915            config,
1916            package_identifier: None,
1917        }
1918    }
1919}
1920
1921/// Payload for a plugin configuration test result (agent -> controller).
1922#[non_exhaustive]
1923#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1924#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1925pub struct TestPluginConfigResultPayload {
1926    /// Correlation ID matching the original request.
1927    pub request_id: String,
1928    /// Whether the test passed.
1929    pub success: bool,
1930    /// Command output or connectivity response.
1931    #[serde(default, skip_serializing_if = "Option::is_none")]
1932    pub output: Option<String>,
1933    /// Error message if the test failed.
1934    #[serde(default, skip_serializing_if = "Option::is_none")]
1935    pub error: Option<String>,
1936    /// Detected version (for version detection tests).
1937    #[serde(default, skip_serializing_if = "Option::is_none")]
1938    pub detected_version: Option<String>,
1939    /// Test duration in milliseconds.
1940    pub duration_ms: u64,
1941}
1942
1943impl TestPluginConfigResultPayload {
1944    /// Creates a new test result payload.
1945    pub fn new(request_id: String, success: bool, duration_ms: u64) -> Self {
1946        Self {
1947            request_id,
1948            success,
1949            output: None,
1950            error: None,
1951            detected_version: None,
1952            duration_ms,
1953        }
1954    }
1955}
1956
1957#[cfg(test)]
1958mod resumable_tests {
1959    use super::*;
1960
1961    #[test]
1962    fn test_update_result_payload_resumable_defaults_none() {
1963        let json = r#"{"update_history_id":"00000000-0000-0000-0000-000000000001","status":"completed","output":""}"#;
1964        let p: UpdateResultPayload = serde_json::from_str(json).unwrap();
1965        assert_eq!(p.resumable, None);
1966    }
1967
1968    #[test]
1969    fn test_update_result_payload_resumable_true_round_trips() {
1970        let p = UpdateResultPayload {
1971            update_history_id: uuid::Uuid::nil(),
1972            status: crate::UpdateFinalStatus::Completed,
1973            from_version: None,
1974            to_version: None,
1975            output: String::new(),
1976            error: None,
1977            resumable: Some(true),
1978        };
1979        let json = serde_json::to_string(&p).unwrap();
1980        assert!(json.contains("\"resumable\":true"));
1981        let back: UpdateResultPayload = serde_json::from_str(&json).unwrap();
1982        assert_eq!(back.resumable, Some(true));
1983    }
1984
1985    #[test]
1986    fn test_version_check_result_not_ready_defaults_none() {
1987        let json = r#"{"software_item_id":"00000000-0000-0000-0000-000000000001","update_category":"none"}"#;
1988        let r: VersionCheckResult = serde_json::from_str(json).unwrap();
1989        assert_eq!(r.not_ready, None);
1990    }
1991
1992    #[test]
1993    fn test_version_check_result_not_ready_true_round_trips() {
1994        let r = VersionCheckResult {
1995            software_item_id: uuid::Uuid::nil(),
1996            installed_version: None,
1997            latest_version: None,
1998            error: None,
1999            update_category: crate::UpdateCategory::default(),
2000            host_software_item_id: None,
2001            installed_display_version: None,
2002            not_ready: Some(true),
2003        };
2004        let json = serde_json::to_string(&r).unwrap();
2005        assert!(json.contains("\"not_ready\":true"));
2006        let back: VersionCheckResult = serde_json::from_str(&json).unwrap();
2007        assert_eq!(back.not_ready, Some(true));
2008    }
2009}