Skip to main content

mycelix_bridge_common/
lib.rs

1pub type AgentPubKey = [u8; 32];
2pub type CapSecret = Vec<u8>;
3
4// Copyright (C) 2024-2026 Tristan Stoltz / Luminous Dynamics
5// SPDX-License-Identifier: AGPL-3.0-or-later
6// Commercial licensing: see COMMERCIAL_LICENSE.md at repository root
7// Mycelix Bridge Common — Shared dispatch types and utilities
8//
9// Provides the cross-domain dispatch primitives used by both the
10// Commons and Civic cluster bridge zomes. Each cluster's bridge
11// coordinator imports these types and calls `dispatch_call_checked()`
12// with its own allowlist.
13//
14// ## Civic Thresholds (always available)
15//
16// The `consciousness_thresholds` module (legacy name) contains the canonical
17// threshold constants. The `sovereign_gate` module provides the 8D Sovereign
18// Profile gating system that replaces the old 4D consciousness gating.
19
20pub mod constitutional_envelope;
21// ── Model governance extensions (feature-gated) ──────────────────────────
22pub mod consciousness_thresholds;
23pub mod consciousness_zkp;
24pub mod membership_zkp;
25#[cfg(feature = "model-governance")]
26pub mod model_governance;
27#[cfg(feature = "model-governance")]
28pub mod scoring_model;
29#[cfg(feature = "model-governance")]
30pub mod shadow_evaluation;
31/// Backward-compatible module alias — allows `mycelix_bridge_common::phi_thresholds::*` paths.
32pub use consciousness_thresholds as phi_thresholds;
33pub use consciousness_thresholds::{ConsciousnessThresholds, PhiThresholds};
34
35pub mod consciousness_profile;
36// Pure Rust re-exports (always available)
37pub use consciousness_profile::{
38    bootstrap_credential, decay_reputation, evaluate_bootstrap_governance, evaluate_governance,
39    evaluate_governance_with_reputation, is_bootstrap_eligible, needs_refresh,
40    requirement_for_basic, requirement_for_constitutional, requirement_for_guardian,
41    requirement_for_proposal, requirement_for_voting, ConsciousnessCredential,
42    ConsciousnessProfile, ConsciousnessTier, ExtensionKey, GateAuditInput, GovernanceAuditFilter,
43    GovernanceAuditResult, GovernanceEligibility, GovernanceRequirement, ReputationState,
44    GRACE_PERIOD_US, REFRESH_WINDOW_US, REPUTATION_BLACKLIST_THRESHOLD, REPUTATION_DECAY_PER_DAY,
45    REPUTATION_MAX_SLASHES, REPUTATION_RESTORATION_INTERACTIONS, REPUTATION_SLASH_FACTOR,
46};
47// HDK-dependent re-exports
48#[cfg(feature = "hdk")]
49pub use consciousness_profile::gate_consciousness;
50
51// 8D Sovereign Profile — anti-tyranny civic identity (replacing 4D ConsciousnessProfile)
52pub mod sovereign_gate;
53#[cfg(feature = "hdk")]
54pub use sovereign_gate::gate_civic;
55pub use sovereign_profile::weights::DimensionWeights;
56pub use sovereign_profile::{
57    civic_requirement_basic, civic_requirement_constitutional, civic_requirement_guardian,
58    civic_requirement_proposal, civic_requirement_voting, CivicRequirement, CivicTier,
59    SovereignCredential, SovereignDimension, SovereignProfile,
60};
61
62pub mod offline_credential;
63pub mod sub_passport;
64
65// ── Interplanetary extensions (feature-gated) ────────────────────────────
66#[cfg(feature = "interplanetary")]
67pub mod cross_planetary_fl;
68#[cfg(feature = "interplanetary")]
69pub mod earth_colony_protocol;
70#[cfg(feature = "interplanetary")]
71pub mod interplanetary_bridge;
72#[cfg(all(feature = "interplanetary", feature = "hdk"))]
73pub mod mars_isru;
74#[cfg(feature = "interplanetary")]
75pub mod planetary_governance;
76
77// ── Federated learning extensions (feature-gated) ────────────────────────
78#[cfg(feature = "federated")]
79pub mod terrain_fl;
80// #[cfg(feature = "federated")]
81// pub mod consciousness_sync;
82// #[cfg(feature = "federated")]
83// pub mod federated_genomics;
84
85#[cfg(feature = "hdk")]
86pub mod validation;
87#[cfg(feature = "hdk")]
88pub use validation::{check_author_match, check_link_author_match};
89
90pub mod collective_phi;
91pub use collective_phi::{
92    AgentConsciousnessVector, CollectivePhiEngine, CollectivePhiResult, COLLECTIVE_PHI_MAX_SYNC,
93};
94
95pub mod routing;
96pub use routing::{
97    resolve_civic_zome, resolve_commons_zome, BridgeDomain, CivicZome, CommonsZome,
98    CrossClusterRole, CIVIC_DOMAINS, COMMONS_DOMAINS,
99};
100
101pub mod routing_registry;
102
103pub mod metrics;
104
105// ── Infrastructure extensions (feature-gated) ────────────────────────────
106#[cfg(feature = "infrastructure")]
107pub mod migration;
108pub mod notifications;
109#[cfg(feature = "infrastructure")]
110pub mod saga; // Notifications are core — used by all clusters
111
112#[cfg(feature = "infrastructure")]
113pub mod license_enforcement;
114#[cfg(feature = "infrastructure")]
115pub mod merkle_timestamp;
116#[cfg(feature = "infrastructure")]
117pub mod timestamp_anchor;
118
119#[cfg(kani)]
120mod kani_proofs;
121
122#[cfg(feature = "hdk")]
123use hdk::prelude::*;
124use serde::{Deserialize, Serialize};
125
126// ============================================================================
127// Dispatch types
128// ============================================================================
129
130/// Input for dispatching a call to any domain zome within a cluster DNA.
131#[derive(Serialize, Deserialize, Debug, Clone)]
132pub struct DispatchInput {
133    /// Target zome name (e.g., "property_registry", "justice_cases").
134    /// Must be in the cluster's allowed zomes list.
135    pub zome: String,
136    /// Target function name (e.g., "verify_ownership", "get_property").
137    pub fn_name: String,
138    /// MessagePack-serialized input payload. Use `()` serialized for no-arg functions.
139    pub payload: Vec<u8>,
140}
141
142/// Result of a dispatched cross-domain call.
143#[derive(Serialize, Deserialize, Debug, Clone)]
144pub struct DispatchResult {
145    /// Whether the call succeeded.
146    pub success: bool,
147    /// MessagePack-serialized response payload (on success).
148    pub response: Option<Vec<u8>>,
149    /// Error message (on failure).
150    pub error: Option<String>,
151    /// Structured error code (on failure). Enables programmatic error handling
152    /// without parsing error message strings. Populated by dispatch functions;
153    /// defaults to `None` for backward compatibility with existing callers.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub error_code: Option<BridgeErrorCode>,
156}
157
158impl DispatchResult {
159    /// Create a success result.
160    pub fn ok(response: Vec<u8>) -> Self {
161        Self {
162            success: true,
163            response: Some(response),
164            error: None,
165            error_code: None,
166        }
167    }
168
169    /// Create an error result with structured code.
170    pub fn err(code: BridgeErrorCode, message: String) -> Self {
171        Self {
172            success: false,
173            response: None,
174            error: Some(message),
175            error_code: Some(code),
176        }
177    }
178}
179
180/// Input for resolving a query with a result.
181#[cfg(feature = "hdk")]
182#[derive(Serialize, Deserialize, Debug, Clone)]
183pub struct ResolveQueryInput {
184    pub query_hash: ActionHash,
185    pub result: String,
186    pub success: bool,
187}
188
189/// Query for events by type within a domain.
190#[derive(Serialize, Deserialize, Debug, Clone)]
191pub struct EventTypeQuery {
192    pub domain: String,
193    pub event_type: String,
194}
195
196/// Health status for a cluster bridge.
197#[derive(Serialize, Deserialize, Debug, Clone)]
198pub struct BridgeHealth {
199    pub healthy: bool,
200    pub agent: String,
201    pub total_events: u32,
202    pub total_queries: u32,
203    pub domains: Vec<String>,
204}
205
206// ============================================================================
207// Bridge Error Codes — structured error classification for dispatch failures
208// ============================================================================
209
210/// Structured error codes for bridge dispatch failures.
211///
212/// Each code maps to a specific failure mode, making it easy to:
213/// - Track error rates by type in metrics (via `BridgeMetricsSnapshot.error_counts`)
214/// - Diagnose issues from logs without parsing error message strings
215/// - Build alerting rules (e.g., alert on BRG-006 spike = cross-cluster partition)
216///
217/// Codes are stable — do not renumber or reuse after removal.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub enum BridgeErrorCode {
220    /// BRG-001: Target zome not in allowlist (unauthorized dispatch attempt)
221    AllowlistRejected,
222    /// BRG-002: Network error during local dispatch
223    LocalNetworkError,
224    /// BRG-003: Local zome call rejected (non-Ok response)
225    LocalCallRejected,
226    /// BRG-004: No response from local zome call
227    LocalNoResponse,
228    /// BRG-005: Local HDK call failed (runtime error)
229    LocalCallFailed,
230    /// BRG-006: Network error during cross-cluster dispatch
231    CrossClusterNetworkError,
232    /// BRG-007: Cross-cluster call rejected (non-Ok response)
233    CrossClusterCallRejected,
234    /// BRG-008: No response from cross-cluster call
235    CrossClusterNoResponse,
236    /// BRG-009: Cross-cluster HDK call failed (runtime error)
237    CrossClusterCallFailed,
238    /// BRG-010: Dispatch input validation failed (oversized payload/identifier)
239    ValidationFailed,
240}
241
242impl BridgeErrorCode {
243    /// String code for metrics recording (e.g., "BRG-001").
244    pub fn as_str(&self) -> &'static str {
245        match self {
246            Self::AllowlistRejected => "BRG-001",
247            Self::LocalNetworkError => "BRG-002",
248            Self::LocalCallRejected => "BRG-003",
249            Self::LocalNoResponse => "BRG-004",
250            Self::LocalCallFailed => "BRG-005",
251            Self::CrossClusterNetworkError => "BRG-006",
252            Self::CrossClusterCallRejected => "BRG-007",
253            Self::CrossClusterNoResponse => "BRG-008",
254            Self::CrossClusterCallFailed => "BRG-009",
255            Self::ValidationFailed => "BRG-010",
256        }
257    }
258}
259
260impl core::fmt::Display for BridgeErrorCode {
261    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
262        write!(f, "{}", self.as_str())
263    }
264}
265
266// ============================================================================
267// Dispatch size limits — prevent memory exhaustion via oversized payloads
268// ============================================================================
269
270/// Maximum dispatch payload size (1 MB). Prevents memory exhaustion from
271/// oversized payloads being cloned into ExternIO.
272pub const MAX_DISPATCH_PAYLOAD_BYTES: usize = 1_048_576;
273
274/// Maximum dispatch zome/fn_name identifier length (256 bytes).
275pub const MAX_DISPATCH_IDENTIFIER_BYTES: usize = 256;
276
277/// Validate dispatch input field sizes.
278fn validate_dispatch_sizes(zome: &str, fn_name: &str, payload: &[u8]) -> Result<(), String> {
279    if zome.len() > MAX_DISPATCH_IDENTIFIER_BYTES {
280        return Err(format!(
281            "Zome name too long ({} bytes, max {})",
282            zome.len(),
283            MAX_DISPATCH_IDENTIFIER_BYTES
284        ));
285    }
286    if fn_name.len() > MAX_DISPATCH_IDENTIFIER_BYTES {
287        return Err(format!(
288            "Function name too long ({} bytes, max {})",
289            fn_name.len(),
290            MAX_DISPATCH_IDENTIFIER_BYTES
291        ));
292    }
293    if payload.len() > MAX_DISPATCH_PAYLOAD_BYTES {
294        return Err(format!(
295            "Payload too large ({} bytes, max {})",
296            payload.len(),
297            MAX_DISPATCH_PAYLOAD_BYTES
298        ));
299    }
300    Ok(())
301}
302
303// ============================================================================
304// Dispatch logic (HDK functions gated behind `hdk` feature)
305// ============================================================================
306
307/// Dispatch a synchronous call to a domain zome, with allowlist validation.
308///
309/// This is the core cross-domain integration primitive. It validates the
310/// target zome against the provided allowlist, then uses
311/// `call(CallTargetCell::Local, ...)` to invoke the function directly
312/// within the same DNA.
313///
314/// The `payload` field in `DispatchInput` must already be MessagePack-encoded.
315/// We bypass `ExternIO::encode()` to avoid double-serialization.
316#[cfg(feature = "hdk")]
317pub fn dispatch_call_checked(
318    input: &DispatchInput,
319    allowed_zomes: &[&str],
320) -> ExternResult<DispatchResult> {
321    if let Err(msg) = validate_dispatch_sizes(&input.zome, &input.fn_name, &input.payload) {
322        metrics::record_error(
323            &input.zome,
324            &input.fn_name,
325            BridgeErrorCode::ValidationFailed.as_str(),
326        );
327        return Ok(DispatchResult::err(BridgeErrorCode::ValidationFailed, msg));
328    }
329    if !allowed_zomes.contains(&input.zome.as_str()) {
330        metrics::record_error(
331            &input.zome,
332            &input.fn_name,
333            BridgeErrorCode::AllowlistRejected.as_str(),
334        );
335        return Ok(DispatchResult::err(
336            BridgeErrorCode::AllowlistRejected,
337            format!(
338                "Zome '{}' is not in the allowed dispatch list. Valid zomes: {:?}",
339                input.zome, allowed_zomes
340            ),
341        ));
342    }
343
344    let payload = ExternIO(input.payload.clone());
345    let start_us = sys_time().ok().map(|t| t.as_micros() as u64);
346
347    let result = HDK.with(|h| {
348        h.borrow().call(vec![Call::new(
349            CallTarget::ConductorCell(CallTargetCell::Local),
350            ZomeName::from(input.zome.as_str()),
351            FunctionName::from(input.fn_name.as_str()),
352            None,
353            payload,
354        )])
355    });
356
357    let elapsed_us = start_us.and_then(|start| {
358        sys_time()
359            .ok()
360            .map(|end| (end.as_micros() as u64).saturating_sub(start))
361    });
362
363    match result {
364        Ok(responses) => match responses.into_iter().next() {
365            Some(ZomeCallResponse::Ok(extern_io)) => {
366                if let Some(latency) = elapsed_us {
367                    metrics::record_success(&input.zome, &input.fn_name, latency);
368                }
369                Ok(DispatchResult::ok(extern_io.0))
370            }
371            Some(ZomeCallResponse::NetworkError(err)) => {
372                let code = BridgeErrorCode::LocalNetworkError;
373                metrics::record_error(&input.zome, &input.fn_name, code.as_str());
374                Ok(DispatchResult::err(code, format!("Network error: {}", err)))
375            }
376            Some(other) => {
377                let code = BridgeErrorCode::LocalCallRejected;
378                metrics::record_error(&input.zome, &input.fn_name, code.as_str());
379                Ok(DispatchResult::err(
380                    code,
381                    format!("Zome call rejected: {:?}", other),
382                ))
383            }
384            None => {
385                let code = BridgeErrorCode::LocalNoResponse;
386                metrics::record_error(&input.zome, &input.fn_name, code.as_str());
387                Ok(DispatchResult::err(
388                    code,
389                    "No response from zome call".into(),
390                ))
391            }
392        },
393        Err(e) => {
394            let code = BridgeErrorCode::LocalCallFailed;
395            metrics::record_error(&input.zome, &input.fn_name, code.as_str());
396            Ok(DispatchResult::err(code, format!("Call failed: {:?}", e)))
397        }
398    }
399}
400
401// ============================================================================
402// Cross-cluster dispatch (inter-DNA within the same hApp)
403// ============================================================================
404
405// =============================================================================
406// CONSTELLATION PROTOCOL (Cross-hApp Call Routing)
407// =============================================================================
408
409/// Target for a constellation dispatch.
410/// Can be internal (OtherRole) or external (RemoteAgent).
411#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
412pub enum ConstellationTarget {
413    /// Internal to the current hApp (Unified mode).
414    Internal { role: String },
415    /// External to the current hApp (Standalone mode).
416    External {
417        agent: AgentPubKey,
418        cap_secret: Option<CapSecret>,
419    },
420}
421
422/// Dispatch a synchronous call across the constellation (local or remote).
423///
424/// If target is Internal, it uses `CallTargetCell::OtherRole`.
425/// If target is External, it uses `call_remote`.
426#[cfg(feature = "hdk")]
427pub fn dispatch_constellation_call(
428    target: &ConstellationTarget,
429    zome: &str,
430    fn_name: &str,
431    payload: Vec<u8>,
432) -> ExternResult<DispatchResult> {
433    let start_us = sys_time().ok().map(|t| t.as_micros() as u64);
434
435    let result = match target {
436        ConstellationTarget::Internal { role } => HDK.with(|h| {
437            h.borrow().call(vec![Call::new(
438                CallTarget::ConductorCell(CallTargetCell::OtherRole(role.clone())),
439                ZomeName::from(zome),
440                FunctionName::from(fn_name),
441                None,
442                ExternIO(payload),
443            )])
444        }),
445        ConstellationTarget::External { agent, cap_secret } => {
446            // Note: call_remote is asynchronous and returns a different type.
447            // For MVP, we bridge this into the synchronous DispatchResult pattern.
448            match call_remote(
449                agent.clone(),
450                zome,
451                fn_name.into(),
452                *cap_secret,
453                ExternIO(payload),
454            ) {
455                Ok(ZomeCallResponse::Ok(extern_io)) => Ok(vec![ZomeCallResponse::Ok(extern_io)]),
456                Ok(other) => Ok(vec![other]),
457                Err(e) => Err(e),
458            }
459        }
460    };
461
462    let elapsed_us = start_us.and_then(|start| {
463        sys_time()
464            .ok()
465            .map(|end| (end.as_micros() as u64).saturating_sub(start))
466    });
467
468    match result {
469        Ok(responses) => match responses.into_iter().next() {
470            Some(ZomeCallResponse::Ok(extern_io)) => {
471                if let Some(latency) = elapsed_us {
472                    metrics::record_success(zome, fn_name, latency);
473                }
474                Ok(DispatchResult::ok(extern_io.0))
475            }
476            Some(ZomeCallResponse::NetworkError(err)) => Ok(DispatchResult::err(
477                BridgeErrorCode::LocalNetworkError,
478                format!("Network error: {}", err),
479            )),
480            Some(other) => Ok(DispatchResult::err(
481                BridgeErrorCode::LocalCallRejected,
482                format!("Rejected: {:?}", other),
483            )),
484            None => Ok(DispatchResult::err(
485                BridgeErrorCode::LocalNoResponse,
486                "No response".into(),
487            )),
488        },
489        Err(e) => Ok(DispatchResult::err(
490            BridgeErrorCode::LocalCallFailed,
491            format!("Call failed: {:?}", e),
492        )),
493    }
494}
495
496/// Input for dispatching a call to a zome in another DNA within the same hApp.
497///
498/// Used for commons↔civic cross-cluster communication.  The `role` field
499/// identifies the target DNA by its hApp role name (e.g., `"commons"` or
500/// `"civic"`).  The call is routed via `CallTargetCell::OtherRole`.
501#[derive(Serialize, Deserialize, Debug, Clone)]
502pub struct CrossClusterDispatchInput {
503    /// hApp role name of the target DNA (e.g., "commons" or "civic").
504    pub role: String,
505    /// Target zome name within the other DNA.
506    pub zome: String,
507    /// Target function name.
508    pub fn_name: String,
509    /// MessagePack-serialized input payload.
510    pub payload: Vec<u8>,
511}
512
513/// Wrapper for cross-cluster dispatch with audit correlation.
514///
515/// When a coordinator initiates a cross-cluster action, it generates a
516/// correlation ID and wraps the dispatch so both sides can log the same
517/// ID in their audit trail.
518#[derive(Serialize, Deserialize, Debug, Clone)]
519pub struct CorrelatedDispatch {
520    /// Unique correlation ID linking audit events across clusters.
521    /// Format: "<agent_hex_prefix>:<timestamp_us>"
522    pub correlation_id: String,
523    /// Target zome in the other cluster.
524    pub target_zome: String,
525    /// Target function name.
526    pub target_fn: String,
527    /// JSON-serialized payload.
528    pub payload: String,
529}
530
531/// Dispatch a synchronous call to a zome in another DNA, with allowlist
532/// validation.
533///
534/// This is the cross-cluster counterpart of [`dispatch_call_checked`].
535/// Instead of `CallTargetCell::Local`, it uses
536/// `CallTargetCell::OtherRole(role)` to reach a different DNA within the
537/// same installed hApp.  The target zome must be in `allowed_zomes`.
538#[cfg(feature = "hdk")]
539pub fn dispatch_call_cross_cluster(
540    input: &CrossClusterDispatchInput,
541    allowed_zomes: &[&str],
542) -> ExternResult<DispatchResult> {
543    if let Err(msg) = validate_dispatch_sizes(&input.zome, &input.fn_name, &input.payload) {
544        metrics::record_error(
545            &input.zome,
546            &input.fn_name,
547            BridgeErrorCode::ValidationFailed.as_str(),
548        );
549        return Ok(DispatchResult::err(BridgeErrorCode::ValidationFailed, msg));
550    }
551    if input.role.len() > MAX_DISPATCH_IDENTIFIER_BYTES {
552        metrics::record_error(
553            &input.zome,
554            &input.fn_name,
555            BridgeErrorCode::ValidationFailed.as_str(),
556        );
557        return Ok(DispatchResult::err(
558            BridgeErrorCode::ValidationFailed,
559            format!(
560                "Role name too long ({} bytes, max {})",
561                input.role.len(),
562                MAX_DISPATCH_IDENTIFIER_BYTES
563            ),
564        ));
565    }
566
567    if !allowed_zomes.contains(&input.zome.as_str()) {
568        metrics::record_error(
569            &input.zome,
570            &input.fn_name,
571            BridgeErrorCode::AllowlistRejected.as_str(),
572        );
573        return Ok(DispatchResult::err(
574            BridgeErrorCode::AllowlistRejected,
575            format!(
576                "Zome '{}' is not in the allowed cross-cluster dispatch list. Valid zomes: {:?}",
577                input.zome, allowed_zomes
578            ),
579        ));
580    }
581
582    metrics::record_cross_cluster();
583
584    // Default to Internal target for backward compatibility with unified hApp
585    let target = ConstellationTarget::Internal {
586        role: input.role.clone(),
587    };
588
589    dispatch_constellation_call(&target, &input.zome, &input.fn_name, input.payload.clone())
590}
591
592/// Cross-cluster dispatch to commons with automatic sub-cluster role resolution.
593///
594/// Instead of using a fixed `"commons"` role, this resolves the target zome
595/// to either `"commons_land"` or `"commons_care"` based on which sub-cluster
596/// DNA contains that zome.
597///
598/// This is needed because the commons cluster is split into two DNA roles
599/// in the unified hApp to fit under Holochain's 16MB DNA limit.
600#[cfg(feature = "hdk")]
601pub fn dispatch_call_cross_cluster_commons(
602    input: &CrossClusterDispatchInput,
603    allowed_zomes: &[&str],
604) -> ExternResult<DispatchResult> {
605    // Resolve which sub-cluster this zome belongs to
606    let role = CommonsZome::resolve_role(&input.zome).unwrap_or("commons_land");
607
608    let routed_input = CrossClusterDispatchInput {
609        role: role.to_string(),
610        zome: input.zome.clone(),
611        fn_name: input.fn_name.clone(),
612        payload: input.payload.clone(),
613    };
614    dispatch_call_cross_cluster(&routed_input, allowed_zomes)
615}
616
617// ============================================================================
618// Rate limiting constants
619// ============================================================================
620
621/// Maximum dispatch calls per agent within the rate limit window.
622pub const RATE_LIMIT_MAX_DISPATCH: usize = 100;
623
624/// Rate limit window in seconds.
625pub const RATE_LIMIT_WINDOW_SECS: i64 = 60;
626
627/// Check whether the number of recent dispatches exceeds the rate limit.
628///
629/// Returns `Ok(())` if within limits, or an error string if exceeded.
630/// This is a pure validation function — the caller is responsible for
631/// counting recent dispatches (via `get_links` on the agent's rate-limit
632/// links) and passing the count here.
633pub fn check_rate_limit_count(recent_count: usize) -> Result<(), String> {
634    if recent_count >= RATE_LIMIT_MAX_DISPATCH {
635        Err(format!(
636            "Rate limit exceeded: {} dispatches in {}s (max {})",
637            recent_count, RATE_LIMIT_WINDOW_SECS, RATE_LIMIT_MAX_DISPATCH
638        ))
639    } else {
640        Ok(())
641    }
642}
643
644// ============================================================================
645// Typed cross-domain dispatch helpers
646// ============================================================================
647
648/// Input for verifying property ownership (commons: housing → property)
649#[derive(Serialize, Deserialize, Debug, Clone)]
650pub struct PropertyOwnershipQuery {
651    pub property_id: String,
652    pub requester_did: String,
653}
654
655/// Result of a property ownership verification
656#[derive(Serialize, Deserialize, Debug, Clone)]
657pub struct PropertyOwnershipResult {
658    pub is_owner: bool,
659    pub owner_did: Option<String>,
660    pub error: Option<String>,
661}
662
663/// Input for querying care provider availability (commons: mutualaid → care)
664#[derive(Serialize, Deserialize, Debug, Clone)]
665pub struct CareAvailabilityQuery {
666    pub skill_needed: String,
667    pub location: Option<String>,
668}
669
670/// Result of a care availability query
671#[derive(Serialize, Deserialize, Debug, Clone)]
672pub struct CareAvailabilityResult {
673    pub available_count: u32,
674    pub recommendation: String,
675    pub error: Option<String>,
676}
677
678/// Input for checking active cases in an area (civic: emergency → justice)
679#[derive(Serialize, Deserialize, Debug, Clone)]
680pub struct JusticeAreaQuery {
681    pub area: String,
682    pub case_type: Option<String>,
683}
684
685/// Result of an area case query
686#[derive(Serialize, Deserialize, Debug, Clone)]
687pub struct JusticeAreaResult {
688    pub active_cases: u32,
689    pub recommendation: String,
690    pub error: Option<String>,
691}
692
693/// Input for checking factcheck status (civic: justice → media)
694#[derive(Serialize, Deserialize, Debug, Clone)]
695pub struct FactcheckStatusQuery {
696    pub claim_id: String,
697}
698
699/// Result of a factcheck status query
700#[derive(Serialize, Deserialize, Debug, Clone)]
701pub struct FactcheckStatusResult {
702    pub has_factcheck: bool,
703    pub verdict: Option<String>,
704    pub error: Option<String>,
705}
706
707/// Input for querying food availability (commons: emergency → food, mutualaid → food)
708#[derive(Serialize, Deserialize, Debug, Clone)]
709pub struct FoodAvailabilityQuery {
710    pub product_name: Option<String>,
711    pub market_type: Option<String>,
712    pub max_distance_km: Option<f64>,
713}
714
715/// Result of a food availability query
716#[derive(Serialize, Deserialize, Debug, Clone)]
717pub struct FoodAvailabilityResult {
718    pub available_listings: u32,
719    pub nearest_market: Option<String>,
720    pub error: Option<String>,
721}
722
723/// Input for querying transport routes (commons: mutualaid → transport, care → transport)
724#[derive(Serialize, Deserialize, Debug, Clone)]
725pub struct TransportRouteQuery {
726    pub origin_lat: f64,
727    pub origin_lon: f64,
728    pub destination_lat: f64,
729    pub destination_lon: f64,
730    pub mode: Option<String>,
731}
732
733/// Result of a transport route query
734#[derive(Serialize, Deserialize, Debug, Clone)]
735pub struct TransportRouteResult {
736    pub route_count: u32,
737    pub estimated_minutes: Option<u32>,
738    pub estimated_emissions_kg_co2: Option<f64>,
739    pub error: Option<String>,
740}
741
742/// Input for querying carbon credits (commons: property → transport)
743#[derive(Serialize, Deserialize, Debug, Clone)]
744pub struct CarbonCreditQuery {
745    pub agent_did: String,
746}
747
748/// Result of a carbon credit query
749#[derive(Serialize, Deserialize, Debug, Clone)]
750pub struct CarbonCreditResult {
751    pub total_credits_kg_co2: f64,
752    pub trips_logged: u32,
753    pub error: Option<String>,
754}
755
756// ============================================================================
757// Cross-cluster emergency↔commons query types
758// ============================================================================
759
760/// Input for querying water safety in a disaster zone (emergency → water)
761#[derive(Serialize, Deserialize, Debug, Clone)]
762pub struct WaterSafetyQuery {
763    pub area_lat: f64,
764    pub area_lon: f64,
765    pub radius_km: f64,
766}
767
768/// Result of a water safety query
769#[derive(Serialize, Deserialize, Debug, Clone)]
770pub struct WaterSafetyResult {
771    pub safe_sources: u32,
772    pub contaminated_sources: u32,
773    pub total_sources: u32,
774}
775
776/// Input for querying food availability during an emergency (emergency → food)
777#[derive(Serialize, Deserialize, Debug, Clone)]
778pub struct EmergencyFoodQuery {
779    pub area_lat: f64,
780    pub area_lon: f64,
781    pub radius_km: f64,
782    pub people_count: u32,
783}
784
785/// Result of an emergency food availability query
786#[derive(Serialize, Deserialize, Debug, Clone)]
787pub struct EmergencyFoodResult {
788    pub available_kg: f64,
789    pub distribution_points: u32,
790    pub estimated_days_supply: f64,
791}
792
793/// Input for querying shelter capacity during an emergency (emergency → housing)
794#[derive(Serialize, Deserialize, Debug, Clone)]
795pub struct ShelterCapacityQuery {
796    pub area_lat: f64,
797    pub area_lon: f64,
798    pub radius_km: f64,
799    pub beds_needed: u32,
800}
801
802/// Result of a shelter capacity query
803#[derive(Serialize, Deserialize, Debug, Clone)]
804pub struct ShelterCapacityResult {
805    pub available_beds: u32,
806    pub total_shelters: u32,
807    pub nearest_shelter_km: f64,
808}
809
810/// Input for querying available care providers during an emergency (emergency → care)
811#[derive(Serialize, Deserialize, Debug, Clone)]
812pub struct EmergencyCareQuery {
813    pub area_lat: f64,
814    pub area_lon: f64,
815    pub skill_needed: String,
816    pub urgency_level: u8,
817}
818
819/// Result of an emergency care provider query
820#[derive(Serialize, Deserialize, Debug, Clone)]
821pub struct EmergencyCareResult {
822    pub available_providers: u32,
823    pub nearest_provider_km: f64,
824}
825
826// ============================================================================
827// Audit trail query types
828// ============================================================================
829
830/// Input for querying events within a time range, optionally filtered by domain and type.
831#[derive(Serialize, Deserialize, Debug, Clone)]
832pub struct AuditTrailQuery {
833    /// Start of the time range (inclusive), as microseconds since epoch.
834    pub from_us: i64,
835    /// End of the time range (inclusive), as microseconds since epoch.
836    pub to_us: i64,
837    /// Optional domain filter (e.g., "property", "justice").
838    pub domain: Option<String>,
839    /// Optional event type filter (e.g., "ownership_transferred").
840    pub event_type: Option<String>,
841}
842
843/// Summary of a single audit trail entry (lightweight, no full record).
844#[derive(Serialize, Deserialize, Debug, Clone)]
845pub struct AuditTrailEntry {
846    pub domain: String,
847    pub event_type: String,
848    pub source_agent: String,
849    pub payload_preview: String,
850    pub created_at_us: i64,
851    #[cfg(feature = "hdk")]
852    pub action_hash: ActionHash,
853    #[cfg(not(feature = "hdk"))]
854    pub action_hash: String,
855}
856
857/// Result of an audit trail query.
858#[derive(Serialize, Deserialize, Debug, Clone)]
859pub struct AuditTrailResult {
860    pub entries: Vec<AuditTrailEntry>,
861    pub total_matched: u32,
862    pub query_from_us: i64,
863    pub query_to_us: i64,
864}
865
866// ============================================================================
867// Typed hearth↔other cluster query/result helpers (require HDK types)
868// ============================================================================
869
870#[cfg(feature = "hdk")]
871/// Input for querying hearth membership (civic/commons → hearth)
872#[derive(Serialize, Deserialize, Debug, Clone)]
873pub struct HearthMemberQuery {
874    pub hearth_hash: ActionHash,
875    pub agent: AgentPubKey,
876}
877
878/// Result of a hearth membership query
879#[derive(Serialize, Deserialize, Debug, Clone)]
880pub struct HearthMemberResult {
881    pub is_member: bool,
882    pub role: Option<String>,
883    pub display_name: Option<String>,
884    pub error: Option<String>,
885}
886
887#[cfg(feature = "hdk")]
888/// Input for querying hearth care availability (commons → hearth)
889#[derive(Serialize, Deserialize, Debug, Clone)]
890pub struct HearthCareQuery {
891    pub hearth_hash: ActionHash,
892    pub care_type: Option<String>,
893}
894
895/// Result of a hearth care query
896#[derive(Serialize, Deserialize, Debug, Clone)]
897pub struct HearthCareResult {
898    pub available_caregivers: u32,
899    pub active_schedules: u32,
900    pub error: Option<String>,
901}
902
903#[cfg(feature = "hdk")]
904/// Input for querying hearth emergency status (civic → hearth)
905#[derive(Serialize, Deserialize, Debug, Clone)]
906pub struct HearthEmergencyQuery {
907    pub hearth_hash: ActionHash,
908}
909
910/// Result of a hearth emergency status query
911#[derive(Serialize, Deserialize, Debug, Clone)]
912pub struct HearthEmergencyResult {
913    pub has_active_alerts: bool,
914    pub active_alert_count: u32,
915    pub members_checked_in: u32,
916    pub members_missing: u32,
917    pub error: Option<String>,
918}
919
920// ============================================================================
921// Cross-cluster typed queries (Phase 1C — governance/finance/identity/health)
922// ============================================================================
923
924/// Query: Check budget proposal status (Finance ↔ Governance)
925#[derive(Serialize, Deserialize, Debug, Clone)]
926pub struct BudgetProposalQuery {
927    pub proposal_id: String,
928}
929
930/// Result: Budget proposal status
931#[derive(Serialize, Deserialize, Debug, Clone)]
932pub struct BudgetProposalResult {
933    pub approved: bool,
934    pub amount: u64,
935    pub treasury_balance: u64,
936    pub error: Option<String>,
937}
938
939/// Query: Verify property as collateral (Finance → Commons)
940#[derive(Serialize, Deserialize, Debug, Clone)]
941pub struct CollateralPropertyQuery {
942    pub property_hash: String,
943}
944
945/// Result: Collateral property status
946#[derive(Serialize, Deserialize, Debug, Clone)]
947pub struct CollateralPropertyResult {
948    pub valid: bool,
949    pub appraised_value: u64,
950    pub encumbered: bool,
951    pub error: Option<String>,
952}
953
954/// Query: Check restitution ability (Civic → Finance)
955#[derive(Serialize, Deserialize, Debug, Clone)]
956pub struct RestitutionQuery {
957    pub case_id: String,
958    pub defendant_did: String,
959}
960
961/// Result: Restitution check
962#[derive(Serialize, Deserialize, Debug, Clone)]
963pub struct RestitutionResult {
964    pub balance_sufficient: bool,
965    pub amount_due: u64,
966    pub error: Option<String>,
967}
968
969/// Notice: Credential revocation push (Identity → *)
970#[derive(Serialize, Deserialize, Debug, Clone)]
971pub struct RevocationNotice {
972    pub credential_hash: String,
973    pub did: String,
974    pub reason: String,
975    pub effective_at: u64,
976}
977
978/// Acknowledgment: Revocation received
979#[derive(Serialize, Deserialize, Debug, Clone)]
980pub struct RevocationAck {
981    pub received: bool,
982    pub affected_entries: u32,
983    pub error: Option<String>,
984}
985
986/// Query: Consented health record access (Health → Personal)
987#[derive(Serialize, Deserialize, Debug, Clone)]
988pub struct ConsentedRecordQuery {
989    pub patient_did: String,
990    pub record_type: String,
991}
992
993/// Result: Consented record access
994#[derive(Serialize, Deserialize, Debug, Clone)]
995pub struct ConsentedRecordResult {
996    pub authorized: bool,
997    pub record_hash: Option<String>,
998    pub error: Option<String>,
999}
1000
1001/// Query: Energy project governance approval (Energy → Governance)
1002#[derive(Serialize, Deserialize, Debug, Clone)]
1003pub struct ProjectProposalQuery {
1004    pub project_id: String,
1005}
1006
1007/// Result: Project governance approval
1008#[derive(Serialize, Deserialize, Debug, Clone)]
1009pub struct ProjectProposalResult {
1010    pub governance_approved: bool,
1011    pub conditions: Vec<String>,
1012    pub error: Option<String>,
1013}
1014
1015/// Query: Verify knowledge claim (Knowledge → Media)
1016#[derive(Serialize, Deserialize, Debug, Clone)]
1017pub struct ClaimVerificationQuery {
1018    pub claim_hash: String,
1019}
1020
1021/// Result: Claim verification
1022#[derive(Serialize, Deserialize, Debug, Clone)]
1023pub struct ClaimVerificationResult {
1024    pub verified: bool,
1025    pub confidence: f64,
1026    pub sources: Vec<String>,
1027    pub error: Option<String>,
1028}
1029
1030/// Query: Carbon offset from transport (Climate → Transport/Commons)
1031#[derive(Serialize, Deserialize, Debug, Clone)]
1032pub struct CarbonOffsetQuery {
1033    pub route_id: String,
1034    pub distance_km: f64,
1035}
1036
1037/// Result: Carbon offset calculation
1038#[derive(Serialize, Deserialize, Debug, Clone)]
1039pub struct CarbonOffsetResult {
1040    pub credits_earned: f64,
1041    pub offset_hash: Option<String>,
1042    pub error: Option<String>,
1043}
1044
1045/// Priority levels for cross-cluster notifications.
1046#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
1047pub enum NotificationPriority {
1048    /// Batched into daily digest
1049    Low = 0,
1050    /// Delivered on next poll
1051    Normal = 1,
1052    /// Immediate signal
1053    High = 2,
1054    /// Bypass quiet hours, multi-channel delivery
1055    Emergency = 3,
1056}
1057
1058impl NotificationPriority {
1059    pub const fn from_u8(v: u8) -> Self {
1060        match v {
1061            0 => Self::Low,
1062            1 => Self::Normal,
1063            2 => Self::High,
1064            3 => Self::Emergency,
1065            _ => Self::Normal,
1066        }
1067    }
1068
1069    pub const fn as_u8(&self) -> u8 {
1070        *self as u8
1071    }
1072}
1073
1074// ============================================================================
1075// Utilities
1076// ============================================================================
1077
1078/// Convert links to their target records, skipping any that have been deleted.
1079#[cfg(feature = "hdk")]
1080pub fn records_from_links(links: Vec<Link>) -> ExternResult<Vec<Record>> {
1081    let mut records = Vec::new();
1082    for link in links {
1083        let action_hash = ActionHash::try_from(link.target)
1084            .map_err(|_| wasm_error!(WasmErrorInner::Guest("Invalid link target".into())))?;
1085        if let Some(record) = get(action_hash, GetOptions::default())? {
1086            records.push(record);
1087        }
1088    }
1089    Ok(records)
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095
1096    // dispatch_call_checked: the disallowed-zome path returns before
1097    // touching HDK, so we can test it without a running conductor.
1098
1099    #[test]
1100    fn dispatch_rejects_disallowed_zome() {
1101        let input = DispatchInput {
1102            zome: "evil_zome".into(),
1103            fn_name: "steal_data".into(),
1104            payload: vec![],
1105        };
1106        let allowed = &["property_registry", "housing_units"];
1107        let result = dispatch_call_checked(&input, allowed).unwrap();
1108        assert!(!result.success);
1109        assert!(result.response.is_none());
1110        let err = result.error.unwrap();
1111        assert!(err.contains("not in the allowed dispatch list"));
1112        assert!(err.contains("evil_zome"));
1113    }
1114
1115    #[test]
1116    fn dispatch_rejects_empty_allowlist() {
1117        let input = DispatchInput {
1118            zome: "property_registry".into(),
1119            fn_name: "get_property".into(),
1120            payload: vec![],
1121        };
1122        let result = dispatch_call_checked(&input, &[]).unwrap();
1123        assert!(!result.success);
1124        assert!(result.error.is_some());
1125    }
1126
1127    #[test]
1128    fn dispatch_rejects_similar_zome_name() {
1129        let input = DispatchInput {
1130            zome: "property_registry_evil".into(),
1131            fn_name: "get_property".into(),
1132            payload: vec![],
1133        };
1134        let allowed = &["property_registry"];
1135        let result = dispatch_call_checked(&input, allowed).unwrap();
1136        assert!(!result.success);
1137    }
1138
1139    #[test]
1140    fn dispatch_error_lists_valid_zomes() {
1141        let input = DispatchInput {
1142            zome: "bad".into(),
1143            fn_name: "fn".into(),
1144            payload: vec![],
1145        };
1146        let allowed = &["alpha", "beta", "gamma"];
1147        let result = dispatch_call_checked(&input, allowed).unwrap();
1148        let err = result.error.unwrap();
1149        assert!(err.contains("alpha"));
1150        assert!(err.contains("beta"));
1151        assert!(err.contains("gamma"));
1152    }
1153
1154    // Type serde roundtrips
1155
1156    #[test]
1157    fn dispatch_input_serde_roundtrip() {
1158        let input = DispatchInput {
1159            zome: "property_registry".into(),
1160            fn_name: "get_property".into(),
1161            payload: vec![1, 2, 3, 4],
1162        };
1163        let json = serde_json::to_string(&input).unwrap();
1164        let input2: DispatchInput = serde_json::from_str(&json).unwrap();
1165        assert_eq!(input.zome, input2.zome);
1166        assert_eq!(input.fn_name, input2.fn_name);
1167        assert_eq!(input.payload, input2.payload);
1168    }
1169
1170    #[test]
1171    fn dispatch_result_success_serde_roundtrip() {
1172        let result = DispatchResult::ok(vec![10, 20, 30]);
1173        let json = serde_json::to_string(&result).unwrap();
1174        let r2: DispatchResult = serde_json::from_str(&json).unwrap();
1175        assert!(r2.success);
1176        assert_eq!(r2.response, Some(vec![10, 20, 30]));
1177        assert!(r2.error.is_none());
1178        assert!(r2.error_code.is_none());
1179    }
1180
1181    #[test]
1182    fn dispatch_result_error_serde_roundtrip() {
1183        let result =
1184            DispatchResult::err(BridgeErrorCode::LocalCallFailed, "something failed".into());
1185        let json = serde_json::to_string(&result).unwrap();
1186        assert!(json.contains("error_code")); // error code field present
1187        let r2: DispatchResult = serde_json::from_str(&json).unwrap();
1188        assert!(!r2.success);
1189        assert!(r2.response.is_none());
1190        assert_eq!(r2.error.as_deref(), Some("something failed"));
1191        assert_eq!(r2.error_code, Some(BridgeErrorCode::LocalCallFailed));
1192    }
1193
1194    #[test]
1195    fn dispatch_result_backward_compat_without_error_code() {
1196        // Old serialized results without error_code should deserialize fine
1197        let json = r#"{"success":false,"response":null,"error":"old error"}"#;
1198        let r: DispatchResult = serde_json::from_str(json).unwrap();
1199        assert!(!r.success);
1200        assert_eq!(r.error.as_deref(), Some("old error"));
1201        assert_eq!(r.error_code, None); // backward compatible default
1202    }
1203
1204    #[test]
1205    fn event_type_query_serde_roundtrip() {
1206        let q = EventTypeQuery {
1207            domain: "housing".into(),
1208            event_type: "lease_created".into(),
1209        };
1210        let json = serde_json::to_string(&q).unwrap();
1211        let q2: EventTypeQuery = serde_json::from_str(&json).unwrap();
1212        assert_eq!(q.domain, q2.domain);
1213        assert_eq!(q.event_type, q2.event_type);
1214    }
1215
1216    // Cross-cluster dispatch validation tests
1217
1218    #[test]
1219    fn cross_cluster_rejects_disallowed_zome() {
1220        let input = CrossClusterDispatchInput {
1221            role: "civic".into(),
1222            zome: "evil_zome".into(),
1223            fn_name: "steal_data".into(),
1224            payload: vec![],
1225        };
1226        let allowed = &["justice_cases", "emergency_incidents"];
1227        let result = dispatch_call_cross_cluster(&input, allowed).unwrap();
1228        assert!(!result.success);
1229        assert!(result.response.is_none());
1230        let err = result.error.unwrap();
1231        assert!(err.contains("not in the allowed cross-cluster dispatch list"));
1232        assert!(err.contains("evil_zome"));
1233    }
1234
1235    #[test]
1236    fn cross_cluster_rejects_empty_allowlist() {
1237        let input = CrossClusterDispatchInput {
1238            role: "commons".into(),
1239            zome: "property_registry".into(),
1240            fn_name: "get_property".into(),
1241            payload: vec![],
1242        };
1243        let result = dispatch_call_cross_cluster(&input, &[]).unwrap();
1244        assert!(!result.success);
1245        assert!(result.error.is_some());
1246    }
1247
1248    #[test]
1249    fn cross_cluster_rejects_similar_zome_name() {
1250        let input = CrossClusterDispatchInput {
1251            role: "civic".into(),
1252            zome: "justice_cases_evil".into(),
1253            fn_name: "get_case".into(),
1254            payload: vec![],
1255        };
1256        let allowed = &["justice_cases"];
1257        let result = dispatch_call_cross_cluster(&input, allowed).unwrap();
1258        assert!(!result.success);
1259    }
1260
1261    #[test]
1262    fn cross_cluster_error_lists_valid_zomes() {
1263        let input = CrossClusterDispatchInput {
1264            role: "civic".into(),
1265            zome: "bad".into(),
1266            fn_name: "fn".into(),
1267            payload: vec![],
1268        };
1269        let allowed = &["justice_cases", "emergency_incidents", "media_publication"];
1270        let result = dispatch_call_cross_cluster(&input, allowed).unwrap();
1271        let err = result.error.unwrap();
1272        assert!(err.contains("justice_cases"));
1273        assert!(err.contains("emergency_incidents"));
1274        assert!(err.contains("media_publication"));
1275    }
1276
1277    #[test]
1278    fn cross_cluster_dispatch_input_serde_roundtrip() {
1279        let input = CrossClusterDispatchInput {
1280            role: "civic".into(),
1281            zome: "justice_cases".into(),
1282            fn_name: "get_case".into(),
1283            payload: vec![5, 6, 7],
1284        };
1285        let json = serde_json::to_string(&input).unwrap();
1286        let input2: CrossClusterDispatchInput = serde_json::from_str(&json).unwrap();
1287        assert_eq!(input.role, input2.role);
1288        assert_eq!(input.zome, input2.zome);
1289        assert_eq!(input.fn_name, input2.fn_name);
1290        assert_eq!(input.payload, input2.payload);
1291    }
1292
1293    #[test]
1294    fn bridge_health_serde_roundtrip() {
1295        let h = BridgeHealth {
1296            healthy: true,
1297            agent: "uhCAk_test_agent".into(),
1298            total_events: 42,
1299            total_queries: 7,
1300            domains: vec!["property".into(), "housing".into()],
1301        };
1302        let json = serde_json::to_string(&h).unwrap();
1303        let h2: BridgeHealth = serde_json::from_str(&json).unwrap();
1304        assert!(h2.healthy);
1305        assert_eq!(h2.total_events, 42);
1306        assert_eq!(h2.total_queries, 7);
1307        assert_eq!(h2.domains.len(), 2);
1308    }
1309
1310    // Rate limit tests
1311
1312    #[test]
1313    fn rate_limit_zero_calls_passes() {
1314        assert!(check_rate_limit_count(0).is_ok());
1315    }
1316
1317    #[test]
1318    fn rate_limit_under_max_passes() {
1319        assert!(check_rate_limit_count(99).is_ok());
1320    }
1321
1322    #[test]
1323    fn rate_limit_at_max_rejects() {
1324        let err = check_rate_limit_count(RATE_LIMIT_MAX_DISPATCH).unwrap_err();
1325        assert!(err.contains("Rate limit exceeded"));
1326    }
1327
1328    #[test]
1329    fn rate_limit_over_max_rejects() {
1330        let err = check_rate_limit_count(1000).unwrap_err();
1331        assert!(err.contains("Rate limit exceeded"));
1332        assert!(err.contains("1000"));
1333    }
1334
1335    #[test]
1336    fn rate_limit_error_includes_window() {
1337        let err = check_rate_limit_count(200).unwrap_err();
1338        assert!(err.contains(&format!("{}s", RATE_LIMIT_WINDOW_SECS)));
1339    }
1340
1341    // Typed helper serde tests
1342
1343    #[test]
1344    fn property_ownership_query_serde_roundtrip() {
1345        let q = PropertyOwnershipQuery {
1346            property_id: "PROP-001".into(),
1347            requester_did: "did:mycelix:abc".into(),
1348        };
1349        let json = serde_json::to_string(&q).unwrap();
1350        let q2: PropertyOwnershipQuery = serde_json::from_str(&json).unwrap();
1351        assert_eq!(q.property_id, q2.property_id);
1352        assert_eq!(q.requester_did, q2.requester_did);
1353    }
1354
1355    #[test]
1356    fn property_ownership_result_serde_roundtrip() {
1357        let r = PropertyOwnershipResult {
1358            is_owner: true,
1359            owner_did: Some("did:mycelix:owner".into()),
1360            error: None,
1361        };
1362        let json = serde_json::to_string(&r).unwrap();
1363        let r2: PropertyOwnershipResult = serde_json::from_str(&json).unwrap();
1364        assert!(r2.is_owner);
1365        assert_eq!(r2.owner_did, Some("did:mycelix:owner".into()));
1366    }
1367
1368    #[test]
1369    fn care_availability_query_serde_roundtrip() {
1370        let q = CareAvailabilityQuery {
1371            skill_needed: "nursing".into(),
1372            location: None,
1373        };
1374        let json = serde_json::to_string(&q).unwrap();
1375        let q2: CareAvailabilityQuery = serde_json::from_str(&json).unwrap();
1376        assert_eq!(q.skill_needed, q2.skill_needed);
1377        assert!(q2.location.is_none());
1378    }
1379
1380    #[test]
1381    fn justice_area_query_serde_roundtrip() {
1382        let q = JusticeAreaQuery {
1383            area: "north-side".into(),
1384            case_type: Some("civil".into()),
1385        };
1386        let json = serde_json::to_string(&q).unwrap();
1387        let q2: JusticeAreaQuery = serde_json::from_str(&json).unwrap();
1388        assert_eq!(q.area, q2.area);
1389        assert_eq!(q.case_type, q2.case_type);
1390    }
1391
1392    #[test]
1393    fn factcheck_status_query_serde_roundtrip() {
1394        let q = FactcheckStatusQuery {
1395            claim_id: "CL-42".into(),
1396        };
1397        let json = serde_json::to_string(&q).unwrap();
1398        let q2: FactcheckStatusQuery = serde_json::from_str(&json).unwrap();
1399        assert_eq!(q.claim_id, q2.claim_id);
1400    }
1401
1402    #[test]
1403    fn factcheck_status_result_serde_roundtrip() {
1404        let r = FactcheckStatusResult {
1405            has_factcheck: true,
1406            verdict: Some("verified".into()),
1407            error: None,
1408        };
1409        let json = serde_json::to_string(&r).unwrap();
1410        let r2: FactcheckStatusResult = serde_json::from_str(&json).unwrap();
1411        assert!(r2.has_factcheck);
1412        assert_eq!(r2.verdict, Some("verified".into()));
1413    }
1414
1415    // Audit trail type serde tests
1416
1417    #[test]
1418    fn audit_trail_query_full_serde() {
1419        let q = AuditTrailQuery {
1420            from_us: 1_700_000_000_000_000,
1421            to_us: 1_700_001_000_000_000,
1422            domain: Some("property".into()),
1423            event_type: Some("ownership_transferred".into()),
1424        };
1425        let json = serde_json::to_string(&q).unwrap();
1426        let q2: AuditTrailQuery = serde_json::from_str(&json).unwrap();
1427        assert_eq!(q2.from_us, 1_700_000_000_000_000);
1428        assert_eq!(q2.domain.as_deref(), Some("property"));
1429        assert_eq!(q2.event_type.as_deref(), Some("ownership_transferred"));
1430    }
1431
1432    #[test]
1433    fn audit_trail_query_no_filters() {
1434        let q = AuditTrailQuery {
1435            from_us: 0,
1436            to_us: i64::MAX,
1437            domain: None,
1438            event_type: None,
1439        };
1440        let json = serde_json::to_string(&q).unwrap();
1441        let q2: AuditTrailQuery = serde_json::from_str(&json).unwrap();
1442        assert!(q2.domain.is_none());
1443        assert!(q2.event_type.is_none());
1444    }
1445
1446    #[test]
1447    fn audit_trail_entry_serde() {
1448        let e = AuditTrailEntry {
1449            domain: "justice".into(),
1450            event_type: "case_filed".into(),
1451            source_agent: "uhCAk_agent1".into(),
1452            payload_preview: "{\"case_id\":\"CASE-1\"}".into(),
1453            created_at_us: 1_700_000_500_000_000,
1454            action_hash: ActionHash::from_raw_36(vec![0u8; 36]),
1455        };
1456        let json = serde_json::to_string(&e).unwrap();
1457        let e2: AuditTrailEntry = serde_json::from_str(&json).unwrap();
1458        assert_eq!(e2.domain, "justice");
1459        assert_eq!(e2.event_type, "case_filed");
1460    }
1461
1462    #[test]
1463    fn audit_trail_result_serde() {
1464        let r = AuditTrailResult {
1465            entries: vec![],
1466            total_matched: 0,
1467            query_from_us: 0,
1468            query_to_us: 1_000_000,
1469        };
1470        let json = serde_json::to_string(&r).unwrap();
1471        let r2: AuditTrailResult = serde_json::from_str(&json).unwrap();
1472        assert!(r2.entries.is_empty());
1473        assert_eq!(r2.total_matched, 0);
1474    }
1475
1476    // Food/Transport/Carbon typed helper serde tests
1477
1478    #[test]
1479    fn food_availability_query_serde_roundtrip() {
1480        let q = FoodAvailabilityQuery {
1481            product_name: Some("tomatoes".into()),
1482            market_type: Some("FarmersMarket".into()),
1483            max_distance_km: Some(15.0),
1484        };
1485        let json = serde_json::to_string(&q).unwrap();
1486        let q2: FoodAvailabilityQuery = serde_json::from_str(&json).unwrap();
1487        assert_eq!(q2.product_name.as_deref(), Some("tomatoes"));
1488        assert_eq!(q2.market_type.as_deref(), Some("FarmersMarket"));
1489        assert_eq!(q2.max_distance_km, Some(15.0));
1490    }
1491
1492    #[test]
1493    fn food_availability_query_no_filters() {
1494        let q = FoodAvailabilityQuery {
1495            product_name: None,
1496            market_type: None,
1497            max_distance_km: None,
1498        };
1499        let json = serde_json::to_string(&q).unwrap();
1500        let q2: FoodAvailabilityQuery = serde_json::from_str(&json).unwrap();
1501        assert!(q2.product_name.is_none());
1502    }
1503
1504    #[test]
1505    fn food_availability_result_serde_roundtrip() {
1506        let r = FoodAvailabilityResult {
1507            available_listings: 12,
1508            nearest_market: Some("Southside Farmers Market".into()),
1509            error: None,
1510        };
1511        let json = serde_json::to_string(&r).unwrap();
1512        let r2: FoodAvailabilityResult = serde_json::from_str(&json).unwrap();
1513        assert_eq!(r2.available_listings, 12);
1514        assert_eq!(
1515            r2.nearest_market.as_deref(),
1516            Some("Southside Farmers Market")
1517        );
1518        assert!(r2.error.is_none());
1519    }
1520
1521    #[test]
1522    fn transport_route_query_serde_roundtrip() {
1523        let q = TransportRouteQuery {
1524            origin_lat: 32.9483,
1525            origin_lon: -96.7299,
1526            destination_lat: 32.7767,
1527            destination_lon: -96.7970,
1528            mode: Some("Cycling".into()),
1529        };
1530        let json = serde_json::to_string(&q).unwrap();
1531        let q2: TransportRouteQuery = serde_json::from_str(&json).unwrap();
1532        assert!((q2.origin_lat - 32.9483).abs() < 1e-4);
1533        assert_eq!(q2.mode.as_deref(), Some("Cycling"));
1534    }
1535
1536    #[test]
1537    fn transport_route_result_serde_roundtrip() {
1538        let r = TransportRouteResult {
1539            route_count: 3,
1540            estimated_minutes: Some(45),
1541            estimated_emissions_kg_co2: Some(0.0),
1542            error: None,
1543        };
1544        let json = serde_json::to_string(&r).unwrap();
1545        let r2: TransportRouteResult = serde_json::from_str(&json).unwrap();
1546        assert_eq!(r2.route_count, 3);
1547        assert_eq!(r2.estimated_minutes, Some(45));
1548        assert_eq!(r2.estimated_emissions_kg_co2, Some(0.0));
1549    }
1550
1551    #[test]
1552    fn carbon_credit_query_serde_roundtrip() {
1553        let q = CarbonCreditQuery {
1554            agent_did: "did:mycelix:agent123".into(),
1555        };
1556        let json = serde_json::to_string(&q).unwrap();
1557        let q2: CarbonCreditQuery = serde_json::from_str(&json).unwrap();
1558        assert_eq!(q2.agent_did, "did:mycelix:agent123");
1559    }
1560
1561    #[test]
1562    fn carbon_credit_result_serde_roundtrip() {
1563        let r = CarbonCreditResult {
1564            total_credits_kg_co2: 127.5,
1565            trips_logged: 34,
1566            error: None,
1567        };
1568        let json = serde_json::to_string(&r).unwrap();
1569        let r2: CarbonCreditResult = serde_json::from_str(&json).unwrap();
1570        assert!((r2.total_credits_kg_co2 - 127.5).abs() < 1e-6);
1571        assert_eq!(r2.trips_logged, 34);
1572        assert!(r2.error.is_none());
1573    }
1574
1575    // Emergency↔Commons cross-cluster type serde tests
1576
1577    #[test]
1578    fn water_safety_query_serde_roundtrip() {
1579        let q = WaterSafetyQuery {
1580            area_lat: 32.9483,
1581            area_lon: -96.7299,
1582            radius_km: 10.0,
1583        };
1584        let json = serde_json::to_string(&q).unwrap();
1585        let q2: WaterSafetyQuery = serde_json::from_str(&json).unwrap();
1586        assert!((q2.area_lat - 32.9483).abs() < 1e-4);
1587        assert!((q2.area_lon - (-96.7299)).abs() < 1e-4);
1588        assert!((q2.radius_km - 10.0).abs() < 1e-6);
1589    }
1590
1591    #[test]
1592    fn water_safety_result_serde_roundtrip() {
1593        let r = WaterSafetyResult {
1594            safe_sources: 8,
1595            contaminated_sources: 2,
1596            total_sources: 10,
1597        };
1598        let json = serde_json::to_string(&r).unwrap();
1599        let r2: WaterSafetyResult = serde_json::from_str(&json).unwrap();
1600        assert_eq!(r2.safe_sources, 8);
1601        assert_eq!(r2.contaminated_sources, 2);
1602        assert_eq!(r2.total_sources, 10);
1603    }
1604
1605    #[test]
1606    fn water_safety_result_all_contaminated() {
1607        let r = WaterSafetyResult {
1608            safe_sources: 0,
1609            contaminated_sources: 5,
1610            total_sources: 5,
1611        };
1612        let json = serde_json::to_string(&r).unwrap();
1613        let r2: WaterSafetyResult = serde_json::from_str(&json).unwrap();
1614        assert_eq!(r2.safe_sources, 0);
1615        assert_eq!(r2.contaminated_sources, 5);
1616    }
1617
1618    #[test]
1619    fn emergency_food_query_serde_roundtrip() {
1620        let q = EmergencyFoodQuery {
1621            area_lat: 29.7604,
1622            area_lon: -95.3698,
1623            radius_km: 25.0,
1624            people_count: 500,
1625        };
1626        let json = serde_json::to_string(&q).unwrap();
1627        let q2: EmergencyFoodQuery = serde_json::from_str(&json).unwrap();
1628        assert!((q2.area_lat - 29.7604).abs() < 1e-4);
1629        assert!((q2.area_lon - (-95.3698)).abs() < 1e-4);
1630        assert!((q2.radius_km - 25.0).abs() < 1e-6);
1631        assert_eq!(q2.people_count, 500);
1632    }
1633
1634    #[test]
1635    fn emergency_food_result_serde_roundtrip() {
1636        let r = EmergencyFoodResult {
1637            available_kg: 2500.5,
1638            distribution_points: 4,
1639            estimated_days_supply: 3.5,
1640        };
1641        let json = serde_json::to_string(&r).unwrap();
1642        let r2: EmergencyFoodResult = serde_json::from_str(&json).unwrap();
1643        assert!((r2.available_kg - 2500.5).abs() < 1e-6);
1644        assert_eq!(r2.distribution_points, 4);
1645        assert!((r2.estimated_days_supply - 3.5).abs() < 1e-6);
1646    }
1647
1648    #[test]
1649    fn emergency_food_result_zero_supply() {
1650        let r = EmergencyFoodResult {
1651            available_kg: 0.0,
1652            distribution_points: 0,
1653            estimated_days_supply: 0.0,
1654        };
1655        let json = serde_json::to_string(&r).unwrap();
1656        let r2: EmergencyFoodResult = serde_json::from_str(&json).unwrap();
1657        assert!((r2.available_kg).abs() < 1e-6);
1658        assert_eq!(r2.distribution_points, 0);
1659        assert!((r2.estimated_days_supply).abs() < 1e-6);
1660    }
1661
1662    #[test]
1663    fn shelter_capacity_query_serde_roundtrip() {
1664        let q = ShelterCapacityQuery {
1665            area_lat: 30.2672,
1666            area_lon: -97.7431,
1667            radius_km: 15.0,
1668            beds_needed: 200,
1669        };
1670        let json = serde_json::to_string(&q).unwrap();
1671        let q2: ShelterCapacityQuery = serde_json::from_str(&json).unwrap();
1672        assert!((q2.area_lat - 30.2672).abs() < 1e-4);
1673        assert!((q2.area_lon - (-97.7431)).abs() < 1e-4);
1674        assert!((q2.radius_km - 15.0).abs() < 1e-6);
1675        assert_eq!(q2.beds_needed, 200);
1676    }
1677
1678    #[test]
1679    fn shelter_capacity_result_serde_roundtrip() {
1680        let r = ShelterCapacityResult {
1681            available_beds: 150,
1682            total_shelters: 3,
1683            nearest_shelter_km: 2.4,
1684        };
1685        let json = serde_json::to_string(&r).unwrap();
1686        let r2: ShelterCapacityResult = serde_json::from_str(&json).unwrap();
1687        assert_eq!(r2.available_beds, 150);
1688        assert_eq!(r2.total_shelters, 3);
1689        assert!((r2.nearest_shelter_km - 2.4).abs() < 1e-6);
1690    }
1691
1692    #[test]
1693    fn shelter_capacity_result_no_shelters() {
1694        let r = ShelterCapacityResult {
1695            available_beds: 0,
1696            total_shelters: 0,
1697            nearest_shelter_km: 0.0,
1698        };
1699        let json = serde_json::to_string(&r).unwrap();
1700        let r2: ShelterCapacityResult = serde_json::from_str(&json).unwrap();
1701        assert_eq!(r2.available_beds, 0);
1702        assert_eq!(r2.total_shelters, 0);
1703    }
1704
1705    #[test]
1706    fn emergency_care_query_serde_roundtrip() {
1707        let q = EmergencyCareQuery {
1708            area_lat: 32.7767,
1709            area_lon: -96.7970,
1710            skill_needed: "trauma_surgeon".into(),
1711            urgency_level: 5,
1712        };
1713        let json = serde_json::to_string(&q).unwrap();
1714        let q2: EmergencyCareQuery = serde_json::from_str(&json).unwrap();
1715        assert!((q2.area_lat - 32.7767).abs() < 1e-4);
1716        assert!((q2.area_lon - (-96.7970)).abs() < 1e-4);
1717        assert_eq!(q2.skill_needed, "trauma_surgeon");
1718        assert_eq!(q2.urgency_level, 5);
1719    }
1720
1721    #[test]
1722    fn emergency_care_query_low_urgency() {
1723        let q = EmergencyCareQuery {
1724            area_lat: 0.0,
1725            area_lon: 0.0,
1726            skill_needed: "first_aid".into(),
1727            urgency_level: 1,
1728        };
1729        let json = serde_json::to_string(&q).unwrap();
1730        let q2: EmergencyCareQuery = serde_json::from_str(&json).unwrap();
1731        assert_eq!(q2.urgency_level, 1);
1732        assert_eq!(q2.skill_needed, "first_aid");
1733    }
1734
1735    #[test]
1736    fn emergency_care_result_serde_roundtrip() {
1737        let r = EmergencyCareResult {
1738            available_providers: 7,
1739            nearest_provider_km: 1.2,
1740        };
1741        let json = serde_json::to_string(&r).unwrap();
1742        let r2: EmergencyCareResult = serde_json::from_str(&json).unwrap();
1743        assert_eq!(r2.available_providers, 7);
1744        assert!((r2.nearest_provider_km - 1.2).abs() < 1e-6);
1745    }
1746
1747    #[test]
1748    fn emergency_care_result_no_providers() {
1749        let r = EmergencyCareResult {
1750            available_providers: 0,
1751            nearest_provider_km: 0.0,
1752        };
1753        let json = serde_json::to_string(&r).unwrap();
1754        let r2: EmergencyCareResult = serde_json::from_str(&json).unwrap();
1755        assert_eq!(r2.available_providers, 0);
1756    }
1757
1758    // Boundary validation tests for emergency↔commons types
1759
1760    #[test]
1761    fn water_safety_query_extreme_coordinates() {
1762        let q = WaterSafetyQuery {
1763            area_lat: 90.0,
1764            area_lon: 180.0,
1765            radius_km: 0.001,
1766        };
1767        let json = serde_json::to_string(&q).unwrap();
1768        let q2: WaterSafetyQuery = serde_json::from_str(&json).unwrap();
1769        assert!((q2.area_lat - 90.0).abs() < 1e-6);
1770        assert!((q2.area_lon - 180.0).abs() < 1e-6);
1771    }
1772
1773    #[test]
1774    fn water_safety_query_negative_coordinates() {
1775        let q = WaterSafetyQuery {
1776            area_lat: -90.0,
1777            area_lon: -180.0,
1778            radius_km: 100.0,
1779        };
1780        let json = serde_json::to_string(&q).unwrap();
1781        let q2: WaterSafetyQuery = serde_json::from_str(&json).unwrap();
1782        assert!((q2.area_lat - (-90.0)).abs() < 1e-6);
1783        assert!((q2.area_lon - (-180.0)).abs() < 1e-6);
1784    }
1785
1786    #[test]
1787    fn shelter_capacity_query_zero_beds_needed() {
1788        let q = ShelterCapacityQuery {
1789            area_lat: 0.0,
1790            area_lon: 0.0,
1791            radius_km: 1.0,
1792            beds_needed: 0,
1793        };
1794        let json = serde_json::to_string(&q).unwrap();
1795        let q2: ShelterCapacityQuery = serde_json::from_str(&json).unwrap();
1796        assert_eq!(q2.beds_needed, 0);
1797    }
1798
1799    #[test]
1800    fn emergency_food_query_zero_people() {
1801        let q = EmergencyFoodQuery {
1802            area_lat: 0.0,
1803            area_lon: 0.0,
1804            radius_km: 1.0,
1805            people_count: 0,
1806        };
1807        let json = serde_json::to_string(&q).unwrap();
1808        let q2: EmergencyFoodQuery = serde_json::from_str(&json).unwrap();
1809        assert_eq!(q2.people_count, 0);
1810    }
1811
1812    #[test]
1813    fn emergency_care_query_max_urgency_level() {
1814        let q = EmergencyCareQuery {
1815            area_lat: 0.0,
1816            area_lon: 0.0,
1817            skill_needed: "any".into(),
1818            urgency_level: 255,
1819        };
1820        let json = serde_json::to_string(&q).unwrap();
1821        let q2: EmergencyCareQuery = serde_json::from_str(&json).unwrap();
1822        assert_eq!(q2.urgency_level, 255);
1823    }
1824
1825    #[test]
1826    fn emergency_care_query_empty_skill() {
1827        let q = EmergencyCareQuery {
1828            area_lat: 0.0,
1829            area_lon: 0.0,
1830            skill_needed: "".into(),
1831            urgency_level: 3,
1832        };
1833        let json = serde_json::to_string(&q).unwrap();
1834        let q2: EmergencyCareQuery = serde_json::from_str(&json).unwrap();
1835        assert_eq!(q2.skill_needed, "");
1836    }
1837
1838    // Hearth↔cluster typed helper serde tests
1839
1840    #[test]
1841    fn hearth_member_query_serde_roundtrip() {
1842        let q = HearthMemberQuery {
1843            hearth_hash: ActionHash::from_raw_36(vec![1u8; 36]),
1844            agent: AgentPubKey::from_raw_36(vec![2u8; 36]),
1845        };
1846        let json = serde_json::to_string(&q).unwrap();
1847        let q2: HearthMemberQuery = serde_json::from_str(&json).unwrap();
1848        assert_eq!(q.hearth_hash, q2.hearth_hash);
1849        assert_eq!(q.agent, q2.agent);
1850    }
1851
1852    #[test]
1853    fn hearth_member_result_found() {
1854        let r = HearthMemberResult {
1855            is_member: true,
1856            role: Some("Adult".into()),
1857            display_name: Some("Alice".into()),
1858            error: None,
1859        };
1860        let json = serde_json::to_string(&r).unwrap();
1861        let r2: HearthMemberResult = serde_json::from_str(&json).unwrap();
1862        assert!(r2.is_member);
1863        assert_eq!(r2.role.as_deref(), Some("Adult"));
1864        assert_eq!(r2.display_name.as_deref(), Some("Alice"));
1865        assert!(r2.error.is_none());
1866    }
1867
1868    #[test]
1869    fn hearth_member_result_not_found() {
1870        let r = HearthMemberResult {
1871            is_member: false,
1872            role: None,
1873            display_name: None,
1874            error: None,
1875        };
1876        let json = serde_json::to_string(&r).unwrap();
1877        let r2: HearthMemberResult = serde_json::from_str(&json).unwrap();
1878        assert!(!r2.is_member);
1879        assert!(r2.role.is_none());
1880    }
1881
1882    #[test]
1883    fn hearth_care_query_serde_roundtrip() {
1884        let q = HearthCareQuery {
1885            hearth_hash: ActionHash::from_raw_36(vec![3u8; 36]),
1886            care_type: Some("Childcare".into()),
1887        };
1888        let json = serde_json::to_string(&q).unwrap();
1889        let q2: HearthCareQuery = serde_json::from_str(&json).unwrap();
1890        assert_eq!(q.hearth_hash, q2.hearth_hash);
1891        assert_eq!(q2.care_type.as_deref(), Some("Childcare"));
1892    }
1893
1894    #[test]
1895    fn hearth_care_query_no_filter() {
1896        let q = HearthCareQuery {
1897            hearth_hash: ActionHash::from_raw_36(vec![4u8; 36]),
1898            care_type: None,
1899        };
1900        let json = serde_json::to_string(&q).unwrap();
1901        let q2: HearthCareQuery = serde_json::from_str(&json).unwrap();
1902        assert!(q2.care_type.is_none());
1903    }
1904
1905    #[test]
1906    fn hearth_care_result_serde_roundtrip() {
1907        let r = HearthCareResult {
1908            available_caregivers: 3,
1909            active_schedules: 7,
1910            error: None,
1911        };
1912        let json = serde_json::to_string(&r).unwrap();
1913        let r2: HearthCareResult = serde_json::from_str(&json).unwrap();
1914        assert_eq!(r2.available_caregivers, 3);
1915        assert_eq!(r2.active_schedules, 7);
1916        assert!(r2.error.is_none());
1917    }
1918
1919    #[test]
1920    fn hearth_emergency_query_serde_roundtrip() {
1921        let q = HearthEmergencyQuery {
1922            hearth_hash: ActionHash::from_raw_36(vec![5u8; 36]),
1923        };
1924        let json = serde_json::to_string(&q).unwrap();
1925        let q2: HearthEmergencyQuery = serde_json::from_str(&json).unwrap();
1926        assert_eq!(q.hearth_hash, q2.hearth_hash);
1927    }
1928
1929    #[test]
1930    fn hearth_emergency_result_active() {
1931        let r = HearthEmergencyResult {
1932            has_active_alerts: true,
1933            active_alert_count: 2,
1934            members_checked_in: 4,
1935            members_missing: 1,
1936            error: None,
1937        };
1938        let json = serde_json::to_string(&r).unwrap();
1939        let r2: HearthEmergencyResult = serde_json::from_str(&json).unwrap();
1940        assert!(r2.has_active_alerts);
1941        assert_eq!(r2.active_alert_count, 2);
1942        assert_eq!(r2.members_checked_in, 4);
1943        assert_eq!(r2.members_missing, 1);
1944        assert!(r2.error.is_none());
1945    }
1946
1947    #[test]
1948    fn hearth_emergency_result_clear() {
1949        let r = HearthEmergencyResult {
1950            has_active_alerts: false,
1951            active_alert_count: 0,
1952            members_checked_in: 5,
1953            members_missing: 0,
1954            error: None,
1955        };
1956        let json = serde_json::to_string(&r).unwrap();
1957        let r2: HearthEmergencyResult = serde_json::from_str(&json).unwrap();
1958        assert!(!r2.has_active_alerts);
1959        assert_eq!(r2.active_alert_count, 0);
1960        assert_eq!(r2.members_missing, 0);
1961    }
1962}