1use 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
16pub const SESSION_PLAN_VERSION: &str = "1";
18pub 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];
36pub 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
100struct 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 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 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 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 "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
652pub 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 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 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 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}