Skip to main content

effect_runtime/
observation.rs

1//! Post-execution observation artifacts: execution receipts, observation bundles, and external ledger entries.
2//!
3//! These types capture the outcome of executed effects, post-execution observation
4//! state, and external-system side-effect records for governance audit trails.
5
6use crate::error::{
7    require_id, require_non_empty, require_non_empty_slice, require_schema_version,
8    EffectRuntimeValidationError, EffectValidationResult,
9};
10use crate::v25::V25ConstitutionCitation;
11use crate::vocab::{
12    ClosureRecommendationV1, ExecutionStateV1, ExternalObservationStateV1, ObservationStateV1,
13};
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use stack_ids::{
17    EffectCommitDecisionId, EffectExecutionReceiptId, EffectObservationBundleId,
18    ExternalEffectLedgerEntryId,
19};
20
21/// Record of an effect's execution outcome including state, partial-completion flags, and generated side-effects.
22///
23/// The `partial_execution` flag must be consistent with `execution_state`,
24/// and completed receipts must not carry a cancellation reason.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
26pub struct EffectExecutionReceiptV1 {
27    pub schema_version: String,
28    pub effect_execution_receipt_id: EffectExecutionReceiptId,
29    pub effect_commit_decision_id: EffectCommitDecisionId,
30    /// Carries EffectiveConstitutionId and CompiledObligationSetId via the canonical v25 citation.
31    #[serde(flatten)]
32    pub citation: V25ConstitutionCitation,
33    pub provider_route: Vec<String>,
34    pub execution_state: ExecutionStateV1,
35    pub partial_execution: bool,
36    pub cancellation_reason: String,
37    pub timeout_exhausted: bool,
38    pub generated_effect_refs: Vec<String>,
39    pub observed_external_handle: String,
40    pub generated_at: String,
41}
42
43/// Post-execution observation bundle that captures observed outcome, drift, and closure recommendation.
44///
45/// Complete observations require non-empty external evidence refs.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
47pub struct EffectObservationBundleV1 {
48    pub schema_version: String,
49    pub effect_observation_bundle_id: EffectObservationBundleId,
50    pub effect_execution_receipt_id: EffectExecutionReceiptId,
51    /// Carries EffectiveConstitutionId and CompiledObligationSetId via the canonical v25 citation.
52    #[serde(flatten)]
53    pub citation: V25ConstitutionCitation,
54    pub observation_window: String,
55    pub observation_state: ObservationStateV1,
56    pub observed_outcome: String,
57    pub drift_summary: String,
58    pub external_evidence_refs: Vec<String>,
59    pub closure_recommendation: ClosureRecommendationV1,
60    pub generated_at: String,
61}
62
63/// Ledger entry recording an effect's side-effect in an external system for audit and reconciliation.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
65pub struct ExternalEffectLedgerEntryV1 {
66    pub schema_version: String,
67    pub external_effect_ledger_entry_id: ExternalEffectLedgerEntryId,
68    pub effect_execution_receipt_id: EffectExecutionReceiptId,
69    /// Carries EffectiveConstitutionId and CompiledObligationSetId via the canonical v25 citation.
70    #[serde(flatten)]
71    pub citation: V25ConstitutionCitation,
72    pub target_system: String,
73    pub external_handle: String,
74    pub externally_observed_state: ExternalObservationStateV1,
75    pub entered_at: String,
76    pub immutable: bool,
77}
78
79impl EffectExecutionReceiptV1 {
80    pub const SCHEMA_VERSION: &'static str = "EffectExecutionReceiptV1";
81
82    /// Returns a builder for a validated execution receipt artifact.
83    #[allow(clippy::too_many_arguments)]
84    pub fn builder(
85        effect_execution_receipt_id: EffectExecutionReceiptId,
86        effect_commit_decision_id: EffectCommitDecisionId,
87        citation: V25ConstitutionCitation,
88        provider_route: Vec<String>,
89        execution_state: ExecutionStateV1,
90        partial_execution: bool,
91        cancellation_reason: impl Into<String>,
92        timeout_exhausted: bool,
93        generated_effect_refs: Vec<String>,
94        observed_external_handle: impl Into<String>,
95        generated_at: impl Into<String>,
96    ) -> EffectExecutionReceiptV1Builder {
97        EffectExecutionReceiptV1Builder::new(
98            effect_execution_receipt_id,
99            effect_commit_decision_id,
100            citation,
101            provider_route,
102            execution_state,
103            partial_execution,
104            cancellation_reason,
105            timeout_exhausted,
106            generated_effect_refs,
107            observed_external_handle,
108            generated_at,
109        )
110    }
111
112    /// Validates the owner-side invariants for an execution receipt.
113    pub fn validate(&self) -> EffectValidationResult {
114        require_schema_version(
115            "EffectExecutionReceiptV1",
116            Self::SCHEMA_VERSION,
117            &self.schema_version,
118        )?;
119        require_id(
120            &self.effect_execution_receipt_id,
121            "effect_execution_receipt_id",
122        )?;
123        require_id(&self.effect_commit_decision_id, "effect_commit_decision_id")?;
124        require_non_empty_slice(&self.provider_route, "provider_route")?;
125        require_non_empty(&self.generated_at, "generated_at")?;
126        require_non_empty(&self.observed_external_handle, "observed_external_handle")?;
127        if self.partial_execution != matches!(self.execution_state, ExecutionStateV1::Partial) {
128            return Err(EffectRuntimeValidationError::InvalidState(
129                "partial_execution must match execution_state=partial",
130            ));
131        }
132        if self.timeout_exhausted
133            && !matches!(
134                self.execution_state,
135                ExecutionStateV1::TimedOut | ExecutionStateV1::Partial
136            )
137        {
138            return Err(EffectRuntimeValidationError::InvalidState(
139                "timeout_exhausted requires execution_state=timed_out or partial",
140            ));
141        }
142        if matches!(self.execution_state, ExecutionStateV1::Completed)
143            && !self.cancellation_reason.trim().is_empty()
144        {
145            return Err(EffectRuntimeValidationError::InvalidState(
146                "completed execution receipts must not carry cancellation_reason",
147            ));
148        }
149        if matches!(
150            self.execution_state,
151            ExecutionStateV1::Completed | ExecutionStateV1::Partial
152        ) {
153            require_non_empty_slice(&self.generated_effect_refs, "generated_effect_refs")?;
154        }
155        Ok(())
156    }
157}
158
159/// Builder for [`EffectExecutionReceiptV1`].
160#[derive(Debug, Clone)]
161pub struct EffectExecutionReceiptV1Builder {
162    effect_execution_receipt_id: EffectExecutionReceiptId,
163    effect_commit_decision_id: EffectCommitDecisionId,
164    citation: V25ConstitutionCitation,
165    provider_route: Vec<String>,
166    execution_state: ExecutionStateV1,
167    partial_execution: bool,
168    cancellation_reason: String,
169    timeout_exhausted: bool,
170    generated_effect_refs: Vec<String>,
171    observed_external_handle: String,
172    generated_at: String,
173}
174
175impl EffectExecutionReceiptV1Builder {
176    /// Creates a builder with the required execution receipt fields.
177    #[allow(clippy::too_many_arguments)]
178    pub fn new(
179        effect_execution_receipt_id: EffectExecutionReceiptId,
180        effect_commit_decision_id: EffectCommitDecisionId,
181        citation: V25ConstitutionCitation,
182        provider_route: Vec<String>,
183        execution_state: ExecutionStateV1,
184        partial_execution: bool,
185        cancellation_reason: impl Into<String>,
186        timeout_exhausted: bool,
187        generated_effect_refs: Vec<String>,
188        observed_external_handle: impl Into<String>,
189        generated_at: impl Into<String>,
190    ) -> Self {
191        Self {
192            effect_execution_receipt_id,
193            effect_commit_decision_id,
194            citation,
195            provider_route,
196            execution_state,
197            partial_execution,
198            cancellation_reason: cancellation_reason.into(),
199            timeout_exhausted,
200            generated_effect_refs,
201            observed_external_handle: observed_external_handle.into(),
202            generated_at: generated_at.into(),
203        }
204    }
205
206    /// Builds a validated execution receipt artifact.
207    pub fn build(self) -> Result<EffectExecutionReceiptV1, EffectRuntimeValidationError> {
208        let value = EffectExecutionReceiptV1 {
209            schema_version: EffectExecutionReceiptV1::SCHEMA_VERSION.to_string(),
210            effect_execution_receipt_id: self.effect_execution_receipt_id,
211            effect_commit_decision_id: self.effect_commit_decision_id,
212            citation: self.citation,
213            provider_route: self.provider_route,
214            execution_state: self.execution_state,
215            partial_execution: self.partial_execution,
216            cancellation_reason: self.cancellation_reason,
217            timeout_exhausted: self.timeout_exhausted,
218            generated_effect_refs: self.generated_effect_refs,
219            observed_external_handle: self.observed_external_handle,
220            generated_at: self.generated_at,
221        };
222        value.validate()?;
223        Ok(value)
224    }
225}
226
227impl EffectObservationBundleV1 {
228    pub const SCHEMA_VERSION: &'static str = "EffectObservationBundleV1";
229
230    /// Returns a builder for a validated observation bundle artifact.
231    #[allow(clippy::too_many_arguments)]
232    pub fn builder(
233        effect_observation_bundle_id: EffectObservationBundleId,
234        effect_execution_receipt_id: EffectExecutionReceiptId,
235        citation: V25ConstitutionCitation,
236        observation_window: impl Into<String>,
237        observation_state: ObservationStateV1,
238        observed_outcome: impl Into<String>,
239        drift_summary: impl Into<String>,
240        external_evidence_refs: Vec<String>,
241        closure_recommendation: ClosureRecommendationV1,
242        generated_at: impl Into<String>,
243    ) -> EffectObservationBundleV1Builder {
244        EffectObservationBundleV1Builder::new(
245            effect_observation_bundle_id,
246            effect_execution_receipt_id,
247            citation,
248            observation_window,
249            observation_state,
250            observed_outcome,
251            drift_summary,
252            external_evidence_refs,
253            closure_recommendation,
254            generated_at,
255        )
256    }
257
258    /// Validates the owner-side invariants for an observation bundle.
259    pub fn validate(&self) -> EffectValidationResult {
260        require_schema_version(
261            "EffectObservationBundleV1",
262            Self::SCHEMA_VERSION,
263            &self.schema_version,
264        )?;
265        require_id(
266            &self.effect_observation_bundle_id,
267            "effect_observation_bundle_id",
268        )?;
269        require_id(
270            &self.effect_execution_receipt_id,
271            "effect_execution_receipt_id",
272        )?;
273        require_non_empty(&self.observation_window, "observation_window")?;
274        require_non_empty(&self.observed_outcome, "observed_outcome")?;
275        require_non_empty(&self.generated_at, "generated_at")?;
276        if matches!(self.observation_state, ObservationStateV1::Complete) {
277            require_non_empty_slice(&self.external_evidence_refs, "external_evidence_refs")?;
278        }
279        Ok(())
280    }
281}
282
283/// Builder for [`EffectObservationBundleV1`].
284#[derive(Debug, Clone)]
285pub struct EffectObservationBundleV1Builder {
286    effect_observation_bundle_id: EffectObservationBundleId,
287    effect_execution_receipt_id: EffectExecutionReceiptId,
288    citation: V25ConstitutionCitation,
289    observation_window: String,
290    observation_state: ObservationStateV1,
291    observed_outcome: String,
292    drift_summary: String,
293    external_evidence_refs: Vec<String>,
294    closure_recommendation: ClosureRecommendationV1,
295    generated_at: String,
296}
297
298impl EffectObservationBundleV1Builder {
299    /// Creates a builder with the required observation bundle fields.
300    #[allow(clippy::too_many_arguments)]
301    pub fn new(
302        effect_observation_bundle_id: EffectObservationBundleId,
303        effect_execution_receipt_id: EffectExecutionReceiptId,
304        citation: V25ConstitutionCitation,
305        observation_window: impl Into<String>,
306        observation_state: ObservationStateV1,
307        observed_outcome: impl Into<String>,
308        drift_summary: impl Into<String>,
309        external_evidence_refs: Vec<String>,
310        closure_recommendation: ClosureRecommendationV1,
311        generated_at: impl Into<String>,
312    ) -> Self {
313        Self {
314            effect_observation_bundle_id,
315            effect_execution_receipt_id,
316            citation,
317            observation_window: observation_window.into(),
318            observation_state,
319            observed_outcome: observed_outcome.into(),
320            drift_summary: drift_summary.into(),
321            external_evidence_refs,
322            closure_recommendation,
323            generated_at: generated_at.into(),
324        }
325    }
326
327    /// Builds a validated observation bundle artifact.
328    pub fn build(self) -> Result<EffectObservationBundleV1, EffectRuntimeValidationError> {
329        let value = EffectObservationBundleV1 {
330            schema_version: EffectObservationBundleV1::SCHEMA_VERSION.to_string(),
331            effect_observation_bundle_id: self.effect_observation_bundle_id,
332            effect_execution_receipt_id: self.effect_execution_receipt_id,
333            citation: self.citation,
334            observation_window: self.observation_window,
335            observation_state: self.observation_state,
336            observed_outcome: self.observed_outcome,
337            drift_summary: self.drift_summary,
338            external_evidence_refs: self.external_evidence_refs,
339            closure_recommendation: self.closure_recommendation,
340            generated_at: self.generated_at,
341        };
342        value.validate()?;
343        Ok(value)
344    }
345}
346
347impl ExternalEffectLedgerEntryV1 {
348    pub const SCHEMA_VERSION: &'static str = "ExternalEffectLedgerEntryV1";
349
350    /// Returns a builder for a validated external ledger entry artifact.
351    #[allow(clippy::too_many_arguments)]
352    pub fn builder(
353        external_effect_ledger_entry_id: ExternalEffectLedgerEntryId,
354        effect_execution_receipt_id: EffectExecutionReceiptId,
355        citation: V25ConstitutionCitation,
356        target_system: impl Into<String>,
357        external_handle: impl Into<String>,
358        externally_observed_state: ExternalObservationStateV1,
359        entered_at: impl Into<String>,
360        immutable: bool,
361    ) -> ExternalEffectLedgerEntryV1Builder {
362        ExternalEffectLedgerEntryV1Builder::new(
363            external_effect_ledger_entry_id,
364            effect_execution_receipt_id,
365            citation,
366            target_system,
367            external_handle,
368            externally_observed_state,
369            entered_at,
370            immutable,
371        )
372    }
373
374    /// Validates the owner-side invariants for an external ledger entry.
375    pub fn validate(&self) -> EffectValidationResult {
376        require_schema_version(
377            "ExternalEffectLedgerEntryV1",
378            Self::SCHEMA_VERSION,
379            &self.schema_version,
380        )?;
381        require_id(
382            &self.external_effect_ledger_entry_id,
383            "external_effect_ledger_entry_id",
384        )?;
385        require_id(
386            &self.effect_execution_receipt_id,
387            "effect_execution_receipt_id",
388        )?;
389        require_non_empty(&self.target_system, "target_system")?;
390        require_non_empty(&self.external_handle, "external_handle")?;
391        require_non_empty(&self.entered_at, "entered_at")?;
392        Ok(())
393    }
394}
395
396/// Builder for [`ExternalEffectLedgerEntryV1`].
397#[derive(Debug, Clone)]
398pub struct ExternalEffectLedgerEntryV1Builder {
399    external_effect_ledger_entry_id: ExternalEffectLedgerEntryId,
400    effect_execution_receipt_id: EffectExecutionReceiptId,
401    citation: V25ConstitutionCitation,
402    target_system: String,
403    external_handle: String,
404    externally_observed_state: ExternalObservationStateV1,
405    entered_at: String,
406    immutable: bool,
407}
408
409impl ExternalEffectLedgerEntryV1Builder {
410    /// Creates a builder with the required external ledger entry fields.
411    #[allow(clippy::too_many_arguments)]
412    pub fn new(
413        external_effect_ledger_entry_id: ExternalEffectLedgerEntryId,
414        effect_execution_receipt_id: EffectExecutionReceiptId,
415        citation: V25ConstitutionCitation,
416        target_system: impl Into<String>,
417        external_handle: impl Into<String>,
418        externally_observed_state: ExternalObservationStateV1,
419        entered_at: impl Into<String>,
420        immutable: bool,
421    ) -> Self {
422        Self {
423            external_effect_ledger_entry_id,
424            effect_execution_receipt_id,
425            citation,
426            target_system: target_system.into(),
427            external_handle: external_handle.into(),
428            externally_observed_state,
429            entered_at: entered_at.into(),
430            immutable,
431        }
432    }
433
434    /// Builds a validated external ledger entry artifact.
435    pub fn build(self) -> Result<ExternalEffectLedgerEntryV1, EffectRuntimeValidationError> {
436        let value = ExternalEffectLedgerEntryV1 {
437            schema_version: ExternalEffectLedgerEntryV1::SCHEMA_VERSION.to_string(),
438            external_effect_ledger_entry_id: self.external_effect_ledger_entry_id,
439            effect_execution_receipt_id: self.effect_execution_receipt_id,
440            citation: self.citation,
441            target_system: self.target_system,
442            external_handle: self.external_handle,
443            externally_observed_state: self.externally_observed_state,
444            entered_at: self.entered_at,
445            immutable: self.immutable,
446        };
447        value.validate()?;
448        Ok(value)
449    }
450}