//! Mobpack authoring contract used by the bundled Flow Editor.
//!
//! Mobpacks are authoring artifacts. Importing, validating, or exporting one
//! does not mutate any running mob runtime; callers launch the resulting
//! `mob.toml`/mobpack through a separate deploy path.

use base64::Engine;
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
use meerkat_mob::definition::{
    CollectionPolicy, ConditionExpr, DependencyMode, DispatchMode, FlowNodeSpec, FlowSpec,
    FlowStepSpec, FrameSpec, SkillSource, StepOutputFormat,
};
use meerkat_mob::{
    BudgetSplitPolicy, ForkContext, MemberLaunchMode, MobBackendKind, MobDefinition,
    MobRuntimeMode, ProfileBinding, ToolConfig,
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tar::{Builder, Header};

pub const MOBPACK_SCHEMA_VERSION: &str = "0.1.0";
pub const MOBPACK_MEDIA_TYPE: &str = "application/vnd.meerkat.mobpack";
pub const MOBPACK_VALIDATION_SOURCE: &str =
    "meerkat_mob::validate_definition + meerkat_mob::SpecValidator";
const GRAPH_GATE_KINDS: &[&str] = &["branch", "fork", "join"];
const GRAPH_PALETTE_GATE_KINDS: &[&str] = &["branch", "fork"];
const GRAPH_TERMINAL_KINDS: &[&str] = &["success", "failed", "human"];
const GRAPH_FRAME_KINDS: &[&str] = &["Branch", "Parallel", "RepeatUntil"];
const GRAPH_EDGE_KINDS: &[&str] = &["next", "cond", "fanout"];
const REPEAT_ITERATION_INPUTS: &[&str] = &["carry"];
const EDITOR_FLOW_STEP_TYPES: &[&str] = &[
    "input", "member", "repeat", "branch", "parallel", "adaptive",
];
/// Adaptive limit contract: (editor camelCase key, policies.toml key, default,
/// editor label, design-prominent primary flag). Order matches
/// `meerkat_mob::adaptive::AdaptiveLimitRecord` (meerkat 0.7.1) field order.
const EDITOR_ADAPTIVE_LIMITS: &[(&str, &str, u64, &str, bool)] = &[
    ("maxDepth", "max_depth", 4, "Max depth", true),
    (
        "maxTotalDecisions",
        "max_total_decisions",
        12,
        "Max total decisions",
        false,
    ),
    (
        "maxRepairAttempts",
        "max_repair_attempts",
        2,
        "Max repair attempts",
        false,
    ),
    (
        "maxLayerFailures",
        "max_layer_failures",
        2,
        "Max layer failures",
        false,
    ),
    (
        "maxAttemptsPerLayer",
        "max_attempts_per_layer",
        2,
        "Max attempts / layer",
        false,
    ),
    (
        "maxMembersPerLayer",
        "max_members_per_layer",
        20,
        "Max members / layer",
        true,
    ),
    (
        "maxTotalSpawnedMembers",
        "max_total_spawned_members",
        60,
        "Max total members",
        true,
    ),
    (
        "maxActiveMembers",
        "max_active_members",
        20,
        "Max active members",
        false,
    ),
    (
        "maxRetainedLayerMobs",
        "max_retained_layer_mobs",
        4,
        "Max retained layer mobs",
        false,
    ),
    (
        "maxWallClockMs",
        "max_wall_clock_ms",
        1_800_000,
        "Max wall clock (ms)",
        false,
    ),
    (
        "maxAggregateTokens",
        "max_aggregate_tokens",
        2_000_000,
        "Max aggregate tokens",
        false,
    ),
    (
        "maxAggregateToolCalls",
        "max_aggregate_tool_calls",
        500,
        "Max aggregate tool calls",
        false,
    ),
];
const EDITOR_ADAPTIVE_TARGET_SURFACES: &[&str] = &["cli", "rpc"];
/// Bundled `adaptive/layer-decision.schema.json` payload: the canonical
/// LayerDecision contract, generated via schemars from
/// `meerkat_mob::adaptive::LayerDecision` (meerkat `schema` feature) — the
/// run_layer/finish tagged union with the full LayerPlan shape. This MUST stay
/// `serde_json::Value`-equal to meerkat's `layer_decision_schema()`; a mismatch
/// fails any adaptive-pack deploy/validate with `StaleAdaptiveSchema`.
/// Regenerate via `schemars::schema_for!(meerkat_mob::adaptive::LayerDecision)`
/// when the adaptive types change.
///
/// As of meerkat 0.7.1+ the `LayerProfile::inline` variant is a full
/// `{"$ref": "#/$defs/Profile"}` with a complete Profile `$def` — inline
/// profiles are validated STRUCTURALLY, not accepted as an opaque `true`. (The
/// `SchemaRef`/`AdaptiveValue` defs still use `anyOf`-with-`true` for genuinely
/// opaque slots.) Inline profiles remain gated by the pack policy's
/// `allow_inline_profiles`.
const ADAPTIVE_LAYER_DECISION_SCHEMA_JSON: &str =
    include_str!("adaptive_layer_decision.schema.json");
const EDITOR_INPUT_STEP_ID_PREFIX: &str = "input";
const EDITOR_INPUT_STEP_DEFAULT_TASK: &str = "Run the mobpack flow.";
/// Default watchdog for the blocking `rkat mob run` child process. Under
/// meerkat 0.7 `mob run` accepts no duration budget and blocks until the
/// flow run is terminal, so this local kill (deploy.max_duration when set,
/// this default otherwise) is the ONLY bound — and it is a hard process
/// kill that loses the in-memory run. Keep it generous: multi-step LLM
/// flows routinely run minutes.
const DEFAULT_DEPLOY_EXEC_TIMEOUT_MS: u64 = 600_000;
const DEPLOY_EXEC_TIMEOUT_GRACE_MS: u64 = 250;
const MOBPACK_DRAFT_STORE_ENV: &str = "MOBKIT_FLOW_EDITOR_DRAFT_STORE";
const MOBPACK_DRAFT_STORE_FILENAME: &str = "meerkat-mobkit-flow-editor-drafts.json";
const MOBPACK_DRAFT_STATE_DIR: &str = "meerkat-mobkit";
const EDITOR_SCHEMA_FIELD_TYPES: &[&str] = &[
    "string", "string[]", "number", "float", "int", "integer", "boolean", "bool", "enum", "bytes",
    "object",
];

static MOBPACK_DRAFT_STORE_LOCK: Mutex<()> = Mutex::new(());

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MobpackRuntimeCatalogState {
    pub loaded_modules: Vec<String>,
    pub runtime_methods: Vec<String>,
    pub has_contact_directory: bool,
    pub has_peer_mob_handles: bool,
    pub has_inproc_contacts: bool,
    pub runtime_flow_rows: Vec<Value>,
    pub runtime_agent_definition_sources: Vec<Value>,
    pub runtime_skill_realms: Vec<Value>,
}

fn editor_input_step_default_task() -> &'static str {
    EDITOR_INPUT_STEP_DEFAULT_TASK
}

fn editor_input_step_value(
    id: &str,
    task: String,
    fields: String,
    input_params: Vec<Value>,
) -> Value {
    json!({
        "id": id,
        "type": "input",
        "task": task,
        "fields": fields,
        "inputParams": input_params
    })
}

fn editor_input_step_draft_contract() -> Value {
    json!({
        "document_path": "document.flow.steps[type=input]",
        "default_step": {
            "id": EDITOR_INPUT_STEP_ID_PREFIX,
            "type": "input",
            "task": editor_input_step_default_task(),
            "fields": "",
            "inputParams": []
        }
    })
}

fn editor_adaptive_limit_defaults() -> Value {
    let mut limits = serde_json::Map::new();
    for (camel_key, _, default, _, _) in EDITOR_ADAPTIVE_LIMITS {
        limits.insert((*camel_key).to_string(), Value::from(*default));
    }
    Value::Object(limits)
}

fn editor_adaptive_step_defaults() -> Value {
    json!({
        "type": "adaptive",
        "flowmasterId": "",
        "prompt": "",
        "objectiveClass": "",
        "resultSchema": "",
        "profileTemplateIds": [],
        "targetSurfaces": EDITOR_ADAPTIVE_TARGET_SURFACES,
        "allowInlineProfiles": false,
        "limits": editor_adaptive_limit_defaults(),
    })
}

fn editor_adaptive_limit_rows() -> Value {
    Value::Array(
        EDITOR_ADAPTIVE_LIMITS
            .iter()
            .map(|(camel_key, toml_key, _, label, primary)| {
                json!({
                    "key": camel_key,
                    "toml_key": toml_key,
                    "label": label,
                    "primary": primary,
                })
            })
            .collect(),
    )
}

fn collect_adaptive_flow_steps(steps: &[Value], path: &str, out: &mut Vec<(String, Value)>) {
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        if step.get("type").and_then(Value::as_str) == Some("adaptive") {
            out.push((step_path.clone(), step.clone()));
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_adaptive_flow_steps(nested, &format!("{step_path}.steps"), out);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_adaptive_flow_steps(
                        branch_steps,
                        &format!("{step_path}.branches[{branch_index}].steps"),
                        out,
                    );
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_adaptive_flow_steps(fallback, &format!("{step_path}.fallback"), out);
        }
    }
}

fn adaptive_flow_steps(flow: &Value) -> Vec<(String, Value)> {
    let mut out = Vec::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_adaptive_flow_steps(steps, "flow.steps", &mut out);
    }
    out
}

/// Registry names must satisfy meerkat's adaptive `SchemaName` identifier
/// rules (`[a-z0-9-]`, no leading/trailing `-`): kebab-case the editor
/// schema id, inserting `-` at camelCase boundaries.
fn adaptive_schema_registry_name(schema_id: &str) -> String {
    let mut out = String::new();
    let mut previous_lower_or_digit = false;
    for ch in schema_id.chars() {
        if ch.is_ascii_uppercase() {
            if previous_lower_or_digit {
                out.push('-');
            }
            out.push(ch.to_ascii_lowercase());
            previous_lower_or_digit = false;
        } else if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
            out.push(ch);
            previous_lower_or_digit = true;
        } else {
            out.push('-');
            previous_lower_or_digit = false;
        }
    }
    let collapsed = out
        .split('-')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("-");
    if collapsed.is_empty() {
        "schema".to_string()
    } else {
        collapsed
    }
}

/// Keep an adaptive step's member references consistent after member
/// deletion: stale flowmaster refs clear, stale profile templates drop.
/// Applied to both the flow step and the graph instance's embedded payload
/// so `graph_derives_flow` equivalence keeps holding.
fn prune_adaptive_step_member_refs(step: &mut Value, member_ids: &BTreeSet<String>) {
    let Some(object) = step.as_object_mut() else {
        return;
    };
    if let Some(flowmaster) = object
        .get("flowmasterId")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        && !member_ids.contains(flowmaster)
    {
        object.insert("flowmasterId".to_string(), Value::String(String::new()));
    }
    if let Some(templates) = object
        .get_mut("profileTemplateIds")
        .and_then(Value::as_array_mut)
    {
        templates.retain(|template| {
            template
                .as_str()
                .map(str::trim)
                .is_some_and(|template| member_ids.contains(template))
        });
    }
}

fn serialized_string_values<T: Serialize>(values: Vec<T>) -> Vec<String> {
    values
        .into_iter()
        .filter_map(|value| {
            serde_json::to_value(value)
                .ok()
                .and_then(|value| value.as_str().map(ToString::to_string))
        })
        .collect()
}

fn editor_schema_field_type_values() -> Vec<String> {
    EDITOR_SCHEMA_FIELD_TYPES
        .iter()
        .map(|value| (*value).to_string())
        .collect()
}

fn is_editor_schema_field_type(value: &str) -> bool {
    EDITOR_SCHEMA_FIELD_TYPES.contains(&value)
}

fn serialized_tag_values<T: DeserializeOwned>(tag: &str, values: Vec<Value>) -> Vec<String> {
    values
        .into_iter()
        .filter_map(|value| {
            serde_json::from_value::<T>(value.clone()).ok()?;
            value
                .get(tag)
                .and_then(Value::as_str)
                .map(ToString::to_string)
        })
        .collect()
}

fn runtime_mode_values() -> Vec<String> {
    serialized_string_values(vec![
        MobRuntimeMode::AutonomousHost,
        MobRuntimeMode::TurnDriven,
    ])
}

fn member_launch_mode_values() -> Vec<String> {
    serialized_tag_values::<MemberLaunchMode>(
        "mode",
        vec![
            json!({ "mode": "fresh" }),
            json!({ "mode": "resume", "bridge_session_id": "00000000-0000-0000-0000-000000000000" }),
            json!({
                "mode": "fork",
                "source_member_id": "lead",
                "fork_context": { "type": "full_history" }
            }),
        ],
    )
}

fn fork_context_values() -> Vec<String> {
    serialized_tag_values::<ForkContext>(
        "type",
        vec![
            json!({ "type": "full_history" }),
            json!({ "type": "last_messages", "count": 5 }),
        ],
    )
}

fn budget_split_policy_values() -> Vec<String> {
    serialized_tag_values::<BudgetSplitPolicy>(
        "type",
        vec![
            json!({ "type": "equal" }),
            json!({ "type": "proportional" }),
            json!({ "type": "remaining" }),
            json!({ "type": "fixed", "value": 4096 }),
        ],
    )
}

fn canonical_fork_context_value(value: &str) -> String {
    match value.trim() {
        "" | "FullHistory" => "full_history".to_string(),
        "LastMessages" => "last_messages".to_string(),
        other => other.to_string(),
    }
}

fn dispatch_mode_values() -> Vec<String> {
    serialized_string_values(vec![
        DispatchMode::FanOut,
        DispatchMode::OneToOne,
        DispatchMode::FanIn,
    ])
}

fn dispatch_mode_labels() -> Value {
    json!({
        "fan_out": "fan_out — broadcast to every lane",
        "one_to_one": "one_to_one — pair inputs with lanes",
        "fan_in": "fan_in — gather upstream outputs"
    })
}

fn collection_policy_values() -> Vec<String> {
    serialized_tag_values::<CollectionPolicy>(
        "type",
        vec![
            json!({ "type": "all" }),
            json!({ "type": "any" }),
            json!({ "type": "quorum", "n": 1 }),
        ],
    )
}

fn collection_policy_labels() -> Value {
    json!({
        "all": "all — wait for every branch",
        "any": "any — accept the first completed branch",
        "quorum": "quorum — require N branches"
    })
}

fn dependency_mode_values() -> Vec<String> {
    serialized_string_values(vec![DependencyMode::All, DependencyMode::Any])
}

fn dependency_mode_labels() -> Value {
    json!({
        "all": "all — every upstream node",
        "any": "any — any upstream node"
    })
}

fn step_output_format_values() -> Vec<String> {
    serialized_string_values(vec![StepOutputFormat::Json, StepOutputFormat::Text])
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MobpackDocument {
    #[serde(default = "default_schema_version")]
    pub schema_version: String,
    #[serde(default)]
    pub mob_id: String,
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub mob_settings: Value,
    #[serde(default)]
    pub members: Value,
    #[serde(default)]
    pub instances: Value,
    #[serde(default)]
    pub edges: Value,
    #[serde(default)]
    pub frames: Value,
    #[serde(default)]
    pub schemas: Value,
    #[serde(default)]
    pub skill_realms: Value,
    #[serde(default)]
    pub flow: Value,
    #[serde(default)]
    pub launch_modes: Value,
    #[serde(default)]
    pub deploy: Value,
    #[serde(default)]
    pub mob_toml: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobpackDiagnostic {
    pub severity: String,
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobpackDisplayRow {
    pub kind: String,
    pub glyph: String,
    pub head: String,
    pub sub: String,
    pub meta: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MobpackValidationResult {
    pub ok: bool,
    pub diagnostics: Vec<MobpackDiagnostic>,
    pub display_rows: Vec<MobpackDisplayRow>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mob_id: Option<String>,
    pub flow_ids: Vec<String>,
    pub validation_source: String,
    pub deploy_command: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MobpackExportResult {
    pub filename: String,
    pub media_type: String,
    pub content_base64: String,
    pub mob_toml: String,
    pub source_files: Vec<MobpackSourceFile>,
    pub validation: MobpackValidationResult,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MobpackSourceResult {
    pub filename: String,
    pub media_type: String,
    pub mob_toml: String,
    pub source_files: Vec<MobpackSourceFile>,
    pub validation: MobpackValidationResult,
    pub source: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MobpackSourceFile {
    pub path: String,
    pub media_type: String,
    pub size_bytes: u64,
    pub content_base64: String,
    #[serde(default)]
    pub sha256: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MobpackDeployResult {
    pub filename: String,
    pub pack_path: String,
    pub pack_sha256: String,
    pub command: String,
    pub argv: Vec<String>,
    pub plan_trace: Vec<Value>,
    pub executed: bool,
    pub success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status_code: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stdout: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stderr: Option<String>,
    pub validation: MobpackValidationResult,
    pub display_rows: Vec<MobpackDisplayRow>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MobpackDeployCommandResult {
    pub command: String,
    pub argv: Vec<String>,
    pub deploy_command: String,
    pub filename: String,
    pub validation: MobpackValidationResult,
    pub source: String,
}

fn default_schema_version() -> String {
    MOBPACK_SCHEMA_VERSION.to_string()
}

fn discover_skill_realms(authoring_sources: &Value) -> Value {
    let mut realms = Vec::new();
    for (realm_id, label, dir) in skill_catalog_dirs() {
        let skills = discover_skills_in_dir(&dir);
        if !skills.is_empty() {
            realms.push(json!({
                "id": realm_id,
                "label": label,
                "default": realms.is_empty(),
                "source": "filesystem",
                "path": dir.to_string_lossy(),
                "skills": skills,
            }));
        }
    }
    if let Some(authoring_realm) = authoring_skill_realm(authoring_sources, realms.is_empty()) {
        realms.push(authoring_realm);
    }
    Value::Array(realms)
}

fn authoring_skill_realm(authoring_sources: &Value, default: bool) -> Option<Value> {
    mobpack_source_skill_realm(
        authoring_sources,
        default,
        "mobkit/authoring-agent-definitions",
        "mobkit/authoring-agent-definitions",
        "mobkit/authoring-agent-definition",
        "authoring_mobpacks[].document.skill_realms[]",
        "authoring_mobpacks[].document.skill_realms[].skills[]",
    )
}

fn sample_skill_realm(sample_mobpacks: &Value, default: bool) -> Option<Value> {
    mobpack_source_skill_realm(
        sample_mobpacks,
        default,
        "mobkit/sample-mobpacks",
        "mobkit/sample-mobpacks",
        "mobkit/sample-mobpack",
        "sample_mobpacks[].document.skill_realms[]",
        "sample_mobpacks[].document.skill_realms[].skills[]",
    )
}

fn mobpack_source_skill_realm(
    mobpacks: &Value,
    default: bool,
    realm_id: &str,
    realm_label: &str,
    realm_source: &str,
    realm_source_document_path: &str,
    skill_source_document_path: &str,
) -> Option<Value> {
    let mut skills = BTreeMap::<String, Value>::new();
    for mobpack in mobpacks.as_array()? {
        let Some(source_mobpack) = mobpack
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            continue;
        };
        let Some(mobpack_source) = mobpack
            .get("source")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            continue;
        };
        let source_name = mobpack
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or(source_mobpack);
        let Some(realms) = mobpack
            .get("document")
            .and_then(|document| document.get("skill_realms"))
            .and_then(Value::as_array)
        else {
            continue;
        };
        for realm in realms {
            let Some(realm_id) = realm
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                continue;
            };
            let Some(realm_skills) = realm.get("skills").and_then(Value::as_array) else {
                continue;
            };
            for skill in realm_skills {
                let Some(skill_id) = skill
                    .get("id")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                else {
                    continue;
                };
                skills.entry(skill_id.to_string()).or_insert_with(|| {
                    let mut projected = skill.as_object().cloned().unwrap_or_default();
                    projected
                        .entry("label".to_string())
                        .or_insert_with(|| Value::String(skill_id.to_string()));
                    projected.insert(
                        "origin".to_string(),
                        Value::String(mobpack_source.to_string()),
                    );
                    projected.insert(
                        "sourceMobpack".to_string(),
                        Value::String(source_mobpack.to_string()),
                    );
                    projected.insert(
                        "sourceMobpackName".to_string(),
                        Value::String(source_name.to_string()),
                    );
                    projected.insert(
                        "sourceRealm".to_string(),
                        Value::String(realm_id.to_string()),
                    );
                    projected.insert(
                        "sourceDocumentPath".to_string(),
                        Value::String(skill_source_document_path.to_string()),
                    );
                    projected.insert(
                        "deployability".to_string(),
                        catalog_deployability(
                            true,
                            "inline/path skill definition is packable into the deployable mobpack",
                        ),
                    );
                    projected.insert(
                        "provenance".to_string(),
                        catalog_provenance(
                            "mobkit/skills/catalog",
                            mobpack_source,
                            skill_source_document_path,
                        ),
                    );
                    Value::Object(projected)
                });
            }
        }
    }
    if skills.is_empty() {
        return None;
    }
    Some(json!({
        "id": realm_id,
        "label": realm_label,
        "default": default,
        "source": realm_source,
        "sourceDocumentPath": realm_source_document_path,
        "skills": skills.into_values().collect::<Vec<_>>(),
    }))
}

fn skill_catalog_dirs() -> Vec<(String, String, PathBuf)> {
    let mut out = Vec::new();
    if let Ok(raw) = std::env::var("MOBKIT_SKILL_DIRS") {
        for (index, path) in std::env::split_paths(&raw).enumerate() {
            out.push((
                format!("env/skills-{index}"),
                format!("env/skills-{index}"),
                path,
            ));
        }
    }
    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    out.push((
        "mobkit/repo".to_string(),
        "mobkit/repo".to_string(),
        manifest_dir.join("../.claude/skills"),
    ));
    if let Ok(cwd) = std::env::current_dir() {
        out.push((
            "workspace/skills".to_string(),
            "workspace/skills".to_string(),
            cwd.join(".rkat/skills"),
        ));
        out.push((
            "workspace/claude".to_string(),
            "workspace/claude".to_string(),
            cwd.join(".claude/skills"),
        ));
    }
    if let Ok(home) = std::env::var("HOME") {
        let home = PathBuf::from(home);
        out.push((
            "meerkat-mob/source".to_string(),
            "meerkat-mob/source".to_string(),
            home.join("src/meerkat/meerkat-mob/skills"),
        ));
        out.extend(meerkat_mob_registry_skill_dirs(
            &home.join(".cargo/registry/src"),
        ));
    }
    let mut seen = BTreeSet::new();
    out.into_iter()
        .filter(|(_, _, path)| seen.insert(path.clone()))
        .collect()
}

fn meerkat_mob_registry_skill_dirs(registry_root: &Path) -> Vec<(String, String, PathBuf)> {
    let Ok(registries) = std::fs::read_dir(registry_root) else {
        return Vec::new();
    };
    let mut out = Vec::new();
    for registry in registries.flatten() {
        let Ok(crates) = std::fs::read_dir(registry.path()) else {
            continue;
        };
        for crate_entry in crates.flatten() {
            let path = crate_entry.path();
            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
                continue;
            };
            let Some(version) = name.strip_prefix("meerkat-mob-") else {
                continue;
            };
            if !version.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
                continue;
            }
            let skills_dir = path.join("skills");
            if !skills_dir.is_dir() {
                continue;
            }
            out.push((
                format!("meerkat-mob/crate/{version}"),
                format!("meerkat-mob/crate/{version}"),
                skills_dir,
            ));
        }
    }
    out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(&b.2)));
    out
}

fn tool_runtime_availability(field: &str, runtime: Option<&MobpackRuntimeCatalogState>) -> Value {
    let Some(runtime) = runtime else {
        return json!({
            "available": true,
            "state": "deferred",
            "reason": "standalone authoring validates the ToolConfig flag; concrete host availability is evaluated by rkat mob run",
            "required_methods": [],
        });
    };
    let required_methods = match field {
        "mob" => vec![
            "mobkit/cross_mob/wire",
            "mobkit/cross_mob/unwire",
            "mobkit/cross_mob/send",
        ],
        _ => Vec::new(),
    };
    let available = required_methods.iter().all(|method| {
        runtime
            .runtime_methods
            .iter()
            .any(|candidate| candidate == method)
    });
    let reason = if required_methods.is_empty() {
        "runtime-bound authoring provider accepts this ToolConfig flag".to_string()
    } else if available {
        "current UnifiedRuntime exposes the required MobKit control-plane methods".to_string()
    } else {
        "current UnifiedRuntime does not expose the required MobKit cross-mob control-plane methods"
            .to_string()
    };
    json!({
        "available": available,
        "state": if available { "available" } else { "unavailable" },
        "reason": reason,
        "required_methods": required_methods,
    })
}

fn tool_catalog_response() -> Vec<Value> {
    tool_catalog_response_with_runtime_state(None)
}

fn tool_catalog_response_with_runtime_state(
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Vec<Value> {
    let mut tools = tool_config_bool_fields()
        .into_iter()
        .map(|field| {
            let runtime_availability = tool_runtime_availability(&field, runtime);
            json!({
                "id": field,
                "label": field,
                "kind": "runtime",
                "field": field,
                "source": "meerkat_mob::ToolConfig",
                "desc": tool_config_field_desc(&field),
                "tag_class": tool_config_field_tag_class(&field),
                "runtime_availability": runtime_availability,
                "runtimeAvailability": runtime_availability,
                "deployability": catalog_deployability(true, "ToolConfig flag is deployable through mob.toml profile tool settings"),
                "provenance": catalog_provenance("mobkit/tools/catalog", "meerkat_mob::ToolConfig", &format!("ToolConfig.{field}")),
            })
        })
        .collect::<Vec<_>>();
    for source in discover_mcp_sources() {
        let runtime_availability = json!({
            "available": true,
            "state": "configured",
            "reason": "MCP source is configured on the current authoring host",
            "required_methods": [],
        });
        tools.push(json!({
            "id": format!("mcp:{source}"),
            "label": format!("mcp:{source}"),
            "kind": "mcp",
            "source": source,
            "desc": "Configured host MCP source allowlist entry",
            "tag_class": "is-write",
            "runtime_availability": runtime_availability.clone(),
            "runtimeAvailability": runtime_availability,
            "deployability": catalog_deployability(true, "MCP source is discovered from the current authoring host configuration"),
            "provenance": catalog_provenance("mobkit/tools/catalog", "host_mcp_config", &source),
        }));
    }
    for bundle in discover_rust_tool_bundles() {
        let runtime_availability = json!({
            "available": true,
            "state": "configured",
            "reason": "Rust tool bundle is configured on the current authoring host",
            "required_methods": [],
        });
        tools.push(json!({
            "id": format!("rust:{bundle}"),
            "label": format!("rust:{bundle}"),
            "kind": "rust",
            "source": bundle,
            "desc": "Host-registered Rust tool bundle name",
            "tag_class": "",
            "runtime_availability": runtime_availability.clone(),
            "runtimeAvailability": runtime_availability,
            "deployability": catalog_deployability(true, "Rust tool bundle is discovered from the current authoring host configuration"),
            "provenance": catalog_provenance("mobkit/tools/catalog", "host_rust_tool_bundles", &bundle),
        }));
    }
    tools
}

fn tool_config_bool_fields() -> Vec<String> {
    serde_json::to_value(ToolConfig::default())
        .ok()
        .and_then(|value| {
            value.as_object().map(|object| {
                object
                    .iter()
                    .filter(|(_, value)| value.is_boolean())
                    .map(|(field, _)| field.clone())
                    .collect::<Vec<_>>()
            })
        })
        .filter(|fields| !fields.is_empty())
        .unwrap_or_default()
}

fn tool_config_field_desc(field: &str) -> String {
    match field {
        "builtins" => "Built-in host tools such as file reads where enabled",
        "shell" => "Host shell execution tool",
        "comms" => "Peer messaging and comms tools",
        "memory" => "Realm memory and semantic search tools",
        "workgraph" => "WorkGraph commitment tools",
        "mob" => "Mob management tools: spawn, retire, wire, unwire, list",
        "schedule" => "Schedule create/list/update/pause/resume/delete tools",
        "image_generation" => "Assistant image generation tools",
        other => return format!("MobKit ToolConfig.{other} runtime tool flag"),
    }
    .to_string()
}

fn tool_config_field_tag_class(field: &str) -> &'static str {
    match field {
        "shell" => "is-shell",
        _ => "",
    }
}

fn discover_mcp_sources() -> BTreeSet<String> {
    let mut sources = BTreeSet::new();
    if let Ok(raw) = std::env::var("MOBKIT_MCP_SOURCES") {
        sources.extend(split_env_list(&raw));
    }
    for path in mcp_config_paths() {
        sources.extend(mcp_sources_from_toml(&path));
    }
    sources
}

fn discover_rust_tool_bundles() -> BTreeSet<String> {
    std::env::var("MOBKIT_RUST_TOOL_BUNDLES")
        .map(|raw| split_env_list(&raw))
        .unwrap_or_default()
}

fn split_env_list(raw: &str) -> BTreeSet<String> {
    raw.split([',', ':', ';'])
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn mcp_config_paths() -> Vec<PathBuf> {
    let mut paths = Vec::new();
    if let Ok(cwd) = std::env::current_dir() {
        paths.push(cwd.join(".rkat/mcp.toml"));
    }
    if let Ok(home) = std::env::var("HOME") {
        paths.push(PathBuf::from(home).join(".rkat/mcp.toml"));
    }
    paths
}

fn mcp_sources_from_toml(path: &Path) -> BTreeSet<String> {
    let Ok(text) = std::fs::read_to_string(path) else {
        return BTreeSet::new();
    };
    let Ok(value) = text.parse::<toml::Value>() else {
        return BTreeSet::new();
    };
    value
        .get("servers")
        .and_then(toml::Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|server| server.get("name").and_then(toml::Value::as_str))
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn discover_skills_in_dir(dir: &Path) -> Vec<Value> {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut skills = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        let skill_path = if path.is_dir() {
            path.join("SKILL.md")
        } else {
            path
        };
        if skill_path.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") {
            continue;
        }
        if let Ok(content) = std::fs::read_to_string(&skill_path)
            && let Some(skill) = skill_catalog_entry_from_markdown(&skill_path, &content)
        {
            skills.push(skill);
        }
    }
    skills.sort_by(|a, b| {
        a.get("id")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .cmp(b.get("id").and_then(Value::as_str).unwrap_or_default())
    });
    skills
}

fn skill_catalog_entry_from_markdown(path: &Path, content: &str) -> Option<Value> {
    let id = path
        .parent()
        .and_then(Path::file_name)
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())?
        .to_string();
    let metadata = parse_skill_frontmatter(content);
    let label = metadata.get("name").cloned().unwrap_or_else(|| id.clone());
    let desc = metadata.get("description").cloned().unwrap_or_default();
    let capabilities = metadata
        .get("requires_capabilities")
        .map(|raw| {
            raw.trim_matches(['[', ']'])
                .split(',')
                .map(|part| part.trim().trim_matches('"').to_string())
                .filter(|part| !part.is_empty())
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    Some(json!({
        "id": id,
        "label": label,
        "source": "path",
        "origin": "filesystem",
        "path": path.to_string_lossy(),
        "desc": desc,
        "requires_capabilities": capabilities,
        "deployability": catalog_deployability(true, "filesystem skill can be packed into the deployable mobpack"),
        "provenance": catalog_provenance("mobkit/skills/catalog", "filesystem", &path.to_string_lossy()),
    }))
}

fn parse_skill_frontmatter(content: &str) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    let mut lines = content.lines();
    if lines.next() != Some("---") {
        return out;
    }
    for line in lines {
        if line.trim() == "---" {
            break;
        }
        if let Some((key, value)) = line.split_once(':') {
            out.insert(
                key.trim().to_string(),
                value.trim().trim_matches('"').to_string(),
            );
        }
    }
    out
}

fn catalog_deployability(deployable: bool, reason: &str) -> Value {
    json!({
        "deployable": deployable,
        "command": "rkat mob run",
        "reason": reason,
    })
}

fn catalog_provenance(catalog: &str, source: &str, path: &str) -> Value {
    json!({
        "catalog": catalog,
        "source": source,
        "path": path,
    })
}

fn with_catalog_snapshot(mut value: Value, source: &str, runtime_backed: bool) -> Value {
    let snapshot_id = serde_json::to_vec(&value)
        .map(|bytes| source_file_sha256(&bytes))
        .unwrap_or_default();
    if let Some(object) = value.as_object_mut() {
        object.insert(
            "catalog_snapshot".to_string(),
            json!({
                "id": snapshot_id,
                "source": source,
                "runtime_backed": runtime_backed,
                "deploy_command": "rkat mob run",
            }),
        );
    }
    value
}

const STANDALONE_AUTHORING_RUNTIME_UNAVAILABLE_REASON: &str =
    "standalone Flow Editor authoring server has no UnifiedRuntime handle";

fn standalone_authoring_provider(source: &str) -> Value {
    json!({
        "id": "standalone_authoring",
        "kind": "mobpack_authoring_provider",
        "source": source,
        "runtime_backed": false,
        "runtime_binding": "unbound",
        "runtime_unavailable_reason": STANDALONE_AUTHORING_RUNTIME_UNAVAILABLE_REASON,
    })
}

fn runtime_authoring_provider(source: &str, state: &MobpackRuntimeCatalogState) -> Value {
    let mut loaded_modules = state.loaded_modules.clone();
    loaded_modules.sort();
    loaded_modules.dedup();
    let mut runtime_methods = state.runtime_methods.clone();
    runtime_methods.sort();
    runtime_methods.dedup();
    json!({
        "id": "unified_runtime",
        "kind": "mobpack_authoring_provider",
        "source": source,
        "runtime_backed": true,
        "runtime_binding": "bound",
        "runtime_type": "unified",
        "loaded_modules": loaded_modules,
        "runtime_methods": runtime_methods,
        "deploy_target": {
            "command": "rkat mob run",
            "surface": ["cli", "rpc"],
            "available": true,
            "realm_backend": ["jsonl", "sqlite"],
        },
        "cross_mob": {
            "contact_directory": state.has_contact_directory,
            "peer_mob_handles": state.has_peer_mob_handles,
            "inproc_contacts": state.has_inproc_contacts,
        },
    })
}

fn authoring_provider(source: &str, runtime: Option<&MobpackRuntimeCatalogState>) -> Value {
    runtime
        .map(|state| runtime_authoring_provider(source, state))
        .unwrap_or_else(|| standalone_authoring_provider(source))
}

fn authoring_provider_is_runtime_backed(provider: &Value) -> bool {
    provider
        .get("runtime_backed")
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn authoring_provider_runtime_binding(provider: &Value) -> &'static str {
    if authoring_provider_is_runtime_backed(provider) {
        "bound"
    } else {
        "unbound"
    }
}

fn runtime_unavailable_reason(provider: &Value) -> Value {
    if authoring_provider_is_runtime_backed(provider) {
        Value::Null
    } else {
        Value::String(STANDALONE_AUTHORING_RUNTIME_UNAVAILABLE_REASON.to_string())
    }
}

fn model_catalog_response() -> Vec<Value> {
    meerkat_models::catalog()
        .iter()
        .map(|entry| {
            json!({
                "id": entry.id,
                "label": entry.display_name,
                "vendor": entry.provider,
                "provider": entry.provider,
                "deployability": catalog_deployability(true, "model is present in the MobKit model catalog used for deploy settings"),
                "provenance": catalog_provenance("mobkit/mobpacks/catalogs", "meerkat_models::catalog", entry.id),
                "profile": meerkat_core::Provider::parse_strict(entry.provider)
                    .and_then(|provider| meerkat_models::profile_for(provider, entry.id))
                    .and_then(|profile| serde_json::to_value(profile).ok()),
            })
        })
        .collect()
}

fn provider_defaults_response() -> Vec<Value> {
    meerkat_models::provider_defaults()
        .iter()
        .filter_map(|default| serde_json::to_value(default).ok())
        .collect()
}

fn authoring_skill_realms_response() -> Value {
    let authoring_sources = authoring_agent_definition_mobpack_sources();
    discover_skill_realms(&authoring_sources)
}

fn skill_realms_response_with_runtime_state(runtime: Option<&MobpackRuntimeCatalogState>) -> Value {
    let mut realms = runtime
        .map(|state| state.runtime_skill_realms.clone())
        .unwrap_or_default();
    let runtime_ids = realms
        .iter()
        .filter_map(|realm| realm.get("id").and_then(Value::as_str).map(str::to_string))
        .collect::<BTreeSet<_>>();
    if let Some(authoring_realms) = authoring_skill_realms_response().as_array() {
        realms.extend(authoring_realms.iter().filter_map(|realm| {
            let id = realm.get("id").and_then(Value::as_str).unwrap_or_default();
            (!runtime_ids.contains(id)).then(|| realm.clone())
        }));
    }
    Value::Array(realms)
}

pub fn mobpack_tools_catalog_response() -> Value {
    mobpack_tools_catalog_response_with_runtime(None)
}

pub fn mobpack_tools_catalog_response_with_runtime(
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Value {
    let provider = authoring_provider("mobkit/tools/catalog", runtime);
    let runtime_backed = authoring_provider_is_runtime_backed(&provider);
    with_catalog_snapshot(
        json!({
            "schema_version": MOBPACK_SCHEMA_VERSION,
            "runtime_backed": runtime_backed,
            "source": "mobkit/tool-config",
            "authoring_provider": provider,
            "runtime_unavailable_reason": runtime_unavailable_reason(&provider),
            "tool_catalog": tool_catalog_response_with_runtime_state(runtime),
        }),
        "mobkit/tools/catalog",
        runtime_backed,
    )
}

pub fn mobpack_skills_catalog_response() -> Value {
    mobpack_skills_catalog_response_with_runtime(None)
}

pub fn mobpack_skills_catalog_response_with_runtime(
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Value {
    let provider = authoring_provider("mobkit/skills/catalog", runtime);
    let runtime_backed = authoring_provider_is_runtime_backed(&provider);
    with_catalog_snapshot(
        json!({
            "schema_version": MOBPACK_SCHEMA_VERSION,
            "runtime_backed": runtime_backed,
            "source": "mobkit/authoring-skill-realms",
            "authoring_provider": provider,
            "runtime_unavailable_reason": runtime_unavailable_reason(&provider),
            "skill_realms": skill_realms_response_with_runtime_state(runtime),
        }),
        "mobkit/skills/catalog",
        runtime_backed,
    )
}

pub fn mobpack_agent_definitions_response() -> Value {
    mobpack_agent_definitions_response_with_runtime(None)
}

pub fn mobpack_agent_definitions_response_with_runtime(
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Value {
    let provider = authoring_provider("mobkit/agent_definitions/list", runtime);
    let runtime_backed = authoring_provider_is_runtime_backed(&provider);
    let tool_catalog = tool_catalog_response_with_runtime_state(runtime);
    let skill_realms = skill_realms_response_with_runtime_state(runtime);
    let authoring_sources = authoring_agent_definition_mobpack_sources();
    let source_mobpacks = runtime
        .filter(|state| !state.runtime_agent_definition_sources.is_empty())
        .map(|state| {
            let mut sources = state.runtime_agent_definition_sources.clone();
            if let Some(fallback_sources) = authoring_sources.as_array() {
                sources.extend(fallback_sources.iter().cloned());
            }
            Value::Array(sources)
        })
        .unwrap_or_else(|| authoring_sources.clone());
    let agent_definitions =
        agent_definition_catalog(&source_mobpacks, &tool_catalog, &skill_realms, "authoring");
    with_catalog_snapshot(
        json!({
            "schema_version": MOBPACK_SCHEMA_VERSION,
            "runtime_backed": runtime_backed,
            "source": "mobkit/authoring-agent-definitions",
            "authoring_provider": provider,
            "runtime_unavailable_reason": runtime_unavailable_reason(&provider),
            "agent_definitions": agent_definitions,
        }),
        "mobkit/agent_definitions/list",
        runtime_backed,
    )
}

pub fn mobpack_templates_response() -> Value {
    mobpack_templates_response_with_runtime(None)
}

pub fn mobpack_templates_response_with_runtime(
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Value {
    let provider = authoring_provider("mobkit/mobpacks/templates", runtime);
    let runtime_backed = authoring_provider_is_runtime_backed(&provider);
    let sample_mobpacks = sample_mobpack_catalog();
    let blank_mobpack = blank_mobpack_template();
    let tool_catalog = tool_catalog_response_with_runtime_state(runtime);
    let sample_skill_realms = sample_skill_realm(&sample_mobpacks, true)
        .map(|realm| Value::Array(vec![realm]))
        .unwrap_or_else(|| Value::Array(Vec::new()));
    let sample_agent_definitions = agent_definition_catalog(
        &sample_mobpacks,
        &tool_catalog,
        &sample_skill_realms,
        "sample",
    );
    with_catalog_snapshot(
        json!({
            "schema_version": MOBPACK_SCHEMA_VERSION,
            "source": "mobkit/mobpack-templates",
            "runtime_backed": runtime_backed,
            "authoring_provider": provider,
            "runtime_unavailable_reason": runtime_unavailable_reason(&provider),
            "blank_mobpack": blank_mobpack,
            "sample_mobpacks": sample_mobpacks,
            "sample_agent_definitions": sample_agent_definitions,
            "templates": {
                "blank_mobpack": blank_mobpack,
                "sample_mobpacks": sample_mobpacks,
            },
        }),
        "mobkit/mobpacks/templates",
        runtime_backed,
    )
}

pub fn mobpack_catalogs_response() -> Value {
    mobpack_catalogs_response_with_runtime(None)
}

pub fn mobpack_catalogs_response_with_runtime(
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Value {
    let provider = authoring_provider("mobkit/mobpacks/catalogs", runtime);
    let runtime_backed = authoring_provider_is_runtime_backed(&provider);
    let runtime_binding = authoring_provider_runtime_binding(&provider);
    let tools = mobpack_tools_catalog_response_with_runtime(runtime);
    let skills = mobpack_skills_catalog_response_with_runtime(runtime);
    let agents = mobpack_agent_definitions_response_with_runtime(runtime);
    let templates = mobpack_templates_response_with_runtime(runtime);
    with_catalog_snapshot(
        json!({
            "schema_version": MOBPACK_SCHEMA_VERSION,
            "runtime_backed": runtime_backed,
            "authoring_provider": provider.clone(),
            "runtime_unavailable_reason": runtime_unavailable_reason(&provider),
            "sources": {
                "catalog": "mobkit/mobpacks/catalogs",
                "tools": "mobkit/tools/catalog",
                "skills": "mobkit/skills/catalog",
                "agent_definitions": "mobkit/agent_definitions/list",
                "templates": "mobkit/mobpacks/templates",
                "runtime": provider["id"].clone(),
                "runtime_binding": runtime_binding,
                "runtime_unavailable_reason": runtime_unavailable_reason(&provider)
            },
            "templates": templates["templates"].clone(),
            "tool_catalog": tools["tool_catalog"].clone(),
            "skill_realms": skills["skill_realms"].clone(),
            "blank_mobpack": templates["blank_mobpack"].clone(),
            "sample_mobpacks": templates["sample_mobpacks"].clone(),
            "runtime_flows": runtime
                .map(|state| Value::Array(state.runtime_flow_rows.clone()))
                .unwrap_or_else(|| Value::Array(Vec::new())),
            "agent_definitions": agents["agent_definitions"].clone(),
            "sample_agent_definitions": templates["sample_agent_definitions"].clone(),
            "models": model_catalog_response(),
            "provider_defaults": provider_defaults_response(),
        }),
        "mobkit/mobpacks/catalogs",
        runtime_backed,
    )
}

pub fn mobpack_authoring_operations() -> Value {
    json!([
        {
            "type": "sync_graph_to_flow",
            "plane": "graph",
            "authority": "mobkit",
            "requires": [],
            "mutates": ["document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "reconcile_members",
            "plane": "document",
            "authority": "mobkit",
            "requires": [],
            "mutates": ["document.flow", "document.instances", "document.edges", "document.mob_settings"],
            "projection_document_supported": false
        },
        {
            "type": "reconcile_condition_fields",
            "plane": "document",
            "authority": "mobkit",
            "requires": [],
            "mutates": ["document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "reconcile_contract_refs",
            "plane": "document",
            "authority": "mobkit",
            "requires": [],
            "mutates": ["document.members", "document.flow", "document.instances", "document.edges", "document.deploy", "document.mob_settings"],
            "projection_document_supported": false
        },
        {
            "type": "add_agent_definition",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["definition_id"],
            "mutates": ["document.members", "document.schemas", "document.skill_realms"],
            "projection_document_supported": false
        },
        {
            "type": "update_member",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id", "patch"],
            "patch_fields": ["name", "systemPrompt", "profileBinding", "realmProfile", "runtimeMode", "model", "backend", "maxInlinePeerNotifications", "providerParams"],
            "mutates": ["document.members", "document.flow", "document.instances"],
            "projection_document_supported": false
        },
        {
            "type": "add_member_tool",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id", "tool_id"],
            "mutates": ["document.members"],
            "projection_document_supported": false
        },
        {
            "type": "remove_member_tool",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id", "tool_id"],
            "mutates": ["document.members"],
            "projection_document_supported": false
        },
        {
            "type": "toggle_member_skill",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id", "skill_id"],
            "mutates": ["document.members"],
            "projection_document_supported": false
        },
        {
            "type": "remove_member_skill",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id", "skill_id"],
            "mutates": ["document.members"],
            "projection_document_supported": false
        },
        {
            "type": "create_inline_skill",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id", "label", "content"],
            "mutates": ["document.members", "document.skill_realms"],
            "projection_document_supported": false
        },
        {
            "type": "delete_member",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id"],
            "mutates": ["document.members", "document.flow", "document.instances", "document.edges", "document.mob_settings"],
            "projection_document_supported": false
        },
        {
            "type": "assign_member_schema",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["member_id", "schema_id"],
            "mutates": ["document.members", "document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "add_schema",
            "plane": "agent",
            "authority": "mobkit",
            "requires": [],
            "mutates": ["document.schemas"],
            "projection_document_supported": false
        },
        {
            "type": "update_schema",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["schema_id", "patch"],
            "patch_fields": ["description"],
            "mutates": ["document.schemas"],
            "projection_document_supported": false
        },
        {
            "type": "rename_schema",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["schema_id", "new_id"],
            "mutates": ["document.schemas", "document.members"],
            "projection_document_supported": false
        },
        {
            "type": "delete_schema",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["schema_id"],
            "mutates": ["document.schemas", "document.members", "document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "add_schema_field",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["schema_id"],
            "mutates": ["document.schemas"],
            "projection_document_supported": false
        },
        {
            "type": "update_schema_field",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["schema_id", "field_id", "patch"],
            "patch_fields": ["name", "type", "required", "description", "enumValues"],
            "mutates": ["document.schemas", "document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "rename_schema_field",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["schema_id", "field_id", "new_name"],
            "mutates": ["document.schemas", "document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "delete_schema_field",
            "plane": "agent",
            "authority": "mobkit",
            "requires": ["schema_id", "field_id"],
            "mutates": ["document.schemas", "document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "update_deploy_settings",
            "plane": "deploy",
            "authority": "mobkit",
            "requires": ["field"],
            "mutates": ["document.deploy"],
            "projection_document_supported": false
        },
        {
            "type": "update_mob_settings",
            "plane": "deploy",
            "authority": "mobkit",
            "requires": ["field"],
            "mutates": ["document.mob_settings"],
            "projection_document_supported": false
        },
        {
            "type": "update_role_wiring",
            "plane": "deploy",
            "authority": "mobkit",
            "requires": ["action"],
            "mutates": ["document.mob_settings.roleWiring"],
            "projection_document_supported": false
        },
        {
            "type": "add_input_param",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_id"],
            "mutates": ["document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "update_input_param",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_id", "param_id", "patch"],
            "patch_fields": ["name", "type", "required", "description", "enumValues"],
            "mutates": ["document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "rename_input_param",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_id", "param_id", "new_name"],
            "mutates": ["document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "delete_input_param",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_id", "param_id"],
            "mutates": ["document.flow", "document.edges"],
            "projection_document_supported": false
        },
        {
            "type": "insert_flow_step",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_or_pick", "lane_ref"],
            "mutates": ["document.flow", "document.instances", "document.edges", "document.frames"],
            "projection_document_supported": false
        },
        {
            "type": "update_flow_step",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_id", "patch"],
            "mutates": ["document.flow", "document.instances", "document.edges", "document.frames"],
            "projection_document_supported": false
        },
        {
            "type": "apply_flow_step_edit",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_id", "action"],
            "mutates": ["document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "delete_flow_step",
            "plane": "basic",
            "authority": "mobkit",
            "requires": ["step_id"],
            "mutates": ["document.flow", "document.instances", "document.edges", "document.frames"],
            "projection_document_supported": false
        },
        {
            "type": "insert_graph_node",
            "plane": "graph",
            "authority": "mobkit",
            "requires": ["pick", "cell"],
            "mutates": ["document.instances", "document.edges", "document.frames", "document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "apply_graph_node_edit",
            "plane": "graph",
            "authority": "mobkit",
            "requires": ["instance_id", "action"],
            "mutates": ["document.instances", "document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "move_graph_node",
            "plane": "graph",
            "authority": "mobkit",
            "requires": ["instance_id", "cell"],
            "mutates": ["document.instances"],
            "projection_document_supported": false
        },
        {
            "type": "delete_graph_node",
            "plane": "graph",
            "authority": "mobkit",
            "requires": ["instance_id"],
            "mutates": ["document.instances", "document.edges", "document.frames", "document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "connect_graph_nodes",
            "plane": "graph",
            "authority": "mobkit",
            "requires": ["from_id", "to_id"],
            "mutates": ["document.edges", "document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "apply_graph_edge_edit",
            "plane": "graph",
            "authority": "mobkit",
            "requires": ["edge_id", "action"],
            "mutates": ["document.edges", "document.flow"],
            "projection_document_supported": false
        },
        {
            "type": "delete_graph_edge",
            "plane": "graph",
            "authority": "mobkit",
            "requires": ["edge_id"],
            "mutates": ["document.edges", "document.flow"],
            "projection_document_supported": false
        }
    ])
}

fn deploy_target_string_array(provider: &Value, key: &str, fallback: &[&str]) -> Vec<String> {
    provider
        .get("deploy_target")
        .and_then(|target| target.get(key))
        .and_then(Value::as_array)
        .map(|values| {
            values
                .iter()
                .filter_map(|value| value.as_str().map(str::trim))
                .filter(|value| !value.is_empty())
                .map(ToString::to_string)
                .collect::<Vec<_>>()
        })
        .filter(|values| !values.is_empty())
        .unwrap_or_else(|| {
            fallback
                .iter()
                .map(std::string::ToString::to_string)
                .collect()
        })
}

fn deploy_settings_schema_for_provider(provider: &Value) -> Value {
    let command = provider
        .get("deploy_target")
        .and_then(|target| target.get("command"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("rkat mob run");
    let surfaces = deploy_target_string_array(provider, "surface", &["cli", "rpc"]);
    let realm_backends =
        deploy_target_string_array(provider, "realm_backend", &["jsonl", "sqlite"]);
    let runtime_backed = authoring_provider_is_runtime_backed(provider);
    json!({
        "command": command,
        "defaults": {
            "surface": surfaces.first().cloned().unwrap_or_else(|| "cli".to_string()),
            "trust_policy": DEFAULT_DEPLOY_TRUST_POLICY,
            // `rkat mob run` (meerkat 0.7) carries no model/token/tool-call
            // override flags — those are pack-policy concerns — so the
            // defaults stay unset; validate_deploy_settings warns when a
            // document sets them. `max_duration` is the local watchdog for
            // the blocking `rkat mob run` process (deploy_execution_timeout),
            // not an rkat-enforced budget: it must comfortably exceed real
            // multi-step LLM flow runtimes.
            "model": "",
            "max_duration": "10m",
            "max_tool_calls": 0,
            "max_total_tokens": 0,
            "isolated": true,
            "realm": "",
            "instance": "",
            "realm_backend": realm_backends.first().cloned().unwrap_or_else(|| "jsonl".to_string()),
            "context_root": "",
            "state_root": "",
            "user_config_root": "",
            "prompt": "Reply with exactly OK."
        },
        "surfaces": surfaces,
        "trust_policies": ["permissive", "strict"],
        "realm_backends": realm_backends,
        "runtime_backed": runtime_backed,
        "provenance": catalog_provenance(
            "mobkit/mobpacks/schema",
            if runtime_backed { "UnifiedRuntime.authoring_provider.deploy_target" } else { "mobkit/standalone_authoring_defaults" },
            "deploy_settings"
        ),
    })
}

pub fn mobpack_schema_response() -> Value {
    mobpack_schema_response_with_runtime(None)
}

pub fn mobpack_schema_response_with_runtime(runtime: Option<&MobpackRuntimeCatalogState>) -> Value {
    let provider = authoring_provider("mobkit/mobpacks/schema", runtime);
    let mob_settings_defaults = json!({
        "orchestrator": "",
        "autoWireOrchestrator": false,
        "roleWiring": [],
        "backendDefault": "session",
        "externalAddressBase": "",
        "advanced": {
            "topology": null,
            "supervisor": null,
            "limits": null,
            "spawnPolicy": null,
            "eventRouter": null
        }
    });
    let base_deploy_settings = deploy_settings_schema_for_provider(&provider);
    let deploy_command = base_deploy_settings
        .get("command")
        .and_then(Value::as_str)
        .unwrap_or("rkat mob run");
    let deploy_cli = format!("{deploy_command} <pack.mobpack> --prompt <prompt>");
    let deploy_cli_value = Value::String(deploy_cli);
    let deploy_settings = json!({
        "command": deploy_command,
        "defaults": {
            "command": deploy_command,
            "surface": base_deploy_settings["defaults"]["surface"].clone(),
            "trust_policy": base_deploy_settings["defaults"]["trust_policy"].clone(),
            "model": base_deploy_settings["defaults"]["model"].clone(),
            "max_duration": base_deploy_settings["defaults"]["max_duration"].clone(),
            "max_tool_calls": base_deploy_settings["defaults"]["max_tool_calls"].clone(),
            "max_total_tokens": base_deploy_settings["defaults"]["max_total_tokens"].clone(),
            "isolated": base_deploy_settings["defaults"]["isolated"].clone(),
            "realm": base_deploy_settings["defaults"]["realm"].clone(),
            "instance": base_deploy_settings["defaults"]["instance"].clone(),
            "realm_backend": base_deploy_settings["defaults"]["realm_backend"].clone(),
            "context_root": base_deploy_settings["defaults"]["context_root"].clone(),
            "state_root": base_deploy_settings["defaults"]["state_root"].clone(),
            "user_config_root": base_deploy_settings["defaults"]["user_config_root"].clone(),
            "prompt": base_deploy_settings["defaults"]["prompt"].clone()
        },
        "surfaces": base_deploy_settings["surfaces"].clone(),
        "trust_policies": base_deploy_settings["trust_policies"].clone(),
        "realm_backends": base_deploy_settings["realm_backends"].clone(),
        "runtime_backed": base_deploy_settings["runtime_backed"].clone(),
        "authoring_provider": provider,
        "provenance": base_deploy_settings["provenance"].clone(),
        "options": [
            "model",
            "max_total_tokens",
            "max_duration",
            "max_tool_calls",
            "trust_policy",
            "surface",
            "realm",
            "isolated",
            "instance",
            "realm_backend",
            "state_root",
            "context_root",
            "user_config_root"
        ]
    });
    let editor_graph_draft = json!({
        "branch_gate_label": "branch",
        "branch_condition_lane_label": "condition",
        "branch_fallback_lane_label": "fallback",
        "branch_join_label": "join · branch paths",
        "fallback_edge_label": "fallback",
        "parallel_lane_labels": ["lane 1", "lane 2"],
        "parallel_edge_label": "parallel",
        "rework_edge_label": "rework",
        "terminal_edge_label_prefix": "to ",
        "join_label_prefix": "join · ",
        "join_quorum_label_prefix": "barrier · ",
        "branch_frame_label_prefix": "BRANCH · ",
        "branch_frame_singular_suffix": " path",
        "branch_frame_plural_suffix": " paths",
        "parallel_frame_label_prefix": "PARALLEL · ",
        "parallel_frame_join_infix": " · join ",
        "parallel_missing_dispatch_label": "missing dispatch",
        "parallel_missing_collection_label": "missing collection",
        "repeat_frame_label_prefix": "REPEAT-UNTIL · ",
        "repeat_max_iterations_prefix": "max ",
        "repeat_missing_max_iterations_label": "missing max_iterations",
        "repeat_edge_until_prefix": "until ",
        "repeat_edge_until_fallback": "until condition"
    });
    let editor_source_view = json!({
        "drawer_eyebrow": "SOURCE · mob.toml",
        "inline_title": "mob.toml",
        "primary_source_path": "mobkit/mob.toml",
        "loading_text": "rendering mob.toml from mobkit/mobpacks/source...",
        "copy_label": "copy",
        "close_label": "×"
    });
    let mut editor_graph_view = json!({
        "zoom_out_title": "Zoom out",
        "fit_title": "Fit to view",
        "zoom_in_title": "Zoom in",
        "port_drag_title": "Drag to a member to connect",
        "add_node_search_icon": "⌕",
        "add_node_search_placeholder": "Add a node…",
        "add_node_close_label": "✕",
        "add_node_close_title": "Close",
        "add_node_agents_label": "Agents",
        "add_node_controls_label": "Control flow",
        "add_node_terminals_label": "Terminals",
        "add_node_empty_prefix": "No matches for “",
        "add_node_empty_suffix": "”",
        "add_node_jump_label": "+ New agent in Agents →",
        "authoring_operation_unavailable_error": "MobKit authoring operation API is unavailable",
        "authoring_operation_fallback_error": "MobKit graph operation failed",
        "gate_palette_rows": [
            {
                "id": "branch",
                "glyph": "⑂",
                "label": "Branch gate",
                "meta": "conditional split"
            },
            {
                "id": "fork",
                "glyph": "‖",
                "label": "Parallel fork",
                "meta": "fan_out lanes"
            },
            {
                "id": "join",
                "glyph": "⋈",
                "label": "Join gate",
                "meta": "fan_in barrier"
            }
        ],
        "terminal_palette_rows": [
            {
                "id": "success",
                "glyph": "■",
                "label": "Success",
                "meta": "visual-only; not deployable"
            },
            {
                "id": "failed",
                "glyph": "■",
                "label": "Failed",
                "meta": "visual-only; not deployable"
            },
            {
                "id": "human",
                "glyph": "□",
                "label": "Needs human",
                "meta": "visual-only; not deployable"
            }
        ]
    });
    editor_graph_view["graph_gate_kind_labels"] = json!({
        "branch": "branch — conditional split",
        "fork": "fork — fan out in parallel",
        "join": "join — wait for branches"
    });
    editor_graph_view["graph_terminal_kind_labels"] = json!({
        "success": "success — done",
        "failed": "failed — blocked",
        "human": "human — needs human"
    });
    editor_graph_view["graph_frame_kind_labels"] = json!({
        "Branch": "Branch — conditional flow frame",
        "Parallel": "Parallel — concurrent flow frame",
        "RepeatUntil": "RepeatUntil — bounded loop frame"
    });
    editor_graph_view["graph_edge_kind_labels"] = json!({
        "next": "next — sequential handoff",
        "fanout": "fanout — parallel sibling",
        "cond": "cond — guarded branch"
    });
    editor_graph_view["inspector_delete_label"] = json!("DELETE");
    editor_graph_view["inspector_label_title"] = json!("LABEL");
    editor_graph_view["inspector_kind_title"] = json!("KIND");
    editor_graph_view["inspector_runtime_default_label"] = json!("runtime default");
    editor_graph_view["instance_eyebrow"] = json!("INSTANCE");
    editor_graph_view["instance_id_line_template"] = json!("{id} · cell ({col},{row})");
    editor_graph_view["instance_member_role_template"] = json!("MEMBER · {role}");
    editor_graph_view["instance_edit_member_label"] = json!("EDIT MEMBER →");
    editor_graph_view["instance_model_label"] = json!("model");
    editor_graph_view["instance_schema_label"] = json!("schema");
    editor_graph_view["instance_tools_label"] = json!("tools");
    editor_graph_view["instance_member_hint"] =
        json!("Editing the member updates every instance that uses it.");
    editor_graph_view["instance_position_title"] = json!("POSITION");
    editor_graph_view["instance_position_stage_label"] = json!("stage (col)");
    editor_graph_view["instance_position_slot_label"] = json!("slot (row)");
    editor_graph_view["instance_output_title_template"] = json!("MEMBER OUTPUT · {schema}");
    editor_graph_view["instance_output_required_label"] = json!("req");
    editor_graph_view["instance_output_hint"] = json!("Defined on the member.");
    editor_graph_view["instance_output_open_member_label"] = json!("Open member →");
    editor_graph_view["gate_eyebrow_template"] = json!("GATE · {kind}");
    editor_graph_view["gate_id_line_template"] = json!("{id} · cell ({col},{row})");
    editor_graph_view["gate_quorum_incoming_template"] = json!("of {count} incoming");
    editor_graph_view["gate_member_option_template"] = json!("{name} · {role}");
    editor_graph_view["terminal_eyebrow_template"] = json!("TERMINAL · {kind}");
    editor_graph_view["terminal_id_line_template"] = json!("{id} · cell ({col},{row})");
    editor_graph_view["terminal_authoring_locked_title"] = json!("Visual terminal node");
    editor_graph_view["terminal_authoring_locked_hint"] = json!(
        "Visual terminal nodes do not compile into deployable MobKit mob.toml. Delete this node or replace it with a real member, branch, join, or loop flow shape."
    );
    editor_graph_view["adaptive_authoring_locked_title"] = json!("Adaptive layer node");
    editor_graph_view["adaptive_authoring_locked_hint"] = json!(
        "Adaptive layers are authored in Basic mode — FlowMaster, prompt, profile templates, and limits are edited there. Deleting this node deletes the adaptive step from the flow."
    );
    editor_graph_view["edge_eyebrow_template"] = json!("EDGE · {kind}");
    editor_graph_view["edge_title_template"] = json!("{from} → {to}");
    editor_graph_view["edge_id_line_template"] = json!("{id}");
    editor_graph_view["edge_field_placeholder"] = json!("— field —");
    editor_graph_view["edge_field_no_schema_placeholder"] = json!("(no schema)");
    editor_graph_view["gate_collection_title"] = json!("COLLECTION POLICY");
    editor_graph_view["gate_join_member_label"] = json!("Join member");
    editor_graph_view["gate_join_member_placeholder"] = json!("— select member —");
    editor_graph_view["gate_join_member_hint"] =
        json!("MobKit uses this real profile to resolve non-all fan-in.");
    editor_graph_view["gate_dispatch_title"] = json!("DISPATCH MODE");
    editor_graph_view["gate_dispatch_hint"] =
        json!("Exports as the MobKit parallel flow dispatch mode.");
    editor_graph_view["gate_conditions_title"] = json!("CONDITIONS");
    editor_graph_view["gate_empty_branch_hint"] =
        json!("add outgoing edges, then configure each as a typed condition or fallback");
    editor_graph_view["gate_wiring_title"] = json!("WIRING");
    editor_graph_view["gate_incoming_label"] = json!("incoming");
    editor_graph_view["gate_outgoing_label"] = json!("outgoing");
    editor_graph_view["branch_condition_mode_condition_label"] = json!("condition");
    editor_graph_view["branch_condition_mode_fallback_label"] = json!("fallback");
    editor_graph_view["branch_condition_target_prefix"] = json!("→");
    editor_graph_view["graph_condition_target_missing_label"] = json!("?");
    editor_graph_view["graph_condition_owner_option_template"] = json!("{name}");
    editor_graph_view["graph_condition_field_option_template"] = json!("{name} · {type}");
    editor_graph_view["branch_input_param_source_label"] = json!("Input params");
    editor_graph_view["source_file_label"] = json!("mob.toml");
    editor_graph_view["source_file_aria_label"] = json!("Open mob.toml read-only source editor");
    editor_graph_view["source_file_glyph"] = json!("{ }");
    editor_graph_view["source_file_role_label"] = json!("source file");
    editor_graph_view["source_file_node_id"] = json!("source_mob_toml");
    editor_graph_view["source_file_node_kind"] = json!("source");
    editor_graph_view["source_file_node_col_offset"] = json!(0);
    editor_graph_view["source_file_node_row_offset"] = json!(-1);
    editor_graph_view["source_file_activation_hash"] = json!("#mobkit-graph-source");
    editor_graph_view["source_file_activation_selector"] = json!(".node--source-file");
    editor_graph_view["branch_condition_field_placeholder"] = json!("— field —");
    editor_graph_view["branch_condition_no_options_hint"] =
        json!("add input params or an upstream schema field for this condition");
    editor_graph_view["edge_condition_title"] = json!("CONDITION");
    editor_graph_view["edge_no_condition_options_hint"] =
        json!("Add an upstream agent with an output schema before configuring this edge.");
    editor_graph_view["edge_owner_placeholder"] = json!("— member —");
    editor_graph_view["edge_from_title"] = json!("FROM");
    editor_graph_view["edge_to_title"] = json!("TO");
    editor_graph_view["edge_row_instance_label"] = json!("instance");
    editor_graph_view["edge_row_member_label"] = json!("member");
    editor_graph_view["edge_row_schema_label"] = json!("schema");
    editor_graph_view["edge_row_missing_value"] = json!("—");
    editor_graph_view["edge_terminal_member_value"] = json!("(terminal)");
    let editor_condition_view = json!({
        "empty_value_label": "—",
        "text_value_placeholder": "value"
    });
    let editor_error_view = json!({
        "critical_glyph": "!",
        "generic_error_head": "MobKit error",
        "deploy_failed_head": "Deploy failed",
        "deploy_plan_failed_head": "Deploy plan failed",
        "deploy_error_meta": "mobkit/mobpacks/deploy",
        "source_failed_head": "Source render failed",
        "source_error_meta": "mobkit/mobpacks/source",
        "validation_api_failed_head": "MobKit API unavailable",
        "rpc_error_meta": "/flow-editor/rpc",
        "authoring_operation_failed_head": "MobKit authoring operation failed",
        "authoring_operation_meta": "MobKit authoring",
        "authoring_operation_fallback_heads": {
            "graph_projection": "MobKit graph projection failed",
            "graph_sync": "MobKit graph sync failed",
            "graph_node_insert": "MobKit graph node insert failed",
            "draft_save": "MobKit draft save failed"
        },
        "authoring_operation_stale_error": "stale authoring operation",
        "authoring_operation_missing_document_error": "MobKit authoring operation did not return a document",
        "export_failed_head": "Export failed",
        "import_failed_head": "Import failed",
        "boot_open_missing_head": "Mob not found",
        "boot_open_missing_meta": "flow-editor?open",
        "missing_editor_flow_head": "Imported mobpack is missing a MobKit editor flow",
        "missing_editor_flow_sub": "mobkit/mobpacks/import did not return document.flow.steps",
        "missing_editor_flow_meta": "missing_editor_flow"
    });
    let editor_agent_view = json!({
        "agents_heading": "AGENTS",
        "schemas_heading": "SCHEMAS",
        "add_schema_label": "+ new schema",
        "add_agent_title": "Create an agent from a MobKit profile-member definition.",
        "add_agent_unavailable_title": "MobKit schema contract has not provided agent definitions yet.",
        "add_agent_unavailable_label": "agents unavailable",
        "add_agent_placeholder_label": "+ new agent...",
        "add_agent_error_prefix": "Agent definition unavailable: ",
        "authoring_operation_unavailable_error": "MobKit authoring operation API is unavailable",
        "member_update_fallback_error": "MobKit member update failed",
        "tool_update_fallback_error": "MobKit tool update failed",
        "schema_assignment_fallback_error": "MobKit schema assignment failed",
        "schema_add_fallback_error": "add_schema failed",
        "definition_catalog_title": "DEFINITION CATALOG",
        "definition_catalog_empty": "No MobKit profile-member definitions available.",
        "definition_catalog_source_label": "source",
        "definition_catalog_tools_label": "tools",
        "definition_catalog_skills_label": "skills",
        "member_sub_label_template": "{role} · {model}",
        "member_placed_empty_label": "unplaced",
        "member_placed_count_template": "×{count}",
        "schema_field_singular_template": "{count} field",
        "schema_field_plural_template": "{count} fields",
        "schema_usage_label_template": "used by {count}",
        "sidebar_sub_label_separator": " · ",
        "empty_title": "AGENT LIBRARY",
        "empty_lines": [
            "Select an agent or schema on the left.",
            "Agents are reusable across topologies. Edit one here and every placement updates."
        ],
        "missing_schema_label": "Schema not found.",
        "missing_agent_label": "Agent not found."
    });
    let editor_schema_view = json!({
        "eyebrow": "OUTPUT SCHEMA",
        "description_title": "DESCRIPTION",
        "description_placeholder": "What is this artifact and when is it emitted?",
        "fields_title_prefix": "FIELDS",
        "fields_title_template": "{prefix} · {count}",
        "add_field_label": "+ field",
        "authoring_operation_unavailable_error": "MobKit authoring operation API is unavailable",
        "schema_operation_fallback_error": "MobKit schema operation failed",
        "field_add_fallback_error": "add_schema_field failed",
        "header_labels": {
            "name": "NAME",
            "type": "TYPE",
            "required": "REQ",
            "description": "DESCRIPTION",
            "action": ""
        },
        "empty_fields_hint": "No fields yet. Click + field to start.",
        "used_by_prefix": "USED BY",
        "used_by_title_template": "{prefix} · {count}",
        "usage_singular_template": "used by {count} agent",
        "usage_plural_template": "used by {count} agents",
        "empty_used_by_hint": "Not yet referenced by any agent.",
        "delete_label": "DELETE",
        "delete_blocked_title": "Unassign from agents first",
        "field_name_placeholder": "field_name",
        "field_description_placeholder": "—",
        "field_remove_title": "Remove field",
        "field_enum_label": "VALUES",
        "field_enum_add_label": "+ value",
        "field_enum_add_value": "value"
    });
    let mut editor_agent_detail_view = json!({
        "used_in_label": "used in",
        "instance_singular": "instance",
        "instance_plural": "instances",
        "delete_label": "DELETE",
        "delete_confirm_intro": "Delete agent",
        "delete_confirm_placed_prefix": "It is placed in",
        "cell_singular": "cell",
        "cell_plural": "cells",
        "delete_confirm_cells_suffix": "those nodes will be removed.",
        "usage_title_prefix": "USED IN",
        "empty_usage_hint": "Not yet placed in any cell. Switch to Graph to add.",
        "identity_title": "IDENTITY",
        "profile_binding_label": "Profile binding",
        "missing_profile_binding_label": "missing profile binding",
        "realm_profile_label": "Realm profile",
        "realm_profile_placeholder": "realm profile id",
        "realm_profile_import_hint_fallback": "Realm profile refs are import-only for this editor. Mobpack archives must use inline profiles before validation/export.",
        "realm_profile_title": "REALM PROFILE",
        "realm_profile_reference_hint_before": "This imported member references",
        "realm_profile_reference_hint_after_fallback": "from a target realm. Convert it to an inline profile before validating or exporting a deployable mobpack.",
        "model_label": "Model",
        "runtime_mode_label": "Runtime mode",
        "missing_runtime_mode_label": "missing runtime mode",
        "backend_label": "Backend",
        "backend_definition_default_label": "definition default",
        "inline_peer_notifications_label": "Inline peer notifications",
        "inline_peer_notifications_placeholder": "runtime default",
        "provider_params_label": "Provider params",
        "provider_params_placeholder": "{\"thinking_budget\":4096}",
        "provider_params_rows": 4,
        "provider_params_invalid_json_label": "invalid JSON",
        "provider_params_object_required_error": "provider_params must be a JSON object",
        "system_prompt_title": "SYSTEM PROMPT",
        "apply_skeleton_label": "APPLY SKELETON",
        "apply_skeleton_title": "Apply a MobKit profile prompt skeleton",
        "system_prompt_placeholder": "Describe the member mandate. This text is exported as the profile peer_description.",
        "output_schema_title": "OUTPUT SCHEMA",
        "schema_none_label": "— none —",
        "schema_required_label": "req",
        "edit_schema_label": "Edit schema →",
        "empty_schema_hint": "No structured output. Agent returns free-form text."
    });
    editor_agent_detail_view["agent_eyebrow_prefix"] = json!("AGENT");
    editor_agent_detail_view["runtime_section_title"] = json!("RUNTIME");
    editor_agent_detail_view["delete_cancel_label"] = json!("CANCEL");
    editor_agent_detail_view["source_title"] = json!("SOURCE");
    editor_agent_detail_view["source_empty_hint"] = json!("Created in this editor.");
    editor_agent_detail_view["source_definition_label"] = json!("Definition");
    editor_agent_detail_view["source_mobpack_label"] = json!("Mobpack");
    editor_agent_detail_view["source_origin_label"] = json!("Origin");
    editor_agent_detail_view["source_document_path_label"] = json!("Member path");
    editor_agent_detail_view["source_schema_path_label"] = json!("Schema path");
    editor_agent_detail_view["source_tools_label"] = json!("Tool refs");
    editor_agent_detail_view["source_skills_label"] = json!("Skill refs");
    editor_agent_detail_view["budget_title"] = json!("BUDGET");
    editor_agent_detail_view["budget_disabled_reason"] = json!(
        "MobKit budgets are authored on flow launch modes (document.launch_modes[].budget_split_policy), not profile members."
    );
    editor_agent_detail_view["budget_weight_label"] = json!("Weight");
    editor_agent_detail_view["budget_token_cap_label"] = json!("Token cap");
    editor_agent_detail_view["budget_split_policies_contract_label"] =
        json!("budget_split_policies");
    editor_agent_detail_view["budget_split_policy_labels"] = json!({
        "Equal": "Equal — split among siblings",
        "Proportional": "Proportional — weighted",
        "Remaining": "Remaining — take what's left",
        "Fixed": "Fixed — hard cap"
    });
    let editor_agent_access_view = json!({
        "tool_invalid_error": "Use a MobKit-listed runtime tool or configured MCP/Rust source.",
        "tool_empty_error": "Choose a tool first.",
        "authoring_operation_unavailable_error": "MobKit authoring operation API is unavailable",
        "tool_title": "TOOL ACCESS",
        "tool_hint": "Authority is calculated from this allowlist. Reviewed once here.",
        "tool_missing_description": "—",
        "tool_remove_label": "×",
        "tool_add_select_placeholder": "+ add tool…",
        "tool_source_label": "Configured tool source",
        "tool_source_placeholder": "choose from MobKit tool catalog",
        "tool_add_button_label": "ADD",
        "inline_skill_realm_id": "mobkit/editor-inline",
        "inline_skill_realm_label": "This mobpack",
        "inline_skill_realm_source": "mobkit/editor",
        "inline_skill_source": "inline",
        "inline_skill_default_description": "Inline MobKit skill stored in this mobpack.",
        "skill_default_description": "MobKit skill",
        "skill_selected_check_label": "✓",
        "skill_remove_label": "×",
        "skill_section_title": "SKILLS",
        "skill_inline_cancel_label": "CANCEL",
        "skill_inline_open_label": "+ INLINE",
        "skill_hint": "Selected skills are baked into the mobpack. Browse a realm to add more.",
        "skill_inline_label_placeholder": "mob.skill-name",
        "skill_inline_content_rows": 4,
        "skill_inline_content_placeholder": "Skill instructions stored as [skills.<id>] content",
        "skill_inline_create_hint": "Creates an inline skill definition in this mobpack.",
        "skill_inline_add_label": "ADD SKILL",
        "skill_inline_error_fallback": "Could not create inline skill.",
        "skill_inline_missing_label_error": "Inline skill id or label is required.",
        "skill_inline_missing_content_error": "Inline skill content is required.",
        "skill_inline_invalid_id_error": "Inline skill id or label must contain letters or numbers.",
        "skill_no_realms_message": "MobKit did not provide skill realms for this document.",
        "skill_realm_label": "Realm",
        "skill_default_realm_suffix": " · default",
        "skill_unavailable_heading": "Unavailable in MobKit skill realms:",
        "skill_outside_realm_heading": "Selected from other realms:"
    });
    let editor_new_flow_view = json!({
        "eyebrow_template": "NEW MOB",
        "close_label": "×",
        "name_label": "Name",
        "name_placeholder": "review-mob",
        "start_from_label": "Start from",
        "create_label": "CREATE"
    });
    let editor_flow_registry_view = json!({
        "eyebrow": "LIBRARY",
        "title_singular_suffix": "mob",
        "title_plural_suffix": "mobs",
        "create_label": "+ NEW MOB",
        "create_ready_title": "Create a MobKit mob",
        "create_unavailable_title": "Waiting for MobKit schema",
        "drafts_section_label": "Drafts",
        "runtime_section_label": "Running",
        "runtime_readonly_hint": "Runtime flows are read-only projections; open one to fork it into a draft.",
        "empty_title": "No mobs yet",
        "empty_text": "Create a mob to start authoring a deployable MobKit flow.",
        "updated_just_now_label": "just now",
        "columns": [
            { "key": "name", "label": "NAME" },
            { "key": "description", "label": "DESCRIPTION" },
            { "key": "updated", "label": "UPDATED" },
            { "key": "stage", "label": "STAGE" }
        ]
    });
    let editor_deploy_view = json!({
        "brand_label": "MobKit · Flow Editor",
        "flow_tab_label": "FLOW",
        "agents_tab_label": "AGENTS",
        "settings_tab_label": "SETTINGS",
        "mob_status_title": "Active mob configuration",
        "mob_file_label": "mob.toml",
        "api_error_label": "api error",
        "api_ready_label": "api ready",
        "api_loading_label": "loading",
        "deploy_prefix_label": "deploy:",
        "flows_crumb_label": "mobs",
        "crumb_separator": "/",
        "switcher_view_all_label": "view all mobs",
        "plan_trace_label": "PLAN TRACE",
        "import_label": "IMPORT",
        "validate_label": "VALIDATE",
        "publish_label": "PUBLISH",
        "deploy_plan_label": "DEPLOY PLAN",
        "deploy_label": "DEPLOY",
        "overflow_label": "MORE",
        "theme_switch_prefix": "Switch to",
        "theme_switch_suffix": "mode",
        "dark_theme_label": "☾ dark",
        "light_theme_label": "☀ light",
        "basic_mode_title": "Basic Editor",
        "basic_mode_label": "Basic",
        "graph_mode_title": "Graph Editor",
        "graph_mode_label": "Graph",
        "validation_eyebrow": "VALIDATE · MobKit",
        "validation_passed_label": "passed",
        "validation_warnings_label": "warnings",
        "validation_blocking_label": "blocking",
        "close_label": "×",
        "plan_eyebrow": "DEPLOY PLAN",
        "plan_unavailable_head": "DEPLOY TRACE UNAVAILABLE",
        "plan_unavailable_body": "mobkit/mobpacks/deploy did not return plan_trace.",
        "plan_first_label": "first",
        "plan_step_label": "step",
        "plan_previous_label": "‹",
        "plan_next_label": "›"
    });
    let mut editor_settings_view = json!({
        "option_separator": " · ",
        "canvas_title": "Canvas",
        "edge_style_label": "Edges",
        "density_label": "Density",
        "theme_title": "Theme",
        "theme_mode_label": "Mode",
        "mob_title": "Mob",
        "orchestrator_label": "Orchestrator",
        "profile_none_label": "none",
        "auto_wire_label": "Auto wire",
        "default_backend_label": "Default backend",
        "external_base_label": "External base",
        "external_base_placeholder": "http://127.0.0.1:9000",
        "deploy_title": "Deploy",
        "surface_label": "Surface",
        "trust_label": "Trust",
        "model_label": "Model",
        "model_default_label": "default",
        "model_vendor_fallback": "provider",
        "duration_label": "Run timeout",
        "duration_placeholder": "10m",
        "tool_calls_label": "Tool calls",
        "tool_calls_min": 0,
        "tool_calls_max": 999,
        "tokens_label": "Tokens",
        "tokens_min": 0,
        "tokens_max": 200000,
        "realm_label": "Realm",
        "realm_id_label": "Realm ID",
        "realm_id_placeholder": "realm id",
        "backend_label": "Backend",
        "prompt_label": "Prompt",
        "prompt_placeholder": "Deploy prompt",
        "command_label": "Command",
        "command_fallback": "--",
        "inspector_title": "Inspector",
        "inspector_layout_label": "Layout"
    });
    editor_settings_view["edge_style_options"] = json!([
        { "value": "text", "label": "Text" },
        { "value": "icons", "label": "Icons" },
        { "value": "colored", "label": "Color" }
    ]);
    editor_settings_view["density_options"] = json!([
        { "value": "compact", "label": "Compact" },
        { "value": "comfortable", "label": "Comfy" }
    ]);
    editor_settings_view["theme_mode_options"] = json!([
        { "value": "light", "label": "Light" },
        { "value": "dark", "label": "Dark" }
    ]);
    editor_settings_view["auto_wire_options"] = json!([
        { "value": "no", "label": "No" },
        { "value": "yes", "label": "Yes" }
    ]);
    editor_settings_view["role_wiring_label"] = json!("Role wiring");
    editor_settings_view["role_wiring_add_label"] = json!("+ rule");
    editor_settings_view["advanced_label"] = json!("Advanced");
    editor_settings_view["advanced_object_required_error"] = json!("object required");
    editor_settings_view["advanced_invalid_json_error"] = json!("invalid JSON");
    editor_settings_view["realm_options"] = json!([
        { "value": "isolated", "label": "Isolated" },
        { "value": "shared", "label": "Shared" }
    ]);
    editor_settings_view["inspector_layout_options"] = json!([
        { "value": "right", "label": "Right" },
        { "value": "bottom", "label": "Bottom" },
        { "value": "modal", "label": "Modal" }
    ]);
    editor_settings_view["unsupported_label_separator"] = json!(" — not in MobKit ");
    editor_settings_view["unsupported_reason_prefix"] = json!("Unsupported by the MobKit ");
    editor_settings_view["unsupported_reason_suffix"] = json!(" contract.");
    editor_settings_view["deploy_surface_contract_label"] = json!("deploy_settings.surfaces");
    editor_settings_view["trust_policy_contract_label"] = json!("deploy_settings.trust_policies");
    editor_settings_view["realm_backend_contract_label"] = json!("deploy_settings.realm_backends");
    editor_settings_view["deploy_surface_labels"] = json!({
        "cli": "cli",
        "rpc": "rpc"
    });
    editor_settings_view["trust_policy_labels"] = json!({
        "permissive": "permissive",
        "strict": "strict"
    });
    editor_settings_view["realm_backend_labels"] = json!({
        "jsonl": "jsonl",
        "sqlite": "sqlite"
    });
    let editor_launch_view = json!({
        "launch_title": "Launch mode",
        "graph_launch_title": "LAUNCH MODE · this position",
        "resume_session_label": "Bridge session",
        "resume_session_placeholder": "session id",
        "fork_source_label": "Fork from",
        "fork_context_label": "Fork context",
        "graph_fork_context_label": "Context",
        "budget_policy_label": "Budget split policy",
        "fixed_budget_label": "Fixed token budget",
        "fixed_budget_default_value": 4096,
        "unsupported_label_separator": " — not in MobKit ",
        "unsupported_reason_prefix": "Unsupported by the MobKit ",
        "unsupported_reason_suffix": " contract.",
        "launch_modes_contract_label": "launch_modes",
        "fork_contexts_contract_label": "mob_definition.fork_contexts",
        "budget_split_policies_contract_label": "budget_split_policies",
        "launch_mode_labels": {
            "Fresh": "Fresh — empty context",
            "Resume": "Resume — existing bridge session",
            "Fork": "Fork — copy context from another step"
        },
        "fork_context_labels": {
            "full_history": "full_history — entire transcript",
            "last_messages": "last_messages — last N messages",
            "FullHistory": "FullHistory — legacy alias for full_history"
        },
        "budget_split_policy_labels": {
            "Equal": "Equal — split remaining budget evenly",
            "Proportional": "Proportional — MobKit proportional split",
            "Remaining": "Remaining — grant all remaining budget",
            "Fixed": "Fixed — token cap for this spawn"
        }
    });
    let mut editor_basic_view = json!({
        "start_label": "START",
        "loop_badge": "LOOP",
        "tips_title": "Tips",
        "empty_panel_title": "Build your mob flow",
        "empty_panel_subtitle_parts": [
            { "kind": "text", "text": "Pick a node to configure, or press " },
            { "kind": "strong", "text": "+" },
            { "kind": "text", "text": " to add a member turn or flow primitive. The result is a " },
            { "kind": "code", "text": "mob.toml" },
            { "kind": "text", "text": " flow." }
        ],
        "source_toggle_label": "{ } mob.toml"
    });
    editor_basic_view["authoring_operation_unavailable_error"] =
        json!("MobKit authoring operation API is unavailable");
    editor_basic_view["authoring_operation_fallback_error"] =
        json!("MobKit authoring operation failed");
    editor_basic_view["member_step_panel_title_fallback"] = json!("Member step");
    editor_basic_view["member_step_panel_sub_fallback"] = json!("Assign a member to run this step");
    editor_basic_view["member_step_member_label"] = json!("Member (profile)");
    editor_basic_view["member_step_member_placeholder"] = json!("— select member —");
    editor_basic_view["member_step_runtime_default_label"] = json!("runtime default");
    editor_basic_view["member_step_instruction_label"] =
        json!("message — instruction for this turn");
    editor_basic_view["member_step_instruction_placeholder"] =
        json!("e.g. Run the focused tests and report failures.");
    editor_basic_view["member_step_dispatch_label"] = json!("Dispatch mode");
    editor_basic_view["member_step_collection_label"] = json!("Collection policy");
    editor_basic_view["member_step_quorum_label"] = json!("Quorum");
    editor_basic_view["member_step_quorum_placeholder"] = json!("required");
    editor_basic_view["member_step_timeout_label"] = json!("Timeout (ms)");
    editor_basic_view["member_step_dependency_label"] = json!("depends_on mode");
    editor_basic_view["member_step_output_format_label"] = json!("Output format");
    editor_basic_view["member_step_allowed_tools_label"] = json!("Allowed tools");
    editor_basic_view["member_step_allowed_tools_empty_label"] = json!("Runtime profile default");
    editor_basic_view["member_step_blocked_tools_label"] = json!("Blocked tools");
    editor_basic_view["member_step_blocked_tools_empty_label"] = json!("No step-level blocks");
    editor_basic_view["member_step_schema_hint_prefix"] = json!("Emits ");
    editor_basic_view["member_step_schema_hint_tools_prefix"] = json!(" · tools: ");
    editor_basic_view["member_step_schema_hint_empty_tools_label"] = json!("—");
    editor_basic_view["tool_scope_not_in_catalog_reason"] = json!("not in MobKit tool catalog");
    editor_basic_view["tool_scope_not_enabled_reason"] = json!("not enabled on profile");
    editor_basic_view["tool_scope_tool_description_fallback"] = json!("MobKit tool");
    editor_basic_view["tool_scope_remove_label"] = json!("×");
    editor_basic_view["tool_scope_select_member_placeholder"] = json!("select a member first");
    editor_basic_view["tool_scope_block_catalog_placeholder"] = json!("+ block MobKit tool...");
    editor_basic_view["tool_scope_add_profile_placeholder"] = json!("+ add profile tool...");
    editor_basic_view["input_panel_icon"] = json!("▤");
    editor_basic_view["input_panel_title"] = json!("Input");
    editor_basic_view["input_panel_sub"] = json!("The task this mob is run with — its ingress");
    editor_basic_view["input_task_label"] = json!("Task");
    editor_basic_view["input_task_placeholder"] = json!("e.g. Fix the issue described below.");
    editor_basic_view["input_params_title_prefix"] = json!("INPUT PARAMS");
    editor_basic_view["input_add_param_label"] = json!("+ param");
    editor_basic_view["input_param_source_label"] = json!("Input params");
    editor_basic_view["input_param_header_labels"] = json!({
        "name": "NAME",
        "type": "TYPE",
        "required": "REQ",
        "description": "DESCRIPTION",
        "action": ""
    });
    editor_basic_view["input_param_name_placeholder"] = json!("param_name");
    editor_basic_view["input_param_description_placeholder"] = json!("—");
    editor_basic_view["input_param_remove_title"] = json!("Remove param");
    editor_basic_view["input_param_enum_label"] = json!("VALUES");
    editor_basic_view["input_param_enum_add_label"] = json!("+ value");
    editor_basic_view["input_param_enum_add_value"] = json!("value");
    editor_basic_view["input_empty_params_parts"] = json!([
        { "key": "prefix", "text": "No params yet. Add one before branching on " },
        { "key": "ref", "text": "params.*", "kind": "code" },
        { "key": "suffix", "text": "." }
    ]);
    editor_basic_view["input_tips"] = json!([
        "Run with: rkat mob run <pack> --prompt \"<task>\" — or run_flow(input).",
        "Typed fields become the input schema the run is validated against.",
        "Event sources & schedules live outside the mobpack (e.g. fugue)."
    ]);
    editor_basic_view["branch_panel_title"] = json!("Branch");
    editor_basic_view["branch_panel_sub"] = json!("Choose one downstream path by condition");
    editor_basic_view["parallel_panel_title"] = json!("Parallel");
    editor_basic_view["parallel_panel_sub"] = json!("fan_out to members, then fan_in and collect");
    editor_basic_view["branch_route_member_label"] = json!("Route member");
    editor_basic_view["parallel_join_member_label"] = json!("Join member");
    editor_basic_view["branch_controller_placeholder_label"] = json!("— direct MobKit lanes —");
    editor_basic_view["branch_empty_controller_hint"] = json!(
        "Without a selected profile, MobKit conditions/parallel lanes attach directly to the first real member in each lane."
    );
    editor_basic_view["branch_condition_title"] = json!("Branch conditions");
    editor_basic_view["branch_condition_intro"] =
        json!("Read in order; the first match wins. Conditions read a member's structured output.");
    editor_basic_view["branch_condition_row_title_prefix"] = json!("Branch");
    editor_basic_view["branch_condition_empty_hint"] =
        json!("Add an upstream member with an output schema before configuring this branch.");
    editor_basic_view["branch_condition_source_placeholder"] = json!("— source —");
    editor_basic_view["branch_condition_field_placeholder"] = json!("— field —");
    editor_basic_view["branch_condition_no_schema_label"] = json!("(no schema)");
    editor_basic_view["branch_condition_preview_prefix"] = json!("when");
    editor_basic_view["branch_condition_preview_fallback"] = json!("…");
    editor_basic_view["branch_fallback_title"] = json!("Fallback");
    editor_basic_view["branch_fallback_hint"] =
        json!("If none match, the flow follows the fallback path; else it stops.");
    editor_basic_view["add_branch_label"] = json!("+ Add branch");
    editor_basic_view["add_parallel_branch_label"] = json!("+ Add parallel branch");
    editor_basic_view["parallel_dispatch_label"] = json!("Dispatch mode");
    editor_basic_view["parallel_collection_label"] = json!("Collection policy (fan_in)");
    editor_basic_view["parallel_quorum_label"] = json!("Quorum (N)");
    editor_basic_view["parallel_quorum_placeholder"] = json!("required");
    editor_basic_view["branch_dependency_label"] = json!("depends_on mode");
    editor_basic_view["repeat_panel_title"] = json!("Repeat until");
    editor_basic_view["repeat_panel_sub"] =
        json!("Loop the body, then evaluate the condition after each iteration");
    editor_basic_view["repeat_loop_id_label"] = json!("loop_id");
    editor_basic_view["repeat_loop_id_placeholder"] = json!("quality_loop");
    editor_basic_view["repeat_condition_title"] = json!("Until condition");
    editor_basic_view["repeat_condition_intro"] = json!(
        "Evaluated on a body member's structured output after each pass. The loop exits when it holds."
    );
    editor_basic_view["repeat_empty_body_hint"] =
        json!("Add a member step inside the loop first — the condition reads its output schema.");
    editor_basic_view["repeat_member_placeholder_label"] = json!("— member —");
    editor_basic_view["repeat_condition_field_placeholder"] = json!("— field —");
    editor_basic_view["repeat_condition_no_schema_label"] = json!("(no schema)");
    editor_basic_view["repeat_preview_label"] = json!("until");
    editor_basic_view["repeat_preview_fallback"] = json!("…");
    editor_basic_view["repeat_iteration_input_label"] =
        json!("Iteration input — what each pass receives");
    editor_basic_view["repeat_max_iterations_label"] = json!("max_iterations");
    editor_basic_view["repeat_max_iterations_placeholder"] = json!("required");
    editor_basic_view["repeat_tips"] = json!([
        "The body is its own FrameSpec — add member steps inside the loop.",
        "The condition reads a member's typed output (e.g. reviewer.verdict == green).",
        "max_iterations bounds the loop so it always terminates."
    ]);
    editor_basic_view["repeat_canvas_while_label"] = json!("while");
    editor_basic_view["repeat_canvas_not_label"] = json!("not");
    editor_basic_view["repeat_canvas_missing_max_iterations_label"] =
        json!("missing max_iterations");
    editor_basic_view["repeat_canvas_max_iterations_prefix"] = json!("max ");
    editor_basic_view["repeat_canvas_loop_back_prefix"] = json!("↑ loop back · ");
    editor_basic_view["repeat_canvas_exit_prefix"] = json!("↓ exit when ");
    editor_basic_view["repeat_canvas_exit_fallback"] = json!("condition met");
    editor_basic_view["repeat_iteration_runtime_default_label"] = json!("runtime default");
    editor_basic_view["repeat_iteration_carry_label"] = json!("carries last output");
    editor_basic_view["repeat_iteration_reuse_unsupported_label"] =
        json!("unsupported: re-use input task");
    editor_basic_view["repeat_iteration_feeds_unsupported_prefix"] = json!("unsupported: feeds ");
    editor_basic_view["repeat_iteration_unsupported_prefix"] = json!("unsupported: ");
    editor_basic_view["adaptive_defaults"] = editor_adaptive_step_defaults();
    editor_basic_view["adaptive_limit_rows"] = editor_adaptive_limit_rows();
    editor_basic_view["adaptive_target_surfaces"] = json!(EDITOR_ADAPTIVE_TARGET_SURFACES);
    editor_basic_view["adaptive_panel_title"] = json!("Adaptive layer");
    editor_basic_view["adaptive_panel_sub"] = json!(
        "A FlowMaster designs the next mob layer at runtime from prior results — dynamic width & depth. Each plan is validated, then run as a normal mob flow."
    );
    editor_basic_view["adaptive_flowmaster_label"] = json!("FlowMaster (planning agent)");
    editor_basic_view["adaptive_flowmaster_placeholder_label"] = json!("— select member —");
    editor_basic_view["adaptive_prompt_label"] = json!("Planning prompt");
    editor_basic_view["adaptive_prompt_placeholder"] =
        json!("How should the FlowMaster decide each layer?");
    editor_basic_view["adaptive_objective_label"] = json!("Objective class");
    editor_basic_view["adaptive_objective_placeholder"] = json!("code-review");
    editor_basic_view["adaptive_result_schema_label"] = json!("LayerResult schema");
    editor_basic_view["adaptive_result_schema_placeholder_label"] =
        json!("— LayerResult (untyped) —");
    editor_basic_view["adaptive_profiles_title"] = json!("Profile templates the layer may spawn");
    editor_basic_view["adaptive_profiles_hint"] = json!(
        "No members are baked in — the FlowMaster picks counts & roles per layer from these shapes."
    );
    editor_basic_view["adaptive_profiles_agents_hint"] =
        json!("Edit member shapes in the Agents tab.");
    editor_basic_view["adaptive_profiles_empty_hint"] =
        json!("No members yet — define some in the Agents tab.");
    editor_basic_view["adaptive_surfaces_label"] = json!("Target surfaces");
    editor_basic_view["adaptive_inline_profiles_label"] = json!("Allow inline profiles");
    editor_basic_view["adaptive_limits_title"] = json!("Limits");
    editor_basic_view["adaptive_limits_advanced_label"] = json!("Advanced limits");
    editor_basic_view["adaptive_tips"] = json!([
        "Dynamic width: the layer is generated with the right number of members.",
        "Dynamic depth: the loop repeats until the FlowMaster returns Finish or limits hit.",
        "Every layer runs through MobMachine and emits a validated LayerResult."
    ]);
    editor_basic_view["adaptive_step_card_title"] = json!("Adaptive layer");
    editor_basic_view["adaptive_block_head_prefix"] =
        json!("ADAPTIVE LAYER · synthesized at runtime · max depth ");
    editor_basic_view["adaptive_block_flowmaster_kicker"] = json!("FLOWMASTER · plan");
    editor_basic_view["adaptive_block_flowmaster_title_fallback"] = json!("—");
    editor_basic_view["adaptive_block_flowmaster_desc"] =
        json!("Designs the next LayerPlan from prior results");
    editor_basic_view["adaptive_block_plan_connector"] = json!("LayerPlan ✓");
    editor_basic_view["adaptive_block_layer_kicker_prefix"] = json!("LAYERMOB · ≤ ");
    editor_basic_view["adaptive_block_layer_kicker_suffix"] = json!(" members");
    editor_basic_view["adaptive_block_chip_suffix"] = json!("×N");
    editor_basic_view["adaptive_block_empty_profiles_label"] = json!("no profiles yet");
    editor_basic_view["adaptive_block_collect_connector_prefix"] = json!("collector → ");
    editor_basic_view["adaptive_block_collect_fallback"] = json!("LayerResult");
    editor_basic_view["adaptive_block_loop_back_prefix"] = json!("↑ loop until Finish · or depth ");
    editor_basic_view["adaptive_block_loop_back_suffix"] = json!(" / budget");
    editor_basic_view["add_step_title"] = json!("Add step");
    editor_basic_view["input_step_card_title"] = json!("Input");
    editor_basic_view["input_step_card_desc_fallback"] = json!("the task this mob is run with");
    editor_basic_view["branch_step_card_title"] = json!("Branch");
    editor_basic_view["branch_step_card_desc"] = json!("Mob picks the first matching path");
    editor_basic_view["parallel_step_card_title"] = json!("Parallel");
    editor_basic_view["parallel_step_card_desc_prefix"] = json!("fan-out → join · ");
    editor_basic_view["parallel_step_card_collection_fallback"] = json!("—");
    editor_basic_view["repeat_step_card_title"] = json!("Repeat until");
    editor_basic_view["repeat_step_card_desc_prefix"] = json!("until ");
    editor_basic_view["repeat_step_card_desc_fallback"] = json!("loop body until condition");
    editor_basic_view["member_step_card_title_fallback"] = json!("Select member");
    editor_basic_view["picker_kickoff_title"] = json!("Input");
    editor_basic_view["picker_kickoff_sub"] =
        json!("Every mob run starts from a single task input");
    editor_basic_view["picker_kickoff_hint"] = json!(
        "This node is the mob's ingress — the task it's deployed/run with. Select it on the canvas to edit the task and any typed input fields."
    );
    editor_basic_view["picker_title"] = json!("Add step");
    editor_basic_view["picker_sub"] = json!("A flow node — a member turn or a flow primitive");
    editor_basic_view["picker_search_icon"] = json!("⌕");
    editor_basic_view["picker_search_placeholder"] = json!("Search members & primitives…");
    editor_basic_view["picker_members_label"] = json!("Mob members");
    editor_basic_view["picker_flow_label"] = json!("Flow");
    editor_basic_view["picker_empty_members_hint"] =
        json!("No members yet — define some in the Agents tab.");
    editor_basic_view["picker_new_badge_label"] = json!("NEW");
    editor_basic_view["flow_primitive_rows"] = json!([
        {
            "id": "repeat",
            "glyph": "↻",
            "tint": "member",
            "label": "Repeat until",
            "sub": "Loop a body of steps until a condition holds (max_iterations)"
        },
        {
            "id": "branch",
            "glyph": "⑂",
            "tint": "member",
            "label": "Branch",
            "sub": "Pick one downstream path by condition (first match wins)"
        },
        {
            "id": "parallel",
            "glyph": "‖",
            "tint": "member",
            "label": "Parallel",
            "sub": "fan_out to several members, then fan_in with a collection policy"
        },
        {
            "id": "adaptive",
            "glyph": "❖",
            "tint": "member",
            "label": "Adaptive layer",
            "sub": "A FlowMaster designs & runs the next mob layer at runtime — dynamic width & depth",
            "is_new": true
        },
        {
            "id": "subagent",
            "glyph": "⬡",
            "tint": "link",
            "label": "Sub-mob",
            "sub": "delegate to a nested mob, tested and packed on its own",
            "disabled": true,
            "disabled_reason": "Current MobKit mob.toml flow semantics do not yet define deployable sub-mob nodes"
        },
        {
            "id": "wait",
            "glyph": "▮▮",
            "tint": "link",
            "label": "Wait for input",
            "sub": "Pause the flow for required input before continuing",
            "disabled": true,
            "disabled_reason": "Current MobKit mob.toml flow semantics do not yet define deployable wait-for-input nodes"
        }
    ]);
    let editor_graph_template_view = json!({
        "template_eyebrow": "TEMPLATE",
        "summary_title": "SUMMARY",
        "triggers_title": "TRIGGERS",
        "trigger_labels_label": "labels",
        "trigger_default_label": "default",
        "default_yes_label": "yes",
        "default_no_label": "no",
        "summary_members_label": "members",
        "summary_instances_label": "instances",
        "summary_terminals_label": "terminals",
        "summary_edges_label": "edges",
        "summary_frames_label": "frames",
        "summary_members_value_template": "{placed} placed / {total} in library",
        "quick_start_title": "QUICK START",
        "quick_start_rows": [
            [
                { "kind": "text", "text": "Click a " },
                { "kind": "strong", "text": "library member" },
                { "kind": "text", "text": " on the left to edit it." }
            ],
            [
                { "kind": "text", "text": "Click an " },
                { "kind": "strong", "text": "empty grid cell" },
                { "kind": "text", "text": " to place a member." }
            ],
            [
                { "kind": "text", "text": "Drag a node's " },
                { "kind": "strong", "text": "right port" },
                { "kind": "text", "text": " to wire it to another." }
            ],
            [
                { "kind": "text", "text": "⌫ deletes the selected instance or edge." }
            ]
        ]
    });
    let mut mob_definition = json!({
        "authoritative_type": "meerkat_mob::MobDefinition",
        "defaults": {
            "runtime_mode": "turn_driven",
            "launch_mode": "fresh",
            "fork_context": "full_history",
            "budget_split_policy": "equal",
            "dispatch_mode": "fan_out",
            "collection_policy": "all",
            "dependency_mode": "all",
            "condition_operator": "==",
            "schema_field_type": "string",
            "branch_param_type": "enum",
            "repeat_iteration_input": "carry",
            "step_output_format": "json",
            "graph_gate_kind": "branch",
            "graph_edge_kind": "next",
            "graph_condition_edge_kind": "cond",
            "graph_fanout_edge_kind": "fanout",
            "graph_terminal_kind": "success"
        },
        "profile_binding": ["inline"],
        "profile_binding_restrictions": {
            "inline": {
                "deployable": true,
                "label": "inline — define profile in this mobpack",
                "reason": "",
                "document_path": "document.members[].profileBinding",
                "archive_path": "profiles.<name>"
            },
            "realm_profile": {
                "deployable": false,
                "label": "realm_profile — import-only; rkat mob validate forbids realm refs in packs",
                "reason": "rkat mob validate rejects mobpack profiles that use realm_profile references; export deployable packs with inline profiles.",
                "document_path": "document.members[].realmProfile",
                "archive_path": "profiles.<name>.realm_profile"
            }
        },
        "runtime_modes": runtime_mode_values(),
        "profile_backends": ["session", "external"],
        "launch_modes": member_launch_mode_values(),
        "launch_mode_document_path": "document.launch_modes[]",
        "fork_contexts": fork_context_values(),
        "fork_context_document_path": "document.launch_modes[].context",
        "budget_split_policies": budget_split_policy_values(),
        "budget_split_policy_document_path": "document.launch_modes[].budget_split_policy",
        "input_schema_document_path": "document.flow.steps[type=input].inputParams",
        "input_schema_archive_path": "schemas/main-input.json",
        "editor_input_param_draft": {
            "document_path": "document.flow.steps[type=input].inputParams",
            "archive_path": "schemas/main-input.json",
            "added_field": {
                "name": "param",
                "required": true,
                "description": "",
                "enumValues": []
            }
        },
        "editor_schema_field_types": editor_schema_field_type_values(),
        "editor_schema_view": editor_schema_view,
        "editor_schema_draft": {
            "document_path": "document.schemas[]",
            "archive_path": "schemas/<schema-id>.json",
            "schema_id_prefix": "Artifact",
            "initial_field": {
                "name": "field_one",
                "required": true,
                "description": "",
                "enumValues": []
            },
            "added_field": {
                "name": "new_field",
                "required": false,
                "description": "",
                "enumValues": []
            }
        },
        "condition_operators": ["==", ">", "<"],
        "step_output_formats": step_output_format_values(),
        "skill_source_document_path": "document.skill_realms[].skills[]",
        "path_skill_archive_path": "skills/<skill-id>.md or a safe relative skill path",
        "mob_settings_document_path": "document.mob_settings",
        "mob_settings": {
            "defaults": mob_settings_defaults,
            "orchestrator": "profile name or empty string",
            "autoWireOrchestrator": "boolean",
            "roleWiring": [{ "a": "profile name", "b": "profile name" }],
            "backendDefault": ["session", "external"],
            "externalAddressBase": "required by MobKit validation when external backend is selected",
            "advanced": {
                "topology": "MobDefinition.topology JSON object or null",
                "supervisor": "MobDefinition.supervisor JSON object or null",
                "limits": "MobDefinition.limits JSON object or null",
                "spawnPolicy": "MobDefinition.spawn_policy JSON object or null",
                "eventRouter": "MobDefinition.event_router JSON object or null"
            }
        },
        "flow_node_kinds": ["step", "repeat_until"],
        "editor_flow_step_types": EDITOR_FLOW_STEP_TYPES,
        "repeat_iteration_inputs": REPEAT_ITERATION_INPUTS,
        "graph_gate_kinds": GRAPH_GATE_KINDS,
        "graph_palette_gate_kinds": GRAPH_PALETTE_GATE_KINDS,
        "graph_terminal_kinds": GRAPH_TERMINAL_KINDS,
        "graph_frame_kinds": GRAPH_FRAME_KINDS,
        "graph_edge_kinds": GRAPH_EDGE_KINDS,
        "editor_graph_draft": editor_graph_draft,
        "editor_graph_view": editor_graph_view,
        "editor_source_view": editor_source_view,
        "editor_agent_view": editor_agent_view,
        "editor_basic_view": editor_basic_view,
        "editor_graph_template_view": editor_graph_template_view,
        "dispatch_modes": dispatch_mode_values(),
        "collection_policies": collection_policy_values(),
        "dependency_modes": dependency_mode_values()
    });
    mob_definition["editor_condition_view"] = editor_condition_view;
    mob_definition["editor_error_view"] = editor_error_view;
    mob_definition["editor_agent_detail_view"] = editor_agent_detail_view;
    mob_definition["editor_agent_access_view"] = editor_agent_access_view;
    mob_definition["editor_new_flow_view"] = editor_new_flow_view;
    mob_definition["editor_flow_registry_view"] = editor_flow_registry_view;
    mob_definition["editor_deploy_view"] = editor_deploy_view;
    mob_definition["editor_settings_view"] = editor_settings_view;
    mob_definition["editor_launch_view"] = editor_launch_view;
    mob_definition["editor_input_step_draft"] = editor_input_step_draft_contract();
    mob_definition["deploy_runtime_mode_compatibility"] = deploy_runtime_mode_compatibility();
    mob_definition["runtime_mode_labels"] = runtime_mode_labels();
    mob_definition["dispatch_mode_labels"] = dispatch_mode_labels();
    mob_definition["collection_policy_labels"] = collection_policy_labels();
    mob_definition["dependency_mode_labels"] = dependency_mode_labels();
    mob_definition["option_unsupported_label_separator"] = json!(" — not in MobKit ");
    mob_definition["option_unsupported_reason_prefix"] = json!("Unsupported by the MobKit ");
    mob_definition["option_unsupported_reason_suffix"] = json!(" contract.");
    json!({
        "schema_version": MOBPACK_SCHEMA_VERSION,
        "media_type": MOBPACK_MEDIA_TYPE,
        "output": "mobpack",
        "archive": "tar.gz",
        "files": ["manifest.toml", "definition.json", "mobkit/editor.json", "mobkit/mob.toml", "schemas/*.json", "skills/*.md"],
        "runtime_mutation": false,
        "host_mutation_methods": {
            "mobkit/mobpacks/deploy": "when execute=true, writes a mobpack archive and runs rkat mob run on the host"
        },
        "required_fields": ["document"],
        "commands": {
            "schema": "mobkit/mobpacks/schema",
            "catalogs": "mobkit/mobpacks/catalogs",
            "tools_catalog": "mobkit/tools/catalog",
            "skills_catalog": "mobkit/skills/catalog",
            "agent_definitions": "mobkit/agent_definitions/list",
            "templates": "mobkit/mobpacks/templates",
            "validate": "mobkit/mobpacks/validate",
            "source": "mobkit/mobpacks/source",
            "export": "mobkit/mobpacks/export",
            "import": "mobkit/mobpacks/import",
            "list": "mobkit/mobpacks/list",
            "get": "mobkit/mobpacks/get",
            "create": "mobkit/mobpacks/create",
            "save": "mobkit/mobpacks/save",
            "delete": "mobkit/mobpacks/delete",
            "undo": "mobkit/mobpacks/undo",
            "redo": "mobkit/mobpacks/redo",
            "apply_operation": "mobkit/mobpacks/apply_operation",
            "graph_projection": "mobkit/mobpacks/graph_projection",
            "graph_to_flow": "mobkit/mobpacks/graph_to_flow",
            "deploy_command": "mobkit/mobpacks/deploy_command",
            "deploy_rpc": "mobkit/mobpacks/deploy",
            "deploy_cli": deploy_cli_value
        },
        "deploy_settings": deploy_settings,
        "operations": mobpack_authoring_operations(),
        "mob_definition": mob_definition,
        "validation_source": MOBPACK_VALIDATION_SOURCE
    })
}

fn tool_catalog_by_id(tool_catalog: &[Value]) -> BTreeMap<String, Value> {
    tool_catalog
        .iter()
        .filter_map(|tool| {
            let id = tool.get("id").and_then(Value::as_str)?.trim();
            if id.is_empty() {
                None
            } else {
                Some((id.to_string(), tool.clone()))
            }
        })
        .collect()
}

fn skill_catalog_by_id(skill_realms: &Value) -> BTreeMap<String, Value> {
    let mut out = BTreeMap::new();
    let Some(realms) = skill_realms.as_array() else {
        return out;
    };
    for realm in realms {
        let realm_id = realm.get("id").and_then(Value::as_str).unwrap_or_default();
        let realm_label = realm
            .get("label")
            .and_then(Value::as_str)
            .unwrap_or(realm_id);
        let Some(skills) = realm.get("skills").and_then(Value::as_array) else {
            continue;
        };
        for skill in skills {
            let Some(id) = skill
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                continue;
            };
            out.entry(id.to_string()).or_insert_with(|| {
                let mut projected = skill.as_object().cloned().unwrap_or_default();
                projected
                    .entry("label".to_string())
                    .or_insert_with(|| Value::String(id.to_string()));
                projected.insert("realm".to_string(), Value::String(realm_id.to_string()));
                projected.insert(
                    "realmLabel".to_string(),
                    Value::String(realm_label.to_string()),
                );
                Value::Object(projected)
            });
        }
    }
    out
}

fn resolved_catalog_refs(ids: &[String], catalog: &BTreeMap<String, Value>) -> Value {
    Value::Array(
        ids.iter()
            .filter_map(|id| catalog.get(id).cloned())
            .collect::<Vec<_>>(),
    )
}

fn agent_definition_catalog(
    source_mobpacks: &Value,
    tool_catalog: &[Value],
    skill_realms: &Value,
    source_kind: &'static str,
) -> Value {
    let mut templates = BTreeMap::<String, Value>::new();
    let tools_by_id = tool_catalog_by_id(tool_catalog);
    let skills_by_id = skill_catalog_by_id(skill_realms);
    if let Some(sources) = source_mobpacks.as_array() {
        for source in sources {
            let Some(source_mobpack) = source
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                continue;
            };
            let Some(source_origin) = source
                .get("source")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                continue;
            };
            let schema_by_id = source
                .get("document")
                .and_then(|document| document.get("schemas"))
                .and_then(Value::as_array)
                .map(|schemas| {
                    schemas
                        .iter()
                        .filter_map(|schema| {
                            let id = schema.get("id").and_then(Value::as_str)?;
                            Some((id.to_string(), schema.clone()))
                        })
                        .collect::<BTreeMap<_, _>>()
                })
                .unwrap_or_default();
            let source_name = source
                .get("name")
                .and_then(Value::as_str)
                .unwrap_or(source_mobpack);
            let Some(members) = source
                .get("document")
                .and_then(|document| document.get("members"))
                .and_then(Value::as_array)
            else {
                continue;
            };
            for (member_index, member) in members.iter().enumerate() {
                let role = member
                    .get("role")
                    .and_then(Value::as_str)
                    .or_else(|| member.get("name").and_then(Value::as_str))
                    .unwrap_or("member");
                let Some(profile_binding) = member
                    .get("profileBinding")
                    .or_else(|| member.get("profile_binding"))
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                else {
                    continue;
                };
                let Some(runtime_mode) = member
                    .get("runtimeMode")
                    .or_else(|| member.get("runtime_mode"))
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                else {
                    continue;
                };
                let Some(model) = member
                    .get("model")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                else {
                    continue;
                };
                let key = format!(
                    "{}__{:02}_{}",
                    sanitize_identifier(source_mobpack),
                    member_index + 1,
                    sanitize_identifier(role)
                );
                templates.entry(key.clone()).or_insert_with(|| {
                    let name = member.get("name").and_then(Value::as_str).unwrap_or(role);
                    let schema_id = member
                        .get("schema")
                        .and_then(Value::as_str)
                        .unwrap_or_default();
                    let schema_definition =
                        schema_by_id.get(schema_id).cloned().unwrap_or(Value::Null);
                    let tools = string_vec(member.get("tools"));
                    let skills = string_vec(member.get("skills"));
                    json!({
                        "id": key,
                        "role": role,
                        "label": name,
                        "name": name,
                        "model": model,
                        "schema": schema_id,
                        "schemaSourceDocumentPath": if schema_definition.is_null() { "" } else { "document.schemas[]" },
                        "schemaDefinition": schema_definition,
                        "skills": skills,
                        "skillDefinitions": resolved_catalog_refs(&skills, &skills_by_id),
                        "tools": tools,
                        "toolDefinitions": resolved_catalog_refs(&tools, &tools_by_id),
                        "profileBinding": profile_binding,
                        "realmProfile": member.get("realmProfile").or_else(|| member.get("realm_profile")).and_then(Value::as_str).unwrap_or_default(),
                        "runtimeMode": runtime_mode,
                        "externalAddressable": member.get("externalAddressable").and_then(Value::as_bool).unwrap_or(false),
                        "backend": member.get("backend").and_then(Value::as_str).unwrap_or_default(),
                        "maxInlinePeerNotifications": member.get("maxInlinePeerNotifications").or_else(|| member.get("max_inline_peer_notifications")).cloned().unwrap_or(Value::Null),
                        "systemPrompt": member.get("systemPrompt").and_then(Value::as_str).unwrap_or_default(),
                        "providerParams": member.get("providerParams").or_else(|| member.get("provider_params")).cloned().unwrap_or(Value::Null),
                        "definitionType": "mobkit/profile-member",
                        "definitionKind": source_kind,
                        "sourceKind": source_kind,
                        "source": "mobkit/mobpack-profile-member",
                        "sourceMobpack": source_mobpack,
                        "sourceMobpackName": source_name,
                        "sourceOrigin": source_origin,
                        "sourceDocumentPath": format!("document.members[{member_index}]"),
                        "deployability": catalog_deployability(true, "profile-member definition is projected from a deployable MobKit mobpack member"),
                        "provenance": catalog_provenance(
                            "mobkit/agent_definitions/list",
                            source_origin,
                            &format!("{source_mobpack}:document.members[{member_index}]")
                        ),
                    })
                });
            }
        }
    }
    Value::Array(templates.into_values().collect())
}

fn authoring_agent_definition_mobpack_sources() -> Value {
    let mob_toml = authoring_agent_definitions_toml();
    let Ok(definition) = MobDefinition::from_toml(mob_toml) else {
        return Value::Array(Vec::new());
    };
    let Ok(document) = project_definition_to_editor_document(
        &definition,
        mob_toml,
        Some("MobKit authoring profiles"),
        Value::Null,
    ) else {
        return Value::Array(Vec::new());
    };
    Value::Array(vec![json!({
        "id": "mobkit_authoring_profiles",
        "name": "MobKit authoring profiles",
        "version": MOBPACK_SCHEMA_VERSION,
        "stage": "valid",
        "trigger": "definition · authoring",
        "source": "mobkit/authoring-agent-definitions",
        "document": document,
    })])
}

pub fn runtime_agent_definition_sources_from_definition(definition: &MobDefinition) -> Vec<Value> {
    runtime_agent_definition_source_from_definition(definition)
        .ok()
        .into_iter()
        .collect()
}

pub fn runtime_skill_realms_from_definition(definition: &MobDefinition) -> Vec<Value> {
    skill_realms_from_definition(definition)
        .as_array()
        .cloned()
        .unwrap_or_default()
        .into_iter()
        .map(|mut realm| {
            if let Some(object) = realm.as_object_mut() {
                object
                    .entry("source".to_string())
                    .or_insert_with(|| json!("mobkit/runtime/skills"));
                object.entry("provenance".to_string()).or_insert_with(|| {
                    catalog_provenance(
                        "mobkit/skills/catalog",
                        "UnifiedRuntime.MobDefinition",
                        &format!("{}:skills", definition.id),
                    )
                });
            }
            realm
        })
        .collect()
}

fn runtime_agent_definition_source_from_definition(
    definition: &MobDefinition,
) -> Result<Value, String> {
    let mob_id = definition.id.to_string();
    let name = format!("{mob_id} runtime profiles");
    let mut document =
        project_definition_to_editor_document(definition, "", Some(&name), Value::Null)?;
    document.mob_toml = None;
    let validation = validate_document(&document);
    Ok(json!({
        "id": format!("runtime_profiles_{}", sanitize_identifier(&mob_id)),
        "name": name,
        "version": MOBPACK_SCHEMA_VERSION,
        "stage": if validation.ok { "valid" } else { "draft" },
        "trigger": "runtime · profiles",
        "source": "mobkit/runtime/agent-definitions",
        "document_kind": "runtime_projection",
        "runtime_projection": true,
        "runtime_mob_id": mob_id,
        "deployability": catalog_deployability(
            validation.ok,
            "runtime profile definitions are projected from the live MobKit mob definition"
        ),
        "provenance": catalog_provenance(
            "mobkit/agent_definitions/list",
            "UnifiedRuntime.MobDefinition",
            &format!("{mob_id}:profiles")
        ),
        "document": document,
        "validation": validation,
    }))
}

fn authoring_agent_definitions_toml() -> &'static str {
    r#"
[mob]
id = "mobkit-authoring-profiles"

[profiles.planner]
model = "gpt-5.5"
skills = ["mob.authoring.plan"]
peer_description = "Turn an operator request into a concise mob plan, identify needed artifacts, and hand the implementation member a focused next step."
runtime_mode = "turn_driven"

[profiles.planner.tools]
builtins = true
comms = true
mob = true
workgraph = true

[profiles.implementer]
model = "gpt-5.5"
skills = ["mob.authoring.implement"]
peer_description = "Make the requested code or configuration change, keep evidence current, and report exactly what changed."
runtime_mode = "turn_driven"

[profiles.implementer.tools]
builtins = true
shell = true
comms = true
mob = true
workgraph = true

[profiles.reviewer]
model = "gpt-5.5"
skills = ["mob.authoring.review"]
peer_description = "Review the implementation against the requested MobKit contract and emit a structured verdict."
runtime_mode = "turn_driven"

[profiles.reviewer.tools]
builtins = true
shell = true
comms = true
mob = true

[profiles.agent]
model = "gpt-5.5"
peer_description = "Start from a minimal editable MobKit profile member and configure its prompt, tools, skills, schema, and runtime mode in the Agent Editor."
runtime_mode = "turn_driven"

[profiles.agent.tools]
builtins = true
comms = true

[profiles.reviewer.output_schema]
type = "object"
description = "MobKit authoring review result."
required = ["verdict", "summary"]
additionalProperties = false

[profiles.reviewer.output_schema.properties]

[profiles.reviewer.output_schema.properties.verdict]
type = "string"
description = "Whether the reviewed work is acceptable."
enum = ["accepted", "needs_changes"]

[profiles.reviewer.output_schema.properties.summary]
type = "string"
description = "Short review summary."

[skills."mob.authoring.plan"]
source = "inline"
content = "Plan MobKit authoring work from the requested deployable mobpack contract. Keep scope tied to real MobKit APIs."

[skills."mob.authoring.implement"]
source = "inline"
content = "Implement against MobKit-owned contracts. Preserve controller/UI separation and avoid UI-local mock state."

[skills."mob.authoring.review"]
source = "inline"
content = "Review the mobpack artifact, source preview, validation, export, and deploy command evidence before accepting."

[flows.main]
description = "Plan, implement, and review a MobKit authoring change."

[flows.main.steps.plan]
role = "planner"
message = "Plan the MobKit authoring work."

[flows.main.steps.implement]
role = "implementer"
message = "Implement the approved plan."
depends_on = ["plan"]

[flows.main.steps.review]
role = "reviewer"
message = "Review the implementation and emit a verdict."
depends_on = ["implement"]
expected_schema_ref = "schemas/reviewer.json"

[flows.main.root.nodes.node_plan]
kind = "step"
step_id = "plan"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_implement]
kind = "step"
step_id = "implement"
depends_on = ["node_plan"]
depends_on_mode = "all"

[flows.main.root.nodes.node_review]
kind = "step"
step_id = "review"
depends_on = ["node_implement"]
depends_on_mode = "all"
"#
}

pub fn runtime_flow_registry_rows_from_definition(definition: &MobDefinition) -> Vec<Value> {
    definition
        .flows
        .keys()
        .filter_map(|flow_id| {
            runtime_flow_registry_row_from_definition(definition, flow_id.as_ref()).ok()
        })
        .collect()
}

fn runtime_flow_registry_row_from_definition(
    definition: &MobDefinition,
    flow_id: &str,
) -> Result<Value, String> {
    let flow_id = flow_id.trim();
    if flow_id.is_empty() {
        return Err("runtime flow id is empty".to_string());
    }
    let mob_id = definition.id.to_string();
    let name = if definition.flows.len() > 1 {
        format!("{mob_id} · {flow_id}")
    } else {
        mob_id.clone()
    };
    let mut document = project_definition_to_editor_document_for_flow(
        definition,
        "",
        Some(&name),
        Value::Null,
        Some(flow_id),
    )?;
    document.mob_toml = None;
    let validation = validate_document(&document);
    let validation_value = serde_json::to_value(&validation).unwrap_or(Value::Null);
    Ok(json!({
        "id": format!(
            "runtime_{}_{}",
            sanitize_identifier(&mob_id),
            sanitize_identifier(flow_id)
        ),
        "name": name,
        "version": MOBPACK_SCHEMA_VERSION,
        "stage": if validation.ok { "valid" } else { "draft" },
        "trigger": format!("runtime · {flow_id}"),
        "source": "mobkit/runtime/flow_projection",
        "registry_source": "mobkit/runtime/flow_projection",
        "document_kind": "runtime_projection",
        "runtime_projection": true,
        "runtime_mob_id": mob_id,
        "runtime_flow_id": flow_id,
        "document": document,
        "validation": validation_value,
        "deployability": catalog_deployability(
            validation.ok,
            "runtime flow projection can be exported as a deployable single-flow MobKit mobpack draft"
        ),
        "provenance": catalog_provenance(
            "mobkit/runtime/flow_projection",
            "UnifiedRuntime.MobDefinition",
            &format!("{mob_id}:flows.{flow_id}")
        ),
    }))
}

fn sample_mobpack_catalog() -> Value {
    let samples = [
        (
            "sample_planner_coder_review_loop",
            "planner-coder-reviewer",
            "label · small-fix",
            sample_planner_coder_review_loop_toml(),
        ),
        (
            "sample_docs_only",
            "docs-only",
            "path · docs/**",
            sample_docs_only_toml(),
        ),
        (
            "sample_review_pr",
            "review-pr",
            "kind · pull_request",
            sample_review_pr_toml(),
        ),
    ];
    Value::Array(
        samples
            .into_iter()
            .filter_map(|(id, name, trigger, mob_toml)| {
                let definition = MobDefinition::from_toml(mob_toml).ok()?;
                let document = project_definition_to_editor_document(
                    &definition,
                    mob_toml,
                    Some(name),
                    Value::Null,
                )
                .ok()?;
                let validation = validate_document(&document);
                Some(json!({
                    "id": id,
                    "name": name,
                    "version": MOBPACK_SCHEMA_VERSION,
                    "stage": if validation.ok { "valid" } else { "draft" },
                    "trigger": trigger,
                    "source": "mobkit/sample-mobpack",
                    "deployability": catalog_deployability(validation.ok, "sample mobpack template validates as a deployable MobKit mobpack"),
                    "provenance": catalog_provenance("mobkit/mobpacks/templates", "mobkit/sample-mobpack", id),
                    "document": document,
                    "validation": validation,
                }))
            })
            .collect(),
    )
}

fn blank_mobpack_template() -> Value {
    let mob_toml = blank_mobpack_template_toml();
    let Ok(definition) = MobDefinition::from_toml(mob_toml) else {
        return Value::Null;
    };
    let Ok(document) = project_definition_to_editor_document(
        &definition,
        mob_toml,
        Some("blank-mob"),
        Value::Null,
    ) else {
        return Value::Null;
    };
    let validation = validate_document(&document);
    json!({
        "id": "blank",
        "name": "Blank",
        "version": MOBPACK_SCHEMA_VERSION,
        "stage": if validation.ok { "valid" } else { "draft" },
        "trigger": "label · small-fix",
        "source": "mobkit/blank-mobpack",
        "deployability": catalog_deployability(validation.ok, "blank template validates as a deployable MobKit mobpack"),
        "provenance": catalog_provenance("mobkit/mobpacks/templates", "mobkit/blank-mobpack", "blank"),
        "document": document,
        "validation": validation,
    })
}

fn blank_mobpack_template_toml() -> &'static str {
    r#"
[mob]
id = "blank-mob"

[profiles.worker]
model = "gpt-5.5"
peer_description = "Carry out the requested task and report concise progress."
runtime_mode = "turn_driven"

[profiles.worker.tools]
builtins = true
comms = true

[flows.main]
description = "Minimal deployable MobKit flow."

[flows.main.steps.work]
role = "worker"
message = "Handle the requested task."

[flows.main.root.nodes.node_work]
kind = "step"
step_id = "work"
depends_on = []
depends_on_mode = "all"
"#
}

fn sample_planner_coder_review_loop_toml() -> &'static str {
    r#"
[mob]
id = "planner-coder-reviewer"

[profiles.planner]
model = "gpt-5.5"
skills = ["mob.workpad"]
peer_description = "Own the issue plan and Workpad. Convert reviewer, PR, and check feedback into a focused plan before implementation resumes."
runtime_mode = "turn_driven"

[profiles.planner.tools]
builtins = true
comms = true
mob = true
workgraph = true

[profiles.coder]
model = "gpt-5.5"
skills = ["mob.workpad", "mob.tests"]
peer_description = "Implement only the current Workpad plan, keep validation evidence current, and hand back to the reviewer when local checks are ready."
runtime_mode = "turn_driven"

[profiles.coder.tools]
builtins = true
shell = true
comms = true
mob = true
workgraph = true

[profiles.reviewer]
model = "gpt-5.5"
skills = ["mob.review"]
peer_description = "Gate the local implementation and PR/check evidence. Green review may ask the repo-level mob to create or merge the PR; red review reports detailed findings back to the planner."
runtime_mode = "turn_driven"

[profiles.reviewer.tools]
builtins = true
shell = true
comms = true
mob = true

[profiles.reviewer.output_schema]
type = "object"
description = "Review verdict that gates the repeat-until frame."
required = ["verdict", "findings"]
additionalProperties = false

[profiles.reviewer.output_schema.properties]

[profiles.reviewer.output_schema.properties.verdict]
type = "string"
description = "Pass/fail."
enum = ["green", "red"]

[profiles.reviewer.output_schema.properties.findings]
type = "array"
description = "What needs to change before green."

[profiles.reviewer.output_schema.properties.findings.items]
type = "string"

[skills."mob.workpad"]
source = "inline"
content = "Maintain the shared mob workpad, current plan, evidence, and handoff notes."

[skills."mob.tests"]
source = "inline"
content = "Design and run focused validation. Prefer narrow tests that prove the changed behavior."

[skills."mob.review"]
source = "inline"
content = "Perform structured review. Report blocking findings with file paths, evidence, and suggested fixes."

[flows.main]
description = "Plan, implement, and review until the reviewer emits a green verdict."

[flows.main.steps.plan]
role = "planner"
message = "Plan the work."

[flows.main.steps.implement]
role = "coder"
message = "Implement the current plan and collect validation evidence."
depends_on = ["plan"]

[flows.main.steps.review]
role = "reviewer"
message = "Review the implementation and emit a green or red verdict."
depends_on = ["implement"]
expected_schema_ref = "schemas/reviewer.json"

[flows.main.root.nodes.node_plan]
kind = "step"
step_id = "plan"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_loop]
kind = "repeat_until"
loop_id = "review_loop"
depends_on = ["node_plan"]
depends_on_mode = "all"
until = { op = "eq", path = "steps.review.verdict", value = "green" }
max_iterations = 3

[flows.main.root.nodes.node_loop.body.nodes.node_implement]
kind = "step"
step_id = "implement"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_loop.body.nodes.node_review]
kind = "step"
step_id = "review"
depends_on = ["node_implement"]
depends_on_mode = "all"
"#
}

fn sample_docs_only_toml() -> &'static str {
    r#"
[mob]
id = "docs-only"

[profiles.writer]
model = "gpt-5.5"
skills = ["mob.workpad"]
peer_description = "Draft documentation changes from the requested scope and preserve existing documentation conventions."
runtime_mode = "turn_driven"

[profiles.writer.tools]
builtins = true
comms = true

[profiles.reviewer]
model = "gpt-5.5"
skills = ["mob.review"]
peer_description = "Review documentation for accuracy, navigation fit, and missing validation."
runtime_mode = "turn_driven"

[profiles.reviewer.tools]
builtins = true
comms = true

[skills."mob.workpad"]
source = "inline"
content = "Maintain the shared mob workpad, current plan, evidence, and handoff notes."

[skills."mob.review"]
source = "inline"
content = "Perform structured review. Report blocking findings with file paths, evidence, and suggested fixes."

[flows.main]
description = "Draft and review documentation-only changes."

[flows.main.steps.write]
role = "writer"
message = "Write the documentation update."

[flows.main.steps.review]
role = "reviewer"
message = "Review the documentation update."
depends_on = ["write"]

[flows.main.root.nodes.node_write]
kind = "step"
step_id = "write"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_review]
kind = "step"
step_id = "review"
depends_on = ["node_write"]
depends_on_mode = "all"
"#
}

fn sample_review_pr_toml() -> &'static str {
    r#"
[mob]
id = "review-pr"

[profiles.router]
model = "gpt-5.5"
skills = ["mob.workpad"]
peer_description = "Classify the pull request and choose the review lane."
runtime_mode = "turn_driven"

[profiles.router.tools]
builtins = true
comms = true
mob = true

[profiles.reviewer]
model = "gpt-5.5"
skills = ["mob.review"]
peer_description = "Review the pull request according to the selected lane."
runtime_mode = "turn_driven"

[profiles.reviewer.tools]
builtins = true
shell = true
comms = true

[skills."mob.workpad"]
source = "inline"
content = "Maintain the shared mob workpad, current plan, evidence, and handoff notes."

[skills."mob.review"]
source = "inline"
content = "Perform structured review. Report blocking findings with file paths, evidence, and suggested fixes."

[flows.main]
description = "Route a pull request into focused review lanes."

[flows.main.steps.route]
role = "router"
message = "Classify this pull request."

[flows.main.steps.review_code]
role = "reviewer"
message = "Review code behavior and tests."
depends_on = ["route"]
branch = "review_lane"
condition = { op = "eq", path = "params.kind", value = "code" }

[flows.main.steps.review_docs]
role = "reviewer"
message = "Review documentation accuracy and navigation."
depends_on = ["route"]
branch = "review_lane"
condition = { op = "eq", path = "params.kind", value = "docs" }

[flows.main.root.nodes.node_route]
kind = "step"
step_id = "route"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_review_code]
kind = "step"
step_id = "review_code"
depends_on = ["node_route"]
depends_on_mode = "all"
branch = "review_lane"

[flows.main.root.nodes.node_review_docs]
kind = "step"
step_id = "review_docs"
depends_on = ["node_route"]
depends_on_mode = "all"
branch = "review_lane"
"#
}

pub fn import_mobpack(params: &Value) -> Result<Value, String> {
    let mut document = document_from_params(params)?;
    if document.mob_toml.as_deref().unwrap_or("").trim().is_empty()
        && let Some(text) = params.get("mob_toml").and_then(Value::as_str)
    {
        document.mob_toml = Some(text.to_string());
    }
    if needs_editor_projection(&document)
        && let Some(mob_toml) = document
            .mob_toml
            .as_deref()
            .map(str::trim)
            .filter(|text| !text.is_empty())
    {
        let definition = MobDefinition::from_toml(mob_toml)
            .map_err(|err| format!("failed to parse mob.toml for editor import: {err}"))?;
        if definition.flows.len() > 1 {
            return Err(format!(
                "cannot import mob.toml into Flow Editor: {} flows found, but this editor projection supports exactly one flow; export/deploy the original mob.toml directly or split it into a single-flow mobpack",
                definition.flows.len()
            ));
        }
        document = project_definition_to_editor_document(
            &definition,
            mob_toml,
            (!document.name.trim().is_empty()).then_some(document.name.as_str()),
            document.deploy.clone(),
        )?;
    }
    let validation = validate_document(&document);
    if let Some(diagnostic) = validation
        .diagnostics
        .iter()
        .find(|diagnostic| is_starter_provenance_diagnostic(&diagnostic.code))
    {
        return Err(format!(
            "cannot import prototype starter skill data: {} at {}",
            diagnostic.code,
            diagnostic.path.as_deref().unwrap_or("skill_realms")
        ));
    }
    let source = import_source_from_params(params);
    Ok(json!({
        "document": document,
        "validation": validation,
        "source": source.source,
        "source_label": source.label,
        "source_media_type": source.media_type,
    }))
}

struct MobpackImportSource {
    source: &'static str,
    label: String,
    media_type: String,
}

fn import_source_from_params(params: &Value) -> MobpackImportSource {
    let label = params
        .get("source_name")
        .or_else(|| params.get("filename"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    if params
        .get("content_base64")
        .and_then(Value::as_str)
        .is_some()
    {
        return MobpackImportSource {
            source: "mobkit/mobpacks/import:archive",
            label: label.unwrap_or("mobpack archive").to_string(),
            media_type: import_media_type(params, "application/vnd.meerkat.mobpack"),
        };
    }
    if params.get("mob_toml").and_then(Value::as_str).is_some() {
        return MobpackImportSource {
            source: "mobkit/mobpacks/import:mob.toml",
            label: label.unwrap_or("mob.toml").to_string(),
            media_type: import_media_type(params, "text/x-toml"),
        };
    }
    if params.get("document").is_some() {
        return MobpackImportSource {
            source: "mobkit/mobpacks/import:editor-document",
            label: label.unwrap_or("mobkit/editor.json").to_string(),
            media_type: import_media_type(params, "application/json"),
        };
    }
    MobpackImportSource {
        source: "mobkit/mobpacks/import:document",
        label: label.unwrap_or("mobpack document").to_string(),
        media_type: import_media_type(params, "application/json"),
    }
}

fn import_media_type(params: &Value, fallback: &str) -> String {
    params
        .get("source_media_type")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or(fallback)
        .to_string()
}

fn mobpack_draft_store_path(params: &Value) -> PathBuf {
    params
        .get("store_path")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var(MOBPACK_DRAFT_STORE_ENV)
                .ok()
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty())
                .map(PathBuf::from)
        })
        .unwrap_or_else(default_mobpack_draft_store_path)
}

fn default_mobpack_draft_store_path() -> PathBuf {
    default_mobpack_draft_store_path_from_env(
        std::env::var("XDG_STATE_HOME").ok().as_deref(),
        std::env::var("HOME").ok().as_deref(),
    )
}

fn default_mobpack_draft_store_path_from_env(
    xdg_state_home: Option<&str>,
    home: Option<&str>,
) -> PathBuf {
    if let Some(path) = xdg_state_home
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        return PathBuf::from(path)
            .join(MOBPACK_DRAFT_STATE_DIR)
            .join(MOBPACK_DRAFT_STORE_FILENAME);
    }
    if let Some(path) = home.map(str::trim).filter(|value| !value.is_empty()) {
        return PathBuf::from(path)
            .join(".local/state")
            .join(MOBPACK_DRAFT_STATE_DIR)
            .join(MOBPACK_DRAFT_STORE_FILENAME);
    }
    std::env::temp_dir().join(MOBPACK_DRAFT_STORE_FILENAME)
}

fn read_mobpack_draft_store(path: &Path) -> Result<BTreeMap<String, Value>, String> {
    if !path.exists() {
        return Ok(BTreeMap::new());
    }
    let bytes = std::fs::read(path).map_err(|err| {
        format!(
            "failed to read mobpack draft store {}: {err}",
            path.display()
        )
    })?;
    if bytes.is_empty() {
        return Ok(BTreeMap::new());
    }
    serde_json::from_slice(&bytes).map_err(|err| {
        format!(
            "failed to parse mobpack draft store {}: {err}",
            path.display()
        )
    })
}

fn write_mobpack_draft_store(path: &Path, rows: &BTreeMap<String, Value>) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|err| {
            format!(
                "failed to create mobpack draft store directory {}: {err}",
                parent.display()
            )
        })?;
    }
    let bytes = serde_json::to_vec_pretty(rows)
        .map_err(|err| format!("failed to encode mobpack draft store: {err}"))?;
    let tmp_path = path.with_extension("json.tmp");
    std::fs::write(&tmp_path, bytes).map_err(|err| {
        format!(
            "failed to write mobpack draft store {}: {err}",
            tmp_path.display()
        )
    })?;
    std::fs::rename(&tmp_path, path).map_err(|err| {
        format!(
            "failed to replace mobpack draft store {}: {err}",
            path.display()
        )
    })
}

fn mobpack_draft_id_from_params(
    params: &Value,
    document: &MobpackDocument,
) -> Result<String, String> {
    let id = params
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .or_else(|| {
            (!document.mob_id.trim().is_empty())
                .then(|| sanitize_identifier(document.mob_id.trim()))
        })
        .or_else(|| {
            (!document.name.trim().is_empty()).then(|| sanitize_identifier(document.name.trim()))
        })
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "mobkit/mobpacks/save requires id or document.mob_id".to_string())?;
    Ok(id)
}

fn mobpack_draft_revision_from_row(row: Option<&Value>) -> u64 {
    row.and_then(|value| {
        value
            .get("revision")
            .or_else(|| value.get("draft_revision"))
            .and_then(Value::as_u64)
    })
    .unwrap_or(0)
}

fn mobpack_draft_expected_revision(params: &Value) -> Option<u64> {
    [
        "expected_revision",
        "expectedRevision",
        "base_revision",
        "baseRevision",
        "if_revision",
        "ifRevision",
    ]
    .iter()
    .find_map(|key| params.get(*key).and_then(Value::as_u64))
}

fn mobpack_draft_etag(id: &str, revision: u64) -> String {
    format!("{id}:{revision}")
}

fn mobpack_draft_expected_etag(params: &Value) -> Option<String> {
    [
        "expected_etag",
        "expectedEtag",
        "if_match",
        "ifMatch",
        "draft_etag",
        "etag",
    ]
    .iter()
    .find_map(|key| {
        params
            .get(*key)
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
    })
}

fn mobpack_draft_guard_id(params: &Value) -> Option<String> {
    [
        "id",
        "currentFlowId",
        "current_flow_id",
        "draft_id",
        "draftId",
    ]
    .iter()
    .find_map(|key| {
        params
            .get(*key)
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
    })
}

fn enforce_mobpack_draft_expected_revision(params: &Value, source: &str) -> Result<(), String> {
    let expected_revision = mobpack_draft_expected_revision(params);
    let expected_etag = mobpack_draft_expected_etag(params);
    if expected_revision.is_none() && expected_etag.is_none() {
        return Ok(());
    }
    let id = mobpack_draft_guard_id(params)
        .ok_or_else(|| format!("{source} expected revision/etag requires draft id"))?;
    let _guard = MOBPACK_DRAFT_STORE_LOCK
        .lock()
        .map_err(|_| "mobpack draft store lock poisoned".to_string())?;
    let path = mobpack_draft_store_path(params);
    let rows = read_mobpack_draft_store(&path)?;
    let existing = rows.get(&id);
    let current_revision = mobpack_draft_revision_from_row(existing);
    if let Some(expected_revision) = expected_revision
        && expected_revision != current_revision
    {
        return Err(format!(
            "{source} draft revision conflict for {id}: expected {expected_revision}, found {current_revision}"
        ));
    }
    if let Some(expected_etag) = expected_etag {
        let current_etag = mobpack_draft_etag(&id, current_revision);
        if expected_etag != current_etag {
            return Err(format!(
                "{source} draft etag conflict for {id}: expected {expected_etag}, found {current_etag}"
            ));
        }
    }
    Ok(())
}

fn mobpack_draft_row_from_params(params: &Value, revision: u64) -> Result<Value, String> {
    let mut document = document_from_params(params)?;
    let id = mobpack_draft_id_from_params(params, &document)?;
    let draft_etag = mobpack_draft_etag(&id, revision);
    let validation_result = canonicalize_deployable_authoring_document(&mut document)?;
    let validation = serde_json::to_value(&validation_result).unwrap_or(Value::Null);
    let stage = if validation.get("ok").and_then(Value::as_bool) == Some(true) {
        "valid".to_string()
    } else {
        "draft".to_string()
    };
    let trigger = params
        .get("trigger")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .or_else(|| {
            document
                .flow
                .get("description")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .map(ToString::to_string)
        })
        .unwrap_or_else(|| "MobKit authoring draft".to_string());
    let source = params
        .get("source")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("mobkit/mobpacks/save");
    let updated_at_unix_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis() as u64)
        .unwrap_or(0);
    let artifact = mobpack_registry_artifact_for_document(&document, &validation);
    Ok(json!({
        "id": id,
        "name": if document.name.trim().is_empty() { document.mob_id.clone() } else { document.name.clone() },
        "version": document.schema_version,
        "stage": stage,
        "trigger": trigger,
        "source": source,
        "document_kind": "editor_projection",
        "document": document,
        "artifact": artifact,
        "validation": validation,
        "updated_at_unix_ms": updated_at_unix_ms,
        "registry_source": "mobkit/mobpacks/save",
        "revision": revision,
        "draft_revision": revision,
        "etag": draft_etag,
        "draft_etag": draft_etag
    }))
}

fn mobpack_registry_artifact_for_document(document: &MobpackDocument, validation: &Value) -> Value {
    if validation.get("ok").and_then(Value::as_bool) != Some(true) {
        return json!({
            "kind": "editor_draft",
            "deployable": false,
            "reason": "validation_failed",
            "source": "mobkit/mobpacks/save"
        });
    }
    match export_mobpack(&json!({ "document": document })) {
        Ok(export) => {
            let pack_sha256 = base64::engine::general_purpose::STANDARD
                .decode(export.content_base64.as_bytes())
                .ok()
                .map(|bytes| source_file_sha256(&bytes))
                .unwrap_or_default();
            json!({
                "kind": "mobpack_archive",
                "deployable": true,
                "source": "mobkit/mobpacks/export",
                "filename": export.filename,
                "media_type": export.media_type,
                "content_base64": export.content_base64,
                "pack_sha256": pack_sha256,
                "mob_toml": export.mob_toml,
                "source_files": export.source_files,
                "validation": export.validation
            })
        }
        Err(error) => json!({
            "kind": "editor_draft",
            "deployable": false,
            "reason": "export_failed",
            "error": error,
            "source": "mobkit/mobpacks/save"
        }),
    }
}

fn mobpack_draft_template(template_id: &str) -> Result<Value, String> {
    let template = if template_id == "blank" || template_id.trim().is_empty() {
        blank_mobpack_template()
    } else {
        sample_mobpack_catalog()
            .as_array()
            .and_then(|samples| {
                samples
                    .iter()
                    .find(|sample| sample["id"].as_str() == Some(template_id))
                    .cloned()
            })
            .unwrap_or(Value::Null)
    };
    if template.is_null() {
        return Err(format!(
            "mobkit/mobpacks/create unknown template: {template_id}"
        ));
    }
    Ok(template)
}

fn clone_draft_template_document(template: &Value, name: &str) -> Result<MobpackDocument, String> {
    let mut document = document_from_value(
        template
            .get("document")
            .ok_or_else(|| "mobkit/mobpacks/create template is missing document".to_string())?,
    )?;
    let clean_name = name.trim();
    if !clean_name.is_empty() {
        document.name = clean_name.to_string();
        document.mob_id = sanitize_identifier(clean_name);
        if let Some(flow) = document.flow.as_object_mut() {
            flow.insert("name".to_string(), Value::String(clean_name.to_string()));
        }
    }
    document.mob_toml = None;
    Ok(document)
}

fn mobpack_create_draft_id(
    params: &Value,
    document: &MobpackDocument,
    rows: &BTreeMap<String, Value>,
) -> String {
    if let Some(id) = params
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        return id.to_string();
    }
    let base = params
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .or_else(|| (!document.name.trim().is_empty()).then_some(document.name.trim()))
        .or_else(|| {
            params
                .get("template")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
        })
        .unwrap_or("mobkit_flow");
    let clean = sanitize_identifier(base);
    let prefix = if clean.starts_with("f_") {
        clean
    } else {
        format!("f_{clean}")
    };
    if !rows.contains_key(&prefix) {
        return prefix;
    }
    let mut index = 2;
    loop {
        let candidate = format!("{prefix}_{index}");
        if !rows.contains_key(&candidate) {
            return candidate;
        }
        index += 1;
    }
}

pub fn create_mobpack_draft(params: &Value) -> Result<Value, String> {
    let template_id = params
        .get("template")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("blank");
    let template = mobpack_draft_template(template_id)?;
    let fallback_name = template
        .get("name")
        .and_then(Value::as_str)
        .unwrap_or("MobKit Flow");
    let name = params
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or(fallback_name);
    let document = clone_draft_template_document(&template, name)?;
    let validation = validate_document(&document);
    let trigger = params
        .get("trigger")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .or_else(|| template.get("trigger").and_then(Value::as_str))
        .unwrap_or("MobKit authoring draft");
    let source = template
        .get("source")
        .and_then(Value::as_str)
        .unwrap_or("mobkit/mobpacks/create");
    let _guard = MOBPACK_DRAFT_STORE_LOCK
        .lock()
        .map_err(|_| "mobpack draft store lock poisoned".to_string())?;
    let path = mobpack_draft_store_path(params);
    let mut rows = read_mobpack_draft_store(&path)?;
    let id = mobpack_create_draft_id(params, &document, &rows);
    let updated_at_unix_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis() as u64)
        .unwrap_or(0);
    let validation_value = serde_json::to_value(&validation).unwrap_or(Value::Null);
    let artifact = mobpack_registry_artifact_for_document(&document, &validation_value);
    let revision = 1_u64;
    let draft_etag = mobpack_draft_etag(&id, revision);
    let row = json!({
        "id": id,
        "name": if document.name.trim().is_empty() { &document.mob_id } else { &document.name },
        "version": document.schema_version,
        "stage": "draft",
        "trigger": trigger,
        "source": source,
        "document_kind": "editor_projection",
        "document": document,
        "artifact": artifact,
        "validation": validation_value,
        "updated_at_unix_ms": updated_at_unix_ms,
        "registry_source": "mobkit/mobpacks/create",
        "template": template_id,
        "revision": revision,
        "draft_revision": revision,
        "etag": draft_etag,
        "draft_etag": draft_etag
    });
    rows.insert(id, row.clone());
    write_mobpack_draft_store(&path, &rows)?;
    // The create response keeps the freshly cloned editor document (no
    // artifact re-import); only the store-internal history fields are
    // projected to can_undo/can_redo.
    let mut row = row;
    strip_draft_history_fields(&mut row);
    Ok(json!({
        "source": "mobkit/mobpacks/create",
        "store_path": path,
        "row": row,
        "rows": sorted_mobpack_draft_rows(&rows),
    }))
}

fn sorted_mobpack_draft_rows(rows: &BTreeMap<String, Value>) -> Vec<Value> {
    rows.values().map(normalize_mobpack_draft_row).collect()
}

/// History snapshots stay in the store; outgoing rows only advertise
/// whether MobKit can step.
fn strip_draft_history_fields(row: &mut Value) {
    let Some(object) = row.as_object_mut() else {
        return;
    };
    let can_undo = object
        .get("history")
        .and_then(Value::as_array)
        .is_some_and(|entries| !entries.is_empty());
    let can_redo = object
        .get("future")
        .and_then(Value::as_array)
        .is_some_and(|entries| !entries.is_empty());
    object.remove("history");
    object.remove("future");
    object.insert("can_undo".to_string(), json!(can_undo));
    object.insert("can_redo".to_string(), json!(can_redo));
}

fn normalize_mobpack_draft_row(row: &Value) -> Value {
    let mut row = row.clone();
    strip_draft_history_fields(&mut row);
    let Some(artifact) = row.get("artifact") else {
        return row;
    };
    if artifact.get("kind").and_then(Value::as_str) != Some("mobpack_archive") {
        return row;
    }
    let Some(content_base64) = artifact
        .get("content_base64")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return row;
    };
    let import = import_mobpack(&json!({
        "content_base64": content_base64,
        "filename": artifact.get("filename").and_then(Value::as_str),
        "source_media_type": artifact
            .get("media_type")
            .and_then(Value::as_str)
            .unwrap_or(MOBPACK_MEDIA_TYPE),
    }));
    match import {
        Ok(imported) => {
            row["document_kind"] = json!("editor_projection");
            row["document_source"] = json!("mobpack_archive");
            row["document_authority"] = json!("artifact");
            row["document"] = imported["document"].clone();
            row["validation"] = imported["validation"].clone();
        }
        Err(error) => {
            row["document_source"] = json!("editor_projection");
            row["document_authority"] = json!("fallback");
            row["artifact_import_error"] = json!(error);
        }
    }
    row
}

pub fn list_mobpack_drafts(params: &Value) -> Result<Value, String> {
    list_mobpack_drafts_with_runtime(params, None)
}

pub fn list_mobpack_drafts_with_runtime(
    params: &Value,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<Value, String> {
    let _guard = MOBPACK_DRAFT_STORE_LOCK
        .lock()
        .map_err(|_| "mobpack draft store lock poisoned".to_string())?;
    let path = mobpack_draft_store_path(params);
    let rows = read_mobpack_draft_store(&path)?;
    let runtime_rows = runtime
        .map(|state| state.runtime_flow_rows.clone())
        .unwrap_or_default();
    let runtime_ids = runtime_rows
        .iter()
        .filter_map(|row| row.get("id").and_then(Value::as_str).map(str::to_string))
        .collect::<BTreeSet<_>>();
    let draft_rows = sorted_mobpack_draft_rows(&rows).into_iter().filter(|row| {
        row.get("id")
            .and_then(Value::as_str)
            .map(|id| !runtime_ids.contains(id))
            .unwrap_or(true)
    });
    let merged_rows = runtime_rows
        .into_iter()
        .chain(draft_rows)
        .collect::<Vec<_>>();
    Ok(json!({
        "source": "mobkit/mobpacks/list",
        "store_path": path,
        "runtime_backed": runtime.is_some(),
        "rows": merged_rows,
    }))
}

pub fn get_mobpack_draft(params: &Value) -> Result<Value, String> {
    get_mobpack_draft_with_runtime(params, None)
}

pub fn get_mobpack_draft_with_runtime(
    params: &Value,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<Value, String> {
    let id = params
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "mobkit/mobpacks/get requires id".to_string())?;
    if let Some(row) = runtime
        .into_iter()
        .flat_map(|state| state.runtime_flow_rows.iter())
        .find(|row| row.get("id").and_then(Value::as_str) == Some(id))
    {
        return Ok(json!({
            "source": "mobkit/mobpacks/get",
            "runtime_backed": true,
            "row": row,
        }));
    }
    let _guard = MOBPACK_DRAFT_STORE_LOCK
        .lock()
        .map_err(|_| "mobpack draft store lock poisoned".to_string())?;
    let path = mobpack_draft_store_path(params);
    let rows = read_mobpack_draft_store(&path)?;
    let row = rows
        .get(id)
        .cloned()
        .ok_or_else(|| format!("mobpack draft not found: {id}"))?;
    let row = normalize_mobpack_draft_row(&row);
    Ok(json!({
        "source": "mobkit/mobpacks/get",
        "store_path": path,
        "runtime_backed": runtime.is_some(),
        "row": row,
    }))
}

pub fn save_mobpack_draft(params: &Value) -> Result<Value, String> {
    let candidate = mobpack_draft_row_from_params(params, 0)?;
    let id = candidate["id"]
        .as_str()
        .ok_or_else(|| "mobpack draft row missing id".to_string())?
        .to_string();
    let _guard = MOBPACK_DRAFT_STORE_LOCK
        .lock()
        .map_err(|_| "mobpack draft store lock poisoned".to_string())?;
    let path = mobpack_draft_store_path(params);
    let mut rows = read_mobpack_draft_store(&path)?;
    let existing = rows.get(&id);
    let current_revision = mobpack_draft_revision_from_row(existing);
    if let Some(expected_revision) = mobpack_draft_expected_revision(params)
        && expected_revision != current_revision
    {
        return Err(format!(
            "mobpack draft revision conflict for {id}: expected {expected_revision}, found {current_revision}"
        ));
    }
    if let Some(expected_etag) = mobpack_draft_expected_etag(params) {
        let current_etag = mobpack_draft_etag(&id, current_revision);
        if expected_etag != current_etag {
            return Err(format!(
                "mobpack draft etag conflict for {id}: expected {expected_etag}, found {current_etag}"
            ));
        }
    }
    let mut row = mobpack_draft_row_from_params(params, current_revision.saturating_add(1))?;
    attach_draft_history_on_save(existing, &mut row);
    rows.insert(id, row.clone());
    write_mobpack_draft_store(&path, &rows)?;
    let row = normalize_mobpack_draft_row(&row);
    Ok(json!({
        "source": "mobkit/mobpacks/save",
        "store_path": path,
        "row": row,
        "rows": sorted_mobpack_draft_rows(&rows),
    }))
}

/// Maximum number of undo steps retained per draft in the store.
const MOBPACK_DRAFT_HISTORY_LIMIT: usize = 25;

/// Carry the MobKit-owned operation history across a save: the previous
/// document becomes an undo step (bounded), and a changed authoring head
/// invalidates any redo entries. Saves with an unchanged document keep both
/// stacks as they are, so undo/redo themselves persist through the autosave
/// that follows them.
fn attach_draft_history_on_save(existing: Option<&Value>, row: &mut Value) {
    let Some(existing) = existing else {
        return;
    };
    let mut history = existing
        .get("history")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let previous_document = existing.get("document");
    let document_changed = previous_document.is_some() && previous_document != row.get("document");
    if !document_changed {
        row["history"] = Value::Array(history);
        if let Some(future) = existing.get("future") {
            row["future"] = future.clone();
        }
        return;
    }
    if let Some(previous) = previous_document {
        history.push(previous.clone());
        if history.len() > MOBPACK_DRAFT_HISTORY_LIMIT {
            let excess = history.len() - MOBPACK_DRAFT_HISTORY_LIMIT;
            history.drain(0..excess);
        }
    }
    row["history"] = Value::Array(history);
}

enum MobpackDraftHistoryStep {
    Undo,
    Redo,
}

pub fn undo_mobpack_draft(params: &Value) -> Result<Value, String> {
    step_mobpack_draft_history(params, MobpackDraftHistoryStep::Undo)
}

pub fn redo_mobpack_draft(params: &Value) -> Result<Value, String> {
    step_mobpack_draft_history(params, MobpackDraftHistoryStep::Redo)
}

/// MobKit-owned undo/redo over the draft store. The history lives with the
/// draft (bounded document snapshots the store itself recorded on save), so
/// the browser never authors restore states; stepping rebuilds the row
/// through the same canonicalization and validation as a save and bumps the
/// draft revision.
fn step_mobpack_draft_history(
    params: &Value,
    step: MobpackDraftHistoryStep,
) -> Result<Value, String> {
    let (source, head) = match step {
        MobpackDraftHistoryStep::Undo => ("mobkit/mobpacks/undo", "undo"),
        MobpackDraftHistoryStep::Redo => ("mobkit/mobpacks/redo", "redo"),
    };
    let id = params
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| format!("{source} requires id"))?
        .to_string();
    let _guard = MOBPACK_DRAFT_STORE_LOCK
        .lock()
        .map_err(|_| "mobpack draft store lock poisoned".to_string())?;
    let path = mobpack_draft_store_path(params);
    let mut rows = read_mobpack_draft_store(&path)?;
    let existing = rows
        .get(&id)
        .cloned()
        .ok_or_else(|| format!("mobpack draft not found: {id}"))?;
    let current_revision = mobpack_draft_revision_from_row(Some(&existing));
    if let Some(expected_revision) = mobpack_draft_expected_revision(params)
        && expected_revision != current_revision
    {
        return Err(format!(
            "{source} draft revision conflict for {id}: expected {expected_revision}, found {current_revision}"
        ));
    }
    if let Some(expected_etag) = mobpack_draft_expected_etag(params) {
        let current_etag = mobpack_draft_etag(&id, current_revision);
        if expected_etag != current_etag {
            return Err(format!(
                "{source} draft etag conflict for {id}: expected {expected_etag}, found {current_etag}"
            ));
        }
    }
    let mut history = existing
        .get("history")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let mut future = existing
        .get("future")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let current_document = existing.get("document").cloned().unwrap_or(Value::Null);
    let restored = match step {
        MobpackDraftHistoryStep::Undo => {
            let Some(document) = history.pop() else {
                return Ok(json!({
                    "source": source,
                    "store_path": path,
                    "stepped": false,
                    "reason": format!("nothing to {head}"),
                    "row": normalize_mobpack_draft_row(&existing),
                    "rows": sorted_mobpack_draft_rows(&rows),
                }));
            };
            future.push(current_document);
            document
        }
        MobpackDraftHistoryStep::Redo => {
            let Some(document) = future.pop() else {
                return Ok(json!({
                    "source": source,
                    "store_path": path,
                    "stepped": false,
                    "reason": format!("nothing to {head}"),
                    "row": normalize_mobpack_draft_row(&existing),
                    "rows": sorted_mobpack_draft_rows(&rows),
                }));
            };
            history.push(current_document);
            document
        }
    };
    if future.len() > MOBPACK_DRAFT_HISTORY_LIMIT {
        let excess = future.len() - MOBPACK_DRAFT_HISTORY_LIMIT;
        future.drain(0..excess);
    }
    let mut row = mobpack_draft_row_from_params(
        &json!({
            "id": id,
            "document": restored,
            "trigger": existing.get("trigger").and_then(Value::as_str),
            "source": source,
        }),
        current_revision.saturating_add(1),
    )?;
    row["history"] = Value::Array(history);
    row["future"] = Value::Array(future);
    rows.insert(id, row.clone());
    write_mobpack_draft_store(&path, &rows)?;
    let row = normalize_mobpack_draft_row(&row);
    Ok(json!({
        "source": source,
        "store_path": path,
        "stepped": true,
        "row": row,
        "rows": sorted_mobpack_draft_rows(&rows),
    }))
}

pub fn delete_mobpack_draft(params: &Value) -> Result<Value, String> {
    let id = params
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "mobkit/mobpacks/delete requires id".to_string())?
        .to_string();
    let _guard = MOBPACK_DRAFT_STORE_LOCK
        .lock()
        .map_err(|_| "mobpack draft store lock poisoned".to_string())?;
    let path = mobpack_draft_store_path(params);
    let mut rows = read_mobpack_draft_store(&path)?;
    let current_revision = mobpack_draft_revision_from_row(rows.get(&id));
    if let Some(expected_revision) = mobpack_draft_expected_revision(params)
        && expected_revision != current_revision
    {
        return Err(format!(
            "mobkit/mobpacks/delete draft revision conflict for {id}: expected {expected_revision}, found {current_revision}"
        ));
    }
    if let Some(expected_etag) = mobpack_draft_expected_etag(params) {
        let current_etag = mobpack_draft_etag(&id, current_revision);
        if expected_etag != current_etag {
            return Err(format!(
                "mobkit/mobpacks/delete draft etag conflict for {id}: expected {expected_etag}, found {current_etag}"
            ));
        }
    }
    let deleted = rows.remove(&id).is_some();
    write_mobpack_draft_store(&path, &rows)?;
    Ok(json!({
        "source": "mobkit/mobpacks/delete",
        "store_path": path,
        "id": id,
        "deleted": deleted,
        "rows": sorted_mobpack_draft_rows(&rows),
    }))
}

pub fn apply_mobpack_authoring_operation(params: &Value) -> Result<Value, String> {
    apply_mobpack_authoring_operation_with_runtime(params, None)
}

pub fn apply_mobpack_authoring_operation_with_runtime(
    params: &Value,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<Value, String> {
    enforce_mobpack_draft_expected_revision(params, "mobkit/mobpacks/apply_operation")?;
    let mut document = document_from_params(params)?;
    let operation = params
        .get("operation")
        .and_then(Value::as_object)
        .ok_or_else(|| "mobkit/mobpacks/apply_operation requires operation object".to_string())?;
    let operation_type = operation
        .get("type")
        .or_else(|| operation.get("kind"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "mobkit/mobpacks/apply_operation requires operation.type".to_string())?;
    if operation.get("document").is_some() {
        return Err(format!(
            "{operation_type} does not accept operation.document; use mobkit/mobpacks/import or create/save for document replacement"
        ));
    }
    enforce_expected_catalog_snapshot(params, operation, operation_type, runtime)?;
    let selection = match operation_type {
        "sync_graph_to_flow" => apply_sync_graph_to_flow_operation(&mut document, operation)?,
        "reconcile_members" => apply_reconcile_members_operation(&mut document, operation)?,
        "reconcile_condition_fields" => {
            apply_reconcile_condition_fields_operation(&mut document, operation)?
        }
        "reconcile_contract_refs" => {
            apply_reconcile_contract_refs_operation(&mut document, operation)?
        }
        "add_agent_definition" => {
            apply_add_agent_definition_operation(&mut document, operation, runtime)?
        }
        "update_member" => apply_update_member_operation(&mut document, operation)?,
        "add_member_tool" => apply_member_tool_operation(&mut document, operation, true, runtime)?,
        "remove_member_tool" => {
            apply_member_tool_operation(&mut document, operation, false, runtime)?
        }
        "toggle_member_skill" => {
            apply_member_skill_operation(&mut document, operation, SkillOperation::Toggle, runtime)?
        }
        "remove_member_skill" => {
            apply_member_skill_operation(&mut document, operation, SkillOperation::Remove, runtime)?
        }
        "create_inline_skill" => apply_create_inline_skill_operation(&mut document, operation)?,
        "delete_member" => apply_delete_member_operation(&mut document, operation)?,
        "assign_member_schema" => apply_assign_member_schema_operation(&mut document, operation)?,
        "add_schema" => apply_add_schema_operation(&mut document, operation)?,
        "update_schema" => apply_update_schema_operation(&mut document, operation)?,
        "rename_schema" => apply_rename_schema_operation(&mut document, operation)?,
        "delete_schema" => apply_delete_schema_operation(&mut document, operation)?,
        "add_schema_field" => apply_add_schema_field_operation(&mut document, operation)?,
        "update_schema_field" => apply_update_schema_field_operation(&mut document, operation)?,
        "rename_schema_field" => apply_rename_schema_field_operation(&mut document, operation)?,
        "delete_schema_field" => apply_delete_schema_field_operation(&mut document, operation)?,
        "update_deploy_settings" => {
            apply_update_deploy_settings_operation(&mut document, operation)?
        }
        "update_mob_settings" => apply_update_mob_settings_operation(&mut document, operation)?,
        "update_role_wiring" => apply_update_role_wiring_operation(&mut document, operation)?,
        "add_input_param" => apply_add_input_param_operation(&mut document, operation)?,
        "update_input_param" => apply_update_input_param_operation(&mut document, operation)?,
        "rename_input_param" => apply_rename_input_param_operation(&mut document, operation)?,
        "delete_input_param" => apply_delete_input_param_operation(&mut document, operation)?,
        "insert_flow_step" => apply_insert_flow_step_operation(&mut document, operation)?,
        "update_flow_step" => apply_update_flow_step_operation(&mut document, operation)?,
        "apply_flow_step_edit" => apply_flow_step_edit_operation(&mut document, operation)?,
        "delete_flow_step" => apply_delete_flow_step_operation(&mut document, operation)?,
        "insert_graph_node" => apply_insert_graph_node_operation(&mut document, operation)?,
        "apply_graph_node_edit" => apply_graph_node_edit_operation(&mut document, operation)?,
        "move_graph_node" => apply_move_graph_node_operation(&mut document, operation)?,
        "delete_graph_node" => apply_delete_graph_node_operation(&mut document, operation)?,
        "connect_graph_nodes" => apply_connect_graph_nodes_operation(&mut document, operation)?,
        "apply_graph_edge_edit" => apply_graph_edge_edit_operation(&mut document, operation)?,
        "delete_graph_edge" => apply_delete_graph_edge_operation(&mut document, operation)?,
        other => {
            return Err(format!(
                "mobkit/mobpacks/apply_operation unsupported operation.type: {other}"
            ));
        }
    };
    let mut validation = validate_document(&document);
    if validate_with_rkat_requested(params) && validation.ok {
        validation = validate_document_with_rkat(params, &document, validation)?;
    }
    Ok(json!({
        "source": "mobkit/mobpacks/apply_operation",
        "operation": operation_type,
        "ok": validation.ok,
        "document": document,
        "selection": selection,
        "validation": validation,
    }))
}

fn enforce_expected_catalog_snapshot(
    params: &Value,
    operation: &serde_json::Map<String, Value>,
    operation_type: &str,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<(), String> {
    if !operation_uses_authoring_catalog(operation_type) {
        return Ok(());
    }
    let Some(expected) = expected_catalog_snapshot_id(params, operation) else {
        return Ok(());
    };
    let current = current_authoring_catalog_snapshot_id(runtime)
        .ok_or_else(|| "MobKit authoring catalog did not expose catalog_snapshot.id".to_string())?;
    if expected == current {
        return Ok(());
    }
    Err(format!(
        "mobkit/mobpacks/apply_operation catalog snapshot conflict for {operation_type}: expected {expected}, found {current}"
    ))
}

fn operation_uses_authoring_catalog(operation_type: &str) -> bool {
    matches!(
        operation_type,
        "add_agent_definition" | "add_member_tool" | "toggle_member_skill"
    )
}

fn expected_catalog_snapshot_id(
    params: &Value,
    operation: &serde_json::Map<String, Value>,
) -> Option<String> {
    value_catalog_snapshot_id(params)
        .or_else(|| value_catalog_snapshot_id(&Value::Object(operation.clone())))
}

fn value_catalog_snapshot_id(value: &Value) -> Option<String> {
    value
        .get("expected_catalog_snapshot_id")
        .or_else(|| value.get("expectedCatalogSnapshotId"))
        .or_else(|| value.get("catalog_snapshot_id"))
        .or_else(|| value.get("catalogSnapshotId"))
        .and_then(Value::as_str)
        .or_else(|| {
            value
                .get("catalog_snapshot")
                .or_else(|| value.get("catalogSnapshot"))
                .and_then(|snapshot| {
                    snapshot
                        .get("id")
                        .and_then(Value::as_str)
                        .or_else(|| snapshot.as_str())
                })
        })
        .map(str::trim)
        .filter(|snapshot| !snapshot.is_empty())
        .map(ToString::to_string)
}

fn current_authoring_catalog_snapshot_id(
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Option<String> {
    mobpack_catalogs_response_with_runtime(runtime)["catalog_snapshot"]["id"]
        .as_str()
        .map(ToString::to_string)
}

fn value_kind(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "bool",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}

fn operation_selection_or_clear(operation: &serde_json::Map<String, Value>) -> Value {
    operation
        .get("selection")
        .cloned()
        .unwrap_or_else(|| json!({ "kind": null, "id": null }))
}

fn apply_sync_graph_to_flow_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    // The advanced-mode canvas keeps the user's node/edge authoring as-is;
    // the flow is derived from it. Validation accepts the gate-less graph
    // because `validate_graph_projection` proves the equivalence by
    // re-deriving the flow from the graph.
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    Ok(operation_selection_or_clear(operation))
}

fn apply_reconcile_members_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    reconcile_document_after_member_change(document);
    prune_mob_settings_for_current_members(document);
    Ok(operation_selection_or_clear(operation))
}

fn apply_reconcile_condition_fields_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    reconcile_condition_field_availability(document);
    Ok(operation_selection_or_clear(operation))
}

fn apply_reconcile_contract_refs_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    prune_member_refs_for_contract(document);
    reconcile_document_after_member_change(document);
    prune_mob_settings_for_current_members(document);
    reconcile_condition_field_availability(document);
    if !document.deploy.is_object() {
        document.deploy = json!({});
    }
    document.deploy["command"] = json!("rkat mob run");
    Ok(operation_selection_or_clear(operation))
}

fn apply_delete_member_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let member_id = operation_member_id(operation)?;
    let mut members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = member_index_by_id(&members, &member_id)
        .ok_or_else(|| format!("member not found: {member_id}"))?;
    let removed = members.remove(index);
    let removed_names = member_identity_values(&removed);
    document.members = Value::Array(members);
    prune_mob_settings_for_removed_member(&mut document.mob_settings, &removed_names);
    reconcile_document_after_member_change(document);
    Ok(json!({ "kind": null, "id": null }))
}

fn apply_assign_member_schema_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let member_id = operation_member_id(operation)?;
    let schema_id = operation
        .get("schema_id")
        .or_else(|| operation.get("schemaId"))
        .or_else(|| operation.get("schema"))
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default()
        .to_string();
    if !schema_id.is_empty() && schema_index_by_id(&document.schemas, &schema_id).is_none() {
        return Err(format!("schema not found: {schema_id}"));
    }
    let mut members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = member_index_by_id(&members, &member_id)
        .ok_or_else(|| format!("member not found: {member_id}"))?;
    let old_schema = members[index]
        .get("schema")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    if old_schema != schema_id {
        for field_name in schema_field_names(&document.schemas, &old_schema) {
            rewrite_schema_field_references(document, &old_schema, &field_name, "");
        }
    }
    members[index]["schema"] = Value::String(schema_id);
    document.members = Value::Array(members);
    Ok(json!({ "kind": "agent", "id": member_id }))
}

fn next_schema_id(schemas: &[Value], prefix: &str) -> String {
    let prefix = input_param_name(prefix, "Artifact");
    let used = schemas
        .iter()
        .filter_map(|schema| schema.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .collect::<BTreeSet<_>>();
    let mut index = 1;
    loop {
        let id = format!("{prefix}{index}");
        if !used.contains(id.as_str()) {
            return id;
        }
        index += 1;
    }
}

fn next_schema_field_id(fields: &[Value]) -> String {
    let used = fields
        .iter()
        .filter_map(|field| field.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .collect::<BTreeSet<_>>();
    let mut index = 1;
    loop {
        let id = format!("f{index}");
        if !used.contains(id.as_str()) {
            return id;
        }
        index += 1;
    }
}

fn unique_schema_draft_field_name(fields: &[Value], raw: &str) -> String {
    let base = input_param_name(raw, "field");
    let taken = fields
        .iter()
        .filter_map(|field| field.get("name").and_then(Value::as_str))
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .collect::<BTreeSet<_>>();
    if !taken.contains(base.as_str()) {
        return base;
    }
    let mut index = 2;
    loop {
        let candidate = format!("{base}_{index}");
        if !taken.contains(candidate.as_str()) {
            return candidate;
        }
        index += 1;
    }
}

fn schema_draft_contract() -> Result<(Value, String), String> {
    let schema = mobpack_schema_response();
    let mob_definition = schema
        .get("mob_definition")
        .and_then(Value::as_object)
        .ok_or_else(|| "MobKit schema is missing mob_definition".to_string())?;
    let field_type = mob_definition
        .get("defaults")
        .and_then(Value::as_object)
        .and_then(|defaults| defaults.get("schema_field_type"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("string");
    if !is_editor_schema_field_type(field_type) {
        return Err(format!(
            "MobKit schema default schema_field_type is unsupported: {field_type}"
        ));
    }
    let draft = mob_definition
        .get("editor_schema_draft")
        .cloned()
        .filter(Value::is_object)
        .ok_or_else(|| "MobKit schema is missing mob_definition.editor_schema_draft".to_string())?;
    Ok((draft, field_type.to_string()))
}

fn schema_field_from_draft(fields: &[Value], field_key: &str) -> Result<Value, String> {
    let (draft, field_type) = schema_draft_contract()?;
    let field = draft
        .get(field_key)
        .and_then(Value::as_object)
        .ok_or_else(|| format!("MobKit schema is missing editor_schema_draft.{field_key}"))?;
    let name = field
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("field");
    let enum_values = field
        .get("enumValues")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    Ok(json!({
        "id": next_schema_field_id(fields),
        "name": unique_schema_draft_field_name(fields, name),
        "type": field_type,
        "required": field.get("required").and_then(Value::as_bool).unwrap_or(true),
        "description": field.get("description").and_then(Value::as_str).unwrap_or(""),
        "enumValues": enum_values,
    }))
}

fn schema_from_draft(schemas: &[Value]) -> Result<Value, String> {
    let (draft, _) = schema_draft_contract()?;
    let prefix = draft
        .get("schema_id_prefix")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("Artifact");
    Ok(json!({
        "id": next_schema_id(schemas, prefix),
        "description": "",
        "fields": [schema_field_from_draft(&[], "initial_field")?],
    }))
}

fn apply_add_schema_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let schema = operation
        .get("schema")
        .cloned()
        .map(Ok)
        .unwrap_or_else(|| schema_from_draft(&schemas))?;
    let schema_id = schema
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "add_schema requires schema.id".to_string())?
        .to_string();
    if schemas.iter().any(|schema| {
        schema
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == schema_id)
    }) {
        return Err(format!("schema already exists: {schema_id}"));
    }
    schemas.push(schema);
    document.schemas = Value::Array(schemas);
    Ok(json!({ "kind": "schema", "id": schema_id }))
}

fn apply_update_schema_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let schema_id = operation_schema_id(operation)?;
    let patch = operation
        .get("patch")
        .and_then(Value::as_object)
        .ok_or_else(|| "update_schema requires patch object".to_string())?;
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = schema_index_in_slice(&schemas, &schema_id)
        .ok_or_else(|| format!("schema not found: {schema_id}"))?;
    let schema = schemas[index]
        .as_object_mut()
        .ok_or_else(|| format!("schema is not an object: {schema_id}"))?;
    let normalized_patch = normalize_schema_update_patch(patch)?;
    for (key, value) in normalized_patch {
        schema.insert(key, value);
    }
    document.schemas = Value::Array(schemas);
    Ok(json!({ "kind": "schema", "id": schema_id }))
}

fn normalize_schema_update_patch(
    patch: &serde_json::Map<String, Value>,
) -> Result<serde_json::Map<String, Value>, String> {
    let mut normalized = serde_json::Map::new();
    for (key, value) in patch {
        match key.as_str() {
            "id" => return Err("update_schema cannot change schema id".to_string()),
            "description" => {
                let description = value
                    .as_str()
                    .ok_or_else(|| "update_schema description must be a string".to_string())?;
                normalized.insert(
                    "description".to_string(),
                    Value::String(description.to_string()),
                );
            }
            other => return Err(format!("unsupported update_schema patch field: {other}")),
        }
    }
    Ok(normalized)
}

fn apply_rename_schema_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let schema_id = operation_schema_id(operation)?;
    let new_id = operation
        .get("new_id")
        .or_else(|| operation.get("newId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "rename_schema requires new_id".to_string())?
        .to_string();
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    if schema_index_in_slice(&schemas, &new_id).is_some() {
        return Err(format!("schema already exists: {new_id}"));
    }
    let index = schema_index_in_slice(&schemas, &schema_id)
        .ok_or_else(|| format!("schema not found: {schema_id}"))?;
    schemas[index]["id"] = Value::String(new_id.clone());
    document.schemas = Value::Array(schemas);
    rewrite_member_schema_references(&mut document.members, &schema_id, &new_id);
    rewrite_adaptive_result_schema_references(document, &schema_id, &new_id);
    Ok(json!({ "kind": "schema", "id": new_id }))
}

fn apply_delete_schema_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let schema_id = operation_schema_id(operation)?;
    let removed_fields = schema_field_names(&document.schemas, &schema_id);
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let before = schemas.len();
    schemas.retain(|schema| {
        schema
            .get("id")
            .and_then(Value::as_str)
            .is_none_or(|id| id != schema_id)
    });
    if before == schemas.len() {
        return Err(format!("schema not found: {schema_id}"));
    }
    document.schemas = Value::Array(schemas);
    rewrite_member_schema_references(&mut document.members, &schema_id, "");
    rewrite_adaptive_result_schema_references(document, &schema_id, "");
    for field_name in removed_fields {
        rewrite_schema_field_references(document, &schema_id, &field_name, "");
    }
    Ok(json!({ "kind": null, "id": null }))
}

fn apply_add_schema_field_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let schema_id = operation_schema_id(operation)?;
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = schema_index_in_slice(&schemas, &schema_id)
        .ok_or_else(|| format!("schema not found: {schema_id}"))?;
    let schema = schemas[index]
        .as_object_mut()
        .ok_or_else(|| format!("schema is not an object: {schema_id}"))?;
    let fields = schema
        .entry("fields")
        .or_insert_with(|| Value::Array(Vec::new()))
        .as_array_mut()
        .ok_or_else(|| format!("schema fields is not an array: {schema_id}"))?;
    let field = operation
        .get("field")
        .cloned()
        .map(Ok)
        .unwrap_or_else(|| schema_field_from_draft(fields, "added_field"))?;
    let field_id = field
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "add_schema_field requires field.id".to_string())?
        .to_string();
    if fields.iter().any(|field| {
        field
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == field_id)
    }) {
        return Err(format!("schema field already exists: {field_id}"));
    }
    fields.push(field);
    document.schemas = Value::Array(schemas);
    Ok(json!({ "kind": "schema", "id": schema_id, "field_id": field_id }))
}

fn apply_update_schema_field_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let schema_id = operation_schema_id(operation)?;
    let field_id = operation_field_id(operation)?;
    let patch = operation
        .get("patch")
        .and_then(Value::as_object)
        .ok_or_else(|| "update_schema_field requires patch object".to_string())?;
    update_schema_field(document, &schema_id, &field_id, patch)
}

fn apply_rename_schema_field_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let schema_id = operation_schema_id(operation)?;
    let field_id = operation_field_id(operation)?;
    let new_name = operation
        .get("new_name")
        .or_else(|| operation.get("newName"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "rename_schema_field requires new_name".to_string())?;
    let mut patch = serde_json::Map::new();
    patch.insert("name".to_string(), Value::String(new_name.to_string()));
    update_schema_field(document, &schema_id, &field_id, &patch)
}

fn apply_delete_schema_field_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let schema_id = operation_schema_id(operation)?;
    let field_id = operation_field_id(operation)?;
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let schema_index = schema_index_in_slice(&schemas, &schema_id)
        .ok_or_else(|| format!("schema not found: {schema_id}"))?;
    let fields = schemas[schema_index]
        .get_mut("fields")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| format!("schema fields is not an array: {schema_id}"))?;
    let field_index = field_index_in_slice(fields, &field_id)
        .ok_or_else(|| format!("schema field not found: {field_id}"))?;
    let old_name = fields[field_index]
        .get("name")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    fields.remove(field_index);
    document.schemas = Value::Array(schemas);
    if !old_name.is_empty() {
        rewrite_schema_field_references(document, &schema_id, &old_name, "");
    }
    Ok(json!({ "kind": "schema", "id": schema_id }))
}

fn apply_update_deploy_settings_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    if let Some(deploy) = operation.get("deploy") {
        return Err(format!(
            "update_deploy_settings does not accept full deploy object; use field/value updates (received {})",
            value_kind(deploy)
        ));
    }
    let field = operation
        .get("field")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "update_deploy_settings requires field".to_string())?;
    let key = deploy_settings_field_key(field)
        .ok_or_else(|| format!("unsupported deploy settings field: {field}"))?;
    let value = normalize_deploy_settings_field_value(key, operation.get("value"))?;
    let mut settings = document
        .deploy
        .as_object()
        .cloned()
        .unwrap_or_else(serde_json::Map::new);
    settings.insert(key.to_string(), value);
    document.deploy = Value::Object(settings);
    Ok(operation_selection_or_clear(operation))
}

fn apply_update_mob_settings_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    if let Some(settings) = operation
        .get("mob_settings")
        .or_else(|| operation.get("mobSettings"))
    {
        return Err(format!(
            "update_mob_settings does not accept full mob_settings object; use field/value updates (received {})",
            value_kind(settings)
        ));
    }
    let field = operation
        .get("field")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "update_mob_settings requires field".to_string())?;
    let key = mob_settings_field_key(field)
        .ok_or_else(|| format!("unsupported mob settings field: {field}"))?;
    let value = normalize_mob_settings_field_value(key, operation.get("value"))?;
    let mut settings = document
        .mob_settings
        .as_object()
        .cloned()
        .unwrap_or_else(serde_json::Map::new);
    settings.insert(key.to_string(), value);
    document.mob_settings = Value::Object(settings);
    Ok(operation_selection_or_clear(operation))
}

fn apply_update_role_wiring_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    if let Some(settings) = operation
        .get("mob_settings")
        .or_else(|| operation.get("mobSettings"))
    {
        return Err(format!(
            "update_role_wiring does not accept full mob_settings object; use action updates (received {})",
            value_kind(settings)
        ));
    } else if let Some(role_wiring) = operation
        .get("role_wiring")
        .or_else(|| operation.get("roleWiring"))
    {
        return Err(format!(
            "update_role_wiring does not accept full role_wiring array; use action updates (received {})",
            value_kind(role_wiring)
        ));
    } else if let Some(action) = operation
        .get("action")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        apply_role_wiring_action(document, operation, action)?;
    } else {
        return Err("update_role_wiring requires action".to_string());
    }
    Ok(operation_selection_or_clear(operation))
}

fn deploy_settings_field_key(field: &str) -> Option<&'static str> {
    match field {
        "command" => Some("command"),
        "surface" => Some("surface"),
        "trustPolicy" | "trust_policy" => Some("trust_policy"),
        "model" => Some("model"),
        "maxDuration" | "max_duration" => Some("max_duration"),
        "maxToolCalls" | "max_tool_calls" => Some("max_tool_calls"),
        "maxTotalTokens" | "max_total_tokens" => Some("max_total_tokens"),
        "isolated" => Some("isolated"),
        "realm" => Some("realm"),
        "instance" => Some("instance"),
        "realmBackend" | "realm_backend" => Some("realm_backend"),
        "contextRoot" | "context_root" => Some("context_root"),
        "stateRoot" | "state_root" => Some("state_root"),
        "userConfigRoot" | "user_config_root" => Some("user_config_root"),
        "prompt" => Some("prompt"),
        _ => None,
    }
}

fn mob_settings_field_key(field: &str) -> Option<&'static str> {
    match field {
        "orchestrator" => Some("orchestrator"),
        "autoWireOrchestrator" | "auto_wire_orchestrator" => Some("autoWireOrchestrator"),
        "backendDefault" | "backend_default" => Some("backendDefault"),
        "externalAddressBase" | "external_address_base" => Some("externalAddressBase"),
        "advanced" => Some("advanced"),
        _ => None,
    }
}

fn normalize_deploy_settings_field_value(
    key: &str,
    value: Option<&Value>,
) -> Result<Value, String> {
    let value = value.unwrap_or(&Value::Null);
    match key {
        "command" => {
            let command = required_trimmed_string(value, "update_deploy_settings command")?;
            if command != "rkat mob run" {
                return Err(format!(
                    "deploy.command must be 'rkat mob run', got '{command}'"
                ));
            }
            Ok(Value::String(command))
        }
        "surface" => {
            optional_string_enum_value(value, "update_deploy_settings surface", &["cli", "rpc"])
        }
        "trust_policy" => optional_string_enum_value(
            value,
            "update_deploy_settings trustPolicy",
            &["permissive", "strict"],
        ),
        "realm_backend" => optional_string_enum_value(
            value,
            "update_deploy_settings realmBackend",
            &["jsonl", "sqlite"],
        ),
        "model" => {
            let model = optional_trimmed_string(value, "update_deploy_settings model")?;
            if let Value::String(model) = &model
                && !model.is_empty()
                && !available_model_ids().contains(model)
            {
                return Err(format!("unknown deploy model: {model}"));
            }
            Ok(model)
        }
        "max_tool_calls" | "max_total_tokens" => {
            optional_non_negative_integer_value(value, &format!("update_deploy_settings {key}"))
        }
        "isolated" => optional_bool_value(value, "update_deploy_settings isolated"),
        "max_duration" | "realm" | "instance" | "context_root" | "state_root"
        | "user_config_root" => {
            optional_trimmed_string(value, &format!("update_deploy_settings {key}"))
        }
        "prompt" => required_text_string(value, "update_deploy_settings prompt").map(Value::String),
        other => Err(format!("unsupported deploy settings field: {other}")),
    }
}

fn normalize_mob_settings_field_value(key: &str, value: Option<&Value>) -> Result<Value, String> {
    let value = value.unwrap_or(&Value::Null);
    match key {
        "orchestrator" | "externalAddressBase" => {
            optional_trimmed_string(value, &format!("update_mob_settings {key}"))
        }
        "autoWireOrchestrator" => {
            optional_bool_value(value, "update_mob_settings autoWireOrchestrator")
        }
        "backendDefault" => optional_string_enum_value(
            value,
            "update_mob_settings backendDefault",
            &["session", "external"],
        ),
        "advanced" => {
            if value.is_null() || value.is_object() {
                Ok(value.clone())
            } else {
                Err("update_mob_settings advanced must be a JSON object or null".to_string())
            }
        }
        other => Err(format!("unsupported mob settings field: {other}")),
    }
}

fn required_trimmed_string(value: &Value, context: &str) -> Result<String, String> {
    value
        .as_str()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| format!("{context} must be a non-empty string"))
}

// Required free text: must not be blank, but the stored value keeps the
// user's whitespace — these fields echo back into live inputs per keystroke.
fn required_text_string(value: &Value, context: &str) -> Result<String, String> {
    value
        .as_str()
        .filter(|value| !value.trim().is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| format!("{context} must be a non-empty string"))
}

fn optional_trimmed_string(value: &Value, context: &str) -> Result<Value, String> {
    if value.is_null() {
        return Ok(Value::Null);
    }
    let text = value
        .as_str()
        .map(str::trim)
        .ok_or_else(|| format!("{context} must be a string or null"))?;
    Ok(Value::String(text.to_string()))
}

fn optional_string_enum_value(
    value: &Value,
    context: &str,
    allowed: &[&str],
) -> Result<Value, String> {
    if value.is_null() {
        return Ok(Value::Null);
    }
    let text = required_trimmed_string(value, context)?;
    if allowed.contains(&text.as_str()) {
        Ok(Value::String(text))
    } else {
        Err(format!(
            "{context} must be one of {}, got '{text}'",
            allowed.join(", ")
        ))
    }
}

fn optional_bool_value(value: &Value, context: &str) -> Result<Value, String> {
    if value.is_null() {
        return Ok(Value::Null);
    }
    value
        .as_bool()
        .map(Value::Bool)
        .ok_or_else(|| format!("{context} must be a boolean or null"))
}

fn optional_non_negative_integer_value(value: &Value, context: &str) -> Result<Value, String> {
    if value.is_null() {
        return Ok(Value::Null);
    }
    if let Some(value) = value.as_i64() {
        if value >= 0 {
            return Ok(Value::from(value));
        }
    } else if let Some(value) = value.as_u64() {
        return Ok(Value::from(value));
    } else if let Some(value) = value.as_f64()
        && value >= 0.0
        && value.fract() == 0.0
        && value <= i64::MAX as f64
    {
        return Ok(Value::from(value as i64));
    }
    Err(format!("{context} must be a non-negative integer or null"))
}

fn set_document_role_wiring(document: &mut MobpackDocument, role_wiring: Value) {
    let mut settings = document
        .mob_settings
        .as_object()
        .cloned()
        .unwrap_or_else(serde_json::Map::new);
    settings.insert("roleWiring".to_string(), role_wiring);
    document.mob_settings = Value::Object(settings);
}

fn apply_role_wiring_action(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
    action: &str,
) -> Result<(), String> {
    let mut wiring = editor_role_wiring_pairs(
        document
            .mob_settings
            .get("roleWiring")
            .or_else(|| document.mob_settings.get("role_wiring")),
    );
    let profiles = document_member_profiles(document);
    match action {
        "add" => {
            let first = profiles
                .first()
                .cloned()
                .ok_or_else(|| "update_role_wiring add requires at least one member".to_string())?;
            let second = profiles.get(1).cloned().unwrap_or_else(|| first.clone());
            wiring.push((first, second));
        }
        "delete" | "remove" => {
            let index = operation_index(operation, "update_role_wiring delete")?;
            if index >= wiring.len() {
                return Err(format!("role wiring index out of range: {index}"));
            }
            wiring.remove(index);
        }
        "set_source" | "set_target" => {
            let index = operation_index(operation, "update_role_wiring set")?;
            if index >= wiring.len() {
                return Err(format!("role wiring index out of range: {index}"));
            }
            let value = operation
                .get("value")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .ok_or_else(|| "update_role_wiring set requires value".to_string())?;
            if !profiles.is_empty() && !profiles.iter().any(|profile| profile == value) {
                return Err(format!(
                    "role wiring value is not a member profile: {value}"
                ));
            }
            if action == "set_source" {
                wiring[index].0 = value.to_string();
            } else {
                wiring[index].1 = value.to_string();
            }
        }
        other => return Err(format!("unsupported update_role_wiring action: {other}")),
    }
    set_document_role_wiring(
        document,
        Value::Array(
            wiring
                .into_iter()
                .filter(|(a, b)| !a.trim().is_empty() && !b.trim().is_empty())
                .map(|(a, b)| json!({ "a": a, "b": b }))
                .collect(),
        ),
    );
    Ok(())
}

fn operation_index(
    operation: &serde_json::Map<String, Value>,
    context: &str,
) -> Result<usize, String> {
    let index = operation
        .get("index")
        .and_then(Value::as_u64)
        .ok_or_else(|| format!("{context} requires index"))?;
    usize::try_from(index).map_err(|_| format!("{context} index is too large"))
}

fn document_member_profiles(document: &MobpackDocument) -> Vec<String> {
    document
        .members
        .as_array()
        .into_iter()
        .flatten()
        .map(editor_profile_name)
        .filter(|value| !value.is_empty())
        .collect()
}

fn editor_role_wiring_pairs(value: Option<&Value>) -> Vec<(String, String)> {
    value
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|rule| {
            let a = rule.get("a").and_then(Value::as_str)?.trim();
            let b = rule.get("b").and_then(Value::as_str)?.trim();
            (!a.is_empty() && !b.is_empty()).then(|| (a.to_string(), b.to_string()))
        })
        .collect()
}

fn apply_add_agent_definition_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<Value, String> {
    let definition_id = operation
        .get("definition_id")
        .or_else(|| operation.get("definitionId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "add_agent_definition requires definition_id".to_string())?;
    let definition = mobpack_agent_definition_by_id(definition_id, runtime)
        .ok_or_else(|| format!("unknown MobKit agent definition: {definition_id}"))?;
    let member = member_from_agent_definition(&definition, &document.members)?;
    merge_agent_definition_schema(document, &definition);
    merge_agent_definition_skills(document, &definition);
    let mut members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let member_id = member
        .get("id")
        .and_then(Value::as_str)
        .ok_or_else(|| "MobKit agent definition produced member without id".to_string())?
        .to_string();
    members.push(member);
    document.members = Value::Array(members);
    Ok(json!({ "kind": "agent", "id": member_id }))
}

fn operation_member_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("member_id")
        .or_else(|| operation.get("memberId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires member_id".to_string())
}

fn operation_string(
    operation: &serde_json::Map<String, Value>,
    snake_field: &str,
    camel_field: &str,
) -> Result<String, String> {
    operation
        .get(snake_field)
        .or_else(|| operation.get(camel_field))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| format!("MobKit authoring operation requires {snake_field}"))
}

fn operation_schema_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("schema_id")
        .or_else(|| operation.get("schemaId"))
        .or_else(|| operation.get("id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires schema_id".to_string())
}

fn operation_field_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("field_id")
        .or_else(|| operation.get("fieldId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires field_id".to_string())
}

fn operation_step_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("step_id")
        .or_else(|| operation.get("stepId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires step_id".to_string())
}

fn operation_param_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("param_id")
        .or_else(|| operation.get("paramId"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires param_id".to_string())
}

fn operation_instance_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("instance_id")
        .or_else(|| operation.get("instanceId"))
        .or_else(|| operation.get("id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires instance_id".to_string())
}

fn operation_edge_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("edge_id")
        .or_else(|| operation.get("edgeId"))
        .or_else(|| operation.get("id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires edge_id".to_string())
}

fn operation_cell_field<'a>(
    operation: &'a serde_json::Map<String, Value>,
    field: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
    operation.get(field).and_then(Value::as_object)
}

fn schema_index_by_id(schemas: &Value, schema_id: &str) -> Option<usize> {
    schemas
        .as_array()
        .and_then(|schemas| schema_index_in_slice(schemas, schema_id))
}

fn schema_index_in_slice(schemas: &[Value], schema_id: &str) -> Option<usize> {
    schemas.iter().position(|schema| {
        schema
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == schema_id)
    })
}

fn field_index_in_slice(fields: &[Value], field_id: &str) -> Option<usize> {
    fields.iter().position(|field| {
        field
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == field_id)
    })
}

fn schema_field_names(schemas: &Value, schema_id: &str) -> Vec<String> {
    schemas
        .as_array()
        .and_then(|schemas| {
            schemas.iter().find(|schema| {
                schema
                    .get("id")
                    .and_then(Value::as_str)
                    .is_some_and(|id| id == schema_id)
            })
        })
        .and_then(|schema| schema.get("fields").and_then(Value::as_array))
        .map(|fields| {
            fields
                .iter()
                .filter_map(|field| field.get("name").and_then(Value::as_str))
                .map(str::trim)
                .filter(|name| !name.is_empty())
                .map(ToString::to_string)
                .collect()
        })
        .unwrap_or_default()
}

fn update_schema_field(
    document: &mut MobpackDocument,
    schema_id: &str,
    field_id: &str,
    patch: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let schema_index = schema_index_in_slice(&schemas, schema_id)
        .ok_or_else(|| format!("schema not found: {schema_id}"))?;
    let fields = schemas[schema_index]
        .get_mut("fields")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| format!("schema fields is not an array: {schema_id}"))?;
    let field_index = field_index_in_slice(fields, field_id)
        .ok_or_else(|| format!("schema field not found: {field_id}"))?;
    let old_name = fields[field_index]
        .get("name")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    let normalized_patch =
        normalize_schema_field_update_patch(&fields[field_index], fields, field_id, patch)?;
    let field = fields[field_index]
        .as_object_mut()
        .ok_or_else(|| format!("schema field is not an object: {field_id}"))?;
    for (key, value) in normalized_patch {
        field.insert(key, value);
    }
    let new_name = field
        .get("name")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    document.schemas = Value::Array(schemas);
    if !old_name.is_empty() && old_name != new_name {
        rewrite_schema_field_references(document, schema_id, &old_name, &new_name);
    }
    Ok(json!({ "kind": "schema", "id": schema_id, "field_id": field_id }))
}

fn normalize_schema_field_update_patch(
    current_field: &Value,
    fields: &[Value],
    field_id: &str,
    patch: &serde_json::Map<String, Value>,
) -> Result<serde_json::Map<String, Value>, String> {
    let mut normalized = serde_json::Map::new();
    for (key, value) in patch {
        match key.as_str() {
            "id" => return Err("update_schema_field cannot change field id".to_string()),
            "name" => {
                let name = value
                    .as_str()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    .ok_or_else(|| {
                        "update_schema_field name must be a non-empty string".to_string()
                    })?
                    .to_string();
                let duplicate = fields.iter().any(|field| {
                    field.get("id").and_then(Value::as_str) != Some(field_id)
                        && field
                            .get("name")
                            .and_then(Value::as_str)
                            .map(str::trim)
                            .is_some_and(|candidate| candidate == name)
                });
                if duplicate {
                    return Err(format!("schema field name already exists: {name}"));
                }
                normalized.insert("name".to_string(), Value::String(name));
            }
            "type" => {
                let field_type = value
                    .as_str()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    .ok_or_else(|| {
                        "update_schema_field type must be a non-empty string".to_string()
                    })?
                    .to_string();
                if !is_editor_schema_field_type(&field_type) {
                    return Err(format!(
                        "unsupported update_schema_field type: {field_type}"
                    ));
                }
                normalized.insert("type".to_string(), Value::String(field_type));
            }
            "required" => {
                let required = value
                    .as_bool()
                    .ok_or_else(|| "update_schema_field required must be a boolean".to_string())?;
                normalized.insert("required".to_string(), Value::Bool(required));
            }
            "description" => {
                let description = value.as_str().ok_or_else(|| {
                    "update_schema_field description must be a string".to_string()
                })?;
                normalized.insert(
                    "description".to_string(),
                    Value::String(description.to_string()),
                );
            }
            "enumValues" | "enum_values" => {
                let values = normalized_schema_enum_values(value)?;
                normalized.insert("enumValues".to_string(), Value::Array(values));
            }
            other => {
                return Err(format!(
                    "unsupported update_schema_field patch field: {other}"
                ));
            }
        }
    }
    let next_type = normalized
        .get("type")
        .and_then(Value::as_str)
        .or_else(|| current_field.get("type").and_then(Value::as_str))
        .unwrap_or_default();
    if next_type != "enum"
        && (normalized.contains_key("type") || normalized.contains_key("enumValues"))
    {
        normalized.insert("enumValues".to_string(), Value::Array(Vec::new()));
    }
    Ok(normalized)
}

fn normalized_schema_enum_values(value: &Value) -> Result<Vec<Value>, String> {
    let Some(values) = value.as_array() else {
        return Err("update_schema_field enumValues must be an array".to_string());
    };
    let mut seen = BTreeSet::new();
    let mut normalized = Vec::new();
    for raw in values {
        let enum_value = raw
            .as_str()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| "update_schema_field enumValues must contain strings".to_string())?
            .to_string();
        if seen.insert(enum_value.clone()) {
            normalized.push(Value::String(enum_value));
        }
    }
    Ok(normalized)
}

fn member_identity_values(member: &Value) -> BTreeSet<String> {
    ["id", "name", "role"]
        .into_iter()
        .filter_map(|key| member.get(key).and_then(Value::as_str))
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn member_profile_value(member: &Value) -> String {
    ["name", "role", "id"]
        .into_iter()
        .filter_map(|key| member.get(key).and_then(Value::as_str))
        .map(str::trim)
        .find(|value| !value.is_empty())
        .unwrap_or_default()
        .to_string()
}

fn current_member_profiles(members: &Value) -> BTreeSet<String> {
    members
        .as_array()
        .into_iter()
        .flatten()
        .map(member_profile_value)
        .filter(|value| !value.is_empty())
        .collect()
}

fn prune_mob_settings_for_removed_member(settings: &mut Value, removed_names: &BTreeSet<String>) {
    let Some(object) = settings.as_object_mut() else {
        return;
    };
    for key in ["orchestrator", "orchestratorRole"] {
        let matches_removed = object
            .get(key)
            .and_then(Value::as_str)
            .is_some_and(|value| removed_names.contains(value.trim()));
        if matches_removed {
            object.insert(key.to_string(), Value::String(String::new()));
        }
    }
    if let Some(wiring) = object.get_mut("roleWiring").and_then(Value::as_array_mut) {
        wiring.retain(|row| {
            let a = row
                .get("a")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .trim();
            let b = row
                .get("b")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .trim();
            !removed_names.contains(a) && !removed_names.contains(b)
        });
    }
}

fn prune_mob_settings_for_current_members(document: &mut MobpackDocument) {
    let profiles = current_member_profiles(&document.members);
    let Some(object) = document.mob_settings.as_object_mut() else {
        return;
    };
    for key in ["orchestrator", "orchestratorRole"] {
        let invalid = object
            .get(key)
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_some_and(|value| !profiles.contains(value));
        if invalid {
            object.insert(key.to_string(), Value::String(String::new()));
        }
    }
    if let Some(wiring) = object.get_mut("roleWiring").and_then(Value::as_array_mut) {
        let mut seen = BTreeSet::new();
        wiring.retain(|row| {
            let a = row
                .get("a")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .trim();
            let b = row
                .get("b")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .trim();
            if a.is_empty() || b.is_empty() || !profiles.contains(a) || !profiles.contains(b) {
                return false;
            }
            seen.insert(format!("{a}\u{0}{b}"))
        });
    }
}

fn schema_ids(document: &MobpackDocument) -> BTreeSet<String> {
    document
        .schemas
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|schema| schema.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn available_member_tools() -> BTreeSet<String> {
    tool_catalog_response()
        .iter()
        .filter_map(|tool| tool.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn available_skill_ids(skill_realms: &Value) -> BTreeSet<String> {
    skill_realms
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|realm| realm.get("skills").and_then(Value::as_array))
        .flatten()
        .filter_map(|skill| skill.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn available_model_ids() -> BTreeSet<String> {
    meerkat_models::catalog()
        .iter()
        .map(|entry| entry.id.to_string())
        .filter(|id| !id.is_empty())
        .collect()
}

fn prune_string_list_field_for_set(member: &mut Value, field: &str, allowed: &BTreeSet<String>) {
    let Some(list) = member.get_mut(field).and_then(Value::as_array_mut) else {
        return;
    };
    list.retain(|value| {
        value
            .as_str()
            .map(str::trim)
            .is_some_and(|id| allowed.contains(id))
    });
}

fn prune_member_refs_for_contract(document: &mut MobpackDocument) {
    let schema_ids = schema_ids(document);
    let tool_ids = available_member_tools();
    let skill_ids = available_skill_ids(&document.skill_realms);
    let model_ids = available_model_ids();
    let Some(members) = document.members.as_array_mut() else {
        return;
    };
    for member in members {
        let Some(object) = member.as_object_mut() else {
            continue;
        };
        let invalid_schema = object
            .get("schema")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|schema| !schema.is_empty())
            .is_some_and(|schema| !schema_ids.contains(schema));
        if invalid_schema {
            object.insert("schema".to_string(), Value::String(String::new()));
        }
        let invalid_model = object
            .get("model")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|model| !model.is_empty())
            .is_some_and(|model| !model_ids.contains(model));
        if invalid_model {
            object.insert("model".to_string(), Value::String(String::new()));
        }
        prune_string_list_field_for_set(member, "tools", &tool_ids);
        prune_string_list_field_for_set(member, "skills", &skill_ids);
    }
}

fn rewrite_member_schema_references(members: &mut Value, old_schema: &str, new_schema: &str) {
    let Some(members) = members.as_array_mut() else {
        return;
    };
    for member in members {
        let current = member
            .get("schema")
            .and_then(Value::as_str)
            .unwrap_or_default();
        if current == old_schema {
            member["schema"] = Value::String(new_schema.to_string());
        }
    }
}

/// Keep adaptive layers' result-schema references consistent after a schema
/// rename or delete: a matching `resultSchema` remaps to the new id (clears
/// on delete), the schema analogue of `rewrite_member_schema_references`.
/// Applied to both the flow steps and the graph instances' embedded
/// `adaptive` payloads so `graph_derives_flow` equivalence keeps holding.
fn rewrite_adaptive_result_schema_references(
    document: &mut MobpackDocument,
    old_schema: &str,
    new_schema: &str,
) {
    if let Some(steps) = document.flow.get_mut("steps").and_then(Value::as_array_mut) {
        rewrite_adaptive_result_schema_in_steps(steps, old_schema, new_schema);
    }
    if let Some(instances) = document.instances.as_array_mut() {
        for instance in instances {
            if graph_to_flow_is_adaptive_node(instance)
                && let Some(payload) = instance.get_mut("adaptive")
            {
                rewrite_adaptive_result_schema_in_step(payload, old_schema, new_schema);
            }
        }
    }
}

fn rewrite_adaptive_result_schema_in_steps(
    steps: &mut [Value],
    old_schema: &str,
    new_schema: &str,
) {
    for step in steps {
        match step.get("type").and_then(Value::as_str) {
            Some("adaptive") => {
                rewrite_adaptive_result_schema_in_step(step, old_schema, new_schema);
            }
            Some("repeat") => {
                if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
                    rewrite_adaptive_result_schema_in_steps(nested, old_schema, new_schema);
                }
            }
            Some("branch" | "parallel") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            rewrite_adaptive_result_schema_in_steps(
                                branch_steps,
                                old_schema,
                                new_schema,
                            );
                        }
                    }
                }
                if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
                    rewrite_adaptive_result_schema_in_steps(fallback, old_schema, new_schema);
                }
            }
            _ => {}
        }
    }
}

fn rewrite_adaptive_result_schema_in_step(step: &mut Value, old_schema: &str, new_schema: &str) {
    let Some(object) = step.as_object_mut() else {
        return;
    };
    if object
        .get("resultSchema")
        .and_then(Value::as_str)
        .map(str::trim)
        == Some(old_schema)
    {
        object.insert(
            "resultSchema".to_string(),
            Value::String(new_schema.to_string()),
        );
    }
}

fn member_schema_index(members: &Value) -> BTreeMap<String, String> {
    members
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|member| {
            let id = member.get("id").and_then(Value::as_str)?.trim();
            let schema = member
                .get("schema")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .trim();
            if id.is_empty() {
                None
            } else {
                Some((id.to_string(), schema.to_string()))
            }
        })
        .collect()
}

fn graph_step_schema_index(
    instances: &Value,
    member_schemas: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
    instances
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|instance| {
            let id = instance.get("id").and_then(Value::as_str)?.trim();
            let member_id = instance
                .get("memberId")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .trim();
            let schema = member_schemas.get(member_id).cloned().unwrap_or_default();
            if id.is_empty() {
                None
            } else {
                Some((id.to_string(), schema))
            }
        })
        .collect()
}

fn flow_step_schema_index(
    flow: &Value,
    member_schemas: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_flow_step_schemas(steps, member_schemas, &mut out);
    }
    out
}

fn collect_flow_step_schemas(
    steps: &[Value],
    member_schemas: &BTreeMap<String, String>,
    out: &mut BTreeMap<String, String>,
) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("member")
            && let Some(step_id) = step.get("id").and_then(Value::as_str)
        {
            let role = step.get("role").and_then(Value::as_str).unwrap_or_default();
            out.insert(
                step_id.to_string(),
                member_schemas.get(role).cloned().unwrap_or_default(),
            );
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_flow_step_schemas(nested, member_schemas, out);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_flow_step_schemas(branch_steps, member_schemas, out);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_flow_step_schemas(fallback, member_schemas, out);
        }
    }
}

fn rewrite_schema_field_references(
    document: &mut MobpackDocument,
    schema_id: &str,
    old_name: &str,
    new_name: &str,
) {
    let member_schemas = member_schema_index(&document.members);
    let flow_step_schemas = flow_step_schema_index(&document.flow, &member_schemas);
    if let Some(steps) = document.flow.get_mut("steps").and_then(Value::as_array_mut) {
        rewrite_schema_field_references_in_steps(
            steps,
            &flow_step_schemas,
            schema_id,
            old_name,
            new_name,
        );
    }
    let graph_step_schemas = graph_step_schema_index(&document.instances, &member_schemas);
    rewrite_schema_field_references_in_edges(
        &mut document.edges,
        &graph_step_schemas,
        schema_id,
        old_name,
        new_name,
    );
}

fn rewrite_schema_field_references_in_steps(
    steps: &mut [Value],
    step_schemas: &BTreeMap<String, String>,
    schema_id: &str,
    old_name: &str,
    new_name: &str,
) {
    for step in steps {
        match step.get("type").and_then(Value::as_str) {
            Some("repeat") => {
                if let Some(cond) = step.get_mut("cond") {
                    rewrite_editor_condition(cond, step_schemas, schema_id, old_name, new_name);
                }
                if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
                    rewrite_schema_field_references_in_steps(
                        nested,
                        step_schemas,
                        schema_id,
                        old_name,
                        new_name,
                    );
                }
            }
            Some("branch") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(cond) = branch.get_mut("cond") {
                            rewrite_editor_condition(
                                cond,
                                step_schemas,
                                schema_id,
                                old_name,
                                new_name,
                            );
                        }
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            rewrite_schema_field_references_in_steps(
                                branch_steps,
                                step_schemas,
                                schema_id,
                                old_name,
                                new_name,
                            );
                        }
                    }
                }
                if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
                    rewrite_schema_field_references_in_steps(
                        fallback,
                        step_schemas,
                        schema_id,
                        old_name,
                        new_name,
                    );
                }
            }
            Some("parallel") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            rewrite_schema_field_references_in_steps(
                                branch_steps,
                                step_schemas,
                                schema_id,
                                old_name,
                                new_name,
                            );
                        }
                    }
                }
            }
            _ => {}
        }
    }
}

fn rewrite_editor_condition(
    cond: &mut Value,
    step_schemas: &BTreeMap<String, String>,
    schema_id: &str,
    old_name: &str,
    new_name: &str,
) {
    let Some(object) = cond.as_object_mut() else {
        return;
    };
    let step_id = object
        .get("stepId")
        .or_else(|| object.get("step_id"))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    let field = object
        .get("field")
        .and_then(Value::as_str)
        .unwrap_or_default();
    if step_schemas
        .get(&step_id)
        .is_some_and(|schema| schema == schema_id)
        && field == old_name
    {
        if new_name.is_empty() {
            *cond = json!({});
        } else {
            object.insert("field".to_string(), Value::String(new_name.to_string()));
            object.insert("stepId".to_string(), Value::String(step_id));
        }
    }
}

fn rewrite_schema_field_references_in_edges(
    edges: &mut Value,
    step_schemas: &BTreeMap<String, String>,
    schema_id: &str,
    old_name: &str,
    new_name: &str,
) {
    let Some(edges) = edges.as_array_mut() else {
        return;
    };
    for edge in edges {
        let Some(cond) = edge.get_mut("cond") else {
            continue;
        };
        let Some(cond_object) = cond.as_object_mut() else {
            continue;
        };
        let path = cond_object
            .get("var")
            .or_else(|| cond_object.get("path"))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
        let mut parts = path.split('.');
        let namespace = parts.next().unwrap_or_default();
        let step_id = parts.next().unwrap_or_default();
        let field = parts.next().unwrap_or_default();
        if namespace != "steps"
            || parts.next().is_some()
            || step_schemas
                .get(step_id)
                .is_none_or(|schema| schema != schema_id)
            || field != old_name
        {
            continue;
        }
        if new_name.is_empty() {
            edge["cond"] = Value::Null;
            edge["label"] = Value::String(String::new());
        } else {
            let next_path = format!("steps.{step_id}.{new_name}");
            cond_object.insert("var".to_string(), Value::String(next_path));
        }
    }
}

fn input_param_field_names(flow: &Value) -> BTreeSet<String> {
    let mut out = BTreeSet::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_input_param_field_names(steps, &mut out);
    }
    out
}

fn collect_input_param_field_names(steps: &[Value], out: &mut BTreeSet<String>) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("input")
            && let Some(params) = step.get("inputParams").and_then(Value::as_array)
        {
            out.extend(
                params
                    .iter()
                    .filter_map(|param| param.get("name").and_then(Value::as_str))
                    .map(str::trim)
                    .filter(|name| !name.is_empty())
                    .map(ToString::to_string),
            );
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_input_param_field_names(nested, out);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_input_param_field_names(branch_steps, out);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_input_param_field_names(fallback, out);
        }
    }
}

fn schema_field_name_set(schemas: &Value, schema_id: &str) -> BTreeSet<String> {
    schemas
        .as_array()
        .into_iter()
        .flatten()
        .find(|schema| {
            schema
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| id == schema_id)
        })
        .and_then(|schema| schema.get("fields").and_then(Value::as_array))
        .into_iter()
        .flatten()
        .filter_map(|field| field.get("name").and_then(Value::as_str))
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn condition_field_available(
    namespace: &str,
    source_id: &str,
    field: &str,
    step_schemas: &BTreeMap<String, String>,
    schemas: &Value,
    input_params: &BTreeSet<String>,
) -> bool {
    if field.is_empty() {
        return true;
    }
    if namespace == "params" || source_id == "params" {
        return input_params.contains(field);
    }
    let schema_id = step_schemas.get(source_id).cloned().unwrap_or_default();
    if schema_id.is_empty() {
        return false;
    }
    schema_field_name_set(schemas, &schema_id).contains(field)
}

fn reconcile_editor_condition(
    cond: &mut Value,
    step_schemas: &BTreeMap<String, String>,
    schemas: &Value,
    input_params: &BTreeSet<String>,
) {
    let Some(object) = cond.as_object() else {
        return;
    };
    let step_id = object
        .get("stepId")
        .or_else(|| object.get("step_id"))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .trim();
    let field = object
        .get("field")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .trim();
    if !step_id.is_empty()
        && !condition_field_available("", step_id, field, step_schemas, schemas, input_params)
    {
        *cond = json!({});
    }
}

fn reconcile_editor_conditions_in_steps(
    steps: &mut [Value],
    step_schemas: &BTreeMap<String, String>,
    schemas: &Value,
    input_params: &BTreeSet<String>,
) {
    for step in steps {
        match step.get("type").and_then(Value::as_str) {
            Some("repeat") => {
                if let Some(cond) = step.get_mut("cond") {
                    reconcile_editor_condition(cond, step_schemas, schemas, input_params);
                }
                if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
                    reconcile_editor_conditions_in_steps(
                        nested,
                        step_schemas,
                        schemas,
                        input_params,
                    );
                }
            }
            Some("branch") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(cond) = branch.get_mut("cond") {
                            reconcile_editor_condition(cond, step_schemas, schemas, input_params);
                        }
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            reconcile_editor_conditions_in_steps(
                                branch_steps,
                                step_schemas,
                                schemas,
                                input_params,
                            );
                        }
                    }
                }
                if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
                    reconcile_editor_conditions_in_steps(
                        fallback,
                        step_schemas,
                        schemas,
                        input_params,
                    );
                }
            }
            Some("parallel") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            reconcile_editor_conditions_in_steps(
                                branch_steps,
                                step_schemas,
                                schemas,
                                input_params,
                            );
                        }
                    }
                }
            }
            _ => {}
        }
    }
}

fn reconcile_condition_edges(
    edges: &mut Value,
    step_schemas: &BTreeMap<String, String>,
    schemas: &Value,
    input_params: &BTreeSet<String>,
) {
    let Some(edges) = edges.as_array_mut() else {
        return;
    };
    for edge in edges {
        let Some(cond) = edge.get("cond") else {
            continue;
        };
        let Some(cond_object) = cond.as_object() else {
            continue;
        };
        let path = cond_object
            .get("var")
            .or_else(|| cond_object.get("path"))
            .and_then(Value::as_str)
            .unwrap_or_default();
        let mut parts = path.split('.');
        let namespace = parts.next().unwrap_or_default();
        let source_id = parts.next().unwrap_or_default();
        let field = parts.next().unwrap_or_default();
        if namespace.is_empty() || source_id.is_empty() || parts.next().is_some() {
            continue;
        }
        if !condition_field_available(
            namespace,
            source_id,
            field,
            step_schemas,
            schemas,
            input_params,
        ) {
            edge["cond"] = Value::Null;
            edge["label"] = Value::String(String::new());
        }
    }
}

fn reconcile_condition_field_availability(document: &mut MobpackDocument) {
    let member_schemas = member_schema_index(&document.members);
    let input_params = input_param_field_names(&document.flow);
    let flow_step_schemas = flow_step_schema_index(&document.flow, &member_schemas);
    if let Some(steps) = document.flow.get_mut("steps").and_then(Value::as_array_mut) {
        reconcile_editor_conditions_in_steps(
            steps,
            &flow_step_schemas,
            &document.schemas,
            &input_params,
        );
    }
    let graph_step_schemas = graph_step_schema_index(&document.instances, &member_schemas);
    reconcile_condition_edges(
        &mut document.edges,
        &graph_step_schemas,
        &document.schemas,
        &input_params,
    );
}

fn flow_step_mut_by_id<'a>(flow: &'a mut Value, step_id: &str) -> Option<&'a mut Value> {
    let path = {
        let steps = flow.get("steps")?.as_array()?;
        let mut path = Vec::new();
        if !find_step_path_in_steps(steps, step_id, &mut path) {
            return None;
        }
        path
    };
    let steps = flow.get_mut("steps")?.as_array_mut()?;
    step_mut_by_path(steps, &path)
}

#[derive(Clone, Copy)]
enum StepPathSegment {
    Index(usize),
    Steps,
    Branch(usize),
    Fallback,
}

fn find_step_path_in_steps(
    steps: &[Value],
    step_id: &str,
    path: &mut Vec<StepPathSegment>,
) -> bool {
    for (index, step) in steps.iter().enumerate() {
        path.push(StepPathSegment::Index(index));
        if step
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == step_id)
        {
            return true;
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            path.push(StepPathSegment::Steps);
            if find_step_path_in_steps(nested, step_id, path) {
                return true;
            }
            path.pop();
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    path.push(StepPathSegment::Branch(branch_index));
                    path.push(StepPathSegment::Steps);
                    if find_step_path_in_steps(branch_steps, step_id, path) {
                        return true;
                    }
                    path.pop();
                    path.pop();
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            path.push(StepPathSegment::Fallback);
            if find_step_path_in_steps(fallback, step_id, path) {
                return true;
            }
            path.pop();
        }
        path.pop();
    }
    false
}

fn step_mut_by_path<'a>(steps: &'a mut [Value], path: &[StepPathSegment]) -> Option<&'a mut Value> {
    let (StepPathSegment::Index(index), rest) = path.split_first()? else {
        return None;
    };
    let step = steps.get_mut(*index)?;
    let Some((segment, tail)) = rest.split_first() else {
        return Some(step);
    };
    match segment {
        StepPathSegment::Steps => {
            let nested = step.get_mut("steps")?.as_array_mut()?;
            step_mut_by_path(nested, tail)
        }
        StepPathSegment::Branch(branch_index) => {
            let (StepPathSegment::Steps, branch_tail) = tail.split_first()? else {
                return None;
            };
            let branches = step.get_mut("branches")?.as_array_mut()?;
            let branch_steps = branches
                .get_mut(*branch_index)?
                .get_mut("steps")?
                .as_array_mut()?;
            step_mut_by_path(branch_steps, branch_tail)
        }
        StepPathSegment::Fallback => {
            let fallback = step.get_mut("fallback")?.as_array_mut()?;
            step_mut_by_path(fallback, tail)
        }
        StepPathSegment::Index(_) => None,
    }
}

fn flow_step_ids(flow: &Value) -> BTreeSet<String> {
    let mut ids = BTreeSet::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_flow_step_ids_in_steps(steps, &mut ids);
    }
    ids
}

fn next_flow_step_id(flow: &Value, prefix: &str) -> String {
    let stem = prefix
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() {
                ch.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect::<String>()
        .split('_')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("_");
    let stem = if stem.is_empty() { "s" } else { stem.as_str() };
    let used = flow_step_ids(flow);
    let mut index = 1;
    loop {
        let id = format!("{stem}_{index}");
        if !used.contains(&id) {
            return id;
        }
        index += 1;
    }
}

fn collect_flow_branch_ids_in_steps(steps: &[Value], ids: &mut BTreeSet<String>) {
    for step in steps {
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(id) = branch
                    .get("id")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|id| !id.is_empty())
                {
                    ids.insert(id.to_string());
                }
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_flow_branch_ids_in_steps(branch_steps, ids);
                }
            }
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_flow_branch_ids_in_steps(nested, ids);
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_flow_branch_ids_in_steps(fallback, ids);
        }
    }
}

fn next_flow_branch_id(flow: &Value, ids: &mut BTreeSet<String>) -> String {
    if ids.is_empty()
        && let Some(steps) = flow.get("steps").and_then(Value::as_array)
    {
        collect_flow_branch_ids_in_steps(steps, ids);
    }
    let mut index = 1;
    loop {
        let id = format!("br_{index}");
        if ids.insert(id.clone()) {
            return id;
        }
        index += 1;
    }
}

fn flow_step_from_pick(
    document: &MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let pick = operation
        .get("pick")
        .and_then(Value::as_object)
        .ok_or_else(|| "insert_flow_step requires step object or pick object".to_string())?;
    let kind = pick
        .get("kind")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "insert_flow_step pick.kind is required".to_string())?;
    let id = next_flow_step_id(&document.flow, "s");
    let dependency_mode = "all";
    match kind {
        "member" => {
            let role = pick
                .get("id")
                .or_else(|| pick.get("member_id"))
                .or_else(|| pick.get("memberId"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .ok_or_else(|| "insert_flow_step member pick requires id".to_string())?;
            if !graph_member_ids(&document.members).contains(role) {
                return Err(format!(
                    "member flow step must reference an existing member: {role}"
                ));
            }
            Ok(json!({
                "id": id,
                "type": "member",
                "role": role,
                "instruction": "",
                "dependsMode": dependency_mode,
            }))
        }
        "branch" => {
            let mut branch_ids = BTreeSet::new();
            let branch_id = next_flow_branch_id(&document.flow, &mut branch_ids);
            Ok(json!({
                "id": id,
                "type": "branch",
                "controllerRole": "",
                "branches": [{
                    "id": branch_id,
                    "label": "Branch 1",
                    "condition": "",
                    "steps": []
                }],
                "fallback": [],
                "dependsMode": dependency_mode,
            }))
        }
        "parallel" => {
            let mut branch_ids = BTreeSet::new();
            let first = next_flow_branch_id(&document.flow, &mut branch_ids);
            let second = next_flow_branch_id(&document.flow, &mut branch_ids);
            Ok(json!({
                "id": id,
                "type": "parallel",
                "controllerRole": "",
                "dispatch": "fan_out",
                "collection": "all",
                "branches": [
                    { "id": first, "label": "Branch 1", "steps": [] },
                    { "id": second, "label": "Branch 2", "steps": [] }
                ],
                "dependsMode": dependency_mode,
            }))
        }
        "repeat" => Ok(json!({
            "id": id,
            "type": "repeat",
            "loopId": "",
            "until": "",
            "maxIterations": Value::Null,
            "iterationInput": "",
            "steps": []
        })),
        "adaptive" => {
            if !adaptive_flow_steps(&document.flow).is_empty() {
                return Err("adaptive layer already present".to_string());
            }
            let mut step = editor_adaptive_step_defaults();
            step["id"] = json!(id);
            Ok(step)
        }
        other if EDITOR_FLOW_STEP_TYPES.contains(&other) => {
            Err(format!("unsupported insert_flow_step pick.kind: {other}"))
        }
        other => Err(format!("unsupported insert_flow_step pick.kind: {other}")),
    }
}

fn collect_flow_step_ids_in_steps(steps: &[Value], ids: &mut BTreeSet<String>) {
    for step in steps {
        if let Some(id) = step
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|id| !id.is_empty())
        {
            ids.insert(id.to_string());
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_flow_step_ids_in_steps(nested, ids);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_flow_step_ids_in_steps(branch_steps, ids);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_flow_step_ids_in_steps(fallback, ids);
        }
    }
}

fn validate_flow_step(
    step: &Value,
    flow: &Value,
    members: &Value,
    current_id: Option<&str>,
) -> Result<String, String> {
    let id = step
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .ok_or_else(|| "flow step requires id".to_string())?
        .to_string();
    if let Some(current_id) = current_id
        && id != current_id
    {
        return Err("update_flow_step cannot change step id".to_string());
    }
    let ids = flow_step_ids(flow);
    if ids.contains(&id) && current_id.is_none_or(|current_id| current_id != id) {
        return Err(format!("flow step already exists: {id}"));
    }
    if step.get("type").and_then(Value::as_str) == Some("member") {
        let role = step
            .get("role")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|role| !role.is_empty())
            .ok_or_else(|| "member flow step must reference a member".to_string())?;
        let members = graph_member_ids(members);
        if !members.contains(role) {
            return Err(format!(
                "member flow step must reference an existing member: {role}"
            ));
        }
    }
    if step.get("type").and_then(Value::as_str) == Some("adaptive")
        && adaptive_flow_steps(flow).iter().any(|(_, existing)| {
            existing
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .is_some_and(|existing_id| existing_id != id)
        })
    {
        return Err("adaptive layer already present".to_string());
    }
    Ok(id)
}

fn flow_steps_array_mut(flow: &mut Value) -> Result<&mut Vec<Value>, String> {
    if !flow.is_object() {
        *flow = json!({ "steps": [] });
    }
    if flow.get("steps").is_none() {
        flow["steps"] = Value::Array(Vec::new());
    }
    flow.get_mut("steps")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| "flow.steps is not an array".to_string())
}

fn lane_ref_string(lane_ref: &serde_json::Map<String, Value>, field: &str) -> String {
    lane_ref
        .get(field)
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

fn lane_ref_index(lane_ref: &serde_json::Map<String, Value>, len: usize) -> usize {
    lane_ref
        .get("index")
        .and_then(Value::as_u64)
        .map(|index| (index as usize).min(len))
        .unwrap_or(len)
}

fn insert_flow_step_into_lane(
    steps: &mut Vec<Value>,
    lane_ref: &serde_json::Map<String, Value>,
    new_step: &Value,
) -> bool {
    if lane_ref_string(lane_ref, "lane") == "main" {
        let index = lane_ref_index(lane_ref, steps.len());
        steps.insert(index, new_step.clone());
        return true;
    }
    let parent_id = lane_ref_string(lane_ref, "parentId");
    let branch_id = lane_ref_string(lane_ref, "branchId");
    for step in steps {
        if step
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == parent_id)
        {
            return insert_flow_step_into_parent_lane(step, &branch_id, lane_ref, new_step);
        }
        if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut)
            && insert_flow_step_into_lane(nested, lane_ref, new_step)
        {
            return true;
        }
        if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
            for branch in branches {
                if let Some(branch_steps) = branch.get_mut("steps").and_then(Value::as_array_mut)
                    && insert_flow_step_into_lane(branch_steps, lane_ref, new_step)
                {
                    return true;
                }
            }
        }
        if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut)
            && insert_flow_step_into_lane(fallback, lane_ref, new_step)
        {
            return true;
        }
    }
    false
}

fn insert_flow_step_into_parent_lane(
    parent: &mut Value,
    branch_id: &str,
    lane_ref: &serde_json::Map<String, Value>,
    new_step: &Value,
) -> bool {
    if branch_id == "body" {
        if !parent.get("steps").is_some_and(Value::is_array) {
            parent["steps"] = Value::Array(Vec::new());
        }
        if let Some(steps) = parent.get_mut("steps").and_then(Value::as_array_mut) {
            let index = lane_ref_index(lane_ref, steps.len());
            steps.insert(index, new_step.clone());
            return true;
        }
    }
    if branch_id == "fallback" {
        if !parent.get("fallback").is_some_and(Value::is_array) {
            parent["fallback"] = Value::Array(Vec::new());
        }
        if let Some(steps) = parent.get_mut("fallback").and_then(Value::as_array_mut) {
            let index = lane_ref_index(lane_ref, steps.len());
            steps.insert(index, new_step.clone());
            return true;
        }
    }
    if let Some(branches) = parent.get_mut("branches").and_then(Value::as_array_mut) {
        for branch in branches {
            if branch
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| id == branch_id)
            {
                if !branch.get("steps").is_some_and(Value::is_array) {
                    branch["steps"] = Value::Array(Vec::new());
                }
                if let Some(steps) = branch.get_mut("steps").and_then(Value::as_array_mut) {
                    let index = lane_ref_index(lane_ref, steps.len());
                    steps.insert(index, new_step.clone());
                    return true;
                }
            }
        }
    }
    false
}

fn remove_flow_step_from_steps(steps: &mut Vec<Value>, step_id: &str) -> bool {
    let before = steps.len();
    steps.retain(|step| {
        step.get("id")
            .and_then(Value::as_str)
            .is_none_or(|id| id != step_id)
    });
    let mut removed = steps.len() != before;
    for step in steps {
        if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
            removed |= remove_flow_step_from_steps(nested, step_id);
        }
        if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
            for branch in branches {
                if let Some(branch_steps) = branch.get_mut("steps").and_then(Value::as_array_mut) {
                    removed |= remove_flow_step_from_steps(branch_steps, step_id);
                }
            }
        }
        if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
            removed |= remove_flow_step_from_steps(fallback, step_id);
        }
    }
    removed
}

fn clear_deleted_flow_step_references(flow: &mut Value, deleted_id: &str) {
    let Some(steps) = flow.get_mut("steps").and_then(Value::as_array_mut) else {
        return;
    };
    clear_deleted_flow_step_references_in_steps(steps, deleted_id);
}

fn clear_deleted_flow_step_references_in_steps(steps: &mut [Value], deleted_id: &str) {
    for step in steps {
        clear_deleted_launch_source(step, deleted_id);
        match step.get("type").and_then(Value::as_str) {
            Some("repeat") => {
                if let Some(cond) = step.get_mut("cond") {
                    clear_deleted_flow_step_condition(cond, deleted_id);
                }
                if let Some(until) = step.get_mut("until") {
                    clear_deleted_flow_step_condition_text(until, deleted_id);
                }
                if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
                    clear_deleted_flow_step_references_in_steps(nested, deleted_id);
                }
            }
            Some("branch") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(cond) = branch.get_mut("cond") {
                            clear_deleted_flow_step_condition(cond, deleted_id);
                        }
                        if let Some(condition) = branch.get_mut("condition") {
                            clear_deleted_flow_step_condition_text(condition, deleted_id);
                        }
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            clear_deleted_flow_step_references_in_steps(branch_steps, deleted_id);
                        }
                    }
                }
                if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
                    clear_deleted_flow_step_references_in_steps(fallback, deleted_id);
                }
            }
            Some("parallel") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            clear_deleted_flow_step_references_in_steps(branch_steps, deleted_id);
                        }
                    }
                }
            }
            _ => {}
        }
    }
}

fn clear_deleted_flow_step_condition(cond: &mut Value, deleted_id: &str) {
    let Some(object) = cond.as_object() else {
        return;
    };
    let step_id = object
        .get("stepId")
        .or_else(|| object.get("step_id"))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .trim();
    if step_id == deleted_id {
        *cond = json!({});
    }
}

fn clear_deleted_flow_step_condition_text(text: &mut Value, deleted_id: &str) {
    let Some(raw) = text.as_str() else {
        return;
    };
    if raw.contains(&format!("steps.{deleted_id}."))
        || raw.trim_start().starts_with(&format!("{deleted_id}."))
    {
        *text = Value::String(String::new());
    }
}

fn apply_insert_flow_step_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step = operation
        .get("step")
        .cloned()
        .map(Ok)
        .unwrap_or_else(|| flow_step_from_pick(document, operation))?;
    let lane_ref = operation
        .get("lane_ref")
        .or_else(|| operation.get("laneRef"))
        .and_then(Value::as_object)
        .ok_or_else(|| "insert_flow_step requires lane_ref object".to_string())?;
    let step_id = validate_flow_step(&step, &document.flow, &document.members, None)?;
    let steps = flow_steps_array_mut(&mut document.flow)?;
    if !insert_flow_step_into_lane(steps, lane_ref, &step) {
        return Err("insert_flow_step lane_ref did not match a flow lane".to_string());
    }
    reconcile_flow_step_launch_modes(document);
    reproject_graph_for_flow_change(document);
    Ok(json!({ "kind": "step", "id": step_id }))
}

fn apply_update_flow_step_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step_id = operation_step_id(operation)?;
    let patch = operation
        .get("patch")
        .and_then(Value::as_object)
        .ok_or_else(|| "update_flow_step requires patch object".to_string())?;
    let current = flow_step_mut_by_id(&mut document.flow, &step_id)
        .ok_or_else(|| format!("flow step not found: {step_id}"))?
        .clone();
    let mut next_step = current;
    let normalized_patch = normalize_flow_step_update_patch(&next_step, &document.members, patch)?;
    let object = next_step
        .as_object_mut()
        .ok_or_else(|| format!("flow step is not an object: {step_id}"))?;
    for (key, value) in normalized_patch {
        object.insert(key, value);
    }
    validate_flow_step(
        &next_step,
        &document.flow,
        &document.members,
        Some(&step_id),
    )?;
    *flow_step_mut_by_id(&mut document.flow, &step_id)
        .ok_or_else(|| format!("flow step not found: {step_id}"))? = next_step;
    reconcile_flow_step_launch_modes(document);
    reproject_graph_for_flow_change(document);
    Ok(json!({ "kind": "step", "id": step_id }))
}

fn normalize_flow_step_update_patch(
    current_step: &Value,
    members: &Value,
    patch: &serde_json::Map<String, Value>,
) -> Result<serde_json::Map<String, Value>, String> {
    let mut normalized = serde_json::Map::new();
    let step_type = current_step
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or_default();
    for (key, value) in patch {
        match key.as_str() {
            "id" => return Err("update_flow_step cannot change step id".to_string()),
            "type" => return Err("update_flow_step cannot change step type".to_string()),
            "task" | "instruction" | "loopId" => {
                let text = value
                    .as_str()
                    .ok_or_else(|| format!("update_flow_step {key} must be a string"))?;
                normalized.insert(key.clone(), Value::String(text.to_string()));
            }
            // Free text on the per-keystroke echo path: never trimmed.
            "prompt" | "objectiveClass" => {
                if step_type != "adaptive" {
                    return Err(format!(
                        "update_flow_step {key} patch requires adaptive step"
                    ));
                }
                let text = value
                    .as_str()
                    .ok_or_else(|| format!("update_flow_step {key} must be a string"))?;
                normalized.insert(key.clone(), Value::String(text.to_string()));
            }
            "flowmasterId" => {
                if step_type != "adaptive" {
                    return Err(format!(
                        "update_flow_step {key} patch requires adaptive step"
                    ));
                }
                let member_id = value
                    .as_str()
                    .ok_or_else(|| format!("update_flow_step {key} must be a string"))?
                    .trim()
                    .to_string();
                if !member_id.is_empty() && !graph_member_ids(members).contains(&member_id) {
                    return Err(format!(
                        "update_flow_step {key} must reference an existing member: {member_id}"
                    ));
                }
                normalized.insert(key.clone(), Value::String(member_id));
            }
            "resultSchema" => {
                if step_type != "adaptive" {
                    return Err(format!(
                        "update_flow_step {key} patch requires adaptive step"
                    ));
                }
                let schema_id = value
                    .as_str()
                    .ok_or_else(|| format!("update_flow_step {key} must be a string"))?
                    .trim()
                    .to_string();
                normalized.insert(key.clone(), Value::String(schema_id));
            }
            "profileTemplateIds" => {
                if step_type != "adaptive" {
                    return Err(format!(
                        "update_flow_step {key} patch requires adaptive step"
                    ));
                }
                let items = value
                    .as_array()
                    .ok_or_else(|| format!("update_flow_step {key} must be a string array"))?;
                let member_ids = graph_member_ids(members);
                let mut seen = BTreeSet::new();
                let mut out = Vec::new();
                for item in items {
                    let member_id = item
                        .as_str()
                        .ok_or_else(|| format!("update_flow_step {key} entries must be strings"))?
                        .trim();
                    if member_id.is_empty() || !seen.insert(member_id.to_string()) {
                        continue;
                    }
                    if !member_ids.contains(member_id) {
                        return Err(format!(
                            "update_flow_step {key} must reference existing members: {member_id}"
                        ));
                    }
                    out.push(Value::String(member_id.to_string()));
                }
                normalized.insert(key.clone(), Value::Array(out));
            }
            "targetSurfaces" => {
                if step_type != "adaptive" {
                    return Err(format!(
                        "update_flow_step {key} patch requires adaptive step"
                    ));
                }
                let items = value
                    .as_array()
                    .ok_or_else(|| format!("update_flow_step {key} must be a string array"))?;
                let mut surfaces = BTreeSet::new();
                for item in items {
                    let surface = item
                        .as_str()
                        .ok_or_else(|| format!("update_flow_step {key} entries must be strings"))?
                        .trim();
                    if surface.is_empty() {
                        continue;
                    }
                    if !EDITOR_ADAPTIVE_TARGET_SURFACES.contains(&surface) {
                        return Err(format!("unsupported adaptive target surface: {surface}"));
                    }
                    surfaces.insert(surface.to_string());
                }
                normalized.insert(
                    key.clone(),
                    Value::Array(surfaces.into_iter().map(Value::String).collect()),
                );
            }
            "allowInlineProfiles" => {
                if step_type != "adaptive" {
                    return Err(format!(
                        "update_flow_step {key} patch requires adaptive step"
                    ));
                }
                let allow = value
                    .as_bool()
                    .ok_or_else(|| format!("update_flow_step {key} must be a boolean"))?;
                normalized.insert(key.clone(), Value::Bool(allow));
            }
            "limits" => {
                if step_type != "adaptive" {
                    return Err(format!(
                        "update_flow_step {key} patch requires adaptive step"
                    ));
                }
                let patch_limits = value
                    .as_object()
                    .ok_or_else(|| format!("update_flow_step {key} must be an object"))?;
                for limit_key in patch_limits.keys() {
                    if !EDITOR_ADAPTIVE_LIMITS
                        .iter()
                        .any(|(camel_key, _, _, _, _)| camel_key == limit_key)
                    {
                        return Err(format!("unknown adaptive limit: {limit_key}"));
                    }
                }
                let current_limits = current_step
                    .get("limits")
                    .and_then(Value::as_object)
                    .cloned()
                    .unwrap_or_default();
                let mut limits = serde_json::Map::new();
                for (camel_key, _, default, _, _) in EDITOR_ADAPTIVE_LIMITS {
                    let candidate = patch_limits
                        .get(*camel_key)
                        .or_else(|| current_limits.get(*camel_key));
                    let parsed = match candidate {
                        Some(candidate) => parse_positive_integer_value(
                            candidate,
                            &format!("limits.{camel_key}"),
                            "update_flow_step",
                        )?
                        .ok_or_else(|| {
                            format!(
                                "update_flow_step limits.{camel_key} must be a positive integer"
                            )
                        })?,
                        None => *default,
                    };
                    limits.insert((*camel_key).to_string(), Value::from(parsed));
                }
                normalized.insert(key.clone(), Value::Object(limits));
            }
            "quorum" | "timeoutMs" | "maxIterations" => {
                normalized.insert(key.clone(), positive_integer_or_null(value, key)?);
            }
            "cond" => {
                if !value.is_null() && !value.is_object() {
                    return Err("update_flow_step cond must be an object or null".to_string());
                }
                normalized.insert(key.clone(), value.clone());
            }
            "branches" => {
                if step_type != "branch" && step_type != "parallel" {
                    return Err(
                        "update_flow_step branches patch requires branch or parallel step"
                            .to_string(),
                    );
                }
                if !value.is_array() {
                    return Err("update_flow_step branches must be an array".to_string());
                }
                normalized.insert(key.clone(), value.clone());
            }
            "fallback" => {
                if step_type != "branch" {
                    return Err("update_flow_step fallback patch requires branch step".to_string());
                }
                if !value.is_array() {
                    return Err("update_flow_step fallback must be an array".to_string());
                }
                normalized.insert(key.clone(), value.clone());
            }
            "iterationInput" => {
                let value = value
                    .as_str()
                    .ok_or_else(|| "update_flow_step iterationInput must be a string".to_string())?
                    .trim()
                    .to_string();
                if !value.is_empty() && !REPEAT_ITERATION_INPUTS.contains(&value.as_str()) {
                    return Err(format!("unsupported repeat iteration input: {value}"));
                }
                normalized.insert(key.clone(), Value::String(value));
            }
            "controllerRole" | "role" => {
                let member_id = value
                    .as_str()
                    .ok_or_else(|| format!("update_flow_step {key} must be a string"))?
                    .trim()
                    .to_string();
                if !member_id.is_empty() && !graph_member_ids(members).contains(&member_id) {
                    return Err(format!(
                        "update_flow_step {key} must reference an existing member: {member_id}"
                    ));
                }
                normalized.insert(key.clone(), Value::String(member_id));
            }
            "dispatchMode" | "dispatch" => {
                normalized.insert(
                    key.clone(),
                    allowed_flow_option_value(value, "dispatch mode", &dispatch_mode_values())?,
                );
            }
            "collection" => {
                normalized.insert(
                    key.clone(),
                    allowed_flow_option_value(
                        value,
                        "collection policy",
                        &collection_policy_values(),
                    )?,
                );
            }
            "dependsMode" => {
                normalized.insert(
                    key.clone(),
                    allowed_flow_option_value(value, "dependency mode", &dependency_mode_values())?,
                );
            }
            "outputFormat" => {
                normalized.insert(
                    key.clone(),
                    allowed_flow_option_value(
                        value,
                        "step output format",
                        &step_output_format_values(),
                    )?,
                );
            }
            "allowedTools" => {
                normalized.insert(
                    key.clone(),
                    Value::Array(normalize_flow_step_tool_scope(
                        value,
                        &member_tool_scope_for_step(current_step, members),
                        "allowedTools",
                    )?),
                );
            }
            "blockedTools" => {
                normalized.insert(
                    key.clone(),
                    Value::Array(normalize_flow_step_tool_scope(
                        value,
                        &tool_catalog_id_set(),
                        "blockedTools",
                    )?),
                );
            }
            other => {
                return Err(format!("unsupported update_flow_step patch field: {other}"));
            }
        }
    }
    Ok(normalized)
}

fn positive_integer_or_null(value: &Value, key: &str) -> Result<Value, String> {
    Ok(
        parse_positive_integer_value(value, key, "update_flow_step")?
            .map(Value::from)
            .unwrap_or(Value::Null),
    )
}

fn parse_positive_integer_value(
    value: &Value,
    key: &str,
    context: &str,
) -> Result<Option<u64>, String> {
    if value.is_null() {
        return Ok(None);
    }
    if let Some(text) = value.as_str() {
        let trimmed = text.trim();
        if trimmed.is_empty() {
            return Ok(None);
        }
        let parsed = trimmed
            .parse::<u64>()
            .map_err(|_| format!("{context} {key} must be a positive integer"))?;
        if parsed == 0 {
            return Err(format!("{context} {key} must be positive"));
        }
        return Ok(Some(parsed));
    }
    if let Some(number) = value.as_u64() {
        if number == 0 {
            return Err(format!("{context} {key} must be positive"));
        }
        return Ok(Some(number));
    }
    Err(format!("{context} {key} must be a positive integer"))
}

fn positive_integer_or_default(
    value: Option<&Value>,
    key: &str,
    default: u64,
    context: &str,
) -> Result<u64, String> {
    value
        .map(|candidate| parse_positive_integer_value(candidate, key, context))
        .transpose()
        .map(|parsed| parsed.flatten().unwrap_or(default))
}

fn allowed_flow_option_value(
    value: &Value,
    label: &str,
    allowed: &[String],
) -> Result<Value, String> {
    let option = value
        .as_str()
        .ok_or_else(|| format!("update_flow_step {label} must be a string"))?
        .trim()
        .to_string();
    if !option.is_empty() && !allowed.iter().any(|candidate| candidate == &option) {
        return Err(format!("unsupported {label}: {option}"));
    }
    Ok(Value::String(option))
}

fn tool_catalog_id_set() -> BTreeSet<String> {
    tool_catalog_response()
        .into_iter()
        .filter_map(|tool| {
            tool.get("id")
                .and_then(Value::as_str)
                .map(ToString::to_string)
        })
        .collect()
}

fn member_tool_scope_for_step(step: &Value, members: &Value) -> BTreeSet<String> {
    let role = step
        .get("role")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .trim();
    let list = members.as_array().cloned().unwrap_or_else(Vec::new);
    member_tool_set(&list, role)
}

fn normalize_flow_step_tool_scope(
    value: &Value,
    allowed: &BTreeSet<String>,
    key: &str,
) -> Result<Vec<Value>, String> {
    let items = value
        .as_array()
        .ok_or_else(|| format!("update_flow_step {key} must be an array"))?;
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    for item in items {
        let tool = item
            .as_str()
            .ok_or_else(|| format!("update_flow_step {key} entries must be strings"))?
            .trim();
        if tool.is_empty() || seen.contains(tool) {
            continue;
        }
        if !allowed.contains(tool) {
            return Err(format!("unknown MobKit tool for {key}: {tool}"));
        }
        seen.insert(tool.to_string());
        out.push(Value::String(tool.to_string()));
    }
    Ok(out)
}

fn flow_fork_source_allowed(flow: &Value, step_id: &str, source_id: &str) -> bool {
    let source_id = source_id.trim();
    source_id.is_empty() || (source_id != step_id && flow_step_ids(flow).contains(source_id))
}

fn first_flow_fork_source(flow: &Value, step_id: &str) -> String {
    flow_step_ids(flow)
        .into_iter()
        .find(|id| id != step_id)
        .unwrap_or_default()
}

fn operation_branch_id(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("branch_id")
        .or_else(|| operation.get("branchId"))
        .or_else(|| operation.get("id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit authoring operation requires branch_id".to_string())
}

fn editor_condition_value_literal(value: &Value) -> String {
    let text = value
        .as_str()
        .map(ToString::to_string)
        .unwrap_or_else(|| value.to_string())
        .trim()
        .to_string();
    if text == "true" || text == "false" || text.parse::<f64>().is_ok() {
        text
    } else {
        serde_json::to_string(&text).unwrap_or_else(|_| "\"\"".to_string())
    }
}

fn editor_branch_condition_text(cond: &Value) -> String {
    let Some(field) = cond
        .get("field")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return String::new();
    };
    let op = cond
        .get("op")
        .or_else(|| cond.get("operator"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .unwrap_or_else(|| mob_definition_default("condition_operator"));
    if op.is_empty() {
        return String::new();
    }
    let value = cond
        .get("val")
        .or_else(|| cond.get("value"))
        .cloned()
        .unwrap_or_else(|| Value::String(String::new()));
    let source_is_params = cond
        .get("namespace")
        .and_then(Value::as_str)
        .is_some_and(|namespace| namespace == "params")
        || cond
            .get("stepId")
            .and_then(Value::as_str)
            .is_some_and(|step_id| step_id == "params");
    if source_is_params {
        format!(
            "params.{field} {op} {}",
            editor_condition_value_literal(&value)
        )
    } else {
        let Some(step_id) = cond
            .get("stepId")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            return String::new();
        };
        format!(
            "steps.{step_id}.{field} {op} {}",
            editor_condition_value_literal(&value)
        )
    }
}

fn editor_branch_condition_from_text(text: &str) -> Option<serde_json::Map<String, Value>> {
    let raw = text.trim();
    if raw.is_empty() {
        return None;
    }
    for op in ["==", ">", "<"] {
        if let Some((path, value)) = raw.split_once(op) {
            let path = path.trim();
            let value = value.trim().trim_matches(['"', '\'']).to_string();
            if let Some(field) = path.strip_prefix("params.") {
                let mut out = serde_json::Map::new();
                out.insert("namespace".to_string(), Value::String("params".to_string()));
                out.insert("stepId".to_string(), Value::String("params".to_string()));
                out.insert("field".to_string(), Value::String(field.trim().to_string()));
                out.insert("op".to_string(), Value::String(op.to_string()));
                out.insert("val".to_string(), Value::String(value));
                return Some(out);
            }
            if let Some(rest) = path.strip_prefix("steps.") {
                let (step_id, field) = rest.split_once('.')?;
                let mut out = serde_json::Map::new();
                out.insert("namespace".to_string(), Value::String("steps".to_string()));
                out.insert(
                    "stepId".to_string(),
                    Value::String(step_id.trim().to_string()),
                );
                out.insert("field".to_string(), Value::String(field.trim().to_string()));
                out.insert("op".to_string(), Value::String(op.to_string()));
                out.insert("val".to_string(), Value::String(value));
                return Some(out);
            }
        }
    }
    None
}

fn editor_branch_condition_patch(
    branch: &Value,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let mut cond = branch
        .get("cond")
        .and_then(Value::as_object)
        .cloned()
        .or_else(|| {
            branch
                .get("condition")
                .and_then(Value::as_str)
                .and_then(editor_branch_condition_from_text)
        })
        .unwrap_or_default();
    if let Some(patch) = operation.get("patch").and_then(Value::as_object) {
        for (key, value) in patch {
            cond.insert(
                if key == "step_id" {
                    "stepId".to_string()
                } else {
                    key.clone()
                },
                value.clone(),
            );
        }
    }
    for key in ["namespace", "field", "op", "operator", "val", "value"] {
        if let Some(value) = operation.get(key) {
            cond.insert(key.to_string(), value.clone());
        }
    }
    if let Some(value) = operation
        .get("condition_step_id")
        .or_else(|| operation.get("conditionStepId"))
        .or_else(|| operation.get("source_step_id"))
        .or_else(|| operation.get("sourceStepId"))
    {
        cond.insert("stepId".to_string(), value.clone());
    }
    let out = Value::Object(cond);
    if !out.is_object() {
        return Err("flow step branch condition must be an object".to_string());
    }
    Ok(out)
}

fn editor_basic_view_string(key: &str) -> String {
    mobpack_schema_response()
        .get("mob_definition")
        .and_then(|mob| mob.get("editor_basic_view"))
        .and_then(|view| view.get(key))
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

fn editor_branch_default_label(index: usize) -> String {
    let prefix = editor_basic_view_string("branch_condition_row_title_prefix");
    if prefix.is_empty() {
        format!("Branch {index}")
    } else {
        format!("{prefix} {index}")
    }
}

fn apply_flow_step_edit_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step_id = operation_step_id(operation)?;
    let action = operation_action(operation)?;
    let current = flow_step_mut_by_id(&mut document.flow, &step_id)
        .ok_or_else(|| format!("flow step not found: {step_id}"))?
        .clone();
    let mut next_step = current;
    let mut launch_mode = graph_launch_mode_object(&next_step);
    {
        let object = next_step
            .as_object_mut()
            .ok_or_else(|| format!("flow step is not an object: {step_id}"))?;
        match action.as_str() {
            "set_task" => {
                object.insert(
                    "task".to_string(),
                    Value::String(operation_text_value(operation, &["task", "value"])),
                );
            }
            "set_instruction" => {
                object.insert(
                    "instruction".to_string(),
                    Value::String(operation_text_value(operation, &["instruction", "value"])),
                );
            }
            "set_loop_id" => {
                object.insert(
                    "loopId".to_string(),
                    Value::String(operation_string_value(
                        operation,
                        &["loop_id", "loopId", "value"],
                    )),
                );
            }
            "set_quorum" => {
                let value = operation
                    .get("quorum")
                    .or_else(|| operation.get("n"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or(Value::Null);
                object.insert(
                    "quorum".to_string(),
                    positive_integer_or_null(&value, "quorum")?,
                );
            }
            "set_timeout_ms" => {
                let value = operation
                    .get("timeout_ms")
                    .or_else(|| operation.get("timeoutMs"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or(Value::Null);
                object.insert(
                    "timeoutMs".to_string(),
                    positive_integer_or_null(&value, "timeoutMs")?,
                );
            }
            "set_max_iterations" => {
                let value = operation
                    .get("max_iterations")
                    .or_else(|| operation.get("maxIterations"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or(Value::Null);
                object.insert(
                    "maxIterations".to_string(),
                    positive_integer_or_null(&value, "maxIterations")?,
                );
            }
            "set_repeat_condition" => {
                let value = operation
                    .get("cond")
                    .or_else(|| operation.get("condition"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or(Value::Null);
                if !value.is_null() && !value.is_object() {
                    return Err("flow step repeat condition must be an object or null".to_string());
                }
                object.insert("cond".to_string(), value);
            }
            "set_iteration_input" => {
                let value = operation_string_value(
                    operation,
                    &["iteration_input", "iterationInput", "value"],
                );
                if !value.is_empty() && !REPEAT_ITERATION_INPUTS.contains(&value.as_str()) {
                    return Err(format!("unsupported repeat iteration input: {value}"));
                }
                object.insert("iterationInput".to_string(), Value::String(value));
            }
            "set_controller_role" => {
                let role = operation_string_value(
                    operation,
                    &["controller_role", "controllerRole", "role", "value"],
                );
                if !role.is_empty() && !graph_member_ids(&document.members).contains(&role) {
                    return Err(format!(
                        "flow step controller role must reference an existing member: {role}"
                    ));
                }
                object.insert("controllerRole".to_string(), Value::String(role));
            }
            "set_member_role" => {
                let role =
                    operation_string_value(operation, &["role", "member_id", "memberId", "value"]);
                if !role.is_empty() && !graph_member_ids(&document.members).contains(&role) {
                    return Err(format!(
                        "flow step member role must reference an existing member: {role}"
                    ));
                }
                object.insert("role".to_string(), Value::String(role));
            }
            "set_dispatch_mode" => {
                let value = operation
                    .get("dispatch_mode")
                    .or_else(|| operation.get("dispatchMode"))
                    .or_else(|| operation.get("dispatch"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or_else(|| Value::String(String::new()));
                object.insert(
                    "dispatchMode".to_string(),
                    allowed_flow_option_value(&value, "dispatch mode", &dispatch_mode_values())?,
                );
            }
            "set_parallel_dispatch" => {
                let value = operation
                    .get("dispatch")
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or_else(|| Value::String(String::new()));
                object.insert(
                    "dispatch".to_string(),
                    allowed_flow_option_value(&value, "dispatch mode", &dispatch_mode_values())?,
                );
            }
            "set_collection" => {
                let value = operation
                    .get("collection")
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or_else(|| Value::String(String::new()));
                object.insert(
                    "collection".to_string(),
                    allowed_flow_option_value(
                        &value,
                        "collection policy",
                        &collection_policy_values(),
                    )?,
                );
            }
            "set_dependency_mode" => {
                let value = operation
                    .get("depends_mode")
                    .or_else(|| operation.get("dependsMode"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or_else(|| Value::String(String::new()));
                object.insert(
                    "dependsMode".to_string(),
                    allowed_flow_option_value(
                        &value,
                        "dependency mode",
                        &dependency_mode_values(),
                    )?,
                );
            }
            "set_output_format" => {
                let value = operation
                    .get("output_format")
                    .or_else(|| operation.get("outputFormat"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or_else(|| Value::String(String::new()));
                object.insert(
                    "outputFormat".to_string(),
                    allowed_flow_option_value(
                        &value,
                        "step output format",
                        &step_output_format_values(),
                    )?,
                );
            }
            "set_allowed_tools" => {
                let value = operation
                    .get("tools")
                    .or_else(|| operation.get("allowed_tools"))
                    .or_else(|| operation.get("allowedTools"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or_else(|| Value::Array(Vec::new()));
                object.insert(
                    "allowedTools".to_string(),
                    Value::Array(normalize_flow_step_tool_scope(
                        &value,
                        &member_tool_scope_for_step(
                            &Value::Object(object.clone()),
                            &document.members,
                        ),
                        "allowedTools",
                    )?),
                );
            }
            "set_blocked_tools" => {
                let value = operation
                    .get("tools")
                    .or_else(|| operation.get("blocked_tools"))
                    .or_else(|| operation.get("blockedTools"))
                    .or_else(|| operation.get("value"))
                    .cloned()
                    .unwrap_or_else(|| Value::Array(Vec::new()));
                object.insert(
                    "blockedTools".to_string(),
                    Value::Array(normalize_flow_step_tool_scope(
                        &value,
                        &tool_catalog_id_set(),
                        "blockedTools",
                    )?),
                );
            }
            "set_branch_condition" => {
                if object
                    .get("type")
                    .and_then(Value::as_str)
                    .is_some_and(|kind| kind == "parallel")
                {
                    return Err("parallel branches do not have conditions".to_string());
                }
                let branch_id = operation_branch_id(operation)?;
                let branches = object
                    .get_mut("branches")
                    .and_then(Value::as_array_mut)
                    .ok_or_else(|| format!("flow step branches must be an array: {step_id}"))?;
                let branch = branches
                    .iter_mut()
                    .find(|branch| {
                        branch
                            .get("id")
                            .and_then(Value::as_str)
                            .is_some_and(|id| id == branch_id)
                    })
                    .ok_or_else(|| format!("flow branch not found: {branch_id}"))?;
                let cond = editor_branch_condition_patch(branch, operation)?;
                let condition = editor_branch_condition_text(&cond);
                let branch_object = branch
                    .as_object_mut()
                    .ok_or_else(|| format!("flow branch is not an object: {branch_id}"))?;
                branch_object.insert("cond".to_string(), cond);
                branch_object.insert("condition".to_string(), Value::String(condition));
            }
            "add_branch" => {
                let step_type = object
                    .get("type")
                    .and_then(Value::as_str)
                    .unwrap_or_default()
                    .to_string();
                if step_type != "branch" && step_type != "parallel" {
                    return Err(format!(
                        "add_branch requires a branch or parallel step, found: {step_type}"
                    ));
                }
                let branches = object
                    .entry("branches".to_string())
                    .or_insert_with(|| Value::Array(Vec::new()))
                    .as_array_mut()
                    .ok_or_else(|| format!("flow step branches must be an array: {step_id}"))?;
                let mut branch_ids = BTreeSet::new();
                let branch_id = next_flow_branch_id(&document.flow, &mut branch_ids);
                let mut next_branch = serde_json::Map::new();
                next_branch.insert("id".to_string(), Value::String(branch_id));
                next_branch.insert(
                    "label".to_string(),
                    Value::String(editor_branch_default_label(branches.len() + 1)),
                );
                next_branch.insert("steps".to_string(), Value::Array(Vec::new()));
                if step_type != "parallel" {
                    next_branch.insert("condition".to_string(), Value::String(String::new()));
                }
                branches.push(Value::Object(next_branch));
            }
            "set_launch_kind" => {
                let kind = canonical_graph_launch_kind(&operation_string_value(
                    operation,
                    &["kind", "value"],
                ));
                if !graph_launch_kind_allowed(&kind) {
                    return Err(format!("unsupported launch mode: {kind}"));
                }
                let budget = graph_launch_budget_patch(&launch_mode);
                let mut next = serde_json::Map::new();
                next.insert("kind".to_string(), Value::String(kind.clone()));
                if kind == "Fork" {
                    let from = operation_string_value(
                        operation,
                        &["first_fork_source_id", "firstForkSourceId", "from"],
                    );
                    let from = if from.is_empty() {
                        launch_mode
                            .get("from")
                            .and_then(Value::as_str)
                            .map(str::trim)
                            .filter(|value| !value.is_empty())
                            .map(ToString::to_string)
                            .unwrap_or_else(|| first_flow_fork_source(&document.flow, &step_id))
                    } else {
                        from
                    };
                    if !flow_fork_source_allowed(&document.flow, &step_id, &from) {
                        return Err(format!(
                            "launch fork source must reference another flow step: {from}"
                        ));
                    }
                    next.insert("from".to_string(), Value::String(from));
                    let context = launch_mode
                        .get("context")
                        .and_then(Value::as_str)
                        .map(str::trim)
                        .filter(|value| !value.is_empty())
                        .map(ToString::to_string)
                        .unwrap_or_else(|| mob_definition_default("fork_context"));
                    next.insert("context".to_string(), Value::String(context));
                } else if kind == "Resume" {
                    next.insert(
                        "sessionId".to_string(),
                        Value::String(
                            launch_mode
                                .get("sessionId")
                                .or_else(|| launch_mode.get("session_id"))
                                .and_then(Value::as_str)
                                .unwrap_or_default()
                                .to_string(),
                        ),
                    );
                }
                if let Some(budget) = budget {
                    next.insert("budgetSplitPolicy".to_string(), budget);
                }
                object.insert("launchMode".to_string(), Value::Object(next));
            }
            "set_launch_session" => {
                launch_mode.insert(
                    "sessionId".to_string(),
                    Value::String(operation_string_value(
                        operation,
                        &["session_id", "sessionId", "value"],
                    )),
                );
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_fork_source" => {
                let from =
                    operation_string_value(operation, &["from", "source_id", "sourceId", "value"]);
                if !flow_fork_source_allowed(&document.flow, &step_id, &from) {
                    return Err(format!(
                        "launch fork source must reference another flow step: {from}"
                    ));
                }
                launch_mode.insert("from".to_string(), Value::String(from));
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_fork_context" => {
                let context = operation_string_value(operation, &["context", "value"]);
                if !graph_fork_context_allowed(&context) {
                    return Err(format!("unsupported fork context: {context}"));
                }
                launch_mode.insert("context".to_string(), Value::String(context));
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_budget_kind" => {
                let kind = canonical_graph_budget_kind(&operation_string_value(
                    operation,
                    &["budget_kind", "budgetKind", "kind", "value"],
                ));
                if !graph_budget_kind_allowed(&kind) {
                    return Err(format!("unsupported budget split policy: {kind}"));
                }
                let policy = if kind == "Fixed" {
                    json!({ "kind": "Fixed", "limit": 4096 })
                } else {
                    json!({ "kind": kind })
                };
                launch_mode.insert("budgetSplitPolicy".to_string(), policy);
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_budget_limit" => {
                let limit = positive_integer_or_default(
                    operation.get("limit").or_else(|| operation.get("value")),
                    "limit",
                    4096,
                    "set_launch_budget_limit",
                )?;
                launch_mode.insert(
                    "budgetSplitPolicy".to_string(),
                    json!({ "kind": "Fixed", "limit": limit }),
                );
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            other => return Err(format!("unsupported apply_flow_step_edit action: {other}")),
        }
    }
    validate_flow_step(
        &next_step,
        &document.flow,
        &document.members,
        Some(&step_id),
    )?;
    *flow_step_mut_by_id(&mut document.flow, &step_id)
        .ok_or_else(|| format!("flow step not found: {step_id}"))? = next_step;
    reconcile_flow_step_launch_modes(document);
    reproject_graph_for_flow_change(document);
    Ok(json!({ "kind": "step", "id": step_id }))
}

fn apply_delete_flow_step_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step_id = operation_step_id(operation)?;
    let steps = flow_steps_array_mut(&mut document.flow)?;
    if !remove_flow_step_from_steps(steps, &step_id) {
        return Err(format!("flow step not found: {step_id}"));
    }
    clear_deleted_flow_step_references(&mut document.flow, &step_id);
    reconcile_flow_step_launch_modes(document);
    reproject_graph_for_flow_change(document);
    Ok(json!({ "kind": null, "id": null }))
}

fn input_params_mut_for_step<'a>(
    step: &'a mut Value,
    step_id: &str,
) -> Result<&'a mut Vec<Value>, String> {
    if !step.get("inputParams").is_some_and(Value::is_array) {
        step["inputParams"] = Value::Array(Vec::new());
    }
    step.get_mut("inputParams")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| format!("inputParams is not an array for step {step_id}"))
}

fn input_param_index(params: &[Value], param_id: &str) -> Option<usize> {
    params.iter().position(|param| {
        param
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == param_id)
    })
}

fn refresh_input_param_summary(step: &mut Value) {
    let params = step
        .get("inputParams")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_else(Vec::new);
    step["fields"] = Value::String(input_param_summary(&params));
}

fn next_input_param_id(params: &[Value]) -> String {
    let used = params
        .iter()
        .filter_map(|param| param.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .collect::<BTreeSet<_>>();
    let mut index = 1;
    loop {
        let id = format!("p{index}");
        if !used.contains(id.as_str()) {
            return id;
        }
        index += 1;
    }
}

fn input_param_name(raw: &str, fallback: &str) -> String {
    let out = raw
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '_' {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>()
        .split('_')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("_");
    let out = out.trim_matches('_').to_string();
    let out = if out.is_empty() {
        fallback.to_string()
    } else {
        out
    };
    if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
        format!("_{out}")
    } else {
        out
    }
}

fn unique_input_param_name(params: &[Value], raw: &str) -> String {
    let base = input_param_name(raw, "param");
    let taken = params
        .iter()
        .filter_map(|param| param.get("name").and_then(Value::as_str))
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .collect::<BTreeSet<_>>();
    if !taken.contains(base.as_str()) {
        return base;
    }
    let mut index = 2;
    loop {
        let candidate = format!("{base}_{index}");
        if !taken.contains(candidate.as_str()) {
            return candidate;
        }
        index += 1;
    }
}

fn input_param_from_draft(params: &[Value]) -> Result<Value, String> {
    let schema = mobpack_schema_response();
    let mob_definition = schema
        .get("mob_definition")
        .and_then(Value::as_object)
        .ok_or_else(|| "MobKit schema is missing mob_definition".to_string())?;
    let default_type = mob_definition
        .get("defaults")
        .and_then(Value::as_object)
        .and_then(|defaults| defaults.get("schema_field_type"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("string");
    if !is_editor_schema_field_type(default_type) {
        return Err(format!(
            "MobKit schema default schema_field_type is unsupported: {default_type}"
        ));
    }
    let added_field = mob_definition
        .get("editor_input_param_draft")
        .and_then(Value::as_object)
        .and_then(|draft| draft.get("added_field"))
        .and_then(Value::as_object)
        .ok_or_else(|| {
            "MobKit schema is missing mob_definition.editor_input_param_draft".to_string()
        })?;
    let name = added_field
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("param");
    let enum_values = added_field
        .get("enumValues")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    Ok(json!({
        "id": next_input_param_id(params),
        "name": unique_input_param_name(params, name),
        "type": default_type,
        "required": added_field
            .get("required")
            .and_then(Value::as_bool)
            .unwrap_or(true),
        "description": added_field
            .get("description")
            .and_then(Value::as_str)
            .unwrap_or(""),
        "enumValues": enum_values,
    }))
}

fn apply_add_input_param_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step_id = operation_step_id(operation)?;
    let step = flow_step_mut_by_id(&mut document.flow, &step_id)
        .ok_or_else(|| format!("flow step not found: {step_id}"))?;
    let params = input_params_mut_for_step(step, &step_id)?;
    let param = operation
        .get("param")
        .cloned()
        .map(Ok)
        .unwrap_or_else(|| input_param_from_draft(params))?;
    let param_id = param
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "add_input_param requires param.id".to_string())?
        .to_string();
    if input_param_index(params, &param_id).is_some() {
        return Err(format!("input param already exists: {param_id}"));
    }
    params.push(param);
    refresh_input_param_summary(step);
    Ok(json!({ "kind": "step", "id": step_id, "param_id": param_id }))
}

fn apply_update_input_param_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step_id = operation_step_id(operation)?;
    let param_id = operation_param_id(operation)?;
    let patch = operation
        .get("patch")
        .and_then(Value::as_object)
        .ok_or_else(|| "update_input_param requires patch object".to_string())?;
    update_input_param(document, &step_id, &param_id, patch)
}

fn apply_rename_input_param_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step_id = operation_step_id(operation)?;
    let param_id = operation_param_id(operation)?;
    let new_name = operation
        .get("new_name")
        .or_else(|| operation.get("newName"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "rename_input_param requires new_name".to_string())?;
    let mut patch = serde_json::Map::new();
    patch.insert("name".to_string(), Value::String(new_name.to_string()));
    update_input_param(document, &step_id, &param_id, &patch)
}

fn apply_delete_input_param_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let step_id = operation_step_id(operation)?;
    let param_id = operation_param_id(operation)?;
    let old_name = {
        let step = flow_step_mut_by_id(&mut document.flow, &step_id)
            .ok_or_else(|| format!("flow step not found: {step_id}"))?;
        let params = input_params_mut_for_step(step, &step_id)?;
        let index = input_param_index(params, &param_id)
            .ok_or_else(|| format!("input param not found: {param_id}"))?;
        let old_name = params[index]
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
        params.remove(index);
        refresh_input_param_summary(step);
        old_name
    };
    if !old_name.is_empty() {
        rewrite_input_param_references(document, &old_name, "");
    }
    Ok(json!({ "kind": "step", "id": step_id }))
}

fn update_input_param(
    document: &mut MobpackDocument,
    step_id: &str,
    param_id: &str,
    patch: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let (old_name, new_name) = {
        let step = flow_step_mut_by_id(&mut document.flow, step_id)
            .ok_or_else(|| format!("flow step not found: {step_id}"))?;
        let params = input_params_mut_for_step(step, step_id)?;
        let index = input_param_index(params, param_id)
            .ok_or_else(|| format!("input param not found: {param_id}"))?;
        let old_name = params[index]
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
        let normalized_patch =
            normalize_input_param_update_patch(&params[index], params, param_id, patch)?;
        let param = params[index]
            .as_object_mut()
            .ok_or_else(|| format!("input param is not an object: {param_id}"))?;
        for (key, value) in normalized_patch {
            param.insert(key.clone(), value.clone());
        }
        let new_name = param
            .get("name")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
        refresh_input_param_summary(step);
        (old_name, new_name)
    };
    if !old_name.is_empty() && old_name != new_name {
        rewrite_input_param_references(document, &old_name, &new_name);
    } else {
        clear_unavailable_input_param_conditions(document);
    }
    Ok(json!({ "kind": "step", "id": step_id, "param_id": param_id }))
}

fn normalize_input_param_update_patch(
    current_param: &Value,
    params: &[Value],
    param_id: &str,
    patch: &serde_json::Map<String, Value>,
) -> Result<serde_json::Map<String, Value>, String> {
    let mut normalized = serde_json::Map::new();
    for (key, value) in patch {
        match key.as_str() {
            "id" => return Err("update_input_param cannot change param id".to_string()),
            "name" => {
                let raw_name = value
                    .as_str()
                    .ok_or_else(|| "update_input_param name must be a string".to_string())?;
                let name = unique_input_param_name_for_update(params, raw_name, param_id);
                normalized.insert("name".to_string(), Value::String(name));
            }
            "type" => {
                let field_type = value
                    .as_str()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    .ok_or_else(|| {
                        "update_input_param type must be a non-empty string".to_string()
                    })?
                    .to_string();
                if !is_editor_schema_field_type(&field_type) {
                    return Err(format!("unsupported update_input_param type: {field_type}"));
                }
                normalized.insert("type".to_string(), Value::String(field_type));
            }
            "required" => {
                let required = value
                    .as_bool()
                    .ok_or_else(|| "update_input_param required must be a boolean".to_string())?;
                normalized.insert("required".to_string(), Value::Bool(required));
            }
            "description" => {
                let description = value
                    .as_str()
                    .ok_or_else(|| "update_input_param description must be a string".to_string())?;
                normalized.insert(
                    "description".to_string(),
                    Value::String(description.to_string()),
                );
            }
            "enumValues" | "enum_values" => {
                let values = normalized_schema_enum_values(value)?;
                normalized.insert("enumValues".to_string(), Value::Array(values));
            }
            other => {
                return Err(format!(
                    "unsupported update_input_param patch field: {other}"
                ));
            }
        }
    }
    let next_type = normalized
        .get("type")
        .and_then(Value::as_str)
        .or_else(|| current_param.get("type").and_then(Value::as_str))
        .unwrap_or("string");
    if next_type == "enum" {
        if !normalized.contains_key("enumValues")
            && current_param
                .get("enumValues")
                .and_then(Value::as_array)
                .is_none_or(Vec::is_empty)
        {
            normalized.insert("enumValues".to_string(), json!(["value"]));
        }
    } else if normalized.contains_key("type") || normalized.contains_key("enumValues") {
        normalized.insert("enumValues".to_string(), Value::Array(Vec::new()));
    }
    Ok(normalized)
}

fn unique_input_param_name_for_update(params: &[Value], raw: &str, param_id: &str) -> String {
    let base = input_param_name(raw, "param");
    let taken = params
        .iter()
        .filter(|param| param.get("id").and_then(Value::as_str) != Some(param_id))
        .filter_map(|param| param.get("name").and_then(Value::as_str))
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .collect::<BTreeSet<_>>();
    if !taken.contains(base.as_str()) {
        return base;
    }
    let mut index = 2;
    loop {
        let candidate = format!("{base}_{index}");
        if !taken.contains(candidate.as_str()) {
            return candidate;
        }
        index += 1;
    }
}

fn input_param_names(flow: &Value) -> BTreeSet<String> {
    let mut names = BTreeSet::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_input_param_names_in_steps(steps, &mut names);
    }
    names
}

fn collect_input_param_names_in_steps(steps: &[Value], names: &mut BTreeSet<String>) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("input")
            && let Some(params) = step.get("inputParams").and_then(Value::as_array)
        {
            names.extend(params.iter().filter_map(|param| {
                param
                    .get("name")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|name| !name.is_empty())
                    .map(ToString::to_string)
            }));
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_input_param_names_in_steps(nested, names);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_input_param_names_in_steps(branch_steps, names);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_input_param_names_in_steps(fallback, names);
        }
    }
}

fn rewrite_input_param_references(document: &mut MobpackDocument, old_name: &str, new_name: &str) {
    if let Some(steps) = document.flow.get_mut("steps").and_then(Value::as_array_mut) {
        rewrite_input_param_references_in_steps(steps, old_name, new_name);
    }
    rewrite_input_param_references_in_edges(&mut document.edges, old_name, new_name);
}

fn rewrite_input_param_references_in_steps(steps: &mut [Value], old_name: &str, new_name: &str) {
    for step in steps {
        match step.get("type").and_then(Value::as_str) {
            Some("repeat") => {
                if let Some(cond) = step.get_mut("cond") {
                    rewrite_input_param_condition(cond, old_name, new_name);
                }
                if let Some(until) = step.get_mut("until") {
                    rewrite_input_param_condition_text(until, old_name, new_name);
                }
                if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
                    rewrite_input_param_references_in_steps(nested, old_name, new_name);
                }
            }
            Some("branch") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(cond) = branch.get_mut("cond") {
                            rewrite_input_param_condition(cond, old_name, new_name);
                        }
                        if let Some(condition) = branch.get_mut("condition") {
                            rewrite_input_param_condition_text(condition, old_name, new_name);
                        }
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            rewrite_input_param_references_in_steps(
                                branch_steps,
                                old_name,
                                new_name,
                            );
                        }
                    }
                }
                if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
                    rewrite_input_param_references_in_steps(fallback, old_name, new_name);
                }
            }
            Some("parallel") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            rewrite_input_param_references_in_steps(
                                branch_steps,
                                old_name,
                                new_name,
                            );
                        }
                    }
                }
            }
            _ => {}
        }
    }
}

fn rewrite_input_param_condition(cond: &mut Value, old_name: &str, new_name: &str) {
    let Some(object) = cond.as_object_mut() else {
        return;
    };
    let is_param = object
        .get("namespace")
        .and_then(Value::as_str)
        .is_some_and(|namespace| namespace == "params")
        || object
            .get("stepId")
            .or_else(|| object.get("step_id"))
            .and_then(Value::as_str)
            .is_some_and(|step_id| step_id == "params");
    let field = object
        .get("field")
        .and_then(Value::as_str)
        .unwrap_or_default();
    if !is_param || field != old_name {
        return;
    }
    if new_name.is_empty() {
        *cond = json!({});
    } else {
        object.insert("namespace".to_string(), Value::String("params".to_string()));
        object.insert("stepId".to_string(), Value::String("params".to_string()));
        object.insert("field".to_string(), Value::String(new_name.to_string()));
    }
}

fn rewrite_input_param_condition_text(text: &mut Value, old_name: &str, new_name: &str) {
    let Some(raw) = text.as_str() else {
        return;
    };
    let prefix = format!("params.{old_name}");
    let trimmed = raw.trim_start();
    if !trimmed.starts_with(&prefix) {
        return;
    }
    // Require a token boundary after the matched reference so renaming/deleting
    // `route` does not corrupt an unrelated `params.route_v2` condition. The
    // next character must end the identifier token (end-of-string or any
    // non-identifier char such as a space, operator, or `)`); a continuation
    // char like a letter, digit, or `_` means this is a DIFFERENT param that
    // merely shares a prefix, and must be left untouched.
    if let Some(next) = trimmed[prefix.len()..].chars().next()
        && (next.is_ascii_alphanumeric() || next == '_')
    {
        return;
    }
    if new_name.is_empty() {
        *text = Value::String(String::new());
    } else {
        *text = Value::String(raw.replacen(&prefix, &format!("params.{new_name}"), 1));
    }
}

fn rewrite_input_param_references_in_edges(edges: &mut Value, old_name: &str, new_name: &str) {
    let Some(edges) = edges.as_array_mut() else {
        return;
    };
    for edge in edges {
        let Some(cond) = edge.get_mut("cond") else {
            continue;
        };
        let Some(cond_object) = cond.as_object_mut() else {
            continue;
        };
        let path = cond_object
            .get("var")
            .or_else(|| cond_object.get("path"))
            .and_then(Value::as_str)
            .unwrap_or_default();
        let expected = format!("params.{old_name}");
        if path != expected {
            continue;
        }
        if new_name.is_empty() {
            edge["cond"] = Value::Null;
            edge["label"] = Value::String(String::new());
        } else {
            cond_object.insert(
                "var".to_string(),
                Value::String(format!("params.{new_name}")),
            );
        }
    }
}

fn clear_unavailable_input_param_conditions(document: &mut MobpackDocument) {
    let names = input_param_names(&document.flow);
    if let Some(steps) = document.flow.get_mut("steps").and_then(Value::as_array_mut) {
        clear_unavailable_input_param_conditions_in_steps(steps, &names);
    }
    clear_unavailable_input_param_conditions_in_edges(&mut document.edges, &names);
}

fn clear_unavailable_input_param_conditions_in_steps(
    steps: &mut [Value],
    names: &BTreeSet<String>,
) {
    for step in steps {
        match step.get("type").and_then(Value::as_str) {
            Some("repeat") => {
                if let Some(cond) = step.get_mut("cond") {
                    clear_unavailable_input_param_condition(cond, names);
                }
                if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
                    clear_unavailable_input_param_conditions_in_steps(nested, names);
                }
            }
            Some("branch") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(cond) = branch.get_mut("cond") {
                            clear_unavailable_input_param_condition(cond, names);
                        }
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            clear_unavailable_input_param_conditions_in_steps(branch_steps, names);
                        }
                    }
                }
                if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
                    clear_unavailable_input_param_conditions_in_steps(fallback, names);
                }
            }
            Some("parallel") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            clear_unavailable_input_param_conditions_in_steps(branch_steps, names);
                        }
                    }
                }
            }
            _ => {}
        }
    }
}

fn clear_unavailable_input_param_condition(cond: &mut Value, names: &BTreeSet<String>) {
    let Some(object) = cond.as_object() else {
        return;
    };
    let is_param = object
        .get("namespace")
        .and_then(Value::as_str)
        .is_some_and(|namespace| namespace == "params")
        || object
            .get("stepId")
            .or_else(|| object.get("step_id"))
            .and_then(Value::as_str)
            .is_some_and(|step_id| step_id == "params");
    let field = object
        .get("field")
        .and_then(Value::as_str)
        .unwrap_or_default();
    if is_param && !field.is_empty() && !names.contains(field) {
        *cond = json!({});
    }
}

fn clear_unavailable_input_param_conditions_in_edges(edges: &mut Value, names: &BTreeSet<String>) {
    let Some(edges) = edges.as_array_mut() else {
        return;
    };
    for edge in edges {
        let path = edge
            .get("cond")
            .and_then(Value::as_object)
            .and_then(|cond| cond.get("var").or_else(|| cond.get("path")))
            .and_then(Value::as_str)
            .unwrap_or_default();
        let Some(field) = path.strip_prefix("params.") else {
            continue;
        };
        if !names.contains(field) {
            edge["cond"] = Value::Null;
            edge["label"] = Value::String(String::new());
        }
    }
}

fn graph_instance_index(instances: &[Value], instance_id: &str) -> Option<usize> {
    instances.iter().position(|instance| {
        instance
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == instance_id)
    })
}

fn graph_edge_index(edges: &[Value], edge_id: &str) -> Option<usize> {
    edges.iter().position(|edge| {
        edge.get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == edge_id)
    })
}

fn graph_member_ids(members: &Value) -> BTreeSet<String> {
    members
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|member| {
            member
                .get("id")
                .or_else(|| member.get("role"))
                .and_then(Value::as_str)
        })
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn graph_instance_is_control_node(instance: &Value) -> bool {
    if instance
        .get("isGate")
        .or_else(|| instance.get("is_gate"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
        || instance
            .get("isTerminal")
            .or_else(|| instance.get("is_terminal"))
            .and_then(Value::as_bool)
            .unwrap_or(false)
    {
        return true;
    }
    if instance
        .get("gateKind")
        .or_else(|| instance.get("gate_kind"))
        .and_then(Value::as_str)
        .is_some_and(|kind| !kind.trim().is_empty())
    {
        return true;
    }
    matches!(
        instance
            .get("kind")
            .and_then(Value::as_str)
            .map(str::trim)
            .unwrap_or_default(),
        "gate" | "terminal" | "start" | "end" | "fork" | "join"
    )
}

fn graph_instance_is_uncompiled_terminal(instance: &Value) -> bool {
    instance
        .get("isTerminal")
        .or_else(|| instance.get("is_terminal"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
        || instance
            .get("kind")
            .and_then(Value::as_str)
            .map(str::trim)
            .is_some_and(|kind| kind == "terminal")
}

fn validate_graph_instance(
    instance: &Value,
    instances: &[Value],
    members: &Value,
    current_id: Option<&str>,
) -> Result<String, String> {
    let id = instance
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .ok_or_else(|| "graph node requires id".to_string())?
        .to_string();
    if let Some(current_id) = current_id
        && id != current_id
    {
        return Err("graph node id cannot change".to_string());
    }
    let duplicate = instances.iter().any(|candidate| {
        let candidate_id = candidate
            .get("id")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .trim();
        if candidate_id.is_empty() || candidate_id != id {
            return false;
        }
        match current_id {
            Some(current_id) => candidate_id != current_id,
            None => true,
        }
    });
    if duplicate {
        return Err(format!("graph node already exists: {id}"));
    }
    if graph_instance_is_uncompiled_terminal(instance) {
        return Err(format!(
            "uncompiled graph terminal nodes cannot be persisted through MobKit graph authoring: {id}"
        ));
    }
    if !graph_instance_is_control_node(instance) {
        let member_id = instance
            .get("memberId")
            .or_else(|| instance.get("member_id"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|member_id| !member_id.is_empty())
            .ok_or_else(|| "member graph node must reference a member".to_string())?;
        let members = graph_member_ids(members);
        if !members.contains(member_id) {
            return Err(format!(
                "member graph node must reference an existing member: {member_id}"
            ));
        }
    }
    Ok(id)
}

fn graph_instance_ids(instances: &[Value]) -> BTreeSet<String> {
    instances
        .iter()
        .filter_map(|instance| instance.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn validate_graph_edge(
    edge: &Value,
    edges: &[Value],
    instances: &[Value],
    current_id: Option<&str>,
) -> Result<String, String> {
    let id = edge
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .ok_or_else(|| "graph edge requires id".to_string())?
        .to_string();
    if let Some(current_id) = current_id
        && id != current_id
    {
        return Err("graph edge id cannot change".to_string());
    }
    let from = edge
        .get("from")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "graph edge requires from".to_string())?;
    let to = edge
        .get("to")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "graph edge requires to".to_string())?;
    if from == to {
        return Err("edge endpoints must be different graph nodes".to_string());
    }
    let instance_ids = graph_instance_ids(instances);
    if !instance_ids.contains(from) || !instance_ids.contains(to) {
        return Err("edge endpoints must reference existing graph nodes".to_string());
    }
    if instances.iter().any(|instance| {
        instance
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| {
                (id == from || id == to) && graph_instance_is_uncompiled_terminal(instance)
            })
    }) {
        return Err("edge endpoints cannot reference uncompiled graph terminal nodes".to_string());
    }
    let duplicate = edges.iter().any(|candidate| {
        let candidate_id = candidate
            .get("id")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .trim();
        let candidate_from = candidate
            .get("from")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .trim();
        let candidate_to = candidate
            .get("to")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .trim();
        if current_id.is_some_and(|current_id| candidate_id == current_id) {
            return false;
        }
        candidate_id == id || (candidate_from == from && candidate_to == to)
    });
    if duplicate {
        return Err(format!("graph edge already exists: {id}"));
    }
    Ok(id)
}

fn graph_edge_ids(edges: &[Value]) -> BTreeSet<String> {
    edges
        .iter()
        .filter_map(|edge| edge.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn unique_graph_edge_id(from_id: &str, to_id: &str, edges: &[Value]) -> String {
    let base = format!(
        "e_{}_{}",
        sanitize_identifier(from_id),
        sanitize_identifier(to_id)
    );
    let used = graph_edge_ids(edges);
    if !used.contains(&base) {
        return base;
    }
    let mut index = 2;
    loop {
        let id = format!("{base}_{index}");
        if !used.contains(&id) {
            return id;
        }
        index += 1;
    }
}

fn unique_graph_instance_id(prefix: &str, instances: &[Value]) -> String {
    let stem = sanitize_identifier(prefix);
    let base = if stem.starts_with("i_") {
        stem
    } else {
        format!("i_{stem}")
    };
    let used = graph_instance_ids(instances);
    if !used.contains(&base) {
        return base;
    }
    let mut index = 2;
    loop {
        let id = format!("{base}_{index}");
        if !used.contains(&id) {
            return id;
        }
        index += 1;
    }
}

fn graph_operation_cell(operation: &serde_json::Map<String, Value>) -> Result<(i64, i64), String> {
    let cell = operation
        .get("cell")
        .or_else(|| operation.get("at"))
        .and_then(Value::as_object)
        .ok_or_else(|| "insert_graph_node semantic pick requires cell".to_string())?;
    let col = cell
        .get("col")
        .and_then(Value::as_i64)
        .ok_or_else(|| "insert_graph_node cell.col is required".to_string())?;
    let row = cell
        .get("row")
        .and_then(Value::as_i64)
        .ok_or_else(|| "insert_graph_node cell.row is required".to_string())?;
    Ok((col, row))
}

fn graph_cell_occupied(instances: &[Value], col: i64, row: i64) -> bool {
    instances.iter().any(|instance| {
        instance.get("col").and_then(Value::as_i64) == Some(col)
            && instance.get("row").and_then(Value::as_i64) == Some(row)
    })
}

fn allocate_graph_control_cells(instances: &[Value], col: i64, row: i64) -> [(i64, i64); 4] {
    for candidate_row in row..(row + 24) {
        let cells = [
            (col, candidate_row),
            (col + 1, candidate_row),
            (col + 1, candidate_row + 1),
            (col + 2, candidate_row),
        ];
        if cells
            .iter()
            .all(|(cell_col, cell_row)| !graph_cell_occupied(instances, *cell_col, *cell_row))
        {
            return cells;
        }
    }
    [
        (col, row),
        (col + 1, row),
        (col + 1, row + 1),
        (col + 2, row),
    ]
}

fn unique_graph_control_suffix(kind: &str, instances: &[Value], edges: &[Value]) -> String {
    let prefix = if kind == "branch" {
        "branch"
    } else {
        "parallel"
    };
    let instance_ids = graph_instance_ids(instances);
    let edge_ids = graph_edge_ids(edges);
    let mut index = 1;
    loop {
        let suffix = index.to_string();
        let gate_id = format!("g_{prefix}_{suffix}");
        let left_id = format!("{gate_id}_a");
        let right_id = format!("{gate_id}_b");
        let join_id = format!("j_{prefix}_{suffix}");
        let proposed_edge_ids = [
            format!("e_{gate_id}_{left_id}"),
            format!("e_{gate_id}_{right_id}"),
            format!("e_{left_id}_{join_id}"),
            format!("e_{right_id}_{join_id}"),
        ];
        if [&gate_id, &left_id, &right_id, &join_id]
            .iter()
            .all(|id| !instance_ids.contains(*id))
            && proposed_edge_ids.iter().all(|id| !edge_ids.contains(id))
        {
            return suffix;
        }
        index += 1;
    }
}

fn mob_definition_default(name: &str) -> String {
    mobpack_schema_response()["mob_definition"]["defaults"][name]
        .as_str()
        .unwrap_or_default()
        .to_string()
}

fn graph_draft_value(name: &str) -> Value {
    mobpack_schema_response()["mob_definition"]["editor_graph_draft"][name].clone()
}

fn graph_draft_string(name: &str) -> String {
    graph_draft_value(name)
        .as_str()
        .unwrap_or_default()
        .to_string()
}

fn graph_draft_lane_label(index: usize) -> String {
    graph_draft_value("parallel_lane_labels")
        .as_array()
        .and_then(|items| items.get(index))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string()
}

fn graph_launch_mode_default() -> String {
    match mob_definition_default("launch_mode").as_str() {
        "fresh" | "Fresh" => "Fresh".to_string(),
        "resume" | "Resume" => "Resume".to_string(),
        "fork" | "Fork" => "Fork".to_string(),
        other => other.to_string(),
    }
}

fn graph_quick_insert_from_pick(
    document: &MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<(Vec<Value>, Vec<Value>, String), String> {
    let pick = operation
        .get("pick")
        .and_then(Value::as_object)
        .ok_or_else(|| "insert_graph_node semantic insert requires pick".to_string())?;
    let kind = pick
        .get("kind")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "insert_graph_node pick.kind is required".to_string())?;
    let (col, row) = graph_operation_cell(operation)?;
    let instances = document
        .instances
        .as_array()
        .map(Vec::as_slice)
        .unwrap_or(&[]);
    let edges = document.edges.as_array().map(Vec::as_slice).unwrap_or(&[]);
    if kind == "memberInstance" || kind == "member" {
        let member_id = pick
            .get("memberId")
            .or_else(|| pick.get("member_id"))
            .or_else(|| pick.get("id"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| "insert_graph_node member pick requires memberId".to_string())?;
        if !graph_member_ids(&document.members).contains(member_id) {
            return Err(format!(
                "member graph node must reference an existing member: {member_id}"
            ));
        }
        let instance = json!({
            "id": unique_graph_instance_id(&format!("i_{member_id}"), instances),
            "memberId": member_id,
            "col": col,
            "row": row,
            "launchMode": { "kind": graph_launch_mode_default() },
            "lane": "",
        });
        let id = instance["id"].as_str().unwrap_or_default().to_string();
        return Ok((vec![instance], Vec::new(), id));
    }
    if kind != "gate" {
        return Err(format!("unsupported insert_graph_node pick.kind: {kind}"));
    }
    let gate_kind = pick
        .get("gateKind")
        .or_else(|| pick.get("gate_kind"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "insert_graph_node gate pick requires gateKind".to_string())?;
    if gate_kind != "branch" && gate_kind != "fork" {
        return Err(format!(
            "unsupported insert_graph_node gateKind: {gate_kind}"
        ));
    }
    let members = document
        .members
        .as_array()
        .map(Vec::as_slice)
        .unwrap_or(&[]);
    let first_member = members
        .first()
        .and_then(|member| member.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .ok_or_else(|| "insert_graph_node gate pick requires at least one member".to_string())?;
    let second_member = members
        .get(1)
        .and_then(|member| member.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .unwrap_or(first_member);
    let is_branch = gate_kind == "branch";
    let suffix = unique_graph_control_suffix(gate_kind, instances, edges);
    let gate_id = if is_branch {
        format!("g_branch_{suffix}")
    } else {
        format!("g_parallel_{suffix}")
    };
    let left_id = format!("{gate_id}_a");
    let right_id = format!("{gate_id}_b");
    let join_id = if is_branch {
        format!("j_branch_{suffix}")
    } else {
        format!("j_parallel_{suffix}")
    };
    let [
        (gate_col, gate_row),
        (left_col, left_row),
        (right_col, right_row),
        (join_col, join_row),
    ] = allocate_graph_control_cells(instances, col, row);
    let launch_kind = graph_launch_mode_default();
    let next_edge_kind = mob_definition_default("graph_edge_kind");
    let collection = if is_branch {
        "any".to_string()
    } else {
        mob_definition_default("collection_policy")
    };
    let dispatch = if is_branch {
        String::new()
    } else {
        mob_definition_default("dispatch_mode")
    };
    let branch_gate_label;
    let gate_label = if is_branch {
        branch_gate_label = graph_draft_string("branch_gate_label");
        branch_gate_label.as_str()
    } else {
        dispatch.as_str()
    };
    let mut gate = json!({
        "id": gate_id,
        "isGate": true,
        "gateKind": gate_kind,
        "label": gate_label,
        "col": gate_col,
        "row": gate_row,
    });
    if !is_branch {
        gate["dispatch"] = Value::String(dispatch);
    }
    let instances_out = vec![
        gate,
        json!({
            "id": left_id,
            "memberId": first_member,
            "col": left_col,
            "row": left_row,
            "lane": if is_branch { graph_draft_string("branch_condition_lane_label") } else { graph_draft_lane_label(0) },
            "launchMode": { "kind": launch_kind },
        }),
        json!({
            "id": right_id,
            "memberId": second_member,
            "col": right_col,
            "row": right_row,
            "lane": if is_branch { graph_draft_string("branch_fallback_lane_label") } else { graph_draft_lane_label(1) },
            "launchMode": { "kind": launch_kind },
        }),
        json!({
            "id": join_id,
            "isGate": true,
            "gateKind": "join",
            "label": if is_branch { graph_draft_string("branch_join_label") } else { format!("{}{}", graph_draft_string("join_label_prefix"), collection) },
            "collection": collection,
            "controllerRole": if is_branch { first_member } else { "" },
            "col": join_col,
            "row": join_row,
        }),
    ];
    let edges_out = if is_branch {
        vec![
            json!({ "id": format!("e_{gate_id}_{left_id}"), "from": gate_id, "to": left_id, "kind": mob_definition_default("graph_condition_edge_kind"), "label": "", "cond": Value::Null }),
            json!({ "id": format!("e_{gate_id}_{right_id}"), "from": gate_id, "to": right_id, "kind": next_edge_kind, "label": graph_draft_string("fallback_edge_label") }),
            json!({ "id": format!("e_{left_id}_{join_id}"), "from": left_id, "to": join_id, "kind": next_edge_kind, "label": "" }),
            json!({ "id": format!("e_{right_id}_{join_id}"), "from": right_id, "to": join_id, "kind": next_edge_kind, "label": "" }),
        ]
    } else {
        let fanout_edge_kind = mob_definition_default("graph_fanout_edge_kind");
        vec![
            json!({ "id": format!("e_{gate_id}_{left_id}"), "from": gate_id, "to": left_id, "kind": fanout_edge_kind, "label": "" }),
            json!({ "id": format!("e_{gate_id}_{right_id}"), "from": gate_id, "to": right_id, "kind": fanout_edge_kind, "label": "" }),
            json!({ "id": format!("e_{left_id}_{join_id}"), "from": left_id, "to": join_id, "kind": next_edge_kind, "label": "" }),
            json!({ "id": format!("e_{right_id}_{join_id}"), "from": right_id, "to": join_id, "kind": next_edge_kind, "label": "" }),
        ]
    };
    Ok((instances_out, edges_out, gate_id))
}

fn graph_endpoint_id(
    operation: &serde_json::Map<String, Value>,
    snake: &str,
    camel: &str,
    legacy: &str,
) -> Result<String, String> {
    operation
        .get(snake)
        .or_else(|| operation.get(camel))
        .or_else(|| operation.get(legacy))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| format!("connect_graph_nodes requires {snake}"))
}

fn graph_connection_edge_from_endpoints(
    operation: &serde_json::Map<String, Value>,
    instances: &[Value],
    edges: &[Value],
) -> Result<Value, String> {
    let from_id = graph_endpoint_id(operation, "from_id", "fromId", "from")?;
    let to_id = graph_endpoint_id(operation, "to_id", "toId", "to")?;
    if from_id == to_id {
        return Err("edge endpoints must be different graph nodes".to_string());
    }
    let from = instances
        .iter()
        .find(|instance| instance.get("id").and_then(Value::as_str) == Some(from_id.as_str()))
        .ok_or_else(|| "edge endpoints must reference existing graph nodes".to_string())?;
    let to = instances
        .iter()
        .find(|instance| instance.get("id").and_then(Value::as_str) == Some(to_id.as_str()))
        .ok_or_else(|| "edge endpoints must reference existing graph nodes".to_string())?;
    let default_kind = mob_definition_default("graph_edge_kind");
    let fanout_kind = mob_definition_default("graph_fanout_edge_kind");
    let condition_kind = mob_definition_default("graph_condition_edge_kind");
    let mut kind = default_kind;
    let mut label = String::new();
    if to
        .get("isTerminal")
        .and_then(Value::as_bool)
        .unwrap_or(false)
    {
        label = format!(
            "{}{}",
            graph_draft_string("terminal_edge_label_prefix"),
            to.get("label")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_lowercase()
        );
    } else if from.get("isGate").and_then(Value::as_bool).unwrap_or(false)
        && from.get("gateKind").and_then(Value::as_str) == Some("fork")
    {
        kind = fanout_kind;
    } else if to.get("isGate").and_then(Value::as_bool).unwrap_or(false)
        && to.get("gateKind").and_then(Value::as_str) == Some("join")
    {
        // Keep the default next edge kind.
    } else if to.get("col").and_then(Value::as_i64) == from.get("col").and_then(Value::as_i64) {
        kind = fanout_kind;
        label = graph_draft_string("parallel_edge_label");
    } else if to.get("col").and_then(Value::as_i64).unwrap_or(0)
        < from.get("col").and_then(Value::as_i64).unwrap_or(0)
    {
        kind = condition_kind;
        label = graph_draft_string("rework_edge_label");
    }
    Ok(json!({
        "id": operation
            .get("edge_id")
            .or_else(|| operation.get("edgeId"))
            .or_else(|| operation.get("id"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
            .unwrap_or_else(|| unique_graph_edge_id(&from_id, &to_id, edges)),
        "from": from_id,
        "to": to_id,
        "kind": kind,
        "label": label,
    }))
}

fn reject_raw_graph_operation_sections(
    operation: &serde_json::Map<String, Value>,
    fields: &[&str],
) -> Result<(), String> {
    for field in fields {
        if operation.get(*field).is_some() {
            return Err(format!(
                "graph authoring operations do not accept raw {field}; use semantic graph operations"
            ));
        }
    }
    Ok(())
}

fn apply_insert_graph_node_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    if operation.get("instance").is_some() {
        return Err(
            "insert_graph_node does not accept raw instance; use semantic pick and cell"
                .to_string(),
        );
    }
    if operation.get("instances").is_some() {
        return Err(
            "insert_graph_node does not accept raw instances array; use semantic pick and cell"
                .to_string(),
        );
    }
    let mut incoming_instances = Vec::new();
    let mut incoming_edges = Vec::new();
    let mut semantic_selection = None;
    if operation.get("pick").is_some() {
        let (instances, edges, selection) = graph_quick_insert_from_pick(document, operation)?;
        incoming_instances.extend(instances);
        incoming_edges.extend(edges);
        semantic_selection = Some(selection);
    }
    if incoming_instances.is_empty() {
        return Err("insert_graph_node requires semantic pick and cell".to_string());
    }
    let mut instances = document
        .instances
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let mut inserted_ids = Vec::new();
    for instance in incoming_instances {
        let id = validate_graph_instance(&instance, &instances, &document.members, None)?;
        inserted_ids.push(id);
        instances.push(instance);
    }
    let selected_id = operation
        .get("selection")
        .and_then(Value::as_object)
        .and_then(|selection| selection.get("id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .or_else(|| inserted_ids.first().cloned())
        .unwrap_or_default();
    document.instances = Value::Array(instances.clone());
    if !incoming_edges.is_empty() {
        let mut edges = document.edges.as_array().cloned().unwrap_or_else(Vec::new);
        for edge in incoming_edges {
            validate_graph_edge(&edge, &edges, &instances, None)?;
            edges.push(edge);
        }
        document.edges = Value::Array(edges);
    }
    reject_raw_graph_operation_sections(operation, &["frames"])?;
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    let selected_id = semantic_selection.unwrap_or(selected_id);
    Ok(json!({ "kind": "instance", "id": selected_id, "inserted_ids": inserted_ids }))
}

fn operation_action(operation: &serde_json::Map<String, Value>) -> Result<String, String> {
    operation
        .get("action")
        .or_else(|| operation.get("field"))
        .or_else(|| operation.get("edit"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "MobKit semantic graph edit requires action".to_string())
}

fn operation_string_value(operation: &serde_json::Map<String, Value>, fields: &[&str]) -> String {
    fields
        .iter()
        .find_map(|field| operation.get(*field))
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

// Free-text fields are echoed back to live inputs on every keystroke, so a
// trim here would erase the trailing space the user just typed.
fn operation_text_value(operation: &serde_json::Map<String, Value>, fields: &[&str]) -> String {
    fields
        .iter()
        .find_map(|field| operation.get(*field))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string()
}

fn graph_node_bool(instance: &Value, camel: &str, snake: &str) -> bool {
    instance
        .get(camel)
        .or_else(|| instance.get(snake))
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn graph_node_member_id(instance: &Value) -> String {
    instance
        .get("memberId")
        .or_else(|| instance.get("member_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

fn canonical_graph_launch_kind(value: &str) -> String {
    match value.trim().to_ascii_lowercase().as_str() {
        "fresh" => "Fresh".to_string(),
        "resume" => "Resume".to_string(),
        "fork" => "Fork".to_string(),
        other => other.to_string(),
    }
}

fn graph_launch_kind_allowed(kind: &str) -> bool {
    let lower = kind.trim().to_ascii_lowercase();
    member_launch_mode_values()
        .iter()
        .any(|allowed| allowed == &lower)
}

fn canonical_graph_budget_kind(value: &str) -> String {
    match value.trim().to_ascii_lowercase().as_str() {
        "equal" => "Equal".to_string(),
        "proportional" => "Proportional".to_string(),
        "remaining" => "Remaining".to_string(),
        "fixed" => "Fixed".to_string(),
        other => other.to_string(),
    }
}

fn graph_budget_kind_allowed(kind: &str) -> bool {
    let lower = kind.trim().to_ascii_lowercase();
    budget_split_policy_values()
        .iter()
        .any(|allowed| allowed == &lower)
}

fn graph_fork_context_allowed(context: &str) -> bool {
    let value = context.trim();
    value.is_empty() || fork_context_values().iter().any(|allowed| allowed == value)
}

fn graph_first_fork_source(instances: &[Value], instance_id: &str) -> String {
    instances
        .iter()
        .find(|instance| {
            instance
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| id != instance_id)
                && !graph_node_bool(instance, "isTerminal", "is_terminal")
        })
        .and_then(|instance| instance.get("id").and_then(Value::as_str))
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

fn graph_fork_source_allowed(instances: &[Value], instance_id: &str, source_id: &str) -> bool {
    let source_id = source_id.trim();
    source_id.is_empty()
        || instances.iter().any(|instance| {
            instance
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| id == source_id && id != instance_id)
                && !graph_node_bool(instance, "isTerminal", "is_terminal")
        })
}

fn graph_launch_mode_object(instance: &Value) -> serde_json::Map<String, Value> {
    instance
        .get("launchMode")
        .or_else(|| instance.get("launch_mode"))
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_else(|| {
            let mut mode = serde_json::Map::new();
            mode.insert(
                "kind".to_string(),
                Value::String(canonical_graph_launch_kind(&mob_definition_default(
                    "launch_mode",
                ))),
            );
            mode
        })
}

fn graph_launch_budget_patch(mode: &serde_json::Map<String, Value>) -> Option<Value> {
    mode.get("budgetSplitPolicy")
        .or_else(|| mode.get("budget_split_policy"))
        .cloned()
}

fn apply_graph_node_edit_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    reject_raw_graph_operation_sections(operation, &["frames"])?;
    let instance_id = operation_instance_id(operation)?;
    let action = operation_action(operation)?;
    let mut instances = document
        .instances
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = graph_instance_index(&instances, &instance_id)
        .ok_or_else(|| format!("graph node not found: {instance_id}"))?;
    let mut next_instance = instances[index].clone();
    let mut launch_mode = graph_launch_mode_object(&next_instance);
    {
        let object = next_instance
            .as_object_mut()
            .ok_or_else(|| format!("graph node is not an object: {instance_id}"))?;
        match action.as_str() {
            "set_label" => {
                object.insert(
                    "label".to_string(),
                    Value::String(operation_text_value(operation, &["label", "value"])),
                );
            }
            "set_gate_kind" => {
                let kind =
                    operation_string_value(operation, &["gate_kind", "gateKind", "kind", "value"]);
                if !GRAPH_GATE_KINDS.contains(&kind.as_str()) {
                    return Err(format!("unsupported graph gate kind: {kind}"));
                }
                object.insert("gateKind".to_string(), Value::String(kind));
            }
            "set_terminal_kind" => {
                let kind = operation_string_value(
                    operation,
                    &["terminal_kind", "terminalKind", "kind", "value"],
                );
                if !GRAPH_TERMINAL_KINDS.contains(&kind.as_str()) {
                    return Err(format!("unsupported graph terminal kind: {kind}"));
                }
                object.insert("kind".to_string(), Value::String(kind));
            }
            "set_join_collection" => {
                let collection = operation_string_value(operation, &["collection", "value"]);
                if !collection.is_empty()
                    && !collection_policy_values()
                        .iter()
                        .any(|allowed| allowed == &collection)
                {
                    return Err(format!("unsupported collection policy: {collection}"));
                }
                let incoming_count = document
                    .edges
                    .as_array()
                    .into_iter()
                    .flatten()
                    .filter(|edge| {
                        edge.get("to").and_then(Value::as_str) == Some(instance_id.as_str())
                    })
                    .count()
                    .max(1);
                let first_member_id = document
                    .members
                    .as_array()
                    .into_iter()
                    .flatten()
                    .find_map(|member| member.get("id").and_then(Value::as_str))
                    .map(str::trim)
                    .unwrap_or_default()
                    .to_string();
                object.insert("collection".to_string(), Value::String(collection.clone()));
                let missing_collection_label;
                let join_collection_label = if collection.is_empty() {
                    missing_collection_label =
                        graph_draft_string("parallel_missing_collection_label");
                    missing_collection_label.as_str()
                } else {
                    collection.as_str()
                };
                object.insert(
                    "label".to_string(),
                    Value::String(format!(
                        "{}{}",
                        graph_draft_string("join_label_prefix"),
                        join_collection_label
                    )),
                );
                if collection == "quorum" {
                    let n = object
                        .get("quorum")
                        .and_then(Value::as_object)
                        .and_then(|quorum| quorum.get("n"))
                        .and_then(Value::as_i64)
                        .unwrap_or(incoming_count as i64)
                        .max(1);
                    object.insert("quorum".to_string(), json!({ "n": n, "m": incoming_count }));
                } else {
                    object.insert("quorum".to_string(), Value::Null);
                }
                object.insert(
                    "controllerRole".to_string(),
                    Value::String(if !collection.is_empty() && collection != "all" {
                        object
                            .get("controllerRole")
                            .and_then(Value::as_str)
                            .map(str::trim)
                            .filter(|role| !role.is_empty())
                            .unwrap_or(first_member_id.as_str())
                            .to_string()
                    } else {
                        String::new()
                    }),
                );
            }
            "set_join_quorum" => {
                let n = positive_integer_or_default(
                    operation.get("n").or_else(|| operation.get("value")),
                    "n",
                    1,
                    "set_join_quorum",
                )?;
                let incoming_count = document
                    .edges
                    .as_array()
                    .into_iter()
                    .flatten()
                    .filter(|edge| {
                        edge.get("to").and_then(Value::as_str) == Some(instance_id.as_str())
                    })
                    .count()
                    .max(1);
                object.insert("quorum".to_string(), json!({ "n": n, "m": incoming_count }));
            }
            "set_join_controller_role" => {
                let role = operation_string_value(
                    operation,
                    &["controller_role", "controllerRole", "role", "value"],
                );
                if !role.is_empty() && !graph_member_ids(&document.members).contains(&role) {
                    return Err(format!(
                        "join controller role must reference an existing member: {role}"
                    ));
                }
                object.insert("controllerRole".to_string(), Value::String(role));
            }
            "set_fork_dispatch" => {
                let dispatch = operation_string_value(operation, &["dispatch", "value"]);
                if !dispatch.is_empty()
                    && !dispatch_mode_values()
                        .iter()
                        .any(|allowed| allowed == &dispatch)
                {
                    return Err(format!("unsupported dispatch mode: {dispatch}"));
                }
                object.insert("dispatch".to_string(), Value::String(dispatch.clone()));
                object.insert("label".to_string(), Value::String(dispatch));
            }
            "set_launch_kind" => {
                let kind = canonical_graph_launch_kind(&operation_string_value(
                    operation,
                    &["kind", "value"],
                ));
                if !graph_launch_kind_allowed(&kind) {
                    return Err(format!("unsupported launch mode: {kind}"));
                }
                let budget = graph_launch_budget_patch(&launch_mode);
                let mut next = serde_json::Map::new();
                next.insert("kind".to_string(), Value::String(kind.clone()));
                if kind == "Fork" {
                    let from = operation_string_value(
                        operation,
                        &["first_fork_source_id", "firstForkSourceId", "from"],
                    );
                    let from = if from.is_empty() {
                        launch_mode
                            .get("from")
                            .and_then(Value::as_str)
                            .map(str::trim)
                            .filter(|value| !value.is_empty())
                            .map(ToString::to_string)
                            .unwrap_or_else(|| graph_first_fork_source(&instances, &instance_id))
                    } else {
                        from
                    };
                    if !graph_fork_source_allowed(&instances, &instance_id, &from) {
                        return Err(format!(
                            "launch fork source must reference another graph node: {from}"
                        ));
                    }
                    next.insert("from".to_string(), Value::String(from));
                    let context = launch_mode
                        .get("context")
                        .and_then(Value::as_str)
                        .map(str::trim)
                        .filter(|value| !value.is_empty())
                        .map(ToString::to_string)
                        .unwrap_or_else(|| mob_definition_default("fork_context"));
                    next.insert("context".to_string(), Value::String(context));
                } else if kind == "Resume" {
                    next.insert(
                        "sessionId".to_string(),
                        Value::String(
                            launch_mode
                                .get("sessionId")
                                .or_else(|| launch_mode.get("session_id"))
                                .and_then(Value::as_str)
                                .unwrap_or_default()
                                .to_string(),
                        ),
                    );
                }
                if let Some(budget) = budget {
                    next.insert("budgetSplitPolicy".to_string(), budget);
                }
                object.insert("launchMode".to_string(), Value::Object(next));
            }
            "set_launch_session" => {
                launch_mode.insert(
                    "sessionId".to_string(),
                    Value::String(operation_string_value(
                        operation,
                        &["session_id", "sessionId", "value"],
                    )),
                );
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_fork_source" => {
                let from =
                    operation_string_value(operation, &["from", "source_id", "sourceId", "value"]);
                if !graph_fork_source_allowed(&instances, &instance_id, &from) {
                    return Err(format!(
                        "launch fork source must reference another graph node: {from}"
                    ));
                }
                launch_mode.insert("from".to_string(), Value::String(from));
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_fork_context" => {
                let context = operation_string_value(operation, &["context", "value"]);
                if !graph_fork_context_allowed(&context) {
                    return Err(format!("unsupported fork context: {context}"));
                }
                launch_mode.insert("context".to_string(), Value::String(context));
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_budget_kind" => {
                let kind = canonical_graph_budget_kind(&operation_string_value(
                    operation,
                    &["budget_kind", "budgetKind", "kind", "value"],
                ));
                if !graph_budget_kind_allowed(&kind) {
                    return Err(format!("unsupported budget split policy: {kind}"));
                }
                let policy = if kind == "Fixed" {
                    json!({ "kind": "Fixed", "limit": 4096 })
                } else {
                    json!({ "kind": kind })
                };
                launch_mode.insert("budgetSplitPolicy".to_string(), policy);
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            "set_launch_budget_limit" => {
                let limit = positive_integer_or_default(
                    operation.get("limit").or_else(|| operation.get("value")),
                    "limit",
                    4096,
                    "set_launch_budget_limit",
                )?;
                launch_mode.insert(
                    "budgetSplitPolicy".to_string(),
                    json!({ "kind": "Fixed", "limit": limit }),
                );
                object.insert("launchMode".to_string(), Value::Object(launch_mode));
            }
            other => return Err(format!("unsupported apply_graph_node_edit action: {other}")),
        }
    }
    validate_graph_instance(
        &next_instance,
        &instances,
        &document.members,
        Some(&instance_id),
    )?;
    instances[index] = next_instance;
    document.instances = Value::Array(instances);
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    Ok(json!({ "kind": "instance", "id": instance_id }))
}

fn apply_move_graph_node_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let instance_id = operation_instance_id(operation)?;
    let cell = operation
        .get("cell")
        .and_then(Value::as_object)
        .or_else(|| operation.get("patch").and_then(Value::as_object))
        .ok_or_else(|| "move_graph_node requires cell object".to_string())?;
    let next_col = cell
        .get("col")
        .and_then(Value::as_i64)
        .ok_or_else(|| "move_graph_node requires integer cell.col".to_string())?;
    let next_row = cell
        .get("row")
        .and_then(Value::as_i64)
        .ok_or_else(|| "move_graph_node requires integer cell.row".to_string())?;
    let mut instances = document
        .instances
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = graph_instance_index(&instances, &instance_id)
        .ok_or_else(|| format!("graph node not found: {instance_id}"))?;
    let original_cell = operation_cell_field(operation, "original_cell")
        .or_else(|| operation_cell_field(operation, "originalCell"));
    let original_col = original_cell
        .and_then(|cell| cell.get("col").and_then(Value::as_i64))
        .or_else(|| instances[index].get("col").and_then(Value::as_i64))
        .unwrap_or(next_col);
    let original_row = original_cell
        .and_then(|cell| cell.get("row").and_then(Value::as_i64))
        .or_else(|| instances[index].get("row").and_then(Value::as_i64))
        .unwrap_or(next_row);
    let occupant_id = instances.iter().find_map(|instance| {
        let id = instance.get("id").and_then(Value::as_str)?;
        if id == instance_id {
            return None;
        }
        let col = instance.get("col").and_then(Value::as_i64)?;
        let row = instance.get("row").and_then(Value::as_i64)?;
        (col == next_col && row == next_row).then(|| id.to_string())
    });
    for instance in &mut instances {
        let Some(object) = instance.as_object_mut() else {
            continue;
        };
        let id = object
            .get("id")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
        if id == instance_id {
            object.insert("col".to_string(), json!(next_col));
            object.insert("row".to_string(), json!(next_row));
        } else if occupant_id.as_deref() == Some(id.as_str()) {
            object.insert("col".to_string(), json!(original_col));
            object.insert("row".to_string(), json!(original_row));
        }
    }
    document.instances = Value::Array(instances);
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    Ok(json!({ "kind": "instance", "id": instance_id }))
}

fn clear_deleted_launch_source(source: &mut Value, deleted_id: &str) {
    let Some(object) = source.as_object_mut() else {
        return;
    };
    let launch_key = if object.contains_key("launchMode") {
        "launchMode"
    } else if object.contains_key("launch_mode") {
        "launch_mode"
    } else {
        return;
    };
    let Some(mode) = object.get(launch_key).and_then(Value::as_object) else {
        return;
    };
    let kind = mode
        .get("kind")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .trim();
    let from = mode
        .get("from")
        .or_else(|| mode.get("sourceMemberId"))
        .or_else(|| mode.get("source_member_id"))
        .and_then(Value::as_str)
        .unwrap_or_default()
        .trim();
    if kind != "Fork" || from != deleted_id {
        return;
    }
    let mut next = serde_json::Map::new();
    next.insert("kind".to_string(), json!("Fresh"));
    if let Some(policy) = mode
        .get("budgetSplitPolicy")
        .or_else(|| mode.get("budget_split_policy"))
        .or_else(|| mode.get("budget"))
    {
        next.insert("budgetSplitPolicy".to_string(), policy.clone());
    }
    object.insert(launch_key.to_string(), Value::Object(next));
}

fn clear_deleted_graph_condition_edges(edges: &mut [Value], deleted_id: &str) {
    for edge in edges {
        let Some(cond) = edge.get("cond") else {
            continue;
        };
        let path = cond
            .get("var")
            .or_else(|| cond.get("path"))
            .and_then(Value::as_str)
            .unwrap_or_default();
        let mut parts = path.split('.');
        let namespace = parts.next().unwrap_or_default();
        let step_id = parts.next().unwrap_or_default();
        let field = parts.next().unwrap_or_default();
        if namespace == "steps"
            && step_id == deleted_id
            && !field.is_empty()
            && parts.next().is_none()
        {
            edge["cond"] = Value::Null;
            edge["label"] = json!("");
        }
    }
}

fn apply_delete_graph_node_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let instance_id = operation_instance_id(operation)?;
    let mut instances = document
        .instances
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    if graph_instance_index(&instances, &instance_id).is_none() {
        return Err(format!("graph node not found: {instance_id}"));
    }
    instances.retain(|instance| {
        instance
            .get("id")
            .and_then(Value::as_str)
            .map(|id| id != instance_id)
            .unwrap_or(true)
    });
    for instance in &mut instances {
        clear_deleted_launch_source(instance, &instance_id);
    }
    let mut edges = document.edges.as_array().cloned().unwrap_or_else(Vec::new);
    edges.retain(|edge| {
        let from = edge.get("from").and_then(Value::as_str).unwrap_or_default();
        let to = edge.get("to").and_then(Value::as_str).unwrap_or_default();
        from != instance_id && to != instance_id
    });
    clear_deleted_graph_condition_edges(&mut edges, &instance_id);
    document.instances = Value::Array(instances);
    document.edges = Value::Array(edges);
    reject_raw_graph_operation_sections(operation, &["frames"])?;
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    Ok(json!({ "kind": null, "id": null }))
}

fn apply_connect_graph_nodes_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    if operation.get("edge").is_some() {
        return Err(
            "connect_graph_nodes does not accept raw edge; use from_id and to_id".to_string(),
        );
    }
    let instances = document
        .instances
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let mut edges = document.edges.as_array().cloned().unwrap_or_else(Vec::new);
    let edge = graph_connection_edge_from_endpoints(operation, &instances, &edges)?;
    let id = validate_graph_edge(&edge, &edges, &instances, None)?;
    edges.push(edge);
    document.edges = Value::Array(edges);
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    reject_raw_graph_operation_sections(operation, &["frames"])?;
    Ok(json!({ "kind": "edge", "id": id }))
}

fn graph_edge_condition_path(edge: &Value) -> String {
    graph_to_flow_normalized_edge_condition(edge)
        .map(|(path, _, _)| path)
        .unwrap_or_default()
}

fn graph_edge_condition_value(edge: &Value) -> Value {
    edge.get("cond")
        .and_then(Value::as_object)
        .and_then(|cond| cond.get("val").or_else(|| cond.get("value")))
        .cloned()
        .unwrap_or_else(|| Value::String(String::new()))
}

fn graph_condition_label(path: &str, op: &str, value: &Value) -> String {
    if path.is_empty() || op.is_empty() {
        return String::new();
    }
    format!(
        "{path} {op} {}",
        serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
    )
}

fn graph_edge_condition_source(edge: &Value) -> (String, String) {
    let path = graph_edge_condition_path(edge);
    let mut parts = path.split('.');
    let namespace = parts.next().unwrap_or_default();
    let source_id = parts.next().unwrap_or_default();
    let field = parts.next().unwrap_or_default();
    if !namespace.is_empty() && !source_id.is_empty() && !field.is_empty() && parts.next().is_none()
    {
        return (source_id.to_string(), field.to_string());
    }
    (String::new(), String::new())
}

fn graph_source_schema_id(document: &MobpackDocument, source_id: &str) -> String {
    let member_schemas = member_schema_index(&document.members);
    document
        .instances
        .as_array()
        .into_iter()
        .flatten()
        .find(|instance| instance.get("id").and_then(Value::as_str) == Some(source_id))
        .map(graph_node_member_id)
        .and_then(|member_id| member_schemas.get(&member_id).cloned())
        .unwrap_or_default()
}

fn graph_first_condition_field(document: &MobpackDocument, source_id: &str) -> String {
    if source_id == "params" {
        return input_param_field_names(&document.flow)
            .into_iter()
            .next()
            .unwrap_or_default();
    }
    let schema_id = graph_source_schema_id(document, source_id);
    document
        .schemas
        .as_array()
        .into_iter()
        .flatten()
        .find(|schema| schema.get("id").and_then(Value::as_str) == Some(schema_id.as_str()))
        .and_then(|schema| schema.get("fields").and_then(Value::as_array))
        .into_iter()
        .flatten()
        .find_map(|field| field.get("name").and_then(Value::as_str))
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

fn graph_condition_source_allowed(document: &MobpackDocument, source_id: &str) -> bool {
    if source_id == "params" {
        return !input_param_field_names(&document.flow).is_empty();
    }
    !graph_source_schema_id(document, source_id).is_empty()
}

fn graph_condition_field_allowed(document: &MobpackDocument, source_id: &str, field: &str) -> bool {
    let member_schemas = member_schema_index(&document.members);
    let graph_step_schemas = graph_step_schema_index(&document.instances, &member_schemas);
    let input_params = input_param_field_names(&document.flow);
    let namespace = if source_id == "params" {
        "params"
    } else {
        "steps"
    };
    condition_field_available(
        namespace,
        source_id,
        field,
        &graph_step_schemas,
        &document.schemas,
        &input_params,
    )
}

fn graph_default_condition_source(document: &MobpackDocument) -> String {
    if !input_param_field_names(&document.flow).is_empty() {
        return "params".to_string();
    }
    document
        .instances
        .as_array()
        .into_iter()
        .flatten()
        .find_map(|instance| {
            let id = instance.get("id").and_then(Value::as_str)?.trim();
            graph_condition_source_allowed(document, id).then(|| id.to_string())
        })
        .unwrap_or_default()
}

fn graph_condition_path(source_id: &str, field: &str) -> String {
    if source_id.trim().is_empty() || field.trim().is_empty() {
        return String::new();
    }
    if source_id == "params" {
        format!("params.{}", field.trim())
    } else {
        format!("steps.{}.{}", source_id.trim(), field.trim())
    }
}

fn graph_apply_condition(
    edge: &mut Value,
    path: String,
    op: String,
    val: Value,
    force_label: bool,
) {
    let previous_path = graph_edge_condition_path(edge);
    let previous = graph_to_flow_normalized_edge_condition(edge)
        .map(|(path, op, val)| graph_condition_label(&path, &op, &val))
        .unwrap_or_default();
    let current_label = edge
        .get("label")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_string();
    let should_replace_label = force_label || current_label.is_empty() || current_label == previous;
    if path.is_empty() {
        edge["cond"] = json!({ "var": "" });
        edge["label"] = Value::String(String::new());
        return;
    }
    edge["cond"] = json!({ "var": path, "op": op, "val": val });
    if should_replace_label || previous_path.is_empty() {
        let Some(cond) = edge.get("cond") else {
            return;
        };
        let label = graph_to_flow_normalized_edge_condition(&json!({ "cond": cond }))
            .map(|(path, op, val)| graph_condition_label(&path, &op, &val))
            .unwrap_or_default();
        edge["label"] = Value::String(label);
    }
}

fn graph_edge_kind_for_condition() -> String {
    mob_definition_default("graph_condition_edge_kind")
}

fn apply_graph_edge_edit_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    reject_raw_graph_operation_sections(operation, &["frames"])?;
    let edge_id = operation_edge_id(operation)?;
    let action = operation_action(operation)?;
    let instances = document
        .instances
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let mut edges = document.edges.as_array().cloned().unwrap_or_else(Vec::new);
    let index = graph_edge_index(&edges, &edge_id)
        .ok_or_else(|| format!("graph edge not found: {edge_id}"))?;
    let mut next_edge = edges[index].clone();
    match action.as_str() {
        "set_label" => {
            next_edge["label"] =
                Value::String(operation_text_value(operation, &["label", "value"]));
        }
        "set_kind" => {
            let kind =
                operation_string_value(operation, &["edge_kind", "edgeKind", "kind", "value"]);
            if !GRAPH_EDGE_KINDS.contains(&kind.as_str()) {
                return Err(format!("unsupported graph edge kind: {kind}"));
            }
            let condition_kind = graph_edge_kind_for_condition();
            if kind == condition_kind {
                let (source, field) = graph_edge_condition_source(&next_edge);
                let source = if source.is_empty() {
                    graph_default_condition_source(document)
                } else {
                    source
                };
                let field = if field.is_empty() {
                    graph_first_condition_field(document, &source)
                } else {
                    field
                };
                let path = graph_condition_path(&source, &field);
                let val = graph_edge_condition_value(&next_edge);
                next_edge["kind"] = Value::String(condition_kind);
                graph_apply_condition(
                    &mut next_edge,
                    path,
                    mob_definition_default("condition_operator"),
                    val,
                    true,
                );
            } else {
                let previous = graph_to_flow_normalized_edge_condition(&next_edge)
                    .map(|(path, op, val)| graph_condition_label(&path, &op, &val))
                    .unwrap_or_default();
                let current_label = next_edge
                    .get("label")
                    .and_then(Value::as_str)
                    .unwrap_or_default()
                    .to_string();
                next_edge["kind"] = Value::String(kind);
                next_edge["cond"] = Value::Null;
                if !previous.is_empty() && current_label == previous {
                    next_edge["label"] = Value::String(String::new());
                }
            }
        }
        "set_condition_mode" => {
            let mode = operation_string_value(operation, &["mode", "value"]);
            if mode == "fallback" {
                next_edge["kind"] = Value::String(mob_definition_default("graph_edge_kind"));
                next_edge["label"] = Value::String(graph_draft_string("fallback_edge_label"));
                next_edge["cond"] = Value::Null;
            } else {
                let condition_kind = graph_edge_kind_for_condition();
                if mode != condition_kind {
                    return Err(format!("unsupported graph branch condition mode: {mode}"));
                }
                let source = operation_string_value(
                    operation,
                    &[
                        "owner_instance_id",
                        "ownerInstanceId",
                        "source_id",
                        "sourceId",
                    ],
                );
                let source = if source.is_empty() {
                    graph_default_condition_source(document)
                } else {
                    source
                };
                if !graph_condition_source_allowed(document, &source) {
                    return Err(format!(
                        "condition source must expose an output field: {source}"
                    ));
                }
                let field = graph_first_condition_field(document, &source);
                let path = graph_condition_path(&source, &field);
                let val = graph_edge_condition_value(&next_edge);
                next_edge["kind"] = Value::String(condition_kind);
                graph_apply_condition(
                    &mut next_edge,
                    path,
                    mob_definition_default("condition_operator"),
                    val,
                    true,
                );
            }
        }
        "set_condition_owner" => {
            let source = operation_string_value(
                operation,
                &[
                    "owner_instance_id",
                    "ownerInstanceId",
                    "source_id",
                    "sourceId",
                    "value",
                ],
            );
            if !graph_condition_source_allowed(document, &source) {
                return Err(format!(
                    "condition source must expose an output field: {source}"
                ));
            }
            let field = graph_first_condition_field(document, &source);
            let path = graph_condition_path(&source, &field);
            let op = graph_to_flow_normalized_edge_condition(&next_edge)
                .map(|(_, op, _)| op)
                .unwrap_or_else(|| mob_definition_default("condition_operator"));
            let val = graph_edge_condition_value(&next_edge);
            next_edge["kind"] = Value::String(graph_edge_kind_for_condition());
            graph_apply_condition(&mut next_edge, path, op, val, true);
        }
        "set_condition_field" => {
            let field =
                operation_string_value(operation, &["field_name", "fieldName", "field", "value"]);
            let (mut source, _) = graph_edge_condition_source(&next_edge);
            if source.is_empty() {
                source = graph_default_condition_source(document);
            }
            if !graph_condition_field_allowed(document, &source, &field) {
                return Err(format!(
                    "condition field is not available on source {source}: {field}"
                ));
            }
            let op = graph_to_flow_normalized_edge_condition(&next_edge)
                .map(|(_, op, _)| op)
                .unwrap_or_else(|| mob_definition_default("condition_operator"));
            let val = graph_edge_condition_value(&next_edge);
            next_edge["kind"] = Value::String(graph_edge_kind_for_condition());
            graph_apply_condition(
                &mut next_edge,
                graph_condition_path(&source, &field),
                op,
                val,
                true,
            );
        }
        "set_condition_operator" => {
            let op = operation_string_value(operation, &["operator", "op", "value"]);
            if !editor_condition_operator_supported(&op) {
                return Err(format!("unsupported condition operator: {op}"));
            }
            let (source, field) = graph_edge_condition_source(&next_edge);
            let val = graph_edge_condition_value(&next_edge);
            graph_apply_condition(
                &mut next_edge,
                graph_condition_path(&source, &field),
                op,
                val,
                false,
            );
        }
        "set_condition_value" => {
            let value = operation
                .get("condition_value")
                .or_else(|| operation.get("conditionValue"))
                .or_else(|| operation.get("value"))
                .cloned()
                .unwrap_or_else(|| Value::String(String::new()));
            let (source, field) = graph_edge_condition_source(&next_edge);
            let op = graph_to_flow_normalized_edge_condition(&next_edge)
                .map(|(_, op, _)| op)
                .unwrap_or_else(|| mob_definition_default("condition_operator"));
            graph_apply_condition(
                &mut next_edge,
                graph_condition_path(&source, &field),
                op,
                value,
                false,
            );
        }
        other => return Err(format!("unsupported apply_graph_edge_edit action: {other}")),
    }
    validate_graph_edge(&next_edge, &edges, &instances, Some(&edge_id))?;
    edges[index] = next_edge;
    document.edges = Value::Array(edges);
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    Ok(json!({ "kind": "edge", "id": edge_id }))
}

fn apply_delete_graph_edge_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let edge_id = operation_edge_id(operation)?;
    let mut edges = document.edges.as_array().cloned().unwrap_or_else(Vec::new);
    let before = edges.len();
    edges.retain(|edge| {
        edge.get("id")
            .and_then(Value::as_str)
            .map(|id| id != edge_id)
            .unwrap_or(true)
    });
    if edges.len() == before {
        return Err(format!("graph edge not found: {edge_id}"));
    }
    document.edges = Value::Array(edges);
    reject_raw_graph_operation_sections(operation, &["frames"])?;
    document.flow = graph_to_flow_from_document(document);
    reconcile_flow_step_launch_modes(document);
    Ok(json!({ "kind": null, "id": null }))
}

fn string_list_from_member_field(member: &Value, field: &str) -> Vec<String> {
    string_vec(member.get(field))
}

fn set_member_string_list_field(member: &mut Value, field: &str, items: Vec<String>) {
    member[field] = Value::Array(items.into_iter().map(Value::String).collect());
}

fn member_index_by_id(members: &[Value], member_id: &str) -> Option<usize> {
    members.iter().position(|member| {
        member
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == member_id)
    })
}

fn apply_update_member_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let member_id = operation_member_id(operation)?;
    let patch = operation
        .get("patch")
        .and_then(Value::as_object)
        .ok_or_else(|| "update_member requires patch object".to_string())?;
    let mut members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = member_index_by_id(&members, &member_id)
        .ok_or_else(|| format!("member not found: {member_id}"))?;
    let member = members[index]
        .as_object_mut()
        .ok_or_else(|| format!("member is not an object: {member_id}"))?;
    let normalized_patch = normalize_member_update_patch(patch)?;
    for (key, value) in normalized_patch {
        member.insert(key.clone(), value.clone());
    }
    document.members = Value::Array(members);
    reconcile_document_after_member_change(document);
    Ok(json!({ "kind": "agent", "id": member_id }))
}

fn normalize_member_update_patch(
    patch: &serde_json::Map<String, Value>,
) -> Result<serde_json::Map<String, Value>, String> {
    let mut normalized = serde_json::Map::new();
    for (key, value) in patch {
        match key.as_str() {
            "id" => return Err("update_member cannot change member id".to_string()),
            "name" | "systemPrompt" => {
                let text = value
                    .as_str()
                    .ok_or_else(|| format!("update_member {key} must be a string"))?;
                normalized.insert(key.clone(), Value::String(text.to_string()));
            }
            "profileBinding" | "profile_binding" => {
                let binding = value
                    .as_str()
                    .ok_or_else(|| "update_member profileBinding must be a string".to_string())?
                    .trim()
                    .to_string();
                if !matches!(binding.as_str(), "inline" | "realm_profile") {
                    return Err(format!(
                        "unsupported update_member profileBinding: {binding}"
                    ));
                }
                normalized.insert("profileBinding".to_string(), Value::String(binding));
            }
            "realmProfile" | "realm_profile" => {
                let realm_profile = value
                    .as_str()
                    .ok_or_else(|| "update_member realmProfile must be a string".to_string())?
                    .trim()
                    .to_string();
                normalized.insert("realmProfile".to_string(), Value::String(realm_profile));
            }
            "runtimeMode" | "runtime_mode" => {
                let runtime_mode = value
                    .as_str()
                    .ok_or_else(|| "update_member runtimeMode must be a string".to_string())?
                    .trim()
                    .to_string();
                if !runtime_mode_values()
                    .iter()
                    .any(|mode| mode == &runtime_mode)
                {
                    return Err(format!(
                        "unsupported update_member runtimeMode: {runtime_mode}"
                    ));
                }
                normalized.insert("runtimeMode".to_string(), Value::String(runtime_mode));
            }
            "model" => {
                let model = value
                    .as_str()
                    .ok_or_else(|| "update_member model must be a string".to_string())?
                    .trim()
                    .to_string();
                if model.is_empty() || !available_model_ids().contains(&model) {
                    return Err(format!("unsupported update_member model: {model}"));
                }
                normalized.insert("model".to_string(), Value::String(model));
            }
            "backend" => {
                if value.is_null() {
                    normalized.insert("backend".to_string(), Value::Null);
                    continue;
                }
                let backend = value
                    .as_str()
                    .ok_or_else(|| "update_member backend must be a string or null".to_string())?
                    .trim()
                    .to_string();
                if !backend.is_empty() && !matches!(backend.as_str(), "session" | "external") {
                    return Err(format!("unsupported update_member backend: {backend}"));
                }
                normalized.insert("backend".to_string(), Value::String(backend));
            }
            "maxInlinePeerNotifications" | "max_inline_peer_notifications" => {
                if value.is_null() {
                    normalized.insert("maxInlinePeerNotifications".to_string(), Value::Null);
                    continue;
                }
                let Some(value) = value.as_i64() else {
                    return Err(
                        "update_member maxInlinePeerNotifications must be an integer or null"
                            .to_string(),
                    );
                };
                if value < -1 || i32::try_from(value).is_err() {
                    return Err(
                        "update_member maxInlinePeerNotifications must be >= -1".to_string()
                    );
                }
                normalized.insert("maxInlinePeerNotifications".to_string(), Value::from(value));
            }
            "providerParams" | "provider_params" => {
                if value.is_null() {
                    normalized.insert("providerParams".to_string(), Value::Null);
                    continue;
                }
                if !value.is_object() {
                    return Err(
                        "update_member providerParams must be a JSON object or null".to_string()
                    );
                }
                normalized.insert("providerParams".to_string(), value.clone());
            }
            "role" | "schema" | "tools" | "skills" => {
                return Err(format!(
                    "update_member cannot mutate {key}; use the dedicated MobKit operation"
                ));
            }
            other => {
                return Err(format!("unsupported update_member patch field: {other}"));
            }
        }
    }
    Ok(normalized)
}

fn apply_member_tool_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
    add: bool,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<Value, String> {
    let member_id = operation_member_id(operation)?;
    let tool_id = operation_string(operation, "tool_id", "toolId")?;
    if add {
        let tools = tool_catalog_response_with_runtime_state(runtime);
        let tool = tool_catalog_by_id(&tools)
            .get(&tool_id)
            .cloned()
            .ok_or_else(|| format!("unknown MobKit tool: {tool_id}"))?;
        if runtime.is_some()
            && !tool
                .get("runtime_availability")
                .or_else(|| tool.get("runtimeAvailability"))
                .and_then(|availability| availability.get("available"))
                .and_then(Value::as_bool)
                .unwrap_or(true)
        {
            return Err(format!(
                "MobKit tool is unavailable in current runtime: {tool_id}"
            ));
        }
    }
    let mut members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = member_index_by_id(&members, &member_id)
        .ok_or_else(|| format!("member not found: {member_id}"))?;
    let member = &mut members[index];
    let mut tools = string_list_from_member_field(member, "tools");
    if add {
        if !tools.iter().any(|tool| tool == &tool_id) {
            tools.push(tool_id);
        }
    } else {
        tools.retain(|tool| tool != &tool_id);
    }
    set_member_string_list_field(member, "tools", tools);
    document.members = Value::Array(members);
    reconcile_document_after_member_change(document);
    Ok(json!({ "kind": "agent", "id": member_id }))
}

enum SkillOperation {
    Toggle,
    Remove,
}

fn apply_member_skill_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
    mode: SkillOperation,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<Value, String> {
    let member_id = operation_member_id(operation)?;
    let skill_id = operation_string(operation, "skill_id", "skillId")?;
    let mut members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = member_index_by_id(&members, &member_id)
        .ok_or_else(|| format!("member not found: {member_id}"))?;
    let member = &mut members[index];
    let mut skills = string_list_from_member_field(member, "skills");
    let selected = skills.iter().any(|skill| skill == &skill_id);
    match mode {
        SkillOperation::Toggle if selected => skills.retain(|skill| skill != &skill_id),
        SkillOperation::Toggle => {
            ensure_skill_definition_in_document(document, &skill_id, runtime)?;
            skills.push(skill_id);
        }
        SkillOperation::Remove => skills.retain(|skill| skill != &skill_id),
    }
    set_member_string_list_field(member, "skills", skills);
    document.members = Value::Array(members);
    Ok(json!({ "kind": "agent", "id": member_id }))
}

fn apply_create_inline_skill_operation(
    document: &mut MobpackDocument,
    operation: &serde_json::Map<String, Value>,
) -> Result<Value, String> {
    let member_id = operation_member_id(operation)?;
    let label = operation_string(operation, "label", "label")?;
    let content = operation_string(operation, "content", "content")?;
    let explicit_id = operation
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let skill_id = insert_inline_skill_definition(document, explicit_id, &label, &content)?;
    let mut members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let index = member_index_by_id(&members, &member_id)
        .ok_or_else(|| format!("member not found: {member_id}"))?;
    let member = &mut members[index];
    let mut skills = string_list_from_member_field(member, "skills");
    if !skills.iter().any(|skill| skill == &skill_id) {
        skills.push(skill_id.clone());
    }
    set_member_string_list_field(member, "skills", skills);
    document.members = Value::Array(members);
    Ok(json!({ "kind": "agent", "id": member_id, "skill_id": skill_id }))
}

fn ensure_skill_definition_in_document(
    document: &mut MobpackDocument,
    skill_id: &str,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Result<(), String> {
    if skill_ids_from_realms(&document.skill_realms).contains(skill_id) {
        return Ok(());
    }
    let catalogs = mobpack_catalogs_response_with_runtime(runtime);
    let skills = skill_catalog_by_id(&catalogs["skill_realms"]);
    let skill = skills
        .get(skill_id)
        .cloned()
        .ok_or_else(|| format!("unknown MobKit skill: {skill_id}"))?;
    let realm_id = skill
        .get("realm")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("mobkit/catalog");
    let realm_label = skill
        .get("realmLabel")
        .and_then(Value::as_str)
        .unwrap_or(realm_id);
    let mut realms = document
        .skill_realms
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let realm_index = realms.iter().position(|realm| {
        realm
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == realm_id)
    });
    if realm_index.is_none() {
        realms.push(json!({
            "id": realm_id,
            "label": realm_label,
            "default": realms.is_empty(),
            "source": skill.get("source").and_then(Value::as_str).unwrap_or("mobkit/mobpacks/catalogs"),
            "sourceDocumentPath": "mobkit/mobpacks/catalogs.skill_realms[]",
            "skills": []
        }));
    }
    let index = realm_index.unwrap_or(realms.len() - 1);
    let Some(skills) = realms[index]
        .get_mut("skills")
        .and_then(Value::as_array_mut)
    else {
        return Err(format!(
            "skill realm {realm_id} does not contain skills array"
        ));
    };
    if !skills.iter().any(|skill| {
        skill
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == skill_id)
    }) {
        skills.push(skill);
    }
    document.skill_realms = Value::Array(realms);
    Ok(())
}

fn normalize_inline_skill_id(raw: &str) -> String {
    let mut out = String::new();
    let mut previous_dot = false;
    for ch in raw.trim().to_ascii_lowercase().chars() {
        let next = if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
            Some(ch)
        } else {
            Some('.')
        };
        if let Some(ch) = next {
            if ch == '.' {
                if !previous_dot {
                    out.push(ch);
                }
                previous_dot = true;
            } else {
                out.push(ch);
                previous_dot = false;
            }
        }
    }
    out.trim_matches(['.', '_', '-']).to_string()
}

fn insert_inline_skill_definition(
    document: &mut MobpackDocument,
    explicit_id: Option<&str>,
    label: &str,
    content: &str,
) -> Result<String, String> {
    let identity = explicit_id.unwrap_or(label).trim();
    let raw_id = explicit_id.map(ToString::to_string).unwrap_or_else(|| {
        if label.contains('.') {
            label.to_string()
        } else {
            format!("mob.{label}")
        }
    });
    let base_id = normalize_inline_skill_id(&raw_id);
    if identity.is_empty() || base_id.is_empty() {
        return Err("inline skill id or label must contain letters or numbers".to_string());
    }
    let used = skill_ids_from_realms(&document.skill_realms);
    let mut skill_id = base_id.clone();
    let mut index = 2;
    while used.contains(&skill_id) {
        skill_id = format!("{base_id}.{index}");
        index += 1;
    }
    let mut realms = document
        .skill_realms
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let realm_id = "mobkit/editor-inline";
    let realm_index = realms.iter().position(|realm| {
        realm
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == realm_id)
    });
    if realm_index.is_none() {
        realms.insert(
            0,
            json!({
                "id": realm_id,
                "label": "This mobpack",
                "source": "mobkit/editor",
                "default": realms.is_empty(),
                "skills": []
            }),
        );
    }
    let index = realm_index.unwrap_or(0);
    let Some(skills) = realms[index]
        .get_mut("skills")
        .and_then(Value::as_array_mut)
    else {
        return Err("inline skill realm does not contain skills array".to_string());
    };
    skills.push(json!({
        "id": skill_id,
        "label": label,
        "source": "inline",
        "content": content,
        "desc": "Inline MobKit skill stored in this mobpack."
    }));
    document.skill_realms = Value::Array(realms);
    Ok(skill_id)
}

fn reconcile_document_after_member_change(document: &mut MobpackDocument) {
    let members = document
        .members
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let member_ids = members
        .iter()
        .filter_map(|member| member.get("id").and_then(Value::as_str))
        .map(ToString::to_string)
        .collect::<BTreeSet<_>>();
    prune_flow_steps_for_members(&mut document.flow, &members, &member_ids);
    prune_graph_instances_for_members(&mut document.instances, &members, &member_ids);
    prune_graph_edges_for_instances(&mut document.edges, &document.instances);
    reconcile_flow_step_launch_modes(document);
}

/// Re-derive the graph projection (instances/edges/frames) after a flow-step
/// mutation so the operation result validates against the document it
/// returns, not a stale graph. Documents without a graph projection
/// (all-null) are left untouched.
fn reproject_graph_for_flow_change(document: &mut MobpackDocument) {
    if document.instances.is_null() && document.edges.is_null() && document.frames.is_null() {
        return;
    }
    let Some(steps) = document.flow.get("steps").and_then(Value::as_array) else {
        return;
    };
    let (instances, edges, frames) = graph_projection_from_visual_steps(steps);
    document.instances = Value::Array(instances);
    document.edges = Value::Array(edges);
    document.frames = Value::Array(frames);
}

/// Keep `document.launch_modes` consistent with the flow's member steps:
/// every member step gets exactly one entry, orphaned/duplicate entries are
/// dropped, and entries follow the step's role and inline launch mode.
fn reconcile_flow_step_launch_modes(document: &mut MobpackDocument) {
    let step_roles = editor_flow_member_step_roles(&document.flow);
    if step_roles.is_empty() {
        return;
    }
    let inline_modes = editor_flow_member_step_launch_values(&document.flow);
    let entry_step_id = |entry: &Value| -> Option<String> {
        entry
            .get("step_id")
            .or_else(|| entry.get("stepId"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
    };
    let mut entries = document
        .launch_modes
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let mut seen = BTreeSet::new();
    entries.retain(|entry| {
        entry_step_id(entry)
            .map(|step_id| step_roles.contains_key(&step_id) && seen.insert(step_id))
            .unwrap_or(false)
    });
    for entry in &mut entries {
        let Some(step_id) = entry_step_id(entry) else {
            continue;
        };
        let Some(role) = step_roles.get(&step_id) else {
            continue;
        };
        let Some(object) = entry.as_object_mut() else {
            continue;
        };
        let member_matches = object
            .get("member_id")
            .or_else(|| object.get("memberId"))
            .and_then(Value::as_str)
            .map(str::trim)
            == Some(role.as_str());
        if !member_matches {
            object.remove("memberId");
            object.insert("member_id".to_string(), Value::String(role.clone()));
            object.remove("profile");
        }
        if let Some(inline) = inline_modes.get(&step_id) {
            object.remove("launchMode");
            object.insert("launch_mode".to_string(), inline.clone());
        }
    }
    for (step_id, role) in &step_roles {
        if seen.contains(step_id) {
            continue;
        }
        let launch_mode = inline_modes
            .get(step_id)
            .cloned()
            .unwrap_or_else(|| json!({ "kind": "Fresh" }));
        entries.push(json!({
            "step_id": step_id,
            "member_id": role,
            "launch_mode": launch_mode,
        }));
    }
    document.launch_modes = Value::Array(entries);
}

fn editor_flow_member_step_launch_values(flow: &Value) -> BTreeMap<String, Value> {
    let mut modes = BTreeMap::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_editor_flow_member_step_launch_values(steps, &mut modes);
    }
    modes
}

fn collect_editor_flow_member_step_launch_values(
    steps: &[Value],
    modes: &mut BTreeMap<String, Value>,
) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("member")
            && let Some(id) = step
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())
            && let Some(mode) = step
                .get("launchMode")
                .or_else(|| step.get("launch_mode"))
                .filter(|mode| mode.is_object())
        {
            modes.insert(id.to_string(), mode.clone());
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_member_step_launch_values(nested, modes);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_editor_flow_member_step_launch_values(branch_steps, modes);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_editor_flow_member_step_launch_values(fallback, modes);
        }
    }
}

fn member_tool_set(members: &[Value], member_id: &str) -> BTreeSet<String> {
    members
        .iter()
        .find(|member| {
            member
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| id == member_id)
        })
        .map(|member| string_vec(member.get("tools")).into_iter().collect())
        .unwrap_or_default()
}

fn prune_tool_scope_fields(value: &mut Value, allowed_tools: &BTreeSet<String>) {
    let Some(object) = value.as_object_mut() else {
        return;
    };
    for field in [
        "allowedTools",
        "allowed_tools",
        "blockedTools",
        "blocked_tools",
    ] {
        if let Some(list) = object.get_mut(field).and_then(Value::as_array_mut) {
            list.retain(|tool| {
                tool.as_str()
                    .map(str::trim)
                    .is_some_and(|tool| allowed_tools.contains(tool))
            });
        }
    }
}

fn prune_flow_steps_for_members(
    flow: &mut Value,
    members: &[Value],
    member_ids: &BTreeSet<String>,
) {
    let Some(steps) = flow.get_mut("steps").and_then(Value::as_array_mut) else {
        return;
    };
    prune_step_array_for_members(steps, members, member_ids);
}

fn prune_step_array_for_members(
    steps: &mut Vec<Value>,
    members: &[Value],
    member_ids: &BTreeSet<String>,
) {
    steps.retain(|step| {
        step.get("type").and_then(Value::as_str) != Some("member")
            || step
                .get("role")
                .and_then(Value::as_str)
                .is_some_and(|role| member_ids.contains(role))
    });
    for step in steps {
        match step.get("type").and_then(Value::as_str) {
            Some("member") => {
                let role = step.get("role").and_then(Value::as_str).unwrap_or_default();
                let allowed_tools = member_tool_set(members, role);
                prune_tool_scope_fields(step, &allowed_tools);
            }
            Some("adaptive") => {
                prune_adaptive_step_member_refs(step, member_ids);
            }
            Some("repeat") => {
                if let Some(nested) = step.get_mut("steps").and_then(Value::as_array_mut) {
                    prune_step_array_for_members(nested, members, member_ids);
                }
            }
            Some("branch" | "parallel") => {
                if let Some(branches) = step.get_mut("branches").and_then(Value::as_array_mut) {
                    for branch in branches {
                        if let Some(branch_steps) =
                            branch.get_mut("steps").and_then(Value::as_array_mut)
                        {
                            prune_step_array_for_members(branch_steps, members, member_ids);
                        }
                    }
                }
                if let Some(fallback) = step.get_mut("fallback").and_then(Value::as_array_mut) {
                    prune_step_array_for_members(fallback, members, member_ids);
                }
            }
            _ => {}
        }
    }
}

fn prune_graph_instances_for_members(
    instances: &mut Value,
    members: &[Value],
    member_ids: &BTreeSet<String>,
) {
    let Some(instances) = instances.as_array_mut() else {
        return;
    };
    instances.retain(|instance| {
        instance
            .get("memberId")
            .and_then(Value::as_str)
            .map(|member_id| member_ids.contains(member_id))
            .unwrap_or(true)
    });
    for instance in instances {
        if graph_to_flow_is_adaptive_node(instance) {
            // Mirror the flow-side pruning on the embedded payload so the
            // graph still derives the flow byte-identically.
            if let Some(payload) = instance.get_mut("adaptive") {
                prune_adaptive_step_member_refs(payload, member_ids);
            }
            continue;
        }
        let Some(member_id) = instance.get("memberId").and_then(Value::as_str) else {
            continue;
        };
        let allowed_tools = member_tool_set(members, member_id);
        prune_tool_scope_fields(instance, &allowed_tools);
    }
}

fn prune_graph_edges_for_instances(edges: &mut Value, instances: &Value) {
    let instance_ids = instances
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|instance| instance.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect::<BTreeSet<_>>();
    let Some(edges) = edges.as_array_mut() else {
        return;
    };
    edges.retain(|edge| {
        let from = edge.get("from").and_then(Value::as_str).unwrap_or_default();
        let to = edge.get("to").and_then(Value::as_str).unwrap_or_default();
        (from.trim().is_empty() || instance_ids.contains(from.trim()))
            && (to.trim().is_empty() || instance_ids.contains(to.trim()))
    });
}

fn mobpack_agent_definition_by_id(
    definition_id: &str,
    runtime: Option<&MobpackRuntimeCatalogState>,
) -> Option<Value> {
    let catalogs = mobpack_catalogs_response_with_runtime(runtime);
    ["agent_definitions", "sample_agent_definitions"]
        .into_iter()
        .filter_map(|key| catalogs.get(key).and_then(Value::as_array))
        .flat_map(|definitions| definitions.iter())
        .find(|definition| {
            definition
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| id == definition_id)
        })
        .cloned()
}

fn required_definition_string(definition: &Value, field: &str) -> Result<String, String> {
    definition
        .get(field)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| format!("MobKit agent definition missing {field}"))
}

fn optional_definition_string(definition: &Value, field: &str) -> String {
    definition
        .get(field)
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

fn unique_member_document_id(role: &str, members: &Value) -> String {
    let base = format!("m_{}", sanitize_identifier(role));
    let used = members
        .as_array()
        .map(|members| {
            members
                .iter()
                .filter_map(|member| member.get("id").and_then(Value::as_str))
                .collect::<BTreeSet<_>>()
        })
        .unwrap_or_default();
    if !used.contains(base.as_str()) {
        return base;
    }
    let mut index = 2;
    loop {
        let candidate = format!("{base}_{index}");
        if !used.contains(candidate.as_str()) {
            return candidate;
        }
        index += 1;
    }
}

fn unique_member_document_name(name: &str, members: &Value) -> String {
    let base = name.trim();
    let base = if base.is_empty() { "member" } else { base };
    let used = members
        .as_array()
        .map(|members| {
            members
                .iter()
                .filter_map(|member| member.get("name").and_then(Value::as_str))
                .collect::<BTreeSet<_>>()
        })
        .unwrap_or_default();
    if !used.contains(base) {
        return base.to_string();
    }
    let mut index = 2;
    loop {
        let candidate = format!("{base}-{index}");
        if !used.contains(candidate.as_str()) {
            return candidate;
        }
        index += 1;
    }
}

fn member_from_agent_definition(definition: &Value, members: &Value) -> Result<Value, String> {
    if definition.get("definitionType").and_then(Value::as_str) != Some("mobkit/profile-member") {
        return Err("MobKit agent definition is not a profile-member contract".to_string());
    }
    let role = required_definition_string(definition, "role")?;
    let name = unique_member_document_name(
        &required_definition_string(definition, "name")
            .or_else(|_| required_definition_string(definition, "label"))?,
        members,
    );
    let member_id = unique_member_document_id(&role, members);
    let definition_kind = optional_definition_string(definition, "definitionKind");
    let source_kind = optional_definition_string(definition, "sourceKind");
    Ok(json!({
        "id": member_id,
        "name": name,
        "role": role,
        "model": required_definition_string(definition, "model")?,
        "schema": optional_definition_string(definition, "schema"),
        "skills": definition.get("skills").cloned().unwrap_or_else(|| json!([])),
        "tools": definition.get("tools").cloned().unwrap_or_else(|| json!([])),
        "profileBinding": required_definition_string(definition, "profileBinding")?,
        "realmProfile": optional_definition_string(definition, "realmProfile"),
        "runtimeMode": required_definition_string(definition, "runtimeMode")?,
        "externalAddressable": definition.get("externalAddressable").and_then(Value::as_bool).unwrap_or(false),
        "backend": optional_definition_string(definition, "backend"),
        "maxInlinePeerNotifications": definition.get("maxInlinePeerNotifications").cloned().unwrap_or(Value::Null),
        "systemPrompt": optional_definition_string(definition, "systemPrompt"),
        "providerParams": definition.get("providerParams").cloned().unwrap_or(Value::Null),
        "sourceDefinition": {
            "definitionType": required_definition_string(definition, "definitionType")?,
            "definitionKind": definition_kind,
            "sourceKind": source_kind,
            "definitionId": required_definition_string(definition, "id")?,
            "source": required_definition_string(definition, "source")?,
            "sourceMobpack": required_definition_string(definition, "sourceMobpack")?,
            "sourceMobpackName": optional_definition_string(definition, "sourceMobpackName"),
            "sourceOrigin": required_definition_string(definition, "sourceOrigin")?,
            "sourceDocumentPath": optional_definition_string(definition, "sourceDocumentPath"),
            "schemaSourceDocumentPath": optional_definition_string(definition, "schemaSourceDocumentPath"),
            "toolDefinitions": definition.get("toolDefinitions").cloned().unwrap_or_else(|| json!([])),
            "skillDefinitions": definition.get("skillDefinitions").cloned().unwrap_or_else(|| json!([])),
        }
    }))
}

fn merge_agent_definition_schema(document: &mut MobpackDocument, definition: &Value) {
    let Some(schema_definition) = definition
        .get("schemaDefinition")
        .filter(|value| value.is_object())
        .cloned()
    else {
        return;
    };
    let Some(schema_id) = schema_definition
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return;
    };
    let mut schemas = document
        .schemas
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    if let Some(existing) = schemas.iter_mut().find(|schema| {
        schema
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == schema_id)
    }) {
        *existing = schema_definition;
    } else {
        schemas.push(schema_definition);
    }
    document.schemas = Value::Array(schemas);
}

fn merge_agent_definition_skills(document: &mut MobpackDocument, definition: &Value) {
    let Some(skill_definitions) = definition
        .get("skillDefinitions")
        .and_then(Value::as_array)
        .filter(|skills| !skills.is_empty())
    else {
        return;
    };
    let realm_id = optional_definition_string(definition, "sourceMobpack");
    let realm_id = if realm_id.is_empty() {
        "mobkit/agent-definition".to_string()
    } else {
        realm_id
    };
    let realm_label = optional_definition_string(definition, "sourceMobpackName");
    let realm_source = optional_definition_string(definition, "sourceOrigin");
    let mut realms = document
        .skill_realms
        .as_array()
        .cloned()
        .unwrap_or_else(Vec::new);
    let realm_index = realms.iter().position(|realm| {
        realm
            .get("id")
            .and_then(Value::as_str)
            .is_some_and(|id| id == realm_id)
    });
    if realm_index.is_none() {
        realms.push(json!({
            "id": realm_id,
            "label": if realm_label.is_empty() { "MobKit agent definition skills" } else { realm_label.as_str() },
            "default": realms.is_empty(),
            "source": if realm_source.is_empty() { "mobkit/mobpacks/apply_operation" } else { realm_source.as_str() },
            "sourceDocumentPath": "agent_definitions[].skillDefinitions",
            "skills": []
        }));
    }
    let index = realm_index.unwrap_or(realms.len() - 1);
    let skills = realms[index]
        .get_mut("skills")
        .and_then(Value::as_array_mut);
    let Some(skills) = skills else {
        return;
    };
    let mut existing_ids = skills
        .iter()
        .filter_map(|skill| skill.get("id").and_then(Value::as_str))
        .map(ToString::to_string)
        .collect::<BTreeSet<_>>();
    for skill in skill_definitions {
        let Some(skill_id) = skill
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            continue;
        };
        if existing_ids.insert(skill_id.to_string()) {
            skills.push(skill.clone());
        }
    }
    document.skill_realms = Value::Array(realms);
}

pub fn graph_projection_mobpack(params: &Value) -> Result<Value, String> {
    enforce_mobpack_draft_expected_revision(params, "mobkit/mobpacks/graph_projection")?;
    let mut document = document_from_params(params)?;
    let steps = document
        .flow
        .get("steps")
        .and_then(Value::as_array)
        .ok_or_else(|| {
            "mobkit/mobpacks/graph_projection requires document.flow.steps".to_string()
        })?;
    let (instances, edges, frames) = graph_projection_from_visual_steps(steps);
    document.instances = Value::Array(instances.clone());
    document.edges = Value::Array(edges.clone());
    document.frames = Value::Array(frames.clone());
    Ok(json!({
        "source": "mobkit/mobpacks/graph_projection",
        "instances": instances,
        "edges": edges,
        "frames": frames,
        "validation": validate_document(&document),
    }))
}

pub fn graph_to_flow_mobpack(params: &Value) -> Result<Value, String> {
    enforce_mobpack_draft_expected_revision(params, "mobkit/mobpacks/graph_to_flow")?;
    let mut document = document_from_params(params)?;
    let flow = graph_to_flow_from_document(&document);
    document.flow = flow;
    let validation = validate_document(&document);
    Ok(json!({
        "source": "mobkit/mobpacks/graph_to_flow",
        "ok": validation.ok,
        "document": document,
        "flow": document.flow,
        "validation": validation,
    }))
}

fn graph_to_flow_from_document(document: &MobpackDocument) -> Value {
    let prior = if document.flow.is_object() {
        document.flow.clone()
    } else {
        json!({})
    };
    let prior_steps = prior
        .get("steps")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let input_step = prior_steps
        .iter()
        .find(|step| step.get("type").and_then(Value::as_str) == Some("input"))
        .cloned()
        .unwrap_or_else(|| editor_input_step_draft_contract()["default_step"].clone());
    let instances = document.instances.as_array().cloned().unwrap_or_default();
    let edges = document.edges.as_array().cloned().unwrap_or_default();
    let members = document.members.as_array().cloned().unwrap_or_default();
    let prior_by_id = graph_to_flow_prior_steps_by_id(&prior_steps);
    let mut member_nodes: Vec<Value> = instances
        .iter()
        .filter(|instance| graph_to_flow_is_flow_node(instance))
        .cloned()
        .collect();
    member_nodes.sort_by(graph_to_flow_compare_nodes);
    if member_nodes.is_empty() {
        return graph_to_flow_with_steps(prior, vec![input_step]);
    }

    let instance_by_id: BTreeMap<String, Value> = instances
        .iter()
        .filter_map(|instance| {
            let id = graph_to_flow_string(instance, "id");
            (!id.is_empty()).then(|| (id, instance.clone()))
        })
        .collect();
    let (back_edges, forward_edges): (Vec<Value>, Vec<Value>) = edges
        .iter()
        .cloned()
        .partition(|edge| graph_to_flow_is_back_edge(edge, &instance_by_id));
    let column_steps =
        graph_to_flow_segments_to_steps(&instances, &forward_edges, &members, &prior_by_id);

    if let Some(back_edge) = graph_to_flow_widest_back_edge(&back_edges, &instance_by_id) {
        let from = instance_by_id.get(&graph_to_flow_string(&back_edge, "from"));
        let to = instance_by_id.get(&graph_to_flow_string(&back_edge, "to"));
        if let (Some(from), Some(to)) = (from, to) {
            let first_col = graph_to_flow_col(to);
            let last_col = graph_to_flow_col(from);
            let before: Vec<Value> = column_steps
                .iter()
                .filter(|segment| segment.col < first_col)
                .map(|segment| segment.step.clone())
                .collect();
            let body: Vec<Value> = column_steps
                .iter()
                .filter(|segment| segment.col >= first_col && segment.col <= last_col)
                .map(|segment| segment.step.clone())
                .collect();
            let after: Vec<Value> = column_steps
                .iter()
                .filter(|segment| segment.col > last_col)
                .map(|segment| segment.step.clone())
                .collect();
            if !body.is_empty() {
                let previous_repeat = graph_to_flow_previous_repeat_for_body(&prior_steps, &body);
                let repeat = json!({
                    "id": previous_repeat
                        .as_ref()
                        .and_then(|step| step.get("id").and_then(Value::as_str))
                        .map(ToString::to_string)
                        .unwrap_or_else(|| format!("loop_{}_{}", graph_to_flow_string(to, "id"), graph_to_flow_string(from, "id"))),
                    "type": "repeat",
                    "loopId": previous_repeat.as_ref().and_then(|step| step.get("loopId").and_then(Value::as_str)).unwrap_or_default(),
                    "maxIterations": previous_repeat.as_ref().and_then(|step| step.get("maxIterations")).cloned().unwrap_or(Value::Null),
                    "iterationInput": previous_repeat.as_ref().and_then(|step| step.get("iterationInput").and_then(Value::as_str)).unwrap_or_default(),
                    "cond": graph_to_flow_repeat_condition_from_edge(&back_edge, &graph_to_flow_string(from, "id")).unwrap_or(Value::Null),
                    "steps": body,
                });
                let steps = std::iter::once(input_step)
                    .chain(before)
                    .chain(std::iter::once(repeat))
                    .chain(after)
                    .collect();
                return graph_to_flow_with_steps(prior, steps);
            }
        }
    }

    let steps = std::iter::once(input_step)
        .chain(column_steps.into_iter().map(|segment| segment.step))
        .collect();
    graph_to_flow_with_steps(prior, steps)
}

#[derive(Clone)]
struct GraphToFlowSegment {
    col: i64,
    span_end: i64,
    step: Value,
}

fn graph_to_flow_with_steps(mut prior: Value, steps: Vec<Value>) -> Value {
    if let Some(object) = prior.as_object_mut() {
        object.insert("steps".to_string(), Value::Array(steps));
        prior
    } else {
        json!({ "steps": steps })
    }
}

fn graph_to_flow_prior_steps_by_id(steps: &[Value]) -> BTreeMap<String, Value> {
    fn visit(step: &Value, out: &mut BTreeMap<String, Value>) {
        let id = graph_to_flow_string(step, "id");
        if !id.is_empty() {
            out.insert(id, step.clone());
        }
        for key in ["steps", "fallback"] {
            if let Some(steps) = step.get(key).and_then(Value::as_array) {
                for step in steps {
                    visit(step, out);
                }
            }
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(steps) = branch.get("steps").and_then(Value::as_array) {
                    for step in steps {
                        visit(step, out);
                    }
                }
            }
        }
    }
    let mut out = BTreeMap::new();
    for step in steps {
        visit(step, &mut out);
    }
    out
}

fn graph_to_flow_segments_to_steps(
    instances: &[Value],
    edges: &[Value],
    members: &[Value],
    prior_by_id: &BTreeMap<String, Value>,
) -> Vec<GraphToFlowSegment> {
    let mut member_nodes: Vec<Value> = instances
        .iter()
        .filter(|instance| graph_to_flow_is_flow_node(instance))
        .cloned()
        .collect();
    member_nodes.sort_by(graph_to_flow_compare_nodes);
    let mut gate_nodes: Vec<Value> = instances
        .iter()
        .filter(|instance| {
            graph_to_flow_bool(instance, "isGate")
                || matches!(
                    graph_to_flow_string(instance, "gateKind").as_str(),
                    "branch" | "fork" | "join"
                )
        })
        .cloned()
        .collect();
    gate_nodes.sort_by(graph_to_flow_compare_nodes);
    let mut consumed = BTreeSet::new();
    let mut segments = Vec::new();

    for gate in gate_nodes {
        let gate_kind = graph_to_flow_string(&gate, "gateKind");
        if gate_kind != "fork" && gate_kind != "branch" {
            continue;
        }
        let branch_starts: Vec<(Value, Value)> =
            graph_to_flow_outgoing_edges(edges, &graph_to_flow_string(&gate, "id"))
                .into_iter()
                .filter_map(|edge| {
                    let node =
                        graph_to_flow_node_by_id(instances, &graph_to_flow_string(&edge, "to"))?;
                    graph_to_flow_is_flow_node(&node).then_some((edge, node))
                })
                .collect();
        if branch_starts.len() < 2 {
            continue;
        }
        let join = graph_to_flow_find_join(
            instances,
            edges,
            &branch_starts
                .iter()
                .map(|(_, node)| graph_to_flow_string(node, "id"))
                .collect::<Vec<_>>(),
        );
        let mut lanes = Vec::new();
        for (index, (edge, node)) in branch_starts.iter().enumerate() {
            let join_id = join.as_ref().map(|join| graph_to_flow_string(join, "id"));
            let lane_nodes = graph_to_flow_collect_lane_to_join(
                instances,
                edges,
                &graph_to_flow_string(node, "id"),
                join_id.as_deref(),
            );
            for lane_node in &lane_nodes {
                consumed.insert(graph_to_flow_string(lane_node, "id"));
            }
            let is_fallback =
                gate_kind == "branch" && graph_to_flow_is_fallback_branch_lane(edge, node);
            lanes.push(json!({
                "id": format!("br_{}", graph_to_flow_string(node, "id")),
                "label": graph_to_flow_lane_label(node, members, index),
                "isFallback": is_fallback,
                "condition": if gate_kind == "branch" { graph_to_flow_condition_text_from_edge(edge, "") } else { String::new() },
                "cond": if gate_kind == "branch" { graph_to_flow_edge_condition_to_editor_cond(edge).unwrap_or(Value::Null) } else { Value::Null },
                "steps": lane_nodes.iter().map(|lane_node| graph_to_flow_step_from_instance(lane_node, members, prior_by_id)).collect::<Vec<_>>(),
            }));
        }
        let id = graph_to_flow_primitive_id_from_gate(
            &gate,
            if gate_kind == "branch" {
                "branch"
            } else {
                "parallel"
            },
        );
        let prior = prior_by_id.get(&id).cloned().unwrap_or_else(|| json!({}));
        let depends_mode = graph_to_flow_first_string(&[
            graph_to_flow_string(&gate, "dependsMode"),
            graph_to_flow_string(&gate, "depends_mode"),
            graph_to_flow_string(&prior, "dependsMode"),
            graph_to_flow_string(&prior, "depends_mode"),
        ]);
        let step = if gate_kind == "branch" {
            let branches: Vec<Value> = lanes
                .iter()
                .filter(|lane| {
                    !lane
                        .get("isFallback")
                        .and_then(Value::as_bool)
                        .unwrap_or(false)
                })
                .map(|lane| {
                    json!({
                        "id": lane["id"],
                        "label": lane["label"],
                        "condition": lane["condition"],
                        "cond": lane["cond"],
                        "steps": lane["steps"],
                    })
                })
                .collect();
            let fallback: Vec<Value> = lanes
                .iter()
                .filter(|lane| {
                    lane.get("isFallback")
                        .and_then(Value::as_bool)
                        .unwrap_or(false)
                })
                .flat_map(|lane| {
                    lane.get("steps")
                        .and_then(Value::as_array)
                        .cloned()
                        .unwrap_or_default()
                })
                .collect();
            let mut step = json!({
                "id": id,
                "type": "branch",
                "controllerRole": graph_to_flow_control_role(&gate, join.as_ref(), &prior),
                "branches": branches,
                "fallback": fallback,
            });
            if !depends_mode.is_empty() {
                step["dependsMode"] = json!(depends_mode);
            }
            step
        } else {
            let branches: Vec<Value> = lanes
                .iter()
                .map(|lane| json!({ "id": lane["id"], "label": lane["label"], "steps": lane["steps"] }))
                .collect();
            let mut step = json!({
                "id": id,
                "type": "parallel",
                "controllerRole": graph_to_flow_control_role(&gate, join.as_ref(), &prior),
                "dispatch": graph_to_flow_first_string(&[
                    graph_to_flow_string(&gate, "dispatch"),
                    graph_to_flow_string(&gate, "dispatchMode"),
                    graph_to_flow_string(&gate, "dispatch_mode"),
                    graph_to_flow_string(&prior, "dispatch"),
                    graph_to_flow_string(&prior, "dispatchMode"),
                    graph_to_flow_string(&prior, "dispatch_mode"),
                    "fan_out".to_string(),
                ]),
                "collection": graph_to_flow_first_string(&[
                    join.as_ref().map(graph_to_flow_collection_from_join).unwrap_or_default(),
                    "all".to_string(),
                ]),
                "branches": branches,
            });
            if let Some(quorum) = join
                .as_ref()
                .and_then(|join| join.get("quorum"))
                .and_then(|quorum| quorum.get("n").or(Some(quorum)))
                .cloned()
                && !quorum.is_null()
            {
                step["quorum"] = quorum;
            }
            if !depends_mode.is_empty() {
                step["dependsMode"] = json!(depends_mode);
            }
            step
        };
        segments.push(GraphToFlowSegment {
            col: graph_to_flow_col(&gate),
            span_end: join
                .as_ref()
                .map(graph_to_flow_col)
                .unwrap_or_else(|| graph_to_flow_col(&gate)),
            step,
        });
    }

    let mut groups: BTreeMap<i64, Vec<Value>> = BTreeMap::new();
    for node in member_nodes {
        let node_id = graph_to_flow_string(&node, "id");
        if consumed.contains(&node_id)
            || segments.iter().any(|segment| {
                graph_to_flow_col(&node) >= segment.col
                    && graph_to_flow_col(&node) <= segment.span_end
            })
        {
            continue;
        }
        groups
            .entry(graph_to_flow_col(&node))
            .or_default()
            .push(node);
    }
    for (col, mut nodes) in groups {
        nodes.sort_by(graph_to_flow_compare_nodes);
        segments.push(GraphToFlowSegment {
            col,
            span_end: col,
            step: graph_to_flow_step_for_group(&nodes, edges, members, prior_by_id),
        });
    }
    segments.sort_by(|a, b| a.col.cmp(&b.col).then(a.span_end.cmp(&b.span_end)));
    segments
}

fn graph_to_flow_step_for_group(
    nodes: &[Value],
    edges: &[Value],
    members: &[Value],
    prior_by_id: &BTreeMap<String, Value>,
) -> Value {
    if nodes.len() == 1 {
        return graph_to_flow_step_from_instance(&nodes[0], members, prior_by_id);
    }
    let has_conditional_fan_in = nodes.iter().any(|node| {
        graph_to_flow_incoming_edges(edges, &graph_to_flow_string(node, "id"))
            .iter()
            .any(graph_to_flow_is_condition_edge)
    });
    let prefix = if has_conditional_fan_in {
        "branch"
    } else {
        "parallel"
    };
    let id = format!(
        "{prefix}_{}",
        nodes
            .iter()
            .map(|node| graph_to_flow_string(node, "id"))
            .collect::<Vec<_>>()
            .join("_")
    );
    let prior = prior_by_id.get(&id).cloned().unwrap_or_else(|| json!({}));
    if has_conditional_fan_in {
        json!({
            "id": id,
            "type": "branch",
            "controllerRole": graph_to_flow_control_role(&Value::Null, None, &prior),
            "branches": nodes.iter().enumerate().map(|(index, node)| {
                let edge = graph_to_flow_incoming_edges(edges, &graph_to_flow_string(node, "id"))
                    .into_iter()
                    .find(graph_to_flow_is_condition_edge);
                json!({
                    "id": format!("br_{}", graph_to_flow_string(node, "id")),
                    "label": graph_to_flow_lane_label(node, members, index),
                    "condition": edge.as_ref().map(|edge| graph_to_flow_condition_text_from_edge(edge, "")).unwrap_or_default(),
                    "cond": edge.as_ref().and_then(graph_to_flow_edge_condition_to_editor_cond).unwrap_or(Value::Null),
                    "steps": [graph_to_flow_step_from_instance(node, members, prior_by_id)],
                })
            }).collect::<Vec<_>>(),
            "fallback": [],
        })
    } else {
        json!({
            "id": id,
            "type": "parallel",
            "controllerRole": graph_to_flow_control_role(&Value::Null, None, &prior),
            "dispatch": graph_to_flow_first_string(&[
                graph_to_flow_string(&prior, "dispatch"),
                "fan_out".to_string(),
            ]),
            "collection": graph_to_flow_first_string(&[
                graph_to_flow_string(&prior, "collection"),
                "all".to_string(),
            ]),
            "branches": nodes.iter().enumerate().map(|(index, node)| {
                json!({
                    "id": format!("br_{}", graph_to_flow_string(node, "id")),
                    "label": graph_to_flow_lane_label(node, members, index),
                    "steps": [graph_to_flow_step_from_instance(node, members, prior_by_id)],
                })
            }).collect::<Vec<_>>(),
        })
    }
}

fn graph_to_flow_member_step_from_instance(
    instance: &Value,
    _members: &[Value],
    prior_by_id: &BTreeMap<String, Value>,
) -> Value {
    let id = graph_to_flow_string(instance, "id");
    let prior = prior_by_id.get(&id).cloned().unwrap_or_else(|| json!({}));
    let mut step = json!({
        "id": id,
        "type": "member",
        "role": graph_to_flow_string(instance, "memberId"),
        "instruction": graph_to_flow_string(&prior, "instruction"),
        "launchMode": graph_to_flow_value_with_alias(instance, &prior, &["launchMode", "launch_mode"]).unwrap_or(Value::Null),
        "quorum": graph_to_flow_value_with_alias(instance, &prior, &["quorum", "collectionQuorum"]).unwrap_or(Value::Null),
        "timeoutMs": graph_to_flow_value_with_alias(instance, &prior, &["timeoutMs", "timeout_ms"]).unwrap_or(Value::Null),
        "allowedTools": graph_to_flow_value_with_alias(instance, &prior, &["allowedTools", "allowed_tools"]).unwrap_or_else(|| json!([])),
        "blockedTools": graph_to_flow_value_with_alias(instance, &prior, &["blockedTools", "blocked_tools"]).unwrap_or_else(|| json!([])),
    });
    for (out_key, keys) in [
        ("dispatchMode", ["dispatchMode", "dispatch_mode", ""]),
        (
            "collection",
            ["collection", "collectionPolicy", "collection_policy"],
        ),
        ("dependsMode", ["dependsMode", "depends_mode", ""]),
        ("outputFormat", ["outputFormat", "output_format", ""]),
    ] {
        let value = graph_to_flow_first_string(
            &keys
                .iter()
                .filter(|key| !key.is_empty())
                .map(|key| graph_to_flow_string(instance, key))
                .chain(
                    keys.iter()
                        .filter(|key| !key.is_empty())
                        .map(|key| graph_to_flow_string(&prior, key)),
                )
                .collect::<Vec<_>>(),
        );
        if !value.is_empty() {
            step[out_key] = json!(value);
        }
    }
    step
}

fn graph_to_flow_previous_repeat_for_body(prior_steps: &[Value], body: &[Value]) -> Option<Value> {
    let body_ids = body
        .iter()
        .map(|step| graph_to_flow_string(step, "id"))
        .collect::<Vec<_>>()
        .join("|");
    let mut found = None;
    fn visit(step: &Value, body_ids: &str, found: &mut Option<Value>) {
        if found.is_some() {
            return;
        }
        if step.get("type").and_then(Value::as_str) == Some("repeat") {
            let candidate = step
                .get("steps")
                .and_then(Value::as_array)
                .map(|steps| {
                    steps
                        .iter()
                        .map(|step| graph_to_flow_string(step, "id"))
                        .collect::<Vec<_>>()
                        .join("|")
                })
                .unwrap_or_default();
            if candidate == body_ids {
                *found = Some(step.clone());
                return;
            }
        }
        for key in ["steps", "fallback"] {
            if let Some(steps) = step.get(key).and_then(Value::as_array) {
                for step in steps {
                    visit(step, body_ids, found);
                }
            }
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(steps) = branch.get("steps").and_then(Value::as_array) {
                    for step in steps {
                        visit(step, body_ids, found);
                    }
                }
            }
        }
    }
    for step in prior_steps {
        visit(step, &body_ids, &mut found);
    }
    found
}

fn graph_to_flow_is_member_node(instance: &Value) -> bool {
    !graph_to_flow_string(instance, "memberId").is_empty()
        && !graph_to_flow_bool(instance, "isTerminal")
        && !graph_to_flow_bool(instance, "isGate")
}

fn graph_to_flow_is_adaptive_node(instance: &Value) -> bool {
    graph_to_flow_string(instance, "kind") == "adaptive"
        && !graph_to_flow_bool(instance, "isTerminal")
        && !graph_to_flow_bool(instance, "isGate")
}

/// Member turns and adaptive layers both occupy inline positions in the
/// derived flow chain.
fn graph_to_flow_is_flow_node(instance: &Value) -> bool {
    graph_to_flow_is_member_node(instance) || graph_to_flow_is_adaptive_node(instance)
}

fn graph_to_flow_step_from_instance(
    instance: &Value,
    members: &[Value],
    prior_by_id: &BTreeMap<String, Value>,
) -> Value {
    if graph_to_flow_is_adaptive_node(instance) {
        return graph_to_flow_adaptive_step_from_instance(instance, prior_by_id);
    }
    graph_to_flow_member_step_from_instance(instance, members, prior_by_id)
}

fn graph_to_flow_adaptive_step_from_instance(
    instance: &Value,
    prior_by_id: &BTreeMap<String, Value>,
) -> Value {
    if let Some(payload) = instance
        .get("adaptive")
        .filter(|payload| payload.is_object())
    {
        return payload.clone();
    }
    let id = graph_to_flow_string(instance, "id");
    if let Some(prior) = prior_by_id
        .get(&id)
        .filter(|prior| prior.get("type").and_then(Value::as_str) == Some("adaptive"))
    {
        return prior.clone();
    }
    let mut step = editor_adaptive_step_defaults();
    step["id"] = json!(id);
    step
}

fn graph_to_flow_is_back_edge(edge: &Value, instance_by_id: &BTreeMap<String, Value>) -> bool {
    if !graph_to_flow_is_condition_edge(edge) {
        return false;
    }
    let from = instance_by_id.get(&graph_to_flow_string(edge, "from"));
    let to = instance_by_id.get(&graph_to_flow_string(edge, "to"));
    matches!((from, to), (Some(from), Some(to)) if graph_to_flow_col(to) <= graph_to_flow_col(from))
}

fn graph_to_flow_widest_back_edge(
    back_edges: &[Value],
    instance_by_id: &BTreeMap<String, Value>,
) -> Option<Value> {
    back_edges.iter().cloned().max_by_key(|edge| {
        let from = instance_by_id.get(&graph_to_flow_string(edge, "from"));
        let to = instance_by_id.get(&graph_to_flow_string(edge, "to"));
        match (from, to) {
            (Some(from), Some(to)) => graph_to_flow_col(from) - graph_to_flow_col(to),
            _ => 0,
        }
    })
}

fn graph_to_flow_is_condition_edge(edge: &Value) -> bool {
    graph_to_flow_string(edge, "kind") == "cond"
}

fn graph_to_flow_is_fallback_branch_lane(edge: &Value, node: &Value) -> bool {
    !graph_to_flow_is_condition_edge(edge)
        || graph_to_flow_string(edge, "label").eq_ignore_ascii_case("fallback")
        || graph_to_flow_string(node, "lane").eq_ignore_ascii_case("fallback")
}

fn graph_to_flow_condition_text_from_edge(edge: &Value, fallback: &str) -> String {
    let Some((path, op, val)) = graph_to_flow_normalized_edge_condition(edge) else {
        let label = graph_to_flow_string(edge, "label");
        return if label.is_empty() {
            fallback.to_string()
        } else {
            label
        };
    };
    if path.is_empty() || op.is_empty() || val.is_null() || val.as_str() == Some("") {
        return fallback.to_string();
    }
    format!(
        "{path} {op} {}",
        serde_json::to_string(&val).unwrap_or_else(|_| "\"\"".to_string())
    )
}

fn graph_to_flow_edge_condition_to_editor_cond(edge: &Value) -> Option<Value> {
    let (path, op, val) = graph_to_flow_normalized_edge_condition(edge)?;
    let val_string = match val {
        Value::String(value) => value,
        other => other.to_string(),
    };
    if path.is_empty() || op.is_empty() || val_string.is_empty() {
        return None;
    }
    let parts: Vec<&str> = path.split('.').filter(|part| !part.is_empty()).collect();
    if parts.len() == 2 && parts[0] == "params" {
        return Some(
            json!({ "namespace": "params", "stepId": "params", "field": parts[1], "op": op, "val": val_string }),
        );
    }
    if parts.len() == 3 && parts[0] == "steps" {
        return Some(
            json!({ "namespace": "steps", "stepId": parts[1], "field": parts[2], "op": op, "val": val_string }),
        );
    }
    None
}

fn graph_to_flow_repeat_condition_from_edge(edge: &Value, step_id: &str) -> Option<Value> {
    let (path, op, val) = graph_to_flow_normalized_edge_condition(edge)?;
    let field = path.split('.').rfind(|part| !part.is_empty())?.to_string();
    let val_string = match val {
        Value::String(value) => value,
        other => other.to_string(),
    };
    if field.is_empty() || op.is_empty() || val_string.is_empty() {
        return None;
    }
    Some(json!({ "stepId": step_id, "field": field, "op": op, "val": val_string }))
}

fn graph_to_flow_normalized_edge_condition(edge: &Value) -> Option<(String, String, Value)> {
    let cond = edge.get("cond")?;
    let op = graph_to_flow_first_string(&[
        graph_to_flow_string(cond, "op"),
        graph_to_flow_string(cond, "operator"),
    ]);
    let val = cond
        .get("val")
        .or_else(|| cond.get("value"))
        .cloned()
        .unwrap_or(Value::Null);
    let direct_path = graph_to_flow_first_string(&[
        graph_to_flow_string(cond, "var"),
        graph_to_flow_string(cond, "path"),
        graph_to_flow_string(cond, "source"),
    ]);
    if !direct_path.is_empty() {
        return Some((direct_path, op, val));
    }
    let namespace = graph_to_flow_string(cond, "namespace");
    let step_id = graph_to_flow_first_string(&[
        graph_to_flow_string(cond, "stepId"),
        graph_to_flow_string(cond, "step_id"),
    ]);
    let field = graph_to_flow_string(cond, "field");
    if field.is_empty() {
        return None;
    }
    if namespace == "params" || step_id == "params" {
        return Some((format!("params.{field}"), op, val));
    }
    if !step_id.is_empty() {
        return Some((format!("steps.{step_id}.{field}"), op, val));
    }
    None
}

fn graph_to_flow_find_join(
    instances: &[Value],
    edges: &[Value],
    branch_start_ids: &[String],
) -> Option<Value> {
    let mut joins: Vec<Value> = instances
        .iter()
        .filter(|instance| graph_to_flow_string(instance, "gateKind") == "join")
        .cloned()
        .collect();
    joins.sort_by(graph_to_flow_compare_nodes);
    joins.into_iter().find(|join| {
        let join_id = graph_to_flow_string(join, "id");
        let incoming: BTreeSet<String> = graph_to_flow_incoming_edges(edges, &join_id)
            .iter()
            .map(|edge| graph_to_flow_string(edge, "from"))
            .collect();
        branch_start_ids.iter().any(|id| {
            incoming.contains(id) || graph_to_flow_lane_reaches(instances, edges, id, &join_id)
        })
    })
}

fn graph_to_flow_collect_lane_to_join(
    instances: &[Value],
    edges: &[Value],
    start_id: &str,
    join_id: Option<&str>,
) -> Vec<Value> {
    let mut out = Vec::new();
    let mut current = graph_to_flow_node_by_id(instances, start_id);
    let mut seen = BTreeSet::new();
    while let Some(node) = current {
        let node_id = graph_to_flow_string(&node, "id");
        if join_id == Some(node_id.as_str()) || !seen.insert(node_id.clone()) {
            break;
        }
        if graph_to_flow_is_flow_node(&node) {
            out.push(node.clone());
        }
        let mut next_nodes: Vec<Value> = graph_to_flow_outgoing_edges(edges, &node_id)
            .iter()
            .filter(|edge| join_id != Some(graph_to_flow_string(edge, "to").as_str()))
            .filter_map(|edge| {
                graph_to_flow_node_by_id(instances, &graph_to_flow_string(edge, "to"))
            })
            .filter(|node| !graph_to_flow_bool(node, "isTerminal"))
            .collect();
        next_nodes.sort_by(graph_to_flow_compare_nodes);
        current = next_nodes.into_iter().next();
    }
    out
}

fn graph_to_flow_lane_reaches(
    instances: &[Value],
    edges: &[Value],
    start_id: &str,
    target_id: &str,
) -> bool {
    let mut queue = VecDeque::from([start_id.to_string()]);
    let mut seen = BTreeSet::new();
    while let Some(id) = queue.pop_front() {
        if id == target_id {
            return true;
        }
        if !seen.insert(id.clone()) {
            continue;
        }
        for edge in graph_to_flow_outgoing_edges(edges, &id) {
            if let Some(node) =
                graph_to_flow_node_by_id(instances, &graph_to_flow_string(&edge, "to"))
                && !graph_to_flow_bool(&node, "isTerminal")
            {
                queue.push_back(graph_to_flow_string(&node, "id"));
            }
        }
    }
    false
}

fn graph_to_flow_collection_from_join(join: &Value) -> String {
    graph_to_flow_first_string(&[
        graph_to_flow_string(join, "collection"),
        graph_to_flow_string(join, "collectionPolicy"),
        graph_to_flow_string(join, "collection_policy"),
    ])
}

fn graph_to_flow_control_role(gate: &Value, join: Option<&Value>, prior: &Value) -> String {
    let mut values = Vec::new();
    if let Some(join) = join {
        values.extend([
            graph_to_flow_string(join, "controllerRole"),
            graph_to_flow_string(join, "controllerMemberId"),
            graph_to_flow_string(join, "controlRole"),
        ]);
    }
    values.extend([
        graph_to_flow_string(gate, "controllerRole"),
        graph_to_flow_string(gate, "controllerMemberId"),
        graph_to_flow_string(gate, "controlRole"),
        graph_to_flow_string(prior, "controllerRole"),
        graph_to_flow_string(prior, "controllerMemberId"),
        graph_to_flow_string(prior, "controlRole"),
    ]);
    graph_to_flow_first_string(&values)
}

fn graph_to_flow_primitive_id_from_gate(gate: &Value, kind: &str) -> String {
    let id = graph_to_flow_string(gate, "id");
    let prefix = format!("g_{kind}_");
    if id.starts_with(&prefix) && id.len() > prefix.len() {
        id[prefix.len()..].to_string()
    } else {
        format!(
            "{kind}_{}",
            if id.is_empty() { "flow" } else { id.as_str() }
        )
    }
}

fn graph_to_flow_lane_label(node: &Value, members: &[Value], index: usize) -> String {
    let lane = graph_to_flow_string(node, "lane");
    if !lane.is_empty() {
        return lane;
    }
    let member_id = graph_to_flow_string(node, "memberId");
    members
        .iter()
        .find(|member| graph_to_flow_string(member, "id") == member_id)
        .map(|member| graph_to_flow_string(member, "name"))
        .filter(|name| !name.is_empty())
        .unwrap_or_else(|| {
            if member_id.is_empty() {
                format!("Branch {}", index + 1)
            } else {
                member_id
            }
        })
}

fn graph_to_flow_node_by_id(instances: &[Value], id: &str) -> Option<Value> {
    instances
        .iter()
        .find(|instance| graph_to_flow_string(instance, "id") == id)
        .cloned()
}

fn graph_to_flow_outgoing_edges(edges: &[Value], id: &str) -> Vec<Value> {
    edges
        .iter()
        .filter(|edge| graph_to_flow_string(edge, "from") == id)
        .cloned()
        .collect()
}

fn graph_to_flow_incoming_edges(edges: &[Value], id: &str) -> Vec<Value> {
    edges
        .iter()
        .filter(|edge| graph_to_flow_string(edge, "to") == id)
        .cloned()
        .collect()
}

fn graph_to_flow_compare_nodes(a: &Value, b: &Value) -> std::cmp::Ordering {
    graph_to_flow_col(a)
        .cmp(&graph_to_flow_col(b))
        .then(graph_to_flow_row(a).cmp(&graph_to_flow_row(b)))
        .then(graph_to_flow_string(a, "id").cmp(&graph_to_flow_string(b, "id")))
}

fn graph_to_flow_col(value: &Value) -> i64 {
    value.get("col").and_then(Value::as_i64).unwrap_or(0)
}

fn graph_to_flow_row(value: &Value) -> i64 {
    value.get("row").and_then(Value::as_i64).unwrap_or(0)
}

fn graph_to_flow_bool(value: &Value, key: &str) -> bool {
    value.get(key).and_then(Value::as_bool).unwrap_or(false)
}

fn graph_to_flow_string(value: &Value, key: &str) -> String {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default()
        .to_string()
}

fn graph_to_flow_value_with_alias(
    primary: &Value,
    fallback: &Value,
    keys: &[&str],
) -> Option<Value> {
    keys.iter()
        .find_map(|key| primary.get(*key).filter(|value| !value.is_null()).cloned())
        .or_else(|| {
            keys.iter()
                .find_map(|key| fallback.get(*key).filter(|value| !value.is_null()).cloned())
        })
}

fn graph_to_flow_first_string(values: &[String]) -> String {
    values
        .iter()
        .map(|value| value.trim())
        .find(|value| !value.is_empty())
        .unwrap_or_default()
        .to_string()
}

pub fn validate_mobpack(params: &Value) -> Result<MobpackValidationResult, String> {
    enforce_mobpack_draft_expected_revision(params, "mobkit/mobpacks/validate")?;
    let document = document_from_params(params)?;
    let mut validation = validate_document(&document);
    if validate_with_rkat_requested(params) && validation.ok {
        validation = validate_document_with_rkat(params, &document, validation)?;
    }
    Ok(validation)
}

fn validate_with_rkat_requested(params: &Value) -> bool {
    params
        .get("rkat_validate")
        .or_else(|| params.get("rkatValidate"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn rkat_binary_available(bin: &str) -> bool {
    let path = std::path::Path::new(bin);
    if path.components().count() > 1 {
        return path.is_file();
    }
    std::env::var_os("PATH")
        .map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file()))
        .unwrap_or(false)
}

fn validate_document_with_rkat(
    params: &Value,
    document: &MobpackDocument,
    mut validation: MobpackValidationResult,
) -> Result<MobpackValidationResult, String> {
    let rkat_bin = deploy_rkat_bin(params);
    if !rkat_binary_available(&rkat_bin) {
        // Embedding hosts do not always ship rkat; structural validation
        // already ran, so degrade to a warning instead of failing every
        // validate on such hosts.
        validation.display_rows.push(MobpackDisplayRow {
            kind: "warn".to_string(),
            glyph: "△".to_string(),
            head: "rkat mob validate skipped".to_string(),
            sub: format!(
                "'{rkat_bin}' is not available on this host; only MobKit structural validation ran"
            ),
            meta: "rkat mob validate".to_string(),
        });
        return Ok(validation);
    }
    let export = export_mobpack(&json!({ "document": document }))?;
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(export.content_base64.as_bytes())
        .map_err(|err| format!("failed to decode exported mobpack for validation: {err}"))?;
    let pack_path = validate_pack_path(params, &export.filename)?;
    if let Some(parent) = pack_path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|err| format!("failed to create validation output directory: {err}"))?;
    }
    std::fs::write(&pack_path, bytes)
        .map_err(|err| format!("failed to write validation mobpack: {err}"))?;

    let mut argv = vec![
        rkat_bin,
        "mob".to_string(),
        "validate".to_string(),
        pack_path.to_string_lossy().to_string(),
    ];
    // rkat 0.7 defaults `mob validate` to strict trust, which rejects every
    // unsigned editor-authored pack. Thread the document's effective deploy
    // trust policy (the same setting `rkat mob run` executes with) so
    // VALIDATE verifies the pack under the policy it will actually run under.
    argv.extend([
        "--trust-policy".to_string(),
        effective_deploy_trust_policy(&document.deploy),
    ]);
    let command = shell_command(&argv);
    let output = run_rkat_validate_command(&argv, rkat_validate_timeout(params));
    let combined_output = [output.stdout.as_deref(), output.stderr.as_deref()]
        .into_iter()
        .flatten()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .collect::<Vec<_>>()
        .join("\n");

    if output.status_code == Some(0) {
        validation.validation_source = "rkat mob validate".to_string();
        validation.display_rows.push(MobpackDisplayRow {
            kind: "ok".to_string(),
            glyph: "✓".to_string(),
            head: "rkat mob validate executed".to_string(),
            sub: if combined_output.is_empty() {
                command
            } else {
                combined_output
            },
            meta: pack_path.to_string_lossy().to_string(),
        });
        return Ok(validation);
    }

    let message = if combined_output.is_empty() {
        format!("rkat mob validate failed: {command}")
    } else {
        combined_output
    };
    validation.ok = false;
    validation.validation_source = "rkat mob validate".to_string();
    validation.diagnostics.push(MobpackDiagnostic {
        severity: "error".to_string(),
        code: "rkat_mob_validate_failed".to_string(),
        message: message.clone(),
        path: Some(pack_path.to_string_lossy().to_string()),
    });
    validation.display_rows =
        validation_display_rows(false, &validation.diagnostics, "rkat mob validate");
    validation.display_rows.push(MobpackDisplayRow {
        kind: "crit".to_string(),
        glyph: "!".to_string(),
        head: "rkat mob validate failed".to_string(),
        sub: message,
        meta: pack_path.to_string_lossy().to_string(),
    });
    Ok(validation)
}

pub fn deploy_command_preview(params: &Value) -> Result<MobpackDeployCommandResult, String> {
    let document = params
        .get("document")
        .ok_or_else(|| "mobkit/mobpacks/deploy_command requires document".to_string())
        .and_then(document_from_value)?;
    let mut render_params = params.clone();
    if let Some(object) = render_params.as_object_mut() {
        object.insert("document".to_string(), json!(document));
    }
    let rendered = render_mobpack_source_for(&render_params, "mobkit/mobpacks/deploy_command")
        .map_err(|err| {
            if err == "cannot render source for invalid mobpack document" {
                "cannot preview deploy command for invalid mobpack document".to_string()
            } else {
                err
            }
        })?;
    let input_bindings = deploy_input_bindings(&document, params);
    let deploy = document.deploy;
    let prompt = params
        .get("prompt")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .or_else(|| {
            deploy
                .get("prompt")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
        })
        .unwrap_or("Reply with exactly OK.");
    let pack_path = params
        .get("pack_path")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .unwrap_or_else(|| rendered.filename.clone());
    let rkat_bin = deploy_rkat_bin(params);
    let argv = deploy_argv(
        &rkat_bin,
        &deploy,
        Path::new(&pack_path),
        prompt,
        &input_bindings,
    );
    let command = shell_command(&argv);
    Ok(MobpackDeployCommandResult {
        command,
        argv,
        deploy_command: "rkat mob run".to_string(),
        filename: rendered.filename,
        validation: rendered.validation,
        source: "meerkat_mobkit::mobpack::deploy_argv".to_string(),
    })
}

struct RenderedMobpackSource {
    filename: String,
    mob_toml: String,
    files: BTreeMap<String, Vec<u8>>,
    validation: MobpackValidationResult,
}

fn render_mobpack_source(params: &Value) -> Result<RenderedMobpackSource, String> {
    render_mobpack_source_for(params, "mobkit/mobpacks/source")
}

fn render_mobpack_source_for(
    params: &Value,
    guard_source: &str,
) -> Result<RenderedMobpackSource, String> {
    enforce_mobpack_draft_expected_revision(params, guard_source)?;
    let mut document = document_from_params(params)?;
    let validation = canonicalize_deployable_authoring_document(&mut document)?;
    if !validation.ok {
        return Err("cannot render source for invalid mobpack document".to_string());
    }
    let mob_toml = document
        .mob_toml
        .clone()
        .ok_or_else(|| "canonical authoring document is missing rendered mob.toml".to_string())?;
    let filename = document_export_filename(&document);
    let slug = filename
        .strip_suffix(".mobpack")
        .unwrap_or(filename.as_str())
        .to_string();
    let files = deployable_mobpack_archive_files(&slug, &document, &mob_toml)?;
    Ok(RenderedMobpackSource {
        filename,
        mob_toml,
        files,
        validation,
    })
}

fn canonicalize_deployable_authoring_document(
    document: &mut MobpackDocument,
) -> Result<MobpackValidationResult, String> {
    let validation = validate_document(document);
    if validation.ok {
        document.mob_toml = Some(authoring_mob_toml(document)?);
    }
    Ok(validation)
}

pub fn source_mobpack(params: &Value) -> Result<MobpackSourceResult, String> {
    let rendered = render_mobpack_source(params)?;
    Ok(MobpackSourceResult {
        filename: rendered.filename,
        media_type: MOBPACK_MEDIA_TYPE.to_string(),
        mob_toml: rendered.mob_toml,
        source_files: source_files_from_archive_files(&rendered.files),
        validation: rendered.validation,
        source: "mobkit/mobpacks/source".to_string(),
    })
}

pub fn export_mobpack(params: &Value) -> Result<MobpackExportResult, String> {
    let rendered = render_mobpack_source_for(params, "mobkit/mobpacks/export").map_err(|err| {
        if err == "cannot render source for invalid mobpack document" {
            "cannot export invalid mobpack document".to_string()
        } else {
            err
        }
    })?;
    let bytes = encode_deployable_mobpack_archive(&rendered.files)?;
    Ok(MobpackExportResult {
        filename: rendered.filename,
        media_type: MOBPACK_MEDIA_TYPE.to_string(),
        content_base64: base64::engine::general_purpose::STANDARD.encode(bytes),
        mob_toml: rendered.mob_toml,
        source_files: source_files_from_archive_files(&rendered.files),
        validation: rendered.validation,
    })
}

pub fn deploy_mobpack(params: &Value) -> Result<MobpackDeployResult, String> {
    enforce_mobpack_draft_expected_revision(params, "mobkit/mobpacks/deploy")?;
    let export = export_mobpack(params)?;
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(export.content_base64.as_bytes())
        .map_err(|err| format!("failed to decode exported mobpack: {err}"))?;
    let pack_sha256 = source_file_sha256(&bytes);
    let pack_path = deploy_pack_path(params, &export.filename)?;

    let document = document_from_params(params)?;
    let input_bindings = deploy_input_bindings(&document, params);
    let deploy = document.deploy;
    let prompt = params
        .get("prompt")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .or_else(|| {
            deploy
                .get("prompt")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
        })
        .unwrap_or("Reply with exactly OK.");
    let rkat_bin = deploy_rkat_bin(params);
    let argv = deploy_argv(&rkat_bin, &deploy, &pack_path, prompt, &input_bindings);
    let command = shell_command(&argv);
    let definition = MobDefinition::from_toml(&export.mob_toml)
        .map_err(|err| format!("failed to parse exported mob.toml for deploy plan: {err}"))?;
    let plan_trace =
        deploy_plan_trace_from_definition(&definition, &export.validation, &command, &pack_path);
    let execute = params
        .get("execute")
        .and_then(Value::as_bool)
        .unwrap_or(false);

    let (status_code, stdout, stderr) = if execute {
        if let Some(parent) = pack_path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|err| format!("failed to create deploy output directory: {err}"))?;
        }
        std::fs::write(&pack_path, bytes)
            .map_err(|err| format!("failed to write deploy mobpack: {err}"))?;
        let timeout = deploy_execution_timeout(&deploy);
        let output = run_deploy_command(&argv, timeout);
        (output.status_code, output.stdout, output.stderr)
    } else {
        (None, None, None)
    };
    let run_envelope = if execute {
        stdout.as_deref().and_then(parse_mob_run_stdout_envelope)
    } else {
        None
    };
    // An executed run is a success ONLY when it exits 0 AND its stdout parsed
    // into a `run …status=completed` envelope. Exit 0 with an unparseable /
    // missing envelope (output-format drift, a wrapper at RKAT_BIN, truncated or
    // tracing-interleaved stdout) must NOT be reported as success — that is the
    // exact "exit 0 + unparseable stdout" trap the deploy story forbids, since a
    // failed `rkat mob run` also exits 0 and the `status=` field is the only
    // signal distinguishing completed from failed.
    let success = execute
        && status_code == Some(0)
        && run_envelope
            .as_ref()
            .is_some_and(|envelope| envelope.status == "completed");
    let display_rows = deploy_display_rows(
        &export.validation,
        execute,
        success,
        status_code,
        stdout.as_deref(),
        stderr.as_deref(),
        run_envelope.as_ref(),
        &pack_path,
        &command,
    );

    Ok(MobpackDeployResult {
        filename: export.filename,
        pack_path: pack_path.to_string_lossy().to_string(),
        pack_sha256,
        command,
        argv,
        plan_trace,
        executed: execute,
        success,
        status_code,
        stdout,
        stderr,
        display_rows,
        validation: export.validation,
    })
}

#[allow(clippy::too_many_arguments)]
fn deploy_display_rows(
    validation: &MobpackValidationResult,
    executed: bool,
    success: bool,
    status_code: Option<i32>,
    stdout: Option<&str>,
    stderr: Option<&str>,
    run_envelope: Option<&MobRunStdoutEnvelope>,
    pack_path: &Path,
    command: &str,
) -> Vec<MobpackDisplayRow> {
    let mut rows = validation.display_rows.clone();
    rows.push(MobpackDisplayRow {
        kind: if executed {
            if success { "ok" } else { "crit" }
        } else {
            "warn"
        }
        .to_string(),
        glyph: if executed {
            if success { "✓" } else { "!" }
        } else {
            "△"
        }
        .to_string(),
        head: if executed {
            if success {
                "rkat mob run executed"
            } else {
                "rkat mob run failed"
            }
        } else {
            "Deploy plan ready"
        }
        .to_string(),
        sub: command.to_string(),
        meta: pack_path.to_string_lossy().to_string(),
    });
    if let Some(envelope) = run_envelope {
        let completed = envelope.status == "completed";
        rows.push(MobpackDisplayRow {
            kind: if completed { "ok" } else { "crit" }.to_string(),
            glyph: if completed { "✓" } else { "!" }.to_string(),
            head: format!("Flow run {}", envelope.status),
            sub: envelope
                .result
                .as_ref()
                .map(mob_run_result_text)
                .unwrap_or_else(|| "flow run produced no result output".to_string()),
            meta: envelope.run_meta.clone(),
        });
    } else if executed && status_code == Some(0) {
        // Exit 0 but no parseable `run …status=` envelope. A failed flow also
        // exits 0, so we cannot claim success — surface why it is not reported
        // as completed rather than letting it masquerade as a clean deploy.
        rows.push(MobpackDisplayRow {
            kind: "crit".to_string(),
            glyph: "!".to_string(),
            head: "could not parse rkat result envelope".to_string(),
            sub: "rkat exited 0 but its stdout had no `run …status=…` line; \
                   the flow's outcome (completed vs failed) is unknown"
                .to_string(),
            meta: "rkat mob run".to_string(),
        });
    }
    if executed {
        let output = [stdout.unwrap_or_default(), stderr.unwrap_or_default()]
            .into_iter()
            .filter(|value| !value.trim().is_empty())
            .collect::<Vec<_>>()
            .join("\n")
            .trim()
            .to_string();
        if let Some(status_code) = status_code {
            rows.push(MobpackDisplayRow {
                kind: if status_code == 0 { "ok" } else { "crit" }.to_string(),
                glyph: if status_code == 0 { "✓" } else { "!" }.to_string(),
                head: format!("rkat exit {status_code}"),
                sub: output,
                meta: "rkat mob run".to_string(),
            });
        } else if !output.is_empty() {
            rows.push(MobpackDisplayRow {
                kind: "warn".to_string(),
                glyph: "△".to_string(),
                head: "rkat output".to_string(),
                sub: output,
                meta: "rkat mob run".to_string(),
            });
        }
    }
    rows
}

fn deploy_pack_path(params: &Value, filename: &str) -> Result<PathBuf, String> {
    if let Some(path) = params.get("pack_path").and_then(Value::as_str) {
        let path = path.trim();
        if !path.is_empty() {
            return Ok(PathBuf::from(path));
        }
    }
    let output_dir = params
        .get("output_dir")
        .and_then(Value::as_str)
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir);
    let stamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_err(|err| format!("system clock before unix epoch: {err}"))?
        .as_millis();
    Ok(output_dir.join(format!("{stamp}-{filename}")))
}

fn validate_pack_path(params: &Value, filename: &str) -> Result<PathBuf, String> {
    if let Some(path) = params
        .get("validation_pack_path")
        .or_else(|| params.get("pack_path"))
        .and_then(Value::as_str)
    {
        let path = path.trim();
        if !path.is_empty() {
            return Ok(PathBuf::from(path));
        }
    }
    let output_dir = params
        .get("validation_output_dir")
        .or_else(|| params.get("output_dir"))
        .and_then(Value::as_str)
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir);
    let stamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_err(|err| format!("system clock before unix epoch: {err}"))?
        .as_millis();
    Ok(output_dir.join(format!("{stamp}-validate-{filename}")))
}

fn rkat_validate_timeout(params: &Value) -> std::time::Duration {
    let millis = params
        .get("rkat_validate_timeout_ms")
        .or_else(|| params.get("rkatValidateTimeoutMs"))
        .and_then(Value::as_u64)
        .unwrap_or(30_000);
    std::time::Duration::from_millis(millis.max(1))
}

#[cfg(test)]
fn deploy_rkat_bin(params: &Value) -> String {
    params
        .get("rkat_bin")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .or_else(env_rkat_bin)
        .unwrap_or_else(|| "rkat".to_string())
}

#[cfg(not(test))]
fn deploy_rkat_bin(_params: &Value) -> String {
    env_rkat_bin().unwrap_or_else(|| "rkat".to_string())
}

fn env_rkat_bin() -> Option<String> {
    std::env::var("RKAT_BIN")
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

/// Typed input bindings for the `rkat mob run` argv.
///
/// When the editor document declares typed input params, the rendered pack
/// carries `schemas/main-input.json` with `additionalProperties: false` and
/// `required` entries, and `rkat mob run` validates the bound params against
/// it BEFORE anything runs. `--prompt` is sugar for `--param prompt=<text>`,
/// so blindly injecting it fails that validation for every pack whose schema
/// does not declare a `prompt` field.
struct DeployInputBindings {
    /// Field names `schemas/main-input.json` will declare; `None` when the
    /// document has no typed input params (no schema is emitted, so rkat's
    /// input validation is a no-op and any binding is accepted).
    schema_fields: Option<BTreeSet<String>>,
    /// Caller-supplied input params (RPC `input_params`), forwarded as
    /// repeated `--param key=<json>` bindings.
    params: serde_json::Map<String, Value>,
}

impl DeployInputBindings {
    /// Whether the pack's input schema accepts a `prompt` key.
    fn accepts_prompt(&self) -> bool {
        self.schema_fields
            .as_ref()
            .is_none_or(|fields| fields.contains("prompt"))
    }
}

/// Project the document's input-step params and the deploy RPC's
/// `input_params` object into the argv input bindings. Mirrors
/// `editor_input_schema_files`: the schema exists exactly when the input
/// step declares a non-empty `inputParams` list.
fn deploy_input_bindings(document: &MobpackDocument, params: &Value) -> DeployInputBindings {
    let schema_fields = document
        .flow
        .get("steps")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .find(|step| step.get("type").and_then(Value::as_str) == Some("input"))
        .and_then(|step| step.get("inputParams").and_then(Value::as_array))
        .filter(|params| !params.is_empty())
        .map(|params| {
            params
                .iter()
                .filter_map(|param| param.get("name").and_then(Value::as_str))
                .map(str::trim)
                .filter(|name| !name.is_empty())
                .map(ToString::to_string)
                .collect::<BTreeSet<_>>()
        });
    let supplied = params
        .get("input_params")
        .or_else(|| params.get("inputParams"))
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();
    DeployInputBindings {
        schema_fields,
        params: supplied,
    }
}

// Builds the `rkat mob run` invocation that executes the pack's flow:
// it validates input params against the pack schema, provisions flow
// targets, blocks until the run is terminal, and prints the result
// envelope on stdout.
//
// Input binding: caller-supplied params forward as `--param key=<json>`
// (JSON-encoding the value preserves its type through rkat's
// parse-as-JSON-first `--param` handling); the deploy prompt maps to
// `--prompt` only when the pack's input schema accepts a `prompt` key —
// see `DeployInputBindings`. Realm scope settings map to rkat's global
// realm flags. Model/token/tool caps and surface are pack-policy concerns
// under `mob run` and are not passed as CLI overrides
// (`validate_deploy_settings` warns when a document sets them);
// max_duration bounds the local process via deploy_execution_timeout.
fn deploy_argv(
    rkat_bin: &str,
    deploy: &Value,
    pack_path: &Path,
    prompt: &str,
    input_bindings: &DeployInputBindings,
) -> Vec<String> {
    let mut argv = vec![rkat_bin.to_string(), "mob".to_string(), "run".to_string()];
    argv.push(pack_path.to_string_lossy().to_string());
    for (key, value) in &input_bindings.params {
        let encoded = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
        argv.extend(["--param".to_string(), format!("{key}={encoded}")]);
    }
    if input_bindings.accepts_prompt() && !input_bindings.params.contains_key("prompt") {
        // Single-token `--prompt=<text>` (NOT two tokens). `mob run`'s
        // `--prompt` has no `allow_hyphen_values`, so a two-token value
        // beginning with `-` (e.g. a prompt like `--summarize this`) is
        // rejected by clap as an unexpected argument. The `=` form makes clap
        // take everything after `=` literally, including leading hyphens.
        argv.push(format!("--prompt={prompt}"));
    }
    argv.extend([
        "--trust-policy".to_string(),
        effective_deploy_trust_policy(deploy),
    ]);
    if let Some(realm) = deploy_string(deploy, "realm") {
        argv.extend(["--realm".to_string(), realm]);
    } else if deploy
        .get("isolated")
        .and_then(Value::as_bool)
        .unwrap_or(false)
    {
        argv.push("--isolated".to_string());
    }
    if let Some(instance) = deploy_string(deploy, "instance") {
        argv.extend(["--instance".to_string(), instance]);
    }
    if let Some(backend) = deploy_string(deploy, "realm_backend") {
        argv.extend(["--realm-backend".to_string(), backend]);
    }
    if let Some(context_root) = deploy_string(deploy, "context_root") {
        argv.extend(["--context-root".to_string(), context_root]);
    }
    if let Some(state_root) = deploy_string(deploy, "state_root") {
        argv.extend(["--state-root".to_string(), state_root]);
    }
    if let Some(user_config_root) = deploy_string(deploy, "user_config_root") {
        argv.extend(["--user-config-root".to_string(), user_config_root]);
    }
    argv
}

struct DeployProcessOutput {
    status_code: Option<i32>,
    stdout: Option<String>,
    stderr: Option<String>,
}

/// Parsed projection of the line-oriented result envelope `rkat mob run`
/// prints after blocking until the flow run is terminal:
///
/// ```text
/// run\tmob=<id>\tflow=<id>\trun_id=<id>\tstatus=<status>
/// result\t<compact json>
/// ```
///
/// optionally followed by `warning\t…` trust lines. The parsed result is
/// what the Flow Editor surfaces as the run output display row.
struct MobRunStdoutEnvelope {
    status: String,
    run_meta: String,
    result: Option<Value>,
}

fn parse_mob_run_stdout_envelope(stdout: &str) -> Option<MobRunStdoutEnvelope> {
    let mut status = None;
    let mut run_meta = None;
    let mut result = None;
    for line in stdout.lines() {
        if let Some(rest) = line.strip_prefix("run\t") {
            run_meta = Some(rest.replace('\t', " "));
            status = rest
                .split('\t')
                .find_map(|field| field.strip_prefix("status="))
                .map(ToString::to_string);
        } else if let Some(rest) = line.strip_prefix("result\t") {
            result = serde_json::from_str::<Value>(rest).ok();
        }
    }
    Some(MobRunStdoutEnvelope {
        status: status?,
        run_meta: run_meta?,
        result,
    })
}

fn mob_run_result_text(result: &Value) -> String {
    match result {
        Value::String(text) => text.clone(),
        other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
    }
}

fn run_rkat_validate_command(argv: &[String], timeout: std::time::Duration) -> DeployProcessOutput {
    if argv.is_empty() {
        return DeployProcessOutput {
            status_code: None,
            stdout: None,
            stderr: Some("failed to run rkat mob validate: empty argv".to_string()),
        };
    }
    let mut child = match std::process::Command::new(&argv[0])
        .args(&argv[1..])
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(child) => child,
        Err(err) => {
            return DeployProcessOutput {
                status_code: None,
                stdout: None,
                stderr: Some(format!("failed to run rkat mob validate: {err}")),
            };
        }
    };
    let start = std::time::Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(_)) => {
                return match child.wait_with_output() {
                    Ok(output) => DeployProcessOutput {
                        status_code: output.status.code(),
                        stdout: Some(String::from_utf8_lossy(&output.stdout).to_string()),
                        stderr: Some(String::from_utf8_lossy(&output.stderr).to_string()),
                    },
                    Err(err) => DeployProcessOutput {
                        status_code: None,
                        stdout: None,
                        stderr: Some(format!("failed to collect rkat mob validate output: {err}")),
                    },
                };
            }
            Ok(None) if start.elapsed() >= timeout => {
                let _ = child.kill();
                return match child.wait_with_output() {
                    Ok(output) => {
                        let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
                        if !stderr.trim().is_empty() {
                            stderr.push('\n');
                        }
                        stderr.push_str(&format!(
                            "rkat mob validate timed out after {}ms",
                            timeout.as_millis()
                        ));
                        DeployProcessOutput {
                            status_code: output.status.code(),
                            stdout: Some(String::from_utf8_lossy(&output.stdout).to_string()),
                            stderr: Some(stderr),
                        }
                    }
                    Err(err) => DeployProcessOutput {
                        status_code: None,
                        stdout: None,
                        stderr: Some(format!("failed to stop rkat mob validate: {err}")),
                    },
                };
            }
            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)),
            Err(err) => {
                let _ = child.kill();
                return DeployProcessOutput {
                    status_code: None,
                    stdout: None,
                    stderr: Some(format!("failed to wait for rkat mob validate: {err}")),
                };
            }
        }
    }
}

/// Drain a child pipe to a `Vec<u8>` on a dedicated thread. Spawning a reader
/// per stream means neither pipe can fill its OS buffer (~64KB) and block the
/// child on write while we are still polling for exit — the pipe-buffer
/// deadlock that would otherwise turn a chatty (e.g. `RUST_LOG=debug`)
/// multi-minute flow into a spurious timeout.
fn spawn_pipe_drainer<R>(reader: Option<R>) -> std::thread::JoinHandle<Vec<u8>>
where
    R: std::io::Read + Send + 'static,
{
    std::thread::spawn(move || {
        let mut buf = Vec::new();
        if let Some(mut reader) = reader {
            let _ = reader.read_to_end(&mut buf);
        }
        buf
    })
}

fn run_deploy_command(argv: &[String], timeout: std::time::Duration) -> DeployProcessOutput {
    if argv.is_empty() {
        return DeployProcessOutput {
            status_code: None,
            stdout: None,
            stderr: Some("failed to run rkat mob run: empty argv".to_string()),
        };
    }
    let mut child = match std::process::Command::new(&argv[0])
        .args(&argv[1..])
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
    {
        Ok(child) => child,
        Err(err) => {
            return DeployProcessOutput {
                status_code: None,
                stdout: None,
                stderr: Some(format!("failed to run rkat mob run: {err}")),
            };
        }
    };

    // Drain both pipes concurrently so the child never blocks on a full pipe
    // buffer while we poll for exit/timeout. The readers return when their pipe
    // closes (on child exit or kill), so joining them always terminates.
    let stdout_reader = spawn_pipe_drainer(child.stdout.take());
    let stderr_reader = spawn_pipe_drainer(child.stderr.take());
    let collect = |stdout_reader: std::thread::JoinHandle<Vec<u8>>,
                   stderr_reader: std::thread::JoinHandle<Vec<u8>>|
     -> (String, String) {
        let stdout = stdout_reader.join().unwrap_or_default();
        let stderr = stderr_reader.join().unwrap_or_default();
        (
            String::from_utf8_lossy(&stdout).to_string(),
            String::from_utf8_lossy(&stderr).to_string(),
        )
    };

    let start = std::time::Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(status)) => {
                let (stdout, stderr) = collect(stdout_reader, stderr_reader);
                return DeployProcessOutput {
                    status_code: status.code(),
                    stdout: Some(stdout),
                    stderr: Some(stderr),
                };
            }
            Ok(None) if start.elapsed() >= timeout => {
                let _ = child.kill();
                let status_code = child.wait().ok().and_then(|status| status.code());
                let (stdout, mut stderr) = collect(stdout_reader, stderr_reader);
                if !stderr.trim().is_empty() {
                    stderr.push('\n');
                }
                stderr.push_str(&format!(
                    "rkat mob run timed out after {}ms",
                    timeout.as_millis()
                ));
                return DeployProcessOutput {
                    status_code,
                    stdout: Some(stdout),
                    stderr: Some(stderr),
                };
            }
            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
            Err(err) => {
                let _ = child.kill();
                let _ = child.wait();
                // Drain readers so their threads terminate before we return.
                let (_stdout, _stderr) = collect(stdout_reader, stderr_reader);
                return DeployProcessOutput {
                    status_code: None,
                    stdout: None,
                    stderr: Some(format!("failed to wait for rkat mob run: {err}")),
                };
            }
        }
    }
}

fn deploy_execution_timeout(deploy: &Value) -> std::time::Duration {
    let millis = deploy_string(deploy, "max_duration")
        .and_then(|value| parse_deploy_duration_ms(&value))
        .map(|millis| millis.saturating_add(DEPLOY_EXEC_TIMEOUT_GRACE_MS))
        .filter(|millis| *millis > 0)
        .unwrap_or(DEFAULT_DEPLOY_EXEC_TIMEOUT_MS);
    std::time::Duration::from_millis(millis)
}

fn parse_deploy_duration_ms(value: &str) -> Option<u64> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return None;
    }
    let lower = trimmed.to_ascii_lowercase();
    let bytes = lower.as_bytes();
    let mut index = 0;
    let mut total = 0u64;
    let mut saw_part = false;
    while index < bytes.len() {
        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
            index += 1;
        }
        if index >= bytes.len() {
            break;
        }
        let number_start = index;
        while index < bytes.len() && bytes[index].is_ascii_digit() {
            index += 1;
        }
        if number_start == index {
            return None;
        }
        let number = lower[number_start..index].parse::<u64>().ok()?;
        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
            index += 1;
        }
        let multiplier = if lower[index..].starts_with("ms") {
            index += 2;
            1
        } else if lower[index..].starts_with('h') {
            index += 1;
            3_600_000
        } else if lower[index..].starts_with('m') {
            index += 1;
            60_000
        } else if lower[index..].starts_with('s') {
            index += 1;
            1_000
        } else if index == bytes.len() {
            1_000
        } else {
            return None;
        };
        total = total.saturating_add(number.saturating_mul(multiplier));
        saw_part = true;
    }
    saw_part.then_some(total)
}

fn deploy_string(deploy: &Value, key: &str) -> Option<String> {
    deploy
        .get(key)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}

/// Trust policy `rkat mob run` / `rkat mob validate` execute under when the
/// document does not pin `deploy.trust_policy`. This is the deploy-settings
/// schema default the editor surfaces, so the executed command always matches
/// what SETTINGS shows. rkat 0.7 itself defaults to strict trust, which
/// rejects every unsigned editor-authored pack — the policy must therefore be
/// passed explicitly on every invocation.
const DEFAULT_DEPLOY_TRUST_POLICY: &str = "permissive";

fn effective_deploy_trust_policy(deploy: &Value) -> String {
    deploy_string(deploy, "trust_policy").unwrap_or_else(|| DEFAULT_DEPLOY_TRUST_POLICY.to_string())
}

fn shell_command(argv: &[String]) -> String {
    argv.iter()
        .map(|arg| shell_quote(arg))
        .collect::<Vec<_>>()
        .join(" ")
}

fn shell_quote(arg: &str) -> String {
    if arg
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || "_./:=@%+-".contains(ch))
    {
        arg.to_string()
    } else {
        format!("'{}'", arg.replace('\'', "'\\''"))
    }
}

fn deploy_plan_trace_from_definition(
    definition: &MobDefinition,
    validation: &MobpackValidationResult,
    command: &str,
    pack_path: &Path,
) -> Vec<Value> {
    let mut rows = Vec::new();
    rows.push(plan_trace_row(
        Value::Null,
        format!("MOBPACK · {}", definition.id),
        compact_trace_lines([
            Some("source: mobkit/mob.toml".to_string()),
            Some(format!("command: {command}")),
            Some(format!("pack_path: {}", pack_path.to_string_lossy())),
            Some(format!("validation: {}", validation.validation_source)),
            Some(format!(
                "profiles: {} · flows: {}",
                definition.profiles.len(),
                definition.flows.len()
            )),
        ]),
    ));

    for (profile_name, binding) in &definition.profiles {
        rows.push(profile_plan_trace_row(profile_name.as_ref(), binding));
    }

    for (flow_id, flow) in &definition.flows {
        rows.push(plan_trace_row(
            Value::Null,
            format!("FLOW · {flow_id}"),
            compact_trace_lines([
                flow.description
                    .as_ref()
                    .filter(|description| !description.trim().is_empty())
                    .map(|description| format!("description: {description}")),
                Some(format!("steps: {}", flow.steps.len())),
                // Meerkat 0.7: the root frame is always present (flat-authored
                // steps are compiled into it at the decode boundary).
                Some("root: FrameSpec".to_string()),
            ]),
        ));
        push_frame_plan_trace(&mut rows, flow, &flow.root, "root");
    }

    rows.push(plan_trace_row(
        Value::Null,
        if validation.ok {
            "VALIDATION · ACCEPTED".to_string()
        } else {
            "VALIDATION · BLOCKED".to_string()
        },
        compact_trace_lines([
            Some(validation.validation_source.clone()),
            Some(validation.deploy_command.clone()),
            Some(format!("diagnostics: {}", validation.diagnostics.len())),
        ]),
    ));
    rows
}

fn profile_plan_trace_row(profile_name: &str, binding: &ProfileBinding) -> Value {
    match binding {
        ProfileBinding::RealmRef { realm_profile } => plan_trace_row(
            Value::Null,
            format!("PROFILE · {profile_name}"),
            compact_trace_lines([
                Some("binding: realm_profile".to_string()),
                Some(format!("realm_profile: {realm_profile}")),
            ]),
        ),
        ProfileBinding::Inline(profile) => {
            let tools = tool_ids_from_config(&profile.tools);
            plan_trace_row(
                Value::Null,
                format!("PROFILE · {profile_name}"),
                compact_trace_lines([
                    Some("binding: inline".to_string()),
                    Some(format!("model: {}", profile.model)),
                    Some(format!("runtime_mode: {}", profile.runtime_mode)),
                    profile
                        .backend
                        .as_ref()
                        .map(|backend| format!("backend: {}", backend_kind_string(backend))),
                    Some(format!(
                        "tools: {}",
                        if tools.is_empty() {
                            "none".to_string()
                        } else {
                            tools.join(", ")
                        }
                    )),
                    Some(format!(
                        "skills: {}",
                        if profile.skills.is_empty() {
                            "none".to_string()
                        } else {
                            profile.skills.join(", ")
                        }
                    )),
                    profile
                        .output_schema
                        .as_ref()
                        .map(|_| "output_schema: configured".to_string()),
                    profile
                        .provider_params
                        .as_ref()
                        .map(|_| "provider_params: configured".to_string()),
                ]),
            )
        }
    }
}

fn push_frame_plan_trace(rows: &mut Vec<Value>, flow: &FlowSpec, frame: &FrameSpec, scope: &str) {
    for (node_id, node) in &frame.nodes {
        match node {
            FlowNodeSpec::Step(frame_step) => {
                if let Some(step) = flow.steps.get(&frame_step.step_id) {
                    rows.push(step_plan_trace_row(
                        Value::String(node_id.to_string()),
                        frame_step.step_id.as_ref(),
                        step,
                    ));
                } else {
                    rows.push(plan_trace_row(
                        Value::String(node_id.to_string()),
                        format!("STEP · {}", frame_step.step_id),
                        compact_trace_lines([
                            Some(format!("frame: {scope}")),
                            Some(
                                "definition error: step_id is missing from flows.*.steps"
                                    .to_string(),
                            ),
                        ]),
                    ));
                }
            }
            FlowNodeSpec::RepeatUntil(loop_spec) => {
                rows.push(plan_trace_row(
                    Value::String(node_id.to_string()),
                    format!("LOOP · {}", loop_spec.loop_id),
                    compact_trace_lines([
                        Some(format!("frame: {scope}")),
                        Some(format!(
                            "depends_on: {}",
                            list_or_none(&loop_spec.depends_on)
                        )),
                        Some(format!(
                            "depends_on_mode: {}",
                            dependency_mode_string(&loop_spec.depends_on_mode)
                        )),
                        Some(format!("until: {}", condition_to_label(&loop_spec.until))),
                        Some(format!("max_iterations: {}", loop_spec.max_iterations)),
                    ]),
                ));
                push_frame_plan_trace(
                    rows,
                    flow,
                    &loop_spec.body,
                    &format!("{scope}.{}", loop_spec.loop_id),
                );
            }
        }
    }
}

fn step_plan_trace_row(node: Value, step_id: &str, step: &FlowStepSpec) -> Value {
    plan_trace_row(
        node,
        format!("STEP · {step_id} · {}", step.role),
        compact_trace_lines([
            Some(format!("role: {}", step.role)),
            Some(format!("message: {}", content_input_summary(&step.message))),
            Some(format!("depends_on: {}", list_or_none(&step.depends_on))),
            Some(format!(
                "depends_on_mode: {}",
                dependency_mode_string(&step.depends_on_mode)
            )),
            step.condition
                .as_ref()
                .map(|condition| format!("condition: {}", condition_to_label(condition))),
            step.branch
                .as_ref()
                .map(|branch| format!("branch: {branch}")),
            Some(format!(
                "dispatch_mode: {}",
                dispatch_mode_string(&step.dispatch_mode)
            )),
            Some(format!(
                "collection_policy: {}",
                collection_policy_label(&step.collection_policy)
            )),
            step.timeout_ms
                .map(|timeout| format!("timeout_ms: {timeout}")),
            step.expected_schema_ref
                .as_ref()
                .map(|schema| format!("expected_schema_ref: {}", schema.as_raw())),
            step.allowed_tools
                .as_ref()
                .filter(|tools| !tools.is_empty())
                .map(|tools| format!("allowed_tools: {}", tools.join(", "))),
            step.blocked_tools
                .as_ref()
                .filter(|tools| !tools.is_empty())
                .map(|tools| format!("blocked_tools: {}", tools.join(", "))),
            // Omitted output_format stays visible as the authored state; the
            // effective schema-aware resolution is shown alongside it.
            Some(match step.output_format {
                Some(format) => format!("output_format: {}", step_output_format_string(format)),
                None => format!(
                    "output_format: omitted (effective: {})",
                    step_output_format_string(step.effective_output_format())
                ),
            }),
        ]),
    )
}

fn plan_trace_row(node: Value, head: String, body: String) -> Value {
    json!({
        "node": node,
        "head": head,
        "body": body,
    })
}

fn compact_trace_lines<const N: usize>(lines: [Option<String>; N]) -> String {
    lines
        .into_iter()
        .flatten()
        .map(|line| line.trim().to_string())
        .filter(|line| !line.is_empty())
        .collect::<Vec<_>>()
        .join("\n")
}

fn content_input_summary(input: &meerkat_core::types::ContentInput) -> String {
    match serde_json::to_value(input) {
        Ok(Value::String(text)) => text,
        Ok(value) => value.to_string(),
        Err(_) => "<content>".to_string(),
    }
}

fn list_or_none<T: ToString>(items: &[T]) -> String {
    if items.is_empty() {
        "none".to_string()
    } else {
        items
            .iter()
            .map(ToString::to_string)
            .collect::<Vec<_>>()
            .join(", ")
    }
}

fn collection_policy_label(policy: &CollectionPolicy) -> String {
    match policy {
        CollectionPolicy::All => "all".to_string(),
        CollectionPolicy::Any => "any".to_string(),
        CollectionPolicy::Quorum { n } => format!("quorum ({n})"),
    }
}

fn backend_kind_string(backend: &MobBackendKind) -> &'static str {
    match backend {
        MobBackendKind::Session => "session",
        MobBackendKind::External => "external",
    }
}

fn deployable_mobpack_archive_files(
    slug: &str,
    document: &MobpackDocument,
    mob_toml: &str,
) -> Result<BTreeMap<String, Vec<u8>>, String> {
    let definition = meerkat_mob::MobDefinition::from_toml(mob_toml)
        .map_err(|err| format!("failed to parse mob.toml for archive export: {err}"))?;
    let expected_schema_files =
        expected_schema_files_from_definition(&definition).map_err(|diagnostics| {
            diagnostics
                .into_iter()
                .map(|diagnostic| diagnostic.message)
                .collect::<Vec<_>>()
                .join("; ")
        })?;
    let definition_json = serde_json::to_vec_pretty(&definition)
        .map_err(|err| format!("failed to encode definition.json: {err}"))?;
    let editor_json = serde_json::to_vec_pretty(&json!({
        "schema_version": MOBPACK_SCHEMA_VERSION,
        "media_type": MOBPACK_MEDIA_TYPE,
        "document": document,
    }))
    .map_err(|err| format!("failed to encode mobkit/editor.json: {err}"))?;

    let mut files = BTreeMap::<String, Vec<u8>>::new();
    files.insert("definition.json".to_string(), definition_json);
    files.insert("mobkit/editor.json".to_string(), editor_json);
    files.insert("mobkit/mob.toml".to_string(), mob_toml.as_bytes().to_vec());
    for (path, bytes) in selected_path_skill_files(document)? {
        files.insert(path, bytes);
    }
    for (path, schema) in expected_schema_files {
        let schema_json = serde_json::to_vec_pretty(&schema)
            .map_err(|err| format!("failed to encode {path}: {err}"))?;
        files.insert(path, schema_json);
    }
    for (path, schema) in editor_input_schema_files(document) {
        let schema_json = serde_json::to_vec_pretty(&schema)
            .map_err(|err| format!("failed to encode {path}: {err}"))?;
        files.insert(path, schema_json);
    }
    let adaptive_manifest_section = append_adaptive_archive_files(document, &mut files)?;
    let mut manifest = format!(
        r#"surfaces = ["cli", "rpc"]

[mobpack]
name = "{}"
version = "{}"
description = "{}"
"#,
        escape_toml_string(slug),
        MOBPACK_SCHEMA_VERSION,
        escape_toml_string(if document.name.trim().is_empty() {
            "MobKit Flow Editor mobpack"
        } else {
            document.name.trim()
        })
    );
    if let Some(section) = adaptive_manifest_section {
        manifest.push_str(&section);
    }
    files.insert("manifest.toml".to_string(), manifest.into_bytes());
    Ok(files)
}

/// Compose the adaptive pack artifacts (meerkat 0.7.1 `[adaptive]` contract)
/// from the document's adaptive layer step: `adaptive/policies.toml`,
/// `adaptive/flowmaster.prompt.md`, `adaptive/layer-decision.schema.json`,
/// and `schemas/registry.json`. Returns the string-composed `[requires]` +
/// `[adaptive]` manifest sections (policy digest over the exact emitted
/// policy bytes), or `None` when the document has no adaptive layer.
///
/// Map an editor tool id to the adaptive-policy tool-class string that the
/// meerkat runtime emits as layer admission EVIDENCE.
///
/// The runtime derives a Rust bundle's tool class as `rust_bundle:{name}`
/// (`meerkat_mob::adaptive::collect_profile_tool_classes`), but the editor's
/// canonical id for the same bundle is `rust:{name}`. Without this mapping the
/// policy's `allowed_tool_classes` would contain `rust:foo` while the evidence
/// is `rust_bundle:foo`, so `{rust_bundle:foo} ⊆ {rust:foo}` is false and EVERY
/// adaptive layer whose profile templates use a Rust bundle is admission-
/// rejected forever (the layer is silently skipped). `mcp:{source}` already
/// matches the runtime's `mcp:{name}`, and bool/model/skill classes are
/// verbatim on both sides, so only the `rust:` prefix is rewritten.
fn adaptive_policy_tool_class(tool: &str) -> String {
    match tool.strip_prefix("rust:") {
        Some(bundle) => format!("rust_bundle:{bundle}"),
        None => tool.to_string(),
    }
}

/// The `[requires]` stamp carries the `adaptive_flow` capability: 0.7.1+
/// hosts reject adaptive packs without it (`MissingAdaptiveCapability`) and
/// fail closed on hosts that lack the capability; pre-0.7.1 hosts ignore
/// `[requires]` entirely (the documented legacy window).
fn append_adaptive_archive_files(
    document: &MobpackDocument,
    files: &mut BTreeMap<String, Vec<u8>>,
) -> Result<Option<String>, String> {
    let Some((_, step)) = adaptive_flow_steps(&document.flow).into_iter().next() else {
        return Ok(None);
    };
    let members = document.members.as_array().cloned().unwrap_or_default();
    let member_by_id = |member_id: &str| {
        members.iter().find(|member| {
            member
                .get("id")
                .and_then(Value::as_str)
                .is_some_and(|id| id == member_id)
        })
    };
    let flowmaster_id = step
        .get("flowmasterId")
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default();
    let flowmaster = member_by_id(flowmaster_id)
        .ok_or_else(|| "adaptive layer FlowMaster must reference an existing member".to_string())?;
    let flowmaster_profile = editor_profile_name(flowmaster);

    let mut model_classes = BTreeSet::new();
    let mut tool_classes = BTreeSet::new();
    let mut skill_classes = BTreeSet::new();
    for template_id in string_vec(step.get("profileTemplateIds")) {
        let Some(member) = member_by_id(template_id.trim()) else {
            continue;
        };
        if let Some(model) = member
            .get("model")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|model| !model.is_empty())
        {
            model_classes.insert(model.to_string());
        }
        tool_classes.extend(
            string_vec(member.get("tools"))
                .into_iter()
                .map(|tool| adaptive_policy_tool_class(&tool)),
        );
        skill_classes.extend(string_vec(member.get("skills")));
    }
    let to_sorted_vec = |set: &BTreeSet<String>| set.iter().cloned().collect::<Vec<_>>();
    let allow_inline_profiles = step
        .get("allowInlineProfiles")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let limits = step
        .get("limits")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();

    // Top-level allow-list keys must precede the [limits] table in TOML.
    let mut policy_lines = vec![
        format!(
            "allowed_model_classes = {}",
            toml_array(&to_sorted_vec(&model_classes))
        ),
        format!(
            "allowed_tool_classes = {}",
            toml_array(&to_sorted_vec(&tool_classes))
        ),
        format!(
            "allowed_skill_classes = {}",
            toml_array(&to_sorted_vec(&skill_classes))
        ),
        "allowed_auth_bindings = []".to_string(),
        format!("allow_inline_profiles = {allow_inline_profiles}"),
        String::new(),
        "[limits]".to_string(),
    ];
    for (camel_key, toml_key, default, _, _) in EDITOR_ADAPTIVE_LIMITS {
        let value = limits
            .get(*camel_key)
            .and_then(Value::as_u64)
            .filter(|value| *value > 0)
            .unwrap_or(*default);
        policy_lines.push(format!("{toml_key} = {value}"));
    }
    let policy = format!("{}\n", policy_lines.join("\n"));
    let policy_digest = format!("sha256:{}", source_file_sha256(policy.as_bytes()));
    files.insert("adaptive/policies.toml".to_string(), policy.into_bytes());

    // The planning prompt exports verbatim — never trimmed. An empty prompt
    // is a deployability draft gate, not an export-time mutation.
    let prompt = step
        .get("prompt")
        .and_then(Value::as_str)
        .unwrap_or_default();
    files.insert(
        "adaptive/flowmaster.prompt.md".to_string(),
        prompt.as_bytes().to_vec(),
    );
    files.insert(
        "adaptive/layer-decision.schema.json".to_string(),
        ADAPTIVE_LAYER_DECISION_SCHEMA_JSON.as_bytes().to_vec(),
    );

    let mut registry = serde_json::Map::new();
    let mut required_schemas = Vec::new();
    let result_schema = step
        .get("resultSchema")
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default();
    if !result_schema.is_empty() {
        let schema = editor_schema_map(&document.schemas)
            .get(result_schema)
            .cloned()
            .ok_or_else(|| {
                format!("adaptive layer result schema '{result_schema}' is not a document schema")
            })?;
        let rendered = editor_schema_to_json_schema(&schema);
        let default_path = format!("schemas/{result_schema}.json");
        let path = if files.contains_key(&default_path) {
            default_path
        } else {
            files
                .iter()
                .find(|(path, bytes)| {
                    path.starts_with("schemas/")
                        && path.ends_with(".json")
                        && serde_json::from_slice::<Value>(bytes)
                            .is_ok_and(|existing| existing == rendered)
                })
                .map(|(path, _)| path.clone())
                .unwrap_or(default_path)
        };
        if !files.contains_key(&path) {
            let schema_json = serde_json::to_vec_pretty(&rendered)
                .map_err(|err| format!("failed to encode {path}: {err}"))?;
            files.insert(path.clone(), schema_json);
        }
        let name = adaptive_schema_registry_name(result_schema);
        registry.insert(name.clone(), Value::String(path));
        required_schemas.push(name);
    }
    let registry_json = serde_json::to_vec_pretty(&Value::Object(registry))
        .map_err(|err| format!("failed to encode schemas/registry.json: {err}"))?;
    files.insert("schemas/registry.json".to_string(), registry_json);

    let mut section = String::from("\n[requires]\ncapabilities = [\"adaptive_flow\"]\n");
    section.push_str("\n[adaptive]\n");
    section.push_str(&format!(
        "flowmaster_profile = {}\n",
        toml_string(&flowmaster_profile)
    ));
    section.push_str(&format!(
        "objective_class = {}\n",
        toml_string(
            step.get("objectiveClass")
                .and_then(Value::as_str)
                .unwrap_or_default()
        )
    ));
    section.push_str(&format!(
        "policy_digest = {}\n",
        toml_string(&policy_digest)
    ));
    if !required_schemas.is_empty() {
        section.push_str(&format!(
            "required_schemas = {}\n",
            toml_array(&required_schemas)
        ));
    }
    let target_surfaces = string_vec(step.get("targetSurfaces"))
        .into_iter()
        .map(|surface| surface.trim().to_string())
        .filter(|surface| !surface.is_empty())
        .collect::<BTreeSet<_>>();
    if !target_surfaces.is_empty() {
        section.push_str(&format!(
            "target_surfaces = {}\n",
            toml_array(&to_sorted_vec(&target_surfaces))
        ));
    }
    Ok(Some(section))
}

fn encode_deployable_mobpack_archive(files: &BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>, String> {
    let encoder = GzEncoder::new(Vec::new(), Compression::default());
    let mut archive = Builder::new(encoder);
    for (path, bytes) in files {
        append_archive_file(&mut archive, path, bytes)?;
    }
    let encoder = archive
        .into_inner()
        .map_err(|err| format!("failed to finish mobpack archive: {err}"))?;
    encoder
        .finish()
        .map_err(|err| format!("failed to finish mobpack gzip stream: {err}"))
}

fn source_files_from_archive_files(files: &BTreeMap<String, Vec<u8>>) -> Vec<MobpackSourceFile> {
    files
        .iter()
        .map(|(path, bytes)| MobpackSourceFile {
            path: path.clone(),
            media_type: source_file_media_type(path).to_string(),
            size_bytes: bytes.len() as u64,
            content_base64: base64::engine::general_purpose::STANDARD.encode(bytes),
            sha256: source_file_sha256(bytes),
            text: String::from_utf8(bytes.clone()).ok(),
        })
        .collect()
}

fn source_file_sha256(bytes: &[u8]) -> String {
    format!("{:x}", Sha256::digest(bytes))
}

fn source_file_media_type(path: &str) -> &'static str {
    if path.ends_with(".toml") {
        "text/toml"
    } else if path.ends_with(".json") {
        "application/json"
    } else if path.ends_with(".md") {
        "text/markdown"
    } else {
        "application/octet-stream"
    }
}

fn append_archive_file<W: Write>(
    archive: &mut Builder<W>,
    path: &str,
    bytes: &[u8],
) -> Result<(), String> {
    let mut header = Header::new_gnu();
    header.set_size(bytes.len() as u64);
    header.set_mode(0o644);
    header.set_cksum();
    archive
        .append_data(&mut header, path, bytes)
        .map_err(|err| format!("failed to append {path}: {err}"))
}

fn selected_path_skill_files(
    document: &MobpackDocument,
) -> Result<BTreeMap<String, Vec<u8>>, String> {
    let mut files = BTreeMap::<String, Vec<u8>>::new();
    for (skill_id, source) in selected_skill_sources(document) {
        let source_kind = source
            .get("source")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("inline");
        if source_kind != "path" {
            continue;
        }
        let archive_path = packed_skill_archive_path(&skill_id, &source);
        let bytes = if let Some(content) = source.get("content").and_then(Value::as_str) {
            content.as_bytes().to_vec()
        } else {
            let path = source
                .get("path")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .ok_or_else(|| {
                    format!("selected path skill '{skill_id}' has no filesystem path")
                })?;
            std::fs::read(path).map_err(|err| {
                format!("failed to read selected path skill '{skill_id}' at {path}: {err}")
            })?
        };
        if let Some(existing) = files.get(&archive_path) {
            if existing != &bytes {
                return Err(format!(
                    "multiple selected skills resolve to archive path '{archive_path}' with different content"
                ));
            }
        } else {
            files.insert(archive_path, bytes);
        }
    }
    Ok(files)
}

fn selected_skill_sources(document: &MobpackDocument) -> BTreeMap<String, Value> {
    let mut selected = BTreeMap::<String, Value>::new();
    if let Some(members) = document.members.as_array() {
        for member in members {
            if member
                .get("missing")
                .and_then(Value::as_bool)
                .unwrap_or(false)
            {
                continue;
            }
            for skill in string_vec(member.get("skills")) {
                if selected.contains_key(&skill) {
                    continue;
                }
                if let Some(source) = skill_source_from_realms(&skill, &document.skill_realms) {
                    selected.insert(skill, source);
                }
            }
        }
    }
    selected
}

fn packed_skill_archive_path(skill_id: &str, source: &Value) -> String {
    if let Some(path) = source
        .get("path")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|path| is_safe_relative_archive_path(path))
    {
        return path.to_string();
    }
    let slug = sanitize_slug(skill_id).unwrap_or_else(|| "skill".to_string());
    format!("skills/{slug}.md")
}

fn is_safe_relative_archive_path(path: &str) -> bool {
    let path = path.trim();
    if path.is_empty() || path.contains('\\') || Path::new(path).is_absolute() {
        return false;
    }
    Path::new(path)
        .components()
        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
}

fn hydrate_path_skill_content_from_archive(
    document: &mut MobpackDocument,
    files: &BTreeMap<String, Vec<u8>>,
) -> Result<(), String> {
    let Some(realms) = document.skill_realms.as_array_mut() else {
        return Ok(());
    };
    for realm in realms {
        let Some(skills) = realm.get_mut("skills").and_then(Value::as_array_mut) else {
            continue;
        };
        for skill in skills {
            if skill.get("source").and_then(Value::as_str) != Some("path")
                || skill.get("content").and_then(Value::as_str).is_some()
            {
                continue;
            }
            let Some(path) = skill
                .get("path")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                continue;
            };
            let Some(bytes) = files.get(path).or_else(|| {
                let skill_id = skill.get("id").and_then(Value::as_str).unwrap_or("skill");
                let archive_path = packed_skill_archive_path(skill_id, skill);
                files.get(&archive_path)
            }) else {
                continue;
            };
            let content = String::from_utf8(bytes.clone()).map_err(|err| {
                format!("packed skill archive member {path} is not valid UTF-8: {err}")
            })?;
            skill["content"] = json!(content);
        }
    }
    Ok(())
}

fn document_from_params(params: &Value) -> Result<MobpackDocument, String> {
    if let Some(encoded) = params.get("content_base64").and_then(Value::as_str) {
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(encoded)
            .map_err(|err| format!("invalid content_base64: {err}"))?;
        if let Ok(value) = serde_json::from_slice::<Value>(&bytes) {
            return document_from_value(&value);
        }
        return document_from_archive_bytes(&bytes);
    }
    if let Some(document) = params.get("document") {
        return document_from_value(document);
    }
    document_from_value(params)
}

/// True when a mobpack archive carries an adaptive layer that import cannot
/// reconstruct: the `manifest.toml` `[adaptive]` section, or `adaptive/`
/// artifacts (`policies.toml`, `flowmaster.prompt.md`) written by
/// [`append_adaptive_archive_files`]. Used to fail-closed on adaptive packs
/// that lack a `mobkit/editor.json` (the only form import can round-trip).
fn archive_carries_adaptive_layer(
    manifest_toml: Option<&str>,
    files: &BTreeMap<String, Vec<u8>>,
) -> bool {
    if manifest_toml.is_some_and(manifest_declares_adaptive_section) {
        return true;
    }
    files
        .keys()
        .any(|path| path == "adaptive/policies.toml" || path == "adaptive/flowmaster.prompt.md")
}

/// True when a `manifest.toml` body contains a top-level `[adaptive]` table
/// header (line-anchored so an `[adaptive.x]` subtable or a `[requires]`
/// string value cannot false-match).
fn manifest_declares_adaptive_section(manifest: &str) -> bool {
    manifest
        .lines()
        .map(str::trim)
        .any(|line| line == "[adaptive]")
}

fn document_from_archive_bytes(bytes: &[u8]) -> Result<MobpackDocument, String> {
    let decoder = GzDecoder::new(bytes);
    let mut archive = tar::Archive::new(decoder);
    let mut files = BTreeMap::<String, Vec<u8>>::new();
    let mut editor_json = None;
    let mut mob_toml = None;
    let mut manifest_toml = None;
    for entry in archive
        .entries()
        .map_err(|err| format!("invalid mobpack archive: {err}"))?
    {
        let mut entry = entry.map_err(|err| format!("invalid mobpack archive entry: {err}"))?;
        let path = entry
            .path()
            .map_err(|err| format!("invalid mobpack archive path: {err}"))?
            .to_string_lossy()
            .to_string();
        let mut bytes = Vec::new();
        entry
            .read_to_end(&mut bytes)
            .map_err(|err| format!("failed to read archive member {path}: {err}"))?;
        if matches!(
            path.as_str(),
            "mobkit/editor.json" | "mobkit/mob.toml" | "manifest.toml"
        ) {
            let text = String::from_utf8(bytes.clone())
                .map_err(|err| format!("invalid UTF-8 archive member {path}: {err}"))?;
            match path.as_str() {
                "mobkit/editor.json" => editor_json = Some(text),
                "mobkit/mob.toml" => mob_toml = Some(text),
                "manifest.toml" => manifest_toml = Some(text),
                _ => {}
            }
        }
        files.insert(path, bytes);
    }
    if let Some(text) = editor_json {
        let value: Value = serde_json::from_str(&text)
            .map_err(|err| format!("invalid mobkit/editor.json: {err}"))?;
        let mut document = document_from_value(&value)?;
        if let Some(mob_toml) = mob_toml.as_ref() {
            document.mob_toml = Some(mob_toml.clone());
        }
        hydrate_path_skill_content_from_archive(&mut document, &files)?;
        if let Some(mob_toml) = mob_toml.as_ref()
            && archive_editor_projection_should_reproject(&document, mob_toml)
        {
            let definition = MobDefinition::from_toml(mob_toml)
                .map_err(|err| format!("failed to parse mob.toml from mobpack archive: {err}"))?;
            let fallback_name = document.name.trim().to_string();
            let deploy = document.deploy.clone();
            let mut projected = project_definition_to_editor_document(
                &definition,
                mob_toml,
                (!fallback_name.is_empty()).then_some(fallback_name.as_str()),
                deploy,
            )?;
            hydrate_path_skill_content_from_archive(&mut projected, &files)?;
            // The mob.toml reprojection (member/parallel/branch/repeat steps)
            // cannot reconstruct the adaptive layer step. If the editor.json
            // document carried one, reprojecting would silently gut the
            // adaptive layer — keep the editor-authored document instead of
            // dropping a step that 0.7.1 hosts run on.
            if !adaptive_flow_steps(&document.flow).is_empty()
                && adaptive_flow_steps(&projected.flow).is_empty()
            {
                return Ok(document);
            }
            return Ok(projected);
        }
        return Ok(document);
    }
    // No `mobkit/editor.json`: the document is reconstructed by projecting
    // `mob.toml`, which only produces member/parallel/branch/repeat steps. An
    // adaptive pack (meerkat-side or hand-built) carries its adaptive layer in
    // `manifest.toml [adaptive]` + `adaptive/` artifacts that the projection
    // cannot read back. Refuse rather than silently import a gutted,
    // non-adaptive document (re-export would also strip the `[requires]` /
    // `[adaptive]` capability stamp).
    if archive_carries_adaptive_layer(manifest_toml.as_deref(), &files) {
        return Err(
            "mobpack archive declares an adaptive layer ([adaptive] in manifest.toml) but \
             carries no mobkit/editor.json; the Flow Editor cannot project the adaptive layer \
             from mob.toml alone. Import the editor-authored pack (with mobkit/editor.json) or \
             edit this pack with the meerkat tooling that produced it."
                .to_string(),
        );
    }
    let name = manifest_toml
        .as_deref()
        .and_then(extract_manifest_name)
        .unwrap_or_else(|| "mobpack".to_string());

    if let Some(mob_toml) = mob_toml {
        let definition = MobDefinition::from_toml(&mob_toml)
            .map_err(|err| format!("failed to parse mob.toml from mobpack archive: {err}"))?;
        let mut document = project_definition_to_editor_document(
            &definition,
            &mob_toml,
            Some(&name),
            Value::Null,
        )?;
        hydrate_path_skill_content_from_archive(&mut document, &files)?;
        return Ok(document);
    }

    Ok(MobpackDocument {
        schema_version: MOBPACK_SCHEMA_VERSION.to_string(),
        mob_id: sanitize_slug(&name).unwrap_or_else(|| "mobpack".to_string()),
        name,
        mob_settings: Value::Null,
        members: Value::Null,
        instances: Value::Null,
        edges: Value::Null,
        frames: Value::Null,
        schemas: Value::Null,
        skill_realms: Value::Null,
        flow: Value::Null,
        launch_modes: Value::Null,
        deploy: Value::Null,
        mob_toml: None,
    })
}

fn document_from_value(value: &Value) -> Result<MobpackDocument, String> {
    if let Some(document) = value.get("document") {
        return document_from_value(document);
    }
    let mut document: MobpackDocument = serde_json::from_value(value.clone())
        .map_err(|err| format!("invalid mobpack document: {err}"))?;
    if document.mob_toml.is_none()
        && let Some(files) = value.get("files").and_then(Value::as_object)
        && let Some(text) = files.get("mob.toml").and_then(Value::as_str)
    {
        document.mob_toml = Some(text.to_string());
    }
    upgrade_legacy_deploy_command(&mut document);
    Ok(document)
}

/// Drafts and packs authored before the 0.7.1 migration persisted
/// `deploy.command = "rkat mob deploy"`. The deploy surface now executes
/// `rkat mob run`, so hydration upgrades the legacy pin in place instead
/// of failing validation on old documents.
fn upgrade_legacy_deploy_command(document: &mut MobpackDocument) {
    if document
        .deploy
        .get("command")
        .and_then(Value::as_str)
        .map(str::trim)
        == Some("rkat mob deploy")
    {
        document.deploy["command"] = json!("rkat mob run");
    }
}

fn archive_editor_projection_should_reproject(
    document: &MobpackDocument,
    packed_mob_toml: &str,
) -> bool {
    match render_editor_document_mob_toml(document) {
        Ok(rendered) if rendered.trim() != packed_mob_toml.trim() => return true,
        Err(_) => return true,
        _ => {}
    }
    let validation = validate_document(document);
    validation
        .diagnostics
        .iter()
        .any(|diagnostic| is_archive_editor_projection_drift_code(&diagnostic.code))
}

fn is_archive_editor_projection_drift_code(code: &str) -> bool {
    code.starts_with("editor_profile_")
        || code.starts_with("editor_flow_")
        || code.starts_with("graph_")
        || code.starts_with("stale_editor_")
        || matches!(
            code,
            "flow_step_missing_graph_instance"
                | "graph_instance_missing_from_flow"
                | "missing_compiled_graph_gate"
                | "missing_compiled_graph_frame"
                | "missing_graph_member"
                | "unknown_graph_member"
                | "unknown_graph_edge_endpoint"
                | "uncompiled_graph_gate"
                | "uncompiled_graph_terminal"
                | "uncompiled_graph_frame"
        )
}

fn needs_editor_projection(document: &MobpackDocument) -> bool {
    editor_value_missing(&document.members)
        || editor_value_missing(&document.flow)
        || editor_value_missing(&document.instances)
        || !document.edges.is_array()
}

fn authoring_mob_toml(document: &MobpackDocument) -> Result<String, String> {
    if !needs_editor_projection(document) {
        return render_editor_document_mob_toml(document);
    }
    document
        .mob_toml
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| "mobpack document has no editor projection or mob.toml source".to_string())
}

fn editor_value_missing(value: &Value) -> bool {
    match value {
        Value::Null => true,
        Value::Array(items) => items.is_empty(),
        Value::Object(map) => map.is_empty(),
        _ => false,
    }
}

fn profile_backend_to_value(backend: Option<MobBackendKind>) -> Value {
    backend
        .map(|kind| json!(kind.as_str()))
        .unwrap_or(Value::Null)
}

fn mob_settings_from_definition(definition: &MobDefinition) -> Value {
    json!({
        "orchestrator": definition.orchestrator.as_ref().map(|config| config.profile.to_string()).unwrap_or_default(),
        "autoWireOrchestrator": definition.wiring.auto_wire_orchestrator,
        "roleWiring": definition.wiring.role_wiring.iter().map(|rule| {
            json!({
                "a": rule.a.to_string(),
                "b": rule.b.to_string(),
            })
        }).collect::<Vec<_>>(),
        "backendDefault": definition.backend.default.as_str(),
        "externalAddressBase": definition.backend.external.as_ref().map(|config| config.address_base.as_str()).unwrap_or_default(),
        "advanced": {
            "topology": optional_definition_value(&definition.topology),
            "supervisor": optional_definition_value(&definition.supervisor),
            "limits": optional_definition_value(&definition.limits),
            "spawnPolicy": optional_definition_value(&definition.spawn_policy),
            "eventRouter": optional_definition_value(&definition.event_router),
        },
    })
}

fn optional_definition_value<T: Serialize>(value: &Option<T>) -> Value {
    value
        .as_ref()
        .and_then(|value| serde_json::to_value(value).ok())
        .unwrap_or(Value::Null)
}

fn project_definition_to_editor_document(
    definition: &MobDefinition,
    mob_toml: &str,
    fallback_name: Option<&str>,
    deploy: Value,
) -> Result<MobpackDocument, String> {
    project_definition_to_editor_document_for_flow(
        definition,
        mob_toml,
        fallback_name,
        deploy,
        None,
    )
}

fn project_definition_to_editor_document_for_flow(
    definition: &MobDefinition,
    mob_toml: &str,
    fallback_name: Option<&str>,
    deploy: Value,
    selected_flow_id: Option<&str>,
) -> Result<MobpackDocument, String> {
    let deploy = projected_deploy_for_runtime_modes(definition, deploy);
    let mut schemas = Vec::new();
    let mut members = Vec::new();
    for (profile_name, binding) in &definition.profiles {
        let profile_key = profile_name.to_string();
        match binding {
            ProfileBinding::Inline(profile) => {
                let schema_id = profile
                    .output_schema
                    .as_ref()
                    .and_then(|schema| {
                        let schema_id = format!("{}Output", pascal_identifier(&profile_key));
                        output_schema_to_editor_schema(&schema_id, schema.as_value())
                    })
                    .map(|schema| {
                        let id = schema
                            .get("id")
                            .and_then(Value::as_str)
                            .unwrap_or_default()
                            .to_string();
                        schemas.push(schema);
                        id
                    })
                    .unwrap_or_default();
                members.push(json!({
                    "id": member_id_for_profile(&profile_key),
                    "name": profile_key,
                    "role": profile_name.to_string(),
                    "profileBinding": "inline",
                    "model": profile.model,
                    "systemPrompt": profile.peer_description,
                    "tools": tool_ids_from_config(&profile.tools),
                    "schema": schema_id,
                    "skills": profile.skills,
                    "runtimeMode": profile.runtime_mode.to_string(),
                    "externalAddressable": profile.external_addressable,
                    "backend": profile_backend_to_value(profile.backend),
                    "maxInlinePeerNotifications": profile.max_inline_peer_notifications,
                    "providerParams": profile.provider_params,
                }));
            }
            ProfileBinding::RealmRef { realm_profile } => {
                members.push(json!({
                    "id": member_id_for_profile(&profile_key),
                    "name": profile_key,
                    "role": profile_name.to_string(),
                    "profileBinding": "realm_profile",
                    "model": "",
                    "systemPrompt": format!("Realm profile reference: {realm_profile}"),
                    "tools": [],
                    "schema": "",
                    "skills": [],
                    "runtimeMode": "turn_driven",
                    "externalAddressable": false,
                    "realmProfile": realm_profile,
                }));
            }
        }
    }

    let primary = match selected_flow_id
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        Some(selected) => Some(
            definition
                .flows
                .iter()
                .find(|(flow_id, _)| *flow_id == selected)
                .ok_or_else(|| format!("runtime flow not found in MobDefinition: {selected}"))?,
        ),
        None => definition.flows.iter().next(),
    };
    let (flow, instances, edges, frames) = if let Some((flow_id, flow_spec)) = primary {
        project_flow(flow_id.to_string().as_str(), flow_spec)
    } else {
        (
            json!({
                "name": definition.id.to_string(),
                "steps": [editor_input_step_value(
                    "input_1",
                    editor_input_step_default_task().to_string(),
                    String::new(),
                    Vec::new()
                )]
            }),
            Vec::new(),
            Vec::new(),
            Vec::new(),
        )
    };
    let launch_modes = launch_modes_from_instances(&instances);

    Ok(MobpackDocument {
        schema_version: MOBPACK_SCHEMA_VERSION.to_string(),
        mob_id: definition.id.to_string(),
        name: fallback_name
            .map(ToString::to_string)
            .unwrap_or_else(|| definition.id.to_string()),
        mob_settings: mob_settings_from_definition(definition),
        members: Value::Array(members),
        instances: Value::Array(instances),
        edges: Value::Array(edges),
        frames: Value::Array(frames),
        schemas: Value::Array(schemas),
        skill_realms: skill_realms_from_definition(definition),
        flow,
        launch_modes,
        deploy,
        mob_toml: Some(mob_toml.to_string()),
    })
}

fn projected_deploy_for_runtime_modes(definition: &MobDefinition, deploy: Value) -> Value {
    if deploy
        .get("surface")
        .and_then(Value::as_str)
        .map(str::trim)
        .is_some_and(|value| !value.is_empty())
    {
        return deploy;
    }
    let has_autonomous_host = definition.profiles.values().any(|binding| match binding {
        ProfileBinding::Inline(profile) => profile.runtime_mode.to_string() == "autonomous_host",
        ProfileBinding::RealmRef { .. } => false,
    });
    if !has_autonomous_host {
        return deploy;
    }
    let mut object = deploy.as_object().cloned().unwrap_or_default();
    object
        .entry("command".to_string())
        .or_insert_with(|| json!("rkat mob run"));
    object.insert("surface".to_string(), json!("rpc"));
    Value::Object(object)
}

fn project_flow(flow_id: &str, flow: &FlowSpec) -> (Value, Vec<Value>, Vec<Value>, Vec<Value>) {
    let visual_steps = visual_steps_from_frame(&flow.root, flow);
    let task = flow
        .description
        .clone()
        .unwrap_or_else(|| editor_input_step_default_task().to_string());
    let input_params = input_params_from_flow(flow);
    let input_fields = input_param_summary(&input_params);
    let mut steps = vec![editor_input_step_value(
        "input_1",
        task,
        input_fields,
        input_params,
    )];
    steps.extend(visual_steps);
    let (instances, edges, frames) = graph_projection_from_visual_steps(&steps);
    (
        json!({
            "name": flow_id,
            "steps": steps,
        }),
        instances,
        edges,
        frames,
    )
}

#[derive(Debug, Clone, Default)]
struct VisualGraphProjection {
    entries: Vec<String>,
    exits: Vec<String>,
    next_col: usize,
}

fn graph_projection_from_visual_steps(steps: &[Value]) -> (Vec<Value>, Vec<Value>, Vec<Value>) {
    let mut instances = Vec::new();
    let mut edges = Vec::new();
    let mut frames = Vec::new();
    let mut next_edge = 1usize;
    emit_visual_graph_sequence(
        steps,
        0,
        0,
        &mut instances,
        &mut edges,
        &mut frames,
        &mut next_edge,
    );
    (instances, edges, frames)
}

fn emit_visual_graph_sequence(
    steps: &[Value],
    start_col: usize,
    row: usize,
    instances: &mut Vec<Value>,
    edges: &mut Vec<Value>,
    frames: &mut Vec<Value>,
    next_edge: &mut usize,
) -> VisualGraphProjection {
    let mut entries: Vec<String> = Vec::new();
    let mut exits: Vec<String> = Vec::new();
    let mut col = start_col;
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("input") {
            continue;
        }
        let emitted = emit_visual_graph_step(step, col, row, instances, edges, frames, next_edge);
        if entries.is_empty() {
            entries = emitted.entries.clone();
        }
        for from in &exits {
            for to in &emitted.entries {
                push_visual_graph_edge(edges, next_edge, from, to, "next", "", Value::Null);
            }
        }
        exits = emitted.exits;
        col = emitted.next_col;
    }
    VisualGraphProjection {
        entries,
        exits,
        next_col: col,
    }
}

fn emit_visual_graph_step(
    step: &Value,
    col: usize,
    row: usize,
    instances: &mut Vec<Value>,
    edges: &mut Vec<Value>,
    frames: &mut Vec<Value>,
    next_edge: &mut usize,
) -> VisualGraphProjection {
    let Some(id) = step
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return VisualGraphProjection {
            next_col: col,
            ..VisualGraphProjection::default()
        };
    };
    match step.get("type").and_then(Value::as_str) {
        Some("member") => {
            instances.push(json!({
                "id": id,
                "memberId": step.get("role").and_then(Value::as_str).unwrap_or_default(),
                "col": col,
                "row": row,
                "launchMode": step.get("launchMode").or_else(|| step.get("launch_mode")).cloned().unwrap_or(Value::Null),
                "lane": "",
                "dispatchMode": explicit_visual_dispatch_mode(step),
                "collection": explicit_visual_collection_policy(step),
                "quorum": step.get("quorum").or_else(|| step.get("collectionQuorum")).cloned().unwrap_or(Value::Null),
                "timeoutMs": step.get("timeoutMs").or_else(|| step.get("timeout_ms")).cloned().unwrap_or(Value::Null),
                "allowedTools": step.get("allowedTools").or_else(|| step.get("allowed_tools")).cloned().unwrap_or_else(|| json!([])),
                "blockedTools": step.get("blockedTools").or_else(|| step.get("blocked_tools")).cloned().unwrap_or_else(|| json!([])),
                "outputFormat": step.get("outputFormat").or_else(|| step.get("output_format")).cloned().unwrap_or(Value::Null),
            }));
            VisualGraphProjection {
                entries: vec![id.to_string()],
                exits: vec![id.to_string()],
                next_col: col + 1,
            }
        }
        Some("repeat") => {
            let frame_start = col;
            let body = emit_visual_graph_sequence(
                step.get("steps")
                    .and_then(Value::as_array)
                    .map(Vec::as_slice)
                    .unwrap_or(&[]),
                col,
                row,
                instances,
                edges,
                frames,
                next_edge,
            );
            let cond = step
                .get("cond")
                .and_then(graph_cond_from_editor_cond)
                .unwrap_or(Value::Null);
            let label = step
                .get("until")
                .and_then(Value::as_str)
                .filter(|value| !value.trim().is_empty())
                .map(|value| format!("until {value}"))
                .unwrap_or_else(|| "until condition".to_string());
            for from in &body.exits {
                for to in &body.entries {
                    push_visual_graph_edge(
                        edges,
                        next_edge,
                        from,
                        to,
                        "cond",
                        &label,
                        cond.clone(),
                    );
                }
            }
            if !body.entries.is_empty() {
                let max_iterations_label = step
                    .get("maxIterations")
                    .and_then(Value::as_u64)
                    .filter(|value| *value > 0)
                    .map(|value| format!("max {value}"))
                    .unwrap_or_else(|| "missing max_iterations".to_string());
                frames.push(json!({
                    "id": format!("frame_{id}"),
                    "kind": "RepeatUntil",
                    "colStart": frame_start,
                    "colEnd": body.next_col.saturating_sub(1).max(frame_start),
                    "label": format!("REPEAT-UNTIL · {label} · {max_iterations_label}"),
                }));
            }
            body
        }
        Some("branch" | "parallel") => {
            emit_visual_graph_fork_step(step, id, col, row, instances, edges, frames, next_edge)
        }
        Some("adaptive") => {
            // A single inline node carrying the full step payload; the flow
            // derives back byte-identically from `instance.adaptive`.
            instances.push(json!({
                "id": id,
                "kind": "adaptive",
                "label": "adaptive layer",
                "memberId": Value::Null,
                "adaptive": step.clone(),
                "col": col,
                "row": row,
            }));
            VisualGraphProjection {
                entries: vec![id.to_string()],
                exits: vec![id.to_string()],
                next_col: col + 1,
            }
        }
        _ => VisualGraphProjection {
            next_col: col,
            ..VisualGraphProjection::default()
        },
    }
}

#[allow(clippy::too_many_arguments)]
fn emit_visual_graph_fork_step(
    step: &Value,
    id: &str,
    col: usize,
    row: usize,
    instances: &mut Vec<Value>,
    edges: &mut Vec<Value>,
    frames: &mut Vec<Value>,
    next_edge: &mut usize,
) -> VisualGraphProjection {
    let step_type = step.get("type").and_then(Value::as_str).unwrap_or_default();
    let is_parallel = step_type == "parallel";
    let gate_id = format!("g_{step_type}_{id}");
    let join_id = format!("j_{step_type}_{id}");
    instances.push(json!({
        "id": gate_id,
        "isGate": true,
        "gateKind": if is_parallel { "fork" } else { "branch" },
        "label": if is_parallel { explicit_visual_dispatch_mode(step) } else { "branch".to_string() },
        "col": col,
        "row": row,
    }));
    let mut lane_exits = Vec::new();
    let mut max_col = col + 1;
    if let Some(branches) = step.get("branches").and_then(Value::as_array) {
        for (index, branch) in branches.iter().enumerate() {
            let lane = emit_visual_graph_sequence(
                branch
                    .get("steps")
                    .and_then(Value::as_array)
                    .map(Vec::as_slice)
                    .unwrap_or(&[]),
                col + 1,
                row + index,
                instances,
                edges,
                frames,
                next_edge,
            );
            let condition = branch
                .get("condition")
                .and_then(Value::as_str)
                .unwrap_or_default();
            let cond = branch
                .get("cond")
                .and_then(graph_cond_from_editor_cond)
                .unwrap_or(Value::Null);
            for entry in &lane.entries {
                push_visual_graph_edge(
                    edges,
                    next_edge,
                    &gate_id,
                    entry,
                    if is_parallel { "fanout" } else { "cond" },
                    condition,
                    cond.clone(),
                );
            }
            lane_exits.extend(lane.exits);
            max_col = max_col.max(lane.next_col);
        }
    }
    if !is_parallel {
        let fallback = emit_visual_graph_sequence(
            step.get("fallback")
                .and_then(Value::as_array)
                .map(Vec::as_slice)
                .unwrap_or(&[]),
            col + 1,
            row + step
                .get("branches")
                .and_then(Value::as_array)
                .map(Vec::len)
                .unwrap_or_default(),
            instances,
            edges,
            frames,
            next_edge,
        );
        for entry in &fallback.entries {
            push_visual_graph_edge(
                edges,
                next_edge,
                &gate_id,
                entry,
                "next",
                "fallback",
                Value::Null,
            );
        }
        lane_exits.extend(fallback.exits);
        max_col = max_col.max(fallback.next_col);
    }
    instances.push(json!({
        "id": join_id,
        "isGate": true,
        "gateKind": "join",
        "label": if is_parallel { format!("join · {}", explicit_visual_collection_policy(step)) } else { "join · branch paths".to_string() },
        "col": max_col,
        "row": row,
        "collection": if is_parallel { explicit_visual_collection_policy(step) } else { "any".to_string() },
        "controllerRole": step.get("controllerRole").or_else(|| step.get("controllerMemberId")).or_else(|| step.get("controlRole")).cloned().unwrap_or(Value::Null),
        "quorum": if step.get("collection").and_then(Value::as_str) == Some("quorum") { json!({ "mode": "NofM", "n": step.get("quorum").and_then(Value::as_u64).unwrap_or(2), "m": lane_exits.len().max(1) }) } else { Value::Null },
    }));
    for exit in &lane_exits {
        push_visual_graph_edge(edges, next_edge, exit, &join_id, "next", "", Value::Null);
    }
    frames.push(json!({
        "id": format!("frame_{step_type}_{id}"),
        "kind": if is_parallel { "Parallel" } else { "Branch" },
        "colStart": col,
        "colEnd": max_col,
        "label": if is_parallel {
            visual_parallel_frame_label(step)
        } else {
            format!(
                "BRANCH · {} path{}",
                lane_exits.len(),
                if lane_exits.len() == 1 { "" } else { "s" }
            )
        },
    }));
    VisualGraphProjection {
        entries: vec![gate_id],
        exits: vec![join_id],
        next_col: max_col + 1,
    }
}

fn explicit_visual_dispatch_mode(step: &Value) -> String {
    step.get("dispatch")
        .or_else(|| step.get("dispatchMode"))
        .or_else(|| step.get("dispatch_mode"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .unwrap_or_default()
}

fn explicit_visual_collection_policy(step: &Value) -> String {
    let Some(policy) = step
        .get("collection")
        .or_else(|| step.get("collectionPolicy"))
        .or_else(|| step.get("collection_policy"))
    else {
        return String::new();
    };
    match policy {
        Value::String(text) => text.trim().to_string(),
        Value::Object(map) => map
            .get("type")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
            .unwrap_or_default(),
        _ => String::new(),
    }
}

fn visual_parallel_frame_label(step: &Value) -> String {
    let dispatch = explicit_visual_dispatch_mode(step);
    let collection = explicit_visual_collection_policy(step);
    format!(
        "PARALLEL · {} · join {}",
        if dispatch.is_empty() {
            "missing dispatch"
        } else {
            dispatch.as_str()
        },
        if collection.is_empty() {
            "missing collection"
        } else {
            collection.as_str()
        }
    )
}

fn push_visual_graph_edge(
    edges: &mut Vec<Value>,
    next_edge: &mut usize,
    from: &str,
    to: &str,
    kind: &str,
    label: &str,
    cond: Value,
) {
    edges.push(json!({
        "id": format!("e{}", *next_edge),
        "from": from,
        "to": to,
        "kind": kind,
        "label": label,
        "cond": cond,
    }));
    *next_edge += 1;
}

fn graph_cond_from_editor_cond(cond: &Value) -> Option<Value> {
    let step_id = cond.get("stepId").and_then(Value::as_str)?.trim();
    let field = cond.get("field").and_then(Value::as_str)?.trim();
    if step_id.is_empty() || field.is_empty() {
        return None;
    }
    let namespace = cond
        .get("namespace")
        .and_then(Value::as_str)
        .unwrap_or_default();
    let var = if namespace == "params" || step_id == "params" {
        format!("params.{field}")
    } else {
        format!("steps.{step_id}.{field}")
    };
    Some(json!({
        "var": var,
        "op": cond.get("op").and_then(Value::as_str).unwrap_or("=="),
        "val": cond.get("val").and_then(Value::as_str).unwrap_or_default(),
    }))
}

fn input_params_from_flow(flow: &FlowSpec) -> Vec<Value> {
    let mut names = BTreeSet::new();
    for step in flow.steps.values() {
        if let Some(condition) = &step.condition {
            collect_condition_param_refs(condition, &mut names);
        }
    }
    collect_frame_param_refs(&flow.root, &mut names);
    names
        .into_iter()
        .enumerate()
        .map(|(index, name)| {
            json!({
                "id": format!("p{}", index + 1),
                "name": name,
                "type": "string",
                "required": true,
                "description": "Activation parameter referenced by a MobKit condition.",
                "enumValues": [],
            })
        })
        .collect()
}

fn collect_frame_param_refs(frame: &FrameSpec, out: &mut BTreeSet<String>) {
    for node in frame.nodes.values() {
        if let FlowNodeSpec::RepeatUntil(loop_spec) = node {
            collect_condition_param_refs(&loop_spec.until, out);
            collect_frame_param_refs(&loop_spec.body, out);
        }
    }
}

fn collect_condition_param_refs(condition: &ConditionExpr, out: &mut BTreeSet<String>) {
    match condition {
        ConditionExpr::Eq { path, .. }
        | ConditionExpr::In { path, .. }
        | ConditionExpr::Gt { path, .. }
        | ConditionExpr::Lt { path, .. } => {
            if let Some(field) = path.strip_prefix("params.") {
                let field = field.split('.').next().unwrap_or_default().trim();
                if !field.is_empty() {
                    out.insert(field.to_string());
                }
            }
        }
        ConditionExpr::And { exprs } | ConditionExpr::Or { exprs } => {
            for expr in exprs {
                collect_condition_param_refs(expr, out);
            }
        }
        ConditionExpr::Not { expr } => collect_condition_param_refs(expr, out),
    }
}

fn input_param_summary(params: &[Value]) -> String {
    params
        .iter()
        .filter_map(|param| {
            let name = param.get("name").and_then(Value::as_str)?.trim();
            if name.is_empty() {
                return None;
            }
            let field_type = param
                .get("type")
                .and_then(Value::as_str)
                .unwrap_or("string")
                .trim();
            let optional = param
                .get("required")
                .and_then(Value::as_bool)
                .is_some_and(|required| !required);
            Some(format!(
                "{name}: {}{}",
                if field_type.is_empty() {
                    "string"
                } else {
                    field_type
                },
                if optional { "?" } else { "" }
            ))
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn visual_steps_from_frame(frame: &FrameSpec, flow: &FlowSpec) -> Vec<Value> {
    let mut out = Vec::new();
    let mut consumed = BTreeSet::new();
    for (node_id, node) in &frame.nodes {
        if consumed.contains(node_id) {
            continue;
        }
        match node {
            FlowNodeSpec::Step(frame_step) => {
                if let Some(step) = flow.steps.get(&frame_step.step_id) {
                    if step.branch.is_none() {
                        let parallel_nodes = parallel_sibling_nodes(frame, flow, frame_step);
                        if parallel_nodes.len() > 1 {
                            for (candidate_node_id, _, _) in &parallel_nodes {
                                consumed.insert((*candidate_node_id).clone());
                            }
                            let id = parallel_nodes
                                .iter()
                                .map(|(_, candidate_frame_step, _)| {
                                    sanitize_identifier(candidate_frame_step.step_id.as_ref())
                                })
                                .collect::<Vec<_>>()
                                .join("_");
                            out.push(json!({
                                "id": format!("parallel_{id}"),
                                "type": "parallel",
                                "dispatch": "fan_out",
                                "collection": "all",
                                "dependsMode": dependency_mode_string(&frame_step.depends_on_mode),
                                "branches": parallel_nodes.iter().enumerate().map(|(index, (_, candidate_frame_step, candidate_step))| json!({
                                    "id": format!("br_{}", sanitize_identifier(candidate_frame_step.step_id.as_ref())),
                                    "label": candidate_frame_step.step_id.to_string(),
                                    "steps": [visual_step_from_step(candidate_frame_step.step_id.as_ref(), candidate_step)],
                                    "order": index,
                                })).collect::<Vec<_>>(),
                            }));
                            continue;
                        }
                    }
                    if let Some(branch_id) = &step.branch {
                        let branch_nodes = frame
                            .nodes
                            .iter()
                            .filter_map(|(candidate_node_id, candidate_node)| {
                                let FlowNodeSpec::Step(candidate_frame_step) = candidate_node
                                else {
                                    return None;
                                };
                                let candidate_step =
                                    flow.steps.get(&candidate_frame_step.step_id)?;
                                (candidate_step.branch.as_ref() == Some(branch_id)).then_some((
                                    candidate_node_id,
                                    candidate_frame_step,
                                    candidate_step,
                                ))
                            })
                            .collect::<Vec<_>>();
                        if branch_nodes.len() > 1 {
                            for (candidate_node_id, _, _) in &branch_nodes {
                                consumed.insert((*candidate_node_id).clone());
                            }
                            let controller_role =
                                branch_controller_role_from_depends_on(frame, flow, &branch_nodes);
                            out.push(json!({
                                "id": format!("branch_{}", sanitize_identifier(branch_id.as_ref())),
                                "type": "branch",
                                "controllerRole": controller_role,
                                "dependsMode": dependency_mode_string(&step.depends_on_mode),
                                "branches": branch_nodes.iter().map(|(_, candidate_frame_step, candidate_step)| json!({
                                    "id": format!("br_{}", sanitize_identifier(candidate_frame_step.step_id.as_ref())),
                                    "label": candidate_frame_step.step_id.to_string(),
                                    "condition": candidate_step.condition.as_ref().map(condition_to_label).unwrap_or_default(),
                                    "cond": candidate_step.condition.as_ref().map(condition_to_editor_cond_json),
                                    "steps": [visual_step_from_step(candidate_frame_step.step_id.as_ref(), candidate_step)],
                                })).collect::<Vec<_>>(),
                                "fallback": [],
                            }));
                            continue;
                        }
                    }
                    out.push(visual_step_from_step(frame_step.step_id.as_ref(), step));
                }
            }
            FlowNodeSpec::RepeatUntil(loop_spec) => {
                let body_steps = visual_steps_from_frame(&loop_spec.body, flow);
                let body_last_step = last_step_id_in_frame(&loop_spec.body);
                out.push(json!({
                    "id": node_id.to_string(),
                    "type": "repeat",
                    "loopId": loop_spec.loop_id.to_string(),
                    "maxIterations": loop_spec.max_iterations,
                    "iterationInput": Value::Null,
                    "cond": repeat_condition_to_editor(&loop_spec.until, body_last_step.as_deref()),
                    "steps": body_steps,
                }));
            }
        }
    }
    out
}

fn branch_controller_role_from_depends_on(
    frame: &FrameSpec,
    flow: &FlowSpec,
    branch_nodes: &[(
        &meerkat_mob::FlowNodeId,
        &meerkat_mob::definition::FrameStepSpec,
        &FlowStepSpec,
    )],
) -> Option<String> {
    let (_, frame_step, _) = branch_nodes.first()?;
    for node_id in &frame_step.depends_on {
        let Some(FlowNodeSpec::Step(dep_step)) = frame.nodes.get(node_id) else {
            continue;
        };
        let Some(step) = flow.steps.get(&dep_step.step_id) else {
            continue;
        };
        if step.branch.is_none() {
            return Some(member_id_for_profile(step.role.as_ref()));
        }
    }
    None
}

fn parallel_sibling_nodes<'a>(
    frame: &'a FrameSpec,
    flow: &'a FlowSpec,
    frame_step: &meerkat_mob::definition::FrameStepSpec,
) -> Vec<(
    &'a meerkat_mob::FlowNodeId,
    &'a meerkat_mob::definition::FrameStepSpec,
    &'a FlowStepSpec,
)> {
    frame
        .nodes
        .iter()
        .filter_map(|(candidate_node_id, candidate_node)| {
            let FlowNodeSpec::Step(candidate_frame_step) = candidate_node else {
                return None;
            };
            if candidate_frame_step.depends_on != frame_step.depends_on
                || candidate_frame_step.depends_on_mode != frame_step.depends_on_mode
                || candidate_frame_step.branch.is_some()
            {
                return None;
            }
            let candidate_step = flow.steps.get(&candidate_frame_step.step_id)?;
            if candidate_step.branch.is_some() || candidate_step.condition.is_some() {
                return None;
            }
            Some((candidate_node_id, candidate_frame_step, candidate_step))
        })
        .collect()
}

fn visual_step_from_step(id: &str, step: &FlowStepSpec) -> Value {
    json!({
        "id": id,
        "type": "member",
        "role": member_id_for_profile(step.role.as_ref()),
        "instruction": step.message.text_content(),
        "launchMode": { "kind": "Fresh" },
        "dependsMode": dependency_mode_string(&step.depends_on_mode),
        "dispatchMode": dispatch_mode_string(&step.dispatch_mode),
        "collection": collection_policy_kind_string(&step.collection_policy),
        "quorum": collection_policy_quorum(&step.collection_policy),
        "timeoutMs": step.timeout_ms,
        "allowedTools": step.allowed_tools,
        "blockedTools": step.blocked_tools,
        "outputFormat": imported_step_output_format_value(step.output_format),
        "expectedSchemaRef": step.expected_schema_ref,
    })
}

fn step_output_format_string(format: StepOutputFormat) -> &'static str {
    match format {
        StepOutputFormat::Json => "json",
        StepOutputFormat::Text => "text",
    }
}

/// Editor projection of the authored output format.
///
/// Meerkat 0.7 keeps "omitted" representable (`Option<StepOutputFormat>`),
/// matching the editor's own tri-state: `null` means "unset, resolve
/// schema-aware" (see `default_step_output_format`), while explicit
/// `json`/`text` round-trip as authored. Pre-0.7 an explicit `json` was
/// indistinguishable from omitted and collapsed to `null`; it now survives.
fn imported_step_output_format_value(format: Option<StepOutputFormat>) -> Value {
    match format {
        None => Value::Null,
        Some(StepOutputFormat::Json) => json!("json"),
        Some(StepOutputFormat::Text) => json!("text"),
    }
}

fn dispatch_mode_string(mode: &DispatchMode) -> &'static str {
    match mode {
        DispatchMode::FanOut => "fan_out",
        DispatchMode::OneToOne => "one_to_one",
        DispatchMode::FanIn => "fan_in",
    }
}

fn collection_policy_kind_string(policy: &CollectionPolicy) -> &'static str {
    match policy {
        CollectionPolicy::All => "all",
        CollectionPolicy::Any => "any",
        CollectionPolicy::Quorum { .. } => "quorum",
    }
}

fn collection_policy_quorum(policy: &CollectionPolicy) -> Option<u8> {
    match policy {
        CollectionPolicy::Quorum { n } => Some(*n),
        CollectionPolicy::All | CollectionPolicy::Any => None,
    }
}

fn launch_modes_from_instances(instances: &[Value]) -> Value {
    Value::Array(
        instances
            .iter()
            .filter(|instance| instance.get("memberId").and_then(Value::as_str).is_some())
            .map(|instance| {
                let mut entry = serde_json::Map::new();
                entry.insert(
                    "step_id".to_string(),
                    json!(
                        instance
                            .get("id")
                            .and_then(Value::as_str)
                            .unwrap_or_default()
                    ),
                );
                entry.insert(
                    "member_id".to_string(),
                    json!(
                        instance
                            .get("memberId")
                            .and_then(Value::as_str)
                            .unwrap_or_default()
                    ),
                );
                entry.insert(
                    "launch_mode".to_string(),
                    instance.get("launchMode").cloned().unwrap_or(Value::Null),
                );
                if let Some(policy) =
                    budget_split_policy_from_launch_mode(instance.get("launchMode"))
                {
                    entry.insert("budget_split_policy".to_string(), policy);
                }
                Value::Object(entry)
            })
            .collect::<Vec<_>>(),
    )
}

fn budget_split_policy_from_launch_mode(launch_mode: Option<&Value>) -> Option<Value> {
    let launch_mode = launch_mode.filter(|mode| mode.is_object())?;
    launch_mode
        .get("budgetSplitPolicy")
        .or_else(|| launch_mode.get("budget_split_policy"))
        .or_else(|| launch_mode.get("budget"))
        .and_then(normalize_budget_split_policy_value)
}

fn normalize_budget_split_policy_value(policy: &Value) -> Option<Value> {
    let kind = policy
        .get("type")
        .or_else(|| policy.get("kind"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?
        .to_ascii_lowercase();
    if kind == "fixed" {
        let Some(value) = policy
            .get("value")
            .or_else(|| policy.get("limit"))
            .or_else(|| policy.get("tokens"))
            .and_then(Value::as_u64)
        else {
            return Some(json!({ "type": "fixed" }));
        };
        return Some(json!({ "type": "fixed", "value": value }));
    }
    if matches!(kind.as_str(), "equal" | "proportional" | "remaining") {
        return Some(json!({ "type": kind }));
    }
    Some(json!({ "type": kind }))
}

fn last_step_id_in_frame(frame: &FrameSpec) -> Option<String> {
    let mut out = None;
    for node in frame.nodes.values() {
        match node {
            FlowNodeSpec::Step(step) => out = Some(step.step_id.to_string()),
            FlowNodeSpec::RepeatUntil(loop_spec) => {
                if let Some(step_id) = last_step_id_in_frame(&loop_spec.body) {
                    out = Some(step_id);
                }
            }
        }
    }
    out
}

fn repeat_condition_to_editor(condition: &ConditionExpr, step_id: Option<&str>) -> Value {
    match condition {
        ConditionExpr::Eq { path, value } => json!({
            "stepId": step_id.unwrap_or_default(),
            "field": path.rsplit('.').next().unwrap_or(path),
            "op": "==",
            "val": scalar_condition_value(value),
        }),
        ConditionExpr::Gt { path, value } => json!({
            "stepId": step_id.unwrap_or_default(),
            "field": path.rsplit('.').next().unwrap_or(path),
            "op": ">",
            "val": scalar_condition_value(value),
        }),
        ConditionExpr::Lt { path, value } => json!({
            "stepId": step_id.unwrap_or_default(),
            "field": path.rsplit('.').next().unwrap_or(path),
            "op": "<",
            "val": scalar_condition_value(value),
        }),
        _ => json!({
            "stepId": step_id.unwrap_or_default(),
            "field": "condition",
            "op": "==",
            "val": condition_to_label(condition),
        }),
    }
}

fn condition_to_editor_cond_json(condition: &ConditionExpr) -> Value {
    fn simple(path: &str, op: &str, value: &Value) -> Value {
        if let Some(field) = path.strip_prefix("params.") {
            return json!({
                "namespace": "params",
                "stepId": "params",
                "field": field.split('.').next().unwrap_or(field),
                "op": op,
                "val": scalar_condition_value(value),
            });
        }
        if let Some(rest) = path.strip_prefix("steps.") {
            let mut parts = rest.split('.');
            let step_id = parts.next().unwrap_or_default();
            let field = parts.next().unwrap_or_default();
            return json!({
                "namespace": "steps",
                "stepId": step_id,
                "field": field,
                "op": op,
                "val": scalar_condition_value(value),
            });
        }
        json!({
            "namespace": "params",
            "stepId": "params",
            "field": path.rsplit('.').next().unwrap_or(path),
            "op": op,
            "val": scalar_condition_value(value),
        })
    }

    match condition {
        ConditionExpr::Eq { path, value } => simple(path, "==", value),
        ConditionExpr::Gt { path, value } => simple(path, ">", value),
        ConditionExpr::Lt { path, value } => simple(path, "<", value),
        _ => json!({}),
    }
}

fn condition_to_label(condition: &ConditionExpr) -> String {
    match condition {
        ConditionExpr::Eq { path, value } => format!("{path} == {}", scalar_condition_value(value)),
        ConditionExpr::In { path, values } => format!(
            "{path} in [{}]",
            values
                .iter()
                .map(scalar_condition_value)
                .collect::<Vec<_>>()
                .join(", ")
        ),
        ConditionExpr::Gt { path, value } => format!("{path} > {}", scalar_condition_value(value)),
        ConditionExpr::Lt { path, value } => format!("{path} < {}", scalar_condition_value(value)),
        ConditionExpr::And { exprs } => exprs
            .iter()
            .map(condition_to_label)
            .collect::<Vec<_>>()
            .join(" && "),
        ConditionExpr::Or { exprs } => exprs
            .iter()
            .map(condition_to_label)
            .collect::<Vec<_>>()
            .join(" || "),
        ConditionExpr::Not { expr } => format!("not ({})", condition_to_label(expr)),
    }
}

fn scalar_condition_value(value: &Value) -> String {
    value
        .as_str()
        .map(ToString::to_string)
        .unwrap_or_else(|| value.to_string())
}

fn output_schema_to_editor_schema(schema_id: &str, schema: &Value) -> Option<Value> {
    let object = schema.as_object()?;
    let properties = object.get("properties").and_then(Value::as_object);
    let required = object
        .get("required")
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(ToString::to_string)
                .collect::<BTreeSet<_>>()
        })
        .unwrap_or_default();
    let fields = properties
        .map(|props| {
            props
                .iter()
                .enumerate()
                .map(|(index, (name, value))| {
                    let enum_values = value
                        .get("enum")
                        .and_then(Value::as_array)
                        .map(|items| {
                            items
                                .iter()
                                .filter_map(Value::as_str)
                                .map(ToString::to_string)
                                .collect::<Vec<_>>()
                        })
                        .unwrap_or_default();
                    let field_type = if enum_values.is_empty() {
                        editor_type_from_json_schema(value)
                    } else {
                        "enum".to_string()
                    };
                    json!({
                        "id": format!("f{}", index + 1),
                        "name": name,
                        "type": field_type,
                        "required": required.contains(name),
                        "description": value.get("description").and_then(Value::as_str).unwrap_or_default(),
                        "enumValues": enum_values,
                    })
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    Some(json!({
        "id": schema_id,
        "description": object.get("description").and_then(Value::as_str).unwrap_or_default(),
        "fields": fields,
    }))
}

fn editor_type_from_json_schema(value: &Value) -> String {
    match value
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or("string")
    {
        "integer" => "integer".to_string(),
        "number" => "number".to_string(),
        "boolean" => "boolean".to_string(),
        "array" => value
            .get("items")
            .and_then(|items| items.get("type"))
            .and_then(Value::as_str)
            .map(|item| format!("{item}[]"))
            .unwrap_or_else(|| "string[]".to_string()),
        "object" => "object".to_string(),
        _ => "string".to_string(),
    }
}

fn tool_ids_from_config(config: &ToolConfig) -> Vec<String> {
    let mut out = serde_json::to_value(config)
        .ok()
        .and_then(|value| {
            value.as_object().map(|object| {
                object
                    .iter()
                    .filter(|(_, value)| value.as_bool() == Some(true))
                    .map(|(field, _)| field.clone())
                    .collect::<Vec<_>>()
            })
        })
        .unwrap_or_default();
    out.retain(|field| tool_config_bool_fields().contains(field));
    out.extend(config.mcp.iter().map(|source| format!("mcp:{source}")));
    out.extend(
        config
            .rust_bundles
            .iter()
            .map(|bundle| format!("rust:{bundle}")),
    );
    out
}

fn skill_realms_from_definition(definition: &MobDefinition) -> Value {
    if definition.skills.is_empty() {
        return Value::Array(Vec::new());
    }
    let skills = definition
        .skills
        .iter()
        .map(|(id, source)| match source {
            SkillSource::Inline { content } => json!({
                "id": id,
                "label": id,
                "source": "inline",
                "origin": "meerkat_mob::SkillSource",
                "content": content,
            }),
            SkillSource::Path { path } => json!({
                "id": id,
                "label": id,
                "source": "path",
                "origin": "meerkat_mob::SkillSource",
                "path": path,
            }),
        })
        .collect::<Vec<_>>();
    json!([{
        "id": "imported/mob.toml",
        "label": "imported/mob.toml",
        "default": true,
        "source": "meerkat_mob::MobDefinition",
        "sourceDocumentPath": "[skills]",
        "skills": skills,
    }])
}

fn member_id_for_profile(name: &str) -> String {
    format!("m_{}", sanitize_identifier(name))
}

fn pascal_identifier(name: &str) -> String {
    let mut out = String::new();
    for part in name
        .split(|ch: char| !ch.is_ascii_alphanumeric())
        .filter(|part| !part.is_empty())
    {
        let mut chars = part.chars();
        if let Some(first) = chars.next() {
            out.push(first.to_ascii_uppercase());
            out.extend(chars.map(|ch| ch.to_ascii_lowercase()));
        }
    }
    if out.is_empty() {
        "Profile".to_string()
    } else {
        out
    }
}

fn sanitize_identifier(name: &str) -> String {
    let out = name
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() {
                ch.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect::<String>()
        .split('_')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("_");
    if out.is_empty() {
        "member".to_string()
    } else {
        out
    }
}

fn dependency_mode_string(mode: &DependencyMode) -> &'static str {
    match mode {
        DependencyMode::All => "all",
        DependencyMode::Any => "any",
    }
}

#[derive(Debug, Clone)]
struct RenderedFlow {
    steps: Vec<RenderedStep>,
    root: RenderedFrame,
}

#[derive(Debug, Clone)]
struct RenderedFrame {
    nodes: Vec<RenderedNode>,
}

#[derive(Debug, Clone)]
enum RenderedNode {
    Step(RenderedStepNode),
    Repeat(RenderedRepeatNode),
}

#[derive(Debug, Clone)]
struct RenderedStepNode {
    id: String,
    step_id: String,
    depends_on: Vec<String>,
    depends_mode: String,
    branch: Option<String>,
}

#[derive(Debug, Clone)]
struct RenderedRepeatNode {
    id: String,
    loop_id: String,
    depends_on: Vec<String>,
    depends_mode: String,
    until: String,
    max_iterations: u64,
    body: RenderedFrame,
}

#[derive(Debug, Clone)]
struct RenderedStep {
    id: String,
    role: String,
    message: String,
    depends_on: Vec<String>,
    depends_mode: String,
    dispatch_mode: Option<String>,
    collection_policy: Option<String>,
    branch: Option<String>,
    condition: Option<String>,
    timeout_ms: Option<u64>,
    expected_schema_ref: Option<String>,
    allowed_tools: Vec<String>,
    blocked_tools: Vec<String>,
    output_format: Option<String>,
}

#[derive(Debug)]
struct RenderState<'a> {
    members: BTreeMap<String, &'a Value>,
    steps: Vec<RenderedStep>,
    step_ids_by_visual_id: BTreeMap<String, String>,
    flat_exits_by_node_id: BTreeMap<String, Vec<String>>,
    next: usize,
}

fn render_editor_document_mob_toml(document: &MobpackDocument) -> Result<String, String> {
    let mut lines = Vec::new();
    let members = document
        .members
        .as_array()
        .map(|members| {
            members
                .iter()
                .filter(|member| {
                    !member
                        .get("missing")
                        .and_then(Value::as_bool)
                        .unwrap_or(false)
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    let schemas = editor_schema_map(&document.schemas);
    let settings = normalize_editor_mob_settings(&document.mob_settings);
    let flow_steps = document
        .flow
        .get("steps")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let flow_member_steps = flow_steps
        .iter()
        .filter(|step| step.get("type").and_then(Value::as_str) != Some("input"))
        .cloned()
        .collect::<Vec<_>>();
    let compiled = compile_editor_flow(&flow_member_steps, &members);
    let mob_id = sanitize_identifier(
        if document
            .name
            .trim()
            .strip_suffix(".mobpack")
            .unwrap_or_else(|| document.name.trim())
            .trim()
            .is_empty()
        {
            document.mob_id.as_str()
        } else {
            document.name.trim()
        },
    );

    lines.push("[mob]".to_string());
    lines.push(format!("id = {}", toml_string(&mob_id)));
    if let Some(orchestrator) = settings.string("orchestrator") {
        lines.push(format!("orchestrator = {}", toml_string(&orchestrator)));
    }
    lines.push(String::new());

    emit_rendered_wiring(&mut lines, &settings);
    emit_rendered_backend(&mut lines, &settings);
    emit_rendered_advanced_mob_settings(&mut lines, &settings);

    let mut selected_skills = BTreeMap::<String, Value>::new();
    for member in &members {
        for skill in string_vec(member.get("skills")) {
            if selected_skills.contains_key(&skill) {
                continue;
            }
            if let Some(source) = skill_source_from_realms(&skill, &document.skill_realms) {
                selected_skills.insert(skill, source);
            }
        }
        emit_rendered_profile(&mut lines, member, &schemas);
    }

    for (skill, source) in selected_skills {
        lines.push(format!("[skills.{}]", toml_key(&skill)));
        let source_kind = source
            .get("source")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("inline");
        lines.push(format!("source = {}", toml_string(source_kind)));
        if source_kind == "path" {
            let path = packed_skill_archive_path(&skill, &source);
            lines.push(format!("path = {}", toml_string(&path)));
        } else {
            let content = source
                .get("content")
                .and_then(Value::as_str)
                .unwrap_or_default();
            lines.push(format!("content = {}", toml_string(content)));
        }
        lines.push(String::new());
    }

    let input = flow_steps
        .iter()
        .find(|step| step.get("type").and_then(Value::as_str) == Some("input"));
    lines.push("[flows.main]".to_string());
    lines.push(format!(
        "description = {}",
        toml_string(
            input
                .and_then(|step| step.get("task"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .unwrap_or("Generated by MobKit Flow Editor"),
        )
    ));
    if let Some(summary) = input.and_then(input_param_summary_for_step) {
        lines.push(format!("# input params: {summary}"));
    }
    lines.push(String::new());

    for step in &compiled.steps {
        emit_rendered_flow_step(&mut lines, step);
    }
    emit_rendered_frame_toml(&mut lines, "main", "root", &compiled.root);

    Ok(format!("{}\n", lines.join("\n").trim_end()))
}

fn compile_editor_flow(steps: &[Value], members: &[&Value]) -> RenderedFlow {
    let mut state = RenderState {
        members: members
            .iter()
            .filter_map(|member| {
                member
                    .get("id")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|id| !id.is_empty())
                    .map(|id| (id.to_string(), *member))
            })
            .collect(),
        steps: Vec::new(),
        step_ids_by_visual_id: BTreeMap::new(),
        flat_exits_by_node_id: BTreeMap::new(),
        next: 1,
    };
    let root = compile_editor_lane(steps, Vec::new(), &mut state);
    RenderedFlow {
        steps: state.steps,
        root,
    }
}

fn compile_editor_lane(
    steps: &[Value],
    mut depends_on_nodes: Vec<String>,
    state: &mut RenderState<'_>,
) -> RenderedFrame {
    let mut frame = RenderedFrame { nodes: Vec::new() };
    for step in steps {
        let (nodes, exits) = compile_editor_visual_step(step, depends_on_nodes, state);
        frame.nodes.extend(nodes);
        depends_on_nodes = exits;
    }
    frame
}

fn compile_editor_visual_step(
    step: &Value,
    depends_on_nodes: Vec<String>,
    state: &mut RenderState<'_>,
) -> (Vec<RenderedNode>, Vec<String>) {
    match step.get("type").and_then(Value::as_str).unwrap_or_default() {
        "member" => compile_editor_member_step(step, depends_on_nodes, state),
        "repeat" => compile_editor_repeat_step(step, depends_on_nodes, state),
        "parallel" => compile_editor_parallel_step(step, depends_on_nodes, state),
        "branch" => compile_editor_branch_step(step, depends_on_nodes, state),
        // Adaptive layers are manifest+files artifacts, not mob.toml flow
        // nodes: they compile to nothing and downstream steps chain through.
        "adaptive" => (Vec::new(), depends_on_nodes),
        _ => (Vec::new(), depends_on_nodes),
    }
}

fn compile_editor_member_step(
    step: &Value,
    depends_on_nodes: Vec<String>,
    state: &mut RenderState<'_>,
) -> (Vec<RenderedNode>, Vec<String>) {
    let Some(member) = step
        .get("role")
        .and_then(Value::as_str)
        .map(str::trim)
        .and_then(|member_id| state.members.get(member_id).copied())
    else {
        return (Vec::new(), depends_on_nodes);
    };
    let Some(message) = editor_step_instruction(step) else {
        return (Vec::new(), depends_on_nodes);
    };
    let visual_id = step
        .get("id")
        .and_then(Value::as_str)
        .unwrap_or("step")
        .to_string();
    let profile = editor_profile_name(member);
    let step_id = format!("{:02}_{}", state.next, sanitize_identifier(&profile));
    state.next += 1;
    let node_id = format!("node_{step_id}");
    state
        .step_ids_by_visual_id
        .insert(visual_id, step_id.clone());
    let depends_on = flat_step_depends_on(&depends_on_nodes, state);
    let rendered = RenderedStep {
        id: step_id.clone(),
        role: profile,
        message,
        depends_on,
        depends_mode: step_string(step, "dependsMode").unwrap_or_else(|| "all".to_string()),
        dispatch_mode: explicit_editor_dispatch_mode(step),
        collection_policy: explicit_editor_collection_policy_toml(step),
        branch: None,
        condition: None,
        timeout_ms: editor_u64(step, "timeoutMs").or_else(|| editor_u64(step, "timeout_ms")),
        expected_schema_ref: step
            .get("expectedSchemaRef")
            .or_else(|| step.get("expected_schema_ref"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
            .or_else(|| {
                member
                    .get("schema")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    .map(|schema| format!("schemas/{schema}.json"))
            }),
        allowed_tools: editor_string_vec(
            step.get("allowedTools")
                .or_else(|| step.get("allowed_tools")),
        ),
        blocked_tools: editor_string_vec(
            step.get("blockedTools")
                .or_else(|| step.get("blocked_tools")),
        ),
        output_format: explicit_editor_output_format(step),
    };
    state.steps.push(rendered);
    let node = RenderedNode::Step(RenderedStepNode {
        id: node_id.clone(),
        step_id: step_id.clone(),
        depends_on: depends_on_nodes,
        depends_mode: step_string(step, "dependsMode").unwrap_or_else(|| "all".to_string()),
        branch: None,
    });
    state
        .flat_exits_by_node_id
        .insert(node_id.clone(), vec![step_id]);
    (vec![node], vec![node_id])
}

fn compile_editor_repeat_step(
    step: &Value,
    depends_on_nodes: Vec<String>,
    state: &mut RenderState<'_>,
) -> (Vec<RenderedNode>, Vec<String>) {
    let Some(loop_id) = step_string(step, "loopId")
        .map(|value| sanitize_identifier(&value))
        .filter(|value| !value.is_empty())
    else {
        return (Vec::new(), depends_on_nodes);
    };
    let Some(max_iterations) = editor_u64(step, "maxIterations").filter(|value| *value > 0) else {
        return (Vec::new(), depends_on_nodes);
    };
    let body_steps = step
        .get("steps")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    let body = compile_editor_lane(&body_steps, Vec::new(), state);
    let node_id = format!("node_{loop_id}");
    let Some(until) = condition_from_repeat_step(step, state) else {
        return (Vec::new(), depends_on_nodes);
    };
    let exits = flat_step_depends_on(
        &body
            .nodes
            .iter()
            .filter_map(rendered_node_id)
            .collect::<Vec<_>>(),
        state,
    );
    state.flat_exits_by_node_id.insert(node_id.clone(), exits);
    (
        vec![RenderedNode::Repeat(RenderedRepeatNode {
            id: node_id.clone(),
            loop_id,
            depends_on: depends_on_nodes,
            depends_mode: step_string(step, "dependsMode").unwrap_or_else(|| "all".to_string()),
            until,
            max_iterations,
            body,
        })],
        vec![node_id],
    )
}

fn compile_editor_parallel_step(
    step: &Value,
    depends_on_nodes: Vec<String>,
    state: &mut RenderState<'_>,
) -> (Vec<RenderedNode>, Vec<String>) {
    let Some(_dispatch_mode) = explicit_editor_dispatch_mode(step) else {
        return (Vec::new(), depends_on_nodes);
    };
    let Some(collection_policy) = required_editor_collection_policy(step) else {
        return (Vec::new(), depends_on_nodes);
    };
    let collection_toml = collection_policy_toml_from_key(&collection_policy);
    let mut nodes = Vec::new();
    let mut exits = Vec::new();
    for branch in step
        .get("branches")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
    {
        let branch_steps = branch
            .get("steps")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        let result = compile_editor_lane(&branch_steps, depends_on_nodes.clone(), state);
        exits.extend(result.nodes.iter().filter_map(rendered_node_id));
        nodes.extend(result.nodes);
    }
    let Some(join_member) = control_member_for_step(step, state) else {
        return (nodes, exits);
    };
    let join = synthetic_rendered_step(
        state,
        &format!(
            "join_{}",
            step.get("id").and_then(Value::as_str).unwrap_or("parallel")
        ),
        &join_member,
        &format!("Join parallel branches ({collection_policy})."),
        exits.clone(),
        None,
        if collection_policy == "any" {
            "any"
        } else {
            "all"
        },
        Some(collection_toml),
        None,
        None,
    );
    let node_id = rendered_node_id(&join).unwrap_or_default();
    nodes.push(join);
    (nodes, vec![node_id])
}

fn compile_editor_branch_step(
    step: &Value,
    depends_on_nodes: Vec<String>,
    state: &mut RenderState<'_>,
) -> (Vec<RenderedNode>, Vec<String>) {
    let branch_id = format!(
        "branch_{}",
        sanitize_identifier(step.get("id").and_then(Value::as_str).unwrap_or("branch"))
    );
    let route_member = control_member_for_step(step, state);
    let join_member = control_member_for_step(step, state);
    if route_member.is_none() && join_member.is_none() {
        let mut nodes = Vec::new();
        let mut exits = Vec::new();
        let mut branch_conditions = Vec::new();
        for branch in step
            .get("branches")
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
        {
            let condition = condition_from_editor_branch(branch, state);
            if let Some(condition) = &condition {
                branch_conditions.push(condition.clone());
            }
            let branch_steps = branch
                .get("steps")
                .and_then(Value::as_array)
                .cloned()
                .unwrap_or_default();
            let mut result = compile_editor_lane(&branch_steps, depends_on_nodes.clone(), state);
            apply_branch_to_first_step(&mut result, state, &branch_id, condition);
            exits.extend(result.nodes.iter().filter_map(rendered_node_id));
            nodes.extend(result.nodes);
        }
        if let Some(fallback_steps) = step.get("fallback").and_then(Value::as_array)
            && !fallback_steps.is_empty()
        {
            let mut result = compile_editor_lane(fallback_steps, depends_on_nodes, state);
            apply_branch_to_first_step(
                &mut result,
                state,
                &branch_id,
                fallback_condition_from_branch_conditions(&branch_conditions),
            );
            exits.extend(result.nodes.iter().filter_map(rendered_node_id));
            nodes.extend(result.nodes);
        }
        return (nodes, exits);
    }

    let route_member = match route_member {
        Some(member) => member,
        None => return (Vec::new(), depends_on_nodes),
    };
    let mut nodes = Vec::new();
    let mut exits = Vec::new();
    let mut entry_nodes = Vec::new();
    let mut branch_conditions = Vec::new();
    for (index, branch) in step
        .get("branches")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .enumerate()
    {
        let condition = condition_from_editor_branch(branch, state);
        if let Some(condition) = &condition {
            branch_conditions.push(condition.clone());
        }
        let label = branch
            .get("label")
            .and_then(Value::as_str)
            .map(ToString::to_string)
            .unwrap_or_else(|| format!("branch {}", index + 1));
        let entry = synthetic_rendered_step(
            state,
            &format!(
                "{}_{}",
                step.get("id").and_then(Value::as_str).unwrap_or("branch"),
                branch
                    .get("id")
                    .and_then(Value::as_str)
                    .map(ToString::to_string)
                    .unwrap_or_else(|| index.to_string())
            ),
            &route_member,
            &format!("Enter branch: {label}"),
            depends_on_nodes.clone(),
            None,
            "all",
            None,
            Some(branch_id.clone()),
            condition,
        );
        let entry_node_id = rendered_node_id(&entry).unwrap_or_default();
        entry_nodes.push(entry_node_id.clone());
        let branch_steps = branch
            .get("steps")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        let result = compile_editor_lane(&branch_steps, vec![entry_node_id.clone()], state);
        exits.extend(
            result
                .nodes
                .iter()
                .filter_map(rendered_node_id)
                .collect::<Vec<_>>()
                .into_iter()
                .chain((result.nodes.is_empty()).then_some(entry_node_id.clone())),
        );
        nodes.push(entry);
        nodes.extend(result.nodes);
    }
    if let Some(fallback_steps) = step.get("fallback").and_then(Value::as_array)
        && !fallback_steps.is_empty()
    {
        let fallback_entry = synthetic_rendered_step(
            state,
            &format!(
                "{}_fallback",
                step.get("id").and_then(Value::as_str).unwrap_or("branch")
            ),
            &route_member,
            "Enter branch: fallback",
            depends_on_nodes,
            None,
            "all",
            None,
            Some(branch_id.clone()),
            fallback_condition_from_branch_conditions(&branch_conditions),
        );
        let fallback_node_id = rendered_node_id(&fallback_entry).unwrap_or_default();
        entry_nodes.push(fallback_node_id.clone());
        let fallback_body =
            compile_editor_lane(fallback_steps, vec![fallback_node_id.clone()], state);
        exits.extend(
            fallback_body
                .nodes
                .iter()
                .filter_map(rendered_node_id)
                .collect::<Vec<_>>()
                .into_iter()
                .chain((fallback_body.nodes.is_empty()).then_some(fallback_node_id)),
        );
        nodes.push(fallback_entry);
        nodes.extend(fallback_body.nodes);
    }
    let Some(join_member) = join_member else {
        return (nodes, exits);
    };
    let join = synthetic_rendered_step(
        state,
        &format!(
            "branch_join_{}",
            step.get("id").and_then(Value::as_str).unwrap_or("branch")
        ),
        &join_member,
        "Join branch paths.",
        exits.clone(),
        Some(entry_nodes),
        "any",
        Some("{ type = \"any\" }".to_string()),
        None,
        None,
    );
    let join_node_id = rendered_node_id(&join).unwrap_or_default();
    nodes.push(join);
    (nodes, vec![join_node_id])
}

#[allow(clippy::too_many_arguments)]
fn synthetic_rendered_step(
    state: &mut RenderState<'_>,
    _id: &str,
    member: &Value,
    message: &str,
    depends_on_nodes: Vec<String>,
    flat_depends_on_nodes: Option<Vec<String>>,
    depends_mode: &str,
    collection_policy: Option<String>,
    branch: Option<String>,
    condition: Option<String>,
) -> RenderedNode {
    let profile = editor_profile_name(member);
    let step_id = format!("{:02}_{}", state.next, sanitize_identifier(&profile));
    state.next += 1;
    let node_id = format!("node_{step_id}");
    let flat_source = flat_depends_on_nodes
        .as_ref()
        .unwrap_or(&depends_on_nodes)
        .clone();
    let rendered = RenderedStep {
        id: step_id.clone(),
        role: profile,
        message: message.to_string(),
        depends_on: flat_step_depends_on(&flat_source, state),
        depends_mode: depends_mode.to_string(),
        dispatch_mode: None,
        collection_policy,
        branch: branch.clone(),
        condition,
        timeout_ms: None,
        expected_schema_ref: None,
        allowed_tools: Vec::new(),
        blocked_tools: Vec::new(),
        output_format: None,
    };
    state.steps.push(rendered);
    state
        .flat_exits_by_node_id
        .insert(node_id.clone(), vec![step_id.clone()]);
    RenderedNode::Step(RenderedStepNode {
        id: node_id,
        step_id,
        depends_on: depends_on_nodes,
        depends_mode: depends_mode.to_string(),
        branch,
    })
}

fn rendered_node_id(node: &RenderedNode) -> Option<String> {
    match node {
        RenderedNode::Step(step) => Some(step.id.clone()),
        RenderedNode::Repeat(repeat) => Some(repeat.id.clone()),
    }
}

fn control_member_for_step(step: &Value, state: &RenderState<'_>) -> Option<Value> {
    let member_id = step
        .get("controllerRole")
        .or_else(|| step.get("controllerMemberId"))
        .or_else(|| step.get("controlRole"))
        .or_else(|| step.get("joinRole"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    state.members.get(member_id).map(|member| (*member).clone())
}

fn flat_step_depends_on(node_ids: &[String], state: &RenderState<'_>) -> Vec<String> {
    let mut out = Vec::new();
    let mut seen = BTreeSet::new();
    for node_id in node_ids {
        let exits = state
            .flat_exits_by_node_id
            .get(node_id)
            .cloned()
            .unwrap_or_else(|| {
                node_id
                    .strip_prefix("node_")
                    .map(ToString::to_string)
                    .into_iter()
                    .collect()
            });
        for step_id in exits {
            if seen.insert(step_id.clone()) {
                out.push(step_id);
            }
        }
    }
    out
}

fn apply_branch_to_first_step(
    frame: &mut RenderedFrame,
    state: &mut RenderState<'_>,
    branch_id: &str,
    condition: Option<String>,
) {
    let Some(step_id) = first_rendered_step_node_id(frame) else {
        return;
    };
    if let Some(RenderedNode::Step(step_node)) = find_rendered_step_node_mut(frame, &step_id) {
        step_node.branch = Some(branch_id.to_string());
    }
    if let Some(step) = state.steps.iter_mut().find(|step| step.id == step_id) {
        step.branch = Some(branch_id.to_string());
        step.condition = condition;
    }
}

fn first_rendered_step_node_id(frame: &RenderedFrame) -> Option<String> {
    for node in &frame.nodes {
        match node {
            RenderedNode::Step(step) => return Some(step.step_id.clone()),
            RenderedNode::Repeat(repeat) => {
                if let Some(step_id) = first_rendered_step_node_id(&repeat.body) {
                    return Some(step_id);
                }
            }
        }
    }
    None
}

fn find_rendered_step_node_mut<'a>(
    frame: &'a mut RenderedFrame,
    step_id: &str,
) -> Option<&'a mut RenderedNode> {
    for node in &mut frame.nodes {
        match node {
            RenderedNode::Step(step) if step.step_id == step_id => return Some(node),
            RenderedNode::Step(_) => {}
            RenderedNode::Repeat(repeat) => {
                if let Some(node) = find_rendered_step_node_mut(&mut repeat.body, step_id) {
                    return Some(node);
                }
            }
        }
    }
    None
}

fn emit_rendered_wiring(lines: &mut Vec<String>, settings: &EditorMobSettings) {
    let role_wiring = settings.role_wiring();
    if settings.bool("autoWireOrchestrator") || !role_wiring.is_empty() {
        lines.push("[wiring]".to_string());
        lines.push(format!(
            "auto_wire_orchestrator = {}",
            if settings.bool("autoWireOrchestrator") {
                "true"
            } else {
                "false"
            }
        ));
        lines.push(String::new());
        for (a, b) in role_wiring {
            lines.push("[[wiring.role_wiring]]".to_string());
            lines.push(format!("a = {}", toml_string(&a)));
            lines.push(format!("b = {}", toml_string(&b)));
            lines.push(String::new());
        }
    }
}

fn emit_rendered_backend(lines: &mut Vec<String>, settings: &EditorMobSettings) {
    let backend_default = settings
        .string("backendDefault")
        .unwrap_or_else(|| "session".to_string());
    let external_base = settings.string("externalAddressBase").unwrap_or_default();
    if backend_default != "session" || !external_base.is_empty() {
        lines.push("[backend]".to_string());
        lines.push(format!("default = {}", toml_string(&backend_default)));
        lines.push(String::new());
        if !external_base.is_empty() {
            lines.push("[backend.external]".to_string());
            lines.push(format!("address_base = {}", toml_string(&external_base)));
            lines.push(String::new());
        }
    }
}

fn emit_rendered_advanced_mob_settings(lines: &mut Vec<String>, settings: &EditorMobSettings) {
    let Some(advanced) = settings.advanced_object() else {
        return;
    };
    for (editor_key, table) in [
        ("topology", "topology"),
        ("supervisor", "supervisor"),
        ("limits", "limits"),
        ("spawnPolicy", "spawn_policy"),
        ("eventRouter", "event_router"),
    ] {
        let value = advanced
            .get(editor_key)
            .or_else(|| advanced.get(&camel_to_snake(editor_key)));
        if let Some(Value::Object(map)) = value.filter(|value| !value.is_null()) {
            emit_json_toml(lines, table, &Value::Object(map.clone()));
        }
    }
}

fn emit_rendered_profile(
    lines: &mut Vec<String>,
    member: &Value,
    schemas: &BTreeMap<String, Value>,
) {
    let name = editor_profile_name(member);
    lines.push(format!("[profiles.{}]", toml_key(&name)));
    let realm_profile = member
        .get("realmProfile")
        .or_else(|| member.get("realm_profile"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let profile_binding = member
        .get("profileBinding")
        .or_else(|| member.get("profile_binding"))
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default();
    if profile_binding == "realm_profile" || realm_profile.is_some() {
        lines.push(format!(
            "realm_profile = {}",
            toml_string(realm_profile.unwrap_or(&name))
        ));
        lines.push(String::new());
        return;
    }
    lines.push(format!(
        "model = {}",
        toml_string(
            member
                .get("model")
                .and_then(Value::as_str)
                .unwrap_or_default()
        )
    ));
    let skills = string_vec(member.get("skills"));
    if !skills.is_empty() {
        lines.push(format!("skills = {}", toml_array(&skills)));
    }
    if let Some(prompt) = member
        .get("systemPrompt")
        .or_else(|| member.get("system_prompt"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        lines.push(format!("peer_description = {}", toml_string(prompt)));
    }
    lines.push(format!(
        "external_addressable = {}",
        if member
            .get("externalAddressable")
            .or_else(|| member.get("external_addressable"))
            .and_then(Value::as_bool)
            .unwrap_or(false)
        {
            "true"
        } else {
            "false"
        }
    ));
    if let Some(backend) = editor_profile_backend(member) {
        lines.push(format!("backend = {}", toml_string(backend)));
    }
    lines.push(format!(
        "runtime_mode = {}",
        toml_string(
            member
                .get("runtimeMode")
                .or_else(|| member.get("runtime_mode"))
                .and_then(Value::as_str)
                .unwrap_or("turn_driven")
        )
    ));
    if let Some(max_inline) = editor_max_inline_peer_notifications(member) {
        lines.push(format!("max_inline_peer_notifications = {max_inline}"));
    }
    if let Some(provider_params) = member
        .get("providerParams")
        .or_else(|| member.get("provider_params"))
        .filter(|value| value.is_object())
    {
        lines.push(format!("provider_params = {}", toml_value(provider_params)));
    }
    lines.push(String::new());

    lines.push(format!("[profiles.{}.tools]", toml_key(&name)));
    let tool_config = editor_tool_config(member.get("tools"));
    for key in tool_config_bool_fields() {
        lines.push(format!(
            "{key} = {}",
            if tool_config.booleans.contains(key.as_str()) {
                "true"
            } else {
                "false"
            }
        ));
    }
    if !tool_config.mcp.is_empty() {
        lines.push(format!("mcp = {}", toml_array(&tool_config.mcp)));
    }
    if !tool_config.rust_bundles.is_empty() {
        lines.push(format!(
            "rust_bundles = {}",
            toml_array(&tool_config.rust_bundles)
        ));
    }
    lines.push(String::new());

    if let Some(schema_id) = member
        .get("schema")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        && let Some(schema) = schemas.get(schema_id)
    {
        emit_json_toml(
            lines,
            &format!("profiles.{}.output_schema", toml_key(&name)),
            &editor_schema_to_json_schema(schema),
        );
    }
}

fn emit_rendered_flow_step(lines: &mut Vec<String>, step: &RenderedStep) {
    lines.push(format!("[flows.main.steps.{}]", toml_key(&step.id)));
    lines.push(format!("role = {}", toml_string(&step.role)));
    lines.push(format!("message = {}", toml_string(&step.message)));
    if !step.depends_on.is_empty() {
        lines.push(format!("depends_on = {}", toml_array(&step.depends_on)));
    }
    if step.depends_mode != "all" {
        lines.push(format!(
            "depends_on_mode = {}",
            toml_string(&step.depends_mode)
        ));
    }
    if let Some(dispatch_mode) = &step.dispatch_mode {
        lines.push(format!("dispatch_mode = {}", toml_string(dispatch_mode)));
    }
    if let Some(collection_policy) = &step.collection_policy {
        lines.push(format!("collection_policy = {collection_policy}"));
    }
    if let Some(branch) = &step.branch {
        lines.push(format!("branch = {}", toml_string(branch)));
    }
    if let Some(condition) = &step.condition {
        lines.push(format!("condition = {condition}"));
    }
    if let Some(timeout_ms) = step.timeout_ms {
        lines.push(format!("timeout_ms = {timeout_ms}"));
    }
    if let Some(schema_ref) = &step.expected_schema_ref {
        lines.push(format!("expected_schema_ref = {}", toml_string(schema_ref)));
    }
    if !step.allowed_tools.is_empty() {
        lines.push(format!(
            "allowed_tools = {}",
            toml_array(&step.allowed_tools)
        ));
    }
    if !step.blocked_tools.is_empty() {
        lines.push(format!(
            "blocked_tools = {}",
            toml_array(&step.blocked_tools)
        ));
    }
    // Output format emission spans two host generations: pre-0.7.1 meerkat
    // defaults omitted to json (which rejects free-text replies), 0.7.1+
    // resolves omitted schema-aware (json with a schema, text without). An
    // EXPLICIT user choice is always emitted verbatim so neither default can
    // flip it; an unset format emits text for schema-less steps (correct on
    // both generations) and stays omitted for schema-backed steps (json on
    // both generations).
    match step.output_format.as_deref() {
        Some(explicit) => lines.push(format!("output_format = {}", toml_string(explicit))),
        None => {
            if default_step_output_format(step.expected_schema_ref.is_some()) != "json" {
                lines.push("output_format = \"text\"".to_string());
            }
        }
    }
    lines.push(String::new());
}

fn emit_rendered_frame_toml(
    lines: &mut Vec<String>,
    flow_id: &str,
    path: &str,
    frame: &RenderedFrame,
) {
    for node in &frame.nodes {
        match node {
            RenderedNode::Step(step) => {
                lines.push(format!(
                    "[flows.{flow_id}.{path}.nodes.{}]",
                    toml_key(&step.id)
                ));
                lines.push("kind = \"step\"".to_string());
                lines.push(format!("step_id = {}", toml_string(&step.step_id)));
                lines.push(format!("depends_on = {}", toml_array(&step.depends_on)));
                lines.push(format!(
                    "depends_on_mode = {}",
                    toml_string(&step.depends_mode)
                ));
                if let Some(branch) = &step.branch {
                    lines.push(format!("branch = {}", toml_string(branch)));
                }
                lines.push(String::new());
            }
            RenderedNode::Repeat(repeat) => {
                lines.push(format!(
                    "[flows.{flow_id}.{path}.nodes.{}]",
                    toml_key(&repeat.id)
                ));
                lines.push("kind = \"repeat_until\"".to_string());
                lines.push(format!("loop_id = {}", toml_string(&repeat.loop_id)));
                lines.push(format!("depends_on = {}", toml_array(&repeat.depends_on)));
                lines.push(format!(
                    "depends_on_mode = {}",
                    toml_string(&repeat.depends_mode)
                ));
                lines.push(format!("until = {}", repeat.until));
                lines.push(format!("max_iterations = {}", repeat.max_iterations));
                lines.push(String::new());
                emit_rendered_frame_toml(
                    lines,
                    flow_id,
                    &format!("{path}.nodes.{}.body", toml_key(&repeat.id)),
                    &repeat.body,
                );
            }
        }
    }
}

#[derive(Debug, Clone)]
struct EditorMobSettings {
    value: Value,
}

impl EditorMobSettings {
    fn string(&self, key: &str) -> Option<String> {
        self.value
            .get(key)
            .or_else(|| self.value.get(camel_to_snake(key)))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
    }

    fn bool(&self, key: &str) -> bool {
        self.value
            .get(key)
            .or_else(|| self.value.get(camel_to_snake(key)))
            .and_then(Value::as_bool)
            .unwrap_or(false)
    }

    fn role_wiring(&self) -> Vec<(String, String)> {
        self.value
            .get("roleWiring")
            .or_else(|| self.value.get("role_wiring"))
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
            .filter_map(|rule| {
                let a = rule.get("a").and_then(Value::as_str)?.trim();
                let b = rule.get("b").and_then(Value::as_str)?.trim();
                (!a.is_empty() && !b.is_empty()).then(|| (a.to_string(), b.to_string()))
            })
            .collect()
    }

    fn advanced_object(&self) -> Option<&serde_json::Map<String, Value>> {
        self.value
            .get("advanced")
            .filter(|value| !value.is_null())
            .and_then(Value::as_object)
    }
}

#[derive(Debug, Default)]
struct RenderedToolConfig {
    booleans: BTreeSet<String>,
    mcp: Vec<String>,
    rust_bundles: Vec<String>,
}

fn normalize_editor_mob_settings(value: &Value) -> EditorMobSettings {
    let mut settings = value.clone();
    if !settings.is_object() {
        settings = json!({});
    }
    if settings.get("backendDefault").is_none() && settings.get("backend_default").is_none() {
        settings["backendDefault"] = json!("session");
    }
    EditorMobSettings { value: settings }
}

fn input_param_summary_for_step(step: &Value) -> Option<String> {
    let params = step.get("inputParams").and_then(Value::as_array)?;
    let summary = input_param_summary(params);
    (!summary.is_empty()).then_some(summary)
}

fn editor_input_schema_files(document: &MobpackDocument) -> BTreeMap<String, Value> {
    let params = document
        .flow
        .get("steps")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .find(|step| step.get("type").and_then(Value::as_str) == Some("input"))
        .and_then(|step| step.get("inputParams").and_then(Value::as_array));
    let Some(params) = params.filter(|params| !params.is_empty()) else {
        return BTreeMap::new();
    };
    BTreeMap::from([(
        "schemas/main-input.json".to_string(),
        editor_schema_to_json_schema(&json!({
            "id": "main-input",
            "description": "Activation parameters accepted by the main MobKit flow.",
            "fields": params,
        })),
    )])
}

fn step_string(step: &Value, key: &str) -> Option<String> {
    step.get(key)
        .or_else(|| step.get(camel_to_snake(key)))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}

fn string_vec(value: Option<&Value>) -> Vec<String> {
    match value {
        Some(Value::Array(items)) => items
            .iter()
            .filter_map(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
            .collect(),
        Some(Value::String(text)) => text
            .split(',')
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
            .collect(),
        _ => Vec::new(),
    }
}

fn skill_source_from_realms(skill_id: &str, realms: &Value) -> Option<Value> {
    for realm in realms.as_array()? {
        for skill in realm.get("skills").and_then(Value::as_array)? {
            if skill.get("id").and_then(Value::as_str).map(str::trim) == Some(skill_id) {
                let mut source = skill.clone();
                if let Some(map) = source.as_object_mut() {
                    if !map.contains_key("realm_id")
                        && let Some(realm_id) = realm.get("id").and_then(Value::as_str)
                    {
                        map.insert("realm_id".to_string(), json!(realm_id));
                    }
                    if !map.contains_key("realm_source")
                        && let Some(realm_source) = realm.get("source").and_then(Value::as_str)
                    {
                        map.insert("realm_source".to_string(), json!(realm_source));
                    }
                }
                return Some(source);
            }
        }
    }
    None
}

fn editor_tool_config(value: Option<&Value>) -> RenderedToolConfig {
    let mut config = RenderedToolConfig::default();
    for tool in string_vec(value) {
        if let Some(source) = tool.strip_prefix("mcp:") {
            config.mcp.push(source.to_string());
        } else if let Some(bundle) = tool.strip_prefix("rust:") {
            config.rust_bundles.push(bundle.to_string());
        } else {
            config.booleans.insert(tool);
        }
    }
    config
}

fn collection_policy_toml_from_key(policy: &str) -> String {
    if policy == "any" {
        "{ type = \"any\" }".to_string()
    } else if let Some(n) = policy.strip_prefix("quorum:") {
        format!("{{ type = \"quorum\", n = {n} }}")
    } else {
        "{ type = \"all\" }".to_string()
    }
}

fn condition_from_repeat_step(step: &Value, state: &RenderState<'_>) -> Option<String> {
    let cond = step.get("cond")?;
    let field = cond
        .get("field")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let value = cond
        .get("val")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let op = cond
        .get("op")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let cond_source_is_params = cond
        .get("namespace")
        .and_then(Value::as_str)
        .is_some_and(|namespace| namespace == "params")
        || cond
            .get("stepId")
            .and_then(Value::as_str)
            .is_some_and(|step_id| step_id == "params");
    let path = if cond_source_is_params {
        format!("params.{field}")
    } else {
        let step_id = cond
            .get("stepId")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .and_then(|visual_id| {
                state
                    .step_ids_by_visual_id
                    .get(visual_id)
                    .cloned()
                    .or_else(|| Some(visual_id.to_string()))
            })?;
        if step_id == "params" {
            format!("params.{field}")
        } else {
            format!("steps.{step_id}.{field}")
        }
    };
    condition_expr_for_operator(op, &path, value)
        .or_else(|| condition_expr_for_operator("==", &path, value))
}

fn condition_from_editor_branch(branch: &Value, state: &RenderState<'_>) -> Option<String> {
    branch
        .get("cond")
        .and_then(|cond| condition_from_editor_cond(cond, state))
        .or_else(|| {
            branch
                .get("condition")
                .and_then(Value::as_str)
                .and_then(condition_from_text)
        })
}

fn condition_from_editor_cond(cond: &Value, state: &RenderState<'_>) -> Option<String> {
    let field = cond
        .get("field")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let value = cond
        .get("val")
        .or_else(|| cond.get("value"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let op = cond
        .get("op")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    let source_is_params = cond
        .get("namespace")
        .and_then(Value::as_str)
        .is_some_and(|namespace| namespace == "params")
        || cond
            .get("stepId")
            .and_then(Value::as_str)
            .is_some_and(|step_id| step_id == "params");
    let path = if source_is_params {
        format!("params.{field}")
    } else {
        let step_id = cond
            .get("stepId")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())?;
        let compiled_step_id = state
            .step_ids_by_visual_id
            .get(step_id)
            .cloned()
            .unwrap_or_else(|| step_id.to_string());
        format!("steps.{compiled_step_id}.{field}")
    };
    condition_expr_for_operator(op, &path, value)
}

fn condition_from_text(text: &str) -> Option<String> {
    let raw = text.trim();
    if raw.is_empty() {
        return None;
    }
    if raw.starts_with('{') && raw.contains("op") {
        return Some(raw.to_string());
    }
    if raw.contains("&&") {
        let exprs = raw
            .split("&&")
            .map(condition_from_text)
            .collect::<Option<Vec<_>>>()?;
        return Some(format!(
            "{{ op = \"and\", exprs = [{}] }}",
            exprs.join(", ")
        ));
    }
    if raw.contains("||") {
        let exprs = raw
            .split("||")
            .map(condition_from_text)
            .collect::<Option<Vec<_>>>()?;
        return Some(format!("{{ op = \"or\", exprs = [{}] }}", exprs.join(", ")));
    }
    for op in ["==", ">", "<"] {
        if let Some((path, value)) = raw.split_once(op) {
            let value = value.trim().trim_matches(['"', '\'']);
            return condition_expr_for_operator(op, path.trim(), value);
        }
    }
    None
}

fn fallback_condition_from_branch_conditions(conditions: &[String]) -> Option<String> {
    let exprs = conditions
        .iter()
        .filter_map(|condition| condition_from_text(condition))
        .collect::<Vec<_>>();
    if exprs.is_empty() {
        None
    } else if exprs.len() == 1 {
        Some(format!("{{ op = \"not\", expr = {} }}", exprs[0]))
    } else {
        Some(format!(
            "{{ op = \"not\", expr = {{ op = \"or\", exprs = [{}] }} }}",
            exprs.join(", ")
        ))
    }
}

fn condition_expr_for_operator(op: &str, path: &str, value: &str) -> Option<String> {
    let op = match op {
        "==" => "eq",
        ">" => "gt",
        "<" => "lt",
        _ => return None,
    };
    Some(format!(
        "{{ op = \"{op}\", path = {}, value = {} }}",
        toml_string(path),
        toml_string(value)
    ))
}

fn toml_key(value: &str) -> String {
    let mut chars = value.chars();
    let valid_first = chars
        .next()
        .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_');
    let valid_rest = value
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-');
    if valid_first && valid_rest {
        value.to_string()
    } else {
        toml_string(value)
    }
}

fn toml_string(value: &str) -> String {
    format!("\"{}\"", escape_toml_string(value))
}

fn toml_array(values: &[String]) -> String {
    format!(
        "[{}]",
        values
            .iter()
            .map(|value| toml_string(value))
            .collect::<Vec<_>>()
            .join(", ")
    )
}

fn toml_value(value: &Value) -> String {
    match value {
        Value::Bool(value) => value.to_string(),
        Value::Number(value) => value.to_string(),
        Value::String(value) => toml_string(value),
        Value::Array(values) => format!(
            "[{}]",
            values.iter().map(toml_value).collect::<Vec<_>>().join(", ")
        ),
        Value::Object(map) => format!(
            "{{ {} }}",
            map.iter()
                // TOML has no null: an absent optional field is omitted.
                .filter(|(_, value)| !value.is_null())
                .map(|(key, value)| format!("{} = {}", toml_key(key), toml_value(value)))
                .collect::<Vec<_>>()
                .join(", ")
        ),
        Value::Null => "\"\"".to_string(),
    }
}

fn emit_json_toml(lines: &mut Vec<String>, table: &str, value: &Value) {
    let Some(map) = value.as_object() else {
        return;
    };
    lines.push(format!("[{table}]"));
    for (key, val) in map {
        // TOML has no null: absent optional fields (e.g. meerkat 0.7's
        // `SupervisorSpec.escalation_turn_timeout_ms: Option<u64>`, which
        // serializes as JSON null) are omitted, not rendered as `""`.
        if val.is_object() || val.is_null() {
            continue;
        }
        lines.push(format!("{} = {}", toml_key(key), toml_value(val)));
    }
    lines.push(String::new());
    for (key, val) in map {
        if val.is_object() {
            emit_json_toml(lines, &format!("{table}.{}", toml_key(key)), val);
        }
    }
}

fn validate_document(document: &MobpackDocument) -> MobpackValidationResult {
    let mut diagnostics = Vec::new();
    if document.schema_version.trim() != MOBPACK_SCHEMA_VERSION {
        diagnostics.push(MobpackDiagnostic {
            severity: "warning".to_string(),
            code: "schema_version_mismatch".to_string(),
            message: format!(
                "mobpack schema_version '{}' differs from supported '{}'",
                document.schema_version, MOBPACK_SCHEMA_VERSION
            ),
            path: Some("schema_version".to_string()),
        });
    }
    if document.name.trim().is_empty() {
        diagnostics.push(MobpackDiagnostic {
            severity: "warning".to_string(),
            code: "missing_name".to_string(),
            message: "mobpack document has no display name".to_string(),
            path: Some("name".to_string()),
        });
    }
    diagnostics.extend(validate_launch_modes(document));
    diagnostics.extend(validate_deploy_settings(&document.deploy));
    diagnostics.extend(validate_deploy_runtime_modes(document));
    diagnostics.extend(validate_editor_schemas(&document.schemas));
    diagnostics.extend(validate_editor_input_params(&document.flow));
    diagnostics.extend(validate_editor_flow_step_identities(&document.flow));
    diagnostics.extend(validate_editor_flow_step_types(&document.flow));
    diagnostics.extend(validate_editor_flow_step_metadata(&document.flow));
    diagnostics.extend(validate_editor_adaptive_steps(document));
    diagnostics.extend(validate_editor_flow_member_roles(document));
    diagnostics.extend(validate_editor_flow_control_members(document));
    diagnostics.extend(validate_editor_flow_conditions(&document.flow));
    diagnostics.extend(validate_graph_projection(document));
    diagnostics.extend(validate_editor_member_identities(document));
    diagnostics.extend(validate_member_profile_bindings(document));
    diagnostics.extend(validate_skill_realms(document));
    diagnostics.extend(validate_member_catalog_references(document));
    diagnostics.extend(validate_editor_flow_step_tool_references(document));
    diagnostics.extend(validate_selected_skill_sources(document));
    diagnostics.extend(validate_selected_path_skill_files(document));

    if diagnostics
        .iter()
        .any(|diagnostic| diagnostic.severity == "error")
    {
        return invalid_validation_result(diagnostics);
    }

    let mob_toml = match authoring_mob_toml(document) {
        Ok(toml) => toml,
        Err(err) => {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "render_mob_toml_failed".to_string(),
                message: err,
                path: Some("mob_toml".to_string()),
            });
            return invalid_validation_result(diagnostics);
        }
    };

    match meerkat_mob::MobDefinition::from_toml(&mob_toml) {
        Ok(definition) => {
            diagnostics.extend(
                meerkat_mob::validate_definition(&definition)
                    .into_iter()
                    .chain(meerkat_mob::SpecValidator::validate(&definition))
                    .map(|diagnostic| MobpackDiagnostic {
                        severity: format!("{:?}", diagnostic.severity).to_ascii_lowercase(),
                        code: diagnostic.code.to_string(),
                        message: diagnostic.message,
                        path: diagnostic.location,
                    }),
            );
            diagnostics.extend(validate_editor_projection_matches_definition(
                document,
                &definition,
            ));
            diagnostics.extend(validate_mob_settings_match_definition(
                document,
                &definition,
            ));
            diagnostics.extend(validate_editor_flow_members_match_definition(
                document,
                &definition,
            ));
            diagnostics.extend(validate_editor_flow_step_metadata_matches_definition(
                document,
                &definition,
            ));
            if let Err(schema_diagnostics) = expected_schema_files_from_definition(&definition) {
                diagnostics.extend(schema_diagnostics);
            }
            let flow_ids = definition.flows.keys().map(ToString::to_string).collect();
            let ok = !diagnostics
                .iter()
                .any(|diagnostic| diagnostic.severity == "error");
            MobpackValidationResult {
                ok,
                display_rows: validation_display_rows(ok, &diagnostics, MOBPACK_VALIDATION_SOURCE),
                diagnostics,
                mob_id: Some(definition.id.to_string()),
                flow_ids,
                validation_source: MOBPACK_VALIDATION_SOURCE.to_string(),
                deploy_command: "rkat mob run <pack.mobpack> --prompt <prompt>".to_string(),
            }
        }
        Err(err) => {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_mob_toml".to_string(),
                message: err.to_string(),
                path: Some("mob_toml".to_string()),
            });
            invalid_validation_result(diagnostics)
        }
    }
}

fn invalid_validation_result(diagnostics: Vec<MobpackDiagnostic>) -> MobpackValidationResult {
    MobpackValidationResult {
        ok: false,
        display_rows: validation_display_rows(false, &diagnostics, MOBPACK_VALIDATION_SOURCE),
        diagnostics,
        mob_id: None,
        flow_ids: Vec::new(),
        validation_source: MOBPACK_VALIDATION_SOURCE.to_string(),
        deploy_command: "rkat mob run <pack.mobpack> --prompt <prompt>".to_string(),
    }
}

fn validation_display_rows(
    ok: bool,
    diagnostics: &[MobpackDiagnostic],
    validation_source: &str,
) -> Vec<MobpackDisplayRow> {
    let mut rows = diagnostics
        .iter()
        .map(|diagnostic| {
            let is_error = diagnostic.severity == "error";
            MobpackDisplayRow {
                kind: if is_error { "crit" } else { "warn" }.to_string(),
                glyph: if is_error { "!" } else { "△" }.to_string(),
                head: if diagnostic.code.is_empty() {
                    diagnostic.severity.clone()
                } else {
                    diagnostic.code.clone()
                },
                sub: diagnostic.message.clone(),
                meta: diagnostic.path.clone().unwrap_or_default(),
            }
        })
        .collect::<Vec<_>>();
    if rows.is_empty() && ok {
        rows.push(MobpackDisplayRow {
            kind: "ok".to_string(),
            glyph: "✓".to_string(),
            head: "MobKit mobpack validates".to_string(),
            sub: validation_source.to_string(),
            meta: "meerkat_mob::validate_definition".to_string(),
        });
    } else if rows.is_empty() {
        rows.push(MobpackDisplayRow {
            kind: "crit".to_string(),
            glyph: "!".to_string(),
            head: "MobKit validation failed".to_string(),
            sub: "The validation endpoint returned ok=false without diagnostics.".to_string(),
            meta: validation_source.to_string(),
        });
    }
    rows
}

fn validate_launch_modes(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    if document.launch_modes.is_null() {
        return Vec::new();
    }
    let Some(items) = document.launch_modes.as_array() else {
        return vec![MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_launch_modes".to_string(),
            message: "document.launch_modes must be an array".to_string(),
            path: Some("launch_modes".to_string()),
        }];
    };
    let member_ids = editor_member_ids(document);
    let step_ids = editor_flow_member_step_ids(&document.flow);
    let instance_ids = editor_instance_ids(&document.instances);
    let allowed_sources = step_ids
        .iter()
        .chain(instance_ids.iter())
        .chain(member_ids.iter())
        .cloned()
        .collect::<BTreeSet<_>>();
    let profile_by_member = editor_profile_by_member_id(document);
    let flow_launch_modes = editor_flow_member_launch_modes(&document.flow);
    let mut launch_step_ids = BTreeSet::new();
    let mut diagnostics = Vec::new();
    for (index, item) in items.iter().enumerate() {
        let path = format!("launch_modes[{index}].launch_mode");
        let step_id = item
            .get("step_id")
            .or_else(|| item.get("stepId"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty());
        if let Some(step_id) = step_id {
            if !launch_step_ids.insert(step_id.to_string()) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "duplicate_launch_step".to_string(),
                    message: format!(
                        "launch mode entry duplicates step '{step_id}', but each editor member step must have exactly one launch mode"
                    ),
                    path: Some(format!("launch_modes[{index}].step_id")),
                });
            }
            if !step_ids.is_empty() && !step_ids.contains(step_id) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "unknown_launch_step".to_string(),
                    message: format!(
                        "launch mode references step '{step_id}', but the editor flow has no matching member step"
                    ),
                    path: Some(format!("launch_modes[{index}].step_id")),
                });
            }
        } else if !step_ids.is_empty() {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_launch_step".to_string(),
                message:
                    "launch mode entry must include step_id when editor flow steps are present"
                        .to_string(),
                path: Some(format!("launch_modes[{index}].step_id")),
            });
        }
        let member_id = item
            .get("member_id")
            .or_else(|| item.get("memberId"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty());
        if let Some(member_id) = member_id {
            if !member_ids.is_empty() && !member_ids.contains(member_id) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "unknown_launch_member".to_string(),
                    message: format!(
                        "launch mode references member '{member_id}', but document.members has no matching member"
                    ),
                    path: Some(format!("launch_modes[{index}].member_id")),
                });
            }
            if let Some(profile) = item
                .get("profile")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                && profile_by_member
                    .get(member_id)
                    .is_some_and(|expected| expected != profile)
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "launch_profile_mismatch".to_string(),
                    message: format!(
                        "launch mode profile '{profile}' does not match member '{member_id}'"
                    ),
                    path: Some(format!("launch_modes[{index}].profile")),
                });
            }
        } else if !member_ids.is_empty() {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_launch_member".to_string(),
                message:
                    "launch mode entry must include member_id when document.members are present"
                        .to_string(),
                path: Some(format!("launch_modes[{index}].member_id")),
            });
        }
        let Some(mode) = item
            .get("launch_mode")
            .or_else(|| item.get("launchMode"))
            .filter(|mode| mode.is_object())
        else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_launch_mode".to_string(),
                message: "launch mode entry must include launch_mode".to_string(),
                path: Some(path),
            });
            continue;
        };
        if let Some(step_id) = step_id
            && let Some(expected) = flow_launch_modes.get(step_id)
        {
            let actual = editor_launch_mode_entry_mode(item, mode);
            if &actual != expected {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "launch_mode_flow_mismatch".to_string(),
                    message: format!(
                        "launch mode entry '{actual:?}' does not match flow step launch mode '{expected:?}'"
                    ),
                    path: Some(path.clone()),
                });
            }
        }
        let Some(kind) = mode
            .get("kind")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_launch_mode_kind".to_string(),
                message: "launch mode entry must include launch_mode.kind".to_string(),
                path: Some(path),
            });
            continue;
        };
        let kind = kind.to_ascii_lowercase();
        match kind.as_str() {
            "fresh" => {}
            "resume" => {
                let has_session = mode
                    .get("sessionId")
                    .or_else(|| mode.get("session_id"))
                    .or_else(|| mode.get("bridgeSessionId"))
                    .or_else(|| mode.get("bridge_session_id"))
                    .and_then(Value::as_str)
                    .is_some_and(|value| !value.trim().is_empty());
                if !has_session {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "resume_launch_missing_session".to_string(),
                        message: "Resume launch mode requires a bridge session id".to_string(),
                        path: Some(path),
                    });
                }
            }
            "fork" => {
                let source = mode
                    .get("from")
                    .or_else(|| mode.get("sourceMemberId"))
                    .or_else(|| mode.get("source_member_id"))
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty());
                if source.is_none() {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "fork_launch_missing_source".to_string(),
                        message: "Fork launch mode requires a source member or instance id"
                            .to_string(),
                        path: Some(path.clone()),
                    });
                }
                if let Some(source) = source
                    && !allowed_sources.is_empty()
                    && !allowed_sources.contains(source)
                {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "fork_launch_unknown_source".to_string(),
                        message: format!(
                            "Fork launch source '{source}' does not match any editor step, instance, or member"
                        ),
                        path: Some(path.clone()),
                    });
                }
                if let Some(context) = mode
                    .get("context")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    && !matches!(
                        canonical_fork_context_value(context).as_str(),
                        "full_history" | "last_messages"
                    )
                {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_fork_launch_context".to_string(),
                        message: format!("unsupported Fork launch context '{context}'"),
                        path: Some(path),
                    });
                }
            }
            _ => diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_launch_mode_kind".to_string(),
                message: format!("unsupported launch mode kind '{kind}'"),
                path: Some(path),
            }),
        }
        if let Some(policy) = item
            .get("budget_split_policy")
            .or_else(|| item.get("budgetSplitPolicy"))
            .or_else(|| mode.get("budget_split_policy"))
            .or_else(|| mode.get("budgetSplitPolicy"))
            .or_else(|| mode.get("budget"))
        {
            validate_budget_split_policy(
                policy,
                format!("launch_modes[{index}].budget_split_policy"),
                &mut diagnostics,
            );
        }
    }
    if !step_ids.is_empty() {
        for step_id in step_ids.difference(&launch_step_ids) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_step_launch_mode".to_string(),
                message: format!("editor member step '{step_id}' has no launch mode entry"),
                path: Some("launch_modes".to_string()),
            });
        }
    }
    diagnostics
}

fn validate_budget_split_policy(
    policy: &Value,
    path: String,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(object) = policy.as_object() else {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_budget_split_policy".to_string(),
            message: "budget_split_policy must be an object".to_string(),
            path: Some(path),
        });
        return;
    };
    let kind = object
        .get("type")
        .or_else(|| object.get("kind"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_ascii_lowercase);
    let Some(kind) = kind else {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_budget_split_policy".to_string(),
            message: "budget_split_policy must declare a non-empty type".to_string(),
            path: Some(path),
        });
        return;
    };
    match kind.as_str() {
        "equal" | "proportional" | "remaining" => {}
        "fixed" => {
            let valid_value = object
                .get("value")
                .or_else(|| object.get("limit"))
                .or_else(|| object.get("tokens"))
                .and_then(Value::as_u64)
                .is_some_and(|value| value > 0);
            if !valid_value {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_budget_split_policy".to_string(),
                    message: "fixed budget_split_policy requires a positive integer value"
                        .to_string(),
                    path: Some(path),
                });
            }
        }
        _ => diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_budget_split_policy".to_string(),
            message: format!("unsupported budget_split_policy type '{kind}'"),
            path: Some(path),
        }),
    }
}

fn validate_editor_flow_step_types(flow: &Value) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    collect_editor_flow_step_type_diagnostics(
        flow.get("steps").and_then(Value::as_array),
        "flow.steps",
        &mut diagnostics,
    );
    diagnostics
}

fn collect_editor_flow_step_type_diagnostics(
    steps: Option<&Vec<Value>>,
    path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(steps) = steps else {
        return;
    };
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        let Some(step_type) = step.get("type").and_then(Value::as_str) else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_editor_flow_step_type".to_string(),
                message: "editor flow steps must include a type".to_string(),
                path: Some(format!("{step_path}.type")),
            });
            continue;
        };
        if !EDITOR_FLOW_STEP_TYPES.contains(&step_type) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "unsupported_editor_flow_step_type".to_string(),
                message: format!(
                    "editor flow step type '{step_type}' is not backed by current MobKit mob.toml semantics"
                ),
                path: Some(format!("{step_path}.type")),
            });
        }
        collect_editor_flow_step_type_diagnostics(
            step.get("steps").and_then(Value::as_array),
            &format!("{step_path}.steps"),
            diagnostics,
        );
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                collect_editor_flow_step_type_diagnostics(
                    branch.get("steps").and_then(Value::as_array),
                    &format!("{step_path}.branches[{branch_index}].steps"),
                    diagnostics,
                );
            }
        }
        collect_editor_flow_step_type_diagnostics(
            step.get("fallback").and_then(Value::as_array),
            &format!("{step_path}.fallback"),
            diagnostics,
        );
    }
}

fn validate_editor_flow_step_metadata(flow: &Value) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    collect_editor_flow_step_metadata_diagnostics(
        flow.get("steps").and_then(Value::as_array),
        "flow.steps",
        &mut diagnostics,
    );
    diagnostics
}

/// Adaptive-layer diagnostics. The "empty" gaps (prompt, objectiveClass,
/// profileTemplateIds) are draft gates: like a member step without an
/// instruction they keep the document at stage "draft" until filled in.
fn validate_editor_adaptive_steps(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let steps = adaptive_flow_steps(&document.flow);
    if steps.is_empty() {
        return Vec::new();
    }
    let mut diagnostics = Vec::new();
    let member_ids = editor_member_ids(document);
    let schema_ids = editor_schema_map(&document.schemas);
    for (index, (path, step)) in steps.iter().enumerate() {
        if index > 0 {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "duplicate_adaptive_layer".to_string(),
                message: "a mobpack document supports at most one adaptive layer".to_string(),
                path: Some(path.clone()),
            });
        }
        let flowmaster = step
            .get("flowmasterId")
            .and_then(Value::as_str)
            .map(str::trim)
            .unwrap_or_default();
        if flowmaster.is_empty() || !member_ids.contains(flowmaster) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "unknown_adaptive_flowmaster".to_string(),
                message: if flowmaster.is_empty() {
                    "adaptive layer must select a FlowMaster member".to_string()
                } else {
                    format!(
                        "adaptive layer FlowMaster references member '{flowmaster}', but document.members has no matching id"
                    )
                },
                path: Some(format!("{path}.flowmasterId")),
            });
        }
        let templates = step
            .get("profileTemplateIds")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        let mut known_templates = 0usize;
        for (template_index, template) in templates.iter().enumerate() {
            let template_id = template.as_str().map(str::trim).unwrap_or_default();
            if template_id.is_empty() || !member_ids.contains(template_id) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "unknown_adaptive_profile_template".to_string(),
                    message: format!(
                        "adaptive profile template '{template_id}' is not a document.members id"
                    ),
                    path: Some(format!("{path}.profileTemplateIds[{template_index}]")),
                });
            } else {
                known_templates += 1;
            }
        }
        if known_templates == 0 {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "adaptive_profiles_empty".to_string(),
                message: "adaptive layer needs at least one profile template the layer may spawn"
                    .to_string(),
                path: Some(format!("{path}.profileTemplateIds")),
            });
        }
        if step
            .get("prompt")
            .and_then(Value::as_str)
            .map(str::trim)
            .is_none_or(str::is_empty)
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "adaptive_prompt_empty".to_string(),
                message: "adaptive layer needs a non-empty FlowMaster planning prompt".to_string(),
                path: Some(format!("{path}.prompt")),
            });
        }
        if step
            .get("objectiveClass")
            .and_then(Value::as_str)
            .map(str::trim)
            .is_none_or(str::is_empty)
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "adaptive_objective_class_empty".to_string(),
                message: "adaptive layer needs a non-empty objective class".to_string(),
                path: Some(format!("{path}.objectiveClass")),
            });
        }
        let result_schema = step
            .get("resultSchema")
            .and_then(Value::as_str)
            .map(str::trim)
            .unwrap_or_default();
        if !result_schema.is_empty() && !schema_ids.contains_key(result_schema) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "unknown_adaptive_result_schema".to_string(),
                message: format!(
                    "adaptive layer result schema '{result_schema}' is not a document schema"
                ),
                path: Some(format!("{path}.resultSchema")),
            });
        }
        let limits = step
            .get("limits")
            .and_then(Value::as_object)
            .cloned()
            .unwrap_or_default();
        for (camel_key, _, _, _, _) in EDITOR_ADAPTIVE_LIMITS {
            if limits
                .get(*camel_key)
                .and_then(Value::as_u64)
                .is_none_or(|number| number == 0)
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_adaptive_limit".to_string(),
                    message: format!("adaptive limit '{camel_key}' must be a positive integer"),
                    path: Some(format!("{path}.limits.{camel_key}")),
                });
            }
        }
        for limit_key in limits.keys() {
            if !EDITOR_ADAPTIVE_LIMITS
                .iter()
                .any(|(camel_key, _, _, _, _)| camel_key == limit_key)
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_adaptive_limit".to_string(),
                    message: format!("unknown adaptive limit '{limit_key}'"),
                    path: Some(format!("{path}.limits.{limit_key}")),
                });
            }
        }
        if let Some(surfaces) = step.get("targetSurfaces").and_then(Value::as_array) {
            for (surface_index, surface) in surfaces.iter().enumerate() {
                let surface = surface.as_str().map(str::trim).unwrap_or_default();
                if !EDITOR_ADAPTIVE_TARGET_SURFACES.contains(&surface) {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_adaptive_target_surface".to_string(),
                        message: format!(
                            "adaptive target surface '{surface}' is not a MobKit pack surface"
                        ),
                        path: Some(format!("{path}.targetSurfaces[{surface_index}]")),
                    });
                }
            }
        }
    }
    diagnostics
}

fn collect_editor_flow_step_metadata_diagnostics(
    steps: Option<&Vec<Value>>,
    path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(steps) = steps else {
        return;
    };
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        let step_type = step.get("type").and_then(Value::as_str).unwrap_or_default();

        validate_optional_enum_field(
            step,
            "dependsMode",
            "depends_mode",
            &dependency_mode_values(),
            "invalid_editor_dependency_mode",
            "dependency mode",
            &step_path,
            diagnostics,
        );

        if step_type == "parallel" {
            validate_required_parallel_dispatch_field(step, &step_path, diagnostics);
            validate_required_parallel_collection_field(step, &step_path, diagnostics);
        }

        if step_type == "repeat" {
            validate_required_repeat_loop_id_field(step, &step_path, diagnostics);
            validate_required_repeat_max_iterations_field(step, &step_path, diagnostics);
        }

        if step_type == "member" {
            validate_optional_enum_field(
                step,
                "dispatchMode",
                "dispatch_mode",
                &dispatch_mode_values(),
                "invalid_editor_dispatch_mode",
                "dispatch mode",
                &step_path,
                diagnostics,
            );
            validate_collection_policy_field(step, &step_path, diagnostics);
        }

        if step_type == "member" {
            validate_optional_enum_field(
                step,
                "outputFormat",
                "output_format",
                &step_output_format_values(),
                "invalid_editor_output_format",
                "output format",
                &step_path,
                diagnostics,
            );
            validate_optional_positive_integer_field(
                step,
                "timeoutMs",
                "timeout_ms",
                "invalid_editor_timeout",
                "timeout",
                &step_path,
                diagnostics,
            );
        }

        collect_editor_flow_step_metadata_diagnostics(
            step.get("steps").and_then(Value::as_array),
            &format!("{step_path}.steps"),
            diagnostics,
        );
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                collect_editor_flow_step_metadata_diagnostics(
                    branch.get("steps").and_then(Value::as_array),
                    &format!("{step_path}.branches[{branch_index}].steps"),
                    diagnostics,
                );
            }
        }
        collect_editor_flow_step_metadata_diagnostics(
            step.get("fallback").and_then(Value::as_array),
            &format!("{step_path}.fallback"),
            diagnostics,
        );
    }
}

fn validate_required_parallel_dispatch_field(
    step: &Value,
    step_path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = step
        .get("dispatch")
        .or_else(|| step.get("dispatchMode"))
        .or_else(|| step.get("dispatch_mode"))
    else {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "missing_parallel_dispatch_mode".to_string(),
            message: "parallel editor steps must declare an explicit dispatch mode".to_string(),
            path: Some(format!("{step_path}.dispatch")),
        });
        return;
    };
    match value
        .as_str()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        Some(text) if dispatch_mode_values().iter().any(|allowed| allowed == text) => {}
        Some(text) => diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_editor_dispatch_mode".to_string(),
            message: format!(
                "editor dispatch mode '{text}' is not backed by current MobKit mob.toml semantics"
            ),
            path: Some(format!("{step_path}.dispatch")),
        }),
        None => diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_editor_dispatch_mode".to_string(),
            message: "editor dispatch mode must be a non-empty string when present".to_string(),
            path: Some(format!("{step_path}.dispatch")),
        }),
    }
}

fn validate_required_parallel_collection_field(
    step: &Value,
    step_path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    if step
        .get("collection")
        .or_else(|| step.get("collectionPolicy"))
        .or_else(|| step.get("collection_policy"))
        .is_none()
    {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "missing_parallel_collection_policy".to_string(),
            message: "parallel editor steps must declare an explicit collection policy".to_string(),
            path: Some(format!("{step_path}.collection")),
        });
        return;
    }
    validate_collection_policy_field(step, step_path, diagnostics);
}

fn validate_required_repeat_loop_id_field(
    step: &Value,
    step_path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    if step_string(step, "loopId").is_some() {
        return;
    }
    diagnostics.push(MobpackDiagnostic {
        severity: "error".to_string(),
        code: "missing_repeat_loop_id".to_string(),
        message: "repeat editor steps must declare an explicit non-empty loop_id".to_string(),
        path: Some(format!("{step_path}.loopId")),
    });
}

fn validate_required_repeat_max_iterations_field(
    step: &Value,
    step_path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = step
        .get("maxIterations")
        .or_else(|| step.get("max_iterations"))
    else {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "missing_repeat_max_iterations".to_string(),
            message: "repeat editor steps must declare explicit max_iterations".to_string(),
            path: Some(format!("{step_path}.maxIterations")),
        });
        return;
    };
    if value.as_u64().is_some_and(|number| number > 0) {
        return;
    }
    diagnostics.push(MobpackDiagnostic {
        severity: "error".to_string(),
        code: "invalid_repeat_max_iterations".to_string(),
        message: "repeat editor max_iterations must be a positive integer".to_string(),
        path: Some(format!("{step_path}.maxIterations")),
    });
}

#[allow(clippy::too_many_arguments)]
fn validate_optional_enum_field(
    object: &Value,
    camel_key: &str,
    snake_key: &str,
    allowed: &[String],
    code: &str,
    label: &str,
    path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = object.get(camel_key).or_else(|| object.get(snake_key)) else {
        return;
    };
    if value.is_null() {
        return;
    }
    match value
        .as_str()
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        Some(text) if allowed.iter().any(|allowed| allowed == text) => {}
        Some(text) => diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: code.to_string(),
            message: format!(
                "editor {label} '{text}' is not backed by current MobKit mob.toml semantics"
            ),
            path: Some(format!("{path}.{camel_key}")),
        }),
        None => diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: code.to_string(),
            message: format!("editor {label} must be a non-empty string when present"),
            path: Some(format!("{path}.{camel_key}")),
        }),
    }
}

fn validate_optional_positive_integer_field(
    object: &Value,
    camel_key: &str,
    snake_key: &str,
    code: &str,
    label: &str,
    path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = object.get(camel_key).or_else(|| object.get(snake_key)) else {
        return;
    };
    if value.is_null() {
        return;
    }
    if value.as_u64().is_some_and(|number| number > 0) {
        return;
    }
    diagnostics.push(MobpackDiagnostic {
        severity: "error".to_string(),
        code: code.to_string(),
        message: format!("editor {label} must be a positive integer when present"),
        path: Some(format!("{path}.{camel_key}")),
    });
}

fn validate_collection_policy_field(
    step: &Value,
    step_path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = step
        .get("collection")
        .or_else(|| step.get("collectionPolicy"))
        .or_else(|| step.get("collection_policy"))
    else {
        return;
    };
    let policy = match value {
        Value::String(text) => text.trim(),
        Value::Object(map) => map
            .get("type")
            .and_then(Value::as_str)
            .map(str::trim)
            .unwrap_or("all"),
        _ => "",
    };
    if !collection_policy_values()
        .iter()
        .any(|allowed| allowed == policy)
    {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_editor_collection_policy".to_string(),
            message: format!(
                "editor collection policy '{policy}' is not backed by current MobKit mob.toml semantics"
            ),
            path: Some(format!("{step_path}.collection")),
        });
        return;
    }
    if policy != "quorum" {
        return;
    }
    let quorum = match value {
        Value::Object(map) => map.get("n").or_else(|| map.get("quorum")),
        _ => step.get("quorum").or_else(|| step.get("collectionQuorum")),
    };
    match quorum {
        Some(Value::Null) | None => {}
        Some(value) if value.as_u64().is_some_and(|number| number > 0) => {}
        Some(_) => diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_editor_collection_quorum".to_string(),
            message: "editor quorum collection must use a positive integer threshold".to_string(),
            path: Some(format!("{step_path}.quorum")),
        }),
    }
}

fn validate_editor_flow_member_roles(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    let member_ids = editor_member_ids(document);
    collect_editor_flow_member_role_diagnostics(
        document.flow.get("steps").and_then(Value::as_array),
        "flow.steps",
        &member_ids,
        &mut diagnostics,
    );
    diagnostics
}

fn validate_editor_flow_step_identities(flow: &Value) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    let mut seen = BTreeSet::new();
    collect_editor_flow_step_identity_diagnostics(
        flow.get("steps").and_then(Value::as_array),
        "flow.steps",
        &mut seen,
        &mut diagnostics,
    );
    diagnostics
}

fn collect_editor_flow_step_identity_diagnostics(
    steps: Option<&Vec<Value>>,
    path: &str,
    seen: &mut BTreeSet<String>,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(steps) = steps else {
        return;
    };
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        let Some(step_id) = step
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_editor_flow_step_id".to_string(),
                message: "editor flow steps must have a non-empty id".to_string(),
                path: Some(format!("{step_path}.id")),
            });
            continue;
        };
        if !seen.insert(step_id.to_string()) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "duplicate_editor_flow_step_id".to_string(),
                message: format!("editor flow step id '{step_id}' is used more than once"),
                path: Some(format!("{step_path}.id")),
            });
        }
        collect_editor_flow_step_identity_diagnostics(
            step.get("steps").and_then(Value::as_array),
            &format!("{step_path}.steps"),
            seen,
            diagnostics,
        );
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                collect_editor_flow_step_identity_diagnostics(
                    branch.get("steps").and_then(Value::as_array),
                    &format!("{step_path}.branches[{branch_index}].steps"),
                    seen,
                    diagnostics,
                );
            }
        }
        collect_editor_flow_step_identity_diagnostics(
            step.get("fallback").and_then(Value::as_array),
            &format!("{step_path}.fallback"),
            seen,
            diagnostics,
        );
    }
}

fn collect_editor_flow_member_role_diagnostics(
    steps: Option<&Vec<Value>>,
    path: &str,
    member_ids: &BTreeSet<String>,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(steps) = steps else {
        return;
    };
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        let step_type = step.get("type").and_then(Value::as_str).unwrap_or_default();
        if step_type == "member" {
            if editor_step_instruction(step).is_none() {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "missing_flow_member_instruction".to_string(),
                    message: "flow member step must include an explicit non-empty instruction"
                        .to_string(),
                    path: Some(format!("{step_path}.instruction")),
                });
            }
            match step
                .get("role")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            {
                Some(role) if !member_ids.contains(role) => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "unknown_flow_member".to_string(),
                        message: format!(
                            "flow member step references member '{role}', but document.members has no matching id"
                        ),
                        path: Some(format!("{step_path}.role")),
                    });
                }
                Some(_) => {}
                None => diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "missing_flow_member".to_string(),
                    message: "flow member step must reference a real document.members id"
                        .to_string(),
                    path: Some(format!("{step_path}.role")),
                }),
            }
        }
        collect_editor_flow_member_role_diagnostics(
            step.get("steps").and_then(Value::as_array),
            &format!("{step_path}.steps"),
            member_ids,
            diagnostics,
        );
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                collect_editor_flow_member_role_diagnostics(
                    branch.get("steps").and_then(Value::as_array),
                    &format!("{step_path}.branches[{branch_index}].steps"),
                    member_ids,
                    diagnostics,
                );
            }
        }
        collect_editor_flow_member_role_diagnostics(
            step.get("fallback").and_then(Value::as_array),
            &format!("{step_path}.fallback"),
            member_ids,
            diagnostics,
        );
    }
}

fn validate_editor_flow_control_members(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    let member_ids = editor_member_ids(document);
    collect_editor_flow_control_member_diagnostics(
        document.flow.get("steps").and_then(Value::as_array),
        "flow.steps",
        &member_ids,
        &mut diagnostics,
    );
    diagnostics
}

fn collect_editor_flow_control_member_diagnostics(
    steps: Option<&Vec<Value>>,
    path: &str,
    member_ids: &BTreeSet<String>,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(steps) = steps else {
        return;
    };
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        let step_type = step.get("type").and_then(Value::as_str).unwrap_or_default();
        let controller_role = step
            .get("controllerRole")
            .or_else(|| step.get("controllerMemberId"))
            .or_else(|| step.get("controlRole"))
            .or_else(|| step.get("joinRole"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty());
        if let Some(controller_role) = controller_role
            && !member_ids.is_empty()
            && !member_ids.contains(controller_role)
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "unknown_flow_control_member".to_string(),
                message: format!(
                    "flow {step_type} control member '{controller_role}' is not declared in document.members"
                ),
                path: Some(format!("{step_path}.controllerRole")),
            });
        }
        if step_type == "branch"
            && controller_role.is_none()
            && editor_branch_requires_join_member(step)
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_branch_join_member".to_string(),
                message:
                    "branch convergence requires a real Join member declared in document.members"
                        .to_string(),
                path: Some(format!("{step_path}.controllerRole")),
            });
        }
        if step_type == "parallel" && controller_role.is_none() {
            let collection = step
                .get("collection")
                .or_else(|| step.get("collectionPolicy"))
                .or_else(|| step.get("collection_policy"))
                .and_then(|value| match value {
                    Value::String(text) => Some(text.as_str()),
                    Value::Object(map) => map.get("type").and_then(Value::as_str),
                    _ => None,
                })
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .unwrap_or("all");
            if matches!(collection, "any" | "quorum") {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "missing_parallel_join_member".to_string(),
                    message: format!(
                        "parallel collection policy '{collection}' requires a real Join member"
                    ),
                    path: Some(format!("{step_path}.controllerRole")),
                });
            }
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_control_member_diagnostics(
                Some(nested),
                &format!("{step_path}.steps"),
                member_ids,
                diagnostics,
            );
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                collect_editor_flow_control_member_diagnostics(
                    branch.get("steps").and_then(Value::as_array),
                    &format!("{step_path}.branches[{branch_index}].steps"),
                    member_ids,
                    diagnostics,
                );
            }
        }
        collect_editor_flow_control_member_diagnostics(
            step.get("fallback").and_then(Value::as_array),
            &format!("{step_path}.fallback"),
            member_ids,
            diagnostics,
        );
    }
}

fn editor_branch_requires_join_member(step: &Value) -> bool {
    let branch_count = step
        .get("branches")
        .and_then(Value::as_array)
        .map(Vec::len)
        .unwrap_or(0);
    let has_fallback = step
        .get("fallback")
        .and_then(Value::as_array)
        .is_some_and(|steps| !steps.is_empty());
    branch_count + usize::from(has_fallback) > 1
}

fn validate_editor_flow_conditions(flow: &Value) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    let input_params = editor_input_param_names(flow);
    collect_editor_flow_condition_diagnostics(
        flow.get("steps").and_then(Value::as_array),
        "flow.steps",
        &input_params,
        &mut diagnostics,
    );
    diagnostics
}

fn collect_editor_flow_condition_diagnostics(
    steps: Option<&Vec<Value>>,
    path: &str,
    input_params: &BTreeSet<String>,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(steps) = steps else {
        return;
    };
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        if step.get("type").and_then(Value::as_str) == Some("repeat") {
            if let Some(iteration_input) = step
                .get("iterationInput")
                .or_else(|| step.get("iteration_input"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                && !REPEAT_ITERATION_INPUTS.contains(&iteration_input)
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "unsupported_repeat_iteration_input".to_string(),
                    message: format!("editor repeat iteration input '{iteration_input}' is not supported by current MobKit RepeatUntil"),
                    path: Some(format!("{step_path}.iterationInput")),
                });
            }
            validate_required_repeat_condition_fields(step, &step_path, input_params, diagnostics);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                let has_cond_object = branch.get("cond").and_then(Value::as_object).is_some();
                let has_condition_text = branch
                    .get("condition")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .is_some_and(|value| !value.is_empty());
                if step.get("type").and_then(Value::as_str) == Some("branch")
                    && !has_cond_object
                    && !has_condition_text
                {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "missing_editor_branch_condition".to_string(),
                        message: "editor branch lanes must define an explicit condition"
                            .to_string(),
                        path: Some(format!("{step_path}.branches[{branch_index}]")),
                    });
                }
                if let Some(cond) = branch.get("cond").and_then(Value::as_object) {
                    let cond_path = format!("{step_path}.branches[{branch_index}].cond");
                    let condition_source_is_params = cond
                        .get("namespace")
                        .and_then(Value::as_str)
                        .is_some_and(|namespace| namespace == "params")
                        || cond
                            .get("stepId")
                            .and_then(Value::as_str)
                            .is_some_and(|step_id| step_id == "params");
                    for key in ["stepId", "field", "op", "val"] {
                        if key == "stepId" && condition_source_is_params {
                            continue;
                        }
                        if cond
                            .get(key)
                            .and_then(Value::as_str)
                            .map(str::trim)
                            .is_none_or(str::is_empty)
                        {
                            diagnostics.push(MobpackDiagnostic {
                                severity: "error".to_string(),
                                code: "incomplete_editor_branch_condition".to_string(),
                                message: format!("editor branch condition must include {key}"),
                                path: Some(format!("{cond_path}.{key}")),
                            });
                        }
                    }
                    if let Some(op) = cond
                        .get("op")
                        .and_then(Value::as_str)
                        .map(str::trim)
                        .filter(|value| !value.is_empty())
                        && !editor_condition_operator_supported(op)
                    {
                        diagnostics.push(MobpackDiagnostic {
                            severity: "error".to_string(),
                            code: "unsupported_editor_condition_operator".to_string(),
                            message: format!("editor branch condition operator '{op}' is not supported by current MobKit ConditionExpr"),
                            path: Some(format!("{cond_path}.op")),
                        });
                    }
                    if condition_source_is_params {
                        validate_editor_param_ref(
                            cond.get("field")
                                .and_then(Value::as_str)
                                .unwrap_or_default(),
                            input_params,
                            &format!("{cond_path}.field"),
                            diagnostics,
                        );
                    }
                }
                if let Some(condition) = branch
                    .get("condition")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    && !editor_condition_text_supported(condition)
                {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "unsupported_editor_condition_operator".to_string(),
                        message: format!(
                            "editor branch condition '{condition}' is not supported by current MobKit ConditionExpr"
                        ),
                        path: Some(format!("{step_path}.branches[{branch_index}].condition")),
                    });
                }
                if let Some(condition) = branch
                    .get("condition")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    && let Some(field) = editor_condition_text_param_ref(condition)
                {
                    validate_editor_param_ref(
                        &field,
                        input_params,
                        &format!("{step_path}.branches[{branch_index}].condition"),
                        diagnostics,
                    );
                }
                collect_editor_flow_condition_diagnostics(
                    branch.get("steps").and_then(Value::as_array),
                    &format!("{step_path}.branches[{branch_index}].steps"),
                    input_params,
                    diagnostics,
                );
            }
        }
        collect_editor_flow_condition_diagnostics(
            step.get("steps").and_then(Value::as_array),
            &format!("{step_path}.steps"),
            input_params,
            diagnostics,
        );
        collect_editor_flow_condition_diagnostics(
            step.get("fallback").and_then(Value::as_array),
            &format!("{step_path}.fallback"),
            input_params,
            diagnostics,
        );
    }
}

fn editor_condition_text_param_ref(condition: &str) -> Option<String> {
    let trimmed = condition.trim();
    for separator in ["&&", "||"] {
        if trimmed.contains(separator) {
            return trimmed
                .split(separator)
                .find_map(|part| editor_condition_text_param_ref(part.trim()));
        }
    }
    let path = trimmed.split_whitespace().next()?;
    path.strip_prefix("params.")
        .and_then(|rest| rest.split('.').next())
        .map(str::trim)
        .filter(|field| !field.is_empty())
        .map(ToString::to_string)
}

fn validate_required_repeat_condition_fields(
    step: &Value,
    step_path: &str,
    input_params: &BTreeSet<String>,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(cond) = step.get("cond").and_then(Value::as_object) else {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "missing_repeat_condition".to_string(),
            message: "repeat editor steps must define an explicit until condition".to_string(),
            path: Some(format!("{step_path}.cond")),
        });
        return;
    };

    let cond_path = format!("{step_path}.cond");
    for key in ["stepId", "field", "op", "val"] {
        if cond
            .get(key)
            .and_then(Value::as_str)
            .map(str::trim)
            .is_none_or(str::is_empty)
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "incomplete_repeat_condition".to_string(),
                message: format!("repeat editor condition must include {key}"),
                path: Some(format!("{cond_path}.{key}")),
            });
        }
    }

    if let Some(op) = cond
        .get("op")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        && !editor_condition_operator_supported(op)
    {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "unsupported_editor_condition_operator".to_string(),
            message: format!(
                "editor repeat condition operator '{op}' is not supported by current MobKit ConditionExpr"
            ),
            path: Some(format!("{cond_path}.op")),
        });
    }

    let condition_source_is_params = cond
        .get("namespace")
        .and_then(Value::as_str)
        .is_some_and(|namespace| namespace == "params")
        || cond
            .get("stepId")
            .and_then(Value::as_str)
            .is_some_and(|step_id| step_id == "params");
    if condition_source_is_params {
        validate_editor_param_ref(
            cond.get("field")
                .and_then(Value::as_str)
                .unwrap_or_default(),
            input_params,
            &format!("{cond_path}.field"),
            diagnostics,
        );
    }
}

fn validate_editor_param_ref(
    field: &str,
    input_params: &BTreeSet<String>,
    path: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let field = field.trim();
    if field.is_empty() || !input_params.contains(field) {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "unknown_editor_input_param".to_string(),
            message: format!("condition references undeclared input param '{field}'"),
            path: Some(path.to_string()),
        });
    }
}

fn editor_condition_text_supported(condition: &str) -> bool {
    for separator in ["&&", "||"] {
        if condition.contains(separator) {
            return condition
                .split(separator)
                .all(|part| editor_condition_text_supported(part.trim()));
        }
    }
    let Some((_, op_and_value)) = condition.split_once(char::is_whitespace) else {
        return false;
    };
    let op = op_and_value.split_whitespace().next().unwrap_or_default();
    editor_condition_operator_supported(op)
}

fn editor_condition_operator_supported(op: &str) -> bool {
    matches!(op, "==" | ">" | "<")
}

fn validate_deploy_settings(deploy: &Value) -> Vec<MobpackDiagnostic> {
    if deploy.is_null() {
        return Vec::new();
    }
    let Some(object) = deploy.as_object() else {
        return vec![MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_deploy_settings".to_string(),
            message: "document.deploy must be an object".to_string(),
            path: Some("deploy".to_string()),
        }];
    };
    let mut diagnostics = Vec::new();
    if let Some(command) = object
        .get("command")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        && command != "rkat mob run"
    {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_deploy_command".to_string(),
            message: format!("deploy command must be 'rkat mob run', got '{command}'"),
            path: Some("deploy.command".to_string()),
        });
    }
    validate_deploy_string_enum(
        object,
        "surface",
        &["cli", "rpc"],
        "invalid_deploy_surface",
        &mut diagnostics,
    );
    validate_deploy_string_enum(
        object,
        "trust_policy",
        &["permissive", "strict"],
        "invalid_deploy_trust_policy",
        &mut diagnostics,
    );
    validate_deploy_string_enum(
        object,
        "realm_backend",
        &["jsonl", "sqlite"],
        "invalid_deploy_realm_backend",
        &mut diagnostics,
    );
    if let Some(model) = object
        .get("model")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|model| !model.is_empty())
    {
        let known_models = meerkat_models::catalog()
            .iter()
            .map(|entry| entry.id.to_string())
            .collect::<BTreeSet<_>>();
        if !known_models.contains(model) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "unknown_deploy_model".to_string(),
                message: format!("deploy.model '{model}' is not in the MobKit model catalog"),
                path: Some("deploy.model".to_string()),
            });
        }
    }
    validate_deploy_non_negative_number(object, "max_total_tokens", &mut diagnostics);
    validate_deploy_non_negative_number(object, "max_tool_calls", &mut diagnostics);
    // `rkat mob run` (meerkat 0.7) carries no model/token/tool-call/surface
    // override flags — runtime budgets and model are pack-policy concerns.
    // Surface an explicit warning instead of silently dropping overrides
    // that `rkat mob deploy` used to forward.
    if object
        .get("model")
        .and_then(Value::as_str)
        .map(str::trim)
        .is_some_and(|model| !model.is_empty())
    {
        diagnostics.push(deploy_override_not_forwarded_warning(
            "model",
            "the pack's model policy applies",
        ));
    }
    for key in ["max_total_tokens", "max_tool_calls"] {
        if object
            .get(key)
            .and_then(Value::as_u64)
            .is_some_and(|value| value > 0)
        {
            diagnostics.push(deploy_override_not_forwarded_warning(
                key,
                "runtime budgets come from the pack's deploy policy",
            ));
        }
    }
    if object
        .get("surface")
        .and_then(Value::as_str)
        .map(str::trim)
        .is_some_and(|surface| !surface.is_empty() && surface != "cli")
    {
        diagnostics.push(deploy_override_not_forwarded_warning(
            "surface",
            "execution always uses the CLI surface",
        ));
    }
    for key in [
        "model",
        "max_duration",
        "realm",
        "instance",
        "context_root",
        "state_root",
        "user_config_root",
    ] {
        if object
            .get(key)
            .is_some_and(|value| !value.is_string() && !value.is_null())
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_deploy_string".to_string(),
                message: format!("deploy.{key} must be a string when present"),
                path: Some(format!("deploy.{key}")),
            });
        }
    }
    if object
        .get("isolated")
        .is_some_and(|value| !value.is_boolean() && !value.is_null())
    {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_deploy_isolated".to_string(),
            message: "deploy.isolated must be a boolean when present".to_string(),
            path: Some("deploy.isolated".to_string()),
        });
    }
    if let Some(prompt) = object.get("prompt") {
        match prompt.as_str() {
            Some(value) if !value.trim().is_empty() => {}
            _ => diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_deploy_prompt".to_string(),
                message: "deploy.prompt must be a non-empty string when present".to_string(),
                path: Some("deploy.prompt".to_string()),
            }),
        }
    }
    diagnostics
}

fn validate_deploy_runtime_modes(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let deploy_surface = document
        .deploy
        .get("surface")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("cli");
    let Some(members) = document.members.as_array() else {
        return Vec::new();
    };
    let mut diagnostics = Vec::new();
    for (member_index, member) in members.iter().enumerate() {
        let runtime_mode = member
            .get("runtimeMode")
            .or_else(|| member.get("runtime_mode"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("");
        let label = member
            .get("name")
            .or_else(|| member.get("id"))
            .and_then(Value::as_str)
            .unwrap_or("member");
        if deploy_surface == "cli" {
            if let Some(reason) = deploy_runtime_mode_block_reason(deploy_surface, runtime_mode) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "deploy_surface_runtime_mode_unsupported".to_string(),
                    message: format!(
                        "profile '{label}' uses runtime mode '{runtime_mode}', but deploy surface '{deploy_surface}' does not support it: {reason}"
                    ),
                    path: Some(format!("members[{member_index}].runtimeMode")),
                });
            }
        } else if runtime_mode == "autonomous_host" {
            // The editor admits surface=rpc + autonomous_host for a future
            // RPC-surface deploy path, but the ONLY deploy story today is
            // `rkat mob run`, which always executes as CLI surface — it cannot
            // wire an autonomous_host member's RPC host. Warn (not error: the
            // authoring contract still permits the configuration) so the
            // half-wired state is visible rather than silently mis-deployed.
            diagnostics.push(MobpackDiagnostic {
                severity: "warning".to_string(),
                code: "deploy_surface_runtime_mode_not_deployable".to_string(),
                message: format!(
                    "profile '{label}' uses runtime mode 'autonomous_host' under deploy surface '{deploy_surface}', but the `rkat mob run` deploy executes as CLI surface and cannot wire an autonomous_host member; this member will not be deployed as intended until an RPC-surface deploy path exists"
                ),
                path: Some(format!("members[{member_index}].runtimeMode")),
            });
        }
    }
    diagnostics
}

fn deploy_runtime_mode_compatibility() -> Value {
    json!({
        "cli": {
            "allowed": ["turn_driven"],
            "blocked": {
                "autonomous_host": "RPC surface only; rkat mob run requires turn_driven profiles."
            }
        },
        "rpc": {
            "allowed": runtime_mode_values(),
            "blocked": {}
        }
    })
}

fn deploy_runtime_mode_block_reason(surface: &str, runtime_mode: &str) -> Option<String> {
    let runtime_mode = runtime_mode.trim();
    if runtime_mode.is_empty() {
        return None;
    }
    let compatibility = deploy_runtime_mode_compatibility();
    let surface_contract = compatibility.get(surface)?;
    let allowed = surface_contract
        .get("allowed")
        .and_then(Value::as_array)
        .map(|values| {
            values
                .iter()
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                .collect::<BTreeSet<_>>()
        })
        .unwrap_or_default();
    if allowed.contains(runtime_mode) {
        return None;
    }
    surface_contract
        .get("blocked")
        .and_then(Value::as_object)
        .and_then(|blocked| blocked.get(runtime_mode))
        .and_then(Value::as_str)
        .map(ToString::to_string)
        .or_else(|| Some("runtime mode is not in this surface's allowed runtime_modes".to_string()))
}

fn runtime_mode_labels() -> Value {
    json!({
        "turn_driven": "turn_driven — explicit turn dispatch",
        "autonomous_host": "autonomous_host — RPC keep-alive member loop"
    })
}

fn validate_editor_schemas(schemas: &Value) -> Vec<MobpackDiagnostic> {
    if schemas.is_null() {
        return Vec::new();
    }
    let Some(items) = schemas.as_array() else {
        return vec![MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_editor_schemas".to_string(),
            message: "document.schemas must be an array".to_string(),
            path: Some("schemas".to_string()),
        }];
    };

    let mut diagnostics = Vec::new();
    let mut schema_ids = BTreeSet::new();
    for (schema_index, schema) in items.iter().enumerate() {
        let schema_path = format!("schemas[{schema_index}]");
        let Some(schema_id) = schema
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|id| !id.is_empty())
        else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_editor_schema_id".to_string(),
                message: "editor schema id must be a non-empty string".to_string(),
                path: Some(format!("{schema_path}.id")),
            });
            continue;
        };
        if !schema_ids.insert(schema_id.to_string()) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "duplicate_editor_schema_id".to_string(),
                message: format!("editor schema id '{schema_id}' is duplicated"),
                path: Some(format!("{schema_path}.id")),
            });
        }

        let Some(fields) = schema.get("fields").and_then(Value::as_array) else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_editor_schema_fields".to_string(),
                message: "editor schema fields must be an array".to_string(),
                path: Some(format!("{schema_path}.fields")),
            });
            continue;
        };
        let mut field_names = BTreeSet::new();
        for (field_index, field) in fields.iter().enumerate() {
            let field_path = format!("{schema_path}.fields[{field_index}]");
            let field_name = field
                .get("name")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|name| !name.is_empty());
            match field_name {
                Some(name) if !field_names.insert(name.to_string()) => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "duplicate_editor_schema_field".to_string(),
                        message: format!("editor schema field '{name}' is duplicated"),
                        path: Some(format!("{field_path}.name")),
                    });
                }
                Some(_) => {}
                None => diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_editor_schema_field_name".to_string(),
                    message: "editor schema field name must be a non-empty string".to_string(),
                    path: Some(format!("{field_path}.name")),
                }),
            }

            let field_type = field
                .get("type")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|field_type| !field_type.is_empty())
                .unwrap_or("string");
            if !is_editor_schema_field_type(field_type) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_editor_schema_field_type".to_string(),
                    message: format!("unsupported editor schema field type '{field_type}'"),
                    path: Some(format!("{field_path}.type")),
                });
            }

            let enum_values = field
                .get("enumValues")
                .and_then(Value::as_array)
                .map(|values| {
                    values
                        .iter()
                        .filter_map(|value| value.as_str().map(str::trim))
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();
            if field_type == "enum" {
                if enum_values.is_empty() {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "missing_editor_schema_enum_values".to_string(),
                        message: "enum schema fields must define at least one value".to_string(),
                        path: Some(format!("{field_path}.enumValues")),
                    });
                }
                let mut seen_values = BTreeSet::new();
                for (value_index, value) in enum_values.iter().enumerate() {
                    if value.is_empty() {
                        diagnostics.push(MobpackDiagnostic {
                            severity: "error".to_string(),
                            code: "invalid_editor_schema_enum_value".to_string(),
                            message: "enum values must be non-empty strings".to_string(),
                            path: Some(format!("{field_path}.enumValues[{value_index}]")),
                        });
                    } else if !seen_values.insert((*value).to_string()) {
                        diagnostics.push(MobpackDiagnostic {
                            severity: "error".to_string(),
                            code: "duplicate_editor_schema_enum_value".to_string(),
                            message: format!("enum value '{value}' is duplicated"),
                            path: Some(format!("{field_path}.enumValues[{value_index}]")),
                        });
                    }
                }
            } else if !enum_values.is_empty() {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "stale_editor_schema_enum_values".to_string(),
                    message: "enumValues are only valid on enum schema fields".to_string(),
                    path: Some(format!("{field_path}.enumValues")),
                });
            }
        }
    }
    diagnostics
}

fn validate_editor_input_params(flow: &Value) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    let Some(steps) = flow.get("steps").and_then(Value::as_array) else {
        return diagnostics;
    };
    for (step_index, step) in steps.iter().enumerate() {
        if step.get("type").and_then(Value::as_str) != Some("input") {
            continue;
        }
        let Some(params) = step.get("inputParams") else {
            continue;
        };
        let path = format!("flow.steps[{step_index}].inputParams");
        let Some(params) = params.as_array() else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_editor_input_params".to_string(),
                message: "inputParams must be an array".to_string(),
                path: Some(path),
            });
            continue;
        };
        let mut names = BTreeSet::new();
        for (param_index, param) in params.iter().enumerate() {
            let param_path = format!("{path}[{param_index}]");
            let name = param
                .get("name")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|name| !name.is_empty());
            match name {
                Some(name) if !names.insert(name.to_string()) => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "duplicate_editor_input_param".to_string(),
                        message: format!("input param '{name}' is duplicated"),
                        path: Some(format!("{param_path}.name")),
                    });
                }
                Some(_) => {}
                None => diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_editor_input_param_name".to_string(),
                    message: "input param name must be a non-empty string".to_string(),
                    path: Some(format!("{param_path}.name")),
                }),
            }
            let field_type = param
                .get("type")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|field_type| !field_type.is_empty())
                .unwrap_or("string");
            if !is_editor_schema_field_type(field_type) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_editor_input_param_type".to_string(),
                    message: format!("unsupported input param type '{field_type}'"),
                    path: Some(format!("{param_path}.type")),
                });
            }
            let enum_values = param
                .get("enumValues")
                .and_then(Value::as_array)
                .map(|values| {
                    values
                        .iter()
                        .filter_map(|value| value.as_str().map(str::trim))
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();
            if field_type == "enum" {
                if enum_values.is_empty() {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "missing_editor_input_param_enum_values".to_string(),
                        message: "enum input params must define at least one value".to_string(),
                        path: Some(format!("{param_path}.enumValues")),
                    });
                }
            } else if !enum_values.is_empty() {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "stale_editor_input_param_enum_values".to_string(),
                    message: "enumValues are only valid on enum input params".to_string(),
                    path: Some(format!("{param_path}.enumValues")),
                });
            }
        }
    }
    diagnostics
}

fn validate_graph_projection(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    if document.instances.is_null() && document.edges.is_null() && document.frames.is_null() {
        return Vec::new();
    }
    // Advanced-mode graphs carry the user's plain nodes and edges without
    // materialized fan-out/join gates or frames. When re-deriving the flow
    // from this graph reproduces the document's flow, the graph provably is
    // the source of that flow, so the flow<->graph cross-checks (compiled
    // gates/frames/edges, member/metadata/launch expectations) hold by
    // construction and are skipped. Well-formedness checks (shapes, ids,
    // edge endpoints/kinds/conditions, terminals) always run: the
    // derivation ignores malformed pieces, so equality cannot vouch for
    // them.
    let graph_derives_flow =
        document.flow.is_object() && graph_to_flow_from_document(document) == document.flow;
    let mut diagnostics = Vec::new();
    let Some(instances) = document.instances.as_array() else {
        if !document.instances.is_null() {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_graph_instances".to_string(),
                message: "document.instances must be an array".to_string(),
                path: Some("instances".to_string()),
            });
        }
        return diagnostics;
    };
    let edges = match document.edges.as_array() {
        Some(edges) => edges,
        None => {
            if !document.edges.is_null() {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_graph_edges".to_string(),
                    message: "document.edges must be an array".to_string(),
                    path: Some("edges".to_string()),
                });
            }
            return diagnostics;
        }
    };
    let flow_members = editor_flow_member_step_roles(&document.flow);
    let flow_member_metadata = editor_flow_member_step_metadata(&document.flow);
    let flow_launch_modes = editor_flow_member_launch_modes(&document.flow);
    let graph_controls = editor_flow_graph_controls(&document.flow);
    let member_ids = editor_member_ids(document);
    let input_params = editor_input_param_names(&document.flow);
    let member_schema_refs = editor_member_schema_refs(document);
    let schema_field_names = editor_schema_field_names(&document.schemas);
    let mut instance_ids = BTreeSet::new();
    let mut gate_instance_ids = BTreeSet::new();
    let mut member_instance_ids = BTreeSet::new();
    let mut instance_members = BTreeMap::new();

    for (index, instance) in instances.iter().enumerate() {
        let path = format!("instances[{index}]");
        let Some(id) = instance
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|id| !id.is_empty())
        else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_graph_instance_id".to_string(),
                message: "graph instances must have non-empty string ids".to_string(),
                path: Some(format!("{path}.id")),
            });
            continue;
        };
        if !instance_ids.insert(id.to_string()) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "duplicate_graph_instance_id".to_string(),
                message: format!("duplicate graph instance id '{id}'"),
                path: Some(format!("{path}.id")),
            });
        }
        let is_gate = instance
            .get("isGate")
            .and_then(Value::as_bool)
            .unwrap_or(false);
        let is_terminal = instance
            .get("isTerminal")
            .and_then(Value::as_bool)
            .unwrap_or(false);
        if is_gate {
            gate_instance_ids.insert(id.to_string());
            let gate_kind = instance
                .get("gateKind")
                .and_then(Value::as_str)
                .unwrap_or_default();
            if !GRAPH_GATE_KINDS.contains(&gate_kind) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_graph_gate_kind".to_string(),
                    message: format!("unsupported graph gate kind '{gate_kind}'"),
                    path: Some(format!("{path}.gateKind")),
                });
            }
            if !graph_derives_flow {
                if !graph_controls.gate_ids.contains(id) {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "uncompiled_graph_gate".to_string(),
                        message: format!(
                            "graph gate '{id}' is not backed by a branch/parallel flow primitive"
                        ),
                        path: Some(path.clone()),
                    });
                }
                if gate_kind == "fork"
                    && let Some(expected) = graph_controls.fork_controls.get(id)
                {
                    validate_graph_fork_control(instance, &path, expected, &mut diagnostics);
                }
                if gate_kind == "join"
                    && let Some(expected) = graph_controls.join_controls.get(id)
                {
                    validate_graph_join_control(instance, &path, expected, &mut diagnostics);
                }
            }
            continue;
        }
        if is_terminal {
            let terminal_kind = instance
                .get("kind")
                .and_then(Value::as_str)
                .unwrap_or_default();
            if !GRAPH_TERMINAL_KINDS.contains(&terminal_kind) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_graph_terminal_kind".to_string(),
                    message: format!("unsupported graph terminal kind '{terminal_kind}'"),
                    path: Some(format!("{path}.kind")),
                });
            }
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "uncompiled_graph_terminal".to_string(),
                message: format!(
                    "graph terminal '{id}' is visual-only and does not compile into MobKit mob.toml"
                ),
                path: Some(path),
            });
            continue;
        }
        if graph_to_flow_is_adaptive_node(instance) {
            // Adaptive layer instances carry the flow step payload inline and
            // have no member binding; the flow-side adaptive diagnostics and
            // the graph_derives_flow equivalence vouch for them.
            continue;
        }
        let Some(member_id) = instance
            .get("memberId")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_graph_member".to_string(),
                message: "member graph instances must reference memberId".to_string(),
                path: Some(format!("{path}.memberId")),
            });
            continue;
        };
        member_instance_ids.insert(id.to_string());
        instance_members.insert(id.to_string(), member_id.to_string());
        if !member_ids.is_empty() && !member_ids.contains(member_id) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "unknown_graph_member".to_string(),
                message: format!("graph instance '{id}' references unknown member '{member_id}'"),
                path: Some(format!("{path}.memberId")),
            });
        }
        if !flow_members.is_empty() && !graph_derives_flow {
            match flow_members.get(id) {
                Some(expected_member) if expected_member != member_id => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "graph_flow_member_mismatch".to_string(),
                        message: format!(
                            "graph instance '{id}' member '{member_id}' does not match flow member '{expected_member}'"
                        ),
                        path: Some(format!("{path}.memberId")),
                    });
                }
                Some(_) => {}
                None => diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "graph_instance_missing_from_flow".to_string(),
                    message: format!(
                        "graph member instance '{id}' has no matching member step in document.flow"
                    ),
                    path: Some(path.clone()),
                }),
            }
        }
        if !graph_derives_flow {
            if let Some(expected_metadata) = flow_member_metadata.get(id) {
                validate_graph_member_metadata(
                    instance,
                    &path,
                    expected_metadata,
                    &mut diagnostics,
                );
            }
            if let Some(expected_launch) = flow_launch_modes.get(id) {
                validate_graph_member_launch_mode(
                    instance,
                    &path,
                    expected_launch,
                    &mut diagnostics,
                );
            }
        }
    }

    if !graph_derives_flow {
        for step_id in flow_members.keys() {
            if !member_instance_ids.contains(step_id) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "flow_step_missing_graph_instance".to_string(),
                    message: format!("flow member step '{step_id}' has no matching graph instance"),
                    path: Some("instances".to_string()),
                });
            }
        }
        for gate_id in &graph_controls.gate_ids {
            if !gate_instance_ids.contains(gate_id) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "missing_compiled_graph_gate".to_string(),
                    message: format!(
                        "flow compiles graph gate '{gate_id}', but document.instances has no matching gate instance"
                    ),
                    path: Some("instances".to_string()),
                });
            }
        }
    }

    let mut edge_ids = BTreeSet::new();
    for (index, edge) in edges.iter().enumerate() {
        let path = format!("edges[{index}]");
        if let Some(id) = edge
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|id| !id.is_empty())
            && !edge_ids.insert(id.to_string())
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "duplicate_graph_edge_id".to_string(),
                message: format!("duplicate graph edge id '{id}'"),
                path: Some(format!("{path}.id")),
            });
        }
        for endpoint in ["from", "to"] {
            let Some(value) = edge
                .get(endpoint)
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_graph_edge_endpoint".to_string(),
                    message: format!("graph edge must include non-empty {endpoint}"),
                    path: Some(format!("{path}.{endpoint}")),
                });
                continue;
            };
            if !instance_ids.contains(value) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "unknown_graph_edge_endpoint".to_string(),
                    message: format!("graph edge {endpoint} '{value}' has no matching instance"),
                    path: Some(format!("{path}.{endpoint}")),
                });
            }
        }
        let kind = edge
            .get("kind")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|kind| !kind.is_empty())
            .unwrap_or("");
        if !GRAPH_EDGE_KINDS.contains(&kind) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_graph_edge_kind".to_string(),
                message: if kind.is_empty() {
                    "graph edge must include non-empty kind".to_string()
                } else {
                    format!("unsupported graph edge kind '{kind}'")
                },
                path: Some(format!("{path}.kind")),
            });
        }
        if kind == "cond" {
            let Some(cond) = edge.get("cond").and_then(Value::as_object) else {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "incomplete_graph_condition".to_string(),
                    message: "conditional graph edges must reference a real member output field"
                        .to_string(),
                    path: Some(format!("{path}.cond")),
                });
                continue;
            };
            for key in ["var", "op", "val"] {
                if cond
                    .get(key)
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .is_none_or(str::is_empty)
                {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "incomplete_graph_condition".to_string(),
                        message: format!("graph condition must include non-empty {key}"),
                        path: Some(format!("{path}.cond.{key}")),
                    });
                }
            }
            if let Some(op) = cond
                .get("op")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
                && !editor_condition_operator_supported(op)
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "unsupported_graph_condition_operator".to_string(),
                    message: format!(
                        "graph condition operator '{op}' is not supported by current MobKit ConditionExpr"
                    ),
                    path: Some(format!("{path}.cond.op")),
                });
            }
            if let Some(var) = cond
                .get("var")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            {
                let parts = var.split('.').collect::<Vec<_>>();
                if parts.len() == 2 && parts[0] == "params" && !parts[1].trim().is_empty() {
                    validate_editor_param_ref(
                        parts[1],
                        &input_params,
                        &format!("{path}.cond.var"),
                        &mut diagnostics,
                    );
                } else if parts.len() != 3 || parts[0] != "steps" {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_graph_condition_ref".to_string(),
                        message: format!(
                            "graph condition ref '{var}' must use steps.<instance>.<field> or params.<field>"
                        ),
                        path: Some(format!("{path}.cond.var")),
                    });
                } else {
                    let step_id = parts[1];
                    let field = parts[2];
                    if !member_instance_ids.contains(step_id) {
                        diagnostics.push(MobpackDiagnostic {
                            severity: "error".to_string(),
                            code: "unknown_graph_condition_step".to_string(),
                            message: format!(
                                "graph condition references unknown member instance '{step_id}'"
                            ),
                            path: Some(format!("{path}.cond.var")),
                        });
                    } else if let Some(member_id) = instance_members.get(step_id) {
                        match member_schema_refs.get(member_id) {
                            Some(schema_id) => match schema_field_names.get(schema_id) {
                                Some(fields) if fields.contains(field) => {}
                                Some(_) => diagnostics.push(MobpackDiagnostic {
                                    severity: "error".to_string(),
                                    code: "unknown_graph_condition_field".to_string(),
                                    message: format!(
                                        "graph condition field '{field}' does not exist on schema '{schema_id}'"
                                    ),
                                    path: Some(format!("{path}.cond.var")),
                                }),
                                None => diagnostics.push(MobpackDiagnostic {
                                    severity: "error".to_string(),
                                    code: "unknown_graph_condition_schema".to_string(),
                                    message: format!(
                                        "graph condition references missing schema '{schema_id}'"
                                    ),
                                    path: Some(format!("{path}.cond.var")),
                                }),
                            },
                            None => diagnostics.push(MobpackDiagnostic {
                                severity: "error".to_string(),
                                code: "graph_condition_member_without_schema".to_string(),
                                message: format!(
                                    "graph condition member '{member_id}' has no output schema"
                                ),
                                path: Some(format!("{path}.cond.var")),
                            }),
                        }
                    }
                }
            }
        }
    }
    if !graph_derives_flow {
        validate_expected_graph_edges(edges, &graph_controls.expected_edges, &mut diagnostics);
    }

    let mut frame_ids = BTreeSet::new();
    if let Some(frames) = document.frames.as_array() {
        for (index, frame) in frames.iter().enumerate() {
            let kind = frame
                .get("kind")
                .and_then(Value::as_str)
                .unwrap_or_default();
            if !GRAPH_FRAME_KINDS.contains(&kind) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_graph_frame_kind".to_string(),
                    message: format!("unsupported graph frame kind '{kind}'"),
                    path: Some(format!("frames[{index}].kind")),
                });
            }
            if let Some(frame_id) = frame
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            {
                frame_ids.insert(frame_id.to_string());
                if graph_derives_flow {
                    // flow<->frame backing is implied by the derivation
                } else if !graph_controls.frame_ids.contains(frame_id) {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "uncompiled_graph_frame".to_string(),
                        message: format!(
                            "graph frame '{frame_id}' is not backed by a branch/parallel/repeat flow primitive"
                        ),
                        path: Some(format!("frames[{index}]")),
                    });
                } else if let Some(expected_kind) = graph_controls.frame_kinds.get(frame_id)
                    && kind != expected_kind
                {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "graph_frame_kind_mismatch".to_string(),
                        message: format!(
                            "graph frame '{frame_id}' must render as {expected_kind}, not {kind}"
                        ),
                        path: Some(format!("frames[{index}].kind")),
                    });
                }
            }
        }
    } else if !document.frames.is_null() {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_graph_frames".to_string(),
            message: "document.frames must be an array".to_string(),
            path: Some("frames".to_string()),
        });
    }
    if !graph_derives_flow {
        for frame_id in &graph_controls.frame_ids {
            if !frame_ids.contains(frame_id) {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "missing_compiled_graph_frame".to_string(),
                    message: format!(
                        "flow compiles graph frame '{frame_id}', but document.frames has no matching frame"
                    ),
                    path: Some("frames".to_string()),
                });
            }
        }
    }

    diagnostics
}

#[derive(Debug, Default)]
struct EditorGraphControls {
    gate_ids: BTreeSet<String>,
    frame_ids: BTreeSet<String>,
    frame_kinds: BTreeMap<String, String>,
    fork_controls: BTreeMap<String, EditorGraphForkControl>,
    join_controls: BTreeMap<String, EditorGraphJoinControl>,
    expected_edges: Vec<EditorGraphExpectedEdge>,
}

impl EditorGraphControls {
    fn register_frame(&mut self, id: String, kind: &str) {
        self.frame_ids.insert(id.clone());
        self.frame_kinds.insert(id, kind.to_string());
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGraphForkControl {
    dispatch: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGraphJoinControl {
    collection: String,
    quorum: Option<u64>,
    controller_role: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGraphExpectedEdge {
    from: String,
    to: String,
    kind: String,
    path: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGraphMemberMetadata {
    timeout_ms: Option<u64>,
    allowed_tools: Vec<String>,
    blocked_tools: Vec<String>,
    output_format: String,
    dispatch_mode: String,
    collection_policy: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct EditorGraphLaunchMode {
    kind: String,
    session_id: Option<String>,
    source: Option<String>,
    context: Option<String>,
    budget_split_policy: String,
}

#[derive(Debug, Clone, Default)]
struct EditorGraphEndpointSet {
    entries: Vec<String>,
    exits: Vec<String>,
}

fn editor_flow_graph_controls(flow: &Value) -> EditorGraphControls {
    let mut controls = EditorGraphControls::default();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_editor_flow_graph_controls(steps, &mut controls);
    }
    controls
}

fn collect_editor_flow_graph_controls(steps: &[Value], controls: &mut EditorGraphControls) {
    collect_editor_sequence_graph_edges(steps, "flow.steps", controls);
    for (index, step) in steps.iter().enumerate() {
        let id = step
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty());
        match (step.get("type").and_then(Value::as_str), id) {
            (Some("branch"), Some(id)) => {
                let gate_id = format!("g_branch_{id}");
                let join_id = format!("j_branch_{id}");
                controls.gate_ids.insert(gate_id.clone());
                controls.gate_ids.insert(join_id.clone());
                controls.register_frame(format!("frame_branch_{id}"), "Branch");
                controls
                    .join_controls
                    .insert(join_id.clone(), editor_graph_join_control_for_branch(step));
                collect_editor_branch_graph_edges(step, &gate_id, &join_id, controls);
            }
            (Some("parallel"), Some(id)) => {
                let gate_id = format!("g_parallel_{id}");
                let join_id = format!("j_parallel_{id}");
                controls.gate_ids.insert(gate_id.clone());
                controls.gate_ids.insert(join_id.clone());
                controls.register_frame(format!("frame_parallel_{id}"), "Parallel");
                controls.fork_controls.insert(
                    gate_id.clone(),
                    editor_graph_fork_control_for_parallel(step),
                );
                controls
                    .join_controls
                    .insert(join_id, editor_graph_join_control_for_parallel(step));
                collect_editor_parallel_graph_edges(
                    step,
                    &gate_id,
                    &format!("j_parallel_{id}"),
                    controls,
                );
            }
            (Some("repeat"), Some(id)) => {
                controls.register_frame(format!("frame_{id}"), "RepeatUntil");
                collect_editor_repeat_graph_edges(step, &format!("flow.steps[{index}]"), controls);
            }
            _ => {}
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_graph_controls(nested, controls);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_editor_flow_graph_controls(branch_steps, controls);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_editor_flow_graph_controls(fallback, controls);
        }
    }
}

fn collect_editor_sequence_graph_edges(
    steps: &[Value],
    path: &str,
    controls: &mut EditorGraphControls,
) {
    let mut previous_exits: Vec<String> = Vec::new();
    for (index, step) in steps.iter().enumerate() {
        if step.get("type").and_then(Value::as_str) == Some("input") {
            continue;
        }
        let endpoints = editor_graph_endpoints_for_step(step);
        for from in &previous_exits {
            for to in &endpoints.entries {
                controls.expected_edges.push(EditorGraphExpectedEdge {
                    from: from.clone(),
                    to: to.clone(),
                    kind: "next".to_string(),
                    path: format!("{path}[{index}]"),
                });
            }
        }
        if !endpoints.exits.is_empty() {
            previous_exits = endpoints.exits;
        }
    }
}

fn collect_editor_repeat_graph_edges(step: &Value, path: &str, controls: &mut EditorGraphControls) {
    let endpoints = editor_graph_endpoints_for_steps(step.get("steps").and_then(Value::as_array));
    for from in &endpoints.exits {
        for to in &endpoints.entries {
            controls.expected_edges.push(EditorGraphExpectedEdge {
                from: from.clone(),
                to: to.clone(),
                kind: "cond".to_string(),
                path: path.to_string(),
            });
        }
    }
}

fn collect_editor_parallel_graph_edges(
    step: &Value,
    gate_id: &str,
    join_id: &str,
    controls: &mut EditorGraphControls,
) {
    if let Some(branches) = step.get("branches").and_then(Value::as_array) {
        for (index, branch) in branches.iter().enumerate() {
            let endpoints =
                editor_graph_endpoints_for_steps(branch.get("steps").and_then(Value::as_array));
            for entry in &endpoints.entries {
                controls.expected_edges.push(EditorGraphExpectedEdge {
                    from: gate_id.to_string(),
                    to: entry.clone(),
                    kind: "fanout".to_string(),
                    path: format!("flow.steps.{}.branches[{index}]", graph_step_id(step)),
                });
            }
            for exit in &endpoints.exits {
                controls.expected_edges.push(EditorGraphExpectedEdge {
                    from: exit.clone(),
                    to: join_id.to_string(),
                    kind: "next".to_string(),
                    path: format!("flow.steps.{}.branches[{index}]", graph_step_id(step)),
                });
            }
        }
    }
}

fn collect_editor_branch_graph_edges(
    step: &Value,
    gate_id: &str,
    join_id: &str,
    controls: &mut EditorGraphControls,
) {
    if let Some(branches) = step.get("branches").and_then(Value::as_array) {
        for (index, branch) in branches.iter().enumerate() {
            let endpoints =
                editor_graph_endpoints_for_steps(branch.get("steps").and_then(Value::as_array));
            for entry in &endpoints.entries {
                controls.expected_edges.push(EditorGraphExpectedEdge {
                    from: gate_id.to_string(),
                    to: entry.clone(),
                    kind: "cond".to_string(),
                    path: format!("flow.steps.{}.branches[{index}]", graph_step_id(step)),
                });
            }
            for exit in &endpoints.exits {
                controls.expected_edges.push(EditorGraphExpectedEdge {
                    from: exit.clone(),
                    to: join_id.to_string(),
                    kind: "next".to_string(),
                    path: format!("flow.steps.{}.branches[{index}]", graph_step_id(step)),
                });
            }
        }
    }
    let fallback = editor_graph_endpoints_for_steps(step.get("fallback").and_then(Value::as_array));
    for entry in &fallback.entries {
        controls.expected_edges.push(EditorGraphExpectedEdge {
            from: gate_id.to_string(),
            to: entry.clone(),
            kind: "next".to_string(),
            path: format!("flow.steps.{}.fallback", graph_step_id(step)),
        });
    }
    for exit in &fallback.exits {
        controls.expected_edges.push(EditorGraphExpectedEdge {
            from: exit.clone(),
            to: join_id.to_string(),
            kind: "next".to_string(),
            path: format!("flow.steps.{}.fallback", graph_step_id(step)),
        });
    }
}

fn editor_graph_endpoints_for_steps(steps: Option<&Vec<Value>>) -> EditorGraphEndpointSet {
    let mut entries = Vec::new();
    let mut exits = Vec::new();
    let Some(steps) = steps else {
        return EditorGraphEndpointSet::default();
    };
    for step in steps {
        let endpoints = editor_graph_endpoints_for_step(step);
        if entries.is_empty() {
            entries = endpoints.entries;
        }
        if !endpoints.exits.is_empty() {
            exits = endpoints.exits;
        }
    }
    EditorGraphEndpointSet { entries, exits }
}

fn editor_graph_endpoints_for_step(step: &Value) -> EditorGraphEndpointSet {
    let Some(id) = step
        .get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return EditorGraphEndpointSet::default();
    };
    match step.get("type").and_then(Value::as_str) {
        Some("member") => EditorGraphEndpointSet {
            entries: vec![id.to_string()],
            exits: vec![id.to_string()],
        },
        Some("branch") => EditorGraphEndpointSet {
            entries: vec![format!("g_branch_{id}")],
            exits: vec![format!("j_branch_{id}")],
        },
        Some("parallel") => EditorGraphEndpointSet {
            entries: vec![format!("g_parallel_{id}")],
            exits: vec![format!("j_parallel_{id}")],
        },
        Some("repeat") => {
            editor_graph_endpoints_for_steps(step.get("steps").and_then(Value::as_array))
        }
        // Adaptive layers project as a single inline node, wired like a
        // member turn.
        Some("adaptive") => EditorGraphEndpointSet {
            entries: vec![id.to_string()],
            exits: vec![id.to_string()],
        },
        _ => EditorGraphEndpointSet::default(),
    }
}

fn graph_step_id(step: &Value) -> String {
    step.get("id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("unknown")
        .to_string()
}

fn validate_expected_graph_edges(
    edges: &[Value],
    expected_edges: &[EditorGraphExpectedEdge],
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let actual = edges
        .iter()
        .filter_map(|edge| {
            let kind = edge.get("kind")?.as_str()?.trim().to_string();
            if kind.is_empty() {
                return None;
            }
            Some((
                edge.get("from")?.as_str()?.trim().to_string(),
                edge.get("to")?.as_str()?.trim().to_string(),
                kind,
            ))
        })
        .collect::<BTreeSet<_>>();
    let expected = expected_edges
        .iter()
        .map(|edge| (edge.from.clone(), edge.to.clone(), edge.kind.clone()))
        .collect::<BTreeSet<_>>();
    for expected in expected_edges {
        let key = (
            expected.from.clone(),
            expected.to.clone(),
            expected.kind.clone(),
        );
        if !actual.contains(&key) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "graph_flow_edge_mismatch".to_string(),
                message: format!(
                    "graph is missing {} edge from '{}' to '{}' for {}",
                    expected.kind, expected.from, expected.to, expected.path
                ),
                path: Some("edges".to_string()),
            });
        }
    }
    for (from, to, kind) in actual {
        if !expected.contains(&(from.clone(), to.clone(), kind.clone())) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "graph_extra_uncompiled_edge".to_string(),
                message: format!(
                    "graph has {kind} edge from '{from}' to '{to}', but no matching MobKit flow edge compiles from document.flow"
                ),
                path: Some("edges".to_string()),
            });
        }
    }
}

fn editor_graph_join_control_for_parallel(step: &Value) -> EditorGraphJoinControl {
    let policy = editor_collection_policy(step);
    let (collection, quorum) = if let Some(n) = policy.strip_prefix("quorum:") {
        ("quorum".to_string(), n.parse::<u64>().ok())
    } else {
        (policy, None)
    };
    EditorGraphJoinControl {
        collection,
        quorum,
        controller_role: step
            .get("controllerRole")
            .or_else(|| step.get("controllerMemberId"))
            .or_else(|| step.get("controlRole"))
            .or_else(|| step.get("joinRole"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string),
    }
}

fn editor_graph_join_control_for_branch(step: &Value) -> EditorGraphJoinControl {
    EditorGraphJoinControl {
        collection: "any".to_string(),
        quorum: None,
        controller_role: step
            .get("controllerRole")
            .or_else(|| step.get("controllerMemberId"))
            .or_else(|| step.get("controlRole"))
            .or_else(|| step.get("joinRole"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string),
    }
}

fn editor_graph_fork_control_for_parallel(step: &Value) -> EditorGraphForkControl {
    EditorGraphForkControl {
        dispatch: graph_dispatch_mode_from_value(
            step.get("dispatch")
                .or_else(|| step.get("dispatchMode"))
                .or_else(|| step.get("dispatch_mode")),
        ),
    }
}

fn validate_graph_fork_control(
    instance: &Value,
    path: &str,
    expected: &EditorGraphForkControl,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let actual_dispatch = graph_dispatch_mode_from_value(
        instance
            .get("dispatch")
            .or_else(|| instance.get("dispatchMode"))
            .or_else(|| instance.get("dispatch_mode"))
            .or_else(|| instance.get("label")),
    );
    if actual_dispatch != expected.dispatch {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "graph_fork_dispatch_mismatch".to_string(),
            message: format!(
                "graph fork dispatch '{actual_dispatch}' does not match flow dispatch '{}'",
                expected.dispatch
            ),
            path: Some(format!("{path}.dispatch")),
        });
    }
}

fn graph_dispatch_mode_from_value(value: Option<&Value>) -> String {
    match value.and_then(Value::as_str).map(str::trim) {
        Some("one_to_one") => "one_to_one".to_string(),
        Some("fan_in") => "fan_in".to_string(),
        _ => "fan_out".to_string(),
    }
}

fn validate_graph_join_control(
    instance: &Value,
    path: &str,
    expected: &EditorGraphJoinControl,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let actual_collection = graph_join_collection(instance);
    if actual_collection != expected.collection {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "graph_join_collection_mismatch".to_string(),
            message: format!(
                "graph join collection '{actual_collection}' does not match flow collection '{}'",
                expected.collection
            ),
            path: Some(format!("{path}.collection")),
        });
    }
    if expected.collection == "quorum" {
        let actual_quorum = instance
            .get("quorum")
            .and_then(Value::as_object)
            .and_then(|quorum| quorum.get("n"))
            .and_then(Value::as_u64);
        if actual_quorum != expected.quorum {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "graph_join_quorum_mismatch".to_string(),
                message: format!(
                    "graph join quorum '{actual_quorum:?}' does not match flow quorum '{:?}'",
                    expected.quorum
                ),
                path: Some(format!("{path}.quorum.n")),
            });
        }
    }
    let actual_controller = instance
        .get("controllerRole")
        .or_else(|| instance.get("controllerMemberId"))
        .or_else(|| instance.get("controlRole"))
        .or_else(|| instance.get("joinRole"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    if expected.collection != "all" && actual_controller != expected.controller_role.as_deref() {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "graph_join_controller_mismatch".to_string(),
            message: format!(
                "graph join controller '{:?}' does not match flow controller '{:?}'",
                actual_controller, expected.controller_role
            ),
            path: Some(format!("{path}.controllerRole")),
        });
    }
}

fn graph_join_collection(instance: &Value) -> String {
    if let Some(collection) = instance
        .get("collection")
        .or_else(|| instance.get("collectionPolicy"))
        .or_else(|| instance.get("collection_policy"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    {
        return match collection {
            "any" | "quorum" => collection.to_string(),
            _ => "all".to_string(),
        };
    }
    if instance
        .get("quorum")
        .and_then(Value::as_object)
        .and_then(|quorum| quorum.get("n"))
        .and_then(Value::as_u64)
        .is_some()
    {
        return "quorum".to_string();
    }
    let label = instance
        .get("label")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_ascii_lowercase();
    if label.contains("any") {
        "any".to_string()
    } else {
        "all".to_string()
    }
}

fn validate_graph_member_metadata(
    instance: &Value,
    path: &str,
    expected: &EditorGraphMemberMetadata,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let actual = editor_graph_member_metadata(instance);
    if actual.timeout_ms != expected.timeout_ms {
        diagnostics.push(graph_member_metadata_diagnostic(
            path,
            "timeoutMs",
            "timeout",
            format!("{:?}", actual.timeout_ms),
            format!("{:?}", expected.timeout_ms),
        ));
    }
    if actual.allowed_tools != expected.allowed_tools {
        diagnostics.push(graph_member_metadata_diagnostic(
            path,
            "allowedTools",
            "allowed tools",
            format!("{:?}", actual.allowed_tools),
            format!("{:?}", expected.allowed_tools),
        ));
    }
    if actual.blocked_tools != expected.blocked_tools {
        diagnostics.push(graph_member_metadata_diagnostic(
            path,
            "blockedTools",
            "blocked tools",
            format!("{:?}", actual.blocked_tools),
            format!("{:?}", expected.blocked_tools),
        ));
    }
    if actual.output_format != expected.output_format {
        diagnostics.push(graph_member_metadata_diagnostic(
            path,
            "outputFormat",
            "output format",
            actual.output_format,
            expected.output_format.clone(),
        ));
    }
    if actual.dispatch_mode != expected.dispatch_mode {
        diagnostics.push(graph_member_metadata_diagnostic(
            path,
            "dispatchMode",
            "dispatch mode",
            actual.dispatch_mode,
            expected.dispatch_mode.clone(),
        ));
    }
    if actual.collection_policy != expected.collection_policy {
        diagnostics.push(graph_member_metadata_diagnostic(
            path,
            "collection",
            "collection policy",
            actual.collection_policy,
            expected.collection_policy.clone(),
        ));
    }
}

fn editor_graph_member_metadata(value: &Value) -> EditorGraphMemberMetadata {
    EditorGraphMemberMetadata {
        timeout_ms: editor_u64(value, "timeoutMs").or_else(|| editor_u64(value, "timeout_ms")),
        allowed_tools: editor_string_vec(
            value
                .get("allowedTools")
                .or_else(|| value.get("allowed_tools")),
        ),
        blocked_tools: editor_string_vec(
            value
                .get("blockedTools")
                .or_else(|| value.get("blocked_tools")),
        ),
        // Graph-vs-flow comparison only needs both sides to resolve an unset
        // format identically; the schema-aware default applies at export.
        output_format: editor_output_format(value, false),
        dispatch_mode: editor_dispatch_mode(value),
        collection_policy: editor_collection_policy(value),
    }
}

fn graph_member_metadata_diagnostic(
    path: &str,
    field: &str,
    label: &str,
    actual: String,
    expected: String,
) -> MobpackDiagnostic {
    MobpackDiagnostic {
        severity: "error".to_string(),
        code: "graph_member_metadata_mismatch".to_string(),
        message: format!(
            "graph member {label} '{actual}' does not match flow {label} '{expected}'"
        ),
        path: Some(format!("{path}.{field}")),
    }
}

fn validate_graph_member_launch_mode(
    instance: &Value,
    path: &str,
    expected: &EditorGraphLaunchMode,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let actual = editor_graph_launch_mode(
        instance
            .get("launchMode")
            .or_else(|| instance.get("launch_mode")),
    );
    if &actual != expected {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "graph_launch_mode_mismatch".to_string(),
            message: format!(
                "graph launch mode '{actual:?}' does not match flow launch mode '{expected:?}'"
            ),
            path: Some(format!("{path}.launchMode")),
        });
    }
}

fn editor_graph_launch_mode(value: Option<&Value>) -> EditorGraphLaunchMode {
    let Some(mode) = value.and_then(Value::as_object) else {
        return EditorGraphLaunchMode {
            kind: String::new(),
            session_id: None,
            source: None,
            context: None,
            budget_split_policy: String::new(),
        };
    };
    let kind = mode
        .get("kind")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let canonical_kind = match kind {
        Some("Fresh" | "fresh") => "Fresh".to_string(),
        Some("Resume" | "resume") => "Resume".to_string(),
        Some("Fork" | "fork") => "Fork".to_string(),
        Some(other) => other.to_string(),
        None => String::new(),
    };
    let session_id = mode
        .get("sessionId")
        .or_else(|| mode.get("session_id"))
        .or_else(|| mode.get("bridgeSessionId"))
        .or_else(|| mode.get("bridge_session_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string);
    let source = mode
        .get("from")
        .or_else(|| mode.get("sourceMemberId"))
        .or_else(|| mode.get("source_member_id"))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string);
    let context = mode
        .get("context")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(canonical_fork_context_value);
    let policy = mode
        .get("budgetSplitPolicy")
        .or_else(|| mode.get("budget_split_policy"))
        .or_else(|| mode.get("budget"))
        .and_then(normalize_budget_split_policy_value);
    EditorGraphLaunchMode {
        kind: canonical_kind,
        session_id,
        source,
        context,
        budget_split_policy: policy.map(|policy| policy.to_string()).unwrap_or_default(),
    }
}

fn editor_launch_mode_entry_mode(item: &Value, mode: &Value) -> EditorGraphLaunchMode {
    let mut launch_mode = editor_graph_launch_mode(Some(mode));
    if let Some(policy) = item
        .get("budget_split_policy")
        .or_else(|| item.get("budgetSplitPolicy"))
    {
        launch_mode.budget_split_policy = normalize_budget_split_policy_value(policy)
            .map(|policy| policy.to_string())
            .unwrap_or_default();
    }
    launch_mode
}

fn validate_deploy_string_enum(
    object: &serde_json::Map<String, Value>,
    key: &str,
    allowed: &[&str],
    code: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    if let Some(value) = object.get(key) {
        let Some(text) = value
            .as_str()
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            if !value.is_null() {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_deploy_string".to_string(),
                    message: format!("deploy.{key} must be a string when present"),
                    path: Some(format!("deploy.{key}")),
                });
            }
            return;
        };
        if !allowed.contains(&text) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: code.to_string(),
                message: format!(
                    "deploy.{key} must be one of {}, got '{text}'",
                    allowed.join(", ")
                ),
                path: Some(format!("deploy.{key}")),
            });
        }
    }
}

fn validate_deploy_non_negative_number(
    object: &serde_json::Map<String, Value>,
    key: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = object.get(key) else {
        return;
    };
    let valid = value
        .as_i64()
        .map(|number| number >= 0)
        .or_else(|| value.as_u64().map(|_| true))
        .or_else(|| {
            value
                .as_f64()
                .map(|number| number >= 0.0 && number.fract() == 0.0)
        })
        .unwrap_or(false);
    if !valid {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_deploy_number".to_string(),
            message: format!("deploy.{key} must be a non-negative integer"),
            path: Some(format!("deploy.{key}")),
        });
    }
}

/// Warning for deploy settings that `rkat mob deploy` used to forward as CLI
/// overrides but `rkat mob run` (meerkat 0.7) has no flags for. Keeping the
/// setting in the document is harmless, but it does not affect the run.
fn deploy_override_not_forwarded_warning(key: &str, effect: &str) -> MobpackDiagnostic {
    MobpackDiagnostic {
        severity: "warning".to_string(),
        code: "deploy_override_not_forwarded".to_string(),
        message: format!(
            "deploy.{key} is not forwarded by 'rkat mob run'; {effect}. Remove the override or encode it in the pack's deploy policy."
        ),
        path: Some(format!("deploy.{key}")),
    }
}

fn editor_member_ids(document: &MobpackDocument) -> BTreeSet<String> {
    document
        .members
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|member| member.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn editor_input_param_names(flow: &Value) -> BTreeSet<String> {
    let mut names = BTreeSet::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        for step in steps {
            if step.get("type").and_then(Value::as_str) != Some("input") {
                continue;
            }
            if let Some(params) = step.get("inputParams").and_then(Value::as_array) {
                names.extend(params.iter().filter_map(|param| {
                    param
                        .get("name")
                        .and_then(Value::as_str)
                        .map(str::trim)
                        .filter(|name| !name.is_empty())
                        .map(ToString::to_string)
                }));
            }
            if let Some(fields) = step.get("fields").and_then(Value::as_str) {
                for line in fields.split(['\n', ',']) {
                    if let Some((name, _)) = line.split_once(':') {
                        let name = name.trim();
                        if !name.is_empty() {
                            names.insert(name.to_string());
                        }
                    }
                }
            }
        }
    }
    names
}

fn editor_member_schema_refs(document: &MobpackDocument) -> BTreeMap<String, String> {
    document
        .members
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|member| {
            let id = member
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())?;
            let schema = member
                .get("schema")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|schema| !schema.is_empty())?;
            Some((id.to_string(), schema.to_string()))
        })
        .collect()
}

fn editor_schema_field_names(schemas: &Value) -> BTreeMap<String, BTreeSet<String>> {
    schemas
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|schema| {
            let id = schema
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())?;
            let fields = schema
                .get("fields")
                .and_then(Value::as_array)
                .into_iter()
                .flatten()
                .filter_map(|field| field.get("name").and_then(Value::as_str))
                .map(str::trim)
                .filter(|name| !name.is_empty())
                .map(ToString::to_string)
                .collect::<BTreeSet<_>>();
            Some((id.to_string(), fields))
        })
        .collect()
}

fn editor_profile_by_member_id(document: &MobpackDocument) -> BTreeMap<String, String> {
    document
        .members
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|member| {
            let id = member
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())?;
            Some((id.to_string(), editor_profile_name(member)))
        })
        .collect()
}

fn editor_member_has_schema_by_id(document: &MobpackDocument) -> BTreeMap<String, bool> {
    document
        .members
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|member| {
            let id = member
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())?;
            let has_schema = member
                .get("schema")
                .and_then(Value::as_str)
                .map(str::trim)
                .is_some_and(|value| !value.is_empty());
            Some((id.to_string(), has_schema))
        })
        .collect()
}

fn editor_instance_ids(instances: &Value) -> BTreeSet<String> {
    instances
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|instance| instance.get("id").and_then(Value::as_str))
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn editor_flow_member_step_ids(flow: &Value) -> BTreeSet<String> {
    let mut ids = BTreeSet::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_editor_flow_member_step_ids(steps, &mut ids);
    }
    ids
}

fn editor_flow_member_step_roles(flow: &Value) -> BTreeMap<String, String> {
    let mut roles = BTreeMap::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_editor_flow_member_step_roles(steps, &mut roles);
    }
    roles
}

fn editor_flow_member_step_metadata(flow: &Value) -> BTreeMap<String, EditorGraphMemberMetadata> {
    let mut metadata = BTreeMap::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_editor_flow_member_step_metadata_for_graph(steps, &mut metadata);
    }
    metadata
}

fn editor_flow_member_launch_modes(flow: &Value) -> BTreeMap<String, EditorGraphLaunchMode> {
    let mut launch_modes = BTreeMap::new();
    if let Some(steps) = flow.get("steps").and_then(Value::as_array) {
        collect_editor_flow_member_launch_modes(steps, &mut launch_modes);
    }
    launch_modes
}

fn collect_editor_flow_member_step_ids(steps: &[Value], ids: &mut BTreeSet<String>) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("member")
            && let Some(id) = step
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())
        {
            ids.insert(id.to_string());
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_member_step_ids(nested, ids);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_editor_flow_member_step_ids(branch_steps, ids);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_editor_flow_member_step_ids(fallback, ids);
        }
    }
}

fn collect_editor_flow_member_launch_modes(
    steps: &[Value],
    launch_modes: &mut BTreeMap<String, EditorGraphLaunchMode>,
) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("member")
            && let Some(id) = step
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())
            // graph_to_flow emits launchMode: null for steps without one; a
            // null mode is "no expectation", matching the reconciler
            // (collect_editor_flow_member_step_launch_values) — otherwise the
            // two sides disagree forever and reconciliation cannot converge.
            && let Some(mode) = step
                .get("launchMode")
                .or_else(|| step.get("launch_mode"))
                .filter(|mode| mode.is_object())
        {
            launch_modes.insert(id.to_string(), editor_graph_launch_mode(Some(mode)));
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_member_launch_modes(nested, launch_modes);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_editor_flow_member_launch_modes(branch_steps, launch_modes);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_editor_flow_member_launch_modes(fallback, launch_modes);
        }
    }
}

fn collect_editor_flow_member_step_metadata_for_graph(
    steps: &[Value],
    metadata: &mut BTreeMap<String, EditorGraphMemberMetadata>,
) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("member")
            && let Some(id) = step
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())
        {
            metadata.insert(id.to_string(), editor_graph_member_metadata(step));
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_member_step_metadata_for_graph(nested, metadata);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_editor_flow_member_step_metadata_for_graph(branch_steps, metadata);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_editor_flow_member_step_metadata_for_graph(fallback, metadata);
        }
    }
}

fn collect_editor_flow_member_step_roles(steps: &[Value], roles: &mut BTreeMap<String, String>) {
    for step in steps {
        if step.get("type").and_then(Value::as_str) == Some("member")
            && let (Some(id), Some(role)) = (
                step.get("id")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|id| !id.is_empty()),
                step.get("role")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .filter(|role| !role.is_empty()),
            )
        {
            roles.insert(id.to_string(), role.to_string());
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_member_step_roles(nested, roles);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                if let Some(branch_steps) = branch.get("steps").and_then(Value::as_array) {
                    collect_editor_flow_member_step_roles(branch_steps, roles);
                }
            }
        }
        if let Some(fallback) = step.get("fallback").and_then(Value::as_array) {
            collect_editor_flow_member_step_roles(fallback, roles);
        }
    }
}

fn validate_member_catalog_references(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let Some(members) = document.members.as_array() else {
        return Vec::new();
    };
    let tool_ids = tool_catalog_response()
        .into_iter()
        .filter_map(|tool| {
            tool.get("id")
                .and_then(Value::as_str)
                .map(ToString::to_string)
        })
        .collect::<BTreeSet<_>>();
    let skill_ids = skill_ids_from_realms(&document.skill_realms)
        .into_iter()
        .chain(skill_ids_from_document_definition(document))
        .collect::<BTreeSet<_>>();
    let model_ids = meerkat_models::catalog()
        .iter()
        .map(|entry| entry.id.to_string())
        .collect::<BTreeSet<_>>();
    let mut diagnostics = Vec::new();

    for (member_index, member) in members.iter().enumerate() {
        if let Some(model) = member.get("model").filter(|value| !value.is_null()) {
            match model.as_str().map(str::trim) {
                Some("") => {}
                Some(model_id) => {
                    if !model_ids.contains(model_id) {
                        diagnostics.push(MobpackDiagnostic {
                            severity: "warning".to_string(),
                            code: "unknown_model_ref".to_string(),
                            message: format!(
                                "member references model '{model_id}', but it is not present in the current MobKit editor model catalog"
                            ),
                            path: Some(format!("members[{member_index}].model")),
                        });
                    }
                }
                None => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_model_ref".to_string(),
                        message: "member model references must be strings when set".to_string(),
                        path: Some(format!("members[{member_index}].model")),
                    });
                }
            }
        }

        if let Some(tools) = member.get("tools").and_then(Value::as_array) {
            for (tool_index, tool) in tools.iter().enumerate() {
                let Some(tool_id) = tool
                    .as_str()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                else {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_tool_ref".to_string(),
                        message: "member tool references must be non-empty strings".to_string(),
                        path: Some(format!("members[{member_index}].tools[{tool_index}]")),
                    });
                    continue;
                };
                if !tool_ids.contains(tool_id) {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "unknown_tool_ref".to_string(),
                        message: format!(
                            "member references tool '{tool_id}', but it is not present in MobKit tool_catalog"
                        ),
                        path: Some(format!("members[{member_index}].tools[{tool_index}]")),
                    });
                }
            }
        }

        if let Some(skills) = member.get("skills").and_then(Value::as_array) {
            for (skill_index, skill) in skills.iter().enumerate() {
                let Some(skill_id) = skill
                    .as_str()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                else {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_skill_ref".to_string(),
                        message: "member skill references must be non-empty strings".to_string(),
                        path: Some(format!("members[{member_index}].skills[{skill_index}]")),
                    });
                    continue;
                };
                if !skill_ids.contains(skill_id) {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "unknown_skill_ref".to_string(),
                        message: format!(
                            "member references skill '{skill_id}', but it is not present in the mobpack skill realms"
                        ),
                        path: Some(format!("members[{member_index}].skills[{skill_index}]")),
                    });
                }
            }
        }

        if let Some(provider_params) = member
            .get("providerParams")
            .or_else(|| member.get("provider_params"))
            .filter(|value| !value.is_null())
            && !provider_params.is_object()
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_provider_params".to_string(),
                message: "member providerParams must be a JSON object".to_string(),
                path: Some(format!("members[{member_index}].providerParams")),
            });
        }

        if let Some(backend) = member.get("backend").filter(|value| !value.is_null()) {
            let valid = backend
                .as_str()
                .map(str::trim)
                .is_some_and(|value| value.is_empty() || matches!(value, "session" | "external"));
            if !valid {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_profile_backend".to_string(),
                    message: "member backend must be 'session' or 'external' when set".to_string(),
                    path: Some(format!("members[{member_index}].backend")),
                });
            }
        }

        if let Some(value) = member
            .get("maxInlinePeerNotifications")
            .or_else(|| member.get("max_inline_peer_notifications"))
            .filter(|value| !value.is_null())
        {
            let valid = value
                .as_i64()
                .is_some_and(|threshold| i32::try_from(threshold).is_ok() && threshold >= -1);
            if !valid {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_max_inline_peer_notifications".to_string(),
                    message: "member maxInlinePeerNotifications must be an integer >= -1 when set"
                        .to_string(),
                    path: Some(format!(
                        "members[{member_index}].maxInlinePeerNotifications"
                    )),
                });
            }
        }
    }

    diagnostics
}

fn validate_editor_member_identities(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let Some(members) = document.members.as_array() else {
        return Vec::new();
    };
    let mut diagnostics = Vec::new();
    let mut seen = BTreeSet::new();
    for (member_index, member) in members.iter().enumerate() {
        let Some(member_id) = member
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_editor_member_id".to_string(),
                message: "editor members must have a non-empty id".to_string(),
                path: Some(format!("members[{member_index}].id")),
            });
            continue;
        };
        if !seen.insert(member_id.to_string()) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "duplicate_editor_member_id".to_string(),
                message: format!("editor member id '{member_id}' is used more than once"),
                path: Some(format!("members[{member_index}].id")),
            });
        }
    }
    diagnostics
}

fn validate_member_profile_bindings(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let Some(members) = document.members.as_array() else {
        return Vec::new();
    };
    let mut diagnostics = Vec::new();
    for (member_index, member) in members.iter().enumerate() {
        let binding_value = member
            .get("profileBinding")
            .or_else(|| member.get("profile_binding"));
        let realm_profile = member
            .get("realmProfile")
            .or_else(|| member.get("realm_profile"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty());
        let binding = match binding_value {
            Some(value) => match value
                .as_str()
                .map(str::trim)
                .filter(|value| !value.is_empty())
            {
                Some("inline") => "inline",
                Some("realm_profile") => "realm_profile",
                Some(other) => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_profile_binding".to_string(),
                        message: format!(
                            "member profile binding '{other}' is not backed by current MobKit profile_binding semantics"
                        ),
                        path: Some(format!("members[{member_index}].profileBinding")),
                    });
                    ""
                }
                None => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_profile_binding".to_string(),
                        message: "member profile binding must be a non-empty string when present"
                            .to_string(),
                        path: Some(format!("members[{member_index}].profileBinding")),
                    });
                    ""
                }
            },
            None if realm_profile.is_some() => "realm_profile",
            None => {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "missing_profile_binding".to_string(),
                    message: "member profile binding must be explicitly authored".to_string(),
                    path: Some(format!("members[{member_index}].profileBinding")),
                });
                ""
            }
        };
        match member
            .get("runtimeMode")
            .or_else(|| member.get("runtime_mode"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
        {
            Some(runtime_mode) if runtime_mode_values().iter().any(|mode| mode == runtime_mode) => {}
            Some(runtime_mode) => diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_runtime_mode".to_string(),
                message: format!(
                    "member runtime mode '{runtime_mode}' is not backed by current MobKit runtime_mode semantics"
                ),
                path: Some(format!("members[{member_index}].runtimeMode")),
            }),
            None => diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_runtime_mode".to_string(),
                message: "member runtime mode must be explicitly authored".to_string(),
                path: Some(format!("members[{member_index}].runtimeMode")),
            }),
        }
        if binding == "realm_profile" && realm_profile.is_none() {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_realm_profile_ref".to_string(),
                message: "realm_profile members must name the target realm profile".to_string(),
                path: Some(format!("members[{member_index}].realmProfile")),
            });
        }
        if binding == "realm_profile" && realm_profile.is_some() {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "unsupported_realm_profile_pack_binding".to_string(),
                message: "realm_profile members are not deployable in mobpack archives; use an inline profile definition".to_string(),
                path: Some(format!("members[{member_index}].profileBinding")),
            });
        }
        if binding == "inline" && realm_profile.is_some() {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "conflicting_profile_binding".to_string(),
                message: "inline profile members must not also define realmProfile".to_string(),
                path: Some(format!("members[{member_index}].realmProfile")),
            });
        }
    }
    diagnostics
}

fn validate_skill_realms(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    if document.skill_realms.is_null() {
        return Vec::new();
    }
    let Some(realms) = document.skill_realms.as_array() else {
        return vec![MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_skill_realms".to_string(),
            message: "document.skill_realms must be an array".to_string(),
            path: Some("skill_realms".to_string()),
        }];
    };

    let mut diagnostics = Vec::new();
    let mut seen_skill_ids = BTreeMap::<String, String>::new();
    for (realm_index, realm) in realms.iter().enumerate() {
        let realm_path = format!("skill_realms[{realm_index}]");
        let realm_id = realm
            .get("id")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("");
        if realm_id.is_empty() {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_skill_realm_id".to_string(),
                message: "skill realms must have non-empty string ids".to_string(),
                path: Some(format!("{realm_path}.id")),
            });
        }
        if is_starter_skill_marker(realm_id) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_starter_skill_realm_id".to_string(),
                message:
                    "starter skill realms are prototype data and cannot be used in MobKit mobpacks"
                        .to_string(),
                path: Some(format!("{realm_path}.id")),
            });
        }
        if realm
            .get("source")
            .and_then(Value::as_str)
            .map(str::trim)
            .is_some_and(is_starter_skill_marker)
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_starter_skill_realm_source".to_string(),
                message:
                    "starter skill realms are prototype data and cannot be used in MobKit mobpacks"
                        .to_string(),
                path: Some(format!("{realm_path}.source")),
            });
        }
        let Some(skills) = realm.get("skills").and_then(Value::as_array) else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_skill_realm_skills".to_string(),
                message: "skill realm skills must be an array".to_string(),
                path: Some(format!("{realm_path}.skills")),
            });
            continue;
        };
        for (skill_index, skill) in skills.iter().enumerate() {
            let skill_path = format!("{realm_path}.skills[{skill_index}]");
            let Some(skill_id) = skill
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_skill_id".to_string(),
                    message: "skill definitions must have non-empty string ids".to_string(),
                    path: Some(format!("{skill_path}.id")),
                });
                continue;
            };
            if let Some(first_path) =
                seen_skill_ids.insert(skill_id.to_string(), skill_path.clone())
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "duplicate_skill_id".to_string(),
                    message: format!(
                        "skill id '{skill_id}' is defined more than once; first definition is at {first_path}"
                    ),
                    path: Some(format!("{skill_path}.id")),
                });
            }
            if skill
                .get("source")
                .and_then(Value::as_str)
                .map(str::trim)
                .is_some_and(is_starter_skill_marker)
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_starter_skill_source".to_string(),
                    message: "starter skills are prototype data; use inline, path, or imported MobKit skill definitions".to_string(),
                    path: Some(format!("{skill_path}.source")),
                });
            }
            if skill
                .get("origin")
                .and_then(Value::as_str)
                .map(str::trim)
                .is_some_and(is_starter_skill_marker)
            {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_starter_skill_origin".to_string(),
                    message: "starter skills are prototype data; use inline, path, or imported MobKit skill definitions".to_string(),
                    path: Some(format!("{skill_path}.origin")),
                });
            }
        }
    }
    diagnostics
}

fn is_starter_skill_marker(value: &str) -> bool {
    matches!(value.trim(), "starter" | "mobkit/starters")
}

fn is_starter_provenance_diagnostic(code: &str) -> bool {
    matches!(
        code,
        "invalid_starter_skill_realm_id"
            | "invalid_starter_skill_realm_source"
            | "invalid_starter_skill_source"
            | "invalid_starter_skill_origin"
    )
}

fn validate_selected_skill_sources(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    for (skill_id, source) in selected_skill_sources(document) {
        let source_path = format!("skill_realms.skills[{skill_id}].source");
        let source_kind = match source.get("source") {
            Some(Value::String(text)) => text.trim(),
            Some(_) => {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_selected_skill_source".to_string(),
                    message: format!(
                        "selected skill '{skill_id}' source must be 'inline' or 'path'"
                    ),
                    path: Some(source_path),
                });
                continue;
            }
            None => "inline",
        };
        let source_kind = if source_kind.is_empty() {
            "inline"
        } else {
            source_kind
        };
        match source_kind {
            "inline" => {
                let content = source
                    .get("content")
                    .and_then(Value::as_str)
                    .map(str::trim)
                    .unwrap_or("");
                if content.is_empty() {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "empty_selected_inline_skill".to_string(),
                        message: format!(
                            "selected inline skill '{skill_id}' must include non-empty content"
                        ),
                        path: Some(format!("skill_realms.skills[{skill_id}].content")),
                    });
                }
            }
            "path" => {}
            other => diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "invalid_selected_skill_source".to_string(),
                message: format!(
                    "selected skill '{skill_id}' uses unsupported source '{other}'; use inline or path"
                ),
                path: Some(source_path),
            }),
        }
    }
    diagnostics
}

fn validate_selected_path_skill_files(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let mut diagnostics = Vec::new();
    let mut archive_paths = BTreeMap::<String, Vec<u8>>::new();
    for (skill_id, source) in selected_skill_sources(document) {
        let source_kind = source
            .get("source")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or("inline");
        if source_kind != "path" {
            continue;
        }
        let archive_path = packed_skill_archive_path(&skill_id, &source);
        let bytes = if let Some(content) = source.get("content").and_then(Value::as_str) {
            content.as_bytes().to_vec()
        } else {
            let Some(path) = source
                .get("path")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "selected_skill_path_missing".to_string(),
                    message: format!("selected path skill '{skill_id}' has no filesystem path"),
                    path: Some(format!("skill_realms.skills[{skill_id}].path")),
                });
                continue;
            };
            if source
                .get("realm_source")
                .and_then(Value::as_str)
                .map(str::trim)
                != Some("filesystem")
                && !Path::new(path).is_absolute()
            {
                continue;
            }
            match std::fs::read(path) {
                Ok(bytes) => bytes,
                Err(err) => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "selected_skill_file_unreadable".to_string(),
                        message: format!(
                            "selected path skill '{skill_id}' cannot be read from {path}: {err}"
                        ),
                        path: Some(format!("skill_realms.skills[{skill_id}].path")),
                    });
                    continue;
                }
            }
        };
        if let Some(existing) = archive_paths.get(&archive_path) {
            if existing != &bytes {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "selected_skill_archive_path_conflict".to_string(),
                    message: format!(
                        "selected path skill '{skill_id}' resolves to archive path '{archive_path}' with different content"
                    ),
                    path: Some(format!("skill_realms.skills[{skill_id}].path")),
                });
            }
        } else {
            archive_paths.insert(archive_path, bytes);
        }
    }
    diagnostics
}

fn validate_editor_flow_step_tool_references(document: &MobpackDocument) -> Vec<MobpackDiagnostic> {
    let tool_ids = tool_catalog_response()
        .into_iter()
        .filter_map(|tool| {
            tool.get("id")
                .and_then(Value::as_str)
                .map(ToString::to_string)
        })
        .collect::<BTreeSet<_>>();
    let member_tools = editor_member_tools_by_id(document);
    let mut diagnostics = Vec::new();
    collect_editor_flow_step_tool_reference_diagnostics(
        document.flow.get("steps").and_then(Value::as_array),
        "flow.steps",
        &tool_ids,
        &member_tools,
        &mut diagnostics,
    );
    diagnostics
}

fn editor_member_tools_by_id(document: &MobpackDocument) -> BTreeMap<String, BTreeSet<String>> {
    document
        .members
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|member| {
            let id = member
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())?;
            let tools = string_vec(member.get("tools")).into_iter().collect();
            Some((id.to_string(), tools))
        })
        .collect()
}

fn collect_editor_flow_step_tool_reference_diagnostics(
    steps: Option<&Vec<Value>>,
    path: &str,
    tool_ids: &BTreeSet<String>,
    member_tools: &BTreeMap<String, BTreeSet<String>>,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(steps) = steps else {
        return;
    };
    for (index, step) in steps.iter().enumerate() {
        let step_path = format!("{path}[{index}]");
        if step.get("type").and_then(Value::as_str) == Some("member") {
            let member_id = step
                .get("role")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty());
            for field in [
                "allowedTools",
                "allowed_tools",
                "blockedTools",
                "blocked_tools",
            ] {
                if let Some(value) = step.get(field) {
                    validate_editor_flow_step_tool_list(
                        value,
                        &format!("{step_path}.{field}"),
                        member_id,
                        tool_ids,
                        member_tools,
                        field.starts_with("allowed"),
                        diagnostics,
                    );
                }
            }
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_step_tool_reference_diagnostics(
                Some(nested),
                &format!("{step_path}.steps"),
                tool_ids,
                member_tools,
                diagnostics,
            );
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for (branch_index, branch) in branches.iter().enumerate() {
                collect_editor_flow_step_tool_reference_diagnostics(
                    branch.get("steps").and_then(Value::as_array),
                    &format!("{step_path}.branches[{branch_index}].steps"),
                    tool_ids,
                    member_tools,
                    diagnostics,
                );
            }
        }
        collect_editor_flow_step_tool_reference_diagnostics(
            step.get("fallback").and_then(Value::as_array),
            &format!("{step_path}.fallback"),
            tool_ids,
            member_tools,
            diagnostics,
        );
    }
}

fn validate_editor_flow_step_tool_list(
    value: &Value,
    path: &str,
    member_id: Option<&str>,
    tool_ids: &BTreeSet<String>,
    member_tools: &BTreeMap<String, BTreeSet<String>>,
    require_member_tool: bool,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    match value {
        Value::Array(items) => {
            for (index, item) in items.iter().enumerate() {
                let item_path = format!("{path}[{index}]");
                let Some(tool_id) = item
                    .as_str()
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                else {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_step_tool_ref".to_string(),
                        message: "flow step tool references must be non-empty strings".to_string(),
                        path: Some(item_path),
                    });
                    continue;
                };
                validate_editor_flow_step_tool_ref(
                    tool_id,
                    &item_path,
                    member_id,
                    tool_ids,
                    member_tools,
                    require_member_tool,
                    diagnostics,
                );
            }
        }
        Value::String(text) => {
            for tool_id in text
                .split(',')
                .map(str::trim)
                .filter(|value| !value.is_empty())
            {
                validate_editor_flow_step_tool_ref(
                    tool_id,
                    path,
                    member_id,
                    tool_ids,
                    member_tools,
                    require_member_tool,
                    diagnostics,
                );
            }
        }
        Value::Null => {}
        _ => diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_step_tool_ref".to_string(),
            message: "flow step tool references must be an array or comma-separated string"
                .to_string(),
            path: Some(path.to_string()),
        }),
    }
}

fn validate_editor_flow_step_tool_ref(
    tool_id: &str,
    path: &str,
    member_id: Option<&str>,
    tool_ids: &BTreeSet<String>,
    member_tools: &BTreeMap<String, BTreeSet<String>>,
    require_member_tool: bool,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    if !tool_ids.contains(tool_id) {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "unknown_step_tool_ref".to_string(),
            message: format!(
                "flow step references tool '{tool_id}', but it is not present in MobKit tool_catalog"
            ),
            path: Some(path.to_string()),
        });
        return;
    }
    let Some(member_id) = member_id else {
        return;
    };
    if require_member_tool
        && let Some(tools) = member_tools.get(member_id)
        && !tools.contains(tool_id)
    {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "step_tool_not_enabled_on_member".to_string(),
            message: format!(
                "flow step references tool '{tool_id}', but member '{member_id}' has not enabled it"
            ),
            path: Some(path.to_string()),
        });
    }
}

fn skill_ids_from_realms(realms: &Value) -> BTreeSet<String> {
    let mut ids = BTreeSet::new();
    if let Some(realms) = realms.as_array() {
        for realm in realms {
            if let Some(skills) = realm.get("skills").and_then(Value::as_array) {
                ids.extend(skills.iter().filter_map(|skill| {
                    skill
                        .get("id")
                        .and_then(Value::as_str)
                        .map(ToString::to_string)
                }));
            }
        }
    }
    ids
}

fn editor_profile_backend(member: &Value) -> Option<&str> {
    member
        .get("backend")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
}

fn editor_max_inline_peer_notifications(member: &Value) -> Option<i32> {
    let value = member
        .get("maxInlinePeerNotifications")
        .or_else(|| member.get("max_inline_peer_notifications"))?;
    if value.is_null() {
        return None;
    }
    let threshold = value.as_i64()?;
    i32::try_from(threshold).ok()
}

fn validate_mob_settings_match_definition(
    document: &MobpackDocument,
    definition: &MobDefinition,
) -> Vec<MobpackDiagnostic> {
    if document.mob_settings.is_null() {
        return Vec::new();
    }
    let Some(settings) = document.mob_settings.as_object() else {
        return vec![MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_mob_settings".to_string(),
            message: "mob_settings must be an object".to_string(),
            path: Some("mob_settings".to_string()),
        }];
    };
    let mut diagnostics = Vec::new();
    let expected = mob_settings_from_definition(definition);
    let Some(expected) = expected.as_object() else {
        return vec![MobpackDiagnostic {
            severity: "error".to_string(),
            code: "invalid_mob_settings".to_string(),
            message: "mob settings derived from mob.toml must be an object".to_string(),
            path: Some("mob_settings".to_string()),
        }];
    };

    compare_mob_setting_string(
        settings,
        expected,
        "orchestrator",
        "editor_mob_orchestrator_mismatch",
        &mut diagnostics,
    );
    compare_mob_setting_bool(
        settings,
        expected,
        "autoWireOrchestrator",
        "editor_mob_auto_wire_mismatch",
        &mut diagnostics,
    );
    compare_mob_setting_string(
        settings,
        expected,
        "backendDefault",
        "editor_mob_backend_default_mismatch",
        &mut diagnostics,
    );
    compare_mob_setting_string(
        settings,
        expected,
        "externalAddressBase",
        "editor_mob_external_address_base_mismatch",
        &mut diagnostics,
    );
    let editor_wiring = normalized_role_wiring(settings.get("roleWiring"));
    let definition_wiring = normalized_role_wiring(expected.get("roleWiring"));
    if editor_wiring != definition_wiring {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: "editor_mob_role_wiring_mismatch".to_string(),
            message: "editor mob role wiring does not match mob.toml wiring rules".to_string(),
            path: Some("mob_settings.roleWiring".to_string()),
        });
    }
    compare_advanced_mob_settings(settings, expected, &mut diagnostics);
    diagnostics
}

fn compare_advanced_mob_settings(
    settings: &serde_json::Map<String, Value>,
    expected: &serde_json::Map<String, Value>,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let actual_advanced = match settings.get("advanced").filter(|value| !value.is_null()) {
        Some(value) => match value.as_object() {
            Some(object) => Some(object),
            None => {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "invalid_mob_setting_advanced".to_string(),
                    message: "mob_settings.advanced must be an object".to_string(),
                    path: Some("mob_settings.advanced".to_string()),
                });
                return;
            }
        },
        None => None,
    };
    let Some(expected_advanced) = expected.get("advanced").and_then(Value::as_object) else {
        return;
    };
    for (key, code) in [
        ("topology", "editor_mob_topology_mismatch"),
        ("supervisor", "editor_mob_supervisor_mismatch"),
        ("limits", "editor_mob_limits_mismatch"),
        ("spawnPolicy", "editor_mob_spawn_policy_mismatch"),
        ("eventRouter", "editor_mob_event_router_mismatch"),
    ] {
        let actual = actual_advanced.and_then(|advanced| {
            advanced
                .get(key)
                .or_else(|| advanced.get(&camel_to_snake(key)))
        });
        let expected = expected_advanced.get(key).unwrap_or(&Value::Null);
        if actual.unwrap_or(&Value::Null) != expected {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: code.to_string(),
                message: format!("editor mob advanced setting '{key}' does not match mob.toml"),
                path: Some(format!("mob_settings.advanced.{key}")),
            });
        }
    }
}

fn camel_to_snake(key: &str) -> String {
    let mut out = String::new();
    for (index, ch) in key.chars().enumerate() {
        if ch.is_ascii_uppercase() {
            if index > 0 {
                out.push('_');
            }
            out.push(ch.to_ascii_lowercase());
        } else {
            out.push(ch);
        }
    }
    out
}

fn compare_mob_setting_string(
    settings: &serde_json::Map<String, Value>,
    expected: &serde_json::Map<String, Value>,
    key: &str,
    code: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = settings.get(key).filter(|value| !value.is_null()) else {
        return;
    };
    let Some(actual) = value.as_str() else {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: format!("invalid_mob_setting_{key}"),
            message: format!("mob_settings.{key} must be a string"),
            path: Some(format!("mob_settings.{key}")),
        });
        return;
    };
    let expected = expected
        .get(key)
        .and_then(Value::as_str)
        .unwrap_or_default();
    if actual.trim() != expected {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: code.to_string(),
            message: format!("editor mob setting '{key}' does not match mob.toml"),
            path: Some(format!("mob_settings.{key}")),
        });
    }
}

fn compare_mob_setting_bool(
    settings: &serde_json::Map<String, Value>,
    expected: &serde_json::Map<String, Value>,
    key: &str,
    code: &str,
    diagnostics: &mut Vec<MobpackDiagnostic>,
) {
    let Some(value) = settings.get(key).filter(|value| !value.is_null()) else {
        return;
    };
    let Some(actual) = value.as_bool() else {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: format!("invalid_mob_setting_{key}"),
            message: format!("mob_settings.{key} must be a boolean"),
            path: Some(format!("mob_settings.{key}")),
        });
        return;
    };
    let expected = expected
        .get(key)
        .and_then(Value::as_bool)
        .unwrap_or_default();
    if actual != expected {
        diagnostics.push(MobpackDiagnostic {
            severity: "error".to_string(),
            code: code.to_string(),
            message: format!("editor mob setting '{key}' does not match mob.toml"),
            path: Some(format!("mob_settings.{key}")),
        });
    }
}

fn normalized_role_wiring(value: Option<&Value>) -> Option<Vec<(String, String)>> {
    let value = value?;
    let rules = value.as_array()?;
    let mut out = Vec::new();
    for rule in rules {
        let a = rule.get("a").and_then(Value::as_str)?.trim().to_string();
        let b = rule.get("b").and_then(Value::as_str)?.trim().to_string();
        if a.is_empty() || b.is_empty() {
            return None;
        }
        out.push((a, b));
    }
    Some(out)
}

fn validate_editor_projection_matches_definition(
    document: &MobpackDocument,
    definition: &MobDefinition,
) -> Vec<MobpackDiagnostic> {
    let Some(members) = document.members.as_array() else {
        return Vec::new();
    };
    let schemas = editor_schema_map(&document.schemas);
    let profiles = definition
        .profiles
        .iter()
        .map(|(name, binding)| (name.to_string(), binding))
        .collect::<BTreeMap<_, _>>();
    let mut diagnostics = Vec::new();
    let mut seen_profiles = BTreeSet::new();

    for (member_index, member) in members.iter().enumerate() {
        if member
            .get("missing")
            .and_then(Value::as_bool)
            .unwrap_or(false)
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "missing_editor_member_profile".to_string(),
                message: "editor member is marked missing and cannot be exported as a real profile"
                    .to_string(),
                path: Some(format!("members[{member_index}]")),
            });
            continue;
        }
        let profile_name = editor_profile_name(member);
        if !seen_profiles.insert(profile_name.clone()) {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "duplicate_editor_profile".to_string(),
                message: format!("multiple editor members compile to profile '{profile_name}'"),
                path: Some(format!("members[{member_index}]")),
            });
            continue;
        }
        let Some(binding) = profiles.get(&profile_name) else {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_profile_missing_from_mob_toml".to_string(),
                message: format!(
                    "editor member compiles to profile '{profile_name}', but mob.toml has no matching profile"
                ),
                path: Some(format!("members[{member_index}]")),
            });
            continue;
        };
        let ProfileBinding::Inline(profile) = binding else {
            continue;
        };
        if let Some(model) = member
            .get("model")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            && model != profile.model.clone()
        {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_profile_model_mismatch".to_string(),
                message: format!(
                    "editor member model '{model}' does not match profile '{profile_name}' model '{}'",
                    profile.model
                ),
                path: Some(format!("members[{member_index}].model")),
            });
        }
        if let Some(member_tools) = member.get("tools").and_then(Value::as_array) {
            let editor_tools = member_tools
                .iter()
                .filter_map(|tool| tool.as_str().map(str::trim))
                .filter(|tool| !tool.is_empty())
                .map(ToString::to_string)
                .collect::<BTreeSet<_>>();
            let profile_tools = tool_ids_from_config(&profile.tools)
                .into_iter()
                .collect::<BTreeSet<_>>();
            if editor_tools != profile_tools {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "editor_profile_tools_mismatch".to_string(),
                    message: format!(
                        "editor member tools do not match profile '{profile_name}' tools in mob.toml"
                    ),
                    path: Some(format!("members[{member_index}].tools")),
                });
            }
        }
        if let Some(member_skills) = member.get("skills").and_then(Value::as_array) {
            let editor_skills = member_skills
                .iter()
                .filter_map(|skill| skill.as_str().map(str::trim))
                .filter(|skill| !skill.is_empty())
                .map(ToString::to_string)
                .collect::<BTreeSet<_>>();
            let profile_skills = profile
                .skills
                .iter()
                .map(ToString::to_string)
                .collect::<BTreeSet<_>>();
            if editor_skills != profile_skills {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "editor_profile_skills_mismatch".to_string(),
                    message: format!(
                        "editor member skills do not match profile '{profile_name}' skills in mob.toml"
                    ),
                    path: Some(format!("members[{member_index}].skills")),
                });
            }
        }
        let editor_provider_params = member
            .get("providerParams")
            .or_else(|| member.get("provider_params"))
            .filter(|value| !value.is_null());
        match (editor_provider_params, profile.provider_params.as_ref()) {
            (Some(editor), Some(profile_params)) => {
                // Meerkat 0.7 types provider params; compare against the
                // serialized JSON object shape the editor carries.
                let profile_value = serde_json::to_value(profile_params).unwrap_or(Value::Null);
                if *editor != profile_value {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "editor_profile_provider_params_mismatch".to_string(),
                        message: format!(
                            "editor member provider params do not match profile '{profile_name}' provider_params in mob.toml"
                        ),
                        path: Some(format!("members[{member_index}].providerParams")),
                    });
                }
            }
            (Some(_), None) => diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_profile_provider_params_mismatch".to_string(),
                message: format!(
                    "editor member declares provider params, but profile '{profile_name}' has none"
                ),
                path: Some(format!("members[{member_index}].providerParams")),
            }),
            (None, Some(_)) => diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_profile_provider_params_missing".to_string(),
                message: format!(
                    "profile '{profile_name}' has provider_params, but the editor member has none"
                ),
                path: Some(format!("members[{member_index}].providerParams")),
            }),
            (None, None) => {}
        }
        let editor_backend = editor_profile_backend(member);
        let profile_backend = profile.backend.map(meerkat_mob::MobBackendKind::as_str);
        if editor_backend != profile_backend {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_profile_backend_mismatch".to_string(),
                message: format!(
                    "editor member backend does not match profile '{profile_name}' backend in mob.toml"
                ),
                path: Some(format!("members[{member_index}].backend")),
            });
        }
        let editor_max_inline = editor_max_inline_peer_notifications(member);
        if editor_max_inline != profile.max_inline_peer_notifications {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_profile_max_inline_peer_notifications_mismatch".to_string(),
                message: format!(
                    "editor member max inline peer notifications does not match profile '{profile_name}' max_inline_peer_notifications in mob.toml"
                ),
                path: Some(format!(
                    "members[{member_index}].maxInlinePeerNotifications"
                )),
            });
        }
        let editor_schema_id = member
            .get("schema")
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|schema| !schema.is_empty());
        match (editor_schema_id, profile.output_schema.as_ref()) {
            (Some(_), None) => {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "editor_profile_schema_mismatch".to_string(),
                    message: format!(
                        "editor member declares an output schema, but profile '{profile_name}' has no output_schema"
                    ),
                    path: Some(format!("members[{member_index}].schema")),
                });
            }
            (None, Some(_)) => {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "editor_profile_schema_missing".to_string(),
                    message: format!(
                        "profile '{profile_name}' has an output_schema, but the editor member has no schema selected"
                    ),
                    path: Some(format!("members[{member_index}].schema")),
                });
            }
            (Some(schema_id), Some(profile_schema)) => {
                let Some(editor_schema) = schemas.get(schema_id) else {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "editor_schema_missing".to_string(),
                        message: format!(
                            "editor member references schema '{schema_id}', but document.schemas does not define it"
                        ),
                        path: Some(format!("members[{member_index}].schema")),
                    });
                    continue;
                };
                let compiled_schema = editor_schema_to_json_schema(editor_schema);
                if normalize_json_schema_for_compare(&compiled_schema)
                    != normalize_json_schema_for_compare(profile_schema.as_value())
                {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "editor_profile_schema_mismatch".to_string(),
                        message: format!(
                            "editor schema '{schema_id}' does not match profile '{profile_name}' output_schema in mob.toml"
                        ),
                        path: Some(format!("members[{member_index}].schema")),
                    });
                }
            }
            (None, None) => {}
        }
    }

    diagnostics
}

fn validate_editor_flow_members_match_definition(
    document: &MobpackDocument,
    definition: &MobDefinition,
) -> Vec<MobpackDiagnostic> {
    let profile_by_member = editor_profile_by_member_id(document);
    let mut expected = BTreeMap::<(String, String), usize>::new();
    collect_editor_flow_member_turns(
        document.flow.get("steps").and_then(Value::as_array),
        &profile_by_member,
        &mut expected,
    );
    if expected.is_empty() {
        return Vec::new();
    }

    let actual = definition_flow_step_turns(document, definition);
    let mut diagnostics = Vec::new();
    for ((profile, message), expected_count) in expected {
        let actual_count = actual
            .get(&(profile.clone(), message.clone()))
            .copied()
            .unwrap_or_default();
        if actual_count < expected_count {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_flow_step_missing_from_mob_toml".to_string(),
                message: format!(
                    "editor flow member turn for profile '{profile}' is not present in mob.toml"
                ),
                path: Some("flow.steps".to_string()),
            });
        }
    }
    diagnostics
}

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct FlowStepMetadataKey {
    profile: String,
    message: String,
    timeout_ms: Option<u64>,
    allowed_tools: Vec<String>,
    blocked_tools: Vec<String>,
    output_format: String,
    dispatch_mode: String,
    collection_policy: String,
}

fn validate_editor_flow_step_metadata_matches_definition(
    document: &MobpackDocument,
    definition: &MobDefinition,
) -> Vec<MobpackDiagnostic> {
    let profile_by_member = editor_profile_by_member_id(document);
    // meerkat 0.7 parses an omitted step `output_format` as `None` and
    // resolves it schema-aware (`FlowStepSpec::effective_output_format`:
    // json iff `expected_schema_ref`, text otherwise) — for editor re-renders
    // and pinned snapshot mob.toml sources alike, so both sides of the
    // comparison use the same schema-aware rule. (The pre-0.7 snapshot arm
    // assumed "omitted parses as Json", which no longer exists.)
    let schema_by_member = editor_member_has_schema_by_id(document);
    let mut expected = BTreeMap::<FlowStepMetadataKey, usize>::new();
    collect_editor_flow_step_metadata(
        document.flow.get("steps").and_then(Value::as_array),
        &profile_by_member,
        &schema_by_member,
        &mut expected,
    );
    if expected.is_empty() {
        return Vec::new();
    }
    let actual = definition_flow_step_metadata(document, definition);
    let mut diagnostics = Vec::new();
    for (key, expected_count) in expected {
        let actual_count = actual.get(&key).copied().unwrap_or_default();
        if actual_count < expected_count {
            diagnostics.push(MobpackDiagnostic {
                severity: "error".to_string(),
                code: "editor_flow_step_metadata_mismatch".to_string(),
                message: format!(
                    "editor flow member turn metadata for profile '{}' does not match mob.toml",
                    key.profile
                ),
                path: Some("flow.steps".to_string()),
            });
        }
    }
    diagnostics
}

fn collect_editor_flow_step_metadata(
    steps: Option<&Vec<Value>>,
    profile_by_member: &BTreeMap<String, String>,
    schema_by_member: &BTreeMap<String, bool>,
    out: &mut BTreeMap<FlowStepMetadataKey, usize>,
) {
    let Some(steps) = steps else {
        return;
    };
    for step in steps {
        let step_type = step.get("type").and_then(Value::as_str).unwrap_or_default();
        if step_type == "member"
            && let Some(member_id) = step
                .get("role")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            && let Some(profile) = profile_by_member.get(member_id)
            && let Some(message) = editor_step_instruction(step)
        {
            // Unset formats resolve schema-aware, mirroring meerkat 0.7's
            // `FlowStepSpec::effective_output_format()` (json iff the parsed
            // step carries `expected_schema_ref`, text otherwise). The
            // renderer derives `expected_schema_ref` from the step's explicit
            // ref or the member's assigned schema, so the same two sources
            // feed `has_schema` here.
            let has_schema = step
                .get("expectedSchemaRef")
                .or_else(|| step.get("expected_schema_ref"))
                .and_then(Value::as_str)
                .map(str::trim)
                .is_some_and(|value| !value.is_empty())
                || schema_by_member.get(member_id).copied().unwrap_or(false);
            let output_format = editor_output_format(step, has_schema);
            let key = FlowStepMetadataKey {
                profile: profile.clone(),
                message,
                timeout_ms: editor_u64(step, "timeoutMs")
                    .or_else(|| editor_u64(step, "timeout_ms")),
                allowed_tools: editor_string_vec(
                    step.get("allowedTools")
                        .or_else(|| step.get("allowed_tools")),
                ),
                blocked_tools: editor_string_vec(
                    step.get("blockedTools")
                        .or_else(|| step.get("blocked_tools")),
                ),
                output_format,
                dispatch_mode: editor_dispatch_mode(step),
                collection_policy: editor_collection_policy(step),
            };
            *out.entry(key).or_default() += 1;
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_step_metadata(
                Some(nested),
                profile_by_member,
                schema_by_member,
                out,
            );
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                collect_editor_flow_step_metadata(
                    branch.get("steps").and_then(Value::as_array),
                    profile_by_member,
                    schema_by_member,
                    out,
                );
            }
        }
        collect_editor_flow_step_metadata(
            step.get("fallback").and_then(Value::as_array),
            profile_by_member,
            schema_by_member,
            out,
        );
    }
}

fn definition_flow_step_metadata(
    document: &MobpackDocument,
    definition: &MobDefinition,
) -> BTreeMap<FlowStepMetadataKey, usize> {
    let flow_name = document
        .flow
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let primary_flow = flow_name
        .and_then(|name| definition.flows.get_key_value(name))
        .or_else(|| definition.flows.get_key_value("main"))
        .or_else(|| definition.flows.iter().next());
    let mut out = BTreeMap::new();
    if let Some((_, flow)) = primary_flow {
        for step in flow.steps.values() {
            let key = FlowStepMetadataKey {
                profile: step.role.to_string(),
                message: step.message.text_content(),
                timeout_ms: step.timeout_ms,
                allowed_tools: step.allowed_tools.clone().unwrap_or_default(),
                blocked_tools: step.blocked_tools.clone().unwrap_or_default(),
                output_format: step_output_format_string(step.effective_output_format())
                    .to_string(),
                dispatch_mode: dispatch_mode_string(&step.dispatch_mode).to_string(),
                collection_policy: definition_collection_policy_key(&step.collection_policy),
            };
            *out.entry(key).or_default() += 1;
        }
    }
    out
}

fn editor_u64(value: &Value, key: &str) -> Option<u64> {
    value.get(key).and_then(Value::as_u64)
}

fn editor_step_instruction(step: &Value) -> Option<String> {
    step.get("instruction")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}

fn editor_string_vec(value: Option<&Value>) -> Vec<String> {
    match value {
        Some(Value::Array(items)) => items
            .iter()
            .filter_map(Value::as_str)
            .map(str::trim)
            .filter(|item| !item.is_empty())
            .map(ToString::to_string)
            .collect(),
        Some(Value::String(text)) => text
            .split(',')
            .map(str::trim)
            .filter(|item| !item.is_empty())
            .map(ToString::to_string)
            .collect(),
        _ => Vec::new(),
    }
}

fn default_step_output_format(has_schema: bool) -> &'static str {
    if has_schema { "json" } else { "text" }
}

fn editor_output_format(step: &Value, has_schema: bool) -> String {
    explicit_editor_output_format(step)
        .unwrap_or_else(|| default_step_output_format(has_schema).to_string())
}

fn explicit_editor_output_format(step: &Value) -> Option<String> {
    match step
        .get("outputFormat")
        .or_else(|| step.get("output_format"))
        .and_then(Value::as_str)
        .map(str::trim)
    {
        Some("text") => Some("text".to_string()),
        Some("json") => Some("json".to_string()),
        _ => None,
    }
}

fn editor_dispatch_mode(step: &Value) -> String {
    explicit_editor_dispatch_mode(step).unwrap_or_else(|| "fan_out".to_string())
}

fn explicit_editor_dispatch_mode(step: &Value) -> Option<String> {
    match step
        .get("dispatch")
        .or_else(|| step.get("dispatchMode"))
        .or_else(|| step.get("dispatch_mode"))
        .and_then(Value::as_str)
        .map(str::trim)
    {
        Some("one_to_one") => Some("one_to_one".to_string()),
        Some("fan_in") => Some("fan_in".to_string()),
        Some("fan_out") => Some("fan_out".to_string()),
        _ => None,
    }
}

fn editor_collection_policy(step: &Value) -> String {
    required_editor_collection_policy(step).unwrap_or_else(|| "all".to_string())
}

fn explicit_editor_collection_policy_toml(step: &Value) -> Option<String> {
    required_editor_collection_policy(step).map(|policy| collection_policy_toml_from_key(&policy))
}

fn required_editor_collection_policy(step: &Value) -> Option<String> {
    let policy = step
        .get("collection")
        .or_else(|| step.get("collectionPolicy"))
        .or_else(|| step.get("collection_policy"));
    match policy {
        Some(Value::Object(map)) => match map.get("type").and_then(Value::as_str).map(str::trim) {
            Some("any") => Some("any".to_string()),
            Some("all") => Some("all".to_string()),
            Some("quorum") => map
                .get("n")
                .and_then(Value::as_u64)
                .filter(|value| *value > 0)
                .map(|n| format!("quorum:{n}")),
            _ => None,
        },
        Some(Value::String(text)) if text.trim() == "all" => Some("all".to_string()),
        Some(Value::String(text)) if text.trim() == "any" => Some("any".to_string()),
        Some(Value::String(text)) if text.trim() == "quorum" => step
            .get("quorum")
            .or_else(|| step.get("collectionQuorum"))
            .and_then(Value::as_u64)
            .filter(|value| *value > 0)
            .map(|n| format!("quorum:{n}")),
        _ => None,
    }
}

fn definition_collection_policy_key(policy: &CollectionPolicy) -> String {
    match policy {
        CollectionPolicy::All => "all".to_string(),
        CollectionPolicy::Any => "any".to_string(),
        CollectionPolicy::Quorum { n } => format!("quorum:{n}"),
    }
}

fn collect_editor_flow_member_turns(
    steps: Option<&Vec<Value>>,
    profile_by_member: &BTreeMap<String, String>,
    out: &mut BTreeMap<(String, String), usize>,
) {
    let Some(steps) = steps else {
        return;
    };
    for step in steps {
        let step_type = step.get("type").and_then(Value::as_str).unwrap_or_default();
        if step_type == "member"
            && let Some(member_id) = step
                .get("role")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|value| !value.is_empty())
            && let Some(profile) = profile_by_member.get(member_id)
            && let Some(message) = editor_step_instruction(step)
        {
            *out.entry((profile.clone(), message)).or_default() += 1;
        }
        if let Some(nested) = step.get("steps").and_then(Value::as_array) {
            collect_editor_flow_member_turns(Some(nested), profile_by_member, out);
        }
        if let Some(branches) = step.get("branches").and_then(Value::as_array) {
            for branch in branches {
                collect_editor_flow_member_turns(
                    branch.get("steps").and_then(Value::as_array),
                    profile_by_member,
                    out,
                );
            }
        }
        collect_editor_flow_member_turns(
            step.get("fallback").and_then(Value::as_array),
            profile_by_member,
            out,
        );
    }
}

fn definition_flow_step_turns(
    document: &MobpackDocument,
    definition: &MobDefinition,
) -> BTreeMap<(String, String), usize> {
    let flow_name = document
        .flow
        .get("name")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let primary_flow = flow_name
        .and_then(|name| definition.flows.get_key_value(name))
        .or_else(|| definition.flows.get_key_value("main"))
        .or_else(|| definition.flows.iter().next());
    let mut out = BTreeMap::new();
    if let Some((_, flow)) = primary_flow {
        for step in flow.steps.values() {
            *out.entry((step.role.to_string(), step.message.text_content()))
                .or_default() += 1;
        }
    }
    out
}

fn normalize_json_schema_for_compare(value: &Value) -> Value {
    normalize_json_schema_value(value, None)
}

fn normalize_json_schema_value(value: &Value, key: Option<&str>) -> Value {
    match value {
        Value::Array(items) if key == Some("required") => {
            let mut required = items
                .iter()
                .filter_map(Value::as_str)
                .map(ToString::to_string)
                .collect::<Vec<_>>();
            required.sort();
            Value::Array(required.into_iter().map(Value::String).collect())
        }
        Value::Array(items) => Value::Array(
            items
                .iter()
                .map(|item| normalize_json_schema_value(item, None))
                .collect(),
        ),
        Value::Object(map) => Value::Object(
            map.iter()
                .map(|(child_key, child_value)| {
                    (
                        child_key.clone(),
                        normalize_json_schema_value(child_value, Some(child_key)),
                    )
                })
                .collect(),
        ),
        _ => value.clone(),
    }
}

fn expected_schema_files_from_definition(
    definition: &MobDefinition,
) -> Result<BTreeMap<String, Value>, Vec<MobpackDiagnostic>> {
    let mut files = BTreeMap::new();
    let mut diagnostics = Vec::new();

    for (flow_id, flow) in &definition.flows {
        for (step_id, step) in &flow.steps {
            let Some(schema_ref) = step
                .expected_schema_ref
                .as_ref()
                .map(|schema_ref| schema_ref.as_raw().trim().to_string())
                .filter(|value| !value.is_empty())
            else {
                continue;
            };
            let path = format!("flows.{flow_id}.steps.{step_id}.expected_schema_ref");
            let archive_path = match schema_ref_archive_path(&schema_ref) {
                Ok(Some(path)) => path,
                Ok(None) => continue,
                Err(message) => {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "invalid_expected_schema_ref".to_string(),
                        message,
                        path: Some(path),
                    });
                    continue;
                }
            };
            let Some(schema) = profile_output_schema_for_step(definition, step) else {
                diagnostics.push(MobpackDiagnostic {
                    severity: "error".to_string(),
                    code: "expected_schema_ref_missing_profile_schema".to_string(),
                    message: format!(
                        "step '{step_id}' references '{schema_ref}', but profile '{}' has no inline output_schema to export",
                        step.role
                    ),
                    path: Some(path),
                });
                continue;
            };
            if let Some(existing) = files.get(&archive_path) {
                if existing != schema {
                    diagnostics.push(MobpackDiagnostic {
                        severity: "error".to_string(),
                        code: "expected_schema_ref_conflict".to_string(),
                        message: format!(
                            "multiple steps write different output schemas to '{archive_path}'"
                        ),
                        path: Some(path),
                    });
                }
            } else {
                files.insert(archive_path, schema.clone());
            }
        }
    }

    if diagnostics.is_empty() {
        Ok(files)
    } else {
        Err(diagnostics)
    }
}

fn schema_ref_archive_path(schema_ref: &str) -> Result<Option<String>, String> {
    if serde_json::from_str::<Value>(schema_ref).is_ok() {
        return Ok(None);
    }
    let path = Path::new(schema_ref);
    if schema_ref.is_empty() {
        return Err("expected_schema_ref must not be empty".to_string());
    }
    if schema_ref.contains('\\') {
        return Err(format!(
            "expected_schema_ref '{schema_ref}' must use forward-slash archive paths"
        ));
    }
    if path.is_absolute() {
        return Err(format!(
            "expected_schema_ref '{schema_ref}' must be a relative mobpack path"
        ));
    }
    if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
        return Err(format!(
            "expected_schema_ref '{schema_ref}' must be inline JSON or a relative .json path"
        ));
    }
    let mut parts = Vec::new();
    for component in path.components() {
        match component {
            Component::Normal(part) => {
                let part = part.to_string_lossy();
                if part.is_empty() {
                    return Err(format!(
                        "expected_schema_ref '{schema_ref}' contains an empty path segment"
                    ));
                }
                parts.push(part.to_string());
            }
            Component::CurDir
            | Component::ParentDir
            | Component::RootDir
            | Component::Prefix(_) => {
                return Err(format!(
                    "expected_schema_ref '{schema_ref}' must stay within the mobpack archive"
                ));
            }
        }
    }
    if parts.is_empty() {
        return Err("expected_schema_ref must not be empty".to_string());
    }
    Ok(Some(parts.join("/")))
}

fn profile_output_schema_for_step<'a>(
    definition: &'a MobDefinition,
    step: &FlowStepSpec,
) -> Option<&'a Value> {
    match definition.profiles.get(&step.role) {
        Some(ProfileBinding::Inline(profile)) => profile
            .output_schema
            .as_ref()
            .map(meerkat_core::MeerkatSchema::as_value),
        Some(ProfileBinding::RealmRef { .. }) | None => None,
    }
}

fn editor_schema_map(schemas: &Value) -> BTreeMap<String, Value> {
    schemas
        .as_array()
        .into_iter()
        .flatten()
        .filter_map(|schema| {
            schema
                .get("id")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|id| !id.is_empty())
                .map(|id| (id.to_string(), schema.clone()))
        })
        .collect()
}

fn editor_schema_to_json_schema(schema: &Value) -> Value {
    let mut properties = serde_json::Map::new();
    let mut required = Vec::new();
    if let Some(fields) = schema.get("fields").and_then(Value::as_array) {
        for field in fields {
            let name = field
                .get("name")
                .and_then(Value::as_str)
                .unwrap_or_default()
                .to_string();
            let enum_values = field
                .get("enumValues")
                .and_then(Value::as_array)
                .filter(|_| field.get("type").and_then(Value::as_str) == Some("enum"))
                .map(|items| {
                    items
                        .iter()
                        .filter_map(Value::as_str)
                        .map(|value| Value::String(value.to_string()))
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();
            let mut field_schema = serde_json::Map::new();
            if enum_values.is_empty() {
                let field_type = field.get("type").and_then(Value::as_str);
                field_schema.insert(
                    "type".to_string(),
                    Value::String(editor_schema_field_type(field_type)),
                );
                if let Some(item_type) = editor_schema_array_item_type(field_type) {
                    field_schema.insert(
                        "items".to_string(),
                        json!({
                            "type": item_type,
                        }),
                    );
                }
            } else {
                field_schema.insert("type".to_string(), Value::String("string".to_string()));
                field_schema.insert("enum".to_string(), Value::Array(enum_values));
            }
            if let Some(description) = field
                .get("description")
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|description| !description.is_empty())
            {
                field_schema.insert(
                    "description".to_string(),
                    Value::String(description.to_string()),
                );
            }
            properties.insert(name.clone(), Value::Object(field_schema));
            if field
                .get("required")
                .and_then(Value::as_bool)
                .unwrap_or(false)
            {
                required.push(Value::String(name));
            }
        }
    }
    let mut out = serde_json::Map::new();
    out.insert("type".to_string(), Value::String("object".to_string()));
    if let Some(description) = schema
        .get("description")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|description| !description.is_empty())
    {
        out.insert(
            "description".to_string(),
            Value::String(description.to_string()),
        );
    }
    out.insert("properties".to_string(), Value::Object(properties));
    out.insert("required".to_string(), Value::Array(required));
    out.insert("additionalProperties".to_string(), Value::Bool(false));
    Value::Object(out)
}

fn editor_schema_field_type(field_type: Option<&str>) -> String {
    match field_type.unwrap_or("string") {
        "int" | "integer" => "integer".to_string(),
        "number" | "float" => "number".to_string(),
        "bool" | "boolean" => "boolean".to_string(),
        "object" => "object".to_string(),
        value if value.ends_with("[]") => "array".to_string(),
        "bytes" => "string".to_string(),
        _ => "string".to_string(),
    }
}

fn editor_schema_array_item_type(field_type: Option<&str>) -> Option<String> {
    let field_type = field_type?;
    let item_type = field_type.strip_suffix("[]")?;
    Some(editor_schema_field_type(Some(item_type)))
}

fn editor_profile_name(member: &Value) -> String {
    let raw = member
        .get("name")
        .or_else(|| member.get("role"))
        .or_else(|| member.get("id"))
        .and_then(Value::as_str)
        .unwrap_or("member");
    let cleaned = raw
        .chars()
        .filter_map(|ch| {
            let ch = ch.to_ascii_lowercase();
            (ch.is_ascii_alphanumeric() || ch == '_' || ch == ' ' || ch == '-').then_some(ch)
        })
        .collect::<String>();
    let out = cleaned
        .split(|ch: char| ch.is_ascii_whitespace() || ch == '-')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("_")
        .trim_matches('_')
        .to_string();
    if out.is_empty() {
        "member".to_string()
    } else {
        out
    }
}

fn skill_ids_from_document_definition(document: &MobpackDocument) -> BTreeSet<String> {
    let Some(definition) = definition_from_document_mob_toml(document) else {
        return BTreeSet::new();
    };
    definition.skills.keys().map(ToString::to_string).collect()
}

fn definition_from_document_mob_toml(document: &MobpackDocument) -> Option<MobDefinition> {
    let mob_toml = document
        .mob_toml
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())?;
    MobDefinition::from_toml(mob_toml).ok()
}

fn sanitize_slug(input: &str) -> Option<String> {
    let slug = input
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() {
                ch.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect::<String>()
        .split('-')
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join("-");
    (!slug.is_empty()).then_some(slug)
}

fn document_export_filename(document: &MobpackDocument) -> String {
    let slug = sanitize_slug(
        document
            .name
            .trim()
            .strip_suffix(".mobpack")
            .unwrap_or_else(|| document.name.trim()),
    )
    .or_else(|| sanitize_slug(document.mob_id.trim()))
    .unwrap_or_else(|| "mobpack".to_string());
    format!("{slug}.mobpack")
}

fn extract_manifest_name(text: &str) -> Option<String> {
    let mut in_mobpack = false;
    for line in text.lines().map(str::trim) {
        if line.starts_with('[') {
            in_mobpack = line == "[mobpack]";
            continue;
        }
        if in_mobpack
            && let Some((_, raw_value)) = line
                .strip_prefix("name")
                .and_then(|rest| rest.split_once('='))
        {
            return Some(raw_value.trim().trim_matches('"').to_string());
        }
    }
    None
}

/// Escape a value for a TOML basic (double-quoted, single-line) string.
/// Beyond backslash and quote, every control character must escape too:
/// TOML basic strings reject raw newlines/controls, and free-text feeds
/// such as an adaptive layer's multi-line `objectiveClass` (or a document
/// name) flow through here verbatim — an unescaped `\n` would emit a
/// manifest.toml that meerkat's pack parser rejects outright.
fn escape_toml_string(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\u{0008}' => out.push_str("\\b"),
            '\t' => out.push_str("\\t"),
            '\n' => out.push_str("\\n"),
            '\u{000C}' => out.push_str("\\f"),
            '\r' => out.push_str("\\r"),
            ch if (ch as u32) < 0x20 || ch == '\u{007F}' => {
                out.push_str(&format!("\\u{:04X}", ch as u32));
            }
            ch => out.push(ch),
        }
    }
    out
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
    use super::*;

    fn valid_document() -> MobpackDocument {
        MobpackDocument {
            schema_version: MOBPACK_SCHEMA_VERSION.to_string(),
            mob_id: "review-pack".to_string(),
            name: "Review Pack".to_string(),
            mob_settings: Value::Null,
            members: Value::Null,
            instances: Value::Null,
            edges: Value::Null,
            frames: Value::Null,
            schemas: Value::Null,
            skill_realms: Value::Null,
            flow: Value::Null,
            launch_modes: Value::Null,
            deploy: Value::Null,
            mob_toml: Some(
                r#"
[mob]
id = "review-pack"

[profiles.planner]
model = "gpt-5.5"
skills = []
peer_description = "Planner"

[profiles.planner.tools]
comms = true

[flows.review]
description = "Review flow"

[flows.review.steps.plan]
role = "planner"
message = "Plan the work"
"#
                .to_string(),
            ),
        }
    }

    fn author_inline_member_contracts(document: &mut MobpackDocument) {
        let Some(members) = document.members.as_array_mut() else {
            return;
        };
        for member in members {
            if member.get("profileBinding").is_none() {
                member["profileBinding"] = json!("inline");
            }
            if member.get("runtimeMode").is_none() {
                member["runtimeMode"] = json!("turn_driven");
            }
        }
    }

    fn document_with_member_catalog_refs(
        tools: Value,
        skills: Value,
        skill_realms: Value,
    ) -> MobpackDocument {
        let mut document = valid_document();
        document.members = json!([{
            "id": "m_planner",
            "name": "planner",
            "role": "planner",
            "model": "gpt-5.5",
            "tools": tools,
            "skills": skills
        }]);
        author_inline_member_contracts(&mut document);
        document.skill_realms = skill_realms;
        document.mob_toml = Some(
            r#"
[mob]
id = "review-pack"

[profiles.planner]
model = "gpt-5.5"
skills = ["mob.workpad"]
peer_description = "Planner"

[profiles.planner.tools]
builtins = true
comms = true

[skills."mob.workpad"]
source = "inline"
content = "Maintain the shared mob workpad."

[flows.review]
description = "Review flow"

[flows.review.steps.plan]
role = "planner"
message = "Plan the work"
"#
            .to_string(),
        );
        document
    }

    fn document_with_unused_reviewer_profile() -> MobpackDocument {
        let mut document = valid_document();
        document.members = json!([
            {
                "id": "m_planner",
                "name": "planner",
                "role": "planner",
                "model": "gpt-5.5",
                "tools": ["comms"],
                "skills": []
            },
            {
                "id": "m_reviewer",
                "name": "reviewer",
                "role": "reviewer",
                "model": "gpt-5.5",
                "tools": ["builtins", "shell"],
                "skills": []
            }
        ]);
        author_inline_member_contracts(&mut document);
        document.mob_toml = Some(
            r#"
[mob]
id = "review-pack"

[profiles.planner]
model = "gpt-5.5"
skills = []
peer_description = "Planner"

[profiles.planner.tools]
comms = true

[profiles.reviewer]
model = "gpt-5.5"
skills = []
peer_description = "Reviewer"

[profiles.reviewer.tools]
builtins = true
shell = true

[flows.review]
description = "Review flow"

[flows.review.steps.plan]
role = "planner"
message = "Plan the work"
"#
            .to_string(),
        );
        document
    }

    fn document_with_reviewer_output_schema() -> MobpackDocument {
        let mut document = document_with_unused_reviewer_profile();
        if let Some(members) = document.members.as_array_mut() {
            members[1]["schema"] = json!("ReviewArtifact");
        }
        document.schemas = json!([{
            "id": "ReviewArtifact",
            "description": "Review result",
            "fields": [{
                "id": "f1",
                "name": "verdict",
                "type": "enum",
                "required": true,
                "description": "Pass/fail.",
                "enumValues": ["green", "red"]
            }]
        }]);
        document.mob_toml = Some(
            r#"
[mob]
id = "review-pack"

[profiles.planner]
model = "gpt-5.5"
skills = []
peer_description = "Planner"

[profiles.planner.tools]
comms = true

[profiles.reviewer]
model = "gpt-5.5"
skills = []
peer_description = "Reviewer"

[profiles.reviewer.tools]
builtins = true
shell = true

[profiles.reviewer.output_schema]
type = "object"
description = "Review result"
required = ["verdict"]
additionalProperties = false

[profiles.reviewer.output_schema.properties]

[profiles.reviewer.output_schema.properties.verdict]
type = "string"
description = "Pass/fail."
enum = ["green", "red"]

[flows.review]
description = "Review flow"

[flows.review.steps.plan]
role = "planner"
message = "Plan the work"
"#
            .to_string(),
        );
        document
    }

    fn document_with_expected_schema_ref() -> MobpackDocument {
        let mut document = document_with_reviewer_output_schema();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "size: number", "inputParams": [
                    { "id": "p1", "name": "size", "type": "number", "required": true, "enumValues": [] }
                ] },
                { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" },
                { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review", "schema": "ReviewArtifact", "expectedSchemaRef": "schemas/reviewer.json" }
            ]
        });
        document.instances = json!([
            { "id": "plan", "memberId": "m_planner", "col": 0, "row": 0, "launchMode": { "kind": "Fresh" } },
            { "id": "review", "memberId": "m_reviewer", "col": 1, "row": 0, "launchMode": { "kind": "Fresh" } }
        ]);
        document.edges = json!([
            { "id": "e1", "from": "plan", "to": "review", "kind": "next", "label": "" }
        ]);
        document.frames = json!([]);
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "planner",
                "launch_mode": { "kind": "Fresh" }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fresh" }
            }
        ]);
        document.mob_toml = Some(
            r#"
[mob]
id = "review-pack"

[profiles.planner]
model = "gpt-5.5"
skills = []
peer_description = "Planner"

[profiles.planner.tools]
comms = true

[profiles.reviewer]
model = "gpt-5.5"
skills = []
peer_description = "Reviewer"

[profiles.reviewer.tools]
builtins = true
shell = true

[profiles.reviewer.output_schema]
type = "object"
description = "Review result"
required = ["verdict"]
additionalProperties = false

[profiles.reviewer.output_schema.properties]

[profiles.reviewer.output_schema.properties.verdict]
type = "string"
description = "Pass/fail."
enum = ["green", "red"]

[flows.review]
description = "Review flow"

[flows.review.steps.plan]
role = "planner"
message = "Plan"

[flows.review.steps.review]
role = "reviewer"
message = "Review"
depends_on = ["plan"]
expected_schema_ref = "schemas/reviewer.json"
"#
            .to_string(),
        );
        document
    }

    fn document_with_real_launch_modes() -> MobpackDocument {
        let mut document = document_with_unused_reviewer_profile();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" },
                { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review",
                  "launchMode": { "kind": "Fork", "from": "plan", "context": "FullHistory" } }
            ]
        });
        document.instances = json!([
            { "id": "plan", "memberId": "m_planner", "col": 0, "row": 0, "launchMode": { "kind": "Fresh" } },
            { "id": "review", "memberId": "m_reviewer", "col": 1, "row": 0, "launchMode": { "kind": "Fork", "from": "plan", "context": "FullHistory" } }
        ]);
        document.edges = json!([
            { "id": "e1", "from": "plan", "to": "review", "kind": "next", "label": "" }
        ]);
        document.frames = json!([]);
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "planner",
                "launch_mode": { "kind": "Fresh" }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fork", "from": "plan", "context": "FullHistory" }
            }
        ]);
        document.mob_toml = Some(
            r#"
[mob]
id = "review-pack"

[profiles.planner]
model = "gpt-5.5"
skills = []
peer_description = "Planner"

[profiles.planner.tools]
comms = true

[profiles.reviewer]
model = "gpt-5.5"
skills = []
peer_description = "Reviewer"

[profiles.reviewer.tools]
builtins = true
shell = true

[flows.review]
description = "Review flow"

[flows.review.steps.plan]
role = "planner"
message = "Plan"

[flows.review.steps.review]
role = "reviewer"
message = "Review"
depends_on = ["plan"]
"#
            .to_string(),
        );
        document
    }

    fn document_with_parallel_graph_controls() -> MobpackDocument {
        let mut document = document_with_real_launch_modes();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                {
                    "id": "parallel_review",
                    "type": "parallel",
                    "dispatch": "fan_out",
                    "collection": "all",
                    "branches": [
                        {
                            "id": "br_plan",
                            "label": "Plan",
                            "steps": [
                                { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }
                            ]
                        },
                        {
                            "id": "br_review",
                            "label": "Review",
                            "steps": [
                                { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
                            ]
                        }
                    ],
                    "dependsMode": "all"
                }
            ]
        });
        document.instances = json!([
            { "id": "g_parallel_parallel_review", "isGate": true, "gateKind": "fork", "label": "fan_out", "col": 0, "row": 1 },
            { "id": "plan", "memberId": "m_planner", "col": 1, "row": 1, "launchMode": { "kind": "Fresh" } },
            { "id": "review", "memberId": "m_reviewer", "col": 1, "row": 2, "launchMode": { "kind": "Fresh" } },
            { "id": "j_parallel_parallel_review", "isGate": true, "gateKind": "join", "label": "join · all", "col": 2, "row": 1 }
        ]);
        document.edges = json!([
            { "id": "e1", "from": "g_parallel_parallel_review", "to": "plan", "kind": "fanout", "label": "" },
            { "id": "e2", "from": "g_parallel_parallel_review", "to": "review", "kind": "fanout", "label": "" },
            { "id": "e3", "from": "plan", "to": "j_parallel_parallel_review", "kind": "next", "label": "" },
            { "id": "e4", "from": "review", "to": "j_parallel_parallel_review", "kind": "next", "label": "" }
        ]);
        document.frames = json!([
            { "id": "frame_parallel_parallel_review", "kind": "Parallel", "colStart": 0, "colEnd": 2, "label": "PARALLEL · fan_out · join all" }
        ]);
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "planner",
                "launch_mode": { "kind": "Fresh" }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fresh" }
            }
        ]);
        document
    }

    fn document_with_branch_graph_controls() -> MobpackDocument {
        let mut document = document_with_real_launch_modes();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Route review", "fields": "route: enum" },
                {
                    "id": "branch_route",
                    "type": "branch",
                    "controllerRole": "m_reviewer",
                    "branches": [
                        {
                            "id": "br_plan",
                            "label": "Plan",
                            "condition": "route == plan",
                            "cond": { "field": "route", "namespace": "params", "op": "==", "val": "plan" },
                            "steps": [
                                { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }
                            ]
                        }
                    ],
                    "fallback": [
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
                    ]
                }
            ]
        });
        document.instances = json!([
            { "id": "g_branch_branch_route", "isGate": true, "gateKind": "branch", "label": "branch", "col": 0, "row": 1 },
            { "id": "plan", "memberId": "m_planner", "col": 1, "row": 1, "launchMode": { "kind": "Fresh" } },
            { "id": "review", "memberId": "m_reviewer", "col": 1, "row": 2, "launchMode": { "kind": "Fresh" } },
            { "id": "j_branch_branch_route", "isGate": true, "gateKind": "join", "label": "join · branch paths", "collection": "any", "controllerRole": "m_reviewer", "col": 2, "row": 1 }
        ]);
        document.edges = json!([
            { "id": "e1", "from": "g_branch_branch_route", "to": "plan", "kind": "cond", "label": "route == plan", "cond": { "var": "params.route", "op": "==", "val": "plan" } },
            { "id": "e2", "from": "g_branch_branch_route", "to": "review", "kind": "next", "label": "fallback" },
            { "id": "e3", "from": "plan", "to": "j_branch_branch_route", "kind": "next", "label": "" },
            { "id": "e4", "from": "review", "to": "j_branch_branch_route", "kind": "next", "label": "" }
        ]);
        document.frames = json!([
            { "id": "frame_branch_branch_route", "kind": "Branch", "colStart": 0, "colEnd": 2, "label": "BRANCH · 2 paths" }
        ]);
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "planner",
                "launch_mode": { "kind": "Fresh" }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fresh" }
            }
        ]);
        document
    }

    fn importable_mob_toml() -> String {
        // meerkat 0.7 validates profile models fail-fast (catalogued,
        // [models.<id>]-defined, or provider-annotated); fixtures use the
        // catalogued gpt-5.5.
        r#"
[mob]
id = "importable-real-mob"
orchestrator = "planner"

[wiring]
auto_wire_orchestrator = true

[[wiring.role_wiring]]
a = "planner"
b = "reviewer"

[backend]
default = "external"

[backend.external]
address_base = "http://127.0.0.1:9000"

[topology]
mode = "strict"
rules = [{ from_role = "planner", to_role = "reviewer", allowed = true }]

[supervisor]
role = "planner"
escalation_threshold = 2

[limits]
max_flow_duration_ms = 30000
max_step_retries = 1
max_orphaned_turns = 8
cancel_grace_timeout_ms = 500
max_active_nodes = 4
max_active_frames = 2
max_frame_depth = 3

[spawn_policy]
mode = "auto"

[spawn_policy.profile_map]
reviewer = "reviewer"

[event_router]
buffer_size = 128
include_patterns = ["text_complete"]
exclude_patterns = ["debug_*"]

[profiles.planner]
model = "gpt-5.5"
skills = ["mob.workpad"]
peer_description = "Plan the work"
backend = "session"
runtime_mode = "autonomous_host"
max_inline_peer_notifications = 20
# meerkat 0.7: provider_params is the typed ProviderParamsOverride
# (deny_unknown_fields); legacy keys like thinking_budget/top_k are invalid.
provider_params = { thinking_budget_tokens = 8192, top_p = 0.5 }

[profiles.planner.tools]
builtins = true
comms = true
mob = true

[profiles.reviewer]
model = "gpt-5.5"
skills = ["mob.review"]
peer_description = "Review the work"
runtime_mode = "turn_driven"

[profiles.reviewer.tools]
builtins = true
shell = true
comms = true

[profiles.reviewer.output_schema]
type = "object"
description = "Review verdict"
required = ["verdict"]
additionalProperties = false

[profiles.reviewer.output_schema.properties]

[profiles.reviewer.output_schema.properties.verdict]
type = "string"
description = "Gate verdict"
enum = ["red", "green"]

[skills."mob.workpad"]
source = "inline"
content = "Maintain the shared mob workpad."

[skills."mob.review"]
source = "path"
path = "skills/review.md"

[flows.main]
description = "Imported flow"

[flows.main.steps.plan]
role = "planner"
message = "Plan the work"
dispatch_mode = "one_to_one"
collection_policy = { type = "quorum", n = 1 }
timeout_ms = 1500
allowed_tools = ["mob"]
blocked_tools = ["shell"]
output_format = "text"

[flows.main.steps.review]
role = "reviewer"
message = "Review the work"
depends_on = ["plan"]
expected_schema_ref = "schemas/reviewer.json"

[flows.main.root.nodes.node_plan]
kind = "step"
step_id = "plan"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_review_loop]
kind = "repeat_until"
loop_id = "review_loop"
depends_on = ["node_plan"]
depends_on_mode = "all"
until = { op = "eq", path = "steps.review.verdict", value = "green" }
max_iterations = 3

[flows.main.root.nodes.node_review_loop.body.nodes.node_review]
kind = "step"
step_id = "review"
depends_on = []
depends_on_mode = "all"
"#
        .to_string()
    }

    #[test]
    fn validates_parseable_mobpack_document() {
        let from_env = std::env::var("MOBKIT_MOBPACK_VALIDATE_IN").ok();
        let document = match from_env.as_deref() {
            Some(path) => {
                let params = serde_json::from_str::<Value>(
                    &std::fs::read_to_string(path).expect("read editor mobpack validation fixture"),
                )
                .expect("parse editor mobpack validation fixture");
                document_from_params(&params).expect("document from validation params")
            }
            None => valid_document(),
        };
        let result = validate_document(&document);
        assert_eq!(result.validation_source, MOBPACK_VALIDATION_SOURCE);
        assert_eq!(
            result.deploy_command,
            "rkat mob run <pack.mobpack> --prompt <prompt>"
        );
        if from_env.is_none() {
            assert!(result.ok, "{:?}", result.diagnostics);
            assert_eq!(result.mob_id.as_deref(), Some("review-pack"));
            assert_eq!(result.flow_ids, vec!["review".to_string()]);
            assert!(result.display_rows.iter().any(|row| {
                row.kind == "ok"
                    && row.head == "MobKit mobpack validates"
                    && row.sub == MOBPACK_VALIDATION_SOURCE
                    && row.meta == "meerkat_mob::validate_definition"
            }));
        }
        if let Ok(path) = std::env::var("MOBKIT_MOBPACK_VALIDATE_OUT") {
            std::fs::write(path, serde_json::to_vec_pretty(&result).unwrap())
                .expect("write validation result");
        }
    }

    #[test]
    fn rejects_blank_draft_without_synthetic_profiles() {
        let document = MobpackDocument {
            schema_version: MOBPACK_SCHEMA_VERSION.to_string(),
            mob_id: "blank-real-mob".to_string(),
            name: "blank-real-mob".to_string(),
            mob_settings: json!({}),
            members: json!([]),
            instances: json!([]),
            edges: json!([]),
            frames: json!([]),
            schemas: json!([]),
            skill_realms: json!([]),
            flow: json!({
                "name": "blank-real-mob",
                "steps": [
                    { "id": "input_1", "type": "input", "task": "", "fields": "", "inputParams": [] }
                ]
            }),
            launch_modes: json!([]),
            deploy: json!({
                "command": "rkat mob run",
                "surface": "cli",
                "trust_policy": "permissive",
                "realm_backend": "jsonl",
                "prompt": "Review the draft."
            }),
            mob_toml: Some(
                r#"
[mob]
id = "blank_real_mob"

[flows.main]
description = "Generated by MobKit Flow Editor"
"#
                .to_string(),
            ),
        };

        let result = validate_document(&document);

        assert!(!result.ok);
        assert_eq!(result.mob_id.as_deref(), Some("blank_real_mob"));
        assert!(result.flow_ids.contains(&"main".to_string()));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "empty_profiles" && diagnostic.path.as_deref() == Some("profiles")
        }));
        assert!(!result.diagnostics.iter().any(|diagnostic| {
            diagnostic.message.contains("lead") || diagnostic.message.contains("placeholder")
        }));
    }

    #[test]
    fn validates_launch_mode_metadata_shape() {
        let mut document = valid_document();
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "launch_mode": { "kind": "Fork", "from": "coder", "context": "FullHistory" },
                "budget_split_policy": { "type": "fixed", "value": 4096 }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "launch_mode": { "kind": "Resume", "sessionId": "session-123" },
                "budget_split_policy": { "kind": "Remaining" }
            }
        ]);
        assert!(validate_document(&document).ok);

        document.launch_modes = json!([
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "launch_mode": { "kind": "Fork" }
            }
        ]);
        let result = validate_document(&document);
        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "fork_launch_missing_source")
        );

        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "launch_mode": { "kind": "Fresh" },
                "budget_split_policy": { "type": "fixed", "value": 0 }
            }
        ]);
        let result = validate_document(&document);
        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_budget_split_policy")
        );

        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "launch_mode": { "kind": "Fresh" },
                "budget_split_policy": {}
            }
        ]);
        let result = validate_document(&document);
        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_budget_split_policy"
                && diagnostic.message.contains("non-empty type")
        }));
    }

    #[test]
    fn graph_launch_projection_omits_absent_budget_split_policy() {
        let instances = vec![
            json!({
                "id": "plan",
                "memberId": "m_planner",
                "launchMode": { "kind": "Fresh" }
            }),
            json!({
                "id": "review",
                "memberId": "m_reviewer",
                "launchMode": {
                    "kind": "Fork",
                    "from": "plan",
                    "context": "FullHistory",
                    "budgetSplitPolicy": { "kind": "Fixed", "limit": 2048 }
                }
            }),
        ];
        let launch_modes = launch_modes_from_instances(&instances);
        let rows = launch_modes.as_array().expect("launch mode rows");

        assert_eq!(rows[0]["launch_mode"], json!({ "kind": "Fresh" }));
        assert!(
            rows[0].get("budget_split_policy").is_none(),
            "absent graph budget policy must not become equal"
        );
        assert_eq!(
            rows[1]["budget_split_policy"],
            json!({ "type": "fixed", "value": 2048 })
        );
    }

    #[test]
    fn validates_launch_modes_against_editor_steps_and_members() {
        let document = document_with_real_launch_modes();
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_launch_mode_entry_drift_from_flow_step() {
        let mut document = document_with_real_launch_modes();
        let launch_modes = document
            .launch_modes
            .as_array_mut()
            .expect("launch mode entries");
        launch_modes[1]["launch_mode"] = json!({ "kind": "Fresh" });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "launch_mode_flow_mismatch"
                && diagnostic.path.as_deref() == Some("launch_modes[1].launch_mode")
        }));
    }

    #[test]
    fn rejects_duplicate_launch_mode_steps() {
        let mut document = document_with_real_launch_modes();
        let launch_modes = document
            .launch_modes
            .as_array_mut()
            .expect("launch mode entries");
        launch_modes.push(json!({
            "step_id": "review",
            "member_id": "m_reviewer",
            "profile": "reviewer",
            "launch_mode": { "kind": "Fork", "from": "plan", "context": "FullHistory" }
        }));
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "duplicate_launch_step"
                && diagnostic.path.as_deref() == Some("launch_modes[2].step_id")
        }));
    }

    #[test]
    fn rejects_launch_budget_entry_drift_from_flow_step() {
        let mut document = document_with_real_launch_modes();
        document.flow["steps"][2]["launchMode"]["budgetSplitPolicy"] = json!({ "type": "equal" });
        let launch_modes = document
            .launch_modes
            .as_array_mut()
            .expect("launch mode entries");
        launch_modes[1]["budget_split_policy"] = json!({ "type": "fixed", "value": 4096 });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "launch_mode_flow_mismatch"
                && diagnostic.path.as_deref() == Some("launch_modes[1].launch_mode")
        }));
    }

    #[test]
    fn rejects_editor_flow_primitives_without_mobkit_semantics() {
        let mut document = valid_document();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                { "id": "wait_1", "type": "wait", "prompt": "Approve deploy" },
                { "id": "submob_1", "type": "subagent", "name": "nested review", "steps": [] }
            ]
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_editor_flow_step_type"
                && diagnostic.path.as_deref() == Some("flow.steps[1].type")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_editor_flow_step_type"
                && diagnostic.path.as_deref() == Some("flow.steps[2].type")
        }));
    }

    #[test]
    fn rejects_missing_or_duplicate_editor_flow_step_ids() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[0].as_object_mut().unwrap().remove("id");
        steps[1]["branches"][1]["steps"][0]["id"] = json!("plan");

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_editor_flow_step_id"
                && diagnostic.path.as_deref() == Some("flow.steps[0].id")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "duplicate_editor_flow_step_id"
                && diagnostic.path.as_deref() == Some("flow.steps[1].branches[1].steps[0].id")
        }));
    }

    #[test]
    fn refuses_to_export_unsupported_editor_flow_primitives() {
        let mut document = valid_document();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                { "id": "wait_1", "type": "wait", "prompt": "Approve deploy" }
            ]
        });
        let err = export_mobpack(&json!({ "document": document })).expect_err("export fails");

        assert_eq!(err, "cannot export invalid mobpack document");
    }

    #[test]
    fn validates_supported_editor_condition_operators() {
        let mut document = valid_document();
        document.members = json!([{
            "id": "m_reviewer",
            "name": "reviewer",
            "role": "reviewer",
            "model": "gpt-5.5",
            "schema": "ReviewArtifact",
            "tools": [],
            "skills": [],
            "profileBinding": "inline",
            "runtimeMode": "turn_driven"
        }]);
        document.schemas = json!([{
            "id": "ReviewArtifact",
            "fields": [{ "id": "score", "name": "score", "type": "number", "required": true }]
        }]);
        document.mob_toml = None;
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "size: number", "inputParams": [
                    { "id": "p1", "name": "size", "type": "number", "required": true, "enumValues": [] }
                ] },
                { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review", "schema": "ReviewArtifact" },
                {
                    "id": "branch_1",
                    "type": "branch",
                    "controllerRole": "m_reviewer",
                    "branches": [
                        { "id": "br_fast", "label": "Fast", "condition": "steps.review.score > 10", "cond": { "stepId": "review", "field": "score", "op": ">", "val": "10" }, "steps": [] },
                        { "id": "br_small", "label": "Small", "condition": "params.size < 3", "steps": [] }
                    ],
                    "fallback": []
                },
                {
                    "id": "loop_1",
                    "type": "repeat",
                    "loopId": "loop_1",
                    "maxIterations": 3,
                    "cond": { "stepId": "review", "op": "<", "field": "score", "val": "10" },
                    "steps": [
                        { "id": "loop_review", "type": "member", "role": "m_reviewer", "instruction": "Review again", "schema": "ReviewArtifact" }
                    ]
                }
            ]
        });
        document.mob_toml =
            Some(render_editor_document_mob_toml(&document).expect("render supported operators"));
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_incomplete_structured_branch_condition() {
        let mut document = valid_document();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                {
                    "id": "branch_1",
                    "type": "branch",
                    "branches": [
                        { "id": "br_fast", "label": "Fast", "condition": "", "cond": { "stepId": "review", "op": ">=", "val": "10" }, "steps": [] }
                    ],
                    "fallback": []
                }
            ]
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "incomplete_editor_branch_condition"
                && diagnostic.path.as_deref() == Some("flow.steps[1].branches[0].cond.field")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_editor_condition_operator"
                && diagnostic.path.as_deref() == Some("flow.steps[1].branches[0].cond.op")
        }));
    }

    #[test]
    fn rejects_unsupported_editor_condition_operators() {
        let mut document = valid_document();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                {
                    "id": "branch_1",
                    "type": "branch",
                    "branches": [
                        { "id": "br_fast", "label": "Fast", "condition": "params.score >= 10", "steps": [] }
                    ],
                    "fallback": []
                },
                {
                    "id": "loop_1",
                    "type": "repeat",
                    "loopId": "loop_1",
                    "maxIterations": 3,
                    "cond": { "stepId": "review", "op": "!=", "field": "score", "val": "10" },
                    "steps": []
                }
            ]
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_editor_condition_operator"
                && diagnostic.path.as_deref() == Some("flow.steps[1].branches[0].condition")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_editor_condition_operator"
                && diagnostic.path.as_deref() == Some("flow.steps[2].cond.op")
        }));
    }

    #[test]
    fn rejects_unsupported_repeat_iteration_input() {
        let mut document = valid_document();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                {
                    "id": "loop_1",
                    "type": "repeat",
                    "loopId": "loop_1",
                    "maxIterations": 3,
                    "iterationInput": "reuse",
                    "cond": { "stepId": "review", "op": "==", "field": "score", "val": "10" },
                    "steps": []
                }
            ]
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_repeat_iteration_input"
                && diagnostic.path.as_deref() == Some("flow.steps[1].iterationInput")
        }));
    }

    #[test]
    fn rejects_repeat_steps_without_explicit_max_iterations_and_condition() {
        let mut document = valid_document();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                {
                    "id": "loop_1",
                    "type": "repeat",
                    "steps": []
                }
            ]
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_repeat_loop_id"
                && diagnostic.path.as_deref() == Some("flow.steps[1].loopId")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_repeat_max_iterations"
                && diagnostic.path.as_deref() == Some("flow.steps[1].maxIterations")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_repeat_condition"
                && diagnostic.path.as_deref() == Some("flow.steps[1].cond")
        }));
        assert_eq!(result.mob_id, None);
        assert!(result.flow_ids.is_empty());
    }

    #[test]
    fn rejects_incomplete_repeat_until_conditions() {
        let mut document = valid_document();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                {
                    "id": "loop_1",
                    "type": "repeat",
                    "loopId": "loop_1",
                    "maxIterations": 3,
                    "cond": { "field": "score", "op": ">", "val": "" },
                    "steps": []
                }
            ]
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "incomplete_repeat_condition"
                && diagnostic.path.as_deref() == Some("flow.steps[1].cond.stepId")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "incomplete_repeat_condition"
                && diagnostic.path.as_deref() == Some("flow.steps[1].cond.val")
        }));
    }

    #[test]
    fn repeat_condition_renderer_does_not_synthesize_missing_authoring_state() {
        let mut state = RenderState {
            members: BTreeMap::new(),
            steps: Vec::new(),
            step_ids_by_visual_id: BTreeMap::new(),
            flat_exits_by_node_id: BTreeMap::new(),
            next: 1,
        };
        state
            .step_ids_by_visual_id
            .insert("review".to_string(), "01_review".to_string());

        assert_eq!(condition_from_repeat_step(&json!({}), &state), None);
        assert_eq!(
            condition_from_repeat_step(
                &json!({ "cond": { "stepId": "review", "field": "verdict", "op": "==", "val": "" } }),
                &state,
            ),
            None
        );
        assert_eq!(
            condition_from_repeat_step(
                &json!({ "cond": { "field": "verdict", "op": "==", "val": "green" } }),
                &state,
            ),
            None
        );
        assert_eq!(
            condition_from_repeat_step(
                &json!({ "cond": { "stepId": "review", "field": "verdict", "op": "==", "val": "green" } }),
                &state,
            )
            .as_deref(),
            Some(r#"{ op = "eq", path = "steps.01_review.verdict", value = "green" }"#)
        );
        assert_eq!(
            condition_from_repeat_step(
                &json!({ "cond": { "namespace": "params", "field": "done", "op": "==", "val": "true" } }),
                &state,
            )
            .as_deref(),
            Some(r#"{ op = "eq", path = "params.done", value = "true" }"#)
        );
    }

    #[test]
    fn renderer_does_not_synthesize_missing_parallel_metadata_or_repeat_limits() {
        let planner = json!({
            "id": "m_planner",
            "role": "planner",
            "name": "Planner",
            "profileBinding": "inline",
            "runtimeMode": "turn_driven",
            "systemPrompt": "Plan."
        });
        let members = vec![&planner];

        let missing_parallel = vec![json!({
            "id": "parallel_missing",
            "type": "parallel",
            "branches": [{
                "id": "left",
                "steps": [{ "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }]
            }]
        })];
        let rendered = compile_editor_flow(&missing_parallel, &members);
        assert!(rendered.steps.is_empty());
        assert!(rendered.root.nodes.is_empty());

        let explicit_parallel = vec![json!({
            "id": "parallel_explicit",
            "type": "parallel",
            "dispatchMode": "fan_out",
            "collection": "all",
            "controllerRole": "m_planner",
            "branches": [{
                "id": "left",
                "steps": [{ "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan", "launchMode": { "kind": "Fresh" } }]
            }]
        })];
        let rendered = compile_editor_flow(&explicit_parallel, &members);
        assert!(
            rendered
                .steps
                .iter()
                .any(|step| step.message == "Join parallel branches (all).")
        );

        let missing_repeat_limit = vec![json!({
            "id": "loop",
            "type": "repeat",
            "loopId": "loop",
            "cond": { "namespace": "params", "field": "done", "op": "==", "val": "true" },
            "steps": [{ "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }]
        })];
        let rendered = compile_editor_flow(&missing_repeat_limit, &members);
        assert!(rendered.steps.is_empty());
        assert!(rendered.root.nodes.is_empty());

        let missing_repeat_loop_id = vec![json!({
            "id": "loop",
            "type": "repeat",
            "maxIterations": 2,
            "cond": { "namespace": "params", "field": "done", "op": "==", "val": "true" },
            "steps": [{ "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }]
        })];
        let rendered = compile_editor_flow(&missing_repeat_loop_id, &members);
        assert!(rendered.steps.is_empty());
        assert!(rendered.root.nodes.is_empty());
    }

    #[test]
    fn exports_repeat_until_condition_from_input_param() {
        let mut document = document_with_unused_reviewer_profile();
        document.mob_toml = None;
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "done: boolean", "inputParams": [
                    { "id": "p1", "name": "done", "type": "boolean", "required": true, "enumValues": [] }
                ] },
                {
                    "id": "loop_1",
                    "type": "repeat",
                    "loopId": "review_loop",
                    "maxIterations": 2,
                    "cond": { "namespace": "params", "stepId": "params", "field": "done", "op": "==", "val": "true" },
                    "steps": [
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
                    ]
                }
            ]
        });
        let flow_steps = document
            .flow
            .get("steps")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        let (instances, edges, frames) = graph_projection_from_visual_steps(&flow_steps);
        document.instances = Value::Array(instances);
        document.edges = Value::Array(edges);
        document.frames = Value::Array(frames);
        let validation = validate_document(&document);
        assert!(validation.ok, "{:#?}", validation.diagnostics);
        let export = export_mobpack(&json!({ "document": document })).expect("export succeeds");

        assert!(
            export
                .mob_toml
                .contains(r#"until = { op = "eq", path = "params.done", value = "true" }"#)
        );
        assert!(export.mob_toml.contains("max_iterations = 2"));
    }

    #[test]
    fn visual_graph_projection_preserves_missing_parallel_metadata() {
        let flow_steps = vec![
            json!({ "id": "input_1", "type": "input", "task": "Review" }),
            json!({
                "id": "parallel_review",
                "type": "parallel",
                "branches": [
                    {
                        "id": "br_plan",
                        "steps": [
                            { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }
                        ]
                    },
                    {
                        "id": "br_review",
                        "steps": [
                            { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
                        ]
                    }
                ]
            }),
        ];

        let (instances, _, frames) = graph_projection_from_visual_steps(&flow_steps);
        let fork = instances
            .iter()
            .find(|instance| instance["id"] == "g_parallel_parallel_review")
            .expect("parallel fork");
        let join = instances
            .iter()
            .find(|instance| instance["id"] == "j_parallel_parallel_review")
            .expect("parallel join");
        let plan = instances
            .iter()
            .find(|instance| instance["id"] == "plan")
            .expect("parallel member");

        assert_eq!(fork["label"], "");
        assert_eq!(join["label"], "join · ");
        assert_eq!(join["collection"], "");
        assert_eq!(plan["launchMode"], Value::Null);
        assert_eq!(plan["dispatchMode"], "");
        assert_eq!(plan["collection"], "");
        assert_eq!(plan["outputFormat"], Value::Null);
        assert_eq!(
            frames[0]["label"],
            "PARALLEL · missing dispatch · join missing collection"
        );
    }

    #[test]
    fn visual_graph_projection_preserves_missing_repeat_metadata() {
        let flow_steps = vec![
            json!({ "id": "input_1", "type": "input", "task": "Review" }),
            json!({
                "id": "review_loop",
                "type": "repeat",
                "loopId": "review_loop",
                "cond": { "stepId": "review", "field": "verdict", "op": "==", "val": "green" },
                "steps": [
                    { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
                ]
            }),
        ];

        let (_, _, frames) = graph_projection_from_visual_steps(&flow_steps);

        assert_eq!(frames[0]["kind"], "RepeatUntil");
        assert_eq!(
            frames[0]["label"],
            "REPEAT-UNTIL · until condition · missing max_iterations"
        );
        assert_ne!(frames[0]["label"], "REPEAT-UNTIL · until condition · max 3");
    }

    #[test]
    fn rejects_unsupported_graph_condition_operator() {
        let mut document = document_with_real_launch_modes();
        if let Some(edges) = document.edges.as_array_mut() {
            edges[0]["kind"] = json!("cond");
            edges[0]["cond"] = json!({ "var": "reviewer.verdict", "op": "!=", "val": "red" });
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_graph_condition_operator"
                && diagnostic.path.as_deref() == Some("edges[0].cond.op")
        }));
    }

    #[test]
    fn validates_schema_backed_graph_condition_ref() {
        let mut document = document_with_expected_schema_ref();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                {
                    "id": "node_loop",
                    "type": "repeat",
                    "loopId": "review_loop",
                    "maxIterations": 3,
                    "iterationInput": "carry",
                    "cond": { "stepId": "review", "field": "verdict", "op": "==", "val": "green" },
                    "steps": [
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review", "schema": "ReviewArtifact" }
                    ]
                }
            ]
        });
        document.instances = json!([
            { "id": "review", "memberId": "m_reviewer", "col": 0, "row": 0, "launchMode": { "kind": "Fresh" } }
        ]);
        document.edges = json!([
            {
                "id": "e1",
                "from": "review",
                "to": "review",
                "kind": "cond",
                "label": "until reviewer verdict",
                "cond": { "var": "steps.review.verdict", "op": "==", "val": "green" }
            }
        ]);
        document.frames = json!([
            { "id": "frame_node_loop", "kind": "RepeatUntil", "colStart": 0, "colEnd": 0, "label": "REPEAT · until reviewer verdict" }
        ]);
        document.launch_modes = json!([
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fresh" }
            }
        ]);
        let result = validate_document(&document);

        assert!(result.ok, "{:#?}", result.diagnostics);
    }

    #[test]
    fn validates_runtime_param_graph_condition_ref() {
        let mut document = document_with_real_launch_modes();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "kind: string", "inputParams": [{
                    "id": "p1",
                    "name": "kind",
                    "type": "string",
                    "required": true,
                    "enumValues": []
                }] },
                {
                    "id": "kind_branch",
                    "type": "branch",
                    "controllerRole": "m_reviewer",
                    "dependsMode": "all",
                    "branches": [{
                        "id": "br_docs",
                        "label": "Docs",
                        "condition": "kind == docs",
                        "cond": { "namespace": "params", "stepId": "params", "field": "kind", "op": "==", "val": "docs" },
                        "steps": [
                            { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }
                        ]
                    }],
                    "fallback": [
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
                    ]
                }
            ]
        });
        document.instances = json!([
            { "id": "g_branch_kind_branch", "isGate": true, "gateKind": "branch", "label": "branch", "col": 0, "row": 0 },
            { "id": "plan", "memberId": "m_planner", "col": 1, "row": 0, "launchMode": { "kind": "Fresh" } },
            { "id": "review", "memberId": "m_reviewer", "col": 1, "row": 1, "launchMode": { "kind": "Fresh" } },
            { "id": "j_branch_kind_branch", "isGate": true, "gateKind": "join", "label": "join · branch paths", "collection": "any", "controllerRole": "m_reviewer", "col": 2, "row": 0 }
        ]);
        document.edges = json!([
            {
                "id": "e1",
                "from": "g_branch_kind_branch",
                "to": "plan",
                "kind": "cond",
                "label": "kind == docs",
                "cond": { "var": "params.kind", "op": "==", "val": "docs" }
            },
            { "id": "e2", "from": "g_branch_kind_branch", "to": "review", "kind": "next", "label": "fallback" },
            { "id": "e3", "from": "plan", "to": "j_branch_kind_branch", "kind": "next", "label": "" },
            { "id": "e4", "from": "review", "to": "j_branch_kind_branch", "kind": "next", "label": "" }
        ]);
        document.frames = json!([
            { "id": "frame_branch_kind_branch", "kind": "Branch", "colStart": 0, "colEnd": 2, "label": "BRANCH · kind" }
        ]);
        document.launch_modes.as_array_mut().expect("launch modes")[1]["launch_mode"] =
            json!({ "kind": "Fresh" });
        let result = validate_document(&document);

        assert!(result.ok, "{:#?}", result.diagnostics);
    }

    #[test]
    fn validates_runtime_param_basic_branch_condition_ref() {
        let mut document = document_with_real_launch_modes();
        document.instances = Value::Null;
        document.edges = Value::Null;
        document.frames = Value::Null;
        document.launch_modes = Value::Null;
        document.flow["steps"] = json!([
            {
                "id": "input_1",
                "type": "input",
                "task": "Review",
                "fields": "kind: enum",
                "inputParams": [{
                    "id": "p1",
                    "name": "kind",
                    "type": "enum",
                    "required": true,
                    "enumValues": ["docs", "code"]
                }]
            },
                {
                    "id": "branch_review",
                    "type": "branch",
                    "controllerRole": "m_reviewer",
                    "branches": [
                    {
                        "id": "br_plan",
                        "label": "Plan",
                        "cond": { "namespace": "params", "field": "kind", "op": "==", "val": "code" },
                        "condition": "params.kind == \"code\"",
                        "steps": [{ "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }]
                    },
                    {
                        "id": "br_review",
                        "label": "Review",
                        "cond": { "stepId": "params", "field": "kind", "op": "==", "val": "docs" },
                        "condition": "params.kind == \"docs\"",
                        "steps": [{ "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }]
                    }
                ],
                "fallback": []
            }
        ]);
        let result = validate_document(&document);

        assert!(result.ok, "{:#?}", result.diagnostics);
    }

    #[test]
    fn exports_structured_branch_condition_without_display_text() {
        let mut document = document_with_unused_reviewer_profile();
        document.mob_toml = None;
        document.flow = json!({
            "name": "review",
            "steps": [
                {
                    "id": "input_1",
                    "type": "input",
                    "task": "Review",
                    "fields": "kind: enum",
                    "inputParams": [{
                        "id": "p1",
                        "name": "kind",
                        "type": "enum",
                        "required": true,
                        "enumValues": ["docs", "code"]
                    }]
                },
                {
                    "id": "branch_review",
                    "type": "branch",
                    "controllerRole": "m_reviewer",
                    "branches": [{
                        "id": "br_plan",
                        "label": "Plan",
                        "cond": { "namespace": "params", "stepId": "params", "field": "kind", "op": "==", "val": "code" },
                        "condition": "",
                        "steps": [{ "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" }]
                    }],
                    "fallback": [
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
                    ]
                }
            ]
        });
        let flow_steps = document
            .flow
            .get("steps")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        let (instances, edges, frames) = graph_projection_from_visual_steps(&flow_steps);
        document.instances = Value::Array(instances);
        document.edges = Value::Array(edges);
        document.frames = Value::Array(frames);
        let validation = validate_document(&document);
        assert!(validation.ok, "{:#?}", validation.diagnostics);
        let export = export_mobpack(&json!({ "document": document })).expect("export succeeds");

        assert!(
            export
                .mob_toml
                .contains(r#"condition = { op = "eq", path = "params.kind", value = "code" }"#)
        );
    }

    #[test]
    fn rejects_incomplete_structured_branch_condition_operator_and_value() {
        let mut document = document_with_real_launch_modes();
        document.flow["steps"] = json!([
            { "id": "input_1", "type": "input", "task": "Review", "fields": "", "inputParams": [] },
            {
                "id": "branch_review",
                "type": "branch",
                "branches": [{
                    "id": "br_fast",
                    "label": "Fast",
                    "condition": "",
                    "cond": { "stepId": "review", "field": "verdict" },
                    "steps": []
                }],
                "fallback": []
            }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "incomplete_editor_branch_condition"
                && diagnostic.path.as_deref() == Some("flow.steps[1].branches[0].cond.op")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "incomplete_editor_branch_condition"
                && diagnostic.path.as_deref() == Some("flow.steps[1].branches[0].cond.val")
        }));
    }

    #[test]
    fn rejects_undeclared_runtime_param_condition_ref() {
        let mut document = document_with_real_launch_modes();
        if let Some(edges) = document.edges.as_array_mut() {
            edges[0]["kind"] = json!("cond");
            edges[0]["cond"] = json!({ "var": "params.kind", "op": "==", "val": "docs" });
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_editor_input_param"
                && diagnostic.path.as_deref() == Some("edges[0].cond.var")
        }));
    }

    #[test]
    fn rejects_branch_without_explicit_condition() {
        let mut document = document_with_real_launch_modes();
        document.flow["steps"] = json!([
            { "id": "input_1", "type": "input", "task": "Review", "fields": "", "inputParams": [] },
            {
                "id": "branch_review",
                "type": "branch",
                "branches": [
                    { "id": "br_fast", "label": "Fast", "steps": [] }
                ],
                "fallback": []
            }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_editor_branch_condition"
                && diagnostic.path.as_deref() == Some("flow.steps[1].branches[0]")
        }));
    }

    #[test]
    fn rejects_fake_graph_condition_ref() {
        let mut document = document_with_real_launch_modes();
        if let Some(edges) = document.edges.as_array_mut() {
            edges[0]["kind"] = json!("cond");
            edges[0]["cond"] = json!({ "var": "reviewer.verdict", "op": "==", "val": "green" });
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_graph_condition_ref"
                && diagnostic.path.as_deref() == Some("edges[0].cond.var")
        }));
    }

    #[test]
    fn complete_editor_projection_overrides_stale_flow_turn_text() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = document
            .mob_toml
            .map(|text| text.replace("message = \"Review\"", "message = \"Stale review\""));
        let validation = validate_document(&document);

        assert!(validation.ok, "{:?}", validation.diagnostics);
        let result =
            export_mobpack(&json!({ "document": document })).expect("export rendered document");
        assert!(result.mob_toml.contains(r#"message = "Review""#));
        assert!(!result.mob_toml.contains("Stale review"));
    }

    #[test]
    fn rejects_launch_mode_references_to_unknown_editor_step_or_member() {
        let mut document = document_with_real_launch_modes();
        document.launch_modes = json!([
            {
                "step_id": "ghost",
                "member_id": "m_missing",
                "profile": "missing",
                "launch_mode": { "kind": "Fresh" }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fork", "from": "missing-source", "context": "FullHistory" }
            }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "unknown_launch_step")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "unknown_launch_member")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "fork_launch_unknown_source")
        );
    }

    #[test]
    fn rejects_missing_launch_mode_for_editor_member_step() {
        let mut document = document_with_real_launch_modes();
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "planner",
                "launch_mode": { "kind": "Fresh" }
            }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "missing_step_launch_mode")
        );
    }

    #[test]
    fn rejects_null_launch_mode_for_editor_member_step() {
        let mut document = document_with_real_launch_modes();
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "planner",
                "launch_mode": null
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fork", "from": "plan", "context": "FullHistory" }
            }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_launch_mode"
                && diagnostic.path.as_deref() == Some("launch_modes[0].launch_mode")
        }));
    }

    #[test]
    fn rejects_launch_profile_mismatch_and_invalid_fork_context() {
        let mut document = document_with_real_launch_modes();
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fresh" }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fork", "from": "plan", "context": "Everything" }
            }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "launch_profile_mismatch")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_fork_launch_context")
        );
    }

    #[test]
    fn rejects_stale_non_mobkit_fork_launch_contexts() {
        let mut document = document_with_real_launch_modes();
        document.launch_modes = json!([
            {
                "step_id": "plan",
                "member_id": "m_planner",
                "profile": "planner",
                "launch_mode": { "kind": "Fresh" }
            },
            {
                "step_id": "review",
                "member_id": "m_reviewer",
                "profile": "reviewer",
                "launch_mode": { "kind": "Fork", "from": "plan", "context": "DiffOnly" }
            }
        ]);
        if let Some(steps) = document.flow["steps"].as_array_mut() {
            steps[2]["launchMode"] =
                json!({ "kind": "Fork", "from": "plan", "context": "DiffOnly" });
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_fork_launch_context"
                && diagnostic.path.as_deref() == Some("launch_modes[1].launch_mode")
        }));
    }

    #[test]
    fn validates_graph_projection_against_editor_flow_and_members() {
        let document = document_with_real_launch_modes();
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_graph_member_metadata_drift_from_flow_step() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["timeoutMs"] = json!(2500);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_member_metadata_mismatch"
                && diagnostic.path.as_deref() == Some("instances[0].timeoutMs")
        }));
    }

    #[test]
    fn rejects_graph_launch_mode_drift_from_flow_step() {
        let mut document = document_with_real_launch_modes();
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[1]["launchMode"] = json!({ "kind": "Fresh" });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_launch_mode_mismatch"
                && diagnostic.path.as_deref() == Some("instances[1].launchMode")
        }));
    }

    #[test]
    fn rejects_missing_graph_launch_mode_when_flow_requires_one() {
        let mut document = document_with_real_launch_modes();
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[1]
            .as_object_mut()
            .expect("graph member object")
            .remove("launchMode");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_launch_mode_mismatch"
                && diagnostic.path.as_deref() == Some("instances[1].launchMode")
        }));
    }

    #[test]
    fn validates_editor_document_without_client_rendered_mob_toml() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let result =
            validate_mobpack(&json!({ "document": document })).expect("validate rendered document");

        assert!(result.ok, "{:?}", result.diagnostics);
        assert_eq!(result.mob_id.as_deref(), Some("review_pack"));
        assert_eq!(result.flow_ids, vec!["main".to_string()]);
    }

    #[test]
    fn validates_single_member_editor_document_with_empty_graph_edges() {
        let mut document = document_with_reviewer_output_schema();
        document.mob_toml = None;
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "inputParams": [] },
                { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }
            ]
        });
        document.instances = json!([
            { "id": "review", "memberId": "m_reviewer", "col": 0, "row": 0, "launchMode": { "kind": "Fresh" } }
        ]);
        document.edges = json!([]);
        document.frames = json!([]);
        document.launch_modes = json!([{
            "step_id": "review",
            "member_id": "m_reviewer",
            "profile": "reviewer",
            "launch_mode": { "kind": "Fresh" },
            "budget_split_policy": { "type": "equal" }
        }]);

        let result =
            validate_mobpack(&json!({ "document": document })).expect("validate rendered document");

        assert!(result.ok, "{:?}", result.diagnostics);
        assert_eq!(result.flow_ids, vec!["main".to_string()]);
    }

    #[test]
    fn exports_editor_document_without_client_rendered_mob_toml() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let result =
            export_mobpack(&json!({ "document": document })).expect("export rendered document");

        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(result.mob_toml.contains("[profiles.planner]"));
        assert!(result.mob_toml.contains("[flows.main.steps."));
        assert!(!result.mob_toml.contains("missing_"));
    }

    #[test]
    fn previews_editor_document_source_without_exporting_archive_payload() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let result =
            source_mobpack(&json!({ "document": document })).expect("preview rendered source");

        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert_eq!(result.source, "mobkit/mobpacks/source");
        assert_eq!(result.media_type, MOBPACK_MEDIA_TYPE);
        assert!(result.mob_toml.contains("[profiles.planner]"));
        assert!(
            result
                .source_files
                .iter()
                .any(|file| file.path == "mobkit/mob.toml"
                    && file.text.as_deref() == Some(result.mob_toml.as_str()))
        );
        let serialized = serde_json::to_value(&result).expect("serialize source result");
        assert!(
            serialized.get("content_base64").is_none(),
            "source preview must not return the full archive payload"
        );
    }

    #[test]
    fn exports_agent_definition_source_metadata_only_in_editor_projection() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let members = document.members.as_array_mut().expect("members");
        members[0]["sourceDefinition"] = json!({
            "definitionType": "mobkit/profile-member",
            "definitionId": "planner",
            "source": "mobkit/mobpack-profile-member",
            "sourceMobpack": "sample_planner_coder_review_loop",
            "sourceOrigin": "mobkit/sample-mobpack",
            "sourceDocumentPath": "document.members[]"
        });

        let result =
            export_mobpack(&json!({ "document": document })).expect("export rendered document");

        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(!result.mob_toml.contains("sourceDefinition"));
        assert!(!result.mob_toml.contains("sourceMobpack"));
        assert!(!result.mob_toml.contains("sample_planner_coder_review_loop"));
        let editor_json = result
            .source_files
            .iter()
            .find(|file| file.path == "mobkit/editor.json")
            .and_then(|file| file.text.as_deref())
            .expect("mobkit/editor.json source file");
        let editor_json: Value = serde_json::from_str(editor_json).expect("editor json");
        assert_eq!(
            editor_json["document"]["members"][0]["sourceDefinition"]["sourceMobpack"],
            json!("sample_planner_coder_review_loop")
        );
        assert_eq!(
            editor_json["document"]["members"][0]["sourceDefinition"]["sourceOrigin"],
            json!("mobkit/sample-mobpack")
        );
    }

    #[test]
    fn complete_editor_projection_overrides_stale_packed_mob_toml() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = Some(
            r#"
[mob]
id = "stale-pack"

[profiles.planner]
model = "gpt-5.5"
skills = []

[profiles.planner.tools]
comms = true

[flows.stale]
description = "Stale flow"

[flows.stale.steps.plan]
role = "planner"
message = "stale"
"#
            .to_string(),
        );

        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        assert_eq!(validation.mob_id.as_deref(), Some("review_pack"));

        let result =
            export_mobpack(&json!({ "document": document })).expect("export rendered document");
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(result.mob_toml.contains("[profiles.reviewer]"));
        assert!(result.mob_toml.contains(r#"role = "reviewer""#));
        assert!(result.mob_toml.contains(r#"message = "Review""#));
        assert!(!result.mob_toml.contains("stale-pack"));
        assert!(!result.mob_toml.contains("[flows.stale]"));
        let editor_json = result
            .source_files
            .iter()
            .find(|file| file.path == "mobkit/editor.json")
            .and_then(|file| file.text.as_deref())
            .expect("mobkit/editor.json source file");
        let editor_json: Value = serde_json::from_str(editor_json).expect("editor json");
        assert_eq!(
            editor_json["document"]["mob_toml"].as_str(),
            Some(result.mob_toml.as_str()),
            "editor projection archived with the mobpack must carry MobKit's canonical rendered mob.toml"
        );
    }

    #[test]
    fn rejects_flow_member_step_without_real_member_definition() {
        let mut document = document_with_real_launch_modes();
        document.flow["steps"][1]["role"] = json!("m_missing");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_flow_member"
                && diagnostic.path.as_deref() == Some("flow.steps[1].role")
        }));
    }

    #[test]
    fn rejects_flow_member_step_without_member_ref() {
        let mut document = document_with_real_launch_modes();
        document.flow["steps"][1]["role"] = json!("");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_flow_member"
                && diagnostic.path.as_deref() == Some("flow.steps[1].role")
        }));
    }

    #[test]
    fn validates_compiled_graph_gates_and_frames_from_flow_primitives() {
        let document = document_with_parallel_graph_controls();
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn validates_branch_join_member_as_real_profile_step() {
        let document = document_with_branch_graph_controls();
        let result = validate_document(&document);
        assert!(result.ok, "{:?}", result.diagnostics);

        let export = export_mobpack(&json!({ "document": document })).expect("export");
        assert!(export.validation.ok, "{:?}", export.validation.diagnostics);
        assert!(export.mob_toml.contains("Join branch paths."));
        assert!(export.mob_toml.contains(r#"role = "reviewer""#));
    }

    #[test]
    fn rejects_branch_convergence_without_real_join_member() {
        let mut document = document_with_branch_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1].as_object_mut().unwrap().remove("controllerRole");
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[3]
            .as_object_mut()
            .unwrap()
            .remove("controllerRole");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_branch_join_member"
                && diagnostic.path.as_deref() == Some("flow.steps[1].controllerRole")
        }));
    }

    #[test]
    fn rejects_graph_join_controller_drift_from_branch_flow() {
        let mut document = document_with_branch_graph_controls();
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[3]["controllerRole"] = json!("m_planner");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_join_controller_mismatch"
                && diagnostic.path.as_deref() == Some("instances[3].controllerRole")
        }));
    }

    #[test]
    fn rejects_missing_compiled_graph_gate() {
        let mut document = document_with_parallel_graph_controls();
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances.retain(|instance| instance["id"] != "g_parallel_parallel_review");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_compiled_graph_gate"
                && diagnostic.path.as_deref() == Some("instances")
        }));
    }

    #[test]
    fn rejects_missing_compiled_graph_frame() {
        let mut document = document_with_parallel_graph_controls();
        document.frames = json!([]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_compiled_graph_frame"
                && diagnostic.path.as_deref() == Some("frames")
        }));
    }

    #[test]
    fn rejects_graph_frame_kind_drift_from_repeat_flow() {
        let mut document = document_with_expected_schema_ref();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" },
                {
                    "id": "node_loop",
                    "type": "repeat",
                    "loopId": "review_loop",
                    "maxIterations": 3,
                    "iterationInput": "carry",
                    "cond": { "stepId": "review", "field": "verdict", "op": "==", "val": "green" },
                    "steps": [
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review", "schema": "ReviewArtifact" }
                    ]
                }
            ]
        });
        document.edges = json!([
            { "id": "e1", "from": "plan", "to": "review", "kind": "next", "label": "" },
            {
                "id": "e2",
                "from": "review",
                "to": "review",
                "kind": "cond",
                "label": "until condition",
                "cond": { "var": "steps.review.verdict", "op": "==", "val": "green" }
            }
        ]);
        document.frames = json!([
            { "id": "frame_node_loop", "kind": "Branch", "colStart": 1, "colEnd": 1, "label": "BRANCH · wrong frame" }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_frame_kind_mismatch"
                && diagnostic.path.as_deref() == Some("frames[0].kind")
        }));
    }

    #[test]
    fn validates_graph_join_collection_and_controller_against_parallel_flow() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1]["collection"] = json!("any");
        steps[1]["controllerRole"] = json!("m_planner");
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[3]["collection"] = json!("any");
        instances[3]["controllerRole"] = json!("m_planner");
        instances[3]["label"] = json!("join · any");
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_graph_join_collection_drift_from_parallel_flow() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1]["collection"] = json!("any");
        steps[1]["controllerRole"] = json!("m_planner");
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[3]["collection"] = json!("all");
        instances[3]["controllerRole"] = json!("m_planner");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_join_collection_mismatch"
                && diagnostic.path.as_deref() == Some("instances[3].collection")
        }));
    }

    #[test]
    fn rejects_graph_fork_dispatch_drift_from_parallel_flow() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1]["dispatch"] = json!("one_to_one");
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[0]["dispatch"] = json!("fan_out");
        instances[0]["label"] = json!("fan_out");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_fork_dispatch_mismatch"
                && diagnostic.path.as_deref() == Some("instances[0].dispatch")
        }));
    }

    #[test]
    fn rejects_graph_join_controller_drift_from_parallel_flow() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1]["collection"] = json!("quorum");
        steps[1]["quorum"] = json!(1);
        steps[1]["controllerRole"] = json!("m_planner");
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[3]["collection"] = json!("quorum");
        instances[3]["quorum"] = json!({ "mode": "NofM", "n": 1, "m": 2 });
        instances[3]["controllerRole"] = json!("m_reviewer");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_join_controller_mismatch"
                && diagnostic.path.as_deref() == Some("instances[3].controllerRole")
        }));
    }

    #[test]
    fn rejects_graph_join_quorum_drift_from_parallel_flow() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1]["collection"] = json!("quorum");
        steps[1]["quorum"] = json!(2);
        steps[1]["controllerRole"] = json!("m_planner");
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[3]["collection"] = json!("quorum");
        instances[3]["quorum"] = json!({ "mode": "NofM", "n": 1, "m": 2 });
        instances[3]["controllerRole"] = json!("m_planner");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_join_quorum_mismatch"
                && diagnostic.path.as_deref() == Some("instances[3].quorum.n")
        }));
    }

    #[test]
    fn rejects_graph_fork_edge_drift_from_parallel_flow() {
        let mut document = document_with_parallel_graph_controls();
        let edges = document.edges.as_array_mut().expect("graph edges");
        edges[0]["kind"] = json!("next");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_flow_edge_mismatch"
                && diagnostic.path.as_deref() == Some("edges")
                && diagnostic
                    .message
                    .contains("from 'g_parallel_parallel_review' to 'plan'")
        }));
    }

    #[test]
    fn rejects_graph_join_edge_drift_from_parallel_flow() {
        let mut document = document_with_parallel_graph_controls();
        let edges = document.edges.as_array_mut().expect("graph edges");
        edges[2]["to"] = json!("review");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_flow_edge_mismatch"
                && diagnostic.path.as_deref() == Some("edges")
                && diagnostic
                    .message
                    .contains("from 'plan' to 'j_parallel_parallel_review'")
        }));
    }

    #[test]
    fn rejects_graph_sequence_edge_drift_from_flow() {
        let mut document = document_with_expected_schema_ref();
        let edges = document.edges.as_array_mut().expect("graph edges");
        edges[0]["from"] = json!("review");
        edges[0]["to"] = json!("plan");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_flow_edge_mismatch"
                && diagnostic.path.as_deref() == Some("edges")
                && diagnostic.message.contains("from 'plan' to 'review'")
        }));
    }

    #[test]
    fn rejects_extra_graph_edge_not_compiled_from_flow() {
        let mut document = document_with_expected_schema_ref();
        let edges = document.edges.as_array_mut().expect("graph edges");
        edges.push(json!({
            "id": "e_extra",
            "from": "review",
            "to": "plan",
            "kind": "next",
            "label": "visual-only shortcut"
        }));

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_extra_uncompiled_edge"
                && diagnostic.path.as_deref() == Some("edges")
                && diagnostic.message.contains("from 'review' to 'plan'")
        }));
    }

    #[test]
    fn rejects_graph_repeat_loop_edge_drift_from_flow() {
        let mut document = document_with_expected_schema_ref();
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "" },
                { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan" },
                {
                    "id": "node_loop",
                    "type": "repeat",
                    "loopId": "review_loop",
                    "maxIterations": 3,
                    "iterationInput": "carry",
                    "cond": { "stepId": "review", "field": "verdict", "op": "==", "val": "green" },
                    "steps": [
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review", "schema": "ReviewArtifact" }
                    ]
                }
            ]
        });
        document.edges = json!([
            { "id": "e1", "from": "plan", "to": "review", "kind": "next", "label": "" },
            {
                "id": "e2",
                "from": "review",
                "to": "plan",
                "kind": "cond",
                "label": "until condition",
                "cond": { "var": "steps.review.verdict", "op": "==", "val": "green" }
            }
        ]);
        document.frames = json!([
            { "id": "frame_node_loop", "kind": "RepeatUntil", "colStart": 1, "colEnd": 1, "label": "REPEAT · until condition" }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_flow_edge_mismatch"
                && diagnostic.path.as_deref() == Some("edges")
                && diagnostic.message.contains("from 'review' to 'review'")
        }));
    }

    #[test]
    fn rejects_parallel_any_collection_without_real_join_member() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1]["collection"] = json!("any");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_parallel_join_member"
                && diagnostic.path.as_deref() == Some("flow.steps[1].controllerRole")
        }));
    }

    #[test]
    fn rejects_unknown_parallel_control_member() {
        let mut document = document_with_parallel_graph_controls();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        steps[1]["controllerRole"] = json!("m_missing");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_flow_control_member"
                && diagnostic.path.as_deref() == Some("flow.steps[1].controllerRole")
        }));
    }

    #[test]
    fn rejects_graph_member_instance_that_disagrees_with_flow_step() {
        let mut document = document_with_real_launch_modes();
        if let Some(instances) = document.instances.as_array_mut() {
            instances[1]["memberId"] = json!("m_planner");
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "graph_flow_member_mismatch"
                    && diagnostic.path.as_deref() == Some("instances[1].memberId"))
        );
    }

    #[test]
    fn rejects_graph_edge_with_unknown_endpoint_and_kind() {
        let mut document = document_with_real_launch_modes();
        document.edges = json!([
            { "id": "e1", "from": "plan", "to": "ghost", "kind": "magic", "label": "" }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| diagnostic.code
            == "unknown_graph_edge_endpoint"
            && diagnostic.path.as_deref() == Some("edges[0].to")));
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_graph_edge_kind"
                    && diagnostic.path.as_deref() == Some("edges[0].kind"))
        );
    }

    #[test]
    fn rejects_graph_edge_without_explicit_kind() {
        let mut document = document_with_real_launch_modes();
        document.edges = json!([
            { "id": "e1", "from": "plan", "to": "review", "label": "" }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_graph_edge_kind"
                    && diagnostic.path.as_deref() == Some("edges[0].kind")
                    && diagnostic.message.contains("non-empty kind"))
        );
    }

    #[test]
    fn rejects_visual_terminal_graph_edge_kind() {
        let mut document = document_with_real_launch_modes();
        document.edges = json!([
            { "id": "e1", "from": "plan", "to": "review", "kind": "term", "label": "done" }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_graph_edge_kind"
                && diagnostic.path.as_deref() == Some("edges[0].kind")
        }));
    }

    #[test]
    fn rejects_visual_only_graph_gate_terminal_and_frame() {
        let mut document = document_with_real_launch_modes();
        if let Some(instances) = document.instances.as_array_mut() {
            instances.push(
                json!({ "id": "branch_visual", "isGate": true, "gateKind": "branch", "col": 2, "row": 0 }),
            );
            instances.push(
                json!({ "id": "terminal_visual", "isTerminal": true, "kind": "success", "col": 3, "row": 0 }),
            );
        }
        document.frames = json!([
            { "id": "frame_visual", "kind": "Parallel", "colStart": 0, "colEnd": 2, "label": "visual" }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "uncompiled_graph_gate"
                && diagnostic.path.as_deref() == Some("instances[2]")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "uncompiled_graph_terminal"
                && diagnostic.path.as_deref() == Some("instances[3]")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "uncompiled_graph_frame"
                && diagnostic.path.as_deref() == Some("frames[0]")
        }));
    }

    #[test]
    fn rejects_missing_graph_instance_for_flow_member_step() {
        let mut document = document_with_real_launch_modes();
        document.instances = json!([
            { "id": "plan", "memberId": "m_planner", "col": 0, "row": 0, "launchMode": { "kind": "Fresh" } }
        ]);
        document.edges = json!([]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "flow_step_missing_graph_instance")
        );
    }

    #[test]
    fn rejects_invalid_graph_gate_terminal_and_frame_kinds() {
        let mut document = document_with_real_launch_modes();
        if let Some(instances) = document.instances.as_array_mut() {
            instances.push(json!({ "id": "gate_bad", "isGate": true, "gateKind": "merge", "col": 2, "row": 0 }));
            instances.push(json!({ "id": "terminal_bad", "isTerminal": true, "kind": "done-ish", "col": 3, "row": 0 }));
        }
        document.frames = json!([
            { "id": "frame_bad", "kind": "Decorative", "colStart": 0, "colEnd": 1, "label": "bad" }
        ]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_graph_gate_kind")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_graph_terminal_kind")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_graph_frame_kind")
        );
    }

    #[test]
    fn validates_rkat_deploy_settings() {
        let mut document = valid_document();
        document.deploy = json!({
            "command": "rkat mob run",
            "surface": "cli",
            "trust_policy": "permissive",
            "realm_backend": "jsonl",
            "max_total_tokens": 64,
            "max_tool_calls": 0,
            "model": "gpt-5.5",
            "isolated": true,
            "prompt": "Reply with exactly OK."
        });
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
        // `rkat mob run` has no model/token/tool-call override flags;
        // overrides that `rkat mob deploy` used to forward must warn instead
        // of silently dropping.
        let not_forwarded: Vec<&str> = result
            .diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.code == "deploy_override_not_forwarded")
            .filter_map(|diagnostic| diagnostic.path.as_deref())
            .collect();
        assert!(
            not_forwarded.contains(&"deploy.model"),
            "{:?}",
            result.diagnostics
        );
        assert!(
            not_forwarded.contains(&"deploy.max_total_tokens"),
            "{:?}",
            result.diagnostics
        );
        assert!(
            !not_forwarded.contains(&"deploy.max_tool_calls"),
            "zero/unset caps must not warn: {:?}",
            result.diagnostics
        );
        assert!(
            !not_forwarded.contains(&"deploy.surface"),
            "cli surface must not warn: {:?}",
            result.diagnostics
        );
    }

    #[test]
    fn rejects_non_rkat_deploy_command_and_invalid_deploy_enums() {
        let mut document = valid_document();
        document.deploy = json!({
            "command": "not-rkat deploy",
            "surface": "desktop",
            "trust_policy": "dangerous",
            "realm_backend": "memory",
            "model": "definitely-not-a-real-model",
            "prompt": "Reply with exactly OK."
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_command")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_surface")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_trust_policy")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "unknown_deploy_model")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_realm_backend")
        );
    }

    #[test]
    fn rejects_autonomous_host_profiles_for_cli_deploy() {
        let mut document = document_with_real_launch_modes();
        document.members[0]["runtimeMode"] = json!("autonomous_host");
        document.mob_toml = None;

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "deploy_surface_runtime_mode_unsupported"
                && diagnostic.path.as_deref() == Some("members[0].runtimeMode")
                && diagnostic.message.contains("deploy surface 'cli'")
                && diagnostic.message.contains("RPC surface only")
        }));
    }

    #[test]
    fn allows_autonomous_host_profiles_for_rpc_deploy_surface() {
        let mut document = document_with_real_launch_modes();
        document.deploy["surface"] = json!("rpc");
        document.members[0]["runtimeMode"] = json!("autonomous_host");
        document.mob_toml = None;

        let result = validate_document(&document);

        // The authoring contract still permits surface=rpc + autonomous_host
        // (no error), but a warning must flag that the `rkat mob run` deploy
        // story (CLI surface) cannot fulfill the autonomous_host wiring.
        assert!(result.ok, "{:?}", result.diagnostics);
        assert!(
            result.diagnostics.iter().any(|diagnostic| {
                diagnostic.severity == "warning"
                    && diagnostic.code == "deploy_surface_runtime_mode_not_deployable"
                    && diagnostic.path.as_deref() == Some("members[0].runtimeMode")
            }),
            "rpc + autonomous_host must warn that rkat mob run cannot deploy it: {:?}",
            result.diagnostics
        );
    }

    #[test]
    fn rejects_invalid_deploy_numbers_and_blank_prompt() {
        let mut document = valid_document();
        document.deploy = json!({
            "command": "rkat mob run",
            "max_total_tokens": -1,
            "max_tool_calls": 1.5,
            "isolated": "yes",
            "prompt": "  "
        });
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_number"
                    && diagnostic.path.as_deref() == Some("deploy.max_total_tokens"))
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_number"
                    && diagnostic.path.as_deref() == Some("deploy.max_tool_calls"))
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_isolated")
        );
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "invalid_deploy_prompt")
        );
    }

    #[test]
    fn validates_member_catalog_refs_from_real_mobkit_schema() {
        let document = document_with_member_catalog_refs(
            json!(["builtins", "comms"]),
            json!(["mob.workpad"]),
            json!([{
                "id": "mobkit/sample-mobpacks",
                "source": "mobkit/sample-mobpack",
                "skills": [{
                    "id": "mob.workpad",
                    "source": "inline",
                    "origin": "mobkit/sample-mobpack",
                    "content": "Keep the shared workpad concise and current."
                }]
            }]),
        );
        let result = validate_document(&document);
        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_unknown_editor_tool_and_skill_refs() {
        let document = document_with_member_catalog_refs(
            json!(["builtins", "__definitely_not_a_real_tool__"]),
            json!(["missing.skill"]),
            json!([{
                "id": "mobkit/sample-mobpacks",
                "source": "mobkit/sample-mobpack",
                "skills": [{ "id": "mob.workpad", "source": "inline", "origin": "mobkit/sample-mobpack" }]
            }]),
        );
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_tool_ref"
                && diagnostic.path.as_deref() == Some("members[0].tools[1]")
                && diagnostic.message.contains("MobKit tool_catalog")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_skill_ref"
                && diagnostic.path.as_deref() == Some("members[0].skills[0]")
        }));
    }

    #[test]
    fn warns_on_unknown_editor_member_model_ref() {
        let mut document = document_with_member_catalog_refs(
            json!(["builtins", "comms"]),
            json!(["mob.workpad"]),
            json!([{
                "id": "mobkit/sample-mobpacks",
                "skills": [{
                    "id": "mob.workpad",
                    "source": "inline",
                    "content": "Keep the shared workpad concise and current."
                }]
            }]),
        );
        document.members[0]["model"] = json!("definitely-not-a-real-model");
        document.mob_toml = document.mob_toml.map(|text| {
            text.replace(
                "model = \"gpt-5.5\"",
                // meerkat 0.7 validates profile models fail-fast: an
                // uncatalogued model must be provider-annotated or it is a
                // definition-level error. The editor-catalog miss stays a
                // mobkit warning, which is what this test pins.
                "model = \"definitely-not-a-real-model\"\nprovider = \"openai\"",
            )
        });
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_model_ref"
                && diagnostic.severity == "warning"
                && diagnostic.path.as_deref() == Some("members[0].model")
        }));
    }

    #[test]
    fn rejects_non_string_editor_member_model_ref() {
        let mut document = document_with_member_catalog_refs(
            json!(["builtins", "comms"]),
            json!(["mob.workpad"]),
            json!([{
                "id": "mobkit/sample-mobpacks",
                "skills": [{ "id": "mob.workpad" }]
            }]),
        );
        document.members[0]["model"] = json!(7);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_model_ref"
                && diagnostic.path.as_deref() == Some("members[0].model")
        }));
    }

    #[test]
    fn rejects_self_declared_unconfigured_mcp_tool_ref() {
        let mut document = document_with_member_catalog_refs(
            json!(["builtins", "mcp:not-configured"]),
            json!([]),
            json!([]),
        );
        document.mob_toml = document
            .mob_toml
            .map(|text| text.replace("comms = true", "comms = false\nmcp = [\"not-configured\"]"));
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_tool_ref"
                && diagnostic.path.as_deref() == Some("members[0].tools[1]")
        }));
    }

    #[test]
    fn rejects_non_string_editor_tool_and_skill_refs() {
        let document = document_with_member_catalog_refs(
            json!(["builtins", 7]),
            json!(["mob.workpad", null]),
            json!([{
                "id": "mobkit/sample-mobpacks",
                "source": "mobkit/sample-mobpack",
                "skills": [{ "id": "mob.workpad", "source": "inline", "origin": "mobkit/sample-mobpack" }]
            }]),
        );
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_tool_ref"
                && diagnostic.path.as_deref() == Some("members[0].tools[1]")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_skill_ref"
                && diagnostic.path.as_deref() == Some("members[0].skills[1]")
        }));
    }

    #[test]
    fn validates_unused_editor_members_as_real_profiles() {
        let document = document_with_unused_reviewer_profile();
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_editor_member_missing_from_mob_toml_profiles() {
        let mut document = document_with_unused_reviewer_profile();
        document.mob_toml = Some(
            r#"
[mob]
id = "review-pack"

[profiles.planner]
model = "gpt-5.5"
skills = []
peer_description = "Planner"

[profiles.planner.tools]
comms = true

[flows.review]
description = "Review flow"

[flows.review.steps.plan]
role = "planner"
message = "Plan the work"
"#
            .to_string(),
        );
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_profile_missing_from_mob_toml"
                && diagnostic.path.as_deref() == Some("members[1]")
        }));
    }

    #[test]
    fn rejects_editor_profile_tool_mismatch() {
        let mut document = document_with_unused_reviewer_profile();
        if let Some(members) = document.members.as_array_mut() {
            members[0]["tools"] = json!(["builtins"]);
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_profile_tools_mismatch"
                && diagnostic.path.as_deref() == Some("members[0].tools")
        }));
    }

    #[test]
    fn rejects_invalid_or_drifting_provider_params() {
        let mut document = document_with_unused_reviewer_profile();
        if let Some(members) = document.members.as_array_mut() {
            members[0]["providerParams"] = json!(["not", "an", "object"]);
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_provider_params"
                && diagnostic.path.as_deref() == Some("members[0].providerParams")
        }));

        let mut document = document_with_unused_reviewer_profile();
        if let Some(members) = document.members.as_array_mut() {
            members[0]["providerParams"] = json!({ "thinking_budget": 4096 });
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_profile_provider_params_mismatch"
                && diagnostic.path.as_deref() == Some("members[0].providerParams")
        }));
    }

    #[test]
    fn rejects_invalid_or_drifting_profile_backend_settings() {
        let mut document = document_with_unused_reviewer_profile();
        if let Some(members) = document.members.as_array_mut() {
            members[0]["backend"] = json!("wire");
            members[0]["maxInlinePeerNotifications"] = json!(-2);
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_profile_backend"
                && diagnostic.path.as_deref() == Some("members[0].backend")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_max_inline_peer_notifications"
                && diagnostic.path.as_deref() == Some("members[0].maxInlinePeerNotifications")
        }));

        let mut document = document_with_unused_reviewer_profile();
        if let Some(members) = document.members.as_array_mut() {
            members[0]["backend"] = json!("external");
            members[0]["maxInlinePeerNotifications"] = json!(0);
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_profile_backend_mismatch"
                && diagnostic.path.as_deref() == Some("members[0].backend")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_profile_max_inline_peer_notifications_mismatch"
                && diagnostic.path.as_deref() == Some("members[0].maxInlinePeerNotifications")
        }));
    }

    #[test]
    fn complete_editor_mob_settings_render_as_authoritative() {
        let result = import_mobpack(&json!({ "mob_toml": importable_mob_toml() })).expect("import");
        let mut document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        document.mob_settings["orchestrator"] = json!("reviewer");
        document.mob_settings["autoWireOrchestrator"] = json!(false);
        document.mob_settings["roleWiring"] = json!([{ "a": "reviewer", "b": "planner" }]);
        document.mob_settings["backendDefault"] = json!("session");
        document.mob_settings["externalAddressBase"] = json!("http://127.0.0.1:9999");
        document.mob_settings["advanced"]["topology"]["mode"] = json!("advisory");
        document.mob_settings["advanced"]["supervisor"]["escalation_threshold"] = json!(3);
        document.mob_settings["advanced"]["limits"]["max_orphaned_turns"] = json!(9);
        document.mob_settings["advanced"]["spawnPolicy"]["profile_map"]["reviewer"] =
            json!("planner");
        document.mob_settings["advanced"]["eventRouter"]["buffer_size"] = json!(256);
        let validation = validate_document(&document);

        assert!(validation.ok, "{:?}", validation.diagnostics);
        let mob_toml = render_editor_document_mob_toml(&document).expect("rendered mob.toml");
        assert!(mob_toml.contains(r#"orchestrator = "reviewer""#));
        assert!(mob_toml.contains("[backend.external]"));
        assert!(mob_toml.contains(r#"address_base = "http://127.0.0.1:9999""#));
    }

    #[test]
    fn member_step_runtime_metadata_exports_only_when_authored() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;

        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        let mob_toml = render_editor_document_mob_toml(&document).expect("rendered mob.toml");
        assert!(!mob_toml.contains("dispatch_mode ="));
        assert!(!mob_toml.contains("collection_policy ="));
        // Unauthored output format is schema-aware, not omitted: meerkat
        // defaults steps to json output, which fails free-text steps, so
        // schema-less steps must export an explicit text format.
        assert!(mob_toml.contains(r#"output_format = "text""#));
        assert!(!mob_toml.contains(r#"output_format = "json""#));

        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["dispatchMode"] = json!("one_to_one");
        plan["collection"] = json!("quorum");
        plan["quorum"] = json!(1);
        plan["outputFormat"] = json!("text");
        if let Some(instances) = document.instances.as_array_mut() {
            instances[0]["dispatchMode"] = json!("one_to_one");
            instances[0]["collection"] = json!("quorum");
            instances[0]["quorum"] = json!(1);
            instances[0]["outputFormat"] = json!("text");
        }

        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        let mob_toml = render_editor_document_mob_toml(&document).expect("rendered mob.toml");
        assert!(mob_toml.contains(r#"dispatch_mode = "one_to_one""#));
        assert!(mob_toml.contains(r#"collection_policy = { type = "quorum", n = 1 }"#));
        assert!(mob_toml.contains(r#"output_format = "text""#));

        // An explicit json choice is emitted verbatim: meerkat 0.7.1 resolves
        // an OMITTED format schema-aware (text for schema-less steps), so
        // staying implicit would silently flip the user's explicit json.
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        for step in steps.iter_mut() {
            if step["type"] == "member" {
                step["outputFormat"] = json!("json");
            }
        }
        if let Some(instances) = document.instances.as_array_mut() {
            for instance in instances.iter_mut() {
                instance["outputFormat"] = json!("json");
            }
        }
        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        let mob_toml = render_editor_document_mob_toml(&document).expect("rendered mob.toml");
        assert!(mob_toml.contains(r#"output_format = "json""#));
        assert!(!mob_toml.contains(r#"output_format = "text""#));
    }

    #[test]
    fn rejects_drifting_flow_step_runtime_metadata() {
        let result = import_mobpack(&json!({ "mob_toml": importable_mob_toml() })).expect("import");
        let mut document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["timeoutMs"] = json!(2500);
        plan["allowedTools"] = json!(["comms"]);
        plan["blockedTools"] = json!(["mob"]);
        plan["outputFormat"] = json!("json");
        plan["dispatchMode"] = json!("fan_in");
        plan["collection"] = json!("any");
        plan["quorum"] = Value::Null;
        let validation = validate_document(&document);

        assert!(!validation.ok);
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "graph_member_metadata_mismatch"
                && diagnostic.path.as_deref() == Some("instances[0].timeoutMs")
        }));
    }

    #[test]
    fn validates_step_tool_refs_against_member_profile_tools() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["allowedTools"] = json!(["comms"]);
        let review = steps
            .iter_mut()
            .find(|step| step["id"] == "review")
            .expect("review step");
        review["blockedTools"] = json!(["shell"]);
        let instances = document.instances.as_array_mut().expect("graph instances");
        instances[0]["allowedTools"] = json!(["comms"]);
        instances[1]["blockedTools"] = json!(["shell"]);
        document.mob_toml = document.mob_toml.map(|text| {
            text.replace(
                "message = \"Plan\"",
                "message = \"Plan\"\nallowed_tools = [\"comms\"]",
            )
            .replace(
                "message = \"Review\"\ndepends_on = [\"plan\"]",
                "message = \"Review\"\ndepends_on = [\"plan\"]\nblocked_tools = [\"shell\"]",
            )
        });
        let validation = validate_document(&document);

        assert!(validation.ok, "{:?}", validation.diagnostics);
    }

    #[test]
    fn rejects_member_steps_without_explicit_instruction() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan.as_object_mut()
            .expect("plan object")
            .remove("instruction");
        let review = steps
            .iter_mut()
            .find(|step| step["id"] == "review")
            .expect("review step");
        review["instruction"] = json!(" ");

        let validation = validate_document(&document);

        assert!(!validation.ok);
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_flow_member_instruction"
                && diagnostic.path.as_deref() == Some("flow.steps[1].instruction")
        }));
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_flow_member_instruction"
                && diagnostic.path.as_deref() == Some("flow.steps[2].instruction")
        }));
    }

    #[test]
    fn renderer_does_not_synthesize_missing_member_turn_text() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        for step in steps.iter_mut().filter(|step| step["type"] == "member") {
            step.as_object_mut()
                .expect("member step object")
                .remove("instruction");
        }

        let mob_toml = render_editor_document_mob_toml(&document).expect("rendered mob.toml");

        assert!(!mob_toml.contains("planner turn"));
        assert!(!mob_toml.contains("reviewer turn"));
        assert!(!mob_toml.contains("message ="));
    }

    #[test]
    fn rejects_unknown_step_tool_refs() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["allowedTools"] = json!(["__not_a_mobkit_tool__"]);
        let validation = validate_document(&document);

        assert!(!validation.ok);
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unknown_step_tool_ref"
                && diagnostic.path.as_deref() == Some("flow.steps[1].allowedTools[0]")
                && diagnostic.message.contains("MobKit tool_catalog")
        }));
    }

    #[test]
    fn rejects_unsupported_editor_step_runtime_metadata() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["dependsMode"] = json!("first");
        plan["dispatchMode"] = json!("broadcast");
        plan["collection"] = json!("winner");
        plan["outputFormat"] = json!("xml");
        plan["timeoutMs"] = json!(0);
        let validation = validate_document(&document);

        assert!(!validation.ok);
        for code in [
            "invalid_editor_dependency_mode",
            "invalid_editor_dispatch_mode",
            "invalid_editor_collection_policy",
            "invalid_editor_output_format",
            "invalid_editor_timeout",
        ] {
            assert!(
                validation
                    .diagnostics
                    .iter()
                    .any(|diagnostic| diagnostic.code == code),
                "missing {code}: {:?}",
                validation.diagnostics
            );
        }
    }

    #[test]
    fn rejects_parallel_steps_without_explicit_dispatch_and_collection() {
        let mut document = document_with_real_launch_modes();
        document.flow = json!({
            "name": "missing parallel metadata",
            "steps": [{
                "id": "parallel_missing_metadata",
                "type": "parallel",
                "branches": [{
                    "id": "br_plan",
                    "label": "Plan",
                    "steps": [{
                        "id": "plan",
                        "type": "member",
                        "role": "m_planner",
                        "instruction": "Plan"
                    }]
                }]
            }]
        });
        document.launch_modes = json!([]);

        let validation = validate_document(&document);

        assert!(!validation.ok);
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_parallel_dispatch_mode"
                && diagnostic.path.as_deref() == Some("flow.steps[0].dispatch")
        }));
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_parallel_collection_policy"
                && diagnostic.path.as_deref() == Some("flow.steps[0].collection")
        }));
    }

    #[test]
    fn rejects_invalid_editor_quorum_metadata() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["collection"] = json!("quorum");
        plan["quorum"] = json!(0);
        let validation = validate_document(&document);

        assert!(!validation.ok);
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_editor_collection_quorum"
                && diagnostic.path.as_deref() == Some("flow.steps[1].quorum")
        }));
    }

    #[test]
    fn rejects_non_string_step_tool_refs() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["blockedTools"] = json!(["comms", null]);
        let validation = validate_document(&document);

        assert!(!validation.ok);
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_step_tool_ref"
                && diagnostic.path.as_deref() == Some("flow.steps[1].blockedTools[1]")
        }));
    }

    #[test]
    fn rejects_step_tool_refs_not_enabled_on_member_profile() {
        let mut document = document_with_real_launch_modes();
        let steps = document
            .flow
            .get_mut("steps")
            .and_then(Value::as_array_mut)
            .expect("flow steps");
        let plan = steps
            .iter_mut()
            .find(|step| step["id"] == "plan")
            .expect("plan step");
        plan["allowedTools"] = json!(["mob"]);
        let validation = validate_document(&document);

        assert!(!validation.ok);
        assert!(validation.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "step_tool_not_enabled_on_member"
                && diagnostic.path.as_deref() == Some("flow.steps[1].allowedTools[0]")
        }));
    }

    #[test]
    fn validates_editor_schema_matches_profile_output_schema() {
        let document = document_with_reviewer_output_schema();
        let result = validate_document(&document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_malformed_editor_schema_fields_before_export() {
        let mut document = document_with_reviewer_output_schema();
        if let Some(schemas) = document.schemas.as_array_mut() {
            schemas[0]["fields"] = json!([
                { "id": "f1", "name": "verdict", "type": "enum", "required": true, "enumValues": ["green", "", "green"] },
                { "id": "f2", "name": "verdict", "type": "string", "enumValues": ["stale"] },
                { "id": "f3", "name": "", "type": "mystery" }
            ]);
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        for code in [
            "invalid_editor_schema_enum_value",
            "duplicate_editor_schema_enum_value",
            "duplicate_editor_schema_field",
            "stale_editor_schema_enum_values",
            "invalid_editor_schema_field_name",
            "invalid_editor_schema_field_type",
        ] {
            assert!(
                result
                    .diagnostics
                    .iter()
                    .any(|diagnostic| diagnostic.code == code),
                "missing {code}: {:?}",
                result.diagnostics
            );
        }
    }

    #[test]
    fn rejects_editor_schema_missing_from_document() {
        let mut document = document_with_reviewer_output_schema();
        document.schemas = json!([]);
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_schema_missing"
                && diagnostic.path.as_deref() == Some("members[1].schema")
        }));
    }

    #[test]
    fn rejects_profile_output_schema_missing_from_editor_member() {
        let mut document = document_with_reviewer_output_schema();
        if let Some(members) = document.members.as_array_mut() {
            members[1]["schema"] = json!("");
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_profile_schema_missing"
                && diagnostic.path.as_deref() == Some("members[1].schema")
        }));
    }

    #[test]
    fn rejects_editor_schema_field_drift_from_profile_output_schema() {
        let mut document = document_with_reviewer_output_schema();
        if let Some(schemas) = document.schemas.as_array_mut() {
            schemas[0]["fields"][0]["enumValues"] = json!(["green", "yellow", "red"]);
        }
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "editor_profile_schema_mismatch"
                && diagnostic.path.as_deref() == Some("members[1].schema")
        }));
    }

    #[test]
    fn schema_less_steps_export_text_output_format() {
        // A deployed flow with json-format steps and no schema fails every
        // free-text turn with "malformed JSON output", so the export default
        // must be schema-aware: text without a schema, implicit json with one.
        let document = document_with_expected_schema_ref();
        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        let mob_toml = render_editor_document_mob_toml(&document).expect("rendered mob.toml");
        let plan_block = mob_toml
            .split("[flows.")
            .find(|block| block.contains("message = \"Plan\""))
            .expect("plan step block");
        assert!(
            plan_block.contains(r#"output_format = "text""#),
            "schema-less step must export text output format: {plan_block}"
        );
        let review_block = mob_toml
            .split("[flows.")
            .find(|block| block.contains("message = \"Review\"") && block.contains("steps"))
            .expect("review step block");
        assert!(
            review_block.contains(r#"expected_schema_ref = "schemas/reviewer.json""#),
            "{review_block}"
        );
        assert!(
            !review_block.contains("output_format ="),
            "schema-backed step keeps the implicit json runtime default: {review_block}"
        );
    }

    #[test]
    fn exports_expected_schema_ref_files_as_deployable_archive_members() {
        let mut document = document_with_expected_schema_ref();
        document.flow["steps"][0]["inputParams"] = json!([
            { "id": "p1", "name": "size", "type": "number", "required": true, "enumValues": [] },
            { "id": "p2", "name": "kind", "type": "enum", "required": true, "enumValues": ["docs", "code"] },
            { "id": "p3", "name": "tags", "type": "string[]", "required": false, "enumValues": [] },
            { "id": "p4", "name": "metadata", "type": "object", "required": false, "enumValues": [] }
        ]);
        document.flow["steps"][0]["fields"] =
            json!("size: number, kind: enum, tags: string[]?, metadata: object?");
        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        let result = export_mobpack(&json!({ "document": document })).expect("export");
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        let exported_paths = result
            .source_files
            .iter()
            .map(|file| file.path.as_str())
            .collect::<Vec<_>>();
        assert!(exported_paths.contains(&"manifest.toml"));
        assert!(exported_paths.contains(&"definition.json"));
        assert!(exported_paths.contains(&"mobkit/editor.json"));
        assert!(exported_paths.contains(&"mobkit/mob.toml"));
        assert!(exported_paths.contains(&"schemas/reviewer.json"));
        assert!(exported_paths.contains(&"schemas/main-input.json"));
        let exported_mob_toml = result
            .source_files
            .iter()
            .find(|file| file.path == "mobkit/mob.toml")
            .expect("mob.toml source file");
        assert_eq!(exported_mob_toml.media_type, "text/toml");
        assert_eq!(
            exported_mob_toml.text.as_deref(),
            Some(result.mob_toml.as_str())
        );
        assert_eq!(
            exported_mob_toml.sha256,
            source_file_sha256(result.mob_toml.as_bytes())
        );
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(&result.content_base64)
            .unwrap();
        let mut archive = tar::Archive::new(GzDecoder::new(bytes.as_slice()));
        let mut files = BTreeMap::new();
        for entry in archive.entries().unwrap() {
            let mut entry = entry.unwrap();
            let path = entry.path().unwrap().to_string_lossy().to_string();
            let mut bytes = Vec::new();
            entry.read_to_end(&mut bytes).unwrap();
            files.insert(path, bytes);
        }
        let schema = files
            .get("schemas/reviewer.json")
            .expect("reviewer schema archive member");
        let schema: Value = serde_json::from_slice(schema).expect("schema json");
        assert_eq!(schema["type"], "object");
        assert_eq!(
            schema["properties"]["verdict"]["enum"],
            json!(["green", "red"])
        );
        let input_schema = files
            .get("schemas/main-input.json")
            .expect("main input schema archive member");
        let input_schema: Value = serde_json::from_slice(input_schema).expect("input schema json");
        assert_eq!(input_schema["type"], "object");
        assert_eq!(input_schema["required"], json!(["size", "kind"]));
        assert_eq!(input_schema["properties"]["size"]["type"], "number");
        assert_eq!(
            input_schema["properties"]["kind"]["enum"],
            json!(["docs", "code"])
        );
        assert_eq!(input_schema["properties"]["tags"]["type"], "array");
        assert_eq!(
            input_schema["properties"]["tags"]["items"]["type"],
            "string"
        );
        assert_eq!(input_schema["properties"]["metadata"]["type"], "object");
    }

    #[test]
    fn exports_selected_filesystem_skills_as_packed_mobpack_members() {
        let dir = tempfile::tempdir().expect("tempdir");
        let skill_path = dir.path().join("SKILL.md");
        std::fs::write(&skill_path, "Use the real MobKit platform contract.").expect("write skill");

        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.platform"]);
        document.skill_realms = json!([{
            "id": "local/filesystem",
            "label": "Local filesystem",
            "source": "filesystem",
            "skills": [{
                "id": "mob.platform",
                "label": "MobKit Platform",
                "source": "path",
                "path": skill_path
            }]
        }]);

        let result = export_mobpack(&json!({ "document": document })).expect("export");
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(result.mob_toml.contains(r#"[skills."mob.platform"]"#));
        assert!(result.mob_toml.contains(r#"source = "path""#));
        assert!(
            result
                .mob_toml
                .contains(r#"path = "skills/mob-platform.md""#)
        );
        assert!(
            !result
                .mob_toml
                .contains(dir.path().to_string_lossy().as_ref())
        );
        let exported_skill = result
            .source_files
            .iter()
            .find(|file| file.path == "skills/mob-platform.md")
            .expect("packed skill source file");
        assert_eq!(exported_skill.media_type, "text/markdown");
        assert_eq!(
            exported_skill.text.as_deref(),
            Some("Use the real MobKit platform contract.")
        );

        let bytes = base64::engine::general_purpose::STANDARD
            .decode(&result.content_base64)
            .unwrap();
        let mut archive = tar::Archive::new(GzDecoder::new(bytes.as_slice()));
        let mut files = BTreeMap::new();
        for entry in archive.entries().unwrap() {
            let mut entry = entry.unwrap();
            let path = entry.path().unwrap().to_string_lossy().to_string();
            let mut bytes = Vec::new();
            entry.read_to_end(&mut bytes).unwrap();
            files.insert(path, bytes);
        }
        assert_eq!(
            String::from_utf8(files["skills/mob-platform.md"].clone()).unwrap(),
            "Use the real MobKit platform contract."
        );

        let imported =
            import_mobpack(&json!({ "content_base64": result.content_base64 })).expect("import");
        let imported_skill = imported["document"]["skill_realms"]
            .as_array()
            .unwrap()
            .iter()
            .flat_map(|realm| realm["skills"].as_array().unwrap())
            .find(|skill| skill["id"] == "mob.platform")
            .expect("imported skill");
        assert_eq!(
            imported_skill["content"],
            json!("Use the real MobKit platform contract.")
        );
    }

    #[test]
    fn exports_editor_inline_skills_as_real_mobpack_definitions() {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.editor.review"]);
        document.skill_realms = json!([{
            "id": "mobkit/editor-inline",
            "label": "This mobpack",
            "source": "editor",
            "skills": [{
                "id": "mob.editor.review",
                "label": "Editor Review Skill",
                "source": "inline",
                "content": "Review the mob output against the editor-authored acceptance contract."
            }]
        }]);

        let result = export_mobpack(&json!({ "document": document })).expect("export");

        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(result.mob_toml.contains(r#"[skills."mob.editor.review"]"#));
        assert!(result.mob_toml.contains(r#"source = "inline""#));
        assert!(
            result
                .mob_toml
                .contains("Review the mob output against the editor-authored acceptance contract.")
        );
    }

    #[test]
    fn rejects_duplicate_skill_ids_across_editor_realms() {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.editor.review"]);
        document.skill_realms = json!([
            {
                "id": "mobkit/editor-inline",
                "label": "This mobpack",
                "source": "editor",
                "skills": [{
                    "id": "mob.editor.review",
                    "label": "Review",
                    "source": "inline",
                    "content": "Review the output."
                }]
            },
            {
                "id": "imported/mob.toml",
                "label": "Imported",
                "source": "import",
                "skills": [{
                    "id": "mob.editor.review",
                    "label": "Conflicting Review",
                    "source": "inline",
                    "content": "Different skill definition."
                }]
            }
        ]);

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "duplicate_skill_id"
                && diagnostic.path.as_deref() == Some("skill_realms[1].skills[0].id")
        }));
    }

    #[test]
    fn rejects_selected_skill_sources_that_are_not_mobkit_skill_sources() {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.editor.review"]);
        document.skill_realms = json!([{
            "id": "mobkit/editor-inline",
            "label": "This mobpack",
            "source": "editor",
            "skills": [{
                "id": "mob.editor.review",
                "label": "Review",
                "source": "catalog",
                "content": "Review the output."
            }]
        }]);

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_selected_skill_source"
                && diagnostic.path.as_deref()
                    == Some("skill_realms.skills[mob.editor.review].source")
        }));
        assert!(export_mobpack(&json!({ "document": document })).is_err());
    }

    #[test]
    fn rejects_selected_inline_skills_without_real_content() {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.editor.review"]);
        document.skill_realms = json!([{
            "id": "mobkit/editor-inline",
            "label": "This mobpack",
            "source": "editor",
            "skills": [{
                "id": "mob.editor.review",
                "label": "Review",
                "source": "inline",
                "content": "   "
            }]
        }]);

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "empty_selected_inline_skill"
                && diagnostic.path.as_deref()
                    == Some("skill_realms.skills[mob.editor.review].content")
        }));
        assert!(export_mobpack(&json!({ "document": document })).is_err());
    }

    #[test]
    fn rejects_starter_skill_realm_sources() {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.editor.review"]);
        document.skill_realms = json!([{
            "id": "mobkit/starters",
            "label": "Starter skills",
            "source": "starter",
            "skills": [{
                "id": "mob.editor.review",
                "label": "Review",
                "source": "starter",
                "origin": "mobkit/starters",
                "content": "Prototype starter skill."
            }]
        }]);

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_starter_skill_realm_id"
                && diagnostic.path.as_deref() == Some("skill_realms[0].id")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_starter_skill_realm_source"
                && diagnostic.path.as_deref() == Some("skill_realms[0].source")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_starter_skill_source"
                && diagnostic.path.as_deref() == Some("skill_realms[0].skills[0].source")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_starter_skill_origin"
                && diagnostic.path.as_deref() == Some("skill_realms[0].skills[0].origin")
        }));
    }

    #[test]
    fn import_rejects_starter_skill_realm_projection() {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.editor.review"]);
        document.skill_realms = json!([{
            "id": "mobkit/starters",
            "label": "Starter skills",
            "skills": [{
                "id": "mob.editor.review",
                "label": "Review",
                "source": "inline",
                "origin": "mobkit/starters",
                "content": "Prototype starter skill."
            }]
        }]);

        let err = import_mobpack(&json!({ "document": document })).expect_err("starter import");

        assert!(
            err.contains("cannot import prototype starter skill data"),
            "{err}"
        );
        assert!(err.contains("invalid_starter_skill_realm_id"), "{err}");
    }

    #[test]
    fn import_reports_api_backed_source_metadata() {
        let toml = importable_mob_toml();
        let result = import_mobpack(&json!({
            "source_name": "review.mob.toml",
            "source_media_type": "application/vnd.mobkit.editor-toml",
            "mob_toml": toml,
        }))
        .expect("import mob.toml");

        assert_eq!(result["source"], "mobkit/mobpacks/import:mob.toml");
        assert_eq!(result["source_label"], "review.mob.toml");
        assert_eq!(
            result["source_media_type"],
            "application/vnd.mobkit.editor-toml"
        );
        assert_eq!(result["validation"]["ok"], json!(true));
    }

    #[test]
    fn rejects_invalid_skill_realm_shape() {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.skill_realms = json!([{ "id": "", "skills": { "id": "mob.bad" } }]);

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_skill_realm_id"
                && diagnostic.path.as_deref() == Some("skill_realms[0].id")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_skill_realm_skills"
                && diagnostic.path.as_deref() == Some("skill_realms[0].skills")
        }));
    }

    #[test]
    fn rejects_realm_profile_member_binding_for_deployable_packs() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let members = document.members.as_array_mut().expect("members");
        members[0]["profileBinding"] = json!("realm_profile");
        members[0]["realmProfile"] = json!("planner-v2");

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "unsupported_realm_profile_pack_binding"
                && diagnostic.path.as_deref() == Some("members[0].profileBinding")
        }));
        assert!(export_mobpack(&json!({ "document": document })).is_err());
    }

    #[test]
    fn imports_realm_profile_binding_into_editor_member() {
        let toml = r#"
[mob]
id = "realm-profile-import"

[profiles.worker]
realm_profile = "worker-v2"

[flows.main]
description = "Realm profile import"

[flows.main.steps.work]
role = "worker"
message = "Work"

[flows.main.root.nodes.node_work]
kind = "step"
step_id = "work"
depends_on = []
depends_on_mode = "all"
"#;

        let result = import_mobpack(&json!({ "mob_toml": toml })).expect("import");
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        let member = document
            .members
            .as_array()
            .expect("members")
            .iter()
            .find(|member| member["role"] == "worker")
            .expect("worker member");

        assert_eq!(member["profileBinding"], json!("realm_profile"));
        assert_eq!(member["realmProfile"], json!("worker-v2"));
        assert_eq!(result["validation"]["ok"], json!(false));
        assert!(
            result["validation"]["diagnostics"]
                .as_array()
                .unwrap()
                .iter()
                .any(|diagnostic| diagnostic["code"] == "unsupported_realm_profile_pack_binding")
        );
    }

    #[test]
    fn rejects_invalid_or_ambiguous_editor_profile_bindings() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let members = document.members.as_array_mut().expect("members");
        members[0]["profileBinding"] = json!("catalog");
        members[1]["profileBinding"] = json!("realm_profile");

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_profile_binding"
                && diagnostic.path.as_deref() == Some("members[0].profileBinding")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_realm_profile_ref"
                && diagnostic.path.as_deref() == Some("members[1].realmProfile")
        }));
    }

    #[test]
    fn rejects_members_without_explicit_profile_binding_and_runtime_mode() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        {
            let members = document.members.as_array_mut().expect("members");
            members[0].as_object_mut().unwrap().remove("profileBinding");
            members[0].as_object_mut().unwrap().remove("runtimeMode");
        }

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_profile_binding"
                && diagnostic.path.as_deref() == Some("members[0].profileBinding")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_runtime_mode"
                && diagnostic.path.as_deref() == Some("members[0].runtimeMode")
        }));

        let members = document.members.as_array_mut().expect("members");
        members[0]["profileBinding"] = json!("inline");
        members[0]["runtimeMode"] = json!("daemon");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "invalid_runtime_mode"
                && diagnostic.path.as_deref() == Some("members[0].runtimeMode")
        }));
    }

    #[test]
    fn rejects_missing_or_duplicate_editor_member_ids() {
        let mut document = document_with_unused_reviewer_profile();
        {
            let members = document.members.as_array_mut().expect("members");
            members[0].as_object_mut().unwrap().remove("id");
            members[1]["id"] = json!("m_reviewer");
            members.push(json!({
                "id": "m_reviewer",
                "name": "reviewer_copy",
                "role": "reviewer_copy",
                "model": "gpt-5.5",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "tools": [],
                "skills": []
            }));
        }

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "missing_editor_member_id"
                && diagnostic.path.as_deref() == Some("members[0].id")
        }));
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "duplicate_editor_member_id"
                && diagnostic.path.as_deref() == Some("members[2].id")
        }));
    }

    #[test]
    fn rejects_inline_member_with_realm_profile_ref() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let members = document.members.as_array_mut().expect("members");
        members[0]["profileBinding"] = json!("inline");
        members[0]["realmProfile"] = json!("planner-v2");

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "conflicting_profile_binding"
                && diagnostic.path.as_deref() == Some("members[0].realmProfile")
        }));
    }

    #[test]
    fn validates_selected_filesystem_skill_file_exists_before_export() {
        let dir = tempfile::tempdir().expect("tempdir");
        let missing_skill_path = dir.path().join("missing.md");
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.missing"]);
        document.skill_realms = json!([{
            "id": "local/filesystem",
            "label": "Local filesystem",
            "source": "filesystem",
            "skills": [{
                "id": "mob.missing",
                "label": "Missing skill",
                "source": "path",
                "path": missing_skill_path
            }]
        }]);

        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "selected_skill_file_unreadable"
                && diagnostic.path.as_deref() == Some("skill_realms.skills[mob.missing].path")
        }));
    }

    #[test]
    fn validates_imported_packed_path_skill_without_original_filesystem_path() {
        let dir = tempfile::tempdir().expect("tempdir");
        let skill_path = dir.path().join("SKILL.md");
        std::fs::write(&skill_path, "Packed skill content.").expect("write skill");

        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        document.members.as_array_mut().unwrap()[0]["skills"] = json!(["mob.packed"]);
        document.skill_realms = json!([{
            "id": "local/filesystem",
            "label": "Local filesystem",
            "source": "filesystem",
            "skills": [{
                "id": "mob.packed",
                "label": "Packed skill",
                "source": "path",
                "path": skill_path
            }]
        }]);
        let exported = export_mobpack(&json!({ "document": document })).expect("export");
        std::fs::remove_file(&skill_path).expect("remove original skill");

        let imported =
            import_mobpack(&json!({ "content_base64": exported.content_base64 })).expect("import");
        let imported_document: MobpackDocument =
            serde_json::from_value(imported["document"].clone()).expect("document");
        let result = validate_document(&imported_document);

        assert!(result.ok, "{:?}", result.diagnostics);
    }

    #[test]
    fn rejects_expected_schema_ref_without_profile_output_schema() {
        let mut document = valid_document();
        document.mob_toml = Some(
            r#"
[mob]
id = "schema-missing"

[profiles.reviewer]
model = "gpt-5.5"
skills = []
peer_description = "Reviewer"

[flows.review]
description = "Review flow"

[flows.review.steps.review]
role = "reviewer"
message = "Review"
expected_schema_ref = "schemas/reviewer.json"
"#
            .to_string(),
        );
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "expected_schema_ref_missing_profile_schema"
                && diagnostic.path.as_deref()
                    == Some("flows.review.steps.review.expected_schema_ref")
        }));
    }

    #[test]
    fn rejects_expected_schema_ref_outside_archive() {
        let mut document = document_with_expected_schema_ref();
        document.flow["steps"][2]["expectedSchemaRef"] = json!("../reviewer.json");
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(
            result
                .diagnostics
                .iter()
                .any(|diagnostic| { diagnostic.code == "invalid_expected_schema_ref" })
        );
    }

    #[test]
    fn rejects_conflicting_expected_schema_ref_exports() {
        let mut document = valid_document();
        document.mob_toml = Some(
            r#"
[mob]
id = "schema-conflict"

[profiles.alpha]
model = "gpt-5.5"
skills = []
peer_description = "Alpha"

[profiles.alpha.output_schema]
type = "object"
required = ["verdict"]

[profiles.alpha.output_schema.properties]

[profiles.alpha.output_schema.properties.verdict]
type = "string"
enum = ["green"]

[profiles.beta]
model = "gpt-5.5"
skills = []
peer_description = "Beta"

[profiles.beta.output_schema]
type = "object"
required = ["verdict"]

[profiles.beta.output_schema.properties]

[profiles.beta.output_schema.properties.verdict]
type = "string"
enum = ["red"]

[flows.review]
description = "Review flow"

[flows.review.steps.alpha]
role = "alpha"
message = "Alpha"
expected_schema_ref = "schemas/shared.json"

[flows.review.steps.beta]
role = "beta"
message = "Beta"
expected_schema_ref = "schemas/shared.json"
"#
            .to_string(),
        );
        let result = validate_document(&document);

        assert!(!result.ok);
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "expected_schema_ref_conflict"
                && diagnostic.path.as_deref() == Some("flows.review.steps.beta.expected_schema_ref")
        }));
    }

    #[test]
    fn exports_mobpack_payload_without_runtime_mutation() {
        let params = match std::env::var("MOBKIT_MOBPACK_DOCUMENT_IN") {
            Ok(path) => serde_json::from_str(
                &std::fs::read_to_string(path).expect("read editor mobpack document fixture"),
            )
            .expect("parse editor mobpack document fixture"),
            Err(_) => json!({ "document": valid_document() }),
        };
        let result = export_mobpack(&params).expect("export");
        assert_eq!(result.media_type, MOBPACK_MEDIA_TYPE);
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(result.mob_toml.contains("[flows."));
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(&result.content_base64)
            .unwrap();
        if let Ok(path) = std::env::var("MOBKIT_MOBPACK_EXPORT_OUT") {
            std::fs::write(path, &bytes).expect("write exported mobpack fixture");
        }
        let mut archive = tar::Archive::new(GzDecoder::new(bytes.as_slice()));
        let names = archive
            .entries()
            .unwrap()
            .map(|entry| entry.unwrap().path().unwrap().to_string_lossy().to_string())
            .collect::<Vec<_>>();
        assert!(names.contains(&"manifest.toml".to_string()));
        assert!(names.contains(&"definition.json".to_string()));
        assert!(names.contains(&"mobkit/editor.json".to_string()));
    }

    #[test]
    fn imports_exported_archive_with_packed_mob_toml_source() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let exported = export_mobpack(&json!({ "document": document })).expect("export");
        let imported = import_mobpack(&json!({
            "content_base64": exported.content_base64
        }))
        .expect("import");
        let imported_document: MobpackDocument =
            serde_json::from_value(imported["document"].clone()).expect("document");

        assert_eq!(
            imported_document.mob_toml.as_deref(),
            Some(exported.mob_toml.as_str())
        );
        assert!(imported["validation"]["ok"].as_bool().unwrap_or(false));
    }

    #[test]
    fn archive_import_prefers_packed_mob_toml_over_stale_editor_json() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let packed_mob_toml =
            render_editor_document_mob_toml(&document).expect("render packed mob.toml");
        document.mob_toml = Some(
            r#"
[mob]
id = "stale-editor-json"

[profiles.ghost]
model = "gpt-5.5"
"#
            .to_string(),
        );
        let editor_json = serde_json::to_vec_pretty(&json!({
            "schema_version": MOBPACK_SCHEMA_VERSION,
            "media_type": MOBPACK_MEDIA_TYPE,
            "document": document,
        }))
        .expect("editor json");

        let encoder = GzEncoder::new(Vec::new(), Compression::default());
        let mut archive = Builder::new(encoder);
        append_archive_file(
            &mut archive,
            "manifest.toml",
            b"surfaces = [\"cli\"]\n\n[mobpack]\nname = \"stale-editor-json\"\n",
        )
        .expect("manifest");
        append_archive_file(&mut archive, "mobkit/editor.json", &editor_json).expect("editor json");
        append_archive_file(&mut archive, "mobkit/mob.toml", packed_mob_toml.as_bytes())
            .expect("mob toml");
        let encoder = archive.into_inner().expect("archive");
        let bytes = encoder.finish().expect("gzip");

        let imported = import_mobpack(&json!({
            "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes)
        }))
        .expect("import");
        let imported_document: MobpackDocument =
            serde_json::from_value(imported["document"].clone()).expect("document");

        assert_eq!(
            imported_document.mob_toml.as_deref(),
            Some(packed_mob_toml.as_str())
        );
        assert!(imported["validation"]["ok"].as_bool().unwrap_or(false));
    }

    #[test]
    fn archive_import_reprojects_stale_editor_json_from_packed_mob_toml() {
        let mut document = document_with_real_launch_modes();
        document.mob_toml = None;
        let packed_mob_toml =
            render_editor_document_mob_toml(&document).expect("render packed mob.toml");
        document.members.as_array_mut().unwrap()[0]["model"] = json!("stale-model");
        document.flow["steps"].as_array_mut().unwrap()[1]["instruction"] = json!("Stale plan");
        let editor_json = serde_json::to_vec_pretty(&json!({
            "schema_version": MOBPACK_SCHEMA_VERSION,
            "media_type": MOBPACK_MEDIA_TYPE,
            "document": document,
        }))
        .expect("editor json");

        let encoder = GzEncoder::new(Vec::new(), Compression::default());
        let mut archive = Builder::new(encoder);
        append_archive_file(
            &mut archive,
            "manifest.toml",
            b"surfaces = [\"cli\"]\n\n[mobpack]\nname = \"stale-editor-projection\"\n",
        )
        .expect("manifest");
        append_archive_file(&mut archive, "mobkit/editor.json", &editor_json).expect("editor json");
        append_archive_file(&mut archive, "mobkit/mob.toml", packed_mob_toml.as_bytes())
            .expect("mob toml");
        let encoder = archive.into_inner().expect("archive");
        let bytes = encoder.finish().expect("gzip");

        let imported = import_mobpack(&json!({
            "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes)
        }))
        .expect("import");
        let imported_document: MobpackDocument =
            serde_json::from_value(imported["document"].clone()).expect("document");
        let members = imported_document.members.as_array().expect("members");
        let planner = members
            .iter()
            .find(|member| member["role"] == "planner")
            .expect("planner");
        let flow_json = serde_json::to_string(&imported_document.flow).expect("flow json");

        assert_eq!(planner["model"], json!("gpt-5.5"));
        assert!(flow_json.contains("Plan"), "{flow_json}");
        assert!(!flow_json.contains("Stale plan"), "{flow_json}");
        assert_eq!(
            imported_document.mob_toml.as_deref(),
            Some(packed_mob_toml.as_str())
        );
        assert!(imported["validation"]["ok"].as_bool().unwrap_or(false));
    }

    #[test]
    fn plans_rkat_deploy_without_executing_by_default() {
        let dir = tempfile::tempdir().expect("tempdir");
        let pack_path = dir.path().join("preview-proof.mobpack");
        let preview = deploy_command_preview(&json!({
            "document": valid_document(),
            "pack_path": pack_path,
            "prompt": "Reply with exactly OK."
        }))
        .expect("deploy command preview");
        let result = deploy_mobpack(&json!({
            "document": valid_document(),
            "pack_path": pack_path,
            "output_dir": dir.path(),
            "prompt": "Reply with exactly OK."
        }))
        .expect("deploy plan");
        assert!(!result.executed);
        assert!(!result.success);
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(!std::path::Path::new(&result.pack_path).exists());
        let exported = export_mobpack(&json!({ "document": valid_document() })).expect("export");
        let packed_bytes = base64::engine::general_purpose::STANDARD
            .decode(exported.content_base64.as_bytes())
            .expect("decode exported pack");
        assert_eq!(result.pack_sha256, source_file_sha256(&packed_bytes));
        assert_eq!(&result.argv[0..3], ["rkat", "mob", "run"]);
        assert_eq!(preview.argv, result.argv);
        assert_eq!(preview.command, result.command);
        assert_eq!(preview.deploy_command, "rkat mob run");
        assert_eq!(preview.source, "meerkat_mobkit::mobpack::deploy_argv");
        assert_eq!(preview.filename, "review-pack.mobpack");
        assert!(
            preview.validation.ok,
            "{:?}",
            preview.validation.diagnostics
        );
        assert!(result.command.contains("rkat mob run"));
        assert!(result.command.contains("Reply with exactly OK."));
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "ok"
                && row.head == "MobKit mobpack validates"
                && row.meta == "meerkat_mob::validate_definition"
        }));
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "warn"
                && row.head == "Deploy plan ready"
                && row.sub.contains("rkat mob run")
                && row.meta == result.pack_path
        }));
    }

    fn document_with_input_params(params: Value) -> MobpackDocument {
        let mut document = valid_document();
        document.flow = json!({
            "steps": [{
                "id": "input-1",
                "type": "input",
                "task": "Run the mobpack flow.",
                "fields": "",
                "inputParams": params
            }]
        });
        document
    }

    #[test]
    fn deploy_argv_omits_prompt_when_typed_input_schema_lacks_prompt_field() {
        // Typed input params materialize `schemas/main-input.json` with
        // `additionalProperties: false`; `--prompt` is rkat sugar for
        // `--param prompt=…`, so injecting it would fail rkat's input
        // validation before anything runs.
        let document = document_with_input_params(json!([
            { "id": "ticket", "name": "ticket", "type": "string", "required": true }
        ]));
        let bindings = deploy_input_bindings(
            &document,
            &json!({ "input_params": { "ticket": "ABC-123", "count": 2 } }),
        );
        let argv = deploy_argv(
            "rkat",
            &document.deploy,
            Path::new("typed.mobpack"),
            "Reply with exactly OK.",
            &bindings,
        );
        assert!(
            !argv.iter().any(|arg| arg.starts_with("--prompt")),
            "must not inject --prompt against a schema without a prompt field: {argv:?}"
        );
        // Values are JSON-encoded so rkat's parse-as-JSON-first `--param`
        // handling round-trips the type exactly.
        assert!(
            argv.windows(2)
                .any(|pair| pair[0] == "--param" && pair[1] == "ticket=\"ABC-123\""),
            "{argv:?}"
        );
        assert!(
            argv.windows(2)
                .any(|pair| pair[0] == "--param" && pair[1] == "count=2"),
            "{argv:?}"
        );
    }

    #[test]
    fn deploy_argv_sends_prompt_when_schema_accepts_prompt_key() {
        let document = document_with_input_params(json!([
            { "id": "prompt", "name": "prompt", "type": "string", "required": true }
        ]));
        let bindings = deploy_input_bindings(&document, &json!({}));
        let argv = deploy_argv(
            "rkat",
            &document.deploy,
            Path::new("prompted.mobpack"),
            "Summarize the run.",
            &bindings,
        );
        // Single-token hyphen-safe `--prompt=<text>` form.
        assert!(
            argv.iter().any(|arg| arg == "--prompt=Summarize the run."),
            "{argv:?}"
        );
    }

    #[test]
    fn deploy_argv_keeps_prompt_for_packs_without_typed_input_params() {
        let document = valid_document();
        let bindings = deploy_input_bindings(&document, &json!({}));
        let argv = deploy_argv(
            "rkat",
            &document.deploy,
            Path::new("plain.mobpack"),
            "Reply with exactly OK.",
            &bindings,
        );
        // Single-token hyphen-safe `--prompt=<text>` form.
        assert!(
            argv.iter()
                .any(|arg| arg == "--prompt=Reply with exactly OK."),
            "{argv:?}"
        );
    }

    #[test]
    fn deploy_argv_prefers_supplied_prompt_param_over_prompt_flag() {
        let document = document_with_input_params(json!([
            { "id": "prompt", "name": "prompt", "type": "string", "required": true }
        ]));
        let bindings = deploy_input_bindings(
            &document,
            &json!({ "input_params": { "prompt": "explicit binding" } }),
        );
        let argv = deploy_argv(
            "rkat",
            &document.deploy,
            Path::new("prompted.mobpack"),
            "fallback prompt",
            &bindings,
        );
        assert!(
            !argv.iter().any(|arg| arg.starts_with("--prompt")),
            "explicit prompt binding must not be duplicated by --prompt: {argv:?}"
        );
        assert!(
            argv.windows(2)
                .any(|pair| pair[0] == "--param" && pair[1] == "prompt=\"explicit binding\""),
            "{argv:?}"
        );
    }

    #[test]
    #[cfg(unix)]
    fn validate_mobpack_runs_rkat_validate_when_requested() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("tempdir");
        let rkat_path = dir.path().join("rkat");
        let args_path = dir.path().join("rkat-args.txt");
        let script = format!(
            "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\necho 'valid\\tfake-sha'\n",
            args_path.to_string_lossy()
        );
        std::fs::write(&rkat_path, script).expect("write fake rkat");
        let mut perms = std::fs::metadata(&rkat_path)
            .expect("fake rkat metadata")
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&rkat_path, perms).expect("chmod fake rkat");

        let result = validate_mobpack(&json!({
            "document": valid_document(),
            "rkat_validate": true,
            "rkat_bin": rkat_path,
            "validation_output_dir": dir.path()
        }))
        .expect("rkat validate");

        assert!(result.ok, "{:?}", result.diagnostics);
        assert_eq!(result.validation_source, "rkat mob validate");
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "ok"
                && row.head == "rkat mob validate executed"
                && row.sub.contains("valid")
        }));
        let args = std::fs::read_to_string(args_path).expect("read fake rkat args");
        let args = args.lines().collect::<Vec<_>>();
        assert_eq!(&args[0..2], ["mob", "validate"]);
        assert!(
            args.get(2).is_some_and(
                |path| path.ends_with(".mobpack") && std::path::Path::new(path).exists()
            ),
            "{args:?}"
        );
        // valid_document() carries no deploy settings, so validation runs
        // under the schema-default trust policy (rkat 0.7 defaults to strict,
        // which would reject every unsigned editor-authored pack).
        assert_eq!(
            &args[3..],
            ["--trust-policy", DEFAULT_DEPLOY_TRUST_POLICY],
            "{args:?}"
        );
    }

    #[test]
    #[cfg(unix)]
    fn validate_mobpack_threads_document_trust_policy_into_rkat_validate() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("tempdir");
        let rkat_path = dir.path().join("rkat");
        let args_path = dir.path().join("rkat-args.txt");
        let script = format!(
            "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\necho 'valid\\tfake-sha'\n",
            args_path.to_string_lossy()
        );
        std::fs::write(&rkat_path, script).expect("write fake rkat");
        let mut perms = std::fs::metadata(&rkat_path)
            .expect("fake rkat metadata")
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&rkat_path, perms).expect("chmod fake rkat");

        let mut document = valid_document();
        document.deploy = json!({
            "command": "rkat mob run",
            "trust_policy": "strict"
        });
        let result = validate_mobpack(&json!({
            "document": document,
            "rkat_validate": true,
            "rkat_bin": rkat_path,
            "validation_output_dir": dir.path()
        }))
        .expect("rkat validate");

        assert!(result.ok, "{:?}", result.diagnostics);
        let args = std::fs::read_to_string(args_path).expect("read fake rkat args");
        let args = args.lines().collect::<Vec<_>>();
        assert_eq!(&args[0..2], ["mob", "validate"]);
        assert_eq!(
            &args[3..],
            ["--trust-policy", "strict"],
            "rkat mob validate must run under the document's pinned deploy trust policy"
        );
    }

    #[test]
    #[cfg(unix)]
    fn apply_operation_runs_rkat_validate_when_requested() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("tempdir");
        let rkat_path = dir.path().join("rkat");
        let args_path = dir.path().join("apply-rkat-args.txt");
        let script = format!(
            "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\necho 'valid\\tapply-sha'\n",
            args_path.to_string_lossy()
        );
        std::fs::write(&rkat_path, script).expect("write fake rkat");
        let mut perms = std::fs::metadata(&rkat_path)
            .expect("fake rkat metadata")
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&rkat_path, perms).expect("chmod fake rkat");

        let result = apply_mobpack_authoring_operation(&json!({
            "document": valid_document(),
            "operation": {
                "type": "update_deploy_settings",
                "field": "prompt",
                "value": "Validate the operation-mutated document."
            },
            "rkat_validate": true,
            "rkat_bin": rkat_path,
            "validation_output_dir": dir.path()
        }))
        .expect("rkat-backed apply operation");

        assert!(result["ok"].as_bool().unwrap_or(false), "{result:#?}");
        assert_eq!(
            result["validation"]["validation_source"],
            json!("rkat mob validate")
        );
        assert!(
            result["validation"]["display_rows"]
                .as_array()
                .expect("display rows")
                .iter()
                .any(|row| row["head"] == "rkat mob validate executed"
                    && row["sub"].as_str().is_some_and(|sub| sub.contains("valid")))
        );
        let args = std::fs::read_to_string(args_path).expect("read fake rkat args");
        let args = args.lines().collect::<Vec<_>>();
        assert_eq!(&args[0..2], ["mob", "validate"]);
        assert!(
            args.get(2).is_some_and(
                |path| path.ends_with(".mobpack") && std::path::Path::new(path).exists()
            ),
            "{args:?}"
        );
    }

    #[test]
    fn validate_document_with_rkat_skips_when_rkat_unavailable() {
        let document = valid_document();
        let validation = validate_document(&document);
        assert!(validation.ok, "{validation:#?}");
        let result = validate_document_with_rkat(
            &json!({ "rkat_bin": "definitely-not-a-real-rkat-binary" }),
            &document,
            validation,
        )
        .expect("rkat-unavailable validation");
        assert!(result.ok, "{result:#?}");
        assert!(
            result
                .display_rows
                .iter()
                .any(|row| row.kind == "warn" && row.head == "rkat mob validate skipped"),
            "{result:#?}"
        );
    }

    #[test]
    fn insert_member_step_into_parallel_lane_keeps_launch_modes_valid() {
        // Mirror the editor lifecycle: each flow operation is followed by a
        // graph projection of the resulting document, like the Flow Editor
        // shell does after Basic-mode edits.
        fn project(document: &Value) -> (Value, Value) {
            let projection = graph_projection_mobpack(&json!({ "document": document }))
                .expect("graph projection");
            let mut merged = document.clone();
            merged["instances"] = projection["instances"].clone();
            merged["edges"] = projection["edges"].clone();
            merged["frames"] = projection["frames"].clone();
            (merged, projection["validation"].clone())
        }

        let template = blank_mobpack_template();
        let mut document = template["document"].clone();
        assert!(document.is_object(), "blank template document missing");
        // mobkit/mobpacks/create clears the template mob_toml snapshot.
        document["mob_toml"] = Value::Null;

        let inserted_parallel = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_flow_step",
                "pick": { "kind": "parallel" },
                "lane_ref": { "lane": "main", "index": 1 }
            }
        }))
        .expect("insert parallel step");
        let (parallel_document, parallel_validation) = project(&inserted_parallel["document"]);
        assert!(
            parallel_validation["ok"].as_bool().unwrap_or(false),
            "{parallel_validation:#?}"
        );
        let parallel_id = inserted_parallel["selection"]["id"]
            .as_str()
            .expect("parallel selection id")
            .to_string();
        let branch_id = parallel_document["flow"]["steps"]
            .as_array()
            .and_then(|steps| {
                steps
                    .iter()
                    .find(|step| step["id"].as_str() == Some(parallel_id.as_str()))
            })
            .and_then(|step| step["branches"][0]["id"].as_str())
            .expect("first parallel branch id")
            .to_string();

        let inserted_member = apply_mobpack_authoring_operation(&json!({
            "document": parallel_document,
            "operation": {
                "type": "insert_flow_step",
                "pick": { "kind": "member", "id": "m_worker" },
                "lane_ref": { "parentId": parallel_id, "branchId": branch_id, "index": 0 }
            }
        }))
        .expect("insert member step into parallel lane");
        let (member_document, member_validation) = project(&inserted_member["document"]);
        let launch_codes = [
            "missing_step_launch_mode",
            "unknown_launch_step",
            "duplicate_launch_step",
            "missing_launch_step",
        ];
        assert!(
            member_validation["diagnostics"]
                .as_array()
                .expect("diagnostics array")
                .iter()
                .all(|diagnostic| !launch_codes
                    .contains(&diagnostic["code"].as_str().unwrap_or_default())),
            "member step in a parallel lane must keep launch_modes consistent: {member_validation:#?}"
        );
        let member_step_id = inserted_member["selection"]["id"]
            .as_str()
            .expect("member selection id");
        let launch_entries = member_document["launch_modes"]
            .as_array()
            .expect("launch modes array");
        assert!(
            launch_entries
                .iter()
                .any(|entry| entry["step_id"].as_str() == Some(member_step_id)
                    && entry["member_id"].as_str() == Some("m_worker")),
            "{launch_entries:#?}"
        );

        // With an instruction filled in (the one remaining draft diagnostic),
        // the document must validate end to end.
        let instructed = apply_mobpack_authoring_operation(&json!({
            "document": member_document,
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": member_step_id,
                "action": "set_instruction",
                "instruction": "Handle the branch task."
            }
        }))
        .expect("set member step instruction");
        let (instructed_document, instructed_validation) = project(&instructed["document"]);
        assert!(
            instructed_validation["ok"].as_bool().unwrap_or(false),
            "{instructed_validation:#?}"
        );

        let deleted = apply_mobpack_authoring_operation(&json!({
            "document": instructed_document,
            "operation": {
                "type": "delete_flow_step",
                "step_id": member_step_id
            }
        }))
        .expect("delete member step");
        let (deleted_document, deleted_validation) = project(&deleted["document"]);
        assert!(
            deleted_validation["ok"].as_bool().unwrap_or(false),
            "{deleted_validation:#?}"
        );
        let remaining = deleted_document["launch_modes"]
            .as_array()
            .expect("launch modes array after delete");
        assert!(
            remaining
                .iter()
                .all(|entry| entry["step_id"].as_str() != Some(member_step_id)),
            "deleting a member step must drop its launch mode entry: {remaining:#?}"
        );
    }

    #[test]
    fn free_text_edits_preserve_user_whitespace() {
        // Live inputs re-render from the echoed document on every keystroke:
        // if the server trims, a trailing space is erased the moment it is
        // typed and spaces become impossible to enter.
        let template = blank_mobpack_template();
        let document = template["document"].clone();
        let step_id = document["flow"]["steps"]
            .as_array()
            .expect("flow steps array")
            .first()
            .and_then(|step| step["id"].as_str())
            .expect("first flow step id")
            .to_string();

        let task = "Translate to French ";
        let with_task = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": step_id,
                "action": "set_task",
                "task": task
            }
        }))
        .expect("set task with trailing space");
        let echoed_task = with_task["document"]["flow"]["steps"]
            .as_array()
            .expect("steps after set_task")
            .iter()
            .find(|step| step["id"].as_str() == Some(step_id.as_str()))
            .and_then(|step| step["task"].as_str())
            .expect("task after set_task");
        assert_eq!(
            echoed_task, task,
            "set_task must round-trip byte-identically"
        );

        let instruction = "Format the translations as a table ";
        let with_instruction = apply_mobpack_authoring_operation(&json!({
            "document": with_task["document"],
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": step_id,
                "action": "set_instruction",
                "instruction": instruction
            }
        }))
        .expect("set instruction with trailing space");
        let echoed_instruction = with_instruction["document"]["flow"]["steps"]
            .as_array()
            .expect("steps after set_instruction")
            .iter()
            .find(|step| step["id"].as_str() == Some(step_id.as_str()))
            .and_then(|step| step["instruction"].as_str())
            .expect("instruction after set_instruction");
        assert_eq!(
            echoed_instruction, instruction,
            "set_instruction must round-trip byte-identically"
        );

        let prompt = "Hello world ";
        let with_prompt = apply_mobpack_authoring_operation(&json!({
            "document": with_instruction["document"],
            "operation": {
                "type": "update_deploy_settings",
                "field": "prompt",
                "value": prompt
            }
        }))
        .expect("set deploy prompt with trailing space");
        assert_eq!(
            with_prompt["document"]["deploy"]["prompt"].as_str(),
            Some(prompt),
            "deploy prompt must round-trip byte-identically"
        );

        // Blank-only free text is still rejected, matching the empty-field rule.
        let blank_prompt = apply_mobpack_authoring_operation(&json!({
            "document": with_prompt["document"],
            "operation": {
                "type": "update_deploy_settings",
                "field": "prompt",
                "value": "   "
            }
        }));
        assert!(
            blank_prompt.is_err(),
            "whitespace-only prompt must be rejected"
        );
    }

    fn adaptive_step_from_document(document: &Value, step_id: &str) -> Value {
        document["flow"]["steps"]
            .as_array()
            .expect("flow steps array")
            .iter()
            .find(|step| step["id"].as_str() == Some(step_id))
            .cloned()
            .expect("adaptive flow step")
    }

    /// Deployable adaptive fixture: planner FlowMaster, reviewer profile
    /// template, FindingSet result schema; the prompt keeps a trailing space
    /// on purpose (verbatim export contract).
    fn document_with_adaptive_layer() -> MobpackDocument {
        let mut document = document_with_expected_schema_ref();
        document.mob_toml = None;
        let mut schemas = document.schemas.as_array().cloned().unwrap_or_default();
        schemas.push(json!({
            "id": "FindingSet",
            "description": "Collected layer findings",
            "fields": [{
                "id": "f1",
                "name": "findings",
                "type": "string[]",
                "required": true,
                "description": "Verified findings.",
                "enumValues": []
            }]
        }));
        document.schemas = Value::Array(schemas);
        let steps = document.flow["steps"]
            .as_array_mut()
            .expect("flow steps array");
        steps.insert(
            2,
            json!({
                "id": "s_adaptive",
                "type": "adaptive",
                "flowmasterId": "m_planner",
                "prompt": "Design each layer from prior results. ",
                "objectiveClass": "code-review",
                "resultSchema": "FindingSet",
                "profileTemplateIds": ["m_reviewer"],
                "targetSurfaces": ["rpc", "cli"],
                "allowInlineProfiles": false,
                "limits": editor_adaptive_limit_defaults(),
            }),
        );
        // Keep the graph projection in lockstep with the flow, like the
        // editor shell does after every Basic-mode edit.
        let steps = document.flow["steps"]
            .as_array()
            .cloned()
            .unwrap_or_default();
        let (instances, edges, frames) = graph_projection_from_visual_steps(&steps);
        document.instances = Value::Array(instances);
        document.edges = Value::Array(edges);
        document.frames = Value::Array(frames);
        document
    }

    #[test]
    fn adaptive_step_insert_edit_validate_round_trip() {
        let template = blank_mobpack_template();
        let mut document = template["document"].clone();
        document["mob_toml"] = Value::Null;
        let launch_modes_before = document["launch_modes"].clone();

        let inserted = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_flow_step",
                "pick": { "kind": "adaptive" },
                "lane_ref": { "lane": "main", "index": 2 }
            }
        }))
        .expect("insert adaptive step");
        let step_id = inserted["selection"]["id"]
            .as_str()
            .expect("adaptive selection id")
            .to_string();
        let step = adaptive_step_from_document(&inserted["document"], &step_id);
        let mut expected = editor_adaptive_step_defaults();
        expected["id"] = json!(step_id);
        assert_eq!(
            step, expected,
            "freshly inserted adaptive step must equal the server contract defaults"
        );

        // A fresh adaptive layer is a draft gate, exactly like a member step
        // without an instruction.
        let validation = validate_mobpack(&json!({ "document": inserted["document"] }))
            .expect("validate draft adaptive document");
        assert!(!validation.ok);
        for code in [
            "unknown_adaptive_flowmaster",
            "adaptive_profiles_empty",
            "adaptive_prompt_empty",
            "adaptive_objective_class_empty",
        ] {
            assert!(
                validation
                    .diagnostics
                    .iter()
                    .any(|diagnostic| diagnostic.code == code),
                "missing draft diagnostic {code}: {:?}",
                validation.diagnostics
            );
        }

        let updated = apply_mobpack_authoring_operation(&json!({
            "document": inserted["document"],
            "operation": {
                "type": "update_flow_step",
                "step_id": step_id,
                "patch": {
                    "flowmasterId": "m_worker",
                    "prompt": "Plan each layer from prior results.",
                    "objectiveClass": "smoke",
                    "profileTemplateIds": ["m_worker"],
                    "targetSurfaces": ["rpc"],
                    "allowInlineProfiles": true,
                    "limits": { "maxDepth": 6 }
                }
            }
        }))
        .expect("configure adaptive step");
        let step = adaptive_step_from_document(&updated["document"], &step_id);
        assert_eq!(step["flowmasterId"], json!("m_worker"));
        assert_eq!(step["profileTemplateIds"], json!(["m_worker"]));
        assert_eq!(step["targetSurfaces"], json!(["rpc"]));
        assert_eq!(step["allowInlineProfiles"], json!(true));
        assert_eq!(
            step["limits"]["maxDepth"],
            json!(6),
            "patched limit must round-trip"
        );
        assert_eq!(
            step["limits"]["maxAggregateToolCalls"],
            json!(500),
            "unpatched limits must keep contract defaults"
        );
        let validation = validate_mobpack(&json!({ "document": updated["document"] }))
            .expect("validate configured adaptive document");
        assert!(validation.ok, "{:?}", validation.diagnostics);

        // Adaptive steps never mint launch_modes entries.
        let launch_modes_after = updated["document"]["launch_modes"].clone();
        assert_eq!(
            launch_modes_before, launch_modes_after,
            "adaptive insert/edit must leave launch_modes untouched"
        );
        assert!(
            launch_modes_after
                .as_array()
                .expect("launch modes array")
                .iter()
                .all(|entry| entry["step_id"].as_str() != Some(step_id.as_str())),
            "{launch_modes_after:#?}"
        );

        // Invalid patches are rejected server-side.
        for (label, patch) in [
            ("zero limit", json!({ "limits": { "maxDepth": 0 } })),
            ("unknown limit", json!({ "limits": { "maxTurbo": 3 } })),
            (
                "unknown profile template",
                json!({ "profileTemplateIds": ["m_ghost"] }),
            ),
            ("unknown surface", json!({ "targetSurfaces": ["carrier"] })),
            (
                "non-bool inline profiles",
                json!({ "allowInlineProfiles": "yes" }),
            ),
            ("unknown flowmaster", json!({ "flowmasterId": "m_ghost" })),
        ] {
            assert!(
                apply_mobpack_authoring_operation(&json!({
                    "document": updated["document"],
                    "operation": {
                        "type": "update_flow_step",
                        "step_id": step_id,
                        "patch": patch
                    }
                }))
                .is_err(),
                "{label} patch must be rejected"
            );
        }
    }

    #[test]
    fn adaptive_free_text_edits_preserve_user_whitespace() {
        let template = blank_mobpack_template();
        let mut document = template["document"].clone();
        document["mob_toml"] = Value::Null;
        let inserted = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_flow_step",
                "pick": { "kind": "adaptive" },
                "lane_ref": { "lane": "main", "index": 2 }
            }
        }))
        .expect("insert adaptive step");
        let step_id = inserted["selection"]["id"]
            .as_str()
            .expect("adaptive selection id")
            .to_string();

        // Per-keystroke echo path: trailing, leading, and mid-string spaces
        // must round-trip byte-identically or typing spaces becomes
        // impossible in the live inputs.
        let prompt = "  Design  each layer ";
        let objective_class = "provider auth  audit ";
        let updated = apply_mobpack_authoring_operation(&json!({
            "document": inserted["document"],
            "operation": {
                "type": "update_flow_step",
                "step_id": step_id,
                "patch": { "prompt": prompt, "objectiveClass": objective_class }
            }
        }))
        .expect("set adaptive free text");
        let step = adaptive_step_from_document(&updated["document"], &step_id);
        assert_eq!(
            step["prompt"].as_str(),
            Some(prompt),
            "prompt must round-trip byte-identically"
        );
        assert_eq!(
            step["objectiveClass"].as_str(),
            Some(objective_class),
            "objectiveClass must round-trip byte-identically"
        );
    }

    #[test]
    fn exports_adaptive_manifest_and_pack_files_with_policy_digest() {
        let document = document_with_adaptive_layer();
        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        let result = export_mobpack(&json!({ "document": document })).expect("export");
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);

        let file_text = |path: &str| -> String {
            result
                .source_files
                .iter()
                .find(|file| file.path == path)
                .and_then(|file| file.text.clone())
                .unwrap_or_else(|| panic!("missing archive file {path}"))
        };
        let policies = file_text("adaptive/policies.toml");
        let expected_digest = format!("sha256:{}", source_file_sha256(policies.as_bytes()));
        let manifest = file_text("manifest.toml");
        assert!(manifest.contains("[adaptive]"), "{manifest}");
        // 0.7.1+ hosts reject adaptive packs whose [requires] is not stamped
        // with the adaptive_flow capability (MissingAdaptiveCapability).
        assert!(manifest.contains("[requires]"), "{manifest}");
        assert!(
            manifest.contains("capabilities = [\"adaptive_flow\"]"),
            "{manifest}"
        );
        assert!(
            manifest.find("[requires]") < manifest.find("[adaptive]"),
            "requires stamp must precede the adaptive section: {manifest}"
        );
        assert!(
            manifest.contains("flowmaster_profile = \"planner\""),
            "{manifest}"
        );
        assert!(
            manifest.contains("objective_class = \"code-review\""),
            "{manifest}"
        );
        assert!(
            manifest.contains(&format!("policy_digest = \"{expected_digest}\"")),
            "manifest digest must match the exact emitted policy bytes: {manifest}"
        );
        assert!(
            manifest.contains("required_schemas = [\"finding-set\"]"),
            "{manifest}"
        );
        assert!(
            manifest.contains("target_surfaces = [\"cli\", \"rpc\"]"),
            "target surfaces must export sorted: {manifest}"
        );

        // The policy is complete, valid TOML for meerkat's AdaptivePolicy
        // (deny_unknown_fields): allow-lists first, then the 12-key limits.
        let policy: toml::Value = toml::from_str(&policies).expect("policies.toml parses");
        assert_eq!(
            policy["allowed_model_classes"],
            toml::Value::Array(vec![toml::Value::String("gpt-5.5".to_string())])
        );
        assert_eq!(
            policy["allowed_tool_classes"],
            toml::Value::Array(vec![
                toml::Value::String("builtins".to_string()),
                toml::Value::String("shell".to_string())
            ]),
            "tool classes must be the sorted union of profile template tools"
        );
        assert_eq!(policy["allowed_auth_bindings"], toml::Value::Array(vec![]));
        assert_eq!(policy["allow_inline_profiles"], toml::Value::Boolean(false));
        let limits = policy["limits"].as_table().expect("limits table");
        assert_eq!(limits.len(), 12, "{limits:?}");
        assert_eq!(limits["max_depth"], toml::Value::Integer(4));
        assert_eq!(limits["max_wall_clock_ms"], toml::Value::Integer(1_800_000));
        assert_eq!(
            limits["max_aggregate_tool_calls"],
            toml::Value::Integer(500)
        );

        // Prompt exports verbatim — the trailing space survives.
        assert_eq!(
            file_text("adaptive/flowmaster.prompt.md"),
            "Design each layer from prior results. "
        );
        // The bundled layer-decision schema is the canonical LayerDecision
        // contract (run_layer/finish tagged union + full LayerPlan defs).
        let layer_decision: Value =
            serde_json::from_str(&file_text("adaptive/layer-decision.schema.json"))
                .expect("layer decision schema json");
        assert_eq!(layer_decision["title"], json!("LayerDecision"));
        let variants = layer_decision["oneOf"]
            .as_array()
            .or_else(|| layer_decision["anyOf"].as_array())
            .expect("layer decision union variants");
        let decision_consts: Vec<&str> = variants
            .iter()
            .filter_map(|variant| variant["properties"]["decision"]["const"].as_str())
            .collect();
        assert_eq!(
            decision_consts,
            ["run_layer", "finish"],
            "{layer_decision:#?}"
        );
        for def in [
            "LayerPlan",
            "LayerShape",
            "CollectorContract",
            "FinishResult",
        ] {
            assert!(
                layer_decision["$defs"][def].is_object(),
                "layer decision schema must carry the {def} definition"
            );
        }

        // Registry maps the kebab-cased schema name to the exported file.
        let registry: Value =
            serde_json::from_str(&file_text("schemas/registry.json")).expect("registry json");
        assert_eq!(
            registry,
            json!({ "finding-set": "schemas/FindingSet.json" })
        );
        let finding_set: Value =
            serde_json::from_str(&file_text("schemas/FindingSet.json")).expect("finding set json");
        assert_eq!(finding_set["type"], json!("object"));
        assert_eq!(finding_set["required"], json!(["findings"]));

        // The archive itself carries all four adaptive files.
        let bytes = base64::engine::general_purpose::STANDARD
            .decode(&result.content_base64)
            .expect("archive bytes");
        let mut archive = tar::Archive::new(GzDecoder::new(bytes.as_slice()));
        let mut paths = BTreeSet::new();
        for entry in archive.entries().expect("archive entries") {
            let entry = entry.expect("archive entry");
            paths.insert(
                entry
                    .path()
                    .expect("entry path")
                    .to_string_lossy()
                    .to_string(),
            );
        }
        for path in [
            "adaptive/policies.toml",
            "adaptive/flowmaster.prompt.md",
            "adaptive/layer-decision.schema.json",
            "schemas/registry.json",
            "schemas/FindingSet.json",
        ] {
            assert!(paths.contains(path), "{paths:?}");
        }
    }

    #[test]
    fn adaptive_policy_tool_class_maps_rust_bundle_to_runtime_evidence() {
        // The runtime emits a Rust bundle's layer-admission evidence as
        // `rust_bundle:{name}` while the editor id is `rust:{name}`. The policy
        // allow-list MUST carry the runtime form or every adaptive layer using a
        // Rust bundle is admission-rejected forever. mcp/bool/model/skill ids are
        // verbatim on both sides and must pass through unchanged.
        assert_eq!(adaptive_policy_tool_class("rust:foo"), "rust_bundle:foo");
        assert_eq!(
            adaptive_policy_tool_class("rust:my-bundle"),
            "rust_bundle:my-bundle"
        );
        assert_eq!(adaptive_policy_tool_class("mcp:linear"), "mcp:linear");
        assert_eq!(adaptive_policy_tool_class("shell"), "shell");
        assert_eq!(adaptive_policy_tool_class("builtins"), "builtins");
        assert_eq!(adaptive_policy_tool_class("comms"), "comms");
        // `rust_bundle:` is already the runtime form; do not double-rewrite.
        assert_eq!(
            adaptive_policy_tool_class("rust_bundle:already"),
            "rust_bundle:already"
        );
    }

    #[test]
    fn bundled_layer_decision_schema_matches_meerkat_canonical() {
        // Parity guard pinned to the resolved meerkat-mob version: the bundled
        // adaptive/layer-decision.schema.json MUST stay serde_json::Value-equal
        // to meerkat's canonical `layer_decision_schema()`. meerkat's pack
        // validation (`validate_layer_decision_schema`) requires Value-equality
        // and fails any adaptive-pack deploy/validate with `StaleAdaptiveSchema`
        // on a mismatch. This catches the next meerkat schema bump that would
        // otherwise silently desync the bundled copy (added field, edited
        // description, new $def) — only surfacing at real `rkat mob validate`.
        let bundled: Value = serde_json::from_str(ADAPTIVE_LAYER_DECISION_SCHEMA_JSON)
            .expect("bundled layer-decision schema parses");
        let canonical = meerkat_mob::adaptive::layer_decision_schema()
            .expect("meerkat canonical layer_decision_schema");
        assert_eq!(
            bundled, canonical,
            "bundled adaptive layer-decision schema has drifted from meerkat's \
             canonical layer_decision_schema(); regenerate \
             meerkat-mobkit/src/adaptive_layer_decision.schema.json via \
             schemars::schema_for!(meerkat_mob::adaptive::LayerDecision)"
        );
    }

    /// Regression: an adaptive pack WITHOUT `mobkit/editor.json` (any
    /// meerkat-side or hand-built adaptive pack) cannot be reconstructed by
    /// the editor's mob.toml projection — it produces no adaptive step. Import
    /// must refuse such a pack instead of silently importing a gutted,
    /// non-adaptive document (which on re-export would also strip the
    /// `[requires]` / `[adaptive]` capability stamp). The editor-authored pack
    /// (with `mobkit/editor.json`) still round-trips with its adaptive step.
    #[test]
    fn archive_import_refuses_adaptive_pack_without_editor_json() {
        let document = document_with_adaptive_layer();
        let validation = validate_document(&document);
        assert!(validation.ok, "{:?}", validation.diagnostics);
        let export = export_mobpack(&json!({ "document": document })).expect("export");
        let archive_bytes = base64::engine::general_purpose::STANDARD
            .decode(&export.content_base64)
            .expect("archive bytes");

        // Sanity: the full editor-authored archive imports WITH its adaptive
        // step (the editor.json round-trip path).
        let imported = import_mobpack(&json!({
            "content_base64": export.content_base64,
        }))
        .expect("editor-authored adaptive pack imports");
        let imported_document: MobpackDocument =
            serde_json::from_value(imported["document"].clone()).expect("document");
        assert!(
            !adaptive_flow_steps(&imported_document.flow).is_empty(),
            "editor-authored adaptive pack must keep its adaptive step"
        );

        // Strip mobkit/editor.json to simulate a meerkat-side / hand-built
        // adaptive pack, preserving manifest.toml + adaptive/ artifacts.
        let mut stripped_files = BTreeMap::<String, Vec<u8>>::new();
        let mut archive = tar::Archive::new(GzDecoder::new(archive_bytes.as_slice()));
        for entry in archive.entries().expect("archive entries") {
            let mut entry = entry.expect("archive entry");
            let path = entry
                .path()
                .expect("entry path")
                .to_string_lossy()
                .to_string();
            if path == "mobkit/editor.json" {
                continue;
            }
            let mut bytes = Vec::new();
            entry.read_to_end(&mut bytes).expect("read entry");
            stripped_files.insert(path, bytes);
        }
        assert!(
            stripped_files.contains_key("adaptive/policies.toml"),
            "stripped archive must still carry the adaptive artifacts"
        );
        let stripped_archive =
            encode_deployable_mobpack_archive(&stripped_files).expect("re-encode stripped archive");

        let err = import_mobpack(&json!({
            "content_base64": base64::engine::general_purpose::STANDARD.encode(stripped_archive),
        }))
        .expect_err("adaptive pack without editor.json must be refused");
        assert!(
            err.contains("adaptive layer"),
            "refusal must explain the adaptive-layer projection gap: {err}"
        );
    }

    #[test]
    fn adaptive_registry_reuses_already_exported_schema_path() {
        let mut document = document_with_adaptive_layer();
        // Point the adaptive result at the schema the reviewer step already
        // exports under its custom expected_schema_ref path.
        let steps = document.flow["steps"]
            .as_array_mut()
            .expect("flow steps array");
        steps[2]["resultSchema"] = json!("ReviewArtifact");
        let result = export_mobpack(&json!({ "document": document })).expect("export");
        let registry_text = result
            .source_files
            .iter()
            .find(|file| file.path == "schemas/registry.json")
            .and_then(|file| file.text.clone())
            .expect("registry json file");
        let registry: Value = serde_json::from_str(&registry_text).expect("registry json");
        assert_eq!(
            registry,
            json!({ "review-artifact": "schemas/reviewer.json" }),
            "registry must reuse the exact schema path the export already produces"
        );
        assert!(
            result
                .source_files
                .iter()
                .all(|file| file.path != "schemas/ReviewArtifact.json"),
            "no duplicate schema file when the content is already exported"
        );
    }

    #[test]
    fn multiline_free_text_exports_parseable_manifest_toml() {
        // objectiveClass is free text behind a multi-line textarea and the
        // never-trim contract; a raw newline (or tab/quote/backslash) must
        // escape into the string-composed manifest instead of corrupting it.
        let mut document = document_with_adaptive_layer();
        document.name = "Review Pack\nsecond line".to_string();
        let objective_class = "code-review\nsecond line\twith \"quotes\" and \\ backslash\r";
        let updated = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_flow_step",
                "step_id": "s_adaptive",
                "patch": { "objectiveClass": objective_class }
            }
        }))
        .expect("set multi-line objective class");
        assert_eq!(
            updated["ok"],
            json!(true),
            "multi-line objectiveClass stays deployable: {:?}",
            updated["validation"]
        );

        let result = export_mobpack(&json!({ "document": updated["document"] })).expect("export");
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        let manifest_text = result
            .source_files
            .iter()
            .find(|file| file.path == "manifest.toml")
            .and_then(|file| file.text.clone())
            .expect("manifest.toml file");
        let manifest: toml::Value =
            toml::from_str(&manifest_text).expect("exported manifest.toml must stay valid TOML");
        assert_eq!(
            manifest["adaptive"]["objective_class"],
            toml::Value::String(objective_class.to_string()),
            "objective_class must round-trip byte-identically through the TOML escape"
        );
        assert_eq!(
            manifest["mobpack"]["description"],
            toml::Value::String("Review Pack\nsecond line".to_string()),
            "document name feeds description through the same escape"
        );
    }

    #[test]
    fn rename_schema_remaps_adaptive_result_schema_in_flow_and_graph() {
        let renamed = apply_mobpack_authoring_operation(&json!({
            "document": document_with_adaptive_layer(),
            "operation": {
                "type": "rename_schema",
                "schema_id": "FindingSet",
                "new_id": "VerifiedFindings"
            }
        }))
        .expect("rename adaptive result schema");
        assert_eq!(
            renamed["ok"],
            json!(true),
            "rename must not strand the adaptive layer: {:?}",
            renamed["validation"]
        );
        let step = adaptive_step_from_document(&renamed["document"], "s_adaptive");
        assert_eq!(
            step["resultSchema"],
            json!("VerifiedFindings"),
            "flow step resultSchema must follow the schema rename"
        );
        let instance = renamed["document"]["instances"]
            .as_array()
            .expect("instances")
            .iter()
            .find(|instance| instance["id"] == json!("s_adaptive"))
            .cloned()
            .expect("adaptive graph instance");
        assert_eq!(
            instance["adaptive"], step,
            "graph payload must stay byte-identical to the flow step"
        );

        // The export path keeps working against the renamed schema.
        let result = export_mobpack(&json!({ "document": renamed["document"] })).expect("export");
        let registry_text = result
            .source_files
            .iter()
            .find(|file| file.path == "schemas/registry.json")
            .and_then(|file| file.text.clone())
            .expect("registry json file");
        let registry: Value = serde_json::from_str(&registry_text).expect("registry json");
        assert_eq!(
            registry,
            json!({ "verified-findings": "schemas/VerifiedFindings.json" })
        );
    }

    #[test]
    fn delete_schema_clears_adaptive_result_schema_in_flow_and_graph() {
        let deleted = apply_mobpack_authoring_operation(&json!({
            "document": document_with_adaptive_layer(),
            "operation": { "type": "delete_schema", "schema_id": "FindingSet" }
        }))
        .expect("delete adaptive result schema");
        assert_eq!(
            deleted["ok"],
            json!(true),
            "delete must clear the adaptive resultSchema, not strand it: {:?}",
            deleted["validation"]
        );
        let step = adaptive_step_from_document(&deleted["document"], "s_adaptive");
        assert_eq!(
            step["resultSchema"],
            json!(""),
            "flow step resultSchema must clear when its schema is deleted"
        );
        let instance = deleted["document"]["instances"]
            .as_array()
            .expect("instances")
            .iter()
            .find(|instance| instance["id"] == json!("s_adaptive"))
            .cloned()
            .expect("adaptive graph instance");
        assert_eq!(
            instance["adaptive"], step,
            "graph payload must stay byte-identical to the flow step"
        );
    }

    #[test]
    fn adaptive_step_compiles_to_no_mob_toml_nodes_and_chains_depends_on() {
        let document = document_with_adaptive_layer();
        let result = export_mobpack(&json!({ "document": document })).expect("export");
        assert!(
            !result.mob_toml.to_ascii_lowercase().contains("adaptive"),
            "mob.toml must carry no adaptive keys or nodes: {}",
            result.mob_toml
        );
        // input → plan → adaptive → review: the reviewer step chains straight
        // through the adaptive layer to the planner step.
        assert!(
            result
                .mob_toml
                .contains("[flows.main.steps.\"01_planner\"]"),
            "{}",
            result.mob_toml
        );
        assert!(
            result
                .mob_toml
                .contains("[flows.main.steps.\"02_reviewer\"]"),
            "{}",
            result.mob_toml
        );
        assert!(
            result
                .mob_toml
                .contains("depends_on = [\"node_01_planner\"]"),
            "downstream step must depend on the step before the adaptive layer: {}",
            result.mob_toml
        );
    }

    #[test]
    fn adaptive_step_keeps_launch_modes_member_only() {
        let document = document_with_adaptive_layer();
        let launch_modes_before = document.launch_modes.clone();
        // Any flow-step operation runs the launch-mode reconciler; adaptive
        // steps must not mint, drop, or rewrite entries.
        let updated = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_flow_step",
                "step_id": "s_adaptive",
                "patch": { "prompt": "Plan the next layer." }
            }
        }))
        .expect("update adaptive step");
        assert_eq!(updated["document"]["launch_modes"], launch_modes_before);
        assert!(
            updated["document"]["launch_modes"]
                .as_array()
                .expect("launch modes array")
                .iter()
                .all(|entry| entry["step_id"].as_str() != Some("s_adaptive")),
            "{:#?}",
            updated["document"]["launch_modes"]
        );
    }

    #[test]
    fn adaptive_graph_round_trip_preserves_flow() {
        let mut document = document_with_adaptive_layer();
        let adaptive_step = document.flow["steps"][2].clone();
        assert_eq!(adaptive_step["type"], json!("adaptive"));
        // Canonical graph-derived member shape so the whole flow (not just
        // the adaptive payload) round-trips flow → graph → flow.
        document.flow = json!({
            "name": "review",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Review", "fields": "", "inputParams": [] },
                { "id": "plan", "type": "member", "role": "m_planner", "instruction": "Plan",
                  "launchMode": { "kind": "Fresh" }, "quorum": null, "timeoutMs": null,
                  "allowedTools": [], "blockedTools": [] },
                adaptive_step,
                { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review",
                  "launchMode": { "kind": "Fresh" }, "quorum": null, "timeoutMs": null,
                  "allowedTools": [], "blockedTools": [] }
            ]
        });
        let projection =
            graph_projection_mobpack(&json!({ "document": &document })).expect("graph projection");
        let adaptive_instance = projection["instances"]
            .as_array()
            .expect("instances array")
            .iter()
            .find(|instance| instance["id"].as_str() == Some("s_adaptive"))
            .cloned()
            .expect("adaptive graph instance");
        assert_eq!(adaptive_instance["kind"], json!("adaptive"));
        assert_eq!(adaptive_instance["label"], json!("adaptive layer"));
        assert_eq!(adaptive_instance["memberId"], Value::Null);
        assert_eq!(
            adaptive_instance["adaptive"], document.flow["steps"][2],
            "graph instance must carry the full adaptive step payload"
        );
        assert_eq!(
            projection["frames"],
            json!([]),
            "adaptive layers project no frames"
        );
        // Inline wiring: plan → adaptive → review.
        let edges = projection["edges"].as_array().expect("edges array");
        assert!(
            edges
                .iter()
                .any(|edge| edge["from"].as_str() == Some("plan")
                    && edge["to"].as_str() == Some("s_adaptive")),
            "{edges:#?}"
        );
        assert!(
            edges
                .iter()
                .any(|edge| edge["from"].as_str() == Some("s_adaptive")
                    && edge["to"].as_str() == Some("review")),
            "{edges:#?}"
        );

        let mut merged = json!({ "document": document });
        merged["document"]["instances"] = projection["instances"].clone();
        merged["document"]["edges"] = projection["edges"].clone();
        merged["document"]["frames"] = projection["frames"].clone();
        let derived = graph_to_flow_mobpack(&merged).expect("graph to flow");
        assert_eq!(
            derived["flow"], merged["document"]["flow"],
            "flow → graph → flow must be identical (graph_derives_flow)"
        );
        // The merged projection document validates clean: graph equivalence
        // plus a fully configured adaptive layer.
        let validation =
            validate_mobpack(&merged).expect("validate adaptive graph projection document");
        assert!(validation.ok, "{:?}", validation.diagnostics);
    }

    #[test]
    fn rejects_second_adaptive_layer_insert() {
        let document = document_with_adaptive_layer();
        let picked = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_flow_step",
                "pick": { "kind": "adaptive" },
                "lane_ref": { "lane": "main", "index": 1 }
            }
        }));
        assert!(
            picked
                .expect_err("second adaptive pick must be rejected")
                .contains("adaptive layer already present")
        );
        let raw = apply_mobpack_authoring_operation(&json!({
            "document": document_with_adaptive_layer(),
            "operation": {
                "type": "insert_flow_step",
                "step": { "id": "s_other", "type": "adaptive" },
                "lane_ref": { "lane": "main", "index": 1 }
            }
        }));
        assert!(
            raw.expect_err("raw second adaptive step must be rejected")
                .contains("adaptive layer already present")
        );
        // Documents that smuggle a duplicate in anyway stay draft.
        let mut duplicated = document_with_adaptive_layer();
        let steps = duplicated.flow["steps"]
            .as_array_mut()
            .expect("flow steps array");
        let mut second = steps[2].clone();
        second["id"] = json!("s_adaptive_2");
        steps.push(second);
        let validation = validate_document(&duplicated);
        assert!(!validation.ok);
        assert!(
            validation
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == "duplicate_adaptive_layer"),
            "{:?}",
            validation.diagnostics
        );
    }

    #[test]
    #[cfg(unix)]
    fn validate_mobpack_reports_rkat_validate_failures() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("tempdir");
        let rkat_path = dir.path().join("rkat");
        std::fs::write(
            &rkat_path,
            "#!/bin/sh\necho 'fake validation failure' >&2\nexit 7\n",
        )
        .expect("write fake rkat");
        let mut perms = std::fs::metadata(&rkat_path)
            .expect("fake rkat metadata")
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&rkat_path, perms).expect("chmod fake rkat");

        let result = validate_mobpack(&json!({
            "document": valid_document(),
            "rkat_validate": true,
            "rkat_bin": rkat_path,
            "validation_output_dir": dir.path()
        }))
        .expect("rkat validate failure result");

        assert!(!result.ok);
        assert_eq!(result.validation_source, "rkat mob validate");
        assert!(result.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "rkat_mob_validate_failed"
                && diagnostic.message.contains("fake validation failure")
        }));
        assert!(
            result
                .display_rows
                .iter()
                .any(|row| { row.kind == "crit" && row.head == "rkat mob validate failed" })
        );
    }

    #[test]
    fn deploy_command_preview_derives_pack_filename_from_document() {
        let document = valid_document();
        let preview = deploy_command_preview(&json!({
            "document": document,
            "prompt": "Reply with exactly OK."
        }))
        .expect("deploy command preview");

        assert_eq!(&preview.argv[0..3], ["rkat", "mob", "run"]);
        assert!(
            preview.argv.iter().any(|arg| arg == "review-pack.mobpack"),
            "{:?}",
            preview.argv
        );
        assert!(
            preview.command.contains("review-pack.mobpack"),
            "{}",
            preview.command
        );
        assert!(
            !preview.command.contains("<pack.mobpack>"),
            "{}",
            preview.command
        );
        assert_eq!(preview.filename, "review-pack.mobpack");
        assert!(
            preview.validation.ok,
            "{:?}",
            preview.validation.diagnostics
        );
        assert!(
            deploy_command_preview(&json!({
                "deploy": { "command": "rkat mob run" },
                "pack_path": "<pack.mobpack>"
            }))
            .expect_err("deploy command preview requires document")
            .contains("mobkit/mobpacks/deploy_command requires document")
        );
    }

    #[test]
    fn guarded_document_actions_reject_stale_draft_revision() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");
        let output_dir = dir.path().join("deploy");
        let saved = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "action_guard",
            "document": valid_document(),
        }))
        .expect("save guarded draft");
        let document = saved["row"]["document"].clone();

        let stale_params = json!({
            "store_path": store_path,
            "id": "action_guard",
            "expected_revision": 0,
            "document": document,
            "output_dir": output_dir,
            "prompt": "Reply with exactly OK.",
            "execute": false,
        });

        let validation = validate_mobpack(&stale_params).expect_err("stale validate must fail");
        assert!(
            validation
                .contains("mobkit/mobpacks/validate draft revision conflict for action_guard"),
            "{validation}"
        );

        let source = source_mobpack(&stale_params).expect_err("stale source must fail");
        assert!(
            source.contains("mobkit/mobpacks/source draft revision conflict for action_guard"),
            "{source}"
        );

        let graph_projection =
            graph_projection_mobpack(&stale_params).expect_err("stale graph projection must fail");
        assert!(
            graph_projection.contains(
                "mobkit/mobpacks/graph_projection draft revision conflict for action_guard"
            ),
            "{graph_projection}"
        );

        let graph_to_flow =
            graph_to_flow_mobpack(&stale_params).expect_err("stale graph-to-flow must fail");
        assert!(
            graph_to_flow
                .contains("mobkit/mobpacks/graph_to_flow draft revision conflict for action_guard"),
            "{graph_to_flow}"
        );

        let exported = export_mobpack(&stale_params).expect_err("stale export must fail");
        assert!(
            exported.contains("mobkit/mobpacks/export draft revision conflict for action_guard"),
            "{exported}"
        );

        let preview =
            deploy_command_preview(&stale_params).expect_err("stale deploy command must fail");
        assert!(
            preview.contains(
                "mobkit/mobpacks/deploy_command draft revision conflict for action_guard"
            ),
            "{preview}"
        );

        let deployed = deploy_mobpack(&stale_params).expect_err("stale deploy must fail");
        assert!(
            deployed.contains("mobkit/mobpacks/deploy draft revision conflict for action_guard"),
            "{deployed}"
        );

        let fresh_params = json!({
            "store_path": store_path,
            "id": "action_guard",
            "expected_revision": 1,
            "expected_etag": "action_guard:1",
            "document": saved["row"]["document"].clone(),
            "output_dir": dir.path().join("fresh-deploy"),
            "prompt": "Reply with exactly OK.",
            "execute": false,
        });
        assert!(validate_mobpack(&fresh_params).expect("fresh validate").ok);
        assert!(
            source_mobpack(&fresh_params)
                .expect("fresh source")
                .source_files
                .iter()
                .any(|file| file.path.ends_with("mob.toml"))
        );
        assert!(
            graph_projection_mobpack(&fresh_params).expect("fresh graph projection")["validation"]
                ["ok"]
                .as_bool()
                .unwrap_or(false)
        );
        assert!(
            graph_to_flow_mobpack(&fresh_params).expect("fresh graph-to-flow")["ok"]
                .as_bool()
                .unwrap_or(false)
        );
        assert!(
            export_mobpack(&fresh_params)
                .expect("fresh export")
                .validation
                .ok
        );
        assert!(
            deploy_command_preview(&fresh_params)
                .expect("fresh deploy command")
                .validation
                .ok
        );
        assert!(
            deploy_mobpack(&fresh_params)
                .expect("fresh deploy")
                .validation
                .ok
        );
    }

    #[test]
    fn mobpack_draft_registry_defaults_to_durable_state_path() {
        assert_eq!(
            default_mobpack_draft_store_path_from_env(Some("/state"), Some("/home/luka")),
            PathBuf::from("/state")
                .join(MOBPACK_DRAFT_STATE_DIR)
                .join(MOBPACK_DRAFT_STORE_FILENAME)
        );
        assert_eq!(
            default_mobpack_draft_store_path_from_env(None, Some("/home/luka")),
            PathBuf::from("/home/luka")
                .join(".local/state")
                .join(MOBPACK_DRAFT_STATE_DIR)
                .join(MOBPACK_DRAFT_STORE_FILENAME)
        );
        assert!(
            !default_mobpack_draft_store_path_from_env(None, Some("/home/luka"))
                .starts_with(std::env::temp_dir()),
            "draft registry must not default to temp storage when HOME is available"
        );
    }

    #[test]
    fn mobpack_draft_registry_persists_saved_editor_documents() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");
        let mut document = valid_document();
        document.name = "Registry Draft".to_string();

        let saved = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "registry_draft",
            "document": document,
            "stage": "draft",
            "trigger": "manual save"
        }))
        .expect("save draft");
        assert!(
            saved["row"]["document"]["mob_toml"]
                .as_str()
                .is_some_and(|text| text.contains("[mob]")),
            "saved registry document must persist the server-rendered canonical mob.toml"
        );
        assert_eq!(saved["source"], json!("mobkit/mobpacks/save"));
        assert_eq!(saved["row"]["id"], json!("registry_draft"));
        assert_eq!(saved["row"]["revision"], json!(1));
        assert_eq!(saved["row"]["draft_revision"], json!(1));
        assert_eq!(saved["row"]["draft_etag"], json!("registry_draft:1"));
        assert_eq!(saved["row"]["document"]["name"], json!("Registry Draft"));
        assert_eq!(saved["row"]["document_kind"], json!("editor_projection"));
        assert_eq!(saved["row"]["document_source"], json!("mobpack_archive"));
        assert_eq!(saved["row"]["document_authority"], json!("artifact"));
        assert_eq!(saved["row"]["stage"], json!("valid"));
        assert_eq!(saved["row"]["artifact"]["kind"], json!("mobpack_archive"));
        assert_eq!(saved["row"]["artifact"]["deployable"], json!(true));
        assert_eq!(
            saved["row"]["artifact"]["media_type"],
            json!(MOBPACK_MEDIA_TYPE)
        );
        assert!(
            saved["row"]["artifact"]["content_base64"]
                .as_str()
                .is_some_and(|value| !value.is_empty())
        );
        assert!(
            saved["row"]["artifact"]["pack_sha256"]
                .as_str()
                .is_some_and(|value| !value.is_empty())
        );

        let listed =
            list_mobpack_drafts(&json!({ "store_path": store_path })).expect("list drafts");
        assert_eq!(listed["source"], json!("mobkit/mobpacks/list"));
        assert_eq!(listed["rows"].as_array().expect("rows").len(), 1);
        assert_eq!(listed["rows"][0]["id"], json!("registry_draft"));
        assert_eq!(listed["rows"][0]["revision"], json!(1));
        assert_eq!(listed["rows"][0]["draft_etag"], json!("registry_draft:1"));
        assert_eq!(
            listed["rows"][0]["document_source"],
            json!("mobpack_archive")
        );
        assert_eq!(listed["rows"][0]["document_authority"], json!("artifact"));
        assert_eq!(listed["rows"][0]["artifact"]["deployable"], json!(true));

        let fetched = get_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "registry_draft"
        }))
        .expect("get draft");
        assert_eq!(fetched["row"]["document"]["mob_id"], json!("review-pack"));
        assert_eq!(fetched["row"]["revision"], json!(1));
        assert_eq!(fetched["row"]["draft_etag"], json!("registry_draft:1"));
        assert_eq!(fetched["row"]["document_source"], json!("mobpack_archive"));
        assert_eq!(fetched["row"]["document_authority"], json!("artifact"));
        assert_eq!(fetched["row"]["artifact"]["kind"], json!("mobpack_archive"));

        let deleted = delete_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "registry_draft"
        }))
        .expect("delete draft");
        assert_eq!(deleted["deleted"], json!(true));
        assert_eq!(deleted["rows"].as_array().expect("rows").len(), 0);
    }

    #[test]
    fn mobpack_draft_registry_rejects_stale_expected_revision() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");
        let mut document = valid_document();
        document.name = "Revision One".to_string();

        let first = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "revision_guard",
            "document": document,
        }))
        .expect("initial save");
        assert_eq!(first["row"]["revision"], json!(1));
        assert_eq!(first["row"]["draft_etag"], json!("revision_guard:1"));

        let mut updated = valid_document();
        updated.name = "Revision Two".to_string();
        let second = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "revision_guard",
            "document": updated,
            "expected_revision": 1,
        }))
        .expect("second save");
        assert_eq!(second["row"]["revision"], json!(2));
        assert_eq!(second["row"]["draft_etag"], json!("revision_guard:2"));

        let mut stale = valid_document();
        stale.name = "Stale Revision".to_string();
        let error = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "revision_guard",
            "document": stale,
            "expected_revision": 1,
        }))
        .expect_err("stale save must fail");
        assert!(
            error.contains(
                "mobpack draft revision conflict for revision_guard: expected 1, found 2"
            ),
            "{error}"
        );

        let fetched = get_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "revision_guard"
        }))
        .expect("get draft");
        assert_eq!(fetched["row"]["revision"], json!(2));
        assert_eq!(fetched["row"]["draft_etag"], json!("revision_guard:2"));
        assert_eq!(fetched["row"]["document"]["name"], json!("Revision Two"));
    }

    #[test]
    fn mobpack_draft_registry_rejects_stale_delete_revision() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");

        let saved = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "delete_guard",
            "document": valid_document(),
        }))
        .expect("save draft");
        assert_eq!(saved["row"]["revision"], json!(1));

        let stale = delete_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "delete_guard",
            "expected_revision": 0,
        }))
        .expect_err("stale delete must fail");
        assert!(
            stale.contains("mobkit/mobpacks/delete draft revision conflict for delete_guard"),
            "{stale}"
        );

        let deleted = delete_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "delete_guard",
            "expected_revision": 1,
            "expected_etag": "delete_guard:1",
        }))
        .expect("guarded delete");
        assert_eq!(deleted["deleted"], json!(true));
    }

    #[test]
    fn mobpack_draft_registry_reopens_deployable_artifact_over_stale_projection() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");
        let mut document = valid_document();
        document.name = "Artifact Authority".to_string();

        let saved = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "artifact_authority",
            "document": document
        }))
        .expect("save draft");
        let pack_sha256 = saved["row"]["artifact"]["pack_sha256"].clone();

        let mut rows = read_mobpack_draft_store(&store_path).expect("read draft store");
        let row = rows
            .get_mut("artifact_authority")
            .expect("stored draft row");
        row["document"]["mob_id"] = json!("stale-registry-projection");
        row["document"]["name"] = json!("Stale Registry Projection");
        row["validation"] = json!({
            "ok": false,
            "diagnostics": [],
            "display_rows": [{ "kind": "crit", "head": "stale projection", "sub": "", "meta": "" }],
            "flow_ids": [],
            "validation_source": "caller",
            "deploy_command": "stale"
        });
        write_mobpack_draft_store(&store_path, &rows).expect("write stale draft store");

        let listed =
            list_mobpack_drafts(&json!({ "store_path": store_path })).expect("list drafts");
        let listed_row = &listed["rows"][0];
        assert_eq!(listed_row["document_source"], json!("mobpack_archive"));
        assert_eq!(listed_row["document_authority"], json!("artifact"));
        assert_eq!(listed_row["document"]["mob_id"], json!("review-pack"));
        assert_eq!(listed_row["document"]["name"], json!("Artifact Authority"));
        assert_eq!(listed_row["validation"]["ok"], json!(true));
        assert_eq!(listed_row["artifact"]["pack_sha256"], pack_sha256);

        let fetched = get_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "artifact_authority"
        }))
        .expect("get draft");
        assert_eq!(fetched["row"]["document_source"], json!("mobpack_archive"));
        assert_eq!(fetched["row"]["document_authority"], json!("artifact"));
        assert_eq!(fetched["row"]["document"]["mob_id"], json!("review-pack"));
        assert_eq!(fetched["row"]["validation"]["ok"], json!(true));
    }

    #[test]
    fn mobpack_draft_registry_recomputes_validation_on_save() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");
        let mut document = valid_document();
        document.mob_toml = Some("not valid mob toml =".to_string());

        let saved = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": "fake_valid",
            "document": document,
            "stage": "valid",
            "validation": { "ok": true, "display_rows": [{ "kind": "ok", "head": "caller fake" }] }
        }))
        .expect("save draft");

        assert_eq!(saved["row"]["stage"], json!("draft"));
        assert_eq!(saved["row"]["validation"]["ok"], json!(false));
        assert_ne!(
            saved["row"]["validation"]["display_rows"][0]["head"],
            json!("caller fake")
        );
        assert_eq!(saved["row"]["artifact"]["deployable"], json!(false));
    }

    #[test]
    fn create_mobpack_draft_clones_template_on_mobkit_side() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");

        let created = create_mobpack_draft(&json!({
            "store_path": store_path,
            "template": "sample_docs_only",
            "name": "Docs Flow Draft",
            "trigger": "path · docs/**"
        }))
        .expect("create draft");

        assert_eq!(created["source"], json!("mobkit/mobpacks/create"));
        assert_eq!(
            created["row"]["registry_source"],
            json!("mobkit/mobpacks/create")
        );
        assert_eq!(created["row"]["id"], json!("f_docs_flow_draft"));
        assert_eq!(created["row"]["revision"], json!(1));
        assert_eq!(created["row"]["draft_etag"], json!("f_docs_flow_draft:1"));
        assert_eq!(created["row"]["name"], json!("Docs Flow Draft"));
        assert_eq!(created["row"]["document"]["name"], json!("Docs Flow Draft"));
        assert_eq!(
            created["row"]["document"]["mob_id"],
            json!("docs_flow_draft")
        );
        assert_eq!(
            created["row"]["document"]["flow"]["name"],
            json!("Docs Flow Draft")
        );
        assert!(created["row"]["document"]["mob_toml"].is_null());
        assert!(
            created["row"]["validation"]["ok"]
                .as_bool()
                .unwrap_or(false)
        );
        assert_eq!(created["row"]["artifact"]["kind"], json!("mobpack_archive"));
        assert_eq!(created["row"]["artifact"]["deployable"], json!(true));
        assert_eq!(
            created["row"]["artifact"]["media_type"],
            json!(MOBPACK_MEDIA_TYPE)
        );

        let listed =
            list_mobpack_drafts(&json!({ "store_path": store_path })).expect("list drafts");
        assert_eq!(listed["rows"].as_array().expect("rows").len(), 1);
        assert_eq!(listed["rows"][0]["id"], json!("f_docs_flow_draft"));
    }

    #[test]
    fn draft_store_owns_undo_redo_history() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");

        let created = create_mobpack_draft(&json!({
            "store_path": store_path,
            "template": "blank",
            "name": "History Draft",
        }))
        .expect("create draft");
        let id = created["row"]["id"].as_str().expect("draft id").to_string();
        let original_task = created["row"]["document"]["flow"]["steps"][0]["task"].clone();
        assert_eq!(created["row"]["can_undo"], json!(false));
        assert_eq!(created["row"]["can_redo"], json!(false));

        // Save an edited document: the previous head becomes an undo step.
        let mut edited = created["row"]["document"].clone();
        edited["flow"]["steps"][0]["task"] = json!("Edited input task.");
        let saved = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
            "document": edited,
        }))
        .expect("save edited draft");
        assert_eq!(saved["row"]["can_undo"], json!(true));
        assert_eq!(saved["row"]["can_redo"], json!(false));

        // Saving an unchanged document must not grow the history.
        let resaved = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
            "document": saved["row"]["document"].clone(),
        }))
        .expect("resave unchanged draft");
        assert_eq!(resaved["row"]["can_undo"], json!(true));

        let undone = undo_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
        }))
        .expect("undo");
        assert_eq!(undone["stepped"], json!(true));
        assert_eq!(
            undone["row"]["document"]["flow"]["steps"][0]["task"],
            original_task
        );
        assert_eq!(undone["row"]["can_undo"], json!(false));
        assert_eq!(undone["row"]["can_redo"], json!(true));
        assert!(
            undone["row"]["revision"].as_u64().expect("revision")
                > resaved["row"]["revision"].as_u64().expect("revision"),
            "undo must bump the draft revision"
        );

        let redone = redo_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
        }))
        .expect("redo");
        assert_eq!(redone["stepped"], json!(true));
        assert_eq!(
            redone["row"]["document"]["flow"]["steps"][0]["task"],
            json!("Edited input task.")
        );
        assert_eq!(redone["row"]["can_undo"], json!(true));
        assert_eq!(redone["row"]["can_redo"], json!(false));

        // A fresh save after undo invalidates the redo branch.
        let undone_again = undo_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
        }))
        .expect("undo again");
        assert_eq!(undone_again["stepped"], json!(true));
        let mut diverged = undone_again["row"]["document"].clone();
        diverged["flow"]["steps"][0]["task"] = json!("Diverged input task.");
        let diverged_save = save_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
            "document": diverged,
        }))
        .expect("diverging save");
        assert_eq!(diverged_save["row"]["can_redo"], json!(false));

        // Stepping past the end reports stepped: false without mutating.
        let exhausted = redo_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
        }))
        .expect("redo at head");
        assert_eq!(exhausted["stepped"], json!(false));

        // Stale revision guards apply to history steps too.
        let conflict = undo_mobpack_draft(&json!({
            "store_path": store_path,
            "id": id,
            "expected_revision": 1,
        }))
        .expect_err("stale undo must conflict");
        assert!(conflict.contains("draft revision conflict"), "{conflict}");
    }

    #[test]
    fn graph_projection_rpc_projects_editor_flow_controls() {
        let mut document = valid_document();
        document.members = json!([
            { "id": "m_router", "name": "router", "role": "router", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] },
            { "id": "m_worker", "name": "worker", "role": "worker", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] },
            { "id": "m_reviewer", "name": "reviewer", "role": "reviewer", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] }
        ]);
        document.flow = json!({
            "name": "main",
            "steps": [{
                "id": "input_1",
                "type": "input",
                "task": "Route work",
                "fields": "",
                "inputParams": [{ "id": "p_route", "name": "route", "type": "enum", "required": true, "enumValues": ["code", "fallback"] }]
            }, {
                "id": "branch_route",
                "type": "branch",
                "controllerRole": "m_reviewer",
                "branches": [{
                    "id": "code",
                    "condition": "route == \"code\"",
                    "cond": { "namespace": "params", "stepId": "params", "field": "route", "op": "==", "val": "code" },
                    "steps": [{ "id": "work", "type": "member", "role": "m_worker", "instruction": "Work" }]
                }],
                "fallback": [{ "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review" }]
            }]
        });
        document.instances = json!([]);
        document.edges = json!([]);
        document.frames = json!([]);

        let projection =
            graph_projection_mobpack(&json!({ "document": document })).expect("graph projection");
        assert_eq!(
            projection["source"],
            json!("mobkit/mobpacks/graph_projection")
        );
        assert!(projection["validation"]["ok"].as_bool().unwrap_or(false));
        let instances = projection["instances"].as_array().expect("instances");
        assert!(
            instances.iter().any(|node| {
                node["id"] == "g_branch_branch_route" && node["gateKind"] == "branch"
            })
        );
        assert!(instances.iter().any(|node| {
            node["id"] == "j_branch_branch_route"
                && node["gateKind"] == "join"
                && node["controllerRole"] == "m_reviewer"
        }));
        let edges = projection["edges"].as_array().expect("edges");
        assert!(edges.iter().any(|edge| {
            edge["from"] == "g_branch_branch_route"
                && edge["to"] == "work"
                && edge["kind"] == "cond"
                && edge["cond"]["var"] == "params.route"
        }));
        assert!(
            projection["frames"]
                .as_array()
                .expect("frames")
                .iter()
                .any(|frame| frame["kind"] == "Branch")
        );
    }

    #[test]
    fn graph_to_flow_rpc_projects_member_sequence() {
        let mut document = valid_document();
        document.members = json!([
            { "id": "planner", "name": "Planner", "role": "planner", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] },
            { "id": "reviewer", "name": "Reviewer", "role": "reviewer", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] }
        ]);
        document.flow = json!({
            "name": "main",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Task", "fields": "", "inputParams": [] },
                { "id": "plan", "type": "member", "role": "planner", "instruction": "Plan." },
                { "id": "review", "type": "member", "role": "reviewer", "instruction": "Review." }
            ]
        });
        document.instances = json!([
            { "id": "plan", "memberId": "planner", "col": 0, "row": 0 },
            { "id": "review", "memberId": "reviewer", "col": 1, "row": 0 }
        ]);
        document.edges = json!([
            { "id": "e_plan_review", "from": "plan", "to": "review", "kind": "next", "label": "" }
        ]);
        document.frames = json!([]);

        let projection =
            graph_to_flow_mobpack(&json!({ "document": document })).expect("graph to flow");

        assert_eq!(projection["source"], json!("mobkit/mobpacks/graph_to_flow"));
        assert_eq!(projection["flow"]["steps"][0]["type"], json!("input"));
        assert_eq!(projection["flow"]["steps"][1]["id"], json!("plan"));
        assert_eq!(
            projection["flow"]["steps"][1]["instruction"],
            json!("Plan.")
        );
        assert_eq!(projection["flow"]["steps"][2]["id"], json!("review"));
        assert_eq!(projection["document"]["flow"], projection["flow"]);
    }

    #[test]
    fn graph_to_flow_rpc_projects_branch_fallback() {
        let mut document = valid_document();
        document.members = json!([
            { "id": "m_worker", "name": "Worker", "role": "worker", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] },
            { "id": "m_reviewer", "name": "Reviewer", "role": "reviewer", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] }
        ]);
        document.flow = json!({
            "name": "main",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Route", "fields": "", "inputParams": [{ "id": "route", "name": "route", "type": "string", "required": true }] },
                { "id": "branch_route", "type": "branch", "controllerRole": "m_reviewer", "branches": [], "fallback": [] }
            ]
        });
        document.instances = json!([
            { "id": "g_branch_branch_route", "isGate": true, "gateKind": "branch", "col": 0, "row": 0 },
            { "id": "work", "memberId": "m_worker", "col": 1, "row": 0 },
            { "id": "review", "memberId": "m_reviewer", "lane": "fallback", "col": 1, "row": 1 },
            { "id": "j_branch_branch_route", "isGate": true, "gateKind": "join", "controllerRole": "m_reviewer", "collection": "any", "col": 2, "row": 0 }
        ]);
        document.edges = json!([
            { "id": "e_route_work", "from": "g_branch_branch_route", "to": "work", "kind": "cond", "label": "route == code", "cond": { "namespace": "params", "stepId": "params", "field": "route", "op": "==", "val": "code" } },
            { "id": "e_route_fallback", "from": "g_branch_branch_route", "to": "review", "kind": "next", "label": "fallback" },
            { "id": "e_work_join", "from": "work", "to": "j_branch_branch_route", "kind": "next", "label": "" },
            { "id": "e_review_join", "from": "review", "to": "j_branch_branch_route", "kind": "next", "label": "" }
        ]);
        document.frames = json!([]);

        let projection =
            graph_to_flow_mobpack(&json!({ "document": document })).expect("graph to flow");
        let branch = &projection["flow"]["steps"][1];

        assert_eq!(branch["id"], json!("branch_route"));
        assert_eq!(branch["type"], json!("branch"));
        assert_eq!(branch["controllerRole"], json!("m_reviewer"));
        assert_eq!(branch["branches"].as_array().expect("branches").len(), 1);
        assert_eq!(branch["branches"][0]["steps"][0]["id"], json!("work"));
        assert_eq!(branch["branches"][0]["cond"]["namespace"], json!("params"));
        assert_eq!(branch["branches"][0]["cond"]["field"], json!("route"));
        assert_eq!(branch["fallback"][0]["id"], json!("review"));
    }

    #[test]
    fn graph_to_flow_rpc_projects_parallel_join_policy() {
        let mut document = valid_document();
        document.members = json!([
            { "id": "m_worker", "name": "Worker", "role": "worker", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] },
            { "id": "m_reviewer", "name": "Reviewer", "role": "reviewer", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] }
        ]);
        document.flow = json!({
            "name": "main",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Parallel", "fields": "", "inputParams": [] },
                { "id": "parallel_pair", "type": "parallel", "dispatch": "fan_out", "collection": "all", "branches": [] }
            ]
        });
        document.instances = json!([
            { "id": "g_parallel_parallel_pair", "isGate": true, "gateKind": "fork", "dispatch": "fan_out", "col": 0, "row": 0 },
            { "id": "work", "memberId": "m_worker", "col": 1, "row": 0 },
            { "id": "review", "memberId": "m_reviewer", "col": 1, "row": 1 },
            { "id": "j_parallel_parallel_pair", "isGate": true, "gateKind": "join", "controllerRole": "m_reviewer", "collection": "all", "col": 2, "row": 0 }
        ]);
        document.edges = json!([
            { "id": "e_parallel_work", "from": "g_parallel_parallel_pair", "to": "work", "kind": "next", "label": "" },
            { "id": "e_parallel_review", "from": "g_parallel_parallel_pair", "to": "review", "kind": "next", "label": "" },
            { "id": "e_work_join", "from": "work", "to": "j_parallel_parallel_pair", "kind": "next", "label": "" },
            { "id": "e_review_join", "from": "review", "to": "j_parallel_parallel_pair", "kind": "next", "label": "" }
        ]);
        document.frames = json!([]);

        let projection =
            graph_to_flow_mobpack(&json!({ "document": document })).expect("graph to flow");
        let parallel = &projection["flow"]["steps"][1];

        assert_eq!(parallel["id"], json!("parallel_pair"));
        assert_eq!(parallel["type"], json!("parallel"));
        assert_eq!(parallel["dispatch"], json!("fan_out"));
        assert_eq!(parallel["collection"], json!("all"));
        assert_eq!(parallel["controllerRole"], json!("m_reviewer"));
        assert_eq!(parallel["branches"].as_array().expect("branches").len(), 2);
    }

    #[test]
    fn graph_to_flow_rpc_projects_repeat_back_edge() {
        let mut document = valid_document();
        document.members = json!([
            { "id": "m_worker", "name": "Worker", "role": "worker", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] },
            { "id": "m_reviewer", "name": "Reviewer", "role": "reviewer", "model": "gpt-5.5", "profileBinding": "inline", "runtimeMode": "turn_driven", "skills": [], "tools": ["comms"] }
        ]);
        document.flow = json!({
            "name": "main",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Repeat", "fields": "", "inputParams": [] },
                {
                    "id": "repeat_review",
                    "type": "repeat",
                    "loopId": "review_loop",
                    "maxIterations": 4,
                    "iterationInput": "draft",
                    "steps": [
                        { "id": "work", "type": "member", "role": "m_worker", "instruction": "Work." },
                        { "id": "review", "type": "member", "role": "m_reviewer", "instruction": "Review." }
                    ]
                }
            ]
        });
        document.instances = json!([
            { "id": "work", "memberId": "m_worker", "col": 0, "row": 0 },
            { "id": "review", "memberId": "m_reviewer", "col": 1, "row": 0 }
        ]);
        document.edges = json!([
            { "id": "e_work_review", "from": "work", "to": "review", "kind": "next", "label": "" },
            { "id": "e_review_work", "from": "review", "to": "work", "kind": "cond", "label": "retry", "cond": { "namespace": "steps", "stepId": "review", "field": "status", "op": "==", "val": "revise" } }
        ]);
        document.frames = json!([]);

        let projection =
            graph_to_flow_mobpack(&json!({ "document": document })).expect("graph to flow");
        let repeat = &projection["flow"]["steps"][1];

        assert_eq!(repeat["id"], json!("repeat_review"));
        assert_eq!(repeat["type"], json!("repeat"));
        assert_eq!(repeat["loopId"], json!("review_loop"));
        assert_eq!(repeat["maxIterations"], json!(4));
        assert_eq!(repeat["iterationInput"], json!("draft"));
        assert_eq!(repeat["cond"]["stepId"], json!("review"));
        assert_eq!(repeat["cond"]["field"], json!("status"));
        assert_eq!(repeat["steps"][0]["instruction"], json!("Work."));
        assert_eq!(repeat["steps"][1]["instruction"], json!("Review."));
    }

    #[test]
    fn apply_operation_adds_agent_definition_as_real_document_member() {
        let catalogs = mobpack_catalogs_response();
        let definition = catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions")
            .iter()
            .find(|definition| {
                definition["role"] == "implementer"
                    && definition["sourceOrigin"] == "mobkit/authoring-agent-definitions"
                    && definition["skills"].as_array().is_some_and(|skills| {
                        skills
                            .iter()
                            .any(|skill| skill == "mob.authoring.implement")
                    })
            })
            .expect("implementer definition");
        let document: MobpackDocument =
            serde_json::from_value(catalogs["blank_mobpack"]["document"].clone())
                .expect("blank document");

        let result = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "expected_catalog_snapshot_id": catalogs["catalog_snapshot"]["id"],
            "operation": {
                "type": "add_agent_definition",
                "definition_id": definition["id"],
            }
        }))
        .expect("apply add agent definition");

        assert_eq!(result["source"], json!("mobkit/mobpacks/apply_operation"));
        assert_eq!(result["operation"], json!("add_agent_definition"));
        assert!(result["ok"].as_bool().unwrap_or(false));
        assert!(result["validation"]["ok"].as_bool().unwrap_or(false));
        assert_eq!(result["selection"]["kind"], json!("agent"));
        let document = &result["document"];
        let members = document["members"].as_array().expect("members");
        let added = members
            .iter()
            .find(|member| member["sourceDefinition"]["definitionId"] == definition["id"])
            .expect("added definition member");
        assert_eq!(added["id"], result["selection"]["id"]);
        assert_eq!(added["role"], json!("implementer"));
        assert_eq!(added["profileBinding"], json!("inline"));
        assert!(
            added["tools"]
                .as_array()
                .expect("tools")
                .iter()
                .any(|tool| tool == "shell")
        );
        assert!(
            document["skill_realms"]
                .as_array()
                .expect("skill realms")
                .iter()
                .flat_map(|realm| realm["skills"].as_array().into_iter().flatten())
                .any(|skill| {
                    skill["id"] == "mob.authoring.implement"
                        && skill["source"] == "inline"
                        && skill["content"]
                            .as_str()
                            .is_some_and(|content| content.contains("MobKit-owned contracts"))
                })
        );
    }

    #[test]
    fn apply_operation_adds_blank_authoring_profile_member() {
        let catalogs = mobpack_catalogs_response();
        let blank_definition = catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions")
            .iter()
            .find(|definition| {
                definition["role"] == "agent"
                    && definition["sourceOrigin"] == "mobkit/authoring-agent-definitions"
            })
            .expect("blank authoring profile definition");
        let document: MobpackDocument =
            serde_json::from_value(catalogs["blank_mobpack"]["document"].clone())
                .expect("blank document");

        let result = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "expected_catalog_snapshot_id": catalogs["catalog_snapshot"]["id"],
            "operation": {
                "type": "add_agent_definition",
                "definition_id": blank_definition["id"],
            }
        }))
        .expect("add blank authoring agent definition");

        assert_eq!(result["operation"], json!("add_agent_definition"));
        assert_eq!(
            result["selection"],
            json!({ "kind": "agent", "id": "m_agent" })
        );
        let member = result["document"]["members"]
            .as_array()
            .expect("members")
            .iter()
            .find(|member| member["id"] == "m_agent")
            .expect("blank agent member");
        assert_eq!(member["role"], json!("agent"));
        assert_eq!(member["profileBinding"], json!("inline"));
        assert_eq!(member["runtimeMode"], json!("turn_driven"));
        assert_eq!(member["model"], json!("gpt-5.5"));
        assert_eq!(
            member["sourceDefinition"]["definitionId"],
            blank_definition["id"]
        );
        assert_eq!(
            member["sourceDefinition"]["sourceOrigin"],
            json!("mobkit/authoring-agent-definitions")
        );
    }

    #[test]
    fn apply_operation_rejects_stale_catalog_snapshot_for_catalog_backed_operations() {
        let catalogs = mobpack_catalogs_response();
        let definition = catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions")
            .iter()
            .find(|definition| {
                definition["role"] == "implementer"
                    && definition["sourceOrigin"] == "mobkit/authoring-agent-definitions"
            })
            .expect("implementer definition");
        let document: MobpackDocument =
            serde_json::from_value(catalogs["blank_mobpack"]["document"].clone())
                .expect("blank document");

        let stale = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "expected_catalog_snapshot_id": "stale-catalog-snapshot",
            "operation": {
                "type": "add_agent_definition",
                "definition_id": definition["id"].clone()
            }
        }))
        .expect_err("stale catalog snapshot must reject catalog-backed add");
        assert!(
            stale.contains("catalog snapshot conflict for add_agent_definition"),
            "{stale}"
        );
    }

    #[test]
    fn runtime_bound_apply_operation_uses_runtime_catalog_snapshot() {
        let runtime = MobpackRuntimeCatalogState {
            loaded_modules: vec!["editor-host".to_string()],
            runtime_methods: vec!["mobkit/mobpacks/apply_operation".to_string()],
            has_contact_directory: true,
            has_peer_mob_handles: false,
            has_inproc_contacts: false,
            runtime_flow_rows: Vec::new(),
            runtime_agent_definition_sources: Vec::new(),
            runtime_skill_realms: Vec::new(),
        };
        let runtime_catalogs = mobpack_catalogs_response_with_runtime(Some(&runtime));
        let standalone_catalogs = mobpack_catalogs_response();
        let runtime_mob_tool = runtime_catalogs["tool_catalog"]
            .as_array()
            .expect("runtime tool catalog")
            .iter()
            .find(|tool| tool["id"] == "mob")
            .expect("mob tool");
        assert_eq!(
            runtime_mob_tool["runtime_availability"]["available"],
            json!(false)
        );
        assert_eq!(
            runtime_mob_tool["runtimeAvailability"]["available"],
            json!(false)
        );
        let runtime_planner_definition = runtime_catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions")
            .iter()
            .find(|definition| {
                definition["role"] == "planner"
                    && definition["sourceOrigin"] == "mobkit/authoring-agent-definitions"
            })
            .expect("runtime authoring planner definition");
        let runtime_planner_mob_tool = runtime_planner_definition["toolDefinitions"]
            .as_array()
            .expect("planner tool definitions")
            .iter()
            .find(|tool| tool["id"] == "mob")
            .expect("planner mob tool definition");
        assert_eq!(
            runtime_planner_mob_tool["runtime_availability"]["available"],
            json!(false)
        );
        assert_eq!(
            runtime_planner_mob_tool["runtimeAvailability"]["state"],
            json!("unavailable")
        );
        let runtime_agent_definition = runtime_catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions")
            .iter()
            .find(|definition| {
                definition["role"] == "agent"
                    && definition["sourceOrigin"] == "mobkit/authoring-agent-definitions"
            })
            .expect("blank authoring agent definition");
        let runtime_agent_builtins = runtime_agent_definition["toolDefinitions"]
            .as_array()
            .expect("agent tool definitions")
            .iter()
            .find(|tool| tool["id"] == "builtins")
            .expect("agent builtins tool definition");
        assert_eq!(
            runtime_agent_builtins["runtime_availability"]["state"],
            json!("available")
        );
        let runtime_snapshot = runtime_catalogs["catalog_snapshot"]["id"]
            .as_str()
            .expect("runtime snapshot id");
        assert_ne!(
            runtime_snapshot,
            standalone_catalogs["catalog_snapshot"]["id"]
                .as_str()
                .expect("standalone snapshot id")
        );
        let document: MobpackDocument =
            serde_json::from_value(runtime_catalogs["blank_mobpack"]["document"].clone())
                .expect("blank document");

        let standalone_add_request = json!({
            "document": document,
            "expected_catalog_snapshot_id": runtime_snapshot,
            "operation": {
                "type": "add_agent_definition",
                "definition_id": runtime_agent_definition["id"].clone()
            }
        });
        let standalone_rejects_runtime_snapshot =
            apply_mobpack_authoring_operation(&standalone_add_request)
                .expect_err("standalone apply_operation must not accept runtime snapshot");
        assert!(
            standalone_rejects_runtime_snapshot
                .contains("catalog snapshot conflict for add_agent_definition"),
            "{standalone_rejects_runtime_snapshot}"
        );

        let added = apply_mobpack_authoring_operation_with_runtime(
            &json!({
                "document": document,
                "expected_catalog_snapshot_id": runtime_snapshot,
                "operation": {
                    "type": "add_agent_definition",
                    "definition_id": runtime_agent_definition["id"].clone()
                }
            }),
            Some(&runtime),
        )
        .expect("runtime apply_operation accepts runtime agent catalog snapshot");
        let member_id = added["selection"]["id"]
            .as_str()
            .expect("member id")
            .to_string();

        let tool_added = apply_mobpack_authoring_operation_with_runtime(
            &json!({
                "document": added["document"].clone(),
                "expected_catalog_snapshot_id": runtime_snapshot,
                "operation": {
                    "type": "add_member_tool",
                    "member_id": member_id,
                    "tool_id": "shell"
                }
            }),
            Some(&runtime),
        )
        .expect("runtime apply_operation accepts runtime tool catalog snapshot");
        assert_eq!(tool_added["operation"], json!("add_member_tool"));

        let unavailable_tool = apply_mobpack_authoring_operation_with_runtime(
            &json!({
                "document": tool_added["document"].clone(),
                "expected_catalog_snapshot_id": runtime_snapshot,
                "operation": {
                    "type": "add_member_tool",
                    "member_id": member_id,
                    "tool_id": "mob"
                }
            }),
            Some(&runtime),
        )
        .expect_err("runtime-unavailable tool must fail");
        assert!(
            unavailable_tool.contains("MobKit tool is unavailable in current runtime: mob"),
            "{unavailable_tool}"
        );

        let cross_mob_runtime = MobpackRuntimeCatalogState {
            loaded_modules: vec!["editor-host".to_string()],
            runtime_methods: vec![
                "mobkit/mobpacks/apply_operation".to_string(),
                "mobkit/cross_mob/wire".to_string(),
                "mobkit/cross_mob/unwire".to_string(),
                "mobkit/cross_mob/send".to_string(),
            ],
            has_contact_directory: true,
            has_peer_mob_handles: true,
            has_inproc_contacts: true,
            runtime_flow_rows: Vec::new(),
            runtime_agent_definition_sources: Vec::new(),
            runtime_skill_realms: Vec::new(),
        };
        let cross_mob_catalogs = mobpack_catalogs_response_with_runtime(Some(&cross_mob_runtime));
        let cross_mob_tool = cross_mob_catalogs["tool_catalog"]
            .as_array()
            .expect("cross-mob tool catalog")
            .iter()
            .find(|tool| tool["id"] == "mob")
            .expect("mob tool");
        assert_eq!(
            cross_mob_tool["runtime_availability"]["available"],
            json!(true)
        );

        let skill_toggled = apply_mobpack_authoring_operation_with_runtime(
            &json!({
                "document": tool_added["document"].clone(),
                "expected_catalog_snapshot_id": runtime_snapshot,
                "operation": {
                    "type": "toggle_member_skill",
                    "member_id": member_id,
                    "skill_id": "mob.authoring.review"
                }
            }),
            Some(&runtime),
        )
        .expect("runtime apply_operation accepts runtime skill catalog snapshot");
        assert_eq!(skill_toggled["operation"], json!("toggle_member_skill"));
        let copied_skill = skill_toggled["document"]["skill_realms"]
            .as_array()
            .expect("skill realms")
            .iter()
            .flat_map(|realm| realm["skills"].as_array().into_iter().flatten())
            .find(|skill| skill["id"] == "mob.authoring.review")
            .expect("runtime catalog skill copied into document");
        assert_eq!(
            copied_skill["provenance"]["catalog"],
            json!("mobkit/skills/catalog")
        );
        assert_eq!(
            copied_skill["sourceMobpack"],
            json!("mobkit_authoring_profiles")
        );
    }

    #[test]
    fn runtime_flow_registry_rows_project_each_loaded_runtime_flow() {
        let definition = MobDefinition::from_toml(
            r#"
[mob]
id = "runtime-editor-proof"

[profiles.worker]
model = "gpt-5.5"
runtime_mode = "turn_driven"

[profiles.worker.tools]
builtins = true

[flows.main]
description = "Run the main runtime flow."

[flows.main.steps.work]
role = "worker"
message = "Work."

[flows.main.root.nodes.node_work]
kind = "step"
step_id = "work"
depends_on = []
depends_on_mode = "all"

[flows.audit]
description = "Run the audit runtime flow."

[flows.audit.steps.audit]
role = "worker"
message = "Audit."

[flows.audit.root.nodes.node_audit]
kind = "step"
step_id = "audit"
depends_on = []
depends_on_mode = "all"
"#,
        )
        .expect("runtime definition");

        let rows = runtime_flow_registry_rows_from_definition(&definition);
        assert_eq!(rows.len(), 2);
        assert_eq!(
            rows.iter()
                .map(|row| row["runtime_flow_id"].as_str().unwrap_or_default())
                .collect::<Vec<_>>(),
            vec!["audit", "main"]
        );
        for row in &rows {
            assert_eq!(row["source"], json!("mobkit/runtime/flow_projection"));
            assert_eq!(row["runtime_projection"], json!(true));
            assert_eq!(row["validation"]["ok"], json!(true));
            assert_eq!(row["document"]["mob_id"], json!("runtime-editor-proof"));
            assert_eq!(row["document"]["flow"]["name"], row["runtime_flow_id"]);
        }

        let runtime = MobpackRuntimeCatalogState {
            loaded_modules: vec!["runtime-editor-proof".to_string()],
            runtime_methods: vec!["mobkit/list_flows".to_string()],
            has_contact_directory: false,
            has_peer_mob_handles: false,
            has_inproc_contacts: false,
            runtime_flow_rows: rows.clone(),
            runtime_agent_definition_sources: Vec::new(),
            runtime_skill_realms: Vec::new(),
        };
        let catalogs = mobpack_catalogs_response_with_runtime(Some(&runtime));
        assert_eq!(catalogs["runtime_flows"], json!(rows));
        assert_eq!(
            catalogs["runtime_flows"][0]["provenance"]["catalog"],
            json!("mobkit/runtime/flow_projection")
        );
    }

    #[test]
    fn runtime_agent_definition_sources_project_loaded_runtime_profiles() {
        let definition = MobDefinition::from_toml(
            r#"
[mob]
id = "runtime-agent-proof"

[profiles.reviewer]
model = "gpt-5.5"
skills = ["mob.runtime.review"]
peer_description = "Review live runtime work."
runtime_mode = "turn_driven"

[profiles.reviewer.tools]
builtins = true
shell = true
mob = true

[skills."mob.runtime.review"]
source = "inline"
content = "Review the loaded runtime profile."

[flows.main]
description = "Runtime agent definition proof."

[flows.main.steps.review]
role = "reviewer"
message = "Review."

[flows.main.root.nodes.node_review]
kind = "step"
step_id = "review"
depends_on = []
depends_on_mode = "all"
"#,
        )
        .expect("runtime definition");

        let sources = runtime_agent_definition_sources_from_definition(&definition);
        assert_eq!(sources.len(), 1);
        assert_eq!(
            sources[0]["source"],
            json!("mobkit/runtime/agent-definitions")
        );
        assert_eq!(sources[0]["runtime_projection"], json!(true));
        assert_eq!(sources[0]["runtime_mob_id"], json!("runtime-agent-proof"));
        assert_eq!(
            sources[0]["document"]["members"][0]["role"],
            json!("reviewer")
        );

        let runtime = MobpackRuntimeCatalogState {
            loaded_modules: vec!["runtime-agent-proof".to_string()],
            runtime_methods: vec!["mobkit/mobpacks/apply_operation".to_string()],
            has_contact_directory: false,
            has_peer_mob_handles: false,
            has_inproc_contacts: false,
            runtime_flow_rows: Vec::new(),
            runtime_agent_definition_sources: sources,
            runtime_skill_realms: runtime_skill_realms_from_definition(&definition),
        };
        let catalogs = mobpack_catalogs_response_with_runtime(Some(&runtime));
        let runtime_definition = catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions")
            .iter()
            .find(|definition| {
                definition["role"] == "reviewer"
                    && definition["sourceOrigin"] == "mobkit/runtime/agent-definitions"
            })
            .expect("runtime reviewer definition");
        assert_eq!(
            runtime_definition["sourceMobpack"],
            json!("runtime_profiles_runtime_agent_proof")
        );
        assert_eq!(
            runtime_definition["provenance"]["source"],
            json!("mobkit/runtime/agent-definitions")
        );
        let shell_tool = runtime_definition["toolDefinitions"]
            .as_array()
            .expect("tool definitions")
            .iter()
            .find(|tool| tool["id"] == "shell")
            .expect("shell tool");
        assert_eq!(
            shell_tool["runtime_availability"]["state"],
            json!("available")
        );
        let mob_tool = runtime_definition["toolDefinitions"]
            .as_array()
            .expect("tool definitions")
            .iter()
            .find(|tool| tool["id"] == "mob")
            .expect("mob tool");
        assert_eq!(
            mob_tool["runtime_availability"]["state"],
            json!("unavailable")
        );
        assert_eq!(
            runtime_definition["skillDefinitions"][0]["id"],
            json!("mob.runtime.review")
        );
        assert_eq!(
            catalogs["skill_realms"][0]["provenance"]["source"],
            json!("UnifiedRuntime.MobDefinition")
        );
    }

    #[test]
    fn apply_operation_rejects_stale_draft_revision_when_guarded() {
        let dir = tempfile::tempdir().expect("tempdir");
        let store_path = dir.path().join("drafts.json");
        let document = valid_document();
        let saved = save_mobpack_draft(&json!({
            "id": "f_apply_guard",
            "document": document,
            "store_path": store_path,
        }))
        .expect("save guarded draft");
        let draft_document = saved["row"]["document"].clone();

        let accepted = apply_mobpack_authoring_operation(&json!({
            "id": "f_apply_guard",
            "expected_revision": 1,
            "expected_etag": "f_apply_guard:1",
            "store_path": store_path,
            "document": draft_document,
            "operation": {
                "type": "update_deploy_settings",
                "field": "prompt",
                "value": "Guarded operation."
            }
        }))
        .expect("guarded apply operation");
        assert_eq!(
            accepted["document"]["deploy"]["prompt"],
            json!("Guarded operation.")
        );

        let stale = apply_mobpack_authoring_operation(&json!({
            "id": "f_apply_guard",
            "expected_revision": 0,
            "store_path": store_path,
            "document": accepted["document"].clone(),
            "operation": {
                "type": "update_deploy_settings",
                "field": "prompt",
                "value": "Stale operation."
            }
        }))
        .expect_err("stale apply operation must fail");
        assert!(
            stale.contains("mobkit/mobpacks/apply_operation draft revision conflict"),
            "{stale}"
        );
    }

    #[test]
    fn apply_operation_updates_member_tools_skills_without_raw_member_patches() {
        let catalogs = mobpack_catalogs_response();
        let definition = catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions")
            .iter()
            .find(|definition| {
                definition["role"] == "implementer"
                    && definition["sourceOrigin"] == "mobkit/authoring-agent-definitions"
                    && definition["skills"].as_array().is_some_and(|skills| {
                        skills
                            .iter()
                            .any(|skill| skill == "mob.authoring.implement")
                    })
            })
            .expect("implementer definition");
        let document: MobpackDocument =
            serde_json::from_value(catalogs["blank_mobpack"]["document"].clone())
                .expect("blank document");

        let added = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "add_agent_definition",
                "definition_id": definition["id"].clone(),
            }
        }))
        .expect("add implementer");
        let mut document = added["document"].clone();
        let member_id = added["selection"]["id"]
            .as_str()
            .expect("member id")
            .to_string();

        let updated = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_member",
                "member_id": member_id,
                "patch": {
                    "name": "Edited implementer",
                    "systemPrompt": "Updated through MobKit apply_operation.",
                    "maxInlinePeerNotifications": 3
                }
            }
        }))
        .expect("update member");
        assert_eq!(
            updated["selection"],
            json!({ "kind": "agent", "id": member_id })
        );
        assert!(updated["validation"]["ok"].as_bool().unwrap_or(false));
        assert_eq!(
            updated["document"]["members"]
                .as_array()
                .expect("members")
                .iter()
                .find(|member| member["id"] == member_id)
                .expect("updated member")["name"],
            json!("Edited implementer")
        );
        document = updated["document"].clone();

        let rejected = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_member",
                "member_id": member_id,
                "patch": { "id": "renamed" }
            }
        }));
        assert!(
            rejected
                .expect_err("renaming member id must fail")
                .contains("cannot change member id")
        );

        let unsupported_field = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_member",
                "member_id": member_id,
                "patch": { "tools": ["shell"] }
            }
        }))
        .expect_err("tool access must use dedicated operation");
        assert!(
            unsupported_field
                .contains("update_member cannot mutate tools; use the dedicated MobKit operation"),
            "{unsupported_field}"
        );

        let unknown_field = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_member",
                "member_id": member_id,
                "patch": { "unexpected": true }
            }
        }))
        .expect_err("unknown member patch fields must fail");
        assert!(
            unknown_field.contains("unsupported update_member patch field: unexpected"),
            "{unknown_field}"
        );

        let invalid_model = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_member",
                "member_id": member_id,
                "patch": { "model": "not-a-real-model" }
            }
        }))
        .expect_err("unknown model must fail update_member");
        assert!(
            invalid_model.contains("unsupported update_member model: not-a-real-model"),
            "{invalid_model}"
        );

        let removed_tool = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "remove_member_tool",
                "member_id": member_id,
                "tool_id": "shell"
            }
        }))
        .expect("remove shell");
        let tools = removed_tool["document"]["members"]
            .as_array()
            .expect("members")
            .iter()
            .find(|member| member["id"] == member_id)
            .expect("tool member")["tools"]
            .as_array()
            .expect("tools");
        assert!(!tools.iter().any(|tool| tool == "shell"));
        document = removed_tool["document"].clone();

        let added_tool = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "add_member_tool",
                "member_id": member_id,
                "tool_id": "shell"
            }
        }))
        .expect("add shell");
        assert!(added_tool["validation"]["ok"].as_bool().unwrap_or(false));
        document = added_tool["document"].clone();
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": document,
                "operation": {
                    "type": "add_member_tool",
                    "member_id": member_id,
                    "tool_id": "not-a-real-tool"
                }
            }))
            .expect_err("unknown tool rejected")
            .contains("unknown MobKit tool")
        );

        let toggled_skill = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "toggle_member_skill",
                "member_id": member_id,
                "skill_id": "mob.authoring.review"
            }
        }))
        .expect("toggle skill");
        assert!(
            toggled_skill["document"]["skill_realms"]
                .as_array()
                .expect("skill realms")
                .iter()
                .flat_map(|realm| realm["skills"].as_array().into_iter().flatten())
                .any(|skill| skill["id"] == "mob.authoring.review")
        );
        document = toggled_skill["document"].clone();

        let inline_skill = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "create_inline_skill",
                "member_id": member_id,
                "label": "Editor proof",
                "content": "Prove the editor operation path."
            }
        }))
        .expect("create inline skill");
        assert_eq!(
            inline_skill["selection"]["skill_id"],
            json!("mob.editor.proof")
        );
        assert!(
            inline_skill["document"]["skill_realms"]
                .as_array()
                .expect("skill realms")
                .iter()
                .any(|realm| realm["id"] == "mobkit/editor-inline"
                    && realm["skills"]
                        .as_array()
                        .expect("inline skills")
                        .iter()
                        .any(|skill| skill["id"] == "mob.editor.proof"
                            && skill["content"] == "Prove the editor operation path."))
        );
        document = inline_skill["document"].clone();

        let current_document = document.clone();
        let mut projected_document = document.clone();
        projected_document["name"] = json!("Projected operation payload accepted");
        let rejected_projection = apply_mobpack_authoring_operation(&json!({
            "document": current_document,
            "operation": {
                "type": "update_flow_step",
                "document": projected_document.clone(),
                "selection": { "kind": "schema", "id": "PlanArtifact" }
            }
        }));
        assert!(
            rejected_projection
                .expect_err("semantic operation document replacement must fail")
                .contains("does not accept operation.document")
        );

        let rejected_replacement = apply_mobpack_authoring_operation(&json!({
            "document": current_document,
            "operation": {
                "type": "replace_authoring_document",
                "document": projected_document,
                "selection": { "kind": "schema", "id": "PlanArtifact" }
            }
        }));
        assert!(
            rejected_replacement
                .expect_err("whole-document replacement must use import/create/save")
                .contains("does not accept operation.document")
        );

        let mut deploy = current_document["deploy"].clone();
        deploy["prompt"] = json!("Payload section prompt.");
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": current_document,
                "operation": {
                    "type": "update_deploy_settings",
                    "deploy": deploy,
                    "selection": { "kind": null, "id": null }
                }
            }))
            .expect_err("full deploy settings replacement must fail")
            .contains("does not accept full deploy object")
        );
        let field_settings = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_deploy_settings",
                "field": "prompt",
                "value": "Field section prompt.",
                "selection": { "kind": null, "id": null }
            }
        }))
        .expect("apply deploy settings field");
        assert_eq!(
            field_settings["document"]["deploy"]["prompt"],
            json!("Field section prompt.")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": field_settings["document"],
                "operation": {
                    "type": "update_deploy_settings",
                    "field": "surface",
                    "value": "desktop"
                }
            }))
            .expect_err("invalid deploy surface rejected before mutation")
            .contains("update_deploy_settings surface must be one of cli, rpc")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": field_settings["document"],
                "operation": {
                    "type": "update_deploy_settings",
                    "field": "maxTotalTokens",
                    "value": -1
                }
            }))
            .expect_err("invalid deploy number rejected before mutation")
            .contains(
                "update_deploy_settings max_total_tokens must be a non-negative integer or null"
            )
        );
        let mob_settings = apply_mobpack_authoring_operation(&json!({
            "document": field_settings["document"],
            "operation": {
                "type": "update_mob_settings",
                "field": "backendDefault",
                "value": "external"
            }
        }))
        .expect("apply mob settings field");
        assert_eq!(
            mob_settings["document"]["mob_settings"]["backendDefault"],
            json!("external")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": mob_settings["document"],
                "operation": {
                    "type": "update_mob_settings",
                    "field": "backendDefault",
                    "value": "memory"
                }
            }))
            .expect_err("invalid mob backend rejected before mutation")
            .contains("update_mob_settings backendDefault must be one of session, external")
        );
        let role_wiring_added = apply_mobpack_authoring_operation(&json!({
            "document": mob_settings["document"],
            "operation": {
                "type": "update_role_wiring",
                "action": "add"
            }
        }))
        .expect("add role wiring by action");
        let role_source = role_wiring_added["document"]["mob_settings"]["roleWiring"][0]["a"]
            .as_str()
            .expect("role wiring source")
            .to_string();
        assert!(!role_source.is_empty());
        let role_wiring_target = apply_mobpack_authoring_operation(&json!({
            "document": role_wiring_added["document"],
            "operation": {
                "type": "update_role_wiring",
                "action": "set_target",
                "index": 0,
                "value": role_source
            }
        }))
        .expect("set role wiring target by action");
        assert_eq!(
            role_wiring_target["document"]["mob_settings"]["roleWiring"][0],
            json!({ "a": role_source, "b": role_source })
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": role_wiring_target["document"],
                "operation": {
                    "type": "update_deploy_settings",
                    "field": "notASetting",
                    "value": true
                }
            }))
            .expect_err("unknown deploy field rejected")
            .contains("unsupported deploy settings field: notASetting")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": role_wiring_target["document"],
                "operation": {
                    "type": "update_deploy_settings",
                    "flow": { "name": "fragment fallback must not apply" }
                }
            }))
            .expect_err("deploy settings require typed payload")
            .contains("update_deploy_settings requires field")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": role_wiring_target["document"],
                "operation": {
                    "type": "update_mob_settings",
                    "members": []
                }
            }))
            .expect_err("mob settings require typed payload")
            .contains("update_mob_settings requires field")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": role_wiring_target["document"],
                "operation": {
                    "type": "update_mob_settings",
                    "mob_settings": {}
                }
            }))
            .expect_err("full mob settings replacement rejected")
            .contains("update_mob_settings does not accept full mob_settings object")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": role_wiring_target["document"],
                "operation": {
                    "type": "update_role_wiring",
                    "edges": []
                }
            }))
            .expect_err("role wiring requires typed payload")
            .contains("update_role_wiring requires action")
        );
    }

    #[test]
    fn authoring_schema_and_capabilities_expose_operation_catalog() {
        let schema = mobpack_schema_response();
        let operations = schema["operations"].as_array().expect("schema operations");
        for operation_type in [
            "add_agent_definition",
            "delete_member",
            "rename_schema_field",
            "delete_schema",
            "update_deploy_settings",
            "move_graph_node",
        ] {
            assert!(
                operations
                    .iter()
                    .any(|operation| operation["type"] == operation_type),
                "missing schema operation {operation_type}"
            );
        }
        for operation in operations {
            let operation_type = operation["type"].as_str().expect("schema operation type");
            let projection_supported = operation["projection_document_supported"]
                .as_bool()
                .expect("projection_document_supported bool");
            assert!(
                !projection_supported,
                "normal authoring operations must not accept projected documents: {operation_type}"
            );
            assert_ne!(
                operation_type, "replace_authoring_document",
                "whole-document replacement belongs to import/create/save, not apply_operation"
            );
        }
        let capabilities = crate::rpc::mobpack_authoring_capabilities();
        assert_eq!(
            capabilities["operations"]
                .as_array()
                .expect("capability operations")
                .len(),
            operations.len()
        );
        assert_eq!(capabilities["deploy_command"], json!("rkat mob run"));
    }

    fn graph_apply_operation_document() -> Value {
        let mut document = valid_document();
        document.mob_toml = None;
        document.members = json!([
            {
                "id": "planner",
                "name": "planner",
                "role": "planner",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "tools": []
            },
            {
                "id": "reviewer",
                "name": "reviewer",
                "role": "reviewer",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "tools": []
            }
        ]);
        document.schemas = json!([]);
        document.instances = json!([
            { "id": "n_plan", "memberId": "planner", "kind": "member", "col": 0, "row": 0 },
            { "id": "n_review", "memberId": "reviewer", "kind": "member", "col": 1, "row": 0 }
        ]);
        document.edges = json!([]);
        document.frames = json!([]);
        document.flow = graph_to_flow_from_document(&document);
        serde_json::to_value(document).expect("serialize graph document")
    }

    fn apply_narrow_graph_operation(document: Value, operation: Value) -> Value {
        assert!(
            operation
                .as_object()
                .expect("operation object")
                .get("document")
                .is_none(),
            "graph apply_operation tests must use narrow payloads without operation.document"
        );
        let result = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": operation
        }))
        .expect("apply narrow graph operation");
        assert_eq!(result["source"], json!("mobkit/mobpacks/apply_operation"));
        assert!(result.get("document").is_some());
        assert!(result.get("selection").is_some());
        result
    }

    fn graph_instance<'a>(document: &'a Value, instance_id: &str) -> &'a Value {
        document["instances"]
            .as_array()
            .expect("instances")
            .iter()
            .find(|instance| instance["id"] == instance_id)
            .unwrap_or_else(|| panic!("missing graph instance {instance_id}"))
    }

    fn graph_edge<'a>(document: &'a Value, edge_id: &str) -> &'a Value {
        document["edges"]
            .as_array()
            .expect("edges")
            .iter()
            .find(|edge| edge["id"] == edge_id)
            .unwrap_or_else(|| panic!("missing graph edge {edge_id}"))
    }

    fn assert_graph_flow_projection_synced(result: &Value) {
        let projection = graph_to_flow_mobpack(&json!({ "document": result["document"].clone() }))
            .expect("graph to flow projection");
        assert_eq!(result["document"]["flow"], projection["flow"]);
    }

    #[test]
    fn apply_operation_mutates_graph_nodes_from_narrow_payloads() {
        let inserted = apply_narrow_graph_operation(
            graph_apply_operation_document(),
            json!({
                "type": "insert_graph_node",
                "pick": { "kind": "memberInstance", "memberId": "reviewer" },
                "cell": { "col": 2, "row": 0 }
            }),
        );
        assert_eq!(inserted["operation"], json!("insert_graph_node"));
        assert_eq!(inserted["selection"]["kind"], json!("instance"));
        assert_eq!(inserted["selection"]["id"], json!("i_reviewer"));
        assert_eq!(
            graph_instance(&inserted["document"], "i_reviewer")["col"],
            json!(2)
        );
        assert_graph_flow_projection_synced(&inserted);

        let updated = apply_narrow_graph_operation(
            inserted["document"].clone(),
            json!({
                "type": "apply_graph_node_edit",
                "instance_id": "i_reviewer",
                "action": "set_label",
                "label": "Done"
            }),
        );
        assert_eq!(updated["operation"], json!("apply_graph_node_edit"));
        assert_eq!(
            updated["selection"],
            json!({ "kind": "instance", "id": "i_reviewer" })
        );
        assert_eq!(
            graph_instance(&updated["document"], "i_reviewer")["label"],
            json!("Done")
        );
        assert_graph_flow_projection_synced(&updated);

        let moved = apply_narrow_graph_operation(
            updated["document"].clone(),
            json!({
                "type": "move_graph_node",
                "instance_id": "i_reviewer",
                "cell": { "col": 1, "row": 0 },
                "original_cell": { "col": 2, "row": 0 }
            }),
        );
        assert_eq!(moved["operation"], json!("move_graph_node"));
        assert_eq!(
            moved["selection"],
            json!({ "kind": "instance", "id": "i_reviewer" })
        );
        assert_eq!(
            graph_instance(&moved["document"], "i_reviewer")["col"],
            json!(1)
        );
        assert_eq!(
            graph_instance(&moved["document"], "n_review")["col"],
            json!(2)
        );
        assert_graph_flow_projection_synced(&moved);

        let deleted = apply_narrow_graph_operation(
            moved["document"].clone(),
            json!({
                "type": "delete_graph_node",
                "instance_id": "i_reviewer"
            }),
        );
        assert_eq!(deleted["operation"], json!("delete_graph_node"));
        assert_eq!(deleted["selection"], json!({ "kind": null, "id": null }));
        assert!(
            !deleted["document"]["instances"]
                .as_array()
                .expect("instances")
                .iter()
                .any(|instance| instance["id"] == "i_reviewer")
        );
        assert_graph_flow_projection_synced(&deleted);
    }

    #[test]
    fn apply_operation_rejects_raw_graph_insert_and_connect_payloads() {
        let raw_instance = apply_mobpack_authoring_operation(&json!({
            "document": graph_apply_operation_document(),
            "operation": {
                "type": "insert_graph_node",
                "instance": { "id": "n_done", "kind": "member", "memberId": "reviewer", "col": 2, "row": 0 }
            }
        }))
        .expect_err("raw graph instance insert must fail");
        assert!(
            raw_instance.contains("insert_graph_node does not accept raw instance"),
            "{raw_instance}"
        );

        let raw_instances = apply_mobpack_authoring_operation(&json!({
            "document": graph_apply_operation_document(),
            "operation": {
                "type": "insert_graph_node",
                "instances": [
                    { "id": "n_done", "kind": "member", "memberId": "reviewer", "col": 2, "row": 0 }
                ]
            }
        }))
        .expect_err("raw graph instances array must fail");
        assert!(
            raw_instances.contains("insert_graph_node does not accept raw instances array"),
            "{raw_instances}"
        );

        let raw_edge = apply_mobpack_authoring_operation(&json!({
            "document": graph_apply_operation_document(),
            "operation": {
                "type": "connect_graph_nodes",
                "edge": { "id": "e_raw", "from": "n_plan", "to": "n_review" }
            }
        }))
        .expect_err("raw graph edge connect must fail");
        assert!(
            raw_edge.contains("connect_graph_nodes does not accept raw edge"),
            "{raw_edge}"
        );

        for operation in [
            json!({
                "type": "insert_graph_node",
                "pick": { "kind": "memberInstance", "memberId": "reviewer" },
                "cell": { "col": 2, "row": 0 },
                "frames": [{ "id": "ui_frame", "kind": "Branch" }]
            }),
            json!({
                "type": "apply_graph_node_edit",
                "instance_id": "n_review",
                "action": "set_label",
                "label": "Review",
                "frames": [{ "id": "ui_frame", "kind": "Branch" }]
            }),
            json!({
                "type": "connect_graph_nodes",
                "from_id": "n_plan",
                "to_id": "n_review",
                "frames": [{ "id": "ui_frame", "kind": "Branch" }]
            }),
        ] {
            let err = apply_mobpack_authoring_operation(&json!({
                "document": graph_apply_operation_document(),
                "operation": operation
            }))
            .expect_err("raw graph frames must fail");
            assert!(
                err.contains("graph authoring operations do not accept raw frames"),
                "{err}"
            );
        }
    }

    #[test]
    fn apply_operation_mutates_graph_edges_from_narrow_payloads() {
        let connected = apply_narrow_graph_operation(
            graph_apply_operation_document(),
            json!({
                "type": "connect_graph_nodes",
                "from_id": "n_plan",
                "to_id": "n_review"
            }),
        );
        assert_eq!(connected["operation"], json!("connect_graph_nodes"));
        assert_eq!(connected["selection"]["kind"], json!("edge"));
        assert_eq!(connected["selection"]["id"], json!("e_n_plan_n_review"));
        assert_eq!(
            graph_edge(&connected["document"], "e_n_plan_n_review")["kind"],
            json!("next")
        );
        assert_graph_flow_projection_synced(&connected);

        let edge_updated = apply_narrow_graph_operation(
            connected["document"].clone(),
            json!({
                "type": "apply_graph_edge_edit",
                "edge_id": "e_n_plan_n_review",
                "action": "set_label",
                "label": "ready"
            }),
        );
        assert_eq!(edge_updated["operation"], json!("apply_graph_edge_edit"));
        assert_eq!(
            edge_updated["selection"],
            json!({ "kind": "edge", "id": "e_n_plan_n_review" })
        );
        assert_eq!(
            graph_edge(&edge_updated["document"], "e_n_plan_n_review")["label"],
            json!("ready")
        );
        assert_graph_flow_projection_synced(&edge_updated);

        let edge_deleted = apply_narrow_graph_operation(
            edge_updated["document"].clone(),
            json!({
                "type": "delete_graph_edge",
                "edge_id": "e_n_plan_n_review"
            }),
        );
        assert_eq!(edge_deleted["operation"], json!("delete_graph_edge"));
        assert_eq!(
            edge_deleted["selection"],
            json!({ "kind": null, "id": null })
        );
        assert_eq!(
            edge_deleted["document"]["edges"]
                .as_array()
                .expect("edges")
                .len(),
            0
        );
        assert_graph_flow_projection_synced(&edge_deleted);
    }

    #[test]
    fn apply_operation_mutates_schema_fields_without_projected_document() {
        let mut document = valid_document();
        document.mob_toml = None;
        document.schemas = json!([{
            "id": "Review",
            "description": "Review result",
            "fields": [
                { "id": "f1", "name": "verdict", "type": "enum", "required": true, "description": "", "enumValues": ["red", "green"] },
                { "id": "f2", "name": "notes", "type": "string", "required": false, "description": "", "enumValues": [] }
            ]
        }]);
        document.members = json!([{
            "id": "reviewer",
            "name": "reviewer",
            "role": "reviewer",
            "schema": "Review",
            "profileBinding": "inline",
            "runtimeMode": "turn_driven",
            "model": "gpt-5.5",
            "tools": []
        }]);
        document.instances = json!([
            { "id": "n_review", "memberId": "reviewer", "kind": "member" },
            { "id": "n_done", "kind": "terminal" }
        ]);
        document.edges = json!([{
            "id": "e_review",
            "from": "n_review",
            "to": "n_done",
            "kind": "cond",
            "cond": { "var": "steps.n_review.verdict", "op": "==", "val": "green" },
            "label": "steps.n_review.verdict == green"
        }]);
        document.flow = json!({
            "id": "main",
            "steps": [
                { "type": "member", "id": "s_review", "role": "reviewer" },
                {
                    "type": "branch",
                    "id": "branch_review",
                    "branches": [{
                        "cond": { "stepId": "s_review", "field": "verdict", "op": "==", "val": "green" },
                        "steps": [{ "type": "member", "id": "s_again", "role": "reviewer" }]
                    }]
                }
            ]
        });

        let added_schema = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "add_schema"
            }
        }))
        .expect("add schema from semantic operation");
        assert_eq!(added_schema["selection"]["id"], json!("Artifact1"));
        assert_eq!(
            added_schema["document"]["schemas"][1],
            json!({
                "id": "Artifact1",
                "description": "",
                "fields": [{
                    "id": "f1",
                    "name": "field_one",
                    "type": "string",
                    "required": true,
                    "description": "",
                    "enumValues": []
                }]
            })
        );

        let added_field = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "add_schema_field",
                "schema_id": "Review"
            }
        }))
        .expect("add schema field from semantic operation");
        assert_eq!(added_field["selection"]["field_id"], json!("f3"));
        assert_eq!(
            added_field["document"]["schemas"][0]["fields"][2],
            json!({
                "id": "f3",
                "name": "new_field",
                "type": "string",
                "required": false,
                "description": "",
                "enumValues": []
            })
        );

        let updated_schema = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_schema",
                "schema_id": "Review",
                "patch": { "description": "Updated by MobKit schema operation." }
            }
        }))
        .expect("update schema description");
        assert_eq!(
            updated_schema["document"]["schemas"][0]["description"],
            json!("Updated by MobKit schema operation.")
        );

        let bad_schema_patch = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_schema",
                "schema_id": "Review",
                "patch": { "fields": [] }
            }
        }))
        .expect_err("schema fields must use field operations");
        assert!(
            bad_schema_patch.contains("unsupported update_schema patch field: fields"),
            "{bad_schema_patch}"
        );

        let updated_field = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_schema_field",
                "schema_id": "Review",
                "field_id": "f2",
                "patch": {
                    "type": "enum",
                    "enumValues": ["needs_work", "approved", "approved"],
                    "required": true,
                    "description": "Decision state."
                }
            }
        }))
        .expect("update schema field through MobKit allowlist");
        assert_eq!(
            updated_field["document"]["schemas"][0]["fields"][1],
            json!({
                "id": "f2",
                "name": "notes",
                "type": "enum",
                "required": true,
                "description": "Decision state.",
                "enumValues": ["needs_work", "approved"]
            })
        );

        let bad_field_patch = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_schema_field",
                "schema_id": "Review",
                "field_id": "f2",
                "patch": { "tools": ["shell"] }
            }
        }))
        .expect_err("schema field unknown patch must fail");
        assert!(
            bad_field_patch.contains("unsupported update_schema_field patch field: tools"),
            "{bad_field_patch}"
        );

        let renamed = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "rename_schema_field",
                "schema_id": "Review",
                "field_id": "f1",
                "new_name": "status"
            }
        }))
        .expect("rename field from operation payload");
        assert_eq!(
            renamed["document"]["schemas"][0]["fields"][0]["name"],
            json!("status")
        );
        assert_eq!(
            renamed["document"]["edges"][0]["cond"]["var"],
            json!("steps.n_review.status")
        );
        assert_eq!(
            renamed["document"]["flow"]["steps"][1]["branches"][0]["cond"]["field"],
            json!("status")
        );

        let deleted = apply_mobpack_authoring_operation(&json!({
            "document": renamed["document"],
            "operation": {
                "type": "delete_schema_field",
                "schema_id": "Review",
                "field_id": "f1"
            }
        }))
        .expect("delete field from operation payload");
        assert_eq!(deleted["document"]["edges"][0]["cond"], Value::Null);
        assert_eq!(
            deleted["document"]["flow"]["steps"][1]["branches"][0]["cond"],
            json!({})
        );
        assert_eq!(
            deleted["document"]["schemas"][0]["fields"]
                .as_array()
                .expect("fields")
                .len(),
            1
        );
    }

    #[test]
    fn apply_operation_deletes_members_and_schemas_without_projected_document() {
        let mut document = valid_document();
        document.mob_toml = None;
        document.schemas = json!([{
            "id": "Review",
            "description": "",
            "fields": [{ "id": "f1", "name": "verdict", "type": "string", "required": true }]
        }]);
        document.members = json!([
            {
                "id": "reviewer",
                "name": "reviewer",
                "role": "reviewer",
                "schema": "Review",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "schema": "planner_out",
                "tools": []
            },
            {
                "id": "planner",
                "name": "planner",
                "role": "planner",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "tools": []
            }
        ]);
        document.instances = json!([
            { "id": "n_reviewer", "memberId": "reviewer", "kind": "member" },
            { "id": "n_planner", "memberId": "planner", "kind": "member" }
        ]);
        document.edges = json!([{ "id": "e1", "from": "n_reviewer", "to": "n_planner" }]);
        document.flow = json!({
            "id": "main",
            "steps": [
                { "type": "member", "id": "s_review", "role": "reviewer" },
                { "type": "member", "id": "s_plan", "role": "planner" }
            ]
        });
        document.mob_settings = json!({
            "orchestrator": "reviewer",
            "roleWiring": [{ "a": "reviewer", "b": "planner" }]
        });

        let deleted_member = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": { "type": "delete_member", "member_id": "reviewer" }
        }))
        .expect("delete member");
        assert!(
            !deleted_member["document"]["members"]
                .as_array()
                .expect("members")
                .iter()
                .any(|member| member["id"] == "reviewer")
        );
        assert_eq!(
            deleted_member["document"]["instances"]
                .as_array()
                .expect("instances")
                .len(),
            1
        );
        assert_eq!(
            deleted_member["document"]["edges"]
                .as_array()
                .expect("edges")
                .len(),
            0
        );
        assert_eq!(
            deleted_member["document"]["mob_settings"]["orchestrator"],
            json!("")
        );

        let deleted_schema = apply_mobpack_authoring_operation(&json!({
            "document": deleted_member["document"],
            "operation": { "type": "delete_schema", "schema_id": "Review" }
        }))
        .expect("delete schema");
        assert_eq!(
            deleted_schema["document"]["schemas"]
                .as_array()
                .expect("schemas")
                .len(),
            0
        );
    }

    #[test]
    fn apply_operation_mutates_flow_steps_without_projected_document() {
        let mut document = valid_document();
        document.mob_toml = None;
        document.members = json!([
            {
                "id": "planner",
                "name": "planner",
                "role": "planner",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "schema": "planner_out",
                "tools": []
            },
            {
                "id": "reviewer",
                "name": "reviewer",
                "role": "reviewer",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "tools": ["shell"]
            }
        ]);
        document.flow = json!({
            "id": "main",
            "steps": [
                { "type": "member", "id": "plan", "role": "planner", "instruction": "Plan." },
                {
                    "type": "branch",
                    "id": "route",
                    "branches": [{
                        "id": "approved",
                        "cond": { "stepId": "review", "field": "verdict", "op": "==", "val": "green" },
                        "condition": "steps.review.verdict == green",
                        "steps": []
                    }],
                    "fallback": []
                }
            ]
        });

        let semantic_inserted = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_flow_step",
                "lane_ref": { "lane": "main", "index": 1 },
                "pick": { "kind": "member", "id": "reviewer" }
            }
        }))
        .expect("insert flow step from semantic pick");
        assert_eq!(semantic_inserted["selection"]["id"], json!("s_1"));
        assert_eq!(
            semantic_inserted["document"]["flow"]["steps"][1],
            json!({
                "id": "s_1",
                "type": "member",
                "role": "reviewer",
                "instruction": "",
                "dependsMode": "all"
            })
        );

        let inserted = apply_mobpack_authoring_operation(&json!({
            "document": semantic_inserted["document"],
            "operation": {
                "type": "insert_flow_step",
                "lane_ref": { "lane": "main", "index": 2 },
                "step": { "type": "member", "id": "review", "role": "reviewer", "instruction": "Review." }
            }
        }))
        .expect("insert flow step");
        assert_eq!(inserted["selection"]["id"], json!("review"));
        assert_eq!(
            inserted["document"]["flow"]["steps"][2]["id"],
            json!("review")
        );

        let nested = apply_mobpack_authoring_operation(&json!({
            "document": inserted["document"],
            "operation": {
                "type": "insert_flow_step",
                "lane_ref": { "parentId": "route", "branchId": "approved", "index": 0 },
                "step": { "type": "member", "id": "approve", "role": "planner", "instruction": "Approve." }
            }
        }))
        .expect("insert nested flow step");
        assert_eq!(
            nested["document"]["flow"]["steps"][3]["branches"][0]["steps"][0]["id"],
            json!("approve")
        );

        let updated = apply_mobpack_authoring_operation(&json!({
            "document": nested["document"],
            "operation": {
                "type": "update_flow_step",
                "step_id": "review",
                "patch": { "instruction": "Review carefully." }
            }
        }))
        .expect("update flow step");
        assert_eq!(
            updated["document"]["flow"]["steps"][2]["instruction"],
            json!("Review carefully.")
        );
        let normalized_update = apply_mobpack_authoring_operation(&json!({
            "document": updated["document"],
            "operation": {
                "type": "update_flow_step",
                "step_id": "review",
                "patch": {
                    "quorum": "2",
                    "dispatchMode": "fan_out",
                    "allowedTools": ["shell", "shell"]
                }
            }
        }))
        .expect("normalize flow step update");
        assert_eq!(
            normalized_update["document"]["flow"]["steps"][2]["quorum"],
            json!(2)
        );
        assert_eq!(
            normalized_update["document"]["flow"]["steps"][2]["allowedTools"],
            json!(["shell"])
        );
        let semantic_field_update = apply_mobpack_authoring_operation(&json!({
            "document": normalized_update["document"],
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": "review",
                "action": "set_instruction",
                "value": "Review semantically."
            }
        }))
        .expect("semantic instruction edit");
        assert_eq!(
            semantic_field_update["document"]["flow"]["steps"][2]["instruction"],
            json!("Review semantically.")
        );
        let semantic_options_update = apply_mobpack_authoring_operation(&json!({
            "document": semantic_field_update["document"],
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": "review",
                "action": "set_allowed_tools",
                "tools": ["shell", "shell"]
            }
        }))
        .expect("semantic allowed tools edit");
        assert_eq!(
            semantic_options_update["document"]["flow"]["steps"][2]["allowedTools"],
            json!(["shell"])
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": semantic_options_update["document"],
                "operation": {
                    "type": "apply_flow_step_edit",
                    "step_id": "review",
                    "action": "set_dispatch_mode",
                    "value": "broadcast"
                }
            }))
            .expect_err("unsupported semantic dispatch mode")
            .contains("unsupported dispatch mode")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": semantic_options_update["document"],
                "operation": {
                    "type": "update_flow_step",
                    "step_id": "review",
                    "patch": { "locallyInvented": true }
                }
            }))
            .expect_err("unknown flow step patch field")
            .contains("unsupported update_flow_step patch field")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": normalized_update["document"],
                "operation": {
                    "type": "update_flow_step",
                    "step_id": "review",
                    "patch": { "dispatchMode": "broadcast" }
                }
            }))
            .expect_err("unsupported dispatch mode")
            .contains("unsupported dispatch mode")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": normalized_update["document"],
                "operation": {
                    "type": "update_flow_step",
                    "step_id": "review",
                    "patch": { "role": "missing" }
                }
            }))
            .expect_err("missing member role")
            .contains("must reference an existing member")
        );
        assert!(
            apply_mobpack_authoring_operation(&json!({
                "document": normalized_update["document"],
                "operation": {
                    "type": "update_flow_step",
                    "step_id": "review",
                    "patch": { "allowedTools": ["not-a-real-tool"] }
                }
            }))
            .expect_err("unknown step tool")
            .contains("unknown MobKit tool")
        );

        let launch_edited = apply_mobpack_authoring_operation(&json!({
            "document": normalized_update["document"],
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": "review",
                "action": "set_launch_kind",
                "kind": "Fork",
                "first_fork_source_id": "plan"
            }
        }))
        .expect("semantic flow step launch edit");
        assert_eq!(
            launch_edited["document"]["flow"]["steps"][2]["launchMode"]["kind"],
            json!("Fork")
        );
        assert_eq!(
            launch_edited["document"]["flow"]["steps"][2]["launchMode"]["from"],
            json!("plan")
        );
        let launch_budget_edited = apply_mobpack_authoring_operation(&json!({
            "document": launch_edited["document"],
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": "review",
                "action": "set_launch_budget_limit",
                "limit": "3072"
            }
        }))
        .expect("semantic flow step launch budget edit accepts string numeric input");
        assert_eq!(
            launch_budget_edited["document"]["flow"]["steps"][2]["launchMode"]["budgetSplitPolicy"],
            json!({ "kind": "Fixed", "limit": 3072 })
        );

        let branch_condition_edited = apply_mobpack_authoring_operation(&json!({
            "document": launch_budget_edited["document"],
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": "route",
                "action": "set_branch_condition",
                "branch_id": "approved",
                "patch": {
                    "namespace": "steps",
                    "stepId": "review",
                    "field": "verdict",
                    "op": "==",
                    "val": "blue"
                }
            }
        }))
        .expect("semantic branch condition edit");
        assert_eq!(
            branch_condition_edited["document"]["flow"]["steps"][3]["branches"][0]["cond"],
            json!({
                "namespace": "steps",
                "stepId": "review",
                "field": "verdict",
                "op": "==",
                "val": "blue"
            })
        );
        assert_eq!(
            branch_condition_edited["document"]["flow"]["steps"][3]["branches"][0]["condition"],
            json!("steps.review.verdict == \"blue\"")
        );

        let branch_added = apply_mobpack_authoring_operation(&json!({
            "document": branch_condition_edited["document"],
            "operation": {
                "type": "apply_flow_step_edit",
                "step_id": "route",
                "action": "add_branch"
            }
        }))
        .expect("semantic branch lane add");
        assert_eq!(
            branch_added["document"]["flow"]["steps"][3]["branches"][1],
            json!({
                "id": "br_1",
                "label": "Branch 2",
                "steps": [],
                "condition": ""
            })
        );

        let deleted = apply_mobpack_authoring_operation(&json!({
            "document": branch_added["document"],
            "operation": {
                "type": "delete_flow_step",
                "step_id": "review"
            }
        }))
        .expect("delete flow step");
        assert!(
            !deleted["document"]["flow"]["steps"]
                .as_array()
                .expect("steps")
                .iter()
                .any(|step| step["id"] == "review")
        );
        assert_eq!(
            deleted["document"]["flow"]["steps"][2]["branches"][0]["cond"],
            json!({})
        );
        assert_eq!(
            deleted["document"]["flow"]["steps"][2]["branches"][0]["condition"],
            json!("")
        );
    }

    #[test]
    fn apply_operation_mutates_graph_without_projected_document() {
        let mut document = valid_document();
        document.mob_toml = None;
        document.members = json!([
            {
                "id": "planner",
                "name": "planner",
                "role": "planner",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "schema": "planner_out",
                "tools": []
            },
            {
                "id": "reviewer",
                "name": "reviewer",
                "role": "reviewer",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "tools": []
            }
        ]);
        document.schemas = json!([{
            "id": "planner_out",
            "name": "Planner output",
            "fields": [
                { "id": "status", "name": "status", "type": "string", "required": true }
            ]
        }]);
        document.instances = json!([
            { "id": "n_plan", "memberId": "planner", "kind": "member", "col": 0, "row": 0 },
            { "id": "n_review", "memberId": "reviewer", "kind": "member", "col": 1, "row": 0 }
        ]);
        document.edges = json!([]);
        document.flow = json!({
            "id": "main",
            "steps": [
                { "id": "input_1", "type": "input", "task": "Route.", "fields": "" },
                { "id": "n_plan", "type": "member", "role": "planner", "instruction": "Plan." },
                { "id": "n_review", "type": "member", "role": "reviewer", "instruction": "Review." }
            ]
        });

        let semantic_member = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_graph_node",
                "pick": { "kind": "memberInstance", "memberId": "planner" },
                "cell": { "col": 3, "row": 4 }
            }
        }))
        .expect("insert graph member from semantic pick");
        assert_eq!(semantic_member["selection"]["id"], json!("i_planner"));
        assert_eq!(
            semantic_member["document"]["instances"][2],
            json!({
                "id": "i_planner",
                "memberId": "planner",
                "col": 3,
                "row": 4,
                "launchMode": { "kind": "Fresh" },
                "lane": ""
            })
        );

        let semantic_branch = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_graph_node",
                "pick": { "kind": "gate", "gateKind": "branch" },
                "cell": { "col": 0, "row": 0 }
            }
        }))
        .expect("insert graph branch from semantic pick");
        assert_eq!(semantic_branch["selection"]["id"], json!("g_branch_1"));
        assert_eq!(
            semantic_branch["document"]["instances"]
                .as_array()
                .expect("instances")
                .iter()
                .skip(2)
                .map(|instance| instance["id"].as_str().unwrap_or_default().to_string())
                .collect::<Vec<_>>(),
            vec!["g_branch_1", "g_branch_1_a", "g_branch_1_b", "j_branch_1"]
        );
        assert_eq!(
            semantic_branch["document"]["edges"][1]["label"],
            json!("fallback")
        );
        let semantic_join_quorum = apply_mobpack_authoring_operation(&json!({
            "document": semantic_branch["document"],
            "operation": {
                "type": "apply_graph_node_edit",
                "instance_id": "j_branch_1",
                "action": "set_join_quorum",
                "n": "2"
            }
        }))
        .expect("semantic graph join quorum edit accepts string numeric input");
        assert_eq!(
            semantic_join_quorum["document"]["instances"][5]["quorum"],
            json!({ "n": 2, "m": 2 })
        );

        let endpoint_connected = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "connect_graph_nodes",
                "from_id": "n_plan",
                "to_id": "n_review"
            }
        }))
        .expect("connect member graph nodes by endpoints");
        assert_eq!(endpoint_connected["selection"]["kind"], json!("edge"));
        assert_eq!(
            endpoint_connected["selection"]["id"],
            json!("e_n_plan_n_review")
        );
        assert_eq!(
            endpoint_connected["document"]["flow"],
            graph_to_flow_mobpack(&json!({ "document": endpoint_connected["document"] }))
                .expect("graph sync after endpoint connect")["flow"]
        );
        assert!(
            endpoint_connected["validation"]["ok"]
                .as_bool()
                .unwrap_or(false),
            "{}",
            serde_json::to_string_pretty(&endpoint_connected["validation"]).unwrap()
        );

        let inserted = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_graph_node",
                "pick": { "kind": "memberInstance", "memberId": "reviewer" },
                "cell": { "col": 2, "row": 0 }
            }
        }))
        .expect("insert graph node");
        assert_eq!(inserted["selection"]["id"], json!("i_reviewer"));
        assert_eq!(
            inserted["document"]["instances"]
                .as_array()
                .expect("instances")
                .len(),
            3
        );

        let moved = apply_mobpack_authoring_operation(&json!({
            "document": inserted["document"],
            "operation": {
                "type": "move_graph_node",
                "instance_id": "i_reviewer",
                "cell": { "col": 1, "row": 0 },
                "original_cell": { "col": 2, "row": 0 }
            }
        }))
        .expect("move graph node");
        let moved_instances = moved["document"]["instances"]
            .as_array()
            .expect("instances");
        assert_eq!(
            moved_instances
                .iter()
                .find(|instance| instance["id"] == "i_reviewer")
                .expect("done")["col"],
            json!(1)
        );
        assert_eq!(
            moved_instances
                .iter()
                .find(|instance| instance["id"] == "n_review")
                .expect("review")["col"],
            json!(2)
        );

        let updated = apply_mobpack_authoring_operation(&json!({
            "document": moved["document"],
            "operation": {
                "type": "apply_graph_node_edit",
                "instance_id": "i_reviewer",
                "action": "set_label",
                "label": "Review"
            }
        }))
        .expect("semantic graph node label edit");
        assert_eq!(
            updated["document"]["instances"][2]["label"],
            json!("Review")
        );

        let connected = apply_mobpack_authoring_operation(&json!({
            "document": updated["document"],
            "operation": {
                "type": "connect_graph_nodes",
                "from_id": "n_plan",
                "to_id": "i_reviewer"
            }
        }))
        .expect("connect graph nodes");
        assert_eq!(connected["selection"]["kind"], json!("edge"));
        assert_eq!(connected["selection"]["id"], json!("e_n_plan_i_reviewer"));
        assert_eq!(
            connected["document"]["edges"]
                .as_array()
                .expect("edges")
                .len(),
            1
        );
        assert_eq!(connected["document"]["edges"][0]["kind"], json!("next"));
        assert_eq!(
            connected["document"]["flow"],
            graph_to_flow_mobpack(&json!({ "document": connected["document"] }))
                .expect("graph sync after endpoint connect")["flow"]
        );

        let edge_updated = apply_mobpack_authoring_operation(&json!({
            "document": connected["document"],
            "operation": {
                "type": "apply_graph_edge_edit",
                "edge_id": "e_n_plan_i_reviewer",
                "action": "set_label",
                "label": "done"
            }
        }))
        .expect("semantic graph edge label edit");
        assert_eq!(edge_updated["document"]["edges"][0]["label"], json!("done"));

        let node_edited = apply_mobpack_authoring_operation(&json!({
            "document": edge_updated["document"],
            "operation": {
                "type": "apply_graph_node_edit",
                "instance_id": "n_review",
                "action": "set_launch_kind",
                "kind": "Fork",
                "first_fork_source_id": "n_plan"
            }
        }))
        .expect("semantic graph node edit");
        assert_eq!(
            node_edited["document"]["instances"][1]["launchMode"]["kind"],
            json!("Fork")
        );
        assert_eq!(
            node_edited["document"]["instances"][1]["launchMode"]["from"],
            json!("n_plan")
        );
        let node_budget_edited = apply_mobpack_authoring_operation(&json!({
            "document": node_edited["document"],
            "operation": {
                "type": "apply_graph_node_edit",
                "instance_id": "n_review",
                "action": "set_launch_budget_limit",
                "limit": "2048"
            }
        }))
        .expect("semantic graph node launch budget edit accepts string numeric input");
        assert_eq!(
            node_budget_edited["document"]["instances"][1]["launchMode"]["budgetSplitPolicy"],
            json!({ "kind": "Fixed", "limit": 2048 })
        );

        let semantic_edge = apply_mobpack_authoring_operation(&json!({
            "document": node_budget_edited["document"],
            "operation": {
                "type": "apply_graph_edge_edit",
                "edge_id": "e_n_plan_i_reviewer",
                "action": "set_condition_owner",
                "owner_instance_id": "n_plan"
            }
        }))
        .expect("semantic graph edge condition owner");
        assert_eq!(semantic_edge["document"]["edges"][0]["kind"], json!("cond"));
        assert_eq!(
            semantic_edge["document"]["edges"][0]["cond"]["var"],
            json!("steps.n_plan.status")
        );
        assert_eq!(
            semantic_edge["document"]["flow"],
            graph_to_flow_mobpack(&json!({ "document": semantic_edge["document"] }))
                .expect("graph sync")["flow"]
        );

        let edge_deleted = apply_mobpack_authoring_operation(&json!({
            "document": semantic_edge["document"],
            "operation": {
                "type": "delete_graph_edge",
                "edge_id": "e_n_plan_i_reviewer"
            }
        }))
        .expect("delete graph edge");
        assert_eq!(
            edge_deleted["document"]["edges"]
                .as_array()
                .expect("edges")
                .len(),
            0
        );

        let reconnected = apply_mobpack_authoring_operation(&json!({
            "document": edge_deleted["document"],
            "operation": {
                "type": "connect_graph_nodes",
                "from_id": "i_reviewer",
                "to_id": "n_plan"
            }
        }))
        .expect("reconnect graph nodes");
        let deleted_node = apply_mobpack_authoring_operation(&json!({
            "document": reconnected["document"],
            "operation": {
                "type": "delete_graph_node",
                "instance_id": "i_reviewer"
            }
        }))
        .expect("delete graph node");
        assert!(
            !deleted_node["document"]["instances"]
                .as_array()
                .expect("instances")
                .iter()
                .any(|instance| instance["id"] == "i_reviewer")
        );
        assert_eq!(
            deleted_node["document"]["edges"]
                .as_array()
                .expect("edges")
                .len(),
            0
        );
    }

    #[test]
    fn apply_operation_rejects_uncompiled_graph_terminal_authoring() {
        let mut document = valid_document();
        document.mob_toml = None;
        document.members = json!([
            {
                "id": "planner",
                "name": "planner",
                "role": "planner",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "tools": []
            },
            {
                "id": "reviewer",
                "name": "reviewer",
                "role": "reviewer",
                "profileBinding": "inline",
                "runtimeMode": "turn_driven",
                "model": "gpt-5.5",
                "tools": []
            }
        ]);
        document.instances = json!([
            { "id": "n_plan", "memberId": "planner", "kind": "member", "col": 0, "row": 0 },
            { "id": "n_review", "memberId": "reviewer", "kind": "member", "col": 1, "row": 0 }
        ]);
        document.edges = json!([]);
        document.flow = json!({
            "id": "main",
            "steps": [
                { "id": "n_plan", "type": "member", "role": "planner", "instruction": "Plan." },
                { "id": "n_review", "type": "member", "role": "reviewer", "instruction": "Review." }
            ]
        });

        let insert_err = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "insert_graph_node",
                "pick": { "kind": "terminal", "terminalKind": "success" },
                "cell": { "col": 2, "row": 0 }
            }
        }))
        .expect_err("terminal insert must be rejected");
        assert!(
            insert_err.contains("unsupported insert_graph_node pick.kind: terminal"),
            "{insert_err}"
        );

        let update_err = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "update_graph_node",
                "instance_id": "n_review",
                "patch": { "kind": "terminal", "isTerminal": true }
            }
        }))
        .expect_err("raw graph node update must be rejected");
        assert!(
            update_err.contains("unsupported operation.type: update_graph_node"),
            "{update_err}"
        );

        let mut legacy_terminal_document = document;
        legacy_terminal_document.instances = json!([
            { "id": "n_plan", "memberId": "planner", "kind": "member", "col": 0, "row": 0 },
            { "id": "n_done", "kind": "terminal", "isTerminal": true, "col": 1, "row": 0 }
        ]);
        let connect_err = apply_mobpack_authoring_operation(&json!({
            "document": legacy_terminal_document,
            "operation": {
                "type": "connect_graph_nodes",
                "from_id": "n_plan",
                "to_id": "n_done"
            }
        }))
        .expect_err("terminal endpoint connect must be rejected");
        assert!(
            connect_err.contains("edge endpoints cannot reference uncompiled graph terminal nodes"),
            "{connect_err}"
        );

        legacy_terminal_document.edges = json!([
            { "id": "e1", "from": "n_plan", "to": "n_review", "kind": "next", "label": "" }
        ]);
        let reconnect_err = apply_mobpack_authoring_operation(&json!({
            "document": legacy_terminal_document,
            "operation": {
                "type": "update_graph_edge",
                "edge_id": "e1",
                "patch": { "to": "n_done" }
            }
        }))
        .expect_err("raw graph edge update must be rejected");
        assert!(
            reconnect_err.contains("unsupported operation.type: update_graph_edge"),
            "{reconnect_err}"
        );
    }

    #[test]
    fn apply_operation_mutates_input_params_without_projected_document() {
        let mut document = valid_document();
        document.mob_toml = None;
        document.members = json!([{
            "id": "worker",
            "name": "worker",
            "role": "worker",
            "profileBinding": "inline",
            "runtimeMode": "turn_driven",
            "model": "gpt-5.5",
            "tools": []
        }]);
        document.flow = json!({
            "id": "main",
            "steps": [
                {
                    "type": "input",
                    "id": "input",
                    "task": "Route the work.",
                    "inputParams": [
                        { "id": "p1", "name": "route", "type": "enum", "required": true, "enumValues": ["code", "docs"] }
                    ],
                    "fields": "route: enum"
                },
                {
                    "type": "branch",
                    "id": "branch_route",
                    "branches": [{
                        "cond": { "namespace": "params", "stepId": "params", "field": "route", "op": "==", "val": "code" },
                        "condition": "params.route == code",
                        "steps": [{ "type": "member", "id": "worker_step", "role": "worker" }]
                    }]
                }
            ]
        });
        document.edges = json!([{
            "id": "edge_route",
            "from": "input",
            "to": "worker",
            "kind": "cond",
            "cond": { "var": "params.route", "op": "==", "val": "code" },
            "label": "params.route == code"
        }]);

        let added = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "add_input_param",
                "step_id": "input"
            }
        }))
        .expect("add input param");
        assert_eq!(added["selection"]["param_id"], json!("p2"));
        assert_eq!(
            added["document"]["flow"]["steps"][0]["inputParams"][1],
            json!({
                "id": "p2",
                "name": "param",
                "type": "string",
                "required": true,
                "description": "",
                "enumValues": []
            })
        );
        assert_eq!(
            added["document"]["flow"]["steps"][0]["fields"],
            json!("route: enum, param: string")
        );

        let updated = apply_mobpack_authoring_operation(&json!({
            "document": added["document"],
            "operation": {
                "type": "update_input_param",
                "step_id": "input",
                "param_id": "p2",
                "patch": {
                    "name": "route",
                    "type": "enum",
                    "required": false,
                    "description": "Where this run should go.",
                    "enumValues": ["code", "docs", "docs"]
                }
            }
        }))
        .expect("update input param");
        assert_eq!(
            updated["document"]["flow"]["steps"][0]["inputParams"][1],
            json!({
                "id": "p2",
                "name": "route_2",
                "type": "enum",
                "required": false,
                "description": "Where this run should go.",
                "enumValues": ["code", "docs"]
            })
        );
        assert_eq!(
            updated["document"]["flow"]["steps"][0]["fields"],
            json!("route: enum, route_2: enum?")
        );

        let bad_param_patch = apply_mobpack_authoring_operation(&json!({
            "document": updated["document"],
            "operation": {
                "type": "update_input_param",
                "step_id": "input",
                "param_id": "p2",
                "patch": { "tools": ["shell"] }
            }
        }))
        .expect_err("raw input-param patch field must be rejected");
        assert!(
            bad_param_patch.contains("unsupported update_input_param patch field: tools"),
            "{bad_param_patch}"
        );

        let renamed = apply_mobpack_authoring_operation(&json!({
            "document": updated["document"],
            "operation": {
                "type": "rename_input_param",
                "step_id": "input",
                "param_id": "p1",
                "new_name": "kind"
            }
        }))
        .expect("rename input param");
        assert_eq!(
            renamed["document"]["flow"]["steps"][1]["branches"][0]["cond"]["field"],
            json!("kind")
        );
        assert_eq!(
            renamed["document"]["flow"]["steps"][1]["branches"][0]["condition"],
            json!("params.kind == code")
        );
        assert_eq!(
            renamed["document"]["edges"][0]["cond"]["var"],
            json!("params.kind")
        );

        let deleted = apply_mobpack_authoring_operation(&json!({
            "document": renamed["document"],
            "operation": {
                "type": "delete_input_param",
                "step_id": "input",
                "param_id": "p1"
            }
        }))
        .expect("delete input param");
        assert_eq!(
            deleted["document"]["flow"]["steps"][1]["branches"][0]["cond"],
            json!({})
        );
        assert_eq!(
            deleted["document"]["flow"]["steps"][1]["branches"][0]["condition"],
            json!("")
        );
        assert_eq!(deleted["document"]["edges"][0]["cond"], Value::Null);
    }

    #[test]
    fn rename_or_delete_input_param_does_not_corrupt_prefix_sharing_free_text_conditions() {
        // Regression: a free-text `condition`/`until` referencing `params.route_v2`
        // must NOT be rewritten or wiped when an UNRELATED param `route` (a strict
        // prefix of `route_v2`) is renamed or deleted. The old prefix-substring
        // match silently corrupted/erased the unrelated condition.
        let mut document = valid_document();
        document.mob_toml = None;
        document.members = json!([{
            "id": "worker",
            "name": "worker",
            "role": "worker",
            "profileBinding": "inline",
            "runtimeMode": "turn_driven",
            "model": "gpt-5.5",
            "tools": []
        }]);
        document.flow = json!({
            "id": "main",
            "steps": [
                {
                    "type": "input",
                    "id": "input",
                    "task": "Route the work.",
                    "inputParams": [
                        { "id": "p1", "name": "route", "type": "string", "required": true, "enumValues": [] },
                        { "id": "p2", "name": "route_v2", "type": "string", "required": true, "enumValues": [] }
                    ],
                    "fields": "route: string, route_v2: string"
                },
                {
                    "type": "branch",
                    "id": "branch_route",
                    "branches": [{
                        // Free-text condition references the OTHER param that shares the prefix.
                        "condition": "params.route_v2 == \"x\"",
                        "steps": [{ "type": "member", "id": "worker_step", "role": "worker" }]
                    }]
                },
                {
                    "type": "repeat",
                    "id": "repeat_step",
                    // Free-text until references the prefix-sharing param too.
                    "until": "params.route_v2 != \"done\"",
                    "steps": [{ "type": "member", "id": "repeat_worker", "role": "worker" }]
                }
            ]
        });
        document.edges = json!([]);

        // Rename `route` -> `path`. The `route_v2` references must be untouched.
        let renamed = apply_mobpack_authoring_operation(&json!({
            "document": document,
            "operation": {
                "type": "rename_input_param",
                "step_id": "input",
                "param_id": "p1",
                "new_name": "path"
            }
        }))
        .expect("rename input param");
        assert_eq!(
            renamed["document"]["flow"]["steps"][1]["branches"][0]["condition"],
            json!("params.route_v2 == \"x\""),
            "unrelated prefix-sharing condition must NOT be rewritten on rename"
        );
        assert_eq!(
            renamed["document"]["flow"]["steps"][2]["until"],
            json!("params.route_v2 != \"done\""),
            "unrelated prefix-sharing until must NOT be rewritten on rename"
        );

        // Delete `route` (new_name empty). The `route_v2` references must survive.
        let deleted = apply_mobpack_authoring_operation(&json!({
            "document": renamed["document"],
            "operation": {
                "type": "delete_input_param",
                "step_id": "input",
                "param_id": "p1"
            }
        }))
        .expect("delete input param");
        assert_eq!(
            deleted["document"]["flow"]["steps"][1]["branches"][0]["condition"],
            json!("params.route_v2 == \"x\""),
            "unrelated prefix-sharing condition must NOT be wiped on delete"
        );
        assert_eq!(
            deleted["document"]["flow"]["steps"][2]["until"],
            json!("params.route_v2 != \"done\""),
            "unrelated prefix-sharing until must NOT be wiped on delete"
        );
    }

    #[test]
    fn writes_deploy_result_fixture_when_requested() {
        let params = match std::env::var("MOBKIT_MOBPACK_DEPLOY_IN") {
            Ok(path) => serde_json::from_str(
                &std::fs::read_to_string(path).expect("read editor mobpack deploy params"),
            )
            .expect("parse editor mobpack deploy params"),
            Err(_) => json!({
                "document": valid_document(),
                "prompt": "Reply with exactly OK."
            }),
        };
        let result = deploy_mobpack(&params).expect("deploy");
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        // meerkat 0.7: the editor deploy surface invokes the typed callable
        // entrypoint `rkat mob run <pack> --prompt <task>` (run envelope on
        // stdout) instead of the legacy `rkat mob deploy <pack> <task>`;
        // deploy_argv and the sibling argv tests were migrated with it.
        assert_eq!(&result.argv[1..3], ["mob", "run"]);
        assert!(result.plan_trace.iter().any(|row| {
            row["head"]
                .as_str()
                .unwrap_or_default()
                .starts_with("MOBPACK ·")
        }));
        assert!(result.plan_trace.iter().any(|row| {
            row["head"]
                .as_str()
                .unwrap_or_default()
                .starts_with("PROFILE ·")
        }));
        assert!(result.plan_trace.iter().any(|row| {
            row["head"]
                .as_str()
                .unwrap_or_default()
                .starts_with("STEP ·")
        }));
        let pack_exists = std::path::Path::new(&result.pack_path).exists();
        if result.executed {
            assert!(pack_exists);
        } else {
            assert!(!pack_exists);
        }
        if let Ok(path) = std::env::var("MOBKIT_MOBPACK_DEPLOY_OUT") {
            std::fs::write(path, serde_json::to_vec_pretty(&result).unwrap())
                .expect("write deploy result");
        }
    }

    #[cfg(unix)]
    #[test]
    fn executes_rkat_mob_run_when_requested() {
        let dir = tempfile::tempdir().expect("tempdir");
        let fake_rkat = dir.path().join("rkat");
        let args_file = dir.path().join("rkat.args");
        std::fs::write(
            &fake_rkat,
            format!(
                "#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\necho fake-rkat-ok\nprintf 'run\\tmob=review-pack\\tflow=review\\trun_id=run-1\\tstatus=completed\\n'\nprintf 'result\\t{{\"reply\":\"MIGRATION OK\"}}\\n'\n",
                args_file.to_string_lossy()
            ),
        )
        .expect("write fake rkat");
        let mut permissions = std::fs::metadata(&fake_rkat)
            .expect("fake rkat metadata")
            .permissions();
        use std::os::unix::fs::PermissionsExt;
        permissions.set_mode(0o755);
        std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");

        let result = deploy_mobpack(&json!({
            "document": valid_document(),
            "output_dir": dir.path(),
            "prompt": "Reply with exactly OK.",
            "rkat_bin": fake_rkat,
            "execute": true
        }))
        .expect("deploy execute");

        assert!(result.executed);
        assert_eq!(result.status_code, Some(0));
        assert!(
            result
                .stdout
                .as_deref()
                .unwrap_or("")
                .contains("fake-rkat-ok")
        );
        assert_eq!(result.argv[1], "mob");
        assert_eq!(result.argv[2], "run");
        assert!(result.success);
        assert!(std::path::Path::new(&result.pack_path).exists());
        let packed_bytes = std::fs::read(&result.pack_path).expect("read deploy pack");
        assert_eq!(result.pack_sha256, source_file_sha256(&packed_bytes));

        let argv = std::fs::read_to_string(args_file).expect("recorded fake rkat args");
        assert!(argv.lines().any(|line| line == "mob"));
        assert!(argv.lines().any(|line| line == "run"));
        assert!(argv.lines().any(|line| line == result.pack_path));
        // Single-token `--prompt=<text>` form (hyphen-safe). The two-token form
        // breaks for prompts beginning with `-`.
        assert!(
            argv.lines()
                .any(|line| line == "--prompt=Reply with exactly OK.")
        );
        assert!(
            !argv.lines().any(|line| line == "--prompt"),
            "must not emit the hyphen-unsafe two-token --prompt form: {argv}"
        );
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "ok"
                && row.head == "Flow run completed"
                && row.sub == "{\n  \"reply\": \"MIGRATION OK\"\n}"
                && row.meta.contains("run_id=run-1")
        }));
    }

    #[cfg(unix)]
    #[test]
    fn reports_failed_flow_run_envelope_as_deploy_failure() {
        let dir = tempfile::tempdir().expect("tempdir");
        let fake_rkat = dir.path().join("rkat");
        std::fs::write(
            &fake_rkat,
            "#!/bin/sh\nprintf 'run\\tmob=review-pack\\tflow=review\\trun_id=run-2\\tstatus=failed\\n'\nprintf 'result\\tnull\\n'\n",
        )
        .expect("write fake rkat");
        let mut permissions = std::fs::metadata(&fake_rkat)
            .expect("fake rkat metadata")
            .permissions();
        use std::os::unix::fs::PermissionsExt;
        permissions.set_mode(0o755);
        std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");

        let result = deploy_mobpack(&json!({
            "document": valid_document(),
            "output_dir": dir.path(),
            "prompt": "Reply with exactly OK.",
            "rkat_bin": fake_rkat,
            "execute": true
        }))
        .expect("deploy execute");

        assert!(result.executed);
        assert_eq!(result.status_code, Some(0));
        assert!(
            !result.success,
            "terminal failed flow runs must not report deploy success"
        );
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "crit" && row.head == "Flow run failed" && row.meta.contains("run_id=run-2")
        }));
        assert!(
            result
                .display_rows
                .iter()
                .any(|row| { row.kind == "crit" && row.head == "rkat mob run failed" })
        );
    }

    #[test]
    fn deploy_argv_uses_hyphen_safe_single_token_prompt() {
        // Regression: a deploy prompt beginning with `-` must not break
        // `rkat mob run`. The two-token `["--prompt", "-x"]` form is rejected by
        // clap (no allow_hyphen_values); the single-token `--prompt=-x` form is
        // taken literally. Assert the safe form, including for a hyphen prompt.
        let bindings = DeployInputBindings {
            schema_fields: None,
            params: serde_json::Map::new(),
        };
        let deploy = json!({ "command": "rkat mob run", "surface": "cli" });
        let pack_path = std::path::Path::new("/tmp/pack.mobpack");

        for prompt in ["--summarize this", "-please do X", "normal prompt"] {
            let argv = deploy_argv("rkat", &deploy, pack_path, prompt, &bindings);
            assert!(
                argv.iter()
                    .any(|token| token == &format!("--prompt={prompt}")),
                "prompt {prompt:?} must use the single-token form: {argv:?}"
            );
            assert!(
                !argv.iter().any(|token| token == "--prompt"),
                "must not emit the hyphen-unsafe two-token --prompt for {prompt:?}: {argv:?}"
            );
        }
    }

    #[cfg(unix)]
    #[test]
    fn exit_zero_with_unparseable_envelope_is_not_deploy_success() {
        // Regression: a `rkat mob run` that exits 0 but whose stdout has no
        // parseable `run …status=…` envelope (output-format drift, a wrapper at
        // RKAT_BIN, truncated/garbled output) must NOT be reported as success.
        // A FAILED flow also exits 0, so without an envelope we cannot tell
        // completed from failed and must fail closed with a diagnostic.
        let dir = tempfile::tempdir().expect("tempdir");
        let fake_rkat = dir.path().join("rkat");
        std::fs::write(
            &fake_rkat,
            "#!/bin/sh\necho 'some unexpected output with no run envelope'\nexit 0\n",
        )
        .expect("write fake rkat");
        let mut permissions = std::fs::metadata(&fake_rkat)
            .expect("fake rkat metadata")
            .permissions();
        use std::os::unix::fs::PermissionsExt;
        permissions.set_mode(0o755);
        std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");

        let result = deploy_mobpack(&json!({
            "document": valid_document(),
            "output_dir": dir.path(),
            "prompt": "Reply with exactly OK.",
            "rkat_bin": fake_rkat,
            "execute": true
        }))
        .expect("deploy execute");

        assert!(result.executed);
        assert_eq!(result.status_code, Some(0));
        assert!(
            !result.success,
            "exit 0 with an unparseable run envelope must NOT report deploy success"
        );
        assert!(
            result.display_rows.iter().any(|row| {
                row.kind == "crit" && row.head == "could not parse rkat result envelope"
            }),
            "must surface a diagnostic explaining the unparseable envelope: {:?}",
            result.display_rows
        );
    }

    #[cfg(unix)]
    #[test]
    fn large_rkat_output_does_not_pipe_deadlock() {
        // Regression: a child that writes more than the OS pipe buffer (~64KB)
        // to stdout AND stderr before exiting must not deadlock. The old code
        // only drained pipes AFTER exit, so the child blocked on a full pipe,
        // never exited, and the watchdog killed it as a spurious timeout. We
        // now drain both pipes concurrently, so a chatty run completes cleanly.
        let dir = tempfile::tempdir().expect("tempdir");
        let fake_rkat = dir.path().join("rkat");
        // ~256KB to each stream, then a valid completed envelope, then exit 0.
        std::fs::write(
            &fake_rkat,
            "#!/bin/sh\n\
             i=0\n\
             while [ $i -lt 4096 ]; do\n\
               printf 'stdout-noise-line-%05d-padding-padding-padding\\n' $i\n\
               printf 'stderr-noise-line-%05d-padding-padding-padding\\n' $i >&2\n\
               i=$((i+1))\n\
             done\n\
             printf 'run\\tmob=review-pack\\tflow=review\\trun_id=run-big\\tstatus=completed\\n'\n\
             printf 'result\\t{\"reply\":\"OK\"}\\n'\n\
             exit 0\n",
        )
        .expect("write fake rkat");
        let mut permissions = std::fs::metadata(&fake_rkat)
            .expect("fake rkat metadata")
            .permissions();
        use std::os::unix::fs::PermissionsExt;
        permissions.set_mode(0o755);
        std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");

        let mut document = valid_document();
        // Generous timeout: if it ever trips, the pipe deadlocked.
        document.deploy = json!({
            "command": "rkat mob run",
            "surface": "cli",
            "max_duration": "30s",
            "prompt": "Reply with exactly OK."
        });
        let started = std::time::Instant::now();
        let result = deploy_mobpack(&json!({
            "document": document,
            "output_dir": dir.path(),
            "rkat_bin": fake_rkat,
            "execute": true
        }))
        .expect("deploy execute");

        assert!(
            started.elapsed() < std::time::Duration::from_secs(20),
            "large output must not pipe-deadlock into a timeout"
        );
        assert!(result.executed);
        assert_eq!(result.status_code, Some(0));
        assert!(
            result.success,
            "a completed flow with large output must report success: {:?}",
            result.display_rows
        );
        // The envelope after ~256KB of noise was still parsed.
        assert!(
            result
                .stdout
                .as_deref()
                .unwrap_or_default()
                .contains("status=completed")
        );
        assert!(
            result.stderr.as_deref().unwrap_or_default().len() > 64 * 1024,
            "stderr should have been fully drained past the pipe buffer"
        );
    }

    #[cfg(unix)]
    #[test]
    fn times_out_hung_rkat_mob_run_execution() {
        let dir = tempfile::tempdir().expect("tempdir");
        let fake_rkat = dir.path().join("rkat");
        std::fs::write(&fake_rkat, "#!/bin/sh\nexec sleep 2\n").expect("write fake rkat");
        let mut permissions = std::fs::metadata(&fake_rkat)
            .expect("fake rkat metadata")
            .permissions();
        use std::os::unix::fs::PermissionsExt;
        permissions.set_mode(0o755);
        std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");

        let mut document = valid_document();
        document.deploy = json!({
            "command": "rkat mob run",
            "surface": "cli",
            "max_duration": "1ms",
            "prompt": "Reply with exactly OK."
        });
        let started = std::time::Instant::now();
        let result = deploy_mobpack(&json!({
            "document": document,
            "output_dir": dir.path(),
            "rkat_bin": fake_rkat,
            "execute": true
        }))
        .expect("deploy result");

        assert!(started.elapsed() < std::time::Duration::from_secs(2));
        assert!(result.executed);
        assert!(!result.success);
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(
            result
                .stderr
                .as_deref()
                .unwrap_or_default()
                .contains("rkat mob run timed out"),
            "{result:?}"
        );
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "crit"
                && row.head == "rkat mob run failed"
                && row.sub.contains("rkat mob run")
        }));
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "warn" && row.head == "rkat output" && row.sub.contains("timed out")
        }));
    }

    #[test]
    fn parses_composite_rkat_deploy_durations_for_execution_timeout() {
        assert_eq!(parse_deploy_duration_ms("1h30m"), Some(5_400_000));
        assert_eq!(parse_deploy_duration_ms("2m15s"), Some(135_000));
        assert_eq!(parse_deploy_duration_ms("1h 2m 3s 4ms"), Some(3_723_004));
        assert_eq!(parse_deploy_duration_ms("90"), Some(90_000));
        assert_eq!(parse_deploy_duration_ms("1500ms"), Some(1_500));
        assert_eq!(parse_deploy_duration_ms("1.5h"), None);
        assert_eq!(parse_deploy_duration_ms("1hour"), None);
    }

    #[cfg(unix)]
    #[test]
    fn reports_failed_rkat_mob_run_execution() {
        let dir = tempfile::tempdir().expect("tempdir");
        let fake_rkat = dir.path().join("rkat");
        std::fs::write(&fake_rkat, "#!/bin/sh\necho deploy-failed >&2\nexit 7\n")
            .expect("write fake rkat");
        let mut permissions = std::fs::metadata(&fake_rkat)
            .expect("fake rkat metadata")
            .permissions();
        use std::os::unix::fs::PermissionsExt;
        permissions.set_mode(0o755);
        std::fs::set_permissions(&fake_rkat, permissions).expect("chmod fake rkat");

        let result = deploy_mobpack(&json!({
            "document": valid_document(),
            "output_dir": dir.path(),
            "prompt": "Reply with exactly OK.",
            "rkat_bin": fake_rkat,
            "execute": true
        }))
        .expect("deploy execute");

        assert!(result.executed);
        assert!(!result.success);
        assert_eq!(result.status_code, Some(7));
        assert!(
            result
                .stderr
                .as_deref()
                .unwrap_or("")
                .contains("deploy-failed")
        );
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
    }

    #[test]
    fn reports_missing_rkat_mob_run_as_deploy_result() {
        let dir = tempfile::tempdir().expect("tempdir");
        let missing_rkat = dir.path().join("missing-rkat");

        let result = deploy_mobpack(&json!({
            "document": valid_document(),
            "output_dir": dir.path(),
            "prompt": "Reply with exactly OK.",
            "rkat_bin": missing_rkat,
            "execute": true
        }))
        .expect("deploy result");

        assert!(result.executed);
        assert!(!result.success);
        assert_eq!(result.status_code, None);
        assert!(result.validation.ok, "{:?}", result.validation.diagnostics);
        assert!(std::path::Path::new(&result.pack_path).exists());
        assert!(
            result
                .stderr
                .as_deref()
                .unwrap_or_default()
                .contains("failed to run rkat mob run")
        );
        assert!(result.plan_trace.iter().any(|row| {
            row["head"]
                .as_str()
                .unwrap_or_default()
                .starts_with("MOBPACK ·")
        }));
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "crit"
                && row.head == "rkat mob run failed"
                && row.sub.contains("rkat mob run")
        }));
        assert!(result.display_rows.iter().any(|row| {
            row.kind == "warn"
                && row.head == "rkat output"
                && row.sub.contains("failed to run rkat mob run")
        }));
    }

    #[test]
    fn imports_mobpack_payload_without_runtime_mutation() {
        let params = match std::env::var("MOBKIT_MOBPACK_IMPORT_IN") {
            Ok(path) => serde_json::from_str(
                &std::fs::read_to_string(path).expect("read editor mobpack import params"),
            )
            .expect("parse editor mobpack import params"),
            Err(_) => json!({ "mob_toml": importable_mob_toml() }),
        };
        let result = import_mobpack(&params).expect("import");
        assert_eq!(result["validation"]["ok"], true);
        assert!(!result["document"]["members"].as_array().unwrap().is_empty());
        if let Ok(path) = std::env::var("MOBKIT_MOBPACK_IMPORT_OUT") {
            std::fs::write(path, serde_json::to_vec_pretty(&result).unwrap())
                .expect("write imported mobpack result");
        }
    }

    #[test]
    fn catalogs_response_exposes_filesystem_skill_catalog() {
        let schema = mobpack_schema_response();
        let catalogs = mobpack_catalogs_response();
        assert!(
            schema.get("starter_skills").is_none(),
            "starter skills must be exposed through skill_realms, not a duplicate fallback catalog"
        );
        assert!(
            schema.get("skill_realms").is_none(),
            "dynamic skill realms must be loaded through mobkit/mobpacks/catalogs"
        );
        let realms = catalogs["skill_realms"].as_array().expect("skill realms");
        let mobkit_platform = realms
            .iter()
            .find(|realm| realm["source"] == "filesystem")
            .and_then(|realm| realm["skills"].as_array())
            .and_then(|skills| skills.iter().find(|skill| skill["id"] == "mobkit-platform"))
            .expect("mobkit-platform filesystem skill");
        assert_eq!(mobkit_platform["source"], "path");
        assert_eq!(mobkit_platform["origin"], "filesystem");
        assert!(
            mobkit_platform["path"]
                .as_str()
                .is_some_and(|path| path.ends_with("SKILL.md"))
        );
        assert!(
            realms.iter().all(|realm| realm["source"] != "starter"),
            "skill realms must come from filesystem or authoring mobpacks, not starter mocks"
        );
        assert!(
            realms.iter().all(|realm| {
                realm["id"] != "mobkit/sample-mobpacks"
                    && realm["source"] != "mobkit/sample-mobpack"
            }),
            "global skill catalog must not leak sample mobpack skill realms"
        );
        let authoring_realm = realms
            .iter()
            .find(|realm| realm["id"] == "mobkit/authoring-agent-definitions")
            .expect("authoring agent definition skill realm");
        assert_eq!(
            authoring_realm["source"],
            "mobkit/authoring-agent-definition"
        );
        assert_eq!(
            authoring_realm["sourceDocumentPath"],
            "authoring_mobpacks[].document.skill_realms[]"
        );
        let authoring_skill = authoring_realm["skills"]
            .as_array()
            .expect("authoring realm skills")
            .iter()
            .find(|skill| skill["id"] == "mob.authoring.review")
            .expect("authoring review skill");
        assert_eq!(
            authoring_skill["origin"],
            "mobkit/authoring-agent-definitions"
        );
        assert_eq!(
            authoring_skill["sourceMobpack"],
            "mobkit_authoring_profiles"
        );

        let samples = catalogs["sample_mobpacks"]
            .as_array()
            .expect("sample mobpacks");
        let sample_skills = samples
            .iter()
            .filter_map(|sample| sample.get("document"))
            .flat_map(|document| document["skill_realms"].as_array().into_iter().flatten())
            .flat_map(|realm| realm["skills"].as_array().into_iter().flatten())
            .collect::<Vec<_>>();
        for skill_id in ["mob.workpad", "mob.review"] {
            let skill = sample_skills
                .iter()
                .find(|skill| skill["id"] == skill_id)
                .unwrap_or_else(|| panic!("missing {skill_id} sample skill"));
            assert_eq!(skill["source"], "inline");
            assert!(
                skill["content"]
                    .as_str()
                    .is_some_and(|content| !content.trim().is_empty())
            );
        }
        let sample_agent_definitions = catalogs["sample_agent_definitions"]
            .as_array()
            .expect("sample agent definitions");
        assert!(sample_agent_definitions.iter().any(|definition| {
            definition["skillDefinitions"]
                .as_array()
                .is_some_and(|skills| skills.iter().any(|skill| skill["id"] == "mob.workpad"))
        }));
    }

    #[test]
    fn sample_skill_realm_requires_real_sample_source_metadata() {
        let samples = json!([
            {
                "id": "missing_source",
                "document": {
                    "skill_realms": [{
                        "id": "realm",
                        "skills": [{ "id": "mob.ghost", "source": "inline", "content": "ghost" }]
                    }]
                }
            },
            {
                "source": "mobkit/sample-mobpack",
                "document": {
                    "skill_realms": [{
                        "id": "realm",
                        "skills": [{ "id": "mob.no_id", "source": "inline", "content": "ghost" }]
                    }]
                }
            },
            {
                "id": "valid_sample",
                "source": "mobkit/sample-mobpack",
                "name": "Valid Sample",
                "document": {
                    "skill_realms": [{
                        "id": "realm",
                        "skills": [{ "id": "mob.real", "source": "inline", "content": "real" }]
                    }]
                }
            }
        ]);

        let realm = sample_skill_realm(&samples, true).expect("valid sample realm");
        assert_eq!(realm["source"], "mobkit/sample-mobpack");
        let skills = realm["skills"].as_array().expect("skills");
        assert_eq!(skills.len(), 1);
        assert_eq!(skills[0]["id"], "mob.real");
        assert_eq!(skills[0]["origin"], "mobkit/sample-mobpack");
        assert_eq!(skills[0]["sourceMobpack"], "valid_sample");
        assert_eq!(skills[0]["sourceMobpackName"], "Valid Sample");
    }

    #[test]
    fn sample_agent_definitions_require_real_sample_source_metadata() {
        let samples = json!([
            {
                "id": "missing_source",
                "document": {
                    "members": [{
                        "role": "ghost",
                        "name": "Ghost",
                        "profileBinding": "inline",
                        "runtimeMode": "turn_driven",
                        "tools": ["builtins"],
                        "skills": []
                    }]
                }
            },
            {
                "source": "mobkit/sample-mobpack",
                "document": {
                    "members": [{
                        "role": "no_id",
                        "name": "No ID",
                        "profileBinding": "inline",
                        "runtimeMode": "turn_driven",
                        "tools": ["builtins"],
                        "skills": []
                    }]
                }
            },
            {
                "id": "model_less_sample",
                "source": "mobkit/sample-mobpack",
                "document": {
                    "members": [{
                        "role": "model_less",
                        "name": "Model Less",
                        "profileBinding": "inline",
                        "runtimeMode": "turn_driven",
                        "tools": ["builtins"],
                        "skills": []
                    }]
                }
            },
            {
                "id": "valid_sample",
                "name": "Valid Sample",
                "source": "mobkit/sample-mobpack",
                "document": {
                    "members": [{
                        "role": "real",
                        "name": "Real",
                        "model": "gpt-5.5",
                        "profileBinding": "inline",
                        "runtimeMode": "turn_driven",
                        "tools": ["builtins"],
                        "skills": ["mob.real"],
                        "systemPrompt": "Be real."
                    }],
                    "skill_realms": [{
                        "id": "sample",
                        "label": "Sample skills",
                        "source": "mobkit/sample-mobpack",
                        "skills": [{
                            "id": "mob.real",
                            "label": "Real skill",
                            "source": "inline",
                            "content": "real skill"
                        }]
                    }]
                }
            },
            {
                "id": "second_valid_sample",
                "name": "Second Valid Sample",
                "source": "mobkit/sample-mobpack",
                "document": {
                    "members": [{
                        "role": "real",
                        "name": "Second Real",
                        "model": "gpt-5.5",
                        "profileBinding": "inline",
                        "runtimeMode": "turn_driven",
                        "tools": ["builtins"],
                        "skills": [],
                        "systemPrompt": "Be independently real."
                    }]
                }
            }
        ]);

        let skill_realms = sample_skill_realm(&samples, true).expect("sample skill realm");
        let definitions = agent_definition_catalog(
            &samples,
            &tool_catalog_response(),
            &json!([skill_realms]),
            "sample",
        );
        let definitions = definitions.as_array().expect("agent definitions");
        assert_eq!(definitions.len(), 2);
        let first = definitions
            .iter()
            .find(|definition| definition["sourceMobpack"] == "valid_sample")
            .expect("valid sample definition");
        let second = definitions
            .iter()
            .find(|definition| definition["sourceMobpack"] == "second_valid_sample")
            .expect("second valid sample definition");
        assert_ne!(first["id"], second["id"]);
        assert_eq!(first["role"], "real");
        assert_eq!(second["role"], "real");
        assert_eq!(first["definitionType"], "mobkit/profile-member");
        assert_eq!(first["definitionKind"], "sample");
        assert_eq!(first["sourceKind"], "sample");
        assert_eq!(first["source"], "mobkit/mobpack-profile-member");
        assert_eq!(first["sourceMobpack"], "valid_sample");
        assert_eq!(first["sourceMobpackName"], "Valid Sample");
        assert_eq!(first["sourceOrigin"], "mobkit/sample-mobpack");
        assert_eq!(first["sourceDocumentPath"], "document.members[0]");
        assert_eq!(second["sourceDocumentPath"], "document.members[0]");
        assert_eq!(first["tools"], json!(["builtins"]));
        assert_eq!(first["toolDefinitions"][0]["id"], "builtins");
        assert_eq!(
            first["toolDefinitions"][0]["source"],
            "meerkat_mob::ToolConfig"
        );
        assert_eq!(first["skills"], json!(["mob.real"]));
        assert_eq!(second["skills"], json!([]));
        assert_eq!(first["skillDefinitions"][0]["id"], "mob.real");
        assert_eq!(
            first["skillDefinitions"][0]["origin"],
            "mobkit/sample-mobpack"
        );
        assert_eq!(
            first["skillDefinitions"][0]["sourceMobpack"],
            "valid_sample"
        );
    }

    #[test]
    fn schema_response_exposes_rkat_deploy_defaults_for_editor_hydration() {
        let schema = mobpack_schema_response();
        let deploy = &schema["deploy_settings"];

        assert_eq!(deploy["command"], "rkat mob run");
        assert_eq!(deploy["defaults"]["command"], "rkat mob run");
        assert_eq!(deploy["defaults"]["surface"], "cli");
        assert_eq!(deploy["defaults"]["trust_policy"], "permissive");
        assert_eq!(deploy["defaults"]["realm_backend"], "jsonl");
        assert_eq!(deploy["defaults"]["isolated"], true);
        assert_eq!(deploy["defaults"]["prompt"], "Reply with exactly OK.");
        assert_eq!(deploy["runtime_backed"], json!(false));
        assert_eq!(
            deploy["provenance"]["source"],
            json!("mobkit/standalone_authoring_defaults")
        );
        assert!(
            !deploy["options"]
                .as_array()
                .expect("deploy options")
                .contains(&json!("rkat_bin"))
        );

        let mob_defaults = &schema["mob_definition"]["mob_settings"]["defaults"];
        assert_eq!(mob_defaults["backendDefault"], "session");
        assert_eq!(mob_defaults["autoWireOrchestrator"], false);
        assert_eq!(mob_defaults["roleWiring"], json!([]));
        assert_eq!(mob_defaults["advanced"]["topology"], Value::Null);
    }

    #[test]
    fn runtime_schema_response_derives_deploy_settings_from_authoring_provider() {
        let runtime = MobpackRuntimeCatalogState {
            loaded_modules: vec!["editor-host".to_string()],
            runtime_methods: vec!["mobkit/mobpacks/deploy".to_string()],
            has_contact_directory: true,
            has_peer_mob_handles: false,
            has_inproc_contacts: false,
            runtime_flow_rows: Vec::new(),
            runtime_agent_definition_sources: Vec::new(),
            runtime_skill_realms: Vec::new(),
        };
        let schema = mobpack_schema_response_with_runtime(Some(&runtime));
        let deploy = &schema["deploy_settings"];

        assert_eq!(deploy["runtime_backed"], json!(true));
        assert_eq!(
            deploy["authoring_provider"]["runtime_binding"],
            json!("bound")
        );
        assert_eq!(deploy["command"], json!("rkat mob run"));
        assert_eq!(deploy["defaults"]["command"], json!("rkat mob run"));
        assert_eq!(deploy["surfaces"], json!(["cli", "rpc"]));
        assert_eq!(deploy["defaults"]["surface"], json!("cli"));
        assert_eq!(deploy["realm_backends"], json!(["jsonl", "sqlite"]));
        assert_eq!(deploy["defaults"]["realm_backend"], json!("jsonl"));
        assert_eq!(
            deploy["provenance"]["source"],
            json!("UnifiedRuntime.authoring_provider.deploy_target")
        );
    }

    #[test]
    fn schema_response_commands_match_mobpack_authoring_methods() {
        let schema = mobpack_schema_response();
        let commands = schema["commands"]
            .as_object()
            .expect("schema commands object");
        let expected = BTreeMap::from([
            ("schema", "mobkit/mobpacks/schema"),
            ("catalogs", "mobkit/mobpacks/catalogs"),
            ("tools_catalog", "mobkit/tools/catalog"),
            ("skills_catalog", "mobkit/skills/catalog"),
            ("agent_definitions", "mobkit/agent_definitions/list"),
            ("templates", "mobkit/mobpacks/templates"),
            ("validate", "mobkit/mobpacks/validate"),
            ("source", "mobkit/mobpacks/source"),
            ("export", "mobkit/mobpacks/export"),
            ("import", "mobkit/mobpacks/import"),
            ("list", "mobkit/mobpacks/list"),
            ("get", "mobkit/mobpacks/get"),
            ("create", "mobkit/mobpacks/create"),
            ("save", "mobkit/mobpacks/save"),
            ("delete", "mobkit/mobpacks/delete"),
            ("undo", "mobkit/mobpacks/undo"),
            ("redo", "mobkit/mobpacks/redo"),
            ("apply_operation", "mobkit/mobpacks/apply_operation"),
            ("graph_projection", "mobkit/mobpacks/graph_projection"),
            ("graph_to_flow", "mobkit/mobpacks/graph_to_flow"),
            ("deploy_command", "mobkit/mobpacks/deploy_command"),
            ("deploy_rpc", "mobkit/mobpacks/deploy"),
            (
                "deploy_cli",
                "rkat mob run <pack.mobpack> --prompt <prompt>",
            ),
        ]);

        assert_eq!(
            commands
                .iter()
                .filter_map(|(key, value)| value.as_str().map(|value| (key.as_str(), value)))
                .collect::<BTreeMap<_, _>>(),
            expected
        );

        let rpc_methods = commands
            .iter()
            .filter(|(key, _)| key.as_str() != "deploy_cli")
            .filter_map(|(_, value)| value.as_str())
            .collect::<BTreeSet<_>>();
        let authoring_methods = crate::rpc::MOBPACK_AUTHORING_METHODS
            .iter()
            .copied()
            .collect::<BTreeSet<_>>();
        assert_eq!(rpc_methods, authoring_methods);
    }

    #[test]
    fn catalogs_response_exposes_real_tool_catalog_without_schema_aliases() {
        let schema = mobpack_schema_response();
        let catalogs = mobpack_catalogs_response();
        assert_eq!(catalogs["authoring_provider"]["id"], "standalone_authoring");
        assert_eq!(catalogs["authoring_provider"]["runtime_binding"], "unbound");
        assert_eq!(
            catalogs["runtime_unavailable_reason"],
            STANDALONE_AUTHORING_RUNTIME_UNAVAILABLE_REASON
        );
        assert_eq!(catalogs["sources"]["runtime"], "standalone_authoring");
        assert_eq!(catalogs["sources"]["runtime_binding"], "unbound");
        assert!(
            schema.get("tool_config").is_none(),
            "schema must not expose compatibility tool_config aliases"
        );
        assert!(
            schema.get("tool_catalog").is_none(),
            "dynamic tool catalogs must be loaded through mobkit/mobpacks/catalogs"
        );
        assert!(
            schema.get("agent_definitions").is_none(),
            "dynamic agent definitions must be loaded through mobkit/mobpacks/catalogs"
        );
        let tool_catalog = catalogs["tool_catalog"].as_array().expect("tool_catalog");
        assert_eq!(
            mobpack_tools_catalog_response()["authoring_provider"]["runtime_binding"],
            "unbound"
        );
        assert_eq!(
            mobpack_skills_catalog_response()["authoring_provider"]["runtime_binding"],
            "unbound"
        );
        assert_eq!(
            mobpack_agent_definitions_response()["authoring_provider"]["runtime_binding"],
            "unbound"
        );
        assert_eq!(
            mobpack_templates_response()["authoring_provider"]["runtime_binding"],
            "unbound"
        );
        assert_eq!(
            catalogs["catalog_snapshot"]["source"],
            json!("mobkit/mobpacks/catalogs")
        );
        assert!(
            catalogs["catalog_snapshot"]["id"]
                .as_str()
                .is_some_and(|id| id.len() == 64)
        );

        let tool_config_fields = serde_json::to_value(ToolConfig::default())
            .expect("ToolConfig serializes")
            .as_object()
            .expect("ToolConfig object")
            .iter()
            .filter(|(_, value)| value.is_boolean())
            .map(|(field, _)| field.clone())
            .collect::<BTreeSet<_>>();
        let catalog_fields = tool_catalog
            .iter()
            .filter(|tool| tool["kind"] == "runtime")
            .map(|tool| {
                assert_eq!(tool["source"], "meerkat_mob::ToolConfig");
                assert_eq!(tool["deployability"]["command"], "rkat mob run");
                assert_eq!(tool["deployability"]["deployable"], json!(true));
                assert_eq!(tool["provenance"]["catalog"], "mobkit/tools/catalog");
                let id = tool["id"].as_str().expect("tool id");
                assert_eq!(tool["field"], id);
                id.to_string()
            })
            .collect::<BTreeSet<_>>();
        assert_eq!(catalog_fields, tool_config_fields);
        assert!(tool_catalog.iter().any(|tool| {
            tool["id"] == "builtins" && tool["kind"] == "runtime" && tool["field"] == "builtins"
        }));
        assert!(tool_catalog.iter().any(|tool| {
            tool["id"] == "shell" && tool["kind"] == "runtime" && tool["field"] == "shell"
        }));
        assert!(
            tool_catalog
                .iter()
                .any(|tool| { tool["id"] == "shell" && tool["tag_class"] == "is-shell" })
        );
        assert!(
            tool_catalog
                .iter()
                .any(|tool| { tool["id"] == "builtins" && tool["tag_class"] == "" })
        );
        assert!(tool_catalog.iter().any(|tool| {
            tool["id"] == "comms" && tool["kind"] == "runtime" && tool["field"] == "comms"
        }));
        assert!(tool_catalog.iter().any(|tool| {
            tool["id"] == "mob" && tool["kind"] == "runtime" && tool["field"] == "mob"
        }));
        let standalone_mob_tool = tool_catalog
            .iter()
            .find(|tool| tool["id"] == "mob")
            .expect("mob runtime tool");
        assert_eq!(
            standalone_mob_tool["runtime_availability"]["state"],
            json!("deferred")
        );
        assert_eq!(
            standalone_mob_tool["runtimeAvailability"]["available"],
            json!(true)
        );
        assert!(tool_catalog.iter().all(|tool| {
            tool["desc"].as_str().is_some_and(|desc| !desc.is_empty())
                && tool["label"]
                    .as_str()
                    .is_some_and(|label| !label.is_empty())
                && tool.get("tag_class").is_some_and(Value::is_string)
                && tool["runtime_availability"]["available"].is_boolean()
                && tool["runtimeAvailability"]["available"].is_boolean()
                && tool["deployability"]["deployable"].is_boolean()
                && tool["provenance"]["catalog"].is_string()
        }));
        assert!(
            catalogs["models"]
                .as_array()
                .expect("models")
                .iter()
                .all(|model| {
                    model["deployability"]["command"] == "rkat mob run"
                        && model["provenance"]["catalog"] == "mobkit/mobpacks/catalogs"
                })
        );
    }

    #[test]
    fn schema_response_exposes_flow_control_contract_for_editor_hydration() {
        let schema = mobpack_schema_response();
        let mob_definition = &schema["mob_definition"];
        let defaults = &mob_definition["defaults"];

        assert_eq!(defaults["runtime_mode"], json!("turn_driven"));
        assert_eq!(defaults["launch_mode"], json!("fresh"));
        assert_eq!(defaults["fork_context"], json!("full_history"));
        assert_eq!(defaults["budget_split_policy"], json!("equal"));
        assert_eq!(defaults["dispatch_mode"], json!("fan_out"));
        assert_eq!(defaults["collection_policy"], json!("all"));
        assert_eq!(defaults["dependency_mode"], json!("all"));
        assert_eq!(defaults["condition_operator"], json!("=="));
        assert_eq!(defaults["schema_field_type"], json!("string"));
        assert_eq!(defaults["branch_param_type"], json!("enum"));
        assert_eq!(defaults["repeat_iteration_input"], json!("carry"));
        assert_eq!(defaults["step_output_format"], json!("json"));
        assert_eq!(defaults["graph_gate_kind"], json!("branch"));
        assert_eq!(defaults["graph_edge_kind"], json!("next"));
        assert_eq!(defaults["graph_condition_edge_kind"], json!("cond"));
        assert_eq!(defaults["graph_fanout_edge_kind"], json!("fanout"));
        assert_eq!(defaults["graph_terminal_kind"], json!("success"));
        assert_eq!(
            mob_definition["runtime_modes"],
            json!(runtime_mode_values())
        );
        assert_eq!(
            mob_definition["runtime_mode_labels"]["turn_driven"],
            json!("turn_driven — explicit turn dispatch")
        );
        assert_eq!(
            mob_definition["runtime_mode_labels"]["autonomous_host"],
            json!("autonomous_host — RPC keep-alive member loop")
        );
        assert_eq!(
            mob_definition["deploy_runtime_mode_compatibility"]["cli"]["allowed"],
            json!(["turn_driven"])
        );
        assert_eq!(
            mob_definition["deploy_runtime_mode_compatibility"]["cli"]["blocked"]["autonomous_host"],
            json!("RPC surface only; rkat mob run requires turn_driven profiles.")
        );
        assert_eq!(
            mob_definition["deploy_runtime_mode_compatibility"]["rpc"]["allowed"],
            json!(runtime_mode_values())
        );
        assert_eq!(
            mob_definition["launch_modes"],
            json!(member_launch_mode_values())
        );
        assert_eq!(
            mob_definition["dispatch_modes"],
            json!(dispatch_mode_values())
        );
        assert_eq!(
            mob_definition["dispatch_mode_labels"]["fan_out"],
            json!("fan_out — broadcast to every lane")
        );
        assert_eq!(
            mob_definition["collection_policy_labels"]["quorum"],
            json!("quorum — require N branches")
        );
        assert_eq!(
            mob_definition["dependency_mode_labels"]["any"],
            json!("any — any upstream node")
        );
        assert_eq!(
            mob_definition["option_unsupported_label_separator"],
            json!(" — not in MobKit ")
        );
        assert_eq!(
            mob_definition["option_unsupported_reason_prefix"],
            json!("Unsupported by the MobKit ")
        );
        assert_eq!(
            mob_definition["option_unsupported_reason_suffix"],
            json!(" contract.")
        );
        assert_eq!(
            mob_definition["profile_backends"],
            json!(["session", "external"])
        );
        assert_eq!(mob_definition["profile_binding"], json!(["inline"]));
        assert_eq!(
            mob_definition["profile_binding_restrictions"]["inline"]["deployable"],
            json!(true)
        );
        assert_eq!(
            mob_definition["profile_binding_restrictions"]["realm_profile"]["deployable"],
            json!(false)
        );
        assert!(
            mob_definition["profile_binding_restrictions"]["realm_profile"]["reason"]
                .as_str()
                .is_some_and(|reason| reason.contains("rkat mob validate"))
        );
        let catalogs = mobpack_catalogs_response();
        let blank_mobpack = &catalogs["blank_mobpack"];
        assert_eq!(blank_mobpack["id"], json!("blank"));
        assert_eq!(blank_mobpack["source"], json!("mobkit/blank-mobpack"));
        assert_eq!(blank_mobpack["validation"]["ok"], json!(true));
        assert!(
            blank_mobpack["document"]["members"]
                .as_array()
                .is_some_and(|members| !members.is_empty())
        );
        assert!(
            blank_mobpack["document"]["flow"]["steps"]
                .as_array()
                .is_some_and(|steps| steps.iter().any(|step| step["type"] == "member"))
        );
        assert_eq!(
            mob_definition["fork_contexts"],
            json!(fork_context_values())
        );
        assert_eq!(
            mob_definition["budget_split_policies"],
            json!(budget_split_policy_values())
        );
        assert_eq!(
            mob_definition["editor_schema_field_types"],
            json!(editor_schema_field_type_values())
        );
        assert_eq!(
            mob_definition["editor_input_param_draft"]["document_path"],
            json!("document.flow.steps[type=input].inputParams")
        );
        assert_eq!(
            mob_definition["editor_input_step_draft"]["document_path"],
            json!("document.flow.steps[type=input]")
        );
        assert_eq!(
            mob_definition["editor_input_step_draft"]["default_step"]["id"],
            json!("input")
        );
        assert_eq!(
            mob_definition["editor_input_step_draft"]["default_step"]["task"],
            json!("Run the mobpack flow.")
        );
        assert_eq!(
            mob_definition["editor_input_param_draft"]["archive_path"],
            json!("schemas/main-input.json")
        );
        assert_eq!(
            mob_definition["editor_input_param_draft"]["added_field"]["name"],
            json!("param")
        );
        assert_eq!(
            mob_definition["editor_input_param_draft"]["added_field"]["required"],
            json!(true)
        );
        assert_eq!(
            mob_definition["editor_schema_draft"]["schema_id_prefix"],
            json!("Artifact")
        );
        assert_eq!(
            mob_definition["editor_schema_draft"]["document_path"],
            json!("document.schemas[]")
        );
        assert_eq!(
            mob_definition["editor_schema_draft"]["archive_path"],
            json!("schemas/<schema-id>.json")
        );
        assert_eq!(
            mob_definition["editor_schema_draft"]["initial_field"]["name"],
            json!("field_one")
        );
        assert_eq!(
            mob_definition["editor_schema_draft"]["initial_field"]["required"],
            json!(true)
        );
        assert_eq!(
            mob_definition["editor_schema_draft"]["added_field"]["name"],
            json!("new_field")
        );
        assert_eq!(
            mob_definition["editor_schema_draft"]["added_field"]["required"],
            json!(false)
        );
        assert_eq!(
            mob_definition["condition_operators"],
            json!(["==", ">", "<"])
        );
        assert_eq!(
            mob_definition["step_output_formats"],
            json!(step_output_format_values())
        );
        assert_eq!(
            mob_definition["collection_policies"],
            json!(collection_policy_values())
        );
        assert_eq!(
            mob_definition["dependency_modes"],
            json!(dependency_mode_values())
        );
        assert_eq!(
            mob_definition["editor_flow_step_types"],
            json!([
                "input", "member", "repeat", "branch", "parallel", "adaptive"
            ])
        );
        assert_eq!(mob_definition["repeat_iteration_inputs"], json!(["carry"]));
        assert_eq!(
            mob_definition["graph_gate_kinds"],
            json!(["branch", "fork", "join"])
        );
        assert_eq!(
            mob_definition["graph_palette_gate_kinds"],
            json!(["branch", "fork"])
        );
        assert_eq!(
            mob_definition["graph_terminal_kinds"],
            json!(["success", "failed", "human"])
        );
        assert_eq!(
            mob_definition["graph_frame_kinds"],
            json!(["Branch", "Parallel", "RepeatUntil"])
        );
        assert_eq!(
            mob_definition["graph_edge_kinds"],
            json!(["next", "cond", "fanout"])
        );
        assert_eq!(
            mob_definition["editor_graph_draft"]["branch_gate_label"],
            json!("branch")
        );
        assert_eq!(
            mob_definition["editor_graph_draft"]["fallback_edge_label"],
            json!("fallback")
        );
        assert_eq!(
            mob_definition["editor_graph_draft"]["parallel_lane_labels"],
            json!(["lane 1", "lane 2"])
        );
        assert_eq!(
            mob_definition["editor_graph_draft"]["parallel_missing_dispatch_label"],
            json!("missing dispatch")
        );
        assert_eq!(
            mob_definition["editor_graph_draft"]["join_quorum_label_prefix"],
            json!("barrier · ")
        );
        assert_eq!(
            mob_definition["editor_graph_draft"]["repeat_missing_max_iterations_label"],
            json!("missing max_iterations")
        );
        assert_eq!(
            mob_definition["editor_source_view"]["drawer_eyebrow"],
            json!("SOURCE · mob.toml")
        );
        assert_eq!(
            mob_definition["editor_source_view"]["inline_title"],
            json!("mob.toml")
        );
        assert_eq!(
            mob_definition["editor_source_view"]["primary_source_path"],
            json!("mobkit/mob.toml")
        );
        assert_eq!(
            mob_definition["editor_source_view"]["loading_text"],
            json!("rendering mob.toml from mobkit/mobpacks/source...")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["fit_title"],
            json!("Fit to view")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["port_drag_title"],
            json!("Drag to a member to connect")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["add_node_search_placeholder"],
            json!("Add a node…")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["gate_palette_rows"][1]["id"],
            json!("fork")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["add_node_terminals_label"],
            json!("Terminals")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["terminal_palette_rows"][0]["id"],
            json!("success")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["terminal_palette_rows"][0]["meta"],
            json!("visual-only; not deployable")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["graph_gate_kind_labels"]["join"],
            json!("join — wait for branches")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["graph_edge_kind_labels"]["cond"],
            json!("cond — guarded branch")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["branch_input_param_source_label"],
            json!("Input params")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_label"],
            json!("mob.toml")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_aria_label"],
            json!("Open mob.toml read-only source editor")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_glyph"],
            json!("{ }")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_role_label"],
            json!("source file")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_node_id"],
            json!("source_mob_toml")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_node_kind"],
            json!("source")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_node_col_offset"],
            json!(0)
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_node_row_offset"],
            json!(-1)
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_activation_hash"],
            json!("#mobkit-graph-source")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["source_file_activation_selector"],
            json!(".node--source-file")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["gate_dispatch_hint"],
            json!("Exports as the MobKit parallel flow dispatch mode.")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["edge_terminal_member_value"],
            json!("(terminal)")
        );
        assert_eq!(
            mob_definition["editor_condition_view"]["text_value_placeholder"],
            json!("value")
        );
        assert_eq!(
            mob_definition["editor_condition_view"]["empty_value_label"],
            json!("—")
        );
        assert_eq!(
            mob_definition["editor_error_view"]["deploy_plan_failed_head"],
            json!("Deploy plan failed")
        );
        assert_eq!(
            mob_definition["editor_error_view"]["authoring_operation_fallback_heads"]["graph_projection"],
            json!("MobKit graph projection failed")
        );
        assert_eq!(
            mob_definition["editor_error_view"]["missing_editor_flow_meta"],
            json!("missing_editor_flow")
        );
        assert_eq!(
            mob_definition["editor_error_view"]["boot_open_missing_head"],
            json!("Mob not found")
        );
        assert_eq!(
            mob_definition["editor_error_view"]["boot_open_missing_meta"],
            json!("flow-editor?open")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["agents_heading"],
            json!("AGENTS")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["schemas_heading"],
            json!("SCHEMAS")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["add_schema_label"],
            json!("+ new schema")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["empty_title"],
            json!("AGENT LIBRARY")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["empty_lines"][0],
            json!("Select an agent or schema on the left.")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["missing_agent_label"],
            json!("Agent not found.")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["definition_catalog_title"],
            json!("DEFINITION CATALOG")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["definition_catalog_source_label"],
            json!("source")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["definition_catalog_tools_label"],
            json!("tools")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["definition_catalog_skills_label"],
            json!("skills")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["member_sub_label_template"],
            json!("{role} · {model}")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["member_placed_empty_label"],
            json!("unplaced")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["member_placed_count_template"],
            json!("×{count}")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["schema_field_plural_template"],
            json!("{count} fields")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["schema_usage_label_template"],
            json!("used by {count}")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["sidebar_sub_label_separator"],
            json!(" · ")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["identity_title"],
            json!("IDENTITY")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["agent_eyebrow_prefix"],
            json!("AGENT")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["delete_cancel_label"],
            json!("CANCEL")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["missing_profile_binding_label"],
            json!("missing profile binding")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["backend_definition_default_label"],
            json!("definition default")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["provider_params_label"],
            json!("Provider params")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["provider_params_object_required_error"],
            json!("provider_params must be a JSON object")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["system_prompt_title"],
            json!("SYSTEM PROMPT")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["budget_title"],
            json!("BUDGET")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["budget_split_policy_labels"]["Fixed"],
            json!("Fixed — hard cap")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["empty_schema_hint"],
            json!("No structured output. Agent returns free-form text.")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["source_title"],
            json!("SOURCE")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["source_mobpack_label"],
            json!("Mobpack")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["source_tools_label"],
            json!("Tool refs")
        );
        assert_eq!(
            mob_definition["editor_agent_detail_view"]["source_skills_label"],
            json!("Skill refs")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["tool_title"],
            json!("TOOL ACCESS")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["tool_invalid_error"],
            json!("Use a MobKit-listed runtime tool or configured MCP/Rust source.")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["inline_skill_realm_id"],
            json!("mobkit/editor-inline")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["inline_skill_realm_source"],
            json!("mobkit/editor")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["inline_skill_source"],
            json!("inline")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["skill_inline_add_label"],
            json!("ADD SKILL")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["skill_inline_missing_label_error"],
            json!("Inline skill id or label is required.")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["skill_inline_missing_content_error"],
            json!("Inline skill content is required.")
        );
        assert_eq!(
            mob_definition["editor_agent_access_view"]["skill_inline_invalid_id_error"],
            json!("Inline skill id or label must contain letters or numbers.")
        );
        assert_eq!(
            mob_definition["editor_new_flow_view"]["eyebrow_template"],
            json!("NEW MOB")
        );
        assert_eq!(
            mob_definition["editor_new_flow_view"]["name_placeholder"],
            json!("review-mob")
        );
        assert_eq!(
            mob_definition["editor_new_flow_view"]["create_label"],
            json!("CREATE")
        );
        assert_eq!(
            mob_definition["editor_flow_registry_view"]["eyebrow"],
            json!("LIBRARY")
        );
        assert_eq!(
            mob_definition["editor_flow_registry_view"]["create_label"],
            json!("+ NEW MOB")
        );
        assert_eq!(
            mob_definition["editor_flow_registry_view"]["columns"][3]["label"],
            json!("STAGE")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["brand_label"],
            json!("MobKit · Flow Editor")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["flow_tab_label"],
            json!("FLOW")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["settings_tab_label"],
            json!("SETTINGS")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["switcher_view_all_label"],
            json!("view all mobs")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["deploy_label"],
            json!("DEPLOY")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["overflow_label"],
            json!("MORE")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["validation_eyebrow"],
            json!("VALIDATE · MobKit")
        );
        assert_eq!(
            mob_definition["editor_deploy_view"]["plan_unavailable_body"],
            json!("mobkit/mobpacks/deploy did not return plan_trace.")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["canvas_title"],
            json!("Canvas")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["deploy_title"],
            json!("Deploy")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["duration_placeholder"],
            json!("10m")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["tool_calls_max"],
            json!(999)
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["inspector_layout_options"][0]["label"],
            json!("Right")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["role_wiring_add_label"],
            json!("+ rule")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["unsupported_label_separator"],
            json!(" — not in MobKit ")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["deploy_surface_contract_label"],
            json!("deploy_settings.surfaces")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["trust_policy_contract_label"],
            json!("deploy_settings.trust_policies")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["realm_backend_contract_label"],
            json!("deploy_settings.realm_backends")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["deploy_surface_labels"]["cli"],
            json!("cli")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["trust_policy_labels"]["strict"],
            json!("strict")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["realm_backend_labels"]["sqlite"],
            json!("sqlite")
        );
        assert_eq!(
            mob_definition["editor_settings_view"]["advanced_object_required_error"],
            json!("object required")
        );
        assert_eq!(
            mob_definition["editor_launch_view"]["launch_title"],
            json!("Launch mode")
        );
        assert_eq!(
            mob_definition["editor_launch_view"]["launch_mode_labels"]["Fork"],
            json!("Fork — copy context from another step")
        );
        assert_eq!(
            mob_definition["editor_launch_view"]["fixed_budget_default_value"],
            json!(4096)
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["add_agent_placeholder_label"],
            json!("+ new agent...")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["add_agent_unavailable_label"],
            json!("agents unavailable")
        );
        assert_eq!(
            mob_definition["editor_agent_view"]["add_agent_error_prefix"],
            json!("Agent definition unavailable: ")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["eyebrow"],
            json!("OUTPUT SCHEMA")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["fields_title_prefix"],
            json!("FIELDS")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["fields_title_template"],
            json!("{prefix} · {count}")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["used_by_title_template"],
            json!("{prefix} · {count}")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["usage_singular_template"],
            json!("used by {count} agent")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["usage_plural_template"],
            json!("used by {count} agents")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["header_labels"]["required"],
            json!("REQ")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["delete_blocked_title"],
            json!("Unassign from agents first")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["field_name_placeholder"],
            json!("field_name")
        );
        assert_eq!(
            mob_definition["editor_schema_view"]["field_enum_add_value"],
            json!("value")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["start_label"],
            json!("START")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["loop_badge"],
            json!("LOOP")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["tips_title"],
            json!("Tips")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["empty_panel_subtitle_parts"][3]["text"],
            json!("mob.toml")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["source_toggle_label"],
            json!("{ } mob.toml")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["member_step_instruction_placeholder"],
            json!("e.g. Run the focused tests and report failures.")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["tool_scope_add_profile_placeholder"],
            json!("+ add profile tool...")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["input_panel_title"],
            json!("Input")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["input_param_header_labels"]["required"],
            json!("REQ")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["input_param_name_placeholder"],
            json!("param_name")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["input_param_enum_add_value"],
            json!("value")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["input_empty_params_parts"][1]["text"],
            json!("params.*")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["input_tips"][0],
            json!("Run with: rkat mob run <pack> --prompt \"<task>\" — or run_flow(input).")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["branch_condition_empty_hint"],
            json!("Add an upstream member with an output schema before configuring this branch.")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["parallel_collection_label"],
            json!("Collection policy (fan_in)")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["repeat_panel_title"],
            json!("Repeat until")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["repeat_canvas_loop_back_prefix"],
            json!("↑ loop back · ")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["picker_search_placeholder"],
            json!("Search members & primitives…")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["parallel_step_card_desc_prefix"],
            json!("fan-out → join · ")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][2]["id"],
            json!("parallel")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][3]["id"],
            json!("adaptive")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][3]["is_new"],
            json!(true)
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][3]["disabled"],
            Value::Null
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][4]["id"],
            json!("subagent")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][4]["disabled"],
            json!(true)
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][4]["is_new"],
            Value::Null
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][5]["id"],
            json!("wait")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["flow_primitive_rows"][5]["disabled"],
            json!(true)
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["adaptive_defaults"]["type"],
            json!("adaptive")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["adaptive_defaults"]["limits"]["maxDepth"],
            json!(4)
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["adaptive_limit_rows"]
                .as_array()
                .expect("adaptive limit rows")
                .len(),
            12
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["adaptive_limit_rows"][0]["key"],
            json!("maxDepth")
        );
        assert_eq!(
            mob_definition["editor_basic_view"]["adaptive_limit_rows"][0]["primary"],
            json!(true)
        );
        assert_eq!(
            mob_definition["editor_graph_template_view"]["template_eyebrow"],
            json!("TEMPLATE")
        );
        assert_eq!(
            mob_definition["editor_graph_template_view"]["quick_start_title"],
            json!("QUICK START")
        );
        assert_eq!(
            mob_definition["editor_graph_template_view"]["summary_members_label"],
            json!("members")
        );
        assert_eq!(
            mob_definition["editor_graph_template_view"]["summary_frames_label"],
            json!("frames")
        );
        assert_eq!(
            mob_definition["editor_graph_template_view"]["summary_members_value_template"],
            json!("{placed} placed / {total} in library")
        );
        assert_eq!(
            mob_definition["editor_graph_template_view"]["quick_start_rows"][0][1]["text"],
            json!("library member")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["instance_eyebrow"],
            json!("INSTANCE")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["instance_id_line_template"],
            json!("{id} · cell ({col},{row})")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["instance_output_title_template"],
            json!("MEMBER OUTPUT · {schema}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["gate_eyebrow_template"],
            json!("GATE · {kind}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["gate_quorum_incoming_template"],
            json!("of {count} incoming")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["gate_member_option_template"],
            json!("{name} · {role}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["terminal_eyebrow_template"],
            json!("TERMINAL · {kind}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["terminal_authoring_locked_title"],
            json!("Visual terminal node")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["terminal_authoring_locked_hint"],
            json!(
                "Visual terminal nodes do not compile into deployable MobKit mob.toml. Delete this node or replace it with a real member, branch, join, or loop flow shape."
            )
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["adaptive_authoring_locked_title"],
            json!("Adaptive layer node")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["adaptive_authoring_locked_hint"],
            json!(
                "Adaptive layers are authored in Basic mode — FlowMaster, prompt, profile templates, and limits are edited there. Deleting this node deletes the adaptive step from the flow."
            )
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["edge_eyebrow_template"],
            json!("EDGE · {kind}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["edge_title_template"],
            json!("{from} → {to}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["edge_id_line_template"],
            json!("{id}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["edge_field_placeholder"],
            json!("— field —")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["edge_field_no_schema_placeholder"],
            json!("(no schema)")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["graph_condition_target_missing_label"],
            json!("?")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["graph_condition_owner_option_template"],
            json!("{name}")
        );
        assert_eq!(
            mob_definition["editor_graph_view"]["graph_condition_field_option_template"],
            json!("{name} · {type}")
        );
    }

    #[test]
    fn schema_response_exposes_packed_skill_archive_contract() {
        let schema = mobpack_schema_response();
        let files = schema["files"].as_array().expect("archive files");

        assert!(files.contains(&json!("skills/*.md")));
        assert_eq!(
            schema["mob_definition"]["skill_source_document_path"],
            "document.skill_realms[].skills[]"
        );
        assert_eq!(
            schema["mob_definition"]["path_skill_archive_path"],
            "skills/<skill-id>.md or a safe relative skill path"
        );
    }

    #[test]
    fn writes_schema_response_fixture_when_requested() {
        let schema = mobpack_schema_response();
        let catalogs = mobpack_catalogs_response();
        assert_eq!(schema["schema_version"], MOBPACK_SCHEMA_VERSION);
        assert!(
            schema.get("sample_mobpacks").is_none(),
            "dynamic sample mobpacks must be loaded through mobkit/mobpacks/catalogs"
        );
        assert!(
            catalogs["sample_mobpacks"]
                .as_array()
                .is_some_and(|samples| {
                    samples.iter().all(|sample| {
                        sample["document"]["mob_toml"]
                            .as_str()
                            .is_some_and(|text| text.contains("[mob]"))
                    })
                })
        );
        let invalid_samples = catalogs["sample_mobpacks"]
            .as_array()
            .expect("sample mobpacks")
            .iter()
            .filter(|sample| sample["validation"]["ok"] != true)
            .map(|sample| {
                json!({
                    "name": sample["name"].clone(),
                    "diagnostics": sample["validation"]["diagnostics"].clone(),
                })
            })
            .collect::<Vec<_>>();
        assert!(invalid_samples.is_empty(), "{invalid_samples:#?}");
        assert!(
            schema.get("profile_templates").is_none(),
            "authoring schema must not expose profile-template aliases"
        );
        assert!(
            schema.get("agent_definitions").is_none(),
            "dynamic agent definitions must be loaded through mobkit/mobpacks/catalogs"
        );
        let agent_definitions = catalogs["agent_definitions"]
            .as_array()
            .expect("agent definitions");
        let sample_agent_definitions = catalogs["sample_agent_definitions"]
            .as_array()
            .expect("sample agent definitions");
        assert!(sample_agent_definitions.iter().any(|definition| {
            definition["role"] == "router"
                && definition["tools"]
                    .as_array()
                    .is_some_and(|tools| tools.contains(&json!("mob")))
        }));
        let sample_members = catalogs["sample_mobpacks"]
            .as_array()
            .expect("sample mobpacks")
            .iter()
            .flat_map(|sample| {
                let source_mobpack = sample["id"].as_str().unwrap_or_default().to_string();
                sample["document"]["members"]
                    .as_array()
                    .into_iter()
                    .flatten()
                    .filter_map(move |member| {
                        let role = member["role"]
                            .as_str()
                            .or_else(|| member["name"].as_str())?;
                        Some(((source_mobpack.clone(), role.to_string()), member.clone()))
                    })
            })
            .collect::<BTreeMap<_, _>>();
        assert!(
            agent_definitions.iter().all(|definition| {
                definition["sourceOrigin"] != "mobkit/sample-mobpack"
                    && definition["sourceKind"] == "authoring"
                    && definition["definitionKind"] == "authoring"
            }),
            "Agent Editor definitions must be authoring-owned, not sample-derived: {agent_definitions:#?}"
        );
        assert!(
            sample_agent_definitions.iter().any(|definition| {
                definition["definitionType"] == "mobkit/profile-member"
                    && definition["source"] == "mobkit/mobpack-profile-member"
                    && definition["sourceKind"] == "sample"
                    && definition["definitionKind"] == "sample"
                    && definition["sourceDocumentPath"]
                        .as_str()
                        .is_some_and(|path| path.starts_with("document.members["))
                    && definition["deployability"]["deployable"] == json!(true)
                    && definition["provenance"]["catalog"] == "mobkit/agent_definitions/list"
                    && definition["sourceMobpack"]
                        .as_str()
                        .is_some_and(|value| value.starts_with("sample_"))
            }),
            "catalogs must keep sample-derived agent definitions separate from Agent Editor definitions"
        );
        let sample_agent_definitions = sample_agent_definitions
            .iter()
            .filter(|definition| definition["sourceOrigin"] == "mobkit/sample-mobpack")
            .collect::<Vec<_>>();
        let authoring_agent_definitions = agent_definitions
            .iter()
            .filter(|definition| definition["sourceOrigin"] == "mobkit/authoring-agent-definitions")
            .collect::<Vec<_>>();
        assert!(
            !authoring_agent_definitions.is_empty(),
            "catalogs must expose MobKit-owned authoring definitions, not only sample-derived agents"
        );
        assert!(authoring_agent_definitions.iter().any(|definition| {
            definition["role"] == "reviewer"
                && definition["sourceMobpack"] == "mobkit_authoring_profiles"
                && definition["sourceDocumentPath"]
                    .as_str()
                    .is_some_and(|path| path.starts_with("document.members["))
                && definition["tools"]
                    .as_array()
                    .is_some_and(|tools| tools.contains(&json!("shell")))
                && definition["skills"]
                    .as_array()
                    .is_some_and(|skills| skills.contains(&json!("mob.authoring.review")))
                && definition["skillDefinitions"]
                    .as_array()
                    .is_some_and(|skills| {
                        skills.iter().any(|skill| {
                            skill["id"] == "mob.authoring.review"
                                && skill["origin"] == "mobkit/authoring-agent-definitions"
                        })
                    })
                && definition["schemaDefinition"]["id"] == "ReviewerOutput"
                && definition["deployability"]["command"] == "rkat mob run"
                && definition["provenance"]["source"] == "mobkit/authoring-agent-definitions"
        }));
        assert!(authoring_agent_definitions.iter().any(|definition| {
            definition["role"] == "agent"
                && definition["name"] == "agent"
                && definition["model"] == "gpt-5.5"
                && definition["profileBinding"] == "inline"
                && definition["runtimeMode"] == "turn_driven"
                && definition["sourceKind"] == "authoring"
                && definition["definitionKind"] == "authoring"
                && definition["tools"]
                    .as_array()
                    .is_some_and(|tools| tools.contains(&json!("builtins")))
                && definition["deployability"]["command"] == "rkat mob run"
                && definition["provenance"]["source"] == "mobkit/authoring-agent-definitions"
        }));
        for definition in &sample_agent_definitions {
            let source_mobpack = definition["sourceMobpack"]
                .as_str()
                .expect("agent definition source mobpack");
            let role = definition["role"].as_str().expect("agent definition role");
            let member = sample_members
                .get(&(source_mobpack.to_string(), role.to_string()))
                .expect("agent definition must be projected from a real sample mobpack member");
            assert_eq!(definition["tools"], member["tools"]);
            assert_eq!(definition["skills"], member["skills"]);
            assert_eq!(definition["model"], member["model"]);
            assert_eq!(definition["profileBinding"], member["profileBinding"]);
            assert_eq!(definition["runtimeMode"], member["runtimeMode"]);
            assert_eq!(definition["systemPrompt"], member["systemPrompt"]);
        }
        for ((source_mobpack, role), member) in &sample_members {
            if member["profileBinding"].as_str().is_some()
                && member["runtimeMode"].as_str().is_some()
                && member["model"].as_str().is_some()
            {
                assert!(
                    sample_agent_definitions.iter().any(|definition| {
                        definition["sourceMobpack"] == json!(source_mobpack)
                            && definition["role"] == json!(role)
                    }),
                    "missing agent definition for {source_mobpack}:{role}"
                );
            }
        }
        assert!(agent_definitions.iter().any(|definition| {
            definition["role"] == "reviewer"
                && definition["schema"] == "ReviewerOutput"
                && definition["schemaDefinition"]["id"] == "ReviewerOutput"
                && definition["schemaDefinition"]["fields"]
                    .as_array()
                    .is_some_and(|fields| !fields.is_empty())
        }));
        if let Ok(path) = std::env::var("MOBKIT_MOBPACK_SCHEMA_OUT") {
            std::fs::write(path, serde_json::to_vec_pretty(&schema).unwrap())
                .expect("write schema response fixture");
        }
    }

    #[test]
    fn discovers_configured_tool_sources_without_mock_catalog() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("mcp.toml");
        std::fs::write(
            &path,
            r#"
[[servers]]
name = "linear"
command = "npx"

[[servers]]
name = "github"
url = "https://example.invalid/mcp"
"#,
        )
        .expect("write mcp config");

        let sources = mcp_sources_from_toml(&path);
        assert!(sources.contains("linear"));
        assert!(sources.contains("github"));
        assert_eq!(sources.len(), 2);

        let env_sources = split_env_list("custom-a, custom-b:custom-c; custom-a");
        assert!(env_sources.contains("custom-a"));
        assert!(env_sources.contains("custom-b"));
        assert!(env_sources.contains("custom-c"));
        assert_eq!(env_sources.len(), 3);
    }

    #[test]
    fn discovers_meerkat_mob_registry_skill_dirs_without_pinned_version() {
        let dir = tempfile::tempdir().expect("tempdir");
        let registry = dir.path().join("index.crates.io-abc");
        std::fs::create_dir_all(registry.join("meerkat-mob-0.6.34/skills"))
            .expect("old skills dir");
        std::fs::create_dir_all(registry.join("meerkat-mob-0.6.58/skills"))
            .expect("new skills dir");
        std::fs::create_dir_all(registry.join("meerkat-mob-mcp-0.6.58/skills"))
            .expect("adjacent crate skills dir");
        std::fs::create_dir_all(registry.join("meerkat-mob-not-a-version/skills"))
            .expect("non-version skills dir");

        let dirs = meerkat_mob_registry_skill_dirs(dir.path());
        let ids = dirs
            .iter()
            .map(|(id, _, _)| id.as_str())
            .collect::<Vec<_>>();
        assert_eq!(
            ids,
            vec!["meerkat-mob/crate/0.6.34", "meerkat-mob/crate/0.6.58"]
        );
        assert!(dirs.iter().all(|(_, _, path)| path.ends_with("skills")));
    }

    #[test]
    fn imports_raw_mob_toml_into_editor_projection() {
        let result = import_mobpack(&json!({ "mob_toml": importable_mob_toml() })).expect("import");
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        assert_eq!(document.mob_id, "importable-real-mob");
        assert!(
            document
                .mob_toml
                .as_deref()
                .unwrap_or("")
                .contains("[flows.main]")
        );
        let members = document.members.as_array().expect("members projected");
        assert_eq!(members.len(), 2);
        assert_eq!(document.mob_settings["orchestrator"], json!("planner"));
        assert_eq!(document.mob_settings["autoWireOrchestrator"], json!(true));
        assert_eq!(
            document.mob_settings["roleWiring"],
            json!([{ "a": "planner", "b": "reviewer" }])
        );
        assert_eq!(document.mob_settings["backendDefault"], json!("external"));
        assert_eq!(
            document.mob_settings["externalAddressBase"],
            json!("http://127.0.0.1:9000")
        );
        assert_eq!(
            document.mob_settings["advanced"]["topology"]["mode"],
            json!("strict")
        );
        assert_eq!(
            document.mob_settings["advanced"]["supervisor"]["role"],
            json!("planner")
        );
        assert_eq!(
            document.mob_settings["advanced"]["limits"]["max_orphaned_turns"],
            json!(8)
        );
        assert_eq!(
            document.mob_settings["advanced"]["spawnPolicy"]["profile_map"]["reviewer"],
            json!("reviewer")
        );
        assert_eq!(
            document.mob_settings["advanced"]["eventRouter"]["buffer_size"],
            json!(128)
        );
        assert!(members.iter().any(|member| {
            member["id"] == "m_reviewer"
                && member["runtimeMode"] == "turn_driven"
                && member["tools"]
                    .as_array()
                    .unwrap()
                    .contains(&json!("shell"))
        }));
        assert!(members.iter().any(|member| {
            member["id"] == "m_planner"
                && member["backend"] == "session"
                && member["maxInlinePeerNotifications"] == 20
                && member["providerParams"]
                    == json!({ "thinking_budget_tokens": 8192, "top_p": 0.5 })
        }));
        let plan_step = document.flow["steps"]
            .as_array()
            .and_then(|steps| steps.iter().find(|step| step["id"] == "plan"))
            .expect("plan step projected");
        assert_eq!(plan_step["timeoutMs"], json!(1500));
        assert_eq!(plan_step["allowedTools"], json!(["mob"]));
        assert_eq!(plan_step["blockedTools"], json!(["shell"]));
        assert_eq!(plan_step["outputFormat"], json!("text"));
        assert_eq!(plan_step["dispatchMode"], json!("one_to_one"));
        assert_eq!(plan_step["collection"], json!("quorum"));
        assert_eq!(plan_step["quorum"], json!(1));
        let schemas = document.schemas.as_array().expect("schemas projected");
        assert_eq!(schemas[0]["id"], "ReviewerOutput");
        assert_eq!(schemas[0]["fields"][0]["type"], "enum");
        let skill_realms = document
            .skill_realms
            .as_array()
            .expect("skill realms projected");
        assert!(
            skill_realms[0]["skills"]
                .as_array()
                .unwrap()
                .iter()
                .any(|skill| skill["id"] == "mob.review" && skill["source"] == "path")
        );
        let flow = document.flow.as_object().expect("flow projected");
        assert_eq!(flow["name"], "main");
        assert!(
            flow["steps"]
                .as_array()
                .unwrap()
                .iter()
                .any(|step| step["type"] == "repeat")
        );
        let repeat = flow["steps"]
            .as_array()
            .unwrap()
            .iter()
            .find(|step| step["type"] == "repeat")
            .expect("repeat step projected");
        assert_eq!(repeat["iterationInput"], Value::Null);
        let validation = result["validation"].as_object().expect("validation");
        assert_eq!(validation["ok"], true);
    }

    #[test]
    fn rejects_raw_mob_toml_with_multiple_flows_instead_of_silently_truncating() {
        let toml = r#"
[mob]
id = "multi-flow-import"

[profiles.planner]
model = "gpt-5.5"

[flows.main]
description = "Main flow"

[flows.main.steps.plan]
role = "planner"
message = "Plan"

[flows.audit]
description = "Audit flow"

[flows.audit.steps.audit]
role = "planner"
message = "Audit"
"#;
        let err = import_mobpack(&json!({ "mob_toml": toml })).expect_err("multi-flow import");
        assert!(err.contains("2 flows"), "{err}");
        assert!(err.contains("supports exactly one flow"), "{err}");
    }

    #[test]
    fn import_projection_does_not_materialize_default_json_output_format() {
        let toml = r#"
[mob]
id = "default-output-import"

[profiles.planner]
model = "gpt-5.5"

[flows.main]
description = "Do not materialize parser defaults"

[flows.main.steps.plan]
role = "planner"
message = "Plan without output format"
"#;
        let result = import_mobpack(&json!({ "mob_toml": toml })).expect("import");
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        let plan_step = document.flow["steps"]
            .as_array()
            .and_then(|steps| steps.iter().find(|step| step["id"] == "plan"))
            .expect("plan step projected");
        assert_eq!(plan_step["outputFormat"], Value::Null);
    }

    #[test]
    fn import_projection_uses_editor_input_step_draft_for_missing_flow_description() {
        let toml = r#"
[mob]
id = "missing-flow-description"

[profiles.planner]
model = "gpt-5.5"

[flows.main.steps.plan]
role = "planner"
message = "Plan without a flow description"
"#;
        let result = import_mobpack(&json!({ "mob_toml": toml })).expect("import");
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        let input_step = document.flow["steps"]
            .as_array()
            .and_then(|steps| steps.iter().find(|step| step["type"] == "input"))
            .expect("input step projected");
        assert_eq!(
            input_step["task"],
            editor_input_step_draft_contract()["default_step"]["task"]
        );
    }

    #[test]
    fn imports_repeat_gt_condition_into_basic_projection() {
        let toml = importable_mob_toml().replace(
            r#"until = { op = "eq", path = "steps.review.verdict", value = "green" }"#,
            r#"until = { op = "gt", path = "steps.review.score", value = 90 }"#,
        );
        let result = import_mobpack(&json!({ "mob_toml": toml })).expect("import");
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        let repeat = document
            .flow
            .get("steps")
            .and_then(Value::as_array)
            .unwrap()
            .iter()
            .find(|step| step["type"] == "repeat")
            .expect("repeat projected");

        assert_eq!(repeat["cond"]["op"], ">");
        assert_eq!(repeat["cond"]["field"], "score");
        assert_eq!(repeat["cond"]["val"], "90");
    }

    #[test]
    fn imports_parallel_frame_into_basic_parallel_projection() {
        let toml = r#"
[mob]
id = "parallel-import"

[profiles.coder]
model = "gpt-5.5"

[profiles.reviewer]
model = "gpt-5.5"

[flows.main]
description = "Parallel import"

[flows.main.steps.code]
role = "coder"
message = "Implement"

[flows.main.steps.review]
role = "reviewer"
message = "Review"

[flows.main.root.nodes.node_code]
kind = "step"
step_id = "code"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_review]
kind = "step"
step_id = "review"
depends_on = []
depends_on_mode = "all"
"#;
        let result = import_mobpack(&json!({ "mob_toml": toml })).expect("import");
        assert_eq!(result["validation"]["ok"], true);
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        let parallel = document
            .flow
            .get("steps")
            .and_then(Value::as_array)
            .unwrap()
            .iter()
            .find(|step| step["type"] == "parallel")
            .expect("parallel projected");

        assert_eq!(parallel["collection"], json!("all"));
        assert_eq!(parallel["dispatch"], json!("fan_out"));
        assert_eq!(parallel["branches"].as_array().unwrap().len(), 2);
        assert!(
            parallel["branches"]
                .as_array()
                .unwrap()
                .iter()
                .any(|branch| branch["steps"][0]["role"] == "m_coder")
        );
        assert!(
            parallel["branches"]
                .as_array()
                .unwrap()
                .iter()
                .any(|branch| branch["steps"][0]["role"] == "m_reviewer")
        );
    }

    #[test]
    fn imports_mobpack_archive_without_editor_json_into_projection() {
        let mob_toml = importable_mob_toml();
        let encoder = GzEncoder::new(Vec::new(), Compression::default());
        let mut archive = Builder::new(encoder);
        append_archive_file(
            &mut archive,
            "manifest.toml",
            b"surfaces = [\"cli\"]\n\n[mobpack]\nname = \"imported\"\n",
        )
        .expect("manifest");
        append_archive_file(&mut archive, "mobkit/mob.toml", mob_toml.as_bytes())
            .expect("mob toml");
        let encoder = archive.into_inner().expect("archive");
        let bytes = encoder.finish().expect("gzip");
        let result = import_mobpack(&json!({
            "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes)
        }))
        .expect("import archive");
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        assert_eq!(document.mob_id, "importable-real-mob");
        assert!(document.members.as_array().unwrap().len() >= 2);
        assert!(document.instances.as_array().unwrap().len() >= 2);
        assert!(
            document
                .edges
                .as_array()
                .unwrap()
                .iter()
                .any(|edge| edge["kind"] == "cond")
        );
    }

    #[test]
    fn imports_branch_flow_into_basic_branch_projection() {
        let toml = r#"
[mob]
id = "branch-import"

[profiles.router]
model = "gpt-5.5"

[profiles.worker]
model = "gpt-5.5"

[flows.main]
description = "Branch import"

[flows.main.steps.route]
role = "router"
message = "Route the work"

[flows.main.steps.left]
role = "worker"
message = "Handle left"
depends_on = ["route"]
branch = "choice"
condition = { op = "eq", path = "params.choice", value = "left" }

[flows.main.steps.right]
role = "worker"
message = "Handle right"
depends_on = ["route"]
branch = "choice"
condition = { op = "eq", path = "params.choice", value = "right" }

[flows.main.root.nodes.node_route]
kind = "step"
step_id = "route"
depends_on = []
depends_on_mode = "all"

[flows.main.root.nodes.node_left]
kind = "step"
step_id = "left"
depends_on = ["node_route"]
depends_on_mode = "all"
branch = "choice"

[flows.main.root.nodes.node_right]
kind = "step"
step_id = "right"
depends_on = ["node_route"]
depends_on_mode = "all"
branch = "choice"
"#;
        let result = import_mobpack(&json!({ "mob_toml": toml })).expect("import");
        assert_eq!(result["validation"]["ok"], true);
        let document: MobpackDocument =
            serde_json::from_value(result["document"].clone()).expect("document");
        let steps = document.flow["steps"].as_array().expect("flow steps");
        let input_params = steps[0]["inputParams"].as_array().expect("input params");
        assert!(input_params.iter().any(|param| param["name"] == "choice"));
        let branch = steps
            .iter()
            .find(|step| step["type"] == "branch")
            .expect("branch projected");
        assert_eq!(branch["controllerRole"], json!("m_router"));
        assert_eq!(branch["branches"].as_array().unwrap().len(), 2);
        assert!(
            branch["branches"]
                .as_array()
                .unwrap()
                .iter()
                .any(|branch| branch["condition"] == "params.choice == left")
        );
    }
}
