Constraints are detected up front (one throwaway bind per network) and land in run.constraints, a measurement.vpn_constrained finding, the $7.3 verdict (INCONCLUSIVE outright) and a banner on the run screen - a VPN'd run looked exactly like a clean run of a healthy network before this. v6.broken returns to the registry now that it can be earned: V6ConnectProbe (v6.brokenness) makes a real TCP connection over IPv6 to the enrolled server, and only both transports failing on a network that advertises IPv6 justifies the claim. TCP succeeding turns the finding into 'ICMPv6 is filtered, IPv6 works' at high confidence instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
127 lines
7.9 KiB
Markdown
127 lines
7.9 KiB
Markdown
<!--
|
|
SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
SPDX-License-Identifier: CC-BY-4.0
|
|
-->
|
|
|
|
# Echolot findings registry
|
|
|
|
Closes open item 1 of `measurement-schema.md` §9.
|
|
|
|
A **finding code** is the stable, machine-readable half of a result. The prose around it changes
|
|
freely; the code is what a dashboard groups by, what a diff between two runs keys on, and what
|
|
someone greps a year of archived runs for. That only works if a code means exactly one thing,
|
|
forever.
|
|
|
|
This document is the contract. It is kept in step with
|
|
`echolot-app/core-measurement/.../FindingRegistry.kt` by a test that fails when either side has a
|
|
code the other does not — a registry that drifts from its documentation is worse than none,
|
|
because it looks authoritative.
|
|
|
|
## Rules
|
|
|
|
1. **The prefix determines the category**, and the category determines which verdict light the
|
|
finding rolls up into (§7.3). A `nat.*` code appearing under *connectivity* is not a naming
|
|
quibble; it changes which light turns red. Two codes were renamed from `nat.*` to
|
|
`connectivity.*` for exactly this reason.
|
|
2. **One code per concept.** Two emitters independently produced `connectivity.downstream_loss`
|
|
and `connectivity.loss_downstream` for the same claim before this registry existed. Anyone
|
|
aggregating either would have silently seen half their data.
|
|
3. **Codes are declared, not typed.** Emitters reference a `FindingSpec`, so a typo is a compile
|
|
error and no two call sites can disagree about a finding's category or default severity.
|
|
4. **Severity in the registry is the default.** An emitter may escalate for a specific run; it may
|
|
not quietly reclassify the finding in general.
|
|
5. **Say what is ruled out**, where that is the useful half. "Loss upstream" is worth far more
|
|
when it also states that the return path is clean, because that halves where to look next.
|
|
6. **Renaming a code is a breaking change** once runs are archived at scale. Before 1.0 it is
|
|
cheap; after, it needs an alias and a deprecation window.
|
|
|
|
## Registry
|
|
|
|
### connectivity
|
|
|
|
| code | severity | means | rules out |
|
|
|---|---|---|---|
|
|
| `connectivity.udp_unreachable` | high | No UDP echo replies came back from the server at all. | — |
|
|
| `connectivity.udp_unreachable_upstream` | high | The server received none of the probes, so traffic is dropped on the way out. | The return path: nothing arrived to be replied to. |
|
|
| `connectivity.udp_loss` | medium | A large fraction of round-trip probes were lost, direction unknown. | — |
|
|
| `connectivity.loss_upstream` | medium | Probes were lost on the way to the server. | The return path: replies came back for everything that arrived. |
|
|
| `connectivity.loss_downstream` | medium | Packets were lost on the way back from the server. | The outbound path: the server received what it was answering. |
|
|
| `connectivity.downstream_blocked` | high | Server-initiated packets never arrive, although round trips work. | Basic reachability: the path forwards replies, just not unsolicited traffic. |
|
|
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
|
|
| `connectivity.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
|
|
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
|
|
|
|
### mtu
|
|
|
|
| code | severity | means | rules out |
|
|
|---|---|---|---|
|
|
| `mtu.reduced_downstream` | low | The downstream path MTU is below the usual 1500 bytes. | — |
|
|
| `mtu.downstream_blackhole` | medium | Datagrams above the path MTU are dropped downstream, fragmented or not. | — |
|
|
| `mtu.fragments_blocked` | medium | IP fragments do not reach this device even when sent in order. | — |
|
|
| `mtu.fragment_reorder_sensitive` | low | Fragments are delivered in order but dropped when reordered or delayed. | Fragmentation itself: in-order fragments arrive fine. |
|
|
|
|
### nat
|
|
|
|
| code | severity | means | rules out |
|
|
|---|---|---|---|
|
|
| `nat.udp_rebinding` | medium | A NAT remapped the UDP source port mid-flow. | — |
|
|
| `nat.symmetric` | medium | The NAT assigns a different external port per destination. | — |
|
|
|
|
### perf
|
|
|
|
| code | severity | means | rules out |
|
|
|---|---|---|---|
|
|
| `perf.throughput_no_delivery` | high | No throughput traffic arrived, although the server sent it. | — |
|
|
| `perf.throughput_below_offered` | low | Less throughput arrived than the server sent for the whole run. | — |
|
|
|
|
### dns
|
|
|
|
| code | severity | means | rules out |
|
|
|---|---|---|---|
|
|
| `dns.answer_rewritten` | high | A resolver returned an answer that differs from the authoritative record. | — |
|
|
| `dns.authoritative_unreachable` | medium | The canary zone's authoritative server could not be reached. | — |
|
|
|
|
### v6
|
|
|
|
The prefix is `v6.`, matching the test-type registry (`v6.brokenness`, `v6.happy_eyeballs`, …).
|
|
These were `ipv6.*` while declaring `Category.IPV6`; since the prefix map only knows `v6`, they
|
|
rolled up under *connectivity* instead — the third occurrence of rule 1 being broken.
|
|
|
|
| code | severity | means | rules out |
|
|
|---|---|---|---|
|
|
| `dns.search_domain_unanswered` | high | The network advertises a DNS search domain that its own server does not answer for. | A fault on this device: the same server answers ordinary names normally. |
|
|
| `dns.system_resolver_broken` | high | The network's DNS server answers, but this device cannot resolve names through it. | A network fault: the server replied to a query sent from this device. |
|
|
| `measurement.vpn_constrained` | info | A VPN was active, so the networks underneath it could not be measured. | Nothing — this run says little about the underlying network either way. |
|
|
| `v6.no_default_route` | medium | The device has a global IPv6 address but no IPv6 default route. | Guesswork: this is read from the routing table, not inferred from silence. |
|
|
| `v6.route_without_address` | medium | The network advertises an IPv6 default route but the device has no global IPv6 address. | A working IPv6 setup: SLAAC did not produce a usable address on this link. |
|
|
| `v6.no_icmp_reply` | low | IPv6 is configured but ICMPv6 echo gets no reply. | Nothing on its own: IPv6 may work fine with ICMP filtered. |
|
|
| `v6.broken` | high | IPv6 is advertised on this network but carries no traffic. | ICMP filtering as the benign explanation: a TCP connection over IPv6 failed too. |
|
|
| `v6.not_offered` | info | This network does not offer IPv6. | — |
|
|
|
|
`v6.no_icmp_reply` was `v6.broken` until a phone reported it while loading an IPv6-only site over
|
|
TCP perfectly well. The only evidence behind it is ICMPv6 echo, which is widely filtered on
|
|
networks where IPv6 works — so the finding now states what was observed and names both
|
|
explanations instead of choosing one. It is still worth reporting: filtered ICMPv6 breaks Path MTU
|
|
Discovery.
|
|
|
|
`v6.broken` returned once that corroboration existed: the `v6.brokenness` test attempts a real TCP
|
|
connection over IPv6 to the configured server, and only when *both* transports fail on a network
|
|
that advertises IPv6 is the brokenness claim made — at high severity, because every dual-stack
|
|
destination pays a timeout before falling back to IPv4. When the TCP connect *succeeds*,
|
|
`v6.no_icmp_reply` is emitted at high confidence instead, now able to say plainly that ICMPv6 is
|
|
filtered while IPv6 works. With no server configured there is no corroboration target and the
|
|
two-explanation `v6.no_icmp_reply` stands unchanged.
|
|
|
|
`v6.not_offered` is **info and must stay info**. Most networks still do not offer IPv6 and that is
|
|
not a fault; reporting it as a warning lights a yellow verdict on a healthy network, which teaches
|
|
people to ignore the light — the one thing a diagnostic must never do.
|
|
|
|
## Adding a finding
|
|
|
|
1. Add a `FindingSpec` to `FindingRegistry`, and to its `all` list.
|
|
2. Add the row here, under the section its prefix names.
|
|
3. Emit it with `finding(FindingRegistry.YOUR_CODE, …)`.
|
|
|
|
The registry test checks 1 and 2 agree, that every prefix maps to the category it claims, and that
|
|
no two entries share a code.
|