Skip to main content

oxide_batch_repository/
operator.rs

1//! The durable operator request, audit record, and guard vocabulary.
2//!
3//! Every mutating action carries a bounded envelope and commits its append-only
4//! audit row in the same transaction as its effect. The values below are what a
5//! metadata adapter persists and replays; the service that applies them lives
6//! above this crate.
7
8use std::fmt;
9use std::time::SystemTime;
10
11use oxide_batch_core::{
12    BatchStatus, DefinitionIdentity, ExecutionVersion, FailureSummary, JobExecutionId,
13    JobInstanceId, JobInstanceKey, LifecycleError, OperatorRequestId,
14};
15
16use crate::{
17    ActorRef, AuthorizationClass, OperationId, OperatorAction, ReasonCode, RecoveryDisposition,
18    RecoveryProposal, RecoveryRequest, RecoveryRequestError, RepositoryError, RequestArguments,
19    RequestDigest, hex_digest, request_digest,
20};
21
22/// One validated mutating operator request.
23///
24/// The request digest covers the action, target identity, expected version,
25/// and bounded arguments. Replaying an operation identifier with a different
26/// digest is a conflict rather than a repeat.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct OperatorRequest {
29    action: OperatorAction,
30    operation_id: OperationId,
31    actor: ActorRef,
32    reason: Option<ReasonCode>,
33    target: OperatorTarget,
34    expected_version: Option<ExecutionVersion>,
35    arguments: RequestArguments,
36    digest: RequestDigest,
37}
38
39/// The disposition of one recovery decision together with the evidence that
40/// disposition requires.
41///
42/// Pairing the two makes a `MarkFailed` decision without its stated failure
43/// unrepresentable rather than a deferred validation error, and keeps an
44/// `Abandon` decision from carrying a failure that its durable outcome ignores
45/// but its request digest would still cover.
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47#[non_exhaustive]
48pub enum RecoveryDirective {
49    /// Make the observed attempt restart-eligible under a stated failure.
50    MarkFailed(FailureSummary),
51    /// Make the logical instance permanently non-restartable.
52    Abandon,
53}
54
55impl RecoveryDirective {
56    /// Returns the durable disposition this directive requests.
57    #[must_use]
58    pub const fn disposition(self) -> RecoveryDisposition {
59        match self {
60            Self::MarkFailed(_) => RecoveryDisposition::MarkFailed,
61            Self::Abandon => RecoveryDisposition::Abandon,
62        }
63    }
64
65    /// Returns the stated failure of a `MarkFailed` directive.
66    #[must_use]
67    pub const fn failure(self) -> Option<FailureSummary> {
68        match self {
69            Self::MarkFailed(failure) => Some(failure),
70            Self::Abandon => None,
71        }
72    }
73}
74
75#[derive(Clone, Debug, Eq, PartialEq)]
76enum OperatorTarget {
77    InstanceKey(Box<JobInstanceKey>),
78    Instance(JobInstanceId),
79    Execution(JobExecutionId),
80}
81
82impl OperatorTarget {
83    fn identity(&self) -> String {
84        match self {
85            Self::InstanceKey(key) => {
86                format!("instance-key:{}", hex_digest(&key.digest()))
87            }
88            Self::Instance(id) => format!("instance:{id}"),
89            Self::Execution(id) => format!("execution:{id}"),
90        }
91    }
92}
93
94impl OperatorRequest {
95    /// Requests one launch of the instance selected by an identifying key.
96    #[must_use]
97    pub fn launch(
98        operation_id: OperationId,
99        actor: ActorRef,
100        key: JobInstanceKey,
101        definition: DefinitionIdentity,
102    ) -> Self {
103        Self::build(
104            OperatorAction::Launch,
105            operation_id,
106            actor,
107            None,
108            OperatorTarget::InstanceKey(Box::new(key)),
109            None,
110            RequestArguments::Definition(Box::new(definition)),
111        )
112    }
113
114    /// Requests one restart attempt for an existing logical instance.
115    #[must_use]
116    pub fn restart(
117        operation_id: OperationId,
118        actor: ActorRef,
119        job_instance_id: JobInstanceId,
120        definition: DefinitionIdentity,
121    ) -> Self {
122        Self::build(
123            OperatorAction::Restart,
124            operation_id,
125            actor,
126            None,
127            OperatorTarget::Instance(job_instance_id),
128            None,
129            RequestArguments::Definition(Box::new(definition)),
130        )
131    }
132
133    /// Requests one durable cooperative stop.
134    #[must_use]
135    pub fn stop(
136        operation_id: OperationId,
137        actor: ActorRef,
138        job_execution_id: JobExecutionId,
139        expected_version: ExecutionVersion,
140    ) -> Self {
141        Self::build(
142            OperatorAction::Stop,
143            operation_id,
144            actor,
145            None,
146            OperatorTarget::Execution(job_execution_id),
147            Some(expected_version),
148            RequestArguments::None,
149        )
150    }
151
152    /// Requests that one finished or recovered execution become `ABANDONED`.
153    #[must_use]
154    pub fn abandon(
155        operation_id: OperationId,
156        actor: ActorRef,
157        reason: ReasonCode,
158        job_execution_id: JobExecutionId,
159        expected_version: ExecutionVersion,
160    ) -> Self {
161        Self::build(
162            OperatorAction::Abandon,
163            operation_id,
164            actor,
165            Some(reason),
166            OperatorTarget::Execution(job_execution_id),
167            Some(expected_version),
168            RequestArguments::None,
169        )
170    }
171
172    /// Requests one evidence-bound recovery decision.
173    #[must_use]
174    pub fn recover(
175        operation_id: OperationId,
176        actor: ActorRef,
177        reason: ReasonCode,
178        directive: RecoveryDirective,
179        proposal: &RecoveryProposal,
180    ) -> Self {
181        let job_execution_id = proposal.evidence().execution_id();
182        let expected_version = proposal.observed_version();
183        let evidence_digest = *proposal.digest();
184        Self::build(
185            OperatorAction::Recover,
186            operation_id,
187            actor,
188            Some(reason),
189            OperatorTarget::Execution(job_execution_id),
190            Some(expected_version),
191            RequestArguments::Recovery {
192                directive,
193                evidence_digest,
194                unknown_commit: proposal.evidence().unknown_commit(),
195            },
196        )
197    }
198
199    fn build(
200        action: OperatorAction,
201        operation_id: OperationId,
202        actor: ActorRef,
203        reason: Option<ReasonCode>,
204        target: OperatorTarget,
205        expected_version: Option<ExecutionVersion>,
206        arguments: RequestArguments,
207    ) -> Self {
208        let digest = request_digest(
209            action,
210            &target.identity(),
211            expected_version,
212            reason.as_ref(),
213            &arguments,
214        );
215        Self {
216            action,
217            operation_id,
218            actor,
219            reason,
220            target,
221            expected_version,
222            arguments,
223            digest,
224        }
225    }
226
227    /// Returns the requested action.
228    #[must_use]
229    pub const fn action(&self) -> OperatorAction {
230        self.action
231    }
232
233    /// Returns the class a deployment authorizes before this call.
234    #[must_use]
235    pub const fn authorization_class(&self) -> AuthorizationClass {
236        self.action.authorization_class()
237    }
238
239    /// Borrows the caller-supplied idempotency key.
240    #[must_use]
241    pub const fn operation_id(&self) -> &OperationId {
242        &self.operation_id
243    }
244
245    /// Borrows the opaque actor reference.
246    #[must_use]
247    pub const fn actor(&self) -> &ActorRef {
248        &self.actor
249    }
250
251    /// Borrows the closed-set reason code, when the action requires one.
252    #[must_use]
253    pub const fn reason(&self) -> Option<&ReasonCode> {
254        self.reason.as_ref()
255    }
256
257    /// Returns the observed optimistic version for a lifecycle mutation.
258    #[must_use]
259    pub const fn expected_version(&self) -> Option<ExecutionVersion> {
260        self.expected_version
261    }
262
263    /// Returns the framework-computed canonical request digest.
264    #[must_use]
265    pub const fn digest(&self) -> &RequestDigest {
266        &self.digest
267    }
268
269    /// Returns the targeted execution, when the action names one.
270    #[must_use]
271    pub const fn job_execution_id(&self) -> Option<JobExecutionId> {
272        match self.target {
273            OperatorTarget::Execution(id) => Some(id),
274            _ => None,
275        }
276    }
277
278    /// Returns the targeted logical instance, when the action names one.
279    #[must_use]
280    pub const fn job_instance_id(&self) -> Option<JobInstanceId> {
281        match self.target {
282            OperatorTarget::Instance(id) => Some(id),
283            _ => None,
284        }
285    }
286
287    /// Borrows the identifying key, when the action selects by key.
288    ///
289    /// Only [`OperatorAction::Launch`] names one; every other action names an
290    /// instance or an execution.
291    #[must_use]
292    pub fn job_instance_key(&self) -> Option<&JobInstanceKey> {
293        match &self.target {
294            OperatorTarget::InstanceKey(key) => Some(key),
295            _ => None,
296        }
297    }
298
299    /// Borrows the definition identity this request launches with, if any.
300    #[doc(hidden)]
301    #[must_use]
302    pub fn definition(&self) -> Option<&DefinitionIdentity> {
303        match &self.arguments {
304            RequestArguments::Definition(definition) => Some(definition),
305            _ => None,
306        }
307    }
308
309    /// Builds the durable recovery request this operator request carries.
310    #[doc(hidden)]
311    #[must_use]
312    pub fn recovery_request(&self) -> Option<Result<RecoveryRequest, RecoveryRequestError>> {
313        let RequestArguments::Recovery {
314            directive,
315            evidence_digest,
316            ..
317        } = &self.arguments
318        else {
319            return None;
320        };
321        let expected_version = self.expected_version?;
322        let reason = self.reason.as_ref()?;
323        Some(match directive {
324            RecoveryDirective::Abandon => RecoveryRequest::abandon(
325                expected_version,
326                reason.as_str(),
327                self.actor.as_str(),
328                *evidence_digest,
329            ),
330            RecoveryDirective::MarkFailed(failure) => RecoveryRequest::mark_failed(
331                expected_version,
332                reason.as_str(),
333                self.actor.as_str(),
334                *evidence_digest,
335                failure.category(),
336                failure.failure_id(),
337            ),
338        })
339    }
340
341    /// Borrows the recovery guard this request must satisfy, when it has one.
342    #[doc(hidden)]
343    #[must_use]
344    pub fn recovery_guard(&self) -> Option<(RecoveryDirective, bool)> {
345        match &self.arguments {
346            RequestArguments::Recovery {
347                directive,
348                unknown_commit,
349                ..
350            } => Some((*directive, *unknown_commit)),
351            _ => None,
352        }
353    }
354}
355
356/// The durable class of one recorded operator request.
357#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
358#[non_exhaustive]
359pub enum OperatorOutcomeClass {
360    /// The request was guarded, applied, and audited.
361    Applied,
362    /// A durable record for this operation identifier already existed.
363    Replayed,
364    /// A guard rejected the request; the audit row records the class.
365    Rejected,
366}
367
368impl OperatorOutcomeClass {
369    /// Returns the stable durable code for this class.
370    #[must_use]
371    pub const fn as_str(self) -> &'static str {
372        match self {
373            Self::Applied => "APPLIED",
374            Self::Replayed => "REPLAYED",
375            Self::Rejected => "REJECTED",
376        }
377    }
378}
379
380/// The typed reason one guard rejected an operator action.
381///
382/// A rejection is durable and audited. It carries no user error text, SQL, or
383/// credential.
384#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385#[non_exhaustive]
386pub enum OperatorRejection {
387    /// The supplied expected version lost its compare-and-swap.
388    OptimisticConflict {
389        /// The version observed under lock.
390        current: ExecutionVersion,
391    },
392    /// The action is not legal from the observed status.
393    InvalidState {
394        /// The status observed under lock.
395        status: BatchStatus,
396    },
397    /// The logical instance already completed.
398    InstanceCompleted,
399    /// The logical instance is permanently abandoned.
400    InstanceAbandoned,
401    /// Another attempt is active or requires explicit recovery.
402    ExecutionAlreadyActive {
403        /// The attempt preventing the action.
404        execution_id: JobExecutionId,
405        /// Its status observed under lock.
406        status: BatchStatus,
407    },
408    /// The proposed definition cannot interpret the committed checkpoint.
409    IncompatibleDefinition,
410    /// A restart was requested for an instance with no prior attempt.
411    RestartWithoutPriorAttempt,
412    /// The instance-wide start limit for a logical step is exhausted.
413    StartLimitExceeded,
414    /// Abandoning an ambiguous execution requires an applied recovery decision.
415    UnresolvedRecoveryRequired,
416    /// The targeted execution does not exist.
417    ExecutionNotFound,
418    /// The targeted logical instance does not exist.
419    InstanceNotFound,
420    /// This build cannot apply the requested action.
421    ///
422    /// [`OperatorAction`] is `#[non_exhaustive]`, so a caller compiled against
423    /// a newer definition of it can name an action this build has no effect
424    /// for. Rejecting is the conservative arm: the request is audited and
425    /// nothing is applied.
426    UnsupportedAction,
427}
428
429impl OperatorRejection {
430    /// Returns the stable durable code for this rejection.
431    #[must_use]
432    pub const fn as_str(self) -> &'static str {
433        match self {
434            Self::OptimisticConflict { .. } => "OPTIMISTIC_CONFLICT",
435            Self::InvalidState { .. } => "INVALID_STATE",
436            Self::InstanceCompleted => "INSTANCE_COMPLETED",
437            Self::InstanceAbandoned => "INSTANCE_ABANDONED",
438            Self::ExecutionAlreadyActive { .. } => "EXECUTION_ALREADY_ACTIVE",
439            Self::IncompatibleDefinition => "INCOMPATIBLE_DEFINITION",
440            Self::RestartWithoutPriorAttempt => "RESTART_WITHOUT_PRIOR_ATTEMPT",
441            Self::StartLimitExceeded => "START_LIMIT_EXCEEDED",
442            Self::UnresolvedRecoveryRequired => "UNRESOLVED_RECOVERY_REQUIRED",
443            Self::ExecutionNotFound => "EXECUTION_NOT_FOUND",
444            Self::InstanceNotFound => "INSTANCE_NOT_FOUND",
445            Self::UnsupportedAction => "UNSUPPORTED_ACTION",
446        }
447    }
448
449    /// Classifies one repository failure as a guard rejection, when it is one.
450    #[doc(hidden)]
451    #[must_use]
452    pub fn from_repository(error: &RepositoryError) -> Option<Self> {
453        match error {
454            RepositoryError::CompletedInstance { .. } => Some(Self::InstanceCompleted),
455            RepositoryError::AbandonedInstance { .. } => Some(Self::InstanceAbandoned),
456            RepositoryError::ExecutionAlreadyActive {
457                execution_id,
458                status,
459                ..
460            } => Some(Self::ExecutionAlreadyActive {
461                execution_id: *execution_id,
462                status: *status,
463            }),
464            RepositoryError::IncompatibleDefinition { .. }
465            | RepositoryError::RestartStateNotFound { .. }
466            | RepositoryError::InvalidDefinitionUpgrade { .. } => {
467                Some(Self::IncompatibleDefinition)
468            }
469            RepositoryError::StartLimitExceeded { .. } => Some(Self::StartLimitExceeded),
470            RepositoryError::JobExecutionNotFound { .. } => Some(Self::ExecutionNotFound),
471            RepositoryError::JobInstanceNotFound { .. } => Some(Self::InstanceNotFound),
472
473            RepositoryError::Lifecycle(LifecycleError::StaleVersion { actual, .. }) => {
474                Some(Self::OptimisticConflict { current: *actual })
475            }
476            RepositoryError::Lifecycle(
477                LifecycleError::IllegalTransition { from, .. }
478                | LifecycleError::RestartRequiresNewAttempt { from },
479            ) => Some(Self::InvalidState { status: *from }),
480            RepositoryError::RecoveryNotAllowed { status, .. }
481            | RepositoryError::Lifecycle(LifecycleError::NotRestartable { status }) => {
482                Some(Self::InvalidState { status: *status })
483            }
484            _ => None,
485        }
486    }
487}
488
489impl fmt::Display for OperatorRejection {
490    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
491        formatter.write_str(self.as_str())
492    }
493}
494
495/// One append-only operator audit and idempotency record.
496#[derive(Clone, Debug, Eq, PartialEq)]
497pub struct OperatorRecord {
498    id: OperatorRequestId,
499    action: OperatorAction,
500    operation_id: OperationId,
501    actor: ActorRef,
502    reason: Option<ReasonCode>,
503    digest: RequestDigest,
504    job_instance_id: Option<JobInstanceId>,
505    job_execution_id: Option<JobExecutionId>,
506    observed_version: Option<ExecutionVersion>,
507    prior_status: Option<BatchStatus>,
508    result_status: Option<BatchStatus>,
509    outcome: OperatorOutcomeClass,
510    rejection: Option<OperatorRejection>,
511    requested_at: SystemTime,
512}
513
514impl OperatorRecord {
515    /// Rebuilds a record read from a durable adapter.
516    #[must_use]
517    pub fn from_parts(id: OperatorRequestId, draft: OperatorRecordDraft) -> Self {
518        Self {
519            id,
520            action: draft.action,
521            operation_id: draft.operation_id,
522            actor: draft.actor,
523            reason: draft.reason,
524            digest: draft.digest,
525            job_instance_id: draft.job_instance_id,
526            job_execution_id: draft.job_execution_id,
527            observed_version: draft.observed_version,
528            prior_status: draft.prior_status,
529            result_status: draft.result_status,
530            outcome: draft.outcome,
531            rejection: draft.rejection,
532            requested_at: draft.requested_at,
533        }
534    }
535
536    /// Returns the opaque record identifier.
537    #[must_use]
538    pub const fn id(&self) -> OperatorRequestId {
539        self.id
540    }
541
542    /// Returns the audited action.
543    #[must_use]
544    pub const fn action(&self) -> OperatorAction {
545        self.action
546    }
547
548    /// Borrows the idempotency key.
549    #[must_use]
550    pub const fn operation_id(&self) -> &OperationId {
551        &self.operation_id
552    }
553
554    /// Borrows the opaque actor reference.
555    #[must_use]
556    pub const fn actor(&self) -> &ActorRef {
557        &self.actor
558    }
559
560    /// Borrows the closed-set reason code, when the action required one.
561    #[must_use]
562    pub const fn reason(&self) -> Option<&ReasonCode> {
563        self.reason.as_ref()
564    }
565
566    /// Returns the recorded canonical request digest.
567    #[must_use]
568    pub const fn digest(&self) -> &RequestDigest {
569        &self.digest
570    }
571
572    /// Returns the audited logical instance, when the action named one.
573    #[must_use]
574    pub const fn job_instance_id(&self) -> Option<JobInstanceId> {
575        self.job_instance_id
576    }
577
578    /// Returns the audited execution, when the action produced or named one.
579    #[must_use]
580    pub const fn job_execution_id(&self) -> Option<JobExecutionId> {
581        self.job_execution_id
582    }
583
584    /// Returns the version observed under lock.
585    #[must_use]
586    pub const fn observed_version(&self) -> Option<ExecutionVersion> {
587        self.observed_version
588    }
589
590    /// Returns the status observed before the effect.
591    #[must_use]
592    pub const fn prior_status(&self) -> Option<BatchStatus> {
593        self.prior_status
594    }
595
596    /// Returns the status the effect produced.
597    #[must_use]
598    pub const fn result_status(&self) -> Option<BatchStatus> {
599        self.result_status
600    }
601
602    /// Returns the recorded outcome class.
603    #[must_use]
604    pub const fn outcome(&self) -> OperatorOutcomeClass {
605        self.outcome
606    }
607
608    /// Returns the recorded rejection class, when the action was rejected.
609    #[must_use]
610    pub const fn rejection(&self) -> Option<OperatorRejection> {
611        self.rejection
612    }
613
614    /// Returns the facade-clock instant recorded with the request.
615    #[must_use]
616    pub const fn requested_at(&self) -> SystemTime {
617        self.requested_at
618    }
619}
620
621/// The bounded audit row an adapter appends.
622#[derive(Clone, Debug, Eq, PartialEq)]
623pub struct OperatorRecordDraft {
624    action: OperatorAction,
625    operation_id: OperationId,
626    actor: ActorRef,
627    reason: Option<ReasonCode>,
628    digest: RequestDigest,
629    job_instance_id: Option<JobInstanceId>,
630    job_execution_id: Option<JobExecutionId>,
631    observed_version: Option<ExecutionVersion>,
632    prior_status: Option<BatchStatus>,
633    result_status: Option<BatchStatus>,
634    outcome: OperatorOutcomeClass,
635    rejection: Option<OperatorRejection>,
636    requested_at: SystemTime,
637}
638
639impl OperatorRecordDraft {
640    /// Drafts the audit row for one applied request.
641    ///
642    /// The audited action, actor, reason, and digest are taken from the
643    /// request, so the row cannot disagree with the request it audits. The
644    /// four effect values are what the transaction observed and produced.
645    #[must_use]
646    pub fn applied(
647        request: &OperatorRequest,
648        job_instance_id: Option<JobInstanceId>,
649        job_execution_id: Option<JobExecutionId>,
650        prior_status: Option<BatchStatus>,
651        result_status: Option<BatchStatus>,
652        requested_at: SystemTime,
653    ) -> Self {
654        Self {
655            action: request.action(),
656            operation_id: request.operation_id().clone(),
657            actor: request.actor().clone(),
658            reason: request.reason().cloned(),
659            digest: *request.digest(),
660            job_instance_id,
661            job_execution_id,
662            observed_version: request.expected_version(),
663            prior_status,
664            result_status,
665            outcome: OperatorOutcomeClass::Applied,
666            rejection: None,
667            requested_at,
668        }
669    }
670
671    /// Drafts the audit row for one rejected request.
672    ///
673    /// A rejection applies no effect, so the row carries only the target the
674    /// request already named and no observed or produced status.
675    #[must_use]
676    pub fn rejected(
677        request: &OperatorRequest,
678        rejection: OperatorRejection,
679        requested_at: SystemTime,
680    ) -> Self {
681        Self {
682            action: request.action(),
683            operation_id: request.operation_id().clone(),
684            actor: request.actor().clone(),
685            reason: request.reason().cloned(),
686            digest: *request.digest(),
687            job_instance_id: request.job_instance_id(),
688            job_execution_id: request.job_execution_id(),
689            observed_version: request.expected_version(),
690            prior_status: None,
691            result_status: None,
692            outcome: OperatorOutcomeClass::Rejected,
693            rejection: Some(rejection),
694            requested_at,
695        }
696    }
697
698    /// Rebuilds a draft from one durable audit row.
699    ///
700    /// Durable adapters use this to return a recorded outcome without
701    /// re-deriving it from a request that may no longer exist.
702    #[must_use]
703    #[allow(clippy::too_many_arguments)]
704    pub const fn from_durable(
705        action: OperatorAction,
706        operation_id: OperationId,
707        actor: ActorRef,
708        reason: Option<ReasonCode>,
709        digest: RequestDigest,
710        job_instance_id: Option<JobInstanceId>,
711        job_execution_id: Option<JobExecutionId>,
712        observed_version: Option<ExecutionVersion>,
713        prior_status: Option<BatchStatus>,
714        result_status: Option<BatchStatus>,
715        outcome: OperatorOutcomeClass,
716        rejection: Option<OperatorRejection>,
717        requested_at: SystemTime,
718    ) -> Self {
719        Self {
720            action,
721            operation_id,
722            actor,
723            reason,
724            digest,
725            job_instance_id,
726            job_execution_id,
727            observed_version,
728            prior_status,
729            result_status,
730            outcome,
731            rejection,
732            requested_at,
733        }
734    }
735
736    /// Returns the audited action.
737    #[must_use]
738    pub const fn action(&self) -> OperatorAction {
739        self.action
740    }
741
742    /// Borrows the idempotency key.
743    #[must_use]
744    pub const fn operation_id(&self) -> &OperationId {
745        &self.operation_id
746    }
747
748    /// Borrows the opaque actor reference.
749    #[must_use]
750    pub const fn actor(&self) -> &ActorRef {
751        &self.actor
752    }
753
754    /// Borrows the closed-set reason code, when present.
755    #[must_use]
756    pub const fn reason(&self) -> Option<&ReasonCode> {
757        self.reason.as_ref()
758    }
759
760    /// Returns the canonical request digest.
761    #[must_use]
762    pub const fn digest(&self) -> &RequestDigest {
763        &self.digest
764    }
765
766    /// Returns the audited logical instance, when known.
767    #[must_use]
768    pub const fn job_instance_id(&self) -> Option<JobInstanceId> {
769        self.job_instance_id
770    }
771
772    /// Returns the audited execution, when known.
773    #[must_use]
774    pub const fn job_execution_id(&self) -> Option<JobExecutionId> {
775        self.job_execution_id
776    }
777
778    /// Returns the version observed under lock.
779    #[must_use]
780    pub const fn observed_version(&self) -> Option<ExecutionVersion> {
781        self.observed_version
782    }
783
784    /// Returns the status observed before the effect.
785    #[must_use]
786    pub const fn prior_status(&self) -> Option<BatchStatus> {
787        self.prior_status
788    }
789
790    /// Returns the status the effect produced.
791    #[must_use]
792    pub const fn result_status(&self) -> Option<BatchStatus> {
793        self.result_status
794    }
795
796    /// Returns the recorded outcome class.
797    #[must_use]
798    pub const fn outcome(&self) -> OperatorOutcomeClass {
799        self.outcome
800    }
801
802    /// Returns the recorded rejection class, when the action was rejected.
803    #[must_use]
804    pub const fn rejection(&self) -> Option<OperatorRejection> {
805        self.rejection
806    }
807
808    /// Returns the facade-clock instant recorded with the request.
809    #[must_use]
810    pub const fn requested_at(&self) -> SystemTime {
811        self.requested_at
812    }
813}