The Scope Audit page said a repeater's declared region list can come from CoreDrive RX while nothing the app sends ever reached it: the client-topic switch handled packets and rf only, so /regions was dropped without a log line, and node_declared_regions is read by region_keys.go and config.go but created by nothing in this tree. #2044 found that gap and proved it against a live instance. This lands the implementation that has been carrying the feature in production on the ON8AR fork since 2026-09-06, the instance CoreDrive RX publishes to. Measured there: 1840 answers about 275 repeaters from 51 collectors, 2026-08-18 to 2026-09-19. Beyond storing the answer it keeps three things the first version did not: position (lat, lon, pos_acc_m, filled on 1263 of 1840 rows, with acc_m dropped when the fix it qualifies was rejected), repeater_clock (filled on all 1840, so a wrong repeater clock cannot make an answer look newer than it is), and retention with per-collector history (pruneOldClientDeclaredRegionsAt bounds by age instead of keeping one row per target). On that dataset 138 of 275 repeaters have answers from more than one collector and 40 have collectors that disagree about the region list, which is the signal the Scope Audit exists to surface and which only survives while more than one answer does. It gates on its own clientRegions block rather than riding on clientRxCoverage, so region answers can be accepted without GPS-tagged reception uploads, and AES block padding is trimmed from region names on ingest. Taken from #2044 with the author credited as co-author: declaredRegionsTablePresent() and its test, a real bug this version lacked (supervisord starts both processes together, so a server that probes first ignores every answer until its next restart), and the docs/client-rx-coverage.md section. Ported by cherry-picking the fork's nine commits rather than retyping, so this is the code that has been running. CI run 35434151026 is green: server ok 80.177s, ingestor ok 99.413s, race detector ok 112.406s, no --- FAIL lines. Merged by the interim maintainer without a second human reviewer: CI and the production figures above are the independent checks.
8.0 KiB
Client Region-Discovery Answers
A companion mobile app asks each repeater it comes within direct RF range of which regions that
repeater declares flood-allowed (ANON_REQ_TYPE_REGIONS), and relays the answer to CoreScope on a
dedicated topic. CoreScope already shows what each repeater is observed forwarding
(transported_scopes, derived from transport-route packets actually seen); this feature adds the
declared side, which is what makes a comparison between "claims" and "does" possible.
Enabling region-discovery answers (operators)
Off by default. To turn it on:
- In CoreScope's
config.json, set"clientRegions": { "enabled": true }and restart the ingestor. Independent ofclientRxCoverage,clientRxObservationsandclientRfSamples— a deployment can run any combination. - Required: an ACL-capable broker, same as the other client streams. Bind
meshcore/client/{PUBLIC_KEY}/regionsso each companion may publish only under its own pubkey. The ingestor already subscribes undermeshcore/#, so no separate subscription is needed. - Optionally set
retention.clientRegionsDaysto bound the table.
MQTT topic & payload
Topic: meshcore/client/{PUBLIC_KEY}/regions — {PUBLIC_KEY} is the reporting companion's pubkey.
{
"type": "REGIONS",
"timestamp": "2026-08-18T10:00:00.000Z",
"target": "bb11223344556677889900aabbccddeeff00112233445566778899aabbccddaa",
"regions": ["be", "be-vlg"],
"truncated": false,
"repeater_clock": 1755504000,
"gps": { "lat": 51.2, "lon": 4.4, "acc_m": 8.0 }
}
targetis the repeater that was asked, as a lowercase-hex pubkey. It is payload data, not ACL-bound identity, so it is validated before being trusted — but validated strictly: exactly 64 hex characters, a full pubkey, not the looser 2-64-character rule used for the topic pubkey. The app cannot produce anything else — its request builder throws unless the pubkey it sends is exactly 64 hex characters, so it can only ever ask about (and report back) a full pubkey. A shortertargettherefore indicates a malformed or forged payload, not a legitimate shorthand worth keeping: a wrongly-accepted short value would still insert a row, butCurrentDeclaredRegionsmatches on the exact target string, so that row would never surface for the real node's queries — it would just accumulate silently as junk that looks like data. A missing, malformed, or wrong-lengthtargetdrops the whole message.regionsis the CSV-decoded list the repeater returned. An empty list is a valid, deliberate answer — the repeater declared nothing flood-allowed — and is stored as a row with an emptyregions_csv, not dropped or treated as absence. A missing or malformedregionsfield (not an array, or containing a non-string entry) is a different thing entirely — a malformed payload, not an answer — and the whole message is dropped and logged rather than half-stored.truncatedis a hint the app forwards as reported, not something the ingestor re-derives: the firmware'sexportNamesToskips names that don't fit the reply's CSV budget and keeps going, so an overflowing answer has holes rather than a truncated prefix, with no marker on the wire that lets either side detect it after the fact. The app flagstruncatedheuristically (close to its budget ceiling); the ingestor stores that flag verbatim.gpsis optional, unlike the client coverage and RF-sample streams. A declared-regions answer is meaningful on its own — it says what a repeater claims to forward, regardless of where the companion was standing when it asked. A missing or out-of-range fix stores the row withlat/lonas SQLNULLrather than dropping it (contrast withhandleClientPacket, which drops a position-less message outright because a coverage point without a position is not coverage at all).gps.acc_mis stored aspos_acc_m, the same column nameclient_rf_samplesuses for this shape; a missingacc_mstoresNULL, not0.repeater_clockis the repeater's own RTC reading at the moment it answered — the only signal on this topic that would reveal a repeater with a broken clock. Stored verbatim in therepeater_clockcolumn via the sameoptInt64helperhandleClientRfSampleuses for this shape: a missing field storesNULL, never0— absent is not zero, the same rule enforced forrecv_errorson the/rftopic.- Subscription: the ingestor's default subscription (
meshcore/#) already covers this topic. Sources configured with an explicit topic list must addmeshcore/client/+/regions.
Silence carries no meaning
There is no "did not answer" message on this topic. The repeater only replies to a ANON_REQ_TYPE_REGIONS
request delivered over a DIRECT route — a companion that sent the request but never received a
reply (out of range, rate-limited, busy, or old firmware that doesn't implement the request) has
nothing to publish, and publishes nothing. That means:
- A row exists ⇒ the repeater answered, and the row is that answer (however it reads — an empty list included).
- No row for a target ⇒ either it has never been asked, or it was asked and never answered. The absence itself is not evidence of anything about what the repeater declares — it must never be read as "declares nothing," which is what an empty-list row means.
Trust
Identity = the reporting companion's pubkey, taken from the {PUBLIC_KEY} topic segment — never from a
payload-supplied field, which would defeat the ACL trust model. The ingestor rejects any topic pubkey
that is not lowercase hex before writing (the same clientPubkeyRe used across every client sub-topic),
and the observer blacklist is enforced before any client-topic write, so a blacklisted operator cannot
contribute region-discovery answers any more than it can contribute coverage or RF samples.
target, unlike the topic pubkey, is not ACL-enforced — anyone publishing on their own
meshcore/client/{own_pubkey}/regions topic could in principle claim any target. It is validated only
as a well-formed pubkey, not verified against the actual RF exchange; treat node_declared_regions as
one companion's report of what one repeater told it, not as ground truth independent of the reporting
companion's trustworthiness.
Storage — node_declared_regions (ingestor-owned)
node_declared_regions(
id, target, rx_pubkey, observed_at, ingested_at, regions_csv, truncated, lat, lon,
pos_acc_m, repeater_clock,
UNIQUE(target, rx_pubkey, observed_at)) -- idempotent re-ingest; different reporters both persist
Every answer is stored as an observation, never as replacing state — this table accumulates a
history of answers rather than holding one row per target. observed_at is stored at millisecond
precision (the rxTimeMillisLayout format shared with client_rx_observations.rx_at and
client_rf_samples.sampled_at), not the second-resolution RFC3339 used by client_receptions.rx_at.
This matters for the same reason as those tables: a future retention prune compares its cutoff
lexicographically against these stored strings, and a second-resolution cutoff compared against
millisecond-resolution values would delete rows inside the retention window (. sorts before Z).
Store.CurrentDeclaredRegions(target) returns the most recent answer for a target, ordered by
observed_at (when the repeater actually answered), not ingested_at (when the row arrived at the
ingestor) — a drive buffered offline and uploaded days later must not overwrite a fresher reading with a
stale one just because it arrived later.
Retention: retention.clientRegionsDays bounds the table by observed_at; 0 disables it (Task 6).
Configurable values (future customizer)
retention.clientRegionsDays is the only tunable so far; no map rendering or comparison UI is built on
this table yet in this task — that is future work once both the declared side (here) and the observed
side (transported_scopes) can be joined and rendered together.