diff --git a/Meshflow/common/mc_channel_labels.py b/Meshflow/common/mc_channel_labels.py index 961dcc8..25bb81d 100644 --- a/Meshflow/common/mc_channel_labels.py +++ b/Meshflow/common/mc_channel_labels.py @@ -13,6 +13,22 @@ def _scope_suffix(channel: MessageChannel) -> str: return "" +def mc_channel_mirror_label(channel: MessageChannel) -> str: + """Name/tag for feeder mirror tables where region_scope has its own column.""" + if channel.mc_channel_type == MeshCoreChannelType.HASHTAG: + tag = (channel.name or "").strip().lstrip("#") + if tag: + return f"#{tag}" + name = (channel.name or "").strip() + if name: + return name + return str(channel.pk) + + +def mc_channel_scope_display(channel: MessageChannel) -> str: + return channel.region_scope or "—" + + def mc_channel_admin_label(channel: MessageChannel) -> str: """Human label for admin lists: #hashtag for HASHTAG, plain name for PUBLIC.""" if channel.mc_channel_type == MeshCoreChannelType.HASHTAG: diff --git a/Meshflow/common/tests/test_mc_channel_labels.py b/Meshflow/common/tests/test_mc_channel_labels.py index b5e4929..468c974 100644 --- a/Meshflow/common/tests/test_mc_channel_labels.py +++ b/Meshflow/common/tests/test_mc_channel_labels.py @@ -4,6 +4,8 @@ from common.mc_channel_labels import ( mc_channel_admin_label, + mc_channel_mirror_label, + mc_channel_scope_display, message_channel_to_apply_entry, ) from common.protocol import Protocol @@ -47,6 +49,22 @@ def test_mc_channel_admin_label_hashtag_with_scope(create_constellation): assert mc_channel_admin_label(ch) == "#galloway · sample-west" +@pytest.mark.django_db +def test_mc_channel_mirror_label_and_scope_display(create_constellation): + constellation = create_constellation() + ch = MessageChannel.objects.create( + name="galloway", + constellation=constellation, + protocol=Protocol.MESHCORE, + mc_channel_type=MeshCoreChannelType.HASHTAG, + region_scope="sample-west", + ) + assert mc_channel_mirror_label(ch) == "#galloway" + assert mc_channel_scope_display(ch) == "sample-west" + ch.region_scope = None + assert mc_channel_scope_display(ch) == "—" + + @pytest.mark.django_db def test_message_channel_to_apply_entry_hashtag(create_constellation): constellation = create_constellation() diff --git a/Meshflow/constellations/admin.py b/Meshflow/constellations/admin.py index 6af92ee..70ac405 100644 --- a/Meshflow/constellations/admin.py +++ b/Meshflow/constellations/admin.py @@ -8,6 +8,35 @@ from .models import Constellation, MeshCoreMessageChannel, MessageChannel +_SCOPE_FILTER_NONE = "__none__" +_SCOPE_FILTER_ANY = "__any__" + + +class MeshCoreRegionScopeFilter(admin.SimpleListFilter): + """Filter MeshCore channels by region_scope (null, any, or a specific scope).""" + + title = _("Region scope") + parameter_name = "region_scope" + + def lookups(self, request, model_admin): + choices = [ + (_SCOPE_FILTER_NONE, _("No scope")), + (_SCOPE_FILTER_ANY, _("Has scope")), + ] + qs = model_admin.get_queryset(request).filter(region_scope__isnull=False) + scopes = qs.values_list("region_scope", flat=True).distinct().order_by("region_scope") + choices.extend((scope, scope) for scope in scopes if scope) + return choices + + def queryset(self, request, queryset): + if self.value() == _SCOPE_FILTER_NONE: + return queryset.filter(region_scope__isnull=True) + if self.value() == _SCOPE_FILTER_ANY: + return queryset.filter(region_scope__isnull=False) + if self.value(): + return queryset.filter(region_scope=self.value()) + return queryset + class ConstellationAdminForm(forms.ModelForm): class Meta: @@ -109,11 +138,13 @@ class MeshCoreMessageChannelAdmin(admin.ModelAdmin): list_display = ( "admin_label", "mc_channel_type_display", + "region_scope_display", "constellation", "id", ) list_filter = ( ("mc_channel_type", admin.ChoicesFieldListFilter), + MeshCoreRegionScopeFilter, "constellation", ) search_fields = ("name", "region_scope", "constellation__name") @@ -147,3 +178,7 @@ def admin_label(self, obj): @admin.display(description=_("Type"), ordering="mc_channel_type") def mc_channel_type_display(self, obj): return mc_channel_type_name(obj) + + @admin.display(description=_("Scope"), ordering="region_scope") + def region_scope_display(self, obj): + return obj.region_scope or "—" diff --git a/Meshflow/nodes/admin.py b/Meshflow/nodes/admin.py index 966d6ea..32aae6e 100644 --- a/Meshflow/nodes/admin.py +++ b/Meshflow/nodes/admin.py @@ -11,7 +11,8 @@ from common.mc_channel_labels import ( managed_node_mc_channel_links, managed_node_mc_channels_queryset, - mc_channel_admin_label, + mc_channel_mirror_label, + mc_channel_scope_display, mc_channel_type_name, ) from common.mesh_node_helpers import ( @@ -572,20 +573,22 @@ def mc_channels_mirror(self, obj): ) row_html = format_html_join( "", - "{}{}{}", + "{}{}{}{}", ( ( link.mc_channel_idx, mc_channel_type_name(link.message_channel), - mc_channel_admin_label(link.message_channel), + mc_channel_scope_display(link.message_channel), + mc_channel_mirror_label(link.message_channel), ) for link in links ), ) return format_html( - "{}
{}{}{}
", + "{}
{}{}{}{}
", _("Slot"), _("Type"), + _("Scope"), _("Label"), row_html, ) diff --git a/docs/features/meshcore/mc-channel-sync/README.md b/docs/features/meshcore/mc-channel-sync/README.md index 1f5e0d1..9f8ff19 100644 --- a/docs/features/meshcore/mc-channel-sync/README.md +++ b/docs/features/meshcore/mc-channel-sync/README.md @@ -30,6 +30,7 @@ This feature is **configuration only** — it does not upload mesh traffic. Pack | [region-scope.md](region-scope.md) | Region scope semantics, API field, bot/UI behaviour | | [region-scope-progress.md](region-scope-progress.md) | Execution log ([#391](https://github.com/pskillen/meshflow-api/issues/391)) | | [region-scope-outstanding.md](region-scope-outstanding.md) | Protocol gaps and follow-ups | +| [bug-channel-scope-read-write.md](bug-channel-scope-read-write.md) | Investigation: scoped channels not on radio after bot apply ([#129](https://github.com/pskillen/meshflow-bot/issues/129)) | **Related (other features)** diff --git a/docs/features/meshcore/mc-channel-sync/bug-channel-scope-read-write.md b/docs/features/meshcore/mc-channel-sync/bug-channel-scope-read-write.md new file mode 100644 index 0000000..da04682 --- /dev/null +++ b/docs/features/meshcore/mc-channel-sync/bug-channel-scope-read-write.md @@ -0,0 +1,102 @@ +# Bug: Scoped MC channels not persisted on physical radio + +**Status:** Investigating +**Tracking:** [meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129) +**Related:** [region-scope.md](./region-scope.md), [region-scope-outstanding.md](./region-scope-outstanding.md), [#391](https://github.com/pskillen/meshflow-api/issues/391) + +## Symptom + +Operator configures **multiple MeshCore channels with distinct `region_scope` values** in Meshflow (UI or admin), pushes config to the feeder via the bot (**apply to radio** / admin **Push MC channel config**). + +After apply: + +1. Disconnect the radio from the bot (USB). +2. Connect the same device to the **MeshCore Android companion** over USB. +3. In companion channel settings, the radio shows **only one scope** configured (e.g. `sco`, default) and **every channel slot uses that default scope** — not the per-channel scopes from Meshflow. + +Meshflow API / UI mirror may still show the intended scopes (bot merges scope from apply payload into `mc-channel-sync` when `CHANNEL_INFO` omits scope). **The failure is on the device**, not only in API bookkeeping. + +## Expected vs observed + +| Layer | Expected (operator) | Observed | +| --- | --- | --- | +| Meshflow catalog / mirror | Each canonical channel has its `region_scope` | OK (when apply + sync path runs) | +| Companion per-channel scope | Each slot has the scope assigned in Meshflow | All slots show **default** scope | +| Companion global / flood scope list | Scopes used on device exist | Only **one** scope (e.g. default) | +| Bot connect sync (`get_channel`) | Readback reflects per-slot scope where Android shows it | **Names only**; all `region_scope` null on wire → API mirror loses scope | + +## Current bot apply path (code) + +**Entry:** WebSocket `apply_mc_channel_config` → `MeshflowBot.on_apply_mc_channel_config` → `apply_device_channels` (`meshflow-bot/src/meshcore/channels.py`). + +For each snapshot row: + +1. **`set_channel(mc_channel_idx, name)`** — writes **channel name only** (hashtag gets `#` prefix). **No `region_scope` argument** on this call. +2. **`set_flood_scope(scope_arg)`** — called immediately after each successful `set_channel`. `scope_arg` is the row’s `region_scope` or `"*"` when null (`_apply_active_flood_scope`). + +Documented limitation ([region-scope.md](./region-scope.md)): `set_flood_scope` sets the companion **active flood scope** (global operator scope for sending), **not** a per-slot field returned by `CHANNEL_INFO`. The apply loop therefore calls `set_flood_scope` once per row; **only the last row’s scope remains active** on the radio until the operator changes channel in the app. + +**Read path:** `get_channel` / `CHANNEL_INFO` returns **name + secret only** (no per-channel scope). Post-apply sync uses `merge_channel_region_scopes(..., scope_hints=applied_channels)` so the **API** snapshot includes intent scopes without reading them from firmware. + +## Hypotheses (ranked) + +| # | Hypothesis | If true | +| --- | --- | --- | +| H1 | **No wire API used for per-channel scope on SET_CHANNEL** — bot only sets names; firmware stores per-slot scope via a different command we do not call | Android per-slot UI stays default; fix needs meshcore_py + bot change | +| H2 | **`set_flood_scope` is global only** — it does not write per-channel scope into NVS; Android “per channel scope” is a separate setting | Explains one global scope + all channels default in slot UI | +| H3 | **`set_flood_scope` missing or failing** on feeder (`meshcore.commands.set_flood_scope` absent or ERROR) | Bot logs WARNING; device never leaves default scope | +| H4 | **Wrong scope value on wire** — normalization, `*` for null, or ordering (scope before `set_channel`) | Check apply logs and payload hex | +| H5 | **Firmware / companion version** — older build ignores scope writes | Repro depends on device firmware; compare with manual scope set in Android | +| H6 | **Scopes must exist in companion region list before per-channel bind** — `set_flood_scope` only sets active TX key; Android lists regions separately | `ioi`/`gla` never registered; all slots show default `sco` | +| H7 | **Apply is partial** — only listed slots written; stale slots 4–8 remain | Duplicate `#glasgow` in slots 3 and 6 after 5-channel push → [meshflow-bot#130](https://github.com/pskillen/meshflow-bot/issues/130) | + +## Verification checklist + +- [x] **Bot logs during apply** (docker / local): search for + `set_flood_scope unavailable`, `set_flood_scope(%r) failed`, `active flood scope=`, `set_channel(%s) failed` +- [ ] **Apply payload** — confirm each WS/API entry includes `region_scope` (`message_channel_to_apply_entry` in `Meshflow/common/mc_channel_labels.py`) +- [ ] **`meshcore` package version** on feeder (`requirements.txt` currently `>=2.3.7,<3.0.0`) and whether `Commands.set_flood_scope` exists +- [ ] **meshcore_py / firmware docs** — command to set **per-channel** region scope vs `CMD_SET_FLOOD_SCOPE` +- [x] **After apply, before unplug** — bot `log_device_channels` lines (scope from **scope_hints**, not device truth) +- [x] **Android companion** — manual slot scope on device; bot read does not see it (see investigation log 2026-06-04 connect read) +- [x] **API mirror after connect sync** — Admin UI: channels created, **no** `region_scope` (confirms read path → reconcile gap) +- [ ] **Optional:** serial trace or meshcore_py REPL `get_channel(i)` raw payload after apply + +## Investigation log + +| Date | Who | Action / result | +| --- | --- | --- | +| 2026-06-04 | Operator | Reported repro: apply scoped channels via bot → unplug → Android USB → one scope (`sco`/default), all channels default scope. | +| 2026-06-04 | Agent | Documented apply path (`set_channel` + `set_flood_scope` only); aligned with [region-scope-outstanding.md](./region-scope-outstanding.md) open item “confirm per-channel vs global on hardware”. **No device trace yet.** | +| 2026-06-04 | Agent | Filed [meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129). | +| 2026-06-04 | Operator | **Android 1.44.0, firmware 1.15.0.** Apply 5 channels: Public→`sco`, #scotland→`sco`, #norniron→`ioi`, #glasgow→`gla`, #test→null. Bot logs: `set_flood_scope` OK for slots 0–3 (no INFO line for null scope on #test). Post-apply sync shows scopes 0–3 from **scope_hints** only; slots 4–8 unchanged on device (`test` at 4, duplicate `#glasgow` at 3+6). Android after storage clear: **no scopes in node list**; **all channels default `sco`**. Confirms H2/H3 rejected (commands succeed); supports H6 (regions `ioi`/`gla` not on device list; `set_flood_scope` ≠ per-channel bind). | +| 2026-06-04 | Operator | **Connect read (no apply).** Configured on Android only: `#test-gla` (explicit **gla**), `#test-sco` and `#test-unset` (both unset in UI → default **sco**). USB to bot → connect sync logged 3 channels, **all** `region_scope=(none)`. **Admin UI:** three canonical channels created correctly, **none** have `region_scope`. Confirms `CHANNEL_INFO` / `get_channel` cannot read per-slot scope; connect sync cannot distinguish gla vs default-sco; API mirror is misled unless `enrich_snapshot_region_scope` matches prior links by name only. Strengthens H1/H2. | + +## Fixes to apply + +Tracked work to reduce confusion while the protocol gap ([#129](https://github.com/pskillen/meshflow-bot/issues/129)) is resolved. + +| Item | Repo | Description | +| --- | --- | --- | +| [meshflow-bot#130](https://github.com/pskillen/meshflow-bot/issues/130) | meshflow-bot | **Clear unlisted channel slots** when applying MC channel config so stale slots (duplicate hashtags, old names) are removed from the device. | +| Apply verify logging | meshflow-bot | On **apply** (`apply_device_channels` or caller): (1) log **desired** config from the apply payload (per `mc_channel_idx`: name, type, `region_scope`). (2) **Read back** from device via `read_device_channels` **without** `scope_hints`. (3) log **readback** config with clear labels (`DESIRED` vs `READBACK`). (4) **WARNING** on mismatch (slot index, name, and `region_scope` when present on read path). Expect scope mismatches until firmware exposes scope in `CHANNEL_INFO`; names/indices should match after #130 + scope fix. | + +## Next steps + +1. ~~Capture **one full apply** log line set~~ — done (see investigation log 2026-06-04 operator). +2. Spike **meshcore_py + firmware 1.15**: `set_default_flood_scope` (registers default + auto-creates region per [default-scope blog](https://blog.meshcore.io/2026/04/17/default-scope)) vs command for **per-channel** scope bind (Android issue [#1575](https://github.com/meshcore-dev/MeshCore/issues/1575): region must be created then activated per channel in app). +3. ~~Decide whether apply should **clear unlisted slots**~~ → [meshflow-bot#130](https://github.com/pskillen/meshflow-bot/issues/130). +4. If only global scope exists today, update [region-scope.md](./region-scope.md) operator docs and UI copy so we do not imply per-slot persistence until firmware supports it. +5. ~~File tracking issue~~ → [meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129). +6. Implement **apply verify logging** (see [Fixes to apply](#fixes-to-apply)) to stop treating post-apply `scope_hints` merge as device truth. + +## Related code + +| Repo | Path | +| --- | --- | +| meshflow-bot | `src/meshcore/channels.py` — `apply_device_channels`, `_apply_active_flood_scope`, `read_device_channels` | +| meshflow-bot | `src/bot.py` — `on_apply_mc_channel_config`, `scope_hints` on post-apply sync | +| meshflow-api | `Meshflow/meshcore_packets/services/channel_apply.py` — `build_apply_channels_for_managed_node` | +| meshflow-api | `Meshflow/common/mc_channel_labels.py` — `message_channel_to_apply_entry` (`region_scope` in payload) | + +**External:** [Region filtering](https://blog.meshcore.io/2026/01/20/region-filtering) · [Default scope](https://blog.meshcore.io/2026/04/17/default-scope) diff --git a/docs/features/meshcore/mc-channel-sync/region-scope-outstanding.md b/docs/features/meshcore/mc-channel-sync/region-scope-outstanding.md index 224969f..301e282 100644 --- a/docs/features/meshcore/mc-channel-sync/region-scope-outstanding.md +++ b/docs/features/meshcore/mc-channel-sync/region-scope-outstanding.md @@ -8,7 +8,8 @@ Items **skipped**, **incomplete**, or **discovered during execution** for [#391] ## Protocol / device +- [ ] **Active investigation:** [bug-channel-scope-read-write.md](./bug-channel-scope-read-write.md) — bot apply does not leave per-channel scopes on device ([meshflow-bot#129](https://github.com/pskillen/meshflow-bot/issues/129)) - [ ] Per-channel `region_scope` in companion `CHANNEL_INFO` — not on wire today; bot may sync `null` until meshcore_py/firmware expose it -- [ ] Apply path: confirm whether `set_flood_scope` is per-channel or global on hardware (spike on real feeder) +- [ ] Apply path: confirm whether `set_flood_scope` is per-channel or global on hardware (spike on real feeder) — see bug log verification checklist - [x] Bot merges `region_scope` from apply payload into post-apply sync (CHANNEL_INFO does not return scope) - [ ] Per-channel scope in `CHANNEL_INFO` / extended `SET_CHANNEL` — firmware still name+secret only; active scope is `set_flood_scope` (global) per slot during apply diff --git a/docs/features/meshcore/packet-path-tracing/README.md b/docs/features/meshcore/packet-path-tracing/README.md index 17c9d72..a331082 100644 --- a/docs/features/meshcore/packet-path-tracing/README.md +++ b/docs/features/meshcore/packet-path-tracing/README.md @@ -15,6 +15,7 @@ It is intentionally separate from the Meshtastic `traceroute` subsystem: - [Progress](./packet-path-tracing-progress.md) — what has shipped / is in flight - [Outstanding](./packet-path-tracing-outstanding.md) — open decisions and discovered debt +- [Bug: no path info](./bug-no-path-info.md) — investigation when `heard[]` / path hashes are empty ([#385](https://github.com/pskillen/meshflow-api/issues/385)) ## Related docs diff --git a/docs/features/meshcore/packet-path-tracing/bug-no-path-info.md b/docs/features/meshcore/packet-path-tracing/bug-no-path-info.md new file mode 100644 index 0000000..741ffa8 --- /dev/null +++ b/docs/features/meshcore/packet-path-tracing/bug-no-path-info.md @@ -0,0 +1,97 @@ +# Bug: MeshCore channel text often has no path in heard / DB + +**Status:** Investigating (pre-prod verified 2026-06-03) +**Tracking:** [meshflow-api#385](https://github.com/pskillen/meshflow-api/issues/385) (Tier 1 twin), epic [#267](https://github.com/pskillen/meshflow-api/issues/267) +**Related:** [packet-path-tracing-outstanding.md](./packet-path-tracing-outstanding.md), [tier-1-message-path-twin.md](./tier-1-message-path-twin.md) + +## Symptom + +MeshCore public / hashtag **text messages are stored**, but **>90%** show **no hop path** in the UI “Heard” map and in API `heard[]` (`path_hashes` empty on the `original_mc_packet` observation). + +Operators expect path hashes to ride with (or immediately follow) the same on-air message. + +## Prior agent / design context + +| Source | What we already knew | +| --- | --- | +| [packet-path-tracing-outstanding.md](./packet-path-tracing-outstanding.md) § Message path data chain | `channel_message` decode has `path_len` / mode but usually **no `path` hex**; path on wire is often on **`rx_log_data` PATH/TEXT_MSG**; bot historically uploaded **ADVERT only**. | +| [tier-1-message-path-twin.md](./tier-1-message-path-twin.md) | **Shipped approach:** thin bot uploads TEXT_MSG/PATH `rx_log_data`; API **twin-merge** copies `path_hashes` onto `channel_text` within **120s** (`MESHCORE_DECODED_TWIN_WINDOW_SECONDS`). | +| PR [#390](https://github.com/pskillen/meshflow-api/pull/390) (`api-388`) | Implemented `path_twin.py`, `path_hashes` ingest, heard tests — merged to `main`. | +| Agent [meta workspace consolidation](c074c495-8f39-4d1d-b755-e81567cd29ad) | Confirmed tier-1 docs on `main`; branch `api-388/pskillen/mc-path-twin-and-resolution`. | + +**Conclusion from this session:** Tier-1 **code path works when a PATH/TEXT_MSG twin exists in-window**; pre-prod failure rate is dominated by **missing or mis-timed rx_log twins**, not broken heard assembly. + +## Pre-prod sample messages (2026-06-03) + +Compared four `TextMessage` rows (`protocol = MeshCore`) on the single feeder: + +| Message text (substring) | `sent_at` (UTC) | `path_hashes` on text packet obs | `rx_log` twin in ±120s | +| --- | --- | --- | --- | +| `GM1DSV: Or is it Blazing Saddles??` | 07:59:12 | **Yes** `['e1317c','4cd741','f3bcf1']` | **PATH** @ 07:58:48 (−24s) | +| `Rogue Two: Beans 🤦‍♂️` | 07:59:59 | **Yes** (same path) | **PATH** @ 07:58:48 (−71s) | +| `Rogue Two: 😂` | 07:50:48 | **No** | **None** | +| `MCEK 4: @[Rogue Two]: Lol` | 07:50:58 | **No** | **None** | + +**7-day aggregate (pre-prod):** 22 / 533 MeshCore `TextMessage` rows with `path_hashes` length ≥ 2 → **4.1%** success rate (hourly buckets 0–33%). + +### Same 20-minute window (07:45–08:05 UTC) + +| Ingest type | Count | +| --- | --- | +| `channel_text` packets | 8 | +| `rx_log_data` **PATH** or **TEXT_MSG** (`payload_type=raw`) | **2** (one TEXT_MSG @ 07:47:28, one PATH @ 07:58:48) | + +So most channel messages **never get a correlatable PATH/TEXT_MSG upload** on that feeder in the twin window. + +### Extra detail on the lone TEXT_MSG @ 07:47:28 + +- Observation has **8** path segments (path was captured on the raw row). +- Stored `raw_json` has **no `text`** and **no `channel_idx`** → API content-key match to `channel_text` **cannot run** (`_content_key_for_raw` returns `None`). +- `channel_text` for `Rogue Two: 😂` is **200s later** → **outside** the 120s twin window anyway. + +## Root cause (current hypothesis) + +```text +channel_message → channel_text → TextMessage.original_mc_packet + ↓ +heard[] reads obs.path_hashes on THAT packet only + +PATH/TEXT_MSG rx_log_data (separate upload) ──twin merge (120s, same feeder)──► copy path onto channel_text obs +``` + +Failures are **not** because: + +- Heard API ignores path (covered by `text_messages/tests/test_heard_api.py` + tier-1 tests). +- `channel_text` rows never arrive. + +Failures **are** because: + +1. **Sparse PATH/TEXT_MSG capture** — bot uploads those typenames, but pre-prod shows ~2 raw path frames vs 8 channel texts in 20 minutes (radio/library may not emit `rx_log_data` for every message, or frames are dropped before upload). +2. **Twin window** — default **120s**; PATH for the success pair arrived 24–71s **before** text; the 07:47 TEXT_MSG was **200s** before the 07:50 failures. +3. **Weak correlation keys on TEXT_MSG raw** — no text / channel_idx in uploaded envelope → merge falls back to time/channel heuristics or skips when ambiguous. + +`channel_message` payloads still typically lack inline `path` hex (see outstanding doc); twin merge is the intended production path. + +## Code pointers + +| Layer | Location | +| --- | --- | +| Bot upload filter | `meshflow-bot` `src/meshcore/serializers.py` — `TEXT_MSG` / `PATH` → `payload_type: raw` | +| Twin merge | `Meshflow/meshcore_packets/services/path_twin.py` | +| Ingest hook | `Meshflow/meshcore_packets/serializers.py` `_run_path_twin_sync` | +| Heard output | `Meshflow/text_messages/serializers.py` | + +## Open questions / next steps + +- [ ] **Feeder / bot:** confirm MeshCore build emits `rx_log_data` PATH (or TEXT_MSG with path) for *every* channel message the feeder hears; compare with on-disk `data/meshcore_packets/` dumps for fail vs success times. +- [ ] **Correlation:** can `pkt_hash` (or wire dedup fields) link PATH to `channel_text` more reliably than time + optional text? (ADR-0004 / #276) +- [ ] **TEXT_MSG envelope:** ensure bot forwards `channel_idx` + decoded text (if present on wire) so content-key twin matching works when multiple messages fall in one window. +- [ ] **Window / ordering:** measure distribution of Δ(rx_time) between PATH and `channel_text` on pre-prod; consider bounded async merge (e.g. delayed job) if PATH often follows text by >120s. +- [ ] **Metrics:** add pre-prod-friendly counts: `channel_text` with/without merged path per day; `raw` PATH/TEXT_MSG per day — re-run queries after bot/deploy changes. + +## Investigation log + +| Date | Action | Result | +| --- | --- | --- | +| 2026-06-03 | Pre-prod DB via Django + `Meshflow/ai-env` | Sample table + 4.1% / 7d rate; fail cases = no in-window PATH twin | +| 2026-06-03 | This doc created | Baseline for continued debugging |