Swaps the SQLite driver from `modernc.org/sqlite` (pure Go, SQLite
3.46.0) to `github.com/mattn/go-sqlite3` (cgo, bundled SQLite 3.53.4),
and pays the resulting cross-compilation cost with `zig cc`.
Draft because the riskiest part of this deletes rows — see [Please
review this part first](#please-review-this-part-first) — and because
three things remain unverified at the bottom.
`modernc.org/sqlite` is a transpilation of the C amalgamation. This repo
is read-heavy: `cmd/server` chunk-loads a graph at startup and fans out
neighbour/topology/analytics queries per request, and it pays for that
transpilation on exactly those paths. Head-to-head on the same
120k-transmission / 240k-observation database, running our own hot-path
SQL under both drivers (Apple M4, `-count=5`, medians):
| workload | modernc | mattn | |
|---|---:|---:|---|
| chunk load (`chunked_load.go` v3 join, 20k tx) | 449ms | 196ms |
**2.3×** |
| aggregate scan (240k-row join + `GROUP BY`) | 276ms | 137ms | **2.0×**
|
| 1500 prepared-statement lookups | 512ms | 403ms | **1.3×** |
Allocations fall with it: 1.12M vs 1.64M allocs and 21MB vs 30MB on the
chunk load.
**Superseded by a production run.** @efiten measured both drivers on a
real instance — 11,077,038 observations, 9.7GB database, 4-core arm64 —
as server-only containers against the same live volume, one at a time,
with round 2 reversing the order so the page cache favours the old
driver:
| | audit 7d | audit 24h | background fill (13 chunks) | start →
/api/health |
|---|---:|---:|---:|---:|
| modernc, round 1 | 16.67s | 2.27s | 130.2s | 16.6s |
| mattn, round 1 | 7.87s | 1.34s | 93.8s | 13.5s |
| mattn, round 2 | 8.15s | 1.35s | 96.4s | 13.0s |
| modernc, round 2 | 13.46s | 2.29s | 137.8s | 15.5s |
Warm, the old driver improves to 13.46s on the 7d audit and still loses
by ~1.8×. Chunk load is ~1.4×. `/api/nodes?limit=500` is 0.039s against
0.037s — nothing.
**So the real gain is ~1.4–1.8× on the paths that matter, not 2–2.3×.**
The shape the harness predicted holds — scans and joins gain, small
lookups do not — which is more reassuring than the magnitude would have
been. Quote these numbers.
**The counterweight**, cold and native on that machine: a build goes
from **52s to 163s**. An instance that builds its own image pays that
per deploy.
## The build is cgo now, and one thing about that is a trap
**`CGO_ENABLED=0` still builds.** mattn links a stub, and the binary
dies on its first query with `go-sqlite3 requires cgo to work. This is a
stub`. A green build is not evidence of anything here, which is why
`AGENTS.md` now says so explicitly. `GOOS=linux go build` genuinely
cannot cross-compile any more.
A new root `Makefile` is the entry point. `make crossbuild` uses `zig cc
-target {x86_64,aarch64}-linux-musl` and links static, so each artifact
stays a single self-contained file and the `alpine:3.20` runtime no
longer depends on the base image's libc at all.
`-Wl,-s` is load-bearing: Go's own `-s -w` does not reach the musl
objects zig links in, and without it the server binary is 19.8MB instead
of 12.1MB.
The Dockerfile keeps its single `$BUILDPLATFORM` builder — still no QEMU
for compilation — and gains a checksum-pinned zig plus BuildKit cache
mounts. The mounts are not a nicety: without them an image build
recompiles the amalgamation from cold and takes over half an hour.
## Please review this part first
`internal/dbschema/dedup_index.go` **deletes observation rows**. It is
the one part of this change that can lose data, and it exists because
the migration exposed a real bug rather than causing one.
`stmtInsertObservation` resolves its `ON CONFLICT` against
`idx_observations_dedup`, which `cmd/ingestor/db.go` only ever created
inside the branch that creates the `observations` table for the first
time. Any database whose table predates that branch never got one, so
the UPSERT had no conflict target. modernc failed on the first insert;
mattn fails at `OpenStore`. Same bug, found earlier.
Creating the index unconditionally repairs it — but the index is what
was supposed to prevent duplicates, so a database that never had it can
already hold rows violating it. **`test-fixtures/e2e-fixture.db` in this
repo holds one.** So duplicates are collapsed first. Refusing is not the
safer option: without the index the ingestor cannot prepare its UPSERT,
so it cannot start at all.
Replaying that UPSERT faithfully is subtler than it looks, and a first
cut of this got it wrong twice:
- `COALESCE(excluded.x, x)` means the **incoming** value wins, so down a
group in id order the survivor keeps the **last** non-NULL value. Taking
the first silently discarded newer readings.
- The UPSERT names exactly five columns (`snr`, `rssi`, `score`,
`raw_hex`, `resolved_path`). Every other column must keep the surviving
row's own value; merging those too invents history the ingestor would
never have written.
Merge, delete and `CREATE UNIQUE INDEX` now share one transaction. Split
apart, a writer inserting a duplicate in the gap fails the index
creation while leaving the deletions committed — rows destroyed and no
index to show for it.
Cost, measured on 2.4M synthetic rows holding 5 duplicates: **4.1s**,
holding the write lock throughout, once, at ingestor startup before MQTT
subscribe. Materialising the duplicate-group scan once rather than per
column took that from 9.7s; the pathological case (400k of 600k rows
duplicated) is 5.7s, slightly worse than the 4.2s it was before that
change.
## Four more behavioural differences
Full detail in `docs/sqlite-driver-migration.md`. Briefly:
**Statement preparation is eager.** modernc's `newStmt` stored the SQL
and compiled lazily; mattn calls `sqlite3_prepare_v2` inside `Prepare`,
so SQL naming a missing table fails at *open*. 59 server tests failed on
this alone, all fixtures with partial schemas. `OpenDB` keeps failing
loudly (#1901; `main.go` gates on `dbschema.AssertReady` anyway) and the
fixtures now declare what they are prepared against via
`ensurePreparable`. This also exposed nine `nodes(pubkey …)`
declarations across seven files, where production has only ever had
`public_key` — lazy compilation had hidden the mismatch for as long as
it existed.
**`synchronous` silently dropped FULL → NORMAL.** mattn defaults it to
NORMAL and executes the pragma unconditionally, where SQLite's own
default (what modernc left alone) is FULL. In WAL mode that weakens
durability under power loss. Pinned in `dbschema.WriterDSN`, which both
writers now share — `cmd/migrate` kept a bare path at first and so
quietly wrote at NORMAL, which is what a second copy of a DSN buys you.
**The DSN dialects are mutually invisible.** modernc understood only
`_pragma=name(value)`, mattn only `_`-prefixed parameters, and neither
errors on the other's form — a driver-only rename would have dropped
every pragma in silence. `_journal_mode=WAL` is also gone from the
server's read handle: modernc ignored it, mattn honours it, and setting
`journal_mode` on a read-only connection is a write. Dropping
`_busy_timeout` with it costs nothing, since mattn already defaults to
5000ms — which means the read handle finally *gets* the busy timeout it
had silently lacked.
**`mode=ro` survives for a non-obvious reason.** mattn always passes
`READWRITE|CREATE` and its amalgamation has `SQLITE_USE_URI=0`; what
makes the URI work is its C wrapper ORing `SQLITE_OPEN_URI` in. So the
#1283/#1289 invariant holds with no build flags — but it depends on the
`file:` prefix. `cmd/decrypt` had been building its DSN without one, so
its `mode=ro` had never applied and a missing path was created
read-write. Fixed in passing; never a migration regression.
## What did not change
No modernc-specific API was in use: no `RegisterFunction`, no
`*sqlite.Conn`, no `sqlite/lib` error constants, no `sql.Register`. No
`time.Time` is ever bound as a query argument, so driver time handling
is not in play. Both drivers convert declared
`DATE`/`DATETIME`/`TIMESTAMP` columns to `time.Time`, so
`/api/dropped-packets` keeps emitting `dropped_at` as RFC3339 — an
earlier draft "fixed" that with a `CAST` and would have been the
regression.
## Tests and CI
New regression tests, each written because something got through without
it:
- `TestEnsureObservationsDedupIndexKeepsLatestValues` — the merge
ordering. The original test used complementary NULLs, which passes
whichever direction you pick, which is why the bug survived it.
- `TestCollapseDuplicatesAndIndexIsAtomic` — a failed index creation
must roll the deletions back.
- `TestOpenStorePragmas` / `TestWriterDSNPragmas` — every writer pragma,
read back through the store's own connection. A separate `sqlite3`
session or the startup log line would prove nothing.
- `TestOpenDBRefusesMissingDatabase` — the read-only invariant, which
now rests on a detail of the driver's C wrapper.
- `TestEnsurePreparableMatchesPrepareStatements` — fails when a new
prepared statement outgrows the fixture helper.
CI gains test execution for `cmd/migrate` and `internal/dbschema`, which
had none and both open the database. A PR-time two-arch build plus an
arm64 QEMU smoke gate is new: the GHCR push is push/tag-only, so without
it nothing on a PR would exercise zig, static musl linking or arm64, and
the first signal would arrive on master. `cache-dependency-path` widens
from 2 of the 5 tracked `go.sum` files to all of them.
`make test` passes across all 14 modules, `cmd/server` also under `-race
-count=2` with no failures and no races. `gofmt` and `go vet` clean.
Release-routing and Dockerfile COPY-invariant gates pass.
## Verified by running
- All 8 cross-builds static and correct-architecture; both arches of the
container image built, exported and run under QEMU, serving
`/api/health` and `/api/nodes` against a 2.9M-observation production
snapshot.
- The `migrate` binary repairing that snapshot's duplicate on bare
Alpine.
- `CGO_ENABLED=0` producing a binary that builds and then fails on first
query.
## Not verified
- ~~The 2–2.3× figures come from a standalone harness, not this load
under the old driver.~~ **Closed** by @efiten's production run above,
which also corrected the multiplier.
- SQLite 3.46.0 → 3.53.4 query-planner differences on queries with no
total `ORDER BY`.
- Sustained live ingest through the new writer DSN, and the duplicate
collapse against a database an ingestor is actively writing to. Verified
against a static snapshot only, and the collapse is measured at 4.1s on
2.4M synthetic rows with 5 duplicates — well short of an 11M-row
instance. @efiten has offered a staging instance taking real MQTT
traffic; **this is the item to close before the PR leaves draft.**
An earlier revision of this branch shipped the dedup merge in the wrong
direction with a green test suite, and review then found three more
things in the same file: the repair gated on an error string, a
non-atomic TEMP table drop aimed at the wrong connection, and a deletion
whose only record was a row count. All fixed in ac7e8d38. Passing tests
did not establish safety here, which is why the deletion path wanted a
second pair of eyes rather than a rubber stamp.
17 KiB
Client RX Coverage
Crowdsourced RF coverage from mobile clients: a phone connects over BLE to a MeshCore
companion radio, captures which nodes the companion hears (with SNR/RSSI), tags each reception
with the phone's GPS position, and publishes it to MQTT. CoreScope ingests these into
client_receptions and renders per-node H3-style hex coverage on the Reach page.
Companion app — where to get it
The mobile capture side is corescope-rx — an
open-source (GPL-3.0) Android PWA. Operators who enable coverage point their users at it: it connects
over BLE to a MeshCore companion radio, captures directly-heard nodes + the phone's GPS, and publishes
the payload defined below. It's self-hostable and generic — a runtime config.json aims it at your
own MQTT broker + CoreScope instance (see its README).
Enabling coverage (operators)
Coverage is off by default. To turn it on:
- In CoreScope's
config.json, set"clientRxCoverage": { "enabled": true }and restart the server and ingestor. This is a single flag read by both processes — the ingestor and server each parse the sameconfig.json, so you setclientRxCoverage.enabledonce and it gates both the ingest write path and the read endpoints. There is no separate per-process flag. - Required: an ACL-capable broker. Bind
meshcore/client/{PUBLIC_KEY}/packetsso each client may publish only under its own pubkey (e.g. an EMQX ACL keyed on the connected client's identity). This is the trust boundary, not an optimization — see Trust. The ingestor already subscribes undermeshcore/#. - Optionally set
retention.clientRxDaysto bound the coverage tables (see Storage). - Point your users at corescope-rx and they start
contributing. Results show on each node's Reach page (coverage toggle) and the
#/rx-coveragedashboard. Warn them first that their contribution is world-readable and a per-observer view can reconstruct their movements — see Privacy.
The rest of this document is the MQTT payload contract the companion app implements.
Companion BLE source (verified against firmware)
The mobile app's RX data comes from the companion's PUSH_CODE_LOG_RX_DATA (0x88) BLE frame:
[0x88][snr×4 int8][rssi int8][raw packet bytes]. This is emitted for every received
packet (promiscuous, incl. overheard flood traffic), not just messages addressed to the device:
src/Dispatcher.cpp:198callslogRxRaw(getLastSNR(), getLastRSSI(), raw, len)incheckRecv()unconditionally — NOT behind#if MESH_PACKET_LOGGING. So it works on stock firmware.examples/companion_radio/MyMesh.cpp:283overrides it to write the 0x88 frame whenever the app is connected over BLE (_serial->isConnected()).
So per received packet the app gets SNR + RSSI + the raw bytes. It decodes the raw packet (standard
MeshCore format) to derive the directly-heard node (path[last] or 0-hop advert pubkey) and pairs it
with the phone's GPS. The bare advert push (PUSH_CODE_ADVERT 0x80) carries only a pubkey (no SNR/
RSSI/path) and is NOT used — 0x88 already covers adverts (the raw advert is in its payload).
Caveats: 0x88 is only sent while the app is BLE-connected; packets larger than MAX_FRAME_SIZE are
skipped; the firmware doc labels 0x88 "can be ignored" (messaging-app view) — for coverage it is the
primary frame. GPS is always the phone's, never the companion's.
MQTT topic & payload
Topic: meshcore/client/{PUBLIC_KEY}/packets — {PUBLIC_KEY} is the companion's pubkey. The
broker (EMQX) should ACL-restrict each client to publish only under its own pubkey, which is how
"a connected companion may only inject under the keys that apply" is enforced.
Payload — meshcoretomqtt-compatible packet, plus a gps object:
{
"origin": "<companion name>",
"origin_id": "<companion pubkey hex>",
"timestamp": "2026-06-09T12:00:00Z",
"type": "PACKET",
"direction": "rx",
"raw": "<packet hex>",
"SNR": -7,
"RSSI": -92,
"gps": { "lat": 51.05, "lon": 3.72, "acc_m": 8 }
}
- The discriminator is the
gpsobject. A packet withoutgpsis dropped (coverage needs a position). rawis decoded server-side to derive the directly-heard node and the path;hash/pathfields are not required.- Subscription: the ingestor's default subscription (
meshcore/#) already covers this topic. Sources configured with an explicit topic list must addmeshcore/client/+/packets.
Capture HARD RULE — only what was heard directly
The app and ingestor record only the node the companion physically received, never upstream relayers:
- FLOOD packet with a path (≥1 hop) → record
path[len-1](the last forwarder = the immediate RF transmitter). Confirmed against firmwareMesh.cpp(routeRecvPacketappends the forwarder's hash to the END of the path) and CoreScope'sneighbor_builder.go:226-228. - DIRECT packet with a path → NOT attributable, discarded. Direct forwarders consume the
next hop from the FRONT (
Mesh.cpp removeSelfFromPath), sopath[len-1]is the route's destination-side end, NOT the node we heard. Attributing it credits the SNR to the wrong (often far-away) node. Only FLOOD routes (0,1) are recorded from a path. - Packet with no path (0 hops) and an advert → record the advertiser's full pubkey.
directionmust berx. 1-byte (2 hex char) prefixes are excluded (collision-prone, like Reach).- The RSSI/SNR belong to the directly-received transmission, so they attach to the recorded node.
- The rest of the path is discarded for coverage.
Storage — client_receptions (ingestor-owned)
A roaming companion is a mobile observer with a moving position, so it gets its own table (not
observations, which assumes a fixed observer location). Per the #1283 read/write invariant, the
table and all writes live in cmd/ingestor/.
client_receptions(
id, rx_pubkey, heard_key, heard_keylen, rssi, snr,
lat, lon, pos_acc_m, rx_at, ingested_at, src,
UNIQUE(rx_pubkey, heard_key, rx_at)) -- idempotent re-ingest
heard_keylen is 32 for a full pubkey (0-hop advert) or 2/3 for a multibyte prefix. src is
advert or rxlog. No hex cell is stored — binning is computed server-side from lat/lon.
Indexes: a composite (heard_key, heard_keylen, lat, lon) and a (lat, lon) index back the coverage
queries; the per-node query matches a sargable heard_key IN (pubkey, prefix6, prefix4) list so the
composite is used instead of a table scan (see the benchmark in cmd/ingestor).
Retention: the table grows on every submission, so set retention.clientRxDays (ingestor) to delete
rows older than N days (and stale client_observers); 0 disables it. Without it the table is
unbounded.
Diagnostic observations — client_rx_observations (ingestor-owned)
The client topic may also carry packets the companion could not attribute to a directly-heard node — a DIRECT-route packet with a path, for instance (see the capture HARD RULE above). Those packets are still decodable, and are optionally recorded as a diagnostic RF observation, independent of whether they produced a coverage row.
Not literally every decodable packet, though. A packet still needs a gps fix and
direction: "rx" to reach the decoder/observation write at all — handleClientPacket returns
early (before DecodePacket even runs) when gps is missing or its lat/lon don't parse, and
direction: "tx" (a companion's own outgoing transmission) is decoded but explicitly excluded
from the observation write, the same as it already was from coverage. A diagnostic table silently
requiring a GPS fix is a bit surprising, so: no gps → no observation row either, same constraint
as coverage.
- Written to
client_rx_observationsonly, never toclient_receptions— the coverage invariant (only directly-heard nodes) is unchanged and unaffected by this feature. - Gated by its own flag,
"clientRxObservations": { "enabled": true }— a top-levelConfigfield, not nested insideclientRxCoveragein the JSON. It IS gated behindclientRxCoveragein the control flow:handleClientPacket(where the observation write lives) is only reached whenclientRxCoverage.enabledis true, so observations require coverage to be enabled even though the two keys are siblings on disk:Config loading is plain{ "clientRxCoverage": { "enabled": true }, "clientRxObservations": { "enabled": true } }json.Unmarshalwith noDisallowUnknownFields, so nestingclientRxObservationsunderclientRxCoverageas written above is silently ignored — the key is never read, the feature stays off, and nothing logs or errors. An ingestor withoutclientRxObservations.enabledsimply drops these packets (no table writes, no error). - Enabling the companion app's
fullRfLogflag whileclientRxObservations.enabledisfalsehere is pure waste: the phone spends mobile data uploading packets this ingestor decodes and discards, with no row written and no warning anywhere.fullRfLogmultiplies normal upload volume — see the corescope-rx README. - The JSON payload shape from the companion app is unchanged either way — this is purely an
ingestor-side decision based on what
rawdecodes to, not a new field the app must send. - Captures routing detail the coverage path discards:
route_type,payload_type,code1/code2transport codes (route types 0/3 only),scope_name(matched against configured region keys),hash_size,hop_count, the full forwarder path (path_json), and — for FLOOD routes only — the immediateforwarder. rx_atis stored at millisecond precision (unlikeclient_receptions.rx_at), becauseUNIQUE(rx_pubkey, pkt_hash, rx_at)deliberately allows multiple rows perpkt_hash: each row is one forwarder's copy of the same flood, and that multiplicity is the flood-amplification signal this table exists to capture. Retention isretention.clientRxObsDays(separate from, and typically shorter than,retention.clientRxDays— this table is diagnostic, not archival).pkt_hash(ComputeContentHash) deliberately excludes both the transport-code bytes and the path bytes, so distinctness insideUNIQUE(rx_pubkey, pkt_hash, rx_at)rests entirely onrx_at. On the happy path that's fine — real receive times come from the envelope timestamp at millisecond resolution, and same-millisecond collisions aren't physical on a half-duplex LoRa radio. But on any fallback path (missing/unparseable/implausible timestamp — seeresolveRxTimeCore), every packet in a buffered upload batch is stamped with the same ingest-timerx_at, and distinct forwarder copies of one flood inside that batch collapse into a single row viaON CONFLICT DO NOTHING. Not a correctness bug — the constraint is doing exactly what it's told — but it means a buffered/late upload with a bad envelope timestamp under-reports flood amplification for that batch. This isn't limited to the server-side fallback path either: the companion app stampsrx_atat BLE-frame processing time, not true RF receive time (app.js), so two forwarder copies processed in the same millisecond collapse just as effectively even when the envelope timestamp itself is fine.- A 0-hop advert gets
forwarder = NULLinclient_rx_observationseven though the transmitter is known — it's the advert's own pubkey, which the coverage path records separately withsrc='advert'(seeclient_receptionsabove). Don't mistake thisNULLfor "unknown".
Read API — coverage GeoJSON
GET /api/nodes/{pubkey}/rx-coverage?bbox={minLat,minLon,maxLat,maxLon}&z={zoom}
Returns a GeoJSON FeatureCollection of hexagons covering where clients heard the node, aggregated
server-side (read-only). Each feature:
{ "type": "Feature",
"geometry": { "type": "Polygon", "coordinates": [[[lon,lat], ...]] },
"properties": { "cell": "9:123:-45", "count": 7, "best_snr": -6, "has_sig": true,
"nodes": [{ "prefix": "aabbcc", "name": "Alice", "snr": -6, "count": 3 }],
"nodes_truncated": false } }
- Hex binning is a pure-Go pointy-top grid over Web Mercator (
cmd/server/hexgrid.go). We do not useuber/h3-go, which would add a C dependency for no benefit (the SQLite driver is cgo since the mattn/go-sqlite3 move, but that is a library we need). Latitude is only defined within ±85.05° (Web Mercator limit) and is clamped to that range. z(Leaflet zoom) selects the hex resolution (zoom-adaptive). Raw points never leave the server (privacy: contributors' tracks are not exposed).best_snr/has_sigdrive the colour: green→orange by best SNR, grey when no signal metric.- Features are sorted by
cellfor a deterministic (cacheable) payload. - Bounds: the per-cell
nodeslist is capped (withnodes_truncated), and the collection is capped at a fixed feature count — when exceeded, the densest cells are kept and the top-leveltruncatedflag is set. The per-node endpoint also returnsmobile_receptionsandmobile_clientstotals (node-wide, independent of the bbox).
Frontend
Shown only in the Reach view (#/nodes/{pubkey}/reach), as a toggleable hex layer drawn on the
existing Leaflet map (public/node-reach-coverage.js), deep-linked via ?coverage=1. No new
frontend dependencies. Colours come from CSS variables in public/node-reach.css
(--nq-cov-strong|mid|weak|grey).
Trust
Identity = the companion pubkey (rx_pubkey), taken from the {PUBLIC_KEY} topic segment.
The feature requires an ACL-capable broker. The reported GPS position is the contributor's own
claim, so the only thing anchoring a reception to a real identity is the broker ACL binding
meshcore/client/{PUBLIC_KEY}/packets to the client that holds that key. Without such an ACL, the
topic — and therefore the GPS and the heard-node attribution — is spoofable: anyone who can publish
to the broker could inject coverage under any pubkey. Do not enable this feature on an open/no-ACL
broker if you trust the resulting map.
Server/ingestor-side defense-in-depth (these reduce blast radius but do not replace the ACL):
- The ingestor rejects any topic pubkey that is not lowercase hex before writing, and never falls back
to a payload-supplied id (
cmd/ingestor/client_reception.go, #2/#10). - A blacklisted operator cannot contribute via the client topic (the blacklist is enforced before the coverage write, #1).
- The frontend HTML-escapes the pubkey it renders, so a junk pubkey can't inject markup (#14).
/api/nodes/resolveand coverage tooltips never reveal blacklisted or hidden-prefix node identities (#15).
Privacy — contributor location is public
⚠️ Enabling coverage publishes contributors' GPS-tagged receptions, and the per-observer view can
reconstruct a contributor's movements. The hex map is read without authentication. The leaderboard
exposes each companion's pubkey, and clicking one filters the map to that single companion
(/api/rx-coverage?rx=<pubkey>); at high zoom over the retention window this is effectively a public
movement trail (home / work / commute) of whoever carries that companion. A pseudonymous companion
name does not mitigate this — the locations themselves are identifying (overnight clustering = home),
and all of one contributor's points are linked by the pubkey.
This is an accepted tradeoff of the feature, not a bug: fine resolution is what makes the aggregate coverage map useful, the feature is opt-in and OFF by default, and contributors choose to run the companion. But the consent must be informed:
- Operators: tell your users, before they contribute, that their coverage (including a per-observer
view of their own track) is world-readable for as long as
retention.clientRxDayskeeps it. - Contributors: do not contribute from a device you carry on your person if a public record of where you have been is a concern. Use a dedicated/stationary node, or accept that the trail is public.
Operators who want to harden this further can lower retention.clientRxDays, run the dashboard behind
their own auth/proxy, or (future hardening) coarsen stored coordinates / apply a k-anonymity threshold
to the per-observer view.
Optional future hardening: have the companion sign a broker-issued token (the firmware exposes on-device signing) — not required for the MVP, tracked as a follow-up.
Configurable values (future customizer)
Hardcoded initially, tracked for the customizer per AGENTS.md rule 8: hex resolution per zoom
(zoomToHexRes), colour SNR thresholds (coverageColorVar), and any rx_at max-age validation.