WhisperDocs
Infrastructure

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 and whisper.agents - 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:

$ 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:

$ 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) - any category on your block list
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 the graph makes possible

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 policy rule can reach conclusions a list never could. Category is live today: 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. That's the toggle in the worked example below, and it's one call on the control plane, not a maintenance job.

Geography (geo-fencing resolution to jurisdictions you name), Ownership (denying an entity across every provider it hides behind), and Routing (dropping a destination the instant its route goes RPKI-invalid, per RPKI & routing security) are the shape the graph is built to support and the direction this page is heading - but as of this writing, op:'policy' accepts a geo or routing argument and answers ok:true without persisting either: a follow-up read of the stored policy shows only default, mode, retention, and block/allow. Sending them today is a silent no-op, not an error, which is exactly the kind of gap this project doesn't like to leave open - treat only block/allow-by-category as live until this page says otherwise.

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:

# raw control plane - the whisper.agents verb, over the graph gateway, with your key
curl -s https://graph.whisper.online/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\"]})"}'

# the CLI, same effect
whisper policy --block tor-exit --block newly-registered

Live, read back with no arguments:

$ whisper policy
KEY                  VALUE
default              allow
mode                 hybrid
retention            90
block                tor-exit
block                newly-registered
...

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

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 (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:

# 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