{
  "$type": "site.standard.document",
  "path": "/devlog/010",
  "publishedAt": "2026-05-25T15:56:58Z",
  "site": "at://did:plc:mkqt76xvfgxuemlwlx6ruc3w/site.standard.publication/3khuwc44c2256",
  "textContent": "# the network is input\n\ndevlog 009 ended with \"zat is v0.3.0-alpha. no API changes from this.\" the next release is different. `v0.3.1` is small in surface area, but it changes two parts of zat's network behavior:\n\n1. resolving [AT Protocol handles](https://atproto.com/specs/handle) and [DIDs](https://atproto.com/specs/did) can require DNS lookups and HTTP fetches.\n2. [XRPC](https://atproto.com/specs/xrpc) error responses can carry a JSON error envelope that callers need to inspect.\n\nthe release is three commits:\n\n- [`8287ff2`](https://tangled.org/zat.dev/zat/commit/8287ff2) - harden identity network resolution\n- [`8ba4cc0`](https://tangled.org/zat.dev/zat/commit/8ba4cc0) - add checked xrpc errors and retries\n- [`8de5f40`](https://tangled.org/zat.dev/zat/commit/8de5f40) - release: v0.3.1\n\n## identity resolution performs network requests\n\nAT Protocol account identity uses two related identifiers: [handles and DIDs](https://atproto.com/guides/identity#identifiers). handles are DNS names that resolve to DIDs; DIDs resolve to DID documents with the account's signing key and PDS service endpoint.\n\nwhen zat resolves those identifiers, it may issue these network requests:\n\n- `did:plc:...` resolves through the PLC directory\n- `did:web:example.com` resolves through `https://example.com/.well-known/did.json`\n- `handle.example.com` resolves through `https://handle.example.com/.well-known/atproto-did` or the `_atproto.handle.example.com` DNS TXT record\n\nhandles and DIDs often come from user input, API parameters, repo records, or event streams. when zat resolves one, the library may fetch a URL or ask DNS for an address. syntax validation does not make that safe. `did:web:127.0.0.1` is syntactically ordinary and operationally not something a server should fetch on behalf of an untrusted caller.\n\nthis comes up directly in [`atproto-bench`](https://tangled.org/zzstoatzz.io/atproto-bench). the [full trust-chain verifier](https://tangled.org/zzstoatzz.io/atproto-bench/blob/main/zig/src/verify.zig) accepts a handle or DID, resolves the handle when needed, then resolves the DID document before fetching and verifying the repo. the relay and signature capture harnesses also resolve DIDs from live firehose frames to build signing-key corpora.\n\nthe first chunk adds `src/internal/identity/network_safety.zig`. before `did:web` or handle HTTP resolution fetches anything, zat checks the host and the resolved addresses. obvious unsafe targets are rejected directly:\n\n```zig\ntry std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost(\"localhost\"));\ntry std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost(\"127.0.0.1\"));\ntry std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost(\"10.1.2.3\"));\ntry std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost(\"192.168.1.1\"));\ntry std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost(\"[::1]\"));\ntry std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost(\"::ffff:127.0.0.1\"));\n```\n\nDNS needs a separate check. `evil.example` can be a public name that resolves to `127.0.0.1`, `10.0.0.5`, `fc00::1`, or a link-local address. the resolver now does a DNS-over-HTTPS preflight before the HTTP fetch. it asks for `A` and `AAAA`, rejects non-routable answers, and only then dials.\n\nfor `did:web` and [handle well-known HTTP](https://atproto.com/specs/handle#handle-resolution), redirects are disabled. otherwise, an attacker could provide a safe-looking public URL that redirects zat's server-side fetch to a private address.\n\n## using the checked address\n\nthe DNS-over-HTTPS preflight means zat has to preserve the checked address through the HTTP request. once zat has checked an address, it should not hand the hostname back to `std.http.Client` and let it resolve the name a second time.\n\n`HttpTransport` now has an internal `ResolvedConnection` mode:\n\n```zig\npub const ResolvedConnection = struct {\n    dial_host: []const u8,\n    logical_host: []const u8,\n};\n```\n\nthe transport connects to `dial_host`, but keeps `logical_host` for the HTTP `Host` header and TLS server name. that preserves the caller's intended URL while avoiding a second unchecked resolver hop. it also checks that the request URL still matches the logical host, so the preflight result cannot be accidentally reused for a different URL.\n\nthis is narrow internal plumbing for identity resolution: \"I already checked where this name points; use that address for this request.\"\n\n## XRPC errors are data\n\nthe second chunk came from downstream use. the original XRPC API made this pattern easy:\n\n```zig\nvar response = try client.query(nsid, params);\nif (!response.ok()) return error.ApiFailed;\n```\n\nthat collapses protocol errors into a boolean. the [XRPC specification](https://atproto.com/specs/xrpc#error-responses) says unsuccessful responses should use a JSON object with an `error` string and optional `message` string:\n\n```json\n{\"error\":\"RateLimitExceeded\",\"message\":\"slow down\"}\n```\n\ndiscarding that body loses the difference between `InvalidRequest`, `ExpiredToken`, `RateLimitExceeded`, and an arbitrary 500. it also makes retry behavior hard to centralize because the transport sees the status and headers, while application code sees only a boolean.\n\n`v0.3.1` adds checked XRPC calls:\n\n```zig\nvar query_result = client.queryChecked(nsid, params, .{}) catch |err| {\n    log.err(\"getAuthorFeed API error for {s}: {}\", .{ actor_did, err });\n    return error.ApiFailed;\n};\ndefer query_result.deinit();\n\nconst response = switch (query_result) {\n    .ok => |response| response,\n    .err => |xrpc_error| {\n        logXrpcError(\"getAuthorFeed\", actor_did, xrpc_error);\n        return error.ApiFailed;\n    },\n};\n```\n\nthat is the pattern now used in [`music-atmosphere-feed`](https://tangled.org/zzstoatzz.io/music-atmosphere-feed/blob/main/src/bsky/api.zig): public functions still return the application's `ApiFailed` error, but logs keep the XRPC status, error name, and message for AppView calls like `app.bsky.graph.getFollows` and `app.bsky.feed.getAuthorFeed`. the old `query` and `procedure` calls stay. the checked calls are additive, and the return type forces the caller to decide what to do with protocol errors.\n\n## retries belong with the client\n\nthe same change adds `XrpcClient.RetryPolicy`. the default is conservative: retry transient transport errors and HTTP `429`, `500`, `502`, `503`, `504`; do not retry ordinary client errors. the delay is exponential, capped, and jittered. if the server sends `retry-after`, that wins. if a rate-limit reset timestamp is available, the policy can use that too.\n\n`HttpTransport.fetch` now preserves:\n\n- `ratelimit-limit`\n- `ratelimit-remaining`\n- `ratelimit-reset`\n- `retry-after`\n\nthose fields are present on both successful responses and `XrpcError`. that matters because a caller might need to surface the error immediately but still update local rate-limit state.\n\nthe local smoke in [`atproto-bench`](https://tangled.org/zzstoatzz.io/atproto-bench/blob/main/zig/src/xrpc_checked.zig) uses this API directly. it runs a fixture server, asks `queryChecked` to retry a `429`, then verifies that a structured `400` carries `InvalidRequest`, `message`, and rate-limit headers through `XrpcError`.\n\n## smoke tests belong downstream\n\nwe did not put the smoke harness in zat. zat has unit tests for the pieces:\n\n- unsafe identity hosts\n- unsafe DNS answers\n- resolved-host mismatch\n- rate-limit header parsing\n- XRPC error-envelope parsing\n- deterministic retry delay behavior\n\nthe end-to-end smoke went into [`atproto-bench`](https://tangled.org/zzstoatzz.io/atproto-bench). that repository already holds protocol harnesses and benchmark fixtures, so it is the right place to exercise behavior that spans zat plus a local HTTP server.\n\nthen [`music-atmosphere-feed`](https://tangled.org/zzstoatzz.io/music-atmosphere-feed/blob/main/src/bsky/api.zig) adopted `queryChecked` for its public AppView calls. application code keeps returning its own errors, while logs preserve the protocol error details.\n\n## the release\n\n`v0.3.1` is a patch release because the existing API remains available. the new calls are additive, and the identity hardening changes the default from \"fetch any syntactically valid identity target\" to \"reject private-network identity targets.\"\n\nthere is one practical compatibility note: if someone was intentionally resolving `did:web` or handles to private infrastructure through zat's identity resolvers, that now fails with `error.UnsafeIdentityHost`. for code that resolves identifiers from untrusted public inputs, blocking private-network targets is the safer default. private-network identity resolution is still a legitimate use case, but it should be configured explicitly instead of happening by accident.\n\nthe local release checks are boring, which is what a patch release should be:\n\n```text\nzig build\nzig build test --summary all  # 427/427\njust check\njust test                     # 427/427\n```\n\nzat is `v0.3.1`.\n",
  "title": "the network is input"
}