Skip to main content

oxide_batch_repository/
explorer.rs

1//! Bounded, keyset-paginated, redacted metadata projections and their port.
2//!
3//! The explorer owns a closed query set. Aggregation, arbitrary predicates,
4//! caller-supplied ordering, and any filter over parameter, context, or
5//! checkpoint content are deliberately absent. Every projection is redacted by
6//! construction: a projection that cannot be produced without a prohibited
7//! value fails rather than degrading.
8//!
9//! The paging vocabulary lives with the port because every cursor key is an
10//! immutable ordering column of a row the port returns, and every token is
11//! bound to a query the port defines.
12
13use std::error::Error;
14use std::fmt;
15use std::time::{Duration, SystemTime};
16
17use sha2::{Digest, Sha256};
18
19use oxide_batch_core::{
20    BatchStatus, DefinitionRevision, DurableStateKind, ExecutionCounts, ExecutionTimestamps,
21    ExecutionVersion, ExitStatus, FailureSummary, JobExecutionId, JobInstanceId, JobName, NodeId,
22    ParameterName, ParameterValueKind, StateSchemaId, StateSchemaVersion, StepExecutionId,
23    StepName, StepPartitionId,
24};
25
26use crate::{
27    BoxFuture, CanonicalWriter, FlowDecision, OperatorRecord, RecoveryDecision, RepositoryError,
28    RetentionHold, hex_digest,
29};
30
31/// Maximum rows one page may contain.
32pub const MAX_PAGE_SIZE: u16 = 500;
33/// Page size used when a caller does not choose one.
34pub const DEFAULT_PAGE_SIZE: u16 = 50;
35/// Maximum estimated encoded size of one page.
36pub const MAX_RESPONSE_BYTES: usize = 256 * 1024;
37/// Maximum size of one opaque cursor token.
38pub const MAX_CURSOR_BYTES: usize = 256;
39/// Smallest age bound accepted by the unresolved-execution query.
40pub const MIN_UNRESOLVED_AGE: Duration = Duration::from_mins(1);
41
42const CURSOR_FORMAT_VERSION: u8 = 1;
43const MAX_CURSOR_NAME_BYTES: usize = 128;
44const KEY_TAG_IDENTITY: u8 = 1;
45const KEY_TAG_ORDERED: u8 = 2;
46const KEY_TAG_NAME: u8 = 3;
47const BINDING_BYTES: usize = 8;
48
49/// A validated page size in `1..=500`.
50#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub struct PageSize(u16);
52
53impl PageSize {
54    /// Validates a caller-supplied page size.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`ExplorerError::PageSizeOutOfRange`] outside `1..=500`.
59    pub const fn new(value: u16) -> Result<Self, ExplorerError> {
60        if value == 0 || value > MAX_PAGE_SIZE {
61            return Err(ExplorerError::PageSizeOutOfRange { requested: value });
62        }
63        Ok(Self(value))
64    }
65
66    /// Returns the validated row bound.
67    #[must_use]
68    pub const fn get(self) -> u16 {
69        self.0
70    }
71}
72
73impl Default for PageSize {
74    fn default() -> Self {
75        Self(DEFAULT_PAGE_SIZE)
76    }
77}
78
79/// One bounded page request.
80#[derive(Clone, Debug, Default, Eq, PartialEq)]
81pub struct PageRequest {
82    size: PageSize,
83    cursor: Option<Cursor>,
84}
85
86impl PageRequest {
87    /// Requests the first page of a traversal.
88    #[must_use]
89    pub const fn first(size: PageSize) -> Self {
90        Self { size, cursor: None }
91    }
92
93    /// Requests the page that continues an existing traversal.
94    #[must_use]
95    pub const fn resume(size: PageSize, cursor: Cursor) -> Self {
96        Self {
97            size,
98            cursor: Some(cursor),
99        }
100    }
101
102    /// Returns the requested row bound.
103    #[must_use]
104    pub const fn size(&self) -> PageSize {
105        self.size
106    }
107
108    /// Borrows the continuation cursor, when this is not the first page.
109    #[must_use]
110    pub const fn cursor(&self) -> Option<&Cursor> {
111        self.cursor.as_ref()
112    }
113}
114
115/// An opaque keyset continuation token.
116///
117/// The encoding is not a documented format and confers no authority. A token
118/// presented to a different query, different filters, or a different page size
119/// is rejected rather than reinterpreted.
120#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
121pub struct Cursor(Vec<u8>);
122
123impl Cursor {
124    /// Reconstructs a cursor from its opaque bytes.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`CursorError::CursorInvalid`] when the token is empty or
129    /// exceeds [`MAX_CURSOR_BYTES`].
130    pub fn from_bytes(value: impl Into<Vec<u8>>) -> Result<Self, CursorError> {
131        let value = value.into();
132        if value.is_empty() || value.len() > MAX_CURSOR_BYTES {
133            return Err(CursorError::CursorInvalid);
134        }
135        Ok(Self(value))
136    }
137
138    /// Reconstructs a cursor from its lowercase hexadecimal text form.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`CursorError::CursorInvalid`] when the text is not an even
143    /// number of hexadecimal digits within the token bound.
144    pub fn from_hex(value: &str) -> Result<Self, CursorError> {
145        if !value.len().is_multiple_of(2) {
146            return Err(CursorError::CursorInvalid);
147        }
148        let mut bytes = Vec::with_capacity(value.len() / 2);
149        let raw = value.as_bytes();
150        for pair in raw.chunks_exact(2) {
151            let high = hex_value(pair[0]).ok_or(CursorError::CursorInvalid)?;
152            let low = hex_value(pair[1]).ok_or(CursorError::CursorInvalid)?;
153            bytes.push((high << 4) | low);
154        }
155        Self::from_bytes(bytes)
156    }
157
158    /// Returns the opaque token bytes.
159    #[must_use]
160    pub fn as_bytes(&self) -> &[u8] {
161        &self.0
162    }
163}
164
165fn hex_value(value: u8) -> Option<u8> {
166    match value {
167        b'0'..=b'9' => Some(value - b'0'),
168        b'a'..=b'f' => Some(value - b'a' + 10),
169        _ => None,
170    }
171}
172
173impl fmt::Debug for Cursor {
174    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
175        formatter
176            .debug_struct("Cursor")
177            .field("bytes", &self.0.len())
178            .finish()
179    }
180}
181
182impl fmt::Display for Cursor {
183    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
184        formatter.write_str(&hex_digest(&self.0))
185    }
186}
187
188/// One bounded page and its continuation token.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct Page<T> {
191    rows: Vec<T>,
192    next: Option<Cursor>,
193}
194
195impl<T> Page<T> {
196    pub(crate) const fn new(rows: Vec<T>, next: Option<Cursor>) -> Self {
197        Self { rows, next }
198    }
199
200    /// Borrows the rows of this page.
201    #[must_use]
202    pub fn rows(&self) -> &[T] {
203        &self.rows
204    }
205
206    /// Consumes the page and returns its rows.
207    #[must_use]
208    pub fn into_rows(self) -> Vec<T> {
209        self.rows
210    }
211
212    /// Borrows the token that continues this traversal, when more may remain.
213    #[must_use]
214    pub const fn next_cursor(&self) -> Option<&Cursor> {
215        self.next.as_ref()
216    }
217}
218
219/// The closed set of paginated explorer queries.
220///
221/// `get_execution` is the one named query that returns a single projection and
222/// therefore takes no cursor.
223#[derive(Clone, Debug, Eq, PartialEq)]
224#[non_exhaustive]
225pub enum ExplorerQuery {
226    /// Registered job names in byte order.
227    JobNames,
228    /// Instances of one job name, newest identity first.
229    Instances {
230        /// Filtered job name.
231        job_name: JobName,
232    },
233    /// Executions of one instance, newest attempt first.
234    Executions {
235        /// Filtered logical instance.
236        job_instance_id: JobInstanceId,
237    },
238    /// Step executions of one job execution.
239    StepExecutions {
240        /// Filtered job execution.
241        job_execution_id: JobExecutionId,
242    },
243    /// Non-terminal executions older than a bounded age.
244    UnresolvedExecutions {
245        /// Minimum durable age, at least [`MIN_UNRESOLVED_AGE`].
246        minimum_age: Duration,
247    },
248    /// Recovery decisions of one job execution.
249    RecoveryDecisions {
250        /// Filtered job execution.
251        job_execution_id: JobExecutionId,
252    },
253    /// Flow decisions of one job execution in sequence order.
254    FlowDecisions {
255        /// Filtered job execution.
256        job_execution_id: JobExecutionId,
257    },
258    /// Partitions of one partitioned step execution.
259    StepPartitions {
260        /// Filtered parent step execution.
261        step_execution_id: StepExecutionId,
262    },
263    /// Audited operator requests for one job execution.
264    OperatorRequests {
265        /// Filtered job execution.
266        job_execution_id: JobExecutionId,
267    },
268}
269
270impl ExplorerQuery {
271    const fn discriminant(&self) -> u8 {
272        match self {
273            Self::JobNames => 1,
274            Self::Instances { .. } => 2,
275            Self::Executions { .. } => 3,
276            Self::StepExecutions { .. } => 4,
277            Self::UnresolvedExecutions { .. } => 5,
278            Self::RecoveryDecisions { .. } => 6,
279            Self::FlowDecisions { .. } => 7,
280            Self::StepPartitions { .. } => 8,
281            Self::OperatorRequests { .. } => 9,
282        }
283    }
284
285    /// Returns the stable name of the query for diagnostics and telemetry.
286    #[must_use]
287    pub const fn name(&self) -> &'static str {
288        match self {
289            Self::JobNames => "list_job_names",
290            Self::Instances { .. } => "list_instances",
291            Self::Executions { .. } => "list_executions",
292            Self::StepExecutions { .. } => "list_step_executions",
293            Self::UnresolvedExecutions { .. } => "list_unresolved_executions",
294            Self::RecoveryDecisions { .. } => "list_recovery_decisions",
295            Self::FlowDecisions { .. } => "list_flow_decisions",
296            Self::StepPartitions { .. } => "list_step_partitions",
297            Self::OperatorRequests { .. } => "list_operator_requests",
298        }
299    }
300
301    fn identity(&self, size: PageSize) -> [u8; 32] {
302        let mut writer = CanonicalWriter::new("oxide-batch.explorer-query.v1");
303        writer.push_str(self.name());
304        writer.push_u64(u64::from(size.get()));
305        match self {
306            Self::JobNames => writer.push_str(""),
307            Self::Instances { job_name } => writer.push_str(job_name.as_str()),
308            Self::Executions { job_instance_id } => writer.push_u64(job_instance_id.get()),
309            Self::StepExecutions { job_execution_id }
310            | Self::RecoveryDecisions { job_execution_id }
311            | Self::FlowDecisions { job_execution_id }
312            | Self::OperatorRequests { job_execution_id } => {
313                writer.push_u64(job_execution_id.get());
314            }
315            Self::UnresolvedExecutions { minimum_age } => {
316                writer.push_u64(minimum_age.as_secs());
317            }
318            Self::StepPartitions { step_execution_id } => writer.push_u64(step_execution_id.get()),
319        }
320        writer.digest()
321    }
322}
323
324/// The immutable ordering key of the last row returned by a page.
325#[derive(Clone, Debug, Eq, PartialEq)]
326#[non_exhaustive]
327pub enum CursorKey {
328    /// A single immutable identity column.
329    Identity(u64),
330    /// An immutable ordinal paired with its identity column.
331    Ordered {
332        /// Immutable primary ordinal, such as an attempt or sequence.
333        primary: u64,
334        /// Identity tiebreaker.
335        identity: u64,
336    },
337    /// An immutable byte-ordered name column.
338    Name(String),
339}
340
341impl CursorKey {
342    fn encode(&self, target: &mut Vec<u8>) -> Result<(), CursorError> {
343        match self {
344            Self::Identity(value) => {
345                target.push(KEY_TAG_IDENTITY);
346                target.extend_from_slice(&value.to_be_bytes());
347            }
348            Self::Ordered { primary, identity } => {
349                target.push(KEY_TAG_ORDERED);
350                target.extend_from_slice(&primary.to_be_bytes());
351                target.extend_from_slice(&identity.to_be_bytes());
352            }
353            Self::Name(value) => {
354                if value.len() > MAX_CURSOR_NAME_BYTES {
355                    return Err(CursorError::CursorInvalid);
356                }
357                target.push(KEY_TAG_NAME);
358                let length = u8::try_from(value.len()).map_err(|_| CursorError::CursorInvalid)?;
359                target.push(length);
360                target.extend_from_slice(value.as_bytes());
361            }
362        }
363        Ok(())
364    }
365
366    fn decode(bytes: &[u8]) -> Result<(Self, &[u8]), CursorError> {
367        let (tag, rest) = bytes.split_first().ok_or(CursorError::CursorInvalid)?;
368        match *tag {
369            KEY_TAG_IDENTITY => {
370                let (value, rest) = read_u64(rest)?;
371                Ok((Self::Identity(value), rest))
372            }
373            KEY_TAG_ORDERED => {
374                let (primary, rest) = read_u64(rest)?;
375                let (identity, rest) = read_u64(rest)?;
376                Ok((Self::Ordered { primary, identity }, rest))
377            }
378            KEY_TAG_NAME => {
379                let (length, rest) = rest.split_first().ok_or(CursorError::CursorInvalid)?;
380                let length = usize::from(*length);
381                if rest.len() < length {
382                    return Err(CursorError::CursorInvalid);
383                }
384                let (value, rest) = rest.split_at(length);
385                let value = core::str::from_utf8(value).map_err(|_| CursorError::CursorInvalid)?;
386                Ok((Self::Name(value.to_owned()), rest))
387            }
388            _ => Err(CursorError::CursorInvalid),
389        }
390    }
391}
392
393fn read_u64(bytes: &[u8]) -> Result<(u64, &[u8]), CursorError> {
394    if bytes.len() < 8 {
395        return Err(CursorError::CursorInvalid);
396    }
397    let (head, rest) = bytes.split_at(8);
398    let mut value = [0_u8; 8];
399    value.copy_from_slice(head);
400    Ok((u64::from_be_bytes(value), rest))
401}
402
403/// The bounded keyset window one adapter statement must honour.
404#[derive(Clone, Debug, Eq, PartialEq)]
405pub struct QueryWindow {
406    after: Option<CursorKey>,
407    ceiling: u64,
408    limit: u16,
409}
410
411impl QueryWindow {
412    pub(crate) const fn new(after: Option<CursorKey>, ceiling: u64, limit: u16) -> Self {
413        Self {
414            after,
415            ceiling,
416            limit,
417        }
418    }
419
420    /// Borrows the exclusive ordering key of the previous page, when present.
421    #[must_use]
422    pub const fn after(&self) -> Option<&CursorKey> {
423        self.after.as_ref()
424    }
425
426    /// Returns the inclusive identity ceiling captured by the traversal.
427    ///
428    /// A row whose identity exceeds this ceiling was created after the
429    /// traversal started and is never returned by it.
430    #[must_use]
431    pub const fn ceiling(&self) -> u64 {
432        self.ceiling
433    }
434
435    /// Returns the maximum number of rows the statement may return.
436    #[must_use]
437    pub const fn limit(&self) -> u16 {
438        self.limit
439    }
440}
441
442/// A redacted description of one job parameter.
443///
444/// The descriptor carries the parameter name, its type tag, and whether it
445/// participates in instance identity. Values never appear.
446#[derive(Clone, Debug, Eq, PartialEq)]
447pub struct ParameterDescriptor {
448    name: ParameterName,
449    kind: ParameterValueKind,
450    identifying: bool,
451}
452
453impl ParameterDescriptor {
454    /// Describes one redacted job parameter read by an adapter.
455    #[doc(hidden)]
456    #[must_use]
457    pub const fn new(name: ParameterName, kind: ParameterValueKind, identifying: bool) -> Self {
458        Self {
459            name,
460            kind,
461            identifying,
462        }
463    }
464
465    /// Borrows the parameter name.
466    #[must_use]
467    pub const fn name(&self) -> &ParameterName {
468        &self.name
469    }
470
471    /// Returns the parameter type tag.
472    #[must_use]
473    pub const fn kind(&self) -> ParameterValueKind {
474        self.kind
475    }
476
477    /// Returns whether the parameter participates in instance identity.
478    #[must_use]
479    pub const fn is_identifying(&self) -> bool {
480        self.identifying
481    }
482}
483
484/// A redacted description of one durable state envelope.
485///
486/// Presence, format, schema, schema version, and encoded size are observable;
487/// the payload is not.
488#[derive(Clone, Debug, Eq, PartialEq)]
489pub struct StateEnvelopeDescriptor {
490    kind: DurableStateKind,
491    format_version: u16,
492    schema_id: StateSchemaId,
493    schema_version: StateSchemaVersion,
494    encoded_len: usize,
495}
496
497impl StateEnvelopeDescriptor {
498    /// Describes one redacted durable state envelope read by an adapter.
499    ///
500    /// Durable adapters and the in-memory partition reference retain only this
501    /// redacted envelope description at the explorer boundary.
502    #[doc(hidden)]
503    #[must_use]
504    pub const fn new(
505        kind: DurableStateKind,
506        format_version: u16,
507        schema_id: StateSchemaId,
508        schema_version: StateSchemaVersion,
509        encoded_len: usize,
510    ) -> Self {
511        Self {
512            kind,
513            format_version,
514            schema_id,
515            schema_version,
516            encoded_len,
517        }
518    }
519
520    /// Returns the durable state category.
521    #[must_use]
522    pub const fn kind(&self) -> DurableStateKind {
523        self.kind
524    }
525
526    /// Returns the envelope format version.
527    #[must_use]
528    pub const fn format_version(&self) -> u16 {
529        self.format_version
530    }
531
532    /// Borrows the application-owned schema identifier.
533    #[must_use]
534    pub const fn schema_id(&self) -> &StateSchemaId {
535        &self.schema_id
536    }
537
538    /// Returns the application-owned schema version.
539    #[must_use]
540    pub const fn schema_version(&self) -> StateSchemaVersion {
541        self.schema_version
542    }
543
544    /// Returns the encoded payload size in bytes.
545    #[must_use]
546    pub const fn encoded_len(&self) -> usize {
547        self.encoded_len
548    }
549}
550
551/// A redacted description of the definition bound to one execution.
552#[derive(Clone, Debug, Eq, PartialEq)]
553pub struct DefinitionDescriptor {
554    revision: DefinitionRevision,
555    manifest_format: u16,
556    manifest_digest: [u8; 32],
557}
558
559impl DefinitionDescriptor {
560    /// Describes one durable definition identity read by an adapter.
561    #[doc(hidden)]
562    #[must_use]
563    pub const fn new(
564        revision: DefinitionRevision,
565        manifest_format: u16,
566        manifest_digest: [u8; 32],
567    ) -> Self {
568        Self {
569            revision,
570            manifest_format,
571            manifest_digest,
572        }
573    }
574
575    /// Borrows the application-owned definition revision.
576    #[must_use]
577    pub const fn revision(&self) -> &DefinitionRevision {
578        &self.revision
579    }
580
581    /// Returns the manifest format version.
582    #[must_use]
583    pub const fn manifest_format(&self) -> u16 {
584        self.manifest_format
585    }
586
587    /// Returns the manifest digest.
588    #[must_use]
589    pub const fn manifest_digest(&self) -> &[u8; 32] {
590        &self.manifest_digest
591    }
592
593    /// Returns the hexadecimal manifest digest.
594    #[must_use]
595    pub fn manifest_digest_hex(&self) -> String {
596        hex_digest(&self.manifest_digest)
597    }
598}
599
600/// A redacted logical job instance projection.
601#[derive(Clone, Debug, Eq, PartialEq)]
602pub struct JobInstanceProjection {
603    id: JobInstanceId,
604    job_name: JobName,
605    instance_key_digest: [u8; 32],
606    parameters: Vec<ParameterDescriptor>,
607    created_at: Option<SystemTime>,
608    hold: Option<RetentionHold>,
609}
610
611impl JobInstanceProjection {
612    /// Builds one redacted instance projection read by an adapter.
613    #[doc(hidden)]
614    #[must_use]
615    pub const fn new(
616        id: JobInstanceId,
617        job_name: JobName,
618        instance_key_digest: [u8; 32],
619        parameters: Vec<ParameterDescriptor>,
620        created_at: Option<SystemTime>,
621        hold: Option<RetentionHold>,
622    ) -> Self {
623        Self {
624            id,
625            job_name,
626            instance_key_digest,
627            parameters,
628            created_at,
629            hold,
630        }
631    }
632
633    /// Returns the opaque instance identifier.
634    #[must_use]
635    pub const fn id(&self) -> JobInstanceId {
636        self.id
637    }
638
639    /// Borrows the job name.
640    #[must_use]
641    pub const fn job_name(&self) -> &JobName {
642        &self.job_name
643    }
644
645    /// Returns the canonical identifying-key digest.
646    #[must_use]
647    pub const fn instance_key_digest(&self) -> &[u8; 32] {
648        &self.instance_key_digest
649    }
650
651    /// Returns the hexadecimal identifying-key digest.
652    #[must_use]
653    pub fn instance_key_digest_hex(&self) -> String {
654        hex_digest(&self.instance_key_digest)
655    }
656
657    /// Borrows the redacted parameter descriptors.
658    #[must_use]
659    pub fn parameters(&self) -> &[ParameterDescriptor] {
660        &self.parameters
661    }
662
663    /// Returns the durable creation instant when the adapter records one.
664    #[must_use]
665    pub const fn created_at(&self) -> Option<SystemTime> {
666        self.created_at
667    }
668
669    /// Borrows the active retention hold, when one is placed.
670    #[must_use]
671    pub const fn hold(&self) -> Option<&RetentionHold> {
672        self.hold.as_ref()
673    }
674}
675
676/// A redacted job execution projection.
677#[derive(Clone, Debug, Eq, PartialEq)]
678pub struct JobExecutionProjection {
679    id: JobExecutionId,
680    job_instance_id: JobInstanceId,
681    job_name: JobName,
682    attempt: u32,
683    status: BatchStatus,
684    exit_status: ExitStatus,
685    counts: ExecutionCounts,
686    version: ExecutionVersion,
687    timestamps: ExecutionTimestamps,
688    updated_at: SystemTime,
689    failure: Option<FailureSummary>,
690    definition: Option<DefinitionDescriptor>,
691    context: Option<StateEnvelopeDescriptor>,
692    stop_requested_at: Option<SystemTime>,
693    owner_recorded: bool,
694}
695
696impl JobExecutionProjection {
697    /// Builds one redacted execution projection read by an adapter.
698    #[allow(clippy::too_many_arguments)]
699    #[doc(hidden)]
700    #[must_use]
701    pub const fn new(
702        id: JobExecutionId,
703        job_instance_id: JobInstanceId,
704        job_name: JobName,
705        attempt: u32,
706        status: BatchStatus,
707        exit_status: ExitStatus,
708        counts: ExecutionCounts,
709        version: ExecutionVersion,
710        timestamps: ExecutionTimestamps,
711        updated_at: SystemTime,
712        failure: Option<FailureSummary>,
713        definition: Option<DefinitionDescriptor>,
714        context: Option<StateEnvelopeDescriptor>,
715        stop_requested_at: Option<SystemTime>,
716        owner_recorded: bool,
717    ) -> Self {
718        Self {
719            id,
720            job_instance_id,
721            job_name,
722            attempt,
723            status,
724            exit_status,
725            counts,
726            version,
727            timestamps,
728            updated_at,
729            failure,
730            definition,
731            context,
732            stop_requested_at,
733            owner_recorded,
734        }
735    }
736
737    /// Returns the opaque execution identifier.
738    #[must_use]
739    pub const fn id(&self) -> JobExecutionId {
740        self.id
741    }
742
743    /// Returns the owning logical instance.
744    #[must_use]
745    pub const fn job_instance_id(&self) -> JobInstanceId {
746        self.job_instance_id
747    }
748
749    /// Borrows the job name.
750    #[must_use]
751    pub const fn job_name(&self) -> &JobName {
752        &self.job_name
753    }
754
755    /// Returns the attempt ordinal within the logical instance.
756    #[must_use]
757    pub const fn attempt(&self) -> u32 {
758        self.attempt
759    }
760
761    /// Returns the framework status.
762    #[must_use]
763    pub const fn status(&self) -> BatchStatus {
764        self.status
765    }
766
767    /// Borrows the operator-facing exit status.
768    #[must_use]
769    pub const fn exit_status(&self) -> &ExitStatus {
770        &self.exit_status
771    }
772
773    /// Returns the durable counters.
774    #[must_use]
775    pub const fn counts(&self) -> ExecutionCounts {
776        self.counts
777    }
778
779    /// Returns the observed optimistic version.
780    #[must_use]
781    pub const fn version(&self) -> ExecutionVersion {
782        self.version
783    }
784
785    /// Returns the lifecycle timestamps.
786    #[must_use]
787    pub const fn timestamps(&self) -> ExecutionTimestamps {
788        self.timestamps
789    }
790
791    /// Returns the durable last-update instant.
792    #[must_use]
793    pub const fn updated_at(&self) -> SystemTime {
794        self.updated_at
795    }
796
797    /// Returns the framework failure category and opaque failure identifier.
798    #[must_use]
799    pub const fn failure(&self) -> Option<FailureSummary> {
800        self.failure
801    }
802
803    /// Borrows the definition descriptor when the adapter records one.
804    #[must_use]
805    pub const fn definition(&self) -> Option<&DefinitionDescriptor> {
806        self.definition.as_ref()
807    }
808
809    /// Borrows the execution-context envelope description.
810    #[must_use]
811    pub const fn context(&self) -> Option<&StateEnvelopeDescriptor> {
812        self.context.as_ref()
813    }
814
815    /// Returns the durable stop-request instant, when a stop was recorded.
816    #[must_use]
817    pub const fn stop_requested_at(&self) -> Option<SystemTime> {
818        self.stop_requested_at
819    }
820
821    /// Returns whether a process recorded ownership of this execution.
822    ///
823    /// Ownership is evidence only. It is not a lease and never authorizes a
824    /// takeover.
825    #[must_use]
826    pub const fn owner_recorded(&self) -> bool {
827        self.owner_recorded
828    }
829}
830
831/// A redacted step execution projection.
832#[derive(Clone, Debug, Eq, PartialEq)]
833pub struct StepExecutionProjection {
834    id: StepExecutionId,
835    job_execution_id: JobExecutionId,
836    step_name: StepName,
837    node_id: Option<NodeId>,
838    status: BatchStatus,
839    exit_status: ExitStatus,
840    counts: ExecutionCounts,
841    version: ExecutionVersion,
842    timestamps: ExecutionTimestamps,
843    failure: Option<FailureSummary>,
844    checkpoint: Option<StateEnvelopeDescriptor>,
845    context: Option<StateEnvelopeDescriptor>,
846}
847
848impl StepExecutionProjection {
849    /// Builds one redacted step projection read by an adapter.
850    #[allow(clippy::too_many_arguments)]
851    #[doc(hidden)]
852    #[must_use]
853    pub const fn new(
854        id: StepExecutionId,
855        job_execution_id: JobExecutionId,
856        step_name: StepName,
857        node_id: Option<NodeId>,
858        status: BatchStatus,
859        exit_status: ExitStatus,
860        counts: ExecutionCounts,
861        version: ExecutionVersion,
862        timestamps: ExecutionTimestamps,
863        failure: Option<FailureSummary>,
864        checkpoint: Option<StateEnvelopeDescriptor>,
865        context: Option<StateEnvelopeDescriptor>,
866    ) -> Self {
867        Self {
868            id,
869            job_execution_id,
870            step_name,
871            node_id,
872            status,
873            exit_status,
874            counts,
875            version,
876            timestamps,
877            failure,
878            checkpoint,
879            context,
880        }
881    }
882
883    /// Returns the opaque step-execution identifier.
884    #[must_use]
885    pub const fn id(&self) -> StepExecutionId {
886        self.id
887    }
888
889    /// Returns the owning job execution.
890    #[must_use]
891    pub const fn job_execution_id(&self) -> JobExecutionId {
892        self.job_execution_id
893    }
894
895    /// Borrows the durable step name.
896    #[must_use]
897    pub const fn step_name(&self) -> &StepName {
898        &self.step_name
899    }
900
901    /// Borrows the stable logical node identifier, when the adapter records one.
902    #[must_use]
903    pub const fn node_id(&self) -> Option<&NodeId> {
904        self.node_id.as_ref()
905    }
906
907    /// Returns the framework status.
908    #[must_use]
909    pub const fn status(&self) -> BatchStatus {
910        self.status
911    }
912
913    /// Borrows the operator-facing exit status.
914    #[must_use]
915    pub const fn exit_status(&self) -> &ExitStatus {
916        &self.exit_status
917    }
918
919    /// Returns the durable counters.
920    #[must_use]
921    pub const fn counts(&self) -> ExecutionCounts {
922        self.counts
923    }
924
925    /// Returns the observed optimistic version.
926    #[must_use]
927    pub const fn version(&self) -> ExecutionVersion {
928        self.version
929    }
930
931    /// Returns the lifecycle timestamps.
932    #[must_use]
933    pub const fn timestamps(&self) -> ExecutionTimestamps {
934        self.timestamps
935    }
936
937    /// Returns the framework failure category and opaque failure identifier.
938    #[must_use]
939    pub const fn failure(&self) -> Option<FailureSummary> {
940        self.failure
941    }
942
943    /// Borrows the checkpoint envelope description.
944    #[must_use]
945    pub const fn checkpoint(&self) -> Option<&StateEnvelopeDescriptor> {
946        self.checkpoint.as_ref()
947    }
948
949    /// Borrows the step-context envelope description.
950    #[must_use]
951    pub const fn context(&self) -> Option<&StateEnvelopeDescriptor> {
952        self.context.as_ref()
953    }
954}
955
956/// A redacted durable partition projection.
957///
958/// Payloads remain hidden while plan identity, lifecycle, counters, worker
959/// assignment, and context schema metadata stay inspectable.
960#[derive(Clone, Debug, Eq, PartialEq)]
961pub struct StepPartitionProjection {
962    id: StepPartitionId,
963    step_execution_id: StepExecutionId,
964    partition_key: String,
965    ordinal: u32,
966    status: BatchStatus,
967    exit_status: ExitStatus,
968    counts: ExecutionCounts,
969    version: ExecutionVersion,
970    worker_step_execution_id: Option<StepExecutionId>,
971    context: Option<StateEnvelopeDescriptor>,
972}
973
974impl StepPartitionProjection {
975    /// Builds one redacted partition projection read by an adapter.
976    #[allow(clippy::too_many_arguments)]
977    #[doc(hidden)]
978    #[must_use]
979    pub const fn new(
980        id: StepPartitionId,
981        step_execution_id: StepExecutionId,
982        partition_key: String,
983        ordinal: u32,
984        status: BatchStatus,
985        exit_status: ExitStatus,
986        counts: ExecutionCounts,
987        version: ExecutionVersion,
988        worker_step_execution_id: Option<StepExecutionId>,
989        context: Option<StateEnvelopeDescriptor>,
990    ) -> Self {
991        Self {
992            id,
993            step_execution_id,
994            partition_key,
995            ordinal,
996            status,
997            exit_status,
998            counts,
999            version,
1000            worker_step_execution_id,
1001            context,
1002        }
1003    }
1004
1005    /// Returns the opaque partition row identifier.
1006    #[must_use]
1007    pub const fn id(&self) -> StepPartitionId {
1008        self.id
1009    }
1010
1011    /// Returns the parent partitioned step execution.
1012    #[must_use]
1013    pub const fn step_execution_id(&self) -> StepExecutionId {
1014        self.step_execution_id
1015    }
1016
1017    /// Borrows the immutable partition key.
1018    #[must_use]
1019    pub fn partition_key(&self) -> &str {
1020        &self.partition_key
1021    }
1022
1023    /// Returns the partition ordinal within its plan.
1024    #[must_use]
1025    pub const fn ordinal(&self) -> u32 {
1026        self.ordinal
1027    }
1028
1029    /// Returns the framework status.
1030    #[must_use]
1031    pub const fn status(&self) -> BatchStatus {
1032        self.status
1033    }
1034
1035    /// Borrows the operator-facing exit status.
1036    #[must_use]
1037    pub const fn exit_status(&self) -> &ExitStatus {
1038        &self.exit_status
1039    }
1040
1041    /// Returns the durable counters.
1042    #[must_use]
1043    pub const fn counts(&self) -> ExecutionCounts {
1044        self.counts
1045    }
1046
1047    /// Returns the observed optimistic version.
1048    #[must_use]
1049    pub const fn version(&self) -> ExecutionVersion {
1050        self.version
1051    }
1052
1053    /// Returns the worker step execution that owns this partition.
1054    #[must_use]
1055    pub const fn worker_step_execution_id(&self) -> Option<StepExecutionId> {
1056        self.worker_step_execution_id
1057    }
1058
1059    /// Borrows the partition-context envelope description.
1060    #[must_use]
1061    pub const fn context(&self) -> Option<&StateEnvelopeDescriptor> {
1062        self.context.as_ref()
1063    }
1064}
1065
1066/// A bounded read port one metadata adapter implements.
1067///
1068/// Every method executes one statement under the adapter's ordinary read
1069/// committed isolation, returns at most [`QueryWindow::limit`] rows, and takes
1070/// no lock. Cross-page snapshot isolation is not provided.
1071pub trait ExplorerRepository: Send + Sync {
1072    /// Captures the exclusive identity ceiling for one traversal.
1073    fn identity_ceiling<'a>(
1074        &'a self,
1075        query: &'a ExplorerQuery,
1076    ) -> BoxFuture<'a, Result<u64, ExplorerError>>;
1077
1078    /// Reads registered job names in byte order.
1079    fn job_names<'a>(
1080        &'a self,
1081        window: &'a QueryWindow,
1082    ) -> BoxFuture<'a, Result<Vec<JobName>, ExplorerError>>;
1083
1084    /// Reads instances of one job name, newest identity first.
1085    fn instances<'a>(
1086        &'a self,
1087        job_name: &'a JobName,
1088        window: &'a QueryWindow,
1089    ) -> BoxFuture<'a, Result<Vec<JobInstanceProjection>, ExplorerError>>;
1090
1091    /// Reads executions of one instance, newest attempt first.
1092    fn executions<'a>(
1093        &'a self,
1094        job_instance_id: JobInstanceId,
1095        window: &'a QueryWindow,
1096    ) -> BoxFuture<'a, Result<Vec<JobExecutionProjection>, ExplorerError>>;
1097
1098    /// Reads one execution projection.
1099    fn execution(
1100        &self,
1101        job_execution_id: JobExecutionId,
1102    ) -> BoxFuture<'_, Result<Option<JobExecutionProjection>, ExplorerError>>;
1103
1104    /// Reads step executions of one job execution.
1105    fn step_executions<'a>(
1106        &'a self,
1107        job_execution_id: JobExecutionId,
1108        window: &'a QueryWindow,
1109    ) -> BoxFuture<'a, Result<Vec<StepExecutionProjection>, ExplorerError>>;
1110
1111    /// Reads non-terminal executions older than `minimum_age`.
1112    fn unresolved_executions<'a>(
1113        &'a self,
1114        minimum_age: Duration,
1115        window: &'a QueryWindow,
1116    ) -> BoxFuture<'a, Result<Vec<JobExecutionProjection>, ExplorerError>>;
1117
1118    /// Reads recovery decisions of one job execution.
1119    fn recovery_decisions<'a>(
1120        &'a self,
1121        job_execution_id: JobExecutionId,
1122        window: &'a QueryWindow,
1123    ) -> BoxFuture<'a, Result<Vec<RecoveryDecision>, ExplorerError>>;
1124
1125    /// Reads flow decisions of one job execution in sequence order.
1126    fn flow_decisions<'a>(
1127        &'a self,
1128        job_execution_id: JobExecutionId,
1129        window: &'a QueryWindow,
1130    ) -> BoxFuture<'a, Result<Vec<FlowDecision>, ExplorerError>>;
1131
1132    /// Reads partitions of one partitioned step execution.
1133    fn step_partitions<'a>(
1134        &'a self,
1135        step_execution_id: StepExecutionId,
1136        window: &'a QueryWindow,
1137    ) -> BoxFuture<'a, Result<Vec<StepPartitionProjection>, ExplorerError>>;
1138
1139    /// Reads audited operator requests for one job execution.
1140    fn operator_requests<'a>(
1141        &'a self,
1142        job_execution_id: JobExecutionId,
1143        window: &'a QueryWindow,
1144    ) -> BoxFuture<'a, Result<Vec<OperatorRecord>, ExplorerError>>;
1145}
1146
1147/// A stable inspection failure independent of a database or async runtime.
1148#[derive(Clone, Debug, Eq, PartialEq)]
1149#[non_exhaustive]
1150pub enum ExplorerError {
1151    /// The requested page size is outside `1..=500`.
1152    PageSizeOutOfRange {
1153        /// Rejected size.
1154        requested: u16,
1155    },
1156    /// The unresolved-execution query requires an explicit larger age bound.
1157    AgeBoundTooSmall {
1158        /// Smallest accepted age.
1159        minimum: Duration,
1160    },
1161    /// A continuation token was rejected.
1162    Cursor(CursorError),
1163    /// One row alone exceeds the encoded response bound.
1164    ResponseTooLarge {
1165        /// Maximum encoded response size in bytes.
1166        limit: usize,
1167    },
1168    /// The statement exceeded the configured statement timeout.
1169    Timeout,
1170    /// The adapter cannot provide bounded keyset pagination.
1171    UnsupportedCapability,
1172    /// The underlying repository failed.
1173    Repository(RepositoryError),
1174}
1175
1176impl fmt::Display for ExplorerError {
1177    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1178        match self {
1179            Self::PageSizeOutOfRange { requested } => write!(
1180                formatter,
1181                "page size {requested} is outside 1..={MAX_PAGE_SIZE}"
1182            ),
1183            Self::AgeBoundTooSmall { minimum } => write!(
1184                formatter,
1185                "the age bound must be at least {} seconds",
1186                minimum.as_secs()
1187            ),
1188            Self::Cursor(error) => error.fmt(formatter),
1189            Self::ResponseTooLarge { limit } => {
1190                write!(formatter, "the encoded response exceeds {limit} bytes")
1191            }
1192            Self::Timeout => {
1193                formatter.write_str("the bounded query exceeded its statement timeout")
1194            }
1195            Self::UnsupportedCapability => {
1196                formatter.write_str("the adapter does not support keyset pagination")
1197            }
1198            Self::Repository(error) => error.fmt(formatter),
1199        }
1200    }
1201}
1202
1203impl Error for ExplorerError {
1204    fn source(&self) -> Option<&(dyn Error + 'static)> {
1205        match self {
1206            Self::Cursor(error) => Some(error),
1207            Self::Repository(error) => Some(error),
1208            _ => None,
1209        }
1210    }
1211}
1212
1213impl From<CursorError> for ExplorerError {
1214    fn from(value: CursorError) -> Self {
1215        Self::Cursor(value)
1216    }
1217}
1218
1219impl From<RepositoryError> for ExplorerError {
1220    fn from(value: RepositoryError) -> Self {
1221        Self::Repository(value)
1222    }
1223}
1224
1225/// A rejected continuation token.
1226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1227#[non_exhaustive]
1228pub enum CursorError {
1229    /// The token was malformed, oversized, or failed its checksum.
1230    CursorInvalid,
1231    /// The token belongs to a different query, filter, or page size.
1232    CursorQueryMismatch,
1233}
1234
1235impl fmt::Display for CursorError {
1236    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1237        match self {
1238            Self::CursorInvalid => formatter.write_str("the continuation token is not valid"),
1239            Self::CursorQueryMismatch => {
1240                formatter.write_str("the continuation token belongs to a different query")
1241            }
1242        }
1243    }
1244}
1245
1246impl Error for CursorError {}
1247
1248fn encode_cursor(
1249    query: &ExplorerQuery,
1250    size: PageSize,
1251    key: &CursorKey,
1252    ceiling: u64,
1253) -> Result<Cursor, CursorError> {
1254    let mut bytes = Vec::with_capacity(80);
1255    bytes.push(CURSOR_FORMAT_VERSION);
1256    bytes.push(query.discriminant());
1257    key.encode(&mut bytes)?;
1258    bytes.extend_from_slice(&ceiling.to_be_bytes());
1259    bytes.extend_from_slice(&query_binding(query, size));
1260    let checksum = cursor_checksum(&bytes);
1261    bytes.extend_from_slice(&checksum);
1262    Cursor::from_bytes(bytes)
1263}
1264
1265fn decode_cursor(
1266    cursor: &Cursor,
1267    query: &ExplorerQuery,
1268    size: PageSize,
1269) -> Result<(CursorKey, u64), ExplorerError> {
1270    let bytes = cursor.as_bytes();
1271    if bytes.len() <= 32 {
1272        return Err(CursorError::CursorInvalid.into());
1273    }
1274    let (body, checksum) = bytes.split_at(bytes.len() - 32);
1275    if cursor_checksum(body) != checksum {
1276        return Err(CursorError::CursorInvalid.into());
1277    }
1278    let (version, rest) = body.split_first().ok_or(CursorError::CursorInvalid)?;
1279    if *version != CURSOR_FORMAT_VERSION {
1280        return Err(CursorError::CursorInvalid.into());
1281    }
1282    let (discriminant, rest) = rest.split_first().ok_or(CursorError::CursorInvalid)?;
1283    let (key, rest) = CursorKey::decode(rest)?;
1284    let (ceiling, rest) = read_u64(rest)?;
1285    if rest.len() != BINDING_BYTES {
1286        return Err(CursorError::CursorInvalid.into());
1287    }
1288    // The token is intact. Any difference in query, filter, or page size is a
1289    // mismatch rather than corruption, so a caller can tell a reused token
1290    // from a damaged one.
1291    if *discriminant != query.discriminant() || rest != query_binding(query, size) {
1292        return Err(CursorError::CursorQueryMismatch.into());
1293    }
1294    Ok((key, ceiling))
1295}
1296
1297fn query_binding(query: &ExplorerQuery, size: PageSize) -> [u8; BINDING_BYTES] {
1298    let identity = query.identity(size);
1299    let mut binding = [0_u8; BINDING_BYTES];
1300    binding.copy_from_slice(&identity[..BINDING_BYTES]);
1301    binding
1302}
1303
1304fn cursor_checksum(body: &[u8]) -> [u8; 32] {
1305    let mut hasher = Sha256::new();
1306    hasher.update(body);
1307    hasher.finalize().into()
1308}
1309
1310/// The immutable ordering key and estimated encoded size of one explorer row.
1311#[doc(hidden)]
1312pub trait ExplorerRow {
1313    /// Returns the immutable ordering key of this row.
1314    fn cursor_key(&self) -> CursorKey;
1315
1316    /// Returns the estimated encoded size of this row in bytes.
1317    fn encoded_len(&self) -> usize;
1318}
1319
1320/// Builds the keyset window that starts a traversal.
1321#[doc(hidden)]
1322#[must_use]
1323pub const fn start_window(request: &PageRequest, ceiling: u64) -> QueryWindow {
1324    QueryWindow::new(None, ceiling, request.size().get())
1325}
1326
1327/// Builds the keyset window that continues a traversal from its cursor.
1328///
1329/// # Errors
1330///
1331/// Returns [`ExplorerError::Cursor`] when the token is malformed or belongs to
1332/// a different query, filter, or page size.
1333#[doc(hidden)]
1334pub fn resume_window(
1335    cursor: &Cursor,
1336    query: &ExplorerQuery,
1337    request: &PageRequest,
1338) -> Result<QueryWindow, ExplorerError> {
1339    let (after, ceiling) = decode_cursor(cursor, query, request.size())?;
1340    Ok(QueryWindow::new(Some(after), ceiling, request.size().get()))
1341}
1342
1343/// Bounds one adapter-returned row set and seals its continuation token.
1344///
1345/// # Errors
1346///
1347/// Returns [`ExplorerError::ResponseTooLarge`] when a single row exceeds the
1348/// encoded response bound, and [`ExplorerError::Cursor`] when the continuation
1349/// token cannot be encoded.
1350#[doc(hidden)]
1351pub fn page<T: ExplorerRow>(
1352    query: &ExplorerQuery,
1353    request: &PageRequest,
1354    ceiling: u64,
1355    rows: Vec<T>,
1356) -> Result<Page<T>, ExplorerError> {
1357    let limit = usize::from(request.size().get());
1358    let full = rows.len() >= limit;
1359    let mut kept = Vec::with_capacity(rows.len().min(limit));
1360    let mut encoded = 0_usize;
1361    let mut truncated = false;
1362    for row in rows.into_iter().take(limit) {
1363        let next = encoded.saturating_add(row.encoded_len());
1364        if next > MAX_RESPONSE_BYTES {
1365            if kept.is_empty() {
1366                return Err(ExplorerError::ResponseTooLarge {
1367                    limit: MAX_RESPONSE_BYTES,
1368                });
1369            }
1370            truncated = true;
1371            break;
1372        }
1373        encoded = next;
1374        kept.push(row);
1375    }
1376    let next = if (full || truncated) && !kept.is_empty() {
1377        let key = kept
1378            .last()
1379            .map(ExplorerRow::cursor_key)
1380            .ok_or(ExplorerError::Cursor(CursorError::CursorInvalid))?;
1381        Some(encode_cursor(query, request.size(), &key, ceiling)?)
1382    } else {
1383        None
1384    };
1385    Ok(Page::new(kept, next))
1386}
1387
1388impl ExplorerRow for JobName {
1389    fn cursor_key(&self) -> CursorKey {
1390        CursorKey::Name(self.as_str().to_owned())
1391    }
1392
1393    fn encoded_len(&self) -> usize {
1394        self.as_str().len().saturating_add(8)
1395    }
1396}
1397
1398impl ExplorerRow for JobInstanceProjection {
1399    fn cursor_key(&self) -> CursorKey {
1400        CursorKey::Identity(self.id().get())
1401    }
1402
1403    fn encoded_len(&self) -> usize {
1404        let parameters = self
1405            .parameters()
1406            .iter()
1407            .map(|parameter| parameter.name().as_str().len().saturating_add(24))
1408            .fold(0_usize, usize::saturating_add);
1409        self.job_name()
1410            .as_str()
1411            .len()
1412            .saturating_add(160)
1413            .saturating_add(parameters)
1414    }
1415}
1416
1417impl ExplorerRow for JobExecutionProjection {
1418    fn cursor_key(&self) -> CursorKey {
1419        CursorKey::Ordered {
1420            primary: u64::from(self.attempt()),
1421            identity: self.id().get(),
1422        }
1423    }
1424
1425    fn encoded_len(&self) -> usize {
1426        self.job_name()
1427            .as_str()
1428            .len()
1429            .saturating_add(self.exit_status().code().as_str().len())
1430            .saturating_add(256)
1431    }
1432}
1433
1434impl ExplorerRow for StepExecutionProjection {
1435    fn cursor_key(&self) -> CursorKey {
1436        CursorKey::Identity(self.id().get())
1437    }
1438
1439    fn encoded_len(&self) -> usize {
1440        self.step_name()
1441            .as_str()
1442            .len()
1443            .saturating_add(self.exit_status().code().as_str().len())
1444            .saturating_add(256)
1445    }
1446}
1447
1448impl ExplorerRow for StepPartitionProjection {
1449    fn cursor_key(&self) -> CursorKey {
1450        CursorKey::Identity(self.id().get())
1451    }
1452
1453    fn encoded_len(&self) -> usize {
1454        self.partition_key().len().saturating_add(192)
1455    }
1456}
1457
1458impl ExplorerRow for RecoveryDecision {
1459    fn cursor_key(&self) -> CursorKey {
1460        CursorKey::Identity(self.id().get())
1461    }
1462
1463    fn encoded_len(&self) -> usize {
1464        self.reason_code()
1465            .len()
1466            .saturating_add(self.operator_reference().len())
1467            .saturating_add(160)
1468    }
1469}
1470
1471impl ExplorerRow for FlowDecision {
1472    fn cursor_key(&self) -> CursorKey {
1473        CursorKey::Ordered {
1474            primary: self.sequence().get(),
1475            identity: self.id().get(),
1476        }
1477    }
1478
1479    fn encoded_len(&self) -> usize {
1480        self.source_node_id()
1481            .as_str()
1482            .len()
1483            .saturating_add(self.observed_outcome().as_str().len())
1484            .saturating_add(224)
1485    }
1486}
1487
1488impl ExplorerRow for OperatorRecord {
1489    fn cursor_key(&self) -> CursorKey {
1490        CursorKey::Identity(self.id().get())
1491    }
1492
1493    fn encoded_len(&self) -> usize {
1494        self.operation_id()
1495            .as_str()
1496            .len()
1497            .saturating_add(self.actor().as_str().len())
1498            .saturating_add(self.reason().map_or(0, |reason| reason.as_str().len()))
1499            .saturating_add(192)
1500    }
1501}