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>
rmnet_data1 on the OnePlus is the carrier's IMS/VoLTE network: it carries
IMS&MMTEL but neither INTERNET nor NOT_RESTRICTED, so binding needs
CONNECTIVITY_USE_RESTRICTED_NETWORKS - signature-level, unobtainable.
EPERM there is permanent and says nothing about any network's health, but
the constraint detector counted it, so a phone with VoLTE reported
'measurement blocked' on every run and every verdict came out
INCONCLUSIVE. Capabilities now decide: networks an app may never bind are
recorded as app_usable:false in networks[] and skipped, rather than
reported as something the run failed to do.
Also retires the 'while it tears down' wording, which was a guess that
turned out to be wrong - the refusal outlasts the VPN indefinitely, so
the text now names the VPN as the usual cause without asserting a
timeline it cannot know.
App version 0.2.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Constraints are detected up front (one throwaway bind per network) and land
in run.constraints, a measurement.vpn_constrained finding, the $7.3 verdict
(INCONCLUSIVE outright) and a banner on the run screen - a VPN'd run looked
exactly like a clean run of a healthy network before this.
v6.broken returns to the registry now that it can be earned: V6ConnectProbe
(v6.brokenness) makes a real TCP connection over IPv6 to the enrolled
server, and only both transports failing on a network that advertises IPv6
justifies the claim. TCP succeeding turns the finding into 'ICMPv6 is
filtered, IPv6 works' at high confidence instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found in a real uploaded run from the phone: the server held
fda1:3fb1:ff92:6696::2662 for a DNS server. The general IPv6 path keeps the
leading two groups on purpose - for a global address that preserves the ISP
allocation, which is the useful part - but for a ULA that passes through 32 of
the 40 random bits of the global ID.
A ULA looks like the v6 RFC1918 and the instinct is to treat it the same. It is
not analogous, and the difference is the point: an RFC1918 prefix is shared by
millions of networks and identifies none of them, while a ULA global ID is
random and unique to one network by construction (RFC 4193). The prefix IS the
identifier, so it was a network fingerprint surviving redaction.
Pseudonymized as a unit now, so two addresses on one ULA subnet still share a
pseudonymous prefix - "these hosts are on one network" survives, "this is that
network" does not. RFC1918 stays readable, and the contrast is what justifies
it; a test pins both halves.
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>
Monorepo root for Echolot. Contains the no-root capability prober
(Kotlin/Compose, app.echo_lot.prober) and the four design docs that act
as the contract for the production app and the Go server.
LICENSE is deliberately absent — still undecided, see docs/build-status.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>