use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::{CoreError, Result, canonical_digest, content_id};
pub const SEMANTIC_VERSION: &str = "cymule.semantic/1";
pub const EVENT_VERSION: &str = "cymule.event/1";
pub const COMMAND_VERSION: &str = "cymule.command/1";
pub const ROOT_SCOPE_ID: &str = "scope:root";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ArtifactRef {
pub artifact_id: String,
pub kind: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ArtifactRecord {
pub reference: ArtifactRef,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandEnvelope {
pub command_version: String,
pub command_id: String,
pub actor: String,
pub run_id: String,
#[serde(default)]
pub expected_precondition: Option<String>,
pub command: Command,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Command {
StartRun {
plan_id: String,
binding_context: String,
},
BeginAttempt {
attempt_id: String,
continuation_id: String,
occurrence_binding: String,
epoch: u64,
},
YieldAttempt {
attempt_id: String,
epoch: u64,
},
AdvanceEpoch,
OpenScope {
scope_id: String,
parent_scope: String,
},
ProposeEffect {
scope_id: String,
invocation_id: String,
site_id: String,
occurrence: String,
operation: String,
args: ArtifactRef,
occurrence_binding: String,
},
TransitionEffect {
intent_id: String,
transition: EffectTransition,
},
CommitScope {
scope_id: String,
},
AbortScope {
scope_id: String,
},
UpdateBinding {
binding_context: String,
},
RecordFact {
key: String,
value: String,
},
CompleteRun {
#[serde(default)]
result: Option<ArtifactRef>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum EffectTransition {
Prepare,
AuthorizeRelease,
StartDispatch,
Observe(WorldOutcome),
Reconcile(ReconciliationResolution),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Event {
pub event_id: String,
pub event_version: String,
pub command_id: String,
pub command_hash: String,
pub run_id: String,
pub parents: Vec<String>,
pub reads: BTreeSet<String>,
pub writes: BTreeSet<String>,
#[serde(default)]
pub coordination_key: Option<String>,
pub payload: EventPayload,
}
#[derive(Serialize)]
struct EventPreimage<'a> {
event_version: &'a str,
command_id: &'a str,
command_hash: &'a str,
run_id: &'a str,
parents: &'a [String],
reads: &'a BTreeSet<String>,
writes: &'a BTreeSet<String>,
coordination_key: &'a Option<String>,
payload: &'a EventPayload,
}
impl Event {
#[allow(clippy::too_many_arguments)]
pub fn new(
command_id: String,
command_hash: String,
run_id: String,
mut parents: Vec<String>,
reads: BTreeSet<String>,
writes: BTreeSet<String>,
coordination_key: Option<String>,
payload: EventPayload,
) -> Result<Self> {
parents.sort();
parents.dedup();
let preimage = EventPreimage {
event_version: EVENT_VERSION,
command_id: &command_id,
command_hash: &command_hash,
run_id: &run_id,
parents: &parents,
reads: &reads,
writes: &writes,
coordination_key: &coordination_key,
payload: &payload,
};
let event_id = content_id("cymule.event/1", &preimage)?;
Ok(Self {
event_id,
event_version: EVENT_VERSION.to_owned(),
command_id,
command_hash,
run_id,
parents,
reads,
writes,
coordination_key,
payload,
})
}
pub fn verify(&self) -> Result<()> {
if self.event_version != EVENT_VERSION {
return Err(CoreError::Validation(format!(
"unsupported event version {:?}",
self.event_version
)));
}
let preimage = EventPreimage {
event_version: &self.event_version,
command_id: &self.command_id,
command_hash: &self.command_hash,
run_id: &self.run_id,
parents: &self.parents,
reads: &self.reads,
writes: &self.writes,
coordination_key: &self.coordination_key,
payload: &self.payload,
};
let expected = content_id("cymule.event/1", &preimage)?;
if expected != self.event_id {
return Err(CoreError::IdentityMismatch(format!(
"event ID {} does not match {expected}",
self.event_id
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EventPayload {
RunStarted {
plan_id: String,
binding_context: String,
},
AttemptStarted {
attempt_id: String,
continuation_id: String,
occurrence_binding: String,
epoch: u64,
},
AttemptYielded {
attempt_id: String,
epoch: u64,
},
EpochAdvanced {
epoch: u64,
},
ScopeOpened {
scope_id: String,
parent_scope: String,
},
EffectProposed {
intent_id: String,
scope_id: String,
operation: String,
mutating: bool,
args: ArtifactRef,
occurrence_binding: String,
},
EffectTransitioned {
intent_id: String,
transition: EffectTransition,
},
ScopeCommitted {
scope_id: String,
obligations: Vec<ObligationProjection>,
},
ScopeAborted {
scope_id: String,
},
BindingUpdated {
previous: String,
current: String,
},
FactRecorded {
key: String,
value: String,
},
RunCompleted {
result: Option<ArtifactRef>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandReceiptStatus {
Applied,
Conflict,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandReceipt {
pub command_id: String,
pub status: CommandReceiptStatus,
#[serde(default)]
pub event_id: Option<String>,
#[serde(default)]
pub error_code: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub observed_precondition: Option<String>,
#[serde(default)]
pub current_precondition: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Projection {
pub runs: BTreeMap<String, RunProjection>,
pub facts: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunProjection {
pub run_id: String,
pub initial_plan: String,
pub current_plan: String,
pub initial_binding_context: String,
pub current_binding_context: String,
pub epoch: u64,
pub status: RunStatus,
pub scopes: BTreeMap<String, ScopeProjection>,
pub effects: BTreeMap<String, EffectProjection>,
pub obligations: BTreeMap<String, ObligationProjection>,
pub attempts: BTreeMap<String, AttemptProjection>,
pub result: Option<ArtifactRef>,
pub last_event: String,
}
impl RunProjection {
pub fn precondition_token(&self) -> String {
format!("pre:{}:{}", self.epoch, self.last_event)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Active,
Waiting,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScopeProjection {
pub scope_id: String,
pub parent_scope: Option<String>,
pub status: ScopeStatus,
pub intents: BTreeSet<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScopeStatus {
Open,
ClosedCommitted,
ClosedAborted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EffectPhase {
Admitted,
Prepared,
ReleaseAuthorized,
DispatchStarted,
CancelledBeforeRelease,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorldOutcome {
Unobserved,
Applied,
NotApplied,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReconciliationResolution {
ResolvedApplied,
ResolvedNotApplied,
StillUnknown,
GovernanceRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReconciliationState {
NotRequired,
Pending,
Resolved,
GovernanceRequired,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EffectProjection {
pub intent_id: String,
pub scope_id: String,
pub operation: String,
pub mutating: bool,
pub args: ArtifactRef,
pub occurrence_binding: String,
pub phase: EffectPhase,
pub outcome: WorldOutcome,
pub reconciliation: ReconciliationState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ObligationProjection {
pub obligation_id: String,
pub intent_id: String,
pub blocking: bool,
pub resolved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AttemptProjection {
pub attempt_id: String,
pub continuation_id: String,
pub occurrence_binding: String,
pub epoch: u64,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ReplayAvailability {
Exact,
ProjectionOnly {
missing: Vec<String>,
},
Unavailable {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompactionCertificate {
pub source_frontier: Vec<String>,
pub projection_digest: String,
pub unresolved_obligations: Vec<String>,
pub occurrence_bindings: Vec<String>,
pub replay_availability: ReplayAvailability,
}
impl Projection {
pub fn apply_event(&mut self, event: &Event) -> Result<()> {
match &event.payload {
EventPayload::RunStarted {
plan_id,
binding_context,
} => {
if self.runs.contains_key(&event.run_id) {
return Err(CoreError::IllegalTransition(format!(
"Run {} already exists",
event.run_id
)));
}
let mut scopes = BTreeMap::new();
scopes.insert(
ROOT_SCOPE_ID.to_owned(),
ScopeProjection {
scope_id: ROOT_SCOPE_ID.to_owned(),
parent_scope: None,
status: ScopeStatus::Open,
intents: BTreeSet::new(),
},
);
self.runs.insert(
event.run_id.clone(),
RunProjection {
run_id: event.run_id.clone(),
initial_plan: plan_id.clone(),
current_plan: plan_id.clone(),
initial_binding_context: binding_context.clone(),
current_binding_context: binding_context.clone(),
epoch: 0,
status: RunStatus::Active,
scopes,
effects: BTreeMap::new(),
obligations: BTreeMap::new(),
attempts: BTreeMap::new(),
result: None,
last_event: event.event_id.clone(),
},
);
return Ok(());
}
EventPayload::FactRecorded { key, value } => {
if let Some(existing) = self.facts.get(key) {
if existing != value {
return Err(CoreError::IllegalTransition(format!(
"fact {key:?} already has a different value"
)));
}
} else {
self.facts.insert(key.clone(), value.clone());
}
}
_ => {}
}
let run = self
.runs
.get_mut(&event.run_id)
.ok_or_else(|| CoreError::NotFound(format!("Run {} does not exist", event.run_id)))?;
if run.status == RunStatus::Completed {
return Err(CoreError::IllegalTransition(format!(
"Run {} is already completed",
event.run_id
)));
}
match &event.payload {
EventPayload::RunStarted { .. } | EventPayload::FactRecorded { .. } => {}
EventPayload::AttemptStarted {
attempt_id,
continuation_id,
occurrence_binding,
epoch,
} => {
if *epoch != run.epoch {
return Err(CoreError::IllegalTransition(format!(
"attempt epoch {epoch} does not match Run epoch {}",
run.epoch
)));
}
if run.attempts.contains_key(attempt_id) {
return Err(CoreError::IllegalTransition(format!(
"attempt {attempt_id} already exists"
)));
}
run.attempts.insert(
attempt_id.clone(),
AttemptProjection {
attempt_id: attempt_id.clone(),
continuation_id: continuation_id.clone(),
occurrence_binding: occurrence_binding.clone(),
epoch: *epoch,
active: true,
},
);
}
EventPayload::AttemptYielded { attempt_id, epoch } => {
let attempt = run.attempts.get_mut(attempt_id).ok_or_else(|| {
CoreError::NotFound(format!("attempt {attempt_id} does not exist"))
})?;
if !attempt.active || attempt.epoch != *epoch {
return Err(CoreError::IllegalTransition(format!(
"attempt {attempt_id} is stale or inactive"
)));
}
attempt.active = false;
}
EventPayload::EpochAdvanced { epoch } => {
if *epoch != run.epoch + 1 {
return Err(CoreError::IllegalTransition(format!(
"epoch must advance from {} to {}; received {epoch}",
run.epoch,
run.epoch + 1
)));
}
run.epoch = *epoch;
for attempt in run.attempts.values_mut() {
attempt.active = false;
}
}
EventPayload::ScopeOpened {
scope_id,
parent_scope,
} => {
if run.scopes.contains_key(scope_id) {
return Err(CoreError::IllegalTransition(format!(
"scope {scope_id} already exists"
)));
}
let parent = run.scopes.get(parent_scope).ok_or_else(|| {
CoreError::NotFound(format!("parent scope {parent_scope} does not exist"))
})?;
if parent.status != ScopeStatus::Open {
return Err(CoreError::IllegalTransition(format!(
"parent scope {parent_scope} is not open"
)));
}
run.scopes.insert(
scope_id.clone(),
ScopeProjection {
scope_id: scope_id.clone(),
parent_scope: Some(parent_scope.clone()),
status: ScopeStatus::Open,
intents: BTreeSet::new(),
},
);
}
EventPayload::EffectProposed {
intent_id,
scope_id,
operation,
mutating,
args,
occurrence_binding,
} => {
if run.effects.contains_key(intent_id) {
return Err(CoreError::IllegalTransition(format!(
"effect intent {intent_id} already exists"
)));
}
let scope = run.scopes.get_mut(scope_id).ok_or_else(|| {
CoreError::NotFound(format!("scope {scope_id} does not exist"))
})?;
if scope.status != ScopeStatus::Open {
return Err(CoreError::IllegalTransition(format!(
"scope {scope_id} is not open"
)));
}
scope.intents.insert(intent_id.clone());
run.effects.insert(
intent_id.clone(),
EffectProjection {
intent_id: intent_id.clone(),
scope_id: scope_id.clone(),
operation: operation.clone(),
mutating: *mutating,
args: args.clone(),
occurrence_binding: occurrence_binding.clone(),
phase: EffectPhase::Admitted,
outcome: WorldOutcome::Unobserved,
reconciliation: ReconciliationState::NotRequired,
},
);
}
EventPayload::EffectTransitioned {
intent_id,
transition,
} => {
let effect = run.effects.get_mut(intent_id).ok_or_else(|| {
CoreError::NotFound(format!("effect {intent_id} does not exist"))
})?;
apply_effect_transition(effect, transition)?;
update_obligation(run, intent_id);
}
EventPayload::ScopeCommitted {
scope_id,
obligations,
} => {
let scope = run.scopes.get_mut(scope_id).ok_or_else(|| {
CoreError::NotFound(format!("scope {scope_id} does not exist"))
})?;
if scope.status != ScopeStatus::Open {
return Err(CoreError::IllegalTransition(format!(
"scope {scope_id} is not open"
)));
}
scope.status = ScopeStatus::ClosedCommitted;
for obligation in obligations {
if !scope.intents.contains(&obligation.intent_id) {
return Err(CoreError::IllegalTransition(format!(
"obligation {} does not belong to scope {scope_id}",
obligation.obligation_id
)));
}
if run
.obligations
.insert(obligation.obligation_id.clone(), obligation.clone())
.is_some()
{
return Err(CoreError::IllegalTransition(format!(
"obligation {} already exists",
obligation.obligation_id
)));
}
}
}
EventPayload::ScopeAborted { scope_id } => {
let scope = run.scopes.get_mut(scope_id).ok_or_else(|| {
CoreError::NotFound(format!("scope {scope_id} does not exist"))
})?;
if scope.status != ScopeStatus::Open {
return Err(CoreError::IllegalTransition(format!(
"scope {scope_id} is not open"
)));
}
for intent_id in &scope.intents {
let effect = run.effects.get_mut(intent_id).ok_or_else(|| {
CoreError::NotFound(format!("effect {intent_id} does not exist"))
})?;
if matches!(
effect.phase,
EffectPhase::ReleaseAuthorized | EffectPhase::DispatchStarted
) {
return Err(CoreError::IllegalTransition(format!(
"scope {scope_id} cannot abort after effect release"
)));
}
effect.phase = EffectPhase::CancelledBeforeRelease;
}
scope.status = ScopeStatus::ClosedAborted;
}
EventPayload::BindingUpdated { previous, current } => {
if &run.current_binding_context != previous {
return Err(CoreError::IllegalTransition(format!(
"binding context changed from expected {previous}"
)));
}
run.current_binding_context.clone_from(current);
}
EventPayload::RunCompleted { result } => {
let unresolved = run
.obligations
.values()
.any(|obligation| obligation.blocking && !obligation.resolved);
if unresolved {
return Err(CoreError::IllegalTransition(
"Run has unresolved blocking effect obligations".to_owned(),
));
}
if run
.scopes
.values()
.any(|scope| scope.status == ScopeStatus::Open)
{
return Err(CoreError::IllegalTransition(
"Run has an open scope".to_owned(),
));
}
run.status = RunStatus::Completed;
run.result.clone_from(result);
}
}
run.last_event.clone_from(&event.event_id);
Ok(())
}
pub fn digest(&self) -> Result<String> {
canonical_digest(self)
}
}
fn apply_effect_transition(
effect: &mut EffectProjection,
transition: &EffectTransition,
) -> Result<()> {
match transition {
EffectTransition::Prepare if effect.phase == EffectPhase::Admitted => {
effect.phase = EffectPhase::Prepared;
}
EffectTransition::AuthorizeRelease if effect.phase == EffectPhase::Prepared => {
effect.phase = EffectPhase::ReleaseAuthorized;
}
EffectTransition::StartDispatch if effect.phase == EffectPhase::ReleaseAuthorized => {
effect.phase = EffectPhase::DispatchStarted;
}
EffectTransition::Observe(outcome)
if effect.phase == EffectPhase::DispatchStarted
&& effect.outcome == WorldOutcome::Unobserved
&& *outcome != WorldOutcome::Unobserved =>
{
effect.outcome = *outcome;
effect.reconciliation = if *outcome == WorldOutcome::Unknown {
ReconciliationState::Pending
} else {
ReconciliationState::NotRequired
};
}
EffectTransition::Reconcile(resolution)
if effect.phase == EffectPhase::DispatchStarted
&& effect.outcome == WorldOutcome::Unknown =>
{
match resolution {
ReconciliationResolution::ResolvedApplied => {
effect.outcome = WorldOutcome::Applied;
effect.reconciliation = ReconciliationState::Resolved;
}
ReconciliationResolution::ResolvedNotApplied => {
effect.outcome = WorldOutcome::NotApplied;
effect.reconciliation = ReconciliationState::Resolved;
}
ReconciliationResolution::StillUnknown => {
effect.reconciliation = ReconciliationState::Pending;
}
ReconciliationResolution::GovernanceRequired => {
effect.reconciliation = ReconciliationState::GovernanceRequired;
}
}
}
_ => {
return Err(CoreError::IllegalTransition(format!(
"illegal effect transition {transition:?} from phase {:?}, outcome {:?}, reconciliation {:?}",
effect.phase, effect.outcome, effect.reconciliation
)));
}
}
Ok(())
}
fn update_obligation(run: &mut RunProjection, intent_id: &str) {
let resolved = run.effects.get(intent_id).is_some_and(|effect| {
matches!(
effect.outcome,
WorldOutcome::Applied | WorldOutcome::NotApplied
)
});
for obligation in run
.obligations
.values_mut()
.filter(|obligation| obligation.intent_id == intent_id)
{
obligation.resolved = resolved;
}
}
#[allow(clippy::too_many_arguments)]
pub fn effect_intent_id(
run_id: &str,
invocation_id: &str,
site_id: &str,
scope_id: &str,
scope_epoch: u64,
occurrence: &str,
args: &ArtifactRef,
effect_schema_version: &str,
) -> Result<String> {
#[derive(Serialize)]
struct IntentPreimage<'a> {
run_id: &'a str,
invocation_id: &'a str,
site_id: &'a str,
scope_id: &'a str,
scope_epoch: u64,
occurrence: &'a str,
args: &'a ArtifactRef,
effect_schema_version: &'a str,
}
content_id(
"cymule.effect-intent/1",
&IntentPreimage {
run_id,
invocation_id,
site_id,
scope_id,
scope_epoch,
occurrence,
args,
effect_schema_version,
},
)
}
pub fn effect_obligation_id(intent_id: &str) -> Result<String> {
content_id("cymule.effect-obligation/1", &intent_id)
}