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/v0.1.0/docs/spec/capability-model.md
9
10use crate::error::DispatchError;
11use crate::ir::{ComponentNode, OutcomeKind, RealizationKind, 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(
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    if node.realization_kind == RealizationKind::Skill {
53        let distinct_tiers: HashSet<&str> =
54            node.llm_calls.iter().map(|c| c.tier.as_str()).collect();
55        if distinct_tiers.len() > 1 {
56            implied.push("llm:per_step_tier".to_string());
57        }
58    }
59
60    match node.trigger.kind {
61        TriggerKind::Scheduled => implied.push("scheduler".to_string()),
62        TriggerKind::Event => implied.push("event_bus".to_string()),
63        TriggerKind::OneShot => {}
64    }
65
66    // Emitting a signal is the producer side of the event transport, symmetric to a `event` trigger
67    // consuming one — both borrow `event_bus`. Shape-derived (from `effect.outcome.emits`), so it
68    // stays enum/shape-keyed, never per-component. The `notify_channel` for concrete on-breach
69    // actions (notify_slack / open_ticket) is a *declared* capability, not implied here.
70    if node
71        .effect
72        .outcome
73        .emits
74        .as_ref()
75        .is_some_and(|e| !e.is_empty())
76    {
77        implied.push("event_bus".to_string());
78    }
79
80    if !node.effect.render_blocks.is_empty() {
81        implied.push("render_contract".to_string());
82    }
83
84    // +Mutating: a mutation outcome always needs a version-control checkpoint to roll back from —
85    // shape-derived from the outcome enum, symmetric to how `emits` implies `event_bus`. `human_
86    // approval` and `blast_radius` are NOT implied here: those come from the component's declared
87    // `required_capabilities` / guardrails, not the bare outcome kind.
88    //
89    // +Constitutive reuses this same arm: `outcome.target == "context"` needs the path-scoped
90    // `context_write_authz` gate instead of `write_authz` (the two scopes — models/knowledge vs
91    // data — must never cross). Every other target value (a data path, or none) keeps `write_authz`.
92    if node.effect.outcome.kind == OutcomeKind::Mutation {
93        implied.push("version_control".to_string());
94        if node.effect.outcome.target.as_deref() == Some("context") {
95            implied.push("context_write_authz".to_string());
96        } else {
97            implied.push("write_authz".to_string());
98        }
99    }
100
101    implied
102}
103
104/// Whether `node`'s context binding is fine-grained enough for Warble to compute `blast_radius`
105/// natively: `context_binding.resolved` must be present and its `lineage.resolvable` must be
106/// `true`. Keyed on binding *shape*, never on the component's id/verb.
107fn has_fine_grained_binding(node: &ComponentNode) -> bool {
108    node.context_binding
109        .resolved
110        .as_ref()
111        .and_then(|resolved| resolved.get("lineage"))
112        .and_then(|lineage| lineage.get("resolvable"))
113        .and_then(|resolvable| resolvable.as_bool())
114        .unwrap_or(false)
115}
116
117/// Union of declared + implied required capabilities, de-duplicated, order-preserving.
118pub fn collect_required_capabilities(node: &ComponentNode) -> Vec<String> {
119    let mut seen = HashSet::new();
120    let mut out = Vec::new();
121    for cap in node
122        .required_capabilities
123        .iter()
124        .cloned()
125        .chain(implied_capabilities(node))
126    {
127        if seen.insert(cap.clone()) {
128            out.push(cap);
129        }
130    }
131    out
132}
133
134/// Resolve every capability required by `node` against `profile`. Returns the report on success;
135/// errors (loud-fail) naming the capability + target if any required capability resolves to `fail`.
136pub fn resolve_capabilities(
137    node: &ComponentNode,
138    target_id: &str,
139    profile: &CapabilityProfile,
140) -> Result<ResolutionReport, DispatchError> {
141    let required = collect_required_capabilities(node);
142    let fallback = unknown_capability_entry();
143    let fine_grained_binding = has_fine_grained_binding(node);
144
145    let report: ResolutionReport = required
146        .into_iter()
147        .map(|capability| {
148            let e = profile.get(capability.as_str()).unwrap_or(&fallback);
149            // `blast_radius` is the one capability resolved by binding shape rather than the
150            // static profile table: fine-grained binding lets Warble compute it natively; coarse
151            // binding keeps it a loud fail (never silently degraded — it's safety-critical).
152            if capability == BLAST_RADIUS_CAPABILITY && fine_grained_binding {
153                ResolvedCapability {
154                    capability,
155                    outcome: CapabilityOutcome::Native,
156                    provided_by: ProvidedBy::Warble,
157                    criticality: e.criticality,
158                    note: None,
159                }
160            } else if capability == BLAST_RADIUS_CAPABILITY {
161                ResolvedCapability {
162                    capability,
163                    outcome: CapabilityOutcome::Fail,
164                    provided_by: e.provided_by,
165                    criticality: e.criticality,
166                    note: Some(
167                        "requires fine_grained_binding (coarse binding on this target)".to_string(),
168                    ),
169                }
170            } else {
171                ResolvedCapability {
172                    capability,
173                    outcome: e.outcome,
174                    provided_by: e.provided_by,
175                    criticality: e.criticality,
176                    note: e.note.map(|s| s.to_string()),
177                }
178            }
179        })
180        .collect();
181
182    if let Some(failed) = report.iter().find(|r| r.outcome == CapabilityOutcome::Fail) {
183        let reason = failed
184            .note
185            .clone()
186            .unwrap_or_else(|| "unsupported on this target".to_string());
187        return Err(DispatchError(format!(
188            "{}: fail on {target_id} ({reason}) — component '{}' cannot be dispatched",
189            failed.capability, node.verb
190        )));
191    }
192
193    Ok(report)
194}