···11+# the network is input
22+33+devlog 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 how the library treats two pieces of AT Protocol reality:
44+55+1. identity strings are not just syntax. resolving them crosses a network boundary.
66+2. failed XRPC calls are not just failed HTTP. the body is protocol data.
77+88+the release is three commits:
99+1010+- [`8287ff2`](https://tangled.org/zat.dev/zat/commit/8287ff2) - harden identity network resolution
1111+- [`8ba4cc0`](https://tangled.org/zat.dev/zat/commit/8ba4cc0) - add checked xrpc errors and retries
1212+- [`8de5f40`](https://tangled.org/zat.dev/zat/commit/8de5f40) - release: v0.3.1
1313+1414+## identity resolution is a fetch
1515+1616+AT Protocol makes identity resolution look friendly:
1717+1818+- `did:plc:...` goes to `plc.directory`
1919+- `did:web:example.com` goes to `https://example.com/.well-known/did.json`
2020+- `handle.example.com` goes to `https://handle.example.com/.well-known/atproto-did` or `_atproto.handle.example.com` TXT
2121+2222+that last sentence hides the problem. handles and DIDs are user-controlled strings that can make the library issue HTTP requests. validating the syntax is not enough. `did:web:127.0.0.1` is syntactically ordinary and operationally not something a server should fetch on behalf of an untrusted caller.
2323+2424+the first chunk adds `src/internal/identity/network_safety.zig`. before `did:web` or handle HTTP resolution fetches anything, zat now checks the host and the resolved addresses. the obvious unsafe cases are rejected directly:
2525+2626+```zig
2727+try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("localhost"));
2828+try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("127.0.0.1"));
2929+try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("10.1.2.3"));
3030+try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("192.168.1.1"));
3131+try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("[::1]"));
3232+try std.testing.expectError(error.UnsafeIdentityHost, checkIdentityHost("::ffff:127.0.0.1"));
3333+```
3434+3535+the less obvious case is DNS. `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. so the resolver does a DoH preflight before the HTTP fetch. it asks for `A` and `AAAA`, rejects non-routable answers, and only then dials.
3636+3737+for `did:web` and handle well-known HTTP, redirects are disabled. redirecting from a safe-looking public URL to a private address is the same bug with one extra step.
3838+3939+## dialing one host while speaking for another
4040+4141+the DoH preflight created a second constraint: once we have checked an address, the actual HTTP request should use that checked address, not resolve the hostname again underneath `std.http.Client`.
4242+4343+`HttpTransport` now has an internal `ResolvedConnection` path:
4444+4545+```zig
4646+pub const ResolvedConnection = struct {
4747+ dial_host: []const u8,
4848+ logical_host: []const u8,
4949+};
5050+```
5151+5252+the transport connects to `dial_host`, but keeps `logical_host` for HTTP/TLS identity. that preserves the thing callers intended to fetch 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.
5353+5454+this is not meant to be a general proxy API. it is just enough machinery for identity resolution to say: "I already checked where this name points; use that."
5555+5656+## XRPC errors are data
5757+5858+the second chunk came from downstream use. the original XRPC API made this easy:
5959+6060+```zig
6161+var response = try client.query(nsid, params);
6262+if (!response.ok()) return error.ApiFailed;
6363+```
6464+6565+that is fine for a prototype. it is not enough for a client that needs to understand AT Protocol behavior. a non-2xx response can still contain a structured XRPC envelope:
6666+6767+```json
6868+{"error":"RateLimitExceeded","message":"slow down"}
6969+```
7070+7171+throwing that away means callers lose 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.
7272+7373+`v0.3.1` adds checked XRPC calls:
7474+7575+```zig
7676+var result = try client.queryChecked(nsid, params, .{});
7777+defer result.deinit();
7878+7979+switch (result) {
8080+ .ok => |response| {
8181+ // parse success body
8282+ _ = response;
8383+ },
8484+ .err => |xrpc_error| {
8585+ // status, error_name, message, body, rate_limit
8686+ _ = xrpc_error;
8787+ },
8888+}
8989+```
9090+9191+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.
9292+9393+## retries belong with the client
9494+9595+the 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.
9696+9797+`HttpTransport.fetch` now preserves:
9898+9999+- `ratelimit-limit`
100100+- `ratelimit-remaining`
101101+- `ratelimit-reset`
102102+- `retry-after`
103103+104104+those 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.
105105+106106+## proof belongs downstream
107107+108108+we did not put the smoke harness in zat. zat has unit tests for the pieces:
109109+110110+- unsafe identity hosts
111111+- unsafe DNS answers
112112+- resolved-host mismatch
113113+- rate-limit header parsing
114114+- XRPC error-envelope parsing
115115+- deterministic retry delay behavior
116116+117117+the end-to-end smoke went into [`atproto-bench`](https://tangled.org/zzstoatzz.io/atproto-bench), where this kind of protocol harness belongs. the harness runs a local HTTP fixture, makes `queryChecked` hit a `429`, verifies the retry succeeds, then verifies a structured `400` comes back as `XrpcError` with rate-limit headers intact.
118118+119119+then [`music-atmosphere-feed`](https://tangled.org/zzstoatzz.io/music-atmosphere-feed) adopted `queryChecked` for its public AppView calls. that is the useful downstream shape: application code still returns its own `ApiFailed`, but it logs the actual XRPC status, error name, and message instead of flattening everything to "not ok."
120120+121121+## the release
122122+123123+`v0.3.1` is a patch release because the existing API remains available. the new calls are additive, and the identity hardening is a fix to behavior that should not have been allowed by default.
124124+125125+there 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`. that is the correct default for a public AT Protocol library. private-network fetching needs an explicit escape hatch, not accidental behavior.
126126+127127+the local release checks are boring, which is what a patch release should be:
128128+129129+```text
130130+zig build
131131+zig build test --summary all # 427/427
132132+just check
133133+just test # 427/427
134134+```
135135+136136+zat is `v0.3.1`.