Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b0cae9e
docs(scopes): design spec for auto-derived region keys and scope-audi…
Sep 7, 2026
4f56227
docs(plans): M1 implementation plan — scope-audit unmatched-traffic c…
Sep 7, 2026
b84bf62
docs(plans): M2 implementation plan — auto-derived region keys
Sep 7, 2026
b610d46
docs(scopes): amend spec — forwarding is attributed to path[last] only
Sep 7, 2026
79f38ef
feat(scope-audit): caveat chip for regions this instance cannot name
Sep 7, 2026
d93b446
fix(scopes): attribute flood forwarding to every path hop, not just t…
Sep 7, 2026
380d87c
docs(plans): sequence M1 and M2 behind M0, record what has landed
Sep 7, 2026
70e6bcd
feat(scope-audit): count the unmatched packets ScopeAuditForwarding d…
Sep 7, 2026
ca464b5
feat(scope-audit): expose observedUnmatchedPackets on the API row
Sep 7, 2026
93a0c38
docs(api): document observedUnmatchedPackets on GET /api/scope-audit
Sep 7, 2026
7673ccb
docs(plans): record M1 as code-complete with verification deferred
Sep 7, 2026
55f46a1
docs(scopes): amend spec — add M1b, declared-region verification
Sep 7, 2026
36435c6
docs(plans): M1b implementation plan — declared-region verification
Sep 7, 2026
64e3ac6
feat(scope-audit): derive a region's on-wire code from a packet's own…
Sep 7, 2026
3e27b11
feat(scope-audit): record which transmissions were unmatched, per target
Sep 7, 2026
409258c
feat(scope-audit): narrow query for the window's unmatched transmissions
Sep 7, 2026
e5cee74
fix(scope-audit): keep the empty-scope_name comment out of gofmt's reach
Sep 7, 2026
28e310f
feat(scope-audit): memoised declared-region verification with a 2-pac…
Sep 7, 2026
985067f
feat(scope-audit): verify declared regions against a repeater's own u…
Sep 7, 2026
a813346
feat(scope-audit): mark verified regions and narrow the caveat to wha…
Sep 7, 2026
a87ce8e
docs(api): document regionEvidence on GET /api/scope-audit
Sep 7, 2026
37b0473
perf(scope-audit): cache verification per region, not per (region, pa…
Sep 7, 2026
82a0081
docs(plans): record M1b as code-complete with deploy verification def…
Sep 7, 2026
ac8ff6d
feat(ingestor): autoRegionKeys config block, default off
Sep 7, 2026
b222421
feat(ingestor): candidate filter and deterministic ranking for derive…
Sep 7, 2026
068af8f
feat(ingestor): two-tier regionKeySet behind an atomic snapshot
Sep 7, 2026
46c50ff
feat(ingestor): tiered scope matching with an explicit-over-derived t…
Sep 7, 2026
a19af37
feat(ingestor): thread the two-tier key set through the ingest path
Sep 7, 2026
757407f
docs(config): document the opt-in autoRegionKeys block
Sep 7, 2026
a5575cb
fix(scope-repair): repair against the full key set, not just hashRegions
Sep 7, 2026
2e2ae3a
test(ingestor): benchmark scope matching across key-set sizes
Sep 7, 2026
73c0d7e
docs(plans): record M2 as code-complete with two checks deferred
Sep 7, 2026
e872530
docs(plans): handover for the scope-audit work
Sep 7, 2026
942761c
perf(scope-audit): read the per-transmission columns once, not once p…
efiten Sep 7, 2026
b7515ce
perf(scope-audit): collapse concurrent cold requests, and give 7d its…
efiten Sep 7, 2026
cdbeda3
docs: record what the first staging deploy measured
efiten Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 68 additions & 4 deletions cmd/ingestor/client_reception.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
Expand All @@ -23,7 +24,7 @@ var clientPubkeyRe = regexp.MustCompile(`^[0-9a-f]{2,64}$`)
// companion reports WHERE it directly heard a node, so we write a
// client_receptions row and never touch the observers/observations tables.
// rxPubkey is the companion pubkey from the topic (ACL-bound by the broker).
func handleClientPacket(store *Store, cfg *Config, tag, rxPubkey string, msg map[string]interface{}, channelKeys map[string]string, regionKeys map[string][]byte) {
func handleClientPacket(store *Store, cfg *Config, tag, rxPubkey string, msg map[string]interface{}, channelKeys map[string]string, regionSet *regionKeySet) {
// The companion identity IS the (ACL-bound) topic pubkey. Reject non-hex
// topic segments so a no-ACL broker can't pollute the coverage tables, and
// never fall back to a payload-supplied id (that would defeat the ACL trust
Expand Down Expand Up @@ -93,7 +94,7 @@ func handleClientPacket(store *Store, cfg *Config, tag, rxPubkey string, msg map
// collapse them and ON CONFLICT DO NOTHING would silently drop all but
// the first.
rxAtMillis := rxTime.Format(rxTimeMillisLayout)
if obs := buildClientRxObservation(direction, rxPubkey, rawHex, rxAtMillis, ingestedAt, decoded, regionKeys, snrPtr, rssiPtr, lat, lon, accPtr); obs != nil {
if obs := buildClientRxObservation(direction, rxPubkey, rawHex, rxAtMillis, ingestedAt, decoded, regionSet, snrPtr, rssiPtr, lat, lon, accPtr); obs != nil {
if _, err := store.InsertClientRxObservation(obs); err != nil {
log.Printf("MQTT [%s] client observation insert: %v", tag, err)
}
Expand Down Expand Up @@ -422,7 +423,7 @@ type ClientRxObservation struct {
// signal — per-flood row multiplicity meant to measure forwarder
// amplification of traffic actually heard over the air.
func buildClientRxObservation(
direction, rxPubkey, rawHex, rxAt, ingestedAt string, decoded *DecodedPacket, regionKeys map[string][]byte,
direction, rxPubkey, rawHex, rxAt, ingestedAt string, decoded *DecodedPacket, regionSet *regionKeySet,
snr *float64, rssi *int, lat, lon float64, posAccM *float64,
) *ClientRxObservation {
if !strings.EqualFold(direction, "rx") {
Expand All @@ -447,7 +448,9 @@ func buildClientRxObservation(
obs.Code1 = &decoded.TransportCodes.Code1
obs.Code2 = &decoded.TransportCodes.Code2
if decoded.TransportCodes.Code1 != "0000" {
sn := matchScope(regionKeys, byte(decoded.Header.PayloadType), decoded.payloadRaw, decoded.TransportCodes.Code1)
m := regionSet.snapshot().match(byte(decoded.Header.PayloadType), decoded.payloadRaw, decoded.TransportCodes.Code1)
recordScopeMatch(m)
sn := m.Name
obs.ScopeName = &sn
}
}
Expand Down Expand Up @@ -567,3 +570,64 @@ func (s *Store) CurrentDeclaredRegions(target string) (*ClientDeclaredRegions, e
o.Truncated = truncated == 1
return &o, nil
}

// DeclaredRegionStats returns every region name currently declared anywhere on
// the network, with the two facts the derived-tier cap ranks on: how many
// distinct repeaters declare it, and the most recent observed_at among them.
//
// Only the LATEST answer per target counts - the same rule
// CurrentDeclaredRegions follows, by observed_at and never ingested_at, so a
// drive buffered offline cannot resurrect a region a repeater has since
// dropped. The window function is covered by idx_ndr_target(target,
// observed_at).
//
// CSV splitting and aggregation happen in Go rather than SQL: regions_csv is
// written with strings.Join, and unpicking it in SQLite would need a recursive
// CTE for no gain at this row count (~200 targets).
func (s *Store) DeclaredRegionStats() ([]declaredRegionStat, error) {
rows, err := s.db.Query(`
WITH ranked AS (
SELECT target, observed_at, regions_csv,
ROW_NUMBER() OVER (PARTITION BY target ORDER BY observed_at DESC) AS rn
FROM node_declared_regions
)
SELECT target, observed_at, regions_csv FROM ranked WHERE rn = 1
`)
if err != nil {
return nil, fmt.Errorf("declared region stats: %w", err)
}
defer rows.Close()

agg := map[string]*declaredRegionStat{}
for rows.Next() {
var target, observedAt, csv string
if err := rows.Scan(&target, &observedAt, &csv); err != nil {
return nil, fmt.Errorf("declared region stats scan: %w", err)
}
seenHere := map[string]bool{} // one target counts once per name
for _, name := range splitDeclaredRegionsCSV(csv) {
if name == "*" || seenHere[name] {
continue // '*' is the wildcard, not a region name
}
seenHere[name] = true
st, ok := agg[name]
if !ok {
st = &declaredRegionStat{Name: name}
agg[name] = st
}
st.Declarers++
if observedAt > st.LastSeen {
st.LastSeen = observedAt
}
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("declared region stats rows: %w", err)
}

out := make([]declaredRegionStat, 0, len(agg))
for _, st := range agg {
out = append(out, *st)
}
return out, nil
}
4 changes: 2 additions & 2 deletions cmd/ingestor/client_reception_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ func TestClientObservationScopeNameFromTransportCode(t *testing.T) {
"timestamp": ts1,
"gps": map[string]interface{}{"lat": 51.2, "lon": 4.4},
}
handleClientPacket(s, cfgWithObservations(), "test", "aa11", msg, nil, regionKeys)
handleClientPacket(s, cfgWithObservations(), "test", "aa11", msg, nil, regionSetFromKeys(regionKeys))

// ComputeContentHash deliberately excludes the transport-code bytes (so the
// same content dedups across scopes), so raw and raw2 below share one
Expand Down Expand Up @@ -732,7 +732,7 @@ func TestClientObservationScopeNameFromTransportCode(t *testing.T) {
"timestamp": ts2,
"gps": map[string]interface{}{"lat": 51.2, "lon": 4.4},
}
handleClientPacket(s, cfgWithObservations(), "test", "aa11", msg2, nil, regionKeys)
handleClientPacket(s, cfgWithObservations(), "test", "aa11", msg2, nil, regionSetFromKeys(regionKeys))

var code1b, scopeNameB sql.NullString
if err := s.db.QueryRow(`SELECT code1, scope_name FROM client_rx_observations WHERE rx_at = ?`, ts2).
Expand Down
56 changes: 56 additions & 0 deletions cmd/ingestor/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type Config struct {
ClientRxObservations *ClientRxObservationsConfig `json:"clientRxObservations,omitempty"`
ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"`
ClientRegions *ClientRegionsConfig `json:"clientRegions,omitempty"`
AutoRegionKeys *AutoRegionKeysConfig `json:"autoRegionKeys,omitempty"`
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
// PathTrust configures the minimum path-hash prefix length trusted as
// mapping/topology evidence (issue #1784).
Expand Down Expand Up @@ -185,6 +186,61 @@ func (c *Config) ClientRegionsEnabled() bool {
return c.ClientRegions != nil && c.ClientRegions.Enabled
}

// AutoRegionKeysConfig controls the opt-in derivation of region keys from the
// names repeaters declare over RF (node_declared_regions), on top of the
// explicit hashRegions list.
//
// TOP-LEVEL BLOCK, a sibling of hashRegions — not nested inside it. Config
// loading is plain json.Unmarshal with no DisallowUnknownFields, so a
// mis-nested key is silently ignored and derivation stays off with no error,
// the same trap clientRxObservations documents.
type AutoRegionKeysConfig struct {
Enabled bool `json:"enabled"`
MaxDerived int `json:"maxDerived"`
RefreshMinutes int `json:"refreshMinutes"`
}

// autoRegionKeysDefaultMaxDerived bounds the derived tier. Every added key
// raises the random ambiguity rate by 1/65536 per scoped packet (code1 is two
// bytes) and costs one more HMAC per transport-scoped packet — the match is
// O(keys) and cannot be indexed, because the code depends on the payload. 256
// on top of a typical explicit set puts the ambiguity rate near 0.5%, which is
// the ceiling this design accepts.
const autoRegionKeysDefaultMaxDerived = 256

// autoRegionKeysDefaultRefreshMinutes is how often the derived tier is rebuilt
// from the database. Declared-region answers arrive at human pace (a drive-by
// with a companion app, or a 24h observer report), so minutes-scale staleness
// is irrelevant and a tighter interval only burns queries.
const autoRegionKeysDefaultRefreshMinutes = 15

// AutoRegionKeysEnabled reports whether region keys may be derived from
// declared-region answers. Default false.
func (c *Config) AutoRegionKeysEnabled() bool {
return c.AutoRegionKeys != nil && c.AutoRegionKeys.Enabled
}

// AutoRegionKeysMaxDerived returns the derived-tier cap, falling back to the
// default for absent, zero, or negative values — a zero is indistinguishable
// from "key omitted" after json.Unmarshal, and neither should silently mean
// "derive nothing" when the operator has switched the feature on.
func (c *Config) AutoRegionKeysMaxDerived() int {
if c.AutoRegionKeys == nil || c.AutoRegionKeys.MaxDerived <= 0 {
return autoRegionKeysDefaultMaxDerived
}
return c.AutoRegionKeys.MaxDerived
}

// AutoRegionKeysRefreshMinutes returns the refresh interval in minutes,
// falling back to the default for absent, zero, or negative values — a zero
// here would otherwise panic time.NewTicker.
func (c *Config) AutoRegionKeysRefreshMinutes() int {
if c.AutoRegionKeys == nil || c.AutoRegionKeys.RefreshMinutes <= 0 {
return autoRegionKeysDefaultRefreshMinutes
}
return c.AutoRegionKeys.RefreshMinutes
}

// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
type RetentionConfig struct {
NodeDays int `json:"nodeDays"`
Expand Down
Loading
Loading