Skip to main content

cymule_core/
model.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{CoreError, Result, canonical_digest, content_id};
6
7/// Semantic specification version.
8pub const SEMANTIC_VERSION: &str = "cymule.semantic/1";
9/// Canonical event version.
10pub const EVENT_VERSION: &str = "cymule.event/1";
11/// Public command version.
12pub const COMMAND_VERSION: &str = "cymule.command/1";
13/// Stable root scope identifier within every Run.
14pub const ROOT_SCOPE_ID: &str = "scope:root";
15
16/// Immutable artifact reference.
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ArtifactRef {
20    /// Content-addressed identity.
21    pub artifact_id: String,
22    /// Stable artifact type.
23    pub kind: String,
24}
25
26/// Immutable artifact bytes held by the embedded store.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct ArtifactRecord {
30    /// Reference derived from type and bytes.
31    pub reference: ArtifactRef,
32    /// Immutable bytes.
33    pub bytes: Vec<u8>,
34}
35
36/// A preconditioned, idempotent command proposal.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct CommandEnvelope {
40    /// Command protocol version.
41    pub command_version: String,
42    /// Caller-generated idempotency identity.
43    pub command_id: String,
44    /// Authenticated actor reference. The embedded profile treats it as opaque.
45    pub actor: String,
46    /// Target Run identity.
47    pub run_id: String,
48    /// Token from the caller's current Run view.
49    #[serde(default)]
50    pub expected_precondition: Option<String>,
51    /// Typed semantic proposal.
52    pub command: Command,
53}
54
55/// Commands admitted by the semantic kernel.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum Command {
59    /// Start a new Run under an immutable plan and future-default binding context.
60    StartRun {
61        /// Sealed plan ID.
62        plan_id: String,
63        /// Default realization context for future occurrences.
64        binding_context: String,
65    },
66    /// Begin a fenced Attempt.
67    BeginAttempt {
68        /// Attempt identity.
69        attempt_id: String,
70        /// Continuation identity.
71        continuation_id: String,
72        /// Occurrence-level immutable binding.
73        occurrence_binding: String,
74        /// Expected continuation epoch.
75        epoch: u64,
76    },
77    /// Yield the active Attempt at a safe point.
78    YieldAttempt {
79        /// Attempt identity.
80        attempt_id: String,
81        /// Expected continuation epoch.
82        epoch: u64,
83    },
84    /// Advance the Run epoch and fence prior attempts.
85    AdvanceEpoch,
86    /// Open a nested state/evidence scope.
87    OpenScope {
88        /// New scope identity.
89        scope_id: String,
90        /// Existing parent scope.
91        parent_scope: String,
92    },
93    /// Propose a structurally identified external effect.
94    ProposeEffect {
95        /// Owning open scope.
96        scope_id: String,
97        /// Invocation identity.
98        invocation_id: String,
99        /// Stable IR effect site.
100        site_id: String,
101        /// Intentional occurrence key.
102        occurrence: String,
103        /// Abstract effect operation.
104        operation: String,
105        /// Canonical argument artifact.
106        args: ArtifactRef,
107        /// Immutable occurrence binding.
108        occurrence_binding: String,
109    },
110    /// Advance one axis of an existing effect.
111    TransitionEffect {
112        /// Structural intent identity.
113        intent_id: String,
114        /// Legal next transition.
115        transition: EffectTransition,
116    },
117    /// Commit internal scope state and transfer unresolved effect obligations.
118    CommitScope {
119        /// Scope identity.
120        scope_id: String,
121    },
122    /// Abort a scope before release.
123    AbortScope {
124        /// Scope identity.
125        scope_id: String,
126    },
127    /// Change the realization default for future occurrences.
128    UpdateBinding {
129        /// New immutable Binding Context reference.
130        binding_context: String,
131    },
132    /// Append an independent immutable fact for causal conformance tests.
133    RecordFact {
134        /// Stable logical fact key.
135        key: String,
136        /// Immutable value digest or reference.
137        value: String,
138    },
139    /// Finish a Run after required obligations have settled.
140    CompleteRun {
141        /// Optional typed Result artifact.
142        #[serde(default)]
143        result: Option<ArtifactRef>,
144    },
145}
146
147/// Legal effect transitions.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(tag = "type", content = "value", rename_all = "snake_case")]
150pub enum EffectTransition {
151    /// Preparation completed.
152    Prepare,
153    /// Governing policy authorized release.
154    AuthorizeRelease,
155    /// External dispatch began.
156    StartDispatch,
157    /// Record the best authoritative world observation currently available.
158    Observe(WorldOutcome),
159    /// Reconcile an earlier unknown outcome.
160    Reconcile(ReconciliationResolution),
161}
162
163/// A canonical event.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct Event {
167    /// Content-addressed event identity.
168    pub event_id: String,
169    /// Event schema version.
170    pub event_version: String,
171    /// Admitted command identity.
172    pub command_id: String,
173    /// Canonical command semantics digest.
174    pub command_hash: String,
175    /// Run identity.
176    pub run_id: String,
177    /// Explicit causal parents.
178    pub parents: Vec<String>,
179    /// Logical point/predicate reads.
180    pub reads: BTreeSet<String>,
181    /// Logical writes.
182    pub writes: BTreeSet<String>,
183    /// Optional non-monotone coordination domain.
184    #[serde(default)]
185    pub coordination_key: Option<String>,
186    /// Trusted semantic transition.
187    pub payload: EventPayload,
188}
189
190#[derive(Serialize)]
191struct EventPreimage<'a> {
192    event_version: &'a str,
193    command_id: &'a str,
194    command_hash: &'a str,
195    run_id: &'a str,
196    parents: &'a [String],
197    reads: &'a BTreeSet<String>,
198    writes: &'a BTreeSet<String>,
199    coordination_key: &'a Option<String>,
200    payload: &'a EventPayload,
201}
202
203impl Event {
204    /// Construct and content-address a trusted event.
205    #[allow(clippy::too_many_arguments)]
206    pub fn new(
207        command_id: String,
208        command_hash: String,
209        run_id: String,
210        mut parents: Vec<String>,
211        reads: BTreeSet<String>,
212        writes: BTreeSet<String>,
213        coordination_key: Option<String>,
214        payload: EventPayload,
215    ) -> Result<Self> {
216        parents.sort();
217        parents.dedup();
218        let preimage = EventPreimage {
219            event_version: EVENT_VERSION,
220            command_id: &command_id,
221            command_hash: &command_hash,
222            run_id: &run_id,
223            parents: &parents,
224            reads: &reads,
225            writes: &writes,
226            coordination_key: &coordination_key,
227            payload: &payload,
228        };
229        let event_id = content_id("cymule.event/1", &preimage)?;
230        Ok(Self {
231            event_id,
232            event_version: EVENT_VERSION.to_owned(),
233            command_id,
234            command_hash,
235            run_id,
236            parents,
237            reads,
238            writes,
239            coordination_key,
240            payload,
241        })
242    }
243
244    /// Verify event schema and content identity.
245    pub fn verify(&self) -> Result<()> {
246        if self.event_version != EVENT_VERSION {
247            return Err(CoreError::Validation(format!(
248                "unsupported event version {:?}",
249                self.event_version
250            )));
251        }
252        let preimage = EventPreimage {
253            event_version: &self.event_version,
254            command_id: &self.command_id,
255            command_hash: &self.command_hash,
256            run_id: &self.run_id,
257            parents: &self.parents,
258            reads: &self.reads,
259            writes: &self.writes,
260            coordination_key: &self.coordination_key,
261            payload: &self.payload,
262        };
263        let expected = content_id("cymule.event/1", &preimage)?;
264        if expected != self.event_id {
265            return Err(CoreError::IdentityMismatch(format!(
266                "event ID {} does not match {expected}",
267                self.event_id
268            )));
269        }
270        Ok(())
271    }
272}
273
274/// Canonical transition payloads.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(tag = "type", rename_all = "snake_case")]
277pub enum EventPayload {
278    /// A Run became canonical.
279    RunStarted {
280        /// Plan identity.
281        plan_id: String,
282        /// Future-default realization context.
283        binding_context: String,
284    },
285    /// An Attempt received a fenced lease.
286    AttemptStarted {
287        /// Attempt identity.
288        attempt_id: String,
289        /// Continuation identity.
290        continuation_id: String,
291        /// Pinned occurrence binding.
292        occurrence_binding: String,
293        /// Pinned epoch.
294        epoch: u64,
295    },
296    /// An Attempt yielded at a safe point.
297    AttemptYielded {
298        /// Attempt identity.
299        attempt_id: String,
300        /// Pinned epoch.
301        epoch: u64,
302    },
303    /// The Run epoch advanced.
304    EpochAdvanced {
305        /// New epoch.
306        epoch: u64,
307    },
308    /// A nested scope opened.
309    ScopeOpened {
310        /// Scope identity.
311        scope_id: String,
312        /// Parent scope identity.
313        parent_scope: String,
314    },
315    /// An effect was admitted with an immutable occurrence binding.
316    EffectProposed {
317        /// Structural intent identity.
318        intent_id: String,
319        /// Owning scope.
320        scope_id: String,
321        /// Abstract operation ID.
322        operation: String,
323        /// Whether it mutates the world.
324        mutating: bool,
325        /// Canonical argument artifact.
326        args: ArtifactRef,
327        /// Pinned occurrence binding.
328        occurrence_binding: String,
329    },
330    /// One effect state axis advanced.
331    EffectTransitioned {
332        /// Structural intent identity.
333        intent_id: String,
334        /// Transition.
335        transition: EffectTransition,
336    },
337    /// Internal scope state committed and obligations transferred.
338    ScopeCommitted {
339        /// Scope identity.
340        scope_id: String,
341        /// Deterministically derived obligations.
342        obligations: Vec<ObligationProjection>,
343    },
344    /// A scope aborted before effect release.
345    ScopeAborted {
346        /// Scope identity.
347        scope_id: String,
348    },
349    /// Future occurrences use a new default binding context.
350    BindingUpdated {
351        /// Previous context.
352        previous: String,
353        /// New context.
354        current: String,
355    },
356    /// An append-only fact was recorded.
357    FactRecorded {
358        /// Logical fact key.
359        key: String,
360        /// Immutable value.
361        value: String,
362    },
363    /// A Run entered a terminal completed state.
364    RunCompleted {
365        /// Optional Result artifact.
366        result: Option<ArtifactRef>,
367    },
368}
369
370/// Public command outcome.
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum CommandReceiptStatus {
374    /// A canonical event was admitted.
375    Applied,
376    /// The caller's precondition was stale.
377    Conflict,
378}
379
380/// Durable command receipt.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(deny_unknown_fields)]
383pub struct CommandReceipt {
384    /// Command identity.
385    pub command_id: String,
386    /// Stable status.
387    pub status: CommandReceiptStatus,
388    /// Admitted event when applied.
389    #[serde(default)]
390    pub event_id: Option<String>,
391    /// Stable structured error code for a conflict.
392    #[serde(default)]
393    pub error_code: Option<String>,
394    /// Human-readable explanation.
395    #[serde(default)]
396    pub message: Option<String>,
397    /// Caller-provided token.
398    #[serde(default)]
399    pub observed_precondition: Option<String>,
400    /// Current token after application or conflict.
401    #[serde(default)]
402    pub current_precondition: Option<String>,
403}
404
405/// Rebuildable full projection.
406#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(deny_unknown_fields)]
408pub struct Projection {
409    /// Run projections.
410    pub runs: BTreeMap<String, RunProjection>,
411    /// Append-only facts used by explainability and conformance.
412    pub facts: BTreeMap<String, String>,
413}
414
415/// Rebuildable Run projection.
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(deny_unknown_fields)]
418pub struct RunProjection {
419    /// Run identity.
420    pub run_id: String,
421    /// Initial immutable plan.
422    pub initial_plan: String,
423    /// Current semantic plan. Plan migration is not implemented in profile 1.
424    pub current_plan: String,
425    /// Initial realization default.
426    pub initial_binding_context: String,
427    /// Future-occurrence realization default.
428    pub current_binding_context: String,
429    /// Fencing epoch.
430    pub epoch: u64,
431    /// Public Run status.
432    pub status: RunStatus,
433    /// Scope projections.
434    pub scopes: BTreeMap<String, ScopeProjection>,
435    /// Effect projections.
436    pub effects: BTreeMap<String, EffectProjection>,
437    /// Outstanding or settled obligations.
438    pub obligations: BTreeMap<String, ObligationProjection>,
439    /// Attempt projections.
440    pub attempts: BTreeMap<String, AttemptProjection>,
441    /// Optional terminal Result.
442    pub result: Option<ArtifactRef>,
443    /// Last applied event in the embedded linear frontier.
444    pub last_event: String,
445}
446
447impl RunProjection {
448    /// Current stale-action protection token.
449    pub fn precondition_token(&self) -> String {
450        format!("pre:{}:{}", self.epoch, self.last_event)
451    }
452}
453
454/// Public Run status.
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "snake_case")]
457pub enum RunStatus {
458    /// New work may be admitted.
459    Active,
460    /// Waiting for a durable condition.
461    Waiting,
462    /// Terminal successful state.
463    Completed,
464    /// Terminal failure state.
465    Failed,
466    /// Terminal cancellation state.
467    Cancelled,
468}
469
470/// Scope projection.
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(deny_unknown_fields)]
473pub struct ScopeProjection {
474    /// Scope identity.
475    pub scope_id: String,
476    /// Optional parent.
477    pub parent_scope: Option<String>,
478    /// Scope lifecycle state.
479    pub status: ScopeStatus,
480    /// Effects admitted in the scope.
481    pub intents: BTreeSet<String>,
482}
483
484/// Scope lifecycle state.
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(rename_all = "snake_case")]
487pub enum ScopeStatus {
488    /// Scope accepts work.
489    Open,
490    /// Internal state/evidence was committed and the scope is closed.
491    ClosedCommitted,
492    /// Overlay and unreleased mutation were discarded.
493    ClosedAborted,
494}
495
496/// Effect control phase.
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(rename_all = "snake_case")]
499pub enum EffectPhase {
500    /// Intent was admitted.
501    Admitted,
502    /// Payload and evidence were prepared.
503    Prepared,
504    /// Governing policy authorized release.
505    ReleaseAuthorized,
506    /// External dispatch began.
507    DispatchStarted,
508    /// Unreleased effect was cancelled by scope abort.
509    CancelledBeforeRelease,
510}
511
512/// Observed external-world outcome.
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "snake_case")]
515pub enum WorldOutcome {
516    /// No external-world observation exists yet.
517    Unobserved,
518    /// The external action occurred.
519    Applied,
520    /// The external action did not occur.
521    NotApplied,
522    /// Dispatch occurred but the outcome cannot currently be determined.
523    Unknown,
524}
525
526/// Reconciliation result transition.
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(rename_all = "snake_case")]
529pub enum ReconciliationResolution {
530    /// Observation proved the action occurred.
531    ResolvedApplied,
532    /// Observation proved the action did not occur.
533    ResolvedNotApplied,
534    /// The outcome remains unknown and may be queried again.
535    StillUnknown,
536    /// Automatic resolution is unavailable.
537    GovernanceRequired,
538}
539
540/// Reconciliation state.
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(rename_all = "snake_case")]
543pub enum ReconciliationState {
544    /// No reconciliation is required for the observed outcome.
545    NotRequired,
546    /// Unknown outcome awaits reconciliation.
547    Pending,
548    /// Unknown outcome was authoritatively resolved.
549    Resolved,
550    /// Governance must decide how to proceed.
551    GovernanceRequired,
552}
553
554/// Effect projection with immutable occurrence binding.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct EffectProjection {
558    /// Structural intent identity.
559    pub intent_id: String,
560    /// Owning scope.
561    pub scope_id: String,
562    /// Abstract operation.
563    pub operation: String,
564    /// External mutation flag.
565    pub mutating: bool,
566    /// Canonical arguments.
567    pub args: ArtifactRef,
568    /// Immutable historical realization.
569    pub occurrence_binding: String,
570    /// Control phase.
571    pub phase: EffectPhase,
572    /// World outcome.
573    pub outcome: WorldOutcome,
574    /// Reconciliation state.
575    pub reconciliation: ReconciliationState,
576}
577
578/// Scope-transferred world-effect obligation.
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580#[serde(deny_unknown_fields)]
581pub struct ObligationProjection {
582    /// Deterministic obligation identity.
583    pub obligation_id: String,
584    /// Effect intent.
585    pub intent_id: String,
586    /// Whether this obligation blocks normal Run completion.
587    pub blocking: bool,
588    /// Whether an authoritative terminal outcome is known.
589    pub resolved: bool,
590}
591
592/// Fenced attempt projection.
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594#[serde(deny_unknown_fields)]
595pub struct AttemptProjection {
596    /// Attempt identity.
597    pub attempt_id: String,
598    /// Continuation identity.
599    pub continuation_id: String,
600    /// Immutable occurrence binding.
601    pub occurrence_binding: String,
602    /// Fencing epoch.
603    pub epoch: u64,
604    /// Whether the Attempt currently owns execution.
605    pub active: bool,
606}
607
608/// Explicit exact-replay capability.
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610#[serde(tag = "status", rename_all = "snake_case")]
611pub enum ReplayAvailability {
612    /// All required canonical inputs are available.
613    Exact,
614    /// State projections remain available but complete nondeterminism does not.
615    ProjectionOnly {
616        /// Missing or redacted references.
617        missing: Vec<String>,
618    },
619    /// Even the requested projection cannot be reconstructed.
620    Unavailable {
621        /// Stable explanation.
622        reason: String,
623    },
624}
625
626/// Compaction witness. The Embedded profile constructs and validates this type
627/// but does not delete canonical history automatically.
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
629#[serde(deny_unknown_fields)]
630pub struct CompactionCertificate {
631    /// Causally closed source frontier.
632    pub source_frontier: Vec<String>,
633    /// Digest of the summarized projection.
634    pub projection_digest: String,
635    /// Unresolved obligations preserved by the summary.
636    pub unresolved_obligations: Vec<String>,
637    /// Historical occurrence bindings retained for interpretation.
638    pub occurrence_bindings: Vec<String>,
639    /// Resulting replay capability.
640    pub replay_availability: ReplayAvailability,
641}
642
643impl Projection {
644    /// Apply one verified canonical event.
645    pub fn apply_event(&mut self, event: &Event) -> Result<()> {
646        match &event.payload {
647            EventPayload::RunStarted {
648                plan_id,
649                binding_context,
650            } => {
651                if self.runs.contains_key(&event.run_id) {
652                    return Err(CoreError::IllegalTransition(format!(
653                        "Run {} already exists",
654                        event.run_id
655                    )));
656                }
657                let mut scopes = BTreeMap::new();
658                scopes.insert(
659                    ROOT_SCOPE_ID.to_owned(),
660                    ScopeProjection {
661                        scope_id: ROOT_SCOPE_ID.to_owned(),
662                        parent_scope: None,
663                        status: ScopeStatus::Open,
664                        intents: BTreeSet::new(),
665                    },
666                );
667                self.runs.insert(
668                    event.run_id.clone(),
669                    RunProjection {
670                        run_id: event.run_id.clone(),
671                        initial_plan: plan_id.clone(),
672                        current_plan: plan_id.clone(),
673                        initial_binding_context: binding_context.clone(),
674                        current_binding_context: binding_context.clone(),
675                        epoch: 0,
676                        status: RunStatus::Active,
677                        scopes,
678                        effects: BTreeMap::new(),
679                        obligations: BTreeMap::new(),
680                        attempts: BTreeMap::new(),
681                        result: None,
682                        last_event: event.event_id.clone(),
683                    },
684                );
685                return Ok(());
686            }
687            EventPayload::FactRecorded { key, value } => {
688                if let Some(existing) = self.facts.get(key) {
689                    if existing != value {
690                        return Err(CoreError::IllegalTransition(format!(
691                            "fact {key:?} already has a different value"
692                        )));
693                    }
694                } else {
695                    self.facts.insert(key.clone(), value.clone());
696                }
697            }
698            _ => {}
699        }
700
701        let run = self
702            .runs
703            .get_mut(&event.run_id)
704            .ok_or_else(|| CoreError::NotFound(format!("Run {} does not exist", event.run_id)))?;
705        if run.status == RunStatus::Completed {
706            return Err(CoreError::IllegalTransition(format!(
707                "Run {} is already completed",
708                event.run_id
709            )));
710        }
711
712        match &event.payload {
713            EventPayload::RunStarted { .. } | EventPayload::FactRecorded { .. } => {}
714            EventPayload::AttemptStarted {
715                attempt_id,
716                continuation_id,
717                occurrence_binding,
718                epoch,
719            } => {
720                if *epoch != run.epoch {
721                    return Err(CoreError::IllegalTransition(format!(
722                        "attempt epoch {epoch} does not match Run epoch {}",
723                        run.epoch
724                    )));
725                }
726                if run.attempts.contains_key(attempt_id) {
727                    return Err(CoreError::IllegalTransition(format!(
728                        "attempt {attempt_id} already exists"
729                    )));
730                }
731                run.attempts.insert(
732                    attempt_id.clone(),
733                    AttemptProjection {
734                        attempt_id: attempt_id.clone(),
735                        continuation_id: continuation_id.clone(),
736                        occurrence_binding: occurrence_binding.clone(),
737                        epoch: *epoch,
738                        active: true,
739                    },
740                );
741            }
742            EventPayload::AttemptYielded { attempt_id, epoch } => {
743                let attempt = run.attempts.get_mut(attempt_id).ok_or_else(|| {
744                    CoreError::NotFound(format!("attempt {attempt_id} does not exist"))
745                })?;
746                if !attempt.active || attempt.epoch != *epoch {
747                    return Err(CoreError::IllegalTransition(format!(
748                        "attempt {attempt_id} is stale or inactive"
749                    )));
750                }
751                attempt.active = false;
752            }
753            EventPayload::EpochAdvanced { epoch } => {
754                if *epoch != run.epoch + 1 {
755                    return Err(CoreError::IllegalTransition(format!(
756                        "epoch must advance from {} to {}; received {epoch}",
757                        run.epoch,
758                        run.epoch + 1
759                    )));
760                }
761                run.epoch = *epoch;
762                for attempt in run.attempts.values_mut() {
763                    attempt.active = false;
764                }
765            }
766            EventPayload::ScopeOpened {
767                scope_id,
768                parent_scope,
769            } => {
770                if run.scopes.contains_key(scope_id) {
771                    return Err(CoreError::IllegalTransition(format!(
772                        "scope {scope_id} already exists"
773                    )));
774                }
775                let parent = run.scopes.get(parent_scope).ok_or_else(|| {
776                    CoreError::NotFound(format!("parent scope {parent_scope} does not exist"))
777                })?;
778                if parent.status != ScopeStatus::Open {
779                    return Err(CoreError::IllegalTransition(format!(
780                        "parent scope {parent_scope} is not open"
781                    )));
782                }
783                run.scopes.insert(
784                    scope_id.clone(),
785                    ScopeProjection {
786                        scope_id: scope_id.clone(),
787                        parent_scope: Some(parent_scope.clone()),
788                        status: ScopeStatus::Open,
789                        intents: BTreeSet::new(),
790                    },
791                );
792            }
793            EventPayload::EffectProposed {
794                intent_id,
795                scope_id,
796                operation,
797                mutating,
798                args,
799                occurrence_binding,
800            } => {
801                if run.effects.contains_key(intent_id) {
802                    return Err(CoreError::IllegalTransition(format!(
803                        "effect intent {intent_id} already exists"
804                    )));
805                }
806                let scope = run.scopes.get_mut(scope_id).ok_or_else(|| {
807                    CoreError::NotFound(format!("scope {scope_id} does not exist"))
808                })?;
809                if scope.status != ScopeStatus::Open {
810                    return Err(CoreError::IllegalTransition(format!(
811                        "scope {scope_id} is not open"
812                    )));
813                }
814                scope.intents.insert(intent_id.clone());
815                run.effects.insert(
816                    intent_id.clone(),
817                    EffectProjection {
818                        intent_id: intent_id.clone(),
819                        scope_id: scope_id.clone(),
820                        operation: operation.clone(),
821                        mutating: *mutating,
822                        args: args.clone(),
823                        occurrence_binding: occurrence_binding.clone(),
824                        phase: EffectPhase::Admitted,
825                        outcome: WorldOutcome::Unobserved,
826                        reconciliation: ReconciliationState::NotRequired,
827                    },
828                );
829            }
830            EventPayload::EffectTransitioned {
831                intent_id,
832                transition,
833            } => {
834                let effect = run.effects.get_mut(intent_id).ok_or_else(|| {
835                    CoreError::NotFound(format!("effect {intent_id} does not exist"))
836                })?;
837                apply_effect_transition(effect, transition)?;
838                update_obligation(run, intent_id);
839            }
840            EventPayload::ScopeCommitted {
841                scope_id,
842                obligations,
843            } => {
844                let scope = run.scopes.get_mut(scope_id).ok_or_else(|| {
845                    CoreError::NotFound(format!("scope {scope_id} does not exist"))
846                })?;
847                if scope.status != ScopeStatus::Open {
848                    return Err(CoreError::IllegalTransition(format!(
849                        "scope {scope_id} is not open"
850                    )));
851                }
852                scope.status = ScopeStatus::ClosedCommitted;
853                for obligation in obligations {
854                    if !scope.intents.contains(&obligation.intent_id) {
855                        return Err(CoreError::IllegalTransition(format!(
856                            "obligation {} does not belong to scope {scope_id}",
857                            obligation.obligation_id
858                        )));
859                    }
860                    if run
861                        .obligations
862                        .insert(obligation.obligation_id.clone(), obligation.clone())
863                        .is_some()
864                    {
865                        return Err(CoreError::IllegalTransition(format!(
866                            "obligation {} already exists",
867                            obligation.obligation_id
868                        )));
869                    }
870                }
871            }
872            EventPayload::ScopeAborted { scope_id } => {
873                let scope = run.scopes.get_mut(scope_id).ok_or_else(|| {
874                    CoreError::NotFound(format!("scope {scope_id} does not exist"))
875                })?;
876                if scope.status != ScopeStatus::Open {
877                    return Err(CoreError::IllegalTransition(format!(
878                        "scope {scope_id} is not open"
879                    )));
880                }
881                for intent_id in &scope.intents {
882                    let effect = run.effects.get_mut(intent_id).ok_or_else(|| {
883                        CoreError::NotFound(format!("effect {intent_id} does not exist"))
884                    })?;
885                    if matches!(
886                        effect.phase,
887                        EffectPhase::ReleaseAuthorized | EffectPhase::DispatchStarted
888                    ) {
889                        return Err(CoreError::IllegalTransition(format!(
890                            "scope {scope_id} cannot abort after effect release"
891                        )));
892                    }
893                    effect.phase = EffectPhase::CancelledBeforeRelease;
894                }
895                scope.status = ScopeStatus::ClosedAborted;
896            }
897            EventPayload::BindingUpdated { previous, current } => {
898                if &run.current_binding_context != previous {
899                    return Err(CoreError::IllegalTransition(format!(
900                        "binding context changed from expected {previous}"
901                    )));
902                }
903                run.current_binding_context.clone_from(current);
904            }
905            EventPayload::RunCompleted { result } => {
906                let unresolved = run
907                    .obligations
908                    .values()
909                    .any(|obligation| obligation.blocking && !obligation.resolved);
910                if unresolved {
911                    return Err(CoreError::IllegalTransition(
912                        "Run has unresolved blocking effect obligations".to_owned(),
913                    ));
914                }
915                if run
916                    .scopes
917                    .values()
918                    .any(|scope| scope.status == ScopeStatus::Open)
919                {
920                    return Err(CoreError::IllegalTransition(
921                        "Run has an open scope".to_owned(),
922                    ));
923                }
924                run.status = RunStatus::Completed;
925                run.result.clone_from(result);
926            }
927        }
928        run.last_event.clone_from(&event.event_id);
929        Ok(())
930    }
931
932    /// Deterministic digest of the complete rebuildable projection.
933    pub fn digest(&self) -> Result<String> {
934        canonical_digest(self)
935    }
936}
937
938fn apply_effect_transition(
939    effect: &mut EffectProjection,
940    transition: &EffectTransition,
941) -> Result<()> {
942    match transition {
943        EffectTransition::Prepare if effect.phase == EffectPhase::Admitted => {
944            effect.phase = EffectPhase::Prepared;
945        }
946        EffectTransition::AuthorizeRelease if effect.phase == EffectPhase::Prepared => {
947            effect.phase = EffectPhase::ReleaseAuthorized;
948        }
949        EffectTransition::StartDispatch if effect.phase == EffectPhase::ReleaseAuthorized => {
950            effect.phase = EffectPhase::DispatchStarted;
951        }
952        EffectTransition::Observe(outcome)
953            if effect.phase == EffectPhase::DispatchStarted
954                && effect.outcome == WorldOutcome::Unobserved
955                && *outcome != WorldOutcome::Unobserved =>
956        {
957            effect.outcome = *outcome;
958            effect.reconciliation = if *outcome == WorldOutcome::Unknown {
959                ReconciliationState::Pending
960            } else {
961                ReconciliationState::NotRequired
962            };
963        }
964        EffectTransition::Reconcile(resolution)
965            if effect.phase == EffectPhase::DispatchStarted
966                && effect.outcome == WorldOutcome::Unknown =>
967        {
968            match resolution {
969                ReconciliationResolution::ResolvedApplied => {
970                    effect.outcome = WorldOutcome::Applied;
971                    effect.reconciliation = ReconciliationState::Resolved;
972                }
973                ReconciliationResolution::ResolvedNotApplied => {
974                    effect.outcome = WorldOutcome::NotApplied;
975                    effect.reconciliation = ReconciliationState::Resolved;
976                }
977                ReconciliationResolution::StillUnknown => {
978                    effect.reconciliation = ReconciliationState::Pending;
979                }
980                ReconciliationResolution::GovernanceRequired => {
981                    effect.reconciliation = ReconciliationState::GovernanceRequired;
982                }
983            }
984        }
985        _ => {
986            return Err(CoreError::IllegalTransition(format!(
987                "illegal effect transition {transition:?} from phase {:?}, outcome {:?}, reconciliation {:?}",
988                effect.phase, effect.outcome, effect.reconciliation
989            )));
990        }
991    }
992    Ok(())
993}
994
995fn update_obligation(run: &mut RunProjection, intent_id: &str) {
996    let resolved = run.effects.get(intent_id).is_some_and(|effect| {
997        matches!(
998            effect.outcome,
999            WorldOutcome::Applied | WorldOutcome::NotApplied
1000        )
1001    });
1002    for obligation in run
1003        .obligations
1004        .values_mut()
1005        .filter(|obligation| obligation.intent_id == intent_id)
1006    {
1007        obligation.resolved = resolved;
1008    }
1009}
1010
1011/// Derive the structural identity of one intentional effect occurrence.
1012#[allow(clippy::too_many_arguments)]
1013pub fn effect_intent_id(
1014    run_id: &str,
1015    invocation_id: &str,
1016    site_id: &str,
1017    scope_id: &str,
1018    scope_epoch: u64,
1019    occurrence: &str,
1020    args: &ArtifactRef,
1021    effect_schema_version: &str,
1022) -> Result<String> {
1023    #[derive(Serialize)]
1024    struct IntentPreimage<'a> {
1025        run_id: &'a str,
1026        invocation_id: &'a str,
1027        site_id: &'a str,
1028        scope_id: &'a str,
1029        scope_epoch: u64,
1030        occurrence: &'a str,
1031        args: &'a ArtifactRef,
1032        effect_schema_version: &'a str,
1033    }
1034    content_id(
1035        "cymule.effect-intent/1",
1036        &IntentPreimage {
1037            run_id,
1038            invocation_id,
1039            site_id,
1040            scope_id,
1041            scope_epoch,
1042            occurrence,
1043            args,
1044            effect_schema_version,
1045        },
1046    )
1047}
1048
1049/// Derive a stable obligation identity from an intent.
1050pub fn effect_obligation_id(intent_id: &str) -> Result<String> {
1051    content_id("cymule.effect-obligation/1", &intent_id)
1052}