Skip to main content

meerkat_contracts/wire/
mob.rs

1//! Mob RPC wire contracts.
2
3use super::connection::WireAuthBindingRef;
4use super::runtime::WireTurnMetadataOverride;
5use super::session::WireContentInput;
6use super::supervisor_bridge::BridgeBootstrapToken;
7use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
8use meerkat_core::OutputSchema;
9use meerkat_core::{
10    HandlingMode,
11    types::{RenderClass, RenderMetadata, RenderSalience},
12};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::BTreeMap;
16
17use meerkat_core::{SurfaceMetadata, SurfaceMetadataError};
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(rename_all = "snake_case")]
22pub enum WireMobBackendKind {
23    #[default]
24    Session,
25    External,
26}
27
28/// Runtime binding for spawn requests.
29///
30/// First step toward identity-first mobs. Carries backend-specific binding
31/// details at spawn time. `External` requires typed process identity; callers
32/// do not supply raw comms peer IDs.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
36pub enum WireRuntimeBinding {
37    Session,
38    External {
39        address: String,
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        bootstrap_token: Option<BridgeBootstrapToken>,
42        /// Typed Ed25519 signing identity for the external process. The
43        /// canonical comms `PeerId` is derived from this key after the wire
44        /// boundary, so callers cannot spoof an unrelated raw peer id.
45        identity: WireTrustedPeerIdentity,
46    },
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
50#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
51#[serde(rename_all = "snake_case")]
52pub enum WireMobRuntimeMode {
53    #[default]
54    AutonomousHost,
55    TurnDriven,
56}
57
58/// How a mob member should be launched by `mob/spawn`.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(tag = "mode", rename_all = "snake_case")]
62pub enum WireMemberLaunchMode {
63    Fresh,
64    Resume {
65        bridge_session_id: String,
66    },
67    Fork {
68        source_member_id: String,
69        #[serde(default)]
70        fork_context: WireForkContext,
71    },
72}
73
74/// Conversation history scope used when forking a mob member.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77#[serde(tag = "type", rename_all = "snake_case")]
78pub enum WireForkContext {
79    #[default]
80    FullHistory,
81    LastMessages {
82        count: u32,
83    },
84}
85
86/// Public tool access policy for a spawned member or delegated session fork.
87#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89#[serde(tag = "type", content = "value", rename_all = "snake_case")]
90pub enum WireToolAccessPolicy {
91    #[default]
92    Inherit,
93    AllowList(Vec<String>),
94    DenyList(Vec<String>),
95}
96
97impl WireToolAccessPolicy {
98    /// Lower the closed public wire vocabulary into the core session policy.
99    ///
100    /// Keeping this conversion at the contract boundary lets schemas and
101    /// generated SDKs retain the discriminated union instead of widening the
102    /// fork request field to an untyped JSON object.
103    #[must_use]
104    pub fn into_core(self) -> meerkat_core::ops::ToolAccessPolicy {
105        match self {
106            Self::Inherit => meerkat_core::ops::ToolAccessPolicy::Inherit,
107            Self::AllowList(names) => {
108                meerkat_core::ops::ToolAccessPolicy::AllowList(names.into_iter().collect())
109            }
110            Self::DenyList(names) => {
111                meerkat_core::ops::ToolAccessPolicy::DenyList(names.into_iter().collect())
112            }
113        }
114    }
115}
116
117/// Pre-resolved tool filter inherited by a spawned mob member.
118#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub enum WireToolFilter {
121    #[default]
122    All,
123    Allow(Vec<String>),
124    Deny(Vec<String>),
125}
126
127/// Tool configuration embedded in a wire mob profile override.
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(deny_unknown_fields)]
131pub struct WireMobToolConfig {
132    #[serde(default)]
133    pub builtins: bool,
134    #[serde(default)]
135    pub shell: bool,
136    #[serde(default)]
137    pub comms: bool,
138    #[serde(default)]
139    pub memory: bool,
140    #[serde(default)]
141    pub workgraph: bool,
142    #[serde(default)]
143    pub mob: bool,
144    #[serde(default)]
145    pub schedule: bool,
146    #[serde(default)]
147    pub image_generation: bool,
148    #[serde(default)]
149    pub mcp: Vec<String>,
150}
151
152/// Profile fields that win over durable session metadata on resume.
153///
154/// Wire twin of `meerkat_mob::ResumeOverrideField`; closed snake_case
155/// vocabulary, parsed fail-closed at the wire boundary.
156#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158#[serde(rename_all = "snake_case")]
159pub enum WireMobResumeOverrideField {
160    Model,
161    Provider,
162    ProviderParams,
163}
164
165/// Profile override for `mob/spawn`.
166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168#[serde(deny_unknown_fields)]
169pub struct WireMobProfile {
170    pub model: String,
171    /// Explicit typed provider for the profile model (closed vocabulary,
172    /// fail-closed at the wire boundary).
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub provider: Option<meerkat_core::Provider>,
175    /// Durable self-hosted server binding for configured self-hosted aliases.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub self_hosted_server_id: Option<String>,
178    /// Configured default provider for `Auto` image-generation targets.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub image_generation_provider: Option<meerkat_core::Provider>,
181    /// Per-profile auto-compaction threshold override (tokens, non-zero).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub auto_compact_threshold: Option<std::num::NonZeroU64>,
184    /// Profile fields that win over durable session metadata on resume.
185    #[serde(default, skip_serializing_if = "Vec::is_empty")]
186    pub resume_overrides: Vec<WireMobResumeOverrideField>,
187    #[serde(default)]
188    pub skills: Vec<String>,
189    #[serde(default)]
190    pub tools: WireMobToolConfig,
191    #[serde(default)]
192    pub peer_description: String,
193    #[serde(default)]
194    pub external_addressable: bool,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub backend: Option<WireMobBackendKind>,
197    #[serde(default)]
198    pub runtime_mode: WireMobRuntimeMode,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub max_inline_peer_notifications: Option<i32>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub output_schema: Option<Value>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub provider_params: Option<crate::wire::runtime::WireProviderParamsOverride>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
209#[serde(deny_unknown_fields)]
210pub struct MobOrchestratorInput {
211    pub profile: String,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
216#[serde(tag = "source", rename_all = "snake_case")]
217pub enum MobSkillSourceInput {
218    Inline { content: String },
219    Path { path: String },
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224#[serde(deny_unknown_fields)]
225pub struct MobRoleWiringRuleInput {
226    pub a: String,
227    pub b: String,
228}
229
230#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232#[serde(deny_unknown_fields)]
233pub struct MobWiringRulesInput {
234    #[serde(default)]
235    pub auto_wire_orchestrator: bool,
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub role_wiring: Vec<MobRoleWiringRuleInput>,
238}
239
240#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242#[serde(deny_unknown_fields)]
243pub struct MobToolConfigInput {
244    #[serde(default)]
245    pub builtins: bool,
246    #[serde(default)]
247    pub shell: bool,
248    #[serde(default)]
249    pub comms: bool,
250    #[serde(default)]
251    pub memory: bool,
252    #[serde(default)]
253    pub workgraph: bool,
254    #[serde(default)]
255    pub mob: bool,
256    #[serde(default)]
257    pub schedule: bool,
258    #[serde(default)]
259    pub image_generation: bool,
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub mcp: Vec<String>,
262}
263
264/// Profile binding input: either an inline profile or a realm profile reference.
265///
266/// Not `Eq`: `Inline(MobProfileInput)` transitively carries float provider
267/// params (`temperature`, `top_p`) so `Eq` cannot be derived without
268/// losing fidelity.
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270#[allow(clippy::large_enum_variant)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[serde(untagged)]
273pub enum MobProfileBindingInput {
274    /// Reference to a realm-scoped profile.
275    RealmRef {
276        /// Name of the realm profile.
277        realm_profile: String,
278    },
279    /// Inline profile definition.
280    Inline(MobProfileInput),
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285#[serde(deny_unknown_fields)]
286pub struct MobProfileInput {
287    pub model: String,
288    /// Explicit typed provider for the profile model (closed vocabulary,
289    /// fail-closed at the wire boundary).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub provider: Option<meerkat_core::Provider>,
292    /// Durable self-hosted server binding for configured self-hosted aliases.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub self_hosted_server_id: Option<String>,
295    /// Configured default provider for `Auto` image-generation targets.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub image_generation_provider: Option<meerkat_core::Provider>,
298    /// Per-profile auto-compaction threshold override (tokens, non-zero).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub auto_compact_threshold: Option<std::num::NonZeroU64>,
301    /// Profile fields that win over durable session metadata on resume.
302    #[serde(default, skip_serializing_if = "Vec::is_empty")]
303    pub resume_overrides: Vec<WireMobResumeOverrideField>,
304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
305    pub skills: Vec<String>,
306    #[serde(default)]
307    pub tools: MobToolConfigInput,
308    #[serde(default, skip_serializing_if = "String::is_empty")]
309    pub peer_description: String,
310    #[serde(default)]
311    pub external_addressable: bool,
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub backend: Option<WireMobBackendKind>,
314    #[serde(default)]
315    pub runtime_mode: WireMobRuntimeMode,
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub max_inline_peer_notifications: Option<i32>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub output_schema: Option<OutputSchema>,
320    /// Non-`Eq` field: `WireProviderParamsOverride` contains float scalars
321    /// (`temperature`, `top_p`) so the struct can't derive `Eq` without
322    /// losing fidelity.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub provider_params: Option<crate::wire::runtime::WireProviderParamsOverride>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
329#[serde(deny_unknown_fields)]
330pub struct MobExternalBackendConfigInput {
331    pub address_base: String,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub supervisor_bridge: Option<MobSupervisorBridgeEndpointConfigInput>,
334}
335
336#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
338#[serde(deny_unknown_fields)]
339pub struct MobSupervisorBridgeEndpointConfigInput {
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub bind_address: Option<String>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub advertised_address: Option<String>,
344}
345
346#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
348#[serde(deny_unknown_fields)]
349pub struct MobBackendConfigInput {
350    #[serde(default)]
351    pub default: WireMobBackendKind,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub external: Option<MobExternalBackendConfigInput>,
354}
355
356#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
357#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
358#[serde(rename_all = "snake_case")]
359pub enum MobDispatchModeInput {
360    #[default]
361    FanOut,
362    OneToOne,
363    FanIn,
364}
365
366#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
368#[serde(tag = "type", rename_all = "snake_case")]
369pub enum MobCollectionPolicyInput {
370    #[default]
371    All,
372    Any,
373    Quorum {
374        n: u8,
375    },
376}
377
378#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
380#[serde(rename_all = "snake_case")]
381pub enum MobDependencyModeInput {
382    #[default]
383    All,
384    Any,
385}
386
387/// Explicit step output format. Omitting `output_format` on a step is
388/// meaningful — the definition layer resolves a schema-aware default (`json`
389/// when the step declares `expected_schema_ref`, `text` otherwise) — so the
390/// wire shape keeps "omitted" representable instead of baking in a default.
391#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
392#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
393#[serde(rename_all = "snake_case")]
394pub enum MobStepOutputFormatInput {
395    Json,
396    Text,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
400#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
401#[serde(tag = "op", rename_all = "snake_case")]
402pub enum MobConditionExprInput {
403    Eq { path: String, value: Value },
404    In { path: String, values: Vec<Value> },
405    Gt { path: String, value: Value },
406    Lt { path: String, value: Value },
407    And { exprs: Vec<MobConditionExprInput> },
408    Or { exprs: Vec<MobConditionExprInput> },
409    Not { expr: Box<MobConditionExprInput> },
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
413#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
414#[serde(deny_unknown_fields)]
415pub struct MobFrameSpecInput {
416    pub nodes: BTreeMap<String, MobFlowNodeInput>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
420#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
421#[serde(tag = "kind", rename_all = "snake_case")]
422pub enum MobFlowNodeInput {
423    Step(MobFrameStepInput),
424    RepeatUntil(MobRepeatUntilInput),
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
428#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
429#[serde(deny_unknown_fields)]
430pub struct MobFrameStepInput {
431    pub step_id: String,
432    #[serde(default, skip_serializing_if = "Vec::is_empty")]
433    pub depends_on: Vec<String>,
434    #[serde(default)]
435    pub depends_on_mode: MobDependencyModeInput,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub branch: Option<String>,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442#[serde(deny_unknown_fields)]
443pub struct MobRepeatUntilInput {
444    pub loop_id: String,
445    #[serde(default, skip_serializing_if = "Vec::is_empty")]
446    pub depends_on: Vec<String>,
447    #[serde(default)]
448    pub depends_on_mode: MobDependencyModeInput,
449    pub body: MobFrameSpecInput,
450    pub until: MobConditionExprInput,
451    pub max_iterations: u32,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
455#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
456#[serde(deny_unknown_fields)]
457pub struct MobFlowStepInput {
458    pub role: String,
459    pub message: WireContentInput,
460    #[serde(default, skip_serializing_if = "Vec::is_empty")]
461    pub depends_on: Vec<String>,
462    #[serde(default)]
463    pub dispatch_mode: MobDispatchModeInput,
464    #[serde(default)]
465    pub collection_policy: MobCollectionPolicyInput,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub condition: Option<MobConditionExprInput>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub timeout_ms: Option<u64>,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub expected_schema_ref: Option<String>,
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub branch: Option<String>,
474    #[serde(default)]
475    pub depends_on_mode: MobDependencyModeInput,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub allowed_tools: Option<Vec<String>>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub blocked_tools: Option<Vec<String>>,
480    /// Explicit output format; omitted resolves schema-aware at the
481    /// definition layer (`json` with `expected_schema_ref`, `text` without).
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub output_format: Option<MobStepOutputFormatInput>,
484}
485
486#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
488#[serde(deny_unknown_fields)]
489pub struct MobFlowSpecInput {
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub description: Option<String>,
492    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
493    pub steps: BTreeMap<String, MobFlowStepInput>,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub root: Option<MobFrameSpecInput>,
496}
497
498#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
499#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
500#[serde(rename_all = "snake_case")]
501pub enum MobPolicyModeInput {
502    #[default]
503    Advisory,
504    Strict,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
508#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
509#[serde(deny_unknown_fields)]
510pub struct MobTopologyRuleInput {
511    pub from_role: String,
512    pub to_role: String,
513    pub allowed: bool,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
518#[serde(deny_unknown_fields)]
519pub struct MobTopologySpecInput {
520    pub mode: MobPolicyModeInput,
521    pub rules: Vec<MobTopologyRuleInput>,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
525#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
526#[serde(deny_unknown_fields)]
527pub struct MobSupervisorSpecInput {
528    pub role: String,
529    pub escalation_threshold: u32,
530    /// Declared escalation turn timeout in milliseconds. Absent means the
531    /// runtime default applies (mirrors the domain `SupervisorSpec` owner).
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub escalation_turn_timeout_ms: Option<u64>,
534}
535
536#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
537#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
538#[serde(deny_unknown_fields)]
539pub struct MobLimitsSpecInput {
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub max_flow_duration_ms: Option<u64>,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub max_step_retries: Option<u32>,
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub max_orphaned_turns: Option<u32>,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub cancel_grace_timeout_ms: Option<u64>,
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub max_active_nodes: Option<u64>,
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub max_active_frames: Option<u64>,
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub max_frame_depth: Option<u64>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
557#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
558#[serde(tag = "mode", rename_all = "snake_case")]
559pub enum MobSpawnPolicyInput {
560    None,
561    Auto {
562        profile_map: BTreeMap<String, String>,
563    },
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
567#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
568#[serde(deny_unknown_fields)]
569pub struct MobEventRouterConfigInput {
570    #[serde(default = "default_event_router_buffer_size")]
571    pub buffer_size: usize,
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub include_patterns: Option<Vec<String>>,
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub exclude_patterns: Option<Vec<String>>,
576}
577
578const fn default_event_router_buffer_size() -> usize {
579    256
580}
581
582/// Public mob definition input for `mob/create`.
583///
584/// This mirrors the public creation contract shape. Runtime-owned lifecycle and
585/// bookkeeping fields such as internal owner/runtime bindings,
586/// `session_cleanup_policy`, `is_implicit`, and internal-only profile tool
587/// bundles are intentionally not part of this schema.
588///
589/// Not `Eq`: `profiles` transitively carries float provider params.
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
592#[serde(deny_unknown_fields)]
593pub struct MobDefinitionInput {
594    pub id: String,
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub orchestrator: Option<MobOrchestratorInput>,
597    pub profiles: BTreeMap<String, MobProfileBindingInput>,
598    /// Mob-scoped custom model registry entries (`[models.<id>]`). Reuses the
599    /// typed config owner so one definition feeds provider inference,
600    /// compaction scaling, capability gates, and call timeouts.
601    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
602    pub models: BTreeMap<String, meerkat_core::config::CustomModelConfig>,
603    /// Mob-level default provider for `Auto` image-generation targets.
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub image_generation_provider: Option<meerkat_core::Provider>,
606    #[serde(default)]
607    pub wiring: MobWiringRulesInput,
608    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
609    pub skills: BTreeMap<String, MobSkillSourceInput>,
610    #[serde(default)]
611    pub backend: MobBackendConfigInput,
612    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
613    pub flows: BTreeMap<String, MobFlowSpecInput>,
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub topology: Option<MobTopologySpecInput>,
616    #[serde(default, skip_serializing_if = "Option::is_none")]
617    pub supervisor: Option<MobSupervisorSpecInput>,
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub limits: Option<MobLimitsSpecInput>,
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub spawn_policy: Option<MobSpawnPolicyInput>,
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub event_router: Option<MobEventRouterConfigInput>,
624}
625
626/// Request payload for `mob/create`.
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
629#[serde(deny_unknown_fields)]
630pub struct MobCreateParams {
631    pub definition: MobDefinitionInput,
632}
633
634/// Response payload for `mob/create`.
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
637pub struct MobCreateResult {
638    pub mob_id: String,
639}
640
641/// Shared request payload for mob methods that address a mob by id.
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
643#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
644#[serde(deny_unknown_fields)]
645pub struct MobIdParams {
646    pub mob_id: String,
647}
648
649/// Shared request payload for mob methods that address one member by identity.
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
652#[serde(deny_unknown_fields)]
653pub struct MobMemberParams {
654    pub mob_id: String,
655    pub agent_identity: String,
656}
657
658/// Lifecycle status of a mob on the wire. Mirrors
659/// `meerkat_mob::runtime::MobState` so surfaces report mob lifecycle through a
660/// closed type rather than re-deriving meaning from free-form status text.
661///
662/// Variants serialize to their PascalCase names (`"Creating"`, `"Running"`,
663/// ...) to match the canonical `MobState::as_str()` projection that producers
664/// emit on the wire.
665#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
666#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
667pub enum WireMobLifecycleStatus {
668    Creating,
669    Running,
670    Stopped,
671    Completed,
672    Destroyed,
673}
674
675/// One active mob row returned by `mob/list`.
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
677#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
678pub struct MobStatusResult {
679    pub mob_id: String,
680    pub status: WireMobLifecycleStatus,
681}
682
683/// Response payload for `mob/list`.
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
685#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
686pub struct MobListResult {
687    pub mobs: Vec<MobStatusResult>,
688}
689
690/// Request payload for `mob/spawn`.
691#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
693#[serde(deny_unknown_fields)]
694pub struct MobSpawnParams {
695    pub mob_id: String,
696    pub profile: String,
697    pub agent_identity: String,
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub initial_message: Option<WireContentInput>,
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub runtime_mode: Option<WireMobRuntimeMode>,
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub backend: Option<WireMobBackendKind>,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub labels: Option<BTreeMap<String, String>>,
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub context: Option<Value>,
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub additional_instructions: Option<Vec<String>>,
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub binding: Option<WireRuntimeBinding>,
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub shell_env: Option<BTreeMap<String, String>>,
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub auto_wire_parent: Option<bool>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub launch_mode: Option<WireMemberLaunchMode>,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub tool_access_policy: Option<WireToolAccessPolicy>,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub inherited_tool_filter: Option<WireToolFilter>,
722    #[serde(default, skip_serializing_if = "Option::is_none")]
723    pub override_profile: Option<WireMobProfile>,
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub model_override: Option<String>,
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub auth_binding: Option<WireAuthBindingRef>,
728    /// Requested placement host ref (comms `PeerId` string — the
729    /// `MemberOperatorSpawnSpec.placement` representation); `None` places
730    /// on the controlling host (§7.3 default). Admission is machine-owned
731    /// (`ResolveSpawnMemberAdmission` host-bound/capability arms), never a
732    /// surface-side check.
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub placement: Option<String>,
735}
736
737/// Response payload for `mob/spawn`.
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
739#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
740pub struct MobSpawnResult {
741    pub mob_id: String,
742    pub agent_identity: String,
743    pub member_ref: WireMemberRef,
744}
745
746/// Per-member request payload inside `mob/spawn_many`.
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
748#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
749#[serde(deny_unknown_fields)]
750pub struct MobSpawnSpecParams {
751    pub profile: String,
752    pub agent_identity: String,
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub initial_message: Option<WireContentInput>,
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub runtime_mode: Option<WireMobRuntimeMode>,
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub backend: Option<WireMobBackendKind>,
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub labels: Option<BTreeMap<String, String>>,
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub context: Option<Value>,
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub additional_instructions: Option<Vec<String>>,
765    /// Bound host peer ID for placed execution; omit for the controlling host.
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub placement: Option<WireHostRef>,
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub model_override: Option<String>,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub auth_binding: Option<WireAuthBindingRef>,
772}
773
774/// Request payload for `mob/spawn_many`.
775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
776#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
777#[serde(deny_unknown_fields)]
778pub struct MobSpawnManyParams {
779    pub mob_id: String,
780    pub specs: Vec<MobSpawnSpecParams>,
781}
782
783/// Typed status for one `mob/spawn_many` row.
784#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
785#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
786#[serde(rename_all = "snake_case")]
787pub enum MobSpawnManyResultStatus {
788    Spawned,
789    Failed,
790}
791
792/// Successful per-member `mob/spawn_many` result payload.
793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
794#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
795#[serde(deny_unknown_fields)]
796pub struct MobSpawnManySpawnedResult {
797    pub agent_identity: String,
798    pub member_ref: WireMemberRef,
799}
800
801/// Typed failure cause for one failed `mob/spawn_many` member row.
802#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
803#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
804#[serde(rename_all = "snake_case")]
805pub enum MobSpawnManyFailureCause {
806    ProfileNotFound,
807    MemberNotFound,
808    MemberAlreadyExists,
809    NotExternallyAddressable,
810    InvalidTransition,
811    WiringError,
812    BridgeCommandRejected,
813    MemberRestoreFailed,
814    KickoffWaitTimedOut,
815    ReadyWaitTimedOut,
816    DefinitionError,
817    FlowNotFound,
818    FlowFailed,
819    RunNotFound,
820    RunCanceled,
821    FlowTurnTimedOut,
822    FrameDepthLimitExceeded,
823    FrameAtomicPersistenceUnavailable,
824    SpecRevisionConflict,
825    SchemaValidation,
826    InsufficientTargets,
827    TopologyViolation,
828    BridgeDeliveryRejected,
829    SupervisorEscalation,
830    UnsupportedForMode,
831    MissingMemberCapability,
832    ResetBarrier,
833    StorageError,
834    SessionError,
835    CommsError,
836    CallbackPending,
837    StaleFenceToken,
838    StaleEventCursor,
839    WorkNotFound,
840    Internal,
841}
842
843/// Failed per-member `mob/spawn_many` result payload.
844#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
845#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
846#[serde(deny_unknown_fields)]
847pub struct MobSpawnManyFailedResult {
848    pub cause: MobSpawnManyFailureCause,
849    pub message: String,
850}
851
852/// Typed payload for one `mob/spawn_many` row.
853#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
854#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
855#[serde(untagged)]
856pub enum MobSpawnManyResultPayload {
857    Spawned(MobSpawnManySpawnedResult),
858    Failed(MobSpawnManyFailedResult),
859}
860
861/// One typed result entry in a `mob/spawn_many` response.
862#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
863#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
864#[serde(try_from = "MobSpawnManyResultEntryRaw")]
865pub struct MobSpawnManyResultEntry {
866    pub status: MobSpawnManyResultStatus,
867    pub result: MobSpawnManyResultPayload,
868}
869
870#[derive(Debug, Deserialize)]
871#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
872#[serde(deny_unknown_fields)]
873struct MobSpawnManyResultEntryRaw {
874    status: MobSpawnManyResultStatus,
875    result: MobSpawnManyResultPayload,
876}
877
878impl TryFrom<MobSpawnManyResultEntryRaw> for MobSpawnManyResultEntry {
879    type Error = String;
880
881    fn try_from(raw: MobSpawnManyResultEntryRaw) -> Result<Self, Self::Error> {
882        let entry = Self {
883            status: raw.status,
884            result: raw.result,
885        };
886        entry.validate().map_err(str::to_owned)?;
887        Ok(entry)
888    }
889}
890
891impl MobSpawnManyResultEntry {
892    pub fn spawned(agent_identity: impl Into<String>, member_ref: WireMemberRef) -> Self {
893        Self {
894            status: MobSpawnManyResultStatus::Spawned,
895            result: MobSpawnManyResultPayload::Spawned(MobSpawnManySpawnedResult {
896                agent_identity: agent_identity.into(),
897                member_ref,
898            }),
899        }
900    }
901
902    pub fn failed(cause: MobSpawnManyFailureCause, message: impl Into<String>) -> Self {
903        Self {
904            status: MobSpawnManyResultStatus::Failed,
905            result: MobSpawnManyResultPayload::Failed(MobSpawnManyFailedResult {
906                cause,
907                message: message.into(),
908            }),
909        }
910    }
911
912    pub fn validate(&self) -> Result<(), &'static str> {
913        match (&self.status, &self.result) {
914            (MobSpawnManyResultStatus::Spawned, MobSpawnManyResultPayload::Spawned(_))
915            | (MobSpawnManyResultStatus::Failed, MobSpawnManyResultPayload::Failed(_)) => Ok(()),
916            (MobSpawnManyResultStatus::Spawned, MobSpawnManyResultPayload::Failed(_)) => {
917                Err("mob spawn_many result status spawned requires spawned result")
918            }
919            (MobSpawnManyResultStatus::Failed, MobSpawnManyResultPayload::Spawned(_)) => {
920                Err("mob spawn_many result status failed requires failed result")
921            }
922        }
923    }
924}
925
926/// Response payload for `mob/spawn_many`.
927#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
928#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
929pub struct MobSpawnManyResult {
930    pub results: Vec<MobSpawnManyResultEntry>,
931}
932
933/// Response payload for `mob/retire`.
934#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
935#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
936pub struct MobRetireResult {
937    pub retired: bool,
938}
939
940/// Request payload for `mob/respawn`.
941#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
942#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
943#[serde(deny_unknown_fields)]
944pub struct MobRespawnParams {
945    pub mob_id: String,
946    pub agent_identity: String,
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub initial_message: Option<WireContentInput>,
949}
950
951/// Identity-native respawn receipt returned inside `MobRespawnResult`.
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
953#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
954pub struct MobRespawnReceipt {
955    pub identity: String,
956    pub member_ref: WireMemberRef,
957}
958
959/// Outcome of a `mob/respawn` call. Mirrors the success vs
960/// `MobRespawnError::TopologyRestoreFailed` distinction as a closed type so SDK
961/// consumers branch on a typed variant instead of re-deriving meaning from a
962/// free-form status string.
963#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
964#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
965#[serde(rename_all = "snake_case")]
966pub enum WireMobRespawnOutcome {
967    Completed,
968    TopologyRestoreFailed,
969}
970
971/// Response payload for `mob/respawn`.
972#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
973#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
974pub struct MobRespawnResult {
975    pub status: WireMobRespawnOutcome,
976    pub receipt: MobRespawnReceipt,
977    #[serde(default, skip_serializing_if = "Vec::is_empty")]
978    pub failed_peer_ids: Vec<String>,
979}
980
981/// Response payload for `mob/members`.
982#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
983#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
984pub struct MobMembersResult {
985    pub mob_id: String,
986    pub members: Vec<MobMemberListEntryWire>,
987}
988
989/// Request payload for `mob/events`.
990#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
991#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
992#[serde(deny_unknown_fields)]
993pub struct MobEventsParams {
994    pub mob_id: String,
995    #[serde(default)]
996    pub after_cursor: u64,
997    #[serde(default = "default_mob_events_limit")]
998    pub limit: usize,
999    #[serde(default)]
1000    pub strict: bool,
1001}
1002
1003const fn default_mob_events_limit() -> usize {
1004    100
1005}
1006
1007/// Response payload for `mob/events`.
1008#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1009#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1010pub struct MobEventsResult {
1011    pub events: Vec<Value>,
1012}
1013
1014/// Typed external peer identity for public mob wiring surfaces.
1015#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1016#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1017#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1018pub enum WireTrustedPeerIdentity {
1019    /// Recoverable Ed25519 public key string in `ed25519:<base64>` form.
1020    Ed25519PublicKey { public_key: String },
1021}
1022
1023/// Resolved external peer identity atoms used after the wire boundary.
1024#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1025pub struct ResolvedWireTrustedPeerIdentity {
1026    pub peer_id: meerkat_core::comms::PeerId,
1027    pub pubkey: [u8; 32],
1028}
1029
1030/// Failure modes for resolving a typed external peer identity.
1031#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
1032pub enum WireTrustedPeerIdentityError {
1033    #[error("external peer identity public_key must start with 'ed25519:'")]
1034    MissingEd25519Prefix,
1035    #[error("external peer identity public_key is not valid base64: {0}")]
1036    InvalidBase64(String),
1037    #[error("external peer identity public_key must decode to 32 bytes, got {actual}")]
1038    InvalidLength { actual: usize },
1039    #[error("external peer identity public_key must be non-zero")]
1040    ZeroPublicKey,
1041}
1042
1043impl WireTrustedPeerIdentity {
1044    pub fn resolve(&self) -> Result<ResolvedWireTrustedPeerIdentity, WireTrustedPeerIdentityError> {
1045        match self {
1046            Self::Ed25519PublicKey { public_key } => {
1047                let pubkey = parse_ed25519_public_key(public_key)?;
1048                if pubkey == [0u8; 32] {
1049                    return Err(WireTrustedPeerIdentityError::ZeroPublicKey);
1050                }
1051                Ok(ResolvedWireTrustedPeerIdentity {
1052                    peer_id: meerkat_core::comms::PeerId::from_ed25519_pubkey(&pubkey),
1053                    pubkey,
1054                })
1055            }
1056        }
1057    }
1058}
1059
1060fn parse_ed25519_public_key(raw: &str) -> Result<[u8; 32], WireTrustedPeerIdentityError> {
1061    const PREFIX: &str = "ed25519:";
1062    let encoded = raw
1063        .strip_prefix(PREFIX)
1064        .ok_or(WireTrustedPeerIdentityError::MissingEd25519Prefix)?;
1065    let bytes = BASE64
1066        .decode(encoded)
1067        .map_err(|err| WireTrustedPeerIdentityError::InvalidBase64(err.to_string()))?;
1068    let actual = bytes.len();
1069    let pubkey: [u8; 32] = bytes
1070        .try_into()
1071        .map_err(|_| WireTrustedPeerIdentityError::InvalidLength { actual })?;
1072    Ok(pubkey)
1073}
1074
1075/// Minimal trusted peer spec for public mob wiring surfaces.
1076///
1077/// `identity` is required and resolves to the Ed25519 signing public key
1078/// plus the canonical comms `PeerId` derived from that key. MCP callers do
1079/// not provide raw peer IDs, and missing key material fails at the boundary.
1080#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1081#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1082#[serde(deny_unknown_fields)]
1083pub struct WireTrustedPeerSpec {
1084    pub name: String,
1085    pub address: String,
1086    pub identity: WireTrustedPeerIdentity,
1087}
1088
1089/// Target for a mob wire/unwire call.
1090#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1091#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1092#[serde(rename_all = "snake_case")]
1093pub enum MobPeerTarget {
1094    Local(String),
1095    External(WireTrustedPeerSpec),
1096}
1097
1098/// Request payload for `mob/wire`.
1099#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1101#[serde(deny_unknown_fields)]
1102pub struct MobWireParams {
1103    pub mob_id: String,
1104    pub member: String,
1105    pub peer: MobPeerTarget,
1106}
1107
1108/// Response payload for `mob/wire`.
1109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1111pub struct MobWireResult {
1112    pub wired: bool,
1113}
1114
1115/// One local-member edge in `mob/wire_members_batch`.
1116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1118#[serde(deny_unknown_fields)]
1119pub struct MobWireMembersBatchEdge {
1120    pub a: String,
1121    pub b: String,
1122}
1123
1124/// Request payload for `mob/wire_members_batch`.
1125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1127#[serde(deny_unknown_fields)]
1128pub struct MobWireMembersBatchParams {
1129    pub mob_id: String,
1130    pub edges: Vec<MobWireMembersBatchEdge>,
1131}
1132
1133/// Response payload for `mob/wire_members_batch`.
1134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1136pub struct MobWireMembersBatchResult {
1137    pub requested: usize,
1138    pub wired: Vec<MobWireMembersBatchEdge>,
1139    pub already_wired: Vec<MobWireMembersBatchEdge>,
1140}
1141
1142/// Request payload for `mob/unwire`.
1143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1145#[serde(deny_unknown_fields)]
1146pub struct MobUnwireParams {
1147    pub mob_id: String,
1148    pub member: String,
1149    pub peer: MobPeerTarget,
1150}
1151
1152/// Response payload for `mob/unwire`.
1153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1155pub struct MobUnwireResult {
1156    pub unwired: bool,
1157}
1158
1159/// Request payload for host-side mob member delivery.
1160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1162#[serde(deny_unknown_fields)]
1163pub struct MobMemberSendParams {
1164    pub mob_id: String,
1165    pub agent_identity: String,
1166    pub content: WireContentInput,
1167    #[serde(default)]
1168    pub handling_mode: WireHandlingMode,
1169    #[serde(default, skip_serializing_if = "Option::is_none")]
1170    pub render_metadata: Option<WireRenderMetadata>,
1171}
1172
1173/// Response payload for host-side mob member delivery.
1174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1175#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1176pub struct WireAgentRuntimeId {
1177    pub identity: String,
1178    pub generation: u64,
1179}
1180
1181/// Response payload for host-side mob member delivery.
1182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1184pub struct MobMemberSendResult {
1185    pub mob_id: String,
1186    /// Identity-native member identity (0.6).
1187    pub agent_identity: String,
1188    /// Server-resolved opaque handle for subsequent member-targeted calls.
1189    /// App code routes through `member_ref`; the binding-era
1190    /// `{identity, generation}` pair carried by `WireAgentRuntimeId` is
1191    /// retired from app-facing responses per dogma #10.
1192    pub member_ref: WireMemberRef,
1193    pub handling_mode: WireHandlingMode,
1194}
1195
1196/// Request payload for `mob/ingress_interaction`.
1197///
1198/// This is the ergonomic "ensure an ingress member, then deliver user input"
1199/// path. It composes the existing declarative roster and member-send
1200/// semantics without introducing a separate thread/project runtime.
1201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1202#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1203#[serde(deny_unknown_fields)]
1204pub struct MobIngressInteractionParams {
1205    pub mob_id: String,
1206    pub spec: MobMemberSpecWire,
1207    pub content: WireContentInput,
1208    #[serde(default)]
1209    pub handling_mode: WireHandlingMode,
1210    #[serde(default, skip_serializing_if = "Option::is_none")]
1211    pub render_metadata: Option<WireRenderMetadata>,
1212}
1213
1214/// Response payload for `mob/ingress_interaction`.
1215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1217pub struct MobIngressInteractionResult {
1218    pub mob_id: String,
1219    pub agent_identity: String,
1220    pub member_ref: WireMemberRef,
1221    pub ensure_outcome: MobEnsureMemberOutcomeWire,
1222    pub delivery: MobMemberSendResult,
1223    /// Cursor observed immediately before the ensure/send composition.
1224    pub events_after_cursor: u64,
1225    /// Cursor observed after delivery was accepted.
1226    pub latest_event_cursor: u64,
1227}
1228
1229/// Public handling mode for mob member delivery.
1230#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1232#[serde(rename_all = "snake_case")]
1233pub enum WireHandlingMode {
1234    #[default]
1235    Queue,
1236    Steer,
1237}
1238
1239impl From<WireHandlingMode> for HandlingMode {
1240    fn from(mode: WireHandlingMode) -> Self {
1241        match mode {
1242            WireHandlingMode::Queue => HandlingMode::Queue,
1243            WireHandlingMode::Steer => HandlingMode::Steer,
1244        }
1245    }
1246}
1247
1248impl From<HandlingMode> for WireHandlingMode {
1249    fn from(mode: HandlingMode) -> Self {
1250        match mode {
1251            HandlingMode::Queue => WireHandlingMode::Queue,
1252            HandlingMode::Steer => WireHandlingMode::Steer,
1253        }
1254    }
1255}
1256
1257/// Public render class contract for mob member delivery.
1258#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1260#[serde(rename_all = "snake_case")]
1261pub enum WireRenderClass {
1262    UserPrompt,
1263    PeerMessage,
1264    PeerRequest,
1265    PeerResponse,
1266    ExternalEvent,
1267    FlowStep,
1268    Continuation,
1269    SystemNotice,
1270    ToolScopeNotice,
1271    OpsProgress,
1272}
1273
1274impl From<WireRenderClass> for RenderClass {
1275    fn from(class: WireRenderClass) -> Self {
1276        match class {
1277            WireRenderClass::UserPrompt => RenderClass::UserPrompt,
1278            WireRenderClass::PeerMessage => RenderClass::PeerMessage,
1279            WireRenderClass::PeerRequest => RenderClass::PeerRequest,
1280            WireRenderClass::PeerResponse => RenderClass::PeerResponse,
1281            WireRenderClass::ExternalEvent => RenderClass::ExternalEvent,
1282            WireRenderClass::FlowStep => RenderClass::FlowStep,
1283            WireRenderClass::Continuation => RenderClass::Continuation,
1284            WireRenderClass::SystemNotice => RenderClass::SystemNotice,
1285            WireRenderClass::ToolScopeNotice => RenderClass::ToolScopeNotice,
1286            WireRenderClass::OpsProgress => RenderClass::OpsProgress,
1287        }
1288    }
1289}
1290
1291impl From<RenderClass> for WireRenderClass {
1292    fn from(class: RenderClass) -> Self {
1293        match class {
1294            RenderClass::UserPrompt => WireRenderClass::UserPrompt,
1295            RenderClass::PeerMessage => WireRenderClass::PeerMessage,
1296            RenderClass::PeerRequest => WireRenderClass::PeerRequest,
1297            RenderClass::PeerResponse => WireRenderClass::PeerResponse,
1298            RenderClass::ExternalEvent => WireRenderClass::ExternalEvent,
1299            RenderClass::FlowStep => WireRenderClass::FlowStep,
1300            RenderClass::Continuation => WireRenderClass::Continuation,
1301            RenderClass::SystemNotice => WireRenderClass::SystemNotice,
1302            RenderClass::ToolScopeNotice => WireRenderClass::ToolScopeNotice,
1303            RenderClass::OpsProgress => WireRenderClass::OpsProgress,
1304        }
1305    }
1306}
1307
1308/// Public render salience contract for mob member delivery.
1309#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1310#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1311#[serde(rename_all = "snake_case")]
1312pub enum WireRenderSalience {
1313    Background,
1314    Normal,
1315    Important,
1316    Urgent,
1317}
1318
1319impl From<WireRenderSalience> for RenderSalience {
1320    fn from(salience: WireRenderSalience) -> Self {
1321        match salience {
1322            WireRenderSalience::Background => RenderSalience::Background,
1323            WireRenderSalience::Normal => RenderSalience::Normal,
1324            WireRenderSalience::Important => RenderSalience::Important,
1325            WireRenderSalience::Urgent => RenderSalience::Urgent,
1326        }
1327    }
1328}
1329
1330impl From<RenderSalience> for WireRenderSalience {
1331    fn from(salience: RenderSalience) -> Self {
1332        match salience {
1333            RenderSalience::Background => WireRenderSalience::Background,
1334            RenderSalience::Normal => WireRenderSalience::Normal,
1335            RenderSalience::Important => WireRenderSalience::Important,
1336            RenderSalience::Urgent => WireRenderSalience::Urgent,
1337        }
1338    }
1339}
1340
1341/// Public render metadata contract for mob member delivery.
1342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1343#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1344pub struct WireRenderMetadata {
1345    pub class: WireRenderClass,
1346    #[serde(default, skip_serializing_if = "Option::is_none")]
1347    pub salience: Option<WireRenderSalience>,
1348}
1349
1350impl From<WireRenderMetadata> for RenderMetadata {
1351    fn from(metadata: WireRenderMetadata) -> Self {
1352        Self {
1353            class: metadata.class.into(),
1354            salience: metadata
1355                .salience
1356                .unwrap_or(WireRenderSalience::Normal)
1357                .into(),
1358        }
1359    }
1360}
1361
1362impl From<RenderMetadata> for WireRenderMetadata {
1363    fn from(metadata: RenderMetadata) -> Self {
1364        Self {
1365            class: metadata.class.into(),
1366            salience: Some(metadata.salience.into()),
1367        }
1368    }
1369}
1370
1371// ---------------------------------------------------------------------------
1372// Declarative roster API (`mob/ensure_member`, `mob/reconcile`,
1373// `mob/list_members_matching`). These methods compose over spawn / retire /
1374// list_members; they introduce no new lifecycle.
1375// ---------------------------------------------------------------------------
1376
1377/// Per-member spec for `mob/ensure_member` and the `desired` entries of
1378/// `mob/reconcile`.
1379///
1380/// Mirrors the essential, codegen-friendly fields of
1381/// [`meerkat_mob::SpawnMemberSpec`]. Complex sub-types (tool access policy,
1382/// budget split, inherited tool filter, override profile) are not on this
1383/// wire surface — callers that need that parity should use the non-declarative
1384/// `mob/spawn` method.
1385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1387pub struct MobMemberSpecWire {
1388    /// Profile name (role) in the mob definition.
1389    pub profile: String,
1390    /// Stable member identity within the mob.
1391    pub agent_identity: String,
1392    #[serde(default, skip_serializing_if = "Option::is_none")]
1393    pub initial_message: Option<WireContentInput>,
1394    #[serde(default, skip_serializing_if = "Option::is_none")]
1395    pub runtime_mode: Option<WireMobRuntimeMode>,
1396    #[serde(default, skip_serializing_if = "Option::is_none")]
1397    pub backend: Option<WireMobBackendKind>,
1398    /// Bound host peer ID for placed execution; omit for the controlling host.
1399    #[serde(default, skip_serializing_if = "Option::is_none")]
1400    pub placement: Option<WireHostRef>,
1401    #[serde(default, skip_serializing_if = "Option::is_none")]
1402    pub binding: Option<WireRuntimeBinding>,
1403    #[serde(default, skip_serializing_if = "Option::is_none")]
1404    pub context: Option<Value>,
1405    #[serde(default, skip_serializing_if = "Option::is_none")]
1406    pub labels: Option<BTreeMap<String, String>>,
1407    #[serde(default, skip_serializing_if = "Option::is_none")]
1408    pub additional_instructions: Option<Vec<String>>,
1409    #[serde(default, skip_serializing_if = "Option::is_none")]
1410    pub auto_wire_parent: Option<bool>,
1411}
1412
1413impl MobMemberSpecWire {
1414    /// Compose the existing member `labels` and opaque `context` fields into
1415    /// the shared surface metadata contract without changing the JSON shape.
1416    #[must_use]
1417    pub fn surface_metadata(&self) -> SurfaceMetadata {
1418        SurfaceMetadata::from_optional_parts(self.labels.clone(), self.context.clone())
1419    }
1420
1421    /// Validate caller-supplied metadata for public member create surfaces.
1422    pub fn validate_public_surface_metadata(&self) -> Result<(), SurfaceMetadataError> {
1423        self.surface_metadata().validate_public()
1424    }
1425}
1426
1427/// Request payload for `mob/ensure_member`.
1428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1429#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1430#[serde(deny_unknown_fields)]
1431pub struct MobEnsureMemberParams {
1432    pub mob_id: String,
1433    pub spec: MobMemberSpecWire,
1434}
1435
1436/// Server-resolved opaque handle for a mob member.
1437///
1438/// Encodes `{mob_id, agent_identity}` as a single base64url-encoded token
1439/// that callers treat as opaque. The server resolves the current
1440/// `AgentRuntimeId` and fence token against the live mob roster on every
1441/// dispatch — clients never reason about `generation` or `fence_token`
1442/// directly.
1443///
1444/// Use [`WireMemberRef::encode`] to produce a token and
1445/// [`WireMemberRef::decode`] inside an RPC handler to recover the
1446/// `(mob_id, agent_identity)` pair before resolving against the runtime.
1447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
1448#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1449#[serde(transparent)]
1450pub struct WireMemberRef(String);
1451
1452impl WireMemberRef {
1453    /// Construct a handle from its components. The `mob_id` and
1454    /// `agent_identity` together form the resolution key the server uses to
1455    /// look up the member's current incarnation.
1456    #[must_use]
1457    pub fn encode(mob_id: &str, agent_identity: &str) -> Self {
1458        // Single-letter keys keep the encoded payload short so the token
1459        // remains compact in URLs and JSON payloads.
1460        // `Value::to_string` on a two-field object is infallible.
1461        let payload = serde_json::json!({ "m": mob_id, "a": agent_identity });
1462        Self(base64_url_encode(payload.to_string().as_bytes()))
1463    }
1464
1465    /// Borrow the raw token string for transport.
1466    #[must_use]
1467    pub fn as_str(&self) -> &str {
1468        &self.0
1469    }
1470
1471    /// Construct a handle from a raw token string without validation. Used
1472    /// when forwarding an opaque token received from the wire.
1473    #[must_use]
1474    pub fn from_token(token: impl Into<String>) -> Self {
1475        Self(token.into())
1476    }
1477
1478    /// Decode the handle into `(mob_id, agent_identity)`. Returns `Err` when
1479    /// the token is malformed.
1480    pub fn decode(&self) -> Result<(String, String), WireMemberRefError> {
1481        let bytes = base64_url_decode(&self.0).map_err(|_| WireMemberRefError::Malformed)?;
1482        let value: Value =
1483            serde_json::from_slice(&bytes).map_err(|_| WireMemberRefError::Malformed)?;
1484        let mob_id = value
1485            .get("m")
1486            .and_then(Value::as_str)
1487            .ok_or(WireMemberRefError::Malformed)?;
1488        let agent_identity = value
1489            .get("a")
1490            .and_then(Value::as_str)
1491            .ok_or(WireMemberRefError::Malformed)?;
1492        Ok((mob_id.to_string(), agent_identity.to_string()))
1493    }
1494}
1495
1496/// Failure modes for [`WireMemberRef::decode`].
1497#[derive(Debug, thiserror::Error)]
1498pub enum WireMemberRefError {
1499    /// Token is not valid base64url or its decoded payload is not the
1500    /// expected `{m, a}` shape.
1501    #[error("malformed member ref token")]
1502    Malformed,
1503}
1504
1505fn base64_url_encode(bytes: &[u8]) -> String {
1506    use base64::Engine as _;
1507    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
1508}
1509
1510fn base64_url_decode(input: &str) -> Result<Vec<u8>, base64::DecodeError> {
1511    use base64::Engine as _;
1512    base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(input)
1513}
1514
1515/// Identity-native payload for `EnsureMemberOutcome::Spawned`.
1516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1518pub struct MobSpawnReceiptWire {
1519    pub agent_identity: String,
1520    /// Server-resolved opaque handle for subsequent member-targeted calls
1521    /// (work submission, cancellation, lifecycle). Replaces the binding-era
1522    /// `generation` / `fence_token` pair on app-facing surfaces.
1523    pub member_ref: WireMemberRef,
1524}
1525
1526/// Execution status mirroring `meerkat_mob::runtime::MobMemberStatus`.
1527#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1528#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1529#[serde(rename_all = "snake_case")]
1530pub enum WireMobMemberStatus {
1531    Active,
1532    Retiring,
1533    Broken,
1534    Completed,
1535    Unknown,
1536}
1537
1538/// Public roster entry returned by `mob/ensure_member`'s `Existed` outcome
1539/// (and other surfaces that want a typed snapshot of a single member). Mirrors
1540/// the public-facing fields of `meerkat_mob::runtime::MobMemberListEntry`
1541/// without leaking bridge-internal fields.
1542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1544pub struct MobMemberListEntryWire {
1545    pub agent_identity: String,
1546    pub member_ref: WireMemberRef,
1547    pub role: String,
1548    pub runtime_mode: WireMobRuntimeMode,
1549    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1550    pub wired_to: Vec<String>,
1551    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1552    pub labels: BTreeMap<String, String>,
1553    pub status: WireMobMemberStatus,
1554    #[serde(default, skip_serializing_if = "Option::is_none")]
1555    pub error: Option<String>,
1556    pub is_final: bool,
1557}
1558
1559/// Outcome of a `mob/ensure_member` call.
1560///
1561/// `Existed` returns the typed [`MobMemberListEntryWire`] roster snapshot so
1562/// public consumers do not need out-of-band knowledge of the Rust domain
1563/// `MobMemberListEntry` shape.
1564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1565#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1566pub enum MobEnsureMemberOutcomeWire {
1567    #[serde(rename = "spawned")]
1568    Spawned(MobSpawnReceiptWire),
1569    #[serde(rename = "existed")]
1570    Existed(MobMemberListEntryWire),
1571}
1572
1573/// Response payload for `mob/ensure_member`.
1574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1575#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1576pub struct MobEnsureMemberResult {
1577    pub outcome: MobEnsureMemberOutcomeWire,
1578}
1579
1580/// Options controlling a `mob/reconcile` pass.
1581#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1583#[serde(deny_unknown_fields)]
1584pub struct MobReconcileOptionsWire {
1585    /// When `true`, members on the roster whose identity is not in the
1586    /// `desired` set are retired.
1587    #[serde(default)]
1588    pub retire_stale: bool,
1589}
1590
1591/// Closed wire stage for a per-identity `mob/reconcile` failure.
1592#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1593#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1594#[serde(rename_all = "snake_case")]
1595pub enum WireMobReconcileStage {
1596    Spawn,
1597    Retire,
1598}
1599
1600/// Request payload for `mob/reconcile`.
1601#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1602#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1603#[serde(deny_unknown_fields)]
1604pub struct MobReconcileParams {
1605    pub mob_id: String,
1606    #[serde(default)]
1607    pub desired: Vec<MobMemberSpecWire>,
1608    #[serde(default)]
1609    pub options: MobReconcileOptionsWire,
1610}
1611
1612/// Typed mob error projection for wire surfaces. Carries the closed failure
1613/// class alongside the human-readable message so consumers branch on the typed
1614/// `code` rather than parsing the free-form `message`. Reuses
1615/// [`MobSpawnManyFailureCause`] as the canonical closed mob-error vocabulary.
1616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1617#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1618pub struct WireMobError {
1619    pub code: MobSpawnManyFailureCause,
1620    pub message: String,
1621}
1622
1623/// Per-identity failure in a `mob/reconcile` pass.
1624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1625#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1626pub struct MobReconcileFailureWire {
1627    pub agent_identity: String,
1628    pub stage: WireMobReconcileStage,
1629    /// Typed mob error: closed failure `code` plus human-readable `message`.
1630    pub error: WireMobError,
1631}
1632
1633/// Summary produced by a `mob/reconcile` pass.
1634#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1635#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1636pub struct MobReconcileReportWire {
1637    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1638    pub desired: Vec<String>,
1639    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1640    pub retained: Vec<String>,
1641    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1642    pub spawned: Vec<MobSpawnReceiptWire>,
1643    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1644    pub retired: Vec<String>,
1645    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1646    pub failures: Vec<MobReconcileFailureWire>,
1647}
1648
1649/// Response payload for `mob/reconcile`.
1650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1652pub struct MobReconcileResult {
1653    pub report: MobReconcileReportWire,
1654}
1655
1656/// Typed lifecycle action for `mob/lifecycle`. Replaces the prior
1657/// `action: String` discriminator with an exhaustive enum so callers and
1658/// handlers reason about lifecycle transitions through the type system
1659/// rather than string folklore.
1660#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1662#[serde(rename_all = "snake_case")]
1663pub enum WireMobLifecycleAction {
1664    Stop,
1665    Resume,
1666    Complete,
1667    Reset,
1668    Destroy,
1669}
1670
1671/// Typed wire/unwire action for the `mob_wire` agent tool. Replaces the prior
1672/// `action: String` discriminator with an exhaustive enum so the agent-tool
1673/// surface reasons about the wire/unwire distinction through the type system
1674/// rather than string folklore (mirrors [`WireMobLifecycleAction`]).
1675#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1677#[serde(rename_all = "snake_case")]
1678pub enum WireMobWireAction {
1679    Wire,
1680    Unwire,
1681}
1682
1683/// Request payload for `mob/lifecycle`.
1684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1685#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1686#[serde(deny_unknown_fields)]
1687pub struct MobLifecycleParams {
1688    pub mob_id: String,
1689    pub action: WireMobLifecycleAction,
1690}
1691
1692/// Response payload for `mob/lifecycle`.
1693#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1695pub struct MobLifecycleResult {
1696    pub mob_id: String,
1697    pub action: WireMobLifecycleAction,
1698    pub ok: bool,
1699    #[serde(default, skip_serializing_if = "Option::is_none")]
1700    pub destroy_report: Option<Value>,
1701}
1702
1703/// Request payload for `mob/append_system_context`.
1704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1705#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1706#[serde(deny_unknown_fields)]
1707pub struct MobAppendSystemContextParams {
1708    pub mob_id: String,
1709    pub agent_identity: String,
1710    pub text: String,
1711    #[serde(default, skip_serializing_if = "Option::is_none")]
1712    pub source: Option<String>,
1713    #[serde(default, skip_serializing_if = "Option::is_none")]
1714    pub idempotency_key: Option<String>,
1715}
1716
1717/// Outcome of a `mob/append_system_context` call on the wire. Mirrors
1718/// `meerkat_core::AppendSystemContextStatus` so consumers reason about the
1719/// applied/staged/duplicate distinction through a closed type rather than a
1720/// free-form status string.
1721#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1722#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1723#[serde(rename_all = "snake_case")]
1724pub enum WireAppendSystemContextStatus {
1725    Applied,
1726    Staged,
1727    Duplicate,
1728}
1729
1730impl From<meerkat_core::AppendSystemContextStatus> for WireAppendSystemContextStatus {
1731    fn from(status: meerkat_core::AppendSystemContextStatus) -> Self {
1732        match status {
1733            meerkat_core::AppendSystemContextStatus::Applied => Self::Applied,
1734            meerkat_core::AppendSystemContextStatus::Staged => Self::Staged,
1735            meerkat_core::AppendSystemContextStatus::Duplicate => Self::Duplicate,
1736        }
1737    }
1738}
1739
1740/// Response payload for `mob/append_system_context`.
1741#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1742#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1743pub struct MobAppendSystemContextResult {
1744    pub mob_id: String,
1745    pub agent_identity: String,
1746    pub status: WireAppendSystemContextStatus,
1747}
1748
1749/// Response payload for `mob/flows`.
1750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1751#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1752pub struct MobFlowsResult {
1753    pub mob_id: String,
1754    pub flows: Vec<String>,
1755}
1756
1757/// Request payload for `mob/flow_run`.
1758#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1759#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1760#[serde(deny_unknown_fields)]
1761pub struct MobFlowRunParams {
1762    pub mob_id: String,
1763    pub flow_id: String,
1764    #[serde(default)]
1765    pub params: Value,
1766}
1767
1768/// Request payload for `mob/run`.
1769///
1770/// Starts the pack's callable flow. `flow_id` defaults to `main`; `prompt` is
1771/// sugar for `params.prompt` when the caller does not provide that key.
1772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1773#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1774#[serde(deny_unknown_fields)]
1775pub struct MobRunParams {
1776    pub mob_id: String,
1777    #[serde(default, skip_serializing_if = "Option::is_none")]
1778    pub flow_id: Option<String>,
1779    #[serde(default, skip_serializing_if = "Option::is_none")]
1780    pub prompt: Option<String>,
1781    #[serde(default)]
1782    pub params: Value,
1783}
1784
1785/// Response payload for `mob/flow_run`.
1786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1787#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1788pub struct MobFlowRunResult {
1789    pub run_id: String,
1790}
1791
1792/// Request payload for `mob/flow_status`.
1793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1794#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1795#[serde(deny_unknown_fields)]
1796pub struct MobFlowStatusParams {
1797    pub mob_id: String,
1798    pub run_id: String,
1799}
1800
1801/// Lifecycle status of a flow run on the wire. Mirrors
1802/// `meerkat_mob::MobRunStatus` so consumers branch on a closed type rather than
1803/// re-deriving meaning from a free-form status string.
1804#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1805#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1806#[serde(rename_all = "snake_case")]
1807pub enum WireMobRunStatus {
1808    Pending,
1809    Running,
1810    Completed,
1811    Failed,
1812    Canceled,
1813}
1814
1815/// Typed public projection of a single flow run for `mob/flow_status`.
1816///
1817/// The canonical identity and lifecycle fields (`run_id`, `mob_id`, `flow_id`,
1818/// `status`) are typed; the remaining kernel-owned step/loop projection rides
1819/// along as the `kernel` map. Producers project a domain `MobRun` into this
1820/// shape so consumers never re-derive run identity or lifecycle from a free
1821/// `serde_json::Value`.
1822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1823#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1824pub struct WireMobRun {
1825    pub run_id: String,
1826    pub mob_id: String,
1827    pub flow_id: String,
1828    pub status: WireMobRunStatus,
1829    /// Remaining kernel-owned run projection (step ledger, frame/loop outputs,
1830    /// flow state) after the typed identity/lifecycle fields are lifted out.
1831    #[serde(flatten)]
1832    pub kernel: serde_json::Map<String, Value>,
1833}
1834
1835/// Response payload for `mob/flow_status`.
1836///
1837/// `run` is `None` when the requested run id has no persisted run.
1838#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1839#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1840pub struct MobFlowStatusResult {
1841    #[serde(default, skip_serializing_if = "Option::is_none")]
1842    pub run: Option<WireMobRun>,
1843}
1844
1845/// Request payload for `mob/run_result`.
1846#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1847#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1848#[serde(deny_unknown_fields)]
1849pub struct MobRunResultParams {
1850    pub mob_id: String,
1851    pub run_id: String,
1852}
1853
1854/// Typed output envelope for a completed or in-flight mob flow run.
1855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1856#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1857#[serde(deny_unknown_fields)]
1858pub struct WireMobRunResultEnvelope {
1859    pub run_id: String,
1860    pub mob_id: String,
1861    pub flow_id: String,
1862    pub status: WireMobRunStatus,
1863    #[serde(default, skip_serializing_if = "Option::is_none")]
1864    pub result: Option<Value>,
1865    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1866    pub outputs: BTreeMap<String, Value>,
1867}
1868
1869/// Response payload for `mob/run_result`.
1870///
1871/// `run` is `None` when the requested run id has no persisted run.
1872#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1873#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1874pub struct MobRunResult {
1875    #[serde(default, skip_serializing_if = "Option::is_none")]
1876    pub run: Option<WireMobRunResultEnvelope>,
1877}
1878
1879/// Request payload for `mob/flow_cancel`.
1880#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1881#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1882#[serde(deny_unknown_fields)]
1883pub struct MobFlowCancelParams {
1884    pub mob_id: String,
1885    pub run_id: String,
1886}
1887
1888/// Response payload for `mob/flow_cancel`.
1889#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1890#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1891pub struct MobFlowCancelResult {
1892    pub canceled: bool,
1893}
1894
1895/// Request payload for `mob/spawn_helper`.
1896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1897#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1898#[serde(deny_unknown_fields)]
1899pub struct MobSpawnHelperParams {
1900    pub mob_id: String,
1901    pub prompt: String,
1902    #[serde(default, skip_serializing_if = "Option::is_none")]
1903    pub agent_identity: Option<String>,
1904    #[serde(default, skip_serializing_if = "Option::is_none")]
1905    pub role_name: Option<String>,
1906    #[serde(default, skip_serializing_if = "Option::is_none")]
1907    pub model_override: Option<String>,
1908    #[serde(default, skip_serializing_if = "Option::is_none")]
1909    pub auth_binding: Option<WireAuthBindingRef>,
1910    #[serde(default, skip_serializing_if = "Option::is_none")]
1911    pub runtime_mode: Option<WireMobRuntimeMode>,
1912    #[serde(default, skip_serializing_if = "Option::is_none")]
1913    pub backend: Option<WireMobBackendKind>,
1914}
1915
1916/// Request payload for `mob/fork_helper`.
1917#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1918#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1919#[serde(deny_unknown_fields)]
1920pub struct MobForkHelperParams {
1921    pub mob_id: String,
1922    pub source_member_id: String,
1923    pub prompt: String,
1924    #[serde(default, skip_serializing_if = "Option::is_none")]
1925    pub agent_identity: Option<String>,
1926    #[serde(default, skip_serializing_if = "Option::is_none")]
1927    pub role_name: Option<String>,
1928    #[serde(default, skip_serializing_if = "Option::is_none")]
1929    pub model_override: Option<String>,
1930    #[serde(default, skip_serializing_if = "Option::is_none")]
1931    pub auth_binding: Option<WireAuthBindingRef>,
1932    #[serde(default, skip_serializing_if = "Option::is_none")]
1933    pub fork_context: Option<Value>,
1934    #[serde(default, skip_serializing_if = "Option::is_none")]
1935    pub runtime_mode: Option<WireMobRuntimeMode>,
1936    #[serde(default, skip_serializing_if = "Option::is_none")]
1937    pub backend: Option<WireMobBackendKind>,
1938}
1939
1940/// Response payload for `mob/spawn_helper` and `mob/fork_helper`.
1941#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1942#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1943pub struct MobHelperResult {
1944    #[serde(default, skip_serializing_if = "Option::is_none")]
1945    pub output: Option<String>,
1946    pub tokens_used: u64,
1947    pub agent_identity: String,
1948    pub member_ref: WireMemberRef,
1949}
1950
1951/// Response payload for `mob/force_cancel`.
1952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1953#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1954pub struct MobForceCancelResult {
1955    pub cancelled: bool,
1956}
1957
1958/// Request payload for `mob/turn_start`.
1959///
1960/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
1961/// tri-state via [`WireTurnMetadataOverride`]; unknown fields (including the
1962/// retired `clear_*` split wire form) fail closed at the serde boundary via
1963/// `deny_unknown_fields`, which also keeps the emitted JSON Schema's
1964/// `additionalProperties: false` aligned with the deserializer.
1965#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1966#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1967#[serde(deny_unknown_fields)]
1968pub struct MobTurnStartParams {
1969    pub mob_id: String,
1970    pub agent_identity: String,
1971    pub prompt: WireContentInput,
1972    #[serde(default, skip_serializing_if = "Option::is_none")]
1973    pub skill_refs: Option<Vec<meerkat_core::skills::SkillRef>>,
1974    #[serde(default, skip_serializing_if = "Option::is_none")]
1975    pub turn_tool_overlay: Option<meerkat_core::service::PublicTurnToolOverlay>,
1976    #[serde(default, skip_serializing_if = "Option::is_none")]
1977    pub additional_instructions: Option<Vec<String>>,
1978    #[serde(default, skip_serializing_if = "Option::is_none")]
1979    pub keep_alive: Option<bool>,
1980    #[serde(default, skip_serializing_if = "Option::is_none")]
1981    pub model: Option<String>,
1982    #[serde(default, skip_serializing_if = "Option::is_none")]
1983    pub provider: Option<String>,
1984    /// Exact configured local-server route for a self-hosted model.
1985    #[serde(default, skip_serializing_if = "Option::is_none")]
1986    pub self_hosted_server_id: Option<String>,
1987    #[serde(default, skip_serializing_if = "Option::is_none")]
1988    pub max_tokens: Option<u32>,
1989    #[serde(default, skip_serializing_if = "Option::is_none")]
1990    pub system_prompt: Option<String>,
1991    #[serde(default, skip_serializing_if = "Option::is_none")]
1992    pub output_schema: Option<Value>,
1993    #[serde(default, skip_serializing_if = "Option::is_none")]
1994    pub structured_output_retries: Option<u32>,
1995    #[serde(default, skip_serializing_if = "Option::is_none")]
1996    pub provider_params:
1997        Option<WireTurnMetadataOverride<crate::wire::runtime::WireProviderParamsOverride>>,
1998    #[serde(default, skip_serializing_if = "Option::is_none")]
1999    pub auth_binding: Option<WireTurnMetadataOverride<WireAuthBindingRef>>,
2000    /// Host-attached injected context for this turn. Each entry materializes
2001    /// as a separate typed injected-context transcript message immediately
2002    /// before the turn's user message, in order. `mob/turn_start` already
2003    /// rejects autonomous members, so this always rides a turn-driven turn.
2004    #[serde(default, skip_serializing_if = "Option::is_none")]
2005    pub injected_context: Option<Vec<WireContentInput>>,
2006}
2007
2008/// One currently wired peer that is known to be unreachable.
2009#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2010#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2011pub struct WireUnreachablePeer {
2012    pub peer: String,
2013    #[serde(default, skip_serializing_if = "Option::is_none")]
2014    pub reason: Option<String>,
2015}
2016
2017/// Live connectivity summary for a member's currently wired peers. Mirrors
2018/// `meerkat_mob::MobPeerConnectivitySnapshot`.
2019#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2020#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2021pub struct WirePeerConnectivitySnapshot {
2022    pub reachable_peer_count: usize,
2023    pub unknown_peer_count: usize,
2024    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2025    pub unreachable_peers: Vec<WireUnreachablePeer>,
2026}
2027
2028/// Tri-state peer-connectivity projection for `mob/member_status`.
2029///
2030/// Distinguishes "connectivity is not applicable to this member" (no bridge
2031/// session backs the member) from "the live probe timed out" (the answer is
2032/// transiently unknown) from a resolved connectivity snapshot. The legacy
2033/// `Option<MobPeerConnectivitySnapshot>` projection collapsed both the
2034/// not-applicable and timed-out cases into `None`, laundering a transient
2035/// probe fault into the same shape as a structurally-absent binding.
2036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2037#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2038#[serde(tag = "status", rename_all = "snake_case")]
2039pub enum WirePeerConnectivity {
2040    /// The member has no bridge session, so live peer connectivity is not a
2041    /// resolvable fact for it.
2042    NotApplicable,
2043    /// A live connectivity probe was attempted but did not resolve in time.
2044    ProbeTimedOut,
2045    /// A resolved connectivity snapshot.
2046    Known {
2047        snapshot: WirePeerConnectivitySnapshot,
2048    },
2049}
2050
2051/// Response payload for `mob/member_status`.
2052#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2053#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2054pub struct MobMemberStatusResult {
2055    pub status: WireMobMemberStatus,
2056    /// Server-resolved opaque handle for subsequent member-targeted calls.
2057    pub member_ref: WireMemberRef,
2058    #[serde(default, skip_serializing_if = "Option::is_none")]
2059    pub output_preview: Option<String>,
2060    #[serde(default, skip_serializing_if = "Option::is_none")]
2061    pub error: Option<String>,
2062    pub tokens_used: u64,
2063    pub is_final: bool,
2064    #[serde(default, skip_serializing_if = "Option::is_none")]
2065    pub current_session_id: Option<String>,
2066    #[serde(default, skip_serializing_if = "Option::is_none")]
2067    pub peer_connectivity: Option<WirePeerConnectivity>,
2068    #[serde(default, skip_serializing_if = "Option::is_none")]
2069    pub kickoff: Option<Value>,
2070    #[serde(default, skip_serializing_if = "Option::is_none")]
2071    pub external_member: Option<Value>,
2072    #[serde(default, skip_serializing_if = "Option::is_none")]
2073    pub resolved_capabilities: Option<crate::wire::WireResolvedModelCapabilities>,
2074    #[serde(default, skip_serializing_if = "Option::is_none")]
2075    pub progress: Option<WireMemberProgressSnapshot>,
2076    /// Execution activity is separate from member lifecycle status.
2077    #[serde(default, skip_serializing_if = "Option::is_none")]
2078    pub activity: Option<crate::wire::JobExecutionActivity>,
2079    /// Durable detached work owned by this member's current session.
2080    #[serde(default, skip_serializing_if = "Option::is_none")]
2081    pub detached_jobs: Option<crate::wire::DetachedJobsActivitySummary>,
2082    // Multi-host projections (SD-5): placement and reachability are TYPED
2083    // fields here — never smuggled inside the opaque `external_member`
2084    // value. All optional + absent-omitted for byte-compat with released
2085    // SDKs.
2086    /// Host the member is materialized on; `None` = controlling host.
2087    #[serde(default, skip_serializing_if = "Option::is_none")]
2088    pub placement: Option<WireHostRef>,
2089    /// Bridge control-plane reachability of the owning host/member.
2090    #[serde(default, skip_serializing_if = "Option::is_none")]
2091    pub control_reachability: Option<WireReachability>,
2092    /// Comms data-plane reachability of the member peer.
2093    #[serde(default, skip_serializing_if = "Option::is_none")]
2094    pub comms_reachability: Option<WireReachability>,
2095    /// Observer-local monotonic ms since last verified contact — never a
2096    /// remote wall-clock comparison.
2097    #[serde(default, skip_serializing_if = "Option::is_none")]
2098    pub last_seen_ms: Option<u64>,
2099    #[serde(default, skip_serializing_if = "Option::is_none")]
2100    pub freshness_reason: Option<String>,
2101    /// Lifecycle capability flags for this member's placement (§19.L7).
2102    #[serde(default, skip_serializing_if = "Option::is_none")]
2103    pub lifecycle_capabilities: Option<WireMemberLifecycleCapabilities>,
2104    /// Reserved portability projection; placed v1 members report an empty
2105    /// list because non-portable resources are rejected, never disabled.
2106    #[serde(default, skip_serializing_if = "Option::is_none")]
2107    pub non_portable_disabled: Option<Vec<super::portable_spec::WireNonPortableResourceKind>>,
2108}
2109
2110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2112#[serde(rename_all = "snake_case")]
2113pub enum WireMemberRunState {
2114    Idle,
2115    RunOpen,
2116    Unknown,
2117}
2118
2119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2121#[serde(rename_all = "snake_case")]
2122pub enum WireMemberHealthClass {
2123    Healthy,
2124    Degraded,
2125    Wedged,
2126    Unknown,
2127}
2128
2129#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2131#[serde(rename_all = "snake_case")]
2132pub enum WireMemberProgressEvent {
2133    ExecutionAdvanced,
2134    BecameIdle,
2135    Unchanged,
2136}
2137
2138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2140pub struct WireMemberProgressSnapshot {
2141    pub run_state: WireMemberRunState,
2142    pub in_flight_work: u64,
2143    pub last_progress_at_ms: u64,
2144    pub last_progress_event: WireMemberProgressEvent,
2145    pub health: WireMemberHealthClass,
2146}
2147
2148/// Response payload for `mob/snapshot`.
2149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2151pub struct MobSnapshotResult {
2152    pub mob_id: String,
2153    pub status: WireMobLifecycleStatus,
2154    pub members: Vec<MobMemberListEntryWire>,
2155}
2156
2157#[cfg(test)]
2158mod member_status_capability_tests {
2159    use super::*;
2160
2161    #[test]
2162    fn member_status_result_round_trips_resolved_capabilities() -> Result<(), serde_json::Error> {
2163        let capabilities = crate::wire::WireResolvedModelCapabilities {
2164            vision: true,
2165            image_input: true,
2166            image_tool_results: false,
2167            inline_video: false,
2168            realtime: true,
2169            web_search: true,
2170            image_generation: true,
2171        };
2172        let result = MobMemberStatusResult {
2173            status: WireMobMemberStatus::Active,
2174            member_ref: WireMemberRef::encode("mob-1", "worker-1"),
2175            output_preview: None,
2176            error: None,
2177            tokens_used: 0,
2178            is_final: false,
2179            current_session_id: Some("session-1".to_string()),
2180            peer_connectivity: Some(WirePeerConnectivity::Known {
2181                snapshot: WirePeerConnectivitySnapshot {
2182                    reachable_peer_count: 1,
2183                    unknown_peer_count: 0,
2184                    unreachable_peers: Vec::new(),
2185                },
2186            }),
2187            kickoff: None,
2188            external_member: None,
2189            resolved_capabilities: Some(capabilities.clone()),
2190            progress: None,
2191            activity: None,
2192            detached_jobs: None,
2193            placement: None,
2194            control_reachability: None,
2195            comms_reachability: None,
2196            last_seen_ms: None,
2197            freshness_reason: None,
2198            lifecycle_capabilities: None,
2199            non_portable_disabled: None,
2200        };
2201
2202        let json = serde_json::to_string(&result)?;
2203        assert!(json.contains("\"resolved_capabilities\""));
2204        let parsed: MobMemberStatusResult = serde_json::from_str(&json)?;
2205        assert_eq!(parsed.resolved_capabilities, Some(capabilities));
2206        Ok(())
2207    }
2208}
2209
2210/// Response payload for `mob/destroy`.
2211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2213pub struct MobDestroyResult {
2214    pub mob_id: String,
2215    pub ok: bool,
2216    pub destroy_report: Value,
2217}
2218
2219/// Response payload for `mob/rotate_supervisor`.
2220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2222pub struct MobRotateSupervisorResult {
2223    pub mob_id: String,
2224    pub ok: bool,
2225    pub report: SupervisorRotationReportWire,
2226}
2227
2228/// Confirmed supervisor rotation report returned by `mob/rotate_supervisor`.
2229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2230#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2231pub struct SupervisorRotationReportWire {
2232    pub previous_epoch: u64,
2233    pub current_epoch: u64,
2234    pub public_peer_id: String,
2235}
2236
2237/// Discriminator kind for the supervisor-rotation-incomplete error details.
2238#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2240#[serde(rename_all = "snake_case")]
2241pub enum SupervisorRotationIncompleteKind {
2242    SupervisorRotationIncomplete,
2243}
2244
2245/// Which authority a supervisor-rotation retry validates against.
2246#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2248#[serde(rename_all = "snake_case")]
2249pub enum SupervisorRotationRetryAuthority {
2250    PendingRotation,
2251    PreRotation,
2252}
2253
2254/// Durability scope of a supervisor-rotation retry.
2255#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2257#[serde(rename_all = "snake_case")]
2258pub enum SupervisorRotationRetryScope {
2259    Durable,
2260    PreRotation,
2261}
2262
2263/// Typed details of `MobError::SupervisorRotationIncomplete` on the wire.
2264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2266#[serde(rename_all = "snake_case")]
2267pub struct SupervisorRotationIncompleteDetailsWire {
2268    pub kind: SupervisorRotationIncompleteKind,
2269    pub previous_epoch: u64,
2270    pub attempted_epoch: u64,
2271    pub attempted_public_peer_id: String,
2272    pub rotated_peer_count: usize,
2273    pub rollback_succeeded: bool,
2274    pub pending_authority_recorded: bool,
2275    #[serde(default, skip_serializing_if = "Option::is_none")]
2276    pub rollback_error: Option<String>,
2277    pub retry_authority: SupervisorRotationRetryAuthority,
2278    pub retry_scope: SupervisorRotationRetryScope,
2279}
2280
2281/// JSON-RPC `error.data` payload for an incomplete supervisor rotation.
2282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2283#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2284#[serde(rename_all = "snake_case")]
2285pub struct SupervisorRotationIncompleteDataWire {
2286    pub code: String,
2287    pub message: String,
2288    pub details: SupervisorRotationIncompleteDetailsWire,
2289}
2290
2291/// Shared request payload for mob readiness waits.
2292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2294#[serde(deny_unknown_fields)]
2295pub struct MobWaitParams {
2296    pub mob_id: String,
2297    #[serde(default, skip_serializing_if = "Option::is_none")]
2298    pub member_ids: Option<Vec<String>>,
2299    #[serde(default, skip_serializing_if = "Option::is_none")]
2300    pub timeout_ms: Option<u64>,
2301}
2302
2303/// Response payload for `mob/wait_kickoff` and `mob/wait_ready`.
2304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2305#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2306pub struct MobWaitMembersResult {
2307    pub members: Vec<Value>,
2308}
2309
2310/// Response payload for `mob/cancel_work`.
2311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2313pub struct MobCancelWorkResult {
2314    pub mob_id: String,
2315    pub ok: bool,
2316}
2317
2318/// Response payload for `mob/cancel_all_work`.
2319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2321pub struct MobCancelAllWorkResult {
2322    pub mob_id: String,
2323    pub ok: bool,
2324}
2325
2326/// Request payload for `mob/profile/create`.
2327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2329#[serde(deny_unknown_fields)]
2330pub struct MobProfileCreateParams {
2331    pub name: String,
2332    pub profile: MobProfileInput,
2333}
2334
2335/// Request payload for `mob/profile/get`.
2336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2338#[serde(deny_unknown_fields)]
2339pub struct MobProfileNameParams {
2340    pub name: String,
2341}
2342
2343/// Request payload for `mob/profile/update`.
2344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2345#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2346#[serde(deny_unknown_fields)]
2347pub struct MobProfileUpdateParams {
2348    pub name: String,
2349    pub profile: MobProfileInput,
2350    pub expected_revision: u64,
2351}
2352
2353/// Request payload for `mob/profile/delete`.
2354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2355#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2356#[serde(deny_unknown_fields)]
2357pub struct MobProfileDeleteParams {
2358    pub name: String,
2359    pub expected_revision: u64,
2360}
2361
2362/// Stored realm profile projection returned by `mob/profile/*`.
2363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2364#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2365pub struct MobProfileLookupResult {
2366    #[serde(default)]
2367    pub not_found: bool,
2368    pub name: String,
2369    #[serde(default, skip_serializing_if = "Option::is_none")]
2370    pub profile: Option<WireMobProfile>,
2371    #[serde(default, skip_serializing_if = "Option::is_none")]
2372    pub revision: Option<u64>,
2373    #[serde(default, skip_serializing_if = "Option::is_none")]
2374    pub created_at: Option<String>,
2375    #[serde(default, skip_serializing_if = "Option::is_none")]
2376    pub updated_at: Option<String>,
2377}
2378
2379/// Response payload for `mob/profile/list`.
2380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2382pub struct MobProfileListResult {
2383    pub profiles: Vec<MobProfileLookupResult>,
2384}
2385
2386/// Response payload for `mob/profile/delete`.
2387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2388#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2389pub struct MobProfileDeleteResult {
2390    pub name: String,
2391    pub deleted_revision: u64,
2392}
2393
2394/// Request payload for `mob/stream_open`.
2395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2396#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2397#[serde(deny_unknown_fields)]
2398pub struct MobStreamOpenParams {
2399    pub mob_id: String,
2400    #[serde(default, skip_serializing_if = "Option::is_none")]
2401    pub agent_identity: Option<String>,
2402}
2403
2404/// Response payload for `mob/stream_open`.
2405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2406#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2407pub struct MobStreamOpenResult {
2408    pub stream_id: String,
2409    pub opened: bool,
2410}
2411
2412/// Request payload for `mob/stream_close`.
2413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2414#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2415#[serde(deny_unknown_fields)]
2416pub struct MobStreamCloseParams {
2417    pub stream_id: String,
2418}
2419
2420/// Response payload for `mob/stream_close`.
2421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2422#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2423pub struct MobStreamCloseResult {
2424    pub stream_id: String,
2425    pub closed: bool,
2426    pub already_closed: bool,
2427}
2428
2429/// Origin for `MobSubmitWorkParams`. Replaces the prior free-form
2430/// `origin: Option<String>` shape.
2431#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2432#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2433#[serde(rename_all = "snake_case")]
2434pub enum WireWorkOrigin {
2435    #[default]
2436    External,
2437    Internal,
2438}
2439
2440/// Request payload for `mob/submit_work`.
2441///
2442/// Identifies the member through the opaque [`WireMemberRef`] handle the
2443/// server resolves against the live roster — callers do not pass
2444/// `generation` or `fence_token`.
2445#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2446#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2447#[serde(deny_unknown_fields)]
2448pub struct MobSubmitWorkParams {
2449    pub member_ref: WireMemberRef,
2450    /// Optional caller-supplied work reference. When absent the server
2451    /// generates a fresh UUID.
2452    #[serde(default, skip_serializing_if = "Option::is_none")]
2453    pub work_ref: Option<String>,
2454    pub content: WireContentInput,
2455    #[serde(default)]
2456    pub origin: WireWorkOrigin,
2457    /// Host-attached injected context delivered alongside the work content.
2458    /// Each entry materializes on the member as a separate typed
2459    /// injected-context transcript message immediately before the work
2460    /// content, in order. Deliverable to queue-mode turn-driven members;
2461    /// autonomous inbox delivery rejects it with a typed error.
2462    #[serde(default, skip_serializing_if = "Option::is_none")]
2463    pub injected_context: Option<Vec<WireContentInput>>,
2464    /// Durable kickoff objective correlation to stamp onto this delegated turn.
2465    #[serde(default, skip_serializing_if = "Option::is_none")]
2466    pub objective_id: Option<String>,
2467}
2468
2469/// Response payload for `mob/submit_work`.
2470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2471#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2472pub struct MobSubmitWorkResult {
2473    pub mob_id: String,
2474    pub work_ref: String,
2475    pub member_ref: WireMemberRef,
2476    #[serde(default, skip_serializing_if = "Option::is_none")]
2477    pub objective_id: Option<String>,
2478}
2479
2480/// Explicitly concludes one machine-owned kickoff objective.
2481#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2482#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2483#[serde(deny_unknown_fields)]
2484pub struct MobConcludeObjectiveParams {
2485    pub member_ref: WireMemberRef,
2486    pub objective_id: String,
2487    pub outcome: String,
2488}
2489
2490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2491#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2492pub struct MobConcludeObjectiveResult {
2493    pub member_ref: WireMemberRef,
2494    pub objective_id: String,
2495    pub concluded: bool,
2496}
2497
2498/// Request payload for `mob/cancel_work`.
2499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2500#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2501#[serde(deny_unknown_fields)]
2502pub struct MobCancelWorkParams {
2503    pub mob_id: String,
2504    pub work_ref: String,
2505}
2506
2507/// Request payload for `mob/cancel_all_work`.
2508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2510#[serde(deny_unknown_fields)]
2511pub struct MobCancelAllWorkParams {
2512    pub member_ref: WireMemberRef,
2513}
2514
2515/// Filter for `mob/list_members_matching`. Non-empty / `Some` fields are
2516/// combined conjunctively; an empty filter matches every member.
2517#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2518#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2519#[serde(deny_unknown_fields)]
2520pub struct MobMemberFilterWire {
2521    /// Required exact matches on member labels.
2522    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2523    pub labels: BTreeMap<String, String>,
2524    /// Required profile name (role).
2525    #[serde(default, skip_serializing_if = "Option::is_none")]
2526    pub role: Option<String>,
2527    /// Required canonical machine-projected member status.
2528    #[serde(default, skip_serializing_if = "Option::is_none")]
2529    pub status: Option<WireMobMemberStatus>,
2530}
2531
2532/// Request payload for `mob/list_members_matching`.
2533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2534#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2535#[serde(deny_unknown_fields)]
2536pub struct MobListMembersMatchingParams {
2537    pub mob_id: String,
2538    #[serde(default)]
2539    pub filter: MobMemberFilterWire,
2540}
2541
2542/// Response payload for `mob/list_members_matching`. Each member is the raw
2543/// roster entry JSON.
2544#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2545#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2546pub struct MobListMembersMatchingResult {
2547    #[serde(default)]
2548    pub members: Vec<Value>,
2549}
2550
2551// ---------------------------------------------------------------------------
2552// Multi-host mob DTOs (V4): control scopes, host roster, remote history,
2553// grants, member live console. Types only — RPC catalog entries land with
2554// the surface phases.
2555// ---------------------------------------------------------------------------
2556
2557/// Closed control-plane scope vocabulary (A9). Grants and bridge scope
2558/// denials speak exactly this set.
2559#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
2560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2561#[serde(rename_all = "snake_case")]
2562pub enum WireControlScope {
2563    List,
2564    ReadHistory,
2565    SubscribeEvents,
2566    SendCommand,
2567    Cancel,
2568    Retire,
2569    WireTopology,
2570    Live,
2571    AdminHost,
2572    AdminGrants,
2573}
2574
2575/// Observer-computed reachability class (§7.5). A projection from typed
2576/// bridge/pump outcomes — never a membership fact, and a DIFFERENT fact
2577/// from the bridge's own `BridgePeerConnectivity`.
2578#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2579#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2580#[serde(rename_all = "snake_case")]
2581pub enum WireReachability {
2582    Reachable,
2583    Stale,
2584    Unreachable,
2585    Unknown,
2586}
2587
2588/// Opaque host reference: the host's canonical comms `PeerId` string.
2589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
2590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2591#[serde(transparent)]
2592pub struct WireHostRef(pub String);
2593
2594/// Lifecycle capabilities available for a member at its placement (§19.L7).
2595#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2596#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2597#[serde(deny_unknown_fields)]
2598pub struct WireMemberLifecycleCapabilities {
2599    pub transcript_edits: bool,
2600    pub revisions: bool,
2601    pub resume_after_restart: bool,
2602}
2603
2604/// Host bind lifecycle phase as recorded by the controlling machine.
2605#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2606#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2607#[serde(rename_all = "snake_case")]
2608pub enum WireHostBindPhase {
2609    Requested,
2610    Bound,
2611}
2612
2613/// Wire mirror of the DSL `HostCapabilityFlags` single enumeration (§6.1) —
2614/// the machine owns the fact; this is its console projection.
2615///
2616/// Field vocabulary matches the machine maps and the domain
2617/// `HostCapabilityReport` exactly (ADJ-P7-1, FLAG-A2): `u64` protocol bounds
2618/// and an OPEN `BTreeSet<String>` provider vocabulary — a newer member host
2619/// advertising a provider this build's enum lacks must stay representable
2620/// (no silent caps).
2621#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2622#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2623#[serde(deny_unknown_fields)]
2624pub struct WireHostCapabilityFlags {
2625    pub protocol_min: u64,
2626    pub protocol_max: u64,
2627    pub engine_version: String,
2628    pub durable_sessions: bool,
2629    pub autonomous_members: bool,
2630    pub hard_cancel_member: bool,
2631    #[serde(default)]
2632    pub tracked_input_cancel: bool,
2633    pub memory_store: bool,
2634    pub mcp: bool,
2635    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2636    pub resolvable_providers: std::collections::BTreeSet<String>,
2637    pub approval_forwarding: bool,
2638    #[serde(default, skip_serializing_if = "Option::is_none")]
2639    pub live_endpoint: Option<String>,
2640}
2641
2642/// One tracked host row for `mob/hosts` (A13).
2643///
2644/// `endpoint`, `authority_epoch`, and `capabilities` are the CommitHostBind
2645/// facts — present for `Bound` hosts, typed-absent for a `Requested`-phase
2646/// host (an open or failed bind window commits nothing; fabricating empty
2647/// values would launder ceremony state into committed facts).
2648///
2649/// `control_reachability`/`last_seen_ms`/`freshness_reason` are fed by the
2650/// observer-local periodic `HostStatus` driver shared with orphan
2651/// reconciliation. They remain typed-absent until the first observation and
2652/// never become durable membership facts.
2653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2654#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2655pub struct MobHostStatus {
2656    pub host_id: WireHostRef,
2657    #[serde(default, skip_serializing_if = "Option::is_none")]
2658    pub endpoint: Option<String>,
2659    pub bind_phase: WireHostBindPhase,
2660    #[serde(default, skip_serializing_if = "Option::is_none")]
2661    pub authority_epoch: Option<u64>,
2662    #[serde(default, skip_serializing_if = "Option::is_none")]
2663    pub capabilities: Option<WireHostCapabilityFlags>,
2664    #[serde(default, skip_serializing_if = "Option::is_none")]
2665    pub control_reachability: Option<WireReachability>,
2666    /// Observer-local monotonic ms since last verified contact.
2667    #[serde(default, skip_serializing_if = "Option::is_none")]
2668    pub last_seen_ms: Option<u64>,
2669    #[serde(default, skip_serializing_if = "Option::is_none")]
2670    pub freshness_reason: Option<String>,
2671    pub materialized_member_count: u64,
2672}
2673
2674/// Response payload for `mob/hosts`.
2675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2677pub struct MobHostsResult {
2678    pub hosts: Vec<MobHostStatus>,
2679}
2680
2681/// One outstanding cross-host route-install obligation.
2682#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2684pub struct WireRouteInstallObligation {
2685    pub edge_a: String,
2686    pub edge_b: String,
2687    pub host: WireHostRef,
2688}
2689
2690/// Response payload for the route-install status projection.
2691#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2692#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2693pub struct MobRouteInstallsResult {
2694    pub outstanding: Vec<WireRouteInstallObligation>,
2695    pub complete: bool,
2696}
2697
2698/// Who attests a remotely-served projection (§7/§20): `HostClaimed` facts
2699/// are only what the owning host reports; `ControllingHostVerified` facts
2700/// were checked against controlling-machine records.
2701#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2702#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2703#[serde(rename_all = "snake_case")]
2704pub enum WireProjectionProvenance {
2705    HostClaimed,
2706    ControllingHostVerified,
2707}
2708
2709/// Equality adapter over a canonical wire transcript row.
2710///
2711/// `WireSessionMessage` deliberately derives no `PartialEq` (opaque
2712/// tool-call args ride `RawValue`), but the bridge reply chain that
2713/// carries history pages must be `Eq` (the comms envelope enums derive
2714/// it). Equality here is semantic-JSON equality of the serialized wire
2715/// form — exactly the fact reply comparison needs. Transparent: the wire
2716/// shape stays the raw row object.
2717#[derive(Debug, Clone, Serialize, Deserialize)]
2718#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2719#[serde(transparent)]
2720pub struct WireHistoryRow(pub super::session::WireSessionMessage);
2721
2722impl PartialEq for WireHistoryRow {
2723    fn eq(&self, other: &Self) -> bool {
2724        match (
2725            serde_json::to_value(&self.0),
2726            serde_json::to_value(&other.0),
2727        ) {
2728            (Ok(a), Ok(b)) => a == b,
2729            // Unreachable for transcript rows (their serialization is
2730            // infallible); kept fail-closed rather than laundering a
2731            // serialize error into equality.
2732            _ => false,
2733        }
2734    }
2735}
2736
2737impl Eq for WireHistoryRow {}
2738
2739/// Shared transcript page body used by both the bridge
2740/// `MemberHistoryPage` reply and the console `mob/member_history` result —
2741/// same page shape for local and remote members.
2742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2743#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2744#[serde(deny_unknown_fields)]
2745pub struct WireMemberHistoryPageBody {
2746    pub from_index: u64,
2747    pub messages: Vec<WireHistoryRow>,
2748    /// Total transcript length — carried so offset math (e.g. fork
2749    /// `LastMessages`) needs no extra round-trip.
2750    pub message_count: u64,
2751    #[serde(default, skip_serializing_if = "Option::is_none")]
2752    pub next_index: Option<u64>,
2753    pub complete: bool,
2754}
2755
2756impl WireMemberHistoryPageBody {
2757    /// THE page-shape projection (multi-host mobs DEC-P6E-6): the member
2758    /// host's `ReadMemberHistory` arm AND the controlling host's local
2759    /// history branch both call this, so "remote page read == local page
2760    /// shape" holds by construction, not by test luck.
2761    pub fn try_from_history_page(
2762        page: &meerkat_core::service::SessionHistoryPage,
2763    ) -> Result<Self, super::error::WireConversionError> {
2764        let invalid =
2765            |reason: String| super::error::WireConversionError::MemberHistoryPage { debug: reason };
2766        let message_count = u64::try_from(page.message_count).map_err(|_| {
2767            invalid(format!(
2768                "message_count {} exceeds the u64 wire domain",
2769                page.message_count
2770            ))
2771        })?;
2772        let from_index = u64::try_from(page.offset).map_err(|_| {
2773            invalid(format!(
2774                "offset {} exceeds the u64 wire domain",
2775                page.offset
2776            ))
2777        })?;
2778        let served = u64::try_from(page.messages.len()).map_err(|_| {
2779            invalid(format!(
2780                "served row count {} exceeds the u64 wire domain",
2781                page.messages.len()
2782            ))
2783        })?;
2784        let next_index = if page.has_more {
2785            if served == 0 {
2786                return Err(invalid(format!(
2787                    "page at offset {from_index} claims more rows but serves none"
2788                )));
2789            }
2790            Some(from_index.checked_add(served).ok_or_else(|| {
2791                invalid(format!(
2792                    "offset {from_index} plus served row count {served} exhausts the u64 cursor domain"
2793                ))
2794            })?)
2795        } else {
2796            None
2797        };
2798        Ok(Self {
2799            from_index,
2800            messages: page
2801                .messages
2802                .iter()
2803                .map(|message| {
2804                    WireHistoryRow(super::session::WireSessionMessage::from(message.clone()))
2805                })
2806                .collect(),
2807            message_count,
2808            next_index,
2809            complete: !page.has_more,
2810        })
2811    }
2812}
2813
2814/// Request payload for `mob/member_history`.
2815#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2816#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2817#[serde(deny_unknown_fields)]
2818pub struct MobMemberHistoryParams {
2819    pub mob_id: String,
2820    pub agent_identity: String,
2821    #[serde(default, skip_serializing_if = "Option::is_none")]
2822    pub from_index: Option<u64>,
2823    #[serde(default, skip_serializing_if = "Option::is_none")]
2824    pub limit: Option<u32>,
2825}
2826
2827/// Response payload for `mob/member_history`. Pagination facts live inside
2828/// `page` (one owner); this envelope adds the placement/provenance facts
2829/// only the controlling host knows.
2830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2831#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2832pub struct MobMemberHistoryResult {
2833    pub page: WireMemberHistoryPageBody,
2834    pub generation: u64,
2835    #[serde(default, skip_serializing_if = "Option::is_none")]
2836    pub placement: Option<WireHostRef>,
2837    pub provenance: WireProjectionProvenance,
2838}
2839
2840/// Request payload for `mob/bind_host`.
2841#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2842#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2843#[serde(deny_unknown_fields)]
2844pub struct MobBindHostParams {
2845    pub mob_id: String,
2846    pub descriptor: super::supervisor_bridge::WireHostBindingDescriptor,
2847}
2848
2849/// Response payload for `mob/bind_host`.
2850#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2851#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2852pub struct MobBindHostResult {
2853    pub host_id: WireHostRef,
2854    pub capabilities: WireHostCapabilityFlags,
2855    pub authority_epoch: u64,
2856}
2857
2858/// Request payload for `mob/revoke_host`.
2859#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2860#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2861#[serde(deny_unknown_fields)]
2862pub struct MobRevokeHostParams {
2863    pub mob_id: String,
2864    pub host_id: WireHostRef,
2865}
2866
2867/// Response payload for `mob/revoke_host`.
2868#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2869#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2870pub struct MobRevokeHostResult {
2871    pub host_id: WireHostRef,
2872    /// Agent identities whose materializations were released by the
2873    /// revocation.
2874    pub released_members: Vec<String>,
2875}
2876
2877/// One control-plane grant record (A9).
2878#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2879#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2880pub struct WireGrantRecord {
2881    pub principal: String,
2882    pub scopes: Vec<WireControlScope>,
2883    #[serde(default, skip_serializing_if = "Option::is_none")]
2884    pub expires_at_ms: Option<u64>,
2885}
2886
2887/// Request payload for `mob/grant_scopes`.
2888#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2889#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2890#[serde(deny_unknown_fields)]
2891pub struct MobGrantScopesParams {
2892    pub mob_id: String,
2893    pub principal: String,
2894    pub scopes: Vec<WireControlScope>,
2895    #[serde(default, skip_serializing_if = "Option::is_none")]
2896    pub expires_at_ms: Option<u64>,
2897}
2898
2899/// Response payload for `mob/grant_scopes`.
2900#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2901#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2902pub struct MobGrantScopesResult {
2903    pub record: WireGrantRecord,
2904}
2905
2906/// Request payload for `mob/revoke_scopes`. `scopes: None` revokes the
2907/// principal's entire grant.
2908#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2910#[serde(deny_unknown_fields)]
2911pub struct MobRevokeScopesParams {
2912    pub mob_id: String,
2913    pub principal: String,
2914    #[serde(default, skip_serializing_if = "Option::is_none")]
2915    pub scopes: Option<Vec<WireControlScope>>,
2916}
2917
2918/// Response payload for `mob/revoke_scopes`.
2919#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2920#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2921pub struct MobRevokeScopesResult {
2922    pub removed: bool,
2923}
2924
2925/// Response payload for `mob/grants`.
2926#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2927#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2928pub struct MobGrantsResult {
2929    pub grants: Vec<WireGrantRecord>,
2930}
2931
2932/// Typed `details` payload for `ErrorCode::ScopeDenied` (§17.4). Every
2933/// console surface serializes exactly this struct into the wire error's
2934/// `details` carrier; the field shape mirrors
2935/// `BridgeRejectionCause::ScopeDenied` so bridge and console denials speak
2936/// one shape. `presented` is the denied caller's own effective (post-expiry)
2937/// scope set — never another principal's grants.
2938#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2939#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2940#[serde(deny_unknown_fields)]
2941pub struct WireScopeDeniedDetail {
2942    pub required: WireControlScope,
2943    pub presented: Vec<WireControlScope>,
2944}
2945
2946/// Request payload for `mob/member_live_open` (§16.4). Result reuses
2947/// `LiveOpenResult` verbatim.
2948#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2949#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2950#[serde(deny_unknown_fields)]
2951pub struct MobMemberLiveOpenParams {
2952    pub mob_id: String,
2953    pub agent_identity: String,
2954    #[serde(default, skip_serializing_if = "Option::is_none")]
2955    pub turning_mode: Option<super::realtime::RealtimeTurningMode>,
2956    #[serde(default, skip_serializing_if = "Option::is_none")]
2957    pub transport: Option<super::live::LiveOpenTransport>,
2958}
2959
2960/// Request payload for `mob/member_live_close`. Close-what-you-name
2961/// (ADJ-P6B-15): `channel_id` is REQUIRED — a reconciling console can never
2962/// race-kill a channel a concurrent legitimate open just minted. The status
2963/// read has its own params type ([`MobMemberLiveStatusParams`]) because its
2964/// `channel_id` is optional by contract.
2965#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2966#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2967#[serde(deny_unknown_fields)]
2968pub struct MobMemberLiveChannelParams {
2969    pub mob_id: String,
2970    pub agent_identity: String,
2971    pub channel_id: String,
2972}
2973
2974/// Request payload for `mob/member_live_status` (§16.9, ADJ-P6B-2).
2975/// `channel_id: None` IS the reply-loss discovery primitive — it resolves
2976/// "the member's active channel" on the owning host, so an orphaned open's
2977/// id can be discovered and closed. A dedicated type (not
2978/// [`MobMemberLiveChannelParams`]) so the wire cannot amputate the
2979/// discovery read (DEC-P7A-2).
2980#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2981#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2982#[serde(deny_unknown_fields)]
2983pub struct MobMemberLiveStatusParams {
2984    pub mob_id: String,
2985    pub agent_identity: String,
2986    #[serde(default, skip_serializing_if = "Option::is_none")]
2987    pub channel_id: Option<String>,
2988}
2989
2990/// Request payload for `mob/hard_cancel_member` (DEC-P6E-8). `reason` is
2991/// REQUIRED: the handle verb demands one, and a handler-minted default
2992/// string would be handler-owned meaning (DEC-P7A-2).
2993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2994#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2995#[serde(deny_unknown_fields)]
2996pub struct MobHardCancelParams {
2997    pub mob_id: String,
2998    pub agent_identity: String,
2999    pub reason: String,
3000}
3001
3002/// Response payload for `mob/hard_cancel_member`. A dedicated type (not
3003/// [`MobForceCancelResult`] reuse) so the hard/force distinction stays
3004/// legible in SDK type names (DEC-P7A-2).
3005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3006#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3007pub struct MobHardCancelResult {
3008    pub cancelled: bool,
3009}
3010
3011/// Request payload for `mob/member_live_control`.
3012#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3013#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3014#[serde(deny_unknown_fields)]
3015pub struct MobMemberLiveControlParams {
3016    pub mob_id: String,
3017    pub agent_identity: String,
3018    pub channel_id: String,
3019    pub verb: super::supervisor_bridge::BridgeLiveControlVerb,
3020}
3021
3022#[cfg(test)]
3023#[allow(clippy::expect_used, clippy::panic)]
3024mod tests {
3025    use super::*;
3026
3027    #[test]
3028    fn mob_helper_params_carry_structural_auth_binding() {
3029        let parsed: MobSpawnHelperParams = serde_json::from_value(serde_json::json!({
3030            "mob_id": "mob-1",
3031            "prompt": "help",
3032            "agent_identity": "helper",
3033            "auth_binding": {
3034                "realm": "dev",
3035                "binding": "default_anthropic",
3036                "profile": "console"
3037            }
3038        }))
3039        .expect("spawn helper params parse");
3040        let auth_binding = parsed.auth_binding.expect("auth_binding should parse");
3041        assert_eq!(auth_binding.realm.as_str(), "dev");
3042        assert_eq!(auth_binding.binding.as_str(), "default_anthropic");
3043        assert_eq!(
3044            auth_binding
3045                .profile
3046                .as_ref()
3047                .map(|profile| profile.as_str()),
3048            Some("console")
3049        );
3050
3051        let parsed: MobForkHelperParams = serde_json::from_value(serde_json::json!({
3052            "mob_id": "mob-1",
3053            "source_member_id": "source",
3054            "prompt": "help",
3055            "agent_identity": "helper",
3056            "auth_binding": {
3057                "realm": "dev",
3058                "binding": "default_anthropic"
3059            }
3060        }))
3061        .expect("fork helper params parse");
3062        let auth_binding = parsed.auth_binding.expect("auth_binding should parse");
3063        assert_eq!(auth_binding.realm.as_str(), "dev");
3064        assert_eq!(auth_binding.binding.as_str(), "default_anthropic");
3065        assert!(auth_binding.profile.is_none());
3066    }
3067
3068    #[test]
3069    fn wire_mob_profile_parses_provider_fields_fail_closed() {
3070        // Minimal legacy payload (no new fields) still parses.
3071        let legacy: WireMobProfile =
3072            serde_json::from_str(r#"{"model":"claude-opus-4-8"}"#).expect("legacy profile parses");
3073        assert_eq!(legacy.provider, None);
3074        assert!(legacy.resume_overrides.is_empty());
3075
3076        // Typed provider + resume override vocabulary parse into closed enums.
3077        let full: WireMobProfile = serde_json::from_str(
3078            r#"{
3079                "model": "claude-internal-preview",
3080                "provider": "anthropic",
3081                "image_generation_provider": "gemini",
3082                "auto_compact_threshold": 60000,
3083                "resume_overrides": ["model", "provider"]
3084            }"#,
3085        )
3086        .expect("typed profile parses");
3087        assert_eq!(full.provider, Some(meerkat_core::Provider::Anthropic));
3088        assert_eq!(
3089            full.image_generation_provider,
3090            Some(meerkat_core::Provider::Gemini)
3091        );
3092        assert_eq!(
3093            full.resume_overrides,
3094            vec![
3095                WireMobResumeOverrideField::Model,
3096                WireMobResumeOverrideField::Provider
3097            ]
3098        );
3099
3100        // Fail-closed: unknown provider names and zero thresholds reject.
3101        assert!(
3102            serde_json::from_str::<WireMobProfile>(r#"{"model":"m","provider":"not-a-provider"}"#)
3103                .is_err(),
3104            "unknown provider names must fail closed at the wire boundary"
3105        );
3106        assert!(
3107            serde_json::from_str::<WireMobProfile>(r#"{"model":"m","auto_compact_threshold":0}"#)
3108                .is_err(),
3109            "zero auto_compact_threshold must fail closed at the wire boundary"
3110        );
3111        assert!(
3112            serde_json::from_str::<WireMobProfile>(
3113                r#"{"model":"m","resume_overrides":["everything"]}"#
3114            )
3115            .is_err(),
3116            "resume_overrides vocabulary is closed"
3117        );
3118    }
3119
3120    #[test]
3121    fn mob_definition_input_parses_custom_models() {
3122        let input: MobDefinitionInput = serde_json::from_str(
3123            r#"{
3124                "id": "m",
3125                "profiles": {"worker": {"model": "claude-internal-preview"}},
3126                "models": {
3127                    "claude-internal-preview": {
3128                        "provider": "anthropic",
3129                        "context_window": 500000,
3130                        "vision": true
3131                    }
3132                },
3133                "image_generation_provider": "openai"
3134            }"#,
3135        )
3136        .expect("definition with custom models parses");
3137        let model = input
3138            .models
3139            .get("claude-internal-preview")
3140            .expect("custom model present");
3141        assert_eq!(model.provider, meerkat_core::Provider::Anthropic);
3142        assert_eq!(model.context_window, Some(500_000));
3143        assert_eq!(model.vision, Some(true));
3144        assert_eq!(
3145            input.image_generation_provider,
3146            Some(meerkat_core::Provider::OpenAI)
3147        );
3148    }
3149
3150    #[test]
3151    fn wire_member_ref_round_trips_through_encode_decode() {
3152        let token = WireMemberRef::encode("mob-42", "worker-1");
3153        let (mob_id, agent_identity) = token.decode().expect("decode round-trips");
3154        assert_eq!(mob_id, "mob-42");
3155        assert_eq!(agent_identity, "worker-1");
3156    }
3157
3158    #[test]
3159    fn wire_member_ref_rejects_malformed_token() {
3160        let err = WireMemberRef::from_token("not-a-token-payload")
3161            .decode()
3162            .expect_err("malformed tokens must fail to decode");
3163        assert!(matches!(err, WireMemberRefError::Malformed));
3164    }
3165
3166    #[test]
3167    fn mob_spawn_many_spec_placement_is_optional_and_round_trips() {
3168        let placed: MobSpawnSpecParams = serde_json::from_value(serde_json::json!({
3169            "profile": "worker",
3170            "agent_identity": "w1",
3171            "placement": "host-b-peer"
3172        }))
3173        .expect("placed spawn-many spec parses");
3174        assert_eq!(
3175            placed.placement.as_ref().map(|host| host.0.as_str()),
3176            Some("host-b-peer")
3177        );
3178        assert_eq!(
3179            serde_json::to_value(&placed).expect("placed spawn-many spec serializes")["placement"],
3180            "host-b-peer"
3181        );
3182
3183        let local: MobSpawnSpecParams = serde_json::from_value(serde_json::json!({
3184            "profile": "worker",
3185            "agent_identity": "w2"
3186        }))
3187        .expect("local spawn-many spec parses");
3188        assert!(local.placement.is_none());
3189        assert!(
3190            serde_json::to_value(&local)
3191                .expect("local spawn-many spec serializes")
3192                .get("placement")
3193                .is_none(),
3194            "absent placement must remain omitted for source and wire compatibility"
3195        );
3196    }
3197
3198    #[test]
3199    fn mob_member_spec_placement_is_optional_and_round_trips() {
3200        let placed: MobMemberSpecWire = serde_json::from_value(serde_json::json!({
3201            "profile": "worker",
3202            "agent_identity": "w1",
3203            "placement": "host-b-peer"
3204        }))
3205        .expect("placed declarative member spec parses");
3206        assert_eq!(
3207            placed.placement.as_ref().map(|host| host.0.as_str()),
3208            Some("host-b-peer")
3209        );
3210
3211        let local: MobMemberSpecWire = serde_json::from_value(serde_json::json!({
3212            "profile": "worker",
3213            "agent_identity": "w2"
3214        }))
3215        .expect("local declarative member spec parses");
3216        assert!(local.placement.is_none());
3217    }
3218
3219    #[test]
3220    fn mob_member_spec_exposes_shared_surface_metadata() {
3221        let spec = MobMemberSpecWire {
3222            profile: "worker".into(),
3223            agent_identity: "w1".into(),
3224            initial_message: None,
3225            runtime_mode: None,
3226            backend: None,
3227            placement: None,
3228            binding: None,
3229            context: Some(serde_json::json!({"client_ref": "member-card"})),
3230            labels: Some(BTreeMap::from([("client.member_id".into(), "w1".into())])),
3231            additional_instructions: None,
3232            auto_wire_parent: None,
3233        };
3234
3235        let metadata = spec.surface_metadata();
3236        assert_eq!(
3237            metadata.labels.get("client.member_id").map(String::as_str),
3238            Some("w1")
3239        );
3240        assert_eq!(
3241            metadata.app_context,
3242            Some(serde_json::json!({"client_ref": "member-card"}))
3243        );
3244    }
3245
3246    #[test]
3247    fn mob_member_spec_surface_metadata_rejects_reserved_keys() {
3248        let spec = MobMemberSpecWire {
3249            profile: "worker".into(),
3250            agent_identity: "w1".into(),
3251            initial_message: None,
3252            runtime_mode: None,
3253            backend: None,
3254            placement: None,
3255            binding: None,
3256            context: None,
3257            labels: Some(BTreeMap::from([("mob_id".into(), "spoof".into())])),
3258            additional_instructions: None,
3259            auto_wire_parent: None,
3260        };
3261
3262        assert!(spec.validate_public_surface_metadata().is_err());
3263    }
3264
3265    #[test]
3266    fn mob_reconcile_failure_stage_is_typed_wire_enum() {
3267        let failure = MobReconcileFailureWire {
3268            agent_identity: "worker-1".into(),
3269            stage: WireMobReconcileStage::Spawn,
3270            error: WireMobError {
3271                code: MobSpawnManyFailureCause::ProfileNotFound,
3272                message: "spawn failed".into(),
3273            },
3274        };
3275
3276        let json = serde_json::to_value(&failure).expect("serialize failure");
3277        assert_eq!(json["stage"], "spawn");
3278        assert_eq!(json["error"]["code"], "profile_not_found");
3279        assert_eq!(json["error"]["message"], "spawn failed");
3280
3281        let round_trip: MobReconcileFailureWire =
3282            serde_json::from_value(json).expect("deserialize failure");
3283        assert_eq!(round_trip.stage, WireMobReconcileStage::Spawn);
3284        assert_eq!(
3285            round_trip.error.code,
3286            MobSpawnManyFailureCause::ProfileNotFound
3287        );
3288
3289        let err = serde_json::from_value::<MobReconcileFailureWire>(serde_json::json!({
3290            "agent_identity": "worker-1",
3291            "stage": "restart",
3292            "error": { "code": "profile_not_found", "message": "bad stage" }
3293        }))
3294        .expect_err("unknown reconcile stage must be rejected");
3295        assert!(err.to_string().contains("unknown variant"));
3296    }
3297
3298    #[test]
3299    fn mob_lifecycle_params_reject_unknown_action_string() {
3300        let err = serde_json::from_value::<MobLifecycleParams>(serde_json::json!({
3301            "mob_id": "mob-1",
3302            "action": "explode"
3303        }))
3304        .expect_err("unknown lifecycle actions must fail at the typed wire boundary");
3305
3306        assert!(
3307            err.to_string().contains("unknown variant"),
3308            "unexpected error: {err}"
3309        );
3310    }
3311
3312    #[test]
3313    fn mob_lifecycle_result_round_trips_typed_action() {
3314        let result = MobLifecycleResult {
3315            mob_id: "mob-1".into(),
3316            action: WireMobLifecycleAction::Complete,
3317            ok: true,
3318            destroy_report: None,
3319        };
3320
3321        let json = serde_json::to_value(&result).expect("serialize lifecycle result");
3322        assert_eq!(json["action"], "complete");
3323
3324        let round_trip: MobLifecycleResult =
3325            serde_json::from_value(json).expect("deserialize lifecycle result");
3326        assert_eq!(round_trip.action, WireMobLifecycleAction::Complete);
3327    }
3328
3329    #[test]
3330    fn mob_wire_members_batch_contract_is_local_edge_native() {
3331        let params: MobWireMembersBatchParams = serde_json::from_value(serde_json::json!({
3332            "mob_id": "mob-1",
3333            "edges": [
3334                { "a": "lead", "b": "worker-b" },
3335                { "a": "worker-a", "b": "lead" }
3336            ]
3337        }))
3338        .expect("batch wire params deserialize");
3339
3340        assert_eq!(params.mob_id, "mob-1");
3341        assert_eq!(params.edges.len(), 2);
3342        assert_eq!(params.edges[0].a, "lead");
3343        assert_eq!(params.edges[0].b, "worker-b");
3344
3345        let result = MobWireMembersBatchResult {
3346            requested: 2,
3347            wired: vec![MobWireMembersBatchEdge {
3348                a: "lead".into(),
3349                b: "worker-a".into(),
3350            }],
3351            already_wired: vec![MobWireMembersBatchEdge {
3352                a: "lead".into(),
3353                b: "worker-b".into(),
3354            }],
3355        };
3356        let json = serde_json::to_value(&result).expect("serialize batch wire result");
3357        assert_eq!(json["requested"], 2);
3358        assert_eq!(json["wired"][0]["a"], "lead");
3359        assert_eq!(json["already_wired"][0]["b"], "worker-b");
3360
3361        let err = serde_json::from_value::<MobWireMembersBatchParams>(serde_json::json!({
3362            "mob_id": "mob-1",
3363            "edges": [{ "member": "lead", "peer": "worker-a" }]
3364        }))
3365        .expect_err("mixed local/external mob/wire shape must not deserialize");
3366        let message = err.to_string();
3367        assert!(
3368            message.contains("unknown field `member`") || message.contains("missing field `a`"),
3369            "unexpected error: {message}"
3370        );
3371    }
3372
3373    #[test]
3374    fn mob_spawn_many_result_entry_uses_typed_status_result_envelope() {
3375        let member_ref = WireMemberRef::encode("mob-1", "worker-1");
3376        let entry = MobSpawnManyResultEntry::spawned("worker-1", member_ref.clone());
3377
3378        let json = serde_json::to_value(&entry).expect("serialize typed spawn_many row");
3379        assert_eq!(json["status"], "spawned");
3380        assert_eq!(json["result"]["agent_identity"], "worker-1");
3381        assert_eq!(json["result"]["member_ref"], member_ref.as_str());
3382        assert!(json.get("ok").is_none());
3383        assert!(json.get("error").is_none());
3384
3385        let round_trip: MobSpawnManyResultEntry =
3386            serde_json::from_value(json).expect("deserialize typed spawn_many row");
3387        assert_eq!(round_trip, entry);
3388
3389        let failed = MobSpawnManyResultEntry::failed(
3390            MobSpawnManyFailureCause::ProfileNotFound,
3391            "profile missing",
3392        );
3393        let json = serde_json::to_value(&failed).expect("serialize typed failed spawn_many row");
3394        assert_eq!(json["status"], "failed");
3395        assert_eq!(json["result"]["cause"], "profile_not_found");
3396        assert_eq!(json["result"]["message"], "profile missing");
3397        assert!(json.get("ok").is_none());
3398        assert!(json.get("error").is_none());
3399
3400        let round_trip: MobSpawnManyResultEntry =
3401            serde_json::from_value(json).expect("deserialize typed failed spawn_many row");
3402        assert_eq!(round_trip, failed);
3403    }
3404
3405    #[test]
3406    fn mob_spawn_many_result_entry_rejects_legacy_or_malformed_envelopes() {
3407        let legacy = serde_json::json!({
3408            "ok": true,
3409            "agent_identity": "worker-1",
3410            "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3411        });
3412        let err = serde_json::from_value::<MobSpawnManyResultEntry>(legacy)
3413            .expect_err("legacy ok carrier must not deserialize");
3414        assert!(
3415            err.to_string().contains("missing field `status`")
3416                || err.to_string().contains("unknown field"),
3417            "unexpected error: {err}"
3418        );
3419
3420        let missing_result = serde_json::json!({
3421            "status": "spawned"
3422        });
3423        let err = serde_json::from_value::<MobSpawnManyResultEntry>(missing_result)
3424            .expect_err("missing typed result must fail closed");
3425        assert!(
3426            err.to_string().contains("missing field `result`"),
3427            "unexpected error: {err}"
3428        );
3429
3430        let unknown_status = serde_json::json!({
3431            "status": "ok",
3432            "result": {
3433                "agent_identity": "worker-1",
3434                "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3435            }
3436        });
3437        let err = serde_json::from_value::<MobSpawnManyResultEntry>(unknown_status)
3438            .expect_err("unknown typed status must fail closed");
3439        assert!(
3440            err.to_string().contains("unknown variant"),
3441            "unexpected error: {err}"
3442        );
3443
3444        let mismatched = serde_json::json!({
3445            "status": "spawned",
3446            "result": {
3447                "cause": "profile_not_found",
3448                "message": "profile missing"
3449            }
3450        });
3451        let err = serde_json::from_value::<MobSpawnManyResultEntry>(mismatched)
3452            .expect_err("status/result mismatch must fail closed");
3453        assert!(
3454            err.to_string()
3455                .contains("status spawned requires spawned result"),
3456            "unexpected error: {err}"
3457        );
3458
3459        let message_only_failure = serde_json::json!({
3460            "status": "failed",
3461            "result": {
3462                "message": "profile missing"
3463            }
3464        });
3465        let err = serde_json::from_value::<MobSpawnManyResultEntry>(message_only_failure)
3466            .expect_err("string-only failure result must fail closed");
3467        assert!(
3468            err.to_string().contains("data did not match any variant")
3469                || err.to_string().contains("missing field `cause`"),
3470            "unexpected error: {err}"
3471        );
3472
3473        let unknown_failure_cause = serde_json::json!({
3474            "status": "failed",
3475            "result": {
3476                "cause": "future_failure",
3477                "message": "future failure"
3478            }
3479        });
3480        let err = serde_json::from_value::<MobSpawnManyResultEntry>(unknown_failure_cause)
3481            .expect_err("unknown failure cause must fail closed");
3482        assert!(
3483            err.to_string().contains("data did not match any variant")
3484                || err.to_string().contains("unknown variant"),
3485            "unexpected error: {err}"
3486        );
3487    }
3488
3489    #[test]
3490    fn mob_wire_params_reject_legacy_local_target_shape() {
3491        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3492            "mob_id": "mob-1",
3493            "local": "member-a",
3494            "target": { "local": "member-b" }
3495        }))
3496        .expect_err("legacy local/target shape must be rejected");
3497
3498        let msg = err.to_string();
3499        assert!(
3500            msg.contains("unknown field `local`") || msg.contains("missing field `member`"),
3501            "unexpected error: {msg}"
3502        );
3503    }
3504
3505    #[test]
3506    fn mob_wire_params_accept_canonical_external_peer_identity() {
3507        let params = serde_json::from_value::<MobWireParams>(serde_json::json!({
3508            "mob_id": "mob-1",
3509            "member": "member-a",
3510            "peer": {
3511                "external": {
3512                    "name": "external-worker",
3513                    "address": "inproc://external-worker",
3514                    "identity": {
3515                        "kind": "ed25519_public_key",
3516                        "public_key": "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
3517                    }
3518                }
3519            }
3520        }))
3521        .expect("canonical external peer identity should deserialize");
3522
3523        let MobPeerTarget::External(spec) = params.peer else {
3524            panic!("expected external peer target");
3525        };
3526        assert_eq!(spec.name, "external-worker");
3527    }
3528
3529    #[test]
3530    fn mob_wire_params_reject_raw_external_peer_id_shape() {
3531        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3532            "mob_id": "mob-1",
3533            "member": "member-a",
3534            "peer": {
3535                "external": {
3536                    "name": "external-worker",
3537                    "peer_id": meerkat_core::comms::PeerId::from_ed25519_pubkey(&[7u8; 32]).to_string(),
3538                    "address": "inproc://external-worker",
3539                    "pubkey": vec![7u8; 32]
3540                }
3541            }
3542        }))
3543        .expect_err("raw peer_id/pubkey external peer shape must be rejected");
3544
3545        let msg = err.to_string();
3546        assert!(
3547            msg.contains("peer_id") || msg.contains("identity"),
3548            "unexpected error: {msg}"
3549        );
3550    }
3551
3552    #[test]
3553    fn mob_wire_params_reject_missing_external_peer_pubkey_material() {
3554        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3555            "mob_id": "mob-1",
3556            "member": "member-a",
3557            "peer": {
3558                "external": {
3559                    "name": "external-worker",
3560                    "address": "inproc://external-worker",
3561                    "identity": {
3562                        "kind": "ed25519_public_key"
3563                    }
3564                }
3565            }
3566        }))
3567        .expect_err("missing external peer pubkey material must fail closed");
3568
3569        let msg = err.to_string();
3570        assert!(
3571            msg.contains("public_key") || msg.contains("identity"),
3572            "unexpected error: {msg}"
3573        );
3574    }
3575
3576    #[test]
3577    fn runtime_binding_accepts_canonical_external_peer_identity() {
3578        let binding = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3579            "kind": "external",
3580            "address": "inproc://external-worker",
3581            "identity": {
3582                "kind": "ed25519_public_key",
3583                "public_key": "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
3584            }
3585        }))
3586        .expect("canonical external runtime binding identity should deserialize");
3587
3588        let WireRuntimeBinding::External {
3589            identity, address, ..
3590        } = binding
3591        else {
3592            panic!("expected external runtime binding");
3593        };
3594        assert_eq!(address, "inproc://external-worker");
3595        assert_eq!(
3596            identity.resolve().expect("identity resolves").pubkey,
3597            [7u8; 32]
3598        );
3599    }
3600
3601    #[test]
3602    fn runtime_binding_rejects_raw_external_peer_id_shape() {
3603        let err = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3604            "kind": "external",
3605            "peer_id": meerkat_core::comms::PeerId::from_ed25519_pubkey(&[7u8; 32]).to_string(),
3606            "address": "inproc://external-worker",
3607            "pubkey": vec![7u8; 32]
3608        }))
3609        .expect_err("raw peer_id/pubkey external runtime binding shape must be rejected");
3610
3611        let msg = err.to_string();
3612        assert!(
3613            msg.contains("peer_id") || msg.contains("identity"),
3614            "unexpected error: {msg}"
3615        );
3616    }
3617
3618    #[test]
3619    fn runtime_binding_rejects_missing_external_peer_pubkey_material() {
3620        let err = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3621            "kind": "external",
3622            "address": "inproc://external-worker",
3623            "identity": {
3624                "kind": "ed25519_public_key"
3625            }
3626        }))
3627        .expect_err("missing external runtime binding pubkey material must fail closed");
3628
3629        let msg = err.to_string();
3630        assert!(
3631            msg.contains("public_key") || msg.contains("identity"),
3632            "unexpected error: {msg}"
3633        );
3634    }
3635
3636    #[test]
3637    fn mob_turn_start_params_capture_turn_override_fields() {
3638        let params = serde_json::from_value::<MobTurnStartParams>(serde_json::json!({
3639            "mob_id": "mob-1",
3640            "agent_identity": "worker",
3641            "prompt": "continue",
3642            "output_schema": { "type": "object" },
3643            "structured_output_retries": 2
3644        }))
3645        .expect("turn_start should accept explicit turn override fields");
3646
3647        assert_eq!(params.mob_id, "mob-1");
3648        assert_eq!(params.agent_identity, "worker");
3649        assert_eq!(params.prompt, WireContentInput::Text("continue".into()));
3650        assert_eq!(
3651            params.output_schema,
3652            Some(serde_json::json!({ "type": "object" }))
3653        );
3654        assert_eq!(params.structured_output_retries, Some(2));
3655
3656        let err = serde_json::from_value::<MobTurnStartParams>(serde_json::json!({
3657            "mob_id": "mob-1",
3658            "agent_identity": "worker",
3659            "prompt": "continue",
3660            "unknown_override": true
3661        }))
3662        .expect_err("turn_start must reject unknown override fields");
3663        assert!(
3664            err.to_string().contains("unknown field"),
3665            "unexpected error: {err}"
3666        );
3667    }
3668
3669    #[test]
3670    fn mob_create_params_reject_reserved_runtime_lifecycle_fields() {
3671        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3672            "definition": {
3673                "id": "mob-1",
3674                "owner_runtime_binding": "runtime:worker:0",
3675                "profiles": {
3676                    "worker": { "model": "claude-sonnet-4-6" }
3677                }
3678            }
3679        }))
3680        .expect_err("reserved runtime lifecycle fields must be rejected");
3681
3682        assert!(
3683            err.to_string()
3684                .contains("unknown field `owner_runtime_binding`"),
3685            "unexpected error: {err}"
3686        );
3687    }
3688
3689    #[test]
3690    fn mob_create_params_reject_reserved_runtime_bridge_owner_field() {
3691        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3692            "definition": {
3693                "id": "mob-1",
3694                "owner_transport_binding": "transport:worker:0",
3695                "profiles": {
3696                    "worker": { "model": "claude-sonnet-4-6" }
3697                }
3698            }
3699        }))
3700        .expect_err("reserved runtime bridge owner field must be rejected");
3701
3702        assert!(
3703            err.to_string()
3704                .contains("unknown field `owner_transport_binding`"),
3705            "unexpected error: {err}"
3706        );
3707    }
3708
3709    #[test]
3710    fn mob_create_params_reject_internal_profile_tool_bundles() {
3711        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3712            "definition": {
3713                "id": "mob-1",
3714                "profiles": {
3715                    "worker": {
3716                        "model": "claude-sonnet-4-6",
3717                        "tools": {
3718                            "rust_bundles": ["internal-only"]
3719                        }
3720                    }
3721                }
3722            }
3723        }))
3724        .expect_err("internal rust tool bundles must be rejected");
3725
3726        // With untagged MobProfileBindingInput, the error message is about
3727        // no variant matching rather than the specific unknown field.
3728        assert!(
3729            err.to_string().contains("did not match any variant")
3730                || err.to_string().contains("unknown field `rust_bundles`"),
3731            "unexpected error: {err}"
3732        );
3733    }
3734
3735    #[test]
3736    fn mob_create_params_accept_typed_nested_flow_definition() {
3737        let params = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3738            "definition": {
3739                "id": "mob-1",
3740                "profiles": {
3741                    "worker": { "model": "claude-sonnet-4-6" }
3742                },
3743                "flows": {
3744                    "review": {
3745                        "description": "review flow",
3746                        "steps": {
3747                            "draft": {
3748                                "role": "worker",
3749                                "message": "draft it"
3750                            }
3751                        }
3752                    }
3753                }
3754            }
3755        }))
3756        .expect("typed nested flow definition should parse");
3757
3758        assert_eq!(
3759            params.definition.flows["review"].steps["draft"].role,
3760            "worker"
3761        );
3762    }
3763
3764    /// DEC-1 absence pin: `budget_split_policy` was deleted (functionally
3765    /// unconsumed; accepted-then-discarded budget instructions are a
3766    /// fail-quiet containment lie). A payload still carrying it FAILS
3767    /// decode — replaces the old parity fixtures.
3768    #[test]
3769    fn mob_spawn_params_reject_deleted_budget_split_policy() {
3770        let err = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3771            "mob_id": "mob-1",
3772            "profile": "worker",
3773            "agent_identity": "worker-1",
3774            "budget_split_policy": { "type": "equal" }
3775        }))
3776        .expect_err("deleted budget_split_policy must fail closed at the wire boundary");
3777        assert!(
3778            err.to_string()
3779                .contains("unknown field `budget_split_policy`"),
3780            "unexpected error: {err}"
3781        );
3782    }
3783
3784    /// ADJ-7 pin: `placement` is an optional comms `PeerId` string; absent
3785    /// stays `None` (byte-compat with pre-placement payloads) and `None`
3786    /// never serializes.
3787    #[test]
3788    fn mob_spawn_params_placement_round_trips_and_defaults_absent() {
3789        let params = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3790            "mob_id": "mob-1",
3791            "profile": "worker",
3792            "agent_identity": "worker-1"
3793        }))
3794        .expect("placement-less params must decode");
3795        assert_eq!(params.placement, None);
3796        let encoded = serde_json::to_value(&params).expect("serialize params");
3797        assert!(
3798            encoded.get("placement").is_none(),
3799            "absent placement must not serialize: {encoded}"
3800        );
3801
3802        let params = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3803            "mob_id": "mob-1",
3804            "profile": "worker",
3805            "agent_identity": "worker-1",
3806            "placement": "host-peer-b"
3807        }))
3808        .expect("placed params must decode");
3809        assert_eq!(params.placement.as_deref(), Some("host-peer-b"));
3810        let encoded = serde_json::to_value(&params).expect("serialize params");
3811        assert_eq!(encoded["placement"], serde_json::json!("host-peer-b"));
3812    }
3813
3814    fn minimal_member_status() -> MobMemberStatusResult {
3815        MobMemberStatusResult {
3816            status: WireMobMemberStatus::Active,
3817            member_ref: WireMemberRef::encode("mob-1", "worker-1"),
3818            output_preview: None,
3819            error: None,
3820            tokens_used: 0,
3821            is_final: false,
3822            current_session_id: None,
3823            peer_connectivity: None,
3824            kickoff: None,
3825            external_member: None,
3826            resolved_capabilities: None,
3827            progress: None,
3828            activity: None,
3829            detached_jobs: None,
3830            placement: None,
3831            control_reachability: None,
3832            comms_reachability: None,
3833            last_seen_ms: None,
3834            freshness_reason: None,
3835            lifecycle_capabilities: None,
3836            non_portable_disabled: None,
3837        }
3838    }
3839
3840    /// Byte-compat with released SDKs: every multi-host field skips when
3841    /// `None`, and a pre-field JSON payload still decodes.
3842    #[test]
3843    fn member_status_multi_host_fields_skip_when_absent_and_decode_legacy() {
3844        let value = serde_json::to_value(minimal_member_status()).expect("serialize member status");
3845        for absent in [
3846            "placement",
3847            "control_reachability",
3848            "comms_reachability",
3849            "last_seen_ms",
3850            "freshness_reason",
3851            "lifecycle_capabilities",
3852            "non_portable_disabled",
3853        ] {
3854            assert!(
3855                value.get(absent).is_none(),
3856                "absent {absent} must be omitted from the wire form: {value}"
3857            );
3858        }
3859
3860        // Pre-multi-host payload (as a released SDK would emit) decodes.
3861        let legacy = serde_json::json!({
3862            "status": "active",
3863            "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3864            "tokens_used": 3,
3865            "is_final": false,
3866        });
3867        let decoded: MobMemberStatusResult =
3868            serde_json::from_value(legacy).expect("legacy member status decodes");
3869        assert!(decoded.placement.is_none());
3870        assert!(decoded.lifecycle_capabilities.is_none());
3871    }
3872
3873    /// SD-5 pin: placement facts live ONLY at the typed keys — never
3874    /// inside the opaque `external_member` value.
3875    #[test]
3876    fn member_status_carries_placement_only_at_typed_keys() {
3877        let mut status = minimal_member_status();
3878        status.placement = Some(WireHostRef("host-b-peer".to_string()));
3879        status.control_reachability = Some(WireReachability::Stale);
3880        status.comms_reachability = Some(WireReachability::Reachable);
3881        status.last_seen_ms = Some(1_234);
3882        status.freshness_reason = Some("pump idle".to_string());
3883        status.lifecycle_capabilities = Some(WireMemberLifecycleCapabilities {
3884            transcript_edits: false,
3885            revisions: false,
3886            resume_after_restart: true,
3887        });
3888        status.non_portable_disabled = Some(vec![
3889            super::super::portable_spec::WireNonPortableResourceKind::WorkgraphTools,
3890        ]);
3891        status.external_member = Some(serde_json::json!({"endpoint": "tcp://10.0.0.2:7101"}));
3892
3893        let value = serde_json::to_value(&status).expect("serialize member status");
3894        assert_eq!(value["placement"], serde_json::json!("host-b-peer"));
3895        assert_eq!(value["control_reachability"], serde_json::json!("stale"));
3896        assert_eq!(
3897            value["non_portable_disabled"],
3898            serde_json::json!(["workgraph_tools"])
3899        );
3900        assert!(
3901            value["external_member"].get("placement").is_none(),
3902            "placement must not ride the opaque external_member value (SD-5)"
3903        );
3904
3905        let decoded: MobMemberStatusResult =
3906            serde_json::from_value(value).expect("decode member status");
3907        assert_eq!(decoded.placement, status.placement);
3908        assert_eq!(decoded.control_reachability, status.control_reachability);
3909    }
3910
3911    #[test]
3912    fn control_scope_and_reachability_round_trip_snake_case() {
3913        let scopes: &[(WireControlScope, &str)] = &[
3914            (WireControlScope::List, "list"),
3915            (WireControlScope::ReadHistory, "read_history"),
3916            (WireControlScope::SubscribeEvents, "subscribe_events"),
3917            (WireControlScope::SendCommand, "send_command"),
3918            (WireControlScope::Cancel, "cancel"),
3919            (WireControlScope::Retire, "retire"),
3920            (WireControlScope::WireTopology, "wire_topology"),
3921            (WireControlScope::Live, "live"),
3922            (WireControlScope::AdminHost, "admin_host"),
3923            (WireControlScope::AdminGrants, "admin_grants"),
3924        ];
3925        for (scope, expected) in scopes {
3926            let value = serde_json::to_value(scope).expect("serialize scope");
3927            assert_eq!(value, serde_json::json!(expected));
3928            let decoded: WireControlScope = serde_json::from_value(value).expect("decode scope");
3929            assert_eq!(decoded, *scope);
3930        }
3931        assert!(
3932            serde_json::from_value::<WireControlScope>(serde_json::json!("admin")).is_err(),
3933            "unknown scopes must fail decode (closed vocabulary)"
3934        );
3935
3936        let classes: &[(WireReachability, &str)] = &[
3937            (WireReachability::Reachable, "reachable"),
3938            (WireReachability::Stale, "stale"),
3939            (WireReachability::Unreachable, "unreachable"),
3940            (WireReachability::Unknown, "unknown"),
3941        ];
3942        for (class, expected) in classes {
3943            let value = serde_json::to_value(class).expect("serialize reachability");
3944            assert_eq!(value, serde_json::json!(expected));
3945            let decoded: WireReachability =
3946                serde_json::from_value(value).expect("decode reachability");
3947            assert_eq!(decoded, *class);
3948        }
3949    }
3950
3951    #[test]
3952    fn scope_denied_detail_round_trips_snake_case_and_denies_unknown_fields() {
3953        let detail = WireScopeDeniedDetail {
3954            required: WireControlScope::AdminGrants,
3955            presented: vec![WireControlScope::List, WireControlScope::SendCommand],
3956        };
3957        let value = serde_json::to_value(&detail).expect("serialize detail");
3958        assert_eq!(
3959            value,
3960            serde_json::json!({
3961                "required": "admin_grants",
3962                "presented": ["list", "send_command"],
3963            })
3964        );
3965        let decoded: WireScopeDeniedDetail = serde_json::from_value(value).expect("decode detail");
3966        assert_eq!(decoded, detail);
3967
3968        assert!(
3969            serde_json::from_value::<WireScopeDeniedDetail>(serde_json::json!({
3970                "required": "admin_grants",
3971                "presented": [],
3972                "reason": "extra",
3973            }))
3974            .is_err(),
3975            "unknown fields must be rejected (deny_unknown_fields)"
3976        );
3977    }
3978
3979    #[test]
3980    fn host_status_and_grants_round_trip() {
3981        let host = MobHostStatus {
3982            host_id: WireHostRef("host-b-peer".to_string()),
3983            endpoint: Some("tcp://10.0.0.2:7100".to_string()),
3984            bind_phase: WireHostBindPhase::Bound,
3985            authority_epoch: Some(4),
3986            capabilities: Some(WireHostCapabilityFlags {
3987                protocol_min: 2,
3988                protocol_max: 4,
3989                engine_version: "0.7.22".to_string(),
3990                durable_sessions: true,
3991                autonomous_members: true,
3992                hard_cancel_member: false,
3993                tracked_input_cancel: false,
3994                memory_store: false,
3995                mcp: true,
3996                resolvable_providers: std::collections::BTreeSet::from(["anthropic".to_string()]),
3997                approval_forwarding: false,
3998                live_endpoint: None,
3999            }),
4000            control_reachability: Some(WireReachability::Reachable),
4001            last_seen_ms: Some(250),
4002            freshness_reason: None,
4003            materialized_member_count: 2,
4004        };
4005        let result = MobHostsResult { hosts: vec![host] };
4006        let value = serde_json::to_value(&result).expect("serialize hosts");
4007        assert_eq!(value["hosts"][0]["bind_phase"], serde_json::json!("bound"));
4008        let decoded: MobHostsResult = serde_json::from_value(value).expect("decode hosts");
4009        assert_eq!(decoded, result);
4010
4011        // A Requested-phase host commits nothing: the ceremony facts are
4012        // typed-absent, never fabricated empties.
4013        let requested = MobHostStatus {
4014            host_id: WireHostRef("host-c-peer".to_string()),
4015            endpoint: None,
4016            bind_phase: WireHostBindPhase::Requested,
4017            authority_epoch: None,
4018            capabilities: None,
4019            control_reachability: None,
4020            last_seen_ms: None,
4021            freshness_reason: None,
4022            materialized_member_count: 0,
4023        };
4024        let value = serde_json::to_value(&requested).expect("serialize requested host");
4025        assert_eq!(value["bind_phase"], serde_json::json!("requested"));
4026        assert!(value.get("endpoint").is_none());
4027        assert!(value.get("authority_epoch").is_none());
4028        assert!(value.get("capabilities").is_none());
4029        let decoded: MobHostStatus = serde_json::from_value(value).expect("decode requested host");
4030        assert_eq!(decoded, requested);
4031
4032        let record = WireGrantRecord {
4033            principal: "console:luka".to_string(),
4034            scopes: vec![WireControlScope::List, WireControlScope::Live],
4035            expires_at_ms: None,
4036        };
4037        let value = serde_json::to_value(&record).expect("serialize grant");
4038        assert!(
4039            value.get("expires_at_ms").is_none(),
4040            "absent expiry must be omitted"
4041        );
4042        let decoded: WireGrantRecord = serde_json::from_value(value).expect("decode grant");
4043        assert_eq!(decoded, record);
4044    }
4045
4046    #[test]
4047    fn member_history_result_round_trips_with_provenance() {
4048        let result = MobMemberHistoryResult {
4049            page: WireMemberHistoryPageBody {
4050                from_index: 5,
4051                messages: Vec::new(),
4052                message_count: 12,
4053                next_index: Some(10),
4054                complete: false,
4055            },
4056            generation: 2,
4057            placement: Some(WireHostRef("host-b-peer".to_string())),
4058            provenance: WireProjectionProvenance::HostClaimed,
4059        };
4060        let value = serde_json::to_value(&result).expect("serialize history result");
4061        assert_eq!(value["provenance"], serde_json::json!("host_claimed"));
4062        assert_eq!(value["page"]["message_count"], serde_json::json!(12));
4063        let decoded: MobMemberHistoryResult =
4064            serde_json::from_value(value.clone()).expect("decode history result");
4065        let reencoded = serde_json::to_value(&decoded).expect("reserialize history result");
4066        assert_eq!(value, reencoded);
4067    }
4068
4069    #[test]
4070    fn member_history_projection_rejects_non_advancing_page() {
4071        let page = meerkat_core::service::SessionHistoryPage {
4072            session_id: meerkat_core::SessionId::new(),
4073            message_count: 1,
4074            offset: 0,
4075            limit: Some(1),
4076            has_more: true,
4077            messages: Vec::new(),
4078        };
4079        let error = WireMemberHistoryPageBody::try_from_history_page(&page)
4080            .expect_err("a page that claims more rows must advance its cursor");
4081        assert!(matches!(
4082            error,
4083            crate::wire::error::WireConversionError::MemberHistoryPage { debug }
4084                if debug.contains("serves none")
4085        ));
4086    }
4087
4088    #[cfg(target_pointer_width = "64")]
4089    #[test]
4090    fn member_history_projection_rejects_exhausted_cursor() {
4091        let page = meerkat_core::service::SessionHistoryPage {
4092            session_id: meerkat_core::SessionId::new(),
4093            message_count: usize::MAX,
4094            offset: usize::MAX,
4095            limit: Some(2),
4096            has_more: true,
4097            messages: vec![
4098                meerkat_core::types::Message::User(meerkat_core::types::UserMessage::text("first")),
4099                meerkat_core::types::Message::User(meerkat_core::types::UserMessage::text(
4100                    "second",
4101                )),
4102            ],
4103        };
4104        let error = WireMemberHistoryPageBody::try_from_history_page(&page)
4105            .expect_err("MAX has no representable member-history successor");
4106        assert!(matches!(
4107            error,
4108            crate::wire::error::WireConversionError::MemberHistoryPage { debug }
4109                if debug.contains("exhausts the u64 cursor domain")
4110        ));
4111    }
4112
4113    /// T-A1 (DEC-P7A-2): the two phase-7 params additions round-trip, fail
4114    /// closed on unknown fields, and the live-status discovery read stays
4115    /// expressible (`channel_id` absent ⇒ `None`) while close keeps its
4116    /// required id.
4117    #[test]
4118    fn hard_cancel_params_round_trip_and_reject_unknown_fields() {
4119        let params = MobHardCancelParams {
4120            mob_id: "mob-1".to_string(),
4121            agent_identity: "worker".to_string(),
4122            reason: "operator interrupt".to_string(),
4123        };
4124        let value = serde_json::to_value(&params).expect("serialize hard-cancel params");
4125        let decoded: MobHardCancelParams =
4126            serde_json::from_value(value).expect("decode hard-cancel params");
4127        assert_eq!(decoded, params);
4128
4129        serde_json::from_value::<MobHardCancelParams>(serde_json::json!({
4130            "mob_id": "mob-1",
4131            "agent_identity": "worker",
4132            "reason": "x",
4133            "force": true,
4134        }))
4135        .expect_err("unknown field must be rejected");
4136
4137        // `reason` is required — the handle verb demands one, and a
4138        // handler-minted default would be handler-owned meaning.
4139        serde_json::from_value::<MobHardCancelParams>(serde_json::json!({
4140            "mob_id": "mob-1",
4141            "agent_identity": "worker",
4142        }))
4143        .expect_err("missing reason must be rejected");
4144
4145        let result = MobHardCancelResult { cancelled: true };
4146        let value = serde_json::to_value(&result).expect("serialize hard-cancel result");
4147        assert_eq!(value, serde_json::json!({ "cancelled": true }));
4148    }
4149
4150    #[test]
4151    fn member_live_status_params_keep_the_discovery_read() {
4152        // Absent channel_id parses to None — the ADJ-P6B-2 reply-loss
4153        // discovery primitive stays expressible on the wire.
4154        let discovery: MobMemberLiveStatusParams = serde_json::from_value(serde_json::json!({
4155            "mob_id": "mob-1",
4156            "agent_identity": "worker",
4157        }))
4158        .expect("discovery status params parse");
4159        assert_eq!(discovery.channel_id, None);
4160        let value = serde_json::to_value(&discovery).expect("serialize discovery params");
4161        assert!(
4162            value.get("channel_id").is_none(),
4163            "absent channel_id must be omitted"
4164        );
4165
4166        let named: MobMemberLiveStatusParams = serde_json::from_value(serde_json::json!({
4167            "mob_id": "mob-1",
4168            "agent_identity": "worker",
4169            "channel_id": "chan-7",
4170        }))
4171        .expect("named status params parse");
4172        assert_eq!(named.channel_id.as_deref(), Some("chan-7"));
4173
4174        serde_json::from_value::<MobMemberLiveStatusParams>(serde_json::json!({
4175            "mob_id": "mob-1",
4176            "agent_identity": "worker",
4177            "chan": "chan-7",
4178        }))
4179        .expect_err("unknown field must be rejected");
4180
4181        // Close-what-you-name (ADJ-P6B-15): close still REQUIRES the id.
4182        serde_json::from_value::<MobMemberLiveChannelParams>(serde_json::json!({
4183            "mob_id": "mob-1",
4184            "agent_identity": "worker",
4185        }))
4186        .expect_err("close without channel_id must be rejected");
4187    }
4188}