# Graph & cognition

**An agent about to fetch a URL has about a millisecond to decide whether it should - and "run it through a blocklist" isn't an answer, it's a guess with a timestamp.**

A static feed is hours behind a fast-flux C2 domain; `whois` tells you who registered a name, not who's serving traffic behind the CDN in front of it; and none of it comes back with the receipts a human, an auditor, or another agent needs to trust the call. Whisper answers differently: one call against a live graph of the internet - DNS, BGP, WHOIS, TLS, hosting, threat-intel, fused and current - that hands back a verdict **and the exact evidence behind it**, in under 300ms.

## The question every agent asks before it acts

Three shapes of the same question come up constantly, and none of them are answerable from a flat list:

- *"Who actually runs this host?"* - `api.openai.com` and half the internet sit behind Cloudflare or a load balancer. The IP tells you the CDN, not the tenant.
- *"Is this indicator safe to touch?"* - an IP might be a Tor exit, a bulletproof host, or a freshly-registered domain that hasn't earned a reputation yet either way.
- *"Why should I believe that?"* - a verdict with no reasoning attached is not a decision you can defend later, to a compliance reviewer or to yourself.

Whisper's cognition surface is the graph-first answer to all three, and it's the same graph that powers [graph-first resolution](/docs/resolver) - the policy engine that decides whether a query even leaves an agent's `/128` runs these exact verbs internally, on your behalf, before every answer.

## The surface: named verbs over one graph

Every verb below is a read-only `CALL` against the shared graph. They group into four jobs:

| Group | Verb | What it answers |
|---|---|---|
| **Attribution** | `identify(target)` | Who really operates a host or IP - operator, category, and the resolution chain that gets you there, even behind a CDN |
| | `psl.tldPlusOne(name)` | The registrable-domain boundary for a name, per the [Public Suffix List](https://publicsuffix.org/) - `sub.example.co.uk` → `example.co.uk`. Two siblings live next to it, `psl.isPublicSuffix(name)` and `psl.affiliation(a, b)`; there is no bare `psl` |
| **Risk** | `assess(targets[])` | A risk verdict for one or more indicators - verdict, severity, category (Tor exit, bulletproof hosting, scanner, …) |
| | `explain(target)` | The reasoning behind an `assess` verdict - which feeds fired, the score, how fresh the observation is |
| | `threatIntel.candidateCdnApex(id)` / `.candidateMultiTenantApex(id)` / `.candidateSharedHostingIp(id)` | Structural candidates from the fused feeds - each keyed on an internal numeric node id, not a free-form indicator string. Most callers want `assess`/`explain` instead; there is no bare `threatintel(indicator)` |
| | `lookupTorRelay(ip)` | Whether an address is a current Tor relay or exit node |
| | `lookupTlsFingerprint(ja3\|ja4)` | Known clients/tooling associated with a TLS client fingerprint |
| **Topology** | `walk(node, depth)` | Traverse outward from a node along graph edges - infrastructure clusters, shared-hosting genealogies |
| | `origins(hostname)` | Per-resolved-IP ASN attribution for a hostname - which ASN each of its current IPs sits in, and by what method (`sibling`, `mx`, `spf`). Keyed on a hostname, not a CIDR prefix, and it's not BGP announcement history |
| | `topAsnsByPrefixCount()` | Internet-wide aggregate: ASNs ranked by announced prefix count |
| | `variants(domain)` | Look-alike / typosquat domain variants for a brand or name |
| **Monitoring** | `watch(target)` | Register a standing watch on an entity so future graph changes surface as events |
| | `history(target)` | The historical timeline of DNS, WHOIS, and ownership changes for a target |
| | `submit(indicator)` | Contribute an observation into the shared graph |

Every verb above is live today under the exact name shown - a few sit one level deeper than their group label (`psl.tldPlusOne`, not a bare `psl`), so confirm the literal name with `CALL db.procedures()` before you build on one; the engine matches procedure names case-insensitively, and a near-miss can resolve to something unrelated instead of erroring. A caller may also run **arbitrary read-only Cypher** - `MATCH` patterns of your own - directly against the shared subgraph; the named verbs are just the common queries pre-packaged as one call.

## How to ask: two doors, one graph

Both doors run the identical query engine and return the identical shape. The only difference is how the caller is authenticated.

**With your key, from anywhere**, over the front door:

```bash
curl -s https://graph.whisper.online/api/query \
     -H 'X-API-Key: whisper_live_…' -H 'content-type: application/json' \
     -d '{"query":"CALL whisper.assess([\"185.220.101.1\"])"}'
```

Send the query as a JSON body - `{"query": "…"}` - not a `q=` form parameter; the older form-encoded style still works on the legacy `graph.whisper.security` host but not on the front door above.

Nearly everything above needs no key at all: `assess`, `identify`, `explain`, `variants`, `walk`, `origins`, `history`, `lookupTorRelay`, and the `psl.*` family answer for anyone, from anywhere - and so does **arbitrary raw Cypher**, a `MATCH` you write yourself, and the engine's own built-in `db.schema()`. The `X-API-Key` is required only for the control plane (`whisper.agents` ops) and `submit` - the one write channel here, gated so an anonymous flood can't poison the shared graph.

**Keyless, from inside a connected agent** - the query rides the agent's own routable address, and that `/128` *is* the credential, so no key travels on the wire at all:

```bash
curl -s https://[2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4]/api/query \
     --data-urlencode "q=CALL whisper.identify('api.stripe.com')"
```

The same keyless surface is exposed as one plain HTTP endpoint per verb, right on the agent's own `/128`, so the single most common cognition question (*"is the thing I'm about to connect to safe?"*) is a single GET with no key and no Cypher to write:

```bash
curl -s "https://[2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4]/identify?q=api.openai.com"
# -> rows:[{"host":"api.openai.com","category":"cdn","canonical_name":"Cloudflare",
#           "evidence":["RESOLVES_TO->IPV4->DELEGATED_TO->VENDOR:cloudflare"]}]
```

The address is the credential: the request lands on the agent's own routable `/128`, so the answer is already scoped to that identity and no key ever travels on the wire. Every read verb answers the same way from its own path (`/explain?q=…`, `/walk?q=…`, `/history?q=…`, and the rest); `assess` takes a list, so pass `?q=a&q=b` or a JSON body. Off the agent plane the identical verdict is one keyless `CALL whisper.identify(…)` JSON-body `POST` at the front door shown above.

## Under the hood: what "evidence" actually means

The query language is [openCypher](https://opencypher.org/), the same declarative graph pattern language used across the property-graph ecosystem. Send it as a JSON `{"query": …}` body against `graph.whisper.online` (a `q=` form parameter or `?q=` in the URL still works against the legacy `graph.whisper.security` host), and get back JSON rows. A verb like `identify` is a named, parameterized Cypher procedure; running it is equivalent to writing the underlying `MATCH` pattern yourself, without needing to know the schema.

What matters for trust is what comes back alongside the verdict. Every attribution or risk answer carries an `evidence` field that is the literal traversal the graph made to reach its conclusion - a chain of typed nodes and relationships, not a black-box score:

```
identify('api.openai.com')
  -> canonical_name=Cloudflare · category=cdn · confidence=0.8
  -> evidence: ["RESOLVES_TO->IPV4->DELEGATED_TO->VENDOR:cloudflare", "band=DERIVED"]

assess(['185.220.101.1'])
  -> band=LOW · verdictScore=8.59 · isThreat=true (captured 2026-08-30, a live indicator - it will have moved)
explain('185.220.101.1')
  -> score=18.1 · level=LOW · "listed in 6 threat feed(s)", each with its own first-seen/last-seen
```

`identify` walked `RESOLVES_TO` (a DNS answer) into `DELEGATED_TO` (a WHOIS/RDAP-derived edge) and stopped at a `VENDOR` node - that's the whole chain, reproducible, and disprovable if it's wrong. `explain` unpacks an `assess` verdict the same way: which of the fused threat-intel feeds contributed, the resulting score, and the age of the freshest observation behind it. Nothing here is a magic number; it's a graph traversal you can read.

## Dual example: attribution, the hard way and the easy way

**With stock tools** - chase the resolution chain yourself and hope the last hop tells you something:

```bash
dig +short api.openai.com                          # CNAME/A chain, ends on a CDN edge IP -> 172.66.0.243
whois -h whois.arin.net 172.66.0.243                # org on the IP -- usually the CDN, not the tenant
curl -sL https://rdap.org/ip/172.66.0.243 | jq .name
# "CLOUDFLARENET" -- correct, but you still don't know who's BEHIND Cloudflare
```

None of those three commands tells you the tenant behind the edge - CDNs exist specifically to hide that. `identify` says "Cloudflare, cdn" honestly rather than guess further; for names it fully resolves and owns, the chain terminates at the real organization instead.

**With Whisper** - the same investigative chain, pre-walked, in one round trip, and keyless:

```bash
curl -s https://graph.whisper.online/api/query -H 'content-type: application/json' \
     -d '{"query":"CALL whisper.identify(\"api.openai.com\")"}' | jq .
# {"host":"api.openai.com","vendor_id":"cloudflare","canonical_name":"Cloudflare",
#  "is_canonical":true,"confidence":0.8,"category":"cdn","roles":["ORIGIN_AS","CDN"],
#  "band":"DERIVED","evidence":["RESOLVES_TO->IPV4->DELEGATED_TO->VENDOR:cloudflare","band=DERIVED"]}
```

## Dual example: risk, the hard way and the easy way

**With stock tools**, checking one IP against Tor and whatever abuse databases you have accounts for means several disjoint tools that don't agree on a scale or a timestamp:

```bash
curl -s https://check.torproject.org/torbulkexitlist | grep -qx 185.220.101.1 && echo "tor exit"
whois -h whois.abuseipdb.example 185.220.101.1 2>/dev/null   # only if you have an abuse-DB account
```

**With Whisper**, one verdict and one explanation, on the same fused graph the resolver itself queries - and, like `identify`, both answer keylessly:

```bash
curl -s https://graph.whisper.online/api/query -H 'content-type: application/json' \
     -d '{"query":"CALL whisper.assess([\"185.220.101.1\"])"}' | jq '.rows[0] | {band,verdictScore,isThreat}'
# {"band":"LOW","verdictScore":8.59,"isThreat":true}
curl -s https://graph.whisper.online/api/query -H 'content-type: application/json' \
     -d '{"query":"CALL whisper.explain(\"185.220.101.1\")"}' | jq '.rows[0] | {score,level,explanation}'
# {"score":18.1,"level":"LOW","explanation":"185.220.101.1 is listed in 6 threat feed(s). Score 18.1 (Low - limited risk)."}
```
(captured 2026-08-30 against a live indicator on live feeds - by the time you run it the score and feed count will have moved. That's the point of asking the graph instead of trusting a snapshot.)

## Named views: one call, the whole answer, and the receipts

The verbs above each answer one question. Three questions come up so often, and take so many verbs to answer properly, that they ship as **named views**: one `CALL`, several bounded graph reads underneath, and an answer that carries every query that produced it.

| View | Seed | What it answers |
|---|---|---|
| `whisper.attackSurface({seed})` | a domain you own | What of yours is reachable from the internet right now, and **who actually operates each piece of it** |
| `whisper.blastRadius({seed})` | a nameserver, mail host, CNAME target, SPF provider, IP, CIDR prefix or AS number | What depends on that piece of infrastructure, and therefore what breaks with it |
| `whisper.brandPhishingFleet({seed})` | a brand apex | The look-alike domains standing behind a brand, staged from registered to serving |

`CALL whisper.views()` lists all three, with the arguments each takes, keyless and with no graph round trip.

### Blast radius: the inverse of a supply-chain map

A supply-chain view answers "what does this domain depend on". This one answers the question an incident actually asks: *this provider is down, or compromised, or being withdrawn from routing, so who is affected.*

```bash
curl -s https://graph.whisper.online/api/query \
     -H 'content-type: application/json' \
     --data '{"query":"CALL whisper.blastRadius({seed:'\''ns1.dreamhost.com'\''})"}'
```

The summary row of that call, live:

```json
{
  "seed_labels": ["HOSTNAME"],
  "operator": {"canonical_name": "Cloudflare", "category": "cdn",
               "roles": ["DNS_OPERATOR","MAIL_RECEIVER","ORIGIN_AS","CDN"], "band": "DERIVED"},
  "by_class": {
    "dns":     {"total": 100000, "total_capped": true,  "shown": 25},
    "mail":    {"total": 24,     "total_capped": false, "shown": 24},
    "cname":   {"total": 71,     "total_capped": false, "shown": 25},
    "spf":     {"total": 4,      "total_capped": false, "shown": 4},
    "address": {"total": 0,      "total_capped": false, "shown": 0},
    "prefix":  {"total": 0,      "total_capped": false, "shown": 0},
    "asn":     {"total": 0,      "total_capped": false, "shown": 0}
  },
  "dependents_total_capped": true
}
```

Seven dependency classes, all queried, and you never tell it which kind of thing the seed is: the classes that do not apply come back empty in about a millisecond. `total_capped` is the honest part. A hyperscale provider is not counted to the last domain; the scan stops at 100,000 and says so, rather than spending a minute or quietly reporting the page size as the total.

### Attack surface: the inventory, with the operator column

```bash
curl -s https://graph.whisper.online/api/query \
     -H 'content-type: application/json' \
     --data '{"query":"CALL whisper.attackSurface({seed:'\''mercedes-benz.com'\''})"}'
```

Fifteen steps, five rounds, 1.7 seconds against the live graph. What makes it an attack-surface map rather than a DNS dump is the last column: every observed address is walked out to the prefix that announces it and the organisation that routes that prefix, so the inventory answers "and who runs that".

```json
{"host": "aftersales.mercedes-benz.com", "address": "23.216.132.62",
 "prefix": "23.216.132.0/24", "asn": "AS20940",
 "network_operator": "Akamai International B.V.", "operator": "akamai international"}
```

That join is the point. In the same live run, two of the sixteen prefixes serving hostnames under this brand's own apex are routed by networks with no obvious relationship to the brand. No per-record lookup shows that, because the fact does not exist in any single record.

### Brand phishing fleet: catch them before they serve a page

A phishing domain is not born dangerous. It is registered, then delegated, then given a certificate, and only then pointed at a page that takes someone's credentials. Every one of those steps leaves a mark in the graph before the page exists, so the fleet can be staged:

- **flagged** - a threat feed lists it, or its own verdict band is not `NONE`
- **live** - it resolves to an address, so it can serve a page today
- **armed** - a certificate has been observed for it but it does not resolve: TLS prepared for a name that serves nothing
- **parked** - delegated to nameservers, no address, no certificate
- **observed** - present in the graph with none of the above

```bash
curl -s https://graph.whisper.online/api/query \
     -H 'content-type: application/json' \
     --data '{"query":"CALL whisper.brandPhishingFleet({seed:'\''mercedes-benz.com'\'', limit:200})"}'
```

Live, that brand carries 115 look-alike names the graph has actually seen, and one of them is already known bad:

```json
{"counts": {"look_alikes": 115, "live": 47, "parked": 5, "armed": 0, "flagged": 1,
            "not_yet_serving": 67}}

{"host": "merce-des-benz.com", "method": "HYPHENATION", "stage": "flagged",
 "verdict": "HIGH", "threat_feeds": ["usom-urls"], "addresses": [], "nameservers": []}
```

A popularity listing is never an accusation. `mercedes-benz.ca` is a real Mercedes country domain that appears in Tranco and Cloudflare Radar because it is popular; the stage rule reads the feed's own threat flag, so it stays out of the flagged column while `merce-des-benz.com`, listed in `usom-urls` under category `phishing`, lands in it.

`whisper.watch` is the graph's subscription verb and is what turns this report into an alert the moment a new look-alike appears; the consumable event-delivery contract for those watches is still being built, so today this is the report and the point-in-time replay, not the alert.

### Every answer ships its own queries

A named view returns one row per step, preceded by the `summary` row. Each step row carries its `cypher` and its bound `params` verbatim, so any step of any answer can be re-run by anyone against this same endpoint and produce the same rows:

```json
{"step": "nameservers", "status": "ok", "row_count": 4,
 "source": "HOSTNAME-[:NAMESERVER_FOR]->HOSTNAME",
 "cypher": "MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(:HOSTNAME {name:$apex}) RETURN ns.name AS nameserver LIMIT $limit",
 "params": {"apex": "mercedes-benz.com", "limit": 25}}
```

A view that showed only its conclusions would be an assertion. A view that ships its queries is evidence, which is what an auditor, a regulator or another team's incident review actually needs.

Views degrade one step at a time and never silently. A step whose inputs are empty comes back `skipped` with the reason; a step the graph refuses comes back `error` carrying the graph's own message; a step still unrun when the view's wall-clock budget is spent comes back `deadline`. The rest of the answer arrives regardless, and `statistics.truncated` says so, so a bounded answer can never read as a complete one.

**Both tiers work.** Keyless returns a real answer, bounded by the graph's keyless ceiling of 100 rows per query; your key lifts that ceiling and `limit` (default 50, maximum 500) sets how much of each class you want back.

## Latency, and what happens when the graph is slow

Cognition calls target under 300ms end to end, including the graph traversal. That budget matters beyond the API itself: [graph-first resolution](/docs/resolver) calls `assess` on the resolver's hot path before letting a DNS query proceed, under the same strict timeout - and if the graph doesn't answer inside it, the resolver treats that as **no opinion and fails open**, never a resolution outage. A slow cognition call degrades gracefully everywhere it's used; it never becomes the single point of failure for traffic that has nothing to do with it.

> A verdict without evidence is an opinion. Every `assess`, `identify`, and `explain` response here returns the graph rows behind the answer - the same rows you could walk yourself with raw Cypher - so the decision holds up outside the call that made it.

## Next

- [Graph-first resolution](/docs/resolver) - the same graph, called on the DNS hot path to gate what an agent can resolve
- [Control plane](/docs/control-plane) - `whisper.agents`, the sibling verb that provisions and governs identity rather than answering questions about it
