Skip to main content

cymule_core/
ir.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{CoreError, Result, content_id};
7
8/// Frozen canonical IR version.
9pub const IR_VERSION: &str = "cymule.ir/2";
10
11/// An unsealed, language-neutral semantic plan.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct PlanCandidate {
15    /// IR version.
16    pub ir_version: String,
17    /// Human-readable plan name.
18    pub name: String,
19    /// Entry definition ID.
20    pub entry: String,
21    /// Abstract component contracts.
22    #[serde(default)]
23    pub components: Vec<ComponentContract>,
24    /// Abstract effect contracts.
25    #[serde(default)]
26    pub effects: Vec<EffectContract>,
27    /// Structured definitions.
28    pub definitions: Vec<Definition>,
29    /// Non-semantic author metadata. Keys and values are still content-addressed.
30    #[serde(default)]
31    pub metadata: BTreeMap<String, String>,
32}
33
34/// An immutable sealed plan.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct SealedPlan {
38    /// Content-addressed plan identity.
39    pub plan_id: String,
40    /// Validated canonical candidate.
41    pub candidate: PlanCandidate,
42}
43
44/// Abstract executable component contract.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct ComponentContract {
48    /// Stable operation ID.
49    pub id: String,
50    /// Input JSON Schema.
51    pub input_schema: Value,
52    /// Output JSON Schema.
53    pub output_schema: Value,
54    /// Provider-neutral required properties.
55    #[serde(default)]
56    pub requirements: BTreeMap<String, String>,
57}
58
59/// Abstract world-effect contract.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct EffectContract {
63    /// Stable operation ID.
64    pub id: String,
65    /// Input JSON Schema.
66    pub input_schema: Value,
67    /// Output JSON Schema.
68    pub output_schema: Value,
69    /// Effect safety and recovery properties.
70    pub profile: EffectProfile,
71    /// Provider-neutral required properties.
72    #[serde(default)]
73    pub requirements: BTreeMap<String, String>,
74}
75
76/// Orthogonal effect properties used by admission and binding.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct EffectProfile {
80    /// Whether the operation can change the external world.
81    pub mutation: MutationKind,
82    /// When dispatch may be released.
83    pub dispatch: DispatchPolicy,
84    /// How an ambiguous result can be reconciled.
85    pub reconciliation: ReconciliationMode,
86    /// Whether the provider accepts a stable idempotency key.
87    #[serde(default)]
88    pub keyed_idempotency: bool,
89    /// Whether the operation is irreversible after application.
90    #[serde(default)]
91    pub irreversible: bool,
92}
93
94/// Effect mutation class.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum MutationKind {
98    /// Read-only or observational nondeterminism.
99    Observational,
100    /// External mutation.
101    Mutating,
102}
103
104/// Effect release policy.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum DispatchPolicy {
108    /// The observation may execute immediately.
109    Eager,
110    /// Release only after scope commit.
111    OnScopeCommit,
112    /// Release requires a separate explicit command.
113    Explicit,
114}
115
116/// Reconciliation capability.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ReconciliationMode {
120    /// Adapter can query or otherwise prove the outcome.
121    Queryable,
122    /// An external attestation can prove the outcome.
123    ExternallyAttested,
124    /// Human governance is required.
125    Human,
126    /// No authoritative reconciliation is available.
127    Impossible,
128}
129
130/// A named Flow definition.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct Definition {
134    /// Stable logical definition ID.
135    pub id: String,
136    /// Input JSON Schema.
137    pub input_schema: Value,
138    /// Output JSON Schema.
139    pub output_schema: Value,
140    /// Structured body.
141    pub body: Region,
142}
143
144/// A structured sequence with an explicit result expression.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct Region {
148    /// Ordered semantic operations.
149    #[serde(default)]
150    pub steps: Vec<Step>,
151    /// Region result.
152    pub result: Expression,
153}
154
155/// A stable operation site.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub struct Step {
158    /// Stable source-assigned site ID.
159    pub id: String,
160    /// Operation at the site.
161    #[serde(flatten)]
162    pub operation: Operation,
163}
164
165/// Frozen structured operations. Complex frontend syntax lowers to these five
166/// semantic boundaries.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168#[serde(tag = "op", rename_all = "snake_case")]
169pub enum Operation {
170    /// Call an abstract component.
171    Call {
172        /// Component contract ID.
173        component: String,
174        /// Typed input expression.
175        input: Expression,
176        /// Optional result binding.
177        #[serde(default, skip_serializing_if = "Option::is_none")]
178        bind: Option<String>,
179    },
180    /// Invoke another definition in the same immutable Plan.
181    Invoke {
182        /// Referenced definition ID.
183        definition: String,
184        /// Typed invocation input expression.
185        input: Expression,
186        /// Optional result binding.
187        #[serde(default, skip_serializing_if = "Option::is_none")]
188        bind: Option<String>,
189    },
190    /// Durably suspend.
191    Wait {
192        /// Wait description.
193        wait: WaitSpec,
194    },
195    /// Propose an abstract external effect.
196    Effect {
197        /// Effect contract ID.
198        effect: String,
199        /// Typed argument expression.
200        input: Expression,
201        /// Intentional occurrence key. Retries reuse it.
202        occurrence: String,
203        /// Optional result binding.
204        #[serde(default, skip_serializing_if = "Option::is_none")]
205        bind: Option<String>,
206    },
207    /// Execute a nested transactional scope.
208    Scope {
209        /// Scope behavior profile.
210        mode: ScopeMode,
211        /// Nested body.
212        body: Box<Region>,
213        /// Optional result binding.
214        #[serde(default, skip_serializing_if = "Option::is_none")]
215        bind: Option<String>,
216    },
217}
218
219/// Scope behavior profile.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum ScopeMode {
223    /// Normal nested state/evidence transaction.
224    Transactional,
225    /// Speculative branch; mutating effects remain staged until commit.
226    Speculative,
227}
228
229/// Durable wait descriptions.
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum WaitSpec {
233    /// Wait for an external signal key.
234    Signal {
235        /// Stable signal key.
236        key: String,
237        /// Whether at most one waiter may consume it.
238        #[serde(default)]
239        consume_once: bool,
240    },
241    /// Wait for a logical timer identifier.
242    Timer {
243        /// Stable timer ID. Wall-clock observation belongs in a receipt.
244        timer_id: String,
245    },
246    /// Wait for typed external input.
247    Input {
248        /// Correlation key.
249        correlation: String,
250        /// Input schema.
251        schema: Value,
252    },
253}
254
255/// Pure expressions over input, prior bindings, and literals.
256#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257#[serde(tag = "kind", rename_all = "snake_case")]
258pub enum Expression {
259    /// Definition input.
260    Input,
261    /// Literal canonical JSON.
262    Literal {
263        /// Literal value.
264        value: Value,
265    },
266    /// A prior binding.
267    Binding {
268        /// Binding name.
269        name: String,
270    },
271    /// An object constructed from expressions.
272    Object {
273        /// Deterministically ordered fields.
274        fields: BTreeMap<String, Expression>,
275    },
276    /// An array constructed from expressions.
277    Array {
278        /// Array items.
279        items: Vec<Expression>,
280    },
281}
282
283impl PlanCandidate {
284    /// Validate and content-address a Plan Candidate.
285    pub fn seal(self) -> Result<SealedPlan> {
286        self.validate()?;
287        let plan_id = content_id("cymule.plan/1", &self)?;
288        Ok(SealedPlan {
289            plan_id,
290            candidate: self,
291        })
292    }
293
294    /// Validate semantic structure before canonical identity is computed.
295    pub fn validate(&self) -> Result<()> {
296        if self.ir_version != IR_VERSION {
297            return Err(CoreError::Validation(format!(
298                "unsupported IR version {:?}; expected {IR_VERSION}",
299                self.ir_version
300            )));
301        }
302        validate_id("plan name", &self.name)?;
303
304        let component_ids = unique_contract_ids(
305            "component",
306            self.components.iter().map(|contract| contract.id.as_str()),
307        )?;
308        let effect_ids = unique_contract_ids(
309            "effect",
310            self.effects.iter().map(|contract| contract.id.as_str()),
311        )?;
312        for contract in &self.components {
313            validate_schema("component input", &contract.input_schema)?;
314            validate_schema("component output", &contract.output_schema)?;
315        }
316        for contract in &self.effects {
317            validate_schema("effect input", &contract.input_schema)?;
318            validate_schema("effect output", &contract.output_schema)?;
319            if contract.profile.mutation == MutationKind::Mutating
320                && contract.profile.dispatch == DispatchPolicy::Eager
321            {
322                return Err(CoreError::Validation(format!(
323                    "mutating effect {} cannot use eager dispatch",
324                    contract.id
325                )));
326            }
327        }
328
329        let definition_ids = unique_contract_ids(
330            "definition",
331            self.definitions
332                .iter()
333                .map(|definition| definition.id.as_str()),
334        )?;
335        if !definition_ids.contains(&self.entry) {
336            return Err(CoreError::Validation(format!(
337                "entry definition {:?} does not exist",
338                self.entry
339            )));
340        }
341
342        let mut sites = BTreeSet::new();
343        for definition in &self.definitions {
344            validate_schema("definition input", &definition.input_schema)?;
345            validate_schema("definition output", &definition.output_schema)?;
346            validate_region(
347                &definition.body,
348                &component_ids,
349                &effect_ids,
350                &definition_ids,
351                &mut sites,
352                &BTreeSet::new(),
353            )?;
354        }
355        Ok(())
356    }
357}
358
359impl SealedPlan {
360    /// Verify that the embedded candidate still matches its Plan ID.
361    pub fn verify(&self) -> Result<()> {
362        self.candidate.validate()?;
363        let expected = content_id("cymule.plan/1", &self.candidate)?;
364        if expected != self.plan_id {
365            return Err(CoreError::IdentityMismatch(format!(
366                "plan ID {} does not match {expected}",
367                self.plan_id
368            )));
369        }
370        Ok(())
371    }
372}
373
374fn unique_contract_ids<'a>(
375    kind: &str,
376    ids: impl Iterator<Item = &'a str>,
377) -> Result<BTreeSet<String>> {
378    let mut unique = BTreeSet::new();
379    for id in ids {
380        validate_id(kind, id)?;
381        if !unique.insert(id.to_owned()) {
382            return Err(CoreError::Validation(format!("duplicate {kind} ID {id:?}")));
383        }
384    }
385    Ok(unique)
386}
387
388fn validate_region(
389    region: &Region,
390    components: &BTreeSet<String>,
391    effects: &BTreeSet<String>,
392    definitions: &BTreeSet<String>,
393    sites: &mut BTreeSet<String>,
394    inherited: &BTreeSet<String>,
395) -> Result<()> {
396    let mut bindings = inherited.clone();
397    for step in &region.steps {
398        validate_id("step", &step.id)?;
399        if !sites.insert(step.id.clone()) {
400            return Err(CoreError::Validation(format!(
401                "duplicate stable step site {:?}",
402                step.id
403            )));
404        }
405
406        let result_binding: Option<&String> = match &step.operation {
407            Operation::Call {
408                component,
409                input,
410                bind,
411            } => {
412                if !components.contains(component) {
413                    return Err(CoreError::Validation(format!(
414                        "step {} references unknown component {component:?}",
415                        step.id
416                    )));
417                }
418                validate_expression(input, &bindings)?;
419                bind.as_ref()
420            }
421            Operation::Invoke {
422                definition,
423                input,
424                bind,
425            } => {
426                if !definitions.contains(definition) {
427                    return Err(CoreError::Validation(format!(
428                        "step {} references unknown definition {definition:?}",
429                        step.id
430                    )));
431                }
432                validate_expression(input, &bindings)?;
433                bind.as_ref()
434            }
435            Operation::Wait { wait } => {
436                validate_wait(wait)?;
437                None
438            }
439            Operation::Effect {
440                effect,
441                input,
442                occurrence,
443                bind,
444            } => {
445                if !effects.contains(effect) {
446                    return Err(CoreError::Validation(format!(
447                        "step {} references unknown effect {effect:?}",
448                        step.id
449                    )));
450                }
451                validate_id("occurrence", occurrence)?;
452                validate_expression(input, &bindings)?;
453                bind.as_ref()
454            }
455            Operation::Scope { body, bind, .. } => {
456                validate_region(body, components, effects, definitions, sites, &bindings)?;
457                bind.as_ref()
458            }
459        };
460
461        if let Some(binding) = result_binding {
462            validate_id("binding", binding)?;
463            if !bindings.insert(binding.clone()) {
464                return Err(CoreError::Validation(format!(
465                    "binding {binding:?} is assigned more than once"
466                )));
467            }
468        }
469    }
470    validate_expression(&region.result, &bindings)
471}
472
473fn validate_wait(wait: &WaitSpec) -> Result<()> {
474    match wait {
475        WaitSpec::Signal { key, .. } => validate_id("signal key", key),
476        WaitSpec::Timer { timer_id } => validate_id("timer", timer_id),
477        WaitSpec::Input {
478            correlation,
479            schema,
480        } => {
481            validate_id("input correlation", correlation)?;
482            validate_schema("wait input", schema)
483        }
484    }
485}
486
487fn validate_expression(expression: &Expression, bindings: &BTreeSet<String>) -> Result<()> {
488    match expression {
489        Expression::Input | Expression::Literal { .. } => Ok(()),
490        Expression::Binding { name } => {
491            if bindings.contains(name) {
492                Ok(())
493            } else {
494                Err(CoreError::Validation(format!(
495                    "expression references undefined binding {name:?}"
496                )))
497            }
498        }
499        Expression::Object { fields } => {
500            for expression in fields.values() {
501                validate_expression(expression, bindings)?;
502            }
503            Ok(())
504        }
505        Expression::Array { items } => {
506            for expression in items {
507                validate_expression(expression, bindings)?;
508            }
509            Ok(())
510        }
511    }
512}
513
514fn validate_schema(kind: &str, schema: &Value) -> Result<()> {
515    if !schema.is_object() && !schema.is_boolean() {
516        return Err(CoreError::Validation(format!(
517            "{kind} schema must be a JSON object or boolean"
518        )));
519    }
520    Ok(())
521}
522
523fn validate_id(kind: &str, id: &str) -> Result<()> {
524    let valid = !id.is_empty()
525        && id.len() <= 160
526        && id
527            .bytes()
528            .all(|byte| byte.is_ascii_alphanumeric() || b"._-/:".contains(&byte));
529    if valid {
530        Ok(())
531    } else {
532        Err(CoreError::Validation(format!(
533            "{kind} ID {id:?} must be 1..=160 ASCII identifier characters"
534        )))
535    }
536}