# Passive Mesh Topology → Inferred Trace Routes (design spec) **Status:** proposal / design review (2026-07-05). Owner: Offband. Related: #150 (trace), the RX-fed nearest-repeater work, `meshcore-firmware/docs/trace-path-format.md`. ## 1. The problem we're solving A MeshCore TRACE only walks a route **the caller hands it**. The firmware never discovers or reverses a route (`trace-path-format.md`), and the reference apps make the user **hand-build** the path (pick nodes from a list, or click them on a map). So multi-hop trace today is either a clunky manual chore or it silently fails. No client discovers the route for you — that's the gap. **The opportunity:** we *already* parse every received packet's full routing path (for the new RX-fed nearest-repeater detection in `_handleRxData`). That firehose is a **passive map of the mesh**. If we accumulate it into a topology graph, we can **infer** a route to a target we've observed — and auto-suggest it, with a builder to confirm/tweak. The firmware can't discover routes; the *client* can remember what it has already seen. That's the thing no client does. ## 2. Core idea, in one line Every received packet carries the ordered list of repeaters it crossed. Merge those observations into a graph; to trace node *T*, find a path to *T* on the graph and hand it to the existing round-trip builder. ## 3. What we already have (reuse, don't rebuild) | Existing | Role in this feature | |---|---| | `_handleRxData` → `pathBytes` (per packet) | The observation firehose — the ordered hops a packet crossed. Already parsed; today only adverts are used. | | `_handlePayloadAdvertReceived`, `_handleLogRxData` | Additional path observations (adverts, channel messages). | | `DirectRepeater` list (RX-fed) | The **first ring** of the graph — our direct neighbours + their SNR. The only hops we can SNR-rank. | | `PathHistoryService` | Per-contact observed ACK/flood paths — another observation input; the graph generalises it. | | `PathHelper.buildTraceRoundTrip(routingPath, targetPrefix, throughTarget, …)` | Assembles the out-and-back frame from a route. Inference output feeds straight into this. | | `PathSelectionDialog` | The manual route builder — becomes the confirm/edit surface, pre-filled from inference. | ## 4. Path orientation (get this right) A received flooded/routed packet's `pathBytes` = `[h1, h2, …, hk]`, where the packet travelled `origin → h1 → … → hk → us`. So: - `hk` (= `pathBytes.last`) is **our direct neighbour** (the last hop before us). - `h1` is the hop closest to the origin. - The reversed path `[hk, …, h1]` is a **known route from us toward `origin`**. Adjacencies contributed by one observation: `us~hk`, `hk~h(k-1)`, …, `h2~h1`, and `h1~origin` when the sender prefix is known. ## 5. Data model — `MeshTopologyService` (new, ChangeNotifier) Graph, scoped per device key (like the other stores). ``` Node { prefix : Uint8List // width-byte repeater hash (the key) lastSeen : int // unix seconds, refreshed on every observation neighbourSnr : double? // only set for OUR direct neighbours (from RX SNR); null for deep hops seenCount : int // name is resolved on demand from contacts — NOT stored here } Edge { // undirected link A~B ("observed adjacent in a path") a, b : Uint8List // the two endpoints (store canonically ordered) lastSeen : int count : int // observation confidence lastFailed : int? // set when a trace routed through this link timed out (self-healing) } ``` Sizes (why storage is a non-issue): ~8 B/node, ~12 B/edge. Scales with **mesh size** (distinct repeaters × their links), **not** hop depth or packet volume — each node/link is stored once and merely refreshed. 100 repeaters ≈ 6 KB; 1,000 ≈ 80 KB (≈200 KB as JSON). See §9 for the hard bound. ## 6. Ingestion On every parsed RX path (from `_handleRxData` and the advert/channel handlers), call: ``` bool observePath(List path, {int width, double? lastHopSnr, Uint8List? senderPrefix, int ts}) ``` - Chunk `path` into width-byte hops. - Upsert a `Node` per hop (refresh `lastSeen`, bump `seenCount`); set `neighbourSnr` on `hk` from `lastHopSnr`. - Upsert the `us~hk … h1~origin` `Edge`s (refresh `lastSeen`, bump `count`). - Return `true` only on a **notify-worthy change** (new node/edge, or a materially newer observation) — same notify-on-change discipline as `DirectRepeater.recordHop`. - Cost: `O(path length)` ≤ 64. The parse already happened; this is a handful of map writes. No new per-packet parsing. `DirectRepeater.recordHop` and `observePath` can share the same call site — the direct-neighbour list is just the graph's first ring. ## 7. Route inference ``` List? inferRoute(Uint8List target) // ordered hops us→…→(excluding target); null if unreachable ``` - Target is a **direct neighbour** → return `[]` (one-hop ping is correct). - Else run a shortest-path search (BFS by hop count) from `us` to `target` over the **fresh** subgraph (edges within the freshness window §9). Among equal-length routes, prefer: (1) strongest **first-hop** SNR (we can rank the entry neighbour), (2) freshest edges, (3) highest `count`, (4) no recent `lastFailed`. - Return the intermediate hops `[n1, …, n(m-1)]`. The trace launch then calls `buildTraceRoundTrip(routingPath: that, targetPrefix: target, throughTarget: true)` — existing, tested code. - `null` when `target` isn't in the fresh graph → manual builder. **Honest limit:** we measure SNR only for **our own** receptions, i.e. the first hop. Deep-link quality isn't observable from here, so intermediate hops are chosen topologically (shortest + freshest + most-seen), not by signal. That's still far better than a blind guess or a manual build, and the first hop — the one most likely to matter — *is* SNR-optimised. ## 8. Trace UX (replaces the guess) Repeater / contact **Path Trace**: 1. Committed `contact.path` exists → trace it (today's `hasRoute`). 2. Else `inferRoute(target)`: - **Route found** → open the builder **pre-filled** with the suggestion (`Suggested: A → B → target`), one tap to trace or edit first. (Setting: auto-run vs always-confirm.) - **No route** → open the **empty** builder with an honest hint: *"No route learned yet — build one, or send it a flood ping / catch an advert to learn it."* 3. **Delete the strongest-repeater guess entirely.** The builder is always the transparent surface; inference just pre-fills it. ## 9. Persistence & bounding (storage is capped by design) - Persist the **graph** (deduped nodes + edges), never raw paths — raw paths are the only thing that would bloat. - **Freshness prune:** on load/save, drop nodes/edges whose `lastSeen` is older than `W` (default 7 days; configurable). Keeps the map to the *live* mesh, not all-time. - **Hard cap:** LRU by `lastSeen` at a fixed ceiling (default ~2,000 nodes / ~256 KB). Physically can't run away, even pathologically. - Store: `topology_store` keyed by the first 10 hex of the device pubkey, like the others. ## 10. Failure modes & honesty - **Stale route** (topology moved): the trace just times out like any wrong route; we stamp `lastFailed` on that route's edges so the next inference avoids it. Self-healing over time. - **Asymmetric / duty-cycled return leg** (firmware `#2`): inference can't fully solve this — a route good outbound may not return. Preferring recently- and frequently-observed links mitigates it; a later refinement can weight bidirectionally-seen links higher. - **Unobserved target:** never heard, directly or as an intermediate → no inference → manual builder. Expected and honest. ## 11. Why this is the differentiator The firmware *cannot* discover routes (no flood-trace; empty route = error 1). The reference clients push that cost onto the user (manual build). We turn traffic we're **already parsing** into a route oracle that **improves the longer the app runs** — auto-suggesting routes nobody else can. It degrades gracefully to the same manual builder everyone else has, so we're never worse and usually better. ## 12. Phased build (epic → issues, filed once this design is approved) | Phase | Deliverable | |---|---| | **A — design** | This spec. | | **B — core** | `MeshTopologyService` (graph, `observePath`, `inferRoute`) + unit tests (orientation, dedup, pathfind, bound). Wire ingestion into `_handleRxData`/advert/channel. No UI. | | **C — UX** | Trace launch uses `inferRoute` → pre-filled `PathSelectionDialog`; delete the guess. | | **D — persist + bound** | `topology_store`, freshness prune, LRU cap. | | **E — polish** | `lastFailed` confidence decay; optional on-map topology overlay; auto-run vs confirm setting. | Phases B–D are the working feature; A is done; E is gravy. ## 13. Decisions & open questions **Decided (Ben, 2026-07-05):** - **Separate services.** `PathHistoryService` stays the message-router weighter; `MeshTopologyService` is a distinct trace oracle that *reads* PathHistory's observations as one input — no merge. - **Confirm-first.** An inferred route **pre-fills the builder** and the user confirms/edits before it traces (transparent). Auto-run is a later opt-in setting, not the default. **Still open (tuning, not blocking):** - **Freshness window `W`** — 7 days is a starting guess; tune against real advert/traffic cadence (repeaters now advert ~12 h).