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:
co-authored by
Claude Fable 5
parent
6bba420845
commit
8646bab52d
@@ -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.
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
+31
-1
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user