# Test-labeler Implementation Plan — Phase 3

**Goal:** Ship the first end-to-end labeler check path. The pipeline driver runs with only the identity stage implemented and renders a real `LabelerReport` to the terminal. Users can already diagnose DID-document and labeler-record misconfigurations after this phase.

**Architecture:** New module tree under `src/commands/test/labeler/`: `report.rs` (report aggregator + rendering), `pipeline.rs` (target parsing + driver), `identity.rs` (first stage). The `Outcome<T>` enum, the `CheckResult` type, and the `LabelerReport` live in `report.rs`. The pipeline is non-fail-fast — subsequent stages are present in the driver but return `Outcome::Skipped("not yet implemented")` until Phase 4. The identity stage consumes `common::identity` primitives from Phase 2, producing an `IdentityFacts` that later stages depend on.

**Tech Stack:** adds `atrium-api`, `atrium-xrpc-client` (for fetching `app.bsky.labeler.service/self` via `com.atproto.repo.getRecord`); `insta` (dev-dependency, snapshot tests).

**Scope:** Phase 3 of 6. Implements `test-labeler.AC1.*` and `test-labeler.AC2.*` entirely.

**Codebase verified:** 2026-04-13. Phase 1 + 2 landed. `src/commands/test/labeler.rs` is the Phase 1 stub; `src/common/identity.rs` is the Phase 2 primitive module. The `labeler/` subdirectory and all three new files do not yet exist.

---

## Acceptance Criteria Coverage

This phase implements and tests:

### test-labeler.AC1: CLI skeleton and invocation modes
- **test-labeler.AC1.1 Success:** `atproto-devtool test labeler <handle>` accepts an atproto handle, resolves it to a DID, and runs all four stages.
- **test-labeler.AC1.2 Success:** `atproto-devtool test labeler <did>` accepts a DID directly (did:plc or did:web) and runs all four stages without a prior handle resolution.
- **test-labeler.AC1.3 Success:** `atproto-devtool test labeler <https-url>` accepts a raw endpoint URL and runs the HTTP and subscription stages while marking identity and crypto checks `Skipped` with a clear reason.
- **test-labeler.AC1.4 Success:** `atproto-devtool test labeler <https-url> --did <did>` unlocks identity checks (and therefore the crypto stage) against an endpoint URL, and also cross-checks that the DID's declared labeler service endpoint matches the provided URL — reporting a `SpecViolation` on mismatch.
- **test-labeler.AC1.5 Failure:** A target argument that is neither a valid handle, DID, nor URL produces a clap parsing error with a helpful message and exit code `2`.
- **test-labeler.AC1.6 Edge:** `atproto-devtool test labeler --help` renders the subcommand's help text including all stage-related flags (`--subscribe-timeout`, `--did`, `--verbose`, `--no-color`).

### test-labeler.AC2: Identity-layer checks
- **test-labeler.AC2.1 Success:** A DID document containing a `#atproto_labeler` service entry of type `AtprotoLabeler` with a valid `serviceEndpoint`, plus a signing-key verification method parseable as k256 or p256, passes all identity checks.
- **test-labeler.AC2.2 Success:** A `app.bsky.labeler.service/self` record with a non-empty `policies.labelValues` list passes the labeler-record checks.
- **test-labeler.AC2.3 Failure:** A DID document missing the `atproto_labeler` service entry produces a `SpecViolation` `CheckResult` whose diagnostic's `NamedSource` is the DID-document JSON and whose `#[label]` span highlights the `service` array.
- **test-labeler.AC2.4 Failure:** A DID document missing the labeler signing key produces a `SpecViolation` whose diagnostic highlights the `verificationMethod` array.
- **test-labeler.AC2.5 Failure:** A labeler `serviceEndpoint` that is not a valid HTTPS URL produces a `SpecViolation` with the offending endpoint value highlighted in the DID doc.
- **test-labeler.AC2.6 Failure:** A missing `app.bsky.labeler.service/self` record (PDS returns 404) produces a `SpecViolation` distinct from a PDS transport failure.
- **test-labeler.AC2.7 Failure:** An `app.bsky.labeler.service/self` record with an empty `policies.labelValues` list produces a `SpecViolation` whose diagnostic highlights the `policies` field in the re-serialized record.
- **test-labeler.AC2.8 NetworkError:** A DNS failure resolving a handle, an unreachable `plc.directory`, or an unreachable PDS produces a `NetworkError` result that is called out separately in the summary and does not by itself fail the run.

Phases 4–6 cover the remaining AC groups (AC3, AC4, AC5, AC6). Where this phase must cross-link with later stages (e.g., rendering "not yet implemented" for HTTP/subscription/crypto), the stub pipeline driver marks those stages `Skipped` with a stable reason string so the rendered report is forward-compatible.

---

## Codebase state at start of phase

Verified 2026-04-13 (post Phases 1 and 2):

- `src/common/identity.rs` exists with `resolve_handle`, `resolve_did`, `find_service`, `parse_multikey`, `RealHttpClient`, `RealDnsResolver`, the `HttpClient` and `DnsResolver` traits, the `DidDocument` / `RawDidDocument` types, the `AnyVerifyingKey` enum, and the `IdentityError` type.
- `src/commands/test/labeler.rs` contains the Phase 1 stub `LabelerCmd` struct that accepts `target: String`, `did: Option<String>`, `subscribe_timeout: Duration` and prints a placeholder in its `run` method.
- No `src/commands/test/labeler/` subdirectory yet. No `report.rs`, `pipeline.rs`, or `identity.rs` under it.
- No `tests/fixtures/labeler/` yet. Phase 2 seeded `tests/fixtures/identity/` only.
- `insta` is not yet a dev-dependency.
- No real `atrium-api` or `atrium-xrpc-client` dependency.

---

## External dependency notes

- **`atrium-api` 0.25.8** provides:
  - `atrium_api::com::atproto::repo::get_record::Parameters` / `Output` / `Error` with `const NSID: &str = "com.atproto.repo.getRecord"`.
  - `atrium_api::client::AtpServiceClient::new(xrpc)` to construct a typed client over any `atrium_xrpc::XrpcClient`.
  - `atrium_api::app::bsky::labeler::defs::{LabelerPolicies, LabelerPoliciesData}` — the typed labeler policies record.
- **`atrium-xrpc-client` 0.5.15** provides:
  - `atrium_xrpc_client::reqwest::ReqwestClient::new(base_uri)` — the concrete client built on top of `reqwest::Client`. This is the only client impl we need.
- **`miette` 7.6** `NamedSource<T>` + `#[source_code]` + `#[label]` span derive. The `#[label("...")]` span attribute on a `SourceSpan` field works with the `Diagnostic` derive when `#[source_code]` is a `NamedSource` field on the same struct.
- **`insta` 1.47.2** — dev-only. `insta::assert_snapshot!` for plain text snapshots of the rendered `LabelerReport`. Snapshots live in `tests/snapshots/`.

**atproto spec notes:**

- The labeler service record is stored under collection `app.bsky.labeler.service` at `rkey = "self"`. Its policy field is `policies.labelValues: Vec<String>` (plus an optional `policies.labelValueDefinitions`). A non-empty `labelValues` is the minimum bar the tool checks.
- The `#atproto_labeler` DID service entry has `type: "AtprotoLabeler"` and `serviceEndpoint: <https URL>`.
- The `#atproto_pds` service entry has `type: "AtprotoPersonalDataServer"` and the `serviceEndpoint` used to fetch the labeler record via the `com.atproto.repo.getRecord` XRPC call.

---

<!-- START_TASK_1 -->
### Task 1: Add Phase 3 dependencies

**Files:**
- Modify: `Cargo.toml` (`[dependencies]` and `[dev-dependencies]`)

**Implementation:**

Append to `[dependencies]` (alphabetised overall):

```toml
atrium-api = "0.25"
atrium-xrpc-client = { version = "0.5", default-features = false, features = ["reqwest"] }
```

Append to `[dev-dependencies]`:

```toml
insta = { version = "1.47", features = ["yaml"] }
```

**Verification:**

Run: `cargo check`
Expected: new crates resolve and compile.

**Commit:**
```bash
git add Cargo.toml Cargo.lock
git commit -m "Add Phase 3 dependencies (atrium, insta)"
```
<!-- END_TASK_1 -->

<!-- START_TASK_2 -->
### Task 2: `LabelerReport`, `CheckResult`, `CheckStatus` and renderer

**Verifies:** test-labeler.AC6.1, test-labeler.AC6.5 partial (glyph/reason rendering for `Skipped`); the rest of AC6 arrives in Phase 6 once exit-code wiring is complete.

**Files:**
- Create: `src/commands/test/labeler.rs` — _modify_: add `pub mod report; pub mod pipeline; pub mod identity;` declarations (keep the existing `LabelerCmd` struct but rewrite `run` in Task 5).
- Create: `src/commands/test/labeler/report.rs`

**Implementation:**

`report.rs` contains:

- `pub enum CheckStatus { Pass, SpecViolation, NetworkError, Advisory, Skipped }` — the five rendering severities. `Pass` maps to `[OK]`, `SpecViolation` to `[FAIL]`, `NetworkError` to `[NET]`, `Advisory` to `[WARN]`, `Skipped` to `[SKIP]`.
- `pub struct CheckResult { pub id: &'static str, pub stage: Stage, pub status: CheckStatus, pub summary: Cow<'static, str>, pub diagnostic: Option<miette::Report>, pub skipped_reason: Option<Cow<'static, str>> }`.
- `pub enum Stage { Identity, Http, Subscription, Crypto }` with `fn label(self) -> &'static str` returning the section heading.
- `pub struct LabelerReport { pub header: ReportHeader, pub results: Vec<CheckResult>, pub started_at: Instant, pub finished_at: Option<Instant> }`.
- `pub struct ReportHeader { pub target: String, pub resolved_did: Option<String>, pub pds_endpoint: Option<String>, pub labeler_endpoint: Option<String> }`.
- `LabelerReport::new(header)`, `LabelerReport::record(result: CheckResult)`, `LabelerReport::finish(&mut self)` helpers.
- `LabelerReport::exit_code(&self) -> i32` computing exit code per the severity rules: any `SpecViolation` → 1; else 0. (`NetworkError` and `Advisory` don't fail the run; see AC6.3 / AC6.4.)
- `LabelerReport::summary_counts(&self) -> SummaryCounts` with fields `pass`, `spec_violation`, `network_error`, `advisory`, `skipped`. Network errors are reported as a separate count (AC6.3 calls this out).
- `LabelerReport::render<W: io::Write>(&self, out: &mut W, config: &RenderConfig) -> io::Result<()>` — the main renderer. Writes:
  1. One header line with the target and resolved DID/PDS/labeler endpoint, plus elapsed time.
  2. One section per `Stage`, in order `Identity → Http → Subscription → Crypto`. Each section prints:
     - A heading line `== Identity ==`.
     - Every `CheckResult` for that stage as `[GLYPH] <summary> — <skipped_reason or nothing>`.
     - For each result with `status ∈ {SpecViolation, NetworkError, Advisory}` that carries a `diagnostic`, render the miette `Report` using a **local** `GraphicalReportHandler` the renderer constructs from `RenderConfig`, not the process-global handler installed in Phase 1. See "Why a local handler" below. `Skipped` checks do NOT render a miette diagnostic; they print only the `skipped_reason`.
  3. A summary footer: `Summary: X passed, Y failed (spec), Z network errors, W advisories, V skipped. Exit code: <code>`.

- `pub struct RenderConfig { pub no_color: bool }` — controls whether miette diagnostics are rendered with the graphical theme's color. When `no_color` is true, the local handler uses `GraphicalTheme::unicode_nocolor()`.

**Why a local handler, not the Phase 1 global:** Phase 1 installs a process-global miette handler via `miette::set_hook`. That global is fine for `main`'s top-level `miette::Report` error rendering, but using it here would make `LabelerReport::render` race-sensitive: snapshot tests in Phases 3–6 run in parallel by default, and two tests rendering with different `RenderConfig::no_color` values would clobber each other's global state. Instead, `LabelerReport::render` constructs its own `GraphicalReportHandler` with the per-call theme, calls `handler.render_report(&mut buf, diag.as_ref())` for each diagnostic, and writes the resulting string into `out`. The global is used only for the top-level bootstrap error path (`miette::Result<()>` returned from `main`), and this renderer never depends on it being installed.

- A `Display` impl for `CheckStatus` producing the ASCII glyph; used for both the text renderer and debug output.

**Testing (unit-level, in the same file):**

- `exit_code_only_advisory_is_zero` — empty report with one `Advisory` CheckResult → `exit_code() == 0`.
- `exit_code_only_network_errors_is_zero` — one `NetworkError` → `exit_code() == 0`.
- `exit_code_with_spec_violation_is_one` — one `SpecViolation` → `exit_code() == 1`.
- `summary_counts_partition_correct` — mixed report yields correct per-severity counts.
- `render_basic_glyphs` — a tiny hand-rolled report with one pass, one fail, one skipped. Capture the rendered bytes with a `Vec<u8>` writer; assert the byte sequence contains `[OK]`, `[FAIL]`, and `[SKIP]` — NOT a full snapshot (the full rendered shape is locked down in Task 6 via `insta`).

**Verification:**

Run: `cargo test`
Expected: the unit tests above pass.

**Commit:**
```bash
git add src/commands/test/labeler.rs src/commands/test/labeler/report.rs
git commit -m "Add LabelerReport types and renderer"
```
<!-- END_TASK_2 -->

<!-- START_TASK_3 -->
### Task 3: `LabelerTarget` parsing and pipeline driver skeleton

**Verifies:** test-labeler.AC1.1, AC1.2, AC1.3, AC1.4, AC1.5, AC1.6.

**Files:**
- Create: `src/commands/test/labeler/pipeline.rs`

**Implementation:**

Types:

```text
pub enum LabelerTarget {
    Identified { identifier: AtIdentifier, explicit_did: Option<Did> },
    Endpoint   { url: Url, did: Option<Did> },
}

pub enum AtIdentifier {
    Handle(String),
    Did(Did),
}

pub struct LabelerOptions<'a> {
    pub http: &'a dyn HttpClient,
    pub dns: &'a dyn DnsResolver,
    pub subscribe_timeout: Duration,
    pub verbose: bool,
}
```

Functions:

- `pub fn parse_target(raw: &str, explicit_did: Option<&str>) -> Result<LabelerTarget, TargetParseError>` — called once at CLI argument binding time. The rules (verifies AC1.1 through AC1.5):
  - If `raw` starts with `did:` → `AtIdentifier::Did` in `Identified`. `explicit_did`, if also provided, is rejected as "ambiguous — target is already a DID" (an error, not a silent override). This keeps the flag's meaning clear.
  - Else if `raw` starts with `https://` or `http://` → parse as `Url`; `Endpoint { url, did: explicit_did.map(Did::new) }`. Reject `http://` with a helpful error pointing the user at `https://`.
  - Else if `raw` contains a `.` and otherwise matches the atproto handle grammar → `AtIdentifier::Handle`. `explicit_did`, if provided, is carried in `Identified::explicit_did` so the pipeline can cross-check the resolved DID matches.
  - Else → `TargetParseError::UnrecognizedTarget { raw: raw.to_string() }` with a helpful message listing the three accepted forms. This error is returned from `LabelerCmd::run` via `map_err` into a miette diagnostic and exits 2 (bootstrap/usage error) — AC1.5.

- `pub async fn run_pipeline(target: LabelerTarget, opts: LabelerOptions<'_>) -> LabelerReport` — the driver. Pseudocode:
  1. Build a `ReportHeader` from the target (URL/handle/DID). Initialize `LabelerReport::new(header)`.
  2. Call `identity::run(&target, opts.http, opts.dns) -> IdentityOutcome`, where `IdentityOutcome = Outcome<IdentityFacts>`. Push every per-check `CheckResult` emitted by the identity stage.
     - `LabelerTarget::Endpoint { did: None, .. }` → identity stage returns `Outcome::Skipped("no DID supplied; run with a handle, a DID, or --did <did>")` (AC1.3).
     - `LabelerTarget::Endpoint { did: Some(d), url }` → identity stage runs and, after resolving `d`, emits an _additional_ `CheckResult` cross-checking the `#atproto_labeler` serviceEndpoint against `url`. On mismatch, severity `SpecViolation` (AC1.4).
  3. For each subsequent stage (HTTP, Subscription, Crypto), push a `CheckResult { status: Skipped, skipped_reason: "not yet implemented (phase N)" }` per stage into the report. Phase 4/5/6 replace these calls with real stage implementations.
  4. `report.finish()` and return.

- `struct TargetParseError(String)` — `impl From<_> for miette::Report` so the CLI layer can propagate it.

**Testing (unit, in-file):**

- `parse_target_handle` — `parse_target("alice.bsky.social", None)` → `Identified::Handle`. AC1.1.
- `parse_target_did_plc` / `parse_target_did_web` — AC1.2.
- `parse_target_endpoint_https` → `Endpoint { did: None, .. }`. AC1.3.
- `parse_target_endpoint_with_explicit_did` → `Endpoint { did: Some(..), .. }`. AC1.4 (the cross-check itself is tested in Task 4).
- `parse_target_endpoint_http_rejected` — `parse_target("http://evil.example", None)` returns an error pointing at `https://`. Related to AC1.5 but not the exact case.
- `parse_target_unrecognised` — `parse_target("not a handle or did", None)` → `TargetParseError::UnrecognizedTarget`. AC1.5.
- `parse_target_did_with_conflicting_flag` — `parse_target("did:plc:abc", Some("did:web:example.com"))` → error mentioning ambiguity.

**Verification:**

Run: `cargo test`
Expected: pass.

**Commit:**
```bash
git add src/commands/test/labeler/pipeline.rs
git commit -m "Add LabelerTarget parsing and pipeline driver skeleton"
```
<!-- END_TASK_3 -->

<!-- START_SUBCOMPONENT_A (tasks 4-6) -->
<!-- START_TASK_4 -->
### Task 4: Identity stage — `IdentityFacts` and all identity checks

**Verifies:** test-labeler.AC2.1, AC2.2, AC2.3, AC2.4, AC2.5, AC2.6, AC2.7, AC2.8, AC1.4 (endpoint/DID cross-check).

**Files:**
- Create: `src/commands/test/labeler/identity.rs`

**Implementation:**

Types:

```text
pub struct IdentityFacts {
    pub did: Did,
    pub raw_did_doc: RawDidDocument,
    pub labeler_endpoint: Url,
    pub pds_endpoint: Url,
    pub signing_key_id: String,
    pub signing_key: AnyVerifyingKey,
    pub labeler_record_bytes: Arc<[u8]>,
    pub labeler_policies: LabelerPolicies,
}

pub struct IdentityStageOutput {
    pub facts: Option<IdentityFacts>,
    pub results: Vec<CheckResult>,
}

pub async fn run(
    target: &LabelerTarget,
    http: &dyn HttpClient,
    dns: &dyn DnsResolver,
) -> IdentityStageOutput;
```

The driver emits **named checks** (constant IDs) in this order:

1. **`identity::target_resolved`** — handle → DID resolved, or DID already known. Skipped for `Endpoint { did: None }`. DNS failure resolving a handle → `NetworkError` (AC2.8). Invalid handle → `SpecViolation`.
2. **`identity::did_document_fetched`** — `resolve_did` returned `RawDidDocument`. `plc.directory` unreachable → `NetworkError` (AC2.8); JSON decode failure → `SpecViolation` with diagnostic carrying the raw bytes.
3. **`identity::labeler_service_present`** — `find_service(&doc, "atproto_labeler", "AtprotoLabeler")`. Missing → `SpecViolation` with `NamedSource` = the DID doc bytes and `#[label("service array")]` span covering the `"service"` JSON key range (AC2.3). Found → `Pass`.
4. **`identity::labeler_endpoint_is_https`** — parse the service endpoint as a `Url` and require scheme `https`. Non-https → `SpecViolation`, span over the endpoint value (AC2.5).
5. **`identity::labeler_endpoint_matches_flag`** — only when target is `Endpoint { did: Some(_), url }`: compare the resolved labeler endpoint to the flag URL with scheme and authority normalized. Mismatch → `SpecViolation` (AC1.4). Otherwise skipped.
6. **`identity::signing_key_present`** — `find_verification_method(&doc, "atproto_label")` (a small helper, co-located) returning the multikey string. Missing → `SpecViolation`, span over the `verificationMethod` array key (AC2.4). Present but parseable via `parse_multikey` → `Pass`; unparseable → `SpecViolation` with the multikey string in the diagnostic.
7. **`identity::pds_endpoint_present`** — `find_service(&doc, "atproto_pds", "AtprotoPersonalDataServer")` → `Pass` or `SpecViolation`.
8. **`identity::labeler_record_fetched`** — construct an `atrium_xrpc_client::reqwest::ReqwestClient` against the PDS endpoint, build an `AtpServiceClient`, call `client.service.com.atproto.repo.get_record(params)` with `repo = did`, `collection = "app.bsky.labeler.service"`, `rkey = "self"`. Map:
   - 404 → `SpecViolation` "PDS has no labeler service record" (AC2.6).
   - Network / TLS / DNS failure → `NetworkError` (AC2.8) distinct from 404.
   - 200 with successfully deserialised `LabelerPolicies` → `Pass`, capture the raw bytes for the next check's `NamedSource`.
9. **`identity::labeler_record_policies_nonempty`** — inspect `policies.label_values`. Empty → `SpecViolation` with diagnostic carrying re-serialised record bytes and a span over the `"policies"` JSON key (AC2.7). Non-empty → `Pass` (AC2.2).

**Diagnostic types** — each failing check owns a `#[derive(thiserror::Error, miette::Diagnostic)]` struct with `#[source_code] named_source: NamedSource<Arc<[u8]>>` and `#[label("...")] span: SourceSpan`. Each diagnostic has a stable `code` string of the form `labeler::identity::<check_id>`. A small helper `json_span_for_key(bytes: &[u8], key: &str) -> SourceSpan` scans the JSON byte slice for the first occurrence of `"<key>"` at the top level and returns the span covering the key (and optionally the value). For Phase 3 a simple substring search is acceptable since the DID documents and labeler records are small; if this ever mis-hits, Phase 6 can replace it with a proper JSON tokenizer.

The stage populates `IdentityFacts` only when every check listed above produces a `Pass` (or the optional cross-check is `Pass`). Any `SpecViolation` or `NetworkError` blocks `IdentityFacts` from being produced and downstream stages that depend on it are skipped by the pipeline with `skipped_reason = "blocked by identity stage failures"`.

**Testing:** deferred to Task 6. Unit tests for the `json_span_for_key` helper live in this file as a `#[cfg(test)]` submodule.

**Verification:**

Run: `cargo check`
Expected: compiles.

**Commit:**
```bash
git add src/commands/test/labeler/identity.rs
git commit -m "Implement labeler identity stage with per-check diagnostics"
```
<!-- END_TASK_4 -->

<!-- START_TASK_5 -->
### Task 5: Wire `LabelerCmd::run` into the pipeline and render the report

**Verifies:** test-labeler.AC6.1 (partial — all-OK render on a passing run), AC6.6 (NO_COLOR honored by the render path).

**Files:**
- Modify: `src/commands/test/labeler.rs` (replace the stub `LabelerCmd::run` body; keep the existing `LabelerCmd` struct, the `parse_subscribe_timeout` helper, and the `humantime_like` submodule unchanged).

**Implementation:**

`LabelerCmd::run` now:

1. Parses the target using `pipeline::parse_target(&self.target, self.did.as_deref())`, mapping its error to a `miette::Report` and returning immediately (exit 2 via the clap/bootstrap path when the error is from argument parsing; exit 1 otherwise).
2. Builds `RealHttpClient::new()` and `RealDnsResolver::new()` once.
3. Calls `pipeline::run_pipeline(target, LabelerOptions { http: &http, dns: &dns, subscribe_timeout: self.subscribe_timeout, verbose }).await`.
4. Calls `report.render(&mut stdout_lock, &RenderConfig { no_color })` where `no_color` is threaded through from `cli::run` (add a `no_color: bool` to `LabelerOptions` _or_ use a thread-local set at handler-install time — choose the parameter approach, it is explicit).
5. Returns `Ok(())` if `report.exit_code() == 0`, otherwise returns a minimal `miette::Report` whose only role is to produce the non-zero exit code. (The actual diagnostics were already rendered in step 4; we do NOT rely on returning `Err` as the primary rendering path.)

**Non-obvious detail:** because the report is rendered via `println!`-style writes directly to stdout and miette diagnostics are rendered inline within that output, the `Err`-return-for-exit-code path should use a blank `miette::miette!("")` message that the main handler will swallow. An alternative — calling `std::process::exit(report.exit_code())` — skips tokio/drop semantics and is avoided per user Rust conventions.

A cleaner pattern (chosen here): `LabelerCmd::run` returns `Result<ExitCode, miette::Report>`, and the dispatch chain in `cli::run` matches on `ExitCode` before returning from `main`. This requires propagating `std::process::ExitCode` up through `cli.rs::run` and `main.rs`. Update both.

**Verification:**

Run: `cargo build`
Expected: succeeds.

Run: `cargo run -- test labeler moderation.bsky.app`
Expected (online integration; skip in CI): real resolution of the public Bluesky moderation labeler; identity section prints a mix of `[OK]` lines; subsequent stages print `[SKIP]` "not yet implemented". Exit 0.

Run: `cargo run -- test labeler not-a-thing`
Expected: `TargetParseError` rendered as a miette diagnostic, exit 2 (AC1.5).

Run: `cargo run -- test labeler https://mod.bsky.app`
Expected: identity checks `[SKIP]` with reason "no DID supplied"; HTTP/subscription `[SKIP]` "not yet implemented"; exit 0 (AC1.3).

**Commit:**
```bash
git add src/commands/test/labeler.rs src/cli.rs src/main.rs
git commit -m "Wire test labeler pipeline into CLI and exit codes"
```
<!-- END_TASK_5 -->

<!-- START_TASK_6 -->
### Task 6: Snapshot tests for the identity stage

**Verifies:** all of AC1.1–AC1.6 and AC2.1–AC2.8, via the rendered report for each fixture.

**Files:**
- Create: `tests/fixtures/labeler/identity/` — recorded DID documents and labeler records, one subdirectory per named case:
  - `healthy_plc/` — `did.json`, `labeler_record.json`. A real healthy labeler document (hand-sanitized if needed); signing key resolves through `parse_multikey` cleanly.
  - `missing_service/` — DID doc with no `#atproto_labeler` service entry (AC2.3).
  - `missing_signing_key/` — DID doc with no `#atproto_label` verification method (AC2.4).
  - `non_https_endpoint/` — DID doc whose service endpoint is `http://...` (AC2.5).
  - `missing_labeler_record/` — healthy DID doc, but the PDS returns 404 for the record (AC2.6).
  - `empty_policies/` — healthy DID doc, record present but `policies.labelValues` is `[]` (AC2.7).
  - `plc_directory_unreachable/` — DID doc fetch returns transport error (AC2.8).
  - `endpoint_mismatch/` — `Endpoint { url, did: Some(...) }` where the DID's service endpoint does not match `url` (AC1.4).
  - `endpoint_only_no_did/` — `Endpoint { url, did: None }` to cover AC1.3 (all identity checks skipped with the "no DID supplied" reason).
- Create: `tests/labeler_identity.rs` — integration-level snapshot tests using `insta::assert_snapshot!`. Each test constructs a `FakeHttpClient` + `FakeDnsResolver` seeded from one fixture directory, runs `pipeline::run_pipeline(target, opts).await`, renders the report to a `Vec<u8>` with `RenderConfig { no_color: true }`, and asserts the resulting string matches a locked snapshot in `tests/snapshots/`.
- Create: `tests/snapshots/labeler_identity__*.snap` — produced by `cargo insta test --accept` on first run and committed alongside the fixtures.

**Test list (one test function per fixture case):**

- `healthy_plc_renders_all_ok` — verifies AC2.1, AC2.2. Snapshot shows all identity checks `[OK]`.
- `missing_service_renders_spec_violation_with_span` — verifies AC2.3. Snapshot shows `[FAIL] identity::labeler_service_present` plus miette diagnostic with `source` pointing at the DID doc bytes and a span over `"service"`.
- `missing_signing_key_renders_spec_violation` — AC2.4.
- `non_https_endpoint_renders_spec_violation` — AC2.5.
- `missing_labeler_record_renders_404_distinct_from_transport` — AC2.6. Assert that the failure diagnostic's `code` is `labeler::identity::labeler_record_fetched` AND the message distinguishes "PDS returned 404" from a transport error.
- `empty_policies_renders_spec_violation_with_span` — AC2.7.
- `plc_directory_unreachable_renders_network_error` — AC2.8. Assert severity `NetworkError` and that the summary footer lists 1 network error, 0 spec violations, exit 0.
- `endpoint_mismatch_spec_violation` — AC1.4.
- `endpoint_only_no_did_skips_identity` — AC1.3. Assert all identity checks render `[SKIP]` with the "no DID supplied" reason, and HTTP/subscription still render `[SKIP]` "not yet implemented".
- `handle_resolution_happy_path` — AC1.1. Uses `FakeDnsResolver` returning `"did=did:plc:..."`. Re-uses the `healthy_plc` fixture files for the DID doc + labeler record.
- `did_plc_direct_happy_path` — AC1.2 for did:plc.
- `did_web_direct_happy_path` — AC1.2 for did:web (reuses a hand-authored did:web fixture).

**Snapshot strategy:**
- Use `insta::with_settings!{ { filters => vec![(r"elapsed: [0-9]+ms", "elapsed: XXms")] }, { insta::assert_snapshot!(..) } }` to mask the wall-clock time in the header so snapshots are deterministic.
- Commit snapshots under `tests/snapshots/` (the `insta` default).
- Add a top-level `insta.yaml` (if not yet present) or rely on defaults — no custom config required.

**Verification:**

Run: `cargo insta test --accept` (first time, to capture snapshots)
Run: `cargo test` (subsequent runs)
Expected: all 12 tests pass. On code changes that alter rendered output, `cargo insta review` is used to approve changes explicitly.

**Commit:**
```bash
git add tests/labeler_identity.rs tests/fixtures/labeler/identity tests/snapshots
git commit -m "Add identity stage snapshot tests covering AC1 and AC2"
```
<!-- END_TASK_6 -->
<!-- END_SUBCOMPONENT_A -->

<!-- START_TASK_7 -->
### Task 7: Final phase verification

**Steps:**

1. `cargo fmt --check` — clean.
2. `cargo clippy -- -D warnings` — clean.
3. `cargo test` — all tests including the 12 Phase 3 snapshot tests pass.
4. `cargo run -- test labeler --help` — AC1.6: help renders with `--did`, `--subscribe-timeout`, `--verbose`, `--no-color`, positional `TARGET`.
5. (Optional, online) `cargo run -- test labeler moderation.bsky.app` — identity stage all `[OK]`, later stages `[SKIP]`.

**Done when:** steps 1–4 pass. Step 5 is a smoke check against a real labeler and is allowed to vary from one release to the next; it is not part of the `cargo test` pass criterion.
<!-- END_TASK_7 -->

---

## Phase 3 done-when checklist

- `src/commands/test/labeler/{report,pipeline,identity}.rs` exist and compile.
- `atrium-api` and `atrium-xrpc-client` are in the dependency graph; `insta` is a dev-dependency.
- All 12 Phase 3 snapshot tests pass. Snapshots are committed.
- Running the binary against a real healthy labeler produces an identity section of all `[OK]` glyphs and exits 0.
- Running with an unparseable target exits 2 with a clap/miette error (AC1.5).
- Running with `https://<endpoint>` (no `--did`) renders identity stage as `[SKIP]` with reason "no DID supplied" (AC1.3).
- AC6 items beyond AC6.1/AC6.5/AC6.6 remain to be completed in Phase 6 — they are acknowledged here only as far as rendering/exit-code plumbing.
- Commit history shows the six atomic commits from this phase.
