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>
301 lines
16 KiB
Markdown
301 lines
16 KiB
Markdown
# Echolot Measurement Schema — Spec v1 (draft 1)
|
||
|
||
Project name: **Echolot** (decided 2026-07-29; domain: echo-lot.app). Status: draft for review, 2026-07-29.
|
||
|
||
This document defines the JSON format of one **measurement run** — the unit that is archived on-device, diffed against other runs, and exported/shared. It is the contract between the probe engine, the server's observation API, the report renderer, and (later) the anonymizer.
|
||
|
||
## 1. Design rules
|
||
|
||
1. **Observation and interpretation are separated.** `tests[]` contain raw evidence and computed metrics; `findings[]` contain interpretation with references back to evidence. A reader must be able to re-derive every finding from the evidence alone.
|
||
2. **Schema-versioned.** `schema_version` is semver. Minor = additive only. Consumers must ignore unknown fields (forward compat) and must not require fields added after their version.
|
||
3. **Every value is attributable.** Each test records which network it ran on, which trust tier produced it (`app` / `shizuku` / `root`), and which server session (if any) was involved.
|
||
4. **Two clocks.** Wall-clock timestamps are RFC 3339 UTC with millisecond precision, only for human correlation. All measurement math uses `*_mono_ns`: nanoseconds since `run.clock.mono_origin` (an arbitrary per-run epoch from the monotonic clock). Never mix the two.
|
||
5. **Units in field names.** `_ms`, `_ns`, `_bytes`, `_bps`, `_pct`. No unitless numbers for physical quantities.
|
||
6. **Anonymization-ready.** Every sensitive scalar is declared with a logical type in the schema registry (§8): `ip4`, `ip6`, `mac`, `fqdn`, `ssid`, `bssid`, `imsi-ish`. The v2 anonymizer walks the schema, not the data, and transforms consistently within a document (prefix-preserving for IPs).
|
||
7. **Big arrays go columnar.** Packet-train evidence is stored as parallel arrays (§6.2), not arrays of objects — a 10 000-packet train stays in the hundreds of kB.
|
||
8. **IDs.** `run.id` and `tests[].id` are UUIDv7 (time-ordered). Cross-references use these IDs plus optional JSON Pointers for precision.
|
||
|
||
## 2. Top-level document
|
||
|
||
```json
|
||
{
|
||
"schema": "echolot/measurement",
|
||
"schema_version": "1.0.0",
|
||
"run": { ... }, // §3
|
||
"networks": [ ... ], // §4
|
||
"server_sessions": [ ... ], // §5
|
||
"tests": [ ... ], // §6
|
||
"findings": [ ... ], // §7
|
||
"summary": { ... } // §7.3
|
||
}
|
||
```
|
||
|
||
Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent uses MIME `application/gzip` plus a rendered HTML report alongside.
|
||
|
||
## 3. `run` — context of the whole measurement
|
||
|
||
```json
|
||
{
|
||
"id": "0198c5f2-...-uuidv7",
|
||
"trigger": "manual | scheduled | monitor | peer",
|
||
"started_at": "2026-07-29T14:03:21.114Z",
|
||
"ended_at": "2026-07-29T14:07:44.902Z",
|
||
"clock": {
|
||
"mono_origin_wall": "2026-07-29T14:03:21.114Z",
|
||
"ntp_offset_ms": -12.4, // optional, if NTP test ran
|
||
"ntp_offset_source": "test-uuid" // evidence ref
|
||
},
|
||
"app": { "version": "0.3.1", "build": 310, "git": "a1b2c3d", "flavor": "fdroid" },
|
||
"device": {
|
||
"manufacturer": "Google", "model": "Pixel 8a",
|
||
"android_sdk": 35, "android_release": "15", "security_patch": "2026-06-05"
|
||
},
|
||
"tiers": { "app": true, "shizuku": true, "root": false },
|
||
"profiles_used": ["profile-uuid", ...],
|
||
"constraints": {
|
||
"vpn_active": true,
|
||
"per_network_blocked": true,
|
||
"unmeasured_networks": ["net-0", "net-1"]
|
||
},
|
||
"notes": "free-text user annotation"
|
||
}
|
||
```
|
||
|
||
`tiers` records what was *available*; each test records what it *used*.
|
||
|
||
`constraints` records what was *prevented*. A constrained run is neither a failed run nor a normal
|
||
one, and the distinction has to survive into the data: a run taken through a VPN has the same shape
|
||
and the same green verdict as a clean run of a healthy network, so without this a reader — or a
|
||
server aggregating thousands of them — cannot tell that almost nothing was measured. The known case
|
||
is `per_network_blocked`: Android refuses `Network.bindSocket()` on the underlying networks while a
|
||
VPN holds the default route, so every per-network test measures the tunnel or nothing at all, and
|
||
any conclusion about the link underneath is unfounded. Consumers should treat findings from a
|
||
constrained run as scoped to what was actually reachable, and `unmeasured_networks` names the rest.
|
||
|
||
## 4. `networks[]` — one entry per Android `Network` in play
|
||
|
||
A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ethernet). Everything is a snapshot at run start; a `changes[]` list captures mid-run deltas.
|
||
|
||
```json
|
||
{
|
||
"id": "net-1",
|
||
"transport": "wifi | cellular | ethernet | vpn | other",
|
||
"interface": "wlan0",
|
||
"link": {
|
||
"mtu": 1500,
|
||
"addresses": [
|
||
{ "addr": "192.0.2.23", "prefix_len": 24, "scope": "global",
|
||
"flags": ["temporary"], "valid_lft_s": 3541, "pref_lft_s": 3541 }
|
||
],
|
||
"routes": [
|
||
{ "dst": "0.0.0.0/0", "gateway": "192.0.2.1", "iface": "wlan0",
|
||
"proto": "dhcp | ra | static | unknown", "expires_s": 1799 }
|
||
],
|
||
"dns": {
|
||
"servers": ["192.0.2.1"],
|
||
"private_dns_mode": "off | opportunistic | strict",
|
||
"private_dns_hostname": null,
|
||
"search_domains": ["lan"],
|
||
"nat64_prefix": null
|
||
},
|
||
"dhcp": { "server": "192.0.2.1", "lease_s": 3600 },
|
||
"captive_portal": { "detected": false, "api_url": null, "venue_url": null }
|
||
},
|
||
"wifi": {
|
||
"ssid": "example-net", "bssid": "aa:bb:cc:dd:ee:ff",
|
||
"rssi_dbm": -54, "link_speed_mbps": 573, "frequency_mhz": 5240,
|
||
"channel_width_mhz": 80, "standard": "11ax",
|
||
"security": "wpa3-sae", "mac_randomization": true
|
||
},
|
||
"cellular": { "rat": "nr-nsa", "operator": "...", "band": "n78" },
|
||
"changes": [
|
||
{ "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed",
|
||
"detail": { /* new link snapshot or diff */ } }
|
||
]
|
||
}
|
||
```
|
||
|
||
`routes[].proto` and lifetime fields are Shizuku-tier data (`ip route`/`ip addr`); app-tier snapshots leave them absent — absence means "not observed", never "not present".
|
||
|
||
## 5. `server_sessions[]`
|
||
|
||
```json
|
||
{
|
||
"id": "sess-1",
|
||
"profile_id": "profile-uuid",
|
||
"profile_name": "homelab",
|
||
"control_url": "https://probe.example.net:8443",
|
||
"server_version": "0.4.0",
|
||
"capabilities": ["udp-probe", "stun-5780", "canary-dns", "connect-back", "delayed-echo", "big-send", "tls-echo"],
|
||
"session_id": "opaque-server-issued",
|
||
"target": { "ip4": "203.0.113.10", "ip6": "2001:db8::10", "udp_port": 8442 }
|
||
}
|
||
```
|
||
|
||
`capabilities` here records what the server offered *at run time*; test-level `status: "unsupported"` records what was consequently skipped.
|
||
|
||
## 6. `tests[]` — the generic result envelope
|
||
|
||
```json
|
||
{
|
||
"id": "uuidv7",
|
||
"type": "traceroute.udp4", // registry, §6.1
|
||
"network_ref": "net-1",
|
||
"session_ref": "sess-1", // null for purely local tests
|
||
"tier": "app | shizuku | root",
|
||
"started_mono_ns": 12000000,
|
||
"ended_mono_ns": 4530000000,
|
||
"status": "ok | failed | unsupported | skipped | partial",
|
||
"error": { "code": "timeout", "detail": "..." }, // when failed/partial
|
||
"params": { ... }, // exact inputs, type-specific
|
||
"evidence": { ... }, // raw observations, type-specific
|
||
"metrics": { ... } // derived numbers, type-specific
|
||
}
|
||
```
|
||
|
||
Rules: `params` must contain everything needed to reproduce the test. `evidence` is append-only raw truth. `metrics` must be recomputable from `evidence` (renderer and diff view use `metrics`; auditors use `evidence`).
|
||
|
||
### 6.1 Test type registry (v1)
|
||
|
||
Dotted names, family first. Initial registry; additions are minor version bumps.
|
||
|
||
| Family | Types |
|
||
|---|---|
|
||
| `link` | `link.snapshot`, `link.dhcp_renewal_watch`, `link.ip_monitor` (shizuku) |
|
||
| `icmp` | `icmp.ping4`, `icmp.ping6` (targets: gateway, first-hop-external, server, public refs) |
|
||
| `trace` | `traceroute.udp4`, `traceroute.udp6`, `traceroute.icmp4`, `traceroute.icmp6` |
|
||
| `train` | `train.udp_updown` (loss/jitter/reorder/dup, both directions) |
|
||
| `mtu` | `mtu.pmtud_up`, `mtu.pmtud_down`, `mtu.blackhole`, `mtu.mss_observed`, `mtu.frag_delivery` |
|
||
| `nat` | `nat.stun_5780`, `nat.mapping_lifetime_udp`, `nat.mapping_lifetime_tcp`, `nat.hairpin`, `nat.connect_back`, `nat.cgnat_detect` |
|
||
| `dns` | `dns.resolver_inventory`, `dns.canary`, `dns.interception`, `dns.ttl_integrity`, `dns.answer_integrity`, `dns.dnssec`, `dns.nxdomain_wildcard`, `dns.rebind_filter`, `dns.aaaa_filter`, `dns.dns64`, `dns.compare` |
|
||
| `sec` | `sec.tls_reference`, `sec.clienthello_echo`, `sec.http_echo`, `sec.sni_filter`, `sec.dscp_ecn_survival`, `sec.arp_watch` (shizuku) |
|
||
| `port` | `port.reach_sweep` (outbound), `port.udp_usability` |
|
||
| `perf` | `perf.throughput_tcp`, `perf.throughput_udp`, `perf.bufferbloat`, `perf.rrc_latency` |
|
||
| `v6` | `v6.dualstack_compare`, `v6.happy_eyeballs`, `v6.brokenness`, `v6.nat64_clat` |
|
||
| `wifi` | `wifi.environment_scan`, `wifi.roam_log`, `wifi.signal_log` |
|
||
| `local` | `local.mdns_inventory`, `local.ssdp_inventory`, `local.llmnr_inventory`, `local.gateway_services` (UPnP-IGD/NAT-PMP/PCP), `local.ntp` |
|
||
| `peer` | `peer.reachability`, `peer.isolation`, `peer.multicast`, `peer.lan_train`, `peer.lease_diff` |
|
||
| `time` | `time.server_offset` (4-timestamp exchange, feeds one-way metrics) |
|
||
|
||
### 6.2 Evidence conventions for packet trains
|
||
|
||
Columnar parallel arrays, one index per probe packet. Missing observations are `null` at that index.
|
||
|
||
```json
|
||
"evidence": {
|
||
"epoch_mono_ns": 12000000,
|
||
"seq": [0, 1, 2, 3],
|
||
"t_tx_ns": [0, 20000000, 40000000, 60000000], // relative to epoch
|
||
"t_srv_rx_ns":[8123456, 28090000, null, 68240000], // server clock, session epoch
|
||
"t_srv_tx_ns":[8180000, 28150000, null, 68300000],
|
||
"t_rx_ns": [16500000, 36400000, null, 76800000],
|
||
"size_bytes": [64, 64, 64, 64],
|
||
"dscp_sent": 46, "dscp_seen_by_server": [0, 0, null, 0],
|
||
"ecn_sent": 1, "ecn_seen_by_server": [1, 1, null, 1],
|
||
"ttl_seen_by_server": [54, 54, null, 54]
|
||
}
|
||
```
|
||
|
||
Server-side observations come from the observation API (probe-protocol §6) and are merged in by the app; their clock is the server session epoch — only differences within the same clock are meaningful unless a `time.server_offset` test provides the mapping.
|
||
|
||
### 6.3 Traceroute evidence
|
||
|
||
```json
|
||
"evidence": {
|
||
"flow": { "src_port": 40123, "dst_port": 8442, "fixed_tuple": true },
|
||
"hops": [
|
||
{ "ttl": 1, "probes": [
|
||
{ "reply_from": "192.0.2.1", "rtt_ns": 1830000, "icmp": "ttl-exceeded", "reply_ttl": 64 },
|
||
{ "reply_from": null, "rtt_ns": null, "icmp": null }
|
||
]}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 6.4 DNS test conventions
|
||
|
||
**Resolver targeting.** Every `dns.*` test carries a `params.resolver` object identifying the resolver under test:
|
||
|
||
```json
|
||
"resolver": {
|
||
"source": "system | manual | server-recursive",
|
||
"address": "9.9.9.9", "port": 53,
|
||
"transport": "do53-udp | do53-tcp | dot | doh",
|
||
"doh_url": null
|
||
}
|
||
```
|
||
|
||
The resolver set for a run is: all system resolvers from `LinkProperties` (per network), plus any **manually configured override resolvers** (user-entered, stored per profile or ad-hoc), plus the server's reference recursive resolver. When multiple resolvers are in scope, the full `dns.*` battery is instantiated **once per (test type × resolver) pair** and the instances run concurrently; `dns.compare` then diffs answer sets, TTL behavior, latency, and filtering verdicts across all of them. Override resolvers are tested even if unreachable from the current network (evidence of *that* is itself useful).
|
||
|
||
**`dns.ttl_integrity` methodology.** Uses the spec-defined reference records in the canary zone (probe-protocol §6.1), which have fixed, known TTLs (5 s … 7 d):
|
||
|
||
1. Cache-miss query (unique nonce label): returned TTL must equal the authoritative TTL → detects clamping (min/max caps) and static rewriting on first answer.
|
||
2. Re-query after delay *d*: TTL must have decreased by ≈ *d* → detects TTL freezing/reset-on-every-answer (middleboxes that always return the original TTL).
|
||
3. Query after expiry of a short-TTL record: detects serve-stale and over-caching.
|
||
4. Evidence records, per resolver and per reference record: authoritative TTL, returned TTLs with query times, computed clamp floor/ceiling estimates.
|
||
|
||
**`dns.answer_integrity`.** Compares the full response against spec-defined ground truth for the reference records: RDATA values, record order, flags (AA/RA/AD), case preservation (0x20), EDNS handling, CNAME flattening, stripped/injected additional records, ECS forwarding (visible in server-side canary observations). Any delta is evidence for a finding; TTL deltas are delegated to `dns.ttl_integrity`.
|
||
|
||
## 7. Findings, verdicts, summary
|
||
|
||
### 7.1 `findings[]`
|
||
|
||
```json
|
||
{
|
||
"id": "uuidv7",
|
||
"code": "dns.interception.transparent_redirect", // stable registry, like lint rules
|
||
"category": "dns", // §7.2 list
|
||
"severity": "info | low | medium | high | critical",
|
||
"confidence": "high | medium | low",
|
||
"network_ref": "net-1",
|
||
"title": "DNS queries are transparently redirected",
|
||
"description": "Plain-language explanation of what was observed and why it matters.",
|
||
"evidence_refs": [
|
||
{ "test": "test-uuid", "pointer": "/evidence/answers/2" }
|
||
],
|
||
"recommendation": "optional plain-language next step"
|
||
}
|
||
```
|
||
|
||
Finding **codes** are a stable, documented registry (`findings-registry.md`, to be written; grows continuously). A finding with no `evidence_refs` is invalid.
|
||
|
||
### 7.2 Categories
|
||
|
||
`connectivity`, `dns`, `nat`, `mtu`, `ipv6`, `security`, `performance`, `local`, `wifi`. Fixed in v1; each maps to one traffic light.
|
||
|
||
### 7.3 `summary`
|
||
|
||
```json
|
||
{
|
||
"overall": "green | yellow | red | inconclusive",
|
||
"categories": {
|
||
"dns": { "verdict": "red", "worst_finding": "finding-uuid", "tests_run": 9, "tests_failed": 0 },
|
||
"mtu": { "verdict": "green", "worst_finding": null, "tests_run": 5, "tests_failed": 0 }
|
||
}
|
||
}
|
||
```
|
||
|
||
Verdict derivation is deterministic and fixed in this spec: category = worst severity among its findings (`critical|high → red`, `medium|low → yellow`, `info/none → green`); `inconclusive` when > 50 % of the category's tests are `failed`/`unsupported`. Overall = worst category, except `inconclusive` only if all are. The UI must always allow drilling from a light to the findings to the raw evidence — no unexplained scores.
|
||
|
||
## 8. Logical type registry (anonymization contract)
|
||
|
||
The JSON Schema (machine-readable companion, `measurement.schema.json`, generated from this doc) annotates string fields with `x-echolot-type`:
|
||
|
||
| type | example fields | v2 anonymizer transform |
|
||
|---|---|---|
|
||
| `ip4`, `ip6` | addresses, routes, hops, DNS answers | prefix-preserving pseudonymization, consistent per document; well-known/reserved ranges kept verbatim. **Exception: ULA (`fc00::/7`) has its whole prefix pseudonymized as a unit.** It resembles RFC1918 but is not analogous: a ULA global ID is 40 random bits, unique to one network by construction (RFC 4193), so the prefix *is* the identifier, whereas `192.168.0.0/16` is shared by millions of networks and identifies none. Pseudonymizing it as a unit keeps "these hosts are on one subnet" while dropping "this is that subnet". |
|
||
| `mac`, `bssid` | wifi, arp_watch | OUI kept, NIC part pseudonymized |
|
||
| `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept |
|
||
| `ssid` | wifi | pseudonym |
|
||
| `opaque-id` | session ids, tokens | redacted |
|
||
|
||
Free-text fields (`notes`, `error.detail`, dump excerpts from Shizuku parsers) cannot be safely auto-anonymized; the exporter flags them for manual review.
|
||
|
||
## 9. Open items
|
||
|
||
1. ~~Findings registry document~~ — done: `findings-registry.md`, kept in step with
|
||
`FindingRegistry.kt` by a test that fails when the two disagree.
|
||
2. Whether Shizuku raw-dump excerpts (dumpsys/ip output) are embedded in `evidence` verbatim (auditable, but large and hard to anonymize) or parsed-only with an optional "attach raw dumps" toggle. Proposal: toggle, default on for local archive, default off for export.
|
||
3. Peer-mode documents: each device produces its own run; the coordinator embeds the peer's findings summary and cross-references by `run.id`. Full merge format deferred.
|
||
4. Size guardrails: soft cap 20 MB uncompressed per run; trains beyond that downsample evidence (keep aggregates + first/last N + all anomalies) and record `"evidence_truncated": true`.
|