Skip to main content

warble_claude_code/
resolve.rs

1//! The capability resolution pass — dispatch's "capability linker" (see
2//! [`capability-model.md`][spec-cap]).
3//!
4//! Given an IR component node and a target's capability profile, resolves every capability the
5//! node requires (declared + implied) into a report, or aborts loudly naming the unsupported
6//! capability + target (no silent degradation).
7//!
8//! [spec-cap]: https://github.com/Canner/Warble/blob/main/docs/spec/capability-model.md
9
10use crate::error::DispatchError;
11use crate::ir::{ComponentNode, OutcomeKind, TriggerKind};
12use crate::targets::{
13    CapabilityEntry, CapabilityOutcome, CapabilityProfile, Criticality, ProvidedBy,
14};
15use serde::Serialize;
16use std::collections::HashSet;
17
18/// The one capability whose resolution depends on binding *shape* rather than a static per-target
19/// table entry — see `has_fine_grained_binding`.
20const BLAST_RADIUS_CAPABILITY: &str = "blast_radius";
21
22#[derive(Debug, Clone, Serialize)]
23pub struct ResolvedCapability {
24    pub capability: String,
25    pub outcome: CapabilityOutcome,
26    pub provided_by: ProvidedBy,
27    pub criticality: Criticality,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub note: Option<String>,
30}
31
32pub type ResolutionReport = Vec<ResolvedCapability>;
33
34/// Capability entry used for a capability absent from the target profile entirely — unknown means
35/// it cannot be guaranteed, so it fails as safety-critical.
36fn unknown_capability_entry() -> CapabilityEntry {
37    CapabilityEntry {
38        outcome: CapabilityOutcome::Fail,
39        via: None,
40        provided_by: ProvidedBy::None,
41        criticality: Criticality::SafetyCritical,
42        note: Some(std::borrow::Cow::Borrowed(
43            "capability is not declared in the target's capability profile — unknown means it cannot be guaranteed",
44        )),
45    }
46}
47
48/// Capabilities implied by IR shape beyond the node's declared `required_capabilities`.
49fn implied_capabilities(node: &ComponentNode) -> Vec<String> {
50    let mut implied = Vec::new();
51
52    // Per-step tier is realization-independent: an authored tier is an unambiguous cost/behavior
53    // declaration regardless of how the component connects to the LLM (skill/tool/gated-tool), so
54    // divergent step tiers always imply `llm:per_step_tier` — never gated on `realization_kind`.
55    // Silently ignoring an authored tier for `tool`/`gated-tool` is exactly the silent-collapse
56    // failure the "no silent degradation" design principle exists to prevent.
57    let distinct_tiers: HashSet<&str> = node.llm_calls.iter().map(|c| c.tier.as_str()).collect();
58    if distinct_tiers.len() > 1 {
59        implied.push("llm:per_step_tier".to_string());
60    }
61
62    match node.trigger.kind {
63        TriggerKind::Scheduled => implied.push("scheduler".to_string()),
64        TriggerKind::Event => implied.push("event_bus".to_string()),
65        TriggerKind::OneShot => {}
66    }
67
68    // Emitting a signal is the producer side of the event transport, symmetric to a `event` trigger
69    // consuming one — both borrow `event_bus`. Shape-derived (from `effect.outcome.emits`), so it
70    // stays enum/shape-keyed, never per-component. The `notify_channel` for concrete on-breach
71    // actions (notify_slack / open_ticket) is a *declared* capability, not implied here.
72    if node
73        .effect
74        .outcome
75        .emits
76        .as_ref()
77        .is_some_and(|e| !e.is_empty())
78    {
79        implied.push("event_bus".to_string());
80    }
81
82    if !node.effect.render_blocks.is_empty() {
83        implied.push("render_contract".to_string());
84    }
85
86    // +Mutating: a mutation outcome always needs a version-control checkpoint to roll back from —
87    // shape-derived from the outcome enum, symmetric to how `emits` implies `event_bus`. `human_
88    // approval` and `blast_radius` are NOT implied here: those come from the component's declared
89    // `required_capabilities` / guardrails, not the bare outcome kind.
90    //
91    // +Constitutive reuses this same arm: `outcome.target == "context"` needs the path-scoped
92    // `context_write_authz` gate instead of `write_authz` (the two scopes — models/knowledge vs
93    // data — must never cross). Every other target value (a data path, or none) keeps `write_authz`.
94    if node.effect.outcome.kind == OutcomeKind::Mutation {
95        implied.push("version_control".to_string());
96        if node.effect.outcome.target.as_deref() == Some("context") {
97            implied.push("context_write_authz".to_string());
98        } else {
99            implied.push("write_authz".to_string());
100        }
101    }
102
103    implied
104}
105
106/// Whether `node`'s context binding is fine-grained enough for Warble to compute `blast_radius`
107/// natively: `context_binding.resolved` must be present and its `lineage.resolvable` must be
108/// `true`. Keyed on binding *shape*, never on the component's id/verb.
109fn has_fine_grained_binding(node: &ComponentNode) -> bool {
110    node.context_binding
111        .resolved
112        .as_ref()
113        .and_then(|resolved| resolved.get("lineage"))
114        .and_then(|lineage| lineage.get("resolvable"))
115        .and_then(|resolvable| resolvable.as_bool())
116        .unwrap_or(false)
117}
118
119/// Union of declared + implied required capabilities, de-duplicated, order-preserving.
120pub fn collect_required_capabilities(node: &ComponentNode) -> Vec<String> {
121    let mut seen = HashSet::new();
122    let mut out = Vec::new();
123    for cap in node
124        .required_capabilities
125        .iter()
126        .cloned()
127        .chain(implied_capabilities(node))
128    {
129        if seen.insert(cap.clone()) {
130            out.push(cap);
131        }
132    }
133    out
134}
135
136/// Resolve every capability required by `node` against `profile`. Returns the report on success;
137/// errors (loud-fail) naming the capability + target if any required capability resolves to `fail`.
138pub fn resolve_capabilities(
139    node: &ComponentNode,
140    target_id: &str,
141    profile: &CapabilityProfile,
142) -> Result<ResolutionReport, DispatchError> {
143    let required = collect_required_capabilities(node);
144    let fallback = unknown_capability_entry();
145    let fine_grained_binding = has_fine_grained_binding(node);
146
147    let report: ResolutionReport = required
148        .into_iter()
149        .map(|capability| {
150            let e = profile.get(capability.as_str()).unwrap_or(&fallback);
151            // `blast_radius` is the one capability resolved by binding shape rather than the
152            // static profile table: fine-grained binding lets Warble compute it natively; coarse
153            // binding keeps it a loud fail (never silently degraded — it's safety-critical).
154            if capability == BLAST_RADIUS_CAPABILITY && fine_grained_binding {
155                ResolvedCapability {
156                    capability,
157                    outcome: CapabilityOutcome::Native,
158                    provided_by: ProvidedBy::Warble,
159                    criticality: e.criticality,
160                    note: None,
161                }
162            } else if capability == BLAST_RADIUS_CAPABILITY {
163                ResolvedCapability {
164                    capability,
165                    outcome: CapabilityOutcome::Fail,
166                    provided_by: e.provided_by,
167                    criticality: e.criticality,
168                    note: Some(
169                        "requires fine_grained_binding (coarse binding on this target)".to_string(),
170                    ),
171                }
172            } else {
173                ResolvedCapability {
174                    capability,
175                    outcome: e.outcome,
176                    provided_by: e.provided_by,
177                    criticality: e.criticality,
178                    note: e.note.as_deref().map(str::to_string),
179                }
180            }
181        })
182        .collect();
183
184    if let Some(failed) = report.iter().find(|r| r.outcome == CapabilityOutcome::Fail) {
185        let reason = failed
186            .note
187            .clone()
188            .unwrap_or_else(|| "unsupported on this target".to_string());
189        return Err(DispatchError(format!(
190            "{}: fail on {target_id} ({reason}) — component '{}' cannot be dispatched",
191            failed.capability, node.verb
192        )));
193    }
194
195    Ok(report)
196}