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}