Long mode starts listeners at t=0 and keeps them running past the battery: a network-change watcher that finally fills networks[].changes[] (defined since the schema's first draft, never populated), an RSSI log, a ping series giving loss and jitter over minutes, and mDNS listening for the whole window. This is the class of fault a short run cannot see - a link that drops for four seconds between two probes is reported healthy by both of them. run.mode records which question was asked, because silence means different things in the two modes. The adb relay replaces the retired beacon: AdbRelay watches adbd's own mDNS with the resolve-once discipline the beacon learned the hard way (resolving re-arms adbd and pops a notification), a foreground service keeps it alive with the screen off, and the heartbeat re-posts the cached endpoint rather than re-resolving. It exists because mDNS does not cross subnets and the wireless-debug port rotates every few minutes. Also records why LLDP/CDP cannot follow SSDP into long mode: both are raw L2 frames, so they need CAP_NET_RAW - root tier, not app, and Shizuku's shell user does not have it either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
8.5 KiB
Markdown
134 lines
8.5 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. | — |
|
|
| `connectivity.link_flapping` | medium | A network dropped and came back one or more times during the run. | A momentary probe failure: the drop was watched happening, not inferred from silence. |
|
|
|
|
`connectivity.link_flapping` is only reachable from a **long run** (`run.mode: "long"`,
|
|
measurement-schema §3). It is derived from `networks[].changes[]` rather than from any test's
|
|
evidence, because no one-shot probe can produce it: the probes before and after a four-second drop
|
|
both succeed. The emitter escalates to *high* from three completed drop-and-return cycles, and
|
|
requires the cycle to complete — a network switched off partway through a run is not flapping.
|
|
|
|
### 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.
|