Skip to main content

warble_claude_code/
ir.rs

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