Skip to main content

meerkat_mobkit/identity_first/
types.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6// ---------------------------------------------------------------------------
7// Validation helpers
8// ---------------------------------------------------------------------------
9
10/// Error returned when an identity string fails validation.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct InvalidIdentity {
13    pub input: String,
14    pub reason: String,
15}
16
17impl fmt::Display for InvalidIdentity {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        write!(f, "invalid identity {:?}: {}", self.input, self.reason)
20    }
21}
22
23impl std::error::Error for InvalidIdentity {}
24
25fn validate_identity_string(s: &str) -> Result<(), InvalidIdentity> {
26    if s.is_empty() {
27        return Err(InvalidIdentity {
28            input: s.to_string(),
29            reason: "must not be empty".to_string(),
30        });
31    }
32    if s.contains(char::is_whitespace) {
33        return Err(InvalidIdentity {
34            input: s.to_string(),
35            reason: "must not contain whitespace".to_string(),
36        });
37    }
38    if s.contains('/') {
39        return Err(InvalidIdentity {
40            input: s.to_string(),
41            reason: "must not contain slashes".to_string(),
42        });
43    }
44    Ok(())
45}
46
47// ---------------------------------------------------------------------------
48// Macro for validated string newtypes (AgentIdentity, AgentRuntimeId)
49// ---------------------------------------------------------------------------
50
51macro_rules! validated_string_newtype {
52    ($(#[$meta:meta])* $name:ident) => {
53        $(#[$meta])*
54        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
55        pub struct $name(String);
56
57        impl $name {
58            /// Parse and validate a string into this type.
59            ///
60            /// # Errors
61            ///
62            /// Returns `InvalidIdentity` if the input is empty, contains whitespace,
63            /// or contains slashes.
64            pub fn parse(s: &str) -> Result<Self, InvalidIdentity> {
65                validate_identity_string(s)?;
66                Ok(Self(s.to_string()))
67            }
68
69            #[must_use]
70            pub fn as_str(&self) -> &str {
71                &self.0
72            }
73        }
74
75        impl fmt::Display for $name {
76            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77                f.write_str(&self.0)
78            }
79        }
80
81        impl Serialize for $name {
82            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
83            where
84                S: serde::Serializer,
85            {
86                serializer.serialize_str(&self.0)
87            }
88        }
89
90        impl<'de> Deserialize<'de> for $name {
91            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
92            where
93                D: serde::Deserializer<'de>,
94            {
95                let s = String::deserialize(deserializer)?;
96                Self::parse(&s).map_err(serde::de::Error::custom)
97            }
98        }
99    };
100}
101
102validated_string_newtype!(
103    /// The primary app-facing identity handle for all MobKit control-plane operations.
104    AgentIdentity
105);
106
107validated_string_newtype!(
108    /// Internal runtime-level ID minted at first-create.
109    AgentRuntimeId
110);
111
112// ---------------------------------------------------------------------------
113// AgentAddressability
114// ---------------------------------------------------------------------------
115
116/// Whether an agent accepts `send()` (addressable) or only `dispatch()` (internal-only).
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum AgentAddressability {
120    #[default]
121    Addressable,
122    InternalOnly,
123}
124
125// ---------------------------------------------------------------------------
126// DisplayName
127// ---------------------------------------------------------------------------
128
129/// Human-facing display name. Non-empty.
130#[derive(Debug, Clone, PartialEq, Eq, Hash)]
131pub struct DisplayName(String);
132
133impl DisplayName {
134    /// # Errors
135    ///
136    /// Returns `InvalidIdentity` if the input is empty.
137    pub fn parse(s: &str) -> Result<Self, InvalidIdentity> {
138        if s.is_empty() {
139            return Err(InvalidIdentity {
140                input: s.to_string(),
141                reason: "display name must not be empty".to_string(),
142            });
143        }
144        Ok(Self(s.to_string()))
145    }
146
147    #[must_use]
148    pub fn as_str(&self) -> &str {
149        &self.0
150    }
151}
152
153impl fmt::Display for DisplayName {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.write_str(&self.0)
156    }
157}
158
159impl Serialize for DisplayName {
160    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
161    where
162        S: serde::Serializer,
163    {
164        serializer.serialize_str(&self.0)
165    }
166}
167
168impl<'de> Deserialize<'de> for DisplayName {
169    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
170    where
171        D: serde::Deserializer<'de>,
172    {
173        let s = String::deserialize(deserializer)?;
174        Self::parse(&s).map_err(serde::de::Error::custom)
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Monotonic u64 newtypes
180// ---------------------------------------------------------------------------
181
182macro_rules! monotonic_u64_newtype {
183    ($(#[$meta:meta])* $name:ident) => {
184        $(#[$meta])*
185        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
186        #[serde(transparent)]
187        pub struct $name(u64);
188
189        impl $name {
190            #[must_use]
191            pub const fn new(value: u64) -> Self {
192                Self(value)
193            }
194
195            #[must_use]
196            pub const fn get(self) -> u64 {
197                self.0
198            }
199        }
200
201        impl fmt::Display for $name {
202            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203                write!(f, "{}", self.0)
204            }
205        }
206    };
207}
208
209monotonic_u64_newtype!(
210    /// Monotonic generation counter for continuity. Starts at 0, incremented by `reset()`.
211    ContinuityGeneration
212);
213
214monotonic_u64_newtype!(
215    /// Monotonic checkpoint counter scoped to `(AgentIdentity, ContinuityGeneration)`.
216    CheckpointVersion
217);
218
219monotonic_u64_newtype!(
220    /// Monotonic ownership token issued by `LeaseProvider`.
221    FencingToken
222);
223
224// ---------------------------------------------------------------------------
225// CompletionCursor — correlated turn-completion identity
226// ---------------------------------------------------------------------------
227
228/// Comparable completion identity for one identity's stream of turns.
229///
230/// Waiting for an agent's next answer must never compare output TEXT. Two
231/// consecutive turns can legitimately produce byte-identical output (`ACK`
232/// twice is the production case that produced a phantom 962-second turn), and
233/// a text comparison then reports "no new turn" for the whole configured wait.
234/// This cursor is the comparable atom instead. It is never derived from output
235/// content, a content hash, a wall-clock timestamp, or a uuid regenerated per
236/// poll.
237///
238/// `epoch` is the identity's lease [`FencingToken`] — the runtime-incarnation
239/// atom the `LeaseProvider` already issues, and which the bundled provider
240/// resumes strictly above the continuity store's persisted high-water mark so
241/// it keeps advancing across process restarts. `turns` counts turns observed
242/// as completed within that incarnation.
243///
244/// Ordering is lexicographic (`epoch`, then `turns`), so the pair never
245/// regresses: a fresh incarnation always sorts above every cursor the previous
246/// one published. Turn counts are NOT comparable across incarnations, which is
247/// why callers classify with [`Self::progress_since`] rather than a bare `>`
248/// — an incarnation change is reported, not silently read as progress.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
250pub struct CompletionCursor {
251    /// Lease incarnation this count belongs to.
252    pub epoch: FencingToken,
253    /// Turns observed as completed within `epoch`.
254    pub turns: u64,
255}
256
257impl Default for CompletionCursor {
258    /// The pre-lease cursor: epoch 0, no completed turns. Every real lease
259    /// incarnation starts at token 1 or above, so this sorts below all of them.
260    fn default() -> Self {
261        Self::start(FencingToken::new(0))
262    }
263}
264
265impl CompletionCursor {
266    /// The zero cursor for `epoch`: no completed turn observed yet.
267    #[must_use]
268    pub const fn start(epoch: FencingToken) -> Self {
269        Self { epoch, turns: 0 }
270    }
271
272    #[must_use]
273    pub const fn new(epoch: FencingToken, turns: u64) -> Self {
274        Self { epoch, turns }
275    }
276
277    /// Advance by one completed turn within the same incarnation.
278    #[must_use]
279    pub const fn advanced(self) -> Self {
280        Self {
281            epoch: self.epoch,
282            turns: self.turns.saturating_add(1),
283        }
284    }
285
286    /// Re-anchor onto `epoch` when the identity's lease incarnation moved on.
287    ///
288    /// A stale or equal epoch leaves the cursor untouched, so a caller
289    /// presenting an older token can never rewind what has been published.
290    #[must_use]
291    pub const fn rebased(self, epoch: FencingToken) -> Self {
292        if epoch.get() > self.epoch.get() {
293            Self::start(epoch)
294        } else {
295            self
296        }
297    }
298
299    /// Classify this cursor against a `baseline` captured before a delivery.
300    #[must_use]
301    pub const fn progress_since(self, baseline: Self) -> CompletionProgress {
302        if self.epoch.get() != baseline.epoch.get() {
303            CompletionProgress::IncarnationChanged
304        } else if self.turns > baseline.turns {
305            CompletionProgress::Completed
306        } else {
307            CompletionProgress::Pending
308        }
309    }
310}
311
312impl fmt::Display for CompletionCursor {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        write!(f, "{}:{}", self.epoch, self.turns)
315    }
316}
317
318/// How an observed [`CompletionCursor`] relates to a baseline captured when a
319/// delivery was admitted.
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum CompletionProgress {
323    /// Same incarnation, nothing completed since the baseline.
324    Pending,
325    /// Same incarnation, at least one turn completed since the baseline.
326    Completed,
327    /// The identity's runtime incarnation changed (lease rotation, destructive
328    /// reset, or a process restart). Turn counts do not carry across
329    /// incarnations, so the caller must re-establish a baseline rather than
330    /// infer either completion or continued waiting.
331    IncarnationChanged,
332}
333
334/// Delivery receipt for [`dispatch_admission_tracked`], carrying what a caller
335/// needs to wait for the specific turn it just submitted.
336///
337/// [`dispatch_admission_tracked`]: super::runtime::IdentityRuntime::dispatch_admission_tracked
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct DispatchAdmission {
340    pub fencing_token: FencingToken,
341    /// Whether the dispatch is backed by a runtime store (REQ-04).
342    pub durable: bool,
343    /// Cursor read before delivery was attempted. The turn this dispatch
344    /// starts can only complete after this point, so waiting for a cursor
345    /// whose [`CompletionCursor::progress_since`] against this baseline
346    /// reports [`CompletionProgress::Completed`] cannot miss it.
347    pub completion_baseline: CompletionCursor,
348}
349
350/// Delivery receipt for [`send_admission_tracked`]. Same baseline contract as
351/// [`DispatchAdmission`].
352///
353/// [`send_admission_tracked`]: super::runtime::IdentityRuntime::send_admission_tracked
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub struct SendAdmission {
356    pub fencing_token: FencingToken,
357    pub completion_baseline: CompletionCursor,
358}
359
360// ---------------------------------------------------------------------------
361// Lightweight string newtypes (no validation beyond serde)
362// ---------------------------------------------------------------------------
363
364macro_rules! string_newtype {
365    ($(#[$meta:meta])* $name:ident) => {
366        $(#[$meta])*
367        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
368        #[serde(transparent)]
369        pub struct $name(String);
370
371        impl $name {
372            #[must_use]
373            pub fn new(s: impl Into<String>) -> Self {
374                Self(s.into())
375            }
376
377            #[must_use]
378            pub fn as_str(&self) -> &str {
379                &self.0
380            }
381        }
382
383        impl fmt::Display for $name {
384            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385                f.write_str(&self.0)
386            }
387        }
388    };
389}
390
391string_newtype!(
392    /// Correlation ID for dispatch tracing.
393    CorrelationId
394);
395
396string_newtype!(
397    /// Idempotency key for dispatch deduplication.
398    DispatchIdempotencyKey
399);
400
401// ---------------------------------------------------------------------------
402// ContinuityRecord
403// ---------------------------------------------------------------------------
404
405/// The authoritative continuity record for a durable agent identity.
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct ContinuityRecord {
408    pub identity: AgentIdentity,
409    pub agent_runtime_id: AgentRuntimeId,
410    pub session_id: meerkat_core::types::SessionId,
411    pub generation: ContinuityGeneration,
412    pub checkpoint_version: CheckpointVersion,
413}
414
415// ---------------------------------------------------------------------------
416// ContinuityFailure + ContinuityFailureKind
417// ---------------------------------------------------------------------------
418
419/// Kind of continuity failure.
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "snake_case")]
422pub enum ContinuityFailureKind {
423    SnapshotMissing,
424    SnapshotCorrupted,
425    GenerationMismatch,
426    StoreUnavailable,
427    /// The bridge refused a resume while the durable session row exists (e.g.
428    /// a transcript-continuity rejection). The identity → session binding is
429    /// intact; the identity is degraded until a reconcile retry succeeds.
430    ResumeRejected,
431    /// A terminal typed verdict stands against this identity: the heal
432    /// authority proved the durable session head unrecoverable (proof inputs
433    /// absent), or the resume precondition is provably terminal (the typed
434    /// `ArchivedNotRevivable` refusal - the OB3 heal/refusal loop shape).
435    /// Unlike `ResumeRejected` this is NOT retried by the continuity repair
436    /// supervisor — retrying is exactly the 2026-07-29 heal/re-Break loop.
437    /// The identity stays Broken until an operator intervenes.
438    CheckpointUnrecoverable,
439}
440
441/// A typed failure payload for broken continuity.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443pub struct ContinuityFailure {
444    pub identity: AgentIdentity,
445    pub kind: ContinuityFailureKind,
446    pub record: Option<ContinuityRecord>,
447    pub detail: String,
448}
449
450/// Terminal repair verdict recorded against a Broken identity.
451///
452/// Three producers mint it:
453///
454/// - The session bridge's heal authority reporting that the durable session
455///   head is provably NOT recoverable to a strict-resume-acceptable
456///   committed boundary (`CommittedBoundaryRepair::Unprovable`). The verdict
457///   is stable across calls, so the continuity repair supervisor must not
458///   retry-loop it: before this marker existed, every repair pass
459///   cosmetically re-registered the identity and the next materialization
460///   re-Broke it (measured in production on 2026-07-29 as an infinite
461///   heal/re-Break cycle).
462/// - The typed `ArchivedNotRevivable` resume refusal, recorded on the FIRST
463///   refusal at either resume door (eager restore or on-demand
464///   materialize). The refusal is a stable materialize precondition the
465///   roster heal cannot change, so without this verdict the repair
466///   supervisor "healed" the roster every cycle and the next inbound turn
467///   re-Broke it (OB3 rehearsal, 4 identities) - the N=3 identical-failure
468///   park below never engaged because the heal itself kept succeeding.
469/// - The repair supervisor's bounded-identical-retry park: three consecutive
470///   byte-identical repair failures prove a deterministic wall, and each
471///   blind retry re-executes destructive dispose steps against it (OB3
472///   0.8.12-era field evidence).
473///
474/// The park is process-local (entry state, not durable): after the operator
475/// fixes the blocking cause, a gateway restart re-attempts repair once, and
476/// `mobkit/reset` remains the deliberate fresh-start path. Any non-Broken
477/// lifecycle projection also clears it.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479pub struct ContinuityUnrecoverable {
480    /// The producing authority's reason, verbatim, for operators.
481    pub reason: String,
482}
483
484/// Typed park for a build the HOST deterministically rejected (the
485/// candidate-mode effect gate class).
486///
487/// The app-side `callback/build_agent` round trip COMPLETED and the host
488/// answered with an error, so retrying the SAME spec re-asks the same gate
489/// the same question — each attempt burning a full member build plus a
490/// callback round trip (the herd-investigation churn: Broken with continuous
491/// repair at 30s→10min forever). While parked, materialization fails fast
492/// with a typed error (no bridge call) and the continuity repair supervisor
493/// skips the identity. The park clears when the identity's roster spec
494/// CHANGES (digest mismatch) or via operator clear; it is in-memory, so a
495/// gateway restart re-attempts once and re-parks if the gate still rejects.
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct HostRejectedBuildPark {
498    /// The host's rejection, verbatim, for operators.
499    pub reason: String,
500    /// Digest of the exact [`DurableAgentSpec`] whose build was rejected.
501    pub spec_digest: u64,
502}
503
504// ---------------------------------------------------------------------------
505// ContinuityResolveState
506// ---------------------------------------------------------------------------
507
508/// The resolve result for a single identity from the continuity store.
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
510#[serde(rename_all = "snake_case", tag = "state")]
511pub enum ContinuityResolveState {
512    Uninitialized,
513    Ready { record: ContinuityRecord },
514    Broken { failure: ContinuityFailure },
515}
516
517// ---------------------------------------------------------------------------
518// LeaseGrant
519// ---------------------------------------------------------------------------
520
521/// A lease grant returned by `LeaseProvider`.
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523pub struct LeaseGrant {
524    pub identity: AgentIdentity,
525    pub fencing_token: FencingToken,
526    #[serde(
527        serialize_with = "serde_duration_ms::serialize",
528        deserialize_with = "serde_duration_ms::deserialize"
529    )]
530    pub ttl: std::time::Duration,
531}
532
533/// Custom serde for `Duration` as integer milliseconds.
534mod serde_duration_ms {
535    use serde::{Deserialize, Deserializer, Serializer};
536    use std::time::Duration;
537
538    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
539    where
540        S: Serializer,
541    {
542        let ms = duration.as_millis();
543        // u128 -> u64 is safe for any reasonable TTL
544        serializer.serialize_u64(ms as u64)
545    }
546
547    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
548    where
549        D: Deserializer<'de>,
550    {
551        let ms = u64::deserialize(deserializer)?;
552        Ok(Duration::from_millis(ms))
553    }
554}
555
556// ---------------------------------------------------------------------------
557// LeaseAcquireResult + LeaseRenewResult
558// ---------------------------------------------------------------------------
559
560/// Result of a lease acquisition attempt.
561#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case", tag = "result")]
563pub enum LeaseAcquireResult {
564    Acquired(LeaseGrant),
565    AlreadyHeld {
566        identity: AgentIdentity,
567        holder: String,
568    },
569}
570
571/// Result of a lease renewal attempt.
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "snake_case", tag = "result")]
574pub enum LeaseRenewResult {
575    Renewed(LeaseGrant),
576    Lost { identity: AgentIdentity },
577}
578
579// ---------------------------------------------------------------------------
580// DispatchOrigin + DispatchInput
581// ---------------------------------------------------------------------------
582
583/// Origin of a dispatch request.
584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
585#[serde(rename_all = "snake_case")]
586pub enum DispatchOrigin {
587    Connector,
588    Scheduler,
589    Policy,
590    Flow,
591    System,
592}
593
594/// Input for a dispatch operation.
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
596pub struct DispatchInput {
597    pub content: meerkat_core::ContentInput,
598    pub origin: DispatchOrigin,
599    pub correlation_id: Option<CorrelationId>,
600    pub idempotency_key: Option<DispatchIdempotencyKey>,
601}
602
603impl DispatchInput {
604    /// System-origin dispatch from plain text. The common case.
605    pub fn system(text: impl Into<String>) -> Self {
606        Self {
607            content: meerkat_core::ContentInput::Text(text.into()),
608            origin: DispatchOrigin::System,
609            correlation_id: None,
610            idempotency_key: None,
611        }
612    }
613
614    /// Dispatch with an explicit origin from plain text.
615    pub fn with_origin(text: impl Into<String>, origin: DispatchOrigin) -> Self {
616        Self {
617            content: meerkat_core::ContentInput::Text(text.into()),
618            origin,
619            correlation_id: None,
620            idempotency_key: None,
621        }
622    }
623
624    /// Attach a correlation ID (builder pattern).
625    pub fn with_correlation(mut self, id: impl Into<String>) -> Self {
626        self.correlation_id = Some(CorrelationId::new(id));
627        self
628    }
629
630    /// Attach an idempotency key (builder pattern).
631    pub fn with_idempotency(mut self, key: impl Into<String>) -> Self {
632        self.idempotency_key = Some(DispatchIdempotencyKey::new(key));
633        self
634    }
635}
636
637// ---------------------------------------------------------------------------
638// ManagedPeerEdge
639// ---------------------------------------------------------------------------
640
641/// Error returned when constructing an invalid `ManagedPeerEdge`.
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub enum ManagedPeerEdgeError {
644    SelfEdge,
645}
646
647impl fmt::Display for ManagedPeerEdgeError {
648    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
649        match self {
650            Self::SelfEdge => write!(f, "self-edges are not allowed"),
651        }
652    }
653}
654
655impl std::error::Error for ManagedPeerEdgeError {}
656
657/// A managed dynamic topology edge between two agent identities.
658///
659/// Canonical ordering: `a < b`. Self-edges are rejected at construction time.
660/// Deserialization enforces the same invariant as `new()` via `TryFrom`.
661#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
662#[serde(into = "ManagedPeerEdgeRaw")]
663pub struct ManagedPeerEdge {
664    a: AgentIdentity,
665    b: AgentIdentity,
666}
667
668/// Raw deserialization target for `ManagedPeerEdge`.
669#[derive(Serialize, Deserialize)]
670struct ManagedPeerEdgeRaw {
671    a: AgentIdentity,
672    b: AgentIdentity,
673}
674
675impl From<ManagedPeerEdge> for ManagedPeerEdgeRaw {
676    fn from(edge: ManagedPeerEdge) -> Self {
677        Self {
678            a: edge.a,
679            b: edge.b,
680        }
681    }
682}
683
684impl TryFrom<ManagedPeerEdgeRaw> for ManagedPeerEdge {
685    type Error = ManagedPeerEdgeError;
686
687    fn try_from(raw: ManagedPeerEdgeRaw) -> Result<Self, Self::Error> {
688        Self::new(raw.a, raw.b)
689    }
690}
691
692impl<'de> Deserialize<'de> for ManagedPeerEdge {
693    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
694    where
695        D: serde::Deserializer<'de>,
696    {
697        let raw = ManagedPeerEdgeRaw::deserialize(deserializer)?;
698        Self::try_from(raw).map_err(serde::de::Error::custom)
699    }
700}
701
702impl ManagedPeerEdge {
703    /// Construct a managed peer edge with canonical ordering enforcement.
704    ///
705    /// # Errors
706    ///
707    /// Returns `ManagedPeerEdgeError::SelfEdge` if `a == b`.
708    pub fn new(a: AgentIdentity, b: AgentIdentity) -> Result<Self, ManagedPeerEdgeError> {
709        if a == b {
710            return Err(ManagedPeerEdgeError::SelfEdge);
711        }
712        if a < b {
713            Ok(Self { a, b })
714        } else {
715            Ok(Self { a: b, b: a })
716        }
717    }
718
719    #[must_use]
720    pub fn a(&self) -> &AgentIdentity {
721        &self.a
722    }
723
724    #[must_use]
725    pub fn b(&self) -> &AgentIdentity {
726        &self.b
727    }
728}
729
730// ---------------------------------------------------------------------------
731// NotAddressable error
732// ---------------------------------------------------------------------------
733
734/// Error returned when `send()` targets an `InternalOnly` agent.
735#[derive(Debug, Clone, PartialEq, Eq)]
736pub struct NotAddressable {
737    pub identity: AgentIdentity,
738    pub addressability: AgentAddressability,
739}
740
741impl fmt::Display for NotAddressable {
742    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
743        write!(
744            f,
745            "agent {:?} is not addressable (current: {:?})",
746            self.identity, self.addressability
747        )
748    }
749}
750
751impl std::error::Error for NotAddressable {}
752
753// ---------------------------------------------------------------------------
754// DurableAgentSpec
755// ---------------------------------------------------------------------------
756
757/// The preferred roster/spawn specification for identity-first continuity.
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759pub struct DurableAgentSpec {
760    pub identity: AgentIdentity,
761    pub profile: meerkat_mob::ProfileName,
762    #[serde(default)]
763    pub addressability: AgentAddressability,
764    pub display_name: Option<DisplayName>,
765    #[serde(default)]
766    pub labels: std::collections::BTreeMap<String, String>,
767    pub context: Option<serde_json::Value>,
768    #[serde(default)]
769    pub additional_instructions: Vec<String>,
770    #[serde(default)]
771    pub initial_message: Option<meerkat_core::ContentInput>,
772    #[serde(default)]
773    pub runtime_mode_override: Option<meerkat_mob::MobRuntimeMode>,
774    #[serde(default)]
775    pub backend: Option<meerkat_mob::MobBackendKind>,
776    #[serde(default)]
777    pub binding: Option<meerkat_contracts::WireRuntimeBinding>,
778}
779
780// ---------------------------------------------------------------------------
781// IdentityStatus + supporting types
782// ---------------------------------------------------------------------------
783
784/// Highest supported fan-out for background identity hydration.
785///
786/// Restore already caps concurrent resume work at sixteen because every
787/// materialization can contend on the session/continuity stores.  Keep the
788/// public background-warm control inside that same operational envelope.
789pub const MAX_IDENTITY_BACKGROUND_WARM_CONCURRENCY: usize = 16;
790
791/// Controls how identity-first durable agents are materialized at startup.
792#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(tag = "mode", rename_all = "snake_case")]
794pub enum IdentityBootstrapMode {
795    /// Compatibility mode: startup synchronously creates or resumes every
796    /// identity in the roster.
797    #[default]
798    EagerMaterialize,
799    /// Register roster/topology/continuity metadata only. A concrete member is
800    /// created or resumed on first use or explicit materialization.
801    LazyMaterialize,
802    /// Return after metadata registration and hydrate identities in a tracked
803    /// background task with bounded concurrency.
804    LazyWithBackgroundWarm { concurrency: usize },
805}
806
807impl IdentityBootstrapMode {
808    /// Validate the operational concurrency contract.
809    pub fn validate(&self) -> Result<(), String> {
810        if let Self::LazyWithBackgroundWarm { concurrency } = self {
811            if *concurrency == 0 {
812                return Err("LazyWithBackgroundWarm concurrency must be greater than 0".to_string());
813            }
814            if *concurrency > MAX_IDENTITY_BACKGROUND_WARM_CONCURRENCY {
815                return Err(format!(
816                    "LazyWithBackgroundWarm concurrency must be at most {MAX_IDENTITY_BACKGROUND_WARM_CONCURRENCY}"
817                ));
818            }
819        }
820        Ok(())
821    }
822
823    pub fn is_lazy(&self) -> bool {
824        !matches!(self, Self::EagerMaterialize)
825    }
826}
827
828/// Transient startup-hydration state. This is deliberately separate from
829/// [`IdentityLifecycleState`]: warming is coordination progress, not a durable
830/// identity lifecycle state.
831#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
832#[serde(rename_all = "snake_case")]
833pub enum IdentityBootstrapState {
834    Dormant,
835    Warming,
836    Active,
837    Broken,
838}
839
840/// Bootstrap progress for one durable identity.
841#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
842pub struct IdentityBootstrapEntry {
843    pub state: IdentityBootstrapState,
844    #[serde(skip_serializing_if = "Option::is_none")]
845    pub error: Option<String>,
846}
847
848/// Aggregate bootstrap-state counts.
849#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
850pub struct IdentityBootstrapCounts {
851    pub dormant: usize,
852    pub warming: usize,
853    pub active: usize,
854    pub broken: usize,
855}
856
857/// Typed snapshot for bootstrap observability and readiness barriers.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
859pub struct IdentityBootstrapStatus {
860    pub mode: IdentityBootstrapMode,
861    /// True once the configured startup coordination pass has stopped running.
862    /// Lazy materialization can therefore be complete while `ready` remains
863    /// false because dormant identities intentionally remain.
864    pub complete: bool,
865    /// True exactly when every tracked roster identity is materialized.
866    pub ready: bool,
867    /// Pass-level failure, for example a roster/topology provider or restore
868    /// error that is not attributable to one durable identity.
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub error: Option<String>,
871    pub counts: IdentityBootstrapCounts,
872    pub identities: std::collections::BTreeMap<AgentIdentity, IdentityBootstrapEntry>,
873}
874
875impl IdentityBootstrapStatus {
876    pub fn empty(mode: IdentityBootstrapMode) -> Self {
877        Self {
878            mode,
879            complete: true,
880            ready: true,
881            error: None,
882            counts: IdentityBootstrapCounts::default(),
883            identities: std::collections::BTreeMap::new(),
884        }
885    }
886
887    pub(crate) fn refresh_aggregates(&mut self) {
888        let mut counts = IdentityBootstrapCounts::default();
889        for entry in self.identities.values() {
890            match entry.state {
891                IdentityBootstrapState::Dormant => counts.dormant += 1,
892                IdentityBootstrapState::Warming => counts.warming += 1,
893                IdentityBootstrapState::Active => counts.active += 1,
894                IdentityBootstrapState::Broken => counts.broken += 1,
895            }
896        }
897        self.ready = self.complete
898            && self.error.is_none()
899            && counts.dormant == 0
900            && counts.warming == 0
901            && counts.broken == 0;
902        self.counts = counts;
903    }
904
905    /// All identities have reached a terminal hydration result. Broken is
906    /// terminal but not ready, allowing barriers to return a truthful failure
907    /// instead of waiting forever.
908    pub fn materialization_terminal(&self) -> bool {
909        self.counts.dormant == 0 && self.counts.warming == 0
910    }
911}
912
913/// Lifecycle state of an identity.
914#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
915#[serde(rename_all = "snake_case")]
916pub enum IdentityLifecycleState {
917    /// Identity metadata is registered and addressable, but no concrete mob
918    /// member/session has been spawned or resumed in this runtime yet.
919    Dormant,
920    /// Identity has a concrete mob member/session in this runtime.
921    Active,
922    Retiring,
923    Suspended,
924    /// Continuity exists but cannot currently be materialized safely.
925    Broken,
926    Uninitialized,
927}
928
929impl IdentityLifecycleState {
930    /// Canonical wire vocabulary for identity lifecycle states.
931    ///
932    /// meerkat 0.7 moved the member rows (`mobkit/get_member`,
933    /// `list_members`, `ensure_member`, `find_members`) to lowercase state
934    /// strings — matching the published SDK constants
935    /// (`MEMBER_STATE_ACTIVE = "active"`) and the console vocabulary. The
936    /// identity-first status/inspect surfaces must speak the same casing so
937    /// the two member-state surfaces never disagree on the same wire.
938    pub fn wire_str(self) -> &'static str {
939        match self {
940            Self::Dormant => "dormant",
941            Self::Active => "active",
942            Self::Retiring => "retiring",
943            Self::Suspended => "suspended",
944            Self::Broken => "broken",
945            Self::Uninitialized => "uninitialized",
946        }
947    }
948}
949
950/// Information about a held lease.
951#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
952pub struct LeaseInfo {
953    pub fencing_token: FencingToken,
954    #[serde(
955        serialize_with = "serde_duration_ms::serialize",
956        deserialize_with = "serde_duration_ms::deserialize"
957    )]
958    pub ttl_remaining: std::time::Duration,
959    pub healthy: bool,
960}
961
962/// Durability policy declared by the continuity store.
963#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
964#[serde(rename_all = "snake_case", tag = "kind")]
965pub enum DurabilityPolicy {
966    SyncWriteThrough,
967    AsyncReplicated,
968    BufferedExport { max_loss_window_ms: u64 },
969}
970
971/// Health of the continuity store for an identity.
972#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
973pub struct ContinuityHealth {
974    pub store_reachable: bool,
975    pub durability_policy: DurabilityPolicy,
976    pub last_checkpoint_version: Option<CheckpointVersion>,
977}
978
979/// Full status response for an identity.
980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
981pub struct IdentityStatus {
982    pub identity: AgentIdentity,
983    pub state: IdentityLifecycleState,
984    pub agent_runtime_id: Option<AgentRuntimeId>,
985    pub session_id: Option<meerkat_core::types::SessionId>,
986    pub profile: Option<meerkat_mob::ProfileName>,
987    pub runtime_mode: Option<meerkat_mob::MobRuntimeMode>,
988    pub addressability: AgentAddressability,
989    pub display_name: Option<DisplayName>,
990    #[serde(default)]
991    pub labels: std::collections::BTreeMap<String, String>,
992    pub generation: Option<ContinuityGeneration>,
993    pub checkpoint_version: Option<CheckpointVersion>,
994    pub lease: Option<LeaseInfo>,
995    pub continuity_health: Option<ContinuityHealth>,
996    /// Terminal heal verdict for a Broken identity (2026-07-29 incident):
997    /// present when the heal authority proved the durable head unrecoverable
998    /// and the continuity repair supervisor has parked the identity. Additive
999    /// and optional on the wire; SDK parsers ignore unknown keys.
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub continuity_unrecoverable: Option<ContinuityUnrecoverable>,
1002}
1003
1004// ---------------------------------------------------------------------------
1005// AgentBuildContext + AgentBuildDraft + ExternalToolDef
1006// ---------------------------------------------------------------------------
1007
1008#[derive(Clone, Default)]
1009pub struct AgentRuntimeServices {
1010    mob_handle: Option<meerkat_mob::MobHandle>,
1011}
1012
1013impl AgentRuntimeServices {
1014    pub fn new(mob_handle: meerkat_mob::MobHandle) -> Self {
1015        Self {
1016            mob_handle: Some(mob_handle),
1017        }
1018    }
1019
1020    pub fn empty() -> Self {
1021        Self { mob_handle: None }
1022    }
1023
1024    pub fn mob_handle(&self) -> Option<meerkat_mob::MobHandle> {
1025        self.mob_handle.clone()
1026    }
1027
1028    pub fn has_mob_handle(&self) -> bool {
1029        self.mob_handle.is_some()
1030    }
1031}
1032
1033impl std::fmt::Debug for AgentRuntimeServices {
1034    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1035        f.debug_struct("AgentRuntimeServices")
1036            .field("mob_handle", &self.mob_handle.is_some())
1037            .finish()
1038    }
1039}
1040
1041impl PartialEq for AgentRuntimeServices {
1042    fn eq(&self, other: &Self) -> bool {
1043        self.mob_handle.is_some() == other.mob_handle.is_some()
1044    }
1045}
1046
1047impl Eq for AgentRuntimeServices {}
1048
1049/// Read-only context provided to `AgentCustomizer` at build time.
1050#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1051pub struct AgentBuildContext {
1052    pub identity: AgentIdentity,
1053    pub active_peers: Vec<AgentIdentity>,
1054    pub managed_edges: Vec<ManagedPeerEdge>,
1055    #[serde(default, skip)]
1056    pub runtime_services: AgentRuntimeServices,
1057}
1058
1059/// Tool definition for the customizer boundary.
1060#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1061pub struct ExternalToolDef {
1062    pub name: String,
1063    pub description: String,
1064    pub input_schema: serde_json::Value,
1065}
1066
1067/// Mutable draft that `AgentCustomizer` modifies.
1068#[derive(Clone, Default)]
1069pub struct LocalExternalToolOverlay {
1070    dispatcher: Option<Arc<dyn meerkat_core::agent::AgentToolDispatcher>>,
1071}
1072
1073impl LocalExternalToolOverlay {
1074    pub fn new(dispatcher: Arc<dyn meerkat_core::agent::AgentToolDispatcher>) -> Self {
1075        Self {
1076            dispatcher: Some(dispatcher),
1077        }
1078    }
1079
1080    pub fn empty() -> Self {
1081        Self { dispatcher: None }
1082    }
1083
1084    pub fn dispatcher(&self) -> Option<Arc<dyn meerkat_core::agent::AgentToolDispatcher>> {
1085        self.dispatcher.clone()
1086    }
1087
1088    pub fn is_some(&self) -> bool {
1089        self.dispatcher.is_some()
1090    }
1091}
1092
1093impl std::fmt::Debug for LocalExternalToolOverlay {
1094    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1095        f.debug_struct("LocalExternalToolOverlay")
1096            .field("dispatcher", &self.dispatcher.is_some())
1097            .finish()
1098    }
1099}
1100
1101impl PartialEq for LocalExternalToolOverlay {
1102    fn eq(&self, other: &Self) -> bool {
1103        self.dispatcher.is_some() == other.dispatcher.is_some()
1104    }
1105}
1106
1107impl Eq for LocalExternalToolOverlay {}
1108
1109/// Mutable draft that `AgentCustomizer` modifies.
1110///
1111/// `external_tools` remains the serializable SDK/gateway declaration surface.
1112/// `local_external_tools` is intentionally skipped by serde and is the
1113/// in-process Rust overlay for apps that can supply a real dispatcher.
1114///
1115/// `Eq` is not derived: `provider_params` carries float sampling knobs.
1116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1117pub struct AgentBuildDraft {
1118    pub model: Option<String>,
1119    pub system_prompt: Option<String>,
1120    #[serde(default)]
1121    pub additional_instructions: Vec<String>,
1122    #[serde(default)]
1123    pub labels: std::collections::BTreeMap<String, String>,
1124    pub app_context: Option<serde_json::Value>,
1125    #[serde(default)]
1126    pub external_tools: Vec<ExternalToolDef>,
1127    #[serde(default, skip)]
1128    pub local_external_tools: LocalExternalToolOverlay,
1129    /// Per-identity provider parameter overrides.
1130    ///
1131    /// This is meerkat's own `ProviderParamsOverride` — the exact type behind
1132    /// `AgentBuildConfig.provider_params` — so provider knobs that have no
1133    /// MobKit-local vocabulary (OpenAI `prompt_cache_key` /
1134    /// `prompt_cache_options` / `prompt_cache_retention`, Anthropic
1135    /// `cache_control`) are reachable from a profile, gateway or SDK caller.
1136    /// Reusing the meerkat type inherits its `deny_unknown_fields` ingress:
1137    /// an unknown or mistyped knob rejects the draft at deserialize instead
1138    /// of being ferried as untyped JSON and dropped later.
1139    ///
1140    /// Optional and `#[serde(default)]`: every profile, persisted draft and
1141    /// wire payload written before this field existed still deserializes.
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub provider_params: Option<meerkat_core::lifecycle::run_primitive::ProviderParamsOverride>,
1144}
1145
1146// ---------------------------------------------------------------------------
1147// SessionSnapshot
1148// ---------------------------------------------------------------------------
1149
1150/// Opaque wrapper around serialized Meerkat session state.
1151///
1152/// Stored and loaded by `ContinuityStore`.
1153/// Wire format (JSON-RPC): `{ "data": "<base64 string>" }`.
1154#[derive(Debug, Clone, PartialEq, Eq)]
1155pub struct SessionSnapshot {
1156    pub data: Vec<u8>,
1157}
1158
1159impl Serialize for SessionSnapshot {
1160    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1161    where
1162        S: serde::Serializer,
1163    {
1164        use base64::Engine;
1165        use serde::ser::SerializeStruct;
1166        let encoded = base64::engine::general_purpose::STANDARD.encode(&self.data);
1167        let mut s = serializer.serialize_struct("SessionSnapshot", 1)?;
1168        s.serialize_field("data", &encoded)?;
1169        s.end()
1170    }
1171}
1172
1173impl<'de> Deserialize<'de> for SessionSnapshot {
1174    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1175    where
1176        D: serde::Deserializer<'de>,
1177    {
1178        use base64::Engine;
1179
1180        #[derive(Deserialize)]
1181        struct Wrapper {
1182            data: String,
1183        }
1184
1185        let wrapper = Wrapper::deserialize(deserializer)?;
1186        let data = base64::engine::general_purpose::STANDARD
1187            .decode(&wrapper.data)
1188            .map_err(serde::de::Error::custom)?;
1189        Ok(Self { data })
1190    }
1191}
1192
1193// ---------------------------------------------------------------------------
1194// RosterContext + TopologyContext
1195// ---------------------------------------------------------------------------
1196
1197/// Context passed to `RosterProvider`.
1198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1199pub struct RosterContext {
1200    pub mob_definition: Option<meerkat_mob::MobDefinition>,
1201    pub previous_identities: Vec<AgentIdentity>,
1202}
1203
1204/// Context passed to `TopologyProvider`.
1205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1206pub struct TopologyContext {
1207    pub roster: Vec<DurableAgentSpec>,
1208}
1209
1210// ---------------------------------------------------------------------------
1211// Error types
1212// ---------------------------------------------------------------------------
1213
1214/// Error from the continuity store.
1215#[derive(Debug)]
1216pub enum ContinuityStoreError {
1217    StaleFencingToken {
1218        identity: AgentIdentity,
1219        presented: FencingToken,
1220        current: FencingToken,
1221    },
1222    StaleCheckpointVersion {
1223        identity: AgentIdentity,
1224        presented: CheckpointVersion,
1225        current: CheckpointVersion,
1226    },
1227    StaleContinuityGeneration {
1228        identity: AgentIdentity,
1229        presented: ContinuityGeneration,
1230        current: ContinuityGeneration,
1231    },
1232    NotFound {
1233        identity: AgentIdentity,
1234    },
1235    Io(String),
1236    Corruption(String),
1237    /// Lock contention or interruption at the storage layer: the operation
1238    /// did not observably complete and MAY be retried. Classification alone
1239    /// does not authorize a retry — per the shared storage retryability
1240    /// contract, automatic retry is only sound for idempotent or CAS-keyed
1241    /// operations (continuity writes are fencing-token CAS and qualify);
1242    /// an indeterminate non-idempotent write needs outcome reconciliation
1243    /// first. Retry policy stays with the caller.
1244    Transient(String),
1245}
1246
1247impl fmt::Display for ContinuityStoreError {
1248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1249        match self {
1250            Self::StaleFencingToken {
1251                identity,
1252                presented,
1253                current,
1254            } => write!(
1255                f,
1256                "stale fencing token for {identity}: presented {presented}, current {current}"
1257            ),
1258            Self::StaleCheckpointVersion {
1259                identity,
1260                presented,
1261                current,
1262            } => write!(
1263                f,
1264                "stale checkpoint version for {identity}: presented {presented}, current {current}"
1265            ),
1266            Self::StaleContinuityGeneration {
1267                identity,
1268                presented,
1269                current,
1270            } => write!(
1271                f,
1272                "stale continuity generation for {identity}: presented {presented}, current {current}"
1273            ),
1274            Self::NotFound { identity } => {
1275                write!(f, "continuity record not found for {identity}")
1276            }
1277            Self::Io(msg) => write!(f, "continuity store I/O error: {msg}"),
1278            Self::Corruption(msg) => write!(f, "continuity store corruption: {msg}"),
1279            Self::Transient(msg) => {
1280                write!(f, "continuity store transient failure: {msg}")
1281            }
1282        }
1283    }
1284}
1285
1286impl std::error::Error for ContinuityStoreError {}
1287
1288/// Error from the lease provider.
1289#[derive(Debug)]
1290pub enum LeaseError {
1291    ProviderUnavailable(String),
1292    Io(String),
1293}
1294
1295impl fmt::Display for LeaseError {
1296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1297        match self {
1298            Self::ProviderUnavailable(msg) => {
1299                write!(f, "lease provider unavailable: {msg}")
1300            }
1301            Self::Io(msg) => write!(f, "lease I/O error: {msg}"),
1302        }
1303    }
1304}
1305
1306impl std::error::Error for LeaseError {}
1307
1308/// Error from the roster provider.
1309#[derive(Debug)]
1310pub enum RosterError {
1311    ProviderUnavailable(String),
1312    Io(String),
1313}
1314
1315impl fmt::Display for RosterError {
1316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1317        match self {
1318            Self::ProviderUnavailable(msg) => {
1319                write!(f, "roster provider unavailable: {msg}")
1320            }
1321            Self::Io(msg) => write!(f, "roster I/O error: {msg}"),
1322        }
1323    }
1324}
1325
1326impl std::error::Error for RosterError {}
1327
1328/// Error from the topology provider.
1329#[derive(Debug)]
1330pub enum TopologyError {
1331    InvalidEdge(String),
1332    ProviderUnavailable(String),
1333}
1334
1335impl fmt::Display for TopologyError {
1336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1337        match self {
1338            Self::InvalidEdge(msg) => write!(f, "invalid topology edge: {msg}"),
1339            Self::ProviderUnavailable(msg) => {
1340                write!(f, "topology provider unavailable: {msg}")
1341            }
1342        }
1343    }
1344}
1345
1346impl std::error::Error for TopologyError {}
1347
1348/// Error from the agent customizer.
1349#[derive(Debug)]
1350pub enum CustomizerError {
1351    BuildFailed(String),
1352    Io(String),
1353}
1354
1355impl fmt::Display for CustomizerError {
1356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1357        match self {
1358            Self::BuildFailed(msg) => write!(f, "customizer build failed: {msg}"),
1359            Self::Io(msg) => write!(f, "customizer I/O error: {msg}"),
1360        }
1361    }
1362}
1363
1364impl std::error::Error for CustomizerError {}