CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity): add encode_multikey companion to parse_multikey

Implement encode_multikey to convert AnyVerifyingKey to multibase-multikey
format (base58btc with multicodec curve prefix). This is the inverse of the
existing parse_multikey function and is required for building DID documents
in the self-mint identity server.

Round-trip tests verify both secp256k1 and P-256 keys encode and decode
correctly without information loss.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

authored by

Jack Grigg
Claude Haiku 4.5
and committed by
Tangled
9295c29f 2fe2c840

+6176
+1
Cargo.lock
··· 165 165 "ciborium", 166 166 "clap", 167 167 "futures-util", 168 + "getrandom 0.2.17", 168 169 "hickory-resolver", 169 170 "humantime", 170 171 "insta",
+755
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_01.md
··· 1 + # Labeler report stage — Phase 1: Shared primitives 2 + 3 + **Goal:** Land the crate-wide primitives (signing-key newtype, hand-rolled JWT helpers, sentinel-reason builder, local-labeler viability classifier) that later phases depend on. No report-stage code is introduced yet. 4 + 5 + **Architecture:** Extend `src/common/identity.rs` with a mirror of `AnyVerifyingKey` for signing keys, introduce a new tiny `src/common/jwt.rs` module for compact JWS (hand-rolled over already-present `k256`/`p256`/`sha2`/`base64`), and stage-local helpers for the sentinel-reason string and hostname classification. No new Cargo dependencies. 6 + 7 + **Tech Stack:** Rust 2024, `k256`/`p256` ECDSA, `sha2`, `base64` (all already in Cargo.toml), `std::net::Ipv4Addr::is_private/is_loopback`, `serde_json`. 8 + 9 + **Scope:** Phase 1 of 8 from design `docs/design-plans/2026-04-17-labeler-report-stage.md`. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `Cargo.toml`, `src/common/identity.rs` lines 1-320, `src/commands/test/labeler/CLAUDE.md`, `src/common/CLAUDE.md`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `AnyVerifyingKey` at `src/common/identity.rs:112-117` has variants `K256(k256::ecdsa::VerifyingKey)` and `P256(p256::ecdsa::VerifyingKey)`. Design said `Secp256k1` — actual variant is `K256`. New `AnySigningKey` MUST use matching variant names (`K256`, `P256`) to align with `AnyVerifyingKey`. 15 + - ✓ `AnySignature` at `src/common/identity.rs:150-155` has `K256(k256::ecdsa::Signature)` and `P256(p256::ecdsa::Signature)`. No existing `normalize_s` helper — must normalize on construction. 16 + - ✓ `k256` and `p256` in `Cargo.toml:31,34` with `ecdsa` feature. 17 + - ✓ `src/common/` currently contains only `identity.rs` and `diagnostics.rs`. `jwt.rs` does NOT exist. 18 + - ✓ `src/common.rs` (or `src/common/mod.rs`) sibling file — verify existence via `ls src/common.rs` during Task 1. The crate uses foo.rs + foo/ sibling-file layout (CLAUDE.md convention), so `src/common.rs` exists and declares `pub mod identity;` / `pub mod diagnostics;`. 19 + - ✓ `src/commands/test/labeler/` contains `crypto.rs`, `http.rs`, `identity.rs`, `subscription.rs`, `pipeline.rs`, `report.rs`. No `create_report.rs` / `create_report/` yet. 20 + - ✓ No hostname-classification functions in `src/common/`. 21 + - ✓ No `chrono`/`time`/`uuid` crate. Sentinel RFC3339 and `jti` randomness must be hand-rolled. 22 + - ✓ `rustc 1.85` + Rust 2024 edition. 23 + - ✓ `uninlined_format_args = "deny"` — use `format!("{x}")`, never `format!("{}", x)`. 24 + - + `percent-encoding` at `Cargo.toml:35` is already a dep (will be used in Phase 2 for `did:web:127.0.0.1%3A{port}`). 25 + - + `base64 = "0.22"` already in `Cargo.toml:23` with `base64::Engine` usage pattern at `src/commands/test/labeler/http.rs:12,361-363` (URL_SAFE_NO_PAD needed for JWT). 26 + 27 + **External dependency research findings:** 28 + - ✓ `k256::ecdsa::SigningKey::sign_prehash(&hash) -> Result<Signature>` produces a low-s-normalized signature by default (BIP-0062 low-s enforcement is built-in). Returns a non-recoverable `k256::ecdsa::Signature` whose `r || s` bytes can be extracted via `Signature::to_bytes() -> GenericArray<u8, 64>`. 29 + - ✓ `p256::ecdsa::SigningKey::sign_prehash(&hash) -> Result<Signature>` returns a `p256::ecdsa::Signature` that may be high-s; call `Signature::normalize_s()` to get the low-s form. Both curves: the resulting JWS signature bytes are the raw `r || s` 64-byte big-endian concatenation (not DER), per RFC 7518 §3.4 (ES256) and §3.8 (analogously for ES256K). 30 + - ✓ JWT compact serialization per RFC 7515: `BASE64URL(UTF8(header)).BASE64URL(UTF8(claims)).BASE64URL(signature)` with unpadded base64url (`STANDARD_NO_PAD` is wrong encoding; we need `URL_SAFE_NO_PAD`). 31 + - ✓ Header fields: `{"typ":"JWT","alg":"ES256K"}` or `"ES256"`. `typ` is required in atproto service-auth (observed in Ozone JWT validation). 32 + - ✓ `sha2::Sha256` is already a dep; `Digest::digest` consumes a `&[u8]` and returns a 32-byte array — use for the JWT prehash (the string `header_b64 + "." + claims_b64`). 33 + - ✓ `jti`: atproto spec says "unique random nonce string"; no format constraint. Hex of 16 random bytes (32 hex chars) is a standard choice. **Verified 2026-04-17 via `cargo read` + dependency inspection:** `k256 0.13.4` → `elliptic-curve 0.13.8` → `rand_core 0.6.4` with `default-features = false`, which means `OsRng` is NOT re-exported through `k256::elliptic_curve::rand_core` (OsRng is gated behind the `getrandom` feature). **Resolution: add `getrandom = "0.2"` as a direct dep in `Cargo.toml`.** This is a narrow, well-maintained crate already present transitively (three versions in the lockfile); promoting it to a direct dep costs nothing and is the smallest viable fix. The design's "no new crate dependencies" note assumed OsRng was reachable; investigation showed otherwise. 34 + - ✓ RFC 3339 UTC: format as `YYYY-MM-DDTHH:MM:SSZ` from `SystemTime::now().duration_since(UNIX_EPOCH)`. A 16-line hand-rolled formatter is adequate (no chrono/time needed). Leap seconds ignored; the sentinel reason is a human-readable label, not a parseable timestamp. 35 + - ✓ Hostname classifier: `std::net::Ipv4Addr::is_private()` matches RFC 1918 ranges exactly (10/8, 172.16/12, 192.168/16). `is_loopback()` covers 127/8. IPv6 `::1` via `Ipv6Addr::is_loopback()`. `.local` suffix is a case-insensitive string match. `localhost` is a case-insensitive string match. 36 + 37 + --- 38 + 39 + ## Acceptance criteria coverage 40 + 41 + This phase implements and tests infrastructure only. No acceptance criteria from the design are directly verified here — these primitives are exercised through phases 2-8. 42 + 43 + **Verifies: None** — Phase 1 is pure scaffolding. 44 + 45 + --- 46 + 47 + <!-- START_TASK_0 --> 48 + ### Task 0: Add `getrandom` as a direct Cargo dep 49 + 50 + **Files:** 51 + - Modify: `Cargo.toml` — add a new entry in `[dependencies]`. 52 + 53 + **Implementation:** 54 + 55 + ```toml 56 + # Direct CSPRNG access for JWT `jti` nonces and the sentinel run-id. 57 + # Transitively present (k256 → elliptic-curve → rand_core with the 58 + # `getrandom` feature disabled), so we can't reach OsRng through the 59 + # existing dep graph. Promoting `getrandom` to a direct dep is the 60 + # smallest viable fix. 61 + getrandom = "0.2" 62 + ``` 63 + 64 + Place the entry in the existing `[dependencies]` block at `Cargo.toml:17-45` in alphabetical order (between `futures-util` and `hickory-resolver`). 65 + 66 + **Verification:** 67 + Run: `cargo build` 68 + Expected: new dep is resolved; all existing deps still resolve. 69 + 70 + Run: `cargo tree -p getrandom@0.2 --depth 0 2>/dev/null | head -5` 71 + Expected: `getrandom v0.2.*` appears as a direct dep of `atproto-devtool`. 72 + 73 + **Commit:** `chore(deps): add getrandom for CSPRNG access` 74 + <!-- END_TASK_0 --> 75 + 76 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 77 + <!-- START_TASK_1 --> 78 + ### Task 1: Add `AnySigningKey` enum mirroring `AnyVerifyingKey` 79 + 80 + **Files:** 81 + - Modify: `src/common/identity.rs` — add new enum and impl block directly after `impl AnyVerifyingKey { ... }` (after current line ~146, before `pub enum AnySignature` at line 150). 82 + - Modify: `src/common.rs` if it re-exports symbols — verify and extend if needed. (If `src/common.rs` only contains `pub mod identity;` / `pub mod diagnostics;`, no change is required; `crate::common::identity::AnySigningKey` works.) 83 + 84 + **Implementation:** 85 + 86 + Define a new newtype enum parallel to `AnyVerifyingKey`: 87 + 88 + ```rust 89 + /// A private signing key that may be one of several supported curves. 90 + /// 91 + /// Mirrors `AnyVerifyingKey` for the signing side. Signatures produced by 92 + /// `sign` are always low-s normalized to match the atproto convention 93 + /// already established by `AnySignature`. 94 + #[derive(Debug, Clone)] 95 + pub enum AnySigningKey { 96 + /// secp256k1 signing key. 97 + K256(k256::ecdsa::SigningKey), 98 + /// P-256 signing key. 99 + P256(p256::ecdsa::SigningKey), 100 + } 101 + 102 + impl AnySigningKey { 103 + /// Returns the corresponding verifying key. 104 + pub fn verifying_key(&self) -> AnyVerifyingKey { 105 + match self { 106 + AnySigningKey::K256(k) => AnyVerifyingKey::K256(*k.verifying_key()), 107 + AnySigningKey::P256(k) => AnyVerifyingKey::P256(*k.verifying_key()), 108 + } 109 + } 110 + 111 + /// Returns the JWT `alg` header identifier for this key's curve 112 + /// ("ES256K" for secp256k1, "ES256" for P-256). 113 + pub fn jwt_alg(&self) -> &'static str { 114 + match self { 115 + AnySigningKey::K256(_) => "ES256K", 116 + AnySigningKey::P256(_) => "ES256", 117 + } 118 + } 119 + 120 + /// Signs the SHA-256 prehash of `msg` and returns the signature in 121 + /// low-s normalized form. 122 + /// 123 + /// The returned `AnySignature` is guaranteed to satisfy 124 + /// `AnyVerifyingKey::verify_prehash` against the corresponding 125 + /// verifying key when given the same prehash bytes. 126 + pub fn sign(&self, msg: &[u8]) -> AnySignature { 127 + use sha2::{Digest, Sha256}; 128 + let prehash: [u8; 32] = Sha256::digest(msg).into(); 129 + self.sign_prehash(&prehash) 130 + } 131 + 132 + /// Signs a precomputed 32-byte SHA-256 prehash directly. 133 + pub fn sign_prehash(&self, prehash: &[u8; 32]) -> AnySignature { 134 + use k256::ecdsa::signature::hazmat::PrehashSigner as K256PrehashSigner; 135 + use p256::ecdsa::signature::hazmat::PrehashSigner as P256PrehashSigner; 136 + match self { 137 + AnySigningKey::K256(k) => { 138 + // k256's sign_prehash already returns a low-s normalized 139 + // signature (BIP-0062 enforcement is built in). Returns an 140 + // ecdsa::Signature. 141 + let sig: k256::ecdsa::Signature = 142 + K256PrehashSigner::sign_prehash(k, prehash) 143 + .expect("SHA-256 output is always 32 bytes"); 144 + AnySignature::K256(sig) 145 + } 146 + AnySigningKey::P256(k) => { 147 + // p256's sign_prehash may return a high-s signature; 148 + // normalize explicitly. 149 + let sig: p256::ecdsa::Signature = 150 + P256PrehashSigner::sign_prehash(k, prehash) 151 + .expect("SHA-256 output is always 32 bytes"); 152 + let normalized = sig.normalize_s().unwrap_or(sig); 153 + AnySignature::P256(normalized) 154 + } 155 + } 156 + } 157 + 158 + /// Serializes the signature bytes for JWS compact form: raw `r || s` 159 + /// big-endian concatenation (NOT DER). 160 + /// 161 + /// For both ES256 and ES256K this is a 64-byte fixed-length array. 162 + pub fn signature_to_jws_bytes(sig: &AnySignature) -> [u8; 64] { 163 + match sig { 164 + AnySignature::K256(s) => s.to_bytes().into(), 165 + AnySignature::P256(s) => s.to_bytes().into(), 166 + } 167 + } 168 + } 169 + ``` 170 + 171 + **Notes for the implementor:** 172 + - `PrehashSigner::sign_prehash` returns `Result<Signature, Error>`; the only failure case is an invalid prehash length, which cannot happen for a fixed 32-byte array. The `.expect()` on a known-32-byte input is safe. If clippy complains, an `unreachable!()` after a `match prehash.len()` check is equivalent. 173 + - `k256::ecdsa::Signature::to_bytes()` and `p256::ecdsa::Signature::to_bytes()` both return a `GenericArray<u8, 64>` (32-byte `r` ++ 32-byte `s`). Convert to `[u8; 64]` with `.into()`. 174 + - Imports: no new imports at the top of the file are required for the fn itself beyond what's already present (`sha2::Digest`, `k256`, `p256`). Import the `PrehashSigner` traits inside the function with aliased names per the existing pattern at line 7 (`use k256::ecdsa::signature::hazmat::PrehashVerifier`). 175 + 176 + **Testing:** 177 + 178 + Add a unit-test module inside `src/common/identity.rs` (or extend the existing one if present — search for `#[cfg(test)]`). Tests must verify: 179 + 180 + - Generating a random `AnySigningKey::K256` and `AnySigningKey::P256`, signing the same message, and verifying against the `verifying_key()` succeeds with `verify_prehash`. 181 + - `AnySigningKey::jwt_alg()` returns `"ES256K"` and `"ES256"` respectively. 182 + - `AnySigningKey::signature_to_jws_bytes(&sig)` produces a 64-byte array. 183 + - For P-256: signing yields a low-s signature. After `sig.normalize_s().unwrap_or(sig)`, the resulting `s` scalar is ≤ n/2. (A deterministic test: generate a fixed seed, sign known input, assert `s` is in the low half of the curve order by comparing to `Scalar::ONE.neg().div_by_two()` or use `ecdsa::hazmat::bits2field` identity — simpler is to verify the signature round-trips with a standalone verifier that rejects high-s.) 184 + 185 + Follow the existing test pattern at the bottom of `src/common/identity.rs` (search for `#[cfg(test)]`). Use `k256::ecdsa::SigningKey::random(&mut OsRng)` — the `OsRng` path is `k256::elliptic_curve::rand_core::OsRng` (re-exported via the `rand_core` feature of the underlying `elliptic-curve` crate). If `OsRng` is not re-exported, use a deterministic seed: `SigningKey::from_slice(&[1u8; 32])` (any non-zero 32-byte value works for test purposes). 186 + 187 + **Verification:** 188 + Run: `cargo test --lib common::identity::tests::any_signing_key_` 189 + Expected: all new tests pass. 190 + 191 + Run: `cargo clippy --all-targets -- -D warnings` 192 + Expected: no warnings. 193 + 194 + **Commit:** `feat(identity): add AnySigningKey newtype with ES256/ES256K low-s signing` 195 + <!-- END_TASK_1 --> 196 + 197 + <!-- START_TASK_2 --> 198 + ### Task 2: Add hand-rolled JWT helpers in `src/common/jwt.rs` 199 + 200 + **Files:** 201 + - Create: `src/common/jwt.rs` — new module. 202 + - Modify: `src/common.rs` — add `pub mod jwt;`. 203 + - Test: unit tests in the same file under `#[cfg(test)]`. 204 + 205 + **Implementation:** 206 + 207 + Create the module with a minimal compact-form JWS encoder and a round-trip decoder. No external JWT library; use `serde_json` + `base64` + the new `AnySigningKey`. 208 + 209 + ```rust 210 + //! Minimal hand-rolled JWT (RFC 7515 compact JWS) encoder and decoder for 211 + //! atproto service-auth. 212 + //! 213 + //! This module exists to avoid pulling a full JWT library for a handful of 214 + //! tightly-scoped use cases: minting self-mint JWTs for labeler conformance 215 + //! tests, and decoding them in tests to verify round-trip correctness. 216 + //! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature 217 + //! encoding, unpadded base64url segments, UTF-8 JSON payloads. 218 + 219 + use base64::Engine; 220 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 221 + use serde::{Deserialize, Serialize}; 222 + use thiserror::Error; 223 + 224 + use crate::common::identity::{AnySignature, AnySigningKey, AnyVerifyingKey, AnySignatureError}; 225 + 226 + /// Compact JWS header for atproto service-auth tokens. 227 + /// 228 + /// Field names map 1:1 to the JWS wire format (RFC 7515 §4.1). Do NOT 229 + /// rename without updating `serde` attributes — atproto wire format 230 + /// requires exactly `alg` and `typ`. 231 + #[derive(Debug, Clone, Serialize, Deserialize)] 232 + pub struct JwtHeader { 233 + /// Algorithm identifier: "ES256K" (secp256k1) or "ES256" (P-256). 234 + pub alg: String, 235 + /// Token type; always "JWT". 236 + pub typ: String, 237 + } 238 + 239 + impl JwtHeader { 240 + /// Build a header for the given signing key, setting `alg` to match the 241 + /// curve and `typ` to `"JWT"`. 242 + pub fn for_signing_key(key: &AnySigningKey) -> Self { 243 + Self { 244 + alg: key.jwt_alg().to_string(), 245 + typ: "JWT".to_string(), 246 + } 247 + } 248 + } 249 + 250 + /// Atproto service-auth JWT claims. 251 + /// 252 + /// Fields match the atproto inter-service authentication spec: 253 + /// <https://atproto.com/specs/xrpc#inter-service-authentication>. `nbf` is 254 + /// deliberately omitted — the spec does not require it and some servers 255 + /// reject unexpected claims. 256 + /// 257 + /// **Field names are wire-format-critical:** `iss`, `aud`, `exp`, `iat`, 258 + /// `lxm`, `jti` are the exact JSON keys atproto labelers expect. Do NOT 259 + /// rename without adding `#[serde(rename = "...")]` attributes. 260 + #[derive(Debug, Clone, Serialize, Deserialize)] 261 + pub struct JwtClaims { 262 + /// Issuer DID (e.g., `did:web:127.0.0.1%3A5000`). 263 + pub iss: String, 264 + /// Audience — the target service's DID, bare (no `#fragment`). 265 + pub aud: String, 266 + /// Expiration, UNIX seconds. 267 + pub exp: i64, 268 + /// Issued-at, UNIX seconds. 269 + pub iat: i64, 270 + /// Lexicon method NSID the token authorizes (e.g., 271 + /// `com.atproto.moderation.createReport`). 272 + pub lxm: String, 273 + /// Random nonce to prevent replay — hex string, 32 chars (16 bytes). 274 + pub jti: String, 275 + } 276 + 277 + /// Errors from JWT encode/decode. 278 + /// 279 + /// **Not user-rendered:** these errors only surface inside tests and 280 + /// library helpers. They deliberately do NOT derive `miette::Diagnostic` 281 + /// with stable codes — the stage converts any failure into a 282 + /// `CreateReportStageError::Transport` or a specific check SpecViolation 283 + /// before rendering. If a future caller needs one of these variants 284 + /// rendered to the user, they must wrap it in a stage-local diagnostic 285 + /// with a proper `code = "labeler::..."` string. 286 + #[derive(Debug, Error)] 287 + pub enum JwtError { 288 + /// Compact form was not three `.`-separated base64url segments. 289 + #[error("malformed compact JWT: expected three segments")] 290 + MalformedCompact, 291 + /// A base64url segment failed to decode. 292 + #[error("base64url decode failed for {segment}")] 293 + Base64Decode { 294 + /// Which segment failed: "header", "claims", or "signature". 295 + segment: &'static str, 296 + /// Underlying base64 error. 297 + #[source] 298 + source: base64::DecodeError, 299 + }, 300 + /// A segment decoded to valid bytes but invalid JSON. 301 + #[error("JSON decode failed for {segment}")] 302 + JsonDecode { 303 + /// Which segment failed: "header" or "claims". 304 + segment: &'static str, 305 + /// Underlying serde_json error. 306 + #[source] 307 + source: serde_json::Error, 308 + }, 309 + /// JSON serialization of header or claims failed (should not happen for 310 + /// well-formed structs). 311 + #[error("JSON encode failed")] 312 + JsonEncode(#[from] serde_json::Error), 313 + /// Signature was not exactly 64 bytes. 314 + #[error("signature was {actual} bytes; expected 64")] 315 + SignatureLength { 316 + /// Actual length in bytes. 317 + actual: usize, 318 + }, 319 + /// The algorithm identifier in the header is not recognized. 320 + #[error("unsupported JWT alg `{alg}` (expected ES256 or ES256K)")] 321 + UnsupportedAlg { 322 + /// The unrecognized algorithm string. 323 + alg: String, 324 + }, 325 + /// Underlying ECDSA verification failure (e.g., curve mismatch). 326 + #[error("signature verification failed")] 327 + SignatureVerify(#[from] AnySignatureError), 328 + } 329 + 330 + /// Encode a JWT in compact form: `base64url(header).base64url(claims).base64url(signature)`. 331 + /// 332 + /// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256 333 + /// prehash under the supplied key. Returns the full compact token string. 334 + pub fn encode_compact( 335 + header: &JwtHeader, 336 + claims: &JwtClaims, 337 + signer: &AnySigningKey, 338 + ) -> Result<String, JwtError> { 339 + let header_json = serde_json::to_vec(header)?; 340 + let claims_json = serde_json::to_vec(claims)?; 341 + let header_b64 = URL_SAFE_NO_PAD.encode(&header_json); 342 + let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_json); 343 + let signing_input = format!("{header_b64}.{claims_b64}"); 344 + let sig = signer.sign(signing_input.as_bytes()); 345 + let sig_bytes = AnySigningKey::signature_to_jws_bytes(&sig); 346 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes); 347 + Ok(format!("{header_b64}.{claims_b64}.{sig_b64}")) 348 + } 349 + 350 + /// Decode a compact JWT into `(header, claims, signature_bytes)`. 351 + /// 352 + /// Does NOT verify the signature — use `verify_compact` for that. This helper 353 + /// is primarily for test round-tripping and for negative-test assertions 354 + /// (e.g., "the minted token has the expected `alg` header"). 355 + pub fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> { 356 + let mut parts = token.splitn(3, '.'); 357 + let header_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 358 + let claims_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 359 + let sig_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 360 + if parts.next().is_some() { 361 + return Err(JwtError::MalformedCompact); 362 + } 363 + let header_bytes = URL_SAFE_NO_PAD 364 + .decode(header_b64) 365 + .map_err(|source| JwtError::Base64Decode { segment: "header", source })?; 366 + let claims_bytes = URL_SAFE_NO_PAD 367 + .decode(claims_b64) 368 + .map_err(|source| JwtError::Base64Decode { segment: "claims", source })?; 369 + let sig_bytes = URL_SAFE_NO_PAD 370 + .decode(sig_b64) 371 + .map_err(|source| JwtError::Base64Decode { segment: "signature", source })?; 372 + let header: JwtHeader = serde_json::from_slice(&header_bytes) 373 + .map_err(|source| JwtError::JsonDecode { segment: "header", source })?; 374 + let claims: JwtClaims = serde_json::from_slice(&claims_bytes) 375 + .map_err(|source| JwtError::JsonDecode { segment: "claims", source })?; 376 + Ok((header, claims, sig_bytes)) 377 + } 378 + 379 + /// Verify a compact JWT against the given verifying key. Does NOT check 380 + /// claim values (exp/aud/lxm) — that is the labeler's job in production, 381 + /// or the stage's assertion job in tests. Only verifies the signature. 382 + pub fn verify_compact(token: &str, vkey: &AnyVerifyingKey) -> Result<(JwtHeader, JwtClaims), JwtError> { 383 + let (header, claims, sig_bytes) = decode_compact(token)?; 384 + let expected_alg = match vkey { 385 + AnyVerifyingKey::K256(_) => "ES256K", 386 + AnyVerifyingKey::P256(_) => "ES256", 387 + }; 388 + if header.alg != expected_alg { 389 + return Err(JwtError::UnsupportedAlg { alg: header.alg.clone() }); 390 + } 391 + if sig_bytes.len() != 64 { 392 + return Err(JwtError::SignatureLength { actual: sig_bytes.len() }); 393 + } 394 + let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().expect("len checked above"); 395 + let any_sig = match vkey { 396 + AnyVerifyingKey::K256(_) => { 397 + let sig = k256::ecdsa::Signature::from_bytes(&sig_array.into()) 398 + .map_err(|_| JwtError::SignatureLength { actual: sig_bytes.len() })?; 399 + AnySignature::K256(sig) 400 + } 401 + AnyVerifyingKey::P256(_) => { 402 + let sig = p256::ecdsa::Signature::from_bytes(&sig_array.into()) 403 + .map_err(|_| JwtError::SignatureLength { actual: sig_bytes.len() })?; 404 + AnySignature::P256(sig) 405 + } 406 + }; 407 + // Recompute the signing input and verify. 408 + let dot = token.rfind('.').expect("three-segment token has a last dot"); 409 + let signing_input = &token[..dot]; 410 + use sha2::{Digest, Sha256}; 411 + let prehash: [u8; 32] = Sha256::digest(signing_input.as_bytes()).into(); 412 + vkey.verify_prehash(&prehash, &any_sig)?; 413 + Ok((header, claims)) 414 + } 415 + ``` 416 + 417 + **Testing:** 418 + 419 + Unit tests in the same file under `#[cfg(test)]`: 420 + 421 + - Round-trip for both curves: build a random signing key, encode a JwtClaims struct, decode with `verify_compact`, assert header and claims fields match. 422 + - `encode_compact` then tampering with the payload and calling `verify_compact` returns an error. 423 + - `decode_compact` rejects a two-segment or four-segment input with `MalformedCompact`. 424 + - `decode_compact` rejects invalid base64url with `Base64Decode`. 425 + - `verify_compact` rejects a JWT signed with one curve against the other curve's verifying key with `UnsupportedAlg`. 426 + 427 + **Verification:** 428 + Run: `cargo test --lib common::jwt::tests` 429 + Expected: all tests pass. 430 + 431 + Run: `cargo clippy --all-targets -- -D warnings` 432 + Expected: no warnings. 433 + 434 + **Commit:** `feat(jwt): add hand-rolled compact JWS helpers for ES256/ES256K` 435 + <!-- END_TASK_2 --> 436 + 437 + <!-- START_TASK_3 --> 438 + ### Task 3: Tests proving `AnySigningKey` + `jwt::encode_compact` round-trip end-to-end 439 + 440 + **Files:** 441 + - Modify: `src/common/jwt.rs` — add integration-style test asserting a signed token can be verified by `AnyVerifyingKey::verify_prehash` via `verify_compact`. 442 + - Modify: `src/common/identity.rs` — add a test that generates an `AnySigningKey::P256` key whose underlying `sign_prehash` would produce high-s, and verifies that `AnySigningKey::sign` normalizes. 443 + 444 + **Testing:** 445 + 446 + Tests must verify (these are the "subcomponent completeness" assertions): 447 + 448 + - Given a random `AnySigningKey::K256`, a `JwtClaims` struct, and `JwtHeader::for_signing_key`: `encode_compact` → `verify_compact` succeeds and returns the same claim fields. 449 + - Same for `AnySigningKey::P256`. 450 + - A token with a tampered `claims` segment fails `verify_compact` with `SignatureVerify`. 451 + - `encode_compact` output satisfies `token.split('.').count() == 3` and every segment decodes as URL-safe-no-pad base64. 452 + 453 + **Verification:** 454 + Run: `cargo test --lib common::jwt` 455 + Expected: 4+ tests pass, all green. 456 + 457 + **Commit:** `test(jwt): round-trip ES256/ES256K tokens against AnyVerifyingKey` 458 + <!-- END_TASK_3 --> 459 + <!-- END_SUBCOMPONENT_A --> 460 + 461 + <!-- START_SUBCOMPONENT_B (tasks 4-5) --> 462 + <!-- START_TASK_4 --> 463 + ### Task 4: Add `is_local_labeler_hostname` classifier in `src/common/identity.rs` 464 + 465 + **Files:** 466 + - Modify: `src/common/identity.rs` — add a free function near the bottom of the module (after the existing `plc_history_for_fragment` helpers, before the `#[cfg(test)]` mod). 467 + 468 + **Implementation:** 469 + 470 + ```rust 471 + /// Classify a URL's hostname as "locally reachable from the tool's 472 + /// machine" for the purposes of self-mint `did:web` viability. 473 + /// 474 + /// Returns `true` when the hostname is one of: 475 + /// - `localhost` (case-insensitive) 476 + /// - `127.0.0.1` (or any IPv4 loopback / `::1`) 477 + /// - Any `.local` mDNS suffix (case-insensitive) 478 + /// - Any RFC 1918 IPv4 private address (10/8, 172.16/12, 192.168/16) 479 + /// 480 + /// Returns `false` for all other hostnames. IPv6 private ranges (fc00::/7, 481 + /// link-local) are deliberately NOT classified as local in v1; revisit if 482 + /// users report issues. 483 + pub fn is_local_labeler_hostname(url: &Url) -> bool { 484 + let host = match url.host_str() { 485 + Some(h) => h, 486 + None => return false, 487 + }; 488 + let lower = host.to_ascii_lowercase(); 489 + if lower == "localhost" { 490 + return true; 491 + } 492 + if lower.ends_with(".local") { 493 + return true; 494 + } 495 + if let Ok(ipv4) = lower.parse::<std::net::Ipv4Addr>() { 496 + return ipv4.is_loopback() || ipv4.is_private(); 497 + } 498 + if let Ok(ipv6) = lower.parse::<std::net::Ipv6Addr>() { 499 + return ipv6.is_loopback(); 500 + } 501 + false 502 + } 503 + ``` 504 + 505 + **Testing:** 506 + 507 + Unit test table near the existing tests in `src/common/identity.rs`: 508 + 509 + ```rust 510 + #[test] 511 + fn is_local_labeler_hostname_classifies_expected_hosts() { 512 + let cases: &[(&str, bool)] = &[ 513 + // Positive: localhost variants. 514 + ("http://localhost/", true), 515 + ("https://LOCALHOST:8080/foo", true), 516 + ("http://127.0.0.1/", true), 517 + ("http://127.1.2.3/", true), 518 + ("http://[::1]/", true), 519 + // Positive: .local mDNS. 520 + ("http://mybox.local/", true), 521 + ("https://mybox.LOCAL:8443/", true), 522 + // Positive: RFC 1918. 523 + ("http://10.0.0.1/", true), 524 + ("http://172.16.0.1/", true), 525 + ("http://172.31.255.255/", true), 526 + ("http://192.168.1.100/", true), 527 + // Negative: public. 528 + ("https://labeler.example.com/", false), 529 + ("http://8.8.8.8/", false), 530 + ("http://172.15.0.1/", false), // outside 172.16/12 531 + ("http://172.32.0.1/", false), // outside 172.16/12 532 + ("http://11.0.0.1/", false), // outside 10/8 once we pass 10.x 533 + ("http://172.17.1.1/", true), // inside 172.16/12 534 + ]; 535 + for (url, expected) in cases { 536 + let parsed = url::Url::parse(url).expect("test URLs are valid"); 537 + assert_eq!( 538 + is_local_labeler_hostname(&parsed), 539 + *expected, 540 + "classification mismatch for {url}" 541 + ); 542 + } 543 + } 544 + ``` 545 + 546 + **Verification:** 547 + Run: `cargo test --lib common::identity::tests::is_local_labeler_hostname_` 548 + Expected: table test passes. 549 + 550 + **Commit:** `feat(identity): classify labeler hostnames as local vs remote` 551 + <!-- END_TASK_4 --> 552 + 553 + <!-- START_TASK_5 --> 554 + ### Task 5: Sentinel-reason builder module 555 + 556 + **Files:** 557 + - Create: `src/commands/test/labeler/create_report.rs` — new file containing only the module declaration and a pub-use re-export of the `sentinel` submodule. (This seeds the module tree; phases 3-4 will add the stage's actual logic.) 558 + - Create: `src/commands/test/labeler/create_report/` — new sibling directory. 559 + - Create: `src/commands/test/labeler/create_report/sentinel.rs` — the builder. 560 + - Modify: `src/commands/test/labeler.rs` — add `pub mod create_report;` after the existing `pub mod crypto;` entry (maintaining alphabetical order: crypto, create_report — `create_report` comes before `crypto` alphabetically, so insert it as the new first entry). 561 + 562 + **Implementation:** 563 + 564 + `src/commands/test/labeler/create_report.rs` (seed file, will be extended in Phase 4): 565 + 566 + ```rust 567 + //! `report` stage: exercises the labeler's authenticated 568 + //! `com.atproto.moderation.createReport` path. 569 + //! 570 + //! Scaffolding only in Phase 1. Stage `run()` and full public surface land 571 + //! in Phase 4. The `sentinel` submodule is self-contained and is exercised 572 + //! by later phases for the pollution-avoidance sentinel reason string. 573 + 574 + pub mod sentinel; 575 + ``` 576 + 577 + `src/commands/test/labeler/create_report/sentinel.rs`: 578 + 579 + ```rust 580 + //! Builder for the sentinel `reason` field used in conformance-test reports. 581 + //! 582 + //! Every committing check's report body carries a stable, recognizable 583 + //! string in its `reason` field so that labeler operators can identify and 584 + //! dismiss reports submitted by `atproto-devtool` without mistaking them 585 + //! for real user reports. 586 + //! 587 + //! Format: `atproto-devtool conformance test <RFC3339-UTC> <run-id>` 588 + //! 589 + //! Example: `atproto-devtool conformance test 2026-04-17T12:34:56Z 5f9c1a3b4d7e8a0f` 590 + //! 591 + //! The run-id is a 16-hex-char random nonce generated once per pipeline run 592 + //! (not per check); the same run-id is reused across all report submissions 593 + //! within a single `test labeler` invocation so operators can trace a group 594 + //! of test reports back to one run. 595 + 596 + use std::time::{SystemTime, UNIX_EPOCH}; 597 + 598 + /// Prefix used so operators can grep their moderation queue for 599 + /// conformance-test reports with a single query. 600 + pub const SENTINEL_PREFIX: &str = "atproto-devtool conformance test"; 601 + 602 + /// Build a sentinel reason string. `run_id` should be a stable 16-hex-char 603 + /// identifier for the current test invocation; `now` is the current wall-clock 604 + /// time, typically `SystemTime::now()`. 605 + pub fn build(run_id: &str, now: SystemTime) -> String { 606 + let rfc3339 = format_rfc3339_utc(now); 607 + format!("{SENTINEL_PREFIX} {rfc3339} {run_id}") 608 + } 609 + 610 + /// Hand-rolled RFC 3339 UTC formatter: `YYYY-MM-DDTHH:MM:SSZ`. 611 + /// 612 + /// Avoids a `chrono` / `time` dependency. Leap seconds are not handled; 613 + /// the sentinel reason is a human-readable label, not a parseable timestamp. 614 + /// For times before the UNIX epoch or more than `i64::MAX` seconds in the 615 + /// future we degrade gracefully to `1970-01-01T00:00:00Z`. 616 + fn format_rfc3339_utc(ts: SystemTime) -> String { 617 + let secs = ts.duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); 618 + let (year, month, day, hour, min, sec) = unix_to_civil(secs); 619 + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z") 620 + } 621 + 622 + /// Convert UNIX seconds to a civil date-time (UTC) using Howard Hinnant's 623 + /// algorithm for the Gregorian calendar. Correct for all years in [1, 9999]. 624 + fn unix_to_civil(secs: i64) -> (i32, u32, u32, u32, u32, u32) { 625 + // Seconds-of-day. 626 + let days = secs.div_euclid(86_400); 627 + let sod = secs.rem_euclid(86_400); 628 + let hour = (sod / 3600) as u32; 629 + let min = ((sod % 3600) / 60) as u32; 630 + let sec = (sod % 60) as u32; 631 + 632 + // Days since 1970-01-01 -> civil date. Algorithm from 633 + // http://howardhinnant.github.io/date_algorithms.html#civil_from_days. 634 + let z = days + 719_468; 635 + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; 636 + let doe = (z - era * 146_097) as u32; // [0, 146096] 637 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] 638 + let y = yoe as i32 + era as i32 * 400; 639 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] 640 + let mp = (5 * doy + 2) / 153; // [0, 11] 641 + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] 642 + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] 643 + let year = if m <= 2 { y + 1 } else { y }; 644 + (year, m, d, hour, min, sec) 645 + } 646 + 647 + /// Generate a random 16-hex-char run identifier. Uses `getrandom` 648 + /// (added as a direct dep during this task — see Cargo.toml edit below). 649 + pub fn new_run_id() -> String { 650 + let mut bytes = [0u8; 8]; 651 + getrandom::getrandom(&mut bytes).expect("OS CSPRNG is always available on supported platforms"); 652 + bytes.iter().map(|b| format!("{b:02x}")).collect() 653 + } 654 + ``` 655 + 656 + **Notes for the implementor:** 657 + - Howard Hinnant's algorithm above is correct for Gregorian civil dates in [0001-01-01, 9999-12-31]. The tests below pin a few known conversions so any transcription error is caught immediately. 658 + - If `k256::elliptic_curve::rand_core::OsRng` is not exposed in the installed version of `k256`, run `cargo read k256 --api | grep -i rand` during implementation to locate the re-export path. As a last resort, `getrandom = "0.2"` can be added to `Cargo.toml` — but the design explicitly targets "no new crate dependencies," so prefer the transitive path first. If a new dep is unavoidable, flag to the user rather than adding it silently. 659 + 660 + **Testing:** 661 + 662 + Unit tests in the same file: 663 + 664 + ```rust 665 + #[cfg(test)] 666 + mod tests { 667 + use super::*; 668 + 669 + #[test] 670 + fn format_rfc3339_utc_pins_known_points() { 671 + // 1970-01-01T00:00:00Z 672 + assert_eq!(format_rfc3339_utc(UNIX_EPOCH), "1970-01-01T00:00:00Z"); 673 + // 2026-04-17T00:00:00Z — 1_776_643_200 UNIX seconds. 674 + let t = UNIX_EPOCH + std::time::Duration::from_secs(1_776_643_200); 675 + assert_eq!(format_rfc3339_utc(t), "2026-04-17T00:00:00Z"); 676 + // Leap year: 2024-02-29T12:34:56Z — 1_709_210_096. 677 + let t = UNIX_EPOCH + std::time::Duration::from_secs(1_709_210_096); 678 + assert_eq!(format_rfc3339_utc(t), "2024-02-29T12:34:56Z"); 679 + } 680 + 681 + #[test] 682 + fn build_contains_prefix_and_run_id() { 683 + let s = build("abcdef1234567890", UNIX_EPOCH); 684 + assert!(s.starts_with(SENTINEL_PREFIX)); 685 + assert!(s.ends_with("abcdef1234567890")); 686 + assert!(s.contains("1970-01-01T00:00:00Z")); 687 + } 688 + 689 + #[test] 690 + fn new_run_id_is_16_hex_chars() { 691 + let id = new_run_id(); 692 + assert_eq!(id.len(), 16); 693 + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); 694 + } 695 + 696 + #[test] 697 + fn new_run_id_is_unique_between_calls() { 698 + // 128 bits of entropy in 16 hex chars / 2 = 64 bits. Collision 699 + // probability is negligible in a two-call test. 700 + let a = new_run_id(); 701 + let b = new_run_id(); 702 + assert_ne!(a, b); 703 + } 704 + } 705 + ``` 706 + 707 + **Verification:** 708 + Run: `cargo test --lib commands::test::labeler::create_report::sentinel::tests` 709 + Expected: all 4 tests pass. 710 + 711 + Run: `cargo build` 712 + Expected: builds cleanly, new module trees are wired in. 713 + 714 + Run: `cargo clippy --all-targets -- -D warnings` 715 + Expected: no warnings. 716 + 717 + **Commit:** `feat(labeler): add create_report stage module seed with sentinel builder` 718 + <!-- END_TASK_5 --> 719 + <!-- END_SUBCOMPONENT_B --> 720 + 721 + <!-- START_TASK_6 --> 722 + ### Task 6: Phase 1 integration check 723 + 724 + **Files:** None changed in this task. 725 + 726 + **Implementation:** Run the full test suite and lint pass as a checkpoint that all Phase 1 pieces compose. 727 + 728 + **Verification:** 729 + Run: `cargo build` 730 + Expected: clean build. 731 + 732 + Run: `cargo test` 733 + Expected: all pre-existing tests still pass; 10+ new tests (signing-key, JWT, hostname classifier, sentinel) pass. 734 + 735 + Run: `cargo clippy --all-targets -- -D warnings` 736 + Expected: no warnings. 737 + 738 + Run: `cargo fmt --check` 739 + Expected: no changes required. 740 + 741 + **Commit:** No new commit unless fixes were needed; this task is a gate. 742 + <!-- END_TASK_6 --> 743 + 744 + --- 745 + 746 + ## Phase 1 complete when 747 + 748 + - `AnySigningKey` is available in `src/common/identity.rs` with sign/verify round-trips proven against `AnyVerifyingKey`. 749 + - `src/common/jwt.rs` compiles and its encode→decode→verify round-trip is proven for both ES256 and ES256K. 750 + - `is_local_labeler_hostname` classifies hostnames correctly across a table of cases that covers localhost, RFC 1918, `.local`, and public hosts. 751 + - `src/commands/test/labeler/create_report.rs` + `src/commands/test/labeler/create_report/sentinel.rs` exist and build; `sentinel::build` produces a string matching the design's format. 752 + - `cargo test`, `cargo clippy -- -D warnings`, and `cargo fmt --check` all pass. 753 + - **No new crate dependencies** have been added to `Cargo.toml`. If the implementor finds that `OsRng` is truly unreachable without a new dep, they MUST stop and flag to the user before adding one. 754 + 755 + No acceptance criteria from the design are directly tested by this phase — AC coverage begins in Phase 4.
+657
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_02.md
··· 1 + # Labeler report stage — Phase 2: Self-mint JWT infrastructure 2 + 3 + **Goal:** Produce a `SelfMintSigner` that can mint a JWT the labeler can resolve and verify end-to-end: owns an ephemeral `did:web:127.0.0.1%3A{port}` identity, serves its DID document at `http://127.0.0.1:{port}/.well-known/did.json`, and signs JWTs whose `iss` matches the served identity. 4 + 5 + **Architecture:** Two new types in the new `create_report/` subdirectory: `DidDocServer` (RAII holder around a background `tokio::spawn` serving one JSON response on `tokio::net::TcpListener`) and `SelfMintSigner` (owns the `AnySigningKey`, the issuer `Did`, and the `DidDocServer` handle). A clap `ValueEnum` for `--self-mint-curve` selects the key type. 6 + 7 + **Tech Stack:** `tokio::net::TcpListener` (already depended on via `tokio = { features = ["net", ...] }` in `Cargo.toml:41`), `multibase` (already a dep at line 33), `serde_json`, the Phase 1 `AnySigningKey` + `jwt` module. 8 + 9 + **Scope:** Phase 2 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (verified against `Cargo.toml`, `src/common/identity.rs` lines 1-320 + the existing `parse_multikey` helper, `src/commands/test/labeler.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `tokio` at `Cargo.toml:41` has `net` feature enabled; `tokio::net::TcpListener` is usable without adding features. 15 + - ✓ `multibase` at `Cargo.toml:33` version `0.9`. The existing `parse_multikey` helper in `src/common/identity.rs` (search for `fn parse_multikey`) decodes multibase-prefixed `z...` keys. We need the inverse direction (encode). 16 + - ✓ `percent-encoding` at `Cargo.toml:35`. Used for safely percent-encoding the `:` in `127.0.0.1:{port}` for the DID string. 17 + - ✓ No existing `tokio::spawn` HTTP server in the codebase. This is a genuinely new pattern; the module scopes it narrowly. 18 + - ✓ `atrium-api` at `Cargo.toml:19` version `0.25`. The self-mint DID document must be a standard W3C DID Document that atproto's `did:web` resolver can parse. The tool's own `DidDocument` struct at `src/common/identity.rs:83-95` is a useful shape reference but is *only for deserialization* — we should serialize the atproto-specific DID Document JSON directly as a `serde_json::Value` or typed `serde::Serialize` struct so we control the exact wire bytes. 19 + - ✓ `clap` derive at `Cargo.toml:27` supports `ValueEnum` (feature `derive` is enabled). No existing `ValueEnum` usage in the codebase — this will be the first. 20 + - ✓ `src/commands/test/labeler.rs:26-52` is the `LabelerCmd` struct. `--self-mint-curve` flag lands here in Phase 2 (the other CLI flags in Phase 4). 21 + 22 + **External dependency research findings:** 23 + - ✓ DID Document JSON shape for atproto did:web (see <https://atproto.com/specs/did>): required top-level `id` (the DID itself), an `alsoKnownAs` array (can be empty), a `verificationMethod` array where each entry is `{id, type, controller, publicKeyMultibase}`, and a `service` array (can be empty). For a self-mint JWT signer, one verification method is sufficient. `id` per-method is `"{did}#{fragment}"`; atproto service-auth uses fragments like `#atproto` for the primary account key. We'll use `#atproto` for the self-mint's one and only key. 24 + - ✓ `type` field for verification methods: `"Multikey"` is now the atproto-preferred generic type (previously `EcdsaSecp256k1VerificationKey2019` / `EcdsaSecp256r1VerificationKey2019`). `"Multikey"` paired with `publicKeyMultibase` is what Ozone and modern atproto tooling expect. 25 + - ✓ Multibase/multicodec encoding of public keys per atproto cryptography spec (<https://atproto.com/specs/cryptography>): prefix the raw *compressed SEC1* public key bytes with the multicodec prefix for the curve, then multibase-encode with `base58btc` ('z' prefix): 26 + - secp256k1: multicodec `0xe7 0x01` (varint for `0xe7` = secp256k1-pub) + 33-byte compressed key 27 + - p256: multicodec `0x80 0x24` (varint for `0x1200` = p256-pub) + 33-byte compressed key 28 + 29 + Wait — the correct multicodec values are: `secp256k1-pub = 0xe7` (varint encoding = `0xe7 0x01`), `p256-pub = 0x1200` (varint encoding = `0x80 0x24`). Reference: <https://github.com/multiformats/multicodec/blob/master/table.csv>. Both prefixes are 2 bytes. 30 + - ✓ Compressed SEC1 public key: 33 bytes (1 byte sign tag + 32 byte x-coordinate). `k256::ecdsa::VerifyingKey::to_encoded_point(true)` and `p256::ecdsa::VerifyingKey::to_encoded_point(true)` both produce the compressed form. 31 + - ✓ `did:web` path encoding for localhost + port per <https://atproto.com/specs/did>: `did:web:127.0.0.1%3A{port}`. Resolution URL is `http://127.0.0.1:{port}/.well-known/did.json` (HTTP, not HTTPS, for localhost). The labeler must be configured to allow plaintext-HTTP resolution for did:web (Ozone dev mode allows this via `DID_PLC_URL` / `PLC_URL` config; for self-mint-against-Ozone the user must be running a dev-mode labeler). This is covered by the design's "Viable only when the labeler can reach this machine (local labeler)" gate. 32 + - ✓ atproto service-auth `iss` encoding: the full DID string, optionally with `#fragment` to indicate signing key identifier. Our self-mint uses `iss = "did:web:127.0.0.1%3A{port}"` (no fragment); the labeler resolves the DID document, finds the first suitable `verificationMethod`, and uses it. (With fragment, it looks for a method whose `id` ends in that fragment.) For simplicity and widest compatibility, we'll publish one verificationMethod with `id = "{did}#atproto"` and use a bare `iss` (no fragment); the labeler will match it by curve. 33 + 34 + --- 35 + 36 + **Critical architectural decision:** `DidDocServer::spawn` takes a **body-builder closure** `FnOnce(SocketAddr) -> Vec<u8>` rather than a pre-built body. This lets the server bind first, hand the known `SocketAddr` to the closure (which then builds the DID-doc JSON using the port), and serve — atomically. The earlier "probe + re-spawn" pattern is race-prone (another process can grab the port between probe-drop and re-bind) and is rejected. 37 + 38 + ## Acceptance criteria coverage 39 + 40 + This phase implements and tests: 41 + 42 + ### labeler-report-stage.AC8: CLI flag handling 43 + - **labeler-report-stage.AC8.2 Curve selection:** `--self-mint-curve es256` advertises a P-256 key in the did:web DID doc; `es256k` advertises secp256k1; the minted JWT's `alg` header matches. 44 + 45 + No other ACs are directly verified here — `AC8.2` is partially covered via the in-Phase integration test that asserts key type matches curve selection; end-to-end CLI verification rolls up in Phase 8's `tests/labeler_cli.rs` extension. 46 + 47 + --- 48 + 49 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 50 + <!-- START_TASK_1 --> 51 + ### Task 1: Public-key multibase encoding helper 52 + 53 + **Verifies:** No direct AC; supports AC8.2. 54 + 55 + **Files:** 56 + - Modify: `src/common/identity.rs` — add a companion to the existing `parse_multikey` helper. Place it immediately after `parse_multikey` (search for `fn parse_multikey`). 57 + 58 + **Implementation:** 59 + 60 + ```rust 61 + /// Encode an `AnyVerifyingKey` as the atproto multibase-multikey format: 62 + /// base58btc multibase prefix `z`, multicodec curve prefix, compressed SEC1 63 + /// public key bytes. 64 + /// 65 + /// See <https://atproto.com/specs/cryptography>. The inverse of `parse_multikey`. 66 + pub fn encode_multikey(key: &AnyVerifyingKey) -> String { 67 + // Multicodec varint prefixes (see https://github.com/multiformats/multicodec). 68 + const SECP256K1_PUB: &[u8] = &[0xe7, 0x01]; 69 + const P256_PUB: &[u8] = &[0x80, 0x24]; 70 + 71 + let (prefix, compressed): (&[u8], Vec<u8>) = match key { 72 + AnyVerifyingKey::K256(k) => { 73 + let point = k.to_encoded_point(true); 74 + (SECP256K1_PUB, point.as_bytes().to_vec()) 75 + } 76 + AnyVerifyingKey::P256(k) => { 77 + let point = k.to_encoded_point(true); 78 + (P256_PUB, point.as_bytes().to_vec()) 79 + } 80 + }; 81 + 82 + let mut buf = Vec::with_capacity(prefix.len() + compressed.len()); 83 + buf.extend_from_slice(prefix); 84 + buf.extend_from_slice(&compressed); 85 + multibase::encode(multibase::Base::Base58Btc, &buf) 86 + } 87 + ``` 88 + 89 + **Notes for the implementor:** 90 + - Both `k256::ecdsa::VerifyingKey` and `p256::ecdsa::VerifyingKey` have `to_encoded_point(compress: bool) -> EncodedPoint`. `EncodedPoint::as_bytes()` returns the 33-byte compressed form. These are already in scope if the `ecdsa` feature of the respective crate is enabled (both are, per Cargo.toml). 91 + - `multibase::encode(Base::Base58Btc, &bytes) -> String` produces the `z`-prefixed output. Version 0.9 of the `multibase` crate is the one currently vendored. 92 + 93 + **Testing:** 94 + 95 + Round-trip test: generate a random `AnySigningKey::K256` and `AnySigningKey::P256`, call `encode_multikey(&signing.verifying_key())`, parse the output back with `parse_multikey`, assert the resulting `AnyVerifyingKey` matches the original. Two tests, one per curve. 96 + 97 + **Verification:** 98 + Run: `cargo test --lib common::identity::tests::encode_multikey_round_trip` 99 + Expected: both tests pass. 100 + 101 + **Commit:** `feat(identity): add encode_multikey companion to parse_multikey` 102 + <!-- END_TASK_1 --> 103 + 104 + <!-- START_TASK_2 --> 105 + ### Task 2: DID-construction helper for self-mint identities 106 + 107 + **Verifies:** No direct AC; supports AC8.2. 108 + 109 + **Files:** 110 + - Modify: `src/commands/test/labeler/create_report.rs` — add a small free helper (not a method on `Did`, to keep `src/common/identity.rs` focused on general primitives; self-mint is stage-local). 111 + 112 + **Implementation:** 113 + 114 + Add to the module: 115 + 116 + ```rust 117 + use std::net::SocketAddr; 118 + use url::Url; 119 + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; 120 + 121 + use crate::common::identity::Did; 122 + 123 + /// Construct `did:web:127.0.0.1%3A{port}` for a self-mint identity bound 124 + /// to the given local `SocketAddr`. The `:` between the IP and the port is 125 + /// percent-encoded per atproto did:web rules. 126 + /// 127 + /// Uses the `SocketAddr` IP literally (typically `127.0.0.1`). IPv6 128 + /// loopback would produce `did:web:::1%3A{port}` which the atproto did 129 + /// syntax regex rejects; for v1 the self-mint server is IPv4-only. 130 + pub(crate) fn self_mint_did_for(addr: SocketAddr) -> Did { 131 + assert!(addr.is_ipv4(), "self-mint DidDocServer is IPv4-only"); 132 + let host = addr.ip().to_string(); 133 + let port = addr.port(); 134 + // Percent-encode the `:` (and, defensively, any other non-alphanumeric) 135 + // with the standard set. For the `127.0.0.1:{port}` case this yields 136 + // exactly `127.0.0.1%3A{port}`. 137 + let encoded_hostport = format!( 138 + "{host}{}{port}", 139 + utf8_percent_encode(":", NON_ALPHANUMERIC) 140 + ); 141 + Did(format!("did:web:{encoded_hostport}")) 142 + } 143 + 144 + /// Base URL the labeler uses to fetch the self-mint DID document: 145 + /// `http://127.0.0.1:{port}`. 146 + pub(crate) fn self_mint_base_url(addr: SocketAddr) -> Url { 147 + Url::parse(&format!("http://{addr}")) 148 + .expect("SocketAddr Display is always a valid authority") 149 + } 150 + ``` 151 + 152 + **Testing:** 153 + 154 + Unit tests: 155 + 156 + ```rust 157 + #[cfg(test)] 158 + mod did_tests { 159 + use super::*; 160 + 161 + #[test] 162 + fn self_mint_did_encodes_colon() { 163 + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); 164 + let did = self_mint_did_for(addr); 165 + assert_eq!(did.0, "did:web:127.0.0.1%3A5000"); 166 + } 167 + 168 + #[test] 169 + fn self_mint_base_url_uses_http() { 170 + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); 171 + let url = self_mint_base_url(addr); 172 + assert_eq!(url.as_str(), "http://127.0.0.1:5000/"); 173 + } 174 + } 175 + ``` 176 + 177 + **Verification:** 178 + Run: `cargo test --lib commands::test::labeler::create_report::did_tests` 179 + Expected: both tests pass. 180 + 181 + **Commit:** `feat(create_report): did:web constructor for self-mint identities` 182 + <!-- END_TASK_2 --> 183 + <!-- END_SUBCOMPONENT_A --> 184 + 185 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 186 + <!-- START_TASK_3 --> 187 + ### Task 3: `DidDocServer` RAII type 188 + 189 + **Verifies:** No direct AC; supports AC4 path via integration test. 190 + 191 + **Files:** 192 + - Create: `src/commands/test/labeler/create_report/did_doc_server.rs`. 193 + - Modify: `src/commands/test/labeler/create_report.rs` — add `pub mod did_doc_server;`. 194 + 195 + **Implementation:** 196 + 197 + ```rust 198 + //! Ephemeral `did:web` document server for self-mint conformance checks. 199 + //! 200 + //! Binds `127.0.0.1:0`, accepts the first inbound TCP connection, and 201 + //! serves a single hand-crafted HTTP/1.1 response carrying the DID 202 + //! document JSON at `/.well-known/did.json`. Any other path returns 203 + //! 404. Other requests to `/.well-known/did.json` are honored too — 204 + //! the labeler may retry. 205 + //! 206 + //! Shuts down on drop: the RAII handle aborts the background task and 207 + //! closes the listener. 208 + 209 + use std::net::SocketAddr; 210 + use std::sync::Arc; 211 + 212 + use tokio::io::{AsyncReadExt, AsyncWriteExt}; 213 + use tokio::net::TcpListener; 214 + use tokio::task::JoinHandle; 215 + 216 + /// A running DID document server. 217 + /// 218 + /// The server runs on `127.0.0.1:{os-assigned-port}` in a background 219 + /// task; the listening address is exposed via `local_addr()`. When the 220 + /// `DidDocServer` is dropped, the background task is aborted. 221 + pub struct DidDocServer { 222 + local_addr: SocketAddr, 223 + task: JoinHandle<()>, 224 + } 225 + 226 + impl DidDocServer { 227 + /// Bind `127.0.0.1:0` and start serving DID-document JSON bytes at 228 + /// `/.well-known/did.json`. The body is built **after** the listener 229 + /// has bound, by invoking `build_body` with the known `SocketAddr`. 230 + /// This lets callers embed the bound port into the DID document 231 + /// atomically — there is no probe phase, no possibility of port drift 232 + /// between binding and serving. 233 + pub async fn spawn<F>(build_body: F) -> std::io::Result<Self> 234 + where 235 + F: FnOnce(SocketAddr) -> Vec<u8>, 236 + { 237 + let listener = TcpListener::bind("127.0.0.1:0").await?; 238 + let local_addr = listener.local_addr()?; 239 + let did_doc_json = build_body(local_addr); 240 + let body: Arc<[u8]> = did_doc_json.into(); 241 + 242 + let task = tokio::spawn(async move { 243 + loop { 244 + let accept = listener.accept().await; 245 + let (mut stream, _peer) = match accept { 246 + Ok(sp) => sp, 247 + Err(_) => return, 248 + }; 249 + let body = body.clone(); 250 + tokio::spawn(async move { 251 + let _ = Self::handle_connection(&mut stream, &body).await; 252 + }); 253 + } 254 + }); 255 + 256 + Ok(Self { local_addr, task }) 257 + } 258 + 259 + /// The listening address (always `127.0.0.1:{port}`). 260 + pub fn local_addr(&self) -> SocketAddr { 261 + self.local_addr 262 + } 263 + 264 + /// Minimal HTTP/1.1 handler: reads the request line, routes on path. 265 + async fn handle_connection( 266 + stream: &mut tokio::net::TcpStream, 267 + did_doc: &[u8], 268 + ) -> std::io::Result<()> { 269 + // Read up to 8 KiB of request headers; servers don't send large 270 + // GET requests but we cap to avoid unbounded reads. 271 + let mut buf = [0u8; 8192]; 272 + let mut total = 0usize; 273 + while total < buf.len() { 274 + let n = stream.read(&mut buf[total..]).await?; 275 + if n == 0 { 276 + break; 277 + } 278 + total += n; 279 + // Headers end at CRLFCRLF. 280 + if buf[..total].windows(4).any(|w| w == b"\r\n\r\n") { 281 + break; 282 + } 283 + } 284 + let request = &buf[..total]; 285 + 286 + // Parse just the request line: `GET /path HTTP/1.1\r\n`. 287 + let first_line_end = request 288 + .iter() 289 + .position(|&b| b == b'\r' || b == b'\n') 290 + .unwrap_or(request.len()); 291 + let first_line = std::str::from_utf8(&request[..first_line_end]).unwrap_or(""); 292 + let mut parts = first_line.split_whitespace(); 293 + let method = parts.next().unwrap_or(""); 294 + let path = parts.next().unwrap_or(""); 295 + 296 + let (status_line, body, content_type) = if method == "GET" && path == "/.well-known/did.json" 297 + { 298 + ("HTTP/1.1 200 OK", did_doc, "application/json") 299 + } else { 300 + ("HTTP/1.1 404 Not Found", b"not found" as &[u8], "text/plain") 301 + }; 302 + 303 + let response_head = format!( 304 + "{status_line}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", 305 + body.len() 306 + ); 307 + stream.write_all(response_head.as_bytes()).await?; 308 + stream.write_all(body).await?; 309 + stream.flush().await?; 310 + let _ = stream.shutdown().await; 311 + Ok(()) 312 + } 313 + } 314 + 315 + impl Drop for DidDocServer { 316 + fn drop(&mut self) { 317 + self.task.abort(); 318 + } 319 + } 320 + ``` 321 + 322 + **Notes for the implementor:** 323 + - The HTTP parsing here is deliberately *minimal* — it is not a full HTTP/1.1 server. It handles only the request line and is not robust against pipelining, chunked encoding, or TLS. For the self-mint use case (localhost, single client, single request for DID doc) this is sufficient. 324 + - `Connection: close` after the response keeps each connection single-request, matching the minimal-parser simplification. 325 + - The outer loop accepts connections forever until the task is aborted on drop. Each connection runs in its own `tokio::spawn` so a slow request doesn't block others (labelers sometimes retry). 326 + 327 + **Testing:** 328 + 329 + Integration test: spawn the server with a canned JSON body, use `reqwest::Client::new().get(...)` to hit `http://127.0.0.1:{port}/.well-known/did.json`, assert status 200, content-type `application/json`, body byte-equal to the seed. Also test: GET `/other/path` returns 404. 330 + 331 + ```rust 332 + #[cfg(test)] 333 + mod tests { 334 + use super::*; 335 + 336 + #[tokio::test] 337 + async fn serves_did_doc_on_well_known_path() { 338 + let body = br#"{"id":"did:web:127.0.0.1%3A0"}"#.to_vec(); 339 + let body_for_assert = body.clone(); 340 + let server = DidDocServer::spawn(move |_addr| body).await.expect("spawn"); 341 + let url = format!("http://{}/.well-known/did.json", server.local_addr()); 342 + let resp = reqwest::Client::new().get(&url).send().await.expect("http"); 343 + assert_eq!(resp.status(), 200); 344 + assert_eq!(resp.headers()["content-type"], "application/json"); 345 + let got = resp.bytes().await.expect("bytes"); 346 + assert_eq!(got.as_ref(), body_for_assert.as_slice()); 347 + } 348 + 349 + #[tokio::test] 350 + async fn returns_404_for_other_paths() { 351 + let server = DidDocServer::spawn(|_addr| b"{}".to_vec()).await.expect("spawn"); 352 + let url = format!("http://{}/nope", server.local_addr()); 353 + let resp = reqwest::Client::new().get(&url).send().await.expect("http"); 354 + assert_eq!(resp.status(), 404); 355 + } 356 + 357 + #[tokio::test] 358 + async fn body_builder_receives_bound_addr() { 359 + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); 360 + let captured_clone = captured.clone(); 361 + let server = DidDocServer::spawn(move |addr| { 362 + *captured_clone.lock().unwrap() = Some(addr); 363 + format!(r#"{{"port":{}}}"#, addr.port()).into_bytes() 364 + }) 365 + .await 366 + .expect("spawn"); 367 + let addr = *captured.lock().unwrap(); 368 + assert_eq!(addr, Some(server.local_addr())); 369 + } 370 + } 371 + ``` 372 + 373 + **Verification:** 374 + Run: `cargo test --lib commands::test::labeler::create_report::did_doc_server::tests` 375 + Expected: both tests pass. 376 + 377 + **Commit:** `feat(create_report): ephemeral did:web document server` 378 + <!-- END_TASK_3 --> 379 + 380 + <!-- START_TASK_4 --> 381 + ### Task 4: `SelfMintSigner` struct bringing it all together 382 + 383 + **Verifies:** AC8.2 (via integration test asserting curve → alg header). 384 + 385 + **Files:** 386 + - Create: `src/commands/test/labeler/create_report/self_mint.rs`. 387 + - Modify: `src/commands/test/labeler/create_report.rs` — add `pub mod self_mint;`. 388 + 389 + **Implementation:** 390 + 391 + ```rust 392 + //! `SelfMintSigner`: owns a random key, an ephemeral `did:web` identity 393 + //! server, and a reference curve. Exposes a single method for signing 394 + //! atproto service-auth JWTs with that identity. 395 + 396 + use std::time::Duration; 397 + 398 + use serde_json::json; 399 + 400 + // Local RNG shim: `k256::ecdsa::SigningKey::random` and 401 + // `p256::ecdsa::SigningKey::random` take `CryptoRngCore`. We build a 402 + // thin adapter around `getrandom` (Phase 1 Task 0 direct dep) since 403 + // `elliptic_curve::rand_core::OsRng` is not re-exported through the 404 + // current dep graph. 405 + struct GetrandomRng; 406 + impl rand_core::RngCore for GetrandomRng { 407 + fn next_u32(&mut self) -> u32 { 408 + let mut b = [0u8; 4]; 409 + getrandom::getrandom(&mut b).expect("OS CSPRNG"); 410 + u32::from_le_bytes(b) 411 + } 412 + fn next_u64(&mut self) -> u64 { 413 + let mut b = [0u8; 8]; 414 + getrandom::getrandom(&mut b).expect("OS CSPRNG"); 415 + u64::from_le_bytes(b) 416 + } 417 + fn fill_bytes(&mut self, dest: &mut [u8]) { 418 + getrandom::getrandom(dest).expect("OS CSPRNG"); 419 + } 420 + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { 421 + getrandom::getrandom(dest).map_err(|_| rand_core::Error::new("getrandom failed")) 422 + } 423 + } 424 + impl rand_core::CryptoRng for GetrandomRng {} 425 + 426 + use crate::commands::test::labeler::create_report::did_doc_server::DidDocServer; 427 + use crate::commands::test::labeler::create_report::{self_mint_base_url, self_mint_did_for}; 428 + use crate::common::identity::{AnySigningKey, Did, encode_multikey}; 429 + use crate::common::jwt::{self, JwtClaims, JwtHeader}; 430 + 431 + /// Curve selector for self-mint keys, mirrors clap's `--self-mint-curve` flag. 432 + #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] 433 + pub enum SelfMintCurve { 434 + /// secp256k1 (JWT `alg = "ES256K"`). Default. 435 + Es256k, 436 + /// NIST P-256 (JWT `alg = "ES256"`). 437 + Es256, 438 + } 439 + 440 + impl Default for SelfMintCurve { 441 + fn default() -> Self { 442 + SelfMintCurve::Es256k 443 + } 444 + } 445 + 446 + /// A self-mint JWT signer. Owns the keypair, the DID, and the backing DID 447 + /// document server (which is shut down on drop). 448 + pub struct SelfMintSigner { 449 + signing_key: AnySigningKey, 450 + issuer_did: Did, 451 + /// Held for its Drop side effect (the server stays up while this 452 + /// field is alive). Also read by `did_doc_url()` to expose the 453 + /// listening port. 454 + did_doc_server: DidDocServer, 455 + } 456 + 457 + impl SelfMintSigner { 458 + /// Create a new self-mint signer with a freshly-generated key of the 459 + /// requested curve. Binds `127.0.0.1:0` for the DID document server. 460 + /// 461 + /// Port-stable by construction: the server binds first, then the 462 + /// body-builder closure embeds the bound port into the DID document 463 + /// before the first request is served. There is no probe phase and 464 + /// no window in which the port can drift. 465 + pub async fn spawn(curve: SelfMintCurve) -> std::io::Result<Self> { 466 + let signing_key = match curve { 467 + SelfMintCurve::Es256k => { 468 + AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut GetrandomRng)) 469 + } 470 + SelfMintCurve::Es256 => { 471 + AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut GetrandomRng)) 472 + } 473 + }; 474 + let verifying_key = signing_key.verifying_key(); 475 + let multikey = encode_multikey(&verifying_key); 476 + 477 + // Capture the DID that the builder computes so we can store it. 478 + // `Arc<Mutex<Option<Did>>>` lets the one-shot closure publish the 479 + // DID out through the body-builder boundary. 480 + let issuer_capture: std::sync::Arc<std::sync::Mutex<Option<Did>>> = 481 + std::sync::Arc::new(std::sync::Mutex::new(None)); 482 + let issuer_capture_clone = issuer_capture.clone(); 483 + let multikey_for_builder = multikey.clone(); 484 + 485 + let server = DidDocServer::spawn(move |addr| { 486 + let did = self_mint_did_for(addr); 487 + let did_doc = json!({ 488 + "@context": ["https://www.w3.org/ns/did/v1"], 489 + "id": did.0, 490 + "alsoKnownAs": [], 491 + "verificationMethod": [{ 492 + "id": format!("{}#atproto", did.0), 493 + "type": "Multikey", 494 + "controller": did.0, 495 + "publicKeyMultibase": multikey_for_builder, 496 + }], 497 + "service": [], 498 + }); 499 + let bytes = serde_json::to_vec(&did_doc).expect("static JSON serializes"); 500 + *issuer_capture_clone.lock().unwrap() = Some(did); 501 + bytes 502 + }) 503 + .await?; 504 + 505 + let issuer_did = issuer_capture 506 + .lock() 507 + .unwrap() 508 + .take() 509 + .expect("body-builder runs synchronously before spawn returns"); 510 + 511 + Ok(Self { 512 + signing_key, 513 + issuer_did, 514 + did_doc_server: server, 515 + }) 516 + } 517 + 518 + /// The issuer DID bound to this signer (`did:web:127.0.0.1%3A{port}`). 519 + pub fn issuer_did(&self) -> &Did { 520 + &self.issuer_did 521 + } 522 + 523 + /// URL the labeler will fetch to resolve the DID document. 524 + pub fn did_doc_url(&self) -> url::Url { 525 + let mut u = self_mint_base_url(self.did_doc_server.local_addr()); 526 + u.set_path("/.well-known/did.json"); 527 + u 528 + } 529 + 530 + /// Sign a JWT with these claims. The `iss` field in `claims` is 531 + /// overridden with this signer's DID so callers never forget. 532 + pub fn sign_jwt(&self, mut claims: JwtClaims) -> String { 533 + claims.iss = self.issuer_did.0.clone(); 534 + let header = JwtHeader::for_signing_key(&self.signing_key); 535 + jwt::encode_compact(&header, &claims, &self.signing_key) 536 + .expect("encode_compact is infallible for well-formed structs") 537 + } 538 + 539 + /// Build a valid-claims template for the given labeler DID and the 540 + /// createReport NSID. Callers mutate specific fields for negative 541 + /// tests. `now_unix_secs` is the current wall-clock time in UNIX 542 + /// seconds; `exp_after` is the lifetime. 543 + pub fn valid_claims_template( 544 + &self, 545 + labeler_did: &Did, 546 + lxm: &str, 547 + now_unix_secs: i64, 548 + exp_after: Duration, 549 + ) -> JwtClaims { 550 + JwtClaims { 551 + iss: self.issuer_did.0.clone(), 552 + aud: labeler_did.0.clone(), 553 + exp: now_unix_secs + exp_after.as_secs() as i64, 554 + iat: now_unix_secs, 555 + lxm: lxm.to_string(), 556 + jti: crate::commands::test::labeler::create_report::sentinel::new_run_id(), 557 + } 558 + } 559 + } 560 + ``` 561 + 562 + **Notes for the implementor:** 563 + - The body-builder closure pattern removes the only race in the earlier "probe + re-spawn" design: the listener binds once, and the builder sees the exact `SocketAddr` the server will serve from. The `Arc<Mutex<Option<Did>>>` capture is necessary because the DID (derived from the port) is needed *after* the closure returns; it publishes the value out through shared state. The closure runs synchronously inside `DidDocServer::spawn` before the task returns, so `.take()` after `.await` is safe. 564 + - `k256::ecdsa::SigningKey::random(&mut OsRng)` and `p256::ecdsa::SigningKey::random(&mut OsRng)` are the idiomatic RNG-seeded constructors. `OsRng` path is `k256::elliptic_curve::rand_core::OsRng` (re-exported from `elliptic-curve`'s `rand_core` feature) — verify during Phase 1 with `cargo read k256 --api | grep -i OsRng` and pin the exact path in the shared `src/common/jwt.rs` imports. 565 + - Expose `new_run_id` from Phase 1's sentinel module as the `jti` source. This keeps random-bytes ownership in one place. 566 + 567 + **Testing:** 568 + 569 + Integration-style test (in the same file, `#[cfg(test)]` section, under `#[tokio::test]`): 570 + 571 + ```rust 572 + #[cfg(test)] 573 + mod tests { 574 + use super::*; 575 + use crate::common::identity::{AnyVerifyingKey, parse_multikey}; 576 + use crate::common::jwt::verify_compact; 577 + 578 + async fn round_trip(curve: SelfMintCurve, expected_alg: &str) { 579 + let signer = SelfMintSigner::spawn(curve).await.expect("spawn"); 580 + 581 + // Fetch the DID document as the labeler would. 582 + let url = signer.did_doc_url(); 583 + let client = reqwest::Client::new(); 584 + let resp = client.get(url).send().await.expect("http"); 585 + assert_eq!(resp.status(), 200); 586 + let doc: serde_json::Value = resp.json().await.expect("json"); 587 + assert_eq!(doc["id"], serde_json::Value::String(signer.issuer_did().0.clone())); 588 + let vm = doc["verificationMethod"][0].clone(); 589 + let multikey = vm["publicKeyMultibase"].as_str().expect("multikey").to_string(); 590 + 591 + // Decode the key and verify a signature from the signer. 592 + let parsed = parse_multikey(&multikey).expect("parse multikey"); 593 + let vkey: AnyVerifyingKey = parsed.verifying_key; 594 + 595 + let claims = signer.valid_claims_template( 596 + &Did("did:plc:aaa22222222222222222bbbbbb".to_string()), 597 + "com.atproto.moderation.createReport", 598 + 1_776_000_000, 599 + Duration::from_secs(60), 600 + ); 601 + let token = signer.sign_jwt(claims.clone()); 602 + let (header, decoded_claims) = verify_compact(&token, &vkey).expect("verify"); 603 + assert_eq!(header.alg, expected_alg); 604 + assert_eq!(decoded_claims.iss, signer.issuer_did().0); 605 + assert_eq!(decoded_claims.aud, claims.aud); 606 + assert_eq!(decoded_claims.lxm, "com.atproto.moderation.createReport"); 607 + } 608 + 609 + #[tokio::test] 610 + async fn self_mint_signer_es256k_round_trips() { 611 + round_trip(SelfMintCurve::Es256k, "ES256K").await; 612 + } 613 + 614 + #[tokio::test] 615 + async fn self_mint_signer_es256_round_trips() { 616 + round_trip(SelfMintCurve::Es256, "ES256").await; 617 + } 618 + } 619 + ``` 620 + 621 + **Verification:** 622 + Run: `cargo test --lib commands::test::labeler::create_report::self_mint::tests` 623 + Expected: both tests pass. 624 + 625 + **Commit:** `feat(create_report): SelfMintSigner with curve-selected identity` 626 + <!-- END_TASK_4 --> 627 + <!-- END_SUBCOMPONENT_B --> 628 + 629 + <!-- START_TASK_5 --> 630 + ### Task 5: Phase 2 integration check 631 + 632 + **Files:** None changed. 633 + 634 + **Implementation:** Checkpoint — run full tests to confirm Phase 2 composes cleanly with Phase 1. 635 + 636 + **Verification:** 637 + Run: `cargo build` 638 + Expected: clean build. 639 + 640 + Run: `cargo test` 641 + Expected: all Phase 1 tests still pass; Phase 2 adds 4+ new passing tests (encode_multikey, did helpers, DidDocServer, SelfMintSigner). 642 + 643 + Run: `cargo clippy --all-targets -- -D warnings` 644 + Expected: no warnings. 645 + 646 + **Commit:** No new commit unless fixes were needed. 647 + <!-- END_TASK_5 --> 648 + 649 + --- 650 + 651 + ## Phase 2 complete when 652 + 653 + - `encode_multikey` in `src/common/identity.rs` round-trips against `parse_multikey` for both curves. 654 + - `DidDocServer::spawn` serves a canned JSON body at `/.well-known/did.json` over HTTP on an OS-assigned localhost port. 655 + - `SelfMintSigner::spawn(SelfMintCurve::Es256k)` and `::spawn(SelfMintCurve::Es256)` each produce a running server + a signer whose JWTs decode and verify against the published multikey in the DID doc. 656 + - The JWT `alg` header matches the selected curve (`ES256K` for `Es256k`, `ES256` for `Es256`). 657 + - All Phase 1 tests still pass.
+524
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_03.md
··· 1 + # Labeler report stage — Phase 3: `CreateReportTee` seam 2 + 3 + **Goal:** Stand up the test seam for POSTing `com.atproto.moderation.createReport` so all later functionality phases have a mockable entry point. Mirrors the existing `RawHttpTee` pattern for query_labels. 4 + 5 + **Architecture:** A new trait `CreateReportTee` in the `create_report` module with a single async method `post_create_report(auth, body) -> RawCreateReportResponse`, a production impl `RealCreateReportTee` wrapping `reqwest::Client`, and a `FakeCreateReportTee` test helper in `tests/common/mod.rs` that returns scripted responses keyed by call index and records every request for assertion. 6 + 7 + **Tech Stack:** `reqwest::Client` (existing dep), `async_trait`, `serde_json` for body serialization, `thiserror` + `miette::Diagnostic` for errors. 8 + 9 + **Scope:** Phase 3 of 8. Can proceed in parallel with Phase 2 in principle, but the plan assumes linear execution. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `src/commands/test/labeler/http.rs`, `tests/common/mod.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Trait pattern at `src/commands/test/labeler/http.rs:194-203`: `#[async_trait] pub trait RawHttpTee: Send + Sync { async fn query_labels(...) -> Result<RawXrpcResponse, HttpStageError>; }`. Mirror exactly. 15 + - ✓ Real impl pattern at `src/commands/test/labeler/http.rs:206-295`: `pub struct RealHttpTee { client: reqwest::Client, endpoint: Url }` + `pub fn new(client, endpoint) -> Self`. Mirror exactly. 16 + - ✓ Fake impl pattern at `tests/common/mod.rs:23-103`: `FakeRawHttpTee` with `Arc<Mutex<HashMap<...>>>` for scripted responses and `Arc<Mutex<bool>>` for transport error toggle. Construction style matches existing conventions. 17 + - ✓ `Arc<[u8]>` for raw bodies at `src/commands/test/labeler/http.rs:143`. Reuse for `RawCreateReportResponse`. 18 + - ✓ `reqwest::StatusCode` is the response status type, not a `u16`. Follow `src/commands/test/labeler/http.rs:141`. 19 + - ✓ Error type pattern at `src/commands/test/labeler/http.rs:166-188`: enum with `Transport { message, source }` and a domain-specific variant. No `miette::Diagnostic` on the error itself; diagnostics are constructed when checks fire. 20 + - ✓ No existing `FakeCreateReportTee` in `tests/common/mod.rs` — confirmed by reading the full file. New type goes after `FakeRawHttpTee`. 21 + - ✓ `atrium_api` is already imported in `tests/common/mod.rs:11`. The createReport input body will be a `serde_json::Value` for test flexibility (negative tests need to submit intentionally invalid bodies), not a strongly-typed atrium input. The Real impl accepts `serde_json::Value` and posts it with `Content-Type: application/json`. 22 + - ✓ Production tracing pattern at `src/commands/test/labeler/http.rs:239-243,265-270` (`tracing::debug!(url = %..., status = %..., body_len = ...)`). Mirror for new POST. 23 + 24 + **External dependency research findings:** N/A — uses reqwest already in the project; no new external research. 25 + 26 + --- 27 + 28 + ## Acceptance criteria coverage 29 + 30 + This phase implements and tests infrastructure only. No acceptance criteria are directly verified here — but `AC7.1` (row-count invariant) and every check in `AC2`/`AC3`/`AC4`/`AC5`/`AC6` depends on the `CreateReportTee` seam the stage uses. 31 + 32 + **Verifies: None** — Phase 3 is pure scaffolding. 33 + 34 + --- 35 + 36 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 37 + <!-- START_TASK_1 --> 38 + ### Task 1: `CreateReportTee` trait, response, and error types 39 + 40 + **Files:** 41 + - Modify: `src/commands/test/labeler/create_report.rs` — append the trait, response struct, and error enum at the module root (after the existing `pub mod sentinel;`, `pub mod did_doc_server;`, `pub mod self_mint;` re-exports and the `self_mint_did_for` helper). 42 + 43 + **Implementation:** 44 + 45 + Add to `src/commands/test/labeler/create_report.rs`: 46 + 47 + ```rust 48 + use std::sync::Arc; 49 + 50 + use async_trait::async_trait; 51 + use miette::Diagnostic; 52 + use reqwest::StatusCode; 53 + use thiserror::Error; 54 + use url::Url; 55 + 56 + /// Raw HTTP response from POSTing `com.atproto.moderation.createReport`. 57 + /// 58 + /// Mirrors `RawXrpcResponse` from the HTTP stage but specialized for the 59 + /// createReport shape: no typed decode (positive and negative checks need 60 + /// different decode strategies) and the raw body is kept for diagnostic 61 + /// rendering via miette. 62 + #[derive(Debug)] 63 + pub struct RawCreateReportResponse { 64 + /// HTTP status code. 65 + pub status: StatusCode, 66 + /// Content-Type header value, if present. Lowercased for matching. 67 + pub content_type: Option<String>, 68 + /// Raw response body bytes. 69 + pub raw_body: Arc<[u8]>, 70 + /// The URL that was POSTed to (for diagnostics). 71 + pub source_url: String, 72 + } 73 + 74 + /// Error type for `CreateReportTee` operations. 75 + /// 76 + /// Kept intentionally narrow: either a transport failure (TCP / TLS / DNS / 77 + /// reqwest internal), or a well-formed HTTP response that we return as-is. 78 + /// Callers — i.e., the stage — decide what each non-2xx status means per 79 + /// check. 80 + #[derive(Debug, Error, Diagnostic)] 81 + pub enum CreateReportStageError { 82 + /// Transport-level failure: the request never reached a well-formed 83 + /// HTTP exchange. 84 + #[error("createReport transport error: {message}")] 85 + #[diagnostic(code = "labeler::report::transport_error")] 86 + Transport { 87 + /// Human-readable error message. 88 + message: String, 89 + /// Underlying reqwest error, if available. 90 + #[source] 91 + source: Option<Box<dyn std::error::Error + Send + Sync>>, 92 + }, 93 + } 94 + 95 + /// Trait for POSTing `com.atproto.moderation.createReport`. Production 96 + /// impl (`RealCreateReportTee`) wraps a `reqwest::Client`; tests inject 97 + /// `FakeCreateReportTee` from `tests/common/mod.rs`. 98 + /// 99 + /// The body is serialized from a `serde_json::Value` so negative-shape 100 + /// tests can POST intentionally invalid bodies without fighting the type 101 + /// system. 102 + #[async_trait] 103 + pub trait CreateReportTee: Send + Sync { 104 + /// POST the given body to the labeler's `com.atproto.moderation.createReport` 105 + /// endpoint. 106 + /// 107 + /// # Arguments 108 + /// * `auth` — optional Bearer token. `None` ⇒ no `Authorization` header 109 + /// (for the `unauthenticated_rejected` check). `Some(token)` is 110 + /// included as `Authorization: Bearer {token}`. 111 + /// * `body` — JSON body to POST. The impl sends `Content-Type: application/json`. 112 + async fn post_create_report( 113 + &self, 114 + auth: Option<&str>, 115 + body: &serde_json::Value, 116 + ) -> Result<RawCreateReportResponse, CreateReportStageError>; 117 + } 118 + ``` 119 + 120 + **Notes for the implementor:** 121 + - Don't derive `Clone` on `RawCreateReportResponse` — `Arc<[u8]>` is already cheap to clone structurally, and the response travels through the stage once. If a later phase needs to duplicate it for diagnostic attachment, clone the `Arc` explicitly. 122 + - The `Transport` variant is intentionally the only variant. Any HTTP-level response — including 5xx — is returned successfully; "status = 500" is a labeler bug, not a transport failure from our point of view. This mirrors `HttpClient::get_bytes` which returns the status even for non-2xx. 123 + 124 + **Testing:** Trait definitions aren't directly tested; the Real impl's behavior is tested in Task 2, and the Fake impl's behavior is tested in Task 3. 125 + 126 + **Verification:** 127 + Run: `cargo build` 128 + Expected: builds cleanly. 129 + 130 + Run: `cargo clippy --all-targets -- -D warnings` 131 + Expected: no warnings. 132 + 133 + **Commit:** `feat(create_report): CreateReportTee trait and response/error types` 134 + <!-- END_TASK_1 --> 135 + 136 + <!-- START_TASK_2 --> 137 + ### Task 2: `RealCreateReportTee` production implementation 138 + 139 + **Files:** 140 + - Modify: `src/commands/test/labeler/create_report.rs` — append the Real impl. 141 + 142 + **Implementation:** 143 + 144 + ```rust 145 + /// Real `CreateReportTee` implementation using reqwest. 146 + pub struct RealCreateReportTee { 147 + client: reqwest::Client, 148 + endpoint: Url, 149 + } 150 + 151 + impl RealCreateReportTee { 152 + /// Create a new `RealCreateReportTee` using the given shared reqwest 153 + /// client and labeler endpoint. The endpoint is the labeler's service 154 + /// URL (e.g., `https://labeler.example.com`); the POST path 155 + /// `/xrpc/com.atproto.moderation.createReport` is appended. 156 + pub fn new(client: reqwest::Client, endpoint: Url) -> Self { 157 + Self { client, endpoint } 158 + } 159 + } 160 + 161 + #[async_trait] 162 + impl CreateReportTee for RealCreateReportTee { 163 + async fn post_create_report( 164 + &self, 165 + auth: Option<&str>, 166 + body: &serde_json::Value, 167 + ) -> Result<RawCreateReportResponse, CreateReportStageError> { 168 + let mut url = self.endpoint.clone(); 169 + url.set_path("xrpc/com.atproto.moderation.createReport"); 170 + let source_url = url.to_string(); 171 + 172 + tracing::debug!( 173 + url = %source_url, 174 + auth_kind = match auth { 175 + None => "none", 176 + Some(t) if !t.starts_with("ey") => "malformed", 177 + Some(_) => "jwt", 178 + }, 179 + "report stage: issuing createReport POST" 180 + ); 181 + 182 + let mut req = self 183 + .client 184 + .post(url.as_str()) 185 + .header("Content-Type", "application/json") 186 + .body(serde_json::to_vec(body).expect("serde_json::Value always serializes")); 187 + if let Some(token) = auth { 188 + req = req.header("Authorization", format!("Bearer {token}")); 189 + } 190 + 191 + let response = req 192 + .send() 193 + .await 194 + .map_err(|e| CreateReportStageError::Transport { 195 + message: e.to_string(), 196 + source: Some(Box::new(e)), 197 + })?; 198 + 199 + let status = response.status(); 200 + let content_type = response 201 + .headers() 202 + .get(reqwest::header::CONTENT_TYPE) 203 + .and_then(|h| h.to_str().ok()) 204 + .map(|s| s.to_ascii_lowercase()); 205 + 206 + let body_bytes = response 207 + .bytes() 208 + .await 209 + .map_err(|e| CreateReportStageError::Transport { 210 + message: e.to_string(), 211 + source: Some(Box::new(e)), 212 + })?; 213 + 214 + tracing::debug!( 215 + url = %source_url, 216 + status = %status, 217 + body_len = body_bytes.len(), 218 + "report stage: createReport response received" 219 + ); 220 + 221 + Ok(RawCreateReportResponse { 222 + status, 223 + content_type, 224 + raw_body: Arc::from(body_bytes.as_ref()), 225 + source_url, 226 + }) 227 + } 228 + } 229 + ``` 230 + 231 + **Testing:** 232 + 233 + No standalone unit test for `RealCreateReportTee` beyond a compile check — the real HTTP path is exercised indirectly by end-to-end tests in Phase 8. The FakeCreateReportTee (Task 3) is the artifact exercised by per-check integration tests. 234 + 235 + **Verification:** 236 + Run: `cargo build` 237 + Expected: clean build; the new type + impl compile. 238 + 239 + **Commit:** `feat(create_report): RealCreateReportTee wrapping reqwest` 240 + <!-- END_TASK_2 --> 241 + <!-- END_SUBCOMPONENT_A --> 242 + 243 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 244 + <!-- START_TASK_3 --> 245 + ### Task 3: `FakeCreateReportTee` test fake with scripted responses + request capture 246 + 247 + **Files:** 248 + - Modify: `tests/common/mod.rs` — add the `FakeCreateReportTee` type after `FakeRawHttpTee`'s impl block (around line 103). 249 + 250 + **Implementation:** 251 + 252 + Add these imports at the top alongside the existing ones: 253 + 254 + ```rust 255 + use atproto_devtool::commands::test::labeler::create_report::{ 256 + CreateReportStageError, CreateReportTee, RawCreateReportResponse, 257 + }; 258 + use reqwest::StatusCode; 259 + ``` 260 + 261 + Then the fake type: 262 + 263 + ```rust 264 + /// Scripted response for a single `FakeCreateReportTee::post_create_report` 265 + /// call. A `Transport` variant short-circuits with an error; a `Response` 266 + /// variant returns a `RawCreateReportResponse` built from the supplied parts. 267 + #[derive(Debug, Clone)] 268 + pub enum FakeCreateReportResponse { 269 + /// Simulate a transport-level failure (no HTTP exchange took place). 270 + Transport { 271 + /// Error message the stage will surface. 272 + message: String, 273 + }, 274 + /// Simulate a well-formed HTTP response. 275 + Response { 276 + /// HTTP status (200, 401, 400, 500, ...). 277 + status: u16, 278 + /// Optional content-type header. Fake normalizes to lowercase. 279 + content_type: Option<String>, 280 + /// Raw response body bytes. 281 + body: Vec<u8>, 282 + }, 283 + } 284 + 285 + impl FakeCreateReportResponse { 286 + /// Convenience: a 200 OK with an empty atproto createReport#output body. 287 + pub fn ok_empty() -> Self { 288 + Self::Response { 289 + status: 200, 290 + content_type: Some("application/json".to_string()), 291 + body: br#"{"id":1,"reasonType":"com.atproto.moderation.defs#reasonOther","subject":{"$type":"com.atproto.admin.defs#repoRef","did":"did:plc:aaa22222222222222222bbbbbb"},"reportedBy":"did:web:127.0.0.1%3A0","createdAt":"2026-04-17T00:00:00.000Z"}"#.to_vec(), 292 + } 293 + } 294 + 295 + /// Convenience: a 401 Unauthorized with the atproto error envelope. 296 + pub fn unauthorized(error_name: &str, message: &str) -> Self { 297 + Self::Response { 298 + status: 401, 299 + content_type: Some("application/json".to_string()), 300 + body: serde_json::to_vec(&serde_json::json!({ 301 + "error": error_name, 302 + "message": message, 303 + })).unwrap(), 304 + } 305 + } 306 + 307 + /// Convenience: a 400 Bad Request with the given error and message. 308 + pub fn bad_request(error_name: &str, message: &str) -> Self { 309 + Self::Response { 310 + status: 400, 311 + content_type: Some("application/json".to_string()), 312 + body: serde_json::to_vec(&serde_json::json!({ 313 + "error": error_name, 314 + "message": message, 315 + })).unwrap(), 316 + } 317 + } 318 + } 319 + 320 + /// A recorded request observed by `FakeCreateReportTee`. 321 + #[derive(Debug, Clone)] 322 + pub struct RecordedCreateReportRequest { 323 + /// Authorization bearer token, if any (stripped of "Bearer " prefix). 324 + pub auth: Option<String>, 325 + /// JSON body as posted by the stage. 326 + pub body: serde_json::Value, 327 + } 328 + 329 + /// Fake `CreateReportTee` for integration tests. 330 + /// 331 + /// Scripted per-call-index responses: first call gets `responses[0]`, 332 + /// second gets `responses[1]`, etc. Panics if a call is made with no 333 + /// script queued — tests must declare every `post_create_report` the 334 + /// stage is expected to make. 335 + pub struct FakeCreateReportTee { 336 + /// Queued responses. 337 + scripts: Arc<Mutex<Vec<FakeCreateReportResponse>>>, 338 + /// Every request observed (in order). 339 + recorded: Arc<Mutex<Vec<RecordedCreateReportRequest>>>, 340 + } 341 + 342 + impl FakeCreateReportTee { 343 + /// Create a fake with no scripted responses. 344 + pub fn new() -> Self { 345 + Self { 346 + scripts: Arc::new(Mutex::new(Vec::new())), 347 + recorded: Arc::new(Mutex::new(Vec::new())), 348 + } 349 + } 350 + 351 + /// Queue a scripted response for the next `post_create_report` call. 352 + pub fn enqueue(&self, response: FakeCreateReportResponse) { 353 + self.scripts.lock().unwrap().push(response); 354 + } 355 + 356 + /// Return the recorded request history (cloned). 357 + pub fn recorded_requests(&self) -> Vec<RecordedCreateReportRequest> { 358 + self.recorded.lock().unwrap().clone() 359 + } 360 + 361 + /// Get the last recorded request, panicking if none. 362 + pub fn last_request(&self) -> RecordedCreateReportRequest { 363 + self.recorded 364 + .lock() 365 + .unwrap() 366 + .last() 367 + .cloned() 368 + .expect("FakeCreateReportTee: no requests recorded yet") 369 + } 370 + } 371 + 372 + impl Default for FakeCreateReportTee { 373 + fn default() -> Self { 374 + Self::new() 375 + } 376 + } 377 + 378 + #[async_trait] 379 + impl CreateReportTee for FakeCreateReportTee { 380 + async fn post_create_report( 381 + &self, 382 + auth: Option<&str>, 383 + body: &serde_json::Value, 384 + ) -> Result<RawCreateReportResponse, CreateReportStageError> { 385 + self.recorded.lock().unwrap().push(RecordedCreateReportRequest { 386 + auth: auth.map(|s| s.to_string()), 387 + body: body.clone(), 388 + }); 389 + 390 + let mut scripts = self.scripts.lock().unwrap(); 391 + if scripts.is_empty() { 392 + panic!( 393 + "FakeCreateReportTee: post_create_report called with no script queued. \ 394 + Each test must enqueue() exactly the responses it expects the stage to consume." 395 + ); 396 + } 397 + let script = scripts.remove(0); 398 + 399 + match script { 400 + FakeCreateReportResponse::Transport { message } => { 401 + Err(CreateReportStageError::Transport { 402 + message, 403 + source: None, 404 + }) 405 + } 406 + FakeCreateReportResponse::Response { status, content_type, body } => { 407 + let raw_body: Arc<[u8]> = Arc::from(body.as_slice()); 408 + Ok(RawCreateReportResponse { 409 + status: StatusCode::from_u16(status) 410 + .expect("test must use valid HTTP status"), 411 + content_type: content_type.map(|s| s.to_ascii_lowercase()), 412 + raw_body, 413 + source_url: "https://labeler.test/xrpc/com.atproto.moderation.createReport" 414 + .to_string(), 415 + }) 416 + } 417 + } 418 + } 419 + } 420 + ``` 421 + 422 + **Notes for the implementor:** 423 + - `tests/common/mod.rs` already has the `#![allow(dead_code)]` attribute at the top — these new helpers will not trigger dead-code warnings in test binaries that don't use them. 424 + - The panic on "no script queued" matches `FakeWebSocketClient::new`'s behavior and deliberately forces tests to declare every call. 425 + 426 + **Testing:** 427 + 428 + Add a smoke test in `tests/common/mod.rs` under `#[cfg(test)]`: 429 + 430 + ```rust 431 + #[cfg(test)] 432 + mod tests { 433 + use super::*; 434 + 435 + #[tokio::test] 436 + async fn fake_create_report_tee_serves_scripted_responses_and_records_requests() { 437 + let fake = FakeCreateReportTee::new(); 438 + fake.enqueue(FakeCreateReportResponse::unauthorized( 439 + "AuthenticationRequired", 440 + "jwt required", 441 + )); 442 + fake.enqueue(FakeCreateReportResponse::ok_empty()); 443 + 444 + let body1 = serde_json::json!({"a": 1}); 445 + let resp1 = fake 446 + .post_create_report(None, &body1) 447 + .await 448 + .expect("fake returns Ok"); 449 + assert_eq!(resp1.status, StatusCode::UNAUTHORIZED); 450 + let envelope: serde_json::Value = serde_json::from_slice(&resp1.raw_body).unwrap(); 451 + assert_eq!(envelope["error"], "AuthenticationRequired"); 452 + 453 + let body2 = serde_json::json!({"b": 2}); 454 + let resp2 = fake 455 + .post_create_report(Some("abc.def.ghi"), &body2) 456 + .await 457 + .expect("fake returns Ok"); 458 + assert_eq!(resp2.status, StatusCode::OK); 459 + 460 + let recorded = fake.recorded_requests(); 461 + assert_eq!(recorded.len(), 2); 462 + assert_eq!(recorded[0].auth, None); 463 + assert_eq!(recorded[0].body, body1); 464 + assert_eq!(recorded[1].auth.as_deref(), Some("abc.def.ghi")); 465 + assert_eq!(recorded[1].body, body2); 466 + } 467 + 468 + #[tokio::test] 469 + #[should_panic(expected = "no script queued")] 470 + async fn fake_create_report_tee_panics_on_unscripted_call() { 471 + let fake = FakeCreateReportTee::new(); 472 + let _ = fake 473 + .post_create_report(None, &serde_json::json!({})) 474 + .await; 475 + } 476 + } 477 + ``` 478 + 479 + **Notes:** These tests live inside `tests/common/mod.rs`, which cargo compiles as part of each integration test binary. The module-level `#![allow(dead_code)]` already at `tests/common/mod.rs:8` (existing convention; the comment there explains why `#[expect(...)]` can't be used at module scope when different test binaries use different subsets) already silences unused-item warnings. No new attributes are needed in `tests/common/mod.rs` itself. 480 + 481 + To ensure a test binary actually compiles and runs the smoke tests, put them in a new file `tests/common_fakes.rs` with `mod common;` and `use common::*;`. That isolates the tests without touching existing binaries, keeps per-binary attribute hygiene intact, and avoids the `#[expect]`-vs-`#[allow]` question at the item level (no items are tagged either way). 482 + 483 + **Verification:** 484 + Run: `cargo test --test common_fakes` (or whichever test binary hosts these) 485 + Expected: smoke tests pass. 486 + 487 + Run: `cargo test` (full suite) 488 + Expected: all pre-existing tests still pass. 489 + 490 + **Commit:** `test(create_report): FakeCreateReportTee with scripted responses` 491 + <!-- END_TASK_3 --> 492 + 493 + <!-- START_TASK_4 --> 494 + ### Task 4: Phase 3 integration check 495 + 496 + **Files:** None changed. 497 + 498 + **Implementation:** Gate. 499 + 500 + **Verification:** 501 + Run: `cargo build` 502 + Expected: clean build. 503 + 504 + Run: `cargo test` 505 + Expected: Phase 1 + Phase 2 tests still pass; Phase 3 adds 2+ new passing smoke tests. 506 + 507 + Run: `cargo clippy --all-targets -- -D warnings` 508 + Expected: no warnings. 509 + 510 + Run: `cargo fmt --check` 511 + Expected: no changes required. 512 + 513 + **Commit:** No new commit unless fixes were needed. 514 + <!-- END_TASK_4 --> 515 + 516 + --- 517 + 518 + ## Phase 3 complete when 519 + 520 + - `CreateReportTee` trait + `RawCreateReportResponse` + `CreateReportStageError` exist in `src/commands/test/labeler/create_report.rs`. 521 + - `RealCreateReportTee` compiles and matches the `RealHttpTee` pattern. 522 + - `FakeCreateReportTee` in `tests/common/mod.rs` serves scripted responses in FIFO order, records every request (auth + body) for inspection, and panics on unscripted calls. 523 + - Smoke tests prove the fake's scripted-response and request-capture behavior. 524 + - No acceptance criteria are directly verified in this phase; Phase 4 begins AC coverage.
+1166
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_04.md
··· 1 + # Labeler report stage — Phase 4: Stage scaffolding and contract check 2 + 3 + **Goal:** Wire a new `report` stage into `pipeline::run_pipeline` with `report::contract_published` operational in all four gating outcomes. Add the four new `LabelerCmd` CLI flags that Phase 4 needs (`--commit-report`, `--self-mint-curve`, `--force-self-mint`, `--report-subject-did`), plus supporting infrastructure — the `Stage::Report` variant, the `CreateReportStageOutput`/`Facts` types, the stage's `run()` function, and the pipeline integration. All 10 `report::*` row IDs are emitted every run (9 as `Skipped` for now). 4 + 5 + **Architecture:** The stage's `run()` takes `identity_facts`, a `&dyn CreateReportTee`, pipeline options, and CLI-derived flags; it decides the contract state, emits `report::contract_published` with the correct status, and emits all nine other checks as `Skipped` with the appropriate reason. The stage output follows the existing `*StageOutput { facts, results }` pattern (e.g., `HttpStageOutput` at `src/commands/test/labeler/http.rs:130-136`). 6 + 7 + **Tech Stack:** clap derive for new flags, `serde_json::Value` for rough payload inspection, the Phase 3 `CreateReportTee` seam (unused in this phase but plumbed so later phases need zero pipeline changes), insta snapshots for pinning output. 8 + 9 + **Scope:** Phase 4 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `src/commands/test/labeler/pipeline.rs` lines 1-381, `src/commands/test/labeler/report.rs`, `src/commands/test/labeler/http.rs`, `src/commands/test/labeler.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Stage enum at `src/commands/test/labeler/report.rs:84-94`: `Identity, Http, Subscription, Crypto`. ADD `Report` variant after `Crypto`; update `Stage::label` at line 98-105. 15 + - ✓ Pipeline insertion point at `src/commands/test/labeler/pipeline.rs:341-344` (the `crypto::run(...)` call site, inside the `if http_facts.is_some() || sub_has_labels` branch of the crypto block). New `report::run(...)` call goes AFTER the entire crypto block (after line 377) and BEFORE `report.finish()` at line 379. 16 + - ✓ `LabelerOptions` at `src/commands/test/labeler/pipeline.rs:48-61`. Add fields: `create_report_tee: CreateReportTeeKind<'a>`, `commit_report: bool`, `force_self_mint: bool`, `self_mint_curve: SelfMintCurve`, `report_subject_override: Option<Did>`. Mirror the `HttpTee<'a>` Real/Test enum at lines 64-69 with a new `CreateReportTeeKind<'a>`. 17 + - ✓ Identity facts: `IdentityFacts` populated in `identity::run` at `src/commands/test/labeler/identity.rs:375+` and threaded through pipeline at lines 209-216. The field `labeler_policies` is of type `atrium_api::app::bsky::labeler::defs::LabelerPolicies`. To access `reason_types` and `subject_types` without making the stage depend on atrium-api internals, the stage reads them via the already-public `IdentityFacts` (same pattern as the crypto stage consumes identity facts). 18 + - ✓ Check-builder pattern at `src/commands/test/labeler/http.rs:22-114`: `enum Check { ... }` with `fn id(self) -> &'static str` and `pub fn pass(self) / spec_violation / network_error / advisory` helpers. Mirror exactly for `create_report::Check`. 19 + - ✓ `CheckResult` direct construction pattern at `src/commands/test/labeler/pipeline.rs:264-271`. For `Skipped` the whole `CheckResult { id, stage, status: Skipped, summary, diagnostic: None, skipped_reason: Some(...) }` is constructed inline. 20 + - ✓ Diagnostic pattern at `src/commands/test/labeler/http.rs:151-163`: `#[derive(Debug, Error, Diagnostic)] #[diagnostic(code = "labeler::<stage>::<subject>")]` with `#[source_code]` + `#[label]` fields. Use for `labeler::report::contract_missing`. 21 + - ✓ LabelerCmd at `src/commands/test/labeler.rs:26-52`. Add new `#[arg(long)]` fields. clap `ValueEnum` for `--self-mint-curve`: use `SelfMintCurve` from Phase 2. 22 + - ✓ `LabelerCmd::run` at `src/commands/test/labeler.rs:54-93` constructs `LabelerOptions`. Extend with the new fields. `--handle`/`--app-password`/`clap requires` land in Phase 8; do NOT add them here. 23 + - ✓ **Correction after direct source-read:** `reason_types` and `subject_types` are NOT on `atrium_api::app::bsky::labeler::defs::LabelerPoliciesData` (that struct holds only `label_value_definitions` and `label_values`). They ARE on the full `atrium_api::app::bsky::labeler::service::RecordData` (the `app.bsky.labeler.service` record itself), which is already parsed in `src/commands/test/labeler/identity.rs:922` as `GetRecordResponse::value`, BUT the existing code extracts only `response.value.policies` into `IdentityFacts.labeler_policies` — it discards `reason_types` and `subject_types`. **Phase 4 must extend `IdentityFacts` to retain them.** The fields, per `atrium-api 0.25.8/src/app/bsky/labeler/service.rs`: 24 + - `reason_types: Option<Vec<crate::com::atproto::moderation::defs::ReasonType>>` — where `ReasonType = String`. 25 + - `subject_types: Option<Vec<crate::com::atproto::moderation::defs::SubjectType>>` — where `SubjectType = String`. 26 + - `subject_collections: Option<Vec<crate::types::string::Nsid>>` — bonus field, useful for AC3.5 subject-type validation; retain for future use. 27 + - `None` vs `Some(vec![])` are semantically distinct per the lexicon comment ("If not defined (distinct from empty array), all reason types are allowed"). However, the design's AC1.4 explicitly treats empty arrays as equivalent to absent — the stage normalizes `None` or `Some(empty)` as "no contract advertised." 28 + - ✓ Snapshot pattern at `tests/labeler_endtoend.rs:304` using `insta::assert_snapshot!("name", body)`. Per-stage binaries (e.g., `tests/labeler_http.rs`) follow the same pattern. Integration tests normalize output then snapshot. 29 + - ✓ `tests/fixtures/labeler/<stage>/<case>/` layout — mirror with new `tests/fixtures/labeler/report/contract_present/`, `/contract_missing/`, etc. 30 + - ✓ Empty case directories need `.gitkeep` (documented in `src/commands/test/labeler/CLAUDE.md:151-152`). 31 + 32 + **External dependency research findings:** N/A — internal wiring only. 33 + 34 + --- 35 + 36 + ## Acceptance criteria coverage 37 + 38 + This phase implements and tests: 39 + 40 + ### labeler-report-stage.AC1: `report::contract_published` behavior 41 + - **labeler-report-stage.AC1.1 Success:** Labeler advertises non-empty `reasonTypes` and `subjectTypes` → check emits `Pass`. 42 + - **labeler-report-stage.AC1.2 Success (stage-skip):** No `--commit-report`, contract missing → every `report::*` check emits `Skipped` with reason "labeler does not advertise report acceptance". 43 + - **labeler-report-stage.AC1.3 Failure:** `--commit-report` set, contract missing → `report::contract_published` emits `SpecViolation` with diagnostic `labeler::report::contract_missing`; all other checks emit `Skipped` with reason "blocked by `report::contract_published`". 44 + - **labeler-report-stage.AC1.4 Edge:** Empty arrays (`reasonTypes: []`) treated identically to absent field. 45 + 46 + ### labeler-report-stage.AC7: Never-short-circuit and row-count invariants 47 + - **labeler-report-stage.AC7.1 Row count:** Every `test labeler` run that reaches the report stage emits exactly 10 `report::*` `CheckResult` rows, regardless of flag or environment combinations. (Exercised in Phase 4 for the contract-present / contract-missing / commit-on / commit-off combinations; re-verified in Phase 8 with the full 10-check matrix.) 48 + - **labeler-report-stage.AC7.2 Row order:** Row order is stable and matches the DoD list top-to-bottom. 49 + 50 + --- 51 + 52 + <!-- START_TASK_0 --> 53 + ### Task 0: Extend `IdentityFacts` to retain `reason_types` / `subject_types` / `subject_collections` 54 + 55 + **Verifies:** Prerequisite for AC1.1–AC1.4 (and indirectly every downstream committing check that uses the advertised contract). 56 + 57 + **Files:** 58 + - Modify: `src/commands/test/labeler/identity.rs` — add three new fields to `IdentityFacts`, update `fetch_labeler_record` to return them, update the facts-construction block at lines 812-823. 59 + 60 + **Implementation:** 61 + 62 + In `IdentityFacts` (around lines 27-50), add three new fields after `labeler_policies`: 63 + 64 + ```rust 65 + /// `app.bsky.labeler.service.reasonTypes` — the NSIDs of reason types 66 + /// this labeler accepts for `createReport`. `None` means "not advertised"; 67 + /// the report stage treats `None` and `Some(vec![])` identically as 68 + /// "contract not published" per AC1.4. 69 + pub reason_types: Option<Vec<String>>, 70 + /// `app.bsky.labeler.service.subjectTypes` — the subject-type kinds 71 + /// (`account`, `record`, ...) this labeler accepts for reports. 72 + pub subject_types: Option<Vec<String>>, 73 + /// `app.bsky.labeler.service.subjectCollections` — NSIDs of record 74 + /// collections this labeler will accept reports about. Retained for 75 + /// future AC use (e.g., refined pollution-avoidance in Phase 7); not 76 + /// read by Phase 4. 77 + pub subject_collections: Option<Vec<String>>, 78 + ``` 79 + 80 + Update `fetch_labeler_record` (at `src/commands/test/labeler/identity.rs:893-951`) to return `(Arc<[u8]>, LabelerPolicies, Option<Vec<String>>, Option<Vec<String>>, Option<Vec<String>>)`. At the success path on line 923, extract: 81 + 82 + ```rust 83 + Ok(response) => { 84 + let reason_types = response 85 + .value 86 + .reason_types 87 + .as_ref() 88 + .map(|v| v.iter().map(|r| r.clone()).collect::<Vec<String>>()); 89 + let subject_types = response 90 + .value 91 + .subject_types 92 + .as_ref() 93 + .map(|v| v.iter().map(|s| s.clone()).collect::<Vec<String>>()); 94 + let subject_collections = response 95 + .value 96 + .subject_collections 97 + .as_ref() 98 + .map(|v| v.iter().map(|n| n.to_string()).collect::<Vec<String>>()); 99 + Ok((body_arc, response.value.policies, reason_types, subject_types, subject_collections)) 100 + } 101 + ``` 102 + 103 + **Notes for the implementor:** 104 + - `ReasonType` and `SubjectType` are type aliases (`type ReasonType = String`) in atrium-api 0.25.8 (`com/atproto/moderation/defs.rs:15,19`). They deserialize as plain strings; the `.clone()` above is the type-safe conversion even though they're already `String`s under the hood. 105 + - `Nsid` (for `subject_collections`) is a wrapper type `atrium_api::types::string::Nsid`. Use `.to_string()` to unwrap to `String` for the plain field shape in `IdentityFacts`. 106 + - Update the `Ok((bytes, policies)) =>` match arm at lines 719-723 to destructure the 5-tuple and bind all five values into `labeler_record_bytes`, `labeler_policies`, `reason_types`, `subject_types`, `subject_collections`. 107 + - Thread all three new fields into the `IdentityFacts` construction at lines 812-823. 108 + - The existing `LabelerServiceRecordData` import at line 10 is already correct — `RecordData` already carries these fields on the Rust side; this task only adds their propagation into `IdentityFacts`. 109 + 110 + **Testing:** 111 + 112 + Add a unit test in `src/commands/test/labeler/identity.rs` `#[cfg(test)]` module: 113 + 114 + ```rust 115 + #[tokio::test] 116 + async fn identity_retains_reason_and_subject_types() { 117 + // Use an existing fixture under tests/fixtures/labeler/identity/ that 118 + // has a labeler service record with reasonTypes/subjectTypes set. If 119 + // none exists, create a new fixture directory with a minimal 120 + // {uri, cid, value: {createdAt, policies: {...}, reasonTypes: [...], subjectTypes: [...]}} 121 + // JSON. Drive identity::run with a FakeHttpClient scripting the PDS 122 + // getRecord response. Assert facts.reason_types == Some(vec![...]) 123 + // and facts.subject_types == Some(vec!["account"]). 124 + } 125 + ``` 126 + 127 + The implementor should reuse an existing identity fixture (grep `tests/fixtures/labeler/identity/` for `reasonTypes` — one may already exist). If none do, create `tests/fixtures/labeler/identity/report_stage_contract_present/labeler_record.json` with the minimal shape. 128 + 129 + **Verification:** 130 + Run: `cargo test --lib commands::test::labeler::identity::tests::identity_retains_reason_and_subject_types` 131 + Expected: passes. 132 + 133 + Run: `cargo test --lib commands::test::labeler::identity` 134 + Expected: all pre-existing identity unit tests still pass. 135 + 136 + Run: `cargo test --test labeler_identity` 137 + Expected: all pre-existing identity integration tests still pass (the struct change is additive, so existing constructions break — fix them: any code constructing `IdentityFacts { ... }` must add `reason_types: None, subject_types: None, subject_collections: None` defaults). Grep the codebase for `IdentityFacts {` to locate construction sites — the only current one is at `src/commands/test/labeler/identity.rs:813`. 138 + 139 + **Commit:** `feat(identity): retain reason_types / subject_types / subject_collections in IdentityFacts` 140 + <!-- END_TASK_0 --> 141 + 142 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 143 + <!-- START_TASK_1 --> 144 + ### Task 1: Add `Report` variant to `Stage` enum 145 + 146 + **Verifies:** Supports AC7.2 (row order). 147 + 148 + **Files:** 149 + - Modify: `src/commands/test/labeler/report.rs` — add `Report` variant to `Stage` enum at line 85, update the `label()` match at line 98-105. 150 + 151 + **Implementation:** 152 + 153 + ```rust 154 + // In src/commands/test/labeler/report.rs, modify Stage enum: 155 + pub enum Stage { 156 + Identity, 157 + Http, 158 + Subscription, 159 + Crypto, 160 + /// `com.atproto.moderation.createReport` authenticated-write stage. 161 + Report, 162 + } 163 + 164 + // And Stage::label(): 165 + pub fn label(self) -> &'static str { 166 + match self { 167 + Stage::Identity => "Identity", 168 + Stage::Http => "HTTP", 169 + Stage::Subscription => "Subscription", 170 + Stage::Crypto => "Crypto", 171 + Stage::Report => "Report", 172 + } 173 + } 174 + ``` 175 + 176 + **Testing:** 177 + 178 + Add an assertion test in the same file's test module: 179 + 180 + ```rust 181 + #[test] 182 + fn report_stage_ordering_places_report_last() { 183 + assert!(Stage::Identity < Stage::Http); 184 + assert!(Stage::Http < Stage::Subscription); 185 + assert!(Stage::Subscription < Stage::Crypto); 186 + assert!(Stage::Crypto < Stage::Report); 187 + } 188 + ``` 189 + 190 + **Verification:** 191 + Run: `cargo test --lib commands::test::labeler::report::tests::report_stage_` 192 + Expected: passes. 193 + 194 + Run: `cargo build` 195 + Expected: clean build (other stages' `CheckResult` constructors unaffected). 196 + 197 + **Commit:** `feat(report): add Report variant to Stage enum` 198 + <!-- END_TASK_1 --> 199 + 200 + <!-- START_TASK_2 --> 201 + ### Task 2: Create `CreateReportStageOutput`, `CreateReportFacts`, and the `Check` enum 202 + 203 + **Verifies:** Supports all AC coverage (infrastructure for check construction). 204 + 205 + **Files:** 206 + - Modify: `src/commands/test/labeler/create_report.rs` — append after the Phase 3 types. 207 + 208 + **Implementation:** 209 + 210 + ```rust 211 + use std::borrow::Cow; 212 + 213 + use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage}; 214 + 215 + /// Minimal per-check outcome facts for possible future consumer stages. 216 + /// All three `Option<bool>` fields are `None` unless the corresponding 217 + /// positive check ran and produced a concrete outcome. 218 + #[derive(Debug, Clone, Default)] 219 + pub struct CreateReportFacts { 220 + /// `self_mint_accepted` outcome: `Some(true)` on Pass, `Some(false)` on 221 + /// SpecViolation, `None` on Skipped/NetworkError. 222 + pub self_mint_succeeded: Option<bool>, 223 + /// `pds_service_auth_accepted` outcome (see above). 224 + pub pds_service_auth_succeeded: Option<bool>, 225 + /// `pds_proxied_accepted` outcome (see above). 226 + pub pds_proxied_succeeded: Option<bool>, 227 + } 228 + 229 + /// Stage output: facts (populated only when the stage produced meaningful 230 + /// outcome data) and the full 10-row results vector. 231 + #[derive(Debug)] 232 + pub struct CreateReportStageOutput { 233 + pub facts: Option<CreateReportFacts>, 234 + pub results: Vec<CheckResult>, 235 + } 236 + 237 + /// Stable check identifiers for the `report` stage. 238 + /// 239 + /// Order MUST match the DoD ordering (AC7.2): contract, unauth, malformed, 240 + /// wrong-aud, wrong-lxm, expired, rejected-shape, self-mint, pds-service-auth, 241 + /// pds-proxied. 242 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 243 + pub enum Check { 244 + ContractPublished, 245 + UnauthenticatedRejected, 246 + MalformedBearerRejected, 247 + WrongAudRejected, 248 + WrongLxmRejected, 249 + ExpiredRejected, 250 + RejectedShapeReturns400, 251 + SelfMintAccepted, 252 + PdsServiceAuthAccepted, 253 + PdsProxiedAccepted, 254 + } 255 + 256 + impl Check { 257 + /// Stable `CheckResult.id` string. 258 + pub fn id(self) -> &'static str { 259 + match self { 260 + Check::ContractPublished => "report::contract_published", 261 + Check::UnauthenticatedRejected => "report::unauthenticated_rejected", 262 + Check::MalformedBearerRejected => "report::malformed_bearer_rejected", 263 + Check::WrongAudRejected => "report::wrong_aud_rejected", 264 + Check::WrongLxmRejected => "report::wrong_lxm_rejected", 265 + Check::ExpiredRejected => "report::expired_rejected", 266 + Check::RejectedShapeReturns400 => "report::rejected_shape_returns_400", 267 + Check::SelfMintAccepted => "report::self_mint_accepted", 268 + Check::PdsServiceAuthAccepted => "report::pds_service_auth_accepted", 269 + Check::PdsProxiedAccepted => "report::pds_proxied_accepted", 270 + } 271 + } 272 + 273 + /// Canonical iteration order for the 10 checks, matching AC7.2. 274 + pub const ORDER: [Check; 10] = [ 275 + Check::ContractPublished, 276 + Check::UnauthenticatedRejected, 277 + Check::MalformedBearerRejected, 278 + Check::WrongAudRejected, 279 + Check::WrongLxmRejected, 280 + Check::ExpiredRejected, 281 + Check::RejectedShapeReturns400, 282 + Check::SelfMintAccepted, 283 + Check::PdsServiceAuthAccepted, 284 + Check::PdsProxiedAccepted, 285 + ]; 286 + 287 + /// Build a `Pass` result for this check with a default summary. 288 + pub fn pass(self) -> CheckResult { 289 + CheckResult { 290 + id: self.id(), 291 + stage: Stage::Report, 292 + status: CheckStatus::Pass, 293 + summary: Cow::Borrowed(self.default_summary_pass()), 294 + diagnostic: None, 295 + skipped_reason: None, 296 + } 297 + } 298 + 299 + /// Build a `SpecViolation` result for this check with an optional 300 + /// diagnostic. 301 + pub fn spec_violation( 302 + self, 303 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 304 + ) -> CheckResult { 305 + CheckResult { 306 + id: self.id(), 307 + stage: Stage::Report, 308 + status: CheckStatus::SpecViolation, 309 + summary: Cow::Borrowed(self.default_summary_fail()), 310 + diagnostic, 311 + skipped_reason: None, 312 + } 313 + } 314 + 315 + /// Build an `Advisory` result (used by `rejected_shape_returns_400` AC3.6). 316 + pub fn advisory( 317 + self, 318 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 319 + ) -> CheckResult { 320 + CheckResult { 321 + id: self.id(), 322 + stage: Stage::Report, 323 + status: CheckStatus::Advisory, 324 + summary: Cow::Borrowed(self.default_summary_fail()), 325 + diagnostic, 326 + skipped_reason: None, 327 + } 328 + } 329 + 330 + /// Build a `NetworkError` result (used by PDS-side failure modes). 331 + pub fn network_error(self, message: String) -> CheckResult { 332 + CheckResult { 333 + id: self.id(), 334 + stage: Stage::Report, 335 + status: CheckStatus::NetworkError, 336 + summary: Cow::Owned(format!("{}: {message}", self.default_summary_fail())), 337 + diagnostic: None, 338 + skipped_reason: None, 339 + } 340 + } 341 + 342 + /// Build a `Skipped` result with the supplied reason. 343 + pub fn skip(self, reason: &'static str) -> CheckResult { 344 + CheckResult { 345 + id: self.id(), 346 + stage: Stage::Report, 347 + status: CheckStatus::Skipped, 348 + summary: Cow::Borrowed(self.default_summary_pass()), 349 + diagnostic: None, 350 + skipped_reason: Some(Cow::Borrowed(reason)), 351 + } 352 + } 353 + 354 + fn default_summary_pass(self) -> &'static str { 355 + match self { 356 + Check::ContractPublished => "Labeler advertises reportable shape", 357 + Check::UnauthenticatedRejected => "Unauthenticated report rejected", 358 + Check::MalformedBearerRejected => "Malformed bearer rejected", 359 + Check::WrongAudRejected => "JWT with wrong `aud` rejected", 360 + Check::WrongLxmRejected => "JWT with wrong `lxm` rejected", 361 + Check::ExpiredRejected => "Expired JWT rejected", 362 + Check::RejectedShapeReturns400 => "Invalid shape returns 400 InvalidRequest", 363 + Check::SelfMintAccepted => "Self-mint report accepted", 364 + Check::PdsServiceAuthAccepted => "PDS-minted JWT accepted", 365 + Check::PdsProxiedAccepted => "PDS-proxied report accepted", 366 + } 367 + } 368 + 369 + fn default_summary_fail(self) -> &'static str { 370 + match self { 371 + Check::ContractPublished => "Labeler does not advertise a reportable shape", 372 + Check::UnauthenticatedRejected => "Unauthenticated report accepted (should have been rejected)", 373 + Check::MalformedBearerRejected => "Malformed bearer accepted (should have been rejected)", 374 + Check::WrongAudRejected => "JWT with wrong `aud` accepted", 375 + Check::WrongLxmRejected => "JWT with wrong `lxm` accepted", 376 + Check::ExpiredRejected => "Expired JWT accepted", 377 + Check::RejectedShapeReturns400 => "Rejection status was not 400 InvalidRequest", 378 + Check::SelfMintAccepted => "Self-mint report rejected", 379 + Check::PdsServiceAuthAccepted => "PDS-minted JWT rejected", 380 + Check::PdsProxiedAccepted => "PDS-proxied report rejected", 381 + } 382 + } 383 + } 384 + ``` 385 + 386 + **Testing:** 387 + 388 + ```rust 389 + #[cfg(test)] 390 + mod check_tests { 391 + use super::*; 392 + 393 + #[test] 394 + fn check_ids_are_unique_and_report_namespaced() { 395 + let mut seen = std::collections::HashSet::new(); 396 + for c in Check::ORDER { 397 + let id = c.id(); 398 + assert!(id.starts_with("report::"), "{id} not in report:: namespace"); 399 + assert!(seen.insert(id), "duplicate check id: {id}"); 400 + } 401 + assert_eq!(Check::ORDER.len(), 10, "DoD requires exactly 10 checks"); 402 + } 403 + } 404 + ``` 405 + 406 + **Verification:** 407 + Run: `cargo test --lib commands::test::labeler::create_report::check_tests` 408 + Expected: pass. 409 + 410 + **Commit:** `feat(create_report): Check enum and CreateReportStageOutput` 411 + <!-- END_TASK_2 --> 412 + 413 + <!-- START_TASK_3 --> 414 + ### Task 3: Contract-missing diagnostic type 415 + 416 + **Verifies:** AC1.3. 417 + 418 + **Files:** 419 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 420 + 421 + **Implementation:** 422 + 423 + ```rust 424 + /// Diagnostic for the `contract_missing` spec violation (AC1.3). 425 + /// 426 + /// Emitted when `--commit-report` is set and the identity-stage 427 + /// `labeler_policies` does not advertise a non-empty `reasonTypes` and 428 + /// `subjectTypes`. The body of the labeler record is attached as source 429 + /// so users can see what _was_ published. 430 + #[derive(Debug, Error, Diagnostic)] 431 + #[error("Labeler does not advertise a reportable `LabelerPolicies` shape")] 432 + #[diagnostic( 433 + code = "labeler::report::contract_missing", 434 + 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." 435 + )] 436 + pub struct ContractMissing { 437 + /// `reasonTypes` present and non-empty? 438 + pub has_reason_types: bool, 439 + /// `subjectTypes` present and non-empty? 440 + pub has_subject_types: bool, 441 + } 442 + ``` 443 + 444 + **Testing:** No standalone test — exercised by AC1.3 integration test in Task 7. 445 + 446 + **Verification:** 447 + Run: `cargo build` 448 + Expected: clean. 449 + 450 + **Commit:** `feat(create_report): ContractMissing diagnostic for AC1.3` 451 + <!-- END_TASK_3 --> 452 + <!-- END_SUBCOMPONENT_A --> 453 + 454 + <!-- START_SUBCOMPONENT_B (tasks 4-5) --> 455 + <!-- START_TASK_4 --> 456 + ### Task 4: Add the new CLI flags to `LabelerCmd` 457 + 458 + **Verifies:** AC8.2 (via `--self-mint-curve`); supports gating assertions in AC1/AC4. 459 + 460 + **Files:** 461 + - Modify: `src/commands/test/labeler.rs` — extend `LabelerCmd` struct with four new flags. 462 + 463 + **Implementation:** 464 + 465 + ```rust 466 + // Add imports at the top: 467 + use crate::commands::test::labeler::create_report::self_mint::SelfMintCurve; 468 + use crate::common::identity::Did; 469 + 470 + // In LabelerCmd struct, after existing fields: 471 + 472 + /// Commit: opt in to actually POSTing report bodies to the labeler and 473 + /// assert reporting conformance (missing `LabelerPolicies` becomes a 474 + /// SpecViolation rather than a stage-skip). 475 + #[arg(long)] 476 + pub commit_report: bool, 477 + 478 + /// Force self-mint checks to run even when the labeler endpoint is 479 + /// classified as non-local by the hostname heuristic. Use when 480 + /// running against a LAN-reachable labeler that the heuristic misses. 481 + #[arg(long)] 482 + pub force_self_mint: bool, 483 + 484 + /// Curve to use for self-mint JWTs. Default `es256k` (maximum overlap 485 + /// with real atproto accounts). 486 + #[arg(long, value_enum, default_value_t = SelfMintCurve::default())] 487 + pub self_mint_curve: SelfMintCurve, 488 + 489 + /// Override the default computed subject DID for committing checks. 490 + /// Passed through to `self_mint_accepted`, `pds_service_auth_accepted`, 491 + /// and `pds_proxied_accepted` bodies. 492 + #[arg(long)] 493 + pub report_subject_did: Option<String>, 494 + ``` 495 + 496 + In `LabelerCmd::run`, thread the new fields through to `LabelerOptions`. Build a `Did` from `report_subject_did` if provided: 497 + 498 + ```rust 499 + // In LabelerCmd::run, when constructing LabelerOptions: 500 + let report_subject_override = self.report_subject_did.clone().map(Did); 501 + 502 + // Build a SelfMintSigner lazily: only construct if the stage will actually 503 + // use it. For Phase 4, the stage never uses it — Phase 6 starts consuming it. 504 + // Phase 4 still needs LabelerOptions to accept Option<&SelfMintSigner>, so 505 + // we pass None here. Phase 6 adds the construction path. 506 + 507 + let opts = LabelerOptions { 508 + http: &http, 509 + dns: &dns, 510 + http_tee: pipeline::HttpTee::Real(&reqwest_client), 511 + ws_client: None, 512 + subscribe_timeout: self.subscribe_timeout, 513 + verbose: self.verbose, 514 + 515 + create_report_tee: pipeline::CreateReportTeeKind::Real(&reqwest_client), 516 + commit_report: self.commit_report, 517 + force_self_mint: self.force_self_mint, 518 + self_mint_curve: self.self_mint_curve, 519 + report_subject_override: report_subject_override.as_ref(), 520 + self_mint_signer: None, // plumbed in Phase 6/7 521 + pds_credentials: None, // plumbed in Phase 8 522 + }; 523 + ``` 524 + 525 + **Notes for the implementor:** 526 + - The new `self_mint_signer: None` and `pds_credentials: None` fields are *forward-declared* here even though Phase 4 code doesn't populate them. This means Phase 4's pipeline integration can accept those types without needing a second `LabelerOptions` refactor in Phase 6 or Phase 8. See Task 6 below for the full `LabelerOptions` shape. 527 + 528 + **Testing:** 529 + 530 + Extend `tests/labeler_cli.rs` with a new test verifying the help lists all the new flags. Existing `help_lists_all_flags()` pattern shows the approach — assert `--commit-report`, `--force-self-mint`, `--self-mint-curve`, `--report-subject-did` appear in `--help` output. 531 + 532 + **Verification:** 533 + Run: `cargo build` 534 + Expected: clean (may need to import `SelfMintCurve` correctly). 535 + 536 + Run: `cargo test --test labeler_cli help_lists_all_flags` 537 + Expected: pass after updating the expected flag list. 538 + 539 + Run: `cargo run -- test labeler --help` 540 + Expected: new flags appear. 541 + 542 + **Commit:** `feat(labeler): add report-stage CLI flags` 543 + <!-- END_TASK_4 --> 544 + 545 + <!-- START_TASK_5 --> 546 + ### Task 5: Extend `LabelerOptions` and `CreateReportTeeKind` 547 + 548 + **Verifies:** Infrastructure for AC1/AC7. 549 + 550 + **Files:** 551 + - Modify: `src/commands/test/labeler/pipeline.rs` — extend `LabelerOptions` and add new enum. 552 + 553 + **Implementation:** 554 + 555 + ```rust 556 + // In src/commands/test/labeler/pipeline.rs, add imports: 557 + use crate::commands::test::labeler::create_report::{self, CreateReportTee, RealCreateReportTee}; 558 + use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner}; 559 + 560 + /// CreateReport tee selector. `Real` delegates to a shared reqwest client 561 + /// that the pipeline instantiates per-endpoint; `Test` lets integration 562 + /// tests inject a `FakeCreateReportTee`. 563 + pub enum CreateReportTeeKind<'a> { 564 + /// Shared reqwest client; pipeline constructs a `RealCreateReportTee`. 565 + Real(&'a reqwest::Client), 566 + /// Explicit control (for tests). 567 + Test(&'a dyn CreateReportTee), 568 + } 569 + 570 + /// Credentials for PDS-mediated modes (modes 2 and 3). Present when 571 + /// `--handle` and `--app-password` are both supplied. Phase 8 populates 572 + /// this; Phase 4 and earlier leave it as `None`. 573 + #[derive(Debug, Clone)] 574 + pub struct PdsCredentials { 575 + /// The user's handle (e.g., `alice.bsky.social`). 576 + pub handle: String, 577 + /// The user's app password. 578 + pub app_password: String, 579 + } 580 + 581 + // Modify LabelerOptions: 582 + pub struct LabelerOptions<'a> { 583 + pub http: &'a dyn HttpClient, 584 + pub dns: &'a dyn DnsResolver, 585 + pub http_tee: HttpTee<'a>, 586 + pub ws_client: Option<&'a dyn subscription::WebSocketClient>, 587 + pub subscribe_timeout: Duration, 588 + pub verbose: bool, 589 + 590 + // Report stage wiring: 591 + pub create_report_tee: CreateReportTeeKind<'a>, 592 + pub commit_report: bool, 593 + pub force_self_mint: bool, 594 + pub self_mint_curve: SelfMintCurve, 595 + pub report_subject_override: Option<&'a Did>, 596 + /// Self-mint signer. Populated in `LabelerCmd::run` only when the 597 + /// heuristic + `--force-self-mint` say self-mint is viable. Phase 4 598 + /// leaves this as `None` when the pipeline can't reach the labeler 599 + /// locally. 600 + pub self_mint_signer: Option<&'a SelfMintSigner>, 601 + /// PDS credentials for modes 2 and 3. Populated in Phase 8. 602 + pub pds_credentials: Option<&'a PdsCredentials>, 603 + } 604 + ``` 605 + 606 + **Notes for the implementor:** 607 + - Do NOT change `HttpTee<'a>` or any existing fields — this is additive only. 608 + - Tests that construct `LabelerOptions` will need the new fields; include a pattern in Task 6's integration tests. 609 + 610 + **Testing:** No standalone test; exercised by subsequent tasks. 611 + 612 + **Verification:** 613 + Run: `cargo build` 614 + Expected: clean. 615 + 616 + **Commit:** `feat(pipeline): extend LabelerOptions for report stage` 617 + <!-- END_TASK_5 --> 618 + <!-- END_SUBCOMPONENT_B --> 619 + 620 + <!-- START_SUBCOMPONENT_C (tasks 6-7) --> 621 + <!-- START_TASK_6 --> 622 + ### Task 6: Implement `create_report::run` with contract check and 9 stub skips 623 + 624 + **Verifies:** AC1.1, AC1.2, AC1.3, AC1.4, AC7.1, AC7.2. 625 + 626 + **Files:** 627 + - Modify: `src/commands/test/labeler/create_report.rs` — append the `run` function. 628 + 629 + **Implementation:** 630 + 631 + ```rust 632 + use crate::commands::test::labeler::identity::IdentityFacts; 633 + use crate::commands::test::labeler::pipeline::{CreateReportTeeKind, LabelerOptions}; 634 + 635 + /// Run the report stage. 636 + /// 637 + /// Stage inputs are passed via `LabelerOptions` (or directly as arguments 638 + /// here, to keep the signature mirroring the other stages). The stage 639 + /// always emits exactly 10 `report::*` CheckResults (AC7.1) in canonical 640 + /// order (AC7.2), regardless of gating decisions. 641 + pub async fn run( 642 + identity_facts: Option<&IdentityFacts>, 643 + report_tee: &dyn CreateReportTee, 644 + opts: &CreateReportRunOptions<'_>, 645 + ) -> CreateReportStageOutput { 646 + let mut results = Vec::with_capacity(10); 647 + 648 + // If identity didn't land, every check is blocked by the identity 649 + // stage. Emit 10 Skipped rows and return. 650 + let Some(id_facts) = identity_facts else { 651 + for c in Check::ORDER { 652 + results.push(c.skip("blocked by identity stage")); 653 + } 654 + return CreateReportStageOutput { 655 + facts: None, 656 + results, 657 + }; 658 + }; 659 + 660 + // Examine the published contract (from Task 0's extended IdentityFacts). 661 + let reason_types = id_facts.reason_types.as_ref(); 662 + let subject_types = id_facts.subject_types.as_ref(); 663 + let has_reason_types = reason_types.map(|v| !v.is_empty()).unwrap_or(false); 664 + let has_subject_types = subject_types.map(|v| !v.is_empty()).unwrap_or(false); 665 + let contract_advertised = has_reason_types && has_subject_types; 666 + 667 + // AC1: compute the contract_published row and the blocking reason for 668 + // all downstream checks if the contract is missing. 669 + // 670 + // Control-flow contract: each branch below pushes EXACTLY 10 rows 671 + // (1 contract row + 9 downstream) and returns. No fallthrough — the 672 + // "contract advertised" branch is the one that invokes Phases 5/6/7/8 673 + // logic (added incrementally; Phase 4 emits Skipped stubs for them). 674 + if !contract_advertised { 675 + if opts.commit_report { 676 + // AC1.3: commit requested, contract missing ⇒ SpecViolation + 677 + // every other check blocked by this one. 678 + let diag = Box::new(ContractMissing { 679 + has_reason_types, 680 + has_subject_types, 681 + }); 682 + results.push(Check::ContractPublished.spec_violation(Some(diag))); 683 + for c in Check::ORDER.iter().skip(1).copied() { 684 + results.push(c.skip("blocked by `report::contract_published`")); 685 + } 686 + } else { 687 + // AC1.2: no commit, contract missing ⇒ whole stage skipped. 688 + results.push(Check::ContractPublished.skip( 689 + "labeler does not advertise report acceptance", 690 + )); 691 + for c in Check::ORDER.iter().skip(1).copied() { 692 + results.push(c.skip("labeler does not advertise report acceptance")); 693 + } 694 + } 695 + return CreateReportStageOutput { 696 + facts: None, 697 + results, 698 + }; 699 + } 700 + 701 + // Contract advertised. Emit the Pass row and fall through into the 702 + // per-check logic that Phases 5-8 replace incrementally. 703 + results.push(Check::ContractPublished.pass()); 704 + 705 + // Phase 4 leaves all 9 downstream checks as Skipped stubs. Phases 706 + // 5-8 replace this block in place. The `_ = report_tee` suppresses 707 + // dead-code warnings until Phase 5. 708 + let _ = report_tee; 709 + 710 + for c in [ 711 + Check::UnauthenticatedRejected, 712 + Check::MalformedBearerRejected, 713 + ] { 714 + results.push(c.skip("not yet implemented (Phase 5)")); 715 + } 716 + for c in [ 717 + Check::WrongAudRejected, 718 + Check::WrongLxmRejected, 719 + Check::ExpiredRejected, 720 + Check::RejectedShapeReturns400, 721 + ] { 722 + results.push(c.skip("not yet implemented (Phase 6)")); 723 + } 724 + results.push(Check::SelfMintAccepted.skip("not yet implemented (Phase 7)")); 725 + results.push(Check::PdsServiceAuthAccepted.skip("not yet implemented (Phase 8)")); 726 + results.push(Check::PdsProxiedAccepted.skip("not yet implemented (Phase 8)")); 727 + 728 + CreateReportStageOutput { 729 + facts: None, 730 + results, 731 + } 732 + } 733 + 734 + /// Aggregate of the stage-relevant options, extracted from `LabelerOptions` 735 + /// by the pipeline and passed to `run`. Having a local, narrow shape 736 + /// avoids forcing `run`'s signature to take everything in `LabelerOptions`. 737 + #[derive(Debug)] 738 + pub struct CreateReportRunOptions<'a> { 739 + pub commit_report: bool, 740 + pub force_self_mint: bool, 741 + pub self_mint_curve: crate::commands::test::labeler::create_report::self_mint::SelfMintCurve, 742 + pub report_subject_override: Option<&'a crate::common::identity::Did>, 743 + pub self_mint_signer: Option<&'a crate::commands::test::labeler::create_report::self_mint::SelfMintSigner>, 744 + pub pds_credentials: Option<&'a crate::commands::test::labeler::pipeline::PdsCredentials>, 745 + } 746 + ``` 747 + 748 + **Notes for the implementor:** 749 + - The stage accepts `Option<&IdentityFacts>` rather than a required reference so the pipeline can pass `None` when identity didn't produce facts — matching how the crypto stage handles the same scenario. 750 + - The 9 post-contract "not yet implemented" `Skipped` rows are temporary — they become real checks in Phases 5-8. Keep the wording deliberately boring; these exact strings will appear in Phase 4's insta snapshots and later phases will overwrite them. 751 + 752 + **Testing:** Covered in Task 7's integration tests. 753 + 754 + **Verification:** 755 + Run: `cargo build` 756 + Expected: clean. 757 + 758 + **Commit:** `feat(create_report): stage run() with contract check and stubs` 759 + <!-- END_TASK_6 --> 760 + 761 + <!-- START_TASK_7 --> 762 + ### Task 7: Pipeline integration + first integration test 763 + 764 + **Verifies:** AC1.1–AC1.4, AC7.1, AC7.2. 765 + 766 + **Files:** 767 + - Modify: `src/commands/test/labeler/pipeline.rs` — call `create_report::run` after the crypto block (after line 377), before `report.finish()` at line 379. 768 + - Create: `tests/labeler_report.rs` — new integration test binary for per-stage tests. 769 + - Create: `tests/fixtures/labeler/report/.gitkeep` — fixture directory seed. 770 + - Modify: `tests/common/mod.rs` (already done in Phase 3) — `FakeCreateReportTee` is ready. 771 + 772 + **Implementation (pipeline.rs):** 773 + 774 + ```rust 775 + // At the end of run_pipeline, after the crypto block (around line 378), 776 + // before `report.finish()`: 777 + 778 + // Run the report stage. Uses `identity_output.facts.as_ref()` so the 779 + // stage can skip-with-reason when identity didn't produce facts. 780 + let create_report_run_opts = create_report::CreateReportRunOptions { 781 + commit_report: opts.commit_report, 782 + force_self_mint: opts.force_self_mint, 783 + self_mint_curve: opts.self_mint_curve, 784 + report_subject_override: opts.report_subject_override, 785 + self_mint_signer: opts.self_mint_signer, 786 + pds_credentials: opts.pds_credentials, 787 + }; 788 + let labeler_endpoint_for_report = labeler_endpoint.clone(); 789 + let report_output = match opts.create_report_tee { 790 + CreateReportTeeKind::Test(tee) => { 791 + create_report::run( 792 + identity_output.facts.as_ref(), 793 + tee, 794 + &create_report_run_opts, 795 + ) 796 + .await 797 + } 798 + CreateReportTeeKind::Real(client) => { 799 + // Resolve the labeler endpoint for the real tee. When identity 800 + // didn't supply an endpoint, we'd still want `run` to emit the 801 + // 10 `Skipped` rows — but construct a do-nothing tee with a 802 + // dummy URL to satisfy the type; the function short-circuits on 803 + // `identity_facts == None` before any tee method is called. 804 + let endpoint = labeler_endpoint_for_report.unwrap_or_else(|| { 805 + // Safe dummy: we know `run` won't issue any POSTs in this 806 + // path (identity_facts is None). 807 + url::Url::parse("http://127.0.0.1:0").expect("dummy URL parses") 808 + }); 809 + let real_tee = RealCreateReportTee::new(client.clone(), endpoint); 810 + create_report::run( 811 + identity_output.facts.as_ref(), 812 + &real_tee, 813 + &create_report_run_opts, 814 + ) 815 + .await 816 + } 817 + }; 818 + for result in report_output.results { 819 + report.record(result); 820 + } 821 + 822 + report.finish(); 823 + report 824 + ``` 825 + 826 + **Implementation (tests/labeler_report.rs):** 827 + 828 + ```rust 829 + //! Integration tests for the `report` stage. 830 + //! 831 + //! Uses `FakeCreateReportTee` from `tests/common/mod.rs` to drive each 832 + //! stage-gating scenario, then snapshots the rendered output to pin the 833 + //! exact 10-row sequence. 834 + 835 + mod common; 836 + 837 + use std::sync::Arc; 838 + use std::time::Duration; 839 + 840 + use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 841 + use atproto_devtool::commands::test::labeler::identity::IdentityFacts; 842 + use atproto_devtool::commands::test::labeler::pipeline::{ 843 + self, CreateReportTeeKind, LabelerOptions, 844 + }; 845 + use atproto_devtool::commands::test::labeler::report::{ 846 + CheckResult, CheckStatus, LabelerReport, RenderConfig, ReportHeader, Stage, 847 + }; 848 + use atproto_devtool::commands::test::labeler::create_report; 849 + use atproto_devtool::common::identity::Did; 850 + 851 + use atrium_api::app::bsky::labeler::defs::LabelerPolicies; 852 + use atrium_api::types::Object; 853 + 854 + use common::{FakeCreateReportTee, FakeRawHttpTee, FakeWebSocketClient}; 855 + 856 + /// Build a synthetic `IdentityFacts` with the requested contract shape. 857 + /// 858 + /// Populates every required field with stable, known-valid defaults so the 859 + /// fixture is safe to reuse across all AC tests. The labeler endpoint is a 860 + /// public HTTPS URL by default (non-local per the viability heuristic); 861 + /// tests that need a local endpoint override `facts.labeler_endpoint` 862 + /// directly before passing in. 863 + fn make_identity_facts( 864 + reason_types: Option<Vec<String>>, 865 + subject_types: Option<Vec<String>>, 866 + ) -> IdentityFacts { 867 + use std::sync::Arc; 868 + use atrium_api::app::bsky::labeler::defs::{LabelerPolicies, LabelerPoliciesData}; 869 + use atrium_api::types::Object; 870 + use atproto_devtool::common::identity::{ 871 + AnyVerifyingKey, Did, DidDocument, RawDidDocument, parse_multikey, 872 + }; 873 + 874 + // A stable secp256k1 multikey drawn from an existing test fixture. 875 + // The exact value is load-bearing only insofar as it must parse via 876 + // `parse_multikey` — any valid secp256k1 multikey works. 877 + let multikey = "zQ3shNcc9CfAhG1vLj3UEV3SA4VESNiJKJiFLgs6WfGo4qG7B"; 878 + let parsed = parse_multikey(multikey).expect("test multikey parses"); 879 + let verifying_key: AnyVerifyingKey = parsed.verifying_key; 880 + 881 + // Minimal DID document with the one verification method the stages 882 + // care about. Raw bytes must match the parsed form so `NamedSource` 883 + // diagnostics land correctly; the exact bytes aren't snapshotted in 884 + // Phase 4 tests, so a small JSON is fine. 885 + let did_string = "did:plc:aaa22222222222222222bbbbbb"; 886 + let doc_json = format!( 887 + r#"{{"id":"{did_string}","verificationMethod":[{{"id":"{did_string}#atproto_label","type":"Multikey","controller":"{did_string}","publicKeyMultibase":"{multikey}"}}],"service":[{{"id":"#atproto_labeler","type":"AtprotoLabeler","serviceEndpoint":"https://labeler.example.com"}},{{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}}]}}"# 888 + ); 889 + let doc: DidDocument = serde_json::from_str(&doc_json).expect("test DID doc parses"); 890 + let raw_did_doc = RawDidDocument { 891 + parsed: doc, 892 + source_bytes: Arc::<[u8]>::from(doc_json.as_bytes()), 893 + source_name: "test DID document".to_string(), 894 + }; 895 + 896 + // Empty `LabelerPolicies` is always valid — the AC1 gate reads 897 + // `reason_types`/`subject_types` on `IdentityFacts` directly (Task 0), 898 + // not on `labeler_policies`. 899 + let labeler_policies: LabelerPolicies = Object { 900 + data: LabelerPoliciesData { 901 + label_value_definitions: None, 902 + label_values: vec![], 903 + }, 904 + extra_data: ipld_core::ipld::Ipld::Null, 905 + }; 906 + 907 + IdentityFacts { 908 + did: Did(did_string.to_string()), 909 + raw_did_doc, 910 + labeler_endpoint: url::Url::parse("https://labeler.example.com").unwrap(), 911 + pds_endpoint: url::Url::parse("https://pds.example.com").unwrap(), 912 + signing_key_id: format!("{did_string}#atproto_label"), 913 + signing_key_multikey: multikey.to_string(), 914 + signing_key: verifying_key, 915 + labeler_record_bytes: Arc::<[u8]>::from(b"{}" as &[u8]), 916 + labeler_policies, 917 + reason_types, 918 + subject_types, 919 + subject_collections: None, 920 + } 921 + } 922 + 923 + /// Run the report stage directly (not through run_pipeline) with the 924 + /// given fake tee and options. Returns the 10 CheckResults. 925 + async fn run_report_stage( 926 + facts: &IdentityFacts, 927 + tee: &FakeCreateReportTee, 928 + opts: create_report::CreateReportRunOptions<'_>, 929 + ) -> Vec<CheckResult> { 930 + let out = create_report::run(Some(facts), tee, &opts).await; 931 + out.results 932 + } 933 + 934 + #[tokio::test] 935 + async fn ac1_1_contract_present_emits_pass() { 936 + let facts = make_identity_facts( 937 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 938 + Some(vec!["account".to_string()]), 939 + ); 940 + let tee = FakeCreateReportTee::new(); 941 + let opts = create_report::CreateReportRunOptions { 942 + commit_report: false, 943 + force_self_mint: false, 944 + self_mint_curve: SelfMintCurve::Es256k, 945 + report_subject_override: None, 946 + self_mint_signer: None, 947 + pds_credentials: None, 948 + }; 949 + let results = run_report_stage(&facts, &tee, opts).await; 950 + 951 + assert_eq!(results.len(), 10, "AC7.1 requires exactly 10 rows"); 952 + assert_eq!(results[0].id, "report::contract_published"); 953 + assert_eq!(results[0].status, CheckStatus::Pass); 954 + } 955 + 956 + #[tokio::test] 957 + async fn ac1_2_contract_missing_without_commit_skips_stage() { 958 + let facts = make_identity_facts(None, None); 959 + let tee = FakeCreateReportTee::new(); 960 + let opts = create_report::CreateReportRunOptions { 961 + commit_report: false, 962 + force_self_mint: false, 963 + self_mint_curve: SelfMintCurve::Es256k, 964 + report_subject_override: None, 965 + self_mint_signer: None, 966 + pds_credentials: None, 967 + }; 968 + let results = run_report_stage(&facts, &tee, opts).await; 969 + 970 + assert_eq!(results.len(), 10); 971 + for r in &results { 972 + assert_eq!(r.status, CheckStatus::Skipped, "{}", r.id); 973 + let reason = r.skipped_reason.as_deref().unwrap_or(""); 974 + assert_eq!(reason, "labeler does not advertise report acceptance"); 975 + } 976 + } 977 + 978 + #[tokio::test] 979 + async fn ac1_3_contract_missing_with_commit_is_spec_violation() { 980 + let facts = make_identity_facts(None, None); 981 + let tee = FakeCreateReportTee::new(); 982 + let opts = create_report::CreateReportRunOptions { 983 + commit_report: true, 984 + force_self_mint: false, 985 + self_mint_curve: SelfMintCurve::Es256k, 986 + report_subject_override: None, 987 + self_mint_signer: None, 988 + pds_credentials: None, 989 + }; 990 + let results = run_report_stage(&facts, &tee, opts).await; 991 + 992 + assert_eq!(results.len(), 10); 993 + assert_eq!(results[0].id, "report::contract_published"); 994 + assert_eq!(results[0].status, CheckStatus::SpecViolation); 995 + 996 + for r in &results[1..] { 997 + assert_eq!(r.status, CheckStatus::Skipped, "{}", r.id); 998 + let reason = r.skipped_reason.as_deref().unwrap_or(""); 999 + assert_eq!(reason, "blocked by `report::contract_published`"); 1000 + } 1001 + } 1002 + 1003 + #[tokio::test] 1004 + async fn ac1_4_empty_arrays_equivalent_to_absent() { 1005 + // Empty Vecs treated the same as None per AC1.4. 1006 + let facts = make_identity_facts(Some(vec![]), Some(vec![])); 1007 + let tee = FakeCreateReportTee::new(); 1008 + let opts = create_report::CreateReportRunOptions { 1009 + commit_report: false, 1010 + force_self_mint: false, 1011 + self_mint_curve: SelfMintCurve::Es256k, 1012 + report_subject_override: None, 1013 + self_mint_signer: None, 1014 + pds_credentials: None, 1015 + }; 1016 + let results = run_report_stage(&facts, &tee, opts).await; 1017 + assert_eq!(results[0].status, CheckStatus::Skipped); 1018 + assert_eq!( 1019 + results[0].skipped_reason.as_deref(), 1020 + Some("labeler does not advertise report acceptance"), 1021 + ); 1022 + } 1023 + 1024 + #[tokio::test] 1025 + async fn ac7_2_row_order_is_stable() { 1026 + let facts = make_identity_facts( 1027 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 1028 + Some(vec!["account".to_string()]), 1029 + ); 1030 + let tee = FakeCreateReportTee::new(); 1031 + let opts = create_report::CreateReportRunOptions { 1032 + commit_report: false, 1033 + force_self_mint: false, 1034 + self_mint_curve: SelfMintCurve::Es256k, 1035 + report_subject_override: None, 1036 + self_mint_signer: None, 1037 + pds_credentials: None, 1038 + }; 1039 + let results = run_report_stage(&facts, &tee, opts).await; 1040 + let ids: Vec<&str> = results.iter().map(|r| r.id).collect(); 1041 + assert_eq!( 1042 + ids, 1043 + vec![ 1044 + "report::contract_published", 1045 + "report::unauthenticated_rejected", 1046 + "report::malformed_bearer_rejected", 1047 + "report::wrong_aud_rejected", 1048 + "report::wrong_lxm_rejected", 1049 + "report::expired_rejected", 1050 + "report::rejected_shape_returns_400", 1051 + "report::self_mint_accepted", 1052 + "report::pds_service_auth_accepted", 1053 + "report::pds_proxied_accepted", 1054 + ], 1055 + ); 1056 + } 1057 + ``` 1058 + 1059 + **Notes for the implementor:** 1060 + 1061 + - The `make_identity_facts` helper above is a complete, runnable fixture-builder. All `IdentityFacts` fields are populated with stable defaults: `did` / `raw_did_doc` / `labeler_endpoint` / `pds_endpoint` / `signing_key_id` / `signing_key_multikey` / `signing_key` / `labeler_record_bytes` / `labeler_policies` / `reason_types` / `subject_types` / `subject_collections` (the last three added by Task 0). 1062 + - `LabelerPolicies` is an `Object<LabelerPoliciesData>` from atrium-api (version 0.25.8). `LabelerPoliciesData` has ONLY `label_value_definitions: Option<...>` and `label_values: Vec<...>` — verified via `cargo read atrium-api --api | grep -A 10 "LabelerPoliciesData"` during planning. The `reason_types`/`subject_types` fields are on the parent `app.bsky.labeler.service::RecordData`, not on `LabelerPolicies`; that's why Task 0 adds them to `IdentityFacts` directly. 1063 + - The four contract × commit snapshot files should be pinned with `insta::assert_snapshot!` after rendering the `LabelerReport` through `RenderConfig { no_color: true }` to get a stable byte-for-byte output. Extend tests to compute the full rendered output and snapshot it — the assertions above are structure checks; the snapshot pins the surface-level display. 1064 + 1065 + **Testing:** 1066 + 1067 + Also add snapshot tests: 1068 + 1069 + ```rust 1070 + async fn render_results_to_string(results: Vec<CheckResult>) -> String { 1071 + // Mirror the pipeline's header population (src/commands/test/labeler/ 1072 + // pipeline.rs:212-216) so snapshot output matches what a real CLI run 1073 + // would produce. The DID / PDS / labeler values match the 1074 + // `make_identity_facts` defaults. 1075 + let mut report = LabelerReport::new(ReportHeader { 1076 + target: "test-labeler".to_string(), 1077 + resolved_did: Some("did:plc:aaa22222222222222222bbbbbb".to_string()), 1078 + pds_endpoint: Some("https://pds.example.com/".to_string()), 1079 + labeler_endpoint: Some("https://labeler.example.com/".to_string()), 1080 + }); 1081 + for r in results { 1082 + report.record(r); 1083 + } 1084 + report.finish(); 1085 + let mut buf = Vec::new(); 1086 + report 1087 + .render(&mut buf, &RenderConfig { no_color: true }) 1088 + .expect("render"); 1089 + String::from_utf8(buf).expect("utf-8") 1090 + } 1091 + 1092 + #[tokio::test] 1093 + async fn snapshot_contract_present_no_commit() { 1094 + let facts = make_identity_facts( 1095 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 1096 + Some(vec!["account".to_string()]), 1097 + ); 1098 + let tee = FakeCreateReportTee::new(); 1099 + let opts = create_report::CreateReportRunOptions { 1100 + commit_report: false, 1101 + force_self_mint: false, 1102 + self_mint_curve: SelfMintCurve::Es256k, 1103 + report_subject_override: None, 1104 + self_mint_signer: None, 1105 + pds_credentials: None, 1106 + }; 1107 + let results = run_report_stage(&facts, &tee, opts).await; 1108 + insta::assert_snapshot!( 1109 + "report_contract_present_no_commit", 1110 + render_results_to_string(results).await 1111 + ); 1112 + } 1113 + 1114 + // And three more: contract_present_with_commit, contract_missing_no_commit, 1115 + // contract_missing_with_commit. Each pins its output. 1116 + ``` 1117 + 1118 + **Verification:** 1119 + Run: `cargo test --test labeler_report` 1120 + Expected: all 8 tests pass (4 assertions + 4 snapshot pins). On first run, `cargo insta review` prompts to accept the snapshots. 1121 + 1122 + Run: `cargo insta review` 1123 + Expected: review and accept the four new snapshot files. 1124 + 1125 + Run: `cargo test` 1126 + Expected: existing tests unaffected. 1127 + 1128 + **Commit:** `feat(pipeline): integrate report stage with AC1 coverage` 1129 + <!-- END_TASK_7 --> 1130 + <!-- END_SUBCOMPONENT_C --> 1131 + 1132 + <!-- START_TASK_8 --> 1133 + ### Task 8: Phase 4 integration check 1134 + 1135 + **Files:** None changed. 1136 + 1137 + **Implementation:** Gate. 1138 + 1139 + **Verification:** 1140 + Run: `cargo build` 1141 + Expected: clean. 1142 + 1143 + Run: `cargo test` 1144 + Expected: all Phase 1-3 tests pass; Phase 4 adds 8+ new tests + 4 snapshots. 1145 + 1146 + Run: `cargo clippy --all-targets -- -D warnings` 1147 + Expected: no warnings. 1148 + 1149 + Run: `cargo insta pending-snapshots` 1150 + Expected: no pending. All Phase 4 snapshots are accepted. 1151 + 1152 + **Commit:** No new commit unless fixes were needed. 1153 + <!-- END_TASK_8 --> 1154 + 1155 + --- 1156 + 1157 + ## Phase 4 complete when 1158 + 1159 + - `Stage::Report` exists with a `label()` entry; stage ordering places Report last. 1160 + - `create_report::Check` enum covers 10 IDs in the canonical order; `ORDER` array + builder helpers compile and work. 1161 + - `ContractMissing` diagnostic carries `code = "labeler::report::contract_missing"`. 1162 + - `LabelerCmd` exposes `--commit-report`, `--self-mint-curve`, `--force-self-mint`, `--report-subject-did`. 1163 + - `LabelerOptions` has the new fields; pipeline wires the report stage after crypto. 1164 + - `create_report::run` emits exactly 10 rows in canonical order for every run (AC7.1 + AC7.2). 1165 + - The four contract × commit combinations each pin an insta snapshot under `tests/snapshots/labeler_report__*`. 1166 + - **Acceptance criteria satisfied:** AC1.1, AC1.2, AC1.3, AC1.4, AC7.1, AC7.2.
+584
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_05.md
··· 1 + # Labeler report stage — Phase 5: No-JWT negative checks 2 + 3 + **Goal:** Turn the first two Phase-4 stub rows (`report::unauthenticated_rejected` and `report::malformed_bearer_rejected`) into real checks. Neither constructs a JWT — they simply POST to the labeler with no `Authorization` header or with an obviously invalid bearer, and verify the labeler rejects with 401 + a non-empty atproto error envelope. 4 + 5 + **Architecture:** Two small helpers inside `create_report.rs`: `parse_xrpc_error_envelope(body) -> Option<XrpcErrorEnvelope>` (loosely permissive, returns `None` rather than failing), and `assert_401_with_envelope(response) -> CheckOutcome` that classifies a `RawCreateReportResponse` as Pass, SpecViolation, or Advisory. The two checks call this helper with different request arguments. 6 + 7 + **Tech Stack:** `serde_json` (existing), the Phase 3 `CreateReportTee` seam, the Phase 4 `Check` enum. 8 + 9 + **Scope:** Phase 5 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (relies on Phase 3/4 artifacts). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Phase 3 `CreateReportTee::post_create_report(auth: Option<&str>, body: &serde_json::Value)` supports `auth: None` (omits Authorization header) and `auth: Some("not-a-jwt")` (sets the raw Bearer string). 15 + - ✓ Phase 4 stage structure: `create_report::run` has 9 "not yet implemented (Phase N)" Skipped stubs after the `contract_published` row; Phase 5 replaces the first two. 16 + - ✓ Phase 4 Check builder at `Check::UnauthenticatedRejected` / `MalformedBearerRejected` supplies `pass` / `spec_violation` / `advisory` shapes with the right summaries. 17 + - ✓ Diagnostic pattern: mirror `HttpDecodeFailure` at `src/commands/test/labeler/http.rs:151-163` — `#[derive(Debug, Error, Diagnostic)]`, `#[source_code]` NamedSource, `#[label]` SourceSpan, `code = "labeler::report::..."`. 18 + - ✓ The stage runs even for non-committing invocations — these two checks don't POST a *committing* report body (no real subject, no sentinel), just an empty/minimal body to exercise the auth path. A POST of `{}` gets rejected by auth before the labeler examines the shape; no pollution risk even on production labelers. 19 + - ⚠ **Pollution consideration:** a malformed-bearer POST to a real labeler is still a POST, though the labeler rejects it at the auth layer before any moderation record is created. These two checks are always safe to POST against a non-committing labeler; no `--commit-report` gate needed. 20 + 21 + **External dependency research findings:** 22 + - ✓ atproto XRPC error envelope per <https://atproto.com/specs/xrpc>: `{"error": "<pascal-case-name>", "message": "<human-readable>"}`. The `error` field is effectively required by the spec for error responses but in practice some implementations omit it (loose conformance). Per design "Error envelope assertion is deliberately loose" — we assert status 401 *alone*, with an Advisory if the envelope is missing or has an empty `error` field. 23 + - ✓ Ozone `@atproto/xrpc-server` emits `AuthenticationRequired` for missing bearer, `BadJwt` or similar for malformed bearer. These are informative but not required — our assertion is on status + envelope presence, not exact string. 24 + 25 + --- 26 + 27 + ## Acceptance criteria coverage 28 + 29 + This phase implements and tests: 30 + 31 + ### labeler-report-stage.AC2: No-JWT negative checks 32 + - **labeler-report-stage.AC2.1 Success:** `unauthenticated_rejected` emits `Pass` when labeler returns 401 with non-empty atproto error envelope for unauthenticated POST. 33 + - **labeler-report-stage.AC2.2 Failure:** `unauthenticated_rejected` emits `SpecViolation` (diagnostic `labeler::report::unauthenticated_accepted`) when labeler returns 2xx. 34 + - **labeler-report-stage.AC2.3 Success:** `malformed_bearer_rejected` emits `Pass` when labeler returns 401 for garbage Bearer. 35 + - **labeler-report-stage.AC2.4 Failure:** `malformed_bearer_rejected` emits `SpecViolation` (diagnostic `labeler::report::malformed_bearer_accepted`) when labeler accepts garbage Bearer. 36 + - **labeler-report-stage.AC2.5 Edge:** 401 with empty or missing `error` envelope field still treated as `Pass` on status alone; summary text notes the non-conformant response shape. 37 + 38 + --- 39 + 40 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 41 + <!-- START_TASK_1 --> 42 + ### Task 1: XRPC error envelope parser and 401 assertion helper 43 + 44 + **Verifies:** Supports AC2.1–AC2.5. 45 + 46 + **Files:** 47 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 48 + 49 + **Implementation:** 50 + 51 + ```rust 52 + use miette::{NamedSource, SourceSpan}; 53 + 54 + use crate::common::diagnostics::{pretty_json_for_display, span_for_quoted_literal}; 55 + 56 + /// A loosely-parsed atproto XRPC error envelope. Missing fields are 57 + /// rendered as `None` rather than failing the parse — the "loose 58 + /// assertion" philosophy in the design (see "Error envelope assertion 59 + /// is deliberately loose"). 60 + #[derive(Debug, Clone)] 61 + pub struct XrpcErrorEnvelope { 62 + /// The `error` field (PascalCase error name). `None` if absent or 63 + /// not a string. 64 + pub error: Option<String>, 65 + /// The `message` field. `None` if absent or not a string. 66 + pub message: Option<String>, 67 + } 68 + 69 + impl XrpcErrorEnvelope { 70 + /// Try to parse an atproto error envelope from the response body. 71 + /// Returns `None` only when the body is not valid JSON at all. 72 + /// Otherwise returns an envelope with whatever fields we could find. 73 + pub fn parse(body: &[u8]) -> Option<Self> { 74 + let v: serde_json::Value = serde_json::from_slice(body).ok()?; 75 + let obj = v.as_object()?; 76 + Some(Self { 77 + error: obj.get("error").and_then(|x| x.as_str()).map(String::from), 78 + message: obj.get("message").and_then(|x| x.as_str()).map(String::from), 79 + }) 80 + } 81 + 82 + /// `true` when the envelope has a non-empty `error` string. 83 + pub fn has_nonempty_error(&self) -> bool { 84 + self.error.as_deref().map(|s| !s.is_empty()).unwrap_or(false) 85 + } 86 + } 87 + 88 + /// Outcome of the 401-envelope assertion. 89 + pub enum RejectionShape { 90 + /// 401 with a non-empty `error` field — full-conformant. 91 + Conformant { 92 + /// The envelope for diagnostic rendering. 93 + envelope: XrpcErrorEnvelope, 94 + }, 95 + /// 401 but the envelope is missing or has an empty `error` field. 96 + /// Treated as Pass on status alone per AC2.5 but the summary 97 + /// notes the non-conformant response shape. 98 + ConformantStatusNonConformantShape, 99 + /// Any non-401 status. 100 + WrongStatus { 101 + /// The observed status code. 102 + status: reqwest::StatusCode, 103 + }, 104 + } 105 + 106 + impl RejectionShape { 107 + /// Classify a createReport response against the 401-envelope rubric. 108 + pub fn classify(resp: &RawCreateReportResponse) -> Self { 109 + if resp.status != reqwest::StatusCode::UNAUTHORIZED { 110 + return Self::WrongStatus { status: resp.status }; 111 + } 112 + match XrpcErrorEnvelope::parse(&resp.raw_body) { 113 + Some(env) if env.has_nonempty_error() => Self::Conformant { envelope: env }, 114 + _ => Self::ConformantStatusNonConformantShape, 115 + } 116 + } 117 + } 118 + 119 + /// Diagnostic surfaced when a labeler accepts (status 2xx) a request 120 + /// that should have been rejected. Carries the response body so users 121 + /// can see what the labeler returned instead. 122 + #[derive(Debug, Error, Diagnostic)] 123 + #[error("Labeler accepted a request that should have been rejected: {status_line}")] 124 + pub struct UnexpectedAcceptance { 125 + /// Status code line, e.g., "200 OK". 126 + pub status_line: String, 127 + /// Response body as a named source for miette rendering. 128 + #[source_code] 129 + pub body: NamedSource<Arc<[u8]>>, 130 + /// Span of the response body (empty span; the whole body is the context). 131 + #[label("labeler accepted here")] 132 + pub span: Option<SourceSpan>, 133 + } 134 + 135 + /// Construct an `UnexpectedAcceptance` diagnostic with the given stable 136 + /// miette code (so each check gets its own code per the DoD). 137 + pub fn unexpected_acceptance( 138 + diagnostic_code: &'static str, 139 + resp: &RawCreateReportResponse, 140 + ) -> Box<dyn miette::Diagnostic + Send + Sync> { 141 + let pretty_body = pretty_json_for_display(&resp.raw_body); 142 + Box::new(UnexpectedAcceptanceWithCode { 143 + code: diagnostic_code, 144 + status_line: format!("{} {}", resp.status.as_u16(), resp.status.canonical_reason().unwrap_or("")), 145 + source_code: NamedSource::new(resp.source_url.clone(), Arc::from(pretty_body)), 146 + }) 147 + } 148 + 149 + /// Internal shim that lets us parameterize the miette `code` per-check 150 + /// without needing one struct per check. `code` is assigned dynamically 151 + /// via `with_code`. 152 + /// 153 + /// NOTE: miette's Diagnostic trait derives use a static code in the 154 + /// `#[diagnostic(code = ...)]` attribute. To let each check emit its own 155 + /// stable code, define two separate tiny diagnostic structs — one per 156 + /// check ID — rather than a single dynamic one. See Task 2 below. 157 + #[derive(Debug, Error)] 158 + #[error("{status_line}")] 159 + struct UnexpectedAcceptanceWithCode { 160 + code: &'static str, 161 + status_line: String, 162 + source_code: NamedSource<Arc<[u8]>>, 163 + } 164 + 165 + // This shim is replaced by two per-check diagnostic structs in Task 2; 166 + // the Box<dyn Diagnostic> pattern above is retained as a helper signature 167 + // but the concrete type is supplied by the caller. 168 + ``` 169 + 170 + **Notes for the implementor:** 171 + 172 + The `UnexpectedAcceptanceWithCode` shim above is a red herring — miette `#[diagnostic(code = ...)]` is static. The right pattern is one concrete diagnostic struct per stable code. Task 2 defines two of them (one for `unauthenticated_accepted`, one for `malformed_bearer_accepted`). Delete the shim from the above block during implementation; keep `XrpcErrorEnvelope`, `RejectionShape`, and omit `UnexpectedAcceptance` + `unexpected_acceptance`. 173 + 174 + **Testing:** 175 + 176 + Unit tests in `create_report.rs`: 177 + 178 + ```rust 179 + #[cfg(test)] 180 + mod envelope_tests { 181 + use super::*; 182 + 183 + #[test] 184 + fn parse_well_formed_envelope() { 185 + let body = br#"{"error":"BadJwt","message":"invalid token"}"#; 186 + let env = XrpcErrorEnvelope::parse(body).expect("parses"); 187 + assert_eq!(env.error.as_deref(), Some("BadJwt")); 188 + assert_eq!(env.message.as_deref(), Some("invalid token")); 189 + assert!(env.has_nonempty_error()); 190 + } 191 + 192 + #[test] 193 + fn parse_empty_envelope() { 194 + let body = br#"{}"#; 195 + let env = XrpcErrorEnvelope::parse(body).expect("parses empty object"); 196 + assert_eq!(env.error, None); 197 + assert!(!env.has_nonempty_error()); 198 + } 199 + 200 + #[test] 201 + fn parse_non_json_returns_none() { 202 + assert!(XrpcErrorEnvelope::parse(b"<html>").is_none()); 203 + } 204 + 205 + #[test] 206 + fn parse_empty_error_field_treated_as_missing() { 207 + let body = br#"{"error":""}"#; 208 + let env = XrpcErrorEnvelope::parse(body).unwrap(); 209 + assert!(!env.has_nonempty_error()); 210 + } 211 + } 212 + ``` 213 + 214 + **Verification:** 215 + Run: `cargo test --lib commands::test::labeler::create_report::envelope_tests` 216 + Expected: all pass. 217 + 218 + **Commit:** `feat(create_report): XRPC error envelope parser` 219 + <!-- END_TASK_1 --> 220 + 221 + <!-- START_TASK_2 --> 222 + ### Task 2: Per-check diagnostics for `unauthenticated_accepted` and `malformed_bearer_accepted` 223 + 224 + **Verifies:** AC2.2, AC2.4. 225 + 226 + **Files:** 227 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 228 + 229 + **Implementation:** 230 + 231 + ```rust 232 + /// Diagnostic for AC2.2: labeler accepted an unauthenticated createReport POST. 233 + #[derive(Debug, Error, Diagnostic)] 234 + #[error("Labeler accepted unauthenticated createReport (status {status})")] 235 + #[diagnostic( 236 + code = "labeler::report::unauthenticated_accepted", 237 + help = "A labeler must reject createReport with 401 when no Authorization header is supplied." 238 + )] 239 + pub struct UnauthenticatedAccepted { 240 + /// Observed status code, e.g., 200. 241 + pub status: u16, 242 + /// Response body for context. 243 + #[source_code] 244 + pub source_code: NamedSource<Arc<[u8]>>, 245 + /// Pseudo-span over the whole body. 246 + #[label("accepted here")] 247 + pub span: Option<SourceSpan>, 248 + } 249 + 250 + /// Diagnostic for AC2.4: labeler accepted a malformed bearer token. 251 + #[derive(Debug, Error, Diagnostic)] 252 + #[error("Labeler accepted malformed Bearer token (status {status})")] 253 + #[diagnostic( 254 + code = "labeler::report::malformed_bearer_accepted", 255 + help = "A labeler must reject createReport with 401 when the Authorization header carries a non-JWT string." 256 + )] 257 + pub struct MalformedBearerAccepted { 258 + /// Observed status code, e.g., 200. 259 + pub status: u16, 260 + /// Response body for context. 261 + #[source_code] 262 + pub source_code: NamedSource<Arc<[u8]>>, 263 + /// Pseudo-span over the whole body. 264 + #[label("accepted here")] 265 + pub span: Option<SourceSpan>, 266 + } 267 + 268 + /// Construct a `NamedSource` from the pretty-printed response body. 269 + /// Used for every `accepted_*` diagnostic here. 270 + fn body_as_named_source(resp: &RawCreateReportResponse) -> NamedSource<Arc<[u8]>> { 271 + let pretty = pretty_json_for_display(&resp.raw_body); 272 + NamedSource::new(resp.source_url.clone(), Arc::from(pretty)) 273 + } 274 + ``` 275 + 276 + **Notes for the implementor:** 277 + - The `help` field on `#[diagnostic]` is rendered by miette after the error message. Keep them to one informative sentence. 278 + - Both diagnostic structs have nearly identical shape — if a refactor helper (e.g., a generic `RejectionBodyDiagnostic<const CODE: &str>`) makes sense later, defer it. Copy-paste is fine for v1. 279 + 280 + **Testing:** Exercised via the integration tests in Task 3. 281 + 282 + **Verification:** `cargo build` clean. 283 + 284 + **Commit:** `feat(create_report): diagnostics for AC2.2/AC2.4 acceptance` 285 + <!-- END_TASK_2 --> 286 + <!-- END_SUBCOMPONENT_A --> 287 + 288 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 289 + <!-- START_TASK_3 --> 290 + ### Task 3: Wire the `unauthenticated_rejected` check 291 + 292 + **Verifies:** AC2.1, AC2.2, AC2.5. 293 + 294 + **Files:** 295 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase-4 `Check::UnauthenticatedRejected` stub emit in `run(...)` with real logic. 296 + 297 + **Implementation:** 298 + 299 + Replace this block in `run()`: 300 + 301 + ```rust 302 + for c in [Check::UnauthenticatedRejected, Check::MalformedBearerRejected] { 303 + results.push(c.skip("not yet implemented (Phase 5)")); 304 + } 305 + ``` 306 + 307 + with a full implementation for both checks: 308 + 309 + ```rust 310 + // Minimal body for negative checks. The labeler should reject at auth 311 + // before examining body shape; we nonetheless supply a plausible body so 312 + // a labeler that performs body validation first doesn't return 400 313 + // instead of 401, which would make the test ambiguous. 314 + let negative_body = build_minimal_report_body(id_facts); 315 + 316 + // AC2.1/AC2.2/AC2.5 — unauthenticated: 317 + match report_tee.post_create_report(None, &negative_body).await { 318 + Ok(resp) => match RejectionShape::classify(&resp) { 319 + RejectionShape::Conformant { .. } => { 320 + results.push(Check::UnauthenticatedRejected.pass()); 321 + } 322 + RejectionShape::ConformantStatusNonConformantShape => { 323 + results.push(CheckResult { 324 + summary: Cow::Borrowed( 325 + "Unauthenticated report rejected (status 401, non-conformant envelope)", 326 + ), 327 + ..Check::UnauthenticatedRejected.pass() 328 + }); 329 + } 330 + RejectionShape::WrongStatus { status } => { 331 + let status_u16 = status.as_u16(); 332 + let diag = Box::new(UnauthenticatedAccepted { 333 + status: status_u16, 334 + source_code: body_as_named_source(&resp), 335 + span: None, 336 + }); 337 + results.push(Check::UnauthenticatedRejected.spec_violation(Some(diag))); 338 + } 339 + }, 340 + Err(CreateReportStageError::Transport { message, .. }) => { 341 + results.push(Check::UnauthenticatedRejected.network_error(message)); 342 + } 343 + } 344 + 345 + // AC2.3/AC2.4 — malformed bearer: 346 + match report_tee 347 + .post_create_report(Some("not-a-jwt"), &negative_body) 348 + .await 349 + { 350 + Ok(resp) => match RejectionShape::classify(&resp) { 351 + RejectionShape::Conformant { .. } => { 352 + results.push(Check::MalformedBearerRejected.pass()); 353 + } 354 + RejectionShape::ConformantStatusNonConformantShape => { 355 + results.push(CheckResult { 356 + summary: Cow::Borrowed( 357 + "Malformed bearer rejected (status 401, non-conformant envelope)", 358 + ), 359 + ..Check::MalformedBearerRejected.pass() 360 + }); 361 + } 362 + RejectionShape::WrongStatus { status } => { 363 + let status_u16 = status.as_u16(); 364 + let diag = Box::new(MalformedBearerAccepted { 365 + status: status_u16, 366 + source_code: body_as_named_source(&resp), 367 + span: None, 368 + }); 369 + results.push(Check::MalformedBearerRejected.spec_violation(Some(diag))); 370 + } 371 + }, 372 + Err(CreateReportStageError::Transport { message, .. }) => { 373 + results.push(Check::MalformedBearerRejected.network_error(message)); 374 + } 375 + } 376 + 377 + // NOTE: the following helper lives at module scope in 378 + // src/commands/test/labeler/create_report.rs (NOT nested inside `run()`), 379 + // so Phases 6 and 7 can invoke it from their own branches of `run()`. 380 + // It reads the Phase 4 Task 0 extended `IdentityFacts` fields 381 + // (`id_facts.reason_types` / `.subject_types` / `.did`) directly, not 382 + // through `labeler_policies`. 383 + 384 + /// Build a minimal, plausible createReport body for negative tests. 385 + /// 386 + /// Chooses the lex-first advertised `reasonType` and the first advertised 387 + /// `subjectType`, pointing at a safe subject (the labeler's own DID — 388 + /// labelers never take action on themselves). The body is well-formed so 389 + /// any validation short-circuit returns auth-layer rejection rather than 390 + /// shape-layer rejection. 391 + pub(crate) fn build_minimal_report_body(facts: &IdentityFacts) -> serde_json::Value { 392 + // Unwrap the contract — run() has already guaranteed it's present 393 + // and non-empty before this function is reachable. 394 + let reason_type = facts 395 + .reason_types 396 + .as_ref() 397 + .and_then(|v| v.first()) 398 + .cloned() 399 + .unwrap_or_else(|| "com.atproto.moderation.defs#reasonOther".to_string()); 400 + 401 + let subject_types: &[String] = facts 402 + .subject_types 403 + .as_ref() 404 + .map(|v| v.as_slice()) 405 + .unwrap_or(&[]); 406 + let subject = if subject_types.iter().any(|t| t == "account") { 407 + serde_json::json!({ 408 + "$type": "com.atproto.admin.defs#repoRef", 409 + "did": facts.did.0, 410 + }) 411 + } else if subject_types.iter().any(|t| t == "record") { 412 + serde_json::json!({ 413 + "$type": "com.atproto.repo.strongRef", 414 + // Ghost AT-URI targeting the labeler's own DID. Negative-path 415 + // only; positive paths use real pollution-avoidance logic in 416 + // Phase 7. 417 + "uri": format!("at://{}/app.bsky.feed.post/not-real", facts.did.0), 418 + "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 419 + }) 420 + } else { 421 + // Fallback: account shape against the labeler itself. 422 + serde_json::json!({ 423 + "$type": "com.atproto.admin.defs#repoRef", 424 + "did": facts.did.0, 425 + }) 426 + }; 427 + 428 + serde_json::json!({ 429 + "reasonType": reason_type, 430 + "subject": subject, 431 + }) 432 + } 433 + ``` 434 + 435 + **Notes for the implementor:** 436 + - Use `Cow::Borrowed` for the AC2.5 non-conformant-shape summary strings (they're `'static`). The `CheckResult { summary: ..., ..Check::X.pass() }` struct-update pattern copies everything else from the builder. 437 + - `IdentityFacts::did` is the labeler's DID. Verify the exact field name by grepping `src/commands/test/labeler/identity.rs` for `pub struct IdentityFacts` — the design assumes `facts.did` but codebase may use `facts.labeler_did` or similar. If different, adjust `build_minimal_report_body` accordingly. 438 + - CID placeholder `bafyrei...` is a syntactically valid but unresolvable CID. It's only used in negative checks where the labeler rejects at auth; the CID is never looked up. 439 + 440 + **Testing:** 441 + 442 + Extend `tests/labeler_report.rs` with AC2-specific tests. Mirror the Phase-4 structure; add new test cases: 443 + 444 + ```rust 445 + #[tokio::test] 446 + async fn ac2_1_unauthenticated_401_with_envelope_passes() { 447 + let facts = make_identity_facts( 448 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 449 + Some(vec!["account".to_string()]), 450 + ); 451 + let tee = FakeCreateReportTee::new(); 452 + tee.enqueue(FakeCreateReportResponse::unauthorized( 453 + "AuthenticationRequired", 454 + "jwt required", 455 + )); 456 + tee.enqueue(FakeCreateReportResponse::unauthorized( 457 + "BadJwt", 458 + "invalid bearer", 459 + )); 460 + let results = run_report_stage(&facts, &tee, default_opts()).await; 461 + 462 + assert_eq!(results[1].id, "report::unauthenticated_rejected"); 463 + assert_eq!(results[1].status, CheckStatus::Pass); 464 + assert_eq!(results[2].id, "report::malformed_bearer_rejected"); 465 + assert_eq!(results[2].status, CheckStatus::Pass); 466 + } 467 + 468 + #[tokio::test] 469 + async fn ac2_2_unauthenticated_200_is_spec_violation() { 470 + let facts = make_identity_facts( 471 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 472 + Some(vec!["account".to_string()]), 473 + ); 474 + let tee = FakeCreateReportTee::new(); 475 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 476 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwt", "x")); 477 + let results = run_report_stage(&facts, &tee, default_opts()).await; 478 + 479 + assert_eq!(results[1].status, CheckStatus::SpecViolation); 480 + let diag = results[1].diagnostic.as_ref().expect("diagnostic present"); 481 + assert_eq!( 482 + diag.code().map(|c| c.to_string()), 483 + Some("labeler::report::unauthenticated_accepted".to_string()), 484 + ); 485 + } 486 + 487 + #[tokio::test] 488 + async fn ac2_4_malformed_bearer_200_is_spec_violation() { 489 + let facts = make_identity_facts( 490 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 491 + Some(vec!["account".to_string()]), 492 + ); 493 + let tee = FakeCreateReportTee::new(); 494 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 495 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 496 + let results = run_report_stage(&facts, &tee, default_opts()).await; 497 + 498 + assert_eq!(results[2].status, CheckStatus::SpecViolation); 499 + let diag = results[2].diagnostic.as_ref().expect("diagnostic present"); 500 + assert_eq!( 501 + diag.code().map(|c| c.to_string()), 502 + Some("labeler::report::malformed_bearer_accepted".to_string()), 503 + ); 504 + } 505 + 506 + #[tokio::test] 507 + async fn ac2_5_401_without_envelope_still_passes() { 508 + let facts = make_identity_facts( 509 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 510 + Some(vec!["account".to_string()]), 511 + ); 512 + let tee = FakeCreateReportTee::new(); 513 + // 401 with empty body — non-conformant envelope, but status still Pass per AC2.5. 514 + tee.enqueue(FakeCreateReportResponse::Response { 515 + status: 401, 516 + content_type: Some("application/json".to_string()), 517 + body: b"{}".to_vec(), 518 + }); 519 + tee.enqueue(FakeCreateReportResponse::Response { 520 + status: 401, 521 + content_type: None, 522 + body: b"<html>".to_vec(), 523 + }); 524 + let results = run_report_stage(&facts, &tee, default_opts()).await; 525 + 526 + assert_eq!(results[1].status, CheckStatus::Pass); 527 + assert!(results[1].summary.contains("non-conformant envelope")); 528 + assert_eq!(results[2].status, CheckStatus::Pass); 529 + assert!(results[2].summary.contains("non-conformant envelope")); 530 + } 531 + 532 + fn default_opts() -> create_report::CreateReportRunOptions<'static> { 533 + create_report::CreateReportRunOptions { 534 + commit_report: false, 535 + force_self_mint: false, 536 + self_mint_curve: SelfMintCurve::Es256k, 537 + report_subject_override: None, 538 + self_mint_signer: None, 539 + pds_credentials: None, 540 + } 541 + } 542 + ``` 543 + 544 + **Notes for the implementor:** 545 + - `default_opts()` is shared by AC2 and later ACs; extract it to the top of `tests/labeler_report.rs` to avoid duplication. 546 + - Add snapshot fixtures under `tests/fixtures/labeler/report/` as needed for each case — but AC2 doesn't need raw fixture JSON; the fakes hand-encode envelope bytes in test bodies, which is fine. 547 + 548 + **Verification:** 549 + Run: `cargo test --test labeler_report ac2_` 550 + Expected: all four AC2 tests pass. 551 + 552 + Run: `cargo insta pending-snapshots` 553 + Expected: none pending. 554 + 555 + **Commit:** `feat(create_report): wire unauthenticated_rejected and malformed_bearer_rejected` 556 + <!-- END_TASK_3 --> 557 + 558 + <!-- START_TASK_4 --> 559 + ### Task 4: Phase 5 integration check 560 + 561 + **Files:** None changed. 562 + 563 + **Implementation:** Gate. 564 + 565 + **Verification:** 566 + Run: `cargo test` 567 + Expected: all Phase 1-4 tests pass; Phase 5 adds 4+ new passing tests (AC2.1-AC2.5). 568 + 569 + Run: `cargo clippy --all-targets -- -D warnings` 570 + Expected: no warnings. 571 + 572 + **Commit:** No new commit unless fixes were needed. 573 + <!-- END_TASK_4 --> 574 + 575 + --- 576 + 577 + ## Phase 5 complete when 578 + 579 + - `XrpcErrorEnvelope::parse` handles well-formed, partial, and non-JSON bodies. 580 + - `RejectionShape::classify` distinguishes Conformant / ConformantStatusNonConformantShape / WrongStatus correctly. 581 + - `UnauthenticatedAccepted` and `MalformedBearerAccepted` diagnostics compile with the correct stable codes. 582 + - `report::unauthenticated_rejected` and `report::malformed_bearer_rejected` rows are real (no longer `Skipped` stubs). 583 + - Integration tests cover AC2.1, AC2.2, AC2.3, AC2.4, and AC2.5. 584 + - **Acceptance criteria satisfied:** AC2.1, AC2.2, AC2.3, AC2.4, AC2.5.
+607
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_06.md
··· 1 + # Labeler report stage — Phase 6: Self-mint negative checks 2 + 3 + **Goal:** Implement the four checks that require a valid-signature JWT with a specific claim or body mutation: `report::wrong_aud_rejected`, `report::wrong_lxm_rejected`, `report::expired_rejected`, and `report::rejected_shape_returns_400`. Gate all four on `self_mint_viable` (the Phase 1 heuristic + `--force-self-mint` override). 4 + 5 + **Architecture:** The stage, when `self_mint_viable` is true and a `SelfMintSigner` is present, lazily constructs JWTs from a valid-claims template with one field mutated per check. Each POST goes through the Phase 3 `CreateReportTee` with the mutated JWT in the `Authorization` header. Response classification reuses `RejectionShape` from Phase 5; `rejected_shape_returns_400` uses a different rubric (400 + `error == "InvalidRequest"` → Pass; 401/500 → Advisory). 6 + 7 + **Tech Stack:** Phase 1 `AnySigningKey`+ `jwt` module, Phase 2 `SelfMintSigner`, Phase 5 response classification. 8 + 9 + **Scope:** Phase 6 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (relies on Phase 1-5 artifacts). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Phase 1 `JwtClaims` struct has mutable `iss`, `aud`, `exp`, `iat`, `lxm`, `jti` — sufficient to express all three claim mutations. 15 + - ✓ Phase 2 `SelfMintSigner::valid_claims_template(labeler_did, lxm, now, exp_after)` produces a fresh template whose `jti` is already a random nonce — mutations apply on top of this. 16 + - ✓ Phase 5 `RejectionShape::classify` returns `Conformant | ConformantStatusNonConformantShape | WrongStatus`. For AC3 it's the exact same rubric as AC2 — 401 + non-empty envelope is Pass, non-401 is SpecViolation. 17 + - ✓ Phase 4 `Check::RejectedShapeReturns400` builder has a distinct `advisory` helper for AC3.6 (400 with wrong status shape). 18 + - ✓ `Check::skip(reason)` handles the `self_mint_viable == false` case for all four. 19 + - ✓ `LabelerCmd::run` must, in Phase 6 or earlier, construct a `SelfMintSigner` when `self_mint_viable` AND (any self-mint check might run). Phase 4 left `self_mint_signer: None` in production; Phase 6 populates it. 20 + - ✓ `SystemTime::now()` + `UNIX_EPOCH` is available (no chrono dep); Phase 1's sentinel builder already uses it. Convert to i64 seconds for JWT claims. 21 + 22 + **External dependency research findings:** 23 + - ✓ Ozone JWT claim-validation order per `packages/xrpc-server/src/auth.ts` (inferred): signature → aud → exp → lxm → iat. Our tests don't depend on order; each check mutates exactly one field so the rejection is unambiguous. 24 + - ✓ "wrong aud" constant: `did:plc:0000000000000000000000000` is the atproto convention for a nonexistent-but-syntactically-valid did:plc. Using a bogus but well-formed DID avoids triggering "malformed aud" error paths. 25 + - ✓ "wrong lxm" substitute: `com.atproto.server.getSession` — an arbitrary but valid atproto NSID that is NOT `createReport`. Easy to read in diagnostic output. 26 + 27 + --- 28 + 29 + ## Acceptance criteria coverage 30 + 31 + This phase implements and tests: 32 + 33 + ### labeler-report-stage.AC3: Self-mint negative checks 34 + - **labeler-report-stage.AC3.1 Success:** `wrong_aud_rejected` emits `Pass` when labeler returns 401 for fresh-signed JWT with mutated `aud`. 35 + - **labeler-report-stage.AC3.2 Failure:** emits `SpecViolation` (diagnostic `labeler::report::wrong_aud_accepted`) when labeler returns 2xx for wrong aud. 36 + - **labeler-report-stage.AC3.3 Success/Failure pair:** `wrong_lxm_rejected` behaves analogously for mutated `lxm`; diagnostic `labeler::report::wrong_lxm_accepted`. 37 + - **labeler-report-stage.AC3.4 Success/Failure pair:** `expired_rejected` behaves analogously for past-expiry JWT; diagnostic `labeler::report::expired_accepted`. 38 + - **labeler-report-stage.AC3.5 Success:** `rejected_shape_returns_400` emits `Pass` when labeler returns 400 `InvalidRequest` for unadvertised `reasonType`. 39 + - **labeler-report-stage.AC3.6 Advisory:** `rejected_shape_returns_400` emits `Advisory` (diagnostic `labeler::report::shape_not_400`) when labeler returns 401 or 500 (rejection for wrong reason). 40 + - **labeler-report-stage.AC3.7 Skip:** Every self-mint negative check emits `Skipped` with reason naming the `--force-self-mint` override when heuristic classifies labeler non-local. 41 + - **labeler-report-stage.AC3.8 Override:** `--force-self-mint` bypasses the heuristic; all self-mint checks run regardless of hostname. 42 + 43 + --- 44 + 45 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 46 + <!-- START_TASK_1 --> 47 + ### Task 1: Per-check diagnostics for the self-mint negative family 48 + 49 + **Verifies:** AC3.2, AC3.3-failure, AC3.4-failure, AC3.6. 50 + 51 + **Files:** 52 + - Modify: `src/commands/test/labeler/create_report.rs` — append four diagnostic structs. 53 + 54 + **Implementation:** 55 + 56 + ```rust 57 + #[derive(Debug, Error, Diagnostic)] 58 + #[error("Labeler accepted JWT with wrong `aud` (status {status})")] 59 + #[diagnostic( 60 + code = "labeler::report::wrong_aud_accepted", 61 + help = "A labeler must reject JWTs whose `aud` claim does not match its own DID." 62 + )] 63 + pub struct WrongAudAccepted { 64 + pub status: u16, 65 + #[source_code] 66 + pub source_code: NamedSource<Arc<[u8]>>, 67 + #[label("accepted here")] 68 + pub span: Option<SourceSpan>, 69 + } 70 + 71 + #[derive(Debug, Error, Diagnostic)] 72 + #[error("Labeler accepted JWT with wrong `lxm` (status {status})")] 73 + #[diagnostic( 74 + code = "labeler::report::wrong_lxm_accepted", 75 + help = "A labeler must reject JWTs whose `lxm` claim does not match the invoked Lexicon method." 76 + )] 77 + pub struct WrongLxmAccepted { 78 + pub status: u16, 79 + #[source_code] 80 + pub source_code: NamedSource<Arc<[u8]>>, 81 + #[label("accepted here")] 82 + pub span: Option<SourceSpan>, 83 + } 84 + 85 + #[derive(Debug, Error, Diagnostic)] 86 + #[error("Labeler accepted expired JWT (status {status})")] 87 + #[diagnostic( 88 + code = "labeler::report::expired_accepted", 89 + help = "A labeler must reject JWTs whose `exp` claim is in the past." 90 + )] 91 + pub struct ExpiredAccepted { 92 + pub status: u16, 93 + #[source_code] 94 + pub source_code: NamedSource<Arc<[u8]>>, 95 + #[label("accepted here")] 96 + pub span: Option<SourceSpan>, 97 + } 98 + 99 + #[derive(Debug, Error, Diagnostic)] 100 + #[error("Unadvertised `reasonType` was rejected with status {status}, expected 400 InvalidRequest")] 101 + #[diagnostic( 102 + code = "labeler::report::shape_not_400", 103 + help = "A labeler should return 400 InvalidRequest (not 401 or 500) for a `reasonType` not listed in its published LabelerPolicies.reasonTypes." 104 + )] 105 + pub struct ShapeNot400 { 106 + pub status: u16, 107 + pub error_name: Option<String>, 108 + #[source_code] 109 + pub source_code: NamedSource<Arc<[u8]>>, 110 + #[label("rejected with wrong status here")] 111 + pub span: Option<SourceSpan>, 112 + } 113 + ``` 114 + 115 + **Testing:** Exercised indirectly via Task 3 integration tests. 116 + 117 + **Verification:** `cargo build` clean. 118 + 119 + **Commit:** `feat(create_report): diagnostics for AC3 self-mint negative checks` 120 + <!-- END_TASK_1 --> 121 + 122 + <!-- START_TASK_2 --> 123 + ### Task 2: Populate `self_mint_signer` in `LabelerCmd::run` when viable 124 + 125 + **Verifies:** AC3.7, AC3.8, AC8.2. 126 + 127 + **Files:** 128 + - Modify: `src/commands/test/labeler.rs` — in `LabelerCmd::run`, compute `self_mint_viable` against the parsed target's endpoint, construct a `SelfMintSigner` when appropriate, and thread it through `LabelerOptions`. 129 + 130 + **Implementation:** 131 + 132 + ```rust 133 + // After the `target` is parsed in LabelerCmd::run, derive the labeler 134 + // endpoint (if available) for the self-mint-viability heuristic. When the 135 + // target is a DID or handle, we don't know the endpoint yet — identity 136 + // stage will discover it. For the self-mint signer construction we need 137 + // the endpoint now; in that case, we construct the signer pessimistically 138 + // (the stage still decides viability via the real heuristic) ONLY when 139 + // --force-self-mint is set. Otherwise we defer. 140 + 141 + use crate::commands::test::labeler::create_report::self_mint::SelfMintSigner; 142 + use crate::common::identity::is_local_labeler_hostname; 143 + 144 + // Determine tentative endpoint for the locality check. 145 + let tentative_endpoint: Option<Url> = match &target { 146 + pipeline::LabelerTarget::Endpoint { url, .. } => Some(url.clone()), 147 + // Identified targets: endpoint known only after identity stage. 148 + // For self-mint-viable decisions the stage will re-check using 149 + // the actual endpoint; here we only pre-bind the signer if the 150 + // user forced it. 151 + pipeline::LabelerTarget::Identified { .. } => None, 152 + }; 153 + 154 + let tentative_local = tentative_endpoint 155 + .as_ref() 156 + .map(is_local_labeler_hostname) 157 + .unwrap_or(false); 158 + 159 + // Pre-construct the self-mint signer (binds the DidDocServer) when: 160 + // - --force-self-mint is set, OR 161 + // - tentative endpoint is known and classified local. 162 + // Otherwise we skip the allocation and let the stage see 163 + // `self_mint_signer == None` → skip all self-mint checks. 164 + let self_mint_signer_opt = if self.force_self_mint || tentative_local { 165 + Some( 166 + SelfMintSigner::spawn(self.self_mint_curve) 167 + .await 168 + .map_err(|e| miette::miette!("Failed to bind self-mint DID server: {e}"))?, 169 + ) 170 + } else { 171 + None 172 + }; 173 + let self_mint_signer_ref = self_mint_signer_opt.as_ref(); 174 + 175 + // ...construct LabelerOptions with self_mint_signer: self_mint_signer_ref. 176 + ``` 177 + 178 + **Notes for the implementor:** 179 + - The signer construction is async because `DidDocServer::spawn` is async. Ensure `LabelerCmd::run` is itself async (it is). 180 + - Hold the `SelfMintSigner` in a local variable in `LabelerCmd::run` so the `DidDocServer` stays alive for the entire pipeline run. The `_self_mint_signer_opt` binding dropping at the end of `run` matches the server's lifetime to the run's lifetime. 181 + - When the target is Identified (handle/DID), the stage re-checks viability using the actual endpoint discovered by identity. If the stage decides self-mint is NOT viable in that case, all four negative checks Skipped. The pre-allocated signer is wasted but not incorrect. 182 + 183 + **Testing:** 184 + 185 + Add `tests/labeler_cli.rs` CLI flag tests: 186 + - `--self-mint-curve es256` + `--help` prints the expected flag value. 187 + - `--force-self-mint` without arguments is accepted. 188 + 189 + For AC3.8 (override) — covered via a runtime test in Phase 7 or 8 with a non-local endpoint + `--force-self-mint=true`. For Phase 6, add a test that exercises the stage directly with `self_mint_viable = false` AND `self_mint_signer = Some(...)` but `force_self_mint = true` to prove the override flag takes precedence inside the stage (not just in CLI parsing). 190 + 191 + **Verification:** 192 + Run: `cargo build` 193 + Expected: clean. 194 + 195 + Run: `cargo test --test labeler_cli` 196 + Expected: existing + new tests pass. 197 + 198 + **Commit:** `feat(labeler): construct SelfMintSigner based on locality + --force-self-mint` 199 + <!-- END_TASK_2 --> 200 + <!-- END_SUBCOMPONENT_A --> 201 + 202 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 203 + <!-- START_TASK_3 --> 204 + ### Task 3: Implement the four self-mint negative checks 205 + 206 + **Verifies:** AC3.1–AC3.6. 207 + 208 + **Files:** 209 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase-4 "Phase 6" stubs with full implementations. 210 + 211 + **Implementation:** 212 + 213 + Replace this block in `run()`: 214 + 215 + ```rust 216 + for c in [ 217 + Check::WrongAudRejected, 218 + Check::WrongLxmRejected, 219 + Check::ExpiredRejected, 220 + Check::RejectedShapeReturns400, 221 + ] { 222 + results.push(c.skip("not yet implemented (Phase 6)")); 223 + } 224 + ``` 225 + 226 + with real logic: 227 + 228 + ```rust 229 + // Recompute self_mint_viable using the *actual* labeler endpoint now 230 + // that identity has run. 231 + let self_mint_viable = opts.force_self_mint 232 + || crate::common::identity::is_local_labeler_hostname(&id_facts.labeler_endpoint); 233 + 234 + let signer_for_negative = if self_mint_viable { 235 + opts.self_mint_signer 236 + } else { 237 + None 238 + }; 239 + 240 + // CRITICAL: this block either emits 4 Skipped rows OR emits 4 real-check 241 + // rows, then falls through to Phase 7/8 logic for SelfMintAccepted, 242 + // PdsServiceAuthAccepted, PdsProxiedAccepted. Do NOT `return` here — the 243 + // stage always emits 10 rows total, and the later checks need to run 244 + // regardless of self-mint viability. 245 + if let Some(signer) = signer_for_negative { 246 + // Mint per-check tokens from the valid-claims template. All four 247 + // checks share the same `now`, `lxm`, and `template`. Each check 248 + // inlines its own `match` rather than using a shared helper — 249 + // nested `async fn` is unsupported in stable Rust, and a closure 250 + // returning `Box<dyn Diagnostic>` across `await` would force `Send` 251 + // bounds that complicate the call site. 252 + let now = std::time::SystemTime::now() 253 + .duration_since(std::time::UNIX_EPOCH) 254 + .map(|d| d.as_secs() as i64) 255 + .unwrap_or(0); 256 + let lxm = "com.atproto.moderation.createReport"; 257 + let template = signer.valid_claims_template( 258 + &id_facts.did, 259 + lxm, 260 + now, 261 + std::time::Duration::from_secs(60), 262 + ); 263 + 264 + let negative_body = build_minimal_report_body(id_facts); 265 + 266 + // AC3.1/AC3.2 — wrong aud: 267 + { 268 + let mut claims = template.clone(); 269 + claims.aud = "did:plc:0000000000000000000000000".to_string(); 270 + let token = signer.sign_jwt(claims); 271 + match report_tee.post_create_report(Some(&token), &negative_body).await { 272 + Ok(resp) => match RejectionShape::classify(&resp) { 273 + RejectionShape::Conformant { .. } => results.push(Check::WrongAudRejected.pass()), 274 + RejectionShape::ConformantStatusNonConformantShape => results.push(CheckResult { 275 + summary: Cow::Borrowed("Rejected with 401 but envelope is non-conformant"), 276 + ..Check::WrongAudRejected.pass() 277 + }), 278 + RejectionShape::WrongStatus { .. } => { 279 + let diag = Box::new(WrongAudAccepted { 280 + status: resp.status.as_u16(), 281 + source_code: body_as_named_source(&resp), 282 + span: None, 283 + }); 284 + results.push(Check::WrongAudRejected.spec_violation(Some(diag))); 285 + } 286 + }, 287 + Err(CreateReportStageError::Transport { message, .. }) => { 288 + results.push(Check::WrongAudRejected.network_error(message)); 289 + } 290 + } 291 + } 292 + 293 + // AC3.3 — wrong lxm: 294 + { 295 + let mut claims = template.clone(); 296 + claims.lxm = "com.atproto.server.getSession".to_string(); 297 + let token = signer.sign_jwt(claims); 298 + match report_tee.post_create_report(Some(&token), &negative_body).await { 299 + Ok(resp) => match RejectionShape::classify(&resp) { 300 + RejectionShape::Conformant { .. } => results.push(Check::WrongLxmRejected.pass()), 301 + RejectionShape::ConformantStatusNonConformantShape => results.push(CheckResult { 302 + summary: Cow::Borrowed("Rejected with 401 but envelope is non-conformant"), 303 + ..Check::WrongLxmRejected.pass() 304 + }), 305 + RejectionShape::WrongStatus { .. } => { 306 + let diag = Box::new(WrongLxmAccepted { 307 + status: resp.status.as_u16(), 308 + source_code: body_as_named_source(&resp), 309 + span: None, 310 + }); 311 + results.push(Check::WrongLxmRejected.spec_violation(Some(diag))); 312 + } 313 + }, 314 + Err(CreateReportStageError::Transport { message, .. }) => { 315 + results.push(Check::WrongLxmRejected.network_error(message)); 316 + } 317 + } 318 + } 319 + 320 + // AC3.4 — expired: 321 + { 322 + let mut claims = template.clone(); 323 + claims.exp = now - 300; 324 + claims.iat = now - 360; 325 + let token = signer.sign_jwt(claims); 326 + match report_tee.post_create_report(Some(&token), &negative_body).await { 327 + Ok(resp) => match RejectionShape::classify(&resp) { 328 + RejectionShape::Conformant { .. } => results.push(Check::ExpiredRejected.pass()), 329 + RejectionShape::ConformantStatusNonConformantShape => results.push(CheckResult { 330 + summary: Cow::Borrowed("Rejected with 401 but envelope is non-conformant"), 331 + ..Check::ExpiredRejected.pass() 332 + }), 333 + RejectionShape::WrongStatus { .. } => { 334 + let diag = Box::new(ExpiredAccepted { 335 + status: resp.status.as_u16(), 336 + source_code: body_as_named_source(&resp), 337 + span: None, 338 + }); 339 + results.push(Check::ExpiredRejected.spec_violation(Some(diag))); 340 + } 341 + }, 342 + Err(CreateReportStageError::Transport { message, .. }) => { 343 + results.push(Check::ExpiredRejected.network_error(message)); 344 + } 345 + } 346 + } 347 + 348 + // AC3.5/AC3.6 — rejected shape: 349 + { 350 + let claims = template.clone(); 351 + let token = signer.sign_jwt(claims); 352 + // Invalid body: a reasonType that is NOT in id_facts.reason_types. 353 + let bogus_reason_type = synth_unadvertised_reason_type(id_facts); 354 + let invalid_body = { 355 + let mut body = negative_body.clone(); 356 + if let Some(obj) = body.as_object_mut() { 357 + obj.insert( 358 + "reasonType".to_string(), 359 + serde_json::Value::String(bogus_reason_type), 360 + ); 361 + } 362 + body 363 + }; 364 + match report_tee.post_create_report(Some(&token), &invalid_body).await { 365 + Ok(resp) => { 366 + let envelope = XrpcErrorEnvelope::parse(&resp.raw_body); 367 + let error_name = envelope.as_ref().and_then(|e| e.error.clone()); 368 + if resp.status == reqwest::StatusCode::BAD_REQUEST 369 + && error_name.as_deref() == Some("InvalidRequest") 370 + { 371 + // AC3.5: 400 InvalidRequest → Pass. 372 + results.push(Check::RejectedShapeReturns400.pass()); 373 + } else if resp.status == reqwest::StatusCode::UNAUTHORIZED 374 + || resp.status.is_server_error() 375 + { 376 + // AC3.6: 401 or 5xx → Advisory with shape_not_400. 377 + let diag = Box::new(ShapeNot400 { 378 + status: resp.status.as_u16(), 379 + error_name: error_name.clone(), 380 + source_code: body_as_named_source(&resp), 381 + span: None, 382 + }); 383 + results.push(Check::RejectedShapeReturns400.advisory(Some(diag))); 384 + } else if resp.status == reqwest::StatusCode::BAD_REQUEST { 385 + // 400 but not `InvalidRequest` name → Advisory. 386 + let diag = Box::new(ShapeNot400 { 387 + status: 400, 388 + error_name: error_name.clone(), 389 + source_code: body_as_named_source(&resp), 390 + span: None, 391 + }); 392 + results.push(Check::RejectedShapeReturns400.advisory(Some(diag))); 393 + } else { 394 + // Catch-all: 200 accepted → Advisory. A 200 for an invalid 395 + // shape is a labeler looseness issue, not the same category 396 + // as the `self_mint_accepted` SpecViolation (which expects 397 + // a *valid* shape to be accepted). 398 + let diag = Box::new(ShapeNot400 { 399 + status: resp.status.as_u16(), 400 + error_name, 401 + source_code: body_as_named_source(&resp), 402 + span: None, 403 + }); 404 + results.push(Check::RejectedShapeReturns400.advisory(Some(diag))); 405 + } 406 + } 407 + Err(CreateReportStageError::Transport { message, .. }) => { 408 + results.push(Check::RejectedShapeReturns400.network_error(message)); 409 + } 410 + } 411 + } 412 + } else { 413 + let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)"; 414 + for c in [ 415 + Check::WrongAudRejected, 416 + Check::WrongLxmRejected, 417 + Check::ExpiredRejected, 418 + Check::RejectedShapeReturns400, 419 + ] { 420 + results.push(c.skip(reason)); 421 + } 422 + } 423 + 424 + // Fallthrough to Phase 7/8 check logic below. In Phase 6 (before Phases 425 + // 7 and 8 replace their stubs), the Phase 4 stubs for SelfMintAccepted / 426 + // PdsServiceAuthAccepted / PdsProxiedAccepted still fire here. Keeping 427 + // this block fallthrough-safe is why the `if let Some(signer)` above 428 + // does NOT `return`. 429 + 430 + /// Synthesize a `reasonType` string that is definitely NOT in the 431 + /// labeler's advertised `reason_types`. NSID syntax (segments alphanumeric + 432 + /// period only, fragment after `#`) is strictly valid so the labeler does 433 + /// not reject for wrong reason (malformed NSID) before checking membership. 434 + fn synth_unadvertised_reason_type(facts: &IdentityFacts) -> String { 435 + let empty = Vec::new(); 436 + let advertised: &[String] = facts 437 + .reason_types 438 + .as_ref() 439 + .unwrap_or(&empty); 440 + for i in 0..1000 { 441 + let candidate = format!("xyz.atprotodevtool.conformance.defs#unadvertised{i:03}"); 442 + if !advertised.iter().any(|r| r == &candidate) { 443 + return candidate; 444 + } 445 + } 446 + // Unreachable in practice. 447 + "xyz.atprotodevtool.conformance.defs#unadvertisedFallback".to_string() 448 + } 449 + ``` 450 + 451 + **Notes for the implementor:** 452 + - Each of the four checks inlines its own `match` block. A shared async helper was considered and rejected: nested `async fn` is unsupported in stable Rust, and a module-scope helper taking a `FnOnce` closure returning `Box<dyn Diagnostic + Send + Sync>` across an `await` forces `Send` bounds that complicate the call sites more than the duplication. 453 + - `Check::RejectedShapeReturns400` has slightly different handling because Pass requires both status 400 AND `error == "InvalidRequest"`; it's inlined separately. 454 + - `IdentityFacts.did`, `IdentityFacts.labeler_endpoint`, `IdentityFacts.reason_types` — all field names verified against `src/commands/test/labeler/identity.rs` (post Phase 4 Task 0 extension). `.did` is the labeler's DID. 455 + - `build_minimal_report_body` is the `pub(crate)` module-scope helper introduced in Phase 5 Task 3. It reads `id_facts.reason_types`, `.subject_types`, and `.did` directly from the Phase 4 Task 0 extended `IdentityFacts`. 456 + - `synth_unadvertised_reason_type` uses `xyz.atprotodevtool.conformance.defs#unadvertisedNNN`. The `xyz.*` top-level is a reserved-for-experimental namespace in atproto NSID convention; domain segments have no hyphens (valid NSID syntax). 457 + 458 + **Testing:** 459 + 460 + Extend `tests/labeler_report.rs` with AC3 tests: 461 + 462 + ```rust 463 + /// Helper: an IdentityFacts fixture whose labeler_endpoint is a 464 + /// localhost URL (self_mint_viable = true). 465 + fn local_identity_facts() -> IdentityFacts { 466 + let mut facts = make_identity_facts( 467 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 468 + Some(vec!["account".to_string()]), 469 + ); 470 + // Override endpoint to localhost. Exact field name per 471 + // the IdentityFacts struct. 472 + facts.labeler_endpoint = url::Url::parse("http://localhost:8080").unwrap(); 473 + facts 474 + } 475 + 476 + #[tokio::test] 477 + async fn ac3_1_wrong_aud_401_passes() { 478 + let facts = local_identity_facts(); 479 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 480 + let tee = FakeCreateReportTee::new(); 481 + // Six POSTs expected: unauthenticated, malformed, wrong_aud, 482 + // wrong_lxm, expired, rejected_shape. Enqueue each: 483 + for _ in 0..2 { 484 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); // phase 5 checks 485 + } 486 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtAudience", "aud mismatch")); 487 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtLexiconMethod", "lxm mismatch")); 488 + tee.enqueue(FakeCreateReportResponse::unauthorized("JwtExpired", "expired")); 489 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "unadvertised reasonType")); 490 + 491 + let mut opts = default_opts(); 492 + opts.self_mint_signer = Some(&signer); 493 + let results = run_report_stage(&facts, &tee, opts).await; 494 + 495 + // Rows 3-5 are AC3.1-AC3.4, row 6 is AC3.5. 496 + assert_eq!(results[3].id, "report::wrong_aud_rejected"); 497 + assert_eq!(results[3].status, CheckStatus::Pass); 498 + assert_eq!(results[4].status, CheckStatus::Pass); 499 + assert_eq!(results[5].status, CheckStatus::Pass); 500 + assert_eq!(results[6].id, "report::rejected_shape_returns_400"); 501 + assert_eq!(results[6].status, CheckStatus::Pass); 502 + } 503 + 504 + #[tokio::test] 505 + async fn ac3_2_wrong_aud_200_is_spec_violation() { 506 + // ... similar, but enqueue OK for the wrong_aud call; assert SpecViolation + 507 + // diagnostic code "labeler::report::wrong_aud_accepted". 508 + } 509 + 510 + #[tokio::test] 511 + async fn ac3_6_shape_not_400_emits_advisory() { 512 + // enqueue 401 for the rejected_shape request; assert Advisory + diagnostic 513 + // code "labeler::report::shape_not_400". 514 + } 515 + 516 + #[tokio::test] 517 + async fn ac3_7_non_local_labeler_skips_self_mint_checks() { 518 + let mut facts = make_identity_facts( 519 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 520 + Some(vec!["account".to_string()]), 521 + ); 522 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 523 + let tee = FakeCreateReportTee::new(); 524 + // Only two Phase 5 POSTs expected (unauth + malformed). 525 + for _ in 0..2 { 526 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 527 + } 528 + let mut opts = default_opts(); 529 + opts.self_mint_signer = None; 530 + let results = run_report_stage(&facts, &tee, opts).await; 531 + for i in 3..=6 { 532 + assert_eq!(results[i].status, CheckStatus::Skipped, "row {} ({})", i, results[i].id); 533 + assert!( 534 + results[i].skipped_reason.as_deref().unwrap().contains("--force-self-mint"), 535 + "row {}: {:?}", i, results[i].skipped_reason, 536 + ); 537 + } 538 + } 539 + 540 + #[tokio::test] 541 + async fn ac3_8_force_self_mint_overrides_non_local() { 542 + let mut facts = make_identity_facts( 543 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 544 + Some(vec!["account".to_string()]), 545 + ); 546 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 547 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 548 + let tee = FakeCreateReportTee::new(); 549 + for _ in 0..2 { 550 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 551 + } 552 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtAudience", "x")); 553 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtLexiconMethod", "x")); 554 + tee.enqueue(FakeCreateReportResponse::unauthorized("JwtExpired", "x")); 555 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 556 + let mut opts = default_opts(); 557 + opts.self_mint_signer = Some(&signer); 558 + opts.force_self_mint = true; 559 + let results = run_report_stage(&facts, &tee, opts).await; 560 + assert_eq!(results[3].status, CheckStatus::Pass); 561 + assert_eq!(results[6].status, CheckStatus::Pass); 562 + } 563 + ``` 564 + 565 + **Verification:** 566 + Run: `cargo test --test labeler_report ac3_` 567 + Expected: all AC3 tests pass. Add at least one test per AC case (AC3.1 through AC3.8). 568 + 569 + Run: `cargo insta pending-snapshots` 570 + Expected: any new snapshots are reviewed. 571 + 572 + **Commit:** `feat(create_report): wire self-mint negative checks (AC3.1-AC3.8)` 573 + <!-- END_TASK_3 --> 574 + 575 + <!-- START_TASK_4 --> 576 + ### Task 4: Phase 6 integration check 577 + 578 + **Files:** None changed. 579 + 580 + **Implementation:** Gate + snapshot churn acceptance. 581 + 582 + **Verification:** 583 + Run: `cargo test` 584 + Expected: most tests pass, but Phase 4's insta snapshots that pinned Phase-4 stub strings (`"not yet implemented (Phase 6)"`) will now fail because Phase 6 replaces those rows with real Pass/SpecViolation outcomes. This is expected churn, not a regression. 585 + 586 + Run: `cargo insta review` 587 + Expected: step through every pending snapshot from Phase 6 changes. Accept each snapshot that shows real AC3 rows instead of Phase-4 stubs. Reject any snapshot that shows unexpected content (e.g., a row in the wrong position or a diagnostic code that doesn't match the design). 588 + 589 + Run: `cargo test` (again, after accepting snapshots) 590 + Expected: all tests pass; Phase 6 adds ~8 new tests (AC3.1-AC3.8) plus updates to pre-existing Phase 4/5 snapshots. 591 + 592 + Run: `cargo clippy --all-targets -- -D warnings` 593 + Expected: no warnings. 594 + 595 + **Commit:** `test(create_report): accept Phase 6 snapshot churn` 596 + <!-- END_TASK_4 --> 597 + 598 + --- 599 + 600 + ## Phase 6 complete when 601 + 602 + - Four new diagnostics (`WrongAudAccepted`, `WrongLxmAccepted`, `ExpiredAccepted`, `ShapeNot400`) with stable codes compile and are used by the stage. 603 + - `LabelerCmd::run` constructs a `SelfMintSigner` when the heuristic or `--force-self-mint` warrants it. 604 + - `report::wrong_aud_rejected`, `report::wrong_lxm_rejected`, `report::expired_rejected`, `report::rejected_shape_returns_400` are all live (no longer `Skipped` stubs), each with their own mutation and classifier. 605 + - Self-mint-viable gating emits `Skipped` with the exact `--force-self-mint` override hint. 606 + - `--force-self-mint` bypasses the heuristic. 607 + - **Acceptance criteria satisfied:** AC3.1, AC3.2, AC3.3, AC3.4, AC3.5, AC3.6, AC3.7, AC3.8.
+613
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_07.md
··· 1 + # Labeler report stage — Phase 7: Self-mint positive check + pollution avoidance 2 + 3 + **Goal:** Implement `report::self_mint_accepted`: the first committing check. POST a well-formed report body (advertised `reasonType`, advertised subject shape, sentinel reason) with a valid self-mint JWT, expect 2xx + a `createReport#output`-shaped response body. Apply pollution-avoidance rules based on labeler locality. 4 + 5 + **Architecture:** A new `pollution.rs` helper module under `create_report/` houses the `choose_reason_type` and `choose_subject` helpers. The `self_mint_accepted` check composes these with the sentinel-reason builder from Phase 1 and the JWT minting from Phase 2. 6 + 7 + **Tech Stack:** Phases 1-6 artifacts. No new externals. 8 + 9 + **Scope:** Phase 7 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (relies on Phase 1-6 artifacts). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `LabelerPolicies.reason_types: Option<Vec<String>>` and `subject_types: Option<Vec<String>>`, already proven non-empty by Phase 4's `contract_advertised` gate (reachable code paths). 15 + - ✓ Phase 1's `sentinel::build(run_id, now)` produces the "atproto-devtool conformance test <RFC3339> <run-id>" string; `sentinel::new_run_id` produces a 16-hex-char id. 16 + - ✓ Phase 6's `build_minimal_report_body(facts)` is a *negative-path* helper — reuses the labeler's own DID as the subject. Phase 7 needs a distinct *positive-path* helper that respects pollution-avoidance. 17 + - ✓ Phase 5's AC2.5 handling (Pass-with-non-conformant-envelope) is not reused — self_mint_accepted expects 2xx, not 401. 18 + 19 + **External dependency research findings:** 20 + - ✓ `com.atproto.moderation.createReport#output` response body per the lexicon: 21 + ```json 22 + { 23 + "id": <integer>, 24 + "reasonType": "<string NSID>", 25 + "subject": { "$type": "com.atproto.admin.defs#repoRef", "did": "did:..." }, 26 + "reportedBy": "did:...", 27 + "createdAt": "<ISO 8601 UTC>" 28 + } 29 + ``` 30 + Required fields: `id`, `reasonType`, `subject`, `reportedBy`, `createdAt`. `reason` is echoed back optionally. The stage's Pass criterion is "2xx + body parses as JSON containing at minimum `id` (number) and `reportedBy` (string)". We don't need a strict atrium-api decode; a loose `serde_json::Value` check is enough. 31 + - ✓ `com.atproto.moderation.defs#reasonType` enum membership: `reasonSpam`, `reasonViolation`, `reasonMisleading`, `reasonSexual`, `reasonRude`, `reasonOther` (canonical order). For pollution-avoidance the stage prefers `reasonOther` when it's in the advertised set; else lex-first. 32 + - ✓ `subjectTypes`: `"account"` and `"record"` are the two canonical values. 33 + - ✓ AT-URI syntax: `at://<did-or-handle>/<collection>/<rkey>`. For strongRef, also needs a `cid`. For v1 the constants are placeholders — the release-gate adds real values. 34 + 35 + --- 36 + 37 + ## Acceptance criteria coverage 38 + 39 + This phase implements and tests: 40 + 41 + ### labeler-report-stage.AC4: Self-mint positive check 42 + - **labeler-report-stage.AC4.1 Success (local labeler):** `self_mint_accepted` emits `Pass` using lex-first `reasonType` and account subject = reporter DID when labeler returns 2xx. 43 + - **labeler-report-stage.AC4.2 Success (non-local labeler):** `self_mint_accepted` emits `Pass` using `reasonOther` (if advertised) and `record` subject with hard-coded AT-URI (if advertised) when labeler returns 2xx. 44 + - **labeler-report-stage.AC4.3 Failure:** emits `SpecViolation` (diagnostic `labeler::report::self_mint_rejected`) when labeler returns non-2xx. 45 + - **labeler-report-stage.AC4.4 Skip (no commit):** emits `Skipped` with reason naming the `--commit-report` gate. 46 + - **labeler-report-stage.AC4.5 Skip (not viable):** emits `Skipped` with the self-mint-unreachable reason when heuristic trips. 47 + - **labeler-report-stage.AC4.6 Sentinel:** The `reason` field in the submitted POST body contains the stable sentinel string `"atproto-devtool conformance test <RFC3339> <run-id>"`. 48 + 49 + --- 50 + 51 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 52 + <!-- START_TASK_1 --> 53 + ### Task 1: `pollution` helper module 54 + 55 + **Verifies:** Supports AC4.1, AC4.2, AC4.6. 56 + 57 + **Files:** 58 + - Create: `src/commands/test/labeler/create_report/pollution.rs`. 59 + - Modify: `src/commands/test/labeler/create_report.rs` — add `pub mod pollution;`. 60 + 61 + **Implementation:** 62 + 63 + ```rust 64 + //! Pollution-avoidance helpers for committing report checks. 65 + //! 66 + //! When the tool actually POSTs a report (positive path only), we have 67 + //! two obligations: 68 + //! 69 + //! 1. Avoid contaminating real moderation queues on public labelers. 70 + //! 2. Be easy to identify and dismiss for operators who see the report. 71 + //! 72 + //! Obligation 1 is satisfied by preferring: 73 + //! - `reasonOther` reasonType when advertised (signals "this is a test 74 + //! or edge case, review leisurely"), falling back to the lex-first 75 + //! advertised value. 76 + //! - `record` subject type with a hardcoded AT-URI pointing at an 77 + //! explanation post the tool author publishes (release-gate item), 78 + //! falling back to `account` subject pointing at the *reporter's* 79 + //! own DID (the self-mint DID, which is always safe). 80 + //! 81 + //! Obligation 2 is satisfied by the sentinel `reason` string from the 82 + //! `sentinel` module — it's stable and greppable. 83 + //! 84 + //! On *local* labelers the safety constraint relaxes: the queue is the 85 + //! developer's own, so we use lex-first `reasonType` + `account` subject 86 + //! (pointing at the reporter's own DID) to exercise the simplest working 87 + //! shape. This makes the test deterministic for round-trip debugging. 88 + 89 + use serde_json::{Value, json}; 90 + 91 + use crate::common::identity::Did; 92 + 93 + /// Placeholder constant for the record-subject AT-URI used in 94 + /// non-local pollution-safe POSTs. **Must be replaced with a real 95 + /// published post's AT-URI before v1 ships** — see 96 + /// `docs/design-plans/2026-04-17-labeler-report-stage.md` § "Pre-release 97 + /// TODO". 98 + pub const CONFORMANCE_REPORT_SUBJECT_URI: &str = 99 + "<TBD: at://did:plc:... — release-gate>"; 100 + 101 + /// Placeholder constant for the record-subject CID. Same release-gate 102 + /// story: capture when the explanation post is published. 103 + pub const CONFORMANCE_REPORT_SUBJECT_CID: &str = 104 + "<TBD: bafyrei... — release-gate>"; 105 + 106 + /// Choose the `reasonType` for a committing POST. 107 + /// 108 + /// Returns the full NSID string (e.g., 109 + /// `"com.atproto.moderation.defs#reasonOther"`). Preconditions: 110 + /// `advertised` is non-empty (the stage's contract-published gate 111 + /// guarantees this). 112 + pub fn choose_reason_type(advertised: &[String], is_local: bool) -> String { 113 + let prefer_other = "com.atproto.moderation.defs#reasonOther"; 114 + if !is_local && advertised.iter().any(|r| r == prefer_other) { 115 + return prefer_other.to_string(); 116 + } 117 + advertised 118 + .first() 119 + .cloned() 120 + .unwrap_or_else(|| prefer_other.to_string()) 121 + } 122 + 123 + /// Choose the `subject` JSON for a committing POST. 124 + /// 125 + /// - `advertised_types`: non-empty (contract-published guarantees). 126 + /// - `reporter_did`: the self-mint DID (for the "own reporter" fallback). 127 + /// - `override_did`: if `Some`, always use an `account` subject with this 128 + /// DID (for `--report-subject-did`). 129 + /// - `is_local`: local labelers use the simplest shape for debugging. 130 + pub fn choose_subject( 131 + advertised_types: &[String], 132 + reporter_did: &Did, 133 + override_did: Option<&Did>, 134 + is_local: bool, 135 + ) -> Value { 136 + if let Some(did) = override_did { 137 + return json!({ 138 + "$type": "com.atproto.admin.defs#repoRef", 139 + "did": did.0, 140 + }); 141 + } 142 + if !is_local && advertised_types.iter().any(|s| s == "record") { 143 + return json!({ 144 + "$type": "com.atproto.repo.strongRef", 145 + "uri": CONFORMANCE_REPORT_SUBJECT_URI, 146 + "cid": CONFORMANCE_REPORT_SUBJECT_CID, 147 + }); 148 + } 149 + // Local, override absent, or `record` not advertised → account subject 150 + // pointing at the reporter's own DID (always safe). 151 + json!({ 152 + "$type": "com.atproto.admin.defs#repoRef", 153 + "did": reporter_did.0, 154 + }) 155 + } 156 + ``` 157 + 158 + **Testing:** 159 + 160 + ```rust 161 + #[cfg(test)] 162 + mod tests { 163 + use super::*; 164 + 165 + #[test] 166 + fn choose_reason_type_prefers_other_when_advertised_and_non_local() { 167 + let advertised = vec![ 168 + "com.atproto.moderation.defs#reasonSpam".to_string(), 169 + "com.atproto.moderation.defs#reasonOther".to_string(), 170 + ]; 171 + assert_eq!( 172 + choose_reason_type(&advertised, false), 173 + "com.atproto.moderation.defs#reasonOther" 174 + ); 175 + } 176 + 177 + #[test] 178 + fn choose_reason_type_uses_lex_first_when_local() { 179 + let advertised = vec![ 180 + "com.atproto.moderation.defs#reasonSpam".to_string(), 181 + "com.atproto.moderation.defs#reasonOther".to_string(), 182 + ]; 183 + assert_eq!( 184 + choose_reason_type(&advertised, true), 185 + "com.atproto.moderation.defs#reasonSpam" 186 + ); 187 + } 188 + 189 + #[test] 190 + fn choose_reason_type_falls_back_to_first_when_other_absent() { 191 + let advertised = vec!["com.atproto.moderation.defs#reasonSpam".to_string()]; 192 + assert_eq!( 193 + choose_reason_type(&advertised, false), 194 + "com.atproto.moderation.defs#reasonSpam" 195 + ); 196 + } 197 + 198 + #[test] 199 + fn choose_subject_local_returns_account_on_reporter() { 200 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 201 + let subj = choose_subject(&["account".to_string(), "record".to_string()], &reporter, None, true); 202 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 203 + assert_eq!(subj["did"], reporter.0); 204 + } 205 + 206 + #[test] 207 + fn choose_subject_non_local_prefers_record_when_advertised() { 208 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 209 + let subj = choose_subject(&["account".to_string(), "record".to_string()], &reporter, None, false); 210 + assert_eq!(subj["$type"], "com.atproto.repo.strongRef"); 211 + assert_eq!(subj["uri"], CONFORMANCE_REPORT_SUBJECT_URI); 212 + } 213 + 214 + #[test] 215 + fn choose_subject_non_local_falls_back_to_account_when_record_absent() { 216 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 217 + let subj = choose_subject(&["account".to_string()], &reporter, None, false); 218 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 219 + assert_eq!(subj["did"], reporter.0); 220 + } 221 + 222 + #[test] 223 + fn choose_subject_override_always_wins() { 224 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 225 + let override_did = Did("did:plc:target".to_string()); 226 + let subj = choose_subject( 227 + &["record".to_string()], 228 + &reporter, 229 + Some(&override_did), 230 + false, // non-local; without override this would be record/strongRef 231 + ); 232 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 233 + assert_eq!(subj["did"], "did:plc:target"); 234 + } 235 + } 236 + ``` 237 + 238 + **Verification:** 239 + Run: `cargo test --lib commands::test::labeler::create_report::pollution::tests` 240 + Expected: 7 tests pass. 241 + 242 + **Commit:** `feat(create_report): pollution-avoidance helpers` 243 + <!-- END_TASK_1 --> 244 + 245 + <!-- START_TASK_2 --> 246 + ### Task 2: Stable run-id threaded through the stage 247 + 248 + **Verifies:** AC4.6 (sentinel string). 249 + 250 + **Files:** 251 + - Modify: `src/commands/test/labeler/create_report.rs` — add `run_id: &'a str` to `CreateReportRunOptions` (see refined shape below). 252 + - Modify: `src/commands/test/labeler.rs` — construct a run-id in `LabelerCmd::run` and thread it through. 253 + 254 + **Implementation:** 255 + 256 + Extend `CreateReportRunOptions` (introduced in Phase 4 Task 6) with one new field: `pub run_id: &'a str`. The reference-typed field avoids ownership games — the caller (`LabelerCmd::run` in production, test helpers in tests) holds the `String` and passes a borrow. 257 + 258 + ```rust 259 + // Final shape of CreateReportRunOptions: 260 + pub struct CreateReportRunOptions<'a> { 261 + pub commit_report: bool, 262 + pub force_self_mint: bool, 263 + pub self_mint_curve: SelfMintCurve, 264 + pub report_subject_override: Option<&'a Did>, 265 + pub self_mint_signer: Option<&'a SelfMintSigner>, 266 + pub pds_credentials: Option<&'a PdsCredentials>, 267 + pub run_id: &'a str, // NEW in Phase 7 268 + } 269 + ``` 270 + 271 + In `LabelerCmd::run`, construct the run-id before building `LabelerOptions`: 272 + 273 + ```rust 274 + let run_id = crate::commands::test::labeler::create_report::sentinel::new_run_id(); 275 + // ... construct LabelerOptions with run_id: &run_id (the `run_id` String 276 + // lives for the duration of LabelerCmd::run, which exceeds the lifetime 277 + // of the LabelerOptions borrow). 278 + ``` 279 + 280 + Phase 4 tests must add `run_id: "test-run-id-0000"` (or similar) to every `CreateReportRunOptions` they construct. Update `default_opts()` in `tests/labeler_report.rs` to include this field. 281 + 282 + **Notes for the implementor:** 283 + - The `run_id` field must lifetime-tie to a variable whose `Drop` happens after the pipeline completes. Putting `let run_id = ...` at the top of `LabelerCmd::run` is sufficient. 284 + 285 + **Testing:** No dedicated test; Task 3's integration test asserts the sentinel string in the POSTed body. 286 + 287 + **Verification:** 288 + Run: `cargo build` 289 + Expected: clean. 290 + 291 + **Commit:** `feat(create_report): thread run_id through CreateReportRunOptions` 292 + <!-- END_TASK_2 --> 293 + <!-- END_SUBCOMPONENT_A --> 294 + 295 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 296 + <!-- START_TASK_3 --> 297 + ### Task 3: Implement `self_mint_accepted` with the sentinel + pollution policy 298 + 299 + **Verifies:** AC4.1–AC4.6. 300 + 301 + **Files:** 302 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase 4/6 stub for `Check::SelfMintAccepted` with real logic; add the `SelfMintRejected` diagnostic. 303 + 304 + **Implementation:** 305 + 306 + ```rust 307 + #[derive(Debug, Error, Diagnostic)] 308 + #[error("Self-mint report rejected (status {status})")] 309 + #[diagnostic( 310 + code = "labeler::report::self_mint_rejected", 311 + 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." 312 + )] 313 + pub struct SelfMintRejected { 314 + pub status: u16, 315 + #[source_code] 316 + pub source_code: NamedSource<Arc<[u8]>>, 317 + #[label("rejected here")] 318 + pub span: Option<SourceSpan>, 319 + } 320 + ``` 321 + 322 + Replace this block in `run()`: 323 + 324 + ```rust 325 + results.push(Check::SelfMintAccepted.skip("not yet implemented (Phase 7)")); 326 + ``` 327 + 328 + with: 329 + 330 + ```rust 331 + // AC4.4 — gate on commit_report. 332 + if !opts.commit_report { 333 + results.push(Check::SelfMintAccepted.skip( 334 + "commit gated behind --commit-report", 335 + )); 336 + } else { 337 + // AC4.1/AC4.2 — construct a positive POST with pollution-avoidance. 338 + // Reads contract from the Phase 4 Task 0 extended IdentityFacts 339 + // fields (`reason_types` / `subject_types`). 340 + let reason_type = pollution::choose_reason_type( 341 + id_facts.reason_types.as_deref().unwrap_or(&[]), 342 + crate::common::identity::is_local_labeler_hostname(&id_facts.labeler_endpoint), 343 + ); 344 + let subject = pollution::choose_subject( 345 + id_facts.subject_types.as_deref().unwrap_or(&[]), 346 + signer.issuer_did(), 347 + opts.report_subject_override, 348 + crate::common::identity::is_local_labeler_hostname(&id_facts.labeler_endpoint), 349 + ); 350 + let sentinel = sentinel::build(opts.run_id, std::time::SystemTime::now()); 351 + let positive_body = serde_json::json!({ 352 + "reasonType": reason_type, 353 + "subject": subject, 354 + "reason": sentinel, 355 + }); 356 + 357 + // AC4.6 — the built body carries the sentinel; the integration test 358 + // in Task 4 asserts it via FakeCreateReportTee::last_request(). 359 + 360 + let claims = signer.valid_claims_template( 361 + &id_facts.did, 362 + "com.atproto.moderation.createReport", 363 + now, 364 + std::time::Duration::from_secs(60), 365 + ); 366 + let token = signer.sign_jwt(claims); 367 + 368 + match report_tee.post_create_report(Some(&token), &positive_body).await { 369 + Ok(resp) if resp.status.is_success() => { 370 + // AC4.1/AC4.2: Pass. Optionally inspect body for createReport#output 371 + // shape — loose check: `id` is a number. 372 + let body_ok = serde_json::from_slice::<serde_json::Value>(&resp.raw_body) 373 + .ok() 374 + .and_then(|v| v.as_object().map(|o| o.contains_key("id"))) 375 + .unwrap_or(false); 376 + if body_ok { 377 + results.push(Check::SelfMintAccepted.pass()); 378 + } else { 379 + // 2xx but body doesn't look like createReport#output. Accept as 380 + // Pass per design (status alone suffices), but note the 381 + // non-conformant body in the summary. 382 + results.push(CheckResult { 383 + summary: std::borrow::Cow::Borrowed( 384 + "Self-mint report accepted (2xx), body did not match createReport#output shape", 385 + ), 386 + ..Check::SelfMintAccepted.pass() 387 + }); 388 + } 389 + } 390 + Ok(resp) => { 391 + // AC4.3: non-2xx ⇒ SpecViolation. 392 + let diag = Box::new(SelfMintRejected { 393 + status: resp.status.as_u16(), 394 + source_code: body_as_named_source(&resp), 395 + span: None, 396 + }); 397 + results.push(Check::SelfMintAccepted.spec_violation(Some(diag))); 398 + } 399 + Err(CreateReportStageError::Transport { message, .. }) => { 400 + results.push(Check::SelfMintAccepted.network_error(message)); 401 + } 402 + } 403 + } 404 + ``` 405 + 406 + **Notes for the implementor:** 407 + - AC4.5 (`Skipped` with the viability reason when heuristic trips) is already handled by the earlier `Some(signer) = signer else { ... }` block in Phase 6. When we reach `self_mint_accepted` in Phase 7, either we have a signer (viable or `--force-self-mint`) or we've already emitted `Skipped` and returned. The `else` branch above handles commit-off; the matched body handles the accepted case. 408 + 409 + **Testing:** 410 + 411 + Extend `tests/labeler_report.rs` with AC4 tests: 412 + 413 + ```rust 414 + #[tokio::test] 415 + async fn ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject() { 416 + let facts = local_identity_facts(); 417 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 418 + let tee = FakeCreateReportTee::new(); 419 + // Enqueue responses for Phase 5 (2), Phase 6 (4), then AC4 positive. 420 + for _ in 0..2 { 421 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 422 + } 423 + for _ in 0..3 { 424 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwt*", "x")); 425 + } 426 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 427 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 428 + 429 + let mut opts = default_opts(); 430 + opts.self_mint_signer = Some(&signer); 431 + opts.commit_report = true; 432 + let run_id = "test-run-1234567890".to_string(); 433 + opts.run_id = &run_id; 434 + let results = run_report_stage(&facts, &tee, opts).await; 435 + 436 + assert_eq!(results[7].id, "report::self_mint_accepted"); 437 + assert_eq!(results[7].status, CheckStatus::Pass); 438 + 439 + // AC4.6: last_request() body contains the sentinel. 440 + let last_req = tee.last_request(); 441 + let body_reason = last_req.body["reason"].as_str().unwrap_or(""); 442 + assert!(body_reason.starts_with("atproto-devtool conformance test")); 443 + assert!(body_reason.ends_with(&run_id)); 444 + 445 + // AC4.1: reasonType is lex-first (reasonSpam), subject is account. 446 + let body = &last_req.body; 447 + assert_eq!(body["reasonType"], "com.atproto.moderation.defs#reasonSpam"); 448 + assert_eq!(body["subject"]["$type"], "com.atproto.admin.defs#repoRef"); 449 + } 450 + 451 + #[tokio::test] 452 + async fn ac4_2_non_local_labeler_prefers_other_and_record() { 453 + let mut facts = make_identity_facts( 454 + Some(vec![ 455 + "com.atproto.moderation.defs#reasonSpam".to_string(), 456 + "com.atproto.moderation.defs#reasonOther".to_string(), 457 + ]), 458 + Some(vec!["account".to_string(), "record".to_string()]), 459 + ); 460 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 461 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 462 + let tee = FakeCreateReportTee::new(); 463 + for _ in 0..2 { 464 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 465 + } 466 + // Phase 6 self-mint checks all Skipped (non-local); only 2 Phase 5 467 + // POSTs happen, then the AC4 positive POST. 468 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 469 + let mut opts = default_opts(); 470 + opts.self_mint_signer = Some(&signer); 471 + opts.commit_report = true; 472 + opts.force_self_mint = true; // so the Phase 6 checks do run 473 + // Re-queue Phase 6 responses since force_self_mint is on: 474 + // ... actually, if force_self_mint is on, Phase 6 POSTs happen (4 more) 475 + // before AC4. Adjust the queue accordingly. 476 + // ... [implementor: work out the exact FIFO order]. 477 + 478 + let results = run_report_stage(&facts, &tee, opts).await; 479 + assert_eq!(results[7].status, CheckStatus::Pass); 480 + 481 + let last_req = tee.last_request(); 482 + assert_eq!( 483 + last_req.body["reasonType"], 484 + "com.atproto.moderation.defs#reasonOther" 485 + ); 486 + assert_eq!( 487 + last_req.body["subject"]["$type"], 488 + "com.atproto.repo.strongRef" 489 + ); 490 + } 491 + 492 + #[tokio::test] 493 + async fn ac4_3_non_2xx_is_spec_violation() { 494 + let facts = local_identity_facts(); 495 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 496 + let tee = FakeCreateReportTee::new(); 497 + // 2 Phase 5 + 4 Phase 6 + 1 AC4. 498 + for _ in 0..2 { 499 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 500 + } 501 + for _ in 0..3 { 502 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 503 + } 504 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 505 + tee.enqueue(FakeCreateReportResponse::Response { 506 + status: 400, 507 + content_type: Some("application/json".to_string()), 508 + body: br#"{"error":"InvalidRequest","message":"nope"}"#.to_vec(), 509 + }); 510 + 511 + let mut opts = default_opts(); 512 + opts.self_mint_signer = Some(&signer); 513 + opts.commit_report = true; 514 + let run_id = "x".to_string(); 515 + opts.run_id = &run_id; 516 + let results = run_report_stage(&facts, &tee, opts).await; 517 + 518 + assert_eq!(results[7].status, CheckStatus::SpecViolation); 519 + assert_eq!( 520 + results[7].diagnostic.as_ref().unwrap().code().map(|c| c.to_string()), 521 + Some("labeler::report::self_mint_rejected".to_string()), 522 + ); 523 + } 524 + 525 + #[tokio::test] 526 + async fn ac4_4_commit_false_skips() { 527 + let facts = local_identity_facts(); 528 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 529 + let tee = FakeCreateReportTee::new(); 530 + for _ in 0..2 { 531 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 532 + } 533 + for _ in 0..3 { 534 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 535 + } 536 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 537 + // No POST for self_mint_accepted because commit is false. 538 + 539 + let mut opts = default_opts(); 540 + opts.self_mint_signer = Some(&signer); 541 + opts.commit_report = false; 542 + let results = run_report_stage(&facts, &tee, opts).await; 543 + 544 + assert_eq!(results[7].status, CheckStatus::Skipped); 545 + assert!( 546 + results[7].skipped_reason.as_deref().unwrap().contains("--commit-report"), 547 + "expected skip reason to mention --commit-report", 548 + ); 549 + } 550 + 551 + #[tokio::test] 552 + async fn ac4_5_non_viable_skip_matches_phase_6_reason() { 553 + // When self_mint_viable=false AND commit_report=true, self_mint_accepted 554 + // is Skipped with the Phase-6 viability reason (already tested, retest 555 + // that the row is Skipped here for completeness). 556 + let mut facts = make_identity_facts( 557 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 558 + Some(vec!["account".to_string()]), 559 + ); 560 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 561 + let tee = FakeCreateReportTee::new(); 562 + for _ in 0..2 { 563 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 564 + } 565 + let mut opts = default_opts(); 566 + opts.self_mint_signer = None; 567 + opts.commit_report = true; 568 + let results = run_report_stage(&facts, &tee, opts).await; 569 + assert_eq!(results[7].status, CheckStatus::Skipped); 570 + } 571 + ``` 572 + 573 + **Verification:** 574 + Run: `cargo test --test labeler_report ac4_` 575 + Expected: 5 AC4 tests pass. 576 + 577 + **Commit:** `feat(create_report): self_mint_accepted with pollution avoidance (AC4)` 578 + <!-- END_TASK_3 --> 579 + 580 + <!-- START_TASK_4 --> 581 + ### Task 4: Phase 7 integration check 582 + 583 + **Files:** None changed. 584 + 585 + **Implementation:** Gate. 586 + 587 + **Verification:** 588 + Run: `cargo test` 589 + Expected: all Phase 1-6 tests pass; Phase 7 adds ~7 new tests (pollution helpers + AC4.1-AC4.6). 590 + 591 + Run: `cargo clippy --all-targets -- -D warnings` 592 + Expected: no warnings. 593 + 594 + **Commit:** No new commit unless fixes were needed. 595 + <!-- END_TASK_4 --> 596 + 597 + --- 598 + 599 + ## Phase 7 complete when 600 + 601 + - `pollution::choose_reason_type` and `choose_subject` apply the local vs non-local pollution rules correctly. 602 + - `CONFORMANCE_REPORT_SUBJECT_URI` and `_CID` placeholder constants exist with the release-gate comment. 603 + - `report::self_mint_accepted` uses pollution helpers + sentinel + valid JWT to POST, classifies 2xx vs non-2xx correctly. 604 + - The `reason` field in committing POSTs carries the sentinel string. 605 + - `SelfMintRejected` diagnostic has stable code `labeler::report::self_mint_rejected`. 606 + - **Acceptance criteria satisfied:** AC4.1, AC4.2, AC4.3, AC4.4, AC4.5, AC4.6. 607 + 608 + ## Release-gate TODO (not code work) 609 + 610 + Before v1 ships: 611 + 1. Publish a post on an atproto account explaining what `atproto-devtool` is and that reports with the sentinel string are test submissions. 612 + 2. Capture the AT-URI and CID of that post. 613 + 3. Replace `CONFORMANCE_REPORT_SUBJECT_URI` / `CONFORMANCE_REPORT_SUBJECT_CID` in `src/commands/test/labeler/create_report/pollution.rs` with the real values.
+1038
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_08.md
··· 1 + # Labeler report stage — Phase 8: PDS modes and end-to-end integration 2 + 3 + **Goal:** Land the last two positive checks (`report::pds_service_auth_accepted`, `report::pds_proxied_accepted`), wire the remaining CLI flags (`--handle`, `--app-password` with clap `requires`), finish Phase 8 end-to-end snapshot fixtures, and re-verify AC7.1 (always-10-rows) and AC8 (CLI-flag validation) via `tests/labeler_cli.rs` extensions. 4 + 5 + **Architecture:** Two new helper types — `PdsJwtFetcher` (issues `com.atproto.server.createSession` + `com.atproto.server.getServiceAuth`) and `PdsProxiedPoster` (POSTs createReport to the PDS with the `atproto-proxy` header). Because the existing `HttpClient` trait is GET-only (`src/common/identity.rs:282-284`), Phase 8 introduces a new narrow seam `PdsXrpcClient` for the POSTs these checks need. Mirror-exact fakes go in `tests/common/mod.rs`. 6 + 7 + **Tech Stack:** `reqwest::Client` (existing), `serde_json`, Phase 3-7 artifacts. No new crate deps. 8 + 9 + **Scope:** Phase 8 of 8 — final phase. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `src/common/identity.rs:278-294`, `src/commands/test/labeler/pipeline.rs` up through full file, `src/commands/test/labeler.rs`, `tests/labeler_cli.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `HttpClient` trait at `src/common/identity.rs:282-284` has a single method `async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError>`. **GET-only. Cannot POST.** 15 + - ✓ **DISCREPANCY from design:** the design says "`http: &dyn HttpClient` (reused for PDS calls in modes 2 and 3)" but the existing `HttpClient` is GET-only. **Resolution:** introduce a new trait `PdsXrpcClient` (narrow: one method for POST, one for GET with bearer and query params; the GET is needed because `getServiceAuth` is a GET). Keep the existing `HttpClient` untouched. The new trait gets its own `Real*` production impl and `Fake*` test helper, following the same seam-per-concern pattern already in the codebase. 16 + - ✓ `getServiceAuth` is a GET with query params (`aud`, `lxm`, `exp`) per atproto lexicon — confirmed in external research. 17 + - ✓ `createSession` is a POST with JSON body per atproto lexicon. 18 + - ✓ The PDS-proxied flow needs a POST with two extra headers: `Authorization: Bearer <access_jwt>` and `atproto-proxy: <labeler-did>#atproto_labeler`. The labeler's DID (without fragment) comes from `IdentityFacts.labeler_did`. 19 + - ✓ `clap` `requires` attribute at `Cargo.toml:27` is available. The existing `LabelerCmd` does not use `requires`; Phase 8 is the first adopter. 20 + - ✓ `tests/labeler_cli.rs` uses `assert_cmd::Command::cargo_bin("atproto-devtool")` — existing pattern for CLI tests. 21 + - ✓ `tests/labeler_endtoend.rs` uses `insta::assert_snapshot!("name", normalized)` for whole-pipeline snapshots. Phase 8 adds three more: `all_pass_local_labeler`, `all_pass_full_suite`, `all_fail_misconfigured_labeler`. 22 + 23 + **External dependency research findings:** 24 + - ✓ `com.atproto.server.createSession`: POST to `/xrpc/com.atproto.server.createSession` with JSON body `{"identifier": "<handle or email>", "password": "<app_password>"}`. Response JSON has `accessJwt`, `refreshJwt`, `did`, `handle` (and optionally `didDoc`). Content-Type: `application/json`. 25 + - ✓ `com.atproto.server.getServiceAuth`: GET to `/xrpc/com.atproto.server.getServiceAuth?aud=<did>&lxm=<nsid>&exp=<unix-seconds-absolute>`. Returns `{"token": "<jwt>"}`. Requires `Authorization: Bearer <access_jwt>`. Note: atproto lexicon uses `exp` = absolute UNIX seconds, not duration. Use `now + 60`. 26 + - ✓ `atproto-proxy` header: format `<did>#<service-id>`. For labelers, the service-id fragment is `atproto_labeler`. Example: `atproto-proxy: did:plc:xxx#atproto_labeler`. 27 + - ✓ PDS endpoint discovery: the user's PDS is discovered via their DID document's `#atproto_pds` service entry. The tool's existing `IdentityFacts.pds_endpoint` is populated during the identity stage when a handle/DID target is used — reuse it. 28 + 29 + --- 30 + 31 + ## Acceptance criteria coverage 32 + 33 + This phase implements and tests: 34 + 35 + ### labeler-report-stage.AC5: PDS `getServiceAuth` mode 36 + - **labeler-report-stage.AC5.1 Success:** `pds_service_auth_accepted` emits `Pass` when `createSession` + `getServiceAuth` + labeler POST all succeed. 37 + - **labeler-report-stage.AC5.2 Failure (labeler-side):** emits `SpecViolation` (diagnostic `labeler::report::pds_service_auth_rejected`) when labeler returns non-2xx for the PDS-minted JWT. 38 + - **labeler-report-stage.AC5.3 Failure (PDS-side):** emits `NetworkError` when PDS is unreachable, credentials are rejected, or `getServiceAuth` returns an error. 39 + - **labeler-report-stage.AC5.4 Skip:** emits `Skipped` with reason "requires --handle, --app-password, and --commit-report" when any of the three are missing. 40 + 41 + ### labeler-report-stage.AC6: PDS-proxied mode 42 + - **labeler-report-stage.AC6.1 Success:** `pds_proxied_accepted` emits `Pass` when the proxied POST returns 2xx from the PDS. 43 + - **labeler-report-stage.AC6.2 Failure (labeler-side):** emits `SpecViolation` (diagnostic `labeler::report::pds_proxied_rejected`) when the PDS surfaces a labeler-side rejection (status/error envelope indicating downstream 4xx/5xx). 44 + - **labeler-report-stage.AC6.3 Failure (PDS-side):** emits `NetworkError` when PDS is unreachable or rejects the proxy attempt itself. 45 + - **labeler-report-stage.AC6.4 Skip:** emits `Skipped` ("requires --handle, --app-password, and --commit-report") when any of the three are missing. 46 + 47 + ### labeler-report-stage.AC7: Never-short-circuit and row-count invariants (re-verified) 48 + - **labeler-report-stage.AC7.1 Row count:** exactly 10 rows in every run (re-verified via end-to-end snapshot tests in Phase 8). 49 + 50 + ### labeler-report-stage.AC8: CLI flag handling 51 + - **labeler-report-stage.AC8.1 Both-or-neither:** `--handle` without `--app-password` (and vice versa) produces a clap parse error before any stage runs. 52 + - **labeler-report-stage.AC8.3 Subject override:** `--report-subject-did <did>` replaces the computed default subject in the body of committing checks. 53 + - **labeler-report-stage.AC8.4 Exit codes:** Exit 1 on any `SpecViolation`; exit 2 on any `NetworkError` absent `SpecViolation`; exit 0 otherwise. 54 + 55 + --- 56 + 57 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 58 + <!-- START_TASK_1 --> 59 + ### Task 1: Add `--handle` and `--app-password` with clap `requires` 60 + 61 + **Verifies:** AC8.1. 62 + 63 + **Files:** 64 + - Modify: `src/commands/test/labeler.rs` — extend `LabelerCmd` with two new flags using `requires`. 65 + 66 + **Implementation:** 67 + 68 + ```rust 69 + #[derive(Debug, Args)] 70 + pub struct LabelerCmd { 71 + // ... existing fields ... 72 + 73 + /// User handle for PDS-mediated report modes. Must be supplied together 74 + /// with --app-password; enables `pds_service_auth_accepted` and 75 + /// `pds_proxied_accepted` checks when combined with --commit-report. 76 + #[arg(long, requires = "app_password")] 77 + pub handle: Option<String>, 78 + 79 + /// App password for PDS-mediated report modes. Must be supplied 80 + /// together with --handle. 81 + #[arg(long, requires = "handle")] 82 + pub app_password: Option<String>, 83 + } 84 + ``` 85 + 86 + **Notes for the implementor:** 87 + - clap converts the field name to kebab-case automatically: `handle` → `--handle`, `app_password` → `--app-password`. The `requires = "..."` string references the *struct field name* (snake_case), not the flag name. 88 + - clap's `requires` is symmetric: both `--handle --app-password alice` and `--app-password alice --handle foo` parse; either one alone fails with a parse error. 89 + 90 + **Testing:** 91 + 92 + Add to `tests/labeler_cli.rs`: 93 + 94 + ```rust 95 + #[test] 96 + fn ac8_1_handle_without_app_password_fails() { 97 + let output = Command::cargo_bin("atproto-devtool") 98 + .expect("bin") 99 + .args([ 100 + "test", 101 + "labeler", 102 + "alice.bsky.social", 103 + "--handle", 104 + "alice.bsky.social", 105 + ]) 106 + .output() 107 + .expect("run"); 108 + assert!(!output.status.success(), "expected parse failure"); 109 + let stderr = String::from_utf8_lossy(&output.stderr); 110 + assert!( 111 + stderr.contains("--app-password") || stderr.contains("app_password"), 112 + "stderr should mention missing --app-password, got: {stderr}" 113 + ); 114 + } 115 + 116 + #[test] 117 + fn ac8_1_app_password_without_handle_fails() { 118 + let output = Command::cargo_bin("atproto-devtool") 119 + .expect("bin") 120 + .args([ 121 + "test", 122 + "labeler", 123 + "alice.bsky.social", 124 + "--app-password", 125 + "xxxx-xxxx-xxxx-xxxx", 126 + ]) 127 + .output() 128 + .expect("run"); 129 + assert!(!output.status.success(), "expected parse failure"); 130 + } 131 + ``` 132 + 133 + **Verification:** 134 + Run: `cargo test --test labeler_cli ac8_1_` 135 + Expected: both tests pass. 136 + 137 + **Commit:** `feat(labeler): add --handle and --app-password with clap requires` 138 + <!-- END_TASK_1 --> 139 + 140 + <!-- START_TASK_2 --> 141 + ### Task 2: `PdsXrpcClient` trait + production `RealPdsXrpcClient` + test `FakePdsXrpcClient` 142 + 143 + **Verifies:** AC5.1, AC5.3, AC6.1, AC6.3 (infrastructure). 144 + 145 + **Files:** 146 + - Modify: `src/commands/test/labeler/create_report.rs` — add the trait and real impl. 147 + - Modify: `tests/common/mod.rs` — add `FakePdsXrpcClient`. 148 + 149 + **Implementation:** 150 + 151 + ```rust 152 + /// A response from the PDS XRPC seam. 153 + #[derive(Debug)] 154 + pub struct RawPdsXrpcResponse { 155 + pub status: reqwest::StatusCode, 156 + pub raw_body: Arc<[u8]>, 157 + pub content_type: Option<String>, 158 + pub source_url: String, 159 + } 160 + 161 + /// Narrow seam for POSTing/GETting against the user's PDS. 162 + /// 163 + /// The existing `HttpClient` in `src/common/identity.rs` is GET-only and 164 + /// does not support bearer headers or request bodies. This trait exists 165 + /// to keep those capabilities out of the identity-resolution seam. 166 + #[async_trait] 167 + pub trait PdsXrpcClient: Send + Sync { 168 + /// POST `body` (JSON-serialized) to the PDS endpoint at the given path 169 + /// (e.g., `"xrpc/com.atproto.server.createSession"`). Optional bearer 170 + /// and `atproto-proxy` headers. 171 + async fn post( 172 + &self, 173 + path: &str, 174 + bearer: Option<&str>, 175 + atproto_proxy: Option<&str>, 176 + body: &serde_json::Value, 177 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError>; 178 + 179 + /// GET the PDS endpoint at the given path with optional bearer and 180 + /// URL-encoded query pairs. 181 + async fn get( 182 + &self, 183 + path: &str, 184 + bearer: Option<&str>, 185 + query: &[(&str, &str)], 186 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError>; 187 + } 188 + 189 + pub struct RealPdsXrpcClient { 190 + client: reqwest::Client, 191 + base: url::Url, 192 + } 193 + 194 + impl RealPdsXrpcClient { 195 + pub fn new(client: reqwest::Client, base: url::Url) -> Self { 196 + Self { client, base } 197 + } 198 + } 199 + 200 + #[async_trait] 201 + impl PdsXrpcClient for RealPdsXrpcClient { 202 + async fn post( 203 + &self, 204 + path: &str, 205 + bearer: Option<&str>, 206 + atproto_proxy: Option<&str>, 207 + body: &serde_json::Value, 208 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 209 + let mut url = self.base.clone(); 210 + url.set_path(path); 211 + let source_url = url.to_string(); 212 + let mut req = self 213 + .client 214 + .post(url.as_str()) 215 + .header("Content-Type", "application/json") 216 + .body(serde_json::to_vec(body).expect("serde_json::Value always serializes")); 217 + if let Some(b) = bearer { 218 + req = req.header("Authorization", format!("Bearer {b}")); 219 + } 220 + if let Some(p) = atproto_proxy { 221 + req = req.header("atproto-proxy", p); 222 + } 223 + let resp = req 224 + .send() 225 + .await 226 + .map_err(|e| CreateReportStageError::Transport { 227 + message: e.to_string(), 228 + source: Some(Box::new(e)), 229 + })?; 230 + let status = resp.status(); 231 + let content_type = resp 232 + .headers() 233 + .get(reqwest::header::CONTENT_TYPE) 234 + .and_then(|h| h.to_str().ok()) 235 + .map(|s| s.to_ascii_lowercase()); 236 + let body = resp 237 + .bytes() 238 + .await 239 + .map_err(|e| CreateReportStageError::Transport { 240 + message: e.to_string(), 241 + source: Some(Box::new(e)), 242 + })?; 243 + Ok(RawPdsXrpcResponse { 244 + status, 245 + raw_body: Arc::from(body.as_ref()), 246 + content_type, 247 + source_url, 248 + }) 249 + } 250 + 251 + async fn get( 252 + &self, 253 + path: &str, 254 + bearer: Option<&str>, 255 + query: &[(&str, &str)], 256 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 257 + let mut url = self.base.clone(); 258 + url.set_path(path); 259 + { 260 + let mut pairs = url.query_pairs_mut(); 261 + for (k, v) in query { 262 + pairs.append_pair(k, v); 263 + } 264 + } 265 + let source_url = url.to_string(); 266 + let mut req = self.client.get(url.as_str()); 267 + if let Some(b) = bearer { 268 + req = req.header("Authorization", format!("Bearer {b}")); 269 + } 270 + let resp = req 271 + .send() 272 + .await 273 + .map_err(|e| CreateReportStageError::Transport { 274 + message: e.to_string(), 275 + source: Some(Box::new(e)), 276 + })?; 277 + let status = resp.status(); 278 + let content_type = resp 279 + .headers() 280 + .get(reqwest::header::CONTENT_TYPE) 281 + .and_then(|h| h.to_str().ok()) 282 + .map(|s| s.to_ascii_lowercase()); 283 + let body = resp 284 + .bytes() 285 + .await 286 + .map_err(|e| CreateReportStageError::Transport { 287 + message: e.to_string(), 288 + source: Some(Box::new(e)), 289 + })?; 290 + Ok(RawPdsXrpcResponse { 291 + status, 292 + raw_body: Arc::from(body.as_ref()), 293 + content_type, 294 + source_url, 295 + }) 296 + } 297 + } 298 + ``` 299 + 300 + For `tests/common/mod.rs`, add a `FakePdsXrpcClient` that follows the same scripted-FIFO + request-capture pattern as `FakeCreateReportTee`. Key difference: script-keyed-by-path OR sequential. Go sequential (FIFO) for consistency with the other fakes. 301 + 302 + ```rust 303 + // In tests/common/mod.rs: 304 + 305 + use atproto_devtool::commands::test::labeler::create_report::{ 306 + PdsXrpcClient, RawPdsXrpcResponse, 307 + }; 308 + 309 + #[derive(Debug, Clone)] 310 + pub enum FakePdsXrpcResponse { 311 + Transport { message: String }, 312 + Response { status: u16, body: Vec<u8> }, 313 + } 314 + 315 + #[derive(Debug, Clone)] 316 + pub struct RecordedPdsRequest { 317 + pub method: &'static str, // "POST" or "GET" 318 + pub path: String, 319 + pub bearer: Option<String>, 320 + pub atproto_proxy: Option<String>, 321 + pub body: Option<serde_json::Value>, 322 + pub query: Vec<(String, String)>, 323 + } 324 + 325 + pub struct FakePdsXrpcClient { 326 + scripts: Arc<Mutex<Vec<FakePdsXrpcResponse>>>, 327 + recorded: Arc<Mutex<Vec<RecordedPdsRequest>>>, 328 + } 329 + 330 + // ... impl new, enqueue, recorded_requests, last_request matching FakeCreateReportTee ... 331 + 332 + #[async_trait] 333 + impl PdsXrpcClient for FakePdsXrpcClient { 334 + async fn post( 335 + &self, 336 + path: &str, 337 + bearer: Option<&str>, 338 + atproto_proxy: Option<&str>, 339 + body: &serde_json::Value, 340 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 341 + self.recorded.lock().unwrap().push(RecordedPdsRequest { 342 + method: "POST", 343 + path: path.to_string(), 344 + bearer: bearer.map(String::from), 345 + atproto_proxy: atproto_proxy.map(String::from), 346 + body: Some(body.clone()), 347 + query: Vec::new(), 348 + }); 349 + self.dispatch_next() 350 + } 351 + async fn get( 352 + &self, 353 + path: &str, 354 + bearer: Option<&str>, 355 + query: &[(&str, &str)], 356 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 357 + self.recorded.lock().unwrap().push(RecordedPdsRequest { 358 + method: "GET", 359 + path: path.to_string(), 360 + bearer: bearer.map(String::from), 361 + atproto_proxy: None, 362 + body: None, 363 + query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), 364 + }); 365 + self.dispatch_next() 366 + } 367 + } 368 + 369 + // dispatch_next pulls the head of scripts, returns matching Result. 370 + ``` 371 + 372 + **Testing:** 373 + 374 + Smoke tests in `tests/common_fakes.rs` (or wherever FakeCreateReportTee tests live) — two quick asserts that enqueue + call + recorded work. 375 + 376 + **Verification:** 377 + Run: `cargo build` 378 + Expected: clean. 379 + 380 + **Commit:** `feat(create_report): PdsXrpcClient trait + Real impl` 381 + `feat(test-common): FakePdsXrpcClient` 382 + (two commits is fine; one is fine too — implementor's call) 383 + <!-- END_TASK_2 --> 384 + 385 + <!-- START_TASK_3 --> 386 + ### Task 3: `PdsJwtFetcher` and `PdsProxiedPoster` concrete types 387 + 388 + **Verifies:** AC5.1, AC5.3, AC6.1, AC6.3 (infrastructure). 389 + 390 + **Files:** 391 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 392 + 393 + **Implementation:** 394 + 395 + ```rust 396 + /// Fetches a service-auth JWT from a PDS by first creating a session and 397 + /// then calling `getServiceAuth`. Used in mode-2 (`pds_service_auth_accepted`). 398 + pub struct PdsJwtFetcher<'a> { 399 + client: &'a dyn PdsXrpcClient, 400 + } 401 + 402 + #[derive(Debug)] 403 + pub enum PdsJwtFetchError { 404 + /// Any failure at the PDS boundary — unreachable, auth rejected, etc. 405 + Pds { message: String }, 406 + } 407 + 408 + impl<'a> PdsJwtFetcher<'a> { 409 + pub fn new(client: &'a dyn PdsXrpcClient) -> Self { 410 + Self { client } 411 + } 412 + 413 + /// Run `createSession` then `getServiceAuth`, returning the minted 414 + /// service-auth JWT. 415 + pub async fn fetch( 416 + &self, 417 + handle: &str, 418 + app_password: &str, 419 + aud: &str, 420 + lxm: &str, 421 + exp_absolute_unix: i64, 422 + ) -> Result<String, PdsJwtFetchError> { 423 + // 1. createSession. 424 + let body = serde_json::json!({ 425 + "identifier": handle, 426 + "password": app_password, 427 + }); 428 + let resp = self 429 + .client 430 + .post("xrpc/com.atproto.server.createSession", None, None, &body) 431 + .await 432 + .map_err(|e| PdsJwtFetchError::Pds { 433 + message: format!("createSession transport: {e}"), 434 + })?; 435 + if !resp.status.is_success() { 436 + return Err(PdsJwtFetchError::Pds { 437 + message: format!( 438 + "createSession returned status {}", 439 + resp.status 440 + ), 441 + }); 442 + } 443 + let session: serde_json::Value = serde_json::from_slice(&resp.raw_body) 444 + .map_err(|e| PdsJwtFetchError::Pds { 445 + message: format!("createSession body not JSON: {e}"), 446 + })?; 447 + let access_jwt = session["accessJwt"] 448 + .as_str() 449 + .ok_or_else(|| PdsJwtFetchError::Pds { 450 + message: "createSession response missing accessJwt".to_string(), 451 + })? 452 + .to_string(); 453 + 454 + // 2. getServiceAuth (GET with query params). 455 + let exp_s = exp_absolute_unix.to_string(); 456 + let resp = self 457 + .client 458 + .get( 459 + "xrpc/com.atproto.server.getServiceAuth", 460 + Some(&access_jwt), 461 + &[("aud", aud), ("lxm", lxm), ("exp", &exp_s)], 462 + ) 463 + .await 464 + .map_err(|e| PdsJwtFetchError::Pds { 465 + message: format!("getServiceAuth transport: {e}"), 466 + })?; 467 + if !resp.status.is_success() { 468 + return Err(PdsJwtFetchError::Pds { 469 + message: format!( 470 + "getServiceAuth returned status {}", 471 + resp.status 472 + ), 473 + }); 474 + } 475 + let auth: serde_json::Value = serde_json::from_slice(&resp.raw_body) 476 + .map_err(|e| PdsJwtFetchError::Pds { 477 + message: format!("getServiceAuth body not JSON: {e}"), 478 + })?; 479 + let token = auth["token"] 480 + .as_str() 481 + .ok_or_else(|| PdsJwtFetchError::Pds { 482 + message: "getServiceAuth response missing token".to_string(), 483 + })? 484 + .to_string(); 485 + 486 + Ok(token) 487 + } 488 + } 489 + 490 + /// Posts `com.atproto.moderation.createReport` to the PDS (not the 491 + /// labeler) with the `atproto-proxy` header, letting the PDS mint and 492 + /// forward the JWT itself. 493 + pub struct PdsProxiedPoster<'a> { 494 + client: &'a dyn PdsXrpcClient, 495 + } 496 + 497 + impl<'a> PdsProxiedPoster<'a> { 498 + pub fn new(client: &'a dyn PdsXrpcClient) -> Self { 499 + Self { client } 500 + } 501 + 502 + /// Post the createReport body through the PDS with the given user 503 + /// access JWT. Returns the `RawPdsXrpcResponse` so the caller can 504 + /// classify success / labeler-side rejection / PDS-side rejection. 505 + pub async fn post( 506 + &self, 507 + labeler_did: &str, 508 + access_jwt: &str, 509 + body: &serde_json::Value, 510 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 511 + self.client 512 + .post( 513 + "xrpc/com.atproto.moderation.createReport", 514 + Some(access_jwt), 515 + Some(&format!("{labeler_did}#atproto_labeler")), 516 + body, 517 + ) 518 + .await 519 + } 520 + } 521 + ``` 522 + 523 + **Notes for the implementor:** 524 + - `PdsJwtFetchError` is a single-variant enum because the stage treats *every* PDS-side failure as `NetworkError` (AC5.3 / AC6.3). We carry a human-readable message; no miette diagnostic is wired (NetworkError rows don't display diagnostics per `src/commands/test/labeler/report.rs:274-276`). 525 + 526 + **Testing:** Covered by Task 4 integration tests. 527 + 528 + **Verification:** 529 + Run: `cargo build` 530 + Expected: clean. 531 + 532 + **Commit:** `feat(create_report): PdsJwtFetcher and PdsProxiedPoster` 533 + <!-- END_TASK_3 --> 534 + <!-- END_SUBCOMPONENT_A --> 535 + 536 + <!-- START_SUBCOMPONENT_B (tasks 4-6) --> 537 + <!-- START_TASK_4 --> 538 + ### Task 4: Wire `report::pds_service_auth_accepted` and `report::pds_proxied_accepted` checks 539 + 540 + **Verifies:** AC5.1-AC5.4, AC6.1-AC6.4. 541 + 542 + **Files:** 543 + - Modify: `src/commands/test/labeler/pipeline.rs` — add `pds_xrpc_client: Option<&'a dyn PdsXrpcClient>` to `LabelerOptions`. (Alternatively a `PdsXrpcKind::Real/Test` enum matching `HttpTee`; pick the kind-enum pattern for consistency.) 544 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase 4 Phase-8 stubs with real check logic. 545 + - Modify: `src/commands/test/labeler.rs` — construct `RealPdsXrpcClient` and `PdsCredentials` in `LabelerCmd::run` when `handle` + `app_password` are set; populate `LabelerOptions` fields. 546 + 547 + **Implementation (stage check logic):** 548 + 549 + Replace this block in `run()`: 550 + 551 + ```rust 552 + results.push(Check::PdsServiceAuthAccepted.skip("not yet implemented (Phase 8)")); 553 + results.push(Check::PdsProxiedAccepted.skip("not yet implemented (Phase 8)")); 554 + ``` 555 + 556 + with: 557 + 558 + ```rust 559 + // Compute the gating precondition common to both PDS checks. 560 + let pds_gate_reason: &'static str = 561 + "requires --handle, --app-password, and --commit-report"; 562 + let pds_ready = 563 + opts.commit_report && opts.pds_credentials.is_some() && opts.pds_xrpc_client.is_some(); 564 + 565 + if !pds_ready { 566 + results.push(Check::PdsServiceAuthAccepted.skip(pds_gate_reason)); 567 + results.push(Check::PdsProxiedAccepted.skip(pds_gate_reason)); 568 + } else { 569 + // Safe to unwrap thanks to pds_ready. 570 + let creds = opts.pds_credentials.expect("pds_ready implies creds"); 571 + let pds_client = opts.pds_xrpc_client.expect("pds_ready implies client"); 572 + 573 + // Recompute locality for pollution-avoidance. 574 + let is_local = crate::common::identity::is_local_labeler_hostname( 575 + &id_facts.labeler_endpoint, 576 + ); 577 + let reason_type = pollution::choose_reason_type( 578 + id_facts.reason_types.as_deref().unwrap_or(&[]), 579 + is_local, 580 + ); 581 + // For PDS modes the reporter DID is the PDS-resolved user DID, not 582 + // the self-mint DID. We use the session's DID after createSession; 583 + // for body-shaping before the session is known we tentatively use 584 + // the override (if any) or defer — simplest: run createSession first 585 + // and use its `did` field. 586 + 587 + // AC5.1-AC5.4 — PDS getServiceAuth direct-POST. 588 + let fetcher = PdsJwtFetcher::new(pds_client); 589 + // createSession implicitly happens inside fetcher.fetch; we also 590 + // need the user DID from it for subject shaping. Refactor: 591 + match fetch_session_and_did(pds_client, &creds.handle, &creds.app_password).await { 592 + Err(message) => { 593 + results.push(Check::PdsServiceAuthAccepted.network_error(message.clone())); 594 + // AC6.3: if session fetch fails, proxied mode also fails at PDS. 595 + results.push(Check::PdsProxiedAccepted.network_error(message)); 596 + } 597 + Ok(session) => { 598 + let user_did = Did(session.did); 599 + let access_jwt = session.access_jwt; 600 + let subject = pollution::choose_subject( 601 + id_facts.subject_types.as_deref().unwrap_or(&[]), 602 + &user_did, 603 + opts.report_subject_override, 604 + is_local, 605 + ); 606 + let sentinel = sentinel::build(opts.run_id, std::time::SystemTime::now()); 607 + let pds_body = serde_json::json!({ 608 + "reasonType": reason_type, 609 + "subject": subject, 610 + "reason": sentinel, 611 + }); 612 + 613 + // Mode 2: getServiceAuth direct-POST. 614 + let exp_abs = now + 60; 615 + match fetcher 616 + .fetch( 617 + &creds.handle, 618 + &creds.app_password, 619 + &id_facts.did.0, 620 + "com.atproto.moderation.createReport", 621 + exp_abs, 622 + ) 623 + .await 624 + { 625 + Err(PdsJwtFetchError::Pds { message }) => { 626 + results.push(Check::PdsServiceAuthAccepted.network_error(message)); 627 + } 628 + Ok(service_jwt) => { 629 + match report_tee 630 + .post_create_report(Some(&service_jwt), &pds_body) 631 + .await 632 + { 633 + Ok(resp) if resp.status.is_success() => { 634 + results.push(Check::PdsServiceAuthAccepted.pass()); 635 + } 636 + Ok(resp) => { 637 + let diag = Box::new(PdsServiceAuthRejected { 638 + status: resp.status.as_u16(), 639 + source_code: body_as_named_source(&resp), 640 + span: None, 641 + }); 642 + results.push( 643 + Check::PdsServiceAuthAccepted.spec_violation(Some(diag)), 644 + ); 645 + } 646 + Err(CreateReportStageError::Transport { message, .. }) => { 647 + // Labeler-side transport failure during direct POST. 648 + results.push( 649 + Check::PdsServiceAuthAccepted.network_error(message), 650 + ); 651 + } 652 + } 653 + } 654 + } 655 + 656 + // Mode 3: PDS-proxied. 657 + let proxier = PdsProxiedPoster::new(pds_client); 658 + match proxier 659 + .post(&id_facts.did.0, &access_jwt, &pds_body) 660 + .await 661 + { 662 + Err(CreateReportStageError::Transport { message, .. }) => { 663 + // Transport to the PDS itself; classify PDS-side. 664 + results.push(Check::PdsProxiedAccepted.network_error(message)); 665 + } 666 + Ok(resp) if resp.status.is_success() => { 667 + results.push(Check::PdsProxiedAccepted.pass()); 668 + } 669 + Ok(resp) => { 670 + // PDS surfaced a non-2xx. Interpret per envelope to 671 + // distinguish PDS-side vs labeler-side: 672 + let envelope = XrpcErrorEnvelope::parse(&resp.raw_body); 673 + let err_name = envelope.as_ref().and_then(|e| e.error.clone()); 674 + let is_upstream_label_error = matches!( 675 + err_name.as_deref(), 676 + Some("UpstreamError") | Some("UpstreamFailure") 677 + ) || resp.status.as_u16() == 502 678 + || resp.status.as_u16() == 504; 679 + if is_upstream_label_error { 680 + // AC6.2: labeler-side rejection surfaced by PDS. 681 + let diag = Box::new(PdsProxiedRejected { 682 + status: resp.status.as_u16(), 683 + source_code: body_as_named_source_from_pds(&resp), 684 + span: None, 685 + }); 686 + results.push( 687 + Check::PdsProxiedAccepted.spec_violation(Some(diag)), 688 + ); 689 + } else { 690 + // AC6.3: PDS-side rejection of the proxy attempt. 691 + results.push(Check::PdsProxiedAccepted.network_error( 692 + format!("PDS rejected proxy attempt with status {}", resp.status), 693 + )); 694 + } 695 + } 696 + } 697 + } 698 + } 699 + } 700 + 701 + /// Convenience wrapper that does createSession and returns both the DID 702 + /// and the accessJwt. Mirrors part of `PdsJwtFetcher::fetch` but also 703 + /// exposes the user DID. 704 + struct SessionResult { 705 + did: String, 706 + access_jwt: String, 707 + } 708 + 709 + async fn fetch_session_and_did( 710 + client: &dyn PdsXrpcClient, 711 + handle: &str, 712 + app_password: &str, 713 + ) -> Result<SessionResult, String> { 714 + let body = serde_json::json!({ "identifier": handle, "password": app_password }); 715 + let resp = client 716 + .post("xrpc/com.atproto.server.createSession", None, None, &body) 717 + .await 718 + .map_err(|e| format!("createSession transport: {e}"))?; 719 + if !resp.status.is_success() { 720 + return Err(format!("createSession returned {}", resp.status)); 721 + } 722 + let session: serde_json::Value = 723 + serde_json::from_slice(&resp.raw_body).map_err(|e| format!("createSession body: {e}"))?; 724 + let did = session["did"] 725 + .as_str() 726 + .ok_or("createSession missing did")? 727 + .to_string(); 728 + let access_jwt = session["accessJwt"] 729 + .as_str() 730 + .ok_or("createSession missing accessJwt")? 731 + .to_string(); 732 + Ok(SessionResult { did, access_jwt }) 733 + } 734 + 735 + /// Named source for a PDS response (source_url is the PDS URL). 736 + fn body_as_named_source_from_pds(resp: &RawPdsXrpcResponse) -> NamedSource<Arc<[u8]>> { 737 + let pretty = pretty_json_for_display(&resp.raw_body); 738 + NamedSource::new(resp.source_url.clone(), Arc::from(pretty)) 739 + } 740 + ``` 741 + 742 + Add the two new diagnostics: 743 + 744 + ```rust 745 + #[derive(Debug, Error, Diagnostic)] 746 + #[error("Labeler rejected PDS-minted service-auth JWT (status {status})")] 747 + #[diagnostic( 748 + code = "labeler::report::pds_service_auth_rejected", 749 + help = "The PDS issued a service-auth JWT for this user bound to the labeler's DID and the createReport NSID; the labeler should have accepted it." 750 + )] 751 + pub struct PdsServiceAuthRejected { 752 + pub status: u16, 753 + #[source_code] 754 + pub source_code: NamedSource<Arc<[u8]>>, 755 + #[label("rejected here")] 756 + pub span: Option<SourceSpan>, 757 + } 758 + 759 + #[derive(Debug, Error, Diagnostic)] 760 + #[error("Labeler rejected PDS-proxied createReport (status {status})")] 761 + #[diagnostic( 762 + code = "labeler::report::pds_proxied_rejected", 763 + help = "The PDS forwarded the createReport call on the user's behalf; the downstream labeler reached it but rejected the submission." 764 + )] 765 + pub struct PdsProxiedRejected { 766 + pub status: u16, 767 + #[source_code] 768 + pub source_code: NamedSource<Arc<[u8]>>, 769 + #[label("rejected here")] 770 + pub span: Option<SourceSpan>, 771 + } 772 + ``` 773 + 774 + **Notes for the implementor:** 775 + - The `UpstreamError` / 502 / 504 heuristic for distinguishing labeler-side vs PDS-side is an approximation — PDSes don't have a uniform error vocabulary yet. Document it loosely in the code comment. The tool should *err toward* `SpecViolation` when the status looks like a downstream problem (matches the design's "PDS surfaces a labeler-side rejection" phrasing in AC6.2). 776 + - The `now` variable is already in scope from Phase 6's template construction. Keep using it. 777 + 778 + **Implementation (pipeline + CLI plumbing):** 779 + 780 + ```rust 781 + // In pipeline.rs LabelerOptions: 782 + pub pds_xrpc_client: Option<&'a dyn PdsXrpcClient>, 783 + 784 + // In LabelerCmd::run: 785 + use crate::commands::test::labeler::create_report::RealPdsXrpcClient; 786 + 787 + // After identity stage (for handle targets) or inline for explicit PDS: 788 + let pds_credentials = match (self.handle.as_deref(), self.app_password.as_deref()) { 789 + (Some(h), Some(p)) => Some(pipeline::PdsCredentials { 790 + handle: h.to_string(), 791 + app_password: p.to_string(), 792 + }), 793 + _ => None, 794 + }; 795 + let pds_credentials_ref = pds_credentials.as_ref(); 796 + 797 + // For the PDS client URL: use identity_facts.pds_endpoint once identity 798 + // has run. But LabelerCmd::run constructs the options BEFORE running the 799 + // pipeline. Resolution: construct RealPdsXrpcClient lazily inside 800 + // run_pipeline, after identity. That means threading the raw shared 801 + // reqwest::Client through options. 802 + // 803 + // Simpler: pass the shared client as-is and let the report stage 804 + // construct the RealPdsXrpcClient from `identity_facts.pds_endpoint` at 805 + // stage entry. Amend LabelerOptions to carry an `Option<&reqwest::Client>` 806 + // for that purpose — or reuse the existing HttpTee::Real(client) reference 807 + // (which already carries it). 808 + // 809 + // Pipeline-side change: when pds_credentials is Some and HttpTee::Real, in 810 + // pipeline.rs's report-stage branch, construct RealPdsXrpcClient pointing 811 + // at identity_facts.pds_endpoint and pass it as the `pds_xrpc_client` 812 + // opt. 813 + ``` 814 + 815 + **Pipeline edit (revisited):** 816 + 817 + ```rust 818 + // In run_pipeline, in the report-stage branch: 819 + let pds_xrpc_client_owned: Option<RealPdsXrpcClient> = match (&opts.pds_credentials, &opts.create_report_tee) { 820 + (Some(_), CreateReportTeeKind::Real(client)) => { 821 + identity_output.facts.as_ref().map(|f| { 822 + // `client` is `&reqwest::Client`; dereference once and clone. 823 + RealPdsXrpcClient::new((*client).clone(), f.pds_endpoint.clone()) 824 + }) 825 + } 826 + _ => None, 827 + }; 828 + let pds_xrpc_client_ref: Option<&dyn PdsXrpcClient> = pds_xrpc_client_owned 829 + .as_ref() 830 + .map(|c| c as &dyn PdsXrpcClient); 831 + ``` 832 + 833 + Plus a corresponding `CreateReportTeeKind::Test` path where tests supply a `FakePdsXrpcClient` directly via a new field on `LabelerOptions`: 834 + 835 + ```rust 836 + pub pds_xrpc_client_override: Option<&'a dyn PdsXrpcClient>, 837 + ``` 838 + 839 + If `pds_xrpc_client_override.is_some()`, use it; else construct `RealPdsXrpcClient`. This keeps the test path explicit and the production path simple. 840 + 841 + **Testing:** 842 + 843 + Add AC5 and AC6 tests to `tests/labeler_report.rs`. Follow the same pattern as AC4 — enqueue responses on both the `FakeCreateReportTee` (for labeler POSTs) AND the `FakePdsXrpcClient` (for PDS calls), assert on row status and diagnostic codes. 844 + 845 + Tests to add: 846 + - `ac5_1_full_flow_passes` — createSession OK, getServiceAuth OK, labeler POST OK → Pass. 847 + - `ac5_2_labeler_rejects_service_auth_jwt` — createSession + getServiceAuth OK; labeler POST returns 401 → SpecViolation + `pds_service_auth_rejected`. 848 + - `ac5_3_pds_unreachable` — createSession transport error → NetworkError on the `pds_service_auth_accepted` row. 849 + - `ac5_4_missing_creds_or_commit_skips` — with/without each of handle+app_password+commit-report → Skipped with the gate reason. 850 + - `ac6_1_proxied_pass` — createSession OK; proxied POST returns 200 → Pass. 851 + - `ac6_2_labeler_side_rejection_via_proxy` — proxied POST returns 502 `UpstreamError` → SpecViolation + `pds_proxied_rejected`. 852 + - `ac6_3_pds_rejects_proxy` — proxied POST returns 400 `InvalidRequest` from PDS (not downstream-flavored) → NetworkError. 853 + - `ac6_4_missing_creds_or_commit_skips` — same as AC5.4 but for the proxied row. 854 + 855 + **Verification:** 856 + Run: `cargo test --test labeler_report ac5_ ac6_` 857 + Expected: all AC5 + AC6 tests pass. 858 + 859 + **Commit:** `feat(create_report): wire PDS service-auth and PDS-proxied checks (AC5, AC6)` 860 + <!-- END_TASK_4 --> 861 + 862 + <!-- START_TASK_5 --> 863 + ### Task 5: `--report-subject-did` integration test (AC8.3) 864 + 865 + **Verifies:** AC8.3. 866 + 867 + **Files:** 868 + - Modify: `tests/labeler_report.rs` — add a test that passes `report_subject_override = Some(&Did("did:plc:override".to_string()))` through options, drives the self_mint_accepted check, and asserts `tee.last_request().body["subject"]["did"]` equals `"did:plc:override"`. 869 + 870 + **Implementation:** Test pattern follows AC4.1. Implementation of `report_subject_override` is already complete in Phase 7 via `pollution::choose_subject`. This phase just verifies. 871 + 872 + **Verification:** 873 + Run: `cargo test --test labeler_report ac8_3` 874 + Expected: passes. 875 + 876 + **Commit:** `test(create_report): AC8.3 subject override` 877 + <!-- END_TASK_5 --> 878 + 879 + <!-- START_TASK_6 --> 880 + ### Task 6: End-to-end snapshot fixtures + AC7.1/AC8.4 881 + 882 + **Verifies:** AC7.1, AC8.4. 883 + 884 + **Files:** 885 + - Create: `tests/fixtures/labeler/report/all_pass_local_labeler/.gitkeep`, `all_pass_full_suite/.gitkeep`, `all_fail_misconfigured_labeler/.gitkeep`. 886 + - Modify: `tests/labeler_report.rs` or extend `tests/labeler_endtoend.rs` — add three whole-pipeline snapshot tests that run `run_pipeline` end-to-end with specific fake wirings and snapshot the rendered report. 887 + - Modify: `tests/labeler_cli.rs` — add AC8.4 exit-code tests. 888 + 889 + **Implementation (snapshot tests):** 890 + 891 + ```rust 892 + // In tests/labeler_endtoend.rs (or a new file): 893 + 894 + #[tokio::test] 895 + async fn report_all_pass_local_labeler_snapshot() { 896 + // Compose: local labeler endpoint, self-mint signer, commit=true, 897 + // no PDS credentials. 7 of 10 checks Pass (contract + 2 Phase 5 + 898 + // 4 Phase 6 + self_mint_accepted); PDS checks Skipped. 899 + // ... set up fakes, run run_pipeline ... 900 + let rendered: String = // render through RenderConfig { no_color: true } 901 + insta::assert_snapshot!("report_all_pass_local_labeler", rendered); 902 + } 903 + 904 + #[tokio::test] 905 + async fn report_all_pass_full_suite_snapshot() { 906 + // Compose: local labeler + self-mint + PDS creds + commit=true. 907 + // All 10 checks Pass. 908 + insta::assert_snapshot!("report_all_pass_full_suite", rendered); 909 + } 910 + 911 + #[tokio::test] 912 + async fn report_all_fail_misconfigured_labeler_snapshot() { 913 + // Compose: labeler that accepts everything (2xx for every POST). 914 + // Phase 5 + Phase 6 SpecViolations; Phase 7 + Phase 8 Pass. 915 + insta::assert_snapshot!("report_all_fail_misconfigured_labeler", rendered); 916 + } 917 + 918 + #[tokio::test] 919 + async fn report_stage_always_emits_10_rows() { 920 + // AC7.1: run with various flag combinations and assert the count. 921 + for (contract, commit, pds, self_mint_viable) in [ 922 + (true, false, false, false), 923 + (true, false, false, true), 924 + (true, true, false, true), 925 + (true, true, true, true), 926 + (false, false, false, false), 927 + (false, true, false, false), 928 + ] { 929 + let report = run_with_config(contract, commit, pds, self_mint_viable).await; 930 + let report_rows: Vec<_> = report.results.iter() 931 + .filter(|r| r.id.starts_with("report::")) 932 + .collect(); 933 + assert_eq!(report_rows.len(), 10, "case ({contract}, {commit}, {pds}, {self_mint_viable})"); 934 + } 935 + } 936 + ``` 937 + 938 + **Implementation (CLI exit-code tests):** 939 + 940 + ```rust 941 + // In tests/labeler_cli.rs: 942 + 943 + #[test] 944 + fn ac8_4_spec_violation_exits_1() { 945 + // Drive the CLI with a mocked target that produces a known 946 + // SpecViolation. Simplest: construct a LabelerReport programmatically 947 + // in a unit test inside src/commands/test/labeler/report.rs and 948 + // assert exit_code() — but that's already tested at 949 + // src/commands/test/labeler/report.rs:361-406. Keep AC8.4 coverage 950 + // there plus a light CLI smoke test. 951 + // 952 + // Here, spawn the CLI against a non-existent endpoint to trigger a 953 + // NetworkError path (exit 2) and assert the exit code. 954 + let out = Command::cargo_bin("atproto-devtool") 955 + .unwrap() 956 + .args(["test", "labeler", "https://doesnt-exist.example.test"]) 957 + .output() 958 + .unwrap(); 959 + // Depending on DNS availability, this may exit 2 (network) — don't 960 + // over-constrain; just assert exit is != 0 and != 1. 961 + assert_ne!(out.status.code(), Some(0)); 962 + } 963 + ``` 964 + 965 + **Notes for the implementor:** 966 + - AC8.4 is already well covered by the unit tests in `src/commands/test/labeler/report.rs:320-406`. The new CLI-level test just smokes the end-to-end exit-code path. 967 + - The all_fail_misconfigured_labeler snapshot exercises the "negatives Pass, positives Fail" matrix from the design — a useful reference test for regression detection. 968 + 969 + **Verification:** 970 + Run: `cargo test --test labeler_endtoend report_all_ report_stage_always` 971 + Expected: pass; accept new snapshots via `cargo insta review`. 972 + 973 + Run: `cargo test --test labeler_cli ac8_4_` 974 + Expected: pass. 975 + 976 + Run: `cargo test` (full suite) 977 + Expected: all Phase 1-8 tests pass; no regressions in pre-existing tests. 978 + 979 + **Commit:** `feat(create_report): end-to-end snapshots + AC7.1 + AC8.4` 980 + <!-- END_TASK_6 --> 981 + <!-- END_SUBCOMPONENT_B --> 982 + 983 + <!-- START_TASK_7 --> 984 + ### Task 7: Phase 8 integration check 985 + 986 + **Files:** None changed. 987 + 988 + **Implementation:** Final gate. 989 + 990 + **Verification:** 991 + Run: `cargo test` 992 + Expected: all Phase 1-8 tests pass. 993 + 994 + Run: `cargo insta pending-snapshots` 995 + Expected: none pending. 996 + 997 + Run: `cargo clippy --all-targets -- -D warnings` 998 + Expected: no warnings. 999 + 1000 + Run: `cargo fmt --check` 1001 + Expected: clean. 1002 + 1003 + Run: `cargo build --release` 1004 + Expected: clean release build. 1005 + 1006 + **Commit:** No new commit unless fixes were needed. 1007 + <!-- END_TASK_7 --> 1008 + 1009 + --- 1010 + 1011 + ## Phase 8 complete when 1012 + 1013 + - `--handle` / `--app-password` land with `requires` (AC8.1). 1014 + - `PdsXrpcClient` trait + `RealPdsXrpcClient` + `FakePdsXrpcClient` exist. 1015 + - `PdsJwtFetcher` and `PdsProxiedPoster` exist and drive the two PDS checks. 1016 + - `report::pds_service_auth_accepted` and `report::pds_proxied_accepted` are live (no longer stubs) with correct Pass/SpecViolation/NetworkError/Skipped classification. 1017 + - Three end-to-end snapshot fixtures pin the 10-row output contract for representative configurations. 1018 + - AC7.1 row-count invariant re-verified. 1019 + - AC8.3 subject override exercised. 1020 + - AC8.4 exit codes re-verified at CLI level. 1021 + - **Acceptance criteria satisfied:** AC5.1, AC5.2, AC5.3, AC5.4, AC6.1, AC6.2, AC6.3, AC6.4, AC7.1, AC7.2, AC8.1, AC8.2, AC8.3, AC8.4. 1022 + 1023 + ## Full AC coverage (post-Phase-8) 1024 + 1025 + | AC | Phase verifying | 1026 + |---|---| 1027 + | AC1.1–AC1.4 | Phase 4 | 1028 + | AC2.1–AC2.5 | Phase 5 | 1029 + | AC3.1–AC3.8 | Phase 6 | 1030 + | AC4.1–AC4.6 | Phase 7 | 1031 + | AC5.1–AC5.4 | Phase 8 | 1032 + | AC6.1–AC6.4 | Phase 8 | 1033 + | AC7.1 | Phases 4 + 8 | 1034 + | AC7.2 | Phase 4 | 1035 + | AC8.1 | Phase 8 | 1036 + | AC8.2 | Phase 2 + Phase 4 | 1037 + | AC8.3 | Phase 8 | 1038 + | AC8.4 | Phase 8 (existing coverage in `report.rs` tests) |
+142
docs/implementation-plans/2026-04-17-labeler-report-stage/test-requirements.md
··· 1 + # Labeler report stage — test requirements 2 + 3 + Last verified: 2026-04-17 4 + 5 + Generated from: 6 + - Design: docs/design-plans/2026-04-17-labeler-report-stage.md 7 + - Implementation: docs/implementation-plans/2026-04-17-labeler-report-stage/ 8 + 9 + ## Automated coverage 10 + 11 + ### labeler-report-stage.AC1: `report::contract_published` behavior 12 + 13 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 14 + |---|---|---|---|---|---| 15 + | AC1.1 | tests/labeler_report.rs | `ac1_1_contract_present_emits_pass` | 4 | (inline `make_identity_facts(Some(vec!["...#reasonSpam"]), Some(vec!["account"]))`) | `results[0].id == "report::contract_published"` and `results[0].status == CheckStatus::Pass`; row count is exactly 10 (AC7.1 sanity). | 16 + | AC1.2 | tests/labeler_report.rs | `ac1_2_contract_missing_without_commit_skips_stage` | 4 | (inline `make_identity_facts(None, None)`) | All 10 rows `CheckStatus::Skipped` with `skipped_reason == "labeler does not advertise report acceptance"`. | 17 + | AC1.3 | tests/labeler_report.rs | `ac1_3_contract_missing_with_commit_is_spec_violation` | 4 | (inline `make_identity_facts(None, None)`, `commit_report = true`) | `results[0].status == CheckStatus::SpecViolation`; attached diagnostic code equals `"labeler::report::contract_missing"`; `results[1..]` each `Skipped` with `"blocked by \`report::contract_published\`"`. | 18 + | AC1.4 | tests/labeler_report.rs | `ac1_4_empty_arrays_equivalent_to_absent` | 4 | (inline `make_identity_facts(Some(vec![]), Some(vec![]))`) | Behavior matches AC1.2 — `results[0]` is `Skipped` with the `"labeler does not advertise report acceptance"` reason. | 19 + | AC1.1–AC1.4 (rendered) | tests/labeler_report.rs | `snapshot_contract_present_no_commit`, `snapshot_contract_present_with_commit`, `snapshot_contract_missing_no_commit`, `snapshot_contract_missing_with_commit` | 4 | (inline synthetic `IdentityFacts`) | Four insta snapshots (`tests/snapshots/labeler_report__report_contract_present_no_commit.snap`, `..._with_commit.snap`, `..._missing_no_commit.snap`, `..._missing_with_commit.snap`) pin the full rendered report bytes including each check ID, status glyph, skipped reason string, and the `labeler::report::contract_missing` diagnostic code. | 20 + 21 + ### labeler-report-stage.AC2: No-JWT negative checks 22 + 23 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 24 + |---|---|---|---|---|---| 25 + | AC2.1 | tests/labeler_report.rs | `ac2_1_unauthenticated_401_with_envelope_passes` | 5 | (inline: `FakeCreateReportResponse::unauthorized("AuthenticationRequired", ...)` + `unauthorized("BadJwt", ...)`) | `results[1].id == "report::unauthenticated_rejected"` and `status == Pass`; `results[2].id == "report::malformed_bearer_rejected"` and `status == Pass` (co-asserts AC2.3 happy path). | 26 + | AC2.2 | tests/labeler_report.rs | `ac2_2_unauthenticated_200_is_spec_violation` | 5 | (inline: first `FakeCreateReportResponse::ok_empty()`, second `unauthorized`) | `results[1].status == SpecViolation`; diagnostic code `"labeler::report::unauthenticated_accepted"`. | 27 + | AC2.3 | tests/labeler_report.rs | `ac2_1_unauthenticated_401_with_envelope_passes` (same test as AC2.1) | 5 | (inline) | Second enqueued response is `unauthorized("BadJwt", ...)`; asserts `results[2].status == Pass`. | 28 + | AC2.4 | tests/labeler_report.rs | `ac2_4_malformed_bearer_200_is_spec_violation` | 5 | (inline: first `unauthorized`, second `ok_empty`) | `results[2].status == SpecViolation`; diagnostic code `"labeler::report::malformed_bearer_accepted"`. | 29 + | AC2.5 | tests/labeler_report.rs | `ac2_5_401_without_envelope_still_passes` | 5 | (inline: `FakeCreateReportResponse::Response { status: 401, body: b"{}" }` + a `401` with `<html>` body) | `results[1].status == Pass` and `results[2].status == Pass`; each `summary` contains the substring `"non-conformant envelope"`. | 30 + 31 + ### labeler-report-stage.AC3: Self-mint negative checks 32 + 33 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 34 + |---|---|---|---|---|---| 35 + | AC3.1 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` | 6 | (inline: enqueues 2 Phase-5 unauthorized, then `unauthorized("BadJwtAudience", ...)`, `unauthorized("BadJwtLexiconMethod", ...)`, `unauthorized("JwtExpired", ...)`, `bad_request("InvalidRequest", ...)`; `local_identity_facts()` with `labeler_endpoint = http://localhost:8080`) | `results[3].id == "report::wrong_aud_rejected"` and `status == Pass`. Co-asserts AC3.3 (`results[4].status == Pass`), AC3.4 (`results[5].status == Pass`), AC3.5 (`results[6].status == Pass`). | 36 + | AC3.2 | tests/labeler_report.rs | `ac3_2_wrong_aud_200_is_spec_violation` | 6 | (inline: `FakeCreateReportResponse::ok_empty()` at the `wrong_aud_rejected` slot) | `results[3].status == SpecViolation`; diagnostic code `"labeler::report::wrong_aud_accepted"`. Verifies the JWT submitted in the recorded request has `aud == "did:plc:0000000000000000000000000"` (mutation applied correctly). | 37 + | AC3.3 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` (success leg) + `ac3_3_wrong_lxm_200_is_spec_violation` (failure leg) | 6 | (inline) | Failure-leg test: `results[4].status == SpecViolation`; diagnostic code `"labeler::report::wrong_lxm_accepted"`; recorded JWT's `lxm == "com.atproto.server.getSession"`. | 38 + | AC3.4 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` (success leg) + `ac3_4_expired_200_is_spec_violation` (failure leg) | 6 | (inline) | Failure-leg test: `results[5].status == SpecViolation`; diagnostic code `"labeler::report::expired_accepted"`; recorded JWT's `exp == now - 300`. | 39 + | AC3.5 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` (co-asserts) | 6 | (inline) | `results[6].id == "report::rejected_shape_returns_400"` and `status == Pass` when the labeler returns `bad_request("InvalidRequest", ...)`; recorded body's `reasonType` is not in `id_facts.reason_types`. | 40 + | AC3.6 | tests/labeler_report.rs | `ac3_6_shape_not_400_emits_advisory` | 6 | (inline: enqueue a `401` at the `rejected_shape_returns_400` slot) | `results[6].status == Advisory`; diagnostic code `"labeler::report::shape_not_400"`. Second variant (parametrized or sibling test) exercises `500` response. | 41 + | AC3.7 | tests/labeler_report.rs | `ac3_7_non_local_labeler_skips_self_mint_checks` | 6 | (inline: `facts.labeler_endpoint = https://labeler.example.com`; `self_mint_signer = None`; `force_self_mint = false`) | For `i in 3..=6`, `results[i].status == Skipped` and `skipped_reason` contains `"--force-self-mint"`. | 42 + | AC3.8 | tests/labeler_report.rs | `ac3_8_force_self_mint_overrides_non_local` | 6 | (inline: non-local endpoint; `self_mint_signer = Some(&signer)`; `force_self_mint = true`) | `results[3].status == Pass` and `results[6].status == Pass` despite the non-local hostname — i.e., the four self-mint negative checks ran against the fake. | 43 + 44 + ### labeler-report-stage.AC4: Self-mint positive check 45 + 46 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 47 + |---|---|---|---|---|---| 48 + | AC4.1 | tests/labeler_report.rs | `ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject` | 7 | (inline: `local_identity_facts()` — `reasonSpam` + `account`; `commit_report = true`; `self_mint_signer = Some(...)`; `FakeCreateReportResponse::ok_empty()` at the positive-POST slot) | `results[7].id == "report::self_mint_accepted"` and `status == Pass`. `tee.last_request().body["reasonType"] == "com.atproto.moderation.defs#reasonSpam"` and `body["subject"]["$type"] == "com.atproto.admin.defs#repoRef"` (account subject, reporter DID = self-mint issuer DID). | 49 + | AC4.2 | tests/labeler_report.rs | `ac4_2_non_local_labeler_prefers_other_and_record` | 7 | (inline: facts advertise both `reasonSpam` + `reasonOther` and both `account` + `record`; `labeler_endpoint = https://labeler.example.com`; `force_self_mint = true`) | `results[7].status == Pass`. `last_request().body["reasonType"] == "com.atproto.moderation.defs#reasonOther"` and `body["subject"]["$type"] == "com.atproto.repo.strongRef"` pointing at `CONFORMANCE_REPORT_SUBJECT_URI`. | 50 + | AC4.3 | tests/labeler_report.rs | `ac4_3_non_2xx_is_spec_violation` | 7 | (inline: enqueue `Response { status: 400, body: {"error":"InvalidRequest","message":"nope"} }` at the positive-POST slot) | `results[7].status == SpecViolation`; diagnostic code `"labeler::report::self_mint_rejected"`. | 51 + | AC4.4 | tests/labeler_report.rs | `ac4_4_commit_false_skips` | 7 | (inline: `commit_report = false`; no positive POST enqueued) | `results[7].status == Skipped`; `skipped_reason` contains `"--commit-report"`. | 52 + | AC4.5 | tests/labeler_report.rs | `ac4_5_non_viable_skip_matches_phase_6_reason` | 7 | (inline: `labeler_endpoint = https://labeler.example.com`; `self_mint_signer = None`; `commit_report = true`) | `results[7].status == Skipped` — reason is the self-mint-unviable message matching the `--force-self-mint` hint. | 53 + | AC4.6 | tests/labeler_report.rs | `ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject` (same test as AC4.1) | 7 | (inline; test sets `run_id = "test-run-1234567890"`) | `tee.last_request().body["reason"]` starts with `"atproto-devtool conformance test"` and ends with the supplied run-id; also exercised by a dedicated assertion in the same test body. | 54 + 55 + ### labeler-report-stage.AC5: PDS `getServiceAuth` mode 56 + 57 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 58 + |---|---|---|---|---|---| 59 + | AC5.1 | tests/labeler_report.rs | `ac5_1_full_flow_passes` | 8 | (inline: `FakePdsXrpcClient` scripts: `200` for `createSession` (body carries `accessJwt` + `did`), `200` for `getServiceAuth` (body carries `token`), then a second `createSession` for the fetcher's second call; `FakeCreateReportTee` enqueues `ok_empty()` at the mode-2 POST slot; `commit_report = true`, `pds_credentials = Some(...)`, `pds_xrpc_client_override = Some(&fake_pds)`) | `results[8].id == "report::pds_service_auth_accepted"` and `status == Pass`. Recorded labeler POST's `Authorization` bearer equals the `token` returned by the fake `getServiceAuth`. | 60 + | AC5.2 | tests/labeler_report.rs | `ac5_2_labeler_rejects_service_auth_jwt` | 8 | (inline: PDS script is as AC5.1; `FakeCreateReportTee` enqueues `unauthorized("BadJwt", ...)` at the mode-2 POST slot) | `results[8].status == SpecViolation`; diagnostic code `"labeler::report::pds_service_auth_rejected"`. | 61 + | AC5.3 | tests/labeler_report.rs | `ac5_3_pds_unreachable` | 8 | (inline: PDS first script entry is `FakePdsXrpcResponse::Transport { message }` for `createSession`) | `results[8].status == NetworkError`; row summary contains the transport message. (Also verifies AC6.3 sibling: `results[9].status == NetworkError` because the shared `createSession` precondition failed.) | 62 + | AC5.4 | tests/labeler_report.rs | `ac5_4_missing_creds_or_commit_skips` | 8 | (inline: three parametrized sub-cases — missing handle, missing app_password, missing `commit_report`) | For each sub-case: `results[8].status == Skipped` with `skipped_reason == "requires --handle, --app-password, and --commit-report"`. | 63 + 64 + ### labeler-report-stage.AC6: PDS-proxied mode 65 + 66 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 67 + |---|---|---|---|---|---| 68 + | AC6.1 | tests/labeler_report.rs | `ac6_1_proxied_pass` | 8 | (inline: PDS scripts `createSession` 200, `getServiceAuth` 200, and `createReport` POST to PDS returns 200) | `results[9].id == "report::pds_proxied_accepted"` and `status == Pass`. Recorded PDS request has `atproto-proxy` header equal to `"<labeler-did>#atproto_labeler"` and `Authorization` bearer equal to the session `accessJwt`. | 69 + | AC6.2 | tests/labeler_report.rs | `ac6_2_labeler_side_rejection_via_proxy` | 8 | (inline: PDS's proxied createReport returns `502` with body `{"error":"UpstreamError","message":"..."}`) | `results[9].status == SpecViolation`; diagnostic code `"labeler::report::pds_proxied_rejected"`. Also verified for `status == 504` and body `{"error":"UpstreamFailure"}` via a sibling case. | 70 + | AC6.3 | tests/labeler_report.rs | `ac6_3_pds_rejects_proxy` | 8 | (inline: PDS's proxied createReport returns `400` `{"error":"InvalidRequest","message":"..."}` — not a downstream-flavored error) | `results[9].status == NetworkError`; row summary indicates PDS rejected the proxy attempt. Separate sibling test also covers AC5.3's shared `createSession` transport error producing `NetworkError` on this row. | 71 + | AC6.4 | tests/labeler_report.rs | `ac6_4_missing_creds_or_commit_skips` | 8 | (inline: three parametrized sub-cases as AC5.4) | For each sub-case: `results[9].status == Skipped` with `skipped_reason == "requires --handle, --app-password, and --commit-report"`. | 72 + 73 + ### labeler-report-stage.AC7: Never-short-circuit and row-count invariants 74 + 75 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 76 + |---|---|---|---|---|---| 77 + | AC7.1 | tests/labeler_report.rs | Every AC1–AC6 test (asserts `results.len() == 10`) | 4–8 | (inline) | Row count invariant enforced per-test. | 78 + | AC7.1 (matrix) | tests/labeler_endtoend.rs (or tests/labeler_report.rs) | `report_stage_always_emits_10_rows` | 8 | (inline parametrization over `(contract, commit, pds, self_mint_viable)` tuples: `(true,false,false,false)`, `(true,false,false,true)`, `(true,true,false,true)`, `(true,true,true,true)`, `(false,false,false,false)`, `(false,true,false,false)`) | For each configuration, filter `report.results` to `id.starts_with("report::")` and assert `len() == 10`. | 79 + | AC7.1 (snapshots) | tests/labeler_endtoend.rs | `report_all_pass_local_labeler_snapshot`, `report_all_pass_full_suite_snapshot`, `report_all_fail_misconfigured_labeler_snapshot` | 8 | `tests/fixtures/labeler/report/all_pass_local_labeler/`, `..._full_suite/`, `..._misconfigured_labeler/` (each with `.gitkeep` + any case-specific JSON) | Rendered-report insta snapshots include all 10 `report::*` rows in the canonical order; snapshot review confirms row count on every accepted diff. | 80 + | AC7.2 | tests/labeler_report.rs | `ac7_2_row_order_is_stable` | 4 | (inline) | Collected `results.iter().map(|r| r.id)` equals the 10-element canonical sequence starting with `"report::contract_published"` and ending with `"report::pds_proxied_accepted"`. Row order also re-pinned by every AC1–AC6 snapshot. | 81 + 82 + ### labeler-report-stage.AC8: CLI flag handling 83 + 84 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 85 + |---|---|---|---|---|---| 86 + | AC8.1 | tests/labeler_cli.rs | `ac8_1_handle_without_app_password_fails`, `ac8_1_app_password_without_handle_fails` | 8 | (none — `assert_cmd` invocation) | `assert_cmd` run of `atproto-devtool test labeler <target> --handle alice.bsky.social` exits non-zero and stderr mentions `--app-password` (or `app_password`); symmetric test for `--app-password` alone. Clap's `requires = "..."` makes this a parse-time error before any stage runs. | 87 + | AC8.2 (unit) | src/commands/test/labeler/create_report/self_mint.rs | `self_mint_signer_es256k_round_trips`, `self_mint_signer_es256_round_trips` | 2 | (in-test randomly generated keys + spawned DidDocServer) | For each curve, the published DID document's `verificationMethod[0].publicKeyMultibase` round-trips through `parse_multikey` to the expected `AnyVerifyingKey` variant (`K256` or `P256`). A token signed with `SelfMintSigner::sign_jwt` decodes via `verify_compact` with `header.alg == "ES256K"` (for `Es256k`) or `"ES256"` (for `Es256`). | 88 + | AC8.2 (CLI help) | tests/labeler_cli.rs | `help_lists_all_flags` (extended in Phase 4) | 4 | (none) | `--help` output includes `--self-mint-curve` with a default of `es256k`. | 89 + | AC8.3 | tests/labeler_report.rs | `ac8_3_subject_override_replaces_computed_default` | 8 | (inline: `report_subject_override = Some(&Did("did:plc:override"))`; commit-report = true; self-mint signer present; labeler returns `ok_empty()` for the positive POST) | `tee.last_request().body["subject"]["$type"] == "com.atproto.admin.defs#repoRef"` and `body["subject"]["did"] == "did:plc:override"`, regardless of what `subject_types` the identity facts advertise. | 90 + | AC8.4 (unit precedence) | src/commands/test/labeler/report.rs (existing `#[cfg(test)]` module, extended to cover Report-stage rows) | existing exit-code unit tests at `report.rs:361-406` | 8 | (inline `LabelerReport` construction) | Programmatically constructed reports with a `report::*` `SpecViolation` return `exit_code() == 1`; with only a `NetworkError` return `2`; all `Pass`/`Skipped`/`Advisory` return `0`. Precedence: `SpecViolation` beats `NetworkError`. | 91 + | AC8.4 (CLI smoke) | tests/labeler_cli.rs | `ac8_4_spec_violation_exits_1`, `ac8_4_network_error_exits_2` | 8 | (none; `assert_cmd` against an unreachable endpoint for the NetworkError path) | CLI invocation exit code matches the expected integer; smoke test confirms the unit-tested precedence is preserved when run end-to-end. | 92 + 93 + ## Human verification requirements 94 + 95 + None. Every AC listed in the design (AC1.1–AC8.4, 32 cases total) is automatable via the existing fake-based seams: 96 + 97 + - `FakeCreateReportTee` (Phase 3) scripts every labeler-side HTTP response, making AC1/AC2/AC3/AC4/AC7 fully deterministic. 98 + - `FakePdsXrpcClient` (Phase 8) scripts every PDS-side createSession / getServiceAuth / proxied-POST response, making AC5/AC6 fully deterministic. 99 + - `SelfMintSigner::spawn(curve)` binds a real `tokio::net::TcpListener` on `127.0.0.1:0` and serves a real DID document, so AC8.2's curve selection is exercised against the same code path the CLI uses without needing a real labeler. 100 + - `assert_cmd` makes clap parse errors (AC8.1) and process exit codes (AC8.4) testable as ordinary integration tests. 101 + 102 + The sentinel string AC (AC4.6) is specifically asserted on the recorded request body in `ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject`. Operators' ability to grep for the sentinel in a real moderation queue is a property of the chosen string format, which is pinned both by `SENTINEL_PREFIX` (unit-tested in `create_report::sentinel::tests`) and by the submitted-body assertion. 103 + 104 + The hard-coded `CONFORMANCE_REPORT_SUBJECT_URI` / `_CID` placeholders remain a release-gate checklist item (documented in Phase 7 and the design's "Pre-release TODO" section) — this is a pre-release content task, not a test-coverage gap. 105 + 106 + ## Coverage summary 107 + 108 + - Total ACs in design: 32 cases across 8 groups (AC1: 4, AC2: 5, AC3: 8, AC4: 6, AC5: 4, AC6: 4, AC7: 2, AC8: 4 — with AC8.2 verified at both unit and CLI-help layers, and AC8.4 verified at both unit-precedence and CLI-smoke layers). 109 + - Automated: 32 cases. 110 + - Human verification: 0 cases. 111 + - Uncovered (flag): 0. 112 + 113 + ## Snapshot coverage 114 + 115 + Stable check IDs pinned by at least one accepted automated snapshot: 116 + 117 + - `report::contract_published` — tests/snapshots/labeler_report__report_contract_present_no_commit.snap, ..._with_commit.snap, ..._missing_no_commit.snap, ..._missing_with_commit.snap (Phase 4). Re-pinned in tests/snapshots/labeler_endtoend__report_all_pass_local_labeler.snap, ..._full_suite.snap, ..._misconfigured_labeler.snap (Phase 8). 118 + - `report::unauthenticated_rejected` — Phase 4 stub snapshots (`"not yet implemented (Phase 5)"`), overwritten in Phase 5 with Pass/SpecViolation/Advisory snapshots. Re-pinned in Phase 8 end-to-end snapshots. 119 + - `report::malformed_bearer_rejected` — same coverage pattern as `unauthenticated_rejected` (Phases 4 → 5 → 8). 120 + - `report::wrong_aud_rejected` — Phase 4 stub ("not yet implemented (Phase 6)"), overwritten in Phase 6, re-pinned in Phase 8. 121 + - `report::wrong_lxm_rejected` — same (Phases 4 → 6 → 8). 122 + - `report::expired_rejected` — same (Phases 4 → 6 → 8). 123 + - `report::rejected_shape_returns_400` — same (Phases 4 → 6 → 8). 124 + - `report::self_mint_accepted` — Phase 4 stub ("not yet implemented (Phase 7)"), overwritten in Phase 7, re-pinned in Phase 8. 125 + - `report::pds_service_auth_accepted` — Phase 4 stub ("not yet implemented (Phase 8)"), overwritten in Phase 8. 126 + - `report::pds_proxied_accepted` — same (Phases 4 → 8). 127 + 128 + Stable diagnostic codes pinned by at least one accepted automated snapshot: 129 + 130 + - `labeler::report::contract_missing` — AC1.3 snapshot (Phase 4) and end-to-end snapshots (Phase 8). 131 + - `labeler::report::unauthenticated_accepted` — AC2.2 assertion + snapshot (Phase 5); end-to-end snapshot when misconfigured-labeler fixture exercises this path. 132 + - `labeler::report::malformed_bearer_accepted` — AC2.4 assertion + snapshot (Phase 5). 133 + - `labeler::report::wrong_aud_accepted` — AC3.2 assertion + snapshot (Phase 6). 134 + - `labeler::report::wrong_lxm_accepted` — AC3.3 failure-leg assertion + snapshot (Phase 6). 135 + - `labeler::report::expired_accepted` — AC3.4 failure-leg assertion + snapshot (Phase 6). 136 + - `labeler::report::shape_not_400` — AC3.6 assertion + snapshot (Phase 6). 137 + - `labeler::report::self_mint_rejected` — AC4.3 assertion + snapshot (Phase 7). 138 + - `labeler::report::pds_service_auth_rejected` — AC5.2 assertion + snapshot (Phase 8). 139 + - `labeler::report::pds_proxied_rejected` — AC6.2 assertion + snapshot (Phase 8). 140 + - `labeler::report::transport_error` — exercised indirectly by AC5.3 and AC6.3 `NetworkError` rows (Phase 8); the code string is carried on the `CreateReportStageError::Transport` variant and surfaces on any transport-level failure. 141 + 142 + The `all_fail_misconfigured_labeler_snapshot` fixture in Phase 8 is the single snapshot that exercises every negative-check `SpecViolation`/`Advisory` diagnostic code simultaneously (by scripting the labeler to return `200 OK` for every negative POST and `400 InvalidRequest` for the shape check). Its acceptance pins the complete diagnostic-code vocabulary as part of the public CLI contract.
+89
src/common/identity.rs
··· 749 749 } 750 750 } 751 751 752 + /// Encode an `AnyVerifyingKey` as the atproto multibase-multikey format: 753 + /// base58btc multibase prefix `z`, multicodec curve prefix, compressed SEC1 754 + /// public key bytes. 755 + /// 756 + /// See <https://atproto.com/specs/cryptography>. The inverse of `parse_multikey`. 757 + pub fn encode_multikey(key: &AnyVerifyingKey) -> String { 758 + // Multicodec varint prefixes (see https://github.com/multiformats/multicodec). 759 + const SECP256K1_PUB: &[u8] = &[0xe7, 0x01]; 760 + const P256_PUB: &[u8] = &[0x80, 0x24]; 761 + 762 + let (prefix, compressed): (&[u8], Vec<u8>) = match key { 763 + AnyVerifyingKey::K256(k) => { 764 + let point = k.to_encoded_point(true); 765 + (SECP256K1_PUB, point.as_bytes().to_vec()) 766 + } 767 + AnyVerifyingKey::P256(k) => { 768 + let point = k.to_encoded_point(true); 769 + (P256_PUB, point.as_bytes().to_vec()) 770 + } 771 + }; 772 + 773 + let mut buf = Vec::with_capacity(prefix.len() + compressed.len()); 774 + buf.extend_from_slice(prefix); 775 + buf.extend_from_slice(&compressed); 776 + multibase::encode(multibase::Base::Base58Btc, &buf) 777 + } 778 + 752 779 /// A historic key entry from a PLC audit log for a given verification method fragment. 753 780 #[derive(Debug, Clone, PartialEq, Eq)] 754 781 pub struct PlcHistoricKey { ··· 1619 1646 *expected, 1620 1647 "classification mismatch for {url}" 1621 1648 ); 1649 + } 1650 + } 1651 + 1652 + #[test] 1653 + fn encode_multikey_round_trip_k256() { 1654 + // Create a random k256 signing key, encode its public key, then 1655 + // decode it back and verify the keys match. 1656 + let signing_key = AnySigningKey::K256(k256::ecdsa::SigningKey::random( 1657 + &mut k256::elliptic_curve::rand_core::OsRng, 1658 + )); 1659 + let original_verifying = signing_key.verifying_key(); 1660 + 1661 + // Encode to multikey format. 1662 + let encoded = encode_multikey(&original_verifying); 1663 + assert!( 1664 + encoded.starts_with('z'), 1665 + "multikey should start with 'z' (base58btc)" 1666 + ); 1667 + 1668 + // Decode back and verify. 1669 + let parsed = parse_multikey(&encoded).expect("encoded multikey should parse"); 1670 + match (&original_verifying, &parsed.verifying_key) { 1671 + (AnyVerifyingKey::K256(original), AnyVerifyingKey::K256(decoded)) => { 1672 + let orig_bytes = original.to_sec1_bytes(); 1673 + let decoded_bytes = decoded.to_sec1_bytes(); 1674 + assert_eq!( 1675 + orig_bytes, decoded_bytes, 1676 + "k256 keys should match after round-trip" 1677 + ); 1678 + } 1679 + _ => panic!("Expected K256 keys"), 1680 + } 1681 + } 1682 + 1683 + #[test] 1684 + fn encode_multikey_round_trip_p256() { 1685 + // Create a random p256 signing key, encode its public key, then 1686 + // decode it back and verify the keys match. 1687 + let signing_key = AnySigningKey::P256(p256::ecdsa::SigningKey::random( 1688 + &mut p256::elliptic_curve::rand_core::OsRng, 1689 + )); 1690 + let original_verifying = signing_key.verifying_key(); 1691 + 1692 + // Encode to multikey format. 1693 + let encoded = encode_multikey(&original_verifying); 1694 + assert!( 1695 + encoded.starts_with('z'), 1696 + "multikey should start with 'z' (base58btc)" 1697 + ); 1698 + 1699 + // Decode back and verify. 1700 + let parsed = parse_multikey(&encoded).expect("encoded multikey should parse"); 1701 + match (&original_verifying, &parsed.verifying_key) { 1702 + (AnyVerifyingKey::P256(original), AnyVerifyingKey::P256(decoded)) => { 1703 + let orig_bytes = original.to_encoded_point(true).as_bytes().to_vec(); 1704 + let decoded_bytes = decoded.to_encoded_point(true).as_bytes().to_vec(); 1705 + assert_eq!( 1706 + orig_bytes, decoded_bytes, 1707 + "p256 keys should match after round-trip" 1708 + ); 1709 + } 1710 + _ => panic!("Expected P256 keys"), 1622 1711 } 1623 1712 } 1624 1713 }