Skip to main content

cymule_core/
machine.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::ir::{EffectContract, MutationKind};
4use crate::model::{effect_intent_id, effect_obligation_id};
5use crate::{
6    ArtifactRecord, ArtifactRef, COMMAND_VERSION, Command, CommandEnvelope, CommandReceipt,
7    CommandReceiptStatus, CoreError, Event, EventPayload, ObligationProjection, PlanCandidate,
8    Projection, ReplayAvailability, Result, SealedPlan, WorldOutcome, canonical_digest, content_id,
9    sha256_bytes,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(deny_unknown_fields)]
14struct CommandRecord {
15    semantic_hash: String,
16    receipt: CommandReceipt,
17}
18
19/// One verified canonical base projection replacing a causally closed prefix.
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct MachineBaseSnapshot {
23    /// Content digest of all compacted prefix events and any prior base.
24    pub prefix_digest: String,
25    /// Exact identities in the compacted causal prefix.
26    pub compacted_event_ids: BTreeSet<String>,
27    /// Projection after applying the complete compacted prefix.
28    pub projection: Projection,
29    /// Digest that authenticates the retained projection bytes.
30    pub projection_digest: String,
31}
32
33impl MachineBaseSnapshot {
34    fn verify(&self) -> Result<()> {
35        if !self.prefix_digest.starts_with("sha256:")
36            || self.prefix_digest.len() != "sha256:".len() + 64
37            || self.compacted_event_ids.is_empty()
38            || self.compacted_event_ids.iter().any(String::is_empty)
39        {
40            return Err(CoreError::Validation(
41                "machine base snapshot has malformed prefix evidence".to_owned(),
42            ));
43        }
44        let expected = self.projection.digest()?;
45        if self.projection_digest != expected {
46            return Err(CoreError::IdentityMismatch(format!(
47                "machine base projection digest {} does not match {expected}",
48                self.projection_digest
49            )));
50        }
51        for run in self.projection.runs.values() {
52            if !self.compacted_event_ids.contains(&run.last_event) {
53                return Err(CoreError::Causal(format!(
54                    "machine base Run {} ends at an unretained event {}",
55                    run.run_id, run.last_event
56                )));
57            }
58        }
59        Ok(())
60    }
61}
62
63/// Portable evidence returned after compacting one canonical event prefix.
64#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct MachineCompaction {
67    /// Content-addressed base snapshot identity.
68    pub base_id: String,
69    /// Cumulative number of compacted event identities.
70    pub compacted_events: u64,
71    /// Number of full suffix Events retained for resume.
72    pub retained_events: u64,
73    /// Causal frontier connecting the base to retained execution.
74    pub causal_frontier: BTreeSet<String>,
75    /// Authenticated base projection digest.
76    pub projection_digest: String,
77}
78
79/// Portable, provider-neutral snapshot of canonical inputs and optional base.
80#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct MachineSnapshot {
83    /// Snapshot schema version.
84    pub snapshot_version: String,
85    /// Sealed Plans in content-ID order.
86    pub plans: Vec<SealedPlan>,
87    /// Immutable Artifacts in content-ID order.
88    pub artifacts: Vec<ArtifactRecord>,
89    /// Optional verified projection for a compacted causal prefix.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub base: Option<MachineBaseSnapshot>,
92    /// Canonical suffix Events in admitted causal order.
93    pub events: Vec<Event>,
94    /// Command semantic hashes and receipts for idempotent recovery.
95    commands: BTreeMap<String, CommandRecord>,
96}
97
98impl MachineSnapshot {
99    /// Current snapshot schema version.
100    pub const VERSION: &'static str = "cymule.machine-snapshot/2";
101
102    /// Content digest used by conditional durable-store writes.
103    pub fn digest(&self) -> Result<String> {
104        canonical_digest(self)
105    }
106
107    /// Stable content digests for idempotent command records, keyed by command
108    /// identity. Durable layers use this to validate an exact canonical delta
109    /// without exposing the private command-record representation.
110    pub fn command_digests(&self) -> Result<BTreeMap<String, String>> {
111        self.commands
112            .iter()
113            .map(|(command_id, record)| {
114                canonical_digest(record).map(|digest| (command_id.clone(), digest))
115            })
116            .collect()
117    }
118}
119
120/// In-memory reference machine for the Semantic Interpreter and Embedded
121/// profiles. All ambient I/O belongs in higher-level runtime crates.
122#[derive(Debug, Clone, Default)]
123pub struct Machine {
124    plans: BTreeMap<String, SealedPlan>,
125    artifacts: BTreeMap<String, ArtifactRecord>,
126    events: BTreeMap<String, Event>,
127    event_order: Vec<String>,
128    base: Option<MachineBaseSnapshot>,
129    compacted_event_ids: BTreeSet<String>,
130    projection: Projection,
131    commands: BTreeMap<String, CommandRecord>,
132}
133
134impl Machine {
135    /// Create an empty semantic machine.
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Export canonical inputs for durable persistence.
141    pub fn snapshot(&self) -> MachineSnapshot {
142        MachineSnapshot {
143            snapshot_version: MachineSnapshot::VERSION.to_owned(),
144            plans: self.plans.values().cloned().collect(),
145            artifacts: self.artifacts.values().cloned().collect(),
146            base: self.base.clone(),
147            events: self.events().cloned().collect(),
148            commands: self.commands.clone(),
149        }
150    }
151
152    /// Restore a Machine and deterministically rebuild all projections.
153    pub fn restore(snapshot: MachineSnapshot) -> Result<Self> {
154        if snapshot.snapshot_version != MachineSnapshot::VERSION
155            && snapshot.snapshot_version != "cymule.machine-snapshot/1"
156        {
157            return Err(CoreError::Validation(format!(
158                "unsupported machine snapshot version {:?}",
159                snapshot.snapshot_version
160            )));
161        }
162        let mut machine = Self::new();
163        for plan in snapshot.plans {
164            machine.insert_plan(plan)?;
165        }
166        for artifact in snapshot.artifacts {
167            let restored = machine.put_artifact(artifact.reference.kind.clone(), artifact.bytes);
168            if restored != artifact.reference {
169                return Err(CoreError::IdentityMismatch(format!(
170                    "artifact ID {} does not match its bytes",
171                    artifact.reference.artifact_id
172                )));
173            }
174        }
175        if let Some(base) = snapshot.base {
176            base.verify()?;
177            machine.projection = base.projection.clone();
178            machine
179                .compacted_event_ids
180                .clone_from(&base.compacted_event_ids);
181            machine.base = Some(base);
182        }
183        for event in snapshot.events {
184            machine.append_event(event)?;
185        }
186        for (command_id, record) in &snapshot.commands {
187            if record.receipt.command_id != *command_id {
188                return Err(CoreError::IdentityMismatch(format!(
189                    "command snapshot key {command_id} does not match its receipt"
190                )));
191            }
192            if let Some(event_id) = &record.receipt.event_id
193                && !machine.events.contains_key(event_id)
194                && !machine.compacted_event_ids.contains(event_id)
195            {
196                return Err(CoreError::NotFound(format!(
197                    "command {command_id} references missing event {event_id}"
198                )));
199            }
200        }
201        machine.commands = snapshot.commands;
202        machine.verify_replay()?;
203        Ok(machine)
204    }
205
206    /// Compact a causally closed event prefix and retain a full suffix.
207    pub fn compact_event_history(&mut self, retain_suffix: usize) -> Result<MachineCompaction> {
208        if retain_suffix >= self.event_order.len() {
209            return Err(CoreError::Validation(
210                "event compaction must remove at least one retained Event".to_owned(),
211            ));
212        }
213        let cut = self.event_order.len() - retain_suffix;
214        let prefix_ids = self.event_order[..cut].to_vec();
215        let prefix: Vec<Event> = prefix_ids
216            .iter()
217            .map(|event_id| {
218                self.events
219                    .get(event_id)
220                    .expect("event order references existing Event")
221                    .clone()
222            })
223            .collect();
224        let mut projection = self
225            .base
226            .as_ref()
227            .map(|base| base.projection.clone())
228            .unwrap_or_default();
229        for event in &prefix {
230            projection.apply_event(event)?;
231        }
232        let prior_digest = self.base.as_ref().map(|base| base.prefix_digest.as_str());
233        let prefix_digest = content_id("cymule.machine-event-prefix/1", &(prior_digest, &prefix))?;
234        let mut compacted_event_ids = self.compacted_event_ids.clone();
235        compacted_event_ids.extend(prefix_ids.iter().cloned());
236        let projection_digest = projection.digest()?;
237        let base = MachineBaseSnapshot {
238            prefix_digest,
239            compacted_event_ids: compacted_event_ids.clone(),
240            projection,
241            projection_digest: projection_digest.clone(),
242        };
243        base.verify()?;
244        let base_id = content_id("cymule.machine-base/1", &base)?;
245        for event_id in &prefix_ids {
246            self.events.remove(event_id);
247        }
248        self.event_order.drain(..cut);
249        self.compacted_event_ids = compacted_event_ids;
250        self.base = Some(base);
251        let causal_frontier = self.compaction_frontier();
252        Ok(MachineCompaction {
253            base_id,
254            compacted_events: u64::try_from(self.compacted_event_ids.len())
255                .map_err(|error| CoreError::Validation(error.to_string()))?,
256            retained_events: u64::try_from(self.event_order.len())
257                .map_err(|error| CoreError::Validation(error.to_string()))?,
258            causal_frontier,
259            projection_digest,
260        })
261    }
262
263    /// Validate, seal, and store a Plan Candidate.
264    pub fn seal_plan(&mut self, candidate: PlanCandidate) -> Result<SealedPlan> {
265        let plan = candidate.seal()?;
266        self.insert_plan(plan.clone())?;
267        Ok(plan)
268    }
269
270    /// Insert and verify an already sealed plan.
271    pub fn insert_plan(&mut self, plan: SealedPlan) -> Result<()> {
272        plan.verify()?;
273        if let Some(existing) = self.plans.get(&plan.plan_id) {
274            if existing != &plan {
275                return Err(CoreError::IdentityMismatch(format!(
276                    "plan {} already exists with different content",
277                    plan.plan_id
278                )));
279            }
280            return Ok(());
281        }
282        self.plans.insert(plan.plan_id.clone(), plan);
283        Ok(())
284    }
285
286    /// Read a sealed plan.
287    pub fn plan(&self, plan_id: &str) -> Option<&SealedPlan> {
288        self.plans.get(plan_id)
289    }
290
291    /// Store immutable typed bytes and return their content reference.
292    pub fn put_artifact(&mut self, kind: impl Into<String>, bytes: Vec<u8>) -> ArtifactRef {
293        let kind = kind.into();
294        let mut preimage = Vec::with_capacity(kind.len() + bytes.len() + 20);
295        preimage.extend_from_slice(b"cymule.artifact/1\0");
296        preimage.extend_from_slice(kind.as_bytes());
297        preimage.push(0);
298        preimage.extend_from_slice(&bytes);
299        let reference = ArtifactRef {
300            artifact_id: format!("sha256:{}", sha256_bytes(&preimage)),
301            kind,
302        };
303        self.artifacts
304            .entry(reference.artifact_id.clone())
305            .or_insert_with(|| ArtifactRecord {
306                reference: reference.clone(),
307                bytes,
308            });
309        reference
310    }
311
312    /// Read an immutable artifact.
313    pub fn artifact(&self, reference: &ArtifactRef) -> Option<&ArtifactRecord> {
314        self.artifacts
315            .get(&reference.artifact_id)
316            .filter(|record| record.reference.kind == reference.kind)
317    }
318
319    /// Remove an artifact to exercise retention behavior in conformance tests.
320    pub fn remove_artifact_for_test(&mut self, artifact_id: &str) -> Option<ArtifactRecord> {
321        self.artifacts.remove(artifact_id)
322    }
323
324    /// Classify replay capability for a required artifact set.
325    pub fn replay_availability(&self, required: &[ArtifactRef]) -> ReplayAvailability {
326        let missing: Vec<String> = required
327            .iter()
328            .filter(|reference| self.artifact(reference).is_none())
329            .map(|reference| reference.artifact_id.clone())
330            .collect();
331        if missing.is_empty() {
332            ReplayAvailability::Exact
333        } else if self.events.is_empty() {
334            ReplayAvailability::Unavailable {
335                reason: "canonical event history is unavailable".to_owned(),
336            }
337        } else {
338            ReplayAvailability::ProjectionOnly { missing }
339        }
340    }
341
342    /// Admit an idempotent command and reduce its canonical event.
343    pub fn submit(&mut self, envelope: CommandEnvelope) -> Result<CommandReceipt> {
344        validate_envelope(&envelope)?;
345        let semantic_hash = canonical_digest(&envelope)?;
346        if let Some(record) = self.commands.get(&envelope.command_id) {
347            if record.semantic_hash == semantic_hash {
348                return Ok(record.receipt.clone());
349            }
350            return Err(CoreError::CommandReuse(format!(
351                "command ID {} was already used with different semantics",
352                envelope.command_id
353            )));
354        }
355
356        let current_precondition = self
357            .projection
358            .runs
359            .get(&envelope.run_id)
360            .map(crate::RunProjection::precondition_token);
361        if !matches!(envelope.command, Command::StartRun { .. }) {
362            let observed = envelope.expected_precondition.clone().ok_or_else(|| {
363                CoreError::Validation("mutating commands require expected_precondition".to_owned())
364            })?;
365            if Some(&observed) != current_precondition.as_ref() {
366                let receipt = CommandReceipt {
367                    command_id: envelope.command_id.clone(),
368                    status: CommandReceiptStatus::Conflict,
369                    event_id: None,
370                    error_code: Some("stale_action".to_owned()),
371                    message: Some("the Run changed after the caller's view".to_owned()),
372                    observed_precondition: Some(observed),
373                    current_precondition,
374                };
375                self.commands.insert(
376                    envelope.command_id,
377                    CommandRecord {
378                        semantic_hash,
379                        receipt: receipt.clone(),
380                    },
381                );
382                return Ok(receipt);
383            }
384        }
385
386        let payload = self.admit_command(&envelope)?;
387        let (reads, writes, coordination_key) = footprints(&envelope.run_id, &payload);
388        let parents = self
389            .projection
390            .runs
391            .get(&envelope.run_id)
392            .map_or_else(Vec::new, |run| vec![run.last_event.clone()]);
393        let event = Event::new(
394            envelope.command_id.clone(),
395            semantic_hash.clone(),
396            envelope.run_id.clone(),
397            parents,
398            reads,
399            writes,
400            coordination_key,
401            payload,
402        )?;
403        self.append_event(event.clone())?;
404        let receipt = CommandReceipt {
405            command_id: envelope.command_id.clone(),
406            status: CommandReceiptStatus::Applied,
407            event_id: Some(event.event_id.clone()),
408            error_code: None,
409            message: None,
410            observed_precondition: envelope.expected_precondition,
411            current_precondition: self
412                .projection
413                .runs
414                .get(&envelope.run_id)
415                .map(crate::RunProjection::precondition_token),
416        };
417        self.commands.insert(
418            envelope.command_id,
419            CommandRecord {
420                semantic_hash,
421                receipt: receipt.clone(),
422            },
423        );
424        Ok(receipt)
425    }
426
427    /// Append a trusted event after identity, parent, and transition validation.
428    pub fn append_event(&mut self, event: Event) -> Result<()> {
429        event.verify()?;
430        if self.compacted_event_ids.contains(&event.event_id) {
431            return Err(CoreError::IdentityMismatch(format!(
432                "event {} belongs to the compacted prefix",
433                event.event_id
434            )));
435        }
436        if let Some(existing) = self.events.get(&event.event_id) {
437            if existing == &event {
438                return Ok(());
439            }
440            return Err(CoreError::IdentityMismatch(format!(
441                "event {} already exists with different content",
442                event.event_id
443            )));
444        }
445        for parent in &event.parents {
446            if !self.events.contains_key(parent) && !self.compacted_event_ids.contains(parent) {
447                return Err(CoreError::Causal(format!(
448                    "event {} references missing parent {parent}",
449                    event.event_id
450                )));
451            }
452        }
453        let mut next = self.projection.clone();
454        next.apply_event(&event)?;
455        self.projection = next;
456        self.event_order.push(event.event_id.clone());
457        self.events.insert(event.event_id.clone(), event);
458        Ok(())
459    }
460
461    /// Current rebuildable projection.
462    pub const fn projection(&self) -> &Projection {
463        &self.projection
464    }
465
466    /// Events in admission order.
467    pub fn events(&self) -> impl Iterator<Item = &Event> {
468        self.event_order
469            .iter()
470            .filter_map(|event_id| self.events.get(event_id))
471    }
472
473    /// Rebuild a projection from an unordered causal event set.
474    pub fn replay(events: impl IntoIterator<Item = Event>) -> Result<Projection> {
475        let mut remaining = BTreeMap::new();
476        for event in events {
477            event.verify()?;
478            if let Some(existing) = remaining.insert(event.event_id.clone(), event.clone())
479                && existing != event
480            {
481                return Err(CoreError::IdentityMismatch(format!(
482                    "duplicate event ID {} has different content",
483                    event.event_id
484                )));
485            }
486        }
487        for event in remaining.values() {
488            for parent in &event.parents {
489                if !remaining.contains_key(parent) {
490                    return Err(CoreError::Causal(format!(
491                        "event {} references missing parent {parent}",
492                        event.event_id
493                    )));
494                }
495            }
496        }
497
498        let mut projection = Projection::default();
499        let mut applied = BTreeSet::new();
500        while !remaining.is_empty() {
501            let ready_id = remaining
502                .iter()
503                .find(|(_, event)| event.parents.iter().all(|parent| applied.contains(parent)))
504                .map(|(event_id, _)| event_id.clone())
505                .ok_or_else(|| CoreError::Causal("event graph contains a cycle".to_owned()))?;
506            let event = remaining.remove(&ready_id).ok_or_else(|| {
507                CoreError::Causal("ready event disappeared during replay".to_owned())
508            })?;
509            projection.apply_event(&event)?;
510            applied.insert(ready_id);
511        }
512        Ok(projection)
513    }
514
515    /// Replay all current events and verify the digest matches the live projection.
516    pub fn verify_replay(&self) -> Result<()> {
517        let mut replayed = self
518            .base
519            .as_ref()
520            .map(|base| base.projection.clone())
521            .unwrap_or_default();
522        let mut applied = self.compacted_event_ids.clone();
523        for event in self.events() {
524            if !event.parents.iter().all(|parent| applied.contains(parent)) {
525                return Err(CoreError::Causal(format!(
526                    "event {} has a parent outside the compacted base and suffix",
527                    event.event_id
528                )));
529            }
530            replayed.apply_event(event)?;
531            applied.insert(event.event_id.clone());
532        }
533        let current_digest = self.projection.digest()?;
534        let replayed_digest = replayed.digest()?;
535        if current_digest != replayed_digest {
536            return Err(CoreError::IdentityMismatch(format!(
537                "projection digest {current_digest} does not match replay {replayed_digest}"
538            )));
539        }
540        Ok(())
541    }
542
543    fn compaction_frontier(&self) -> BTreeSet<String> {
544        let mut frontier: BTreeSet<String> = self
545            .events()
546            .flat_map(|event| event.parents.iter())
547            .filter(|parent| self.compacted_event_ids.contains(*parent))
548            .cloned()
549            .collect();
550        if let Some(base) = &self.base {
551            frontier.extend(
552                base.projection
553                    .runs
554                    .values()
555                    .map(|run| run.last_event.clone()),
556            );
557        }
558        frontier
559    }
560
561    fn admit_command(&self, envelope: &CommandEnvelope) -> Result<EventPayload> {
562        match &envelope.command {
563            Command::StartRun {
564                plan_id,
565                binding_context,
566            } => {
567                if self.projection.runs.contains_key(&envelope.run_id) {
568                    return Err(CoreError::IllegalTransition(format!(
569                        "Run {} already exists",
570                        envelope.run_id
571                    )));
572                }
573                if !self.plans.contains_key(plan_id) {
574                    return Err(CoreError::NotFound(format!(
575                        "plan {plan_id} does not exist"
576                    )));
577                }
578                Ok(EventPayload::RunStarted {
579                    plan_id: plan_id.clone(),
580                    binding_context: binding_context.clone(),
581                })
582            }
583            Command::BeginAttempt {
584                attempt_id,
585                continuation_id,
586                occurrence_binding,
587                epoch,
588            } => Ok(EventPayload::AttemptStarted {
589                attempt_id: attempt_id.clone(),
590                continuation_id: continuation_id.clone(),
591                occurrence_binding: occurrence_binding.clone(),
592                epoch: *epoch,
593            }),
594            Command::YieldAttempt { attempt_id, epoch } => Ok(EventPayload::AttemptYielded {
595                attempt_id: attempt_id.clone(),
596                epoch: *epoch,
597            }),
598            Command::AdvanceEpoch => {
599                let run = self.run(&envelope.run_id)?;
600                Ok(EventPayload::EpochAdvanced {
601                    epoch: run.epoch + 1,
602                })
603            }
604            Command::OpenScope {
605                scope_id,
606                parent_scope,
607            } => Ok(EventPayload::ScopeOpened {
608                scope_id: scope_id.clone(),
609                parent_scope: parent_scope.clone(),
610            }),
611            Command::ProposeEffect {
612                scope_id,
613                invocation_id,
614                site_id,
615                occurrence,
616                operation,
617                args,
618                occurrence_binding,
619            } => {
620                if self.artifact(args).is_none() {
621                    return Err(CoreError::NotFound(format!(
622                        "effect argument artifact {} does not exist",
623                        args.artifact_id
624                    )));
625                }
626                let run = self.run(&envelope.run_id)?;
627                let contract = self.effect_contract(&run.current_plan, operation)?;
628                let intent_id = effect_intent_id(
629                    &envelope.run_id,
630                    invocation_id,
631                    site_id,
632                    scope_id,
633                    run.epoch,
634                    occurrence,
635                    args,
636                    "cymule.effect-schema/1",
637                )?;
638                Ok(EventPayload::EffectProposed {
639                    intent_id,
640                    scope_id: scope_id.clone(),
641                    operation: operation.clone(),
642                    mutating: contract.profile.mutation == MutationKind::Mutating,
643                    args: args.clone(),
644                    occurrence_binding: occurrence_binding.clone(),
645                })
646            }
647            Command::TransitionEffect {
648                intent_id,
649                transition,
650            } => Ok(EventPayload::EffectTransitioned {
651                intent_id: intent_id.clone(),
652                transition: transition.clone(),
653            }),
654            Command::CommitScope { scope_id } => {
655                let run = self.run(&envelope.run_id)?;
656                let scope = run.scopes.get(scope_id).ok_or_else(|| {
657                    CoreError::NotFound(format!("scope {scope_id} does not exist"))
658                })?;
659                let mut obligations = Vec::new();
660                for intent_id in &scope.intents {
661                    let effect = run.effects.get(intent_id).ok_or_else(|| {
662                        CoreError::NotFound(format!("effect {intent_id} does not exist"))
663                    })?;
664                    if effect.mutating {
665                        obligations.push(ObligationProjection {
666                            obligation_id: effect_obligation_id(intent_id)?,
667                            intent_id: intent_id.clone(),
668                            blocking: true,
669                            resolved: matches!(
670                                effect.outcome,
671                                WorldOutcome::Applied | WorldOutcome::NotApplied
672                            ),
673                        });
674                    }
675                }
676                obligations.sort_by(|left, right| left.obligation_id.cmp(&right.obligation_id));
677                Ok(EventPayload::ScopeCommitted {
678                    scope_id: scope_id.clone(),
679                    obligations,
680                })
681            }
682            Command::AbortScope { scope_id } => Ok(EventPayload::ScopeAborted {
683                scope_id: scope_id.clone(),
684            }),
685            Command::UpdateBinding { binding_context } => {
686                let run = self.run(&envelope.run_id)?;
687                Ok(EventPayload::BindingUpdated {
688                    previous: run.current_binding_context.clone(),
689                    current: binding_context.clone(),
690                })
691            }
692            Command::RecordFact { key, value } => Ok(EventPayload::FactRecorded {
693                key: key.clone(),
694                value: value.clone(),
695            }),
696            Command::CompleteRun { result } => {
697                if let Some(reference) = result
698                    && self.artifact(reference).is_none()
699                {
700                    return Err(CoreError::NotFound(format!(
701                        "Result artifact {} does not exist",
702                        reference.artifact_id
703                    )));
704                }
705                Ok(EventPayload::RunCompleted {
706                    result: result.clone(),
707                })
708            }
709        }
710    }
711
712    fn run(&self, run_id: &str) -> Result<&crate::RunProjection> {
713        self.projection
714            .runs
715            .get(run_id)
716            .ok_or_else(|| CoreError::NotFound(format!("Run {run_id} does not exist")))
717    }
718
719    fn effect_contract(&self, plan_id: &str, operation: &str) -> Result<&EffectContract> {
720        self.plans
721            .get(plan_id)
722            .and_then(|plan| {
723                plan.candidate
724                    .effects
725                    .iter()
726                    .find(|contract| contract.id == operation)
727            })
728            .ok_or_else(|| {
729                CoreError::NotFound(format!(
730                    "effect operation {operation} does not exist in plan {plan_id}"
731                ))
732            })
733    }
734}
735
736fn validate_envelope(envelope: &CommandEnvelope) -> Result<()> {
737    if envelope.command_version != COMMAND_VERSION {
738        return Err(CoreError::Validation(format!(
739            "unsupported command version {:?}",
740            envelope.command_version
741        )));
742    }
743    for (kind, value) in [
744        ("command ID", envelope.command_id.as_str()),
745        ("actor", envelope.actor.as_str()),
746        ("Run ID", envelope.run_id.as_str()),
747    ] {
748        if value.is_empty() || value.len() > 200 {
749            return Err(CoreError::Validation(format!(
750                "{kind} must contain 1..=200 characters"
751            )));
752        }
753    }
754    Ok(())
755}
756
757fn footprints(
758    run_id: &str,
759    payload: &EventPayload,
760) -> (BTreeSet<String>, BTreeSet<String>, Option<String>) {
761    let run_key = format!("run:{run_id}");
762    let mut reads = BTreeSet::new();
763    let mut writes = BTreeSet::new();
764    let coordination_key = match payload {
765        EventPayload::RunStarted { .. } => {
766            writes.insert(run_key.clone());
767            Some(run_key)
768        }
769        EventPayload::FactRecorded { key, .. } => {
770            writes.insert(format!("fact:{run_id}:{key}"));
771            None
772        }
773        EventPayload::EffectProposed { intent_id, .. }
774        | EventPayload::EffectTransitioned { intent_id, .. } => {
775            reads.insert(run_key);
776            let key = format!("effect:{run_id}:{intent_id}");
777            writes.insert(key.clone());
778            Some(key)
779        }
780        EventPayload::ScopeOpened { scope_id, .. }
781        | EventPayload::ScopeCommitted { scope_id, .. }
782        | EventPayload::ScopeAborted { scope_id } => {
783            reads.insert(run_key);
784            let key = format!("scope:{run_id}:{scope_id}");
785            writes.insert(key.clone());
786            Some(key)
787        }
788        EventPayload::AttemptStarted { attempt_id, .. }
789        | EventPayload::AttemptYielded { attempt_id, .. } => {
790            reads.insert(run_key);
791            let key = format!("attempt:{run_id}:{attempt_id}");
792            writes.insert(key.clone());
793            Some(key)
794        }
795        EventPayload::EpochAdvanced { .. }
796        | EventPayload::BindingUpdated { .. }
797        | EventPayload::RunCompleted { .. } => {
798            reads.insert(run_key.clone());
799            writes.insert(run_key.clone());
800            Some(run_key)
801        }
802    };
803    (reads, writes, coordination_key)
804}