Skip to main content

warble_claude_code/
ir.rs

1//! Typed view of the Warble IR (`warble_ir_version: 0.3`) 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/v0.1.0/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`; kept in
17/// lockstep by `ir_version_tests.rs`. An unrecognized `warble_ir_version` is a loud-fail — see
18/// [`validate_ir_version`] — never a silent best-effort read.
19pub const SUPPORTED_IR_VERSION: &str = "0.3";
20
21/// The one version gate every IR-consuming entry point in this crate (and the `cli` binary, at IR
22/// parse time) must call before doing anything else with `ir`: `emit_claude_code_with_realization`
23/// calls this as its first statement, and `cli::load_ir` calls it right after deserializing, so
24/// every subcommand that reads an `ir.json` — `dispatch` and `manifest` alike — is covered by the
25/// same check instead of each reimplementing (and potentially forgetting) it.
26pub fn validate_ir_version(ir: &WarbleIr) -> Result<(), DispatchError> {
27    if ir.warble_ir_version != SUPPORTED_IR_VERSION {
28        return Err(DispatchError::new(format!(
29            "unsupported warble_ir_version '{}' (this back-end understands: {SUPPORTED_IR_VERSION})",
30            ir.warble_ir_version
31        )));
32    }
33    Ok(())
34}
35
36/// A step's tier is an **open string**, not a fixed enum. Warble ships a standard core vocabulary
37/// (`strong`, `cheap`) that keeps components portable, but a component may name a custom tier; the
38/// dispatch-time model config (see `ModelConfig`) maps each tier name to a concrete model, and a
39/// tier with no mapping is a loud-fail. Kept as `String` so the seam never constrains the vocabulary.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum RealizationKind {
43    Skill,
44    Tool,
45    GatedTool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum ComponentType {
51    Analytical,
52    Assertive,
53    Mutating,
54    Constitutive,
55    Orchestrating,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum TriggerKind {
61    OneShot,
62    Scheduled,
63    Event,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum OutcomeKind {
69    None,
70    Assertion,
71    Mutation,
72    Dispatch,
73}
74
75impl RealizationKind {
76    pub fn as_str(&self) -> &'static str {
77        match self {
78            RealizationKind::Skill => "skill",
79            RealizationKind::Tool => "tool",
80            RealizationKind::GatedTool => "gated-tool",
81        }
82    }
83}
84
85impl TriggerKind {
86    pub fn as_str(&self) -> &'static str {
87        match self {
88            TriggerKind::OneShot => "one_shot",
89            TriggerKind::Scheduled => "scheduled",
90            TriggerKind::Event => "event",
91        }
92    }
93}
94
95impl OutcomeKind {
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            OutcomeKind::None => "none",
99            OutcomeKind::Assertion => "assertion",
100            OutcomeKind::Mutation => "mutation",
101            OutcomeKind::Dispatch => "dispatch",
102        }
103    }
104}
105
106impl ComponentType {
107    pub fn as_str(&self) -> &'static str {
108        match self {
109            ComponentType::Analytical => "analytical",
110            ComponentType::Assertive => "assertive",
111            ComponentType::Mutating => "mutating",
112            ComponentType::Constitutive => "constitutive",
113            ComponentType::Orchestrating => "orchestrating",
114        }
115    }
116}
117
118#[derive(Debug, Clone, Deserialize)]
119pub struct ContextBinding {
120    pub project: String,
121    pub binding_mode: String,
122    /// Fine-grained resolved binding (IR v0.3): metrics/dimensions/grains/lineage summary the
123    /// front-end learned from the bound semantic layer. Carried through and tolerated here; the
124    /// Claude Code back-end does not yet consume it (it still drives off the coarse project path).
125    #[serde(default)]
126    pub resolved: Option<serde_json::Value>,
127}
128
129#[derive(Debug, Clone, Deserialize)]
130pub struct IrConfig {
131    #[serde(default)]
132    pub tier_policy: Option<String>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
136pub struct LlmCall {
137    pub name: String,
138    /// Tier name (conventionally `strong`/`cheap`; custom names allowed — see [`RealizationKind`]
139    /// docs and `ModelConfig`). Resolved to a concrete model at dispatch.
140    pub tier: String,
141    #[serde(default)]
142    pub consumes: Vec<String>,
143    #[serde(default)]
144    pub produces: Option<String>,
145    pub prompt: String,
146    /// Whether this step only runs under a runtime condition (e.g. a prior step's outcome).
147    #[serde(default)]
148    pub conditional: bool,
149    /// The closed-vocabulary guard deciding whether a `conditional` step runs (IR v0.3+; see
150    /// [`ir-schema.md`][spec-ir]). This back-end's actual emission still treats `conditional` as an
151    /// opaque flag — the static Claude Code CLI target has no deterministic runtime, so the guard is
152    /// judged emergently by the live CLI agent from the emitted prompt text, not evaluated here.
153    /// `conditional.rs` carries a deterministic evaluator for the same closed vocabulary, but it is
154    /// conformance-only (tested against a fixture shared with the SDK back-end's evaluator, not
155    /// wired into emission). This field is tolerated either way so the seam stays forward-compatible.
156    ///
157    /// [spec-ir]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/ir-schema.md
158    #[serde(default)]
159    pub when: Option<WhenGuard>,
160}
161
162/// A closed-vocabulary guard on a conditional `llm_call`: `guard` is one of `on_failure` /
163/// `on_flag` / `on_missing`, `target` is the guard-specific argument. See
164/// [`ir-schema.md`][spec-ir].
165///
166/// [spec-ir]: https://github.com/Canner/Warble/blob/v0.1.0/docs/spec/ir-schema.md
167#[derive(Debug, Clone, Deserialize)]
168pub struct WhenGuard {
169    pub guard: String,
170    pub target: String,
171}
172
173#[derive(Debug, Clone, Deserialize)]
174pub struct Guardrail {
175    pub name: String,
176    pub locked: bool,
177    #[serde(default)]
178    pub scope: Option<String>,
179    /// Optional numeric or structured threshold the guardrail enforces (shape is guardrail-specific).
180    #[serde(default)]
181    pub threshold: Option<serde_json::Value>,
182}
183
184#[derive(Debug, Clone, Deserialize)]
185pub struct Trigger {
186    pub kind: TriggerKind,
187}
188
189/// A typed render block: a block type plus its field-name → field-type schema. The field-type
190/// strings are echoed into the agent's render contract verbatim; the back-end never interprets
191/// them. `BTreeMap` keeps field order stable (deterministic output).
192#[derive(Debug, Clone, Deserialize)]
193pub struct RenderBlock {
194    #[serde(rename = "type")]
195    pub block_type: String,
196    #[serde(default)]
197    pub fields: BTreeMap<String, String>,
198}
199
200#[derive(Debug, Clone, Deserialize)]
201pub struct Outcome {
202    pub kind: OutcomeKind,
203    /// Free-form verdict classification for `assertion`-kind outcomes.
204    #[serde(default)]
205    pub verdict_type: Option<String>,
206    /// Event/signal names this outcome emits (e.g. for `dispatch`-kind outcomes).
207    #[serde(default)]
208    pub emits: Option<Vec<String>>,
209    /// The mutation/dispatch target, when applicable.
210    #[serde(default)]
211    pub target: Option<String>,
212    /// The kind of change made, for `mutation`-kind outcomes.
213    #[serde(default)]
214    pub change_type: Option<String>,
215    /// Routing scope for `dispatch`-kind outcomes (shape is dispatch-target-specific).
216    #[serde(default)]
217    pub routable_scope: Option<serde_json::Value>,
218}
219
220#[derive(Debug, Clone, Deserialize)]
221pub struct Effect {
222    #[serde(default)]
223    pub render_blocks: Vec<RenderBlock>,
224    pub outcome: Outcome,
225}
226
227#[derive(Debug, Clone, Deserialize)]
228pub struct PreconditionResult {
229    pub status: String,
230    /// Per-predicate evaluation results (IR v0.3): each `{predicate, outcome}`. In v0.2 this was a
231    /// free-form string list; v0.3 makes it structured now that predicates are really evaluated.
232    #[serde(default)]
233    pub checks: Vec<PreconditionCheck>,
234}
235
236/// One evaluated `context_precondition` and its outcome (`pass` in emitted IR — a failing
237/// predicate loud-fails the compile before any IR is emitted).
238#[derive(Debug, Clone, Deserialize)]
239pub struct PreconditionCheck {
240    pub predicate: String,
241    pub outcome: String,
242}
243
244/// A context precondition a component requires to hold before it runs (e.g. `has_metric`).
245#[derive(Debug, Clone, Deserialize)]
246pub struct Precondition {
247    pub predicate: String,
248    #[serde(default)]
249    pub args: Option<serde_json::Value>,
250}
251
252/// A component parameter, either bound at dispatch time (`bind`, with an optional `default`) or
253/// sourced from context (`source`). Exactly one of `bind`/`source` is expected to be present per
254/// the schema, but both are optional here since this is a Deserialize-only view.
255#[derive(Debug, Clone, Deserialize)]
256pub struct Param {
257    pub name: String,
258    #[serde(default)]
259    pub bind: Option<String>,
260    #[serde(default)]
261    pub source: Option<String>,
262    #[serde(default)]
263    pub default: Option<serde_json::Value>,
264}
265
266/// An authored evaluation spec: which eval template to run and which metrics it scores.
267#[derive(Debug, Clone, Deserialize)]
268pub struct EvalSpec {
269    pub template_ref: String,
270    #[serde(default)]
271    pub metrics: Vec<String>,
272}
273
274#[derive(Debug, Clone, Deserialize)]
275pub struct ComponentNode {
276    pub id: String,
277    pub verb: String,
278    #[serde(rename = "type")]
279    pub component_type: ComponentType,
280    pub realization_kind: RealizationKind,
281    pub context_binding: ContextBinding,
282    pub precondition_result: PreconditionResult,
283    pub prompt_fragment: String,
284    pub llm_calls: Vec<LlmCall>,
285    pub guardrails: Vec<Guardrail>,
286    pub trigger: Trigger,
287    #[serde(default)]
288    pub required_capabilities: Vec<String>,
289    #[serde(default)]
290    pub borrowed_actions: Vec<String>,
291    pub eval_ref: String,
292    pub effect: Effect,
293    /// Free-text context capabilities this component requires (e.g. "a wren project ...").
294    #[serde(default)]
295    pub context_requirements: Vec<String>,
296    /// Structured preconditions the bound context must satisfy (e.g. `has_metric`).
297    #[serde(default)]
298    pub context_precondition: Vec<Precondition>,
299    /// Component parameters, either context-sourced or dispatch-bound.
300    #[serde(default)]
301    pub params: Vec<Param>,
302    /// Authored evaluation spec, when the component declares one.
303    #[serde(default)]
304    pub eval: Option<EvalSpec>,
305}
306
307#[derive(Debug, Clone, Deserialize)]
308pub struct WarbleIr {
309    pub warble_ir_version: String,
310    pub profile: String,
311    pub context_binding: ContextBinding,
312    pub config: IrConfig,
313    pub components: Vec<ComponentNode>,
314}