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 |