Skip to main content

coven_sync/
pending.rs

1//! Current unmet conditions shared by calls, persistence and the application query.
2
3use super::pending_encoding as encoding;
4use crate::{OperationId, OperationKind, Refusal};
5use coven_database::PendingValue;
6use coven_format::{admin::SnapshotId, path::ObjectPath};
7use coven_foundation::id_source::{DeviceId, FileId, KeyId, MemberId, ObjectId};
8use coven_merge::{Audience, Slot, WriteId};
9use coven_storage::{CloudProvider, RetainedAccess, StorageFailure};
10use serde::{Deserialize, Serialize};
11
12pub use coven_format::RequiredUpdate;
13
14/// The staging device cannot supply the file's original bytes.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub enum FileSourceFailure {
17    /// The original file is absent.
18    Missing,
19    /// The original changed after it was staged.
20    Changed,
21    /// The original fails its retained content checks.
22    Integrity,
23}
24
25/// One local first unmet condition, identified by its subject (§19.1, E5).
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct PendingRecord {
28    /// The work that cannot advance.
29    pub subject: PendingSubject,
30    /// The first condition in its processing order.
31    pub reason: PendingReason,
32    /// The text of the error behind `reason`: the provider, disk or
33    /// database failure, naming every cause below it. `coven_pending.cause`
34    /// keeps it beside the record, written in the same step. Only the run
35    /// that met the error writes one: opening clears every cause, and a
36    /// record whose last failure went back to an app call has none. It
37    /// names the failure and is no part of the record's identity: equality
38    /// ignores it, and `record` leaves it out.
39    #[serde(skip)]
40    pub cause: Option<String>,
41}
42
43impl PartialEq for PendingRecord {
44    fn eq(&self, other: &Self) -> bool {
45        self.subject == other.subject && self.reason == other.reason
46    }
47}
48impl Eq for PendingRecord {}
49
50/// The subject and its reason, then the error behind it when the record
51/// keeps one, such as `Operation { .. }: Storage { .. }: S3: Network: …`.
52impl std::fmt::Display for PendingRecord {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{:?}: {:?}", self.subject, self.reason)?;
55        match &self.cause {
56            Some(cause) => write!(f, ": {cause}"),
57            None => Ok(()),
58        }
59    }
60}
61
62/// Stable identities of work, independent of attempts and wall-clock time.
63#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
64pub enum PendingSubject {
65    /// A numbered write.
66    Write(#[serde(with = "encoding::write")] WriteId),
67    /// A position in the store sequence.
68    Slot(#[serde(with = "coven_database::slot_json")] Slot),
69    /// A sealed key addressed to a member.
70    KeyCopy {
71        /// The key's audience.
72        #[serde(with = "crate::pending_encoding::audience")]
73        audience: Audience,
74        /// The key identity.
75        key: KeyId,
76        /// The recipient.
77        member: MemberId,
78        /// The recipient's exact sealing credential (D9, Key copies).
79        #[serde(with = "encoding::sealing")]
80        sealing: coven_crypto::SealingKey,
81    },
82    /// A numbered snapshot.
83    Snapshot(#[serde(with = "crate::pending_encoding::snapshot_id")] SnapshotId),
84    /// This device's upload of a stored object, and its reads of its staged
85    /// original while the upload waits.
86    Upload {
87        /// This device, which uploads it.
88        device: DeviceId,
89        /// The stored object's id.
90        object: ObjectId,
91    },
92    /// This device's downloads of a stored object, for an app's pins: a
93    /// read's failure goes to its caller and leaves no record (§17.7).
94    Download {
95        /// The device that uploaded it.
96        device: DeviceId,
97        /// The stored object's id.
98        object: ObjectId,
99    },
100    /// A file a switch to the cloud asked this device to keep whole, which
101    /// coven downloads itself: its pin is work coven drives, not an app
102    /// call's (§17.8).
103    Keep {
104        /// The file.
105        file: FileId,
106    },
107    /// A move of this device's files to the cloud: the files one write
108    /// staged for the cloud, or the files the app moved (§17.3).
109    Move {
110        /// The move's identity on this device.
111        id: coven_database::MoveId,
112    },
113    /// An unfinished journal row.
114    Operation {
115        /// The never reused local identity.
116        id: OperationId,
117        /// The work's purpose.
118        kind: OperationKind,
119    },
120    /// A physical object waiting for safe deletion.
121    Retention {
122        /// The storage path.
123        path: ObjectPath,
124    },
125    /// An audience that requires dependent work.
126    Audience(#[serde(with = "crate::pending_encoding::audience")] Audience),
127    /// The local storage connection.
128    Connection,
129}
130
131/// A fact required before processing can continue.
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
133pub enum Prerequisite {
134    /// A completed read and apply through the current sequence end.
135    SequenceCatchUp,
136    /// This audience must reload.
137    Reload(#[serde(with = "crate::pending_encoding::audience")] Audience),
138    /// This audience must be raised to this schema version: a queued write of
139    /// this device's at that version has a part in it, and every device would
140    /// refuse that part until the raise (§18.5).
141    Raise {
142        /// The audience below the write's version.
143        #[serde(with = "crate::pending_encoding::audience")]
144        audience: Audience,
145        /// The version the write is at, this device's newest breaking
146        /// migration.
147        version: u32,
148    },
149}
150
151/// A local invariant or resource failure; immediate calls retain the native cause.
152#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
153pub enum LocalFailure {
154    /// Database state or persistence failed.
155    Database,
156    /// Local file access failed because the disk is full or its quota is
157    /// spent.
158    DiskFull,
159    /// Local file access failed because the operating system refused this
160    /// process the file or folder, or the volume is read-only.
161    DiskDenied,
162    /// Local file access failed for a reason that is neither: the file is
163    /// gone, the device failed, or the cause names no operating system
164    /// error.
165    DiskOther,
166    /// Key custody failed.
167    KeyCustody,
168    /// Cryptographic work failed.
169    Crypto,
170    /// Operation state failed.
171    Operation,
172    /// The work's runtime is shutting down and dropped it: the process is
173    /// ending, and the next one runs the work again (E15).
174    Shutdown,
175    /// Coven's own code broke its rule or panicked: a bug. The work retries
176    /// under backoff, as a database failure does, and fails again until the
177    /// bug is fixed (E15).
178    Bug,
179}
180
181impl LocalFailure {
182    /// The disk failure `error` is, read from the operating system error
183    /// first in its chain of causes: a full disk or spent quota, a refusal
184    /// of access or a read-only volume, or anything else.
185    pub(crate) fn of_disk(error: &(dyn std::error::Error + 'static)) -> Self {
186        let mut current = Some(error);
187        while let Some(cause) = current {
188            if let Some(os) = cause.downcast_ref::<std::io::Error>() {
189                return match os.kind() {
190                    std::io::ErrorKind::StorageFull | std::io::ErrorKind::QuotaExceeded => {
191                        Self::DiskFull
192                    }
193                    std::io::ErrorKind::PermissionDenied
194                    | std::io::ErrorKind::ReadOnlyFilesystem => Self::DiskDenied,
195                    _ => Self::DiskOther,
196                };
197            }
198            current = cause.source();
199        }
200        Self::DiskOther
201    }
202}
203
204/// The provider's intended sharing change.
205#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
206pub enum ProviderAccessAction {
207    /// Share the location.
208    Grant,
209    /// Take sharing back.
210    Revoke,
211}
212
213/// Why a file can't be read (§17.6).
214#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
215pub enum FileMissingReason {
216    /// Storage confirms nothing at the path a file's record names: the
217    /// provider lost it. Nothing brings it back on its own; the app can stage
218    /// it again.
219    Lost,
220    /// A file this device uploads, on it only until then, whose source
221    /// failed: its original is missing, or coven's copy fails its check; its
222    /// upload retries (§17.3, §17.9). A changed original fails its move.
223    Source(FileSourceFailure),
224}
225
226/// A typed current condition, never an error's display string.
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228pub enum PendingReason {
229    /// The named object has not arrived.
230    Missing {
231        /// The storage path.
232        path: ObjectPath,
233    },
234    /// An earlier prerequisite remains unmet.
235    Waits(Prerequisite),
236    /// No usable copy of a required key has arrived.
237    KeyUnavailable {
238        /// The key's audience.
239        #[serde(with = "crate::pending_encoding::audience")]
240        audience: Audience,
241        /// The missing key.
242        key: KeyId,
243    },
244    /// Processing requires a supported schema or format.
245    UpdateRequired(RequiredUpdate),
246    /// No storage is connected.
247    NoStorage,
248    /// A classified provider failure.
249    Storage {
250        /// The provider.
251        provider: CloudProvider,
252        /// The classification, without a process-local cause.
253        failure: StorageFailure,
254        /// Whether the provider answered the failed request, even with an
255        /// error. `Network` alone cannot say: a server's error response is
256        /// an answer, a lookup or connection failure is not (E5).
257        answered: bool,
258    },
259    /// Complete immutable bytes failed a check.
260    Refused(Refusal),
261    /// Writes consumed after a permanent lower-slot cut-off.
262    WritesRefused {
263        /// The removal or replacement slot.
264        #[serde(with = "coven_database::slot_json")]
265        cutoff: Slot,
266        /// The event that excluded the original author.
267        cause: coven_database::WriteCutoff,
268    },
269    /// A provider has accepted work that is still waiting.
270    ProviderPending {
271        /// The intended change.
272        action: ProviderAccessAction,
273        /// The provider account.
274        account: String,
275    },
276    /// Another member or invite still needs this account.
277    AccountInUse {
278        /// The shared account.
279        account: String,
280    },
281    /// Revocation retained grants requiring owner action.
282    AccessRemains {
283        /// The account whose access remains.
284        account: String,
285        /// The retained grants.
286        shares: Vec<RetainedAccess>,
287    },
288    /// The uploading device cannot supply this file.
289    FileUnavailable(FileMissingReason),
290    /// A user's original a move uploads is missing, and the move waits for
291    /// it, or changed, and the move failed (§17.3).
292    Original {
293        /// The original's recorded path.
294        #[serde(with = "crate::pending_encoding::path")]
295        path: std::path::PathBuf,
296        /// Missing or changed.
297        failure: FileSourceFailure,
298    },
299    /// A local failure prevented progress.
300    Failed(LocalFailure),
301    /// The action was refused; an enclosing cleanup may still be owed (§11.3).
302    Rejected(crate::OperationRejection),
303    /// Both the initiating failure and the failure preventing undo (§11.3).
304    Cleanup {
305        /// The failure that made cleanup necessary.
306        operation: Box<PendingReason>,
307        /// The current condition preventing cleanup.
308        cleanup: Box<PendingReason>,
309    },
310    /// Work awaits the app’s next action, such as approval or resuming a transfer.
311    Paused,
312    /// This installation was removed.
313    Removed,
314    /// The admission that published this joined store was refused (§13.2).
315    NotAdmitted,
316    /// Sending needs this stable member's current credentials, which this
317    /// installation does not hold (§14.1).
318    MemberKeysReplaced {
319        /// The member whose held keys were replaced.
320        member: MemberId,
321    },
322}
323
324/// What can permit another attempt; no attempt count determines it.
325#[derive(Clone, Copy, Debug, PartialEq, Eq)]
326pub enum Retry {
327    /// Retry when the prerequisite or retry deadline changes.
328    Automatic,
329    /// Retry only under software that may decide otherwise: a refusal under
330    /// another build of coven, a required update once it is installed.
331    AfterUpdate,
332    /// A person must act.
333    AppAction,
334    /// A permanent cut-off or removal never retries.
335    Never,
336}
337impl PendingRecord {
338    /// The record of `subject`'s first unmet condition, with no cause.
339    pub fn new(subject: PendingSubject, reason: PendingReason) -> Self {
340        Self { subject, reason, cause: None }
341    }
342
343    /// Retry according to the cause and the owner of the unfinished work.
344    pub fn retry(&self) -> Retry {
345        match (&self.subject, self.reason.retry_cause()) {
346            (
347                PendingSubject::Upload { .. },
348                PendingReason::FileUnavailable(FileMissingReason::Source(_)),
349            ) => Retry::Automatic,
350            // Only a pin's failed download is recorded, and its call
351            // returns the failure: the app's next pin or read tries again,
352            // and nothing else does.
353            (PendingSubject::Download { .. }, reason) => match reason.clears() {
354                Some(Clears::Never) => Retry::Never,
355                Some(Clears::ByItself | Clears::ByPerson) => Retry::AppAction,
356                None => match reason.retry() {
357                    Retry::Automatic => Retry::AppAction,
358                    retry => retry,
359                },
360            },
361            (subject, reason)
362                if matches!(
363                    subject,
364                    PendingSubject::Upload { .. } | PendingSubject::Keep { .. }
365                ) || matches!(subject, PendingSubject::Operation { kind, .. } if kind.coven_owned()) =>
366            {
367                match reason.clears() {
368                    Some(Clears::ByItself) => Retry::Automatic,
369                    Some(Clears::ByPerson) => Retry::AppAction,
370                    Some(Clears::Never) => Retry::Never,
371                    None => reason.retry(),
372                }
373            }
374            // An operation the app started returned this failure to the
375            // app's call, and the app calls again. Nobody calls again for
376            // work coven drives itself, such as the pass: a local failure
377            // that clears by itself retries under the shared backoff.
378            (subject, reason @ PendingReason::Failed(_))
379                if !matches!(subject, PendingSubject::Operation { .. })
380                    && reason.clears() == Some(Clears::ByItself) =>
381            {
382                Retry::Automatic
383            }
384            (_, reason) => reason.retry(),
385        }
386    }
387}
388
389/// How a failure clears (E5). Every failure is classified here, once.
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub(crate) enum Clears {
392    /// It can clear on its own: retry under the shared backoff.
393    ByItself,
394    /// Only a person's action clears it, such as signing in again.
395    ByPerson,
396    /// Nothing clears it: the provider or this installation refuses outright.
397    Never,
398}
399
400/// A failure that received no usable provider response: the device is
401/// offline (E5), and only a change in its network can end the wait early
402/// (§6.7). A server's error response is an answer, so it is not offline.
403pub(crate) fn offline(failure: StorageFailure, answered: bool) -> bool {
404    failure == StorageFailure::Network && !answered
405}
406
407/// Storage failures that subjects other than files and coven's own
408/// operations retry automatically (E5).
409pub(crate) fn automatic_storage(failure: StorageFailure) -> bool {
410    matches!(
411        failure,
412        StorageFailure::Network
413            | StorageFailure::Corrupted
414            | StorageFailure::Conflict
415            | StorageFailure::RateLimited
416            | StorageFailure::NotFound
417            | StorageFailure::SessionExpired
418    )
419}
420
421/// How a provider failure clears. Every variant is matched by name, so a
422/// new one is classified here.
423#[deny(clippy::wildcard_enum_match_arm)]
424pub(crate) fn storage_clears(failure: StorageFailure) -> Clears {
425    use StorageFailure::*;
426    match failure {
427        Network | Corrupted | Conflict | RateLimited | NotFound | SessionExpired
428        | CatchUpRequired | QuotaExceeded | File | Bug => Clears::ByItself,
429        Authentication
430        | PermissionDenied
431        | AccessNotRegistered { .. }
432        | AccessNotGranted { .. }
433        | ContainerNotFound
434        | RegionMismatch
435        | ClockSkew
436        | InvalidConfiguration
437        | NotStoreOwner
438        | AccountIdUnavailable
439        | SharingRefused(_)
440        | ProviderUnavailable
441        | MemberKeysMissing => Clears::ByPerson,
442        DropboxAppFolderNotShareable
443        | AlreadyExists
444        | StoreExists
445        | OwnLocationRequired
446        | Refused
447        | Protocol
448        | InvalidPath
449        | InvalidRange
450        | InviteeIsStoreOwner
451        | InvitationMismatch
452        | SessionMismatch
453        | SessionIdentityUnknown
454        | InvalidPart
455        | SingleRequestTooLarge { .. }
456        | CopyTooLarge { .. }
457        | Encoding => Clears::Never,
458    }
459}
460
461impl PendingReason {
462    /// Cleanup retries its own unmet condition, retaining the initiating cause.
463    pub(crate) fn retry_cause(&self) -> &Self {
464        match self {
465            Self::Cleanup { cleanup, .. } => cleanup.retry_cause(),
466            reason => reason,
467        }
468    }
469
470    /// How the failure this reason reports clears; `None` for a wait or a
471    /// verdict, which are not failures.
472    #[deny(clippy::wildcard_enum_match_arm)]
473    pub(crate) fn clears(&self) -> Option<Clears> {
474        match self.retry_cause() {
475            Self::Failed(
476                LocalFailure::Database
477                | LocalFailure::DiskFull
478                | LocalFailure::DiskDenied
479                | LocalFailure::DiskOther
480                | LocalFailure::KeyCustody
481                | LocalFailure::Shutdown
482                | LocalFailure::Bug,
483            ) => Some(Clears::ByItself),
484            Self::Failed(LocalFailure::Crypto | LocalFailure::Operation) => Some(Clears::Never),
485            Self::Storage { failure, .. } => Some(storage_clears(*failure)),
486            Self::Missing { .. }
487            | Self::Waits(_)
488            | Self::KeyUnavailable { .. }
489            | Self::UpdateRequired(_)
490            | Self::NoStorage
491            | Self::Refused(_)
492            | Self::WritesRefused { .. }
493            | Self::ProviderPending { .. }
494            | Self::AccountInUse { .. }
495            | Self::AccessRemains { .. }
496            | Self::FileUnavailable(_)
497            | Self::Original { .. }
498            | Self::Rejected(_)
499            | Self::Cleanup { .. }
500            | Self::Paused
501            | Self::Removed
502            | Self::NotAdmitted
503            | Self::MemberKeysReplaced { .. } => None,
504        }
505    }
506
507    /// Whether this reason waits for a condition to change, as opposed to
508    /// reporting a failure or a verdict. A wait is ordinary progress, and
509    /// is logged at debug where a failure is logged at warn.
510    pub(crate) fn waits(&self) -> bool {
511        match self {
512            Self::Missing { .. }
513            | Self::Waits(_)
514            | Self::KeyUnavailable { .. }
515            | Self::NoStorage
516            | Self::ProviderPending { .. }
517            | Self::AccountInUse { .. }
518            | Self::Paused => true,
519            Self::Original { failure, .. } => *failure == FileSourceFailure::Missing,
520            Self::UpdateRequired(_)
521            | Self::Storage { .. }
522            | Self::Refused(_)
523            | Self::WritesRefused { .. }
524            | Self::AccessRemains { .. }
525            | Self::FileUnavailable(_)
526            | Self::Failed(_)
527            | Self::Rejected(_)
528            | Self::Removed
529            | Self::NotAdmitted
530            | Self::MemberKeysReplaced { .. } => false,
531            Self::Cleanup { operation, cleanup } => operation.waits() && cleanup.waits(),
532        }
533    }
534
535    /// Whether this reason records an attempt that received no usable
536    /// provider response, which the connection reports as `Offline` (E5).
537    pub(crate) fn offline(&self) -> bool {
538        matches!(self, Self::Storage { failure, answered, .. } if offline(*failure, *answered))
539    }
540
541    #[deny(clippy::wildcard_enum_match_arm)]
542    fn retry(&self) -> Retry {
543        match self {
544            Self::Missing { .. }
545            | Self::Waits(_)
546            | Self::KeyUnavailable { .. }
547            | Self::ProviderPending { .. }
548            | Self::AccountInUse { .. } => Retry::Automatic,
549            // A missing original's move retries as its upload does, until
550            // the original is back; a changed one failed the move, which
551            // stays recorded until the app cancels it (§17.3).
552            Self::Original { failure: FileSourceFailure::Missing, .. } => Retry::Automatic,
553            Self::Original {
554                failure: FileSourceFailure::Changed | FileSourceFailure::Integrity,
555                ..
556            } => Retry::AppAction,
557            Self::Storage { failure, .. } if automatic_storage(*failure) => Retry::Automatic,
558            Self::Refused(_) | Self::UpdateRequired(_) => Retry::AfterUpdate,
559            Self::Rejected(_)
560            | Self::WritesRefused { .. }
561            | Self::Removed
562            | Self::NotAdmitted
563            | Self::MemberKeysReplaced { .. } => Retry::Never,
564            Self::NoStorage
565            | Self::Storage { .. }
566            | Self::AccessRemains { .. }
567            | Self::FileUnavailable(_)
568            | Self::Failed(_)
569            | Self::Cleanup { .. }
570            | Self::Paused => Retry::AppAction,
571        }
572    }
573}
574
575/// A record as coven's `coven_uploads` and `coven_pending` views hold it
576/// (api.md E4), without its cause: `coven_pending.cause` holds that.
577impl coven_database::types::FromSql for PendingRecord {
578    fn column_result(
579        value: coven_database::types::ValueRef<'_>,
580    ) -> coven_database::types::FromSqlResult<Self> {
581        serde_json::from_slice(value.as_blob()?)
582            .map_err(|error| coven_database::types::FromSqlError::Other(Box::new(error)))
583    }
584}
585
586impl PendingValue for PendingRecord {
587    fn provider_access(id: OperationId, account: String, grant: bool) -> Self {
588        Self::new(
589            PendingSubject::Operation { id, kind: OperationKind::RevokeAccess },
590            PendingReason::ProviderPending {
591                action: if grant {
592                    ProviderAccessAction::Grant
593                } else {
594                    ProviderAccessAction::Revoke
595                },
596                account,
597            },
598        )
599    }
600    fn reload(audience: Audience) -> Self {
601        Self::new(
602            PendingSubject::Audience(audience.clone()),
603            PendingReason::Waits(Prerequisite::Reload(audience)),
604        )
605    }
606    fn writes_refused(slot: Slot, cutoff: Slot, cause: coven_database::WriteCutoff) -> Self {
607        Self::new(PendingSubject::Slot(slot), PendingReason::WritesRefused { cutoff, cause })
608    }
609    fn raise_wait(write: coven_merge::WriteId, audience: Audience, version: u32) -> Self {
610        Self::new(
611            PendingSubject::Write(write),
612            PendingReason::Waits(Prerequisite::Raise { audience, version }),
613        )
614    }
615    fn raise_awaited(&self) -> Option<(coven_merge::WriteId, &Audience, u32)> {
616        match (&self.subject, &self.reason) {
617            (
618                PendingSubject::Write(write),
619                PendingReason::Waits(Prerequisite::Raise { audience, version }),
620            ) => Some((*write, audience, *version)),
621            _ => None,
622        }
623    }
624    type Subject = PendingSubject;
625    fn subject(&self) -> &PendingSubject {
626        &self.subject
627    }
628    fn operation(&self) -> Option<OperationId> {
629        match self.subject {
630            PendingSubject::Operation { id, .. } => Some(id),
631            _ => None,
632        }
633    }
634    fn move_of(&self) -> Option<coven_database::MoveId> {
635        match self.subject {
636            PendingSubject::Move { id } => Some(id),
637            _ => None,
638        }
639    }
640    fn pin_of(&self) -> Option<FileId> {
641        match self.subject {
642            PendingSubject::Keep { file } => Some(file),
643            _ => None,
644        }
645    }
646    fn object(&self) -> Option<(coven_database::FileWork, DeviceId, ObjectId)> {
647        match self.subject {
648            PendingSubject::Upload { device, object } => {
649                Some((coven_database::FileWork::Upload, device, object))
650            }
651            PendingSubject::Download { device, object } => {
652                Some((coven_database::FileWork::Download, device, object))
653            }
654            _ => None,
655        }
656    }
657    fn slot(&self) -> Option<Slot> {
658        match self.subject {
659            PendingSubject::Slot(slot) => Some(slot),
660            _ => None,
661        }
662    }
663    fn ends_when_covered(&self) -> bool {
664        !matches!(
665            self.reason.retry_cause(),
666            PendingReason::Refused(_) | PendingReason::WritesRefused { .. }
667        )
668    }
669    /// Drawn at random each time this crate compiles. Every refusal is made
670    /// here, from checks in this crate and in the crates it depends on, so a
671    /// change to any of that code, or to a dependency, compiles this crate
672    /// again and draws another. A version number would not do: development
673    /// never moves it, so a refusal a since-fixed bug made would never be
674    /// rechecked.
675    // crates/coven-sync/build.rs fails the build when CONST_RANDOM_SEED fixes this draw.
676    const BUILD: u128 = const_random::const_random!(u128);
677    /// A refusal is a verdict on bytes that never change, judged against
678    /// what never changes either: only another build can judge it
679    /// differently, and each other build checks it once (§19.1).
680    fn can_retry(&self, recorded_build: u128, schema_version: u32) -> bool {
681        match self.reason.retry_cause() {
682            PendingReason::Refused(_) => recorded_build != Self::BUILD,
683            PendingReason::UpdateRequired(RequiredUpdate::AppSchema { version }) => {
684                schema_version >= *version
685            }
686            PendingReason::UpdateRequired(RequiredUpdate::CovenFormat { version }) => {
687                coven_format::FORMAT_VERSION >= *version
688            }
689            _ => self.retry() == Retry::Automatic,
690        }
691    }
692    fn cause(&self) -> Option<&str> {
693        self.cause.as_deref()
694    }
695    fn with_cause(self, cause: Option<String>) -> Self {
696        Self { cause, ..self }
697    }
698}
699
700#[cfg(test)]
701#[path = "pending_tests.rs"]
702mod tests;