Skip to main content

coven_replication/sync/store/device_join/
transport.rs

1//! Storage-mediated delivery for the device-join exchange.
2//!
3//! The join protocol owned by [`crate::sync::store::Store`] produces signed
4//! artifacts plus the unwind artifacts, and hands each to the host as a
5//! [`DeviceJoinAction`] to deliver however it likes. This module is the delivery
6//! coven ships by default: each artifact travels as one create-once object in
7//! the store's cloud home, under a per-attempt namespace, sealed with a key
8//! minted for that attempt alone.
9//!
10//! The layer carries bytes and nothing else. It never inspects an artifact
11//! beyond naming which slot it belongs in, and unsealing is not part of the
12//! trust story — the artifact's own signature and hash chaining, checked by the
13//! protocol when it accepts the artifact, are.
14//!
15//! The offer does not travel here. It is the out-of-band kickoff: the host
16//! encodes a [`DeviceJoinOfferBundle`] (the offer plus the slots and seal key
17//! this module needs) as a QR, a link, or a typed code, and the joiner's copy of
18//! that bundle is what bootstraps everything below.
19
20use std::collections::BTreeMap;
21use std::time::Duration;
22
23use serde::{Deserialize, Serialize};
24
25use super::OwnerJoinPublication;
26use crate::sync::store::{
27    DeviceJoinAbandonment, DeviceJoinAction, DeviceJoinActivation, DeviceJoinError,
28    DeviceJoinOffer, DeviceJoinReadiness, DeviceJoinRole, DeviceJoinStatus,
29    DeviceProviderAccessAdministrator, DeviceProviderAccessRequest,
30    DeviceProviderAdmissionApproval, DeviceRegistrationRequest, SamePrincipalDeviceJoin, Store,
31};
32use coven_keys::encryption::{EncryptionService, MasterKeyring, SealError};
33use coven_protocol::objects::ObjectSlot;
34use coven_protocol::objects::{ProtocolObjectContext, ProtocolObjectDomain, StorageError};
35use coven_protocol::store_commit::device_join_exchange::DeviceProviderAdmission;
36use coven_protocol::store_commit::device_join_exchange::DeviceProviderChallengePublication;
37use coven_protocol::store_commit::{DeviceJoinAttemptId, ObjectHash, STORE_PROTOCOL_VERSION};
38use coven_storage::CloudSyncObjectStorage;
39
40/// The prefix every transport slot's logical key starts with.
41const TRANSPORT_ROOT: &str = "store-v1/device-join-transport";
42
43/// Domain separation for the per-attempt seal, so a sealed artifact cannot be
44/// opened as anything but the kind and attempt it was written for.
45const SEAL_AAD_LABEL: &[u8] = b"coven.device-join-transport.v1";
46
47/// One artifact kind in transit. Every kind has exactly one producing role in
48/// the protocol and exactly one slot per attempt.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case", deny_unknown_fields)]
51pub enum DeviceJoinTransportKind {
52    ProviderAccessRequest,
53    ProviderAdmissionApproval,
54    RegistrationRequest,
55    ProviderReadyBootstrap,
56    Readiness,
57    SamePrincipalJoin,
58    Activation,
59    Abandonment,
60}
61
62impl DeviceJoinTransportKind {
63    /// Every kind, in protocol order. An attempt's namespace holds one slot per
64    /// entry — allocated together, deleted together.
65    pub const ALL: [Self; 8] = [
66        Self::ProviderAccessRequest,
67        Self::ProviderAdmissionApproval,
68        Self::RegistrationRequest,
69        Self::ProviderReadyBootstrap,
70        Self::Readiness,
71        Self::SamePrincipalJoin,
72        Self::Activation,
73        Self::Abandonment,
74    ];
75
76    /// The last path component of this kind's slot.
77    fn slug(self) -> &'static str {
78        match self {
79            Self::ProviderAccessRequest => "provider-access-request",
80            Self::ProviderAdmissionApproval => "provider-admission-approval",
81            Self::RegistrationRequest => "registration-request",
82            Self::ProviderReadyBootstrap => "provider-ready-bootstrap",
83            Self::Readiness => "readiness",
84            Self::SamePrincipalJoin => "same-principal-join",
85            Self::Activation => "activation",
86            Self::Abandonment => "abandonment",
87        }
88    }
89
90    /// The one role the protocol lets produce this kind. A publish from any
91    /// other role is refused before it reaches storage.
92    fn producer(self) -> DeviceJoinRole {
93        match self {
94            Self::ProviderAccessRequest | Self::RegistrationRequest | Self::Readiness => {
95                DeviceJoinRole::Joiner
96            }
97            Self::ProviderAdmissionApproval
98            | Self::ProviderReadyBootstrap
99            | Self::SamePrincipalJoin
100            | Self::Activation
101            | Self::Abandonment => DeviceJoinRole::Owner,
102        }
103    }
104
105    /// The kind an action's artifact belongs in, or `None` for the actions that
106    /// name local work rather than a transfer (`CompleteJoin`,
107    /// `ResumeOperation`) and for the offer, which travels out of band.
108    fn of(action: &DeviceJoinAction) -> Option<Self> {
109        match action {
110            DeviceJoinAction::TransferProviderAccessRequest(_) => Some(Self::ProviderAccessRequest),
111            DeviceJoinAction::TransferProviderAdmissionApproval(_) => {
112                Some(Self::ProviderAdmissionApproval)
113            }
114            DeviceJoinAction::TransferRegistrationRequest(_) => Some(Self::RegistrationRequest),
115            DeviceJoinAction::TransferProviderReadyBootstrap(_) => {
116                Some(Self::ProviderReadyBootstrap)
117            }
118            DeviceJoinAction::TransferReadiness(_) => Some(Self::Readiness),
119            DeviceJoinAction::TransferSamePrincipalJoin(_) => Some(Self::SamePrincipalJoin),
120            DeviceJoinAction::TransferActivation(_) => Some(Self::Activation),
121            DeviceJoinAction::TransferAbandonment(_) => Some(Self::Abandonment),
122            DeviceJoinAction::TransferOffer(_)
123            | DeviceJoinAction::CompleteJoin(_)
124            | DeviceJoinAction::ResumeOperation { .. } => None,
125        }
126    }
127}
128
129/// The artifact type a kind carries. Awaiting a kind yields exactly this type,
130/// so a caller never re-matches the action enum it just asked for by kind.
131pub trait DeviceJoinArtifact: Sized {
132    const KIND: DeviceJoinTransportKind;
133
134    fn from_action(action: DeviceJoinAction) -> Option<Self>;
135}
136
137macro_rules! device_join_artifact {
138    ($type:ty, $kind:ident, $variant:ident) => {
139        impl DeviceJoinArtifact for $type {
140            const KIND: DeviceJoinTransportKind = DeviceJoinTransportKind::$kind;
141
142            fn from_action(action: DeviceJoinAction) -> Option<Self> {
143                match action {
144                    DeviceJoinAction::$variant(value) => Some(value),
145                    _ => None,
146                }
147            }
148        }
149    };
150}
151
152device_join_artifact!(
153    DeviceProviderAccessRequest,
154    ProviderAccessRequest,
155    TransferProviderAccessRequest
156);
157device_join_artifact!(
158    DeviceProviderAdmissionApproval,
159    ProviderAdmissionApproval,
160    TransferProviderAdmissionApproval
161);
162device_join_artifact!(
163    DeviceRegistrationRequest,
164    RegistrationRequest,
165    TransferRegistrationRequest
166);
167device_join_artifact!(
168    coven_protocol::store_commit::device_join_exchange::ProviderReadyDeviceBootstrap,
169    ProviderReadyBootstrap,
170    TransferProviderReadyBootstrap
171);
172device_join_artifact!(DeviceJoinReadiness, Readiness, TransferReadiness);
173device_join_artifact!(
174    SamePrincipalDeviceJoin,
175    SamePrincipalJoin,
176    TransferSamePrincipalJoin
177);
178device_join_artifact!(DeviceJoinActivation, Activation, TransferActivation);
179device_join_artifact!(DeviceJoinAbandonment, Abandonment, TransferAbandonment);
180
181/// The slots and seal key one attempt's artifacts travel through.
182///
183/// The owner allocates the slots when it begins the join, because on providers
184/// whose exact slots carry an opaque provider locator (Google Drive) a reader
185/// cannot derive a slot from its logical key — the same reason the protocol's
186/// own attempt, outcome, and registration slots are reserved up front and named
187/// in the signed artifact that precedes them.
188#[derive(Clone, Debug, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct DeviceJoinTransportParams {
191    pub version: u32,
192    pub attempt_namespace: String,
193    pub slots: BTreeMap<DeviceJoinTransportKind, ObjectSlot>,
194    #[serde(with = "seal_key")]
195    seal_key: MasterKeyring,
196}
197
198/// `MasterKeyring` is the codebase's symmetric-key carrier and travels as its
199/// own serialized form; the transport adds no second key encoding.
200mod seal_key {
201    use super::MasterKeyring;
202    use serde::{Deserialize, Deserializer, Serializer};
203
204    pub(super) fn serialize<S: Serializer>(
205        keyring: &MasterKeyring,
206        serializer: S,
207    ) -> Result<S::Ok, S::Error> {
208        serializer.serialize_str(&keyring.to_serialized())
209    }
210
211    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
212        deserializer: D,
213    ) -> Result<MasterKeyring, D::Error> {
214        let encoded = String::deserialize(deserializer)?;
215        MasterKeyring::from_serialized(&encoded).map_err(serde::de::Error::custom)
216    }
217}
218
219impl DeviceJoinTransportParams {
220    pub(crate) fn new(
221        attempt_namespace: String,
222        slots: BTreeMap<DeviceJoinTransportKind, ObjectSlot>,
223        seal_key: MasterKeyring,
224    ) -> Self {
225        Self {
226            version: STORE_PROTOCOL_VERSION,
227            attempt_namespace,
228            slots,
229            seal_key,
230        }
231    }
232
233    fn slot(&self, kind: DeviceJoinTransportKind) -> Result<&ObjectSlot, DeviceJoinTransportError> {
234        self.slots
235            .get(&kind)
236            .ok_or(DeviceJoinTransportError::MissingSlot { kind })
237    }
238
239    fn validate_for(&self, offer: &DeviceJoinOffer) -> Result<(), DeviceJoinTransportError> {
240        if self.version != STORE_PROTOCOL_VERSION
241            || self.attempt_namespace != attempt_namespace(offer.attempt_id)
242        {
243            return Err(DeviceJoinTransportError::BundleMismatch);
244        }
245        let context = slot_context(offer.store_root.store_root_hash);
246        for kind in DeviceJoinTransportKind::ALL {
247            context.validate_slot(
248                self.slot(kind)?,
249                &semantic_prefix(&self.attempt_namespace, kind),
250            )?;
251        }
252        Ok(())
253    }
254}
255
256/// The out-of-band kickoff: the offer plus everything the transport needs to
257/// carry the rest of the exchange. The host encodes this however it delivers a
258/// join code; coven does not choose that encoding.
259#[derive(Clone, Debug, Serialize, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct DeviceJoinOfferBundle {
262    pub version: u32,
263    pub offer: DeviceJoinOffer,
264    pub transport: DeviceJoinTransportParams,
265}
266
267impl DeviceJoinOfferBundle {
268    pub fn to_bytes(&self) -> Vec<u8> {
269        serde_json::to_vec(self).expect("device join offer bundle serialization cannot fail")
270    }
271
272    pub fn from_bytes(bytes: &[u8]) -> Result<Self, DeviceJoinTransportError> {
273        let bundle: Self = serde_json::from_slice(bytes)?;
274        if bundle.version != STORE_PROTOCOL_VERSION {
275            return Err(DeviceJoinTransportError::BundleMismatch);
276        }
277        bundle.transport.validate_for(&bundle.offer)?;
278        Ok(bundle)
279    }
280}
281
282/// What a joining device found while waiting for its next artifact: the
283/// artifact, or the owner's abandonment of the attempt.
284#[derive(Clone, Debug, PartialEq, Eq)]
285pub enum DeviceJoinStep<T> {
286    Continue(T),
287    Abandoned(DeviceJoinAbandonment),
288}
289
290/// How a driven join ended for the admitting side.
291#[derive(Clone, Debug, PartialEq, Eq)]
292pub enum DeviceJoinDriveOutcome {
293    Activated(DeviceJoinActivation),
294    Abandoned(DeviceJoinAbandonment),
295}
296
297/// The joining device's current user-visible operation. These values describe
298/// the work actually executing or the exact counterpart artifact being
299/// awaited; hosts render them directly instead of collapsing the whole join
300/// into one indeterminate state.
301#[derive(Clone, Debug, PartialEq, Eq)]
302pub enum JoiningDeviceJoinProgress {
303    WaitingForApproval,
304    RequestingProviderAccess,
305    WaitingForProviderAccess,
306    RegisteringDevice,
307    WaitingForLibrary,
308    DownloadingSnapshot { bytes_done: u64, bytes_total: u64 },
309    InstallingSnapshot,
310    WaitingForActivation,
311    CatchingUp,
312    SavingLibrary,
313}
314
315/// A joining device's retained progress sink. Provider reads keep a clone while
316/// their response stream is active, so every received buffer reaches the host.
317pub type JoiningDeviceJoinProgressObserver =
318    std::sync::Arc<dyn Fn(JoiningDeviceJoinProgress) + Send + Sync>;
319
320/// The existing device's current user-visible operation while admitting the
321/// joining device.
322#[derive(Clone, Copy, Debug, PartialEq, Eq)]
323pub enum AdmittingDeviceJoinProgress {
324    PreparingInvitation,
325    WaitingForProviderAccessRequest,
326    GrantingProviderAccess,
327    WaitingForRegistrationRequest,
328    RegisteringDevice,
329    PreparingLibrary,
330    WaitingForJoiningDevice,
331    ActivatingDevice,
332}
333
334/// How often to look for a counterpart's artifact, and how long to keep
335/// looking before giving up on it.
336#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337pub struct DeviceJoinTransportTiming {
338    pub poll: Duration,
339    pub deadline: Duration,
340}
341
342impl DeviceJoinTransportTiming {
343    /// Pairing's product timing. Hosts render the states; coven decides how
344    /// frequently storage and the local pairing endpoint are observed and when
345    /// an absent counterpart becomes a failure.
346    pub const fn interactive() -> Self {
347        Self {
348            poll: Duration::from_millis(100),
349            deadline: Duration::from_secs(180),
350        }
351    }
352
353    /// The cadence a wait on this timing uses.
354    fn polls(self) -> JoinPollBackoff {
355        JoinPollBackoff {
356            next: self.poll,
357            ceiling: JOIN_POLL_CEILING.max(self.poll),
358        }
359    }
360}
361
362/// The longest a wait ever sleeps between looks.
363///
364/// A wait on a counterpart is a wait on a person — an owner reading an approval
365/// prompt — or on that device's next sync cycle, which is tens of seconds away.
366/// Looking every hundred milliseconds for all of it is hundreds of provider
367/// reads that answer "not yet", and a provider that rate-limits them makes the
368/// join slower, not faster. The first look is immediate and the cadence backs
369/// off to this, so a counterpart that answers at once is still seen at once.
370const JOIN_POLL_CEILING: Duration = Duration::from_secs(2);
371
372struct JoinPollBackoff {
373    next: Duration,
374    ceiling: Duration,
375}
376
377impl JoinPollBackoff {
378    fn next(&mut self) -> Duration {
379        let current = self.next;
380        self.next = (current * 2).min(self.ceiling);
381        current
382    }
383}
384
385/// Time one owner-side device-join step and report it the way every other
386/// staged run reports.
387///
388/// Each of these is one transition in the Add-a-device flow — approve the
389/// provider access, accept the registration, activate — and each is one or more
390/// provider round trips, which is what `requests` counts. Two flows reach them:
391/// the discrete command API a host drives itself, and the pairing driver's
392/// `drive_once`. They share this function so a run through either one reads the
393/// same in the log, and each passes the counter of the home it drives.
394pub async fn timed_owner_join_step<T>(
395    step: &'static str,
396    requests: Option<std::sync::Arc<dyn coven_foundation::stage_timing::ProviderRequests>>,
397    work: impl std::future::Future<Output = T>,
398) -> T {
399    let mut timings =
400        coven_foundation::stage_timing::StageTimings::counting("Device join owner step", requests);
401    let outcome = timings.stage(step, work).await;
402    timings.report();
403    outcome
404}
405
406/// One wait on the counterpart, reported when it ends.
407///
408/// A join that took four minutes is either waiting on the other device or
409/// fetching, and until these lines existed the logs could not say which. The
410/// poll count separates a wait that sat through the owner's next sync cycle
411/// from one that answered immediately.
412struct JoinWait {
413    kind: DeviceJoinTransportKind,
414    started: coven_foundation::clock::Stopwatch,
415    polls: std::sync::atomic::AtomicU64,
416}
417
418impl JoinWait {
419    fn begin(kind: DeviceJoinTransportKind) -> Self {
420        Self {
421            kind,
422            started: coven_foundation::clock::Stopwatch::start(),
423            polls: std::sync::atomic::AtomicU64::new(0),
424        }
425    }
426
427    fn polled(&self) {
428        self.polls
429            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
430    }
431
432    fn report(self) {
433        tracing::info!(
434            kind = ?self.kind,
435            produced_by = ?self.kind.producer(),
436            waited_ms = self.started.elapsed().as_millis() as u64,
437            looks = self.polls.load(std::sync::atomic::Ordering::Relaxed),
438            "Device join waited for its counterpart"
439        );
440    }
441}
442
443/// Why a transfer through the transport failed.
444#[derive(Debug, thiserror::Error)]
445pub enum DeviceJoinTransportError {
446    #[error("storage: {0}")]
447    Storage(#[from] StorageError),
448    #[error("device join: {0}")]
449    DeviceJoin(#[from] DeviceJoinError),
450    #[error("transport artifact is not valid JSON: {0}")]
451    Malformed(#[from] serde_json::Error),
452    #[error("transport artifact could not be unsealed: {0}")]
453    Unsealable(#[from] SealError),
454    #[error("the offer bundle does not describe this attempt's transport")]
455    BundleMismatch,
456    #[error("this attempt's transport has no {kind:?} slot")]
457    MissingSlot { kind: DeviceJoinTransportKind },
458    /// The action carries no transferable artifact — the offer travels out of
459    /// band, and `CompleteJoin`/`CompleteCleanup`/`ResumeOperation` name local
460    /// work rather than a transfer.
461    #[error("{0:?} carries nothing for the transport to deliver")]
462    NotTransferable(Box<DeviceJoinAction>),
463    /// Only one role produces each kind, and this device does not hold it.
464    #[error("a {kind:?} artifact is the {role:?}'s to publish, not this device's")]
465    WrongProducer {
466        kind: DeviceJoinTransportKind,
467        role: DeviceJoinRole,
468    },
469    /// The slot already holds a different artifact of this kind. Republishing
470    /// the same artifact after a crash succeeds; a different one never
471    /// overwrites what a counterpart may already have read.
472    #[error("the {kind:?} slot already holds a different artifact")]
473    ArtifactConflict { kind: DeviceJoinTransportKind },
474    /// The slot's stored bytes are not the ones this write produced — a
475    /// concurrent writer reached it first.
476    #[error("the {kind:?} slot was written concurrently with different bytes")]
477    SlotConflict { kind: DeviceJoinTransportKind },
478    /// The unsealed bytes decode as a different kind than the slot they sat in.
479    #[error("the {kind:?} slot holds an artifact of another kind")]
480    KindMismatch { kind: DeviceJoinTransportKind },
481    #[error("the {producer:?} never published its {kind:?} artifact")]
482    Timeout {
483        kind: DeviceJoinTransportKind,
484        producer: DeviceJoinRole,
485    },
486}
487
488/// One attempt's slot namespace, bound to the side of the exchange this device
489/// is on.
490pub struct DeviceJoinTransport<'a> {
491    storage: &'a dyn CloudSyncObjectStorage,
492    params: &'a DeviceJoinTransportParams,
493    store_root_hash: ObjectHash,
494    seal: EncryptionService,
495    role: DeviceJoinRole,
496}
497
498impl<'a> DeviceJoinTransport<'a> {
499    /// Open the transport described by `bundle` against `storage`, for the role
500    /// this device plays. It may publish only the kinds that role produces; it
501    /// may read every kind.
502    pub fn open(
503        storage: &'a dyn CloudSyncObjectStorage,
504        bundle: &'a DeviceJoinOfferBundle,
505        role: DeviceJoinRole,
506    ) -> Result<Self, DeviceJoinTransportError> {
507        bundle.transport.validate_for(&bundle.offer)?;
508        Ok(Self {
509            storage,
510            params: &bundle.transport,
511            store_root_hash: bundle.offer.store_root.store_root_hash,
512            seal: EncryptionService::from(bundle.transport.seal_key.clone()),
513            role,
514        })
515    }
516
517    /// Seal an artifact and create it at its slot.
518    ///
519    /// Republishing an artifact already at its slot succeeds — that is what a
520    /// crash between the durable journal advance and the create resumes into.
521    /// The seal draws a fresh nonce per call, so sameness is decided on the
522    /// artifact, not on the stored ciphertext; the first write's bytes stay.
523    /// A *different* artifact at an occupied slot is refused: a counterpart may
524    /// already have read what is there.
525    pub async fn publish(&self, action: &DeviceJoinAction) -> Result<(), DeviceJoinTransportError> {
526        let kind = DeviceJoinTransportKind::of(action)
527            .ok_or_else(|| DeviceJoinTransportError::NotTransferable(Box::new(action.clone())))?;
528        let producer = kind.producer();
529        if self.role != producer {
530            return Err(DeviceJoinTransportError::WrongProducer {
531                kind,
532                role: producer,
533            });
534        }
535        let sealed = self
536            .seal
537            .seal_app_data(&serde_json::to_vec(action)?, &self.seal_aad(kind));
538        let prepared = self.storage.prepare_protocol_object(
539            &slot_context(self.store_root_hash),
540            self.params.slot(kind)?.clone(),
541            &self.semantic_prefix(kind),
542            sealed,
543        )?;
544        match self.storage.create_protocol_object(&prepared).await {
545            Ok(()) => Ok(()),
546            Err(StorageError::SlotCollision(_)) => match self.read(kind).await? {
547                Some(existing) if existing == *action => Ok(()),
548                Some(_) => Err(DeviceJoinTransportError::ArtifactConflict { kind }),
549                None => Err(DeviceJoinTransportError::SlotConflict { kind }),
550            },
551            Err(error) => Err(error.into()),
552        }
553    }
554
555    /// Read one kind's artifact, or `None` while its slot is still empty.
556    pub async fn read(
557        &self,
558        kind: DeviceJoinTransportKind,
559    ) -> Result<Option<DeviceJoinAction>, DeviceJoinTransportError> {
560        let sealed = match self
561            .storage
562            .read_protocol_slot(
563                &slot_context(self.store_root_hash),
564                self.params.slot(kind)?,
565                &self.semantic_prefix(kind),
566            )
567            .await
568        {
569            Ok((sealed, _)) => sealed,
570            Err(StorageError::NotFound(_)) => return Ok(None),
571            Err(error) => return Err(error.into()),
572        };
573        let opened = self.seal.open_app_data(&sealed, &self.seal_aad(kind))?;
574        let action: DeviceJoinAction = serde_json::from_slice(&opened)?;
575        if DeviceJoinTransportKind::of(&action) != Some(kind) {
576            return Err(DeviceJoinTransportError::KindMismatch { kind });
577        }
578        Ok(Some(action))
579    }
580
581    /// Poll for the counterpart's artifact of type `T` until the deadline. The
582    /// timeout names the role that never published, so a host can tell the user
583    /// which device it is waiting on.
584    pub async fn await_artifact<T: DeviceJoinArtifact>(
585        &self,
586        timing: DeviceJoinTransportTiming,
587    ) -> Result<T, DeviceJoinTransportError> {
588        let kind = T::KIND;
589        let wait = JoinWait::begin(kind);
590        let polled = tokio::time::timeout(timing.deadline, async {
591            let mut poll = timing.polls();
592            loop {
593                wait.polled();
594                if let Some(action) = self.read(kind).await? {
595                    return T::from_action(action)
596                        .ok_or(DeviceJoinTransportError::KindMismatch { kind });
597                }
598                tokio::time::sleep(poll.next()).await;
599            }
600        })
601        .await;
602        wait.report();
603        match polled {
604            Ok(artifact) => artifact,
605            Err(_) => Err(DeviceJoinTransportError::Timeout {
606                kind,
607                producer: kind.producer(),
608            }),
609        }
610    }
611
612    /// Observe one artifact without imposing a phase deadline. A concurrent
613    /// operation owns the deadline; this observation exists to interrupt that
614    /// operation when a terminal artifact appears.
615    ///
616    /// This is the longest-running wait in a join and the one least likely to
617    /// find anything: it watches for the owner cancelling, for the whole join,
618    /// alongside the snapshot download and the install. At the asked-for
619    /// cadence that is a provider read every hundred milliseconds for minutes
620    /// to answer "not yet" — which is exactly what the poll backoff was
621    /// introduced to stop for the phase waits, and this one was left behind
622    /// because it takes a bare interval rather than a timing. It takes the
623    /// timing now and backs off like the others: the first look is immediate,
624    /// so a cancellation still interrupts promptly, and the cadence settles at
625    /// the same ceiling instead of running flat out under a several-second
626    /// download.
627    pub async fn observe_artifact<T: DeviceJoinArtifact>(
628        &self,
629        timing: DeviceJoinTransportTiming,
630    ) -> Result<T, DeviceJoinTransportError> {
631        let kind = T::KIND;
632        let mut poll = timing.polls();
633        loop {
634            if let Some(action) = self.read(kind).await? {
635                return T::from_action(action)
636                    .ok_or(DeviceJoinTransportError::KindMismatch { kind });
637            }
638            tokio::time::sleep(poll.next()).await;
639        }
640    }
641
642    /// Poll for the next artifact of type `T`, or for the owner's abandonment
643    /// of the whole attempt, whichever appears first.
644    ///
645    /// The owner may give up on an attempt while the joining device is waiting
646    /// for the next step, so every joiner wait watches both slots. A wait that
647    /// watched only its own kind would sit until its deadline against an
648    /// abandonment already published.
649    pub async fn await_step<T: DeviceJoinArtifact>(
650        &self,
651        timing: DeviceJoinTransportTiming,
652    ) -> Result<DeviceJoinStep<T>, DeviceJoinTransportError> {
653        let kind = T::KIND;
654        let wait = JoinWait::begin(kind);
655        let polled = tokio::time::timeout(timing.deadline, async {
656            let mut poll = timing.polls();
657            loop {
658                wait.polled();
659                if let Some(action) = self.read(DeviceJoinTransportKind::Abandonment).await? {
660                    return DeviceJoinAbandonment::from_action(action)
661                        .map(DeviceJoinStep::Abandoned)
662                        .ok_or(DeviceJoinTransportError::KindMismatch {
663                            kind: DeviceJoinTransportKind::Abandonment,
664                        });
665                }
666                if let Some(action) = self.read(kind).await? {
667                    return T::from_action(action)
668                        .map(DeviceJoinStep::Continue)
669                        .ok_or(DeviceJoinTransportError::KindMismatch { kind });
670                }
671                tokio::time::sleep(poll.next()).await;
672            }
673        })
674        .await;
675        wait.report();
676        match polled {
677            Ok(step) => step,
678            Err(_) => Err(DeviceJoinTransportError::Timeout {
679                kind,
680                producer: kind.producer(),
681            }),
682        }
683    }
684
685    /// Remove everything under this attempt's namespace.
686    ///
687    /// Called once the exchange has reached an end the joining device has
688    /// consumed — its completed join or its accepted abandonment. The joining
689    /// device is the last reader on both, which is why the deletion is its to
690    /// make: the admitting device has no artifact by which it could learn that
691    /// the joiner read the last thing it published. There is no sweep behind
692    /// this.
693    ///
694    /// The namespace is listed rather than probed kind by kind. Probing asks
695    /// for every name this build knows and so leaves behind anything written
696    /// under a name it does not — an artifact from a different version, or from
697    /// anyone else who can write to the provider. A listing names what is
698    /// actually there, which is what "remove the namespace" has to mean.
699    ///
700    /// Each object is still deleted by the exact reference its own stored bytes
701    /// produce, so a delete cannot race a concurrent write: the reference
702    /// carries the size and hash observed, and the delete refuses if what sits
703    /// there no longer matches. Nothing is opened — this is removing a
704    /// namespace, not reading it, and an object this device cannot decrypt is
705    /// exactly as much garbage as one it can.
706    pub async fn delete_attempt_slots(&self) -> Result<(), DeviceJoinTransportError> {
707        let context = slot_context(self.store_root_hash);
708        let listed = self
709            .storage
710            .list_protocol_slots(&context, &format!("{}/", self.params.attempt_namespace))
711            .await?;
712        let deletions = futures_util::future::join_all(listed.iter().map(|slot| async move {
713            let Some(object) = self.storage.observe_exact_slot(slot).await? else {
714                return Ok(());
715            };
716            self.storage
717                .delete_protocol_object(&object)
718                .await
719                .map_err(DeviceJoinTransportError::from)
720        }))
721        .await;
722        for result in deletions {
723            result?;
724        }
725        Ok(())
726    }
727
728    fn semantic_prefix(&self, kind: DeviceJoinTransportKind) -> String {
729        semantic_prefix(&self.params.attempt_namespace, kind)
730    }
731
732    /// Bind a sealed artifact to its store, its attempt, and its kind, so bytes
733    /// lifted from one slot cannot be opened as another.
734    fn seal_aad(&self, kind: DeviceJoinTransportKind) -> Vec<u8> {
735        let prefix = self.semantic_prefix(kind);
736        let mut aad = SEAL_AAD_LABEL.to_vec();
737        aad.extend_from_slice(self.store_root_hash.as_bytes());
738        aad.extend_from_slice(&(prefix.len() as u64).to_le_bytes());
739        aad.extend_from_slice(prefix.as_bytes());
740        aad
741    }
742}
743
744pub(crate) fn attempt_namespace(attempt_id: DeviceJoinAttemptId) -> String {
745    format!("{TRANSPORT_ROOT}/{attempt_id}")
746}
747
748pub(crate) fn semantic_prefix(attempt_namespace: &str, kind: DeviceJoinTransportKind) -> String {
749    format!("{attempt_namespace}/{}", kind.slug())
750}
751
752pub(crate) fn slot_context(store_root_hash: ObjectHash) -> ProtocolObjectContext {
753    ProtocolObjectContext::recipient_sealed(
754        store_root_hash,
755        ProtocolObjectDomain::DeviceJoinTransport,
756    )
757}
758
759/// Whether the driver approves an access request, and on whose say-so.
760pub enum DeviceJoinApprovalPolicy<'a> {
761    /// Approve requests against an attempt this device itself issued: its own
762    /// owner journal holds the attempt, and the request carries the offer this
763    /// bundle names. Anything else is refused. The host opts into this; it is
764    /// never the implicit behavior.
765    AutoApproveSelfIssued,
766    /// Ask the host, which prompts whoever is at the device.
767    Ask(&'a (dyn Fn(&DeviceProviderAccessRequest) -> DeviceJoinApproval + Send + Sync)),
768}
769
770#[derive(Clone, Copy, Debug, PartialEq, Eq)]
771pub enum DeviceJoinApproval {
772    Approve,
773    Refuse,
774}
775
776pub struct StoreDeviceJoinTransport<'store> {
777    store: &'store Store,
778}
779
780impl<'store> StoreDeviceJoinTransport<'store> {
781    pub(crate) fn new(store: &'store Store) -> Self {
782        Self { store }
783    }
784
785    pub async fn allocate_bundle(
786        &self,
787        offer: DeviceJoinOffer,
788    ) -> Result<DeviceJoinOfferBundle, DeviceJoinTransportError> {
789        self.store
790            .allocate_device_join_transport_bundle(offer)
791            .await
792    }
793
794    pub async fn drive(
795        &self,
796        bundle: &DeviceJoinOfferBundle,
797        policy: DeviceJoinApprovalPolicy<'_>,
798        access_administrator: Option<&dyn DeviceProviderAccessAdministrator>,
799        on_progress: &(dyn Fn(AdmittingDeviceJoinProgress) + Send + Sync),
800        timing: DeviceJoinTransportTiming,
801    ) -> Result<DeviceJoinDriveOutcome, DeviceJoinTransportError> {
802        retrying_activation_conflicts(|| async {
803            AttemptTransport::open(self.store, bundle)
804                .await?
805                .drive_once(&policy, access_administrator, on_progress, timing)
806                .await
807        })
808        .await
809    }
810
811    pub async fn abandon(
812        &self,
813        bundle: &DeviceJoinOfferBundle,
814    ) -> Result<DeviceJoinAbandonment, DeviceJoinTransportError> {
815        let attempt = AttemptTransport::open(self.store, bundle).await?;
816        let abandonment = self.store.abandon_device_join(bundle.offer.clone()).await?;
817        attempt.finish_abandonment(&abandonment).await?;
818        Ok(abandonment)
819    }
820
821    /// Give up on an attempt this device offered.
822    ///
823    /// Only an attempt that has not reached its Store commit can be given up on:
824    /// up to that point nothing is published about the joining device, so an
825    /// abandonment is the whole story. Past it the device has been approved and
826    /// holds storage access, and taking that back is member removal with a key
827    /// rotation — not something a pairing window can do.
828    pub async fn abort(
829        &self,
830        bundle: &DeviceJoinOfferBundle,
831    ) -> Result<(), DeviceJoinTransportError> {
832        let attempt = AttemptTransport::open(self.store, bundle).await?;
833        match attempt.owner_status().await? {
834            // No row means the attempt already finished and its terminal step
835            // deleted it. There is nothing left to give up on, and minting a
836            // second abandonment for a finished attempt would only republish
837            // what was already delivered.
838            None => Ok(()),
839            Some(
840                DeviceJoinStatus::AwaitingAccessRequest { .. }
841                | DeviceJoinStatus::AwaitingProviderAdmission { .. }
842                | DeviceJoinStatus::ProviderAccessGrantPublished { .. }
843                | DeviceJoinStatus::AwaitingRegistrationRequest { .. }
844                | DeviceJoinStatus::AwaitingBootstrap { .. }
845                | DeviceJoinStatus::Abandoned { .. }
846                | DeviceJoinStatus::StorePublicationPending {
847                    operation: OwnerJoinPublication::Abandonment { .. },
848                },
849            ) => {
850                self.abandon(bundle).await?;
851                Ok(())
852            }
853            status => Err(DeviceJoinError::Store(format!(
854                "device join {} is past the point it could be given up on: {status:?}",
855                bundle.offer.attempt_id
856            ))
857            .into()),
858        }
859    }
860}
861
862/// One attempt in flight: the bundle naming its transport slots and the attempt
863/// every status read addresses. Every step of a drive shares both.
864struct AttemptTransport<'attempt> {
865    store: &'attempt Store,
866    bundle: &'attempt DeviceJoinOfferBundle,
867    attempt_id: DeviceJoinAttemptId,
868}
869
870impl<'attempt> AttemptTransport<'attempt> {
871    async fn open(
872        store: &'attempt Store,
873        bundle: &'attempt DeviceJoinOfferBundle,
874    ) -> Result<Self, DeviceJoinTransportError> {
875        store.require_device_join_admitter(&bundle.offer).await?;
876        Ok(Self {
877            store,
878            bundle,
879            attempt_id: bundle.offer.attempt_id,
880        })
881    }
882
883    /// Put an artifact at its transport slot. An artifact already at its slot is
884    /// the same transfer, so a step that produced its artifact and died before
885    /// publishing it republishes here for nothing.
886    async fn publish(&self, action: DeviceJoinAction) -> Result<(), DeviceJoinTransportError> {
887        self.step(
888            "publish artifact",
889            self.store
890                .publish_device_join_transport_artifact(self.bundle, &action),
891        )
892        .await
893    }
894
895    /// Put the abandonment at its slot and drop the row that anchored getting
896    /// it there.
897    ///
898    /// These are one step. Until the artifact is published the row is what a
899    /// resumed drive reads to know it still owes it; once published there is
900    /// nothing further to say about the attempt, and a row left behind would
901    /// keep offering the same transfer on every pass forever.
902    async fn finish_abandonment(
903        &self,
904        abandonment: &DeviceJoinAbandonment,
905    ) -> Result<(), DeviceJoinTransportError> {
906        self.publish(DeviceJoinAction::TransferAbandonment(abandonment.clone()))
907            .await?;
908        self.store
909            .retire_device_join_row(self.attempt_id, DeviceJoinRole::Owner)
910            .await
911    }
912
913    /// Time one step of the driven exchange under the shared owner-step line.
914    ///
915    /// Construct the boxed work before polling it. An async wrapper would keep
916    /// its construction storage on the stack while the join's nested work runs.
917    #[inline(never)]
918    fn step<T>(
919        &self,
920        step: &'static str,
921        work: impl std::future::Future<Output = T>,
922    ) -> impl std::future::Future<Output = T> {
923        timed_owner_join_step(step, self.store.provider_requests(), Box::pin(work))
924    }
925
926    /// Read the artifact the other side owes this step, waiting for it to appear.
927    async fn await_artifact<T: DeviceJoinArtifact>(
928        &self,
929        timing: DeviceJoinTransportTiming,
930    ) -> Result<T, DeviceJoinTransportError> {
931        self.store
932            .await_device_join_transport_artifact::<T>(self.bundle, timing)
933            .await
934    }
935
936    async fn owner_status(&self) -> Result<Option<DeviceJoinStatus>, DeviceJoinTransportError> {
937        self.store
938            .device_join_transport_status(self.attempt_id, DeviceJoinRole::Owner)
939            .await
940    }
941
942    /// Carry the admitting side of one attempt as far as it will go.
943    ///
944    /// One device admits, so there is one journal and one status to read: every
945    /// pass takes the durable state and performs the step that follows it. A
946    /// step that produced its artifact and died before publishing republishes
947    /// here for nothing, and a step already past does nothing.
948    async fn drive_once(
949        &self,
950        policy: &DeviceJoinApprovalPolicy<'_>,
951        access_administrator: Option<&dyn DeviceProviderAccessAdministrator>,
952        on_progress: &(dyn Fn(AdmittingDeviceJoinProgress) + Send + Sync),
953        timing: DeviceJoinTransportTiming,
954    ) -> Result<DeviceJoinDriveOutcome, DeviceJoinTransportError> {
955        loop {
956            match self.owner_status().await? {
957                // A row still at Abandoned is one whose terminal step did not
958                // finish. Delivering the artifact is what a driver started
959                // after the abandonment owes a joining device that has not seen
960                // it yet, and retiring the row behind it is the rest of that
961                // same step.
962                Some(DeviceJoinStatus::Abandoned { abandonment }) => {
963                    self.finish_abandonment(&abandonment).await?;
964                    return Ok(DeviceJoinDriveOutcome::Abandoned(abandonment));
965                }
966                Some(DeviceJoinStatus::SamePrincipalCompleted { join }) => {
967                    self.publish(DeviceJoinAction::TransferSamePrincipalJoin(join.clone()))
968                        .await?;
969                    return Ok(DeviceJoinDriveOutcome::Activated(join.activation));
970                }
971                Some(DeviceJoinStatus::AwaitingCompletion { activation }) => {
972                    self.publish(DeviceJoinAction::TransferActivation(activation.clone()))
973                        .await?;
974                    return Ok(DeviceJoinDriveOutcome::Activated(activation));
975                }
976                None | Some(DeviceJoinStatus::AwaitingAccessRequest { .. }) => {
977                    on_progress(AdmittingDeviceJoinProgress::WaitingForProviderAccessRequest);
978                    let request = self
979                        .await_artifact::<DeviceProviderAccessRequest>(timing)
980                        .await?;
981                    self.step(
982                        "approve access request",
983                        self.approve_access_request(&request, policy),
984                    )
985                    .await?;
986                    if request.offer.provider_admin.provider == request.peer_provider {
987                        on_progress(AdmittingDeviceJoinProgress::RegisteringDevice);
988                        let join = self
989                            .step(
990                                "activate same-provider device",
991                                self.activate_same_principal(request, access_administrator),
992                            )
993                            .await?;
994                        self.publish(DeviceJoinAction::TransferSamePrincipalJoin(join.clone()))
995                            .await?;
996                        return Ok(DeviceJoinDriveOutcome::Activated(join.activation));
997                    }
998                    on_progress(AdmittingDeviceJoinProgress::GrantingProviderAccess);
999                    let approval = self
1000                        .step(
1001                            "authorize provider access",
1002                            self.store
1003                                .authorize_device_provider_access(request, access_administrator),
1004                        )
1005                        .await?;
1006                    self.publish(DeviceJoinAction::TransferProviderAdmissionApproval(
1007                        approval,
1008                    ))
1009                    .await?;
1010                }
1011                Some(
1012                    DeviceJoinStatus::AwaitingProviderAdmission { request }
1013                    | DeviceJoinStatus::ProviderAccessGrantPublished { request, .. },
1014                ) => {
1015                    on_progress(AdmittingDeviceJoinProgress::GrantingProviderAccess);
1016                    let approval = self
1017                        .step(
1018                            "authorize provider access",
1019                            self.store
1020                                .authorize_device_provider_access(request, access_administrator),
1021                        )
1022                        .await?;
1023                    self.publish(DeviceJoinAction::TransferProviderAdmissionApproval(
1024                        approval,
1025                    ))
1026                    .await?;
1027                }
1028                Some(DeviceJoinStatus::AwaitingRegistrationRequest { approval }) => {
1029                    self.publish(DeviceJoinAction::TransferProviderAdmissionApproval(
1030                        approval.clone(),
1031                    ))
1032                    .await?;
1033                    if matches!(approval.admission, DeviceProviderAdmission::SamePrincipal) {
1034                        let request = DeviceRegistrationRequest::same_principal(approval)
1035                            .map_err(DeviceJoinError::from)?;
1036                        on_progress(AdmittingDeviceJoinProgress::RegisteringDevice);
1037                        let join = self
1038                            .step(
1039                                "activate same-provider device",
1040                                self.store.resume_same_principal_device_join(request),
1041                            )
1042                            .await?;
1043                        self.publish(DeviceJoinAction::TransferSamePrincipalJoin(join.clone()))
1044                            .await?;
1045                        return Ok(DeviceJoinDriveOutcome::Activated(join.activation));
1046                    }
1047                    on_progress(AdmittingDeviceJoinProgress::WaitingForRegistrationRequest);
1048                    let request = self
1049                        .await_artifact::<DeviceRegistrationRequest>(timing)
1050                        .await?;
1051                    on_progress(AdmittingDeviceJoinProgress::RegisteringDevice);
1052                    self.accept_registration(request).await?;
1053                }
1054                Some(DeviceJoinStatus::AwaitingBootstrap { request }) => {
1055                    on_progress(AdmittingDeviceJoinProgress::RegisteringDevice);
1056                    if matches!(request, DeviceRegistrationRequest::SamePrincipal { .. }) {
1057                        let join = self
1058                            .step(
1059                                "activate same-provider device",
1060                                self.store.resume_same_principal_device_join(request),
1061                            )
1062                            .await?;
1063                        self.publish(DeviceJoinAction::TransferSamePrincipalJoin(join.clone()))
1064                            .await?;
1065                        return Ok(DeviceJoinDriveOutcome::Activated(join.activation));
1066                    }
1067                    self.accept_registration(request).await?;
1068                }
1069                Some(DeviceJoinStatus::SamePrincipalActivationPublished { request }) => {
1070                    on_progress(AdmittingDeviceJoinProgress::RegisteringDevice);
1071                    let join = self
1072                        .step(
1073                            "activate same-provider device",
1074                            self.store.resume_same_principal_device_join(request),
1075                        )
1076                        .await?;
1077                    self.publish(DeviceJoinAction::TransferSamePrincipalJoin(join.clone()))
1078                        .await?;
1079                    return Ok(DeviceJoinDriveOutcome::Activated(join.activation));
1080                }
1081                Some(DeviceJoinStatus::StorePublicationPending {
1082                    operation: OwnerJoinPublication::ProviderAccessGrant { request, .. },
1083                }) => {
1084                    on_progress(AdmittingDeviceJoinProgress::GrantingProviderAccess);
1085                    let approval = self
1086                        .step(
1087                            "authorize provider access",
1088                            self.store
1089                                .authorize_device_provider_access(request, access_administrator),
1090                        )
1091                        .await?;
1092                    self.publish(DeviceJoinAction::TransferProviderAdmissionApproval(
1093                        approval,
1094                    ))
1095                    .await?;
1096                }
1097                Some(DeviceJoinStatus::StorePublicationPending {
1098                    operation:
1099                        OwnerJoinPublication::Attempt { request }
1100                        | OwnerJoinPublication::SamePrincipalActivation { request },
1101                }) => {
1102                    on_progress(AdmittingDeviceJoinProgress::RegisteringDevice);
1103                    if matches!(request, DeviceRegistrationRequest::SamePrincipal { .. }) {
1104                        let join = self
1105                            .step(
1106                                "activate same-provider device",
1107                                self.store.resume_same_principal_device_join(request),
1108                            )
1109                            .await?;
1110                        self.publish(DeviceJoinAction::TransferSamePrincipalJoin(join.clone()))
1111                            .await?;
1112                        return Ok(DeviceJoinDriveOutcome::Activated(join.activation));
1113                    }
1114                    self.accept_registration(request).await?;
1115                }
1116                Some(DeviceJoinStatus::StorePublicationPending {
1117                    operation: OwnerJoinPublication::JoinActivation { completion },
1118                }) => {
1119                    on_progress(AdmittingDeviceJoinProgress::ActivatingDevice);
1120                    let activation = self
1121                        .step(
1122                            "publish activation",
1123                            self.store.finalize_device_join(completion),
1124                        )
1125                        .await?;
1126                    self.publish(DeviceJoinAction::TransferActivation(activation.clone()))
1127                        .await?;
1128                    return Ok(DeviceJoinDriveOutcome::Activated(activation));
1129                }
1130                Some(DeviceJoinStatus::StorePublicationPending {
1131                    operation: OwnerJoinPublication::Abandonment { .. },
1132                }) => {
1133                    return Err(DeviceJoinError::Store(format!(
1134                        "device join {} is being abandoned",
1135                        self.attempt_id
1136                    ))
1137                    .into());
1138                }
1139                Some(DeviceJoinStatus::AwaitingChallengePublication { bootstrap }) => {
1140                    on_progress(AdmittingDeviceJoinProgress::PreparingLibrary);
1141                    let ready = self
1142                        .step(
1143                            "publish provider challenge",
1144                            self.store.publish_device_provider_challenge(bootstrap),
1145                        )
1146                        .await?;
1147                    self.publish(DeviceJoinAction::TransferProviderReadyBootstrap(ready))
1148                        .await?;
1149                }
1150                Some(DeviceJoinStatus::AwaitingReadiness { bootstrap }) => {
1151                    self.publish(DeviceJoinAction::TransferProviderReadyBootstrap(
1152                        bootstrap.clone(),
1153                    ))
1154                    .await?;
1155                    if matches!(
1156                        bootstrap.challenge_publication,
1157                        DeviceProviderChallengePublication::SamePrincipal
1158                    ) {
1159                        self.step(
1160                            "complete same-provider admission",
1161                            self.store
1162                                .complete_same_principal_device_admission(bootstrap),
1163                        )
1164                        .await?;
1165                        continue;
1166                    }
1167                    on_progress(AdmittingDeviceJoinProgress::WaitingForJoiningDevice);
1168                    let readiness = self.await_artifact::<DeviceJoinReadiness>(timing).await?;
1169                    on_progress(AdmittingDeviceJoinProgress::ActivatingDevice);
1170                    self.step(
1171                        "complete provider admission",
1172                        self.store.complete_device_provider_admission(readiness),
1173                    )
1174                    .await?;
1175                }
1176                Some(DeviceJoinStatus::AwaitingProviderCompletion { readiness }) => {
1177                    on_progress(AdmittingDeviceJoinProgress::ActivatingDevice);
1178                    self.step(
1179                        "complete provider admission",
1180                        self.store.complete_device_provider_admission(readiness),
1181                    )
1182                    .await?;
1183                }
1184                Some(DeviceJoinStatus::AwaitingActivation { completion }) => {
1185                    on_progress(AdmittingDeviceJoinProgress::ActivatingDevice);
1186                    let activation = self
1187                        .step(
1188                            "publish activation",
1189                            self.store.finalize_device_join(completion),
1190                        )
1191                        .await?;
1192                    self.publish(DeviceJoinAction::TransferActivation(activation.clone()))
1193                        .await?;
1194                    return Ok(DeviceJoinDriveOutcome::Activated(activation));
1195                }
1196            }
1197        }
1198    }
1199
1200    async fn accept_registration(
1201        &self,
1202        request: DeviceRegistrationRequest,
1203    ) -> Result<(), DeviceJoinTransportError> {
1204        self.step(
1205            "accept registration",
1206            self.store.accept_device_registration_request(request),
1207        )
1208        .await?;
1209        Ok(())
1210    }
1211
1212    /// Admit a device that uses this Store's provider account through one
1213    /// authorized writer. Each protocol transition is still journaled before
1214    /// the next begins, so a failure resumes through `drive_once`; keeping the
1215    /// writer open avoids reconstructing and re-verifying the same Store
1216    /// authority between consecutive transitions.
1217    async fn activate_same_principal(
1218        &self,
1219        request: DeviceProviderAccessRequest,
1220        access_administrator: Option<&dyn DeviceProviderAccessAdministrator>,
1221    ) -> Result<SamePrincipalDeviceJoin, DeviceJoinTransportError> {
1222        // Sixty-eight seconds hid behind this one step in a live run. It is
1223        // three provider-facing pieces, and they report as three.
1224        let mut timings = coven_foundation::stage_timing::StageTimings::counting(
1225            "Device join same-provider activation",
1226            self.store.provider_requests(),
1227        );
1228        let outcome = async {
1229            let mut writer = timings
1230                .stage("authorize writer", self.store.authorize_writer())
1231                .await
1232                .map_err(DeviceJoinError::from)?;
1233            let approval = timings
1234                .stage(
1235                    "authorize provider access",
1236                    writer
1237                        .join_operation()
1238                        .authorize_access(request, access_administrator),
1239                )
1240                .await?;
1241            let registration = DeviceRegistrationRequest::same_principal(approval)
1242                .map_err(DeviceJoinError::from)?;
1243            timings
1244                .stage(
1245                    "activate the join",
1246                    writer
1247                        .join_operation()
1248                        .activate_same_principal_join(registration),
1249                )
1250                .await
1251                .map_err(DeviceJoinTransportError::from)
1252        }
1253        .await;
1254        timings.report();
1255        outcome
1256    }
1257
1258    async fn approve_access_request(
1259        &self,
1260        request: &DeviceProviderAccessRequest,
1261        policy: &DeviceJoinApprovalPolicy<'_>,
1262    ) -> Result<(), DeviceJoinTransportError> {
1263        let offer = &self.bundle.offer;
1264        let approval = match policy {
1265            DeviceJoinApprovalPolicy::AutoApproveSelfIssued => {
1266                if self.self_issued().await? && request.offer.as_ref() == offer {
1267                    DeviceJoinApproval::Approve
1268                } else {
1269                    DeviceJoinApproval::Refuse
1270                }
1271            }
1272            DeviceJoinApprovalPolicy::Ask(ask) => ask(request),
1273        };
1274        match approval {
1275            DeviceJoinApproval::Approve => Ok(()),
1276            DeviceJoinApproval::Refuse => Err(DeviceJoinError::OfferMismatch.into()),
1277        }
1278    }
1279
1280    /// Whether this device issued the offer being admitted — the bound
1281    /// `AutoApproveSelfIssued` keeps to.
1282    ///
1283    /// Two facts decide it, both authoritative: this device is the offer's owner,
1284    /// and its own owner journal holds a record for this attempt. That record
1285    /// exists only because this device ran `begin_device_join` for it. A provider
1286    /// administrator that is a *different* device never satisfies this, so it
1287    /// prompts rather than admitting an offer it did not make.
1288    async fn self_issued(&self) -> Result<bool, DeviceJoinTransportError> {
1289        Ok(self.owner_status().await?.is_some())
1290    }
1291}
1292
1293/// How many times a driver re-derives after losing an activation slot, and how
1294/// long it waits before each retry.
1295///
1296/// A device holding the join also runs its sync loop, so the two publish Store
1297/// operations against the same positions. Losing that race persists nothing, so
1298/// the answer is to re-derive and go again — but only so many times: a store
1299/// that keeps refusing is not a race, and has to surface.
1300const ACTIVATION_CONFLICT_RETRIES: usize = 8;
1301const ACTIVATION_CONFLICT_BACKOFF: Duration = Duration::from_millis(25);
1302
1303/// Whether this failure is another writer having taken the activation slot
1304/// first — which persisted nothing, so the operation can simply be re-derived.
1305fn is_activation_conflict(error: &DeviceJoinTransportError) -> bool {
1306    matches!(
1307        error,
1308        DeviceJoinTransportError::DeviceJoin(DeviceJoinError::Outbound(
1309            crate::sync::store::StoreError::ActivationConflict
1310        ))
1311    )
1312}
1313
1314/// Run a driver pass, re-entering it when it loses an activation slot.
1315///
1316/// Every pass starts from the role journals, so a re-entry resumes rather than
1317/// repeating: the phases already settled are skipped and the one that lost the
1318/// race is re-derived against whatever the winner just committed. The backoff
1319/// grows so a busy store is not hammered, and the last failure propagates
1320/// unchanged once the budget is spent — this retries a lost race, it does not
1321/// paper over a wedged store.
1322async fn retrying_activation_conflicts<Pass, Fut, T>(
1323    mut pass: Pass,
1324) -> Result<T, DeviceJoinTransportError>
1325where
1326    Pass: FnMut() -> Fut,
1327    Fut: std::future::Future<Output = Result<T, DeviceJoinTransportError>>,
1328{
1329    // Each pass is boxed: a driver pass composes many large generators, and
1330    // holding one inline here would add its whole frame to this loop's own.
1331    for attempt in 0..ACTIVATION_CONFLICT_RETRIES {
1332        match Box::pin(pass()).await {
1333            Err(error) if is_activation_conflict(&error) => {
1334                tokio::time::sleep(ACTIVATION_CONFLICT_BACKOFF * (attempt as u32 + 1)).await;
1335            }
1336            settled => return settled,
1337        }
1338    }
1339    Box::pin(pass()).await
1340}
1341
1342impl From<coven_database::DeviceJoinJournalError> for DeviceJoinTransportError {
1343    fn from(error: coven_database::DeviceJoinJournalError) -> Self {
1344        DeviceJoinTransportError::from(super::DeviceJoinError::from(error))
1345    }
1346}
1347
1348#[cfg(test)]
1349#[path = "transport_tests.rs"]
1350mod tests;