1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{CoreError, Result, content_id};
7
8pub const IR_VERSION: &str = "cymule.ir/2";
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct PlanCandidate {
15 pub ir_version: String,
17 pub name: String,
19 pub entry: String,
21 #[serde(default)]
23 pub components: Vec<ComponentContract>,
24 #[serde(default)]
26 pub effects: Vec<EffectContract>,
27 pub definitions: Vec<Definition>,
29 #[serde(default)]
31 pub metadata: BTreeMap<String, String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct SealedPlan {
38 pub plan_id: String,
40 pub candidate: PlanCandidate,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct ComponentContract {
48 pub id: String,
50 pub input_schema: Value,
52 pub output_schema: Value,
54 #[serde(default)]
56 pub requirements: BTreeMap<String, String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct EffectContract {
63 pub id: String,
65 pub input_schema: Value,
67 pub output_schema: Value,
69 pub profile: EffectProfile,
71 #[serde(default)]
73 pub requirements: BTreeMap<String, String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct EffectProfile {
80 pub mutation: MutationKind,
82 pub dispatch: DispatchPolicy,
84 pub reconciliation: ReconciliationMode,
86 #[serde(default)]
88 pub keyed_idempotency: bool,
89 #[serde(default)]
91 pub irreversible: bool,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum MutationKind {
98 Observational,
100 Mutating,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum DispatchPolicy {
108 Eager,
110 OnScopeCommit,
112 Explicit,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ReconciliationMode {
120 Queryable,
122 ExternallyAttested,
124 Human,
126 Impossible,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct Definition {
134 pub id: String,
136 pub input_schema: Value,
138 pub output_schema: Value,
140 pub body: Region,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[serde(deny_unknown_fields)]
147pub struct Region {
148 #[serde(default)]
150 pub steps: Vec<Step>,
151 pub result: Expression,
153}
154
155#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub struct Step {
158 pub id: String,
160 #[serde(flatten)]
162 pub operation: Operation,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168#[serde(tag = "op", rename_all = "snake_case")]
169pub enum Operation {
170 Call {
172 component: String,
174 input: Expression,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 bind: Option<String>,
179 },
180 Invoke {
182 definition: String,
184 input: Expression,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 bind: Option<String>,
189 },
190 Wait {
192 wait: WaitSpec,
194 },
195 Effect {
197 effect: String,
199 input: Expression,
201 occurrence: String,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 bind: Option<String>,
206 },
207 Scope {
209 mode: ScopeMode,
211 body: Box<Region>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 bind: Option<String>,
216 },
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum ScopeMode {
223 Transactional,
225 Speculative,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum WaitSpec {
233 Signal {
235 key: String,
237 #[serde(default)]
239 consume_once: bool,
240 },
241 Timer {
243 timer_id: String,
245 },
246 Input {
248 correlation: String,
250 schema: Value,
252 },
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
257#[serde(tag = "kind", rename_all = "snake_case")]
258pub enum Expression {
259 Input,
261 Literal {
263 value: Value,
265 },
266 Binding {
268 name: String,
270 },
271 Object {
273 fields: BTreeMap<String, Expression>,
275 },
276 Array {
278 items: Vec<Expression>,
280 },
281}
282
283impl PlanCandidate {
284 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 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 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 ®ion.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(®ion.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}