# Graph-first resolution

**A stock recursive resolver answers every query it can — which is exactly the problem.**

It has no opinion about a name registered six minutes ago, hosted on bulletproof infrastructure, behind a hijacked prefix, or owned by an entity you've explicitly banned. It resolves that just as happily as it resolves `github.com`, and your agent connects.

Static blocklists chase that gap and always lose — a feed refreshed hourly is still hours behind a fast-flux C2 domain or a newly announced /24 that just hijacked someone else's routes. Whisper's resolver closes the gap a different way: it doesn't consult a list, it asks a live graph — the same one behind [RDAP](/docs/rdap) and [`whisper.agents`](/docs/control-plane) — a question with real teeth: *who owns this, where does it sit, what category is it, is its route valid right now?* — before it lets a single query leave for the wider internet.

## Two resolvers, same wire protocol

Nothing about the wire format changes. `dig`, `kdig`, and every stub resolver on earth still send the same RFC 1035 query and expect the same RFC 1035 response — a 12-byte header, a question section, and (on success) an answer section of resource records. The difference is what happens between the query landing and the answer leaving.

**With stock tools, against a normal resolver** — it forwards blindly, no matter what's on the other end:

```bash
$ dig @1.1.1.1 +noall +comments +answer AAAA some-freshly-registered-domain.example
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41232
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
some-freshly-registered-domain.example. 300 IN AAAA 2606:4700::...
```

`1.1.1.1` has no idea the name was registered nine minutes ago, or that its authoritative nameserver sits behind an ASN with a week-old RPKI-invalid announcement. It isn't supposed to know — that's not its job. It just does RFC 1035.

**With Whisper, against an agent's resolver** — same query, same wire format, but the answer reflects a policy decision made milliseconds earlier:

```bash
$ dig @2a04:2a01:0:53::53 +dnssec +noall +comments AAAA some-freshly-registered-domain.example
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 41232
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1
```

Same source address, same query — but this tenant's policy blocks `newly-registered` as a category, so the name never reaches a real answer. No packet toward that domain's infrastructure ever left the agent's `/128`. The block is logged the same instant, visible via `whisper.agents({op:'logs'})` or the CLI, with the category and rule that fired.

## The flow: graph first, real DNS second

Every query an agent sends hits three possible outcomes, evaluated against **your** policy before resolution proceeds:

| Verdict | What happens | Example trigger |
|---|---|---|
| **Allow** | Falls through to real resolution — the graph has an opinion and it's positive, or you've explicitly allow-listed the owner/category | An entity you've named on your allow-list |
| **Block** | Answered on the spot — NXDOMAIN or a fixed sinkhole address, your choice via policy, logged with the rule that matched | Tor exit, bulletproof hosting, newly-registered domain (<48h old), a named owner, RPKI-invalid route |
| **No opinion** | Steps aside immediately — real upstream resolution proceeds at full speed, exactly like a stock resolver | The overwhelming majority of the internet: nothing in your policy or the graph's threat surface says otherwise |

The graph read carries a strict time budget. If it doesn't answer inside that budget, the resolver treats it as **no opinion and fails open** — a slow or unreachable policy check never becomes a resolution outage. This is deliberate: a control that can black-hole your production traffic when *it* has a bad day is worse than no control at all. Reliability wins; the query resolves, and the slow check is what gets flagged, not the agent.

## Policy dimensions no static list can express

A blocklist is a flat set of names. The graph carries structure — ownership graphs, ASN and prefix delegation, RIR geolocation, real-time RPKI validity, and content/category classification — so a single policy rule can reach conclusions a list never could:

- **Category** — block a whole class of infrastructure: Tor exits, bulletproof hosting, newly-registered domains, known scanning ranges — not by enumerating IPs, but by the graph's classification of what a host *is*.
- **Geography** — geo-fence resolution to jurisdictions you've named, sourced from [RFC 9092](https://www.rfc-editor.org/rfc/rfc9092) geofeed data and RIR delegation, not GeoIP guesswork.
- **Ownership** — deny an *entity*, not an address. The same organization behind three different hosting providers gets caught once, by the ownership edge in the graph, not three separate IP-range rules.
- **Routing** — drop a destination the instant its announcing prefix goes [RPKI](https://www.rfc-editor.org/rfc/rfc6480)-invalid ([RFC 6811](https://www.rfc-editor.org/rfc/rfc6811) origin validation) — a route hijack turns into an automatic block, live, without you ever touching a list.

Each dimension is one toggle on the [control plane](/docs/control-plane), not a maintenance job.

## Setting policy: dual paths, same effect

**With stock tools** there is no equivalent — this is the point. A resolver that only speaks RFC 1035 has no channel for "block this category" short of you maintaining and pushing an RPZ zone or a blocklist file yourself, by hand, forever behind the threat.

**With Whisper**, one call, either as the raw control verb or the CLI wrapper:

```bash
# raw control plane — the whisper.agents verb, over the graph gateway, with your key
curl -s https://graph.whisper.security/api/query \
  -H "X-API-Key: whisper_live_..." -H 'content-type: application/json' \
  -d '{"query":"CALL whisper.agents({op:\"policy\", block:[\"tor-exit\",\"newly-registered\"], geo:{allow:\"EU\"}, routing:\"drop-hijacked\"})"}'

# the CLI, same effect
whisper policy my-agent --block tor-exit,newly-registered --geo-allow EU --routing drop-hijacked
```

From that point forward, every query from `my-agent`'s address is measured against those rules before it resolves — verify it live:

```bash
dig @<agent's :53 resolver> +short AAAA <a known .onion-adjacent or freshly-registered test name>
# -> NXDOMAIN, and the block shows up in `whisper.agents({op:'logs'})` immediately
```

## Two access paths, one policy engine

The graph-first check applies identically whether an agent reaches Whisper over [DNS-over-HTTPS](/docs/dns-over-https) (RFC 8484, `:443`, API-key header — good for HTTP-only egress environments) or a dedicated per-tenant IPv6 `/128` resolver on standard `:53`, where the source address of the query *is* the authentication — no key on the wire at all:

```bash
# DoH — keyed, wire-format DNS-over-HTTP
curl -s -H "X-API-Key: whisper_live_..." -H 'content-type: application/dns-message' \
     --data-binary @query.bin https://doh.whisper.online/dns-query

# per-tenant :53 — keyless, the source /128 is the identity
kdig @2a04:2a01:0:53::53 +dnssec example.com AAAA
```

Both paths run the same three-way verdict, the same fail-open timeout, and the same per-query log line — pick the transport that fits your agent's egress path, not the policy.

## Next

- [Egress governance](/docs/egress-governance) — the same graph-first check applied to outbound connections, not just names
- [DNS-over-HTTPS](/docs/dns-over-https) — the keyed `:443` transport for HTTP-only environments
