Skip to main content

effect_runtime/
effect.rs

1//! Core effect lifecycle artifacts: window, intent, preflight, and commit decision.
2//!
3//! These types model the governed effect pipeline from intent declaration through
4//! commit authorization. Each artifact enforces owner-side invariants via its
5//! `validate()` method and is constructed through a builder pattern.
6
7use crate::error::{
8    require_id, require_non_empty, require_non_empty_slice, require_schema_version,
9    require_valid_timestamp, EffectRuntimeValidationError, EffectValidationResult,
10};
11use crate::v25::{V25ConstitutionCitation, V25ObligationRefs};
12use crate::vocab::{
13    BlastRadiusCeilingV1, BudgetSufficiencyResultV1, CheckResultV1, CloseMidflightBehaviorV1,
14    CommitAtomicityV1, EffectClassV1, PublicationStatusV1, RetryOwnerV1, ReversibilityClassV1,
15    RunModeV1,
16};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use stack_ids::{
20    ApprovalGrantId, EffectCommitDecisionId, EffectIntentId, EffectPreflightReportId,
21    EffectWindowId, ExecutionPermitId,
22};
23
24/// Outcome disposition of a preflight check determining whether an effect may proceed to commit.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum EffectPreflightDispositionV1 {
28    Blocked,
29    AdvisoryOnly,
30    CommitEligible,
31}
32
33/// Authorization outcome of a commit decision: denied, advisory-only, or authorized for execution.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "snake_case")]
36pub enum EffectCommitDispositionV1 {
37    Denied,
38    AdvisoryOnly,
39    Authorized,
40}
41
42/// Temporal execution window that bounds when an effect may start, timeout, and how mid-flight closures are handled.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
44pub struct EffectWindowV1 {
45    pub schema_version: String,
46    pub effect_window_id: EffectWindowId,
47    pub earliest_start: String,
48    pub latest_admissible_start: String,
49    pub timeout_at: String,
50    pub commit_atomicity: CommitAtomicityV1,
51    pub retry_owner: RetryOwnerV1,
52    pub close_midflight_behavior: CloseMidflightBehaviorV1,
53}
54
55/// Declaration of an intended effect including its class, target surface, blast radius, and approval requirements.
56///
57/// This is the entry point of the effect lifecycle. Live-mode intents require
58/// non-empty approval refs before they can proceed to preflight.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
60pub struct EffectIntentV1 {
61    pub schema_version: String,
62    pub effect_intent_id: EffectIntentId,
63    pub effect_class: EffectClassV1,
64    pub target_surface: String,
65    pub intended_outcome: String,
66    pub initiating_artifact_refs: Vec<String>,
67    pub required_capability_class: String,
68    pub allowed_scope: String,
69    pub blast_radius_ceiling: BlastRadiusCeilingV1,
70    pub reversibility_class: ReversibilityClassV1,
71    pub idempotency_key: String,
72    pub execution_window_id: EffectWindowId,
73    pub observability_obligation_refs: Vec<String>,
74    pub approval_refs: Vec<String>,
75    pub run_mode: RunModeV1,
76    pub publication_status: PublicationStatusV1,
77}
78
79/// Result of preflight checks against an effect intent, gating commit eligibility.
80///
81/// A `CommitEligible` disposition requires all check results to be passing
82/// and budget to be sufficient.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
84pub struct EffectPreflightReportV1 {
85    pub schema_version: String,
86    pub effect_preflight_report_id: EffectPreflightReportId,
87    pub effect_intent_id: EffectIntentId,
88    /// Carries ApplicabilityContextId, ProfileSetId, CompositionReceiptId,
89    /// EffectiveConstitutionId, CompiledObligationSetId, and ProfileExceptionBundleId.
90    #[serde(flatten)]
91    pub citation: V25ConstitutionCitation,
92    #[serde(flatten)]
93    pub obligation_refs: V25ObligationRefs,
94    pub input_integrity_result: CheckResultV1,
95    pub admission_check_result: CheckResultV1,
96    pub dependency_reachability_result: CheckResultV1,
97    pub budget_sufficiency_result: BudgetSufficiencyResultV1,
98    pub disposition: EffectPreflightDispositionV1,
99    pub generated_at: String,
100    pub notes: Vec<String>,
101}
102
103/// Authorization decision for committing an effect, linking intent and preflight report to an execution permit.
104///
105/// Authorized decisions must carry an execution permit and approver refs.
106/// Denied decisions must carry a refusal reason and must not mint permits.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108pub struct EffectCommitDecisionV1 {
109    pub schema_version: String,
110    pub effect_commit_decision_id: EffectCommitDecisionId,
111    pub effect_intent_id: EffectIntentId,
112    pub effect_preflight_report_id: EffectPreflightReportId,
113    /// Carries ApplicabilityContextId, ProfileSetId, CompositionReceiptId,
114    /// EffectiveConstitutionId, CompiledObligationSetId, and ProfileExceptionBundleId.
115    #[serde(flatten)]
116    pub citation: V25ConstitutionCitation,
117    #[serde(flatten)]
118    pub obligation_refs: V25ObligationRefs,
119    #[serde(default)]
120    pub approval_obligation_refs: Vec<String>,
121    pub decision_state: EffectCommitDispositionV1,
122    pub approver_refs: Vec<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub execution_permit_id: Option<ExecutionPermitId>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub approval_grant_id: Option<ApprovalGrantId>,
127    pub refusal_reason: String,
128    pub generated_at: String,
129}
130
131impl EffectWindowV1 {
132    pub const SCHEMA_VERSION: &'static str = "EffectWindowV1";
133
134    /// Returns a builder for a validated execution window artifact.
135    pub fn builder(
136        effect_window_id: EffectWindowId,
137        earliest_start: impl Into<String>,
138        latest_admissible_start: impl Into<String>,
139        timeout_at: impl Into<String>,
140        commit_atomicity: CommitAtomicityV1,
141        retry_owner: RetryOwnerV1,
142        close_midflight_behavior: CloseMidflightBehaviorV1,
143    ) -> EffectWindowV1Builder {
144        EffectWindowV1Builder::new(
145            effect_window_id,
146            earliest_start,
147            latest_admissible_start,
148            timeout_at,
149            commit_atomicity,
150            retry_owner,
151            close_midflight_behavior,
152        )
153    }
154
155    /// Validates the owner-side invariants for an execution window.
156    pub fn validate(&self) -> EffectValidationResult {
157        require_schema_version("EffectWindowV1", Self::SCHEMA_VERSION, &self.schema_version)?;
158        require_id(&self.effect_window_id, "effect_window_id")?;
159        require_valid_timestamp(&self.earliest_start, "earliest_start")?;
160        require_valid_timestamp(&self.latest_admissible_start, "latest_admissible_start")?;
161        require_valid_timestamp(&self.timeout_at, "timeout_at")?;
162        Ok(())
163    }
164}
165
166/// Builder for [`EffectWindowV1`].
167#[derive(Debug, Clone)]
168pub struct EffectWindowV1Builder {
169    effect_window_id: EffectWindowId,
170    earliest_start: String,
171    latest_admissible_start: String,
172    timeout_at: String,
173    commit_atomicity: CommitAtomicityV1,
174    retry_owner: RetryOwnerV1,
175    close_midflight_behavior: CloseMidflightBehaviorV1,
176}
177
178impl EffectWindowV1Builder {
179    /// Creates a builder with the required execution window fields.
180    pub fn new(
181        effect_window_id: EffectWindowId,
182        earliest_start: impl Into<String>,
183        latest_admissible_start: impl Into<String>,
184        timeout_at: impl Into<String>,
185        commit_atomicity: CommitAtomicityV1,
186        retry_owner: RetryOwnerV1,
187        close_midflight_behavior: CloseMidflightBehaviorV1,
188    ) -> Self {
189        Self {
190            effect_window_id,
191            earliest_start: earliest_start.into(),
192            latest_admissible_start: latest_admissible_start.into(),
193            timeout_at: timeout_at.into(),
194            commit_atomicity,
195            retry_owner,
196            close_midflight_behavior,
197        }
198    }
199
200    /// Builds a validated execution window artifact.
201    pub fn build(self) -> Result<EffectWindowV1, EffectRuntimeValidationError> {
202        let value = EffectWindowV1 {
203            schema_version: EffectWindowV1::SCHEMA_VERSION.to_string(),
204            effect_window_id: self.effect_window_id,
205            earliest_start: self.earliest_start,
206            latest_admissible_start: self.latest_admissible_start,
207            timeout_at: self.timeout_at,
208            commit_atomicity: self.commit_atomicity,
209            retry_owner: self.retry_owner,
210            close_midflight_behavior: self.close_midflight_behavior,
211        };
212        value.validate()?;
213        Ok(value)
214    }
215}
216
217impl EffectIntentV1 {
218    pub const SCHEMA_VERSION: &'static str = "EffectIntentV1";
219
220    /// Returns a builder for a validated effect intent artifact.
221    #[allow(clippy::too_many_arguments)]
222    pub fn builder(
223        effect_intent_id: EffectIntentId,
224        effect_class: EffectClassV1,
225        target_surface: impl Into<String>,
226        intended_outcome: impl Into<String>,
227        initiating_artifact_refs: Vec<String>,
228        required_capability_class: impl Into<String>,
229        allowed_scope: impl Into<String>,
230        blast_radius_ceiling: BlastRadiusCeilingV1,
231        reversibility_class: ReversibilityClassV1,
232        idempotency_key: impl Into<String>,
233        execution_window_id: EffectWindowId,
234        observability_obligation_refs: Vec<String>,
235        approval_refs: Vec<String>,
236        run_mode: RunModeV1,
237        publication_status: PublicationStatusV1,
238    ) -> EffectIntentV1Builder {
239        EffectIntentV1Builder::new(
240            effect_intent_id,
241            effect_class,
242            target_surface,
243            intended_outcome,
244            initiating_artifact_refs,
245            required_capability_class,
246            allowed_scope,
247            blast_radius_ceiling,
248            reversibility_class,
249            idempotency_key,
250            execution_window_id,
251            observability_obligation_refs,
252            approval_refs,
253            run_mode,
254            publication_status,
255        )
256    }
257
258    /// Validates the owner-side invariants for an effect intent.
259    pub fn validate(&self) -> EffectValidationResult {
260        require_schema_version("EffectIntentV1", Self::SCHEMA_VERSION, &self.schema_version)?;
261        require_id(&self.effect_intent_id, "effect_intent_id")?;
262        require_non_empty(&self.target_surface, "target_surface")?;
263        require_non_empty(&self.intended_outcome, "intended_outcome")?;
264        require_non_empty_slice(&self.initiating_artifact_refs, "initiating_artifact_refs")?;
265        require_non_empty(&self.required_capability_class, "required_capability_class")?;
266        require_non_empty(&self.allowed_scope, "allowed_scope")?;
267        require_non_empty(&self.idempotency_key, "idempotency_key")?;
268        require_id(&self.execution_window_id, "execution_window_id")?;
269        require_non_empty_slice(
270            &self.observability_obligation_refs,
271            "observability_obligation_refs",
272        )?;
273        if matches!(self.run_mode, RunModeV1::Live) {
274            require_non_empty_slice(&self.approval_refs, "approval_refs")?;
275        }
276        Ok(())
277    }
278}
279
280/// Builder for [`EffectIntentV1`].
281#[derive(Debug, Clone)]
282pub struct EffectIntentV1Builder {
283    effect_intent_id: EffectIntentId,
284    effect_class: EffectClassV1,
285    target_surface: String,
286    intended_outcome: String,
287    initiating_artifact_refs: Vec<String>,
288    required_capability_class: String,
289    allowed_scope: String,
290    blast_radius_ceiling: BlastRadiusCeilingV1,
291    reversibility_class: ReversibilityClassV1,
292    idempotency_key: String,
293    execution_window_id: EffectWindowId,
294    observability_obligation_refs: Vec<String>,
295    approval_refs: Vec<String>,
296    run_mode: RunModeV1,
297    publication_status: PublicationStatusV1,
298}
299
300impl EffectIntentV1Builder {
301    /// Creates a builder with the required effect intent fields.
302    #[allow(clippy::too_many_arguments)]
303    pub fn new(
304        effect_intent_id: EffectIntentId,
305        effect_class: EffectClassV1,
306        target_surface: impl Into<String>,
307        intended_outcome: impl Into<String>,
308        initiating_artifact_refs: Vec<String>,
309        required_capability_class: impl Into<String>,
310        allowed_scope: impl Into<String>,
311        blast_radius_ceiling: BlastRadiusCeilingV1,
312        reversibility_class: ReversibilityClassV1,
313        idempotency_key: impl Into<String>,
314        execution_window_id: EffectWindowId,
315        observability_obligation_refs: Vec<String>,
316        approval_refs: Vec<String>,
317        run_mode: RunModeV1,
318        publication_status: PublicationStatusV1,
319    ) -> Self {
320        Self {
321            effect_intent_id,
322            effect_class,
323            target_surface: target_surface.into(),
324            intended_outcome: intended_outcome.into(),
325            initiating_artifact_refs,
326            required_capability_class: required_capability_class.into(),
327            allowed_scope: allowed_scope.into(),
328            blast_radius_ceiling,
329            reversibility_class,
330            idempotency_key: idempotency_key.into(),
331            execution_window_id,
332            observability_obligation_refs,
333            approval_refs,
334            run_mode,
335            publication_status,
336        }
337    }
338
339    /// Set the effect class.
340    pub fn with_effect_class(mut self, effect_class: EffectClassV1) -> Self {
341        self.effect_class = effect_class;
342        self
343    }
344
345    /// Set the blast radius ceiling.
346    pub fn with_blast_radius_ceiling(mut self, ceiling: BlastRadiusCeilingV1) -> Self {
347        self.blast_radius_ceiling = ceiling;
348        self
349    }
350
351    /// Set the reversibility class.
352    pub fn with_reversibility_class(mut self, class: ReversibilityClassV1) -> Self {
353        self.reversibility_class = class;
354        self
355    }
356
357    /// Set the run mode.
358    pub fn with_run_mode(mut self, mode: RunModeV1) -> Self {
359        self.run_mode = mode;
360        self
361    }
362
363    /// Set the publication status.
364    pub fn with_publication_status(mut self, status: PublicationStatusV1) -> Self {
365        self.publication_status = status;
366        self
367    }
368
369    /// Append approval references.
370    pub fn with_approval_refs(mut self, refs: Vec<String>) -> Self {
371        self.approval_refs = refs;
372        self
373    }
374
375    /// Builds a validated effect intent artifact.
376    pub fn build(self) -> Result<EffectIntentV1, EffectRuntimeValidationError> {
377        let value = EffectIntentV1 {
378            schema_version: EffectIntentV1::SCHEMA_VERSION.to_string(),
379            effect_intent_id: self.effect_intent_id,
380            effect_class: self.effect_class,
381            target_surface: self.target_surface,
382            intended_outcome: self.intended_outcome,
383            initiating_artifact_refs: self.initiating_artifact_refs,
384            required_capability_class: self.required_capability_class,
385            allowed_scope: self.allowed_scope,
386            blast_radius_ceiling: self.blast_radius_ceiling,
387            reversibility_class: self.reversibility_class,
388            idempotency_key: self.idempotency_key,
389            execution_window_id: self.execution_window_id,
390            observability_obligation_refs: self.observability_obligation_refs,
391            approval_refs: self.approval_refs,
392            run_mode: self.run_mode,
393            publication_status: self.publication_status,
394        };
395        value.validate()?;
396        Ok(value)
397    }
398}
399
400impl EffectPreflightReportV1 {
401    pub const SCHEMA_VERSION: &'static str = "EffectPreflightReportV1";
402
403    /// Returns a builder for a validated preflight report artifact.
404    #[allow(clippy::too_many_arguments)]
405    pub fn builder(
406        effect_preflight_report_id: EffectPreflightReportId,
407        effect_intent_id: EffectIntentId,
408        citation: V25ConstitutionCitation,
409        obligation_refs: V25ObligationRefs,
410        input_integrity_result: CheckResultV1,
411        admission_check_result: CheckResultV1,
412        dependency_reachability_result: CheckResultV1,
413        budget_sufficiency_result: BudgetSufficiencyResultV1,
414        disposition: EffectPreflightDispositionV1,
415        generated_at: impl Into<String>,
416        notes: Vec<String>,
417    ) -> EffectPreflightReportV1Builder {
418        EffectPreflightReportV1Builder::new(
419            effect_preflight_report_id,
420            effect_intent_id,
421            citation,
422            obligation_refs,
423            input_integrity_result,
424            admission_check_result,
425            dependency_reachability_result,
426            budget_sufficiency_result,
427            disposition,
428            generated_at,
429            notes,
430        )
431    }
432
433    /// Validates the owner-side invariants for a preflight report.
434    pub fn validate(&self) -> EffectValidationResult {
435        require_schema_version(
436            "EffectPreflightReportV1",
437            Self::SCHEMA_VERSION,
438            &self.schema_version,
439        )?;
440        require_id(
441            &self.effect_preflight_report_id,
442            "effect_preflight_report_id",
443        )?;
444        require_id(&self.effect_intent_id, "effect_intent_id")?;
445        require_non_empty(&self.generated_at, "generated_at")?;
446        require_non_empty_slice(
447            &self.obligation_refs.required_obligation_refs,
448            "required_obligation_refs",
449        )?;
450        if matches!(
451            self.disposition,
452            EffectPreflightDispositionV1::CommitEligible
453        ) && !matches!(
454            (
455                self.input_integrity_result,
456                self.admission_check_result,
457                self.dependency_reachability_result,
458                self.budget_sufficiency_result,
459            ),
460            (
461                CheckResultV1::Pass,
462                CheckResultV1::Pass,
463                CheckResultV1::Pass,
464                BudgetSufficiencyResultV1::Sufficient,
465            )
466        ) {
467            return Err(EffectRuntimeValidationError::InvalidState(
468                "commit-eligible preflight requires pass/pass/pass/sufficient checks",
469            ));
470        }
471        Ok(())
472    }
473}
474
475/// Builder for [`EffectPreflightReportV1`].
476#[derive(Debug, Clone)]
477pub struct EffectPreflightReportV1Builder {
478    effect_preflight_report_id: EffectPreflightReportId,
479    effect_intent_id: EffectIntentId,
480    citation: V25ConstitutionCitation,
481    obligation_refs: V25ObligationRefs,
482    input_integrity_result: CheckResultV1,
483    admission_check_result: CheckResultV1,
484    dependency_reachability_result: CheckResultV1,
485    budget_sufficiency_result: BudgetSufficiencyResultV1,
486    disposition: EffectPreflightDispositionV1,
487    generated_at: String,
488    notes: Vec<String>,
489}
490
491impl EffectPreflightReportV1Builder {
492    /// Creates a builder with the required preflight report fields.
493    #[allow(clippy::too_many_arguments)]
494    pub fn new(
495        effect_preflight_report_id: EffectPreflightReportId,
496        effect_intent_id: EffectIntentId,
497        citation: V25ConstitutionCitation,
498        obligation_refs: V25ObligationRefs,
499        input_integrity_result: CheckResultV1,
500        admission_check_result: CheckResultV1,
501        dependency_reachability_result: CheckResultV1,
502        budget_sufficiency_result: BudgetSufficiencyResultV1,
503        disposition: EffectPreflightDispositionV1,
504        generated_at: impl Into<String>,
505        notes: Vec<String>,
506    ) -> Self {
507        Self {
508            effect_preflight_report_id,
509            effect_intent_id,
510            citation,
511            obligation_refs,
512            input_integrity_result,
513            admission_check_result,
514            dependency_reachability_result,
515            budget_sufficiency_result,
516            disposition,
517            generated_at: generated_at.into(),
518            notes,
519        }
520    }
521
522    /// Builds a validated preflight report artifact.
523    pub fn build(self) -> Result<EffectPreflightReportV1, EffectRuntimeValidationError> {
524        let value = EffectPreflightReportV1 {
525            schema_version: EffectPreflightReportV1::SCHEMA_VERSION.to_string(),
526            effect_preflight_report_id: self.effect_preflight_report_id,
527            effect_intent_id: self.effect_intent_id,
528            citation: self.citation,
529            obligation_refs: self.obligation_refs,
530            input_integrity_result: self.input_integrity_result,
531            admission_check_result: self.admission_check_result,
532            dependency_reachability_result: self.dependency_reachability_result,
533            budget_sufficiency_result: self.budget_sufficiency_result,
534            disposition: self.disposition,
535            generated_at: self.generated_at,
536            notes: self.notes,
537        };
538        value.validate()?;
539        Ok(value)
540    }
541}
542
543impl EffectCommitDecisionV1 {
544    pub const SCHEMA_VERSION: &'static str = "EffectCommitDecisionV1";
545
546    /// Returns a builder for a validated commit decision artifact.
547    #[allow(clippy::too_many_arguments)]
548    pub fn builder(
549        effect_commit_decision_id: EffectCommitDecisionId,
550        effect_intent_id: EffectIntentId,
551        effect_preflight_report_id: EffectPreflightReportId,
552        citation: V25ConstitutionCitation,
553        obligation_refs: V25ObligationRefs,
554        approval_obligation_refs: Vec<String>,
555        decision_state: EffectCommitDispositionV1,
556        approver_refs: Vec<String>,
557        execution_permit_id: Option<ExecutionPermitId>,
558        approval_grant_id: Option<ApprovalGrantId>,
559        refusal_reason: impl Into<String>,
560        generated_at: impl Into<String>,
561    ) -> EffectCommitDecisionV1Builder {
562        EffectCommitDecisionV1Builder::new(
563            effect_commit_decision_id,
564            effect_intent_id,
565            effect_preflight_report_id,
566            citation,
567            obligation_refs,
568            approval_obligation_refs,
569            decision_state,
570            approver_refs,
571            execution_permit_id,
572            approval_grant_id,
573            refusal_reason,
574            generated_at,
575        )
576    }
577
578    /// Validates the owner-side invariants for a commit decision.
579    pub fn validate(&self) -> EffectValidationResult {
580        require_schema_version(
581            "EffectCommitDecisionV1",
582            Self::SCHEMA_VERSION,
583            &self.schema_version,
584        )?;
585        require_id(&self.effect_commit_decision_id, "effect_commit_decision_id")?;
586        require_id(&self.effect_intent_id, "effect_intent_id")?;
587        require_id(
588            &self.effect_preflight_report_id,
589            "effect_preflight_report_id",
590        )?;
591        require_non_empty_slice(
592            &self.obligation_refs.required_obligation_refs,
593            "required_obligation_refs",
594        )?;
595        require_non_empty(&self.generated_at, "generated_at")?;
596        match self.decision_state {
597            EffectCommitDispositionV1::Authorized => {
598                if self.execution_permit_id.is_none() {
599                    return Err(EffectRuntimeValidationError::InvalidState(
600                        "authorized commit decisions require execution_permit_id",
601                    ));
602                }
603                require_non_empty_slice(&self.approver_refs, "approver_refs")?;
604                require_non_empty_slice(
605                    &self.approval_obligation_refs,
606                    "approval_obligation_refs",
607                )?;
608                if !self.refusal_reason.trim().is_empty() {
609                    return Err(EffectRuntimeValidationError::InvalidState(
610                        "authorized commit decisions must not carry refusal_reason",
611                    ));
612                }
613            }
614            EffectCommitDispositionV1::AdvisoryOnly => {
615                if self.execution_permit_id.is_some() || self.approval_grant_id.is_some() {
616                    return Err(EffectRuntimeValidationError::InvalidState(
617                        "advisory-only commit decisions must not mint execution permits or approval grants",
618                    ));
619                }
620            }
621            EffectCommitDispositionV1::Denied => {
622                if self.execution_permit_id.is_some() || self.approval_grant_id.is_some() {
623                    return Err(EffectRuntimeValidationError::InvalidState(
624                        "denied commit decisions must not mint execution permits or approval grants",
625                    ));
626                }
627                require_non_empty(&self.refusal_reason, "refusal_reason")?;
628            }
629        }
630        Ok(())
631    }
632}
633
634/// Builder for [`EffectCommitDecisionV1`].
635#[derive(Debug, Clone)]
636pub struct EffectCommitDecisionV1Builder {
637    effect_commit_decision_id: EffectCommitDecisionId,
638    effect_intent_id: EffectIntentId,
639    effect_preflight_report_id: EffectPreflightReportId,
640    citation: V25ConstitutionCitation,
641    obligation_refs: V25ObligationRefs,
642    approval_obligation_refs: Vec<String>,
643    decision_state: EffectCommitDispositionV1,
644    approver_refs: Vec<String>,
645    execution_permit_id: Option<ExecutionPermitId>,
646    approval_grant_id: Option<ApprovalGrantId>,
647    refusal_reason: String,
648    generated_at: String,
649}
650
651impl EffectCommitDecisionV1Builder {
652    /// Creates a builder with the required commit decision fields.
653    #[allow(clippy::too_many_arguments)]
654    pub fn new(
655        effect_commit_decision_id: EffectCommitDecisionId,
656        effect_intent_id: EffectIntentId,
657        effect_preflight_report_id: EffectPreflightReportId,
658        citation: V25ConstitutionCitation,
659        obligation_refs: V25ObligationRefs,
660        approval_obligation_refs: Vec<String>,
661        decision_state: EffectCommitDispositionV1,
662        approver_refs: Vec<String>,
663        execution_permit_id: Option<ExecutionPermitId>,
664        approval_grant_id: Option<ApprovalGrantId>,
665        refusal_reason: impl Into<String>,
666        generated_at: impl Into<String>,
667    ) -> Self {
668        Self {
669            effect_commit_decision_id,
670            effect_intent_id,
671            effect_preflight_report_id,
672            citation,
673            obligation_refs,
674            approval_obligation_refs,
675            decision_state,
676            approver_refs,
677            execution_permit_id,
678            approval_grant_id,
679            refusal_reason: refusal_reason.into(),
680            generated_at: generated_at.into(),
681        }
682    }
683
684    /// Builds a validated commit decision artifact.
685    pub fn build(self) -> Result<EffectCommitDecisionV1, EffectRuntimeValidationError> {
686        let value = EffectCommitDecisionV1 {
687            schema_version: EffectCommitDecisionV1::SCHEMA_VERSION.to_string(),
688            effect_commit_decision_id: self.effect_commit_decision_id,
689            effect_intent_id: self.effect_intent_id,
690            effect_preflight_report_id: self.effect_preflight_report_id,
691            citation: self.citation,
692            obligation_refs: self.obligation_refs,
693            approval_obligation_refs: self.approval_obligation_refs,
694            decision_state: self.decision_state,
695            approver_refs: self.approver_refs,
696            execution_permit_id: self.execution_permit_id,
697            approval_grant_id: self.approval_grant_id,
698            refusal_reason: self.refusal_reason,
699            generated_at: self.generated_at,
700        };
701        value.validate()?;
702        Ok(value)
703    }
704}