Skip to main content

cairn_mod/pds_admin/
backend.rs

1//! [`PdsAdminBackend`] trait + error types + opaque action-id
2//! newtype (§F23, #84, v1.7).
3//!
4//! Type-only foundation issue. The trait is what
5//! `OzoneBackend` (#86–#90) will implement; v1.8+ adds
6//! `LocusBackend` and potentially others. This issue
7//! intentionally ships no implementations, no HTTP code, no
8//! audit-table integration, and no recordAction-pipeline call
9//! sites — those are #85 / #86+ / #87+ in dependency order.
10//!
11//! See A2 in `.design-notes/v1_7-architectural-decisions.md`
12//! for the trait shape rationale; A5 for the
13//! `apply_label`/`negate_label` `Unsupported` posture on
14//! `OzoneBackend`.
15
16use std::fmt;
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20
21use super::types::Subject;
22
23/// Backend-specific identifier for a recorded enforcement
24/// action.
25///
26/// Different backends return different identifier shapes:
27/// - **Aurora-Locus** (v1.8+, per Aurora findings §7) returns
28///   an auto-incrementing `INTEGER moderation_id`.
29/// - **bsky-PDS** (v1.7's `OzoneBackend`)'s
30///   `com.atproto.admin.updateSubjectStatus` returns no id at
31///   all; the backend implementation synthesizes one
32///   client-side (e.g., a UUID or a `(timestamp, subject)`
33///   tuple — chosen at #86 implementation time).
34///
35/// cairn-mod's audit log treats the value as opaque; only the
36/// backend that issued it can interpret it. Stored as `String`
37/// for serialization simplicity; backends may encode structured
38/// data (e.g., JSON) when needed.
39///
40/// # Construction
41///
42/// Construction is explicit via [`Self::new`]. There's
43/// deliberately no `From<String>` blanket impl — backend
44/// boundaries should be unambiguous in code review (a `.into()`
45/// at a backend's response-mapping site is harder to grep for
46/// than a `BackendActionId::new(...)`).
47///
48/// # Equality and hashing
49///
50/// `Eq + Hash` is intentional: v1.8+ retry logic and the audit
51/// table's lookup paths use this type as a `HashMap` /
52/// `HashSet` key.
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub struct BackendActionId(String);
55
56impl BackendActionId {
57    /// Wrap a backend-issued identifier string. The caller is
58    /// responsible for any backend-specific normalization
59    /// (e.g., trimming whitespace, lowercasing hex) before
60    /// construction.
61    pub fn new(id: impl Into<String>) -> Self {
62        Self(id.into())
63    }
64
65    /// Borrow the underlying identifier as a `&str`. Use this
66    /// at consumer sites (audit-row construction, log lines)
67    /// rather than `Display` when the value is being stored or
68    /// matched programmatically — `as_str` is grep-friendlier
69    /// than `to_string()`.
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73}
74
75impl fmt::Display for BackendActionId {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81/// Errors that can occur when calling a [`PdsAdminBackend`]
82/// method.
83///
84/// Backends map their wire-level errors into these variants.
85/// The audit log (#85) records the variant + any contained
86/// message; the recordAction pipeline (#87+) decides whether to
87/// retry, surface, or log-and-continue based on the variant.
88///
89/// # Mapping guidance for implementations
90///
91/// - **Network-layer failures** (DNS, TCP, TLS, timeout) →
92///   [`Self::Network`]. Treat as transient at the call site;
93///   the recordAction pipeline may decide to log-and-continue
94///   per A13.
95/// - **Auth failures** (401 / 403, expired credentials,
96///   insufficient role) → [`Self::Auth`]. Non-transient; don't
97///   retry without operator intervention.
98/// - **Backend rate limiting** (429) → [`Self::RateLimited`].
99///   Pass through any `Retry-After` hint the backend provided.
100/// - **State conflicts** (already-takendown account on
101///   `takedown_account`, not-takendown on `restore_account`) →
102///   [`Self::Conflict`]. The pipeline may treat as a no-op
103///   success or a real error depending on context.
104/// - **Backend error envelopes without a specific variant** →
105///   [`Self::RemoteError`] with verbatim code + message for
106///   audit forensics.
107/// - **Pre-call validation failures** (malformed request
108///   constructed by cairn-mod itself) → [`Self::Validation`].
109///   Should be rare in production; usually a cairn-mod-side
110///   bug.
111/// - **Unsupported method** → [`Self::Unsupported`]. Should
112///   never reach the recordAction pipeline if #83's action_map
113///   validation runs correctly; if it does, the operator
114///   misconfigured.
115#[derive(Debug, thiserror::Error)]
116pub enum BackendError {
117    /// The backend does not support this method on this PDS.
118    /// Example: `OzoneBackend::apply_label` (per A5 — labels
119    /// stay native to cairn-mod's `subscribeLabels`).
120    ///
121    /// This variant is config-time-detectable; the
122    /// recordAction pipeline should never see it at runtime if
123    /// the action_map validation in #83 ran correctly. If it
124    /// does, that's a bug — log loudly and surface to the
125    /// operator.
126    #[error("backend does not support this operation: {0}")]
127    Unsupported(&'static str),
128
129    /// Network-layer failure (DNS, TCP, TLS, timeout).
130    /// Transient by default — the recordAction pipeline may
131    /// decide to log-and-continue per A13's "fail loud, let the
132    /// operator decide" posture; v1.8 may add automatic retry.
133    #[error("network error: {0}")]
134    Network(String),
135
136    /// Authentication or authorization failure. Either the
137    /// configured credentials are wrong, the credentials' role
138    /// is insufficient, or the credentials expired.
139    /// Non-transient: don't retry without operator
140    /// intervention.
141    #[error("backend auth error: {0}")]
142    Auth(String),
143
144    /// Backend signaled rate limiting. `retry_after_seconds`
145    /// carries the backend's hint when one was provided
146    /// (typically via a `Retry-After` HTTP header).
147    #[error(
148        "backend rate limited: {message}{}",
149        retry_after_seconds.map(|s| format!(" (retry after {s}s)")).unwrap_or_default()
150    )]
151    RateLimited {
152        /// Backend-supplied or client-synthesized message
153        /// describing the rate-limit context.
154        message: String,
155        /// Optional retry hint in seconds.
156        retry_after_seconds: Option<u32>,
157    },
158
159    /// The action conflicts with the backend's current state.
160    /// Examples: trying to `restore_account` an account that
161    /// isn't taken down; trying to `takedown_account` an
162    /// account that's already taken down. The recordAction
163    /// pipeline may treat this as a no-op success or a real
164    /// error depending on context.
165    #[error("backend state conflict: {0}")]
166    Conflict(String),
167
168    /// Backend returned an error envelope cairn-mod doesn't
169    /// have a specific variant for. Carries the backend's error
170    /// code and message verbatim for audit-log forensics.
171    #[error("backend remote error: code={code} message={message}")]
172    RemoteError {
173        /// Backend-supplied error code (typically the value of
174        /// XRPC's `error` field, or an HTTP status reason
175        /// phrase).
176        code: String,
177        /// Backend-supplied human-readable message.
178        message: String,
179    },
180
181    /// Request validation failed before the call could be
182    /// made. Distinct from [`Self::Conflict`] (post-call) —
183    /// `Validation` means the call was malformed at
184    /// construction time. Should be rare in production; usually
185    /// indicates a cairn-mod-side bug.
186    #[error("backend validation error: {0}")]
187    Validation(String),
188}
189
190/// Metadata returned from a successful backend probe (#90, §A15).
191///
192/// v1.7's surface is intentionally minimal — just enough for
193/// operator-facing startup logs ("your `[pds_admin.ozone]` is
194/// reachable and accepts the configured admin credentials"). v1.8
195/// may grow this into capability negotiation when LocusBackend
196/// lands and the `Locus`/`Ozone` runtime selector needs to know
197/// what each backend supports.
198///
199/// The probe runs once at startup. Failure does not block startup
200/// (per A15); the backend's first real call from the recordAction
201/// dispatch retries naturally. The probe's value is the
202/// fast-feedback loop: an operator who misconfigures
203/// `admin_password_env` learns at boot rather than on the first
204/// actual moderation action.
205#[derive(Debug, Clone)]
206pub struct ProbeReport {
207    /// Stable backend identifier. v1.7 only emits `"ozone"`
208    /// (bsky-PDS); v1.8 will add `"locus"` (Aurora-Locus). Used
209    /// in operator-facing log lines and future v1.8 capability
210    /// dispatch.
211    pub backend_name: &'static str,
212
213    /// PDS endpoint that was probed. The full URL operators see
214    /// in their config; helpful for "wait, we hit which PDS?"
215    /// diagnostics during multi-instance rollouts.
216    pub pds_url: String,
217
218    /// Backend-reported version, if available. **`None` for
219    /// `OzoneBackend` in v1.7** — bsky-PDS's
220    /// `com.atproto.server.describeServer` doesn't expose a
221    /// version field as of the responses cairn-mod has been
222    /// validated against. v1.8's LocusBackend may populate it.
223    pub detected_version: Option<String>,
224
225    /// Free-form capability strings the backend reported.
226    /// **Empty for v1.7.** Reserved for v1.8's capability
227    /// negotiation: `OzoneBackend` would report `"takedown"`,
228    /// `"label-emit"`, etc.; the runtime can then short-circuit
229    /// dispatch entries the backend doesn't claim to support.
230    pub capabilities: Vec<String>,
231}
232
233/// Errors from constructing a backend at startup.
234///
235/// Distinct from [`BackendError`] (which is per-call): these
236/// are configuration-time failures that prevent a backend from
237/// existing in the first place. v1.7 only has one variant
238/// ([`Self::HttpClient`]) since `OzoneBackend::new` only owns
239/// HTTP-client construction; v1.8's `LocusBackend` will likely
240/// add variants for JWT-secret resolution and other key-material
241/// loading.
242#[derive(Debug, thiserror::Error)]
243pub enum BackendInitError {
244    /// `reqwest::Client::builder().build()` failed. Almost
245    /// always a TLS/runtime configuration problem; in practice
246    /// reqwest's defaults don't fail on supported platforms,
247    /// but the fallible signature is preserved so a future
248    /// custom-CA / custom-DNS configuration path can surface
249    /// errors cleanly.
250    #[error("failed to build HTTP client: {0}")]
251    HttpClient(String),
252}
253
254/// Trait implemented by PDS-side enforcement backends.
255///
256/// v1.7 ships one implementation: `OzoneBackend` for bsky-PDS
257/// (#86–#90). v1.8+ adds `LocusBackend` for Aurora-Locus and
258/// potentially others.
259///
260/// Methods are async because every implementation involves
261/// network I/O. Implementations must be `Send + Sync` for use
262/// across the recordAction pipeline's async boundaries.
263///
264/// # Method semantics
265///
266/// - [`takedown_account`](Self::takedown_account) and
267///   [`suspend_account`](Self::suspend_account) are
268///   **mutating**; they record an action on the backend and
269///   return its identifier for later reversal via
270///   [`restore_account`](Self::restore_account).
271/// - [`restore_account`](Self::restore_account) is the
272///   inverse; it requires the prior action's
273///   [`BackendActionId`] so the backend can look up the
274///   original action's details.
275/// - [`apply_label`](Self::apply_label) and
276///   [`negate_label`](Self::negate_label) exist for backends
277///   that own label distribution (Aurora-Locus, future Rust
278///   PDSes). For backends where cairn-mod's own
279///   `subscribeLabels` (§F4) is the label distribution surface
280///   (bsky-PDS), these methods return
281///   [`BackendError::Unsupported`]. See A5 in v1.7
282///   architectural decisions.
283///
284/// # Error handling
285///
286/// Implementations map wire-level errors into [`BackendError`]
287/// variants. The recordAction pipeline (#87+) decides
288/// retry/surface/log-and-continue based on the variant. See the
289/// [`BackendError`] doc-comment for per-variant mapping
290/// guidance.
291///
292/// # Startup probe
293///
294/// [`probe`](Self::probe) runs once at server startup (per §A15).
295/// Failure does not block startup — the operator-actionable
296/// signal is logged and the first real call retries.
297#[async_trait]
298pub trait PdsAdminBackend: Send + Sync {
299    /// Take down an account at the PDS side. Records an
300    /// enforcement action on the backend and returns its
301    /// identifier for later reversal.
302    ///
303    /// `reason` is a backend-facing rationale (the operator's
304    /// `[moderation_reasons]` identifier, or a
305    /// `[pds_admin.action_map]`-mapped string); `notes` is
306    /// optional moderator-facing free text.
307    ///
308    /// `precipitating_action_id` is the `subject_actions(id)` of
309    /// the cairn-mod-side action that triggered this call. Some
310    /// backends (notably bsky-PDS, which returns no action id of
311    /// its own) embed it into the synthesized
312    /// [`BackendActionId`] so a later [`Self::restore_account`]
313    /// call can find the original; others (Aurora-Locus, v1.8+)
314    /// pass it through to a backend-side `ref` field. Adopted in
315    /// #87 with the `OzoneBackend::takedown_account` body —
316    /// implementations that don't need it ignore the parameter.
317    async fn takedown_account(
318        &self,
319        did: &str,
320        reason: &str,
321        notes: Option<&str>,
322        precipitating_action_id: i64,
323    ) -> Result<BackendActionId, BackendError>;
324
325    /// Suspend an account at the PDS side, optionally with a
326    /// duration hint. Semantics vary by backend: bsky-PDS
327    /// expresses suspension via takedown with a scheduled lift
328    /// (which v1.7 doesn't yet implement —
329    /// `with_lift_after = true` is rejected at config-load per
330    /// #83); Aurora-Locus has a distinct `suspendUntil` shape
331    /// per Aurora findings §7.
332    ///
333    /// `duration_days` is the operator's intended suspension
334    /// length. `None` means "suspend until manually restored."
335    /// `precipitating_action_id` follows the same convention as
336    /// [`Self::takedown_account`].
337    async fn suspend_account(
338        &self,
339        did: &str,
340        reason: &str,
341        duration_days: Option<u32>,
342        notes: Option<&str>,
343        precipitating_action_id: i64,
344    ) -> Result<BackendActionId, BackendError>;
345
346    /// Restore (un-takedown / un-suspend) an account at the
347    /// PDS side. Requires the prior action's
348    /// [`BackendActionId`] so the backend can look up the
349    /// original action's details (some backends require this;
350    /// others ignore the parameter).
351    async fn restore_account(
352        &self,
353        did: &str,
354        prior_action_id: &BackendActionId,
355        reason: &str,
356    ) -> Result<(), BackendError>;
357
358    /// Apply a label at the PDS side.
359    ///
360    /// **Not implemented by `OzoneBackend` in v1.7** (#89):
361    /// cairn-mod's labels stay native to its `subscribeLabels`
362    /// surface. `OzoneBackend::apply_label` returns
363    /// [`BackendError::Unsupported`] at runtime; #83's
364    /// action_map validation emits a config-load warning when
365    /// an action_map entry routes to this method.
366    ///
367    /// `expires_days` is an optional expiry hint mirroring
368    /// `subject_actions.expires_at` for `temp_suspension`-
369    /// derived labels.
370    async fn apply_label(
371        &self,
372        subject: &Subject,
373        val: &str,
374        expires_days: Option<u32>,
375    ) -> Result<(), BackendError>;
376
377    /// Negate a previously-applied label at the PDS side.
378    /// Same `Unsupported` posture as
379    /// [`apply_label`](Self::apply_label) for `OzoneBackend` in
380    /// v1.7.
381    async fn negate_label(&self, subject: &Subject, val: &str) -> Result<(), BackendError>;
382
383    /// Probe the configured backend at startup (§A15, #90).
384    ///
385    /// Performs a single non-mutating request to verify the
386    /// backend is reachable and authenticated. Failure is logged
387    /// but does **not** block cairn-mod startup — the PDS-admin
388    /// bridge will retry on the first real call from the
389    /// recordAction dispatch (per §A13's "fail loud, let the
390    /// operator decide" posture).
391    ///
392    /// Implementations should:
393    /// - use a non-mutating endpoint (GET, not POST), so a
394    ///   misconfigured probe never accidentally takes down an
395    ///   account at startup;
396    /// - authenticate exactly as production calls do, so an
397    ///   auth failure here means a real auth failure (not a
398    ///   probe-specific quirk operators have to debug separately);
399    /// - return [`ProbeReport`] with whatever metadata the
400    ///   backend exposes on success — version, capabilities, etc.
401    ///   v1.7 leaves both `Some(version)` and a non-empty
402    ///   capabilities list to v1.8 LocusBackend; v1.7's
403    ///   `OzoneBackend` returns the minimal report ("we reached
404    ///   bsky-PDS at the configured URL with the configured
405    ///   credentials").
406    ///
407    /// v1.7's `OzoneBackend` uses
408    /// `com.atproto.server.describeServer` (per bsky-PDS findings).
409    /// v1.8's `LocusBackend` will use Aurora-Locus's equivalent
410    /// describe endpoint.
411    async fn probe(&self) -> Result<ProbeReport, BackendError>;
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    /// Compile-time assertion that the trait is `Send + Sync`.
419    /// Used so the trait can cross async boundaries in the
420    /// recordAction pipeline (#87+) and so trait objects can
421    /// be shared between writer-task callers via
422    /// `Arc<dyn PdsAdminBackend>`. The body is empty because
423    /// the bound is checked by the type checker at the
424    /// signature level; calling this function would link to a
425    /// no-op.
426    #[allow(dead_code)]
427    fn _assert_pds_admin_backend_send_sync() {
428        fn assert_send_sync<T: Send + Sync + ?Sized>() {}
429        assert_send_sync::<dyn PdsAdminBackend>();
430    }
431
432    /// Compile-time assertion that errors cross async
433    /// boundaries cleanly. Required so `Result<_, BackendError>`
434    /// is a valid `Future::Output`.
435    #[allow(dead_code)]
436    fn _assert_backend_error_send_sync() {
437        fn assert_send_sync<T: Send + Sync>() {}
438        assert_send_sync::<BackendError>();
439    }
440
441    /// Compile-time assertion that the action-id newtype
442    /// satisfies the bounds required by v1.8+ retry logic and
443    /// the audit-table's lookup paths.
444    #[allow(dead_code)]
445    fn _assert_backend_action_id_clone_eq_hash() {
446        fn assert_traits<T: Clone + Eq + std::hash::Hash>() {}
447        assert_traits::<BackendActionId>();
448    }
449
450    #[test]
451    fn backend_action_id_round_trip() {
452        let id = BackendActionId::new("backend-12345");
453        assert_eq!(id.as_str(), "backend-12345");
454        assert_eq!(format!("{id}"), "backend-12345");
455    }
456
457    #[test]
458    fn backend_action_id_eq_and_hash_consistent() {
459        // Manual sanity: two ids constructed from equal strings
460        // compare equal and hash identically — required for the
461        // type to function as a HashMap key.
462        let a = BackendActionId::new("xyz");
463        let b = BackendActionId::new(String::from("xyz"));
464        assert_eq!(a, b);
465        let mut map: std::collections::HashMap<BackendActionId, u32> =
466            std::collections::HashMap::new();
467        map.insert(a, 1);
468        assert_eq!(map.get(&b), Some(&1));
469    }
470
471    #[test]
472    fn backend_action_id_serde_roundtrip() {
473        let id = BackendActionId::new("backend-abc");
474        let json = serde_json::to_string(&id).unwrap();
475        let back: BackendActionId = serde_json::from_str(&json).unwrap();
476        assert_eq!(id, back);
477    }
478
479    #[test]
480    fn backend_error_unsupported_renders() {
481        let e = BackendError::Unsupported("OzoneBackend::apply_label");
482        assert_eq!(
483            format!("{e}"),
484            "backend does not support this operation: OzoneBackend::apply_label"
485        );
486    }
487
488    #[test]
489    fn backend_error_network_renders() {
490        let e = BackendError::Network("connection refused".into());
491        assert_eq!(format!("{e}"), "network error: connection refused");
492    }
493
494    #[test]
495    fn backend_error_auth_renders() {
496        let e = BackendError::Auth("HTTP 401".into());
497        assert_eq!(format!("{e}"), "backend auth error: HTTP 401");
498    }
499
500    #[test]
501    fn backend_error_rate_limited_with_retry_after_renders() {
502        let e = BackendError::RateLimited {
503            message: "too many requests".into(),
504            retry_after_seconds: Some(60),
505        };
506        assert_eq!(
507            format!("{e}"),
508            "backend rate limited: too many requests (retry after 60s)"
509        );
510    }
511
512    #[test]
513    fn backend_error_rate_limited_without_retry_after_renders() {
514        let e = BackendError::RateLimited {
515            message: "throttled".into(),
516            retry_after_seconds: None,
517        };
518        assert_eq!(format!("{e}"), "backend rate limited: throttled");
519    }
520
521    #[test]
522    fn backend_error_conflict_renders() {
523        let e = BackendError::Conflict("subject already taken down".into());
524        assert_eq!(
525            format!("{e}"),
526            "backend state conflict: subject already taken down"
527        );
528    }
529
530    #[test]
531    fn backend_error_remote_error_renders() {
532        let e = BackendError::RemoteError {
533            code: "InvalidRequest".into(),
534            message: "subject not a DID".into(),
535        };
536        assert_eq!(
537            format!("{e}"),
538            "backend remote error: code=InvalidRequest message=subject not a DID"
539        );
540    }
541
542    #[test]
543    fn backend_error_validation_renders() {
544        let e = BackendError::Validation("missing required field".into());
545        assert_eq!(
546            format!("{e}"),
547            "backend validation error: missing required field"
548        );
549    }
550}