Skip to main content

didbot_serve/
operator_poll.rs

1//! Drives `didbot_pds::OperatorRelation`'s poll against a real operator repository,
2//! and reacts to what it finds. See `plan/handshake.md`.
3//!
4//! `didbot-pds` holds no HTTP client and no async runtime, so
5//! [`didbot_pds::ClaimSource`] is a plain synchronous trait; the fetch that
6//! actually reaches the operator's repository lives here instead, where both
7//! are already dependencies. The bridge is `Prefetched`, a private
8//! `ClaimSource` below: this module does the one HTTP round trip a poll needs
9//! *before* calling
10//! [`didbot_pds::OperatorRelation::poll`], then hands the already-fetched result to
11//! it through a [`didbot_pds::ClaimSource`] that does no I/O of its own. That
12//! keeps the decision logic in `didbot-pds`, testable with no network at all,
13//! while the network reaches this crate exactly once per tick.
14//!
15//! # What each poll actually does
16//!
17//! 1. Resolve the operator's DID document through
18//!    [`didbot_identity::document_url`], which dispatches by method, to find
19//!    its `#atproto_pds` service endpoint.
20//! 2. `com.atproto.repo.getRecord` at that endpoint, for
21//!    `bot.did.operator` at the record key `didbot_pds::OperatorRelation::hostname`
22//!    — a single direct lookup, not a list-and-filter, because that is what
23//!    keying the record by hostname buys.
24//! 3. Hand the result to [`didbot_pds::OperatorRelation::poll`], and act on the
25//!    [`didbot_pds::OperatorTransition`] it reports: throw or release the
26//!    self-triggered pause, and republish the server's own registration
27//!    record once claimed.
28//! 4. Look for the same record once per root account — one the operator
29//!    created from their own repository — keyed by *that* hostname, and
30//!    report to the registry whether it still stands. A record missing for
31//!    longer than the grace window quarantines the account and everything
32//!    beneath it; see [`refresh_root_standing`].
33//!
34//! # What is unverified
35//!
36//! This has never been run against a real operator PDS — there is no such
37//! server reachable from where this was built. The `getRecord` shape, the
38//! DID resolution and the error handling for "no such record" versus "the
39//! server is actually down" are implemented against atproto's documented
40//! XRPC conventions, not exercised against a live implementation. The
41//! `did:plc` path reaches `https://plc.directory` in production and is
42//! exercised here against a loopback stand-in for it.
43
44use std::collections::BTreeMap;
45use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
46use std::sync::{Arc, Mutex};
47use std::time::Duration as StdDuration;
48
49use didbot_http::PrivateAddresses;
50use didbot_identity::{DidDocument, ResolveError};
51use didbot_pds::allowance::Allowances;
52use didbot_pds::{
53    AccountState, ClaimFetchError, ClaimSource, Estop, EstopCause, EstopMode, HostedAccount, Lock,
54    OperatorClaim, OperatorRelation, OperatorTransition, Party, Registry, ServerLifecycle, Tag,
55};
56use serde::Deserialize;
57use time::Duration;
58use time::OffsetDateTime;
59use tokio::sync::{watch, Notify};
60use tokio::task::JoinHandle;
61use tokio::time::MissedTickBehavior;
62use tracing::{info, warn};
63
64use crate::operator_handle::OperatorHandle;
65use crate::policy_poll::PolicyPoll;
66
67/// The collection this poll reads. `didbot-pds` holds no schema for a
68/// collection defined by someone else's repository convention here, so this
69/// is a plain string rather than a `didbot_lexicon::nsid` constant — the
70/// constant lives with the lexicon, in `didbot_lexicon::nsid::OPERATOR`.
71const COLLECTION: &str = didbot_lexicon::nsid::OPERATOR;
72
73/// How often this deployment ships polling for an operator record, absent
74/// configuration. Independent of [`didbot_pds::DEFAULT_GRACE_WINDOW`]: the
75/// interval is how often the network is asked, the grace window is how long
76/// a missed answer is tolerated before it means something. Five minutes
77/// against a six-hour grace window is 72 chances to succeed before a
78/// transient outage could ever matter.
79///
80/// The two compose into the bound an operator actually cares about: a
81/// revocation is *acted on* — the self-triggered pause thrown — no later
82/// than the grace window plus one interval after the record stops being
83/// readable, because the poll that notices can be a full interval away from
84/// the instant the window elapsed. Six hours and five minutes, as this
85/// deployment ships, and
86/// `a_revocation_is_acted_on_within_the_grace_window_plus_one_interval`
87/// below holds the two constants to it.
88///
89/// That is the bound for *this server's own* behaviour. It says nothing
90/// about a third party, who is not polling anything: a reader learns of a
91/// revocation when they next read the operator's record.
92pub const DEFAULT_POLL_INTERVAL: StdDuration = StdDuration::from_secs(5 * 60);
93
94/// A [`ClaimSource`] holding a result already fetched, so
95/// [`didbot_pds::OperatorRelation::poll`] — synchronous by design — can be called
96/// with no I/O of its own. Not `pub`: the whole point is that nothing
97/// outside this module's own poll tick constructs one.
98struct Prefetched(Result<Option<OperatorClaim>, ClaimFetchError>);
99
100impl ClaimSource for Prefetched {
101    fn get_claim(
102        &self,
103        _operator_did: &str,
104        _hostname: &str,
105    ) -> Result<Option<OperatorClaim>, ClaimFetchError> {
106        self.0.clone()
107    }
108}
109
110/// What `com.atproto.repo.getRecord` answers with on success.
111#[derive(Debug, Deserialize)]
112struct GetRecordResponse {
113    value: serde_json::Value,
114}
115
116/// The atproto error name a repository uses for "there is no record at that
117/// key" -- the one 400 this poll reads as absence rather than as a failure.
118const RECORD_NOT_FOUND: &str = "RecordNotFound";
119
120/// Resolves `operator_did`'s `#atproto_pds` service endpoint, then fetches
121/// the `bot.did.operator` record at `hostname` from it.
122///
123/// The document URL comes from [`didbot_identity::document_url`], so every
124/// method that resolver speaks — `did:web` against the hostname the DID
125/// names, `did:plc` against the PLC directory — is a method an operator can
126/// be configured with here, and a method it does not speak is refused by
127/// name rather than by failing to parse as something else.
128///
129/// `Ok(None)` for "reached the operator's PDS and it has no record at that
130/// key" -- a 404, or a 400 whose body names [`RECORD_NOT_FOUND`].
131/// [`ClaimFetchError::Unresolvable`] for a DID with no document URL at all.
132/// [`ClaimFetchError::Unreachable`] for everything this module cannot tell
133/// apart from an outage: connection failure, a document that does not parse
134/// or claims a different DID, a 400 that means something other than absence,
135/// or any other status this module does not recognise.
136#[cfg(test)]
137async fn fetch_claim(
138    client: &reqwest::Client,
139    operator_did: &str,
140    hostname: &str,
141    private: PrivateAddresses,
142) -> Result<Option<OperatorClaim>, ClaimFetchError> {
143    let (endpoint, pinned) = resolve_endpoint(client, operator_did, private).await?;
144    fetch_claim_from(&pinned, &endpoint, operator_did, hostname).await
145}
146
147/// Resolves `operator_did`'s document and reads its `#atproto_pds`
148/// endpoint out of it, on its own so one tick resolves the operator once
149/// and reads every record it needs from the same endpoint.
150///
151/// Answers with the endpoint and the client to read it through: one
152/// pinned to what the endpoint's host resolved to and following no
153/// redirect. Reading through anything else would let a name that answers
154/// differently the second time, or a 3xx from the operator's PDS, land the
155/// request at an address the check never saw.
156pub(crate) async fn resolve_endpoint(
157    client: &reqwest::Client,
158    operator_did: &str,
159    private: PrivateAddresses,
160) -> Result<(String, reqwest::Client), ClaimFetchError> {
161    let (_, endpoint, pinned) = resolve_operator(client, operator_did, private).await?;
162    Ok((endpoint, pinned))
163}
164
165/// [`resolve_endpoint`], with the document it read the endpoint from.
166async fn resolve_operator(
167    client: &reqwest::Client,
168    operator_did: &str,
169    private: PrivateAddresses,
170) -> Result<(DidDocument, String, reqwest::Client), ClaimFetchError> {
171    let document_url =
172        didbot_identity::document_url(operator_did).map_err(|error| match error {
173            ResolveError::UnsupportedMethod { .. } | ResolveError::InvalidDid { .. } => {
174                ClaimFetchError::Unresolvable(format!("operator did {operator_did}: {error}"))
175            }
176            other => ClaimFetchError::Unreachable(format!("operator did {operator_did}: {other}")),
177        })?;
178    resolve_operator_at(client, &document_url, operator_did, private).await
179}
180
181/// [`fetch_claim`] from `document_url` onward: fetch the document there,
182/// verify it belongs to `operator_did`, and read the claim out of the PDS it
183/// names.
184///
185/// Split from [`fetch_claim`] so this crate's tests can point the whole
186/// exchange at a loopback stand-in for a directory that is otherwise only
187/// reachable over the public internet — `https://plc.directory`, which
188/// [`didbot_identity::document_url`] is the sole source of. Which URL a DID
189/// maps to is `didbot-identity`'s to decide and to test; what this module
190/// does with the document there is tested here.
191#[cfg(test)]
192async fn fetch_claim_at(
193    client: &reqwest::Client,
194    document_url: &str,
195    operator_did: &str,
196    hostname: &str,
197    private: PrivateAddresses,
198) -> Result<Option<OperatorClaim>, ClaimFetchError> {
199    let (_, endpoint, pinned) =
200        resolve_operator_at(client, document_url, operator_did, private).await?;
201    fetch_claim_from(&pinned, &endpoint, operator_did, hostname).await
202}
203
204/// [`resolve_operator`] from `document_url` onward.
205///
206/// The endpoint the document names is checked against
207/// [`didbot_http::REFUSED_BLOCKS`] before anything fetches from it. The
208/// operator's DID is configuration, but the `#atproto_pds` endpoint inside
209/// their document is not: it is a URL this server is told to fetch from by
210/// a document it just read, which is the same kind of URL a `client_id` is.
211async fn resolve_operator_at(
212    client: &reqwest::Client,
213    document_url: &str,
214    operator_did: &str,
215    private: PrivateAddresses,
216) -> Result<(DidDocument, String, reqwest::Client), ClaimFetchError> {
217    let response = client
218        .get(document_url)
219        .send()
220        .await
221        .map_err(|error| ClaimFetchError::Unreachable(format!("fetching {document_url}: {error}")))?
222        .error_for_status()
223        .map_err(|error| {
224            ClaimFetchError::Unreachable(format!("fetching {document_url}: {error}"))
225        })?;
226    let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
227        .await
228        .map_err(|error| {
229            ClaimFetchError::Unreachable(format!("reading {document_url}: {error}"))
230        })?;
231
232    let document: DidDocument = serde_json::from_slice(&body).map_err(|error| {
233        ClaimFetchError::Unreachable(format!("parsing {document_url}: {error}"))
234    })?;
235    // The same check `didbot_identity::resolve` makes, for the same reason
236    // the DID spec gives: "the DID declared in the document (in the `id`
237    // field) should always be verified against what was expected." A
238    // document served at the right URL claiming a different subject is a
239    // document about somebody else, and reading a claim out of the PDS it
240    // names would be reading somebody else's repository.
241    if document.id != operator_did {
242        return Err(ClaimFetchError::Unreachable(format!(
243            "the did document at {document_url} claims {:?}, expected {operator_did:?}",
244            document.id
245        )));
246    }
247
248    let endpoint = document.pds_endpoint().ok_or_else(|| {
249        ClaimFetchError::Unreachable(format!(
250            "{operator_did}'s did document names no #atproto_pds service"
251        ))
252    })?;
253    let url = url::Url::parse(endpoint).map_err(|error| {
254        ClaimFetchError::Unreachable(format!(
255            "the did document at {document_url} names {endpoint} as its #atproto_pds \
256             service, which is not a URL: {error}"
257        ))
258    })?;
259    let pinned = didbot_http::pinned_client(&url, private)
260        .await
261        .map_err(|error| {
262            ClaimFetchError::Unreachable(format!(
263                "the did document at {document_url} names {endpoint} as its #atproto_pds \
264                 service: {error}"
265            ))
266        })?;
267    let endpoint = endpoint.to_owned();
268    Ok((document, endpoint, pinned))
269}
270
271/// Reads the `bot.did.operator` record at `hostname` from the operator's
272/// repository at `endpoint`.
273pub(crate) async fn fetch_claim_from(
274    client: &reqwest::Client,
275    endpoint: &str,
276    operator_did: &str,
277    hostname: &str,
278) -> Result<Option<OperatorClaim>, ClaimFetchError> {
279    let record_url = format!("{endpoint}/xrpc/com.atproto.repo.getRecord");
280    let response = client
281        .get(&record_url)
282        .query(&[
283            ("repo", operator_did),
284            ("collection", COLLECTION),
285            ("rkey", hostname),
286        ])
287        .send()
288        .await
289        .map_err(|error| ClaimFetchError::Unreachable(format!("fetching {record_url}: {error}")))?;
290
291    // atproto answers "no such record" as a 400 naming `RecordNotFound` in
292    // the error body, not a 404 -- but a well-behaved implementation may
293    // reasonably choose either, so both can mean "no claim."
294    //
295    // The status alone does not say which. A 400 is also what a PDS answers
296    // for a collection it does not know, an rkey it will not accept, or a
297    // proxy in front of it rewriting the query, and reading those as "there
298    // is no operator claim" pauses provisioning for a reason nothing names.
299    // So a 400 only means absence when the body says `RecordNotFound`; every
300    // other 400 is a repository this poll could not get an answer out of.
301    let status = response.status().as_u16();
302    if status == 404 {
303        return Ok(None);
304    }
305    if status == 400 {
306        // Under the bound the record itself is read under: a body past it
307        // names no error, and the poll says it could not read the answer.
308        let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
309            .await
310            .map(didbot_http::body_text)
311            .unwrap_or_default();
312        let named = serde_json::from_str::<serde_json::Value>(&body)
313            .ok()
314            .and_then(|value| {
315                value
316                    .get("error")
317                    .and_then(serde_json::Value::as_str)
318                    .map(str::to_owned)
319            });
320        return match named.as_deref() {
321            Some(RECORD_NOT_FOUND) => Ok(None),
322            Some(other) => Err(ClaimFetchError::Unreachable(format!(
323                "{record_url} answered 400 {other}, which is not {RECORD_NOT_FOUND}: \
324                 the repository was reached and refused the request"
325            ))),
326            None => Err(ClaimFetchError::Unreachable(format!(
327                "{record_url} answered 400 with no atproto error name: {body}"
328            ))),
329        };
330    }
331    let response = response
332        .error_for_status()
333        .map_err(|error| ClaimFetchError::Unreachable(format!("{record_url} answered: {error}")))?;
334    let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
335        .await
336        .map_err(|error| ClaimFetchError::Unreachable(format!("reading {record_url}: {error}")))?;
337    let body: GetRecordResponse = serde_json::from_slice(&body)
338        .map_err(|error| ClaimFetchError::Unreachable(format!("parsing {record_url}: {error}")))?;
339
340    Ok(OperatorClaim::from_json(&body.value))
341}
342
343/// Builds the [`OperatorRelation`] a poll needs.
344///
345/// The operator is configuration — a server is launched knowing who runs
346/// it, and never discovers that from the network. The claim is read at the
347/// server's own authority as its DID spells it — a development server's
348/// port included — which is the key `didbot operate` writes and the key
349/// [`refresh_root_standing`] reads every root's record at.
350pub fn configure(
351    registry: &dyn Registry,
352    operator_did: &str,
353    grace: time::Duration,
354) -> Arc<OperatorRelation> {
355    let server_did = registry.service_did();
356    let hostname = didbot_identity::did::AccountDid::parse(&server_did)
357        .expect("the server's own DID is a did:web")
358        .authority();
359    Arc::new(OperatorRelation::new(
360        operator_did.to_owned(),
361        hostname,
362        server_did,
363        grace,
364    ))
365}
366
367/// The shortest interval between two polls, however many nudges arrive.
368///
369/// This is the whole anti-amplification property, and it is a floor on
370/// *outbound* work rather than a limit on inbound requests. A token bucket
371/// on the route would bound how many nudges are accepted while still letting
372/// an accepted burst turn into a burst of polls at somebody else's PDS; a
373/// floor bounds the thing that must not amplify directly, so the worst an
374/// abusive caller achieves is one poll per this interval — which is what the
375/// timer was going to do anyway.
376///
377/// Ten seconds. Short enough that an operator who has just run
378/// `didbot operate` sees the server flip within one breath rather than waiting
379/// out [`DEFAULT_POLL_INTERVAL`], and long enough that the operator's PDS is
380/// never asked more than six times a minute by this server no matter what
381/// arrives at the socket.
382pub const MIN_POLL_INTERVAL: StdDuration = StdDuration::from_secs(10);
383
384/// The most one poll tick -- the claim's up to two sequential HTTP
385/// round trips, then the local decision -- may take before the tick is
386/// abandoned.
387///
388/// A backstop on top of the client's own budget, not a substitute for it:
389/// [`didbot_http::client`] falls back to a client with no timeout at all if
390/// its builder ever fails to construct one, and this is what still bounds a
391/// tick if that happens. Thirty seconds is triple
392/// [`didbot_http::DEFAULT_TIMEOUT`], which is the most either HTTP call
393/// normally takes.
394const POLL_TICK_TIMEOUT: StdDuration = StdDuration::from_secs(30);
395
396/// The most root accounts one tick re-reads the operator's record for.
397///
398/// Sized the way [`crate::stale_sweep::DEFAULT_SWEEP_LIMIT`] is, against the
399/// round trips behind it rather than anything in this process: a hundred
400/// `getRecord`s at the operator's PDS finish well inside
401/// `POLL_TICK_TIMEOUT` against a healthy one. A deployment with more roots
402/// than this reads the rest on the ticks that follow, each resuming where
403/// the last stopped.
404pub const VOUCH_REREAD_LIMIT: usize = 100;
405
406/// What [`refresh_root_standing`] carries from one tick to the next.
407#[derive(Debug, Default)]
408pub struct RootStanding {
409    /// The last root read, so the next tick resumes past it.
410    resume_after: Option<String>,
411    /// When each root's record was last read present — or first read
412    /// missing, for one this poll has never seen present — so a lapse is
413    /// measured against the same grace window the server's own claim gets.
414    last_seen: BTreeMap<String, OffsetDateTime>,
415}
416
417/// A request that this server look for its operator's claim sooner than its
418/// timer would.
419///
420/// **Urgency, never authority.** Nothing here asserts a claim, carries a
421/// credential, or names an operator; the only thing a caller can influence
422/// is *when* this server next reads a repository it was going to read
423/// anyway, and the answer still comes from that read. A server that took
424/// anybody's word for a claim would be a server that could be claimed by
425/// whoever asked, which is the whole reason the flip is observation-only.
426///
427/// Nudges **coalesce**: many arriving inside one [`MIN_POLL_INTERVAL`]
428/// window set one flag and produce one poll, not one poll each. See
429/// [`OperatorPoll::spawn`].
430#[derive(Debug)]
431pub struct PollNudge {
432    notify: Notify,
433    /// Whether an [`OperatorPoll`] is listening, so a wait on a channel
434    /// nothing drains answers at once.
435    served: AtomicBool,
436    /// How many polls have begun.
437    begun: AtomicU64,
438    /// The last poll to finish: its number, and whether it read the
439    /// operator's records.
440    finished: watch::Sender<(u64, bool)>,
441}
442
443impl Default for PollNudge {
444    fn default() -> Self {
445        Self {
446            notify: Notify::new(),
447            served: AtomicBool::new(false),
448            begun: AtomicU64::new(0),
449            finished: watch::Sender::new((0, false)),
450        }
451    }
452}
453
454impl PollNudge {
455    /// A fresh nudge channel.
456    pub fn new() -> Self {
457        Self::default()
458    }
459
460    /// Nudges, then waits up to `within` for a poll that began after the
461    /// nudge to finish. Answers whether that poll read the operator's
462    /// records, so a caller refused for want of an allowance can check
463    /// again against what the operator's repository holds now.
464    ///
465    /// The nudge is still bound by the floor, so a poll that ran moments
466    /// ago makes this wait out the rest of [`MIN_POLL_INTERVAL`].
467    pub async fn nudge_and_wait(&self, within: StdDuration) -> bool {
468        if !self.served.load(Ordering::Acquire) {
469            return false;
470        }
471        let mut finished = self.finished.subscribe();
472        let before = self.begun.load(Ordering::Acquire);
473        self.nudge();
474        let waited =
475            tokio::time::timeout(within, finished.wait_for(|(poll, _)| *poll > before)).await;
476        matches!(waited, Ok(Ok(done)) if done.1)
477    }
478
479    /// Asks the poll task to look now, if it is not already about to.
480    ///
481    /// Cheap and infallible on purpose: the cost of an inbound nudge has to
482    /// stay a wake-up on an existing task, or the route in front of it
483    /// becomes a way to make this server do work.
484    pub fn nudge(&self) {
485        self.notify.notify_one();
486    }
487}
488
489/// When an [`OperatorPoll`] polls: its timer, and the floor a nudge
490/// cannot get under.
491///
492/// The two together, rather than two parameters, because they are one
493/// decision: how often this server reads somebody else's repository. Reading
494/// them apart is how a deployment ends up with a tight floor and a loose
495/// timer, or the reverse, and no idea which one it meant.
496#[derive(Debug, Clone, Copy)]
497pub struct PollSchedule {
498    /// How often the poll fires on its own.
499    pub interval: StdDuration,
500    /// The shortest gap between two polls, however many nudges arrive. See
501    /// [`MIN_POLL_INTERVAL`], which is what every deployment passes; a field
502    /// only so this module's own tests can exercise the bound without
503    /// sleeping through it.
504    pub floor: StdDuration,
505}
506
507impl Default for PollSchedule {
508    /// [`DEFAULT_POLL_INTERVAL`] and [`MIN_POLL_INTERVAL`].
509    fn default() -> Self {
510        Self {
511            interval: DEFAULT_POLL_INTERVAL,
512            floor: MIN_POLL_INTERVAL,
513        }
514    }
515}
516
517/// A running operator poll, stopped by [`OperatorPoll::stop`] or by
518/// dropping it.
519pub struct OperatorPoll {
520    handle: JoinHandle<()>,
521    stop: Arc<Notify>,
522}
523
524impl OperatorPoll {
525    /// Starts polling `relation` against the real network on `schedule`,
526    /// driving `estop`'s self latch from what it finds, admitting the
527    /// reservations `registry` holds that the operator has vouched for, and
528    /// reporting to `lifecycle` whether a claim currently stands. Each tick
529    /// that reads the operator's DID document also starts a check of the
530    /// handle it claims, into `operator_handle`, which the tick does not
531    /// wait for.
532    ///
533    /// The first tick fires immediately, not after one interval: a server
534    /// that waited a full interval before its first chance to find an
535    /// already-existing claim would refuse writes for no reason a fresh
536    /// restart should ever cause.
537    ///
538    /// `nudge` lets a caller ask for a poll sooner than the schedule's
539    /// interval, bounded by its floor. A nudge arriving less than the floor
540    /// since the last poll waits out the remainder rather than polling, and
541    /// every further nudge inside that window coalesces into the same single
542    /// poll. So an unbounded burst of nudges costs this server one extra
543    /// outbound request, not one per nudge — see [`PollNudge`].
544    #[allow(clippy::too_many_arguments)]
545    pub fn spawn(
546        relation: Arc<OperatorRelation>,
547        estop: Arc<Estop>,
548        lifecycle: Arc<ServerLifecycle>,
549        registry: Arc<dyn Registry>,
550        client: reqwest::Client,
551        private: PrivateAddresses,
552        schedule: PollSchedule,
553        nudge: Arc<PollNudge>,
554        policies: Option<Arc<PolicyPoll>>,
555        records_read: Arc<OperatorRecords>,
556        operator_handle: Option<Arc<OperatorHandle>>,
557    ) -> Self {
558        let stop = Arc::new(Notify::new());
559        let notified = stop.clone();
560        nudge.served.store(true, Ordering::Release);
561        let handle = tokio::spawn(async move {
562            let mut ticker = tokio::time::interval(schedule.interval);
563            ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
564            // The floor is kept as an instant rather than a counter so a
565            // nudge that arrives during a slow poll cannot stack: whatever
566            // happened while the poll ran, the next one is still no earlier
567            // than `MIN_POLL_INTERVAL` after this one started.
568            let mut earliest_next = tokio::time::Instant::now();
569            // Where the last tick's re-read of root records stopped, and
570            // when each was last seen; see `RootStanding`.
571            let mut standing = RootStanding::default();
572            loop {
573                let nudged = tokio::select! {
574                    biased;
575                    () = notified.notified() => break,
576                    _ = ticker.tick() => false,
577                    () = nudge.notify.notified() => true,
578                };
579                if nudged {
580                    let now = tokio::time::Instant::now();
581                    if now < earliest_next {
582                        // Wait out the floor instead of polling. Any further
583                        // nudges arriving meanwhile re-arm the same `Notify`
584                        // and are absorbed by the one poll below -- which is
585                        // exactly the coalescing this is for.
586                        tokio::select! {
587                            biased;
588                            () = notified.notified() => break,
589                            () = tokio::time::sleep_until(earliest_next) => {}
590                        }
591                    }
592                }
593                // Absorb a nudge that arrived while the floor was being
594                // waited out: it is asking for exactly the poll about to
595                // happen, and `Notify` holds one permit, so leaving it armed
596                // would buy the burst a second poll it has already been
597                // given. A nudge arriving *during* the poll below is
598                // deliberately not absorbed -- that one may have been sent
599                // after the read it would need to see.
600                {
601                    let pending = nudge.notify.notified();
602                    tokio::pin!(pending);
603                    tokio::select! {
604                        biased;
605                        () = &mut pending => {}
606                        () = std::future::ready(()) => {}
607                    }
608                }
609                earliest_next = tokio::time::Instant::now() + schedule.floor;
610                let poll = nudge.begun.fetch_add(1, Ordering::AcqRel) + 1;
611                let ticked = tokio::time::timeout(
612                    POLL_TICK_TIMEOUT,
613                    tick(
614                        &relation,
615                        &estop,
616                        &lifecycle,
617                        registry.as_ref(),
618                        &client,
619                        OffsetDateTime::now_utc(),
620                        private,
621                        &mut standing,
622                        policies.as_deref(),
623                        &records_read,
624                        operator_handle.as_ref(),
625                    ),
626                )
627                .await;
628                nudge
629                    .finished
630                    .send_replace((poll, matches!(ticked, Ok((_, true)))));
631                if ticked.is_err() {
632                    // No outcome was recorded for this tick -- not even the
633                    // "could not reach the operator's repository" one
634                    // `poll_once` itself would have logged, because it never
635                    // returned. The next tick is unaffected: `earliest_next`
636                    // is already set, and the ticker fires again on its own
637                    // schedule.
638                    warn!(
639                        operator = relation.operator_did(),
640                        hostname = relation.hostname(),
641                        timeout = ?POLL_TICK_TIMEOUT,
642                        "operator poll tick did not finish within its timeout; abandoning it"
643                    );
644                }
645            }
646            nudge.served.store(false, Ordering::Release);
647        });
648        Self { handle, stop }
649    }
650
651    /// Stops the poll and waits for the task to finish.
652    pub async fn stop(self) {
653        self.stop.notify_one();
654        let _ = self.handle.await;
655    }
656}
657
658/// One poll: fetch, decide, act. Split out from [`OperatorPoll::spawn`]'s
659/// closure, and public, so a caller — an integration test walking the
660/// bootstrap, in particular — can drive exactly one tick against a real
661/// operator repository without spawning a task or waiting on a timer. The
662/// spawned poll runs this and, on the same resolved endpoint, the
663/// [`PolicyPoll`] it was given. Every call here re-reads standing vouches
664/// from the first host; the spawned poll is what carries on between ticks.
665pub async fn poll_once(
666    relation: &OperatorRelation,
667    estop: &Estop,
668    lifecycle: &ServerLifecycle,
669    registry: &dyn Registry,
670    client: &reqwest::Client,
671    now: OffsetDateTime,
672    private: PrivateAddresses,
673) -> OperatorTransition {
674    tick(
675        relation,
676        estop,
677        lifecycle,
678        registry,
679        client,
680        now,
681        private,
682        &mut RootStanding::default(),
683        None,
684        &OperatorRecords::new(),
685        None,
686    )
687    .await
688    .0
689}
690
691/// [`poll_once`] with `standing` carried for [`refresh_root_standing`],
692/// followed by `policies`' own tick against the endpoint the claim was
693/// read from. A tick that could not resolve the operator reads no policies
694/// either, and the gate keeps the set it had. Answers the transition, and
695/// whether the operator's records were read and their allowances installed.
696#[allow(clippy::too_many_arguments)]
697async fn tick(
698    relation: &OperatorRelation,
699    estop: &Estop,
700    lifecycle: &ServerLifecycle,
701    registry: &dyn Registry,
702    client: &reqwest::Client,
703    now: OffsetDateTime,
704    private: PrivateAddresses,
705    standing: &mut RootStanding,
706    policies: Option<&PolicyPoll>,
707    records_read: &OperatorRecords,
708    handle: Option<&Arc<OperatorHandle>>,
709) -> (OperatorTransition, bool) {
710    let resolved = match resolve_operator(client, relation.operator_did(), private).await {
711        Ok((document, endpoint, pinned)) => {
712            // Not awaited: the claim below never waits on a name for a page.
713            if let Some(handle) = handle {
714                handle.refresh_in_background(document, private);
715            }
716            Ok((endpoint, pinned))
717        }
718        Err(error) => Err(error),
719    };
720    let fetched = match &resolved {
721        Ok((endpoint, pinned)) => {
722            fetch_claim_from(
723                pinned,
724                endpoint,
725                relation.operator_did(),
726                relation.hostname(),
727            )
728            .await
729        }
730        Err(error) => Err(error.clone()),
731    };
732    if let Err(error) = &fetched {
733        warn!(
734            operator = relation.operator_did(),
735            hostname = relation.hostname(),
736            %error,
737            "operator poll could not reach the operator's repository"
738        );
739    }
740    let source = Prefetched(fetched);
741    let transition = relation.poll(&source, now);
742    // Reported to the lifecycle on every tick, before anything is thrown or
743    // released: `didbot_pds::ServerState` is what the write and provisioning
744    // surfaces consult, and it has to have seen this poll before a caller
745    // can observe the e-stop's reaction to it.
746    //
747    // One boolean. "Never claimed" and "claim lapsed" are the same lifecycle
748    // state with the same recovery, so the distinction stops here, where the
749    // grace window that produced it lives. `GracePending` counts as standing
750    // -- the window exists so a blip at the operator's PDS does not take a
751    // server's writes down.
752    lifecycle.observe_operator_claim(matches!(
753        transition,
754        OperatorTransition::NewlyClaimed
755            | OperatorTransition::StillClaimed
756            | OperatorTransition::GracePending
757    ));
758    match transition {
759        OperatorTransition::NewlyClaimed => {
760            info!(
761                operator = relation.operator_did(),
762                "found the operator's record; the claim stands"
763            );
764            estop.release_self();
765        }
766        OperatorTransition::NewlyLapsed => {
767            warn!(
768                operator = relation.operator_did(),
769                hostname = relation.hostname(),
770                "no matching operator record found within the grace window; pausing the server \
771                 until the operator's claim reappears \
772                 (see plan/alerts.md -- this deployment does not yet write an alert record for it)"
773            );
774            // A legitimate stop: by the time a claim can lapse the server
775            // has been operational, so there is something running to halt.
776            // `ServerPolicy::may_halt` says so as data, and this asserts
777            // the two agree rather than trusting that they do -- a lapse
778            // derived from anywhere the stop is unavailable would mean the
779            // lifecycle and this module disagree about what has happened.
780            debug_assert!(
781                lifecycle.policy().may_halt,
782                "a lapse threw a stop from a state whose policy forbids one"
783            );
784            estop.throw_self(EstopMode::Pause);
785        }
786        OperatorTransition::StillClaimed
787        | OperatorTransition::StillLapsed
788        | OperatorTransition::GracePending
789        | OperatorTransition::StillUnclaimed => {}
790    }
791    // A cheap sanity check that costs nothing on the ordinary path: a poll
792    // reporting anything other than a Cause::OperatorMissing self-pause after
793    // a lapse would mean this module and `Estop` have disagreed about who
794    // engaged the latch.
795    if matches!(
796        transition,
797        OperatorTransition::NewlyLapsed | OperatorTransition::StillLapsed
798    ) {
799        debug_assert_eq!(estop.status().cause, Some(EstopCause::OperatorMissing));
800    }
801    // Claimed or not: a root's standing only ever says what the operator's
802    // repository says, and having reached that repository is all this
803    // needs. The same operator, the same repository, one record key per
804    // root.
805    if let Ok((endpoint, pinned)) = &resolved {
806        refresh_root_standing(
807            registry,
808            pinned,
809            endpoint,
810            relation.operator_did(),
811            standing,
812            now,
813            relation.grace_window(),
814        )
815        .await;
816    }
817    let allowances_read = match &resolved {
818        Ok((endpoint, pinned)) => {
819            refresh_allowances(
820                registry,
821                pinned,
822                endpoint,
823                relation.operator_did(),
824                records_read,
825            )
826            .await
827        }
828        Err(_) => false,
829    };
830    if let (Some(policies), Ok((endpoint, pinned))) = (policies, &resolved) {
831        if let Err(error) = policies.poll_once(pinned, endpoint, registry).await {
832            warn!(
833                operator = relation.operator_did(),
834                %error,
835                "operator policy poll applied nothing; the set last built keeps serving"
836            );
837        }
838    }
839    (transition, allowances_read)
840}
841
842/// One `bot.did.operator` record in the operator's repository, as the last
843/// listing read it.
844#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
845#[serde(rename_all = "camelCase")]
846pub struct OperatorRecordLine {
847    /// The record's own address in the operator's repository.
848    pub uri: String,
849    /// The account the record names.
850    pub subject: String,
851    /// When the writer says they wrote it, by the writer's own clock.
852    pub created_at: Option<String>,
853}
854
855/// Every `bot.did.operator` record the last successful listing of the
856/// operator's repository read.
857///
858/// An app holding the operator's session writes as the operator, and their
859/// PDS cannot tell the two apart. So the operator recognising a record they
860/// did not write is the defence, and that needs the records in front of
861/// them: where each one is, who it names, and when its writer says it was
862/// written.
863/// Kept here because the poll already lists them on every tick for
864/// [`refresh_allowances`], and a page that refetched them would put a
865/// remote read behind a panel.
866///
867/// `None` until a listing has succeeded, which is a different fact from a
868/// repository that holds none.
869#[derive(Debug, Default)]
870pub struct OperatorRecords {
871    last_read: Mutex<Option<Vec<OperatorRecordLine>>>,
872}
873
874impl OperatorRecords {
875    /// A store nothing has written to yet.
876    pub fn new() -> Self {
877        Self::default()
878    }
879
880    /// What the last successful listing read.
881    pub fn read(&self) -> Option<Vec<OperatorRecordLine>> {
882        self.last_read
883            .lock()
884            .unwrap_or_else(|poisoned| poisoned.into_inner())
885            .clone()
886    }
887
888    /// Replaces what the last listing read.
889    pub(crate) fn store(&self, lines: Vec<OperatorRecordLine>) {
890        *self
891            .last_read
892            .lock()
893            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(lines);
894    }
895}
896
897/// Lists every `bot.did.operator` record in the operator's repository and
898/// installs the `creates` allowances they carry; see
899/// [`didbot_pds::allowance`]. A listing that fails leaves the allowances
900/// last installed in force, the same way a failed policy listing leaves
901/// the last policy set serving: a blip at the operator's PDS must not
902/// widen or narrow what hosted accounts may create.
903///
904/// Every record in the collection is read, not only the ones naming
905/// accounts hosted here; an allowance for an account this server does not
906/// host is indexed and never matched.
907///
908/// A record naming an account some hosted account created carries no
909/// allowance, and is logged instead. The operator admits an account by
910/// creating it from their own repository, so a record that names a name
911/// already taken beneath a hosted creator is one the creator raced the
912/// operator to: honouring it would hand the creator whatever the operator
913/// meant to grant the account they were admitting. Records naming a root,
914/// or a name no account holds yet, are indexed as before.
915///
916/// Answers whether the listing succeeded and its allowances were installed.
917pub async fn refresh_allowances(
918    registry: &dyn Registry,
919    client: &reqwest::Client,
920    endpoint: &str,
921    operator_did: &str,
922    records_read: &OperatorRecords,
923) -> bool {
924    match crate::policy_poll::list_records(client, endpoint, operator_did, COLLECTION).await {
925        Ok(records) => {
926            let hosted_parents: BTreeMap<String, String> = registry
927                .accounts()
928                .into_iter()
929                .filter_map(|account| {
930                    let parent = account.parent?;
931                    Some((account.did.as_str().to_owned(), parent.as_str().to_owned()))
932                })
933                .collect();
934            let allowances = Allowances::from_records(records.iter().filter_map(|(uri, value)| {
935                let claim = OperatorClaim::from_json(value)?;
936                if let Some(parent) = hosted_parents.get(&claim.subject) {
937                    warn!(
938                        record = %uri,
939                        subject = %claim.subject,
940                        parent = %parent,
941                        "the operator's record names an account a hosted account created, \
942                         so it grants nothing; the operator admits an account by creating it"
943                    );
944                    return None;
945                }
946                Some((claim.subject, claim.creates))
947            }));
948            registry.set_allowances(allowances);
949            records_read.store(
950                records
951                    .iter()
952                    .filter_map(|(uri, value)| {
953                        Some(OperatorRecordLine {
954                            uri: uri.clone(),
955                            subject: value["subject"].as_str()?.to_owned(),
956                            created_at: value["createdAt"].as_str().map(str::to_owned),
957                        })
958                    })
959                    .collect(),
960            );
961            true
962        }
963        Err(error) => {
964            warn!(
965                operator = operator_did,
966                %error,
967                "could not list the operator's records; the allowances last installed stay in force"
968            );
969            false
970        }
971    }
972}
973
974/// Re-reads the operator's record for every root account and reports each
975/// one's standing to the registry. Answers with the DIDs whose standing
976/// changed.
977///
978/// A root account is one the operator created from their own repository:
979/// active, hosted here, with no hosted parent, and not the server itself.
980/// The same `getRecord` the server's own claim is read with, at the record
981/// key the root's own hostname is. A record counts when it names the
982/// root's DID exactly.
983///
984/// Absence is the only thing read as revocation, and only once it has
985/// lasted `grace`, the window the server's own claim gets and for the
986/// same reason: a server cannot tell "the operator revoked" from "the
987/// operator's PDS is down". `fetch_claim_from` answers `Ok(None)` for a
988/// repository that was reached and holds no record at the key, and
989/// [`ClaimFetchError`] for everything it could not get an answer out of;
990/// an error leaves every standing as it was and ends the pass, since every
991/// root reads from the same endpoint. [`Registry::set_operator_standing`]
992/// quarantines a lapsed root and everything beneath it, and releases them
993/// when the record returns.
994///
995/// At most [`VOUCH_REREAD_LIMIT`] roots per call, in DID order from just
996/// past where the last call stopped and wrapping to the first.
997pub async fn refresh_root_standing(
998    registry: &dyn Registry,
999    client: &reqwest::Client,
1000    endpoint: &str,
1001    operator_did: &str,
1002    standing: &mut RootStanding,
1003    now: OffsetDateTime,
1004    grace: Duration,
1005) -> Vec<String> {
1006    let mut roots: Vec<HostedAccount> = registry
1007        .accounts()
1008        .into_iter()
1009        .filter(|account| account.state == AccountState::Active && account.parent.is_none())
1010        .collect();
1011    roots.sort_by(|a, b| a.did.as_str().cmp(b.did.as_str()));
1012    if roots.is_empty() {
1013        standing.resume_after = None;
1014        return Vec::new();
1015    }
1016    let start = standing.resume_after.as_deref().map_or(0, |last| {
1017        roots.partition_point(|root| root.did.as_str() <= last)
1018    });
1019    let window = roots.len().min(VOUCH_REREAD_LIMIT);
1020    let mut changed = Vec::new();
1021    for root in roots.iter().cycle().skip(start).take(window) {
1022        let did = root.did.as_str();
1023        let rkey = root.did.did().authority();
1024        let found = match fetch_claim_from(client, endpoint, operator_did, &rkey).await {
1025            Ok(found) => found,
1026            Err(error) => {
1027                warn!(
1028                    did,
1029                    %error,
1030                    "could not re-read the operator's record for a root account; \
1031                     leaving its standing as it is until the repository answers"
1032                );
1033                break;
1034            }
1035        };
1036        let present = match found {
1037            Some(claim) if claim.subject == did => true,
1038            Some(claim) => {
1039                warn!(
1040                    did,
1041                    subject = %claim.subject,
1042                    "the record at this root's key names a different did; not its record"
1043                );
1044                false
1045            }
1046            None => false,
1047        };
1048        let stands = if present {
1049            standing.last_seen.insert(did.to_owned(), now);
1050            true
1051        } else {
1052            // A quarantine already hung on the root is this server's own
1053            // record that the window ran out before, and it outlives the
1054            // process that hung it. Starting the clock over on a first read
1055            // would give a revoked subtree another whole window, every
1056            // restart.
1057            let already = root.locks.holds(Tag::new(Lock::Quarantined, Party::Parent));
1058            let since = *standing.last_seen.entry(did.to_owned()).or_insert_with(|| {
1059                if already {
1060                    now - grace
1061                } else {
1062                    now
1063                }
1064            });
1065            now - since < grace
1066        };
1067        match registry.set_operator_standing(did, stands) {
1068            Ok(true) => {
1069                info!(
1070                    did,
1071                    present, stands, "the operator's record for a root account changed standing"
1072                );
1073                changed.push(did.to_owned());
1074            }
1075            Ok(false) => {}
1076            Err(error) => {
1077                warn!(did, %error, "could not update a root account's standing");
1078            }
1079        }
1080        standing.resume_after = Some(did.to_owned());
1081    }
1082    changed
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use axum::extract::Query;
1089    use axum::response::IntoResponse;
1090    use axum::routing::get;
1091    use axum::{Json, Router};
1092    use serde_json::json;
1093    use std::collections::HashMap;
1094    use std::net::SocketAddr;
1095
1096    // ------------------------------------------------------------------
1097    // The revocation bound: what the two constants compose into
1098    // ------------------------------------------------------------------
1099
1100    /// An operator deletes their record. How long until this server has
1101    /// actually stopped?
1102    ///
1103    /// Driven the way the spawned task drives it — a poll every
1104    /// [`DEFAULT_POLL_INTERVAL`], each one finding nothing — so the answer
1105    /// crosses both constants rather than restating either. The pause lands
1106    /// on the first tick at or after the grace window elapses, which is
1107    /// never later than one interval past it.
1108    #[test]
1109    fn a_revocation_is_acted_on_within_the_grace_window_plus_one_interval() {
1110        let grace = didbot_pds::DEFAULT_GRACE_WINDOW;
1111        let interval = time::Duration::seconds(
1112            DEFAULT_POLL_INTERVAL
1113                .as_secs()
1114                .try_into()
1115                .unwrap_or(i64::MAX),
1116        );
1117        let relation = OperatorRelation::new(
1118            "did:web:operator.example",
1119            "agents.example",
1120            "did:web:agents.example",
1121            grace,
1122        );
1123
1124        let start = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp");
1125        let claim = OperatorClaim::new("did:web:agents.example", start);
1126        assert_eq!(
1127            relation.poll(&Prefetched(Ok(Some(claim))), start),
1128            OperatorTransition::NewlyClaimed
1129        );
1130
1131        // From here the record is gone. Every subsequent tick finds nothing.
1132        let mut now = start;
1133        let lapsed_at = loop {
1134            now += interval;
1135            assert!(
1136                now - start <= grace + interval,
1137                "still running {} past the revocation",
1138                now - start
1139            );
1140            if relation.poll(&Prefetched(Ok(None)), now) == OperatorTransition::NewlyLapsed {
1141                break now;
1142            }
1143        };
1144        assert!(
1145            lapsed_at - start >= grace,
1146            "paused before the grace window it promised: an operator outage would trip it"
1147        );
1148        assert!(!relation.is_claimed());
1149    }
1150
1151    // ------------------------------------------------------------------
1152    // fetch_claim: a real round trip against a loopback server over TLS
1153    // ------------------------------------------------------------------
1154
1155    /// A client that trusts `ca_pem` on top of the roots it is built with.
1156    ///
1157    /// The fake below serves TLS, because every server this project talks to
1158    /// does and no client here can be told otherwise. This is the same seam
1159    /// a development stack uses through `didbot_http::EXTRA_CA_CERTS`,
1160    /// reached directly so two tests running at once do not share one
1161    /// environment variable.
1162    fn client_trusting(ca_pem: &str) -> reqwest::Client {
1163        didbot_http::builder()
1164            .add_root_certificate(
1165                reqwest::Certificate::from_pem(ca_pem.as_bytes()).expect("the authority parses"),
1166            )
1167            .build()
1168            .expect("a client with one extra root builds")
1169    }
1170
1171    /// The path the fake serves an operator's document at in addition to
1172    /// `/.well-known/did.json`, standing in for the one directory a
1173    /// `did:plc` document is fetched from. `https://plc.directory` is not
1174    /// reachable from a test, and `didbot_identity::document_url` is the
1175    /// only thing that names it, so the fake offers a second path and the
1176    /// `did:plc` tests hand that URL to [`fetch_claim_at`] directly.
1177    const DIRECTORY_PATH: &str = "/plc-directory-stand-in";
1178
1179    /// A minimal stand-in for an operator's PDS: serves a did:web document
1180    /// naming itself as the `#atproto_pds`, and answers getRecord for
1181    /// whatever `bot.did.operator` records `records` holds, keyed by rkey.
1182    async fn fake_operator_pds(
1183        records: HashMap<String, serde_json::Value>,
1184    ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1185        fake_operator_pds_counting(records, Arc::new(std::sync::atomic::AtomicUsize::new(0))).await
1186    }
1187
1188    /// [`fake_operator_pds`], publishing a document that claims `did` rather
1189    /// than the `did:web` its own address implies — an operator whose
1190    /// identity is a `did:plc`, whose PDS is still an ordinary host.
1191    async fn fake_operator_pds_for(
1192        did: &str,
1193        records: HashMap<String, serde_json::Value>,
1194    ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1195        fake_operator_pds_inner(
1196            records,
1197            Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1198            "RecordNotFound",
1199            Some(did.to_owned()),
1200        )
1201        .await
1202    }
1203
1204    /// [`fake_operator_pds`], counting every `getRecord` it answers — which
1205    /// is one per poll, and therefore the outbound rate the nudge must not
1206    /// amplify.
1207    async fn fake_operator_pds_counting(
1208        records: HashMap<String, serde_json::Value>,
1209        hits: Arc<std::sync::atomic::AtomicUsize>,
1210    ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1211        fake_operator_pds_inner(records, hits, "RecordNotFound", None).await
1212    }
1213
1214    /// [`fake_operator_pds`], answering a miss with a caller-chosen atproto
1215    /// error name rather than `RecordNotFound` -- the only way to tell a 400
1216    /// that means "no such record" from a 400 that means something else.
1217    async fn fake_operator_pds_answering(
1218        records: HashMap<String, serde_json::Value>,
1219        miss: &'static str,
1220    ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1221        fake_operator_pds_inner(
1222            records,
1223            Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1224            miss,
1225            None,
1226        )
1227        .await
1228    }
1229
1230    /// The one fake both of the above are views of.
1231    async fn fake_operator_pds_inner(
1232        records: HashMap<String, serde_json::Value>,
1233        hits: Arc<std::sync::atomic::AtomicUsize>,
1234        miss: &'static str,
1235        did_override: Option<String>,
1236    ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1237        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1238            .await
1239            .expect("bind");
1240        let addr = listener.local_addr().expect("local addr");
1241        let base = format!("https://{addr}");
1242        let (tls, ca_pem) = didbot_tls::test_support::server_config(&["127.0.0.1"]);
1243        // The clients this fake is reached through are built inside the
1244        // code under test, so they are told once, for the process.
1245        didbot_http::test_support::trust(&ca_pem);
1246        let did =
1247            did_override.unwrap_or_else(|| format!("did:web:{}%3A{}", addr.ip(), addr.port()));
1248
1249        let document = json!({
1250            "@context": ["https://www.w3.org/ns/did/v1"],
1251            "id": did,
1252            "alsoKnownAs": ["at://operator.example"],
1253            "service": [{
1254                "id": format!("{did}#atproto_pds"),
1255                "type": "AtprotoPersonalDataServer",
1256                "serviceEndpoint": base,
1257            }],
1258        });
1259
1260        let well_known = document.clone();
1261        let router = Router::new()
1262            .route(
1263                "/.well-known/did.json",
1264                get(move || {
1265                    let document = well_known.clone();
1266                    async move { Json(document) }
1267                }),
1268            )
1269            .route(
1270                DIRECTORY_PATH,
1271                get(move || {
1272                    let document = document.clone();
1273                    async move { Json(document) }
1274                }),
1275            )
1276            .route(
1277                "/xrpc/com.atproto.repo.getRecord",
1278                get(move |Query(params): Query<HashMap<String, String>>| {
1279                    let records = records.clone();
1280                    let hits = hits.clone();
1281                    async move {
1282                        hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1283                        let rkey = params.get("rkey").cloned().unwrap_or_default();
1284                        match records.get(&rkey) {
1285                            Some(value) => Json(json!({
1286                                "uri": format!("at://operator/bot.did.operator/{rkey}"),
1287                                "cid": "bafyfake",
1288                                "value": value,
1289                            }))
1290                            .into_response(),
1291                            None => (
1292                                axum::http::StatusCode::BAD_REQUEST,
1293                                Json(json!({"error": miss})),
1294                            )
1295                                .into_response(),
1296                        }
1297                    }
1298                }),
1299            );
1300
1301        let handle = tokio::spawn(async move {
1302            crate::serve_tls(listener, router, tls).await.ok();
1303        });
1304        (addr, handle, ca_pem)
1305    }
1306
1307    /// A stand-in operator PDS whose `getRecord` answers a `302` pointing
1308    /// at another host rather than a record.
1309    async fn fake_operator_pds_redirecting(
1310        to: String,
1311    ) -> (SocketAddr, tokio::task::JoinHandle<()>) {
1312        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1313            .await
1314            .expect("bind");
1315        let addr = listener.local_addr().expect("local addr");
1316        let base = format!("http://{addr}");
1317        let did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1318        let document = json!({
1319            "@context": ["https://www.w3.org/ns/did/v1"],
1320            "id": did,
1321            "service": [{
1322                "id": format!("{did}#atproto_pds"),
1323                "type": "AtprotoPersonalDataServer",
1324                "serviceEndpoint": base,
1325            }],
1326        });
1327        let well_known = document.clone();
1328        let router = Router::new()
1329            .route(
1330                "/.well-known/did.json",
1331                get(move || {
1332                    let document = well_known.clone();
1333                    async move { Json(document) }
1334                }),
1335            )
1336            .route(
1337                DIRECTORY_PATH,
1338                get(move || {
1339                    let document = document.clone();
1340                    async move { Json(document) }
1341                }),
1342            )
1343            .route(
1344                "/xrpc/com.atproto.repo.getRecord",
1345                get(move |Query(params): Query<HashMap<String, String>>| {
1346                    let to = to.clone();
1347                    async move {
1348                        let rkey = params.get("rkey").cloned().unwrap_or_default();
1349                        axum::response::Redirect::temporary(&format!(
1350                            "{to}/xrpc/com.atproto.repo.getRecord\
1351                             ?repo=operator&collection=bot.did.operator&rkey={rkey}"
1352                        ))
1353                    }
1354                }),
1355            );
1356
1357        let handle = tokio::spawn(async move {
1358            axum::serve(listener, router).await.ok();
1359        });
1360        (addr, handle)
1361    }
1362
1363    /// The endpoint is checked once, so the read of it follows no redirect:
1364    /// a 3xx from the operator's PDS would land the request at an address
1365    /// that check never saw.
1366    #[tokio::test]
1367    async fn a_redirect_from_the_operators_pds_is_not_followed() {
1368        let hostname = "pds.example.test";
1369        let mut records = HashMap::new();
1370        records.insert(
1371            hostname.to_owned(),
1372            json!({
1373                "subject": "did:web:pds.example.test",
1374                "createdAt": "2026-01-01T00:00:00Z",
1375            }),
1376        );
1377        let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1378        // Nothing ever connects to the server the redirect names, so the
1379        // authority it signs from is not needed here.
1380        let (elsewhere, elsewhere_handle, _ca) =
1381            fake_operator_pds_counting(records, hits.clone()).await;
1382        let (addr, handle) = fake_operator_pds_redirecting(format!("https://{elsewhere}")).await;
1383        let document_url = format!("http://{addr}{DIRECTORY_PATH}");
1384
1385        let result = fetch_claim_at(
1386            &didbot_http::client(),
1387            &document_url,
1388            &format!("did:web:{}%3A{}", addr.ip(), addr.port()),
1389            hostname,
1390            PrivateAddresses::Allowed,
1391        )
1392        .await;
1393        assert!(
1394            matches!(result, Err(ClaimFetchError::Unreachable(_))),
1395            "a redirect is not a record, got {result:?}"
1396        );
1397        assert_eq!(
1398            hits.load(std::sync::atomic::Ordering::SeqCst),
1399            0,
1400            "the redirect was followed"
1401        );
1402
1403        handle.abort();
1404        elsewhere_handle.abort();
1405    }
1406
1407    /// The `#atproto_pds` endpoint inside the operator's document is a URL
1408    /// a fetched document told this server to fetch from. A deployment on
1409    /// the public internet refuses one on its own network, and says which
1410    /// endpoint it refused.
1411    #[tokio::test]
1412    async fn an_endpoint_the_document_names_on_this_network_is_refused() {
1413        let (addr, handle, ca) = fake_operator_pds(HashMap::new()).await;
1414        let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1415        let client = client_trusting(&ca);
1416
1417        let error = fetch_claim(
1418            &client,
1419            &operator_did,
1420            "pds.example.test",
1421            PrivateAddresses::Refused,
1422        )
1423        .await
1424        .expect_err("the endpoint is on this deployment's own network");
1425        let message = error.to_string();
1426        assert!(message.contains(&format!("https://{addr}")), "{message}");
1427        assert!(
1428            message.contains("will not fetch from"),
1429            "the refusal names the address list: {message}"
1430        );
1431
1432        handle.abort();
1433    }
1434
1435    #[tokio::test]
1436    async fn a_matching_record_is_fetched_and_parsed() {
1437        let hostname = "pds.example.test";
1438        let mut records = HashMap::new();
1439        records.insert(
1440            hostname.to_owned(),
1441            json!({
1442                "subject": "did:web:pds.example.test",
1443                "createdAt": "2026-01-01T00:00:00Z",
1444            }),
1445        );
1446        let (addr, handle, ca) = fake_operator_pds(records).await;
1447        let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1448        let client = client_trusting(&ca);
1449
1450        let claim = fetch_claim(&client, &operator_did, hostname, PrivateAddresses::Allowed)
1451            .await
1452            .expect("should succeed")
1453            .expect("should find a claim");
1454        assert_eq!(claim.subject, "did:web:pds.example.test");
1455
1456        handle.abort();
1457    }
1458
1459    /// An operator whose `did:web` host accepts the connection and never
1460    /// speaks costs a tick's DID resolution exactly
1461    /// `didbot_http::DEFAULT_CONNECT_TIMEOUT` — the handshake is part of
1462    /// connecting — and then `Unreachable`.
1463    #[tokio::test(start_paused = true)]
1464    async fn an_operator_host_that_never_answers_is_unreachable_after_the_client_timeout() {
1465        let addr = didbot_http::test_support::hung_server();
1466        let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1467        let client = didbot_http::client();
1468        let started = tokio::time::Instant::now();
1469        let fetching = fetch_claim(
1470            &client,
1471            &operator_did,
1472            "pds.example.test",
1473            PrivateAddresses::Allowed,
1474        );
1475        let err = tokio::time::timeout(didbot_http::DEFAULT_TIMEOUT * 2, fetching)
1476            .await
1477            .expect("the client's own timeout fires first")
1478            .expect_err("nothing ever answers");
1479        assert!(matches!(err, ClaimFetchError::Unreachable(_)), "{err:?}");
1480        assert_eq!(started.elapsed(), didbot_http::DEFAULT_CONNECT_TIMEOUT);
1481    }
1482
1483    #[tokio::test]
1484    async fn a_missing_record_is_ok_none_not_an_error() {
1485        let (addr, handle, ca) = fake_operator_pds(HashMap::new()).await;
1486        let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1487        let client = client_trusting(&ca);
1488
1489        let claim = fetch_claim(
1490            &client,
1491            &operator_did,
1492            "pds.example.test",
1493            PrivateAddresses::Allowed,
1494        )
1495        .await
1496        .expect("a 400 RecordNotFound is not an unreachable repository");
1497        assert!(claim.is_none());
1498
1499        handle.abort();
1500    }
1501
1502    #[tokio::test]
1503    async fn a_400_that_is_not_record_not_found_is_unreachable_not_an_absent_claim() {
1504        // The operator's PDS answered, and what it said was that the request
1505        // was wrong -- a collection it does not know, a malformed rkey, a
1506        // proxy in front of it rewriting the query. Reading that as "there is
1507        // no operator claim" pauses provisioning for a reason nothing names.
1508        let (addr, handle, ca) =
1509            fake_operator_pds_answering(HashMap::new(), "InvalidRequest").await;
1510        let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1511        let client = client_trusting(&ca);
1512
1513        let result = fetch_claim(
1514            &client,
1515            &operator_did,
1516            "pds.example.test",
1517            PrivateAddresses::Allowed,
1518        )
1519        .await;
1520        assert!(
1521            matches!(result, Err(ClaimFetchError::Unreachable(_))),
1522            "a 400 the poll cannot read as RecordNotFound must be loud, got {result:?}"
1523        );
1524
1525        handle.abort();
1526    }
1527
1528    /// A 400's body is read under the bound the record is read under, so one
1529    /// naming `RecordNotFound` past it is an answer the poll could not read,
1530    /// not an absent claim.
1531    #[tokio::test]
1532    async fn a_400_body_past_the_bound_is_unreachable_not_an_absent_claim() {
1533        let padding = "x".repeat(didbot_http::MAX_JSON_BODY);
1534        let router = Router::new().route(
1535            "/xrpc/com.atproto.repo.getRecord",
1536            get(move || {
1537                let padding = padding.clone();
1538                async move {
1539                    (
1540                        axum::http::StatusCode::BAD_REQUEST,
1541                        Json(json!({ "error": RECORD_NOT_FOUND, "message": padding })),
1542                    )
1543                }
1544            }),
1545        );
1546        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1547            .await
1548            .expect("bind");
1549        let addr = listener.local_addr().expect("local addr");
1550        let handle = tokio::spawn(async move {
1551            axum::serve(listener, router).await.ok();
1552        });
1553
1554        let result = fetch_claim_from(
1555            &didbot_http::client(),
1556            &format!("http://{addr}"),
1557            "did:web:operator.example",
1558            "pds.example.test",
1559        )
1560        .await;
1561        assert!(
1562            matches!(result, Err(ClaimFetchError::Unreachable(_))),
1563            "an oversized 400 was read as an absent claim: {result:?}"
1564        );
1565
1566        handle.abort();
1567    }
1568
1569    /// The operator whose identity is a `did:plc` — the ordinary case for a
1570    /// person with a Bluesky account — reaches a confirmed claim.
1571    ///
1572    /// The whole path runs: the document is fetched, checked to be the one
1573    /// belonging to that DID, and its `#atproto_pds` service is where the
1574    /// record is read from; the record is then handed to the same
1575    /// `OperatorRelation::poll` a live tick uses, which is where "confirmed"
1576    /// actually means something. The one step held out is the URL the
1577    /// directory lives at, which `didbot-identity` owns and tests, and which
1578    /// `a_did_plc_resolves_against_the_plc_directory` below pins from here.
1579    #[tokio::test]
1580    async fn a_did_plc_operator_reaches_a_confirmed_claim() {
1581        let operator_did = "did:plc:nlzmjyfv6loqtxyzvdcznwgf";
1582        let hostname = "pds.example.test";
1583        let server_did = "did:web:pds.example.test";
1584
1585        let mut records = HashMap::new();
1586        records.insert(
1587            hostname.to_owned(),
1588            json!({
1589                "subject": server_did,
1590                "createdAt": "2026-01-01T00:00:00Z",
1591            }),
1592        );
1593        let (addr, handle, ca) = fake_operator_pds_for(operator_did, records).await;
1594        let document_url = format!("https://{addr}{DIRECTORY_PATH}");
1595        let client = client_trusting(&ca);
1596
1597        let claim = fetch_claim_at(
1598            &client,
1599            &document_url,
1600            operator_did,
1601            hostname,
1602            PrivateAddresses::Allowed,
1603        )
1604        .await
1605        .expect("the directory answered and the operator's pds was reached")
1606        .expect("the operator's repository holds a claim at this hostname");
1607        assert_eq!(claim.subject, server_did);
1608
1609        let relation =
1610            OperatorRelation::new(operator_did, hostname, server_did, time::Duration::hours(6));
1611        assert_eq!(
1612            relation.poll(
1613                &Prefetched(Ok(Some(claim))),
1614                OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("valid timestamp"),
1615            ),
1616            OperatorTransition::NewlyClaimed,
1617            "a did:plc operator's claim confirms exactly as a did:web one does"
1618        );
1619        assert!(relation.is_claimed());
1620
1621        handle.abort();
1622    }
1623
1624    /// A directory that answers with a document belonging to somebody else
1625    /// is not an answer about this operator, and the claim in the repository
1626    /// that document names is not this operator's claim.
1627    #[tokio::test]
1628    async fn a_document_claiming_another_did_is_not_read_as_the_operators() {
1629        let hostname = "pds.example.test";
1630        let mut records = HashMap::new();
1631        records.insert(
1632            hostname.to_owned(),
1633            json!({
1634                "subject": "did:web:pds.example.test",
1635                "createdAt": "2026-01-01T00:00:00Z",
1636            }),
1637        );
1638        let (addr, handle, ca) =
1639            fake_operator_pds_for("did:plc:nlzmjyfv6loqtxyzvdcznwgf", records).await;
1640        let document_url = format!("https://{addr}{DIRECTORY_PATH}");
1641        let client = client_trusting(&ca);
1642
1643        let result = fetch_claim_at(
1644            &client,
1645            &document_url,
1646            "did:plc:vwmrkybzt2ncwyipgnu6cocx",
1647            hostname,
1648            PrivateAddresses::Allowed,
1649        )
1650        .await;
1651        assert!(
1652            matches!(result, Err(ClaimFetchError::Unreachable(_))),
1653            "the document at this url claims a different did, got {result:?}"
1654        );
1655
1656        handle.abort();
1657    }
1658
1659    /// The half held out of the test above: which URL each method's document
1660    /// is fetched from. `did:web` is the hostname the DID names, `did:plc`
1661    /// the PLC directory — and this poll takes both from the one dispatch in
1662    /// `didbot-identity` rather than deciding either itself.
1663    #[test]
1664    fn a_did_plc_resolves_against_the_plc_directory() {
1665        let plc = "did:plc:nlzmjyfv6loqtxyzvdcznwgf";
1666        assert_eq!(
1667            didbot_identity::document_url(plc).expect("did:plc is a method this poll resolves"),
1668            format!("{}/{plc}", didbot_identity::DEFAULT_PLC_DIRECTORY)
1669        );
1670        assert_eq!(
1671            didbot_identity::document_url("did:web:operator.example")
1672                .expect("did:web is a method this poll resolves"),
1673            "https://operator.example/.well-known/did.json"
1674        );
1675    }
1676
1677    /// A DID this deployment has no way to resolve is reported as its own
1678    /// error, and still lands in the grace window: nothing about being
1679    /// unable to look confirms a claim.
1680    #[tokio::test]
1681    async fn an_operator_did_this_poll_cannot_resolve_never_confirms_a_claim() {
1682        let client = didbot_http::client();
1683        let result = fetch_claim(
1684            &client,
1685            "did:key:zQ3shrubbery",
1686            "pds.example.test",
1687            PrivateAddresses::Allowed,
1688        )
1689        .await;
1690        assert!(
1691            matches!(result, Err(ClaimFetchError::Unresolvable(_))),
1692            "a method with no document url is this deployment's configuration, \
1693             not somebody else's outage, got {result:?}"
1694        );
1695
1696        let relation = OperatorRelation::new(
1697            "did:key:zQ3shrubbery",
1698            "pds.example.test",
1699            "did:web:pds.example.test",
1700            time::Duration::hours(6),
1701        );
1702        let now = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("valid timestamp");
1703        assert_eq!(
1704            relation.poll(&Prefetched(result), now),
1705            OperatorTransition::StillUnclaimed,
1706            "an unresolvable operator did leaves the server unclaimed"
1707        );
1708        assert!(!relation.is_claimed());
1709    }
1710
1711    /// The other side of the same taxonomy as
1712    /// `an_operator_did_this_poll_cannot_resolve_never_confirms_a_claim`: a
1713    /// DID that resolves fine to a host that is not answering is somebody
1714    /// else's outage, and stays distinct from one this deployment cannot
1715    /// resolve at all.
1716    #[tokio::test]
1717    async fn a_closed_port_is_unreachable_rather_than_unresolvable() {
1718        // Bind and immediately drop, freeing the port with nothing listening.
1719        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1720            .await
1721            .expect("bind");
1722        let addr = listener.local_addr().expect("local addr");
1723        drop(listener);
1724
1725        let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1726        let client = didbot_http::client();
1727        let result = fetch_claim(
1728            &client,
1729            &operator_did,
1730            "pds.example.test",
1731            PrivateAddresses::Allowed,
1732        )
1733        .await;
1734        assert!(matches!(result, Err(ClaimFetchError::Unreachable(_))));
1735    }
1736    // ------------------------------------------------------------------
1737    // The nudge, and the bound that stops it amplifying
1738    // ------------------------------------------------------------------
1739
1740    /// The property the whole nudge design exists to hold: a burst of
1741    /// "please look now" costs this server *one* extra outbound request, not
1742    /// one per nudge. The bound is on the outbound rate, so it does not
1743    /// matter how many arrive or how fast.
1744    #[tokio::test(flavor = "multi_thread")]
1745    async fn a_burst_of_nudges_produces_at_most_one_extra_poll() {
1746        use std::sync::atomic::{AtomicUsize, Ordering};
1747
1748        let hits = Arc::new(AtomicUsize::new(0));
1749        let (addr, server, ca) = fake_operator_pds_counting(HashMap::new(), hits.clone()).await;
1750        let operator = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1751
1752        let relation = Arc::new(OperatorRelation::new(
1753            operator,
1754            "pds.example.test",
1755            "did:web:pds.example.test",
1756            time::Duration::hours(6),
1757        ));
1758        let nudge = Arc::new(PollNudge::new());
1759        let poll = OperatorPoll::spawn(
1760            relation,
1761            Arc::new(Estop::default()),
1762            Arc::new(ServerLifecycle::unclaimed()),
1763            Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1764            client_trusting(&ca),
1765            PrivateAddresses::Allowed,
1766            PollSchedule {
1767                // Long enough that the timer never fires a second time
1768                // inside this test, so every poll after the immediate first
1769                // one is attributable to a nudge.
1770                interval: StdDuration::from_secs(3600),
1771                // Wide enough that a poll -- a TLS handshake and two
1772                // requests -- finishes inside the window each assertion
1773                // below is about, on a machine doing other things.
1774                floor: StdDuration::from_millis(360),
1775            },
1776            nudge.clone(),
1777            None,
1778            Arc::new(OperatorRecords::new()),
1779            None,
1780        );
1781
1782        // Let the immediate first tick land.
1783        tokio::time::sleep(StdDuration::from_millis(180)).await;
1784        let after_first = hits.load(Ordering::SeqCst);
1785        assert_eq!(after_first, 1, "the first tick should fire immediately");
1786
1787        // Now shout. Every one of these lands inside the floor window the
1788        // first poll opened.
1789        for _ in 0..200 {
1790            nudge.nudge();
1791        }
1792        tokio::time::sleep(StdDuration::from_millis(120)).await;
1793        assert_eq!(
1794            hits.load(Ordering::SeqCst),
1795            1,
1796            "a nudge inside the floor window must wait, not poll"
1797        );
1798
1799        // And when the floor expires, the whole burst collapses into one.
1800        tokio::time::sleep(StdDuration::from_millis(600)).await;
1801        assert_eq!(
1802            hits.load(Ordering::SeqCst),
1803            2,
1804            "200 nudges must cost one extra outbound request, not 200"
1805        );
1806
1807        poll.stop().await;
1808        server.abort();
1809    }
1810
1811    /// And the nudge is not decorative: it does bring a poll forward, which
1812    /// is the whole reason to accept one.
1813    #[tokio::test(flavor = "multi_thread")]
1814    async fn a_nudge_brings_the_next_poll_forward() {
1815        use std::sync::atomic::{AtomicUsize, Ordering};
1816
1817        let hits = Arc::new(AtomicUsize::new(0));
1818        let (addr, server, ca) = fake_operator_pds_counting(HashMap::new(), hits.clone()).await;
1819        let operator = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1820
1821        let relation = Arc::new(OperatorRelation::new(
1822            operator,
1823            "pds.example.test",
1824            "did:web:pds.example.test",
1825            time::Duration::hours(6),
1826        ));
1827        let nudge = Arc::new(PollNudge::new());
1828        let poll = OperatorPoll::spawn(
1829            relation,
1830            Arc::new(Estop::default()),
1831            Arc::new(ServerLifecycle::unclaimed()),
1832            Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1833            client_trusting(&ca),
1834            PrivateAddresses::Allowed,
1835            PollSchedule {
1836                interval: StdDuration::from_secs(3600),
1837                // See the floor in the test above: a poll now opens a TLS
1838                // connection, and each window has to cover one.
1839                floor: StdDuration::from_millis(150),
1840            },
1841            nudge.clone(),
1842            None,
1843            Arc::new(OperatorRecords::new()),
1844            None,
1845        );
1846
1847        tokio::time::sleep(StdDuration::from_millis(300)).await;
1848        assert_eq!(hits.load(Ordering::SeqCst), 1);
1849        // Past the floor now, so this one is taken immediately rather than
1850        // waited out.
1851        nudge.nudge();
1852        tokio::time::sleep(StdDuration::from_millis(300)).await;
1853        assert_eq!(
1854            hits.load(Ordering::SeqCst),
1855            2,
1856            "an hour-long timer plus a nudge should still have polled twice"
1857        );
1858
1859        poll.stop().await;
1860        server.abort();
1861    }
1862
1863    /// A handle lookup that never answers holds up neither the claim nor
1864    /// the next poll: the check runs beside the tick, not inside it.
1865    #[tokio::test(flavor = "multi_thread")]
1866    async fn a_handle_lookup_that_never_answers_does_not_hold_the_claim() {
1867        let mut records = HashMap::new();
1868        records.insert(
1869            "pds.example.test".to_owned(),
1870            json!({
1871                "subject": "did:web:pds.example.test",
1872                "createdAt": "2026-01-01T00:00:00Z",
1873            }),
1874        );
1875        let (addr, server, ca) = fake_operator_pds(records).await;
1876        let relation = Arc::new(OperatorRelation::new(
1877            format!("did:web:{}%3A{}", addr.ip(), addr.port()),
1878            "pds.example.test",
1879            "did:web:pds.example.test",
1880            time::Duration::hours(6),
1881        ));
1882        let lifecycle = Arc::new(ServerLifecycle::unclaimed());
1883        let nudge = Arc::new(PollNudge::new());
1884        let poll = OperatorPoll::spawn(
1885            relation.clone(),
1886            Arc::new(Estop::default()),
1887            lifecycle.clone(),
1888            Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1889            client_trusting(&ca),
1890            PrivateAddresses::Allowed,
1891            PollSchedule {
1892                interval: StdDuration::from_secs(3600),
1893                floor: StdDuration::from_millis(150),
1894            },
1895            nudge.clone(),
1896            None,
1897            Arc::new(OperatorRecords::new()),
1898            Some(Arc::new(OperatorHandle::new(
1899                crate::operator_handle::tests::Stalls,
1900            ))),
1901        );
1902        tokio::time::sleep(StdDuration::from_millis(300)).await;
1903        assert!(relation.is_claimed(), "the first tick found the claim");
1904        assert_eq!(lifecycle.state(), didbot_pds::ServerState::Claimed);
1905
1906        // Well inside `LOOKUP_TIMEOUT`, with the first lookup still stalled.
1907        let started = tokio::time::Instant::now();
1908        nudge.nudge_and_wait(StdDuration::from_secs(3)).await;
1909        assert!(
1910            started.elapsed() < StdDuration::from_secs(2),
1911            "the next poll waited on the lookup: {:?}",
1912            started.elapsed()
1913        );
1914        assert!(relation.is_claimed());
1915
1916        poll.stop().await;
1917        server.abort();
1918    }
1919
1920    /// A caller waiting on a nudge hears back when the poll it asked for
1921    /// finishes, not when its own bound runs out; and with no poll running
1922    /// it hears back at once.
1923    #[tokio::test(flavor = "multi_thread")]
1924    async fn a_wait_on_a_nudge_ends_with_the_poll_it_asked_for() {
1925        use std::sync::atomic::{AtomicUsize, Ordering};
1926
1927        let idle = PollNudge::new();
1928        let started = tokio::time::Instant::now();
1929        assert!(!idle.nudge_and_wait(StdDuration::from_secs(5)).await);
1930        assert!(started.elapsed() < StdDuration::from_secs(1));
1931
1932        let hits = Arc::new(AtomicUsize::new(0));
1933        let (addr, server, ca) = fake_operator_pds_counting(HashMap::new(), hits.clone()).await;
1934        let relation = Arc::new(OperatorRelation::new(
1935            format!("did:web:{}%3A{}", addr.ip(), addr.port()),
1936            "pds.example.test",
1937            "did:web:pds.example.test",
1938            time::Duration::hours(6),
1939        ));
1940        let nudge = Arc::new(PollNudge::new());
1941        let poll = OperatorPoll::spawn(
1942            relation,
1943            Arc::new(Estop::default()),
1944            Arc::new(ServerLifecycle::unclaimed()),
1945            Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1946            client_trusting(&ca),
1947            PrivateAddresses::Allowed,
1948            PollSchedule {
1949                interval: StdDuration::from_secs(3600),
1950                floor: StdDuration::from_millis(150),
1951            },
1952            nudge.clone(),
1953            None,
1954            Arc::new(OperatorRecords::new()),
1955            None,
1956        );
1957        tokio::time::sleep(StdDuration::from_millis(300)).await;
1958        assert_eq!(hits.load(Ordering::SeqCst), 1);
1959
1960        let started = tokio::time::Instant::now();
1961        // This fake lists no records, so the poll reads none.
1962        assert!(!nudge.nudge_and_wait(StdDuration::from_secs(5)).await);
1963        assert!(
1964            started.elapsed() < StdDuration::from_secs(4),
1965            "the wait ends with the poll, not the bound: {:?}",
1966            started.elapsed()
1967        );
1968        assert_eq!(hits.load(Ordering::SeqCst), 2);
1969
1970        poll.stop().await;
1971        server.abort();
1972    }
1973}