Skip to main content

warble_claude_code/
session.rs

1//! Pure producer for host-owned sessions. Compatibility is not execution authority.
2//!
3//! This format preserves step boundaries; it neither launches a vendor nor flattens a
4//! component into a conversational prompt. A host must implement every emitted requirement.
5
6use crate::ir::{
7    ComponentNode, ComponentType, OutcomeKind, RealizationKind, TriggerKind, SUPPORTED_IR_VERSION,
8};
9use crate::slots::{assert_no_slot_references, resolve_ir_json, SlotSupply};
10use crate::DispatchError;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14use std::collections::{BTreeMap, BTreeSet};
15
16/// Producer format version, independent of the IR and package versions.
17pub const SESSION_PLAN_VERSION: &str = "1";
18/// Explicit composed plan format. Legacy hosts must not execute it.
19pub const COMPOSED_SESSION_PLAN_VERSION: &str = "2";
20pub const COMPONENT_HOST_PROTOCOL: &str = "warble-component-host/1";
21pub const COMPOSED_EXECUTION: &[&str] = &[
22    "immutable_step_authority",
23    "isolated_component_tools",
24    "fresh_child_context",
25    "verified_context_preconditions",
26    "component_owned_bindings",
27    "exact_step_tiers",
28    "exact_dataflow",
29    "bounded_repair",
30    "shared_admission_ledger",
31    "deadline_and_descendant_cancellation",
32    "normalized_child_results",
33    "root_only_persistence",
34    "redacted_usage_trace",
35];
36/// Maximum size of either input and the compact output, in bytes.
37pub const MAX_INPUT_BYTES: usize = 4 * 1024 * 1024;
38
39#[derive(Debug, Deserialize, Serialize)]
40#[serde(deny_unknown_fields)]
41struct HostContract {
42    version: String,
43    tiers: Vec<String>,
44    capabilities: BTreeMap<String, HostCapability>,
45    guardrails: Vec<Guardrail>,
46    execution: Vec<String>,
47}
48
49#[derive(Debug, Deserialize, Serialize)]
50#[serde(deny_unknown_fields)]
51struct HostCapability {
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    tool: Option<String>,
54}
55
56#[derive(Debug, Deserialize, Serialize, PartialEq)]
57#[serde(deny_unknown_fields)]
58struct Guardrail {
59    name: String,
60    locked: bool,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    scope: Option<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    threshold: Option<Value>,
65}
66
67#[derive(Debug, Deserialize, Serialize)]
68#[serde(deny_unknown_fields)]
69struct When {
70    guard: String,
71    target: String,
72}
73
74#[derive(Debug, Deserialize)]
75#[serde(deny_unknown_fields)]
76struct Step {
77    name: String,
78    tier: String,
79    consumes: Vec<String>,
80    produces: Option<String>,
81    prompt: String,
82    conditional: bool,
83    when: Option<When>,
84    #[serde(default)]
85    capabilities: Option<Vec<String>>,
86    #[serde(default)]
87    produces_exclusive: bool,
88    #[serde(default)]
89    component_calls: Vec<Value>,
90}
91
92fn fail(message: &str) -> DispatchError {
93    DispatchError::new(format!("direct-session producer: {message} (wall-hit)"))
94}
95
96fn parse<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, DispatchError> {
97    serde_json::from_value(value).map_err(|_| fail("malformed or unsupported contract fields"))
98}
99
100// Value normally accepts duplicate keys. Check recursively *before* projecting away
101// unselected components or deserializing a map so authority cannot depend on parser order.
102struct UniqueJson(Value);
103
104impl<'de> Deserialize<'de> for UniqueJson {
105    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
106        struct Visitor;
107        impl<'de> serde::de::Visitor<'de> for Visitor {
108            type Value = UniqueJson;
109            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
110                f.write_str("JSON with unique object keys")
111            }
112            fn visit_map<M: serde::de::MapAccess<'de>>(
113                self,
114                mut map: M,
115            ) -> Result<Self::Value, M::Error> {
116                let mut object = serde_json::Map::new();
117                while let Some((key, value)) = map.next_entry::<String, UniqueJson>()? {
118                    if object.insert(key, value.0).is_some() {
119                        return Err(serde::de::Error::custom("duplicate JSON key"));
120                    }
121                }
122                Ok(UniqueJson(Value::Object(object)))
123            }
124            fn visit_seq<S: serde::de::SeqAccess<'de>>(
125                self,
126                mut seq: S,
127            ) -> Result<Self::Value, S::Error> {
128                let mut values = Vec::new();
129                while let Some(value) = seq.next_element::<UniqueJson>()? {
130                    values.push(value.0);
131                }
132                Ok(UniqueJson(Value::Array(values)))
133            }
134            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
135                Ok(UniqueJson(json!(v)))
136            }
137            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> {
138                Ok(UniqueJson(json!(v)))
139            }
140            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
141                Ok(UniqueJson(json!(v)))
142            }
143            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
144                Ok(UniqueJson(json!(v)))
145            }
146            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
147                Ok(UniqueJson(json!(v)))
148            }
149            fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
150                Ok(UniqueJson(Value::Null))
151            }
152        }
153        deserializer.deserialize_any(Visitor)
154    }
155}
156
157pub(crate) fn unique_json(raw: &str) -> Result<Value, DispatchError> {
158    serde_json::from_str::<UniqueJson>(raw)
159        .map(|v| v.0)
160        .map_err(|_| fail("invalid JSON or duplicate object key"))
161}
162
163fn validate_slots(raw: &Value, selected: &Value, supply: &SlotSupply) -> Result<(), DispatchError> {
164    let mut names = BTreeSet::new();
165    let mut largest_variant = 0;
166    for owner in [raw, selected] {
167        if let Some(declarations) = owner.get("slots") {
168            let declarations = declarations
169                .as_array()
170                .ok_or_else(|| fail("invalid slots"))?;
171            if declarations.len() > 128 {
172                return Err(fail("too many slots"));
173            }
174            for slot in declarations {
175                keys(slot, &["name", "default", "variants", "present_when"])?;
176                let name = slot["name"]
177                    .as_str()
178                    .ok_or_else(|| fail("invalid slot name"))?;
179                if name.is_empty()
180                    || !name
181                        .bytes()
182                        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
183                    || name.as_bytes()[0].is_ascii_digit()
184                    || !names.insert(name)
185                {
186                    return Err(fail("invalid or duplicate slot name"));
187                }
188                let variants = slot["variants"]
189                    .as_object()
190                    .ok_or_else(|| fail("invalid variants"))?;
191                for text in variants.values() {
192                    let text = text.as_str().ok_or_else(|| fail("invalid variant text"))?;
193                    largest_variant = largest_variant.max(text.len());
194                    // No recursive expansion: a bounded input must not expand exponentially.
195                    assert_no_slot_references(text, "nested slot variant")?;
196                }
197            }
198        }
199    }
200    if supply.keys().any(|name| !names.contains(name.as_str())) {
201        return Err(fail("supplied slot is not in selected scope"));
202    }
203    let serialized = selected.to_string();
204    let references = serialized.matches("{{").count();
205    if references
206        .saturating_mul(largest_variant)
207        .saturating_add(serialized.len())
208        > MAX_INPUT_BYTES
209    {
210        return Err(fail("slot expansion exceeds size limit"));
211    }
212    Ok(())
213}
214
215fn keys(value: &Value, allowed: &[&str]) -> Result<(), DispatchError> {
216    let object = value
217        .as_object()
218        .ok_or_else(|| fail("expected an object"))?;
219    if object.keys().any(|key| !allowed.contains(&key.as_str())) {
220        return Err(fail("unknown IR field; cannot silently discard semantics"));
221    }
222    Ok(())
223}
224
225fn unique(values: &[String]) -> bool {
226    values.iter().all(|v| !v.trim().is_empty())
227        && values.iter().collect::<BTreeSet<_>>().len() == values.len()
228}
229
230fn tool_name(value: &str) -> bool {
231    !value.is_empty()
232        && value.len() <= 64
233        && value
234            .bytes()
235            .all(|b| b.is_ascii_alphanumeric() || b == b'_')
236        && value.as_bytes()[0].is_ascii_alphabetic()
237}
238
239fn digest(bytes: &[u8]) -> String {
240    format!("sha256:{:x}", Sha256::digest(bytes))
241}
242
243fn capability_kind(capability: &str) -> Option<bool> {
244    match capability {
245        "semantic_introspection" | "sql_execution:read_only" | "artifact_write" => Some(true),
246        "render_contract" | "structured_output_capture" => Some(false),
247        _ => None,
248    }
249}
250
251fn identity(value: &str) -> bool {
252    !matches!(value, "__proto__" | "prototype" | "constructor")
253        && value.len() <= 128
254        && value
255            .bytes()
256            .next()
257            .is_some_and(|b| b.is_ascii_lowercase() || b == b'_')
258        && value
259            .bytes()
260            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
261}
262
263fn validate_compiled_parameters(node: &Value) -> Result<(), DispatchError> {
264    let mut names = BTreeSet::new();
265    if let Some(params) = node.get("params") {
266        for param in params
267            .as_array()
268            .ok_or_else(|| fail("invalid parameters"))?
269        {
270            keys(param, &["name", "bind", "default"])?;
271            let name = param["name"]
272                .as_str()
273                .filter(|v| identity(v))
274                .ok_or_else(|| fail("invalid parameter name"))?;
275            if !names.insert(name)
276                || !matches!(param["bind"].as_str(), Some("optional" | "required"))
277                || node.get("binds").and_then(|b| b.get(name)).is_none()
278            {
279                return Err(fail("parameter must have a compiled binding"));
280            }
281        }
282    }
283    if let Some(binds) = node.get("binds") {
284        if binds.is_null() && names.is_empty() {
285            return Ok(());
286        }
287        if binds
288            .as_object()
289            .ok_or_else(|| fail("invalid bindings"))?
290            .keys()
291            .any(|key| !names.contains(key.as_str()))
292        {
293            return Err(fail("unknown parameter binding"));
294        }
295    }
296    Ok(())
297}
298
299fn validate_composed_context(node: &Value) -> Result<(), DispatchError> {
300    let empty = vec![];
301    let conditions = match node.get("context_precondition") {
302        Some(v) => v
303            .as_array()
304            .ok_or_else(|| fail("invalid context predicates"))?,
305        None => &empty,
306    };
307    let checks = node["precondition_result"]["checks"]
308        .as_array()
309        .ok_or_else(|| fail("missing compiled context checks"))?;
310    if checks.len() != conditions.len() {
311        return Err(fail("incomplete compiled context checks"));
312    }
313    for (condition, check) in conditions.iter().zip(checks) {
314        let predicate = condition["predicate"]
315            .as_str()
316            .ok_or_else(|| fail("invalid predicate"))?;
317        if ![
318            "mdl_parseable",
319            "has_metric",
320            "has_queryable_dimension",
321            "has_time_dimension",
322            "has_groupable_dimension",
323            "metric_additive",
324            "model_has_timestamp",
325            "lineage_resolvable",
326            "wren_project_exists",
327            "source_introspectable",
328            "raw_docs_readable",
329        ]
330        .contains(&predicate)
331            || check["predicate"] != predicate
332            || check["outcome"] != "pass"
333        {
334            return Err(fail("unknown or mismatched context check"));
335        }
336        if let Some(args) = condition.get("args") {
337            for (name, value) in args
338                .as_object()
339                .ok_or_else(|| fail("invalid predicate arguments"))?
340            {
341                if value.as_str().is_some_and(|v| v.starts_with("$param:")) {
342                    return Err(fail("unresolved predicate argument"));
343                }
344                if matches!(
345                    (predicate, name.as_str()),
346                    ("metric_additive", "metric") | ("model_has_timestamp", "model")
347                ) && value.as_str().is_none_or(|v| v.trim().is_empty())
348                {
349                    return Err(fail("invalid predicate selector"));
350                }
351            }
352        }
353    }
354    Ok(())
355}
356
357fn validate_calls(
358    calls: &[Value],
359    caps: &[String],
360    tools: &BTreeSet<&String>,
361) -> Result<(), DispatchError> {
362    if !calls.is_empty() && !caps.iter().any(|cap| cap == "component_invocation") {
363        return Err(fail("step lacks component invocation authority"));
364    }
365    let mut aliases = BTreeSet::new();
366    for edge in calls {
367        keys(edge, &["alias", "component"])?;
368        let alias = edge["alias"]
369            .as_str()
370            .filter(|v| identity(v))
371            .ok_or_else(|| fail("invalid call alias"))?;
372        if !aliases.insert(alias)
373            || tools.iter().any(|tool| tool.as_str() == alias)
374            || edge["component"].as_str().is_none_or(|v| !identity(v))
375        {
376            return Err(fail("invalid call target or ambiguous alias"));
377        }
378    }
379    Ok(())
380}
381
382#[derive(Deserialize)]
383#[serde(deny_unknown_fields)]
384struct ComposedHost {
385    version: String,
386    protocol: String,
387    execution: Vec<String>,
388    /// Independently selected binding and exact guardrail requirements for each reachable node.
389    components: BTreeMap<String, HostContract>,
390}
391
392fn visit_composed<'a>(
393    id: &'a str,
394    nodes: &BTreeMap<&'a str, &'a Value>,
395    active: &mut Vec<&'a str>,
396    reachable: &mut BTreeSet<&'a str>,
397    heights: &mut BTreeMap<&'a str, usize>,
398) -> Result<usize, DispatchError> {
399    if active.contains(&id) {
400        return Err(fail("cyclic component invocation"));
401    }
402    if let Some(height) = heights.get(id) {
403        if active.len() + height > 8 {
404            return Err(fail("component depth exceeds limit"));
405        }
406        return Ok(*height);
407    }
408    if active.len() > 8 {
409        return Err(fail("component depth exceeds limit"));
410    }
411    let node = nodes
412        .get(id)
413        .ok_or_else(|| fail("missing mounted callee"))?;
414    active.push(id);
415    let mut height = 0;
416    for step in node["llm_calls"]
417        .as_array()
418        .ok_or_else(|| fail("invalid steps"))?
419    {
420        if let Some(calls) = step.get("component_calls") {
421            for call in calls
422                .as_array()
423                .ok_or_else(|| fail("invalid component calls"))?
424            {
425                let child = call["component"]
426                    .as_str()
427                    .ok_or_else(|| fail("invalid callee"))?;
428                height = height.max(1 + visit_composed(child, nodes, active, reachable, heights)?);
429            }
430        }
431    }
432    active.pop();
433    reachable.insert(id);
434    heights.insert(id, height);
435    Ok(height)
436}
437
438fn produce_composed_session(
439    raw_ir: &str,
440    raw_host: &str,
441    component: &str,
442    slots: &SlotSupply,
443) -> Result<Value, DispatchError> {
444    // Slots across multiple components require a namespaced supply contract. Never silently
445    // apply one component's supplied wording to another component's instructions.
446    if !slots.is_empty() {
447        return Err(fail("composed plans do not support slot supply"));
448    }
449    let host: ComposedHost = parse(unique_json(raw_host)?)?;
450    if host.version != COMPOSED_SESSION_PLAN_VERSION
451        || host.protocol != COMPONENT_HOST_PROTOCOL
452        || host.execution
453            != COMPOSED_EXECUTION
454                .iter()
455                .map(|s| s.to_string())
456                .collect::<Vec<_>>()
457    {
458        return Err(fail(
459            "unsupported composed host protocol or execution requirements",
460        ));
461    }
462    let raw = unique_json(raw_ir)?;
463    let mut nodes = BTreeMap::new();
464    let components = raw["components"]
465        .as_array()
466        .ok_or_else(|| fail("invalid component inventory"))?;
467    if components.len() > 128 {
468        return Err(fail("component inventory exceeds limit"));
469    }
470    for node in components {
471        let id = node["id"]
472            .as_str()
473            .filter(|v| identity(v))
474            .ok_or_else(|| fail("invalid component identity"))?;
475        if nodes.insert(id, node).is_some() {
476            return Err(fail("duplicate component identity"));
477        }
478    }
479    if nodes
480        .get(component)
481        .is_none_or(|node| node["entrypoint"] != true)
482    {
483        return Err(fail("root must be an advertised entry"));
484    }
485    let mut reachable = BTreeSet::new();
486    visit_composed(
487        component,
488        &nodes,
489        &mut vec![],
490        &mut reachable,
491        &mut BTreeMap::new(),
492    )?;
493    if host
494        .components
495        .keys()
496        .map(String::as_str)
497        .collect::<BTreeSet<_>>()
498        != reachable
499    {
500        return Err(fail(
501            "host bindings must exactly match the selected closure",
502        ));
503    }
504    let mut plans = BTreeMap::new();
505    for id in reachable {
506        let node = nodes[id];
507        for owner in [&raw, node] {
508            if owner
509                .get("slots")
510                .is_some_and(|v| !v.as_array().is_some_and(Vec::is_empty))
511            {
512                return Err(fail("composed slots are unsupported"));
513            }
514        }
515        let plan = produce_component(
516            raw_ir,
517            &serde_json::to_string(&host.components[id]).map_err(|_| fail("host serialization"))?,
518            id,
519            slots,
520            true,
521        )?;
522        if id != component
523            && (!plan["borrowed_actions"]
524                .as_array()
525                .is_some_and(Vec::is_empty)
526                || plan["required_capabilities"]
527                    .as_array()
528                    .is_some_and(|caps| caps.iter().any(|cap| cap == "artifact_write"))
529                || plan["guardrails"]
530                    .as_array()
531                    .is_some_and(|guards| guards.iter().any(|g| g["name"] == "artifact_write")))
532        {
533            return Err(fail("callee persistence/action authority is unsupported"));
534        }
535        plans.insert(id, plan);
536    }
537    let mut plan = json!({
538        "session_plan_version": COMPOSED_SESSION_PLAN_VERSION, "producer_version": env!("CARGO_PKG_VERSION"),
539        "warble_ir_version": SUPPORTED_IR_VERSION, "protocol": COMPONENT_HOST_PROTOCOL,
540        "authority": "host_owned", "execution_status": "not_executed",
541        "input_ir_sha256": digest(raw_ir.as_bytes()), "host_contract_sha256": digest(raw_host.as_bytes()),
542        "profile": raw["profile"], "entry": component, "components": plans,
543        "context_binding": raw["context_binding"], "system_prompt": raw.get("system_prompt"),
544        "required_execution": host.execution,
545        "limits": {"max_depth": 8, "max_attempts": 32, "max_steps": 40, "max_steps_per_child": 12,
546            "max_in_flight": 1, "max_request_bytes": 65536, "max_result_bytes": 1048576, "timeout_ms": 120000},
547        "model_turn_hard_limit": false, "monetary_hard_limit": false,
548    });
549    plan["plan_sha256"] = json!(digest(plan.to_string().as_bytes()));
550    if plan.to_string().len() > MAX_INPUT_BYTES {
551        return Err(fail("output exceeds size limit"));
552    }
553    Ok(plan)
554}
555
556fn validate_guardrail(guard: &Guardrail, composed: bool) -> Result<(), DispatchError> {
557    let valid = match guard.name.as_str() {
558        "additivity_guard" if composed => guard.scope.is_none() && guard.threshold.is_none(),
559        "read_only_execution" | "deterministic_gate" => {
560            guard.scope.is_none() && guard.threshold.is_none()
561        }
562        "row_limit" | "statement_timeout" | "drill_depth_limit" => {
563            if guard.name == "drill_depth_limit" && !composed {
564                return Err(fail("unsupported guardrail"));
565            }
566            guard.scope.is_none()
567                && guard
568                    .threshold
569                    .as_ref()
570                    .and_then(Value::as_u64)
571                    .is_some_and(|n| n > 0)
572        }
573        // A logical artifact scope, not a vendor filesystem grant.
574        "artifact_write" => {
575            guard.threshold.is_none()
576                && guard.scope.as_deref().is_some_and(|s| {
577                    !s.is_empty()
578                        && !s.starts_with('/')
579                        && !s.contains('\\')
580                        && s.split('/').all(|part| !part.is_empty() && part != "..")
581                        && !s.chars().any(char::is_control)
582                })
583        }
584        _ => false,
585    };
586    if !valid {
587        return Err(fail("unsupported guardrail or threshold/scope shape"));
588    }
589    Ok(())
590}
591
592fn validate_host(host: &HostContract, composed: bool) -> Result<(), DispatchError> {
593    if host.version
594        != if composed {
595            COMPOSED_SESSION_PLAN_VERSION
596        } else {
597            SESSION_PLAN_VERSION
598        }
599        || !unique(&host.tiers)
600        || !unique(&host.execution)
601    {
602        return Err(fail(
603            "unsupported host version or duplicate/empty declarations",
604        ));
605    }
606    let features = [
607        "ordered_steps",
608        "isolated_step_tools",
609        "artifact_provenance",
610        "per_step_tiers",
611        "bounded_repair",
612        "render_contract",
613    ];
614    if host
615        .execution
616        .iter()
617        .any(|s| !features.contains(&s.as_str()))
618    {
619        return Err(fail("unknown host execution feature"));
620    }
621    let mut tools = BTreeSet::new();
622    for (capability, binding) in &host.capabilities {
623        let kind = if composed
624            && matches!(
625                capability.as_str(),
626                "artifact_write" | "component_invocation"
627            ) {
628            Some(false)
629        } else {
630            capability_kind(capability)
631        };
632        match (kind, binding.tool.as_deref()) {
633            (Some(true), Some(name)) if tool_name(name) && tools.insert(name) => {}
634            (Some(false), None) => {}
635            _ => {
636                return Err(fail(
637                    "unsupported capability/tool binding or aliased tool authority",
638                ))
639            }
640        }
641    }
642    let mut guards = BTreeSet::new();
643    for guard in &host.guardrails {
644        validate_guardrail(guard, composed)?;
645        if !guards.insert(&guard.name) {
646            return Err(fail("duplicate host guardrail"));
647        }
648    }
649    Ok(())
650}
651
652/// Produce a bounded, deterministic JSON artifact using only caller-supplied bytes.
653///
654/// The host contract is an explicit implementation claim, NOT proof of runtime support,
655/// permission, readiness or certification. No files, network, environment or credentials
656/// are accessed. Unknown executable facets and unsupported component anatomy are rejected.
657pub fn produce_session(
658    raw_ir: &str,
659    raw_host: &str,
660    component: &str,
661    slots: &SlotSupply,
662) -> Result<Value, DispatchError> {
663    if raw_ir.len() > MAX_INPUT_BYTES || raw_host.len() > MAX_INPUT_BYTES {
664        return Err(fail("input exceeds size limit"));
665    }
666    let host_value = unique_json(raw_host)?;
667    if host_value["version"] == COMPOSED_SESSION_PLAN_VERSION {
668        return produce_composed_session(raw_ir, raw_host, component, slots);
669    }
670    produce_component(raw_ir, raw_host, component, slots, false)
671}
672
673fn produce_component(
674    raw_ir: &str,
675    raw_host: &str,
676    component: &str,
677    slots: &SlotSupply,
678    composed: bool,
679) -> Result<Value, DispatchError> {
680    let host: HostContract = parse(unique_json(raw_host)?)?;
681    validate_host(&host, composed)?;
682    let raw = unique_json(raw_ir)?;
683    let mut allowed = vec![
684        "warble_ir_version",
685        "profile",
686        "config",
687        "context_binding",
688        "slots",
689        "components",
690    ];
691    if composed {
692        allowed.extend(["system_prompt", "assets"]);
693    }
694    keys(&raw, &allowed)?;
695    if composed {
696        if raw
697            .get("assets")
698            .is_some_and(|v| !v.as_array().is_some_and(Vec::is_empty))
699        {
700            return Err(fail("profile assets are unsupported"));
701        }
702        if let Some(prompt) = raw.get("system_prompt") {
703            assert_no_slot_references(
704                prompt
705                    .as_str()
706                    .ok_or_else(|| fail("invalid system prompt"))?,
707                "system prompt",
708            )?;
709        }
710    }
711    if raw["warble_ir_version"] != SUPPORTED_IR_VERSION {
712        return Err(fail("unsupported IR version"));
713    }
714    let profile = raw["profile"]
715        .as_str()
716        .filter(|s| !s.is_empty())
717        .ok_or_else(|| fail("missing profile"))?;
718    keys(&raw["config"], &["capability_ceiling"])?;
719    keys(
720        &raw["context_binding"],
721        &["project", "binding_mode", "resolved"],
722    )?;
723    let components = raw["components"]
724        .as_array()
725        .ok_or_else(|| fail("missing components"))?;
726    let mut ids = BTreeSet::new();
727    for node in components {
728        let id = node["id"]
729            .as_str()
730            .filter(|s| !s.is_empty())
731            .ok_or_else(|| fail("missing component identity"))?;
732        if !ids.insert(id) {
733            return Err(fail("duplicate component identity"));
734        }
735    }
736    let selected = components
737        .iter()
738        .find(|node| node["id"] == component)
739        .ok_or_else(|| fail("unknown component"))?;
740    keys(
741        selected,
742        &[
743            "id",
744            "entrypoint",
745            "verb",
746            "type",
747            "realization_kind",
748            "context_binding",
749            "precondition_result",
750            "prompt_fragment",
751            "llm_calls",
752            "guardrails",
753            "trigger",
754            "required_capabilities",
755            "borrowed_actions",
756            "eval_ref",
757            "context_requirements",
758            "context_precondition",
759            "params",
760            "eval",
761            "brief",
762            "description",
763            "examples",
764            "slots",
765            "assets",
766            "binds",
767            "effect",
768        ],
769    )?;
770    for field in if composed {
771        vec!["assets"]
772    } else {
773        vec!["assets", "params"]
774    } {
775        if let Some(value) = selected.get(field) {
776            if !value.as_array().is_some_and(Vec::is_empty) {
777                return Err(fail("assets and runtime/bound params are not supported"));
778            }
779        }
780    }
781    if !composed
782        && selected
783            .get("binds")
784            .is_some_and(|v| !v.as_object().is_some_and(serde_json::Map::is_empty))
785    {
786        return Err(fail("bound parameter values are not supported"));
787    }
788    if composed {
789        validate_compiled_parameters(selected)?;
790    }
791    keys(&selected["trigger"], &["kind"])?;
792    keys(&selected["effect"], &["outcome", "render_blocks"])?;
793    keys(&selected["effect"]["outcome"], &["kind"])?;
794    keys(
795        &selected["context_binding"],
796        &["project", "binding_mode", "resolved"],
797    )?;
798    // The compiler carries resolved metadata at profile level, not on every mount.
799    if selected["context_binding"]["project"] != raw["context_binding"]["project"]
800        || selected["context_binding"]["binding_mode"] != raw["context_binding"]["binding_mode"]
801        || selected["context_binding"]
802            .get("resolved")
803            .is_some_and(|v| v != &raw["context_binding"]["resolved"])
804    {
805        return Err(fail("component context differs from profile"));
806    }
807    // Resolve slots only for the selected entry, not unrelated executable work.
808    validate_slots(&raw, selected, slots)?;
809    let mut projected = raw.clone();
810    projected["components"] = json!([selected]);
811    let resolved = resolve_ir_json(&projected.to_string(), slots)?;
812    if resolved.len() > MAX_INPUT_BYTES {
813        return Err(fail("resolved input exceeds size limit"));
814    }
815    let resolved: Value =
816        serde_json::from_str(&resolved).map_err(|_| fail("slot resolution failed"))?;
817    let selected = &resolved["components"][0];
818    let node: ComponentNode = parse(selected.clone())?;
819    if node.context_binding.project.is_empty()
820        || !["runtime_selected", "pinned"].contains(&node.context_binding.binding_mode.as_str())
821    {
822        return Err(fail("unsupported context binding"));
823    }
824    if !composed && !node.entrypoint {
825        return Err(fail("internal component is not a session entry"));
826    }
827    match (
828        node.realization_kind,
829        node.trigger.kind,
830        node.effect.outcome.kind,
831        node.component_type,
832    ) {
833        (
834            RealizationKind::Skill,
835            TriggerKind::OneShot,
836            OutcomeKind::None,
837            ComponentType::Analytical,
838        ) => {}
839        _ => return Err(fail("unsupported component anatomy")),
840    }
841    if composed {
842        validate_composed_context(selected)?;
843    }
844    keys(&selected["precondition_result"], &["status", "checks"])?;
845    if node.precondition_result.status != "pass"
846        || node
847            .precondition_result
848            .checks
849            .iter()
850            .any(|c| c.outcome != "pass")
851    {
852        return Err(fail("context precondition did not pass"));
853    }
854    for check in selected["precondition_result"]["checks"]
855        .as_array()
856        .into_iter()
857        .flatten()
858    {
859        keys(check, &["predicate", "outcome"])?;
860    }
861    for check in selected["context_precondition"]
862        .as_array()
863        .into_iter()
864        .flatten()
865    {
866        keys(check, &["predicate", "args"])?;
867    }
868    for block in selected["effect"]["render_blocks"]
869        .as_array()
870        .into_iter()
871        .flatten()
872    {
873        keys(block, &["type", "fields"])?;
874    }
875    assert_no_slot_references(&node.prompt_fragment, "component fragment")?;
876    if let Some(brief) = &node.brief {
877        assert_no_slot_references(brief, "component brief")?;
878    }
879    let guards: Vec<Guardrail> = parse(selected["guardrails"].clone())?;
880    let mut guard_names = BTreeSet::new();
881    for guard in &guards {
882        validate_guardrail(guard, composed)?;
883        if !guard_names.insert(&guard.name) || !host.guardrails.contains(guard) {
884            return Err(fail("missing exact host guardrail agreement"));
885        }
886    }
887    if !guards
888        .iter()
889        .any(|g| g.name == "read_only_execution" && g.locked)
890    {
891        return Err(fail("a locked read-only boundary is required"));
892    }
893    if host.guardrails.len() != guards.len() {
894        return Err(fail(
895            "host guardrails must exactly match selected requirements",
896        ));
897    }
898    if !unique(&node.required_capabilities) {
899        return Err(fail("duplicate or empty capability"));
900    }
901    if let Some(ceiling) = raw["config"].get("capability_ceiling") {
902        let ceiling: Vec<String> = parse(ceiling.clone())?;
903        if !unique(&ceiling)
904            || node
905                .required_capabilities
906                .iter()
907                .any(|c| !ceiling.contains(c))
908        {
909            return Err(fail("capability ceiling violated"));
910        }
911    }
912    for cap in &node.required_capabilities {
913        if cap == "llm:per_step_tier" {
914            continue;
915        }
916        if let Some(tier) = cap.strip_prefix("llm:") {
917            if !host.tiers.iter().any(|t| t == tier) {
918                return Err(fail("unavailable model tier"));
919            }
920        } else if (capability_kind(cap).is_none() && !(composed && cap == "component_invocation"))
921            || !host.capabilities.contains_key(cap)
922        {
923            return Err(fail("unavailable or unsupported capability"));
924        }
925    }
926    if node
927        .required_capabilities
928        .iter()
929        .any(|c| c == "artifact_write")
930        && !guards
931            .iter()
932            .any(|g| g.name == "artifact_write" && g.locked)
933    {
934        return Err(fail("artifact writing requires a locked scope"));
935    }
936    let mut required_host: BTreeSet<_> = node
937        .required_capabilities
938        .iter()
939        .filter(|c| !c.starts_with("llm:"))
940        .cloned()
941        .collect();
942    let steps: Vec<Step> = parse(selected["llm_calls"].clone())?;
943    if steps.is_empty() || steps.len() > 128 {
944        return Err(fail("invalid step count"));
945    }
946    let mut names = BTreeSet::new();
947    let mut products = BTreeSet::new();
948    let mut conditional_products = BTreeSet::new();
949    let mut requirements = BTreeSet::from([
950        "ordered_steps",
951        "isolated_step_tools",
952        "artifact_provenance",
953        "per_step_tiers",
954    ]);
955    if !node.effect.render_blocks.is_empty() {
956        requirements.insert("render_contract");
957        required_host.insert("render_contract".to_string());
958        if !host.capabilities.contains_key("render_contract") {
959            return Err(fail("render contract capability missing"));
960        }
961    }
962    let mut output_steps = Vec::new();
963    for (index, step) in steps.iter().enumerate() {
964        if selected["llm_calls"][index]
965            .get("capabilities")
966            .is_some_and(Value::is_null)
967        {
968            return Err(fail("step capabilities cannot be null"));
969        }
970        if step.name.is_empty()
971            || !names.insert(&step.name)
972            || step.prompt.is_empty()
973            || !unique(&step.consumes)
974        {
975            return Err(fail("invalid step identity, prompt or consumes"));
976        }
977        if !composed && !step.component_calls.is_empty() {
978            return Err(fail("component invocation is not supported"));
979        }
980        if step.produces_exclusive && step.produces.is_none() {
981            return Err(fail("exclusive provenance needs a product"));
982        }
983        if !host.tiers.contains(&step.tier)
984            || !node
985                .required_capabilities
986                .contains(&format!("llm:{}", step.tier))
987        {
988            return Err(fail("step tier is not declared and supported"));
989        }
990        assert_no_slot_references(&step.prompt, "step prompt")?;
991        let effective = step
992            .capabilities
993            .as_ref()
994            .unwrap_or(&node.required_capabilities);
995        if !unique(effective)
996            || effective
997                .iter()
998                .any(|c| !node.required_capabilities.contains(c))
999        {
1000            return Err(fail("step capability widens component authority"));
1001        }
1002        let tools: BTreeSet<_> = effective
1003            .iter()
1004            .filter_map(|c| host.capabilities.get(c)?.tool.as_ref())
1005            .collect();
1006        if composed {
1007            validate_calls(&step.component_calls, effective, &tools)?;
1008        }
1009        let realization = match (&step.when, step.conditional) {
1010            (None, false) => json!({"kind": "independent"}),
1011            (Some(when), true)
1012                if index > 0
1013                    && when.guard == "on_failure"
1014                    && when.target == steps[index - 1].name
1015                    && !steps[index - 1].conditional =>
1016            {
1017                if step.produces.is_none()
1018                    || steps[index - 1].produces.is_none()
1019                    || !step
1020                        .consumes
1021                        .contains(steps[index - 1].produces.as_ref().unwrap())
1022                {
1023                    return Err(fail(
1024                        "repair must consume the failed attempt and declare its own product",
1025                    ));
1026                }
1027                requirements.insert("bounded_repair");
1028                json!({"kind": "repair_fold", "fold_into": when.target, "max_attempts": 1,
1029                    "failure_input": steps[index - 1].produces, "on_exhaustion": "fail"})
1030            }
1031            _ => return Err(fail("unsupported conditional/guard shape")),
1032        };
1033        for consumed in &step.consumes {
1034            // A repair receives the failed attempt's typed result/error, not an invented product.
1035            if conditional_products.contains(consumed) {
1036                return Err(fail(
1037                    "conditional-only artifact consumption is not supported",
1038                ));
1039            }
1040            if !products.contains(consumed) {
1041                return Err(fail("consumed artifact has no upstream producer"));
1042            }
1043        }
1044        if let Some(product) = &step.produces {
1045            if product.is_empty() || !products.insert(product.clone()) {
1046                return Err(fail("invalid or duplicate artifact producer"));
1047            }
1048            if step.conditional {
1049                conditional_products.insert(product.clone());
1050            }
1051        }
1052        let mut emitted = json!({
1053            "name": step.name, "tier": step.tier, "consumes": step.consumes,
1054            "produces": step.produces, "produces_exclusive": step.produces_exclusive,
1055            "instructions": step.prompt, "capabilities": effective, "tools": tools,
1056            "when": step.when, "realization": realization,
1057            "product_availability": if step.conditional { "if_executed" } else { "after_attempt" },
1058        });
1059        if composed {
1060            emitted["component_calls"] = json!(step.component_calls);
1061        }
1062        output_steps.push(emitted);
1063    }
1064    if requirements
1065        .iter()
1066        .any(|f| !host.execution.iter().any(|s| s == f))
1067    {
1068        return Err(fail("host does not implement required execution semantics"));
1069    }
1070    let mut result = json!({
1071        "session_plan_version": if composed { COMPOSED_SESSION_PLAN_VERSION } else { SESSION_PLAN_VERSION },
1072        "producer_version": env!("CARGO_PKG_VERSION"), "warble_ir_version": SUPPORTED_IR_VERSION,
1073        "input_ir_sha256": digest(raw_ir.as_bytes()), "host_contract_sha256": digest(raw_host.as_bytes()),
1074        "profile": profile, "component": node.id,
1075        "context_identity_sha256": digest(raw["context_binding"].to_string().as_bytes()),
1076        "instructions": {"brief": node.brief}, "steps": output_steps,
1077        "slot_supply": slots,
1078        "capability_bindings": host.capabilities.iter().filter(|(c, _)| required_host.contains(*c)).collect::<BTreeMap<_, _>>(),
1079        "required_host_capabilities": required_host,
1080        "required_capabilities": node.required_capabilities, "guardrails": guards,
1081        "borrowed_actions": node.borrowed_actions, "context_requirements": node.context_requirements,
1082        "context_precondition": selected.get("context_precondition").cloned().unwrap_or_else(|| json!([])),
1083        "precondition_result": selected["precondition_result"],
1084        "render_blocks": selected["effect"].get("render_blocks").cloned().unwrap_or_else(|| json!([])),
1085        "required_execution": requirements,
1086        "authority": "host_owned", "execution_status": "not_executed",
1087    });
1088    if composed {
1089        result["declaration"] = selected.clone();
1090    }
1091    result["plan_sha256"] = json!(digest(result.to_string().as_bytes()));
1092    if result.to_string().len() > MAX_INPUT_BYTES {
1093        return Err(fail("output exceeds size limit"));
1094    }
1095    Ok(result)
1096}