use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{CoreError, Result, content_id};
pub const IR_VERSION: &str = "cymule.ir/2";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanCandidate {
pub ir_version: String,
pub name: String,
pub entry: String,
#[serde(default)]
pub components: Vec<ComponentContract>,
#[serde(default)]
pub effects: Vec<EffectContract>,
pub definitions: Vec<Definition>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SealedPlan {
pub plan_id: String,
pub candidate: PlanCandidate,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ComponentContract {
pub id: String,
pub input_schema: Value,
pub output_schema: Value,
#[serde(default)]
pub requirements: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EffectContract {
pub id: String,
pub input_schema: Value,
pub output_schema: Value,
pub profile: EffectProfile,
#[serde(default)]
pub requirements: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EffectProfile {
pub mutation: MutationKind,
pub dispatch: DispatchPolicy,
pub reconciliation: ReconciliationMode,
#[serde(default)]
pub keyed_idempotency: bool,
#[serde(default)]
pub irreversible: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MutationKind {
Observational,
Mutating,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DispatchPolicy {
Eager,
OnScopeCommit,
Explicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReconciliationMode {
Queryable,
ExternallyAttested,
Human,
Impossible,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Definition {
pub id: String,
pub input_schema: Value,
pub output_schema: Value,
pub body: Region,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Region {
#[serde(default)]
pub steps: Vec<Step>,
pub result: Expression,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Step {
pub id: String,
#[serde(flatten)]
pub operation: Operation,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Operation {
Call {
component: String,
input: Expression,
#[serde(default, skip_serializing_if = "Option::is_none")]
bind: Option<String>,
},
Invoke {
definition: String,
input: Expression,
#[serde(default, skip_serializing_if = "Option::is_none")]
bind: Option<String>,
},
Wait {
wait: WaitSpec,
},
Effect {
effect: String,
input: Expression,
occurrence: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
bind: Option<String>,
},
Scope {
mode: ScopeMode,
body: Box<Region>,
#[serde(default, skip_serializing_if = "Option::is_none")]
bind: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScopeMode {
Transactional,
Speculative,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WaitSpec {
Signal {
key: String,
#[serde(default)]
consume_once: bool,
},
Timer {
timer_id: String,
},
Input {
correlation: String,
schema: Value,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Expression {
Input,
Literal {
value: Value,
},
Binding {
name: String,
},
Object {
fields: BTreeMap<String, Expression>,
},
Array {
items: Vec<Expression>,
},
}
impl PlanCandidate {
pub fn seal(self) -> Result<SealedPlan> {
self.validate()?;
let plan_id = content_id("cymule.plan/1", &self)?;
Ok(SealedPlan {
plan_id,
candidate: self,
})
}
pub fn validate(&self) -> Result<()> {
if self.ir_version != IR_VERSION {
return Err(CoreError::Validation(format!(
"unsupported IR version {:?}; expected {IR_VERSION}",
self.ir_version
)));
}
validate_id("plan name", &self.name)?;
let component_ids = unique_contract_ids(
"component",
self.components.iter().map(|contract| contract.id.as_str()),
)?;
let effect_ids = unique_contract_ids(
"effect",
self.effects.iter().map(|contract| contract.id.as_str()),
)?;
for contract in &self.components {
validate_schema("component input", &contract.input_schema)?;
validate_schema("component output", &contract.output_schema)?;
}
for contract in &self.effects {
validate_schema("effect input", &contract.input_schema)?;
validate_schema("effect output", &contract.output_schema)?;
if contract.profile.mutation == MutationKind::Mutating
&& contract.profile.dispatch == DispatchPolicy::Eager
{
return Err(CoreError::Validation(format!(
"mutating effect {} cannot use eager dispatch",
contract.id
)));
}
}
let definition_ids = unique_contract_ids(
"definition",
self.definitions
.iter()
.map(|definition| definition.id.as_str()),
)?;
if !definition_ids.contains(&self.entry) {
return Err(CoreError::Validation(format!(
"entry definition {:?} does not exist",
self.entry
)));
}
let mut sites = BTreeSet::new();
for definition in &self.definitions {
validate_schema("definition input", &definition.input_schema)?;
validate_schema("definition output", &definition.output_schema)?;
validate_region(
&definition.body,
&component_ids,
&effect_ids,
&definition_ids,
&mut sites,
&BTreeSet::new(),
)?;
}
Ok(())
}
}
impl SealedPlan {
pub fn verify(&self) -> Result<()> {
self.candidate.validate()?;
let expected = content_id("cymule.plan/1", &self.candidate)?;
if expected != self.plan_id {
return Err(CoreError::IdentityMismatch(format!(
"plan ID {} does not match {expected}",
self.plan_id
)));
}
Ok(())
}
}
fn unique_contract_ids<'a>(
kind: &str,
ids: impl Iterator<Item = &'a str>,
) -> Result<BTreeSet<String>> {
let mut unique = BTreeSet::new();
for id in ids {
validate_id(kind, id)?;
if !unique.insert(id.to_owned()) {
return Err(CoreError::Validation(format!("duplicate {kind} ID {id:?}")));
}
}
Ok(unique)
}
fn validate_region(
region: &Region,
components: &BTreeSet<String>,
effects: &BTreeSet<String>,
definitions: &BTreeSet<String>,
sites: &mut BTreeSet<String>,
inherited: &BTreeSet<String>,
) -> Result<()> {
let mut bindings = inherited.clone();
for step in ®ion.steps {
validate_id("step", &step.id)?;
if !sites.insert(step.id.clone()) {
return Err(CoreError::Validation(format!(
"duplicate stable step site {:?}",
step.id
)));
}
let result_binding: Option<&String> = match &step.operation {
Operation::Call {
component,
input,
bind,
} => {
if !components.contains(component) {
return Err(CoreError::Validation(format!(
"step {} references unknown component {component:?}",
step.id
)));
}
validate_expression(input, &bindings)?;
bind.as_ref()
}
Operation::Invoke {
definition,
input,
bind,
} => {
if !definitions.contains(definition) {
return Err(CoreError::Validation(format!(
"step {} references unknown definition {definition:?}",
step.id
)));
}
validate_expression(input, &bindings)?;
bind.as_ref()
}
Operation::Wait { wait } => {
validate_wait(wait)?;
None
}
Operation::Effect {
effect,
input,
occurrence,
bind,
} => {
if !effects.contains(effect) {
return Err(CoreError::Validation(format!(
"step {} references unknown effect {effect:?}",
step.id
)));
}
validate_id("occurrence", occurrence)?;
validate_expression(input, &bindings)?;
bind.as_ref()
}
Operation::Scope { body, bind, .. } => {
validate_region(body, components, effects, definitions, sites, &bindings)?;
bind.as_ref()
}
};
if let Some(binding) = result_binding {
validate_id("binding", binding)?;
if !bindings.insert(binding.clone()) {
return Err(CoreError::Validation(format!(
"binding {binding:?} is assigned more than once"
)));
}
}
}
validate_expression(®ion.result, &bindings)
}
fn validate_wait(wait: &WaitSpec) -> Result<()> {
match wait {
WaitSpec::Signal { key, .. } => validate_id("signal key", key),
WaitSpec::Timer { timer_id } => validate_id("timer", timer_id),
WaitSpec::Input {
correlation,
schema,
} => {
validate_id("input correlation", correlation)?;
validate_schema("wait input", schema)
}
}
}
fn validate_expression(expression: &Expression, bindings: &BTreeSet<String>) -> Result<()> {
match expression {
Expression::Input | Expression::Literal { .. } => Ok(()),
Expression::Binding { name } => {
if bindings.contains(name) {
Ok(())
} else {
Err(CoreError::Validation(format!(
"expression references undefined binding {name:?}"
)))
}
}
Expression::Object { fields } => {
for expression in fields.values() {
validate_expression(expression, bindings)?;
}
Ok(())
}
Expression::Array { items } => {
for expression in items {
validate_expression(expression, bindings)?;
}
Ok(())
}
}
}
fn validate_schema(kind: &str, schema: &Value) -> Result<()> {
if !schema.is_object() && !schema.is_boolean() {
return Err(CoreError::Validation(format!(
"{kind} schema must be a JSON object or boolean"
)));
}
Ok(())
}
fn validate_id(kind: &str, id: &str) -> Result<()> {
let valid = !id.is_empty()
&& id.len() <= 160
&& id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-/:".contains(&byte));
if valid {
Ok(())
} else {
Err(CoreError::Validation(format!(
"{kind} ID {id:?} must be 1..=160 ASCII identifier characters"
)))
}
}