# Test-labeler Implementation Plan — Phase 4

**Goal:** Implement the HTTP stage. Hit `com.atproto.label.queryLabels` on the labeler endpoint, verify schema conformance, exercise pagination once, and publish the collected labels into `HttpFacts::first_page` for the crypto stage to consume in Phase 6.

**Architecture:** New module `src/commands/test/labeler/http.rs`. Uses the typed `atrium-api` client (already in the dependency graph from Phase 3) over an `atrium-xrpc-client::reqwest::ReqwestClient` pointed at the labeler endpoint. The stage emits named per-check `CheckResult`s and returns an `HttpFacts { first_page: Vec<Label>, pagination_ok: bool, first_page_raw_bytes: Arc<[u8]> }`. The pipeline driver (Phase 3) is edited to call this stage between identity and the still-stubbed subscription stage. Tests run against a small in-process `FakeXrpcClient` implementing `atrium_xrpc::XrpcClient` directly, so no real sockets are opened.

**Tech Stack:** No new crates. Uses `atrium-api` + `atrium-xrpc-client` + existing fixture/testing infrastructure.

**Scope:** Phase 4 of 6.

**Codebase verified:** 2026-04-13 (post Phase 3). `src/commands/test/labeler/{report,pipeline,identity}.rs` exist. `src/commands/test/labeler/http.rs` does **not** yet exist.

---

## Acceptance Criteria Coverage

### test-labeler.AC3: HTTP-layer checks (`queryLabels`)
- **test-labeler.AC3.1 Success:** A labeler endpoint that responds to `com.atproto.label.queryLabels` with a well-formed lexicon response decodes into typed labels and passes the schema check.
- **test-labeler.AC3.2 Success:** A labeler that honors the `cursor` parameter — returning a distinct page when called with a cursor from the first page — passes the pagination round-trip check.
- **test-labeler.AC3.3 Success:** A labeler that returns an empty labels array passes the schema and pagination checks and contributes a distinct `Advisory` ("labeler has no published labels") to inform downstream stages.
- **test-labeler.AC3.4 Failure:** A `queryLabels` response that omits required fields or otherwise fails lexicon decoding produces a `SpecViolation` whose diagnostic carries the response JSON as its `NamedSource`.
- **test-labeler.AC3.5 Failure:** A labeler that ignores the `cursor` parameter (returns the first page again) produces a `SpecViolation` on the pagination check.
- **test-labeler.AC3.6 NetworkError:** An unreachable or TLS-failing labeler endpoint produces a `NetworkError` that does not cascade into `queryLabels` schema failures.

No other AC groups are covered in this phase.

---

## Codebase state at start of phase

Post Phase 3:
- `src/commands/test/labeler/report.rs` — the `LabelerReport` and `CheckResult` types.
- `src/commands/test/labeler/pipeline.rs` — `LabelerTarget`, `parse_target`, `run_pipeline`. The driver currently emits `Skipped("not yet implemented (phase N)")` for HTTP, Subscription, and Crypto.
- `src/commands/test/labeler/identity.rs` — complete Phase 3 implementation.
- `src/commands/test/labeler.rs` — wires CLI → pipeline → render.
- No `http.rs` yet; no `tests/fixtures/labeler/http/`.

---

## External dependency notes

- **`atrium-api::com::atproto::label::query_labels`** is a generated module containing `ParametersData { cursor: Option<String>, limit: Option<LimitedNonZeroU8<250>>, sources: Option<Vec<Did>>, uri_patterns: Vec<String> }` and `OutputData { cursor: Option<String>, labels: Vec<Label> }`. The wire field name for `uri_patterns` is `uriPatterns` via `#[serde(rename_all = "camelCase")]`. The test calls `query_labels` with `uri_patterns = vec!["*".into()]` as a catch-all pattern.
- **`atrium-api::com::atproto::label::defs::Label`** — the typed label record returned in the `labels` field. Used directly for Phase 4's schema check: any JSON body that fails to decode into `Output` is a `SpecViolation`. The Phase 4 HTTP stage captures the raw bytes of the response before handing them to `atrium_xrpc` so a decode failure's diagnostic can attach the offending JSON as a `NamedSource`. Because `atrium_xrpc::XrpcClient` does the decode internally, capturing the raw bytes requires providing a small wrapper client whose `send_xrpc` impl tees the HTTP body into a `Vec<u8>` before delegating — see Task 2.
- **`atrium-xrpc::XrpcClient` / `atrium-xrpc::HttpClient`** (re-exported through `atrium-xrpc-client::reqwest::ReqwestClient`) — trait surface is small: a single `async fn send_http(..)` that returns bytes. The typed client layer decodes those bytes into `Output`. We exploit this split in Phase 4: the tee-client wraps the inner `reqwest::Client`-backed `ReqwestClient` and clones the response body, making the raw bytes available for diagnostics without re-issuing the request.

---

<!-- START_SUBCOMPONENT_A (tasks 1-3) -->
<!-- START_TASK_1 -->
### Task 1: `HttpFacts`, `HttpStageOutput`, and stage entry point

**Verifies:** structural precondition for AC3.1–AC3.6 (types only; tests arrive in Task 3).

**Files:**
- Create: `src/commands/test/labeler/http.rs` (types and stage signature)
- Modify: `src/commands/test/labeler.rs` to add `pub mod http;`

**Implementation:**

```text
pub struct HttpFacts {
    pub first_page: Vec<Label>,
    pub first_page_raw_bytes: Arc<[u8]>,
    pub first_page_source_url: String,
    pub pagination_ok: bool,
}

pub struct HttpStageOutput {
    pub facts: Option<HttpFacts>,
    pub results: Vec<CheckResult>,
}

pub async fn run(
    labeler_endpoint: &Url,
    http: &dyn RawHttpTee,
) -> HttpStageOutput;
```

`RawHttpTee` is a new narrow async trait defined here (similar in shape to the `HttpClient` trait from Phase 2, but specialised for returning both the decoded typed response and the raw bytes):

```text
#[async_trait]
pub trait RawHttpTee: Send + Sync {
    /// Perform a `com.atproto.label.queryLabels` call against the labeler.
    /// Returns both the raw response body and, if decoding succeeded, the typed Output.
    async fn query_labels(
        &self,
        cursor: Option<&str>,
    ) -> Result<RawXrpcResponse, HttpStageError>;
}

pub struct RawXrpcResponse {
    pub status: StatusCode,
    pub raw_body: Arc<[u8]>,
    pub decoded: Result<query_labels::Output, serde_json::Error>,
    pub source_url: String,
}
```

The trait signature takes only the wire-level knob the Phase 4 stage actually cares about — the opaque `cursor` string — rather than an atrium `Parameters` value. The stage always sends `uriPatterns=*` and `limit=50`; those are implementation details of the tee, not caller-configurable inputs. Using `cursor: Option<&str>` keeps the trait minimal and mirrors what the real implementation will actually use when constructing its URL (see Task 2).

The trait keeps HTTP IO out of the stage body, so Task 3 can supply a fake implementation. The real implementation (Task 2) issues a direct reqwest GET against `<endpoint>/xrpc/com.atproto.label.queryLabels` with the hard-coded `uriPatterns=*&limit=50` query string and the optional `&cursor=<cursor>` when present.

Seven CheckResult IDs are declared as `const`s in this file so both the real stage and the fake-driven tests reference them by the same name:

- `http::endpoint_reachable`
- `http::query_labels_schema_first_page`
- `http::query_labels_empty_advisory`
- `http::query_labels_schema_second_page`
- `http::pagination_round_trip`
- `http::pagination_ignored_cursor`

Also add an error enum `HttpStageError { Transport(reqwest::Error), DecodeFailed { raw_body: Arc<[u8]>, source: serde_json::Error, source_url: String } }`. The decode path returns `raw_body` so the Phase 4 diagnostic can attach it as `NamedSource`.

**Verification:**

Run: `cargo check`
Expected: compiles.

**Commit:**
```bash
git add src/commands/test/labeler/http.rs src/commands/test/labeler.rs
git commit -m "Add HTTP stage types and RawHttpTee trait"
```
<!-- END_TASK_1 -->

<!-- START_TASK_2 -->
### Task 2: `RealHttpTee` implementation and pipeline wiring

**Verifies:** end-to-end plumbing for AC3.1, AC3.2, AC3.3, AC3.6 (exercised by Task 3 tests).

**Files:**
- Modify: `src/commands/test/labeler/http.rs` — add the real implementation and the stage body.
- Modify: `src/commands/test/labeler/pipeline.rs` — replace the HTTP-stage `Skipped` stub with a real call.

**`RealHttpTee` implementation:**

A small newtype wrapping a `reqwest::Client`, pointed at the labeler's base URL. It performs the `queryLabels` call directly as an HTTP GET to `<endpoint>/xrpc/com.atproto.label.queryLabels?uriPatterns=*&limit=50[&cursor=...]`, reads the full response body into a `Bytes`, converts to `Arc<[u8]>`, and then attempts `serde_json::from_slice::<query_labels::Output>` on the bytes. This intentionally bypasses the atrium typed client for the Phase 4 stage specifically because we need the raw bytes on-hand whether the decode succeeded or failed.

Rationale: atrium's `AtpServiceClient` does the decode internally and discards the bytes, which would make AC3.4's "diagnostic carries the response JSON as its NamedSource" impossible without re-requesting. Issuing the HTTP GET ourselves and running the same typed decode is structurally cleaner and keeps the atrium typed `Output` / `Label` types for downstream Phase 6 consumption.

Return values:
- Transport/TLS error → `Err(HttpStageError::Transport(e))`.
- Non-2xx → `Ok(RawXrpcResponse { status, raw_body, decoded: Err(..), .. })` where the decode error reflects "unexpected status" — the stage body (below) classifies this as a `SpecViolation` only if status is 200-range but decode failed; otherwise it is a `NetworkError` distinct from "labeler returned a malformed body".
- 2xx success → attempt `serde_json::from_slice`; populate `decoded` accordingly.

**Stage body (`http::run`):**

1. Emit `http::endpoint_reachable` — tee a zero-pattern GET. Any network/TLS failure → `NetworkError` and return `HttpStageOutput { facts: None, results }` immediately (AC3.6). 4xx/5xx with a readable body → `Pass` on endpoint reachable but subsequent checks may fail. (We still call the real `queryLabels` next.)
2. Emit `http::query_labels_schema_first_page`:
   - Call `tee.query_labels(None)` (no cursor → first page).
   - On decode success → `Pass` and capture the typed `Output` + raw bytes for later.
   - On decode failure → `SpecViolation` with diagnostic `{ code: "labeler::http::schema_failure", source_code: NamedSource::new(source_url, raw_body), label: span-over-first-error-offset }`. AC3.4. Return `HttpStageOutput { facts: None, results }` — subsequent checks cannot run.
   - On transport failure → `NetworkError` + return. AC3.6.
3. If the first page has an empty `labels` array, emit `http::query_labels_empty_advisory` with severity `Advisory`, summary `"labeler has no published labels"` (AC3.3). Still proceed to pagination check — an empty-but-legal response should still honor `cursor`.
4. Emit `http::query_labels_schema_second_page` + `http::pagination_round_trip`:
   - If the first page had a `cursor` in the response, call `tee.query_labels(Some(&cursor))` again with that cursor.
   - Second call decode failure → `SpecViolation` `http::query_labels_schema_second_page`.
   - Decode success but `output.labels == first_page.labels` (deep equality) → `SpecViolation` `http::pagination_ignored_cursor`, summary "labeler ignored the cursor parameter". AC3.5.
   - Decode success and labels differ (or second page is empty with no cursor → "we hit the end") → `Pass` `http::pagination_round_trip`.
   - If the first page had **no** cursor, emit `http::pagination_round_trip` as `Pass` with summary "first page was complete; pagination not exercised". This is not a violation — labelers smaller than the limit legitimately have no cursor.
5. Build `HttpFacts { first_page, first_page_raw_bytes, first_page_source_url, pagination_ok }` and return.

**Pipeline driver wiring:**

Replace the Phase 3 stub `Skipped` for HTTP with:
- If `identity_facts.is_some()` → derive the labeler endpoint URL from `IdentityFacts::labeler_endpoint`.
- Else if `target == LabelerTarget::Endpoint { url, .. }` → use `url` directly (AC1.3 path: endpoint-only invocations still run the HTTP stage).
- Else → HTTP stage is `Skipped("identity stage produced no labeler endpoint")`.
- Build a `RealHttpTee` and call `http::run(endpoint, &tee).await`. Append all results. Propagate `HttpFacts` into the pipeline state for Phase 6's crypto stage to consume later.

**Verification:**

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

Run: `cargo run -- test labeler moderation.bsky.app` (online)
Expected: HTTP section renders with `[OK]` for reachable, schema, pagination round-trip; later stages still `[SKIP]`.

**Commit:**
```bash
git add src/commands/test/labeler/http.rs src/commands/test/labeler/pipeline.rs
git commit -m "Implement HTTP stage against real labeler endpoint"
```
<!-- END_TASK_2 -->

<!-- START_TASK_3 -->
### Task 3: Snapshot tests for the HTTP stage

**Verifies:** AC3.1, AC3.2, AC3.3, AC3.4, AC3.5, AC3.6.

**Files:**
- Create: `tests/fixtures/labeler/http/` — one subdirectory per case, each containing one or more recorded response JSON files:
  - `healthy/first_page.json`, `healthy/second_page.json` — two distinct non-empty pages, with a `cursor` on the first and none on the second. AC3.1, AC3.2.
  - `empty/first_page.json` — `{ "cursor": null, "labels": [] }`. AC3.3.
  - `malformed/first_page.json` — JSON that fails `serde_json::from_slice::<Output>` (e.g., `"labels"` is `42` instead of an array). AC3.4.
  - `ignored_cursor/first_page.json`, `ignored_cursor/second_page.json` — two pages with identical `labels` arrays. AC3.5.
  - `unreachable/` — empty directory, tests use a fake that returns transport error without looking anything up. AC3.6.
- Create: `tests/labeler_http.rs` — integration-level snapshot tests. Defines a `FakeRawHttpTee { responses: Vec<RawXrpcResponse>, transport_error: bool }` that returns queued responses in order and ignores the request parameters except for the presence of `cursor`.
- Update: `tests/snapshots/` — capture the new snapshots with `cargo insta test --accept`.

**Test list:**

- `http_healthy_renders_all_ok` — AC3.1 + AC3.2. Snapshot shows `[OK]` for reachable, schema_first_page, pagination_round_trip.
- `http_empty_labeler_emits_advisory` — AC3.3. Snapshot shows `[WARN]` on `http::query_labels_empty_advisory` and `[OK]` for the schema + pagination checks. Exit code remains 0 (the rendered report's summary line includes 1 advisory).
- `http_malformed_schema_fails_with_source_span` — AC3.4. Snapshot shows `[FAIL] http::query_labels_schema_first_page` with the miette diagnostic rendering the malformed JSON as source (matching the raw bytes fixture). Assert the rendered string contains `"labels"`.
- `http_ignored_cursor_fails` — AC3.5. Snapshot shows `[FAIL] http::pagination_ignored_cursor` with summary "labeler ignored the cursor parameter".
- `http_transport_error_renders_network_error` — AC3.6. Fake tee's `query_labels` returns `HttpStageError::Transport(_)`. Snapshot shows `[NET] http::endpoint_reachable`, exit 0, summary footer mentions 1 network error. Downstream identity stage in this test uses the `healthy_plc` fixture from Phase 3, so identity is `[OK]` — asserting AC3.6 does not cascade into schema failures.

**Verification:**

Run: `cargo insta test --accept` (first run)
Run: `cargo test` (subsequent runs)
Expected: all 5 new tests pass.

**Commit:**
```bash
git add tests/labeler_http.rs tests/fixtures/labeler/http tests/snapshots
git commit -m "Add HTTP stage snapshot tests covering AC3"
```
<!-- END_TASK_3 -->
<!-- END_SUBCOMPONENT_A -->

<!-- START_TASK_4 -->
### Task 4: Final phase verification

**Steps:**
1. `cargo fmt --check` — clean.
2. `cargo clippy -- -D warnings` — clean.
3. `cargo test` — all Phase 3 and Phase 4 tests pass (17 total from both phases).
4. (Optional, online) `cargo run -- test labeler moderation.bsky.app` — HTTP section populated with `[OK]`s.

**Done when:** steps 1–3 pass.
<!-- END_TASK_4 -->

---

## Phase 4 done-when checklist

- `src/commands/test/labeler/http.rs` exists and exports `HttpFacts`, `HttpStageOutput`, `run`, the `RawHttpTee` trait, and a real `RealHttpTee` implementation.
- Pipeline driver calls `http::run` between the identity stage and the still-stubbed subscription stage.
- All five Phase 4 snapshot tests pass, verifying AC3.1 through AC3.6.
- Running the binary against a real labeler shows a populated HTTP section.
- `cargo clippy -- -D warnings` and `cargo fmt --check` clean.
- Commit history shows the three atomic commits from this phase.
