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    // Multi-host projections (SD-5): placement and reachability are TYPED
2077    // fields here — never smuggled inside the opaque `external_member`
2078    // value. All optional + absent-omitted for byte-compat with released
2079    // SDKs.
2080    /// Host the member is materialized on; `None` = controlling host.
2081    #[serde(default, skip_serializing_if = "Option::is_none")]
2082    pub placement: Option<WireHostRef>,
2083    /// Bridge control-plane reachability of the owning host/member.
2084    #[serde(default, skip_serializing_if = "Option::is_none")]
2085    pub control_reachability: Option<WireReachability>,
2086    /// Comms data-plane reachability of the member peer.
2087    #[serde(default, skip_serializing_if = "Option::is_none")]
2088    pub comms_reachability: Option<WireReachability>,
2089    /// Observer-local monotonic ms since last verified contact — never a
2090    /// remote wall-clock comparison.
2091    #[serde(default, skip_serializing_if = "Option::is_none")]
2092    pub last_seen_ms: Option<u64>,
2093    #[serde(default, skip_serializing_if = "Option::is_none")]
2094    pub freshness_reason: Option<String>,
2095    /// Lifecycle capability flags for this member's placement (§19.L7).
2096    #[serde(default, skip_serializing_if = "Option::is_none")]
2097    pub lifecycle_capabilities: Option<WireMemberLifecycleCapabilities>,
2098    /// Reserved portability projection; placed v1 members report an empty
2099    /// list because non-portable resources are rejected, never disabled.
2100    #[serde(default, skip_serializing_if = "Option::is_none")]
2101    pub non_portable_disabled: Option<Vec<super::portable_spec::WireNonPortableResourceKind>>,
2102}
2103
2104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2106#[serde(rename_all = "snake_case")]
2107pub enum WireMemberRunState {
2108    Idle,
2109    RunOpen,
2110    Unknown,
2111}
2112
2113#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2115#[serde(rename_all = "snake_case")]
2116pub enum WireMemberHealthClass {
2117    Healthy,
2118    Degraded,
2119    Wedged,
2120    Unknown,
2121}
2122
2123#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2124#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2125#[serde(rename_all = "snake_case")]
2126pub enum WireMemberProgressEvent {
2127    ExecutionAdvanced,
2128    BecameIdle,
2129    Unchanged,
2130}
2131
2132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2134pub struct WireMemberProgressSnapshot {
2135    pub run_state: WireMemberRunState,
2136    pub in_flight_work: u64,
2137    pub last_progress_at_ms: u64,
2138    pub last_progress_event: WireMemberProgressEvent,
2139    pub health: WireMemberHealthClass,
2140}
2141
2142/// Response payload for `mob/snapshot`.
2143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2145pub struct MobSnapshotResult {
2146    pub mob_id: String,
2147    pub status: WireMobLifecycleStatus,
2148    pub members: Vec<MobMemberListEntryWire>,
2149}
2150
2151#[cfg(test)]
2152mod member_status_capability_tests {
2153    use super::*;
2154
2155    #[test]
2156    fn member_status_result_round_trips_resolved_capabilities() -> Result<(), serde_json::Error> {
2157        let capabilities = crate::wire::WireResolvedModelCapabilities {
2158            vision: true,
2159            image_input: true,
2160            image_tool_results: false,
2161            inline_video: false,
2162            realtime: true,
2163            web_search: true,
2164            image_generation: true,
2165        };
2166        let result = MobMemberStatusResult {
2167            status: WireMobMemberStatus::Active,
2168            member_ref: WireMemberRef::encode("mob-1", "worker-1"),
2169            output_preview: None,
2170            error: None,
2171            tokens_used: 0,
2172            is_final: false,
2173            current_session_id: Some("session-1".to_string()),
2174            peer_connectivity: Some(WirePeerConnectivity::Known {
2175                snapshot: WirePeerConnectivitySnapshot {
2176                    reachable_peer_count: 1,
2177                    unknown_peer_count: 0,
2178                    unreachable_peers: Vec::new(),
2179                },
2180            }),
2181            kickoff: None,
2182            external_member: None,
2183            resolved_capabilities: Some(capabilities.clone()),
2184            progress: None,
2185            placement: None,
2186            control_reachability: None,
2187            comms_reachability: None,
2188            last_seen_ms: None,
2189            freshness_reason: None,
2190            lifecycle_capabilities: None,
2191            non_portable_disabled: None,
2192        };
2193
2194        let json = serde_json::to_string(&result)?;
2195        assert!(json.contains("\"resolved_capabilities\""));
2196        let parsed: MobMemberStatusResult = serde_json::from_str(&json)?;
2197        assert_eq!(parsed.resolved_capabilities, Some(capabilities));
2198        Ok(())
2199    }
2200}
2201
2202/// Response payload for `mob/destroy`.
2203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2205pub struct MobDestroyResult {
2206    pub mob_id: String,
2207    pub ok: bool,
2208    pub destroy_report: Value,
2209}
2210
2211/// Response payload for `mob/rotate_supervisor`.
2212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2214pub struct MobRotateSupervisorResult {
2215    pub mob_id: String,
2216    pub ok: bool,
2217    pub report: SupervisorRotationReportWire,
2218}
2219
2220/// Confirmed supervisor rotation report returned by `mob/rotate_supervisor`.
2221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2223pub struct SupervisorRotationReportWire {
2224    pub previous_epoch: u64,
2225    pub current_epoch: u64,
2226    pub public_peer_id: String,
2227}
2228
2229/// Discriminator kind for the supervisor-rotation-incomplete error details.
2230#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2232#[serde(rename_all = "snake_case")]
2233pub enum SupervisorRotationIncompleteKind {
2234    SupervisorRotationIncomplete,
2235}
2236
2237/// Which authority a supervisor-rotation retry validates against.
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 SupervisorRotationRetryAuthority {
2242    PendingRotation,
2243    PreRotation,
2244}
2245
2246/// Durability scope of a supervisor-rotation retry.
2247#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2249#[serde(rename_all = "snake_case")]
2250pub enum SupervisorRotationRetryScope {
2251    Durable,
2252    PreRotation,
2253}
2254
2255/// Typed details of `MobError::SupervisorRotationIncomplete` on the wire.
2256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2258#[serde(rename_all = "snake_case")]
2259pub struct SupervisorRotationIncompleteDetailsWire {
2260    pub kind: SupervisorRotationIncompleteKind,
2261    pub previous_epoch: u64,
2262    pub attempted_epoch: u64,
2263    pub attempted_public_peer_id: String,
2264    pub rotated_peer_count: usize,
2265    pub rollback_succeeded: bool,
2266    pub pending_authority_recorded: bool,
2267    #[serde(default, skip_serializing_if = "Option::is_none")]
2268    pub rollback_error: Option<String>,
2269    pub retry_authority: SupervisorRotationRetryAuthority,
2270    pub retry_scope: SupervisorRotationRetryScope,
2271}
2272
2273/// JSON-RPC `error.data` payload for an incomplete supervisor rotation.
2274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2275#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2276#[serde(rename_all = "snake_case")]
2277pub struct SupervisorRotationIncompleteDataWire {
2278    pub code: String,
2279    pub message: String,
2280    pub details: SupervisorRotationIncompleteDetailsWire,
2281}
2282
2283/// Shared request payload for mob readiness waits.
2284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2285#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2286#[serde(deny_unknown_fields)]
2287pub struct MobWaitParams {
2288    pub mob_id: String,
2289    #[serde(default, skip_serializing_if = "Option::is_none")]
2290    pub member_ids: Option<Vec<String>>,
2291    #[serde(default, skip_serializing_if = "Option::is_none")]
2292    pub timeout_ms: Option<u64>,
2293}
2294
2295/// Response payload for `mob/wait_kickoff` and `mob/wait_ready`.
2296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2298pub struct MobWaitMembersResult {
2299    pub members: Vec<Value>,
2300}
2301
2302/// Response payload for `mob/cancel_work`.
2303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2304#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2305pub struct MobCancelWorkResult {
2306    pub mob_id: String,
2307    pub ok: bool,
2308}
2309
2310/// Response payload for `mob/cancel_all_work`.
2311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2313pub struct MobCancelAllWorkResult {
2314    pub mob_id: String,
2315    pub ok: bool,
2316}
2317
2318/// Request payload for `mob/profile/create`.
2319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2321#[serde(deny_unknown_fields)]
2322pub struct MobProfileCreateParams {
2323    pub name: String,
2324    pub profile: MobProfileInput,
2325}
2326
2327/// Request payload for `mob/profile/get`.
2328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2329#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2330#[serde(deny_unknown_fields)]
2331pub struct MobProfileNameParams {
2332    pub name: String,
2333}
2334
2335/// Request payload for `mob/profile/update`.
2336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2337#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2338#[serde(deny_unknown_fields)]
2339pub struct MobProfileUpdateParams {
2340    pub name: String,
2341    pub profile: MobProfileInput,
2342    pub expected_revision: u64,
2343}
2344
2345/// Request payload for `mob/profile/delete`.
2346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2348#[serde(deny_unknown_fields)]
2349pub struct MobProfileDeleteParams {
2350    pub name: String,
2351    pub expected_revision: u64,
2352}
2353
2354/// Stored realm profile projection returned by `mob/profile/*`.
2355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2356#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2357pub struct MobProfileLookupResult {
2358    #[serde(default)]
2359    pub not_found: bool,
2360    pub name: String,
2361    #[serde(default, skip_serializing_if = "Option::is_none")]
2362    pub profile: Option<WireMobProfile>,
2363    #[serde(default, skip_serializing_if = "Option::is_none")]
2364    pub revision: Option<u64>,
2365    #[serde(default, skip_serializing_if = "Option::is_none")]
2366    pub created_at: Option<String>,
2367    #[serde(default, skip_serializing_if = "Option::is_none")]
2368    pub updated_at: Option<String>,
2369}
2370
2371/// Response payload for `mob/profile/list`.
2372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2374pub struct MobProfileListResult {
2375    pub profiles: Vec<MobProfileLookupResult>,
2376}
2377
2378/// Response payload for `mob/profile/delete`.
2379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2380#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2381pub struct MobProfileDeleteResult {
2382    pub name: String,
2383    pub deleted_revision: u64,
2384}
2385
2386/// Request payload for `mob/stream_open`.
2387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2388#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2389#[serde(deny_unknown_fields)]
2390pub struct MobStreamOpenParams {
2391    pub mob_id: String,
2392    #[serde(default, skip_serializing_if = "Option::is_none")]
2393    pub agent_identity: Option<String>,
2394}
2395
2396/// Response payload for `mob/stream_open`.
2397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2399pub struct MobStreamOpenResult {
2400    pub stream_id: String,
2401    pub opened: bool,
2402}
2403
2404/// Request payload for `mob/stream_close`.
2405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2406#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2407#[serde(deny_unknown_fields)]
2408pub struct MobStreamCloseParams {
2409    pub stream_id: String,
2410}
2411
2412/// Response payload for `mob/stream_close`.
2413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2414#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2415pub struct MobStreamCloseResult {
2416    pub stream_id: String,
2417    pub closed: bool,
2418    pub already_closed: bool,
2419}
2420
2421/// Origin for `MobSubmitWorkParams`. Replaces the prior free-form
2422/// `origin: Option<String>` shape.
2423#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2424#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2425#[serde(rename_all = "snake_case")]
2426pub enum WireWorkOrigin {
2427    #[default]
2428    External,
2429    Internal,
2430}
2431
2432/// Request payload for `mob/submit_work`.
2433///
2434/// Identifies the member through the opaque [`WireMemberRef`] handle the
2435/// server resolves against the live roster — callers do not pass
2436/// `generation` or `fence_token`.
2437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2438#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2439#[serde(deny_unknown_fields)]
2440pub struct MobSubmitWorkParams {
2441    pub member_ref: WireMemberRef,
2442    /// Optional caller-supplied work reference. When absent the server
2443    /// generates a fresh UUID.
2444    #[serde(default, skip_serializing_if = "Option::is_none")]
2445    pub work_ref: Option<String>,
2446    pub content: WireContentInput,
2447    #[serde(default)]
2448    pub origin: WireWorkOrigin,
2449    /// Host-attached injected context delivered alongside the work content.
2450    /// Each entry materializes on the member as a separate typed
2451    /// injected-context transcript message immediately before the work
2452    /// content, in order. Deliverable to queue-mode turn-driven members;
2453    /// autonomous inbox delivery rejects it with a typed error.
2454    #[serde(default, skip_serializing_if = "Option::is_none")]
2455    pub injected_context: Option<Vec<WireContentInput>>,
2456    /// Durable kickoff objective correlation to stamp onto this delegated turn.
2457    #[serde(default, skip_serializing_if = "Option::is_none")]
2458    pub objective_id: Option<String>,
2459}
2460
2461/// Response payload for `mob/submit_work`.
2462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2463#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2464pub struct MobSubmitWorkResult {
2465    pub mob_id: String,
2466    pub work_ref: String,
2467    pub member_ref: WireMemberRef,
2468    #[serde(default, skip_serializing_if = "Option::is_none")]
2469    pub objective_id: Option<String>,
2470}
2471
2472/// Explicitly concludes one machine-owned kickoff objective.
2473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2474#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2475#[serde(deny_unknown_fields)]
2476pub struct MobConcludeObjectiveParams {
2477    pub member_ref: WireMemberRef,
2478    pub objective_id: String,
2479    pub outcome: String,
2480}
2481
2482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2483#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2484pub struct MobConcludeObjectiveResult {
2485    pub member_ref: WireMemberRef,
2486    pub objective_id: String,
2487    pub concluded: bool,
2488}
2489
2490/// Request payload for `mob/cancel_work`.
2491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2492#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2493#[serde(deny_unknown_fields)]
2494pub struct MobCancelWorkParams {
2495    pub mob_id: String,
2496    pub work_ref: String,
2497}
2498
2499/// Request payload for `mob/cancel_all_work`.
2500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2501#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2502#[serde(deny_unknown_fields)]
2503pub struct MobCancelAllWorkParams {
2504    pub member_ref: WireMemberRef,
2505}
2506
2507/// Filter for `mob/list_members_matching`. Non-empty / `Some` fields are
2508/// combined conjunctively; an empty filter matches every member.
2509#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2510#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2511#[serde(deny_unknown_fields)]
2512pub struct MobMemberFilterWire {
2513    /// Required exact matches on member labels.
2514    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2515    pub labels: BTreeMap<String, String>,
2516    /// Required profile name (role).
2517    #[serde(default, skip_serializing_if = "Option::is_none")]
2518    pub role: Option<String>,
2519    /// Required canonical machine-projected member status.
2520    #[serde(default, skip_serializing_if = "Option::is_none")]
2521    pub status: Option<WireMobMemberStatus>,
2522}
2523
2524/// Request payload for `mob/list_members_matching`.
2525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2526#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2527#[serde(deny_unknown_fields)]
2528pub struct MobListMembersMatchingParams {
2529    pub mob_id: String,
2530    #[serde(default)]
2531    pub filter: MobMemberFilterWire,
2532}
2533
2534/// Response payload for `mob/list_members_matching`. Each member is the raw
2535/// roster entry JSON.
2536#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
2537#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2538pub struct MobListMembersMatchingResult {
2539    #[serde(default)]
2540    pub members: Vec<Value>,
2541}
2542
2543// ---------------------------------------------------------------------------
2544// Multi-host mob DTOs (V4): control scopes, host roster, remote history,
2545// grants, member live console. Types only — RPC catalog entries land with
2546// the surface phases.
2547// ---------------------------------------------------------------------------
2548
2549/// Closed control-plane scope vocabulary (A9). Grants and bridge scope
2550/// denials speak exactly this set.
2551#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
2552#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2553#[serde(rename_all = "snake_case")]
2554pub enum WireControlScope {
2555    List,
2556    ReadHistory,
2557    SubscribeEvents,
2558    SendCommand,
2559    Cancel,
2560    Retire,
2561    WireTopology,
2562    Live,
2563    AdminHost,
2564    AdminGrants,
2565}
2566
2567/// Observer-computed reachability class (§7.5). A projection from typed
2568/// bridge/pump outcomes — never a membership fact, and a DIFFERENT fact
2569/// from the bridge's own `BridgePeerConnectivity`.
2570#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2571#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2572#[serde(rename_all = "snake_case")]
2573pub enum WireReachability {
2574    Reachable,
2575    Stale,
2576    Unreachable,
2577    Unknown,
2578}
2579
2580/// Opaque host reference: the host's canonical comms `PeerId` string.
2581#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
2582#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2583#[serde(transparent)]
2584pub struct WireHostRef(pub String);
2585
2586/// Lifecycle capabilities available for a member at its placement (§19.L7).
2587#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2588#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2589#[serde(deny_unknown_fields)]
2590pub struct WireMemberLifecycleCapabilities {
2591    pub transcript_edits: bool,
2592    pub revisions: bool,
2593    pub resume_after_restart: bool,
2594}
2595
2596/// Host bind lifecycle phase as recorded by the controlling machine.
2597#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2598#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2599#[serde(rename_all = "snake_case")]
2600pub enum WireHostBindPhase {
2601    Requested,
2602    Bound,
2603}
2604
2605/// Wire mirror of the DSL `HostCapabilityFlags` single enumeration (§6.1) —
2606/// the machine owns the fact; this is its console projection.
2607///
2608/// Field vocabulary matches the machine maps and the domain
2609/// `HostCapabilityReport` exactly (ADJ-P7-1, FLAG-A2): `u64` protocol bounds
2610/// and an OPEN `BTreeSet<String>` provider vocabulary — a newer member host
2611/// advertising a provider this build's enum lacks must stay representable
2612/// (no silent caps).
2613#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2614#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2615#[serde(deny_unknown_fields)]
2616pub struct WireHostCapabilityFlags {
2617    pub protocol_min: u64,
2618    pub protocol_max: u64,
2619    pub engine_version: String,
2620    pub durable_sessions: bool,
2621    pub autonomous_members: bool,
2622    pub hard_cancel_member: bool,
2623    #[serde(default)]
2624    pub tracked_input_cancel: bool,
2625    pub memory_store: bool,
2626    pub mcp: bool,
2627    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2628    pub resolvable_providers: std::collections::BTreeSet<String>,
2629    pub approval_forwarding: bool,
2630    #[serde(default, skip_serializing_if = "Option::is_none")]
2631    pub live_endpoint: Option<String>,
2632}
2633
2634/// One tracked host row for `mob/hosts` (A13).
2635///
2636/// `endpoint`, `authority_epoch`, and `capabilities` are the CommitHostBind
2637/// facts — present for `Bound` hosts, typed-absent for a `Requested`-phase
2638/// host (an open or failed bind window commits nothing; fabricating empty
2639/// values would launder ceremony state into committed facts).
2640///
2641/// `control_reachability`/`last_seen_ms`/`freshness_reason` are fed by the
2642/// observer-local periodic `HostStatus` driver shared with orphan
2643/// reconciliation. They remain typed-absent until the first observation and
2644/// never become durable membership facts.
2645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2646#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2647pub struct MobHostStatus {
2648    pub host_id: WireHostRef,
2649    #[serde(default, skip_serializing_if = "Option::is_none")]
2650    pub endpoint: Option<String>,
2651    pub bind_phase: WireHostBindPhase,
2652    #[serde(default, skip_serializing_if = "Option::is_none")]
2653    pub authority_epoch: Option<u64>,
2654    #[serde(default, skip_serializing_if = "Option::is_none")]
2655    pub capabilities: Option<WireHostCapabilityFlags>,
2656    #[serde(default, skip_serializing_if = "Option::is_none")]
2657    pub control_reachability: Option<WireReachability>,
2658    /// Observer-local monotonic ms since last verified contact.
2659    #[serde(default, skip_serializing_if = "Option::is_none")]
2660    pub last_seen_ms: Option<u64>,
2661    #[serde(default, skip_serializing_if = "Option::is_none")]
2662    pub freshness_reason: Option<String>,
2663    pub materialized_member_count: u64,
2664}
2665
2666/// Response payload for `mob/hosts`.
2667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2668#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2669pub struct MobHostsResult {
2670    pub hosts: Vec<MobHostStatus>,
2671}
2672
2673/// One outstanding cross-host route-install obligation.
2674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2675#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2676pub struct WireRouteInstallObligation {
2677    pub edge_a: String,
2678    pub edge_b: String,
2679    pub host: WireHostRef,
2680}
2681
2682/// Response payload for the route-install status projection.
2683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2684#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2685pub struct MobRouteInstallsResult {
2686    pub outstanding: Vec<WireRouteInstallObligation>,
2687    pub complete: bool,
2688}
2689
2690/// Who attests a remotely-served projection (§7/§20): `HostClaimed` facts
2691/// are only what the owning host reports; `ControllingHostVerified` facts
2692/// were checked against controlling-machine records.
2693#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2695#[serde(rename_all = "snake_case")]
2696pub enum WireProjectionProvenance {
2697    HostClaimed,
2698    ControllingHostVerified,
2699}
2700
2701/// Equality adapter over a canonical wire transcript row.
2702///
2703/// `WireSessionMessage` deliberately derives no `PartialEq` (opaque
2704/// tool-call args ride `RawValue`), but the bridge reply chain that
2705/// carries history pages must be `Eq` (the comms envelope enums derive
2706/// it). Equality here is semantic-JSON equality of the serialized wire
2707/// form — exactly the fact reply comparison needs. Transparent: the wire
2708/// shape stays the raw row object.
2709#[derive(Debug, Clone, Serialize, Deserialize)]
2710#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2711#[serde(transparent)]
2712pub struct WireHistoryRow(pub super::session::WireSessionMessage);
2713
2714impl PartialEq for WireHistoryRow {
2715    fn eq(&self, other: &Self) -> bool {
2716        match (
2717            serde_json::to_value(&self.0),
2718            serde_json::to_value(&other.0),
2719        ) {
2720            (Ok(a), Ok(b)) => a == b,
2721            // Unreachable for transcript rows (their serialization is
2722            // infallible); kept fail-closed rather than laundering a
2723            // serialize error into equality.
2724            _ => false,
2725        }
2726    }
2727}
2728
2729impl Eq for WireHistoryRow {}
2730
2731/// Shared transcript page body used by both the bridge
2732/// `MemberHistoryPage` reply and the console `mob/member_history` result —
2733/// same page shape for local and remote members.
2734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2735#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2736#[serde(deny_unknown_fields)]
2737pub struct WireMemberHistoryPageBody {
2738    pub from_index: u64,
2739    pub messages: Vec<WireHistoryRow>,
2740    /// Total transcript length — carried so offset math (e.g. fork
2741    /// `LastMessages`) needs no extra round-trip.
2742    pub message_count: u64,
2743    #[serde(default, skip_serializing_if = "Option::is_none")]
2744    pub next_index: Option<u64>,
2745    pub complete: bool,
2746}
2747
2748impl WireMemberHistoryPageBody {
2749    /// THE page-shape projection (multi-host mobs DEC-P6E-6): the member
2750    /// host's `ReadMemberHistory` arm AND the controlling host's local
2751    /// history branch both call this, so "remote page read == local page
2752    /// shape" holds by construction, not by test luck.
2753    pub fn try_from_history_page(
2754        page: &meerkat_core::service::SessionHistoryPage,
2755    ) -> Result<Self, super::error::WireConversionError> {
2756        let invalid =
2757            |reason: String| super::error::WireConversionError::MemberHistoryPage { debug: reason };
2758        let message_count = u64::try_from(page.message_count).map_err(|_| {
2759            invalid(format!(
2760                "message_count {} exceeds the u64 wire domain",
2761                page.message_count
2762            ))
2763        })?;
2764        let from_index = u64::try_from(page.offset).map_err(|_| {
2765            invalid(format!(
2766                "offset {} exceeds the u64 wire domain",
2767                page.offset
2768            ))
2769        })?;
2770        let served = u64::try_from(page.messages.len()).map_err(|_| {
2771            invalid(format!(
2772                "served row count {} exceeds the u64 wire domain",
2773                page.messages.len()
2774            ))
2775        })?;
2776        let next_index = if page.has_more {
2777            if served == 0 {
2778                return Err(invalid(format!(
2779                    "page at offset {from_index} claims more rows but serves none"
2780                )));
2781            }
2782            Some(from_index.checked_add(served).ok_or_else(|| {
2783                invalid(format!(
2784                    "offset {from_index} plus served row count {served} exhausts the u64 cursor domain"
2785                ))
2786            })?)
2787        } else {
2788            None
2789        };
2790        Ok(Self {
2791            from_index,
2792            messages: page
2793                .messages
2794                .iter()
2795                .map(|message| {
2796                    WireHistoryRow(super::session::WireSessionMessage::from(message.clone()))
2797                })
2798                .collect(),
2799            message_count,
2800            next_index,
2801            complete: !page.has_more,
2802        })
2803    }
2804}
2805
2806/// Request payload for `mob/member_history`.
2807#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2808#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2809#[serde(deny_unknown_fields)]
2810pub struct MobMemberHistoryParams {
2811    pub mob_id: String,
2812    pub agent_identity: String,
2813    #[serde(default, skip_serializing_if = "Option::is_none")]
2814    pub from_index: Option<u64>,
2815    #[serde(default, skip_serializing_if = "Option::is_none")]
2816    pub limit: Option<u32>,
2817}
2818
2819/// Response payload for `mob/member_history`. Pagination facts live inside
2820/// `page` (one owner); this envelope adds the placement/provenance facts
2821/// only the controlling host knows.
2822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2823#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2824pub struct MobMemberHistoryResult {
2825    pub page: WireMemberHistoryPageBody,
2826    pub generation: u64,
2827    #[serde(default, skip_serializing_if = "Option::is_none")]
2828    pub placement: Option<WireHostRef>,
2829    pub provenance: WireProjectionProvenance,
2830}
2831
2832/// Request payload for `mob/bind_host`.
2833#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2834#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2835#[serde(deny_unknown_fields)]
2836pub struct MobBindHostParams {
2837    pub mob_id: String,
2838    pub descriptor: super::supervisor_bridge::WireHostBindingDescriptor,
2839}
2840
2841/// Response payload for `mob/bind_host`.
2842#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2843#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2844pub struct MobBindHostResult {
2845    pub host_id: WireHostRef,
2846    pub capabilities: WireHostCapabilityFlags,
2847    pub authority_epoch: u64,
2848}
2849
2850/// Request payload for `mob/revoke_host`.
2851#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2852#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2853#[serde(deny_unknown_fields)]
2854pub struct MobRevokeHostParams {
2855    pub mob_id: String,
2856    pub host_id: WireHostRef,
2857}
2858
2859/// Response payload for `mob/revoke_host`.
2860#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2861#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2862pub struct MobRevokeHostResult {
2863    pub host_id: WireHostRef,
2864    /// Agent identities whose materializations were released by the
2865    /// revocation.
2866    pub released_members: Vec<String>,
2867}
2868
2869/// One control-plane grant record (A9).
2870#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2871#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2872pub struct WireGrantRecord {
2873    pub principal: String,
2874    pub scopes: Vec<WireControlScope>,
2875    #[serde(default, skip_serializing_if = "Option::is_none")]
2876    pub expires_at_ms: Option<u64>,
2877}
2878
2879/// Request payload for `mob/grant_scopes`.
2880#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2881#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2882#[serde(deny_unknown_fields)]
2883pub struct MobGrantScopesParams {
2884    pub mob_id: String,
2885    pub principal: String,
2886    pub scopes: Vec<WireControlScope>,
2887    #[serde(default, skip_serializing_if = "Option::is_none")]
2888    pub expires_at_ms: Option<u64>,
2889}
2890
2891/// Response payload for `mob/grant_scopes`.
2892#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2893#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2894pub struct MobGrantScopesResult {
2895    pub record: WireGrantRecord,
2896}
2897
2898/// Request payload for `mob/revoke_scopes`. `scopes: None` revokes the
2899/// principal's entire grant.
2900#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2901#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2902#[serde(deny_unknown_fields)]
2903pub struct MobRevokeScopesParams {
2904    pub mob_id: String,
2905    pub principal: String,
2906    #[serde(default, skip_serializing_if = "Option::is_none")]
2907    pub scopes: Option<Vec<WireControlScope>>,
2908}
2909
2910/// Response payload for `mob/revoke_scopes`.
2911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2912#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2913pub struct MobRevokeScopesResult {
2914    pub removed: bool,
2915}
2916
2917/// Response payload for `mob/grants`.
2918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2919#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2920pub struct MobGrantsResult {
2921    pub grants: Vec<WireGrantRecord>,
2922}
2923
2924/// Typed `details` payload for `ErrorCode::ScopeDenied` (§17.4). Every
2925/// console surface serializes exactly this struct into the wire error's
2926/// `details` carrier; the field shape mirrors
2927/// `BridgeRejectionCause::ScopeDenied` so bridge and console denials speak
2928/// one shape. `presented` is the denied caller's own effective (post-expiry)
2929/// scope set — never another principal's grants.
2930#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2931#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2932#[serde(deny_unknown_fields)]
2933pub struct WireScopeDeniedDetail {
2934    pub required: WireControlScope,
2935    pub presented: Vec<WireControlScope>,
2936}
2937
2938/// Request payload for `mob/member_live_open` (§16.4). Result reuses
2939/// `LiveOpenResult` verbatim.
2940#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2941#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2942#[serde(deny_unknown_fields)]
2943pub struct MobMemberLiveOpenParams {
2944    pub mob_id: String,
2945    pub agent_identity: String,
2946    #[serde(default, skip_serializing_if = "Option::is_none")]
2947    pub turning_mode: Option<super::realtime::RealtimeTurningMode>,
2948    #[serde(default, skip_serializing_if = "Option::is_none")]
2949    pub transport: Option<super::live::LiveOpenTransport>,
2950}
2951
2952/// Request payload for `mob/member_live_close`. Close-what-you-name
2953/// (ADJ-P6B-15): `channel_id` is REQUIRED — a reconciling console can never
2954/// race-kill a channel a concurrent legitimate open just minted. The status
2955/// read has its own params type ([`MobMemberLiveStatusParams`]) because its
2956/// `channel_id` is optional by contract.
2957#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2958#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2959#[serde(deny_unknown_fields)]
2960pub struct MobMemberLiveChannelParams {
2961    pub mob_id: String,
2962    pub agent_identity: String,
2963    pub channel_id: String,
2964}
2965
2966/// Request payload for `mob/member_live_status` (§16.9, ADJ-P6B-2).
2967/// `channel_id: None` IS the reply-loss discovery primitive — it resolves
2968/// "the member's active channel" on the owning host, so an orphaned open's
2969/// id can be discovered and closed. A dedicated type (not
2970/// [`MobMemberLiveChannelParams`]) so the wire cannot amputate the
2971/// discovery read (DEC-P7A-2).
2972#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2973#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2974#[serde(deny_unknown_fields)]
2975pub struct MobMemberLiveStatusParams {
2976    pub mob_id: String,
2977    pub agent_identity: String,
2978    #[serde(default, skip_serializing_if = "Option::is_none")]
2979    pub channel_id: Option<String>,
2980}
2981
2982/// Request payload for `mob/hard_cancel_member` (DEC-P6E-8). `reason` is
2983/// REQUIRED: the handle verb demands one, and a handler-minted default
2984/// string would be handler-owned meaning (DEC-P7A-2).
2985#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2986#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2987#[serde(deny_unknown_fields)]
2988pub struct MobHardCancelParams {
2989    pub mob_id: String,
2990    pub agent_identity: String,
2991    pub reason: String,
2992}
2993
2994/// Response payload for `mob/hard_cancel_member`. A dedicated type (not
2995/// [`MobForceCancelResult`] reuse) so the hard/force distinction stays
2996/// legible in SDK type names (DEC-P7A-2).
2997#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2998#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2999pub struct MobHardCancelResult {
3000    pub cancelled: bool,
3001}
3002
3003/// Request payload for `mob/member_live_control`.
3004#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3005#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3006#[serde(deny_unknown_fields)]
3007pub struct MobMemberLiveControlParams {
3008    pub mob_id: String,
3009    pub agent_identity: String,
3010    pub channel_id: String,
3011    pub verb: super::supervisor_bridge::BridgeLiveControlVerb,
3012}
3013
3014#[cfg(test)]
3015#[allow(clippy::expect_used, clippy::panic)]
3016mod tests {
3017    use super::*;
3018
3019    #[test]
3020    fn mob_helper_params_carry_structural_auth_binding() {
3021        let parsed: MobSpawnHelperParams = serde_json::from_value(serde_json::json!({
3022            "mob_id": "mob-1",
3023            "prompt": "help",
3024            "agent_identity": "helper",
3025            "auth_binding": {
3026                "realm": "dev",
3027                "binding": "default_anthropic",
3028                "profile": "console"
3029            }
3030        }))
3031        .expect("spawn helper params parse");
3032        let auth_binding = parsed.auth_binding.expect("auth_binding should parse");
3033        assert_eq!(auth_binding.realm.as_str(), "dev");
3034        assert_eq!(auth_binding.binding.as_str(), "default_anthropic");
3035        assert_eq!(
3036            auth_binding
3037                .profile
3038                .as_ref()
3039                .map(|profile| profile.as_str()),
3040            Some("console")
3041        );
3042
3043        let parsed: MobForkHelperParams = serde_json::from_value(serde_json::json!({
3044            "mob_id": "mob-1",
3045            "source_member_id": "source",
3046            "prompt": "help",
3047            "agent_identity": "helper",
3048            "auth_binding": {
3049                "realm": "dev",
3050                "binding": "default_anthropic"
3051            }
3052        }))
3053        .expect("fork helper params parse");
3054        let auth_binding = parsed.auth_binding.expect("auth_binding should parse");
3055        assert_eq!(auth_binding.realm.as_str(), "dev");
3056        assert_eq!(auth_binding.binding.as_str(), "default_anthropic");
3057        assert!(auth_binding.profile.is_none());
3058    }
3059
3060    #[test]
3061    fn wire_mob_profile_parses_provider_fields_fail_closed() {
3062        // Minimal legacy payload (no new fields) still parses.
3063        let legacy: WireMobProfile =
3064            serde_json::from_str(r#"{"model":"claude-opus-4-8"}"#).expect("legacy profile parses");
3065        assert_eq!(legacy.provider, None);
3066        assert!(legacy.resume_overrides.is_empty());
3067
3068        // Typed provider + resume override vocabulary parse into closed enums.
3069        let full: WireMobProfile = serde_json::from_str(
3070            r#"{
3071                "model": "claude-internal-preview",
3072                "provider": "anthropic",
3073                "image_generation_provider": "gemini",
3074                "auto_compact_threshold": 60000,
3075                "resume_overrides": ["model", "provider"]
3076            }"#,
3077        )
3078        .expect("typed profile parses");
3079        assert_eq!(full.provider, Some(meerkat_core::Provider::Anthropic));
3080        assert_eq!(
3081            full.image_generation_provider,
3082            Some(meerkat_core::Provider::Gemini)
3083        );
3084        assert_eq!(
3085            full.resume_overrides,
3086            vec![
3087                WireMobResumeOverrideField::Model,
3088                WireMobResumeOverrideField::Provider
3089            ]
3090        );
3091
3092        // Fail-closed: unknown provider names and zero thresholds reject.
3093        assert!(
3094            serde_json::from_str::<WireMobProfile>(r#"{"model":"m","provider":"not-a-provider"}"#)
3095                .is_err(),
3096            "unknown provider names must fail closed at the wire boundary"
3097        );
3098        assert!(
3099            serde_json::from_str::<WireMobProfile>(r#"{"model":"m","auto_compact_threshold":0}"#)
3100                .is_err(),
3101            "zero auto_compact_threshold must fail closed at the wire boundary"
3102        );
3103        assert!(
3104            serde_json::from_str::<WireMobProfile>(
3105                r#"{"model":"m","resume_overrides":["everything"]}"#
3106            )
3107            .is_err(),
3108            "resume_overrides vocabulary is closed"
3109        );
3110    }
3111
3112    #[test]
3113    fn mob_definition_input_parses_custom_models() {
3114        let input: MobDefinitionInput = serde_json::from_str(
3115            r#"{
3116                "id": "m",
3117                "profiles": {"worker": {"model": "claude-internal-preview"}},
3118                "models": {
3119                    "claude-internal-preview": {
3120                        "provider": "anthropic",
3121                        "context_window": 500000,
3122                        "vision": true
3123                    }
3124                },
3125                "image_generation_provider": "openai"
3126            }"#,
3127        )
3128        .expect("definition with custom models parses");
3129        let model = input
3130            .models
3131            .get("claude-internal-preview")
3132            .expect("custom model present");
3133        assert_eq!(model.provider, meerkat_core::Provider::Anthropic);
3134        assert_eq!(model.context_window, Some(500_000));
3135        assert_eq!(model.vision, Some(true));
3136        assert_eq!(
3137            input.image_generation_provider,
3138            Some(meerkat_core::Provider::OpenAI)
3139        );
3140    }
3141
3142    #[test]
3143    fn wire_member_ref_round_trips_through_encode_decode() {
3144        let token = WireMemberRef::encode("mob-42", "worker-1");
3145        let (mob_id, agent_identity) = token.decode().expect("decode round-trips");
3146        assert_eq!(mob_id, "mob-42");
3147        assert_eq!(agent_identity, "worker-1");
3148    }
3149
3150    #[test]
3151    fn wire_member_ref_rejects_malformed_token() {
3152        let err = WireMemberRef::from_token("not-a-token-payload")
3153            .decode()
3154            .expect_err("malformed tokens must fail to decode");
3155        assert!(matches!(err, WireMemberRefError::Malformed));
3156    }
3157
3158    #[test]
3159    fn mob_spawn_many_spec_placement_is_optional_and_round_trips() {
3160        let placed: MobSpawnSpecParams = serde_json::from_value(serde_json::json!({
3161            "profile": "worker",
3162            "agent_identity": "w1",
3163            "placement": "host-b-peer"
3164        }))
3165        .expect("placed spawn-many spec parses");
3166        assert_eq!(
3167            placed.placement.as_ref().map(|host| host.0.as_str()),
3168            Some("host-b-peer")
3169        );
3170        assert_eq!(
3171            serde_json::to_value(&placed).expect("placed spawn-many spec serializes")["placement"],
3172            "host-b-peer"
3173        );
3174
3175        let local: MobSpawnSpecParams = serde_json::from_value(serde_json::json!({
3176            "profile": "worker",
3177            "agent_identity": "w2"
3178        }))
3179        .expect("local spawn-many spec parses");
3180        assert!(local.placement.is_none());
3181        assert!(
3182            serde_json::to_value(&local)
3183                .expect("local spawn-many spec serializes")
3184                .get("placement")
3185                .is_none(),
3186            "absent placement must remain omitted for source and wire compatibility"
3187        );
3188    }
3189
3190    #[test]
3191    fn mob_member_spec_placement_is_optional_and_round_trips() {
3192        let placed: MobMemberSpecWire = serde_json::from_value(serde_json::json!({
3193            "profile": "worker",
3194            "agent_identity": "w1",
3195            "placement": "host-b-peer"
3196        }))
3197        .expect("placed declarative member spec parses");
3198        assert_eq!(
3199            placed.placement.as_ref().map(|host| host.0.as_str()),
3200            Some("host-b-peer")
3201        );
3202
3203        let local: MobMemberSpecWire = serde_json::from_value(serde_json::json!({
3204            "profile": "worker",
3205            "agent_identity": "w2"
3206        }))
3207        .expect("local declarative member spec parses");
3208        assert!(local.placement.is_none());
3209    }
3210
3211    #[test]
3212    fn mob_member_spec_exposes_shared_surface_metadata() {
3213        let spec = MobMemberSpecWire {
3214            profile: "worker".into(),
3215            agent_identity: "w1".into(),
3216            initial_message: None,
3217            runtime_mode: None,
3218            backend: None,
3219            placement: None,
3220            binding: None,
3221            context: Some(serde_json::json!({"client_ref": "member-card"})),
3222            labels: Some(BTreeMap::from([("client.member_id".into(), "w1".into())])),
3223            additional_instructions: None,
3224            auto_wire_parent: None,
3225        };
3226
3227        let metadata = spec.surface_metadata();
3228        assert_eq!(
3229            metadata.labels.get("client.member_id").map(String::as_str),
3230            Some("w1")
3231        );
3232        assert_eq!(
3233            metadata.app_context,
3234            Some(serde_json::json!({"client_ref": "member-card"}))
3235        );
3236    }
3237
3238    #[test]
3239    fn mob_member_spec_surface_metadata_rejects_reserved_keys() {
3240        let spec = MobMemberSpecWire {
3241            profile: "worker".into(),
3242            agent_identity: "w1".into(),
3243            initial_message: None,
3244            runtime_mode: None,
3245            backend: None,
3246            placement: None,
3247            binding: None,
3248            context: None,
3249            labels: Some(BTreeMap::from([("mob_id".into(), "spoof".into())])),
3250            additional_instructions: None,
3251            auto_wire_parent: None,
3252        };
3253
3254        assert!(spec.validate_public_surface_metadata().is_err());
3255    }
3256
3257    #[test]
3258    fn mob_reconcile_failure_stage_is_typed_wire_enum() {
3259        let failure = MobReconcileFailureWire {
3260            agent_identity: "worker-1".into(),
3261            stage: WireMobReconcileStage::Spawn,
3262            error: WireMobError {
3263                code: MobSpawnManyFailureCause::ProfileNotFound,
3264                message: "spawn failed".into(),
3265            },
3266        };
3267
3268        let json = serde_json::to_value(&failure).expect("serialize failure");
3269        assert_eq!(json["stage"], "spawn");
3270        assert_eq!(json["error"]["code"], "profile_not_found");
3271        assert_eq!(json["error"]["message"], "spawn failed");
3272
3273        let round_trip: MobReconcileFailureWire =
3274            serde_json::from_value(json).expect("deserialize failure");
3275        assert_eq!(round_trip.stage, WireMobReconcileStage::Spawn);
3276        assert_eq!(
3277            round_trip.error.code,
3278            MobSpawnManyFailureCause::ProfileNotFound
3279        );
3280
3281        let err = serde_json::from_value::<MobReconcileFailureWire>(serde_json::json!({
3282            "agent_identity": "worker-1",
3283            "stage": "restart",
3284            "error": { "code": "profile_not_found", "message": "bad stage" }
3285        }))
3286        .expect_err("unknown reconcile stage must be rejected");
3287        assert!(err.to_string().contains("unknown variant"));
3288    }
3289
3290    #[test]
3291    fn mob_lifecycle_params_reject_unknown_action_string() {
3292        let err = serde_json::from_value::<MobLifecycleParams>(serde_json::json!({
3293            "mob_id": "mob-1",
3294            "action": "explode"
3295        }))
3296        .expect_err("unknown lifecycle actions must fail at the typed wire boundary");
3297
3298        assert!(
3299            err.to_string().contains("unknown variant"),
3300            "unexpected error: {err}"
3301        );
3302    }
3303
3304    #[test]
3305    fn mob_lifecycle_result_round_trips_typed_action() {
3306        let result = MobLifecycleResult {
3307            mob_id: "mob-1".into(),
3308            action: WireMobLifecycleAction::Complete,
3309            ok: true,
3310            destroy_report: None,
3311        };
3312
3313        let json = serde_json::to_value(&result).expect("serialize lifecycle result");
3314        assert_eq!(json["action"], "complete");
3315
3316        let round_trip: MobLifecycleResult =
3317            serde_json::from_value(json).expect("deserialize lifecycle result");
3318        assert_eq!(round_trip.action, WireMobLifecycleAction::Complete);
3319    }
3320
3321    #[test]
3322    fn mob_wire_members_batch_contract_is_local_edge_native() {
3323        let params: MobWireMembersBatchParams = serde_json::from_value(serde_json::json!({
3324            "mob_id": "mob-1",
3325            "edges": [
3326                { "a": "lead", "b": "worker-b" },
3327                { "a": "worker-a", "b": "lead" }
3328            ]
3329        }))
3330        .expect("batch wire params deserialize");
3331
3332        assert_eq!(params.mob_id, "mob-1");
3333        assert_eq!(params.edges.len(), 2);
3334        assert_eq!(params.edges[0].a, "lead");
3335        assert_eq!(params.edges[0].b, "worker-b");
3336
3337        let result = MobWireMembersBatchResult {
3338            requested: 2,
3339            wired: vec![MobWireMembersBatchEdge {
3340                a: "lead".into(),
3341                b: "worker-a".into(),
3342            }],
3343            already_wired: vec![MobWireMembersBatchEdge {
3344                a: "lead".into(),
3345                b: "worker-b".into(),
3346            }],
3347        };
3348        let json = serde_json::to_value(&result).expect("serialize batch wire result");
3349        assert_eq!(json["requested"], 2);
3350        assert_eq!(json["wired"][0]["a"], "lead");
3351        assert_eq!(json["already_wired"][0]["b"], "worker-b");
3352
3353        let err = serde_json::from_value::<MobWireMembersBatchParams>(serde_json::json!({
3354            "mob_id": "mob-1",
3355            "edges": [{ "member": "lead", "peer": "worker-a" }]
3356        }))
3357        .expect_err("mixed local/external mob/wire shape must not deserialize");
3358        let message = err.to_string();
3359        assert!(
3360            message.contains("unknown field `member`") || message.contains("missing field `a`"),
3361            "unexpected error: {message}"
3362        );
3363    }
3364
3365    #[test]
3366    fn mob_spawn_many_result_entry_uses_typed_status_result_envelope() {
3367        let member_ref = WireMemberRef::encode("mob-1", "worker-1");
3368        let entry = MobSpawnManyResultEntry::spawned("worker-1", member_ref.clone());
3369
3370        let json = serde_json::to_value(&entry).expect("serialize typed spawn_many row");
3371        assert_eq!(json["status"], "spawned");
3372        assert_eq!(json["result"]["agent_identity"], "worker-1");
3373        assert_eq!(json["result"]["member_ref"], member_ref.as_str());
3374        assert!(json.get("ok").is_none());
3375        assert!(json.get("error").is_none());
3376
3377        let round_trip: MobSpawnManyResultEntry =
3378            serde_json::from_value(json).expect("deserialize typed spawn_many row");
3379        assert_eq!(round_trip, entry);
3380
3381        let failed = MobSpawnManyResultEntry::failed(
3382            MobSpawnManyFailureCause::ProfileNotFound,
3383            "profile missing",
3384        );
3385        let json = serde_json::to_value(&failed).expect("serialize typed failed spawn_many row");
3386        assert_eq!(json["status"], "failed");
3387        assert_eq!(json["result"]["cause"], "profile_not_found");
3388        assert_eq!(json["result"]["message"], "profile missing");
3389        assert!(json.get("ok").is_none());
3390        assert!(json.get("error").is_none());
3391
3392        let round_trip: MobSpawnManyResultEntry =
3393            serde_json::from_value(json).expect("deserialize typed failed spawn_many row");
3394        assert_eq!(round_trip, failed);
3395    }
3396
3397    #[test]
3398    fn mob_spawn_many_result_entry_rejects_legacy_or_malformed_envelopes() {
3399        let legacy = serde_json::json!({
3400            "ok": true,
3401            "agent_identity": "worker-1",
3402            "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3403        });
3404        let err = serde_json::from_value::<MobSpawnManyResultEntry>(legacy)
3405            .expect_err("legacy ok carrier must not deserialize");
3406        assert!(
3407            err.to_string().contains("missing field `status`")
3408                || err.to_string().contains("unknown field"),
3409            "unexpected error: {err}"
3410        );
3411
3412        let missing_result = serde_json::json!({
3413            "status": "spawned"
3414        });
3415        let err = serde_json::from_value::<MobSpawnManyResultEntry>(missing_result)
3416            .expect_err("missing typed result must fail closed");
3417        assert!(
3418            err.to_string().contains("missing field `result`"),
3419            "unexpected error: {err}"
3420        );
3421
3422        let unknown_status = serde_json::json!({
3423            "status": "ok",
3424            "result": {
3425                "agent_identity": "worker-1",
3426                "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3427            }
3428        });
3429        let err = serde_json::from_value::<MobSpawnManyResultEntry>(unknown_status)
3430            .expect_err("unknown typed status must fail closed");
3431        assert!(
3432            err.to_string().contains("unknown variant"),
3433            "unexpected error: {err}"
3434        );
3435
3436        let mismatched = serde_json::json!({
3437            "status": "spawned",
3438            "result": {
3439                "cause": "profile_not_found",
3440                "message": "profile missing"
3441            }
3442        });
3443        let err = serde_json::from_value::<MobSpawnManyResultEntry>(mismatched)
3444            .expect_err("status/result mismatch must fail closed");
3445        assert!(
3446            err.to_string()
3447                .contains("status spawned requires spawned result"),
3448            "unexpected error: {err}"
3449        );
3450
3451        let message_only_failure = serde_json::json!({
3452            "status": "failed",
3453            "result": {
3454                "message": "profile missing"
3455            }
3456        });
3457        let err = serde_json::from_value::<MobSpawnManyResultEntry>(message_only_failure)
3458            .expect_err("string-only failure result must fail closed");
3459        assert!(
3460            err.to_string().contains("data did not match any variant")
3461                || err.to_string().contains("missing field `cause`"),
3462            "unexpected error: {err}"
3463        );
3464
3465        let unknown_failure_cause = serde_json::json!({
3466            "status": "failed",
3467            "result": {
3468                "cause": "future_failure",
3469                "message": "future failure"
3470            }
3471        });
3472        let err = serde_json::from_value::<MobSpawnManyResultEntry>(unknown_failure_cause)
3473            .expect_err("unknown failure cause must fail closed");
3474        assert!(
3475            err.to_string().contains("data did not match any variant")
3476                || err.to_string().contains("unknown variant"),
3477            "unexpected error: {err}"
3478        );
3479    }
3480
3481    #[test]
3482    fn mob_wire_params_reject_legacy_local_target_shape() {
3483        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3484            "mob_id": "mob-1",
3485            "local": "member-a",
3486            "target": { "local": "member-b" }
3487        }))
3488        .expect_err("legacy local/target shape must be rejected");
3489
3490        let msg = err.to_string();
3491        assert!(
3492            msg.contains("unknown field `local`") || msg.contains("missing field `member`"),
3493            "unexpected error: {msg}"
3494        );
3495    }
3496
3497    #[test]
3498    fn mob_wire_params_accept_canonical_external_peer_identity() {
3499        let params = serde_json::from_value::<MobWireParams>(serde_json::json!({
3500            "mob_id": "mob-1",
3501            "member": "member-a",
3502            "peer": {
3503                "external": {
3504                    "name": "external-worker",
3505                    "address": "inproc://external-worker",
3506                    "identity": {
3507                        "kind": "ed25519_public_key",
3508                        "public_key": "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
3509                    }
3510                }
3511            }
3512        }))
3513        .expect("canonical external peer identity should deserialize");
3514
3515        let MobPeerTarget::External(spec) = params.peer else {
3516            panic!("expected external peer target");
3517        };
3518        assert_eq!(spec.name, "external-worker");
3519    }
3520
3521    #[test]
3522    fn mob_wire_params_reject_raw_external_peer_id_shape() {
3523        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3524            "mob_id": "mob-1",
3525            "member": "member-a",
3526            "peer": {
3527                "external": {
3528                    "name": "external-worker",
3529                    "peer_id": meerkat_core::comms::PeerId::from_ed25519_pubkey(&[7u8; 32]).to_string(),
3530                    "address": "inproc://external-worker",
3531                    "pubkey": vec![7u8; 32]
3532                }
3533            }
3534        }))
3535        .expect_err("raw peer_id/pubkey external peer shape must be rejected");
3536
3537        let msg = err.to_string();
3538        assert!(
3539            msg.contains("peer_id") || msg.contains("identity"),
3540            "unexpected error: {msg}"
3541        );
3542    }
3543
3544    #[test]
3545    fn mob_wire_params_reject_missing_external_peer_pubkey_material() {
3546        let err = serde_json::from_value::<MobWireParams>(serde_json::json!({
3547            "mob_id": "mob-1",
3548            "member": "member-a",
3549            "peer": {
3550                "external": {
3551                    "name": "external-worker",
3552                    "address": "inproc://external-worker",
3553                    "identity": {
3554                        "kind": "ed25519_public_key"
3555                    }
3556                }
3557            }
3558        }))
3559        .expect_err("missing external peer pubkey material must fail closed");
3560
3561        let msg = err.to_string();
3562        assert!(
3563            msg.contains("public_key") || msg.contains("identity"),
3564            "unexpected error: {msg}"
3565        );
3566    }
3567
3568    #[test]
3569    fn runtime_binding_accepts_canonical_external_peer_identity() {
3570        let binding = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3571            "kind": "external",
3572            "address": "inproc://external-worker",
3573            "identity": {
3574                "kind": "ed25519_public_key",
3575                "public_key": "ed25519:BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
3576            }
3577        }))
3578        .expect("canonical external runtime binding identity should deserialize");
3579
3580        let WireRuntimeBinding::External {
3581            identity, address, ..
3582        } = binding
3583        else {
3584            panic!("expected external runtime binding");
3585        };
3586        assert_eq!(address, "inproc://external-worker");
3587        assert_eq!(
3588            identity.resolve().expect("identity resolves").pubkey,
3589            [7u8; 32]
3590        );
3591    }
3592
3593    #[test]
3594    fn runtime_binding_rejects_raw_external_peer_id_shape() {
3595        let err = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3596            "kind": "external",
3597            "peer_id": meerkat_core::comms::PeerId::from_ed25519_pubkey(&[7u8; 32]).to_string(),
3598            "address": "inproc://external-worker",
3599            "pubkey": vec![7u8; 32]
3600        }))
3601        .expect_err("raw peer_id/pubkey external runtime binding shape must be rejected");
3602
3603        let msg = err.to_string();
3604        assert!(
3605            msg.contains("peer_id") || msg.contains("identity"),
3606            "unexpected error: {msg}"
3607        );
3608    }
3609
3610    #[test]
3611    fn runtime_binding_rejects_missing_external_peer_pubkey_material() {
3612        let err = serde_json::from_value::<WireRuntimeBinding>(serde_json::json!({
3613            "kind": "external",
3614            "address": "inproc://external-worker",
3615            "identity": {
3616                "kind": "ed25519_public_key"
3617            }
3618        }))
3619        .expect_err("missing external runtime binding pubkey material must fail closed");
3620
3621        let msg = err.to_string();
3622        assert!(
3623            msg.contains("public_key") || msg.contains("identity"),
3624            "unexpected error: {msg}"
3625        );
3626    }
3627
3628    #[test]
3629    fn mob_turn_start_params_capture_turn_override_fields() {
3630        let params = serde_json::from_value::<MobTurnStartParams>(serde_json::json!({
3631            "mob_id": "mob-1",
3632            "agent_identity": "worker",
3633            "prompt": "continue",
3634            "output_schema": { "type": "object" },
3635            "structured_output_retries": 2
3636        }))
3637        .expect("turn_start should accept explicit turn override fields");
3638
3639        assert_eq!(params.mob_id, "mob-1");
3640        assert_eq!(params.agent_identity, "worker");
3641        assert_eq!(params.prompt, WireContentInput::Text("continue".into()));
3642        assert_eq!(
3643            params.output_schema,
3644            Some(serde_json::json!({ "type": "object" }))
3645        );
3646        assert_eq!(params.structured_output_retries, Some(2));
3647
3648        let err = serde_json::from_value::<MobTurnStartParams>(serde_json::json!({
3649            "mob_id": "mob-1",
3650            "agent_identity": "worker",
3651            "prompt": "continue",
3652            "unknown_override": true
3653        }))
3654        .expect_err("turn_start must reject unknown override fields");
3655        assert!(
3656            err.to_string().contains("unknown field"),
3657            "unexpected error: {err}"
3658        );
3659    }
3660
3661    #[test]
3662    fn mob_create_params_reject_reserved_runtime_lifecycle_fields() {
3663        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3664            "definition": {
3665                "id": "mob-1",
3666                "owner_runtime_binding": "runtime:worker:0",
3667                "profiles": {
3668                    "worker": { "model": "claude-sonnet-4-6" }
3669                }
3670            }
3671        }))
3672        .expect_err("reserved runtime lifecycle fields must be rejected");
3673
3674        assert!(
3675            err.to_string()
3676                .contains("unknown field `owner_runtime_binding`"),
3677            "unexpected error: {err}"
3678        );
3679    }
3680
3681    #[test]
3682    fn mob_create_params_reject_reserved_runtime_bridge_owner_field() {
3683        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3684            "definition": {
3685                "id": "mob-1",
3686                "owner_transport_binding": "transport:worker:0",
3687                "profiles": {
3688                    "worker": { "model": "claude-sonnet-4-6" }
3689                }
3690            }
3691        }))
3692        .expect_err("reserved runtime bridge owner field must be rejected");
3693
3694        assert!(
3695            err.to_string()
3696                .contains("unknown field `owner_transport_binding`"),
3697            "unexpected error: {err}"
3698        );
3699    }
3700
3701    #[test]
3702    fn mob_create_params_reject_internal_profile_tool_bundles() {
3703        let err = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3704            "definition": {
3705                "id": "mob-1",
3706                "profiles": {
3707                    "worker": {
3708                        "model": "claude-sonnet-4-6",
3709                        "tools": {
3710                            "rust_bundles": ["internal-only"]
3711                        }
3712                    }
3713                }
3714            }
3715        }))
3716        .expect_err("internal rust tool bundles must be rejected");
3717
3718        // With untagged MobProfileBindingInput, the error message is about
3719        // no variant matching rather than the specific unknown field.
3720        assert!(
3721            err.to_string().contains("did not match any variant")
3722                || err.to_string().contains("unknown field `rust_bundles`"),
3723            "unexpected error: {err}"
3724        );
3725    }
3726
3727    #[test]
3728    fn mob_create_params_accept_typed_nested_flow_definition() {
3729        let params = serde_json::from_value::<MobCreateParams>(serde_json::json!({
3730            "definition": {
3731                "id": "mob-1",
3732                "profiles": {
3733                    "worker": { "model": "claude-sonnet-4-6" }
3734                },
3735                "flows": {
3736                    "review": {
3737                        "description": "review flow",
3738                        "steps": {
3739                            "draft": {
3740                                "role": "worker",
3741                                "message": "draft it"
3742                            }
3743                        }
3744                    }
3745                }
3746            }
3747        }))
3748        .expect("typed nested flow definition should parse");
3749
3750        assert_eq!(
3751            params.definition.flows["review"].steps["draft"].role,
3752            "worker"
3753        );
3754    }
3755
3756    /// DEC-1 absence pin: `budget_split_policy` was deleted (functionally
3757    /// unconsumed; accepted-then-discarded budget instructions are a
3758    /// fail-quiet containment lie). A payload still carrying it FAILS
3759    /// decode — replaces the old parity fixtures.
3760    #[test]
3761    fn mob_spawn_params_reject_deleted_budget_split_policy() {
3762        let err = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3763            "mob_id": "mob-1",
3764            "profile": "worker",
3765            "agent_identity": "worker-1",
3766            "budget_split_policy": { "type": "equal" }
3767        }))
3768        .expect_err("deleted budget_split_policy must fail closed at the wire boundary");
3769        assert!(
3770            err.to_string()
3771                .contains("unknown field `budget_split_policy`"),
3772            "unexpected error: {err}"
3773        );
3774    }
3775
3776    /// ADJ-7 pin: `placement` is an optional comms `PeerId` string; absent
3777    /// stays `None` (byte-compat with pre-placement payloads) and `None`
3778    /// never serializes.
3779    #[test]
3780    fn mob_spawn_params_placement_round_trips_and_defaults_absent() {
3781        let params = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3782            "mob_id": "mob-1",
3783            "profile": "worker",
3784            "agent_identity": "worker-1"
3785        }))
3786        .expect("placement-less params must decode");
3787        assert_eq!(params.placement, None);
3788        let encoded = serde_json::to_value(&params).expect("serialize params");
3789        assert!(
3790            encoded.get("placement").is_none(),
3791            "absent placement must not serialize: {encoded}"
3792        );
3793
3794        let params = serde_json::from_value::<MobSpawnParams>(serde_json::json!({
3795            "mob_id": "mob-1",
3796            "profile": "worker",
3797            "agent_identity": "worker-1",
3798            "placement": "host-peer-b"
3799        }))
3800        .expect("placed params must decode");
3801        assert_eq!(params.placement.as_deref(), Some("host-peer-b"));
3802        let encoded = serde_json::to_value(&params).expect("serialize params");
3803        assert_eq!(encoded["placement"], serde_json::json!("host-peer-b"));
3804    }
3805
3806    fn minimal_member_status() -> MobMemberStatusResult {
3807        MobMemberStatusResult {
3808            status: WireMobMemberStatus::Active,
3809            member_ref: WireMemberRef::encode("mob-1", "worker-1"),
3810            output_preview: None,
3811            error: None,
3812            tokens_used: 0,
3813            is_final: false,
3814            current_session_id: None,
3815            peer_connectivity: None,
3816            kickoff: None,
3817            external_member: None,
3818            resolved_capabilities: None,
3819            progress: None,
3820            placement: None,
3821            control_reachability: None,
3822            comms_reachability: None,
3823            last_seen_ms: None,
3824            freshness_reason: None,
3825            lifecycle_capabilities: None,
3826            non_portable_disabled: None,
3827        }
3828    }
3829
3830    /// Byte-compat with released SDKs: every multi-host field skips when
3831    /// `None`, and a pre-field JSON payload still decodes.
3832    #[test]
3833    fn member_status_multi_host_fields_skip_when_absent_and_decode_legacy() {
3834        let value = serde_json::to_value(minimal_member_status()).expect("serialize member status");
3835        for absent in [
3836            "placement",
3837            "control_reachability",
3838            "comms_reachability",
3839            "last_seen_ms",
3840            "freshness_reason",
3841            "lifecycle_capabilities",
3842            "non_portable_disabled",
3843        ] {
3844            assert!(
3845                value.get(absent).is_none(),
3846                "absent {absent} must be omitted from the wire form: {value}"
3847            );
3848        }
3849
3850        // Pre-multi-host payload (as a released SDK would emit) decodes.
3851        let legacy = serde_json::json!({
3852            "status": "active",
3853            "member_ref": WireMemberRef::encode("mob-1", "worker-1"),
3854            "tokens_used": 3,
3855            "is_final": false,
3856        });
3857        let decoded: MobMemberStatusResult =
3858            serde_json::from_value(legacy).expect("legacy member status decodes");
3859        assert!(decoded.placement.is_none());
3860        assert!(decoded.lifecycle_capabilities.is_none());
3861    }
3862
3863    /// SD-5 pin: placement facts live ONLY at the typed keys — never
3864    /// inside the opaque `external_member` value.
3865    #[test]
3866    fn member_status_carries_placement_only_at_typed_keys() {
3867        let mut status = minimal_member_status();
3868        status.placement = Some(WireHostRef("host-b-peer".to_string()));
3869        status.control_reachability = Some(WireReachability::Stale);
3870        status.comms_reachability = Some(WireReachability::Reachable);
3871        status.last_seen_ms = Some(1_234);
3872        status.freshness_reason = Some("pump idle".to_string());
3873        status.lifecycle_capabilities = Some(WireMemberLifecycleCapabilities {
3874            transcript_edits: false,
3875            revisions: false,
3876            resume_after_restart: true,
3877        });
3878        status.non_portable_disabled = Some(vec![
3879            super::super::portable_spec::WireNonPortableResourceKind::WorkgraphTools,
3880        ]);
3881        status.external_member = Some(serde_json::json!({"endpoint": "tcp://10.0.0.2:7101"}));
3882
3883        let value = serde_json::to_value(&status).expect("serialize member status");
3884        assert_eq!(value["placement"], serde_json::json!("host-b-peer"));
3885        assert_eq!(value["control_reachability"], serde_json::json!("stale"));
3886        assert_eq!(
3887            value["non_portable_disabled"],
3888            serde_json::json!(["workgraph_tools"])
3889        );
3890        assert!(
3891            value["external_member"].get("placement").is_none(),
3892            "placement must not ride the opaque external_member value (SD-5)"
3893        );
3894
3895        let decoded: MobMemberStatusResult =
3896            serde_json::from_value(value).expect("decode member status");
3897        assert_eq!(decoded.placement, status.placement);
3898        assert_eq!(decoded.control_reachability, status.control_reachability);
3899    }
3900
3901    #[test]
3902    fn control_scope_and_reachability_round_trip_snake_case() {
3903        let scopes: &[(WireControlScope, &str)] = &[
3904            (WireControlScope::List, "list"),
3905            (WireControlScope::ReadHistory, "read_history"),
3906            (WireControlScope::SubscribeEvents, "subscribe_events"),
3907            (WireControlScope::SendCommand, "send_command"),
3908            (WireControlScope::Cancel, "cancel"),
3909            (WireControlScope::Retire, "retire"),
3910            (WireControlScope::WireTopology, "wire_topology"),
3911            (WireControlScope::Live, "live"),
3912            (WireControlScope::AdminHost, "admin_host"),
3913            (WireControlScope::AdminGrants, "admin_grants"),
3914        ];
3915        for (scope, expected) in scopes {
3916            let value = serde_json::to_value(scope).expect("serialize scope");
3917            assert_eq!(value, serde_json::json!(expected));
3918            let decoded: WireControlScope = serde_json::from_value(value).expect("decode scope");
3919            assert_eq!(decoded, *scope);
3920        }
3921        assert!(
3922            serde_json::from_value::<WireControlScope>(serde_json::json!("admin")).is_err(),
3923            "unknown scopes must fail decode (closed vocabulary)"
3924        );
3925
3926        let classes: &[(WireReachability, &str)] = &[
3927            (WireReachability::Reachable, "reachable"),
3928            (WireReachability::Stale, "stale"),
3929            (WireReachability::Unreachable, "unreachable"),
3930            (WireReachability::Unknown, "unknown"),
3931        ];
3932        for (class, expected) in classes {
3933            let value = serde_json::to_value(class).expect("serialize reachability");
3934            assert_eq!(value, serde_json::json!(expected));
3935            let decoded: WireReachability =
3936                serde_json::from_value(value).expect("decode reachability");
3937            assert_eq!(decoded, *class);
3938        }
3939    }
3940
3941    #[test]
3942    fn scope_denied_detail_round_trips_snake_case_and_denies_unknown_fields() {
3943        let detail = WireScopeDeniedDetail {
3944            required: WireControlScope::AdminGrants,
3945            presented: vec![WireControlScope::List, WireControlScope::SendCommand],
3946        };
3947        let value = serde_json::to_value(&detail).expect("serialize detail");
3948        assert_eq!(
3949            value,
3950            serde_json::json!({
3951                "required": "admin_grants",
3952                "presented": ["list", "send_command"],
3953            })
3954        );
3955        let decoded: WireScopeDeniedDetail = serde_json::from_value(value).expect("decode detail");
3956        assert_eq!(decoded, detail);
3957
3958        assert!(
3959            serde_json::from_value::<WireScopeDeniedDetail>(serde_json::json!({
3960                "required": "admin_grants",
3961                "presented": [],
3962                "reason": "extra",
3963            }))
3964            .is_err(),
3965            "unknown fields must be rejected (deny_unknown_fields)"
3966        );
3967    }
3968
3969    #[test]
3970    fn host_status_and_grants_round_trip() {
3971        let host = MobHostStatus {
3972            host_id: WireHostRef("host-b-peer".to_string()),
3973            endpoint: Some("tcp://10.0.0.2:7100".to_string()),
3974            bind_phase: WireHostBindPhase::Bound,
3975            authority_epoch: Some(4),
3976            capabilities: Some(WireHostCapabilityFlags {
3977                protocol_min: 2,
3978                protocol_max: 4,
3979                engine_version: "0.7.22".to_string(),
3980                durable_sessions: true,
3981                autonomous_members: true,
3982                hard_cancel_member: false,
3983                tracked_input_cancel: false,
3984                memory_store: false,
3985                mcp: true,
3986                resolvable_providers: std::collections::BTreeSet::from(["anthropic".to_string()]),
3987                approval_forwarding: false,
3988                live_endpoint: None,
3989            }),
3990            control_reachability: Some(WireReachability::Reachable),
3991            last_seen_ms: Some(250),
3992            freshness_reason: None,
3993            materialized_member_count: 2,
3994        };
3995        let result = MobHostsResult { hosts: vec![host] };
3996        let value = serde_json::to_value(&result).expect("serialize hosts");
3997        assert_eq!(value["hosts"][0]["bind_phase"], serde_json::json!("bound"));
3998        let decoded: MobHostsResult = serde_json::from_value(value).expect("decode hosts");
3999        assert_eq!(decoded, result);
4000
4001        // A Requested-phase host commits nothing: the ceremony facts are
4002        // typed-absent, never fabricated empties.
4003        let requested = MobHostStatus {
4004            host_id: WireHostRef("host-c-peer".to_string()),
4005            endpoint: None,
4006            bind_phase: WireHostBindPhase::Requested,
4007            authority_epoch: None,
4008            capabilities: None,
4009            control_reachability: None,
4010            last_seen_ms: None,
4011            freshness_reason: None,
4012            materialized_member_count: 0,
4013        };
4014        let value = serde_json::to_value(&requested).expect("serialize requested host");
4015        assert_eq!(value["bind_phase"], serde_json::json!("requested"));
4016        assert!(value.get("endpoint").is_none());
4017        assert!(value.get("authority_epoch").is_none());
4018        assert!(value.get("capabilities").is_none());
4019        let decoded: MobHostStatus = serde_json::from_value(value).expect("decode requested host");
4020        assert_eq!(decoded, requested);
4021
4022        let record = WireGrantRecord {
4023            principal: "console:luka".to_string(),
4024            scopes: vec![WireControlScope::List, WireControlScope::Live],
4025            expires_at_ms: None,
4026        };
4027        let value = serde_json::to_value(&record).expect("serialize grant");
4028        assert!(
4029            value.get("expires_at_ms").is_none(),
4030            "absent expiry must be omitted"
4031        );
4032        let decoded: WireGrantRecord = serde_json::from_value(value).expect("decode grant");
4033        assert_eq!(decoded, record);
4034    }
4035
4036    #[test]
4037    fn member_history_result_round_trips_with_provenance() {
4038        let result = MobMemberHistoryResult {
4039            page: WireMemberHistoryPageBody {
4040                from_index: 5,
4041                messages: Vec::new(),
4042                message_count: 12,
4043                next_index: Some(10),
4044                complete: false,
4045            },
4046            generation: 2,
4047            placement: Some(WireHostRef("host-b-peer".to_string())),
4048            provenance: WireProjectionProvenance::HostClaimed,
4049        };
4050        let value = serde_json::to_value(&result).expect("serialize history result");
4051        assert_eq!(value["provenance"], serde_json::json!("host_claimed"));
4052        assert_eq!(value["page"]["message_count"], serde_json::json!(12));
4053        let decoded: MobMemberHistoryResult =
4054            serde_json::from_value(value.clone()).expect("decode history result");
4055        let reencoded = serde_json::to_value(&decoded).expect("reserialize history result");
4056        assert_eq!(value, reencoded);
4057    }
4058
4059    #[test]
4060    fn member_history_projection_rejects_non_advancing_page() {
4061        let page = meerkat_core::service::SessionHistoryPage {
4062            session_id: meerkat_core::SessionId::new(),
4063            message_count: 1,
4064            offset: 0,
4065            limit: Some(1),
4066            has_more: true,
4067            messages: Vec::new(),
4068        };
4069        let error = WireMemberHistoryPageBody::try_from_history_page(&page)
4070            .expect_err("a page that claims more rows must advance its cursor");
4071        assert!(matches!(
4072            error,
4073            crate::wire::error::WireConversionError::MemberHistoryPage { debug }
4074                if debug.contains("serves none")
4075        ));
4076    }
4077
4078    #[cfg(target_pointer_width = "64")]
4079    #[test]
4080    fn member_history_projection_rejects_exhausted_cursor() {
4081        let page = meerkat_core::service::SessionHistoryPage {
4082            session_id: meerkat_core::SessionId::new(),
4083            message_count: usize::MAX,
4084            offset: usize::MAX,
4085            limit: Some(2),
4086            has_more: true,
4087            messages: vec![
4088                meerkat_core::types::Message::User(meerkat_core::types::UserMessage::text("first")),
4089                meerkat_core::types::Message::User(meerkat_core::types::UserMessage::text(
4090                    "second",
4091                )),
4092            ],
4093        };
4094        let error = WireMemberHistoryPageBody::try_from_history_page(&page)
4095            .expect_err("MAX has no representable member-history successor");
4096        assert!(matches!(
4097            error,
4098            crate::wire::error::WireConversionError::MemberHistoryPage { debug }
4099                if debug.contains("exhausts the u64 cursor domain")
4100        ));
4101    }
4102
4103    /// T-A1 (DEC-P7A-2): the two phase-7 params additions round-trip, fail
4104    /// closed on unknown fields, and the live-status discovery read stays
4105    /// expressible (`channel_id` absent ⇒ `None`) while close keeps its
4106    /// required id.
4107    #[test]
4108    fn hard_cancel_params_round_trip_and_reject_unknown_fields() {
4109        let params = MobHardCancelParams {
4110            mob_id: "mob-1".to_string(),
4111            agent_identity: "worker".to_string(),
4112            reason: "operator interrupt".to_string(),
4113        };
4114        let value = serde_json::to_value(&params).expect("serialize hard-cancel params");
4115        let decoded: MobHardCancelParams =
4116            serde_json::from_value(value).expect("decode hard-cancel params");
4117        assert_eq!(decoded, params);
4118
4119        serde_json::from_value::<MobHardCancelParams>(serde_json::json!({
4120            "mob_id": "mob-1",
4121            "agent_identity": "worker",
4122            "reason": "x",
4123            "force": true,
4124        }))
4125        .expect_err("unknown field must be rejected");
4126
4127        // `reason` is required — the handle verb demands one, and a
4128        // handler-minted default would be handler-owned meaning.
4129        serde_json::from_value::<MobHardCancelParams>(serde_json::json!({
4130            "mob_id": "mob-1",
4131            "agent_identity": "worker",
4132        }))
4133        .expect_err("missing reason must be rejected");
4134
4135        let result = MobHardCancelResult { cancelled: true };
4136        let value = serde_json::to_value(&result).expect("serialize hard-cancel result");
4137        assert_eq!(value, serde_json::json!({ "cancelled": true }));
4138    }
4139
4140    #[test]
4141    fn member_live_status_params_keep_the_discovery_read() {
4142        // Absent channel_id parses to None — the ADJ-P6B-2 reply-loss
4143        // discovery primitive stays expressible on the wire.
4144        let discovery: MobMemberLiveStatusParams = serde_json::from_value(serde_json::json!({
4145            "mob_id": "mob-1",
4146            "agent_identity": "worker",
4147        }))
4148        .expect("discovery status params parse");
4149        assert_eq!(discovery.channel_id, None);
4150        let value = serde_json::to_value(&discovery).expect("serialize discovery params");
4151        assert!(
4152            value.get("channel_id").is_none(),
4153            "absent channel_id must be omitted"
4154        );
4155
4156        let named: MobMemberLiveStatusParams = serde_json::from_value(serde_json::json!({
4157            "mob_id": "mob-1",
4158            "agent_identity": "worker",
4159            "channel_id": "chan-7",
4160        }))
4161        .expect("named status params parse");
4162        assert_eq!(named.channel_id.as_deref(), Some("chan-7"));
4163
4164        serde_json::from_value::<MobMemberLiveStatusParams>(serde_json::json!({
4165            "mob_id": "mob-1",
4166            "agent_identity": "worker",
4167            "chan": "chan-7",
4168        }))
4169        .expect_err("unknown field must be rejected");
4170
4171        // Close-what-you-name (ADJ-P6B-15): close still REQUIRES the id.
4172        serde_json::from_value::<MobMemberLiveChannelParams>(serde_json::json!({
4173            "mob_id": "mob-1",
4174            "agent_identity": "worker",
4175        }))
4176        .expect_err("close without channel_id must be rejected");
4177    }
4178}