Skip to main content

warble_claude_code/
ir.rs

1//! Typed view of the Warble IR (`warble_ir_version: 0.9`) that this back-end consumes.
2//!
3//! Mirrors [`ir-schema.md`][spec-ir] field-for-field. The IR JSON is the language-neutral seam
4//! between the front-end compiler and any back-end: this module depends on the schema doc, not on
5//! the front-end's Rust types. Enum arms cover every schema-valid value; arms this target does not
6//! yet realize are rejected at emit time (a "wall-hit"), not at deserialization.
7//!
8//! [spec-ir]: https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md
9
10use crate::error::DispatchError;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14/// IR version this back-end understands. Copied (not shared via a `core` dependency — dispatchers
15/// never depend on `warble`) from the same source of truth as `docs/spec/ir-schema.md`'s title and
16/// the TS back-end's `SUPPORTED_IR_VERSIONS` in `dispatcher/claude-agent-sdk/src/ir.ts`. The sole
17/// cross-target lockstep owner is `core/tests/ir_version_lockstep_tests.rs`; this target's
18/// `ir_version_tests.rs` covers only its own rejection behavior. An unrecognized
19/// `warble_ir_version` is a loud-fail — see [`validate_ir_version`] — never a silent best-effort
20/// read.
21pub const SUPPORTED_IR_VERSION: &str = "0.9";
22
23/// The one version gate every IR-consuming entry point in this crate (and the `cli` binary, at IR
24/// parse time) must call before doing anything else with `ir`: `emit_claude_code_with_realization`
25/// calls this as its first statement, and `cli::load_ir` calls it right after deserializing, so
26/// every subcommand that reads an `ir.json` — `dispatch` and `manifest` alike — is covered by the
27/// same check instead of each reimplementing (and potentially forgetting) it.
28pub fn validate_ir_version(ir: &WarbleIr) -> Result<(), DispatchError> {
29    if ir.warble_ir_version != SUPPORTED_IR_VERSION {
30        return Err(DispatchError::new(format!(
31            "unsupported warble_ir_version '{}' (this back-end understands: {SUPPORTED_IR_VERSION})",
32            ir.warble_ir_version
33        )));
34    }
35    if ir
36        .components
37        .iter()
38        .any(|node| node.context_binding.absent != ir.context_binding.absent)
39    {
40        return Err(DispatchError::new(
41            "root and component context_binding must agree on context absence",
42        ));
43    }
44    Ok(())
45}
46
47/// Reject the composition facets before any executable artifact is emitted. This target parses
48/// the complete IR 0.9 shape but does not yet own a trusted component invocation runtime.
49pub fn reject_unsupported_component_composition(
50    ir: &WarbleIr,
51    target: &str,
52) -> Result<(), DispatchError> {
53    // This emitter starts advertised entries only. An unreachable internal mount is structural
54    // inventory, not executable work, so it must not block or become an output artifact.
55    for node in ir.components.iter().filter(|node| node.entrypoint) {
56        for call in &node.llm_calls {
57            if let Some(component_call) = call.component_calls.first() {
58                return Err(DispatchError::new(format!(
59                    "step '{}' on component '{}' authorizes component call alias '{}' to '{}', \
60                     but component_invocation resolves fail on target '{target}' because no trusted \
61                     invocation handler is installed (wall-hit)",
62                    call.name, node.id, component_call.alias, component_call.component
63                )));
64            }
65        }
66    }
67    Ok(())
68}
69
70/// A step's tier is an **open string**, not a fixed enum. Warble ships a standard core vocabulary
71/// (`strong`, `cheap`) that keeps components portable, but a component may name a custom tier; the
72/// dispatch-time model config (see `ModelConfig`) maps each tier name to a concrete model, and a
73/// tier with no mapping is a loud-fail. Kept as `String` so the seam never constrains the vocabulary.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "kebab-case")]
76pub enum RealizationKind {
77    Skill,
78    Tool,
79    GatedTool,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum ComponentType {
85    Analytical,
86    Assertive,
87    Mutating,
88    Constitutive,
89    Orchestrating,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum TriggerKind {
95    OneShot,
96    Scheduled,
97    Event,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum OutcomeKind {
103    None,
104    Assertion,
105    Mutation,
106    Dispatch,
107}
108
109impl RealizationKind {
110    pub fn as_str(&self) -> &'static str {
111        match self {
112            RealizationKind::Skill => "skill",
113            RealizationKind::Tool => "tool",
114            RealizationKind::GatedTool => "gated-tool",
115        }
116    }
117}
118
119impl TriggerKind {
120    pub fn as_str(&self) -> &'static str {
121        match self {
122            TriggerKind::OneShot => "one_shot",
123            TriggerKind::Scheduled => "scheduled",
124            TriggerKind::Event => "event",
125        }
126    }
127}
128
129impl OutcomeKind {
130    pub fn as_str(&self) -> &'static str {
131        match self {
132            OutcomeKind::None => "none",
133            OutcomeKind::Assertion => "assertion",
134            OutcomeKind::Mutation => "mutation",
135            OutcomeKind::Dispatch => "dispatch",
136        }
137    }
138}
139
140impl ComponentType {
141    pub fn as_str(&self) -> &'static str {
142        match self {
143            ComponentType::Analytical => "analytical",
144            ComponentType::Assertive => "assertive",
145            ComponentType::Mutating => "mutating",
146            ComponentType::Constitutive => "constitutive",
147            ComponentType::Orchestrating => "orchestrating",
148        }
149    }
150}
151
152#[derive(Debug, Clone, PartialEq)]
153pub struct ContextBinding {
154    /// True only for explicit null in IR 0.9. Empty internal strings are not emitted identities.
155    pub absent: bool,
156    pub project: String,
157    pub binding_mode: String,
158    /// Fine-grained resolved binding (IR v0.3): metrics/dimensions/grains/lineage summary the
159    /// front-end learned from the bound semantic layer. Carried through and tolerated here; the
160    /// Claude Code back-end does not yet consume it (it still drives off the coarse project path).
161    pub resolved: Option<serde_json::Value>,
162}
163
164impl<'de> Deserialize<'de> for ContextBinding {
165    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
166        #[derive(Deserialize)]
167        struct Bound {
168            project: String,
169            binding_mode: String,
170            #[serde(default)]
171            resolved: Option<serde_json::Value>,
172        }
173        Ok(match Option::<Bound>::deserialize(deserializer)? {
174            Some(value) => Self {
175                absent: false,
176                project: value.project,
177                binding_mode: value.binding_mode,
178                resolved: value.resolved,
179            },
180            None => Self {
181                absent: true,
182                project: String::new(),
183                binding_mode: String::new(),
184                resolved: None,
185            },
186        })
187    }
188}
189
190/// The IR's profile-level `config` block. Empty since IR `0.6` removed `tier_policy` (an inert
191/// field no back-end read); kept as a struct so future profile-level config is an additive change.
192#[derive(Debug, Clone, PartialEq, Deserialize)]
193pub struct IrConfig {}
194
195#[derive(Debug, Clone, PartialEq, Deserialize)]
196pub struct LlmCall {
197    pub name: String,
198    /// Tier name (conventionally `strong`/`cheap`; custom names allowed — see [`RealizationKind`]
199    /// docs and `ModelConfig`). Resolved to a concrete model at dispatch.
200    pub tier: String,
201    #[serde(default)]
202    pub consumes: Vec<String>,
203    #[serde(default)]
204    pub produces: Option<String>,
205    pub prompt: String,
206    /// Whether this step only runs under a runtime condition (e.g. a prior step's outcome).
207    #[serde(default)]
208    pub conditional: bool,
209    /// The closed-vocabulary guard deciding whether a `conditional` step runs (IR v0.3+; see
210    /// [`ir-schema.md`][spec-ir]). This back-end's actual emission still treats `conditional` as an
211    /// opaque flag — the static Claude Code CLI target has no deterministic runtime, so the guard is
212    /// judged emergently by the live CLI agent from the emitted prompt text, not evaluated here.
213    /// `conditional.rs` carries a deterministic evaluator for the same closed vocabulary, but it is
214    /// conformance-only (tested against a fixture shared with the SDK back-end's evaluator, not
215    /// wired into emission). This field is tolerated either way so the seam stays forward-compatible.
216    ///
217    /// [spec-ir]: https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md
218    #[serde(default)]
219    pub when: Option<WhenGuard>,
220    /// Same-profile component aliases this exact step may invoke. Parsed even though this target
221    /// cannot realize them yet, so executable preparation can wall-hit instead of dropping them.
222    #[serde(default)]
223    pub component_calls: Vec<ComponentCall>,
224}
225
226#[derive(Debug, Clone, PartialEq, Deserialize)]
227pub struct ComponentCall {
228    pub alias: String,
229    pub component: String,
230}
231
232/// A closed-vocabulary guard on a conditional `llm_call`: `guard` is one of `on_failure` /
233/// `on_flag` / `on_missing`, `target` is the guard-specific argument. See
234/// [`ir-schema.md`][spec-ir].
235///
236/// [spec-ir]: https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md
237#[derive(Debug, Clone, PartialEq, Deserialize)]
238pub struct WhenGuard {
239    pub guard: String,
240    pub target: String,
241}
242
243#[derive(Debug, Clone, PartialEq, Deserialize)]
244pub struct Guardrail {
245    pub name: String,
246    pub locked: bool,
247    #[serde(default)]
248    pub scope: Option<String>,
249    /// Optional numeric or structured threshold the guardrail enforces (shape is guardrail-specific).
250    #[serde(default)]
251    pub threshold: Option<serde_json::Value>,
252}
253
254#[derive(Debug, Clone, PartialEq, Deserialize)]
255pub struct Trigger {
256    pub kind: TriggerKind,
257}
258
259/// A typed render block: a block type plus its field-name → field-type schema. The field-type
260/// strings are echoed into the agent's render contract verbatim; the back-end never interprets
261/// them. `BTreeMap` keeps field order stable (deterministic output).
262#[derive(Debug, Clone, PartialEq, Deserialize)]
263pub struct RenderBlock {
264    #[serde(rename = "type")]
265    pub block_type: String,
266    #[serde(default)]
267    pub fields: BTreeMap<String, String>,
268}
269
270#[derive(Debug, Clone, PartialEq, Deserialize)]
271pub struct Outcome {
272    pub kind: OutcomeKind,
273    /// Free-form verdict classification for `assertion`-kind outcomes.
274    #[serde(default)]
275    pub verdict_type: Option<String>,
276    /// Event/signal names this outcome emits (e.g. for `dispatch`-kind outcomes).
277    #[serde(default)]
278    pub emits: Option<Vec<String>>,
279    /// The mutation/dispatch target, when applicable.
280    #[serde(default)]
281    pub target: Option<String>,
282    /// The kind of change made, for `mutation`-kind outcomes.
283    #[serde(default)]
284    pub change_type: Option<String>,
285    /// Routing scope for `dispatch`-kind outcomes (shape is dispatch-target-specific).
286    #[serde(default)]
287    pub routable_scope: Option<serde_json::Value>,
288}
289
290#[derive(Debug, Clone, PartialEq, Deserialize)]
291pub struct Effect {
292    #[serde(default)]
293    pub render_blocks: Vec<RenderBlock>,
294    pub outcome: Outcome,
295}
296
297#[derive(Debug, Clone, PartialEq, Deserialize)]
298pub struct PreconditionResult {
299    pub status: String,
300    /// Per-predicate evaluation results (IR v0.3): each `{predicate, outcome}`. In v0.2 this was a
301    /// free-form string list; v0.3 makes it structured now that predicates are really evaluated.
302    #[serde(default)]
303    pub checks: Vec<PreconditionCheck>,
304}
305
306/// One evaluated `context_precondition` and its outcome (`pass` in emitted IR — a failing
307/// predicate loud-fails the compile before any IR is emitted).
308#[derive(Debug, Clone, PartialEq, Deserialize)]
309pub struct PreconditionCheck {
310    pub predicate: String,
311    pub outcome: String,
312}
313
314/// A context precondition a component requires to hold before it runs (e.g. `has_metric`).
315#[derive(Debug, Clone, PartialEq, Deserialize)]
316pub struct Precondition {
317    pub predicate: String,
318    #[serde(default)]
319    pub args: Option<serde_json::Value>,
320}
321
322/// A component parameter, either bound at dispatch time (`bind`, with an optional `default`) or
323/// sourced from context (`source`). Exactly one of `bind`/`source` is expected to be present per
324/// the schema, but both are optional here since this is a Deserialize-only view.
325#[derive(Debug, Clone, PartialEq, Deserialize)]
326pub struct Param {
327    pub name: String,
328    #[serde(default)]
329    pub bind: Option<String>,
330    #[serde(default)]
331    pub source: Option<String>,
332    #[serde(default)]
333    pub default: Option<serde_json::Value>,
334}
335
336/// An authored evaluation spec: which eval template to run and which metrics it scores.
337#[derive(Debug, Clone, PartialEq, Deserialize)]
338pub struct EvalSpec {
339    pub template_ref: String,
340    #[serde(default)]
341    pub metrics: Vec<String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Deserialize)]
345pub struct ComponentNode {
346    pub id: String,
347    pub entrypoint: bool,
348    pub verb: String,
349    #[serde(rename = "type")]
350    pub component_type: ComponentType,
351    pub realization_kind: RealizationKind,
352    #[serde(deserialize_with = "ContextBinding::deserialize")]
353    pub context_binding: ContextBinding,
354    pub precondition_result: PreconditionResult,
355    pub prompt_fragment: String,
356    pub llm_calls: Vec<LlmCall>,
357    pub guardrails: Vec<Guardrail>,
358    pub trigger: Trigger,
359    #[serde(default)]
360    pub required_capabilities: Vec<String>,
361    #[serde(default)]
362    pub borrowed_actions: Vec<String>,
363    pub eval_ref: String,
364    pub effect: Effect,
365    /// Free-text context capabilities this component requires (e.g. "a wren project ...").
366    #[serde(default)]
367    pub context_requirements: Vec<String>,
368    /// Structured preconditions the bound context must satisfy (e.g. `has_metric`).
369    #[serde(default)]
370    pub context_precondition: Vec<Precondition>,
371    /// Component parameters, either context-sourced or dispatch-bound.
372    #[serde(default)]
373    pub params: Vec<Param>,
374    /// Authored evaluation spec, when the component declares one.
375    #[serde(default)]
376    pub eval: Option<EvalSpec>,
377    /// Optional free-form framing shared by every step of this component (see
378    /// `docs/spec/ir-schema.md`).
379    #[serde(default)]
380    pub brief: Option<String>,
381    /// What this component is for, authored for a reader choosing between components rather than for
382    /// the agent doing the work. Absent when the author wrote none, in which case the back-end
383    /// synthesizes a shape line instead.
384    #[serde(default)]
385    pub description: Option<String>,
386    /// Example requests this component is the right destination for.
387    #[serde(default)]
388    pub examples: Vec<String>,
389    /// Slots belonging to this component's own prompt text. Profile-level slots live on
390    /// [`WarbleIr`] instead and are deliberately not copied here.
391    #[serde(default)]
392    pub slots: Vec<SlotDecl>,
393}
394
395/// A named position in prompt text, with the alternative wordings that may fill it (IR 0.7).
396///
397/// `default` always names one of `variants` — the compiler refuses otherwise. `present_when` is
398/// carried but never evaluated here: the spec assigns that to the host, and this back-end only asks
399/// the host for the answer.
400#[derive(Debug, Clone, PartialEq, Deserialize, serde::Serialize)]
401pub struct SlotDecl {
402    pub name: String,
403    pub default: String,
404    pub variants: std::collections::BTreeMap<String, String>,
405    /// Present only when the author declared a condition. Carried verbatim, never interpreted.
406    #[serde(default)]
407    pub present_when: Option<serde_json::Value>,
408}
409
410#[derive(Debug, Clone, PartialEq, Deserialize)]
411pub struct WarbleIr {
412    pub warble_ir_version: String,
413    pub profile: String,
414    #[serde(deserialize_with = "ContextBinding::deserialize")]
415    pub context_binding: ContextBinding,
416    pub config: IrConfig,
417    pub components: Vec<ComponentNode>,
418    /// Profile-level slots, belonging to the profile's `system_prompt`. Names are unique across the
419    /// whole project — the compiler refuses a collision with any component's — so a consumer
420    /// resolves `{{ slot.<name> }}` against one flat namespace.
421    #[serde(default)]
422    pub slots: Vec<SlotDecl>,
423}