# Coverage — what we looked at

> The WhisperGraph coverage contract: what known-clean, malicious-evidenced, ambiguous and no-data mean, which procedure returns which coverage value, and which values come only from whisper.walk.

*Source: https://www.whisper.security/docs/whisper-graph/procedures/coverage*

---
> Every Whisper verdict answers two independent questions. `band` tells you **how bad**. `coverage`
> tells you **what we actually looked at**. Read both. They are a grid, not a ladder.

**Only `known-clean` licenses the word "clean". Every other value is not-clean — and `no-data` and
`deadline-hit` mean *unknown*, which is a different thing again.**

`whisper.assess` and `whisper.assessUrl` return `coverage`. **`whisper.explain` does not** — see
[the procedure contract](#procedure-contract) below before you rely on either.

## The four values `whisper.assess` returns {#values}

| `coverage` | What it means | What to do |
|---|---|---|
| `known-clean` | We hold data at this granularity and nothing malicious is in it. | Treat as clean. **This is the only value that licenses closing a ticket on "clean."** |
| `malicious-evidenced` | **Some** positive evidence of malice exists. It may be a single feed at weight 0.5. It does **not** mean the band is high. | Read `evidence[]` for `feed-source-count`, then run `explain()` for the per-feed provenance, weights and timestamps. A count of 1 on a low-weight aggregate list is a lead, not a finding. |
| `ambiguous` | The evidence points both ways — for example an anonymising-egress signal alongside generic abuse listings. | **Escalate to a human. Do not automate a decision on this value.** |
| `no-data` | We have never observed this host. | Unknown. Never benign. Follow the [no-data playbook](#no-data) — do not simply escalate. |

Every one of these arrives as a **populated row**. `no-data` is a row that says `no-data`; it is
never an empty result set. If a query returns zero rows, the first hypothesis is that the query is
wrong, not that the host is clean.

### The case that proves the rule

```cypher expect=rows>0 seed=140.82.121.3 verified=2026-09-02
CALL whisper.assess(["140.82.121.3"]) YIELD host, band, coverage, evidence
RETURN host, band, coverage, evidence
```

    {"host":"140.82.121.3","band":"LOW","coverage":"malicious-evidenced",
     "evidence":["coverage:malicious-evidenced","band:LOW","host-class:unknown",
                 "feed-source:listed","feed-source-count:1"]}

`band: LOW` beside positive evidence of malice. A pipeline that gates on `band` alone treats this row
as unremarkable. The band is low because the verdict reconciler de-escalates for a multi-tenant apex,
for evidence older than 90 days, and for a zero base score — none of which change what we looked at.

That is why the rule is *key on `coverage`, never on `band`*.

## `no-data` is common — here is what to do with it {#no-data}

`no-data` is not exotic: `CALL whisper.assess(["104.16.132.229","104.16.123.96"])`
— two ordinary Cloudflare edge addresses — returns `band: UNKNOWN`, `coverage: no-data` for both.
Hostnames that appear in DNS generally carry real coverage; a bare IPv4 address often does not.

So "escalate everything that says `no-data`" is not a workable rule at volume, and we are not going to
pretend it is. When you get `no-data`, ask a **different** question rather than a louder one.

1. **Ask about the container.** `CALL explain("<enclosing /24>")` and the announcing ASN. A host we
   have never seen inside a heavily-listed block is a different fact from the same host inside a clean
   one. *(On a CIDR or ASN indicator, read `explanation` and `factors[]` alongside `score`: they carry
   the network-level finding — how many listed addresses and subnets the block contains, and its threat
   density. See [the procedure contract](#procedure-contract).)*
2. **Ask who runs it.** `CALL whisper.identify(["<host>"])`. A `no-data` verdict on infrastructure that
   identifies as a known SaaS vendor is expected. On infrastructure that identifies as nothing, the
   `no-data` *is* the finding.
3. **Ask when it appeared.** `CALL whisper.history.whois("<domain>")`. A domain registered this week has
   no feed history *by construction* — that is the signal, not the absence of one.
4. **Only then escalate** — and escalate with the sentence "we have no observation of this host",
   never with "it came back clean."

## The other three declared values {#other-values}

The coverage vocabulary has **seven** values. `whisper.assess` emits four of them. The other three are
real, and they are not `assess` values.

| Value | Where it is observed | Treat it as |
|---|---|---|
| `deadline-hit` | **From `whisper.walk`.** `CALL whisper.walk("google.com", 2, 1)` (a one-millisecond budget) returns `coverage: "deadline-hit"` with `arms.deadline_hit: true` | **Not a verdict.** The time budget expired before coverage could be established. Retry with a larger budget. Never read it as clean and never read it as bad |
| `partial` | **Not observed** by any probe run for this page | Not-clean. We covered only part of what you asked about — narrow the scope and re-ask |
| `structural-only` | **From `whisper.walk` only.** See [below](#not-assess-values) | Not a verdict at all. Do not gate on it |

Treat **any** value other than `known-clean` as not-clean, and `no-data`, `deadline-hit` and `partial`
as *unknown*, which is a different thing again.

We have not observed `partial`, and we are not asserting it is unreachable. If a later measurement
produces it, this table gains a row.

---

## `structural-only` is a `whisper.walk` value. It is not an `assess` value. {#not-assess-values}

**Do not gate on `structural-only`.** If your code has a branch for it on an `assess` result, that
branch is unreachable — and the branch you are missing is `malicious-evidenced`.

The seven values split across two axes, and the split is enforced in the engine:

| Axis | Values | What a value asserts |
|---|---|---|
| **Verdict axis** | `known-clean` · `malicious-evidenced` · `ambiguous` | A claim about malice |
| **Presence axis** | `structural-only` · `partial` · `no-data` · `deadline-hit` | A claim about whether we looked — and nothing about malice |

`whisper.assess` is a verdict procedure: it emits the three verdict-axis values, plus `no-data`.
`whisper.walk` is **not** a verdict procedure: it may emit presence-axis values only, so that a tool
which is not authorised to render a verdict cannot leak one through this vocabulary.

`whisper.walk`'s `coverage` therefore describes **atlas and vendor adjacency**, not threat coverage —
whether the host is reachable in the graph's structure. Its full column set makes this explicit
(`no_atlas_match`, `nearest_known_vendors`, `siblings`, `arms`).

**The same indicator can carry two different coverage values from two different procedures, and for one
indicator the two flatly disagree.**

    CALL whisper.assess(["185.220.101.1"])  ->  coverage: "ambiguous"       (there is evidence)
    CALL whisper.walk("185.220.101.1")      ->  coverage: "no-data"         (no atlas match)

Both are correct. They answer different questions. Read the procedure before you read the column.

**One more trap in `walk`:** a truncated walk still returns `structural-only`.
`CALL whisper.walk("github.com", 2, 3)` returns `coverage: "structural-only"` with
`arms: {arms_completed: 7, arms_truncated: 5, arms_excluded: 1, deadline_hit: false}` — five arms
dropped and no deadline flag. Read `arms` before you read `coverage` on a `walk` row.

## Which procedure carries coverage {#procedure-contract}

| Procedure | Returns `coverage`? | What its `coverage` is about, and the gotcha |
|---|---|---|
| `whisper.assess` | **Yes** | Threat coverage — the four values above. Its output contract is `host, label, band, sub_labels, signals, coverage, evidence, verdictScore, isThreat, threatSources`; `YIELD` anything else and you get HTTP 400 `query-error` naming the valid columns. |
| `whisper.assessUrl` | Yes | **A path axis, not a host axis.** Its `coverage` describes what was checked about the URL path, so the standing rule does not transfer unchanged — read this row before gating on it, and prefer `whisper.assess` for any decision that turns on host coverage. |
| `whisper.walk` | Yes, but **not a verdict** | Atlas and vendor adjacency. Presence-axis values only. Read `arms` first. |
| `whisper.explain` | **No** | Returns `indicator, type, available, cached, found, score, level, explanation, factors, sources, advisory`. **There is no coverage column at all**, so a `level: NONE` from `explain()` is not a clean verdict — it is a score, and the question of whether we looked is simply unanswered. |

Two gotchas on `explain()` worth stating plainly:

- **On a CIDR or an ASN, `score` and `level` describe the whole network, and `explanation` and
  `factors[]` say why.** `CALL explain("3.64.0.0/12")` returns a `CRITICAL` level with an explanation
  that counts the listed addresses and subnets inside the block and gives its threat density. Read the
  explanation before you act on a network-level score: a large block with a modest density is a
  different fact from a small block that is listed end to end.
- **`advisory` is usually null and occasionally load-bearing.** `explain("1.1.1.1")` returns
  `advisory: "allowlist-vouched"`; most indicators return null. Select it by name if you need it, and
  do not treat a blank as "no advisory applies" without checking the indicator type.

The rule *key on `coverage`, never on `band`* is published **per procedure**, never as a blanket
rule, because it is only true of the procedures that return a coverage column.
