findings: adopt the registry in the app module; rename ipv6.* to v6.*

The registry was only used in core-engine. The app still emitted seven codes as
raw strings, so the registry test passed while codes lived outside it - among
them ipv6.broken, which fired on a real network and was in no registry at all.

All seven now take their code, category and severity from a registry entry, so
those three cannot disagree at a call site. Grepping for code = "..." across the
app, engine and probe modules now returns nothing.

ipv6.* -> v6.* is the third instance of the same rule being broken: they
declared Category.IPV6 while the prefix map only knows "v6", so
TestType.category("ipv6.broken") fell through to connectivity and the finding
rolled up under the wrong verdict light. The test-type registry already used v6.

Two severities reconciled rather than assumed:

  connectivity.captive_portal is medium, not high. The registry had guessed
  high; the probe emitting it had always said medium, and the probe was the
  considered value - a captive portal on hotel wifi is what should be there.
  no_internet keeps high, since nothing local fixes that.

  v6.not_offered stays info, and the registry now says why it must. Most
  networks still do not offer IPv6; a warning there lights a yellow verdict on a
  healthy network and teaches people to ignore the light.

Plus a BackHandler: the screen was a plain state variable with nothing tying it
to the back stack, so Back left the app from Settings/History instead of
returning to the run screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 15:47:52 +02:00
co-authored by Claude Fable 5
parent 6bba420845
commit 8646bab52d
5 changed files with 103 additions and 16 deletions
+27
View File
@@ -914,3 +914,30 @@ Three tests, one of which uses the exact value observed on the wire.
Worth recording as a reasoning trap: I had originally raised this as "ULA should probably be kept Worth recording as a reasoning trap: I had originally raised this as "ULA should probably be kept
verbatim, like RFC1918, for consistency". The surface analogy pointed the wrong way, and the verbatim, like RFC1918, for consistency". The surface analogy pointed the wrong way, and the
correct answer was the opposite. correct answer was the opposite.
### Registry adopted everywhere; v6 findings renamed; Back works (2026-08-01)
The findings registry was only adopted in `core-engine`. The app module still emitted seven codes
as raw strings, so the registry test passed while codes existed outside it — including
`ipv6.broken`, which fired on a real network and was in no registry at all.
All seven now reference registry entries for code, category and severity, so those three cannot
disagree at a call site. A grep for `code = "…"` across the app, engine and probe modules returns
nothing.
**`ipv6.*` → `v6.*`.** The third instance of rule 1: they declared `Category.IPV6` while the prefix
map only knows `v6`, so `TestType.category("ipv6.broken")` fell through to *connectivity* and the
finding rolled up under the wrong verdict light. The test-type registry already used `v6.`.
Two severities reconciled while merging:
- `connectivity.captive_portal` is **medium**, not high. The registry had guessed high; the probe
that emits it had always said medium, and the probe was the considered value — a captive portal
on hotel wifi is what should be there, and logging in clears it. `connectivity.no_internet` is
the high one, because nothing the user does locally fixes that.
- `v6.not_offered` is **info, and the registry says it must stay info**. Most networks still do not
offer IPv6 and that is not a fault; a warning here lights a yellow verdict on a healthy network,
which teaches people to ignore the light.
Also: a `BackHandler` now returns from Settings/History to the run screen. The screen was a plain
state variable with nothing connecting it to the back stack, so the system Back gesture left the
app entirely. Enabled only when there is somewhere to go back to, so Back still exits from the run
screen.
+16 -1
View File
@@ -48,7 +48,7 @@ because it looks authoritative.
| `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.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_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.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
| `connectivity.captive_portal` | high | A captive portal is intercepting connectivity checks. | — | | `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.no_internet` | high | Android's own connectivity checks fail on this network. | — |
### mtu ### mtu
@@ -81,6 +81,21 @@ because it looks authoritative.
| `dns.answer_rewritten` | high | A resolver returned an answer that differs from the authoritative record. | — | | `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. | — | | `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 |
|---|---|---|---|
| `v6.broken` | medium | IPv6 is configured on this network but does not work. | Absence of IPv6: it is provisioned, it simply fails. |
| `v6.not_offered` | info | This network does not offer IPv6. | — |
`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 ## Adding a finding
1. Add a `FindingSpec` to `FindingRegistry`, and to its `all` list. 1. Add a `FindingSpec` to `FindingRegistry`, and to its `all` list.
@@ -85,6 +85,14 @@ class MainActivity : ComponentActivity() {
finish() finish()
} }
} }
// Without this, the system Back gesture leaves the activity from Settings or
// History instead of returning to the run screen — the screen is a plain state
// variable, so nothing connects it to the back stack. Registered only when
// there is somewhere to go back to, so Back still exits from the run screen.
androidx.activity.compose.BackHandler(enabled = screen != Screen.RUN) {
screen = Screen.RUN
}
when (screen) { when (screen) {
Screen.SETTINGS -> SettingsScreen( Screen.SETTINGS -> SettingsScreen(
settings = vm.settings, settings = vm.settings,
@@ -356,8 +356,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
when { when {
ev.contains("\"captive_portal\"") -> out.add( ev.contains("\"captive_portal\"") -> out.add(
Finding( Finding(
id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY, id = ids.uuid(), code = FindingRegistry.CAPTIVE_PORTAL.code,
severity = Severity.MEDIUM, confidence = Confidence.HIGH, category = FindingRegistry.CAPTIVE_PORTAL.category,
severity = FindingRegistry.CAPTIVE_PORTAL.severity, confidence = Confidence.HIGH,
title = "Captive portal intercepting connections", title = "Captive portal intercepting connections",
description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.", description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -365,8 +366,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
) )
t.status == TestStatus.FAILED -> out.add( t.status == TestStatus.FAILED -> out.add(
Finding( Finding(
id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY, id = ids.uuid(), code = FindingRegistry.NO_INTERNET.code,
severity = Severity.HIGH, confidence = Confidence.HIGH, category = FindingRegistry.NO_INTERNET.category,
severity = FindingRegistry.NO_INTERNET.severity, confidence = Confidence.HIGH,
title = "No working internet on any network", title = "No working internet on any network",
description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.", description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -379,8 +381,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
if (ev.contains("MISMATCH")) { if (ev.contains("MISMATCH")) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS, id = ids.uuid(), code = FindingRegistry.DNS_ANSWER_REWRITTEN.code,
severity = Severity.HIGH, confidence = Confidence.HIGH, category = FindingRegistry.DNS_ANSWER_REWRITTEN.category,
severity = FindingRegistry.DNS_ANSWER_REWRITTEN.severity, confidence = Confidence.HIGH,
title = "DNS answers are being rewritten", title = "DNS answers are being rewritten",
description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).", description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -389,8 +392,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
} else if (ev.contains("\"reached_authoritative\":false")) { } else if (ev.contains("\"reached_authoritative\":false")) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS, id = ids.uuid(), code = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.code,
severity = Severity.MEDIUM, confidence = Confidence.MEDIUM, category = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.category,
severity = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.severity, confidence = Confidence.MEDIUM,
title = "Canary queries don't reach the authoritative server", title = "Canary queries don't reach the authoritative server",
description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.", description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -403,8 +407,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
if (ev.contains("address/port-dependent (symmetric NAT")) { if (ev.contains("address/port-dependent (symmetric NAT")) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "nat.symmetric", category = Category.NAT, id = ids.uuid(), code = FindingRegistry.NAT_SYMMETRIC.code,
severity = Severity.MEDIUM, confidence = Confidence.HIGH, category = FindingRegistry.NAT_SYMMETRIC.category,
severity = FindingRegistry.NAT_SYMMETRIC.severity, confidence = Confidence.HIGH,
title = "Symmetric NAT — peer-to-peer connections need a relay", title = "Symmetric NAT — peer-to-peer connections need a relay",
description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.", description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -421,8 +426,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
if (ipv6Provisioned(networks)) { if (ipv6Provisioned(networks)) {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6, id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code,
severity = Severity.MEDIUM, confidence = Confidence.HIGH, category = FindingRegistry.V6_BROKEN.category,
severity = FindingRegistry.V6_BROKEN.severity, confidence = Confidence.HIGH,
title = "IPv6 is configured but not working", title = "IPv6 is configured but not working",
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.", description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -431,8 +437,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
} else { } else {
out.add( out.add(
Finding( Finding(
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6, id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
severity = Severity.INFO, confidence = Confidence.HIGH, category = FindingRegistry.V6_NOT_OFFERED.category,
severity = FindingRegistry.V6_NOT_OFFERED.severity, confidence = Confidence.HIGH,
title = "IPv4-only network (no IPv6 offered)", title = "IPv4-only network (no IPv6 offered)",
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.", description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
evidenceRefs = listOf(EvidenceRef(t.id)), evidenceRefs = listOf(EvidenceRef(t.id)),
@@ -89,8 +89,12 @@ object FindingRegistry {
"Downstream packets arrive in a different order than they were sent.", "Downstream packets arrive in a different order than they were sent.",
) )
// MEDIUM, not HIGH: a captive portal is a condition to report, not necessarily a fault - on
// hotel or cafe wifi it is exactly what should be there, and logging in clears it. NO_INTERNET
// is the HIGH one, because nothing the user does locally fixes that. The registry first said
// HIGH; the probe emitting it had always said MEDIUM, and the probe was the considered value.
val CAPTIVE_PORTAL = FindingSpec( val CAPTIVE_PORTAL = FindingSpec(
"connectivity.captive_portal", Category.CONNECTIVITY, Severity.HIGH, "connectivity.captive_portal", Category.CONNECTIVITY, Severity.MEDIUM,
"A captive portal is intercepting connectivity checks.", "A captive portal is intercepting connectivity checks.",
) )
@@ -158,6 +162,31 @@ object FindingRegistry {
"The canary zone's authoritative server could not be reached.", "The canary zone's authoritative server could not be reached.",
) )
// ---- v6 ----------------------------------------------------------------------------
//
// Prefix is `v6.`, matching the test-type registry (v6.brokenness, v6.happy_eyeballs, ...).
// These were `ipv6.*` while declaring Category.IPV6, but the prefix map only knows "v6", so
// they silently rolled up under connectivity: the third instance of a prefix disagreeing with
// its category and quietly moving a fault to a different verdict light.
val V6_BROKEN = FindingSpec(
"v6.broken", Category.IPV6, Severity.MEDIUM,
"IPv6 is configured on this network but does not work.",
rulesOut = "Absence of IPv6: it is provisioned, it simply fails.",
)
/**
* INFO deliberately, and it needs to stay that way.
*
* Most networks still do not offer IPv6, and that is not a fault. Reporting it as a warning
* lights a yellow verdict on a perfectly healthy network, which teaches people to ignore the
* light — the one thing a diagnostic must never do.
*/
val V6_NOT_OFFERED = FindingSpec(
"v6.not_offered", Category.IPV6, Severity.INFO,
"This network does not offer IPv6.",
)
/** Every registered finding, in declaration order. */ /** Every registered finding, in declaration order. */
val all: List<FindingSpec> = listOf( val all: List<FindingSpec> = listOf(
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM, UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
@@ -167,6 +196,7 @@ object FindingRegistry {
NAT_UDP_REBINDING, NAT_SYMMETRIC, NAT_UDP_REBINDING, NAT_SYMMETRIC,
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED, THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE, DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
V6_BROKEN, V6_NOT_OFFERED,
) )
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code } private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }