# Test-labeler Implementation Plan — Phase 6

**Goal:** Ship the final stage — signature verification on the labels collected by the HTTP stage — and close out all outstanding acceptance criteria. This phase adds did:plc key-history fallback for rotated-out keys, the DRISL-CBOR canonicalizer, end-to-end snapshot tests covering mixed-failure runs, and a README. The tool is feature-complete at the end of this phase.

**Architecture:** New module `src/commands/test/labeler/crypto.rs`. Extends `src/common/identity.rs` with a `plc_history` helper (PLC operation log fetch, lazy). The crypto stage consumes `IdentityFacts` and `HttpFacts`, canonicalizes each label to DRISL-CBOR, SHA-256 prehashes the bytes, dispatches to `k256::ecdsa::VerifyingKey::verify_prehash` or `p256::ecdsa::VerifyingKey::verify_prehash` via `AnyVerifyingKey`, and on any per-label failure lazily fetches the PLC audit log to retry against each historic verification method for the `#atproto_label` key slot. Emits a per-label `CheckResult` for failures only (passes are rolled up into a single summary `CheckResult`).

The phase also finalises AC6 — exit-code semantics, `NO_COLOR` rendering, `--verbose` tracing, and the bootstrap/usage `exit 2` path — and adds the end-to-end `insta` suite that covers mixed-failure runs across all four stages.

**Tech Stack:** adds `sha2` 0.11 to `[dependencies]` and `assert_cmd` 2 to `[dev-dependencies]`. All other crates are already in the graph.

**Scope:** Phase 6 of 6.

**Codebase verified:** 2026-04-13 (post Phase 5). `src/commands/test/labeler/crypto.rs` does **not** yet exist. `common::identity` has no PLC audit-log helper yet. Pipeline driver calls identity, HTTP, and subscription stages; crypto remains `Skipped("not yet implemented (phase 6)")`.

---

## Acceptance Criteria Coverage

### test-labeler.AC5: Crypto-layer checks (signature verification with key rotation)
- **test-labeler.AC5.1 Success:** Every label in `HttpFacts::first_page` whose signature verifies against the current declared signing key produces a `Pass` on the crypto rollup with no historic-key fetch performed.
- **test-labeler.AC5.2 Success (rotated-out key, did:plc):** When at least one label fails against the current key, the crypto stage fetches the PLC audit log and retries verification against each historic verification-method entry for the labeler's signing-key slot. Labels that verify against a historic key are accepted, the rollup is `Pass`, and an `Advisory` is attached listing the count and key ids involved.
- **test-labeler.AC5.3 Success (empty labeler):** A labeler with zero published labels results in the crypto stage being `Skipped("labeler published no labels; nothing to verify")` and does not affect the exit code.
- **test-labeler.AC5.4 Failure (current-key mismatch, no history):** A did:web labeler whose labels do not verify against the current key produces a `Fail` rollup with a diagnostic listing the current key id and stating that did:web provides no rotation history.
- **test-labeler.AC5.5 Failure (current and historic mismatch, did:plc):** A did:plc labeler whose labels verify against neither the current nor any historic key produces a `Fail` rollup with a diagnostic listing every key id that was tried.
- **test-labeler.AC5.6 Failure (canonicalization mismatch surfaced):** A label whose serialized-for-signing bytes cannot be produced (e.g., invalid CBOR in the fetched record) produces a per-label `Fail` with a distinct diagnostic code from signature mismatch, so misbehaviour of the canonicalizer is distinguishable from a genuine signature problem.
- **test-labeler.AC5.7 NetworkError:** A failure to fetch the PLC audit log (`plc.directory` unreachable) during the historic-key path produces a `NetworkError` result and prevents the stage from issuing a false `Fail`; the labels that failed against the current key are reported as `Fail` only if history could not be consulted.

### test-labeler.AC6: Cross-cutting reporting and exit semantics
- **test-labeler.AC6.1 Success:** A labeler that passes every stage produces a `LabelerReport` whose rendered output shows each stage with `[OK]` glyphs, a header listing the target and resolved DID/PDS, a summary with all-zero failure/advisory counts, and exits `0`.
- **test-labeler.AC6.2 Success:** A run containing at least one `SpecViolation` exits `1`, and the summary footer shows the count broken down by severity.
- **test-labeler.AC6.3 Success:** A run containing only `NetworkError` results (no `SpecViolation`s) exits `0`, with the network-error count called out separately in the summary.
- **test-labeler.AC6.4 Success:** A run containing only `Advisory` results exits `0`.
- **test-labeler.AC6.5 Success:** `Skipped` checks are rendered with a reason string taken from the stage's `Outcome::Skipped` variant, so users can see why a check was not run (missing DID, upstream failure, empty labeler, not-yet-implemented).
- **test-labeler.AC6.6 Success:** Setting `NO_COLOR=1` suppresses ANSI color codes in the rendered output while keeping ASCII glyphs (`[OK]`/`[FAIL]`/`[SKIP]`/`[WARN]`) and miette diagnostic layout intact.
- **test-labeler.AC6.7 Success:** `--verbose` raises the tracing filter, causing stage IO (HTTP requests, WebSocket frames, PLC log fetch) to be logged at DEBUG to stderr without affecting the rendered report or exit code.
- **test-labeler.AC6.8 Edge:** The tool's own unrecoverable bootstrap failures (invalid CLI args, panics caught by the miette handler, tokio runtime failure) exit `2` — distinct from a `SpecViolation`-driven `1`.

---

## Codebase state at start of phase

- `src/commands/test/labeler/{report,pipeline,identity,http,subscription}.rs` — all present.
- `src/commands/test/labeler/crypto.rs` — does not exist.
- `src/common/identity.rs` — Phase 2's module. Does not yet have a PLC history helper.
- No `README.md` yet.
- Pipeline driver is currently missing the crypto-stage call; it emits `Skipped("not yet implemented (phase 6)")`.

---

## External dependency notes

- **`sha2` 0.11.0** — `use sha2::{Digest, Sha256};` then `let digest: [u8; 32] = Sha256::digest(canon_bytes).into();`. No other features needed.
- **`k256::ecdsa::VerifyingKey`** and **`p256::ecdsa::VerifyingKey`** — verify via `ecdsa::signature::hazmat::PrehashVerifier::verify_prehash(&self, prehash: &[u8], sig: &Signature) -> Result<(), SignatureError>`. Import the trait at call site. `Signature` is `k256::ecdsa::Signature` / `p256::ecdsa::Signature` respectively; both parse from raw (`r || s`, 64 bytes) via `Signature::from_slice(..)`.
- **DRISL-CBOR canonicalization** (the "strict CBOR subset required by atproto for cryptographically signed data"):
  - Encode maps with keys sorted lexicographically as byte strings (RFC 7049 / RFC 8949 deterministic encoding rules), not integers-first.
  - Encode integers in minimum-length form (no leading zeros; prefer the shortest argument byte count).
  - Disallow floats and disallow indefinite-length items.
  - Encode tags only when part of the source document.
  - Strip the `sig` field from the label record before encoding; the signature byte string is over the record with `sig` absent.
  - `ciborium` does not provide a guaranteed-deterministic encoder out of the box, so Phase 6 implements a small canonicalizer on top of `ciborium::Value`: parse the label into `ciborium::Value`, walk the tree (collecting map entries, sorting keys), reject floats and indefinite-length items, and re-serialise with `ciborium::ser::into_writer` in a mode that relies on the canonicalized `Value` tree already being in correct shape. Deterministic byte output is then asserted against a golden fixture from a real labeler's production label.
- **PLC operation log audit endpoint** — `https://plc.directory/{did}/log/audit` returns a JSON array of operation objects. Each operation has `operation.verificationMethods.<fragment>` when the fragment is present in that historic revision, plus a `nullified: bool` flag. The crypto stage walks the array newest-to-oldest and extracts each distinct multikey string for the `atproto_label` fragment.
- **did:web key history** — explicitly unavailable; the crypto stage reports "did:web provides no rotation history" in its Fail diagnostic.

---

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

**Files:** `Cargo.toml`.

Append to `[dependencies]`:

```toml
sha2 = "0.11"
```

Append to `[dev-dependencies]`:

```toml
assert_cmd = "2.0"
```

`sha2` is used by the canonicalizer in Task 3 for the SHA-256 prehash. `assert_cmd` is used by Task 5's `tests/labeler_cli.rs` to drive the compiled binary with specific environment variables (`NO_COLOR=1`) and `--verbose` to validate AC6.6, AC6.7, and AC6.8 end-to-end.

**Verification:** `cargo check` succeeds.

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

<!-- START_TASK_2 -->
### Task 2: Extend `common::identity` with `plc_history`

**Verifies:** mechanism for AC5.2 / AC5.5 / AC5.7 (exercised by the Phase 6 crypto tests).

**Files:**
- Modify: `src/common/identity.rs` — append the helper.
- Create: `tests/fixtures/identity/plc_audit_log_with_rotation.json` — hand-authored (or recorded) operation-log JSON showing one key rotation for the `atproto_label` fragment.

**Implementation:**

Types + helper:

```text
pub struct PlcHistoricKey {
    pub key_id: String,              // the multikey string (not the DID#fragment)
    pub operation_cid: String,       // the CID of the op that introduced it
    pub introduced_at: String,        // ISO8601 from the op
    pub nullified: bool,
}

pub async fn plc_history_for_fragment(
    did: &Did,
    fragment: &str,
    http: &dyn HttpClient,
) -> Result<Vec<PlcHistoricKey>, IdentityError>;
```

Behaviour:
- Only valid for `did.method() == DidMethod::Plc`. Passing a `did:web` panics in debug and returns `Err(IdentityError::UnsupportedDidMethod(..))` in release; the caller is expected to check first.
- `GET https://plc.directory/{did}/log/audit` → expect 200, JSON array of operations.
- For each operation (newest-first in the wire order), read `operation.verificationMethods[fragment]` if present; if it is a multikey string, construct a `PlcHistoricKey`. Skip operations that nullify the key's revision entry if `nullified == true` and the caller opts to exclude nullified keys — for Phase 6 we **include** them in the list but set `nullified = true`, so the crypto stage can choose whether to trust them.
- Return the list in wire order (newest-first).
- Transport failure → `Err(IdentityError::HttpTransport(..))`.
- Decode failure → `Err(IdentityError::DidDocumentDecodeFailed { .. })` (reused variant, with `source_name = "plc audit log"`).

**Unit tests:**
- `plc_history_parses_rotation_fixture` — feed `plc_audit_log_with_rotation.json` via `FakeHttpClient`, assert two historic keys returned with expected multikey strings and `nullified == false`.
- `plc_history_unsupported_method_errors` — pass `did:web:example.com`, assert `UnsupportedDidMethod`.
- `plc_history_transport_error_propagates` — FakeHttpClient returns transport error, assert `HttpTransport` bubbles up.

**Verification:**

Run: `cargo test`
Expected: pass.

**Commit:**
```bash
git add src/common/identity.rs tests/fixtures/identity/plc_audit_log_with_rotation.json
git commit -m "Add plc_history_for_fragment helper to common::identity"
```
<!-- END_TASK_2 -->

<!-- START_SUBCOMPONENT_A (tasks 3-5) -->
<!-- START_TASK_3 -->
### Task 3: DRISL-CBOR canonicalizer

**Verifies:** AC5.1, AC5.2, AC5.4, AC5.5, AC5.6 (prerequisite: canonical encoder must produce byte-identical output to what the labeler signed).

**Files:**
- Create: `src/commands/test/labeler/crypto.rs` (canonicalizer only — stage body arrives in Task 4)
- Modify: `src/commands/test/labeler.rs` — `pub mod crypto;`.
- Create: `tests/fixtures/labeler/crypto/reference_label.json` — a real labeler's label as JSON (captured from `moderation.bsky.app` or similar).
- Create: `tests/fixtures/labeler/crypto/reference_label.cbor` — the same label's canonical DRISL-CBOR bytes, captured directly from the atproto reference encoder output at fixture-capture time. Committed as a test vector so the canonicalizer's byte output is regression-checked.

**Implementation:**

```text
pub struct CanonicalLabel {
    pub prehash: [u8; 32],       // SHA-256 of canonical bytes (sig stripped)
    pub canonical_bytes: Vec<u8>, // the DRISL-CBOR bytes that were hashed
    pub signature_bytes: Vec<u8>, // raw (r || s) bytes from the `sig` field
}

pub enum CanonicalizeError {
    InvalidLabelCbor { cause: String },
    FloatRejected,
    IndefiniteLengthRejected,
    MissingSigField,
    SigFieldWrongType,
    SigFieldWrongLength,
}

pub fn canonicalize_label_for_signing(
    label: &atrium_api::com::atproto::label::defs::Label,
) -> Result<CanonicalLabel, CanonicalizeError>;
```

Logic:
1. Serialize the `Label` directly to a `ciborium::value::Value` via `ciborium::value::Value::serialized(&label)`. This uses atrium's derived serde impl and yields a `Value` tree in one step — no byte round-trip. `atrium_api::com::atproto::label::defs::Label` contains no float-typed fields (all numeric fields are integers), so the produced tree is guaranteed to be free of `Value::Float` in practice; Phase 6 asserts this invariant with a runtime check (step 2).
2. Walk the `Value::Map` tree:
   - Reject any `Value::Float(_)` → `FloatRejected`. (Defensive: should never fire for well-formed `Label` values, but catches a future schema regression immediately.)
   - For each map, sort entries by their canonical CBOR-encoded key bytes (not by the raw string — RFC 8949 deterministic ordering sorts by the byte serialisation of keys).
   - Extract and remove the `"sig"` entry from the top-level map. Its value must be a `Value::Bytes(Vec<u8>)` — if anything else, return `SigFieldWrongType`. Length must be 64 bytes — else `SigFieldWrongLength`.
3. Re-serialize the stripped, sorted tree to canonical CBOR bytes with `ciborium::ser::into_writer`. Since the tree is already canonical (sorted maps, no floats, no indefinite lengths) and ciborium uses minimum-length integer encoding for integer-typed values, the output matches DRISL-CBOR rules for the label record schema. The `reference_label.cbor` golden fixture (loaded by `canonicalize_reference_label_golden`) is the regression check that guards against any ciborium/DRISL drift — if that test ever fails, the canonicalizer has diverged from what the real labeler signed and must be replaced with a hand-rolled serializer before the crypto stage can be trusted. This is called out explicitly in the source design's "Additional Considerations" as the load-bearing risk of the phase.
4. Compute `prehash = Sha256::digest(&canonical_bytes).into()`.
5. Return `CanonicalLabel { prehash, canonical_bytes, signature_bytes }`.

**Tests (in-file):**
- `canonicalize_reference_label_golden` — loads `reference_label.json`, deserialises to `Label`, canonicalizes, asserts `canonical_bytes == read(reference_label.cbor)`. **This is the single most important test in Phase 6.** If the canonicalizer drifts, this test catches it before the user sees a false `Fail` on signature verification.
- `canonicalize_rejects_nan_float` — constructs a label (via `ciborium::Value` directly, bypassing `Label`'s schema) containing a float, asserts `FloatRejected`.
- `canonicalize_missing_sig_errors` — asserts `MissingSigField`.
- `canonicalize_sig_wrong_length_errors` — 32-byte sig, asserts `SigFieldWrongLength`.

**Verification:**

Run: `cargo test`
Expected: pass.

**Commit:**
```bash
git add src/commands/test/labeler/crypto.rs src/commands/test/labeler.rs tests/fixtures/labeler/crypto
git commit -m "Add DRISL-CBOR canonicalizer for label signing"
```
<!-- END_TASK_3 -->

<!-- START_TASK_4 -->
### Task 4: Crypto stage `run` — current key then PLC history

**Verifies:** AC5.1, AC5.2, AC5.3, AC5.4, AC5.5, AC5.6, AC5.7 (exercised by Task 5 tests).

**Files:**
- Modify: `src/commands/test/labeler/crypto.rs` — append the stage body.
- Modify: `src/commands/test/labeler/pipeline.rs` — replace the crypto `Skipped` stub with a real call; thread `&http_client` through.

**Implementation:**

```text
pub struct CryptoFacts {
    pub verified_with_current: usize,
    pub verified_with_historic: Vec<HistoricKeyHit>,  // (key_id, label_count)
    pub unverified: usize,
}

pub struct HistoricKeyHit {
    pub key_id: String,
    pub label_count: usize,
}

pub async fn run(
    identity: &IdentityFacts,
    labels: &[Label],
    http: &dyn HttpClient,
) -> CryptoStageOutput;
```

Logic:

1. **Empty labels** → return `CryptoStageOutput { facts: None, results: vec![Skipped("labeler published no labels; nothing to verify")] }`. AC5.3.
2. For each label in `labels`:
   - `canonicalize_label_for_signing(label)?` — on `Err`, emit a `SpecViolation` `CheckResult` with code `labeler::crypto::canonicalization_failed` containing the canonicalization error detail. AC5.6. Continue to the next label; this label counts as `unverified` for the rollup purposes but is reported separately so users distinguish it from a genuine signature mismatch.
   - Verify the signature against `identity.signing_key` (the current key) using `AnyVerifyingKey::verify_prehash(&prehash, &signature)`. On success, increment `verified_with_current`.
   - On failure, push the label onto a `failed_against_current: Vec<FailedLabel>` buffer and continue.
3. If `failed_against_current.is_empty()`:
   - Emit `CheckResult { id: "crypto::rollup", status: Pass, summary: format!("{} labels verified against current key", verified_with_current) }`. AC5.1.
4. Else if `identity.did.method() == DidMethod::Plc`:
   - Call `plc_history_for_fragment(&identity.did, "atproto_label", http).await`.
   - Transport failure → emit `CheckResult { id: "crypto::plc_history_fetch", status: NetworkError, .. }` and emit one `Fail` per label in `failed_against_current` (no history was consulted, so the failure stands but is explained). AC5.7.
   - Success → iterate historic keys newest-to-oldest:
     - For each historic key, parse via `parse_multikey`. Skip parse failures (corrupt history entries, logged at `tracing::warn!`).
     - For each failed label, try verify_prehash against this historic key. On success, remove from the buffer and attribute the hit to the key (accumulate into a `BTreeMap<String, usize>`).
   - After the loop, emit `CheckResult { id: "crypto::rollup", status: Pass, advisories: true }` only if every failed label found a historic-key match. The check is a `Pass` with an _additional_ `Advisory` `CheckResult { id: "crypto::rotated_keys_used", summary: format!("{} label(s) signed by a rotated-out key ({} distinct key id(s))", total, distinct.len()) }` listing the hit key ids. AC5.2.
   - If any label remained unverified, emit `CheckResult { id: "crypto::rollup", status: SpecViolation, .. }` with a diagnostic listing every key id that was tried (current + historic). AC5.5.
5. Else (`did:web`):
   - Emit `CheckResult { id: "crypto::rollup", status: SpecViolation, .. }` with a diagnostic stating "did:web provides no rotation history" and listing the current key id. AC5.4.

**CheckResult shape guarantees:**

- Exactly one `crypto::rollup` `CheckResult` per run (or zero if AC5.3 empty path).
- Zero or more `crypto::canonicalization_failed` per label that failed to canonicalise. These are severity `SpecViolation` (they are not network errors; the canonicalizer produced a deterministic failure).
- Zero or one `crypto::plc_history_fetch` `NetworkError` per run.
- Zero or one `crypto::rotated_keys_used` `Advisory` per run.

**Pipeline driver wiring:**

Replace the crypto `Skipped` stub. The crypto stage runs only when `identity_facts.is_some()` AND `http_facts.is_some()` — otherwise emit `Skipped("blocked by upstream stage failures")` with the appropriate reason. If HTTP facts exist but `first_page.is_empty()`, pass the empty slice through so the stage itself emits the AC5.3 Skipped result with its canonical reason string.

**Verification:**

Run: `cargo check` and `cargo build`
Expected: compiles.

**Commit:**
```bash
git add src/commands/test/labeler/crypto.rs src/commands/test/labeler/pipeline.rs
git commit -m "Implement crypto stage with PLC key history fallback"
```
<!-- END_TASK_4 -->

<!-- START_TASK_5 -->
### Task 5: End-to-end snapshot tests for AC5 and AC6

**Verifies:** AC5.1 through AC5.7 and AC6.1 through AC6.8.

**Files:**
- Create: `tests/fixtures/labeler/endtoend/` — one subdirectory per end-to-end case. Each contains the full set of fixtures the four stages need: DID doc, labeler record, queryLabels responses (first + second page), subscribeLabels frame fixtures, and optionally a PLC audit log.
  - `all_pass/` — fully healthy labeler with real-labeler-derived fixtures. AC5.1 and AC6.1.
  - `identity_only_failure/` — DID doc missing the labeler signing key; all later stages skipped. AC6.2.
  - `http_decode_failure/` — malformed `queryLabels` response. AC6.2.
  - `subscription_decode_error/` — frame fixture with one malformed frame. AC6.2.
  - `current_key_fail_history_pass/` — did:plc labeler whose current key does not verify any labels but whose prior historic key does. AC5.2.
  - `current_key_fail_history_also_fail/` — did:plc labeler whose labels verify against neither current nor any historic key. AC5.5.
  - `did_web_current_key_fail/` — did:web labeler whose labels do not verify against current key. AC5.4.
  - `canonicalization_error/` — a label whose CBOR breaks the canonicalizer (e.g., contains a float — hand-authored fixture). AC5.6.
  - `plc_directory_unreachable/` — same as `current_key_fail_history_pass/` but the PLC audit log fetch returns transport error. AC5.7.
  - `empty_labeler/` — `queryLabels` returns `[]`; subscription fixture also empty. AC5.3, AC6.4.
- Create: `tests/labeler_endtoend.rs` — top-level snapshot tests that assemble `FakeHttpClient`, `FakeDnsResolver`, `FakeWebSocketClient` from each fixture directory, run the full pipeline, render the report, and assert against `insta::assert_snapshot!`. Also asserts the return value of `report.exit_code()` matches the AC's expectation.
- Create: `tests/labeler_cli.rs` — a handful of `std::process::Command`-based tests (using `assert_cmd` or direct `cargo run --`) that cover AC6 items requiring the real binary:
  - `bootstrap_failure_exits_two_on_invalid_target` — AC6.8.
  - `no_color_env_var_renders_without_ansi` — AC6.6. Set `NO_COLOR=1`; pipe output to a `Vec<u8>`; assert no `\x1b[` escape sequences appear and that the glyphs `[OK]`, `[FAIL]`, etc. are still present.
  - `verbose_flag_emits_debug_tracing_to_stderr` — AC6.7. Run with `--verbose`, assert stderr contains one or more `DEBUG atproto_devtool::identity` lines. (Uses a fake backend via an env variable, or targets a known-fast `did:plc` test fixture.)

**Test list — end-to-end module:**

- `all_pass_exits_zero_and_renders_all_ok` — AC5.1 + AC6.1.
- `identity_only_failure_exits_one_with_severity_breakdown` — AC6.2.
- `http_decode_failure_exits_one` — AC6.2.
- `subscription_decode_error_exits_one` — AC6.2.
- `current_key_fail_history_pass_exits_zero_with_advisory` — AC5.2. Assert severity is `Pass` on rollup, an `Advisory` is present on `crypto::rotated_keys_used`, exit 0.
- `current_key_fail_history_also_fail_exits_one` — AC5.5.
- `did_web_current_key_fail_exits_one` — AC5.4.
- `canonicalization_error_distinct_diagnostic_code` — AC5.6. Assert the `code` on the diagnostic is `labeler::crypto::canonicalization_failed` and NOT `labeler::crypto::signature_mismatch`.
- `plc_directory_unreachable_network_error_no_false_fail` — AC5.7. Assert `crypto::plc_history_fetch` is `NetworkError` and the labels that failed against the current key are reported as `Fail` only if history could not be consulted (i.e., one `Fail` per failed label, plus the `NetworkError`). Exit code is still 1 because of the `Fail`s — this is correct per the AC text "the labels that failed against the current key are reported as Fail only if history could not be consulted".
- `empty_labeler_skipped_crypto_only_advisory_exits_zero` — AC5.3 + AC6.4.
- `exit_code_summary_for_network_only_run_is_zero` — AC6.3. Use a fixture where all stages hit only network errors. Assert exit 0 and summary footer lists "X network errors, 0 spec violations".
- `skipped_reasons_rendered` — AC6.5. Use the `endpoint_only_no_did` fixture from Phase 3 repurposed; assert each `[SKIP]` line includes the reason text.

**Test list — CLI module:**

- `bootstrap_failure_exits_two` — AC6.8.
- `no_color_renders_without_ansi` — AC6.6.
- `verbose_emits_debug_tracing` — AC6.7.

**Verification:**

Run: `cargo insta test --accept` (first run — captures all new snapshots)
Run: `cargo test` (subsequent runs)
Expected: all new tests pass. Total test count across the whole crate is roughly 35–40 at this point.

**Commit:**
```bash
git add tests/labeler_endtoend.rs tests/labeler_cli.rs tests/fixtures/labeler/endtoend tests/snapshots
git commit -m "Add end-to-end tests covering AC5 and AC6"
```
<!-- END_TASK_5 -->
<!-- END_SUBCOMPONENT_A -->

<!-- START_TASK_6 -->
### Task 6: README and final polish

**Verifies:** no additional AC (documentation task).

**Files:**
- Create: `README.md` — install instructions (`cargo install --path .`), the `--help` excerpt produced by `cargo run -- test labeler --help`, and three example invocations:
  - Handle target: `atproto-devtool test labeler moderation.bsky.app`
  - DID target: `atproto-devtool test labeler did:plc:ar7c4by46qjdydhdevvrndac`
  - Endpoint + DID: `atproto-devtool test labeler https://mod.bsky.app --did did:plc:ar7c4by46qjdydhdevvrndac`
- Modify: `src/commands/test/labeler.rs` — audit `tracing::debug!` coverage across the four stages. Ensure HTTP requests, WebSocket frame counts, and PLC log fetches all emit `tracing::debug!` lines so `--verbose` surfaces them (AC6.7). The identity stage already has trace coverage from Phase 2; the HTTP stage should add `debug!` at request start/end; the subscription stage should add `debug!` at connect/disconnect and per decoded frame (at `trace!` level for frames, `debug!` for connect/disconnect); the crypto stage should `debug!` current-key success and each historic-key fetch attempt.

**Verification:**

Run: `cargo run -- test labeler --help > /tmp/help.txt` then copy relevant excerpt into `README.md`.
Run: `cargo run -- test labeler --verbose https://mod.bsky.app 2> /tmp/stderr.txt` (online). Expected: `/tmp/stderr.txt` contains `DEBUG` level tracing lines.

**Commit:**
```bash
git add README.md src/commands/test/labeler
git commit -m "Add README and audit --verbose tracing coverage"
```
<!-- END_TASK_6 -->

<!-- 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 pass (Phase 1 through Phase 6, roughly 35–40 total).
4. `cargo run -- test labeler --help` — renders with all AC1.6-required flags.
5. (Online) `cargo run -- test labeler moderation.bsky.app` — all four stages `[OK]`, exit 0 (AC6.1, AC5.1).
6. (Online) `cargo run -- test labeler https://mod.bsky.app` — endpoint-only mode skips identity/crypto, runs HTTP/subscription, exits 0 (AC1.3).
7. `NO_COLOR=1 cargo run -- test labeler moderation.bsky.app | cat` — stdout contains `[OK]`/`[FAIL]`/`[SKIP]`/`[WARN]` glyphs and no `\x1b[` sequences (AC6.6).

**Done when:** steps 1–4 and 7 pass. Steps 5 and 6 are smoke checks against a real labeler and are allowed to vary from run to run (the real labeler may have temporary incidents); they are not `cargo test` gating.
<!-- END_TASK_7 -->

---

## Phase 6 done-when checklist

- `src/commands/test/labeler/crypto.rs` exists with the canonicalizer and the stage body.
- `src/common/identity.rs::plc_history_for_fragment` exists and is tested.
- Pipeline driver wires the crypto stage between subscription and report finalization.
- `README.md` present with install instructions and examples.
- Every AC from the design is covered by at least one automated test and the automated test suite is green.
- `cargo run -- test labeler moderation.bsky.app` — running the binary against a real healthy labeler produces an all-`[OK]` report and exits 0.
- Running the same binary against a fixture labeler with a rotated-out signing key produces an `Advisory` rollup and still exits 0.
- Running the same binary against a fixture labeler with an actual signature mismatch produces a `Fail` and exits 1.
- `cargo clippy -- -D warnings` and `cargo fmt --check` clean.
- Commit history shows the six atomic commits from this phase.
