diff --git a/docs/build-status.md b/docs/build-status.md index e7b91a0..8b26023 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -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 verbatim, like RFC1918, for consistency". The surface analogy pointed the wrong way, and the 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. diff --git a/docs/findings-registry.md b/docs/findings-registry.md index 2276212..68bc3d1 100644 --- a/docs/findings-registry.md +++ b/docs/findings-registry.md @@ -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.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` | 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. | — | ### 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.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 1. Add a `FindingSpec` to `FindingRegistry`, and to its `all` list. diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 763b19f..1a8b6b8 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -85,6 +85,14 @@ class MainActivity : ComponentActivity() { 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) { Screen.SETTINGS -> SettingsScreen( settings = vm.settings, diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt index 583c8b7..e2200c6 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt @@ -356,8 +356,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { when { ev.contains("\"captive_portal\"") -> out.add( Finding( - id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY, - severity = Severity.MEDIUM, confidence = Confidence.HIGH, + id = ids.uuid(), code = FindingRegistry.CAPTIVE_PORTAL.code, + category = FindingRegistry.CAPTIVE_PORTAL.category, + severity = FindingRegistry.CAPTIVE_PORTAL.severity, confidence = Confidence.HIGH, 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.", evidenceRefs = listOf(EvidenceRef(t.id)), @@ -365,8 +366,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { ) t.status == TestStatus.FAILED -> out.add( Finding( - id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY, - severity = Severity.HIGH, confidence = Confidence.HIGH, + id = ids.uuid(), code = FindingRegistry.NO_INTERNET.code, + category = FindingRegistry.NO_INTERNET.category, + severity = FindingRegistry.NO_INTERNET.severity, confidence = Confidence.HIGH, 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.", evidenceRefs = listOf(EvidenceRef(t.id)), @@ -379,8 +381,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { if (ev.contains("MISMATCH")) { out.add( Finding( - id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS, - severity = Severity.HIGH, confidence = Confidence.HIGH, + id = ids.uuid(), code = FindingRegistry.DNS_ANSWER_REWRITTEN.code, + category = FindingRegistry.DNS_ANSWER_REWRITTEN.category, + severity = FindingRegistry.DNS_ANSWER_REWRITTEN.severity, confidence = Confidence.HIGH, 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).", evidenceRefs = listOf(EvidenceRef(t.id)), @@ -389,8 +392,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { } else if (ev.contains("\"reached_authoritative\":false")) { out.add( Finding( - id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS, - severity = Severity.MEDIUM, confidence = Confidence.MEDIUM, + id = ids.uuid(), code = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.code, + category = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.category, + severity = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.severity, confidence = Confidence.MEDIUM, 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.", evidenceRefs = listOf(EvidenceRef(t.id)), @@ -403,8 +407,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { if (ev.contains("address/port-dependent (symmetric NAT")) { out.add( Finding( - id = ids.uuid(), code = "nat.symmetric", category = Category.NAT, - severity = Severity.MEDIUM, confidence = Confidence.HIGH, + id = ids.uuid(), code = FindingRegistry.NAT_SYMMETRIC.code, + category = FindingRegistry.NAT_SYMMETRIC.category, + severity = FindingRegistry.NAT_SYMMETRIC.severity, confidence = Confidence.HIGH, 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.", evidenceRefs = listOf(EvidenceRef(t.id)), @@ -421,8 +426,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { if (ipv6Provisioned(networks)) { out.add( Finding( - id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6, - severity = Severity.MEDIUM, confidence = Confidence.HIGH, + id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code, + category = FindingRegistry.V6_BROKEN.category, + severity = FindingRegistry.V6_BROKEN.severity, confidence = Confidence.HIGH, 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.", evidenceRefs = listOf(EvidenceRef(t.id)), @@ -431,8 +437,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { } else { out.add( Finding( - id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6, - severity = Severity.INFO, confidence = Confidence.HIGH, + id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code, + category = FindingRegistry.V6_NOT_OFFERED.category, + severity = FindingRegistry.V6_NOT_OFFERED.severity, confidence = Confidence.HIGH, 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.", evidenceRefs = listOf(EvidenceRef(t.id)), diff --git a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt index b649cc6..00db4aa 100644 --- a/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt +++ b/echolot-app/core-measurement/src/main/kotlin/app/echo_lot/measurement/FindingRegistry.kt @@ -89,8 +89,12 @@ object FindingRegistry { "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( - "connectivity.captive_portal", Category.CONNECTIVITY, Severity.HIGH, + "connectivity.captive_portal", Category.CONNECTIVITY, Severity.MEDIUM, "A captive portal is intercepting connectivity checks.", ) @@ -158,6 +162,31 @@ object FindingRegistry { "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. */ val all: List = listOf( UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM, @@ -167,6 +196,7 @@ object FindingRegistry { NAT_UDP_REBINDING, NAT_SYMMETRIC, THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED, DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE, + V6_BROKEN, V6_NOT_OFFERED, ) private val byCode: Map = all.associateBy { it.code }