# Test-labeler Implementation Plan — Phase 2

**Goal:** Build a reusable `common::identity` module covering handle resolution, DID-document fetching, service-entry lookup, and multikey parsing — the primitives every future `test` subcommand will rely on.

**Architecture:** Single module file `src/common/identity.rs` exposing four async/sync functions plus the types they return. Functions are written against a narrow `HttpClient` trait and a narrow `DnsResolver` trait so the unit tests can replay recorded fixtures without network access. `resolve_did` returns both the parsed document _and_ the raw bytes (wrapped in a struct) so Phase 3 onwards can attach those bytes to `miette` diagnostics. All resolution paths are instrumented with `tracing::debug!` calls gated by the existing `--verbose` flag.

**Tech Stack:** adds `reqwest` (`rustls-tls`, `json`), `hickory-resolver`, `multibase`, `k256` (`ecdsa`), `p256` (`ecdsa`), `serde`, `serde_json`, `async-trait` (for the narrow IO traits).

**Scope:** Phase 2 of 6.

**Codebase verified:** 2026-04-13. Phase 1 landed the `common.rs` + `common/diagnostics.rs` scaffold, `Cargo.toml` dependency set, and the CLI bootstrap. `src/common/identity.rs` **does not yet exist**; nothing in the repository imports it. Phase 2 creates it fresh and adds one `pub mod identity;` line to `src/common.rs`.

---

## Acceptance Criteria Coverage

**Verifies: None.**

Phase 2 is an infrastructure/library phase. Per the source design's "Done when" section: _"Infrastructure/library phase — tests verify primitive correctness but do not yet cover acceptance criteria (those arrive once wired into the labeler pipeline in Phase 3)."_ Unit tests in this phase verify primitive correctness (e.g., "did:plc resolver parses a recorded document", "k256 multikey parses to the correct key") but do **not** exercise any AC from the design — the ACs they enable are evaluated in Phase 3.

---

## Codebase state at start of phase

- Phase 1 present and committed: `Cargo.toml` has the bootstrap dependency set; `src/main.rs` is the tokio/miette bootstrap; `src/common.rs` declares `pub mod diagnostics;` only; `src/common/diagnostics.rs` holds the shared miette helpers.
- `src/common/identity.rs` does not exist.
- No test fixtures directory yet (`tests/fixtures/` will be created in this phase, subdirectory `tests/fixtures/identity/`).
- No `CLAUDE.md` in the repo. User-global Rust conventions apply (sibling-file modules, Rust 2024, `#[expect]`, etc.).
- `Cargo.lock` exists from Phase 1 build.

---

## External dependency notes

All crate versions verified via `cargo read` on 2026-04-13:

- **`reqwest` 0.13.2** — used with features `rustls-tls`, `json`, `gzip`. We deliberately avoid the default `native-tls` feature so the binary does not link against OpenSSL on Linux. `reqwest::Client` is cheap to clone and should be shared across the run.
- **`hickory-resolver` 0.25.2** — public API of note: `TokioResolver::tokio(ResolverConfig, ResolverOpts)` builds an async resolver; `.txt_lookup(&str)` is the method used for the `_atproto.<handle>` TXT lookup. Default config reads `/etc/resolv.conf` on Unix. There is no stable `from_system_conf` any more; use `TokioResolver::builder_tokio()` followed by `.build()` in 0.25.
- **`multibase` 0.9.2** — `decode(&str) -> Result<(Base, Vec<u8>)>` returns the base encoding marker plus raw bytes. For atproto multikeys we expect `Base::Base58Btc` (prefix `z`), and the decoded bytes begin with a two-byte varint curve tag (`0xe7 0x01` for `secp256k1` / `k256`, `0x80 0x24` for `p256`).
- **`k256` 0.13.4** and **`p256` 0.13.2** — use feature `ecdsa`. `VerifyingKey::from_sec1_bytes(&[u8])` parses both compressed and uncompressed SEC1 encodings. atproto multikeys use **compressed** 33-byte SEC1 bodies after the curve-tag prefix.
- **`serde` 1.0.228** + **`serde_json` 1.0.149** — standard derive usage. Per user global rules, **do not use `#[serde(flatten)]` or `#[serde(untagged)]`**; use custom visitors where needed. Prefer `serde_ignored` if we need to warn on unknown fields, but Phase 2 does not require this.
- **`async-trait` 0.1** — needed because `HttpClient` and `DnsResolver` traits declare async methods; Rust 2024 still requires it for trait objects that box async futures.

**atproto specs consulted:**

- **Handle resolution** — `https://atproto.com/specs/handle` — resolvers try in order: DNS TXT lookup on `_atproto.<handle>` (record shape `did=did:plc:...`), then HTTPS GET on `https://<handle>/.well-known/atproto-did` (body is the DID string, trimmed). A handle may use either mechanism; the resolver returns on the first success and falls back to the second on a "no such record" error from the first.
- **did:plc** — `https://did-method-plc.github.io/did-method-plc/` — resolve by `GET https://plc.directory/{did}` → JSON DID document.
- **did:web** — `https://w3c-ccg.github.io/did-method-web/` — strip `did:web:` prefix, percent-decode, split on `:` into `host[/path/...]`; resolve via `https://<host>[/path]/.well-known/did.json` (no path) or `https://<host>/<path>/did.json` (with path segments). Phase 2 implements the no-path form plus the simple path form; richer forms can be added later.
- **Multikey** — `https://w3c-ccg.github.io/multikey/` — multibase string whose decoded bytes are a multicodec-prefixed raw public key. The two curves this tool accepts are `secp256k1-pub` (varint `0xe7 0x01`) and `p256-pub` (varint `0x80 0x24`). Both use compressed SEC1 for the key body.

---

<!-- START_TASK_1 -->
### Task 1: Add Phase 2 dependencies to `Cargo.toml`

**Files:**
- Modify: `Cargo.toml` `[dependencies]` (append, keep Phase 1 entries alphabetised in the merged result)

**Implementation:**

Add the following, merging alphabetically with the Phase 1 block:

```toml
async-trait = "0.1"
hickory-resolver = "0.25"
k256 = { version = "0.13", features = ["ecdsa"] }
multibase = "0.9"
p256 = { version = "0.13", features = ["ecdsa"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls-tls", "json", "gzip"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
```

Also add `[dev-dependencies]`:

```toml
[dev-dependencies]
tokio = { version = "1.51", features = ["rt", "macros", "test-util", "time"] }
```

(The runtime `tokio` line keeps its Phase 1 features; dev-dependencies only adds `test-util` for `tokio::time::pause()` in the unit tests.)

**Verification:**

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

**Commit:**
```bash
git add Cargo.toml Cargo.lock
git commit -m "Add Phase 2 dependencies for identity primitives"
```
<!-- END_TASK_1 -->

<!-- START_SUBCOMPONENT_A (tasks 2-4) -->
<!-- START_TASK_2 -->
### Task 2: Define `common::identity` types and narrow IO traits

**Files:**
- Create: `src/common/identity.rs` (types and traits only; function bodies arrive in Task 3)
- Modify: `src/common.rs` (add `pub mod identity;`)

**Implementation:**

The types declared in this task:

- `DidDocument` — minimal serde struct mirroring only the fields we need (`id`, `also_known_as`, `verification_method`, `service`). Uses explicit field-level `#[serde(rename = ...)]` attributes; **no `#[serde(flatten)]`** per user-global rules.
- `RawDidDocument { parsed: DidDocument, source_bytes: Arc<[u8]>, source_name: String }` — what `resolve_did` actually returns. The bytes and name feed `NamedSource` in Phase 3 diagnostics.
- `Service { id, type_, service_endpoint }` and `VerificationMethod { id, type_, controller, public_key_multibase }` with the matching `#[serde(rename)]`s.
- `ParsedMultikey { curve: Curve, verifying_key: AnyVerifyingKey }` where `Curve` is `Secp256k1 | P256` and `AnyVerifyingKey` wraps the curve-specific types.
- `AnyVerifyingKey` — enum `{ K256(k256::ecdsa::VerifyingKey), P256(p256::ecdsa::VerifyingKey) }` with a `verify_prehash(&self, prehash: &[u8; 32], sig: &Signature) -> Result<(), SignatureError>` helper that dispatches to the correct curve. Phase 6 uses this; declaring it here keeps the curve-awareness in one place.
- `IdentityError` — `thiserror::Error` enum with variants covering `DnsLookupFailed`, `HandleHttpFallbackFailed`, `DidParseError`, `DidResolutionFailed { status, body }`, `DidDocumentDecodeFailed`, `MultikeyDecodeFailed`, `UnsupportedCurve`, `HttpTransport`. Every non-trivial variant carries `#[source]` causes and `#[backtrace]` where applicable. The error type is wrapped later by `IdentityFacts` miette diagnostics in Phase 3.
- Traits:
  - `#[async_trait] pub trait HttpClient: Send + Sync { async fn get_bytes(&self, url: &Url) -> Result<(StatusCode, Vec<u8>), IdentityError>; }`
  - `#[async_trait] pub trait DnsResolver: Send + Sync { async fn txt_lookup(&self, name: &str) -> Result<Vec<String>, IdentityError>; }`
  - Real implementations live in Task 3; test fakes live in Task 4.

Type-level invariants to preserve:
- `DidDocument.id` is typed as `String` for Phase 2 (we accept anything the server returned and parse it into a `Did` newtype only when we need to reason about the method). Phase 3 can tighten this if needed.
- `RawDidDocument::source_bytes` uses `Arc<[u8]>` so cloning into `NamedSource` in Phase 3 is cheap.
- `ParsedMultikey::curve` and the `AnyVerifyingKey` variant tag MUST agree (constructor is private, built only via `parse_multikey`).

**Testing:** deferred to Task 4.

**Verification:**

Run: `cargo check`
Expected: compiles. No tests yet.

**Commit:**
```bash
git add src/common.rs src/common/identity.rs
git commit -m "Add common::identity types and IO traits"
```
<!-- END_TASK_2 -->

<!-- START_TASK_3 -->
### Task 3: Implement the four resolution primitives

**Files:**
- Modify: `src/common/identity.rs` — add the function bodies and the two real-IO implementations.

**Implementation:**

Four functions to add, plus the real trait impls:

**`pub async fn resolve_handle(handle: &str, http: &dyn HttpClient, dns: &dyn DnsResolver) -> Result<Did, IdentityError>`**

Logic:
1. Validate `handle` syntactically — at least one `.`, all ASCII, no leading/trailing dot. Return `IdentityError::InvalidHandle` on failure.
2. DNS path: look up `_atproto.<handle>` via `dns.txt_lookup`. For each record, strip leading/trailing whitespace; if it starts with `did=`, parse the tail as a DID. Return the first match.
3. HTTPS fallback: `GET https://<handle>/.well-known/atproto-did`. Expect `200 OK` with a plain-text body that is a valid DID when trimmed. Return `IdentityError::HandleHttpFallbackFailed` on non-200 or an invalid DID body.
4. If both paths fail, return `IdentityError::HandleUnresolvable { dns_error, http_error }`. Both underlying errors are captured; Phase 3 surfaces them in the miette diagnostic.

`Did` here is a local newtype `struct Did(String)` with `fn method(&self) -> DidMethod { .. }` returning `Plc | Web | Other`. Keep it in the same file.

**`pub async fn resolve_did(did: &Did, http: &dyn HttpClient) -> Result<RawDidDocument, IdentityError>`**

Logic by method:
- `did:plc:<id>` → `GET https://plc.directory/{did}`. Expect `200 OK`; body is JSON.
- `did:web:<host>[:path...]` → URL-decode path segments, then `GET https://<host>/<path>/.well-known/did.json` when path is empty, else `https://<host>/<path>/did.json`. Expect `200 OK`; body is JSON.
- Any other method → `IdentityError::UnsupportedDidMethod(method.to_string())`.

Parse the body bytes with `serde_json::from_slice::<DidDocument>`. On parse failure return `IdentityError::DidDocumentDecodeFailed { source_name, source_bytes, cause }` so Phase 3 can use the bytes directly as a `NamedSource`. On success, return `RawDidDocument { parsed, source_bytes: Arc::from(body), source_name }` where `source_name` is the URL the document came from.

**`pub fn find_service<'a>(doc: &'a DidDocument, id_fragment: &str, expected_type: &str) -> Option<&'a Service>`**

Spec-correct matching: a DID doc service entry has `id: "<did>#<fragment>"` or sometimes just `"#<fragment>"`. Accept both forms. Match `type` case-sensitively (atproto uses PascalCase type names like `"AtprotoLabeler"`).

**`pub fn parse_multikey(raw: &str) -> Result<ParsedMultikey, IdentityError>`**

Logic:
1. `multibase::decode(raw)` → `(Base::Base58Btc, bytes)`. Require `Base58Btc`; anything else is `IdentityError::UnsupportedMultibase(_)`.
2. Parse a varint from the start of `bytes` with `unsigned-varint` manually (two bytes is enough for both supported curves; write a tiny inline decoder rather than adding a dep — it is three lines of code). Curve dispatch:
   - `0xe7 0x01` (65 + 128, `secp256k1-pub`) → `k256::ecdsa::VerifyingKey::from_sec1_bytes(rest)` → `AnyVerifyingKey::K256`.
   - `0x80 0x24` (decimal `4608`, `p256-pub`) → `p256::ecdsa::VerifyingKey::from_sec1_bytes(rest)` → `AnyVerifyingKey::P256`.
   - Any other prefix → `IdentityError::UnsupportedCurve { codec_prefix: Vec<u8> }`.
3. `rest` MUST be exactly 33 bytes (compressed SEC1). Length mismatch is `IdentityError::MultikeyLengthInvalid`.

**Real trait implementations:**

- `struct RealHttpClient { inner: reqwest::Client }` implementing `HttpClient`. Build the client in `RealHttpClient::new()` with a conservative default timeout (10s) and rustls. `get_bytes` calls `self.inner.get(url.clone()).send().await?.error_for_status()?.bytes().await?` and maps failures to `IdentityError::HttpTransport`.
- `struct RealDnsResolver { inner: hickory_resolver::TokioResolver }` implementing `DnsResolver`. Build via `TokioResolver::builder_tokio().build()`. `txt_lookup` calls `self.inner.txt_lookup(name).await?.iter().map(|r| r.to_string()).collect()`.

Both real types have a `pub fn new() -> Self` that never fails for the DNS case (system config issues surface on first query) and returns `Result<Self, IdentityError>` for the HTTP case.

**Tracing:** every resolution function emits a `tracing::debug!(target = "atproto_devtool::identity", ..)` span at entry including the input. Details (URL, status, bytes-read) log at debug level. This is wired to stderr by Phase 1's `install_tracing` when `--verbose` is passed.

**Testing:** covered in Task 4.

**Verification:**

Run: `cargo check`
Expected: compiles.

Run: `cargo build`
Expected: succeeds.

**Commit:**
```bash
git add src/common/identity.rs
git commit -m "Implement handle, DID, service, and multikey resolution"
```
<!-- END_TASK_3 -->

<!-- START_TASK_4 -->
### Task 4: Unit tests over recorded fixtures

**Verifies:** no design AC — this task verifies primitive correctness only, per the Phase 2 "Done when" in the source design.

**Files:**
- Create: `tests/fixtures/identity/plc_bsky_labeler.json` (recorded DID document for a well-known Bluesky labeler; byte-for-byte what `plc.directory` returned on the fixture-capture date)
- Create: `tests/fixtures/identity/plc_bsky_labeler.txt` (short README describing the source URL and capture date)
- Create: `tests/fixtures/identity/web_example.json` (a did:web document for `did:web:example.com`; minimal hand-authored)
- Create: `tests/fixtures/identity/multikey_k256.txt` (single-line text: a known-good k256 multikey string)
- Create: `tests/fixtures/identity/multikey_p256.txt` (single-line text: a known-good p256 multikey string)
- Create: `tests/fixtures/README.md` (one paragraph explaining the overall fixture directory and why fixtures are versioned with the repo)
- Modify: `src/common/identity.rs` — append a `#[cfg(test)] mod tests { .. }` block at end of the file with the test cases below, plus the fake `HttpClient`/`DnsResolver` implementations they need.

**Test implementations (as `#[tokio::test]` or `#[test]` as appropriate):**

Each test constructs a `FakeHttpClient` and/or `FakeDnsResolver` from a map of URL→bytes or name→records, then calls the primitive under test.

- `fake_clients` helper — in the same test module, two structs: `FakeHttpClient { responses: HashMap<Url, (StatusCode, Vec<u8>)> }` and `FakeDnsResolver { records: HashMap<String, Vec<String>> }`. Both implement the corresponding trait. Any lookup not in the map returns a `NotFound`-flavored `IdentityError` so tests can exercise the fallback path.
- `resolve_handle_via_dns` — DNS fake returns `["did=did:plc:abc123"]` for `_atproto.alice.example`. Asserts the resolved DID is `did:plc:abc123` and HTTP client is never called.
- `resolve_handle_via_https_fallback` — DNS fake returns `NotFound`; HTTP fake returns `200 OK` body `"did:plc:abc123\n"` for `https://alice.example/.well-known/atproto-did`. Asserts the trimmed DID returned matches.
- `resolve_handle_both_paths_fail` — both fakes return `NotFound`. Asserts `IdentityError::HandleUnresolvable` variant with both causes populated.
- `resolve_did_plc_success` — HTTP fake returns the `plc_bsky_labeler.json` bytes for the PLC URL. Asserts the `RawDidDocument.parsed` contains the expected `id`, at least one service with type `AtprotoLabeler`, and that `source_bytes` equals the input.
- `resolve_did_web_success` — HTTP fake returns `web_example.json` for `https://example.com/.well-known/did.json`. Asserts parse succeeds and the `source_name` field equals the URL.
- `resolve_did_decode_failure_preserves_bytes` — HTTP fake returns 200 with `b"not valid json"`. Asserts the error variant is `DidDocumentDecodeFailed` and that `source_bytes` of the error equals `b"not valid json"` (the Phase 3 diagnostic relies on this).
- `find_service_matches_both_id_forms` — constructs a `DidDocument` in-memory with two services, one with `id: "did:plc:abc#atproto_labeler"` and one with `id: "#atproto_pds"`. Asserts both are matched by `find_service(&doc, "atproto_labeler", "AtprotoLabeler")` and `find_service(&doc, "atproto_pds", "AtprotoPersonalDataServer")` respectively.
- `find_service_type_mismatch_returns_none` — same doc, but searching for type `WrongType`. Asserts `None`.
- `parse_multikey_k256` — reads `multikey_k256.txt`, parses, asserts `curve == Secp256k1`, asserts the decoded `VerifyingKey`'s SEC1 compressed bytes (`verifying_key.to_sec1_bytes()` on the `K256` branch) match a hex literal captured at fixture-authoring time. This is a pure parsing round-trip test — no signature verification — so the failure mode is unambiguous.
- `parse_multikey_p256` — mirror of the above with p256, comparing against the P256 branch's `to_sec1_bytes()` output.
- `parse_multikey_unsupported_curve` — builds an in-memory multikey string whose decoded varint prefix is `0x01 0x00` (a reserved codec). Asserts `UnsupportedCurve`.
- `parse_multikey_not_base58btc` — builds a `base16`-prefixed multikey. Asserts `UnsupportedMultibase`.
- `parse_multikey_wrong_length` — constructs a valid-prefix multikey with a 10-byte body. Asserts `MultikeyLengthInvalid`.

All tests run with `cargo test -p atproto-devtool` or simply `cargo test` (single-crate project).

**Fixture generation note:** the k256/p256 multikey strings committed under `tests/fixtures/identity/multikey_k256.txt` and `multikey_p256.txt` are generated **once** at phase implementation time from ephemeral keypairs; the expected SEC1 hex strings embedded in the test bodies are derived from those same multikeys at fixture-authoring time. They are NOT the real labeler's production keys. Document the generation procedure as a two-line comment above the test.

Signature verification using the decoded keys is deferred to Phase 6, where it is exercised end-to-end against real label fixtures via the crypto stage. Phase 2's tests cover only the parse → `VerifyingKey` primitive round-trip.

**Verification:**

Run: `cargo test`
Expected: all new tests pass. No network access occurs (verified by the absence of real clients in the test harness — fakes are constructed explicitly).

**Commit:**
```bash
git add tests/fixtures/identity src/common/identity.rs tests/fixtures/README.md
git commit -m "Add unit tests for common::identity primitives"
```
<!-- END_TASK_4 -->
<!-- END_SUBCOMPONENT_A -->

<!-- START_TASK_5 -->
### Task 5: Final phase verification

**Steps:**

1. `cargo fmt --check` — expected clean.
2. `cargo clippy -- -D warnings` — expected clean.
3. `cargo test` — expected: all tests (Phase 2's unit tests) pass; no network access.
4. `cargo run -- test labeler example.bsky.social` — the Phase 1 stub still runs and prints its placeholder (Phase 2 has not wired anything into the CLI yet). Expected: placeholder output, exit 0.

**Done when:** All four commands succeed; commit history shows the four atomic commits from this phase on top of the Phase 1 commits.
<!-- END_TASK_5 -->

---

## Phase 2 done-when checklist

- `common::identity` exposes `resolve_handle`, `resolve_did`, `find_service`, `parse_multikey` against the narrow `HttpClient` / `DnsResolver` traits.
- Real `RealHttpClient` / `RealDnsResolver` impls exist but are unused outside of their constructors (Phase 3 wires them in).
- Unit tests pass without network access, using the in-file `FakeHttpClient` / `FakeDnsResolver` and fixtures under `tests/fixtures/identity/`.
- `cargo clippy -- -D warnings` and `cargo fmt --check` are clean.
- **No acceptance criteria from the design plan are tested yet** — those are covered in Phase 3 once the primitives are wired into the labeler identity stage.
- Commit history contains the four atomic commits listed above.
