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>
A finding code is the stable half of a result - what a dashboard groups by and
what someone greps a year of archived runs for. That only holds if a code means
exactly one thing forever, which fifteen ad-hoc string literals cannot promise.
By the time this was written the failure had happened twice:
- Two emitters independently produced connectivity.downstream_loss and
connectivity.loss_downstream for the same claim. Nothing objected. Anyone
aggregating either would have silently seen half their data.
- Two codes sat under nat.* while being declared Category.CONNECTIVITY.
nat.udp_unreachable is not about NAT, and the prefix decides the category,
which decides which verdict light the finding rolls up into. Renamed while
that is still cheap.
Codes are now typed FindingSpecs carrying category and default severity;
emitters reference the spec rather than retyping the string, so a typo is a
compile error and two call sites cannot disagree about a finding's category.
docs/findings-registry.md is the contract and a test reads it, failing when the
document and the code disagree on which codes exist or how severe they are.
Documentation that drifts from its implementation is worse than none, because it
still looks authoritative. The check reads table rows only, so the prose can go
on explaining which codes were retired and why.
Closes open item 1 of measurement-schema.md section 9.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Letting the kernel fragment an oversized datagram answers one question — do
fragments get through. It cannot answer the more interesting one, because the
kernel always emits them in order, first one first.
The classic middlebox fault is exactly about that ordering. Only the first
fragment carries the UDP header, and therefore the ports; a stateful firewall
or NAT that has not seen it has no flow to match the rest against, and many
drop them. That is invisible to any in-order test and shows up in the field as
"large DNS answers fail on this network" or "the tunnel breaks when the MTU
drops" — it works until the network reorders, then fails intermittently, which
is the hardest kind of fault to chase.
So the server now builds the fragments itself (raw socket, IP_HDRINCL) and
controls their order: in_order as a baseline, reversed, and first-fragment-last.
The datagram is assembled and signed whole before being cut up, so what the
client reassembles is indistinguishable from an ordinary packet — otherwise it
would be measuring our sender rather than the path.
Two details that would silently produce wrong answers:
- The UDP checksum is computed rather than left zero. A zero-checksum datagram
is dropped by some middleboxes, and that drop would be recorded as a
fragmentation failure, which is the wrong conclusion entirely.
- Fragment offsets are in 8-byte units, so non-final fragments are rounded to
a multiple of 8. A 100-byte fragment is not an error, it is a datagram no
host will ever reassemble.
frag-send is advertised only when a raw socket can actually be opened — checked
by opening one, since a permission model has more ways to say no than a
capability bit has to say yes.
Fragment header arithmetic is unit-tested (reassembly coverage, MF flags, shared
IP ID, 8-byte offsets, checksum verification), cross-compiled and run on Linux
since the code is build-tagged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).
ServerMeasurement now runs them on the same ProbeSession as the echo train. It
had to: a fresh session restarts client-side sequence numbers and the server's
anti-replay window discards the lot, so the re-primed source is never recorded
and every granted send goes to a socket that has already closed. That produced
four confidently-wrong FAILED tests and a RED verdict on a healthy network.
Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
link.ra_source answers "who advertises IPv6 here, and which box is it":
RA source per network, MAC recovered from the modified-EUI-64 link-local
(privacy addresses reported as such, not guessed), vendor via a curated
OUI table, UPnP/SSDP M-SEARCH for the gateway's server banner + device
description (manufacturer/model/friendly name), and reverse DNS. All SSDP
responders are recorded so a rogue RA sender that isn't the gateway can
still be matched; the MAC accompanies every identity source as the hook
for future LLDP/mDNS cross-matching. UI gains a "Router / IPv6 advertiser"
panel.
Icons: branding adaptive icon converted to vector drawables (+ PNG
mipmaps, monochrome layer). The debug build is now a separate app —
applicationIdSuffix .dev, label "Echolot DEV", DEV-badged icon — so it
installs alongside a production build and can't be confused with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors NetworkMonitor: per active network, fetch the AOSP default
generate_204 endpoints and check for HTTP 204 No Content.
- HTTPS https://www.google.com/generate_204 == 204 -> validated internet
- HTTP http://connectivitycheck.gstatic.com/generate_204: 204 -> clean;
an unfollowed 3xx or a 200-with-body -> captive portal (Location captured)
- both fail -> no_internet
Per-network verdicts (bound via Network.openConnection), redirects not
followed (the 3xx IS the evidence). Findings: captive_portal (medium) and
no_internet (high). New test type net.captive_portal (net family ->
connectivity category). App gains usesCleartextTraffic (a network
diagnostic that intentionally probes plain HTTP).
Builds; measurement verdict tests still green. On-device verification
deferred with the rest (flaky test devices).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure Kotlin/JVM, faithful to the schema contract: two-clock (wall RFC3339 +
*_mono_ns), units in field names, observation/interpretation split
(tests[] vs findings[]), columnar train evidence (nulls preserved per
index), the full v1 test-type registry, the anonymization logical types as
field notes, and a finding-requires-evidence invariant.
The one piece with real logic — §7.3 deterministic verdict derivation
(category = worst finding light; >50% failed/unsupported → inconclusive;
overall = worst category, inconclusive only if all are) — is implemented
in Verdicts and fully unit-tested. Document JSON round-trips (snake_case
wire names, null-in-columns), typed builders for train/traceroute/resolver
evidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>