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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct MachineBaseSnapshot {
23 pub prefix_digest: String,
25 pub compacted_event_ids: BTreeSet<String>,
27 pub projection: Projection,
29 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct MachineCompaction {
67 pub base_id: String,
69 pub compacted_events: u64,
71 pub retained_events: u64,
73 pub causal_frontier: BTreeSet<String>,
75 pub projection_digest: String,
77}
78
79#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct MachineSnapshot {
83 pub snapshot_version: String,
85 pub plans: Vec<SealedPlan>,
87 pub artifacts: Vec<ArtifactRecord>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub base: Option<MachineBaseSnapshot>,
92 pub events: Vec<Event>,
94 commands: BTreeMap<String, CommandRecord>,
96}
97
98impl MachineSnapshot {
99 pub const VERSION: &'static str = "cymule.machine-snapshot/2";
101
102 pub fn digest(&self) -> Result<String> {
104 canonical_digest(self)
105 }
106
107 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#[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 pub fn new() -> Self {
137 Self::default()
138 }
139
140 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 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 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 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 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 pub fn plan(&self, plan_id: &str) -> Option<&SealedPlan> {
288 self.plans.get(plan_id)
289 }
290
291 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 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 pub fn remove_artifact_for_test(&mut self, artifact_id: &str) -> Option<ArtifactRecord> {
321 self.artifacts.remove(artifact_id)
322 }
323
324 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 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 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 pub const fn projection(&self) -> &Projection {
463 &self.projection
464 }
465
466 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 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 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}