Skip to main content

atproto_devtool/commands/test/labeler/
create_report.rs

1//! `report` stage: exercises the labeler's authenticated
2//! `com.atproto.moderation.createReport` path.
3//!
4//! The `sentinel` submodule builds the pollution-avoidance reason string
5//! that every committed report body carries.
6
7use std::borrow::Cow;
8use std::sync::Arc;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10use url::Url;
11
12use async_trait::async_trait;
13use miette::{Diagnostic, NamedSource, SourceSpan};
14use reqwest::StatusCode;
15use thiserror::Error;
16
17use crate::commands::test::labeler::identity::IdentityFacts;
18use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage};
19use crate::common::diagnostics::pretty_json_for_display;
20use crate::common::identity::{Did, is_local_labeler_hostname};
21
22pub mod did_doc_server;
23pub mod pollution;
24pub mod self_mint;
25pub mod sentinel;
26
27/// Raw HTTP response from POSTing `com.atproto.moderation.createReport`.
28///
29/// Mirrors `RawXrpcResponse` from the HTTP stage but specialized for the
30/// createReport shape: no typed decode (positive and negative checks need
31/// different decode strategies) and the raw body is kept for diagnostic
32/// rendering via miette.
33#[derive(Debug)]
34pub struct RawCreateReportResponse {
35    /// HTTP status code.
36    pub status: StatusCode,
37    /// Content-Type header value, if present. Lowercased for matching.
38    pub content_type: Option<String>,
39    /// Raw response body bytes.
40    pub raw_body: Arc<[u8]>,
41    /// The URL that was POSTed to (for diagnostics).
42    pub source_url: String,
43}
44
45/// Error type for `CreateReportTee` operations.
46///
47/// Kept intentionally narrow: either a transport failure (TCP / TLS / DNS /
48/// reqwest internal), or a well-formed HTTP response that we return as-is.
49/// Callers — i.e., the stage — decide what each non-2xx status means per
50/// check.
51#[derive(Debug, Error, Diagnostic)]
52pub enum CreateReportStageError {
53    /// Transport-level failure: the request never reached a well-formed
54    /// HTTP exchange.
55    #[error("createReport transport error: {source}")]
56    #[diagnostic(code = "labeler::report::transport_error")]
57    Transport {
58        /// Underlying error.
59        #[source]
60        source: Box<dyn std::error::Error + Send + Sync>,
61    },
62}
63
64/// Trait for POSTing `com.atproto.moderation.createReport`. Production
65/// impl (`RealCreateReportTee`) wraps a `reqwest::Client`; tests inject
66/// `FakeCreateReportTee` from `tests/common/mod.rs`.
67///
68/// The body is serialized from a `serde_json::Value` so negative-shape
69/// tests can POST intentionally invalid bodies without fighting the type
70/// system.
71#[async_trait]
72pub trait CreateReportTee: Send + Sync {
73    /// POST the given body to the labeler's `com.atproto.moderation.createReport`
74    /// endpoint.
75    ///
76    /// # Arguments
77    /// * `auth` — optional Bearer token. `None` ⇒ no `Authorization` header
78    ///   (for the `unauthenticated_rejected` check). `Some(token)` is
79    ///   included as `Authorization: Bearer {token}`.
80    /// * `body` — JSON body to POST. The impl sends `Content-Type: application/json`.
81    async fn post_create_report(
82        &self,
83        auth: Option<&str>,
84        body: &serde_json::Value,
85    ) -> Result<RawCreateReportResponse, CreateReportStageError>;
86}
87
88/// Raw HTTP response from XRPC calls to the PDS.
89///
90/// Similar to `RawCreateReportResponse` but used for PDS-specific calls
91/// (createSession, getServiceAuth) where the response needs to be parsed
92/// as JSON by the caller.
93#[derive(Debug)]
94pub struct RawPdsXrpcResponse {
95    /// HTTP status code.
96    pub status: StatusCode,
97    /// Raw response body bytes.
98    pub raw_body: Arc<[u8]>,
99    /// Content-Type header value, if present. Lowercased for matching.
100    pub content_type: Option<String>,
101    /// The URL that was requested (for diagnostics).
102    pub source_url: String,
103}
104
105/// Narrow seam for POSTing/GETting against the user's PDS.
106///
107/// The existing `HttpClient` in `src/common/identity.rs` is GET-only and
108/// does not support bearer headers or request bodies. This trait exists
109/// to keep those capabilities out of the identity-resolution seam.
110#[async_trait]
111pub trait PdsXrpcClient: Send + Sync {
112    /// POST `body` (JSON-serialized) to the PDS endpoint at the given path
113    /// (e.g., `"xrpc/com.atproto.server.createSession"`). Optional bearer
114    /// and `atproto-proxy` headers.
115    async fn post(
116        &self,
117        path: &str,
118        bearer: Option<&str>,
119        atproto_proxy: Option<&str>,
120        body: &serde_json::Value,
121    ) -> Result<RawPdsXrpcResponse, CreateReportStageError>;
122
123    /// GET the PDS endpoint at the given path with optional bearer and
124    /// URL-encoded query pairs.
125    async fn get(
126        &self,
127        path: &str,
128        bearer: Option<&str>,
129        query: &[(&str, &str)],
130    ) -> Result<RawPdsXrpcResponse, CreateReportStageError>;
131}
132
133/// Real `PdsXrpcClient` implementation using reqwest.
134pub struct RealPdsXrpcClient {
135    client: reqwest::Client,
136    base: Url,
137}
138
139impl RealPdsXrpcClient {
140    /// Create a new `RealPdsXrpcClient` using the given shared reqwest
141    /// client and PDS base URL.
142    pub fn new(client: reqwest::Client, base: Url) -> Self {
143        Self { client, base }
144    }
145}
146
147#[async_trait]
148impl PdsXrpcClient for RealPdsXrpcClient {
149    async fn post(
150        &self,
151        path: &str,
152        bearer: Option<&str>,
153        atproto_proxy: Option<&str>,
154        body: &serde_json::Value,
155    ) -> Result<RawPdsXrpcResponse, CreateReportStageError> {
156        let mut url = self.base.clone();
157        url.set_path(path);
158        let source_url = url.to_string();
159        let mut req = self
160            .client
161            .post(url.as_str())
162            .header("Content-Type", "application/json")
163            .body(serde_json::to_vec(body).expect("serde_json::Value always serializes"));
164        if let Some(b) = bearer {
165            req = req.header("Authorization", format!("Bearer {b}"));
166        }
167        if let Some(p) = atproto_proxy {
168            req = req.header("atproto-proxy", p);
169        }
170        let resp = req
171            .send()
172            .await
173            .map_err(|e| CreateReportStageError::Transport {
174                source: Box::new(e),
175            })?;
176        let status = resp.status();
177        let content_type = resp
178            .headers()
179            .get(reqwest::header::CONTENT_TYPE)
180            .and_then(|h| h.to_str().ok())
181            .map(|s| s.to_ascii_lowercase());
182        let body = resp
183            .bytes()
184            .await
185            .map_err(|e| CreateReportStageError::Transport {
186                source: Box::new(e),
187            })?;
188        Ok(RawPdsXrpcResponse {
189            status,
190            raw_body: Arc::from(body.as_ref()),
191            content_type,
192            source_url,
193        })
194    }
195
196    async fn get(
197        &self,
198        path: &str,
199        bearer: Option<&str>,
200        query: &[(&str, &str)],
201    ) -> Result<RawPdsXrpcResponse, CreateReportStageError> {
202        let mut url = self.base.clone();
203        url.set_path(path);
204        {
205            let mut pairs = url.query_pairs_mut();
206            for (k, v) in query {
207                pairs.append_pair(k, v);
208            }
209        }
210        let source_url = url.to_string();
211        let mut req = self.client.get(url.as_str());
212        if let Some(b) = bearer {
213            req = req.header("Authorization", format!("Bearer {b}"));
214        }
215        let resp = req
216            .send()
217            .await
218            .map_err(|e| CreateReportStageError::Transport {
219                source: Box::new(e),
220            })?;
221        let status = resp.status();
222        let content_type = resp
223            .headers()
224            .get(reqwest::header::CONTENT_TYPE)
225            .and_then(|h| h.to_str().ok())
226            .map(|s| s.to_ascii_lowercase());
227        let body = resp
228            .bytes()
229            .await
230            .map_err(|e| CreateReportStageError::Transport {
231                source: Box::new(e),
232            })?;
233        Ok(RawPdsXrpcResponse {
234            status,
235            raw_body: Arc::from(body.as_ref()),
236            content_type,
237            source_url,
238        })
239    }
240}
241
242/// Real `CreateReportTee` implementation using reqwest.
243pub struct RealCreateReportTee {
244    client: reqwest::Client,
245    endpoint: Url,
246}
247
248impl RealCreateReportTee {
249    /// Create a new `RealCreateReportTee` using the given shared reqwest
250    /// client and labeler endpoint. The endpoint is the labeler's service
251    /// URL (e.g., `https://labeler.example.com`); the POST path
252    /// `/xrpc/com.atproto.moderation.createReport` is appended.
253    pub fn new(client: reqwest::Client, endpoint: Url) -> Self {
254        Self { client, endpoint }
255    }
256}
257
258#[async_trait]
259impl CreateReportTee for RealCreateReportTee {
260    async fn post_create_report(
261        &self,
262        auth: Option<&str>,
263        body: &serde_json::Value,
264    ) -> Result<RawCreateReportResponse, CreateReportStageError> {
265        let mut url = self.endpoint.clone();
266        url.set_path("xrpc/com.atproto.moderation.createReport");
267        let source_url = url.to_string();
268
269        tracing::debug!(
270            url = %source_url,
271            auth_kind = match auth {
272                None => "none",
273                Some(t) if !t.starts_with("ey") => "malformed",
274                Some(_) => "jwt",
275            },
276            "report stage: issuing createReport POST"
277        );
278
279        let mut req = self
280            .client
281            .post(url.as_str())
282            .header("Content-Type", "application/json")
283            .body(serde_json::to_vec(body).expect("serde_json::Value always serializes"));
284        if let Some(token) = auth {
285            req = req.header("Authorization", format!("Bearer {token}"));
286        }
287
288        let response = req
289            .send()
290            .await
291            .map_err(|e| CreateReportStageError::Transport {
292                source: Box::new(e),
293            })?;
294
295        let status = response.status();
296        let content_type = response
297            .headers()
298            .get(reqwest::header::CONTENT_TYPE)
299            .and_then(|h| h.to_str().ok())
300            .map(|s| s.to_ascii_lowercase());
301
302        let body_bytes = response
303            .bytes()
304            .await
305            .map_err(|e| CreateReportStageError::Transport {
306                source: Box::new(e),
307            })?;
308
309        tracing::debug!(
310            url = %source_url,
311            status = %status,
312            body_len = body_bytes.len(),
313            "report stage: createReport response received"
314        );
315
316        Ok(RawCreateReportResponse {
317            status,
318            content_type,
319            raw_body: Arc::from(body_bytes.as_ref()),
320            source_url,
321        })
322    }
323}
324
325/// Error type for `PdsJwtFetcher` operations.
326///
327/// Carries a human-readable message; every PDS-side failure is treated as
328/// a `NetworkError` by the report stage (per AC5.3 / AC6.3).
329#[derive(Debug)]
330pub enum PdsJwtFetchError {
331    Transport(CreateReportStageError),
332    Failed(RawPdsXrpcResponse),
333    InvalidBody {
334        resp: RawPdsXrpcResponse,
335        error: serde_json::Error,
336    },
337    MissingToken(RawPdsXrpcResponse),
338}
339
340impl std::fmt::Display for PdsJwtFetchError {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        match self {
343            PdsJwtFetchError::Transport(e) => write!(f, "getServiceAuth transport: {e}"),
344            PdsJwtFetchError::Failed(resp) => {
345                write!(f, "getServiceAuth returned status {}", resp.status)
346            }
347            PdsJwtFetchError::InvalidBody { error, .. } => {
348                write!(f, "getServiceAuth body not JSON: {error}")
349            }
350            PdsJwtFetchError::MissingToken(_) => write!(f, "getServiceAuth response missing token"),
351        }
352    }
353}
354
355impl PdsJwtFetchError {
356    fn into_diagnostic(self) -> Option<Box<dyn miette::Diagnostic + Send + Sync>> {
357        match self {
358            Self::Transport(_) => None,
359            Self::Failed(resp) | Self::InvalidBody { resp, .. } | Self::MissingToken(resp) => {
360                let (source_code, span) = body_as_named_source_from_pds(&resp);
361                let diag = CreateReportDiagnostic::PdsServiceAuthRejected {
362                    origin: ResponseOrigin::Pds,
363                    status: resp.status.as_u16(),
364                    source_code,
365                    span,
366                };
367                Some(Box::new(diag))
368            }
369        }
370    }
371}
372
373/// Fetches a service-auth JWT from a PDS by first creating a session and
374/// then calling `getServiceAuth`. Used in mode-2 (`pds_service_auth_accepted`).
375pub struct PdsJwtFetcher<'a> {
376    client: &'a dyn PdsXrpcClient,
377}
378
379impl<'a> PdsJwtFetcher<'a> {
380    /// Create a new `PdsJwtFetcher` using the given PDS client.
381    pub fn new(client: &'a dyn PdsXrpcClient) -> Self {
382        Self { client }
383    }
384
385    /// Call `getServiceAuth` using the provided access JWT, returning the
386    /// minted service-auth JWT. The access JWT should come from a prior
387    /// `createSession` call.
388    pub async fn fetch_with_jwt(
389        &self,
390        access_jwt: &str,
391        aud: &str,
392        lxm: &str,
393        exp_absolute_unix: i64,
394    ) -> Result<String, PdsJwtFetchError> {
395        // getServiceAuth (GET with query params).
396        let exp_s = exp_absolute_unix.to_string();
397        let resp = self
398            .client
399            .get(
400                "xrpc/com.atproto.server.getServiceAuth",
401                Some(access_jwt),
402                &[("aud", aud), ("lxm", lxm), ("exp", &exp_s)],
403            )
404            .await
405            .map_err(PdsJwtFetchError::Transport)?;
406        if !resp.status.is_success() {
407            return Err(PdsJwtFetchError::Failed(resp));
408        }
409        let token = match serde_json::from_slice::<serde_json::Value>(&resp.raw_body) {
410            Err(error) => Err(PdsJwtFetchError::InvalidBody { resp, error }),
411            Ok(auth) => auth["token"]
412                .as_str()
413                .map(|s| s.to_string())
414                .ok_or_else(|| PdsJwtFetchError::MissingToken(resp)),
415        }?;
416
417        Ok(token)
418    }
419}
420
421/// Posts `com.atproto.moderation.createReport` to the PDS (not the
422/// labeler) with the `atproto-proxy` header, letting the PDS mint and
423/// forward the JWT itself.
424pub struct PdsProxiedPoster<'a> {
425    client: &'a dyn PdsXrpcClient,
426}
427
428impl<'a> PdsProxiedPoster<'a> {
429    /// Create a new `PdsProxiedPoster` using the given PDS client.
430    pub fn new(client: &'a dyn PdsXrpcClient) -> Self {
431        Self { client }
432    }
433
434    /// Post the createReport body through the PDS with the given user
435    /// access JWT. Returns the `RawPdsXrpcResponse` so the caller can
436    /// classify success / labeler-side rejection / PDS-side rejection.
437    pub async fn post(
438        &self,
439        labeler_did: &str,
440        access_jwt: &str,
441        body: &serde_json::Value,
442    ) -> Result<RawPdsXrpcResponse, CreateReportStageError> {
443        self.client
444            .post(
445                "xrpc/com.atproto.moderation.createReport",
446                Some(access_jwt),
447                Some(&format!("{labeler_did}#atproto_labeler")),
448                body,
449            )
450            .await
451    }
452}
453
454/// Minimal per-check outcome facts for possible future consumer stages.
455/// All three `Option<bool>` fields are `None` unless the corresponding
456/// positive check ran and produced a concrete outcome.
457#[derive(Debug, Clone, Default)]
458pub struct CreateReportFacts {
459    /// `self_mint_accepted` outcome: `Some(true)` on Pass, `Some(false)` on
460    /// SpecViolation, `None` on Skipped/NetworkError.
461    pub self_mint_succeeded: Option<bool>,
462    /// `pds_service_auth_accepted` outcome (see above).
463    pub pds_service_auth_succeeded: Option<bool>,
464    /// `pds_proxied_accepted` outcome (see above).
465    pub pds_proxied_succeeded: Option<bool>,
466}
467
468/// Stage output: facts (populated only when the stage produced meaningful
469/// outcome data) and the full 10-row results vector.
470#[derive(Debug)]
471pub struct CreateReportStageOutput {
472    pub facts: Option<CreateReportFacts>,
473    pub results: Vec<CheckResult>,
474}
475
476/// Stable check identifiers for the `report` stage.
477///
478/// Order MUST match the DoD ordering (AC7.2): contract, unauth, malformed,
479/// wrong-aud, wrong-lxm, expired, rejected-shape, self-mint, pds-service-auth,
480/// pds-proxied.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum Check {
483    ContractPublished,
484    UnauthenticatedRejected,
485    MalformedBearerRejected,
486    WrongAudRejected,
487    WrongLxmRejected,
488    ExpiredRejected,
489    RejectedShapeReturns400,
490    SelfMintAccepted,
491    PdsServiceAuthAccepted,
492    PdsProxiedAccepted,
493}
494
495impl Check {
496    /// Stable `CheckResult.id` string.
497    pub fn id(self) -> &'static str {
498        match self {
499            Check::ContractPublished => "report::contract_published",
500            Check::UnauthenticatedRejected => "report::unauthenticated_rejected",
501            Check::MalformedBearerRejected => "report::malformed_bearer_rejected",
502            Check::WrongAudRejected => "report::wrong_aud_rejected",
503            Check::WrongLxmRejected => "report::wrong_lxm_rejected",
504            Check::ExpiredRejected => "report::expired_rejected",
505            Check::RejectedShapeReturns400 => "report::rejected_shape_returns_400",
506            Check::SelfMintAccepted => "report::self_mint_accepted",
507            Check::PdsServiceAuthAccepted => "report::pds_service_auth_accepted",
508            Check::PdsProxiedAccepted => "report::pds_proxied_accepted",
509        }
510    }
511
512    /// Canonical iteration order for the 10 checks, matching AC7.2.
513    pub const ORDER: [Check; 10] = [
514        Check::ContractPublished,
515        Check::UnauthenticatedRejected,
516        Check::MalformedBearerRejected,
517        Check::WrongAudRejected,
518        Check::WrongLxmRejected,
519        Check::ExpiredRejected,
520        Check::RejectedShapeReturns400,
521        Check::SelfMintAccepted,
522        Check::PdsServiceAuthAccepted,
523        Check::PdsProxiedAccepted,
524    ];
525
526    /// Build a `Pass` result for this check with a default summary.
527    pub fn pass(self) -> CheckResult {
528        CheckResult {
529            id: self.id(),
530            stage: Stage::Report,
531            status: CheckStatus::Pass,
532            summary: Cow::Borrowed(self.default_summary_pass()),
533            diagnostic: None,
534            skipped_reason: None,
535        }
536    }
537
538    /// Build a `SpecViolation` result for this check with an optional
539    /// diagnostic.
540    pub fn spec_violation(self, diagnostic: CreateReportDiagnostic) -> CheckResult {
541        CheckResult {
542            id: self.id(),
543            stage: Stage::Report,
544            status: CheckStatus::SpecViolation,
545            summary: Cow::Borrowed(self.default_summary_fail()),
546            diagnostic: Some(Box::new(diagnostic) as _),
547            skipped_reason: None,
548        }
549    }
550
551    /// Build an `Advisory` result (used by `rejected_shape_returns_400` AC3.6).
552    pub fn advisory(self, diagnostic: CreateReportDiagnostic) -> CheckResult {
553        CheckResult {
554            id: self.id(),
555            stage: Stage::Report,
556            status: CheckStatus::Advisory,
557            summary: Cow::Borrowed(self.default_summary_fail()),
558            diagnostic: Some(Box::new(diagnostic) as _),
559            skipped_reason: None,
560        }
561    }
562
563    /// Build a `NetworkError` result (used by PDS-side failure modes).
564    pub fn network_error(self, message: String) -> CheckResult {
565        CheckResult {
566            id: self.id(),
567            stage: Stage::Report,
568            status: CheckStatus::NetworkError,
569            summary: Cow::Owned(format!("{}: {message}", self.default_summary_fail())),
570            diagnostic: None,
571            skipped_reason: None,
572        }
573    }
574
575    /// Build a `Skipped` result with the supplied reason.
576    pub fn skip(self, reason: &'static str) -> CheckResult {
577        CheckResult {
578            id: self.id(),
579            stage: Stage::Report,
580            status: CheckStatus::Skipped,
581            summary: Cow::Borrowed(self.default_summary_pass()),
582            diagnostic: None,
583            skipped_reason: Some(Cow::Borrowed(reason)),
584        }
585    }
586
587    fn default_summary_pass(self) -> &'static str {
588        match self {
589            Check::ContractPublished => "Labeler advertises reportable shape",
590            Check::UnauthenticatedRejected => "Unauthenticated report rejected",
591            Check::MalformedBearerRejected => "Malformed bearer rejected",
592            Check::WrongAudRejected => "JWT with wrong `aud` rejected",
593            Check::WrongLxmRejected => "JWT with wrong `lxm` rejected",
594            Check::ExpiredRejected => "Expired JWT rejected",
595            Check::RejectedShapeReturns400 => "Invalid shape returns 400 InvalidRequest",
596            Check::SelfMintAccepted => "Self-mint report accepted",
597            Check::PdsServiceAuthAccepted => "PDS-minted JWT accepted",
598            Check::PdsProxiedAccepted => "PDS-proxied report accepted",
599        }
600    }
601
602    fn default_summary_fail(self) -> &'static str {
603        match self {
604            Check::ContractPublished => "Labeler does not advertise a reportable shape",
605            Check::UnauthenticatedRejected => {
606                "Unauthenticated report accepted (should have been rejected)"
607            }
608            Check::MalformedBearerRejected => {
609                "Malformed bearer accepted (should have been rejected)"
610            }
611            Check::WrongAudRejected => "JWT with wrong `aud` accepted",
612            Check::WrongLxmRejected => "JWT with wrong `lxm` accepted",
613            Check::ExpiredRejected => "Expired JWT accepted",
614            Check::RejectedShapeReturns400 => "Rejection status was not 400 InvalidRequest",
615            Check::SelfMintAccepted => "Self-mint report rejected",
616            Check::PdsServiceAuthAccepted => "PDS-minted JWT rejected",
617            Check::PdsProxiedAccepted => "PDS-proxied report rejected",
618        }
619    }
620}
621
622/// Aggregate of the stage-relevant options, extracted from `LabelerOptions`
623/// by the pipeline and passed to `run`. Having a local, narrow shape
624/// avoids forcing `run`'s signature to take everything in `LabelerOptions`.
625pub struct CreateReportRunOptions<'a> {
626    pub commit_report: bool,
627    pub force_self_mint: bool,
628    pub self_mint_curve: self_mint::SelfMintCurve,
629    pub report_subject_override: Option<&'a crate::common::identity::Did>,
630    pub self_mint_signer: Option<&'a self_mint::SelfMintSigner>,
631    pub pds_credentials: Option<&'a crate::commands::test::labeler::pipeline::PdsCredentials>,
632    pub pds_xrpc_client: Option<&'a dyn PdsXrpcClient>,
633    /// Populated by the pipeline when `--handle` was supplied but resolving
634    /// the handle to the reporter's PDS endpoint failed. Surfaced as a
635    /// `NetworkError` on both PDS-mediated checks so the operator can tell
636    /// a resolution failure apart from a missing-credentials skip.
637    pub pds_resolution_error: Option<&'a str>,
638    pub run_id: &'a str,
639}
640
641/// Run the report stage.
642///
643/// Stage inputs are passed via `LabelerOptions` (or directly as arguments
644/// here, to keep the signature mirroring the other stages). The stage
645/// always emits exactly 10 `report::*` CheckResults (AC7.1) in canonical
646/// order (AC7.2), regardless of gating decisions.
647pub async fn run(
648    identity_facts: Option<&crate::commands::test::labeler::identity::IdentityFacts>,
649    report_tee: &dyn CreateReportTee,
650    opts: &CreateReportRunOptions<'_>,
651) -> CreateReportStageOutput {
652    let mut results = Vec::with_capacity(10);
653
654    // If identity didn't land, every check is blocked by the identity
655    // stage. Emit 10 Skipped rows and return.
656    let Some(id_facts) = identity_facts else {
657        for c in Check::ORDER {
658            results.push(c.skip("blocked by identity stage"));
659        }
660        return CreateReportStageOutput {
661            facts: None,
662            results,
663        };
664    };
665
666    // Examine the published contract (from Task 0's extended IdentityFacts).
667    let reason_types = id_facts.reason_types.as_ref();
668    let subject_types = id_facts.subject_types.as_ref();
669    let has_reason_types = reason_types.map(|v| !v.is_empty()).unwrap_or(false);
670    let has_subject_types = subject_types.map(|v| !v.is_empty()).unwrap_or(false);
671    let contract_advertised = has_reason_types && has_subject_types;
672
673    // AC1: compute the contract_published row and the blocking reason for
674    // all downstream checks if the contract is missing.
675    //
676    // Control-flow contract: each branch below pushes EXACTLY 10 rows
677    // (1 contract row + 9 downstream) and returns. No fallthrough — the
678    // "contract advertised" branch is the one that invokes the
679    // authenticated negative checks and the committing positive checks.
680    if !contract_advertised {
681        if opts.commit_report {
682            // AC1.3: commit requested, contract missing ⇒ SpecViolation +
683            // every other check blocked by this one.
684            let diag = CreateReportDiagnostic::ContractMissing {
685                has_reason_types,
686                has_subject_types,
687            };
688            results.push(Check::ContractPublished.spec_violation(diag));
689            for c in Check::ORDER.iter().skip(1).copied() {
690                results.push(c.skip("blocked by `report::contract_published`"));
691            }
692        } else {
693            // AC1.2: no commit, contract missing ⇒ whole stage skipped.
694            results.push(
695                Check::ContractPublished.skip("labeler does not advertise report acceptance"),
696            );
697            for c in Check::ORDER.iter().skip(1).copied() {
698                results.push(c.skip("labeler does not advertise report acceptance"));
699            }
700        }
701        return CreateReportStageOutput {
702            facts: None,
703            results,
704        };
705    }
706
707    // Contract advertised. Emit the Pass row and fall through into the
708    // per-check logic.
709    results.push(Check::ContractPublished.pass());
710
711    // Minimal body for negative checks. The labeler should reject at auth
712    // before examining body shape; we nonetheless supply a plausible body so
713    // a labeler that performs body validation first doesn't return 400
714    // instead of 401, which would make the test ambiguous.
715    let negative_body = build_minimal_report_body(id_facts);
716
717    // AC2.1/AC2.2/AC2.5 — unauthenticated:
718    match report_tee.post_create_report(None, &negative_body).await {
719        Ok(resp) => match RejectionShape::classify(&resp) {
720            RejectionShape::Conformant { .. } => {
721                results.push(Check::UnauthenticatedRejected.pass());
722            }
723            RejectionShape::ConformantStatusNonConformantShape => {
724                results.push(CheckResult {
725                    summary: Cow::Borrowed(
726                        "Unauthenticated report rejected (status 401, non-conformant envelope)",
727                    ),
728                    ..Check::UnauthenticatedRejected.pass()
729                });
730            }
731            RejectionShape::WrongStatus { status } => {
732                let status_u16 = status.as_u16();
733                let (source_code, span) = body_as_named_source(&resp);
734                let diag = CreateReportDiagnostic::UnauthenticatedAccepted {
735                    status: status_u16,
736                    source_code,
737                    span,
738                };
739                results.push(Check::UnauthenticatedRejected.spec_violation(diag));
740            }
741        },
742        Err(CreateReportStageError::Transport { source }) => {
743            results.push(Check::UnauthenticatedRejected.network_error(source.to_string()));
744        }
745    }
746
747    // AC2.3/AC2.4 — malformed bearer:
748    match report_tee
749        .post_create_report(Some("not-a-jwt"), &negative_body)
750        .await
751    {
752        Ok(resp) => match RejectionShape::classify(&resp) {
753            RejectionShape::Conformant { .. } => {
754                results.push(Check::MalformedBearerRejected.pass());
755            }
756            RejectionShape::ConformantStatusNonConformantShape => {
757                results.push(CheckResult {
758                    summary: Cow::Borrowed(
759                        "Malformed bearer rejected (status 401, non-conformant envelope)",
760                    ),
761                    ..Check::MalformedBearerRejected.pass()
762                });
763            }
764            RejectionShape::WrongStatus { status } => {
765                let status_u16 = status.as_u16();
766                let (source_code, span) = body_as_named_source(&resp);
767                let diag = CreateReportDiagnostic::MalformedBearerAccepted {
768                    status: status_u16,
769                    source_code,
770                    span,
771                };
772                results.push(Check::MalformedBearerRejected.spec_violation(diag));
773            }
774        },
775        Err(CreateReportStageError::Transport { source }) => {
776            results.push(Check::MalformedBearerRejected.network_error(source.to_string()));
777        }
778    }
779
780    // Recompute self_mint_viable using the *actual* labeler endpoint now
781    // that identity has run.
782    let is_local_labeler = is_local_labeler_hostname(&id_facts.labeler_endpoint);
783    let self_mint_viable = opts.force_self_mint || is_local_labeler;
784
785    let signer_for_negative = if self_mint_viable {
786        opts.self_mint_signer
787    } else {
788        None
789    };
790
791    // Compute `now` once for all JWT-based checks.
792    let now = SystemTime::now()
793        .duration_since(UNIX_EPOCH)
794        .map(|d| d.as_secs() as i64)
795        .unwrap_or(0);
796
797    // CRITICAL: this block either emits 4 Skipped rows OR emits 4 real-check
798    // rows, then falls through to the committing checks for SelfMintAccepted,
799    // PdsServiceAuthAccepted, PdsProxiedAccepted. Do NOT `return` here — the
800    // stage always emits 10 rows total, and the later checks need to run
801    // regardless of self-mint viability.
802    if let Some(signer) = signer_for_negative {
803        // Mint per-check tokens from the valid-claims template. All four
804        // checks share the same `now`, `lxm`, and `template`. Each check
805        // inlines its own `match` rather than using a shared helper —
806        // nested `async fn` is unsupported in stable Rust, and a closure
807        // returning `Box<dyn Diagnostic>` across `await` would force `Send`
808        // bounds that complicate the call site.
809        let lxm = "com.atproto.moderation.createReport";
810        let template =
811            signer.valid_claims_template(&id_facts.did, lxm, now, Duration::from_secs(60));
812
813        let negative_body = build_minimal_report_body(id_facts);
814
815        // AC3.1/AC3.2 — wrong aud:
816        {
817            let mut claims = template.clone();
818            claims.aud = "did:plc:0000000000000000000000000".to_string();
819            let token = signer.sign_jwt(claims);
820            match report_tee
821                .post_create_report(Some(&token), &negative_body)
822                .await
823            {
824                Ok(resp) => match RejectionShape::classify(&resp) {
825                    RejectionShape::Conformant { .. } => {
826                        results.push(Check::WrongAudRejected.pass())
827                    }
828                    RejectionShape::ConformantStatusNonConformantShape => {
829                        results.push(CheckResult {
830                            summary: Cow::Borrowed(
831                                "Rejected with 401 but envelope is non-conformant",
832                            ),
833                            ..Check::WrongAudRejected.pass()
834                        })
835                    }
836                    RejectionShape::WrongStatus { .. } => {
837                        let (source_code, span) = body_as_named_source(&resp);
838                        let diag = CreateReportDiagnostic::WrongAudAccepted {
839                            status: resp.status.as_u16(),
840                            source_code,
841                            span,
842                        };
843                        results.push(Check::WrongAudRejected.spec_violation(diag));
844                    }
845                },
846                Err(CreateReportStageError::Transport { source }) => {
847                    results.push(Check::WrongAudRejected.network_error(source.to_string()));
848                }
849            }
850        }
851
852        // AC3.3 — wrong lxm:
853        {
854            let mut claims = template.clone();
855            claims.lxm = "com.atproto.server.getSession".to_string();
856            let token = signer.sign_jwt(claims);
857            match report_tee
858                .post_create_report(Some(&token), &negative_body)
859                .await
860            {
861                Ok(resp) => match RejectionShape::classify(&resp) {
862                    RejectionShape::Conformant { .. } => {
863                        results.push(Check::WrongLxmRejected.pass())
864                    }
865                    RejectionShape::ConformantStatusNonConformantShape => {
866                        results.push(CheckResult {
867                            summary: Cow::Borrowed(
868                                "Rejected with 401 but envelope is non-conformant",
869                            ),
870                            ..Check::WrongLxmRejected.pass()
871                        })
872                    }
873                    RejectionShape::WrongStatus { .. } => {
874                        let (source_code, span) = body_as_named_source(&resp);
875                        let diag = CreateReportDiagnostic::WrongLxmAccepted {
876                            status: resp.status.as_u16(),
877                            source_code,
878                            span,
879                        };
880                        results.push(Check::WrongLxmRejected.spec_violation(diag));
881                    }
882                },
883                Err(CreateReportStageError::Transport { source }) => {
884                    results.push(Check::WrongLxmRejected.network_error(source.to_string()));
885                }
886            }
887        }
888
889        // AC3.4 — expired:
890        {
891            let mut claims = template.clone();
892            claims.exp = now - 300;
893            claims.iat = now - 360;
894            let token = signer.sign_jwt(claims);
895            match report_tee
896                .post_create_report(Some(&token), &negative_body)
897                .await
898            {
899                Ok(resp) => match RejectionShape::classify(&resp) {
900                    RejectionShape::Conformant { .. } => {
901                        results.push(Check::ExpiredRejected.pass())
902                    }
903                    RejectionShape::ConformantStatusNonConformantShape => {
904                        results.push(CheckResult {
905                            summary: Cow::Borrowed(
906                                "Rejected with 401 but envelope is non-conformant",
907                            ),
908                            ..Check::ExpiredRejected.pass()
909                        })
910                    }
911                    RejectionShape::WrongStatus { .. } => {
912                        let (source_code, span) = body_as_named_source(&resp);
913                        let diag = CreateReportDiagnostic::ExpiredAccepted {
914                            status: resp.status.as_u16(),
915                            source_code,
916                            span,
917                        };
918                        results.push(Check::ExpiredRejected.spec_violation(diag));
919                    }
920                },
921                Err(CreateReportStageError::Transport { source }) => {
922                    results.push(Check::ExpiredRejected.network_error(source.to_string()));
923                }
924            }
925        }
926
927        // AC3.5/AC3.6 — rejected shape:
928        {
929            let claims = template.clone();
930            let token = signer.sign_jwt(claims);
931            // Invalid body: a reasonType that is NOT in id_facts.reason_types.
932            let bogus_reason_type = synth_unadvertised_reason_type(id_facts);
933            let invalid_body = {
934                let mut body = negative_body.clone();
935                if let Some(obj) = body.as_object_mut() {
936                    obj.insert(
937                        "reasonType".to_string(),
938                        serde_json::Value::String(bogus_reason_type),
939                    );
940                }
941                body
942            };
943            match report_tee
944                .post_create_report(Some(&token), &invalid_body)
945                .await
946            {
947                Ok(resp) => {
948                    let envelope = XrpcErrorEnvelope::parse(&resp.raw_body);
949                    let error_name = envelope.as_ref().and_then(|e| e.error.clone());
950                    if resp.status == reqwest::StatusCode::BAD_REQUEST
951                        && error_name.as_deref() == Some("InvalidRequest")
952                    {
953                        // AC3.5: 400 InvalidRequest → Pass.
954                        results.push(Check::RejectedShapeReturns400.pass());
955                    } else if resp.status == reqwest::StatusCode::UNAUTHORIZED
956                        || resp.status.is_server_error()
957                    {
958                        // AC3.6: 401 or 5xx → Advisory with shape_not_400.
959                        let (source_code, span) = body_as_named_source(&resp);
960                        let diag = CreateReportDiagnostic::ShapeNot400 {
961                            status: resp.status.as_u16(),
962                            error_name: error_name.clone(),
963                            source_code,
964                            span,
965                        };
966                        results.push(Check::RejectedShapeReturns400.advisory(diag));
967                    } else if resp.status == reqwest::StatusCode::BAD_REQUEST {
968                        // 400 but not `InvalidRequest` name → Advisory.
969                        let (source_code, span) = body_as_named_source(&resp);
970                        let diag = CreateReportDiagnostic::ShapeNot400 {
971                            status: 400,
972                            error_name: error_name.clone(),
973                            source_code,
974                            span,
975                        };
976                        results.push(Check::RejectedShapeReturns400.advisory(diag));
977                    } else {
978                        // Catch-all: 200 accepted → Advisory. A 200 for an invalid
979                        // shape is a labeler looseness issue, not the same category
980                        // as the `self_mint_accepted` SpecViolation (which expects
981                        // a *valid* shape to be accepted).
982                        let (source_code, span) = body_as_named_source(&resp);
983                        let diag = CreateReportDiagnostic::ShapeNot400 {
984                            status: resp.status.as_u16(),
985                            error_name,
986                            source_code,
987                            span,
988                        };
989                        results.push(Check::RejectedShapeReturns400.advisory(diag));
990                    }
991                }
992                Err(CreateReportStageError::Transport { source }) => {
993                    results.push(Check::RejectedShapeReturns400.network_error(source.to_string()));
994                }
995            }
996        }
997    } else {
998        let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)";
999        for c in [
1000            Check::WrongAudRejected,
1001            Check::WrongLxmRejected,
1002            Check::ExpiredRejected,
1003            Check::RejectedShapeReturns400,
1004        ] {
1005            results.push(c.skip(reason));
1006        }
1007    }
1008
1009    // Fallthrough to the committing check logic below. Keeping this block
1010    // fallthrough-safe is why the `if let Some(signer)` above does NOT
1011    // `return`.
1012
1013    // AC4.4 — gate on commit_report.
1014    if !opts.commit_report {
1015        results.push(Check::SelfMintAccepted.skip("commit gated behind --commit-report"));
1016    } else if let Some(signer) = signer_for_negative {
1017        // AC4.1/AC4.2 — construct a positive POST with pollution-avoidance.
1018        // Reads the contract from the `reason_types` / `subject_types` fields
1019        // on `IdentityFacts`.
1020        let reason_type = pollution::choose_reason_type(
1021            id_facts.reason_types.as_deref().unwrap_or(&[]),
1022            is_local_labeler,
1023        );
1024        let subject = pollution::choose_subject(
1025            id_facts.subject_types.as_deref().unwrap_or(&[]),
1026            signer.issuer_did(),
1027            opts.report_subject_override,
1028            is_local_labeler,
1029        );
1030        let sentinel = sentinel::build(opts.run_id, SystemTime::now());
1031        let positive_body = serde_json::json!({
1032            "reasonType": reason_type,
1033            "subject": subject,
1034            "reason": sentinel,
1035        });
1036
1037        // AC4.6 — the built body carries the sentinel; the integration test
1038        // in Task 4 asserts it via FakeCreateReportTee::last_request().
1039
1040        let claims = signer.valid_claims_template(
1041            &id_facts.did,
1042            "com.atproto.moderation.createReport",
1043            now,
1044            Duration::from_secs(60),
1045        );
1046        let token = signer.sign_jwt(claims);
1047
1048        match report_tee
1049            .post_create_report(Some(&token), &positive_body)
1050            .await
1051        {
1052            Ok(resp) if resp.status.is_success() => {
1053                // AC4.1/AC4.2: Pass. Optionally inspect body for createReport#output
1054                // shape — loose check: `id` is a number.
1055                let body_ok = serde_json::from_slice::<serde_json::Value>(&resp.raw_body)
1056                    .ok()
1057                    .and_then(|v| v.get("id").and_then(|id| id.as_i64()))
1058                    .is_some();
1059                if body_ok {
1060                    results.push(Check::SelfMintAccepted.pass());
1061                } else {
1062                    // 2xx but body doesn't look like createReport#output. Accept as
1063                    // Pass per design (status alone suffices), but note the
1064                    // non-conformant body in the summary.
1065                    results.push(CheckResult {
1066                        summary: Cow::Borrowed(
1067                            "Self-mint report accepted (2xx), body did not match createReport#output shape",
1068                        ),
1069                        ..Check::SelfMintAccepted.pass()
1070                    });
1071                }
1072            }
1073            Ok(resp) => {
1074                // AC4.3: non-2xx ⇒ SpecViolation.
1075                let (source_code, span) = body_as_named_source(&resp);
1076                let diag = CreateReportDiagnostic::SelfMintRejected {
1077                    status: resp.status.as_u16(),
1078                    source_code,
1079                    span,
1080                };
1081                results.push(Check::SelfMintAccepted.spec_violation(diag));
1082            }
1083            Err(CreateReportStageError::Transport { source }) => {
1084                results.push(Check::SelfMintAccepted.network_error(source.to_string()));
1085            }
1086        }
1087    } else {
1088        // AC4.5: commit requested but no signer available (non-viable or not provided).
1089        // Skip with the same viability reason as the AC3 checks.
1090        let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)";
1091        results.push(Check::SelfMintAccepted.skip(reason));
1092    }
1093
1094    // AC5/AC6 — PDS-mediated modes (modes 2 and 3).
1095    // Compute the gating precondition common to both PDS checks.
1096    let pds_gate_reason: &'static str = "requires --handle, --app-password, and --commit-report";
1097    let pds_ready =
1098        opts.commit_report && opts.pds_credentials.is_some() && opts.pds_xrpc_client.is_some();
1099    // Distinguish "no credentials" (skip) from "credentials supplied but the
1100    // reporter's PDS could not be resolved" (network error) so the operator
1101    // sees a useful failure mode rather than a silent skip.
1102    let pds_resolution_failed = opts.commit_report
1103        && opts.pds_credentials.is_some()
1104        && opts.pds_xrpc_client.is_none()
1105        && opts.pds_resolution_error.is_some();
1106
1107    if pds_resolution_failed {
1108        let msg = opts
1109            .pds_resolution_error
1110            .expect("pds_resolution_failed implies Some")
1111            .to_string();
1112        results.push(Check::PdsServiceAuthAccepted.network_error(msg.clone()));
1113        results.push(Check::PdsProxiedAccepted.network_error(msg));
1114    } else if !pds_ready {
1115        results.push(Check::PdsServiceAuthAccepted.skip(pds_gate_reason));
1116        results.push(Check::PdsProxiedAccepted.skip(pds_gate_reason));
1117    } else {
1118        // Safe to unwrap thanks to pds_ready.
1119        let creds = opts.pds_credentials.expect("pds_ready implies creds");
1120        let pds_client = opts.pds_xrpc_client.expect("pds_ready implies client");
1121
1122        // Reuse locality computed earlier.
1123        let is_local = is_local_labeler;
1124        let reason_type = pollution::choose_reason_type(
1125            id_facts.reason_types.as_deref().unwrap_or(&[]),
1126            is_local,
1127        );
1128
1129        // Fetch the user session (DID and access JWT). Both PDS modes need
1130        // these upfront.
1131        match fetch_session_and_did(pds_client, &creds.handle, &creds.app_password).await {
1132            Err(message) => {
1133                results.push(Check::PdsServiceAuthAccepted.network_error(message.clone()));
1134                // AC6.3: if session fetch fails, proxied mode also fails at PDS.
1135                results.push(Check::PdsProxiedAccepted.network_error(message));
1136            }
1137            Ok(session) => {
1138                let user_did = Did(session.did);
1139                let access_jwt = session.access_jwt;
1140                let subject = pollution::choose_subject(
1141                    id_facts.subject_types.as_deref().unwrap_or(&[]),
1142                    &user_did,
1143                    opts.report_subject_override,
1144                    is_local,
1145                );
1146                let sentinel = sentinel::build(opts.run_id, SystemTime::now());
1147                let pds_body = serde_json::json!({
1148                    "reasonType": reason_type,
1149                    "subject": subject,
1150                    "reason": sentinel,
1151                });
1152
1153                // Mode 2: getServiceAuth direct-POST.
1154                let exp_abs = now + 60;
1155                let fetcher = PdsJwtFetcher::new(pds_client);
1156                match fetcher
1157                    .fetch_with_jwt(
1158                        &access_jwt,
1159                        &id_facts.did.0,
1160                        "com.atproto.moderation.createReport",
1161                        exp_abs,
1162                    )
1163                    .await
1164                {
1165                    Err(e) => {
1166                        let message = e.to_string();
1167                        let diagnostic = e.into_diagnostic();
1168                        results.push(CheckResult {
1169                            diagnostic,
1170                            ..Check::PdsServiceAuthAccepted.network_error(message)
1171                        });
1172                    }
1173                    Ok(service_jwt) => {
1174                        match report_tee
1175                            .post_create_report(Some(&service_jwt), &pds_body)
1176                            .await
1177                        {
1178                            Ok(resp) if resp.status.is_success() => {
1179                                results.push(Check::PdsServiceAuthAccepted.pass());
1180                            }
1181                            Ok(resp) => {
1182                                let (source_code, span) = body_as_named_source(&resp);
1183                                let diag = CreateReportDiagnostic::PdsServiceAuthRejected {
1184                                    origin: ResponseOrigin::Labeler,
1185                                    status: resp.status.as_u16(),
1186                                    source_code,
1187                                    span,
1188                                };
1189                                results.push(Check::PdsServiceAuthAccepted.spec_violation(diag));
1190                            }
1191                            Err(CreateReportStageError::Transport { source }) => {
1192                                // Labeler-side transport failure during direct POST.
1193                                results.push(
1194                                    Check::PdsServiceAuthAccepted.network_error(source.to_string()),
1195                                );
1196                            }
1197                        }
1198                    }
1199                }
1200
1201                // Mode 3: PDS-proxied.
1202                let proxier = PdsProxiedPoster::new(pds_client);
1203                match proxier.post(&id_facts.did.0, &access_jwt, &pds_body).await {
1204                    Err(CreateReportStageError::Transport { source }) => {
1205                        // Transport to the PDS itself; classify PDS-side.
1206                        results.push(Check::PdsProxiedAccepted.network_error(source.to_string()));
1207                    }
1208                    Ok(resp) if resp.status.is_success() => {
1209                        results.push(Check::PdsProxiedAccepted.pass());
1210                    }
1211                    Ok(resp) => {
1212                        // PDS surfaced a non-2xx. Interpret per envelope to
1213                        // distinguish PDS-side vs labeler-side:
1214                        let (source_code, span) = body_as_named_source_from_pds(&resp);
1215                        let envelope = XrpcErrorEnvelope::parse(&resp.raw_body);
1216                        let err_name = envelope.as_ref().and_then(|e| e.error.clone());
1217                        let is_upstream_label_error = matches!(
1218                            err_name.as_deref(),
1219                            Some("UpstreamError") | Some("UpstreamFailure")
1220                        ) || resp.status.as_u16() == 502
1221                            || resp.status.as_u16() == 504;
1222                        if is_upstream_label_error {
1223                            // AC6.2: labeler-side rejection surfaced by PDS.
1224                            let diag = CreateReportDiagnostic::PdsProxiedRejected {
1225                                origin: ResponseOrigin::Labeler,
1226                                status: resp.status.as_u16(),
1227                                source_code,
1228                                span,
1229                            };
1230                            results.push(Check::PdsProxiedAccepted.spec_violation(diag));
1231                        } else {
1232                            // AC6.3: PDS-side rejection of the proxy attempt.
1233                            let diag = CreateReportDiagnostic::PdsProxiedRejected {
1234                                origin: ResponseOrigin::Pds,
1235                                status: resp.status.as_u16(),
1236                                source_code,
1237                                span,
1238                            };
1239                            results.push(CheckResult {
1240                                diagnostic: Some(Box::new(diag)),
1241                                ..Check::PdsProxiedAccepted.network_error(format!(
1242                                    "PDS rejected proxy attempt with status {}",
1243                                    resp.status
1244                                ))
1245                            });
1246                        }
1247                    }
1248                }
1249            }
1250        }
1251    }
1252
1253    CreateReportStageOutput {
1254        facts: None,
1255        results,
1256    }
1257}
1258
1259/// Convenience wrapper that does createSession and returns both the DID
1260/// and the accessJwt. Needed by both PDS check modes to populate the body
1261/// with the correct subject DID.
1262struct SessionResult {
1263    did: String,
1264    access_jwt: String,
1265}
1266
1267async fn fetch_session_and_did(
1268    client: &dyn PdsXrpcClient,
1269    handle: &str,
1270    app_password: &str,
1271) -> Result<SessionResult, String> {
1272    let body = serde_json::json!({ "identifier": handle, "password": app_password });
1273    let resp = client
1274        .post("xrpc/com.atproto.server.createSession", None, None, &body)
1275        .await
1276        .map_err(|e| format!("createSession transport: {e}"))?;
1277    if !resp.status.is_success() {
1278        return Err(format!("createSession returned {}", resp.status));
1279    }
1280    let session: serde_json::Value =
1281        serde_json::from_slice(&resp.raw_body).map_err(|e| format!("createSession body: {e}"))?;
1282    let did = session["did"]
1283        .as_str()
1284        .ok_or("createSession missing did")?
1285        .to_string();
1286    let access_jwt = session["accessJwt"]
1287        .as_str()
1288        .ok_or("createSession missing accessJwt")?
1289        .to_string();
1290    Ok(SessionResult { did, access_jwt })
1291}
1292
1293/// Synthesize a `reasonType` string that is definitely NOT in the
1294/// labeler's advertised `reason_types`. NSID syntax (segments alphanumeric +
1295/// period only, fragment after `#`) is strictly valid so the labeler does
1296/// not reject for wrong reason (malformed NSID) before checking membership.
1297fn synth_unadvertised_reason_type(facts: &IdentityFacts) -> String {
1298    let empty = Vec::new();
1299    let advertised: &[String] = facts.reason_types.as_ref().unwrap_or(&empty);
1300    for i in 0..1000 {
1301        let candidate = format!("xyz.atprotodevtool.conformance.defs#unadvertised{i:03}");
1302        if !advertised.iter().any(|r| r == &candidate) {
1303            return candidate;
1304        }
1305    }
1306    // Unreachable in practice.
1307    "xyz.atprotodevtool.conformance.defs#unadvertisedFallback".to_string()
1308}
1309
1310#[cfg(test)]
1311mod check_tests {
1312    use super::*;
1313
1314    #[test]
1315    fn check_ids_are_unique_and_report_namespaced() {
1316        let mut seen = std::collections::HashSet::new();
1317        for c in Check::ORDER {
1318            let id = c.id();
1319            assert!(id.starts_with("report::"), "{id} not in report:: namespace");
1320            assert!(seen.insert(id), "duplicate check id: {id}");
1321        }
1322        assert_eq!(Check::ORDER.len(), 10, "DoD requires exactly 10 checks");
1323    }
1324}
1325
1326/// A loosely-parsed atproto XRPC error envelope. Missing fields are
1327/// rendered as `None` rather than failing the parse — the "loose
1328/// assertion" philosophy in the design (see "Error envelope assertion
1329/// is deliberately loose").
1330#[derive(Debug, Clone)]
1331pub struct XrpcErrorEnvelope {
1332    /// The `error` field (PascalCase error name). `None` if absent or
1333    /// not a string.
1334    pub error: Option<String>,
1335    /// The `message` field. `None` if absent or not a string.
1336    pub message: Option<String>,
1337}
1338
1339impl XrpcErrorEnvelope {
1340    /// Try to parse an atproto error envelope from the response body.
1341    /// Returns `None` only when the body is not valid JSON at all.
1342    /// Otherwise returns an envelope with whatever fields we could find.
1343    pub fn parse(body: &[u8]) -> Option<Self> {
1344        let v: serde_json::Value = serde_json::from_slice(body).ok()?;
1345        let obj = v.as_object()?;
1346        Some(Self {
1347            error: obj.get("error").and_then(|x| x.as_str()).map(String::from),
1348            message: obj
1349                .get("message")
1350                .and_then(|x| x.as_str())
1351                .map(String::from),
1352        })
1353    }
1354
1355    /// `true` when the envelope has a non-empty `error` string.
1356    pub fn has_nonempty_error(&self) -> bool {
1357        self.error
1358            .as_deref()
1359            .map(|s| !s.is_empty())
1360            .unwrap_or(false)
1361    }
1362}
1363
1364/// Outcome of the 401-envelope assertion.
1365pub enum RejectionShape {
1366    /// 401 with a non-empty `error` field — full-conformant.
1367    Conformant {
1368        /// The envelope for diagnostic rendering.
1369        envelope: XrpcErrorEnvelope,
1370    },
1371    /// 401 but the envelope is missing or has an empty `error` field.
1372    /// Treated as Pass on status alone per AC2.5 but the summary
1373    /// notes the non-conformant response shape.
1374    ConformantStatusNonConformantShape,
1375    /// Any non-401 status.
1376    WrongStatus {
1377        /// The observed status code.
1378        status: reqwest::StatusCode,
1379    },
1380}
1381
1382impl RejectionShape {
1383    /// Classify a createReport response against the 401-envelope rubric.
1384    pub fn classify(resp: &RawCreateReportResponse) -> Self {
1385        if resp.status != reqwest::StatusCode::UNAUTHORIZED {
1386            return Self::WrongStatus {
1387                status: resp.status,
1388            };
1389        }
1390        match XrpcErrorEnvelope::parse(&resp.raw_body) {
1391            Some(env) if env.has_nonempty_error() => Self::Conformant { envelope: env },
1392            _ => Self::ConformantStatusNonConformantShape,
1393        }
1394    }
1395}
1396
1397#[derive(Debug, Error, Diagnostic)]
1398pub enum CreateReportDiagnostic {
1399    /// Diagnostic for the `contract_missing` spec violation (AC1.3).
1400    ///
1401    /// Emitted when `--commit-report` is set and the identity-stage
1402    /// `labeler_policies` does not advertise a non-empty `reasonTypes` and
1403    /// `subjectTypes`. The body of the labeler record is attached as source
1404    /// so users can see what _was_ published.
1405    #[error("Labeler does not advertise a reportable `LabelerPolicies` shape")]
1406    #[diagnostic(
1407        code = "labeler::report::contract_missing",
1408        help = "`reasonTypes` and `subjectTypes` must both be present and non-empty on the labeler's published policies; the tool cannot verify reporting conformance without them."
1409    )]
1410    ContractMissing {
1411        /// `reasonTypes` present and non-empty?
1412        has_reason_types: bool,
1413        /// `subjectTypes` present and non-empty?
1414        has_subject_types: bool,
1415    },
1416
1417    /// Diagnostic for AC2.2: labeler accepted an unauthenticated createReport POST.
1418    #[error("Labeler accepted unauthenticated createReport (status {status})")]
1419    #[diagnostic(
1420        code = "labeler::report::unauthenticated_accepted",
1421        help = "A labeler must reject createReport with 401 when no Authorization header is supplied."
1422    )]
1423    UnauthenticatedAccepted {
1424        /// Observed status code, e.g., 200.
1425        status: u16,
1426        /// Response body for context.
1427        #[source_code]
1428        source_code: NamedSource<Arc<[u8]>>,
1429        /// Span covering the response body so miette renders `source_code`.
1430        #[label("accepted here")]
1431        span: SourceSpan,
1432    },
1433
1434    /// Diagnostic for AC2.4: labeler accepted a malformed bearer token.
1435    #[error("Labeler accepted malformed Bearer token (status {status})")]
1436    #[diagnostic(
1437        code = "labeler::report::malformed_bearer_accepted",
1438        help = "A labeler must reject createReport with 401 when the Authorization header carries a non-JWT string."
1439    )]
1440    MalformedBearerAccepted {
1441        /// Observed status code, e.g., 200.
1442        status: u16,
1443        /// Response body for context.
1444        #[source_code]
1445        source_code: NamedSource<Arc<[u8]>>,
1446        /// Span covering the response body so miette renders `source_code`.
1447        #[label("accepted here")]
1448        span: SourceSpan,
1449    },
1450
1451    /// Diagnostic for AC3.2: labeler accepted JWT with wrong `aud` claim.
1452    #[error("Labeler accepted JWT with wrong `aud` (status {status})")]
1453    #[diagnostic(
1454        code = "labeler::report::wrong_aud_accepted",
1455        help = "A labeler must reject JWTs whose `aud` claim does not match its own DID."
1456    )]
1457    WrongAudAccepted {
1458        /// Observed status code, e.g., 200.
1459        status: u16,
1460        /// Response body for context.
1461        #[source_code]
1462        source_code: NamedSource<Arc<[u8]>>,
1463        /// Span covering the response body so miette renders `source_code`.
1464        #[label("accepted here")]
1465        span: SourceSpan,
1466    },
1467
1468    /// Diagnostic for AC3.3: labeler accepted JWT with wrong `lxm` claim.
1469    #[error("Labeler accepted JWT with wrong `lxm` (status {status})")]
1470    #[diagnostic(
1471        code = "labeler::report::wrong_lxm_accepted",
1472        help = "A labeler must reject JWTs whose `lxm` claim does not match the invoked Lexicon method."
1473    )]
1474    WrongLxmAccepted {
1475        /// Observed status code, e.g., 200.
1476        status: u16,
1477        /// Response body for context.
1478        #[source_code]
1479        source_code: NamedSource<Arc<[u8]>>,
1480        /// Span covering the response body so miette renders `source_code`.
1481        #[label("accepted here")]
1482        span: SourceSpan,
1483    },
1484
1485    /// Diagnostic for AC3.4: labeler accepted expired JWT.
1486    #[error("Labeler accepted expired JWT (status {status})")]
1487    #[diagnostic(
1488        code = "labeler::report::expired_accepted",
1489        help = "A labeler must reject JWTs whose `exp` claim is in the past."
1490    )]
1491    ExpiredAccepted {
1492        /// Observed status code, e.g., 200.
1493        status: u16,
1494        /// Response body for context.
1495        #[source_code]
1496        source_code: NamedSource<Arc<[u8]>>,
1497        /// Span covering the response body so miette renders `source_code`.
1498        #[label("accepted here")]
1499        span: SourceSpan,
1500    },
1501
1502    /// Diagnostic for AC3.6: labeler rejected invalid shape with wrong status.
1503    #[error(
1504        "Unadvertised `reasonType` was rejected with status {status}, expected 400 InvalidRequest"
1505    )]
1506    #[diagnostic(
1507        code = "labeler::report::shape_not_400",
1508        help = "A labeler should return 400 InvalidRequest (not 401 or 500) for a `reasonType` not listed in its published LabelerPolicies.reasonTypes."
1509    )]
1510    ShapeNot400 {
1511        /// Observed status code.
1512        status: u16,
1513        /// Error name from the response envelope, if present.
1514        error_name: Option<String>,
1515        /// Response body for context.
1516        #[source_code]
1517        source_code: NamedSource<Arc<[u8]>>,
1518        /// Span covering the response body so miette renders `source_code`.
1519        #[label("rejected with wrong status here")]
1520        span: SourceSpan,
1521    },
1522
1523    /// Diagnostic for AC4.3: self-mint report rejected by the labeler.
1524    #[error("Self-mint report rejected (status {status})")]
1525    #[diagnostic(
1526        code = "labeler::report::self_mint_rejected",
1527        help = "A labeler that advertises reportable shape should accept a well-formed, authenticated createReport. Check the labeler's service-auth validation and its acceptance of the advertised reasonType/subject shape."
1528    )]
1529    SelfMintRejected {
1530        /// Observed HTTP status code.
1531        status: u16,
1532        /// Response body for context.
1533        #[source_code]
1534        source_code: NamedSource<Arc<[u8]>>,
1535        /// Span covering the response body so miette renders `source_code`.
1536        #[label("rejected here")]
1537        span: SourceSpan,
1538    },
1539
1540    /// Diagnostic for AC5.2 / AC5.3: the PDS-mediated service-auth flow
1541    /// produced a non-2xx response. `origin` identifies whether the
1542    /// rejection came from the labeler (AC5.2 spec violation) or the
1543    /// user's PDS during `getServiceAuth` (AC5.3 network error).
1544    #[error("{origin} rejected PDS-minted service-auth createReport (status {status})")]
1545    #[diagnostic(
1546        code = "labeler::report::pds_service_auth_rejected",
1547        help = "When `origin` is `Labeler`, the PDS issued a service-auth JWT bound to the labeler's DID and the createReport NSID; the labeler should have accepted it. When `origin` is `PDS`, the user's PDS refused to mint the service-auth JWT — verify the handle and app password, and confirm `--handle` resolves to a PDS that can mint service-auth tokens for this user."
1548    )]
1549    PdsServiceAuthRejected {
1550        /// Which party produced the non-2xx response.
1551        origin: ResponseOrigin,
1552        /// Observed HTTP status code.
1553        status: u16,
1554        /// Response body for context.
1555        #[source_code]
1556        source_code: NamedSource<Arc<[u8]>>,
1557        /// Span covering the response body so miette renders `source_code`.
1558        #[label("rejected here")]
1559        span: SourceSpan,
1560    },
1561
1562    /// Diagnostic for AC6.2 / AC6.3: the PDS-proxied `createReport` flow
1563    /// produced a non-2xx response. `origin` identifies whether the
1564    /// rejection came from the labeler via upstream envelope (AC6.2 spec
1565    /// violation) or the user's PDS refusing the proxy attempt before
1566    /// forwarding (AC6.3 network error).
1567    #[error("{origin} rejected PDS-proxied createReport (status {status})")]
1568    #[diagnostic(
1569        code = "labeler::report::pds_proxied_rejected",
1570        help = "When `origin` is `Labeler`, the PDS forwarded the createReport call on the user's behalf; the downstream labeler reached it but rejected the submission. When `origin` is `PDS`, the user's PDS rejected the proxied call before it could reach the labeler — verify the handle, app password, and that the PDS is configured to proxy moderation calls to the target labeler."
1571    )]
1572    PdsProxiedRejected {
1573        /// Which party produced the non-2xx response.
1574        origin: ResponseOrigin,
1575        /// Observed HTTP status code.
1576        status: u16,
1577        /// Response body for context.
1578        #[source_code]
1579        source_code: NamedSource<Arc<[u8]>>,
1580        /// Span covering the response body so miette renders `source_code`.
1581        #[label("rejected here")]
1582        span: SourceSpan,
1583    },
1584}
1585
1586/// Identifies which party in the PDS-mediated flow produced a non-2xx
1587/// response. Used to discriminate labeler-side spec violations from
1588/// PDS-side network errors within a single diagnostic variant, keeping
1589/// the one-diagnostic-per-check shape the report stage documents.
1590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1591pub enum ResponseOrigin {
1592    /// The labeler itself produced the response (either directly, or via
1593    /// the PDS surfacing an upstream-labeler error envelope).
1594    Labeler,
1595    /// The user's PDS produced the response without the labeler being
1596    /// reached (e.g., `getServiceAuth` refused, or proxy rejected before
1597    /// forwarding).
1598    Pds,
1599}
1600
1601impl std::fmt::Display for ResponseOrigin {
1602    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1603        match self {
1604            ResponseOrigin::Labeler => f.write_str("Labeler"),
1605            ResponseOrigin::Pds => f.write_str("PDS"),
1606        }
1607    }
1608}
1609
1610/// Construct a `NamedSource` and a span covering the whole body.
1611///
1612/// The span must be non-empty (and present on a `#[label]` field) for miette's
1613/// `GraphicalReportHandler` to actually render the `source_code` block; a
1614/// `None` span causes the source to be silently dropped from the rendered
1615/// diagnostic. We therefore return the span alongside the source and expect
1616/// every `accepted_*` / `rejected_*` diagnostic to wire both through.
1617pub(crate) fn body_as_named_source(
1618    resp: &RawCreateReportResponse,
1619) -> (NamedSource<Arc<[u8]>>, SourceSpan) {
1620    let pretty = pretty_json_for_display(&resp.raw_body);
1621    let span = SourceSpan::new(0.into(), pretty.len());
1622    (NamedSource::new(resp.source_url.clone(), pretty), span)
1623}
1624
1625/// Construct a `NamedSource` and whole-body span from the PDS. Used for
1626/// PDS-mediated mode diagnostics where the response comes from the PDS not
1627/// the labeler.
1628pub(crate) fn body_as_named_source_from_pds(
1629    resp: &RawPdsXrpcResponse,
1630) -> (NamedSource<Arc<[u8]>>, SourceSpan) {
1631    let pretty = pretty_json_for_display(&resp.raw_body);
1632    let span = SourceSpan::new(0.into(), pretty.len());
1633    (NamedSource::new(resp.source_url.clone(), pretty), span)
1634}
1635
1636/// Build a minimal, plausible createReport body for negative tests.
1637///
1638/// Chooses the lex-first advertised `reasonType` and the first advertised
1639/// `subjectType`, pointing at a safe subject (the labeler's own DID —
1640/// labelers never take action on themselves). The body is well-formed so
1641/// any validation short-circuit returns auth-layer rejection rather than
1642/// shape-layer rejection.
1643pub(crate) fn build_minimal_report_body(facts: &IdentityFacts) -> serde_json::Value {
1644    // Unwrap the contract — run() has already guaranteed it's present
1645    // and non-empty before this function is reachable.
1646    let reason_type = facts
1647        .reason_types
1648        .as_ref()
1649        .and_then(|v| v.first())
1650        .cloned()
1651        .unwrap_or_else(|| "com.atproto.moderation.defs#reasonOther".to_string());
1652
1653    let subject_types: &[String] = facts.subject_types.as_deref().unwrap_or(&[]);
1654    let subject = if subject_types.iter().any(|t| t == "account") {
1655        serde_json::json!({
1656            "$type": "com.atproto.admin.defs#repoRef",
1657            "did": facts.did.0,
1658        })
1659    } else if subject_types.iter().any(|t| t == "record") {
1660        serde_json::json!({
1661            "$type": "com.atproto.repo.strongRef",
1662            // Ghost AT-URI targeting the labeler's own DID. Negative-path
1663            // only; positive paths use the real pollution-avoidance logic
1664            // in `self_mint_accepted`.
1665            "uri": format!("at://{}/app.bsky.feed.post/not-real", facts.did.0),
1666            "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1667        })
1668    } else {
1669        // Fallback: account shape against the labeler itself.
1670        serde_json::json!({
1671            "$type": "com.atproto.admin.defs#repoRef",
1672            "did": facts.did.0,
1673        })
1674    };
1675
1676    serde_json::json!({
1677        "reasonType": reason_type,
1678        "subject": subject,
1679    })
1680}
1681
1682#[cfg(test)]
1683mod envelope_tests {
1684    use super::*;
1685
1686    #[test]
1687    fn parse_well_formed_envelope() {
1688        let body = br#"{"error":"BadJwt","message":"invalid token"}"#;
1689        let env = XrpcErrorEnvelope::parse(body).expect("parses");
1690        assert_eq!(env.error.as_deref(), Some("BadJwt"));
1691        assert_eq!(env.message.as_deref(), Some("invalid token"));
1692        assert!(env.has_nonempty_error());
1693    }
1694
1695    #[test]
1696    fn parse_empty_envelope() {
1697        let body = br#"{}"#;
1698        let env = XrpcErrorEnvelope::parse(body).expect("parses empty object");
1699        assert_eq!(env.error, None);
1700        assert!(!env.has_nonempty_error());
1701    }
1702
1703    #[test]
1704    fn parse_non_json_returns_none() {
1705        assert!(XrpcErrorEnvelope::parse(b"<html>").is_none());
1706    }
1707
1708    #[test]
1709    fn parse_empty_error_field_treated_as_missing() {
1710        let body = br#"{"error":""}"#;
1711        let env = XrpcErrorEnvelope::parse(body).unwrap();
1712        assert!(!env.has_nonempty_error());
1713    }
1714}