Files changed: engine/lint-report.md vault/.obsidian/workspace.json vault/projects/meshai-config-hot-apply.md vault/projects/meshai-region-routing-plan.md
13 KiB
| title | type | tags | related | updated | status | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| meshai Region × Family Routing — Implementation Plan | project |
|
|
2026-07-07 | proposed |
meshai — Universal Region × Family Routing: Final Implementation Plan
Produced by a local multi-agent ultraplan (3 planners → 3 judges → synthesize → 3 red-team critics → finalize), verified against prod code at commit ceb95fb.
Scope principle (Matt, 2026-07-07): build the PLUMBING, not automation. meshai exposes the mechanism — region-tagged events + a compact
region_routesmatrix the dispatcher honors + a plain editor to set it. Matt does all configuration by hand: defines the region boxes, provisions the radio channels, fills each family×region→channel cell himself. meshai auto-creates nothing — no channels, no regions, no routes, no generated rule objects. "No sprawl" comes from the config being ONE compact object he edits, not from automation.
1. Verdict
GO, phased. Achievable, mostly config/GUI once the keystone code gap is closed: nothing currently writes event.region / event.regions (notifications/events.py:55-56 define them, the dispatcher reads them, zero writers exist). Close that and the existing region-scope machinery comes alive.
Three non-negotiables baked in:
- Matrix routing must resolve ABOVE the toggle
min_severityfloor. Satpass + most road/511 events areroutine; the toggle floor defaults topriority(config.py:616) and runs before delivery-plan build. Left naive, "Magic Valley Ham Radio" and road routing silently do nothing. - Meshtastic has a hard 8-channel ceiling (index 0–7); MeshCore routes by unbounded channel name. A 3×3 matrix is 9+ cells → the transports are asymmetric. Confront with an explicit MT-coarse / MC-fine policy.
- Everything ships behind default-inert flags on the GUARDED toggle path, never the unguarded rules path.
Effort: P1 = S (~½ day), P2 = S (~½ day), P3 = M (~2–3 days). P1 deploys dark, safe to soak indefinitely.
2. Keystone: region tagging (P1)
Region source = config.coverage.areas[].name (named bboxes). Reject mesh_intelligence.regions/RegionAnchor (centroid+radius, wrong for polygon events; its taggers are dead code).
config.py:980addCoverage.region_tagging: bool = False.coverageis restart-required (config_routes.py:29-40) — flip needs a container restart.coverage_area.py: addMonitoringArea.name: str|None=None;areas_from_configkeeps the name; refactor the embedded geometry-extraction (:193-209) into a shared_event_geom_json; add an additivematching_area_names()(match-all, no early return — leavesclassify_geom_areasgate byte-identical) +event_region_names().coverage_filter.py: addregion_taggingctor flag; tag before the gate and only whennot event.regions(never clobber satpass); wire inbuild_pipeline(pipeline/__init__.py:121).- Contract:
regions= all intersecting named areas (config order, deduped);region = regions[0](deterministic, keeps cooldown key stable); 0 matches / no geom / parse error ⇒None/[]⇒ identical to today.
3. Routing model: new compact primitive on the guarded toggle path
Add notifications.region_routes (a RegionRouteMatrix: enabled: bool=False, cells: dict = family→region→{mt, mc, min_severity}) and resolve it inside _dispatch_toggles above the severity/region gates.
- Reject generated per-cell rules: the rules path is fully unguarded (no cold-start/staleness/dedup;
rule.cooldown_minutesis dead code) → routing high-frequency families through it floods. It's also the O(R×F) object pile Matt wants gone. - Schema must be declared ABOVE
NotificationsConfig(nofrom __future__ import annotations→ nested-dataclass coercion fires at class-def; elseNameError). Add an explicit hydration branch mirroringdestinations(config.py:1149). - Dispatcher matrix branch (authoritative-on-match): resolves per event region → cell; per-cell
min_severity(defaultroutine) is the only floor; bypassestoggle.regionsandtoggle.min_severity(this is what lets routine families route); collapses same-channel overlap to one send but arms cooldown for every contributing region;_toggle_to_rulegains optionalbroadcast_channel/meshcore_channeloverrides._audit_routeunchanged (onemesh_broadcasts_outrow per transport). - Cooldown/dedup (matrix branch only; non-matrix path byte-identical): per-region cooldown key
(toggle, category, region); per-(source, id, transport, channel)dedup (preserves retry when one region's channel fails + keeps fire growth stages flowing per channel). Honest caveat:toggle.cooldown_secondsdefaults to0→ out of the box matrix flood protection is dedup-only; rollout requires setting non-zero cooldown on high-frequency families (roads/511/wzdx). Keepfirecooldown=0 (dedup + growth suffix govern it).
4. Manual matrix editor (P3) — plumbing Matt fills in, no generation
This is a plain editor over the region_routes primitive, NOT a generator. It reads the current matrix, lets Matt assign cells by hand, and saves exactly what he entered. meshai computes/creates nothing — no rules synthesized, no channels/regions invented.
- Backend (
dashboard/api/notification_routes.py):GET /api/notifications/regions(lists the region names that exist — coverage area names + satpass observer labels — so the picker offers what Matt already defined; the single routing region source; repoint pickers off the mesh-health/api/regions). MUST read the SAVED (on-disk) coverage, not the in-memory config —coverageis restart-required, so the running config lags disk until restart; but adding/removing a routing column must NOT require a bot restart. Reading disk decouples the routing-UI region list (dynamic, instant) from the engine's coverage re-scoping (still restart-gated). Confirmed in preview: create/delete a region on the Coverage page → it appears/disappears in the matrix immediately, no restart;GET /api/notifications/region-routing(returns the matrix verbatim);POST /api/notifications/region-routing(plain save via explicit server-side read-modify-write — persists only what Matt entered; RMW because the section save rebuilds all ofNotificationsConfigand would wipe toggles/rules/destinations otherwise; golden round-trip test is a release gate); optional read-only preview (shows which categories feed each family + the distinct MT-index count vs the 8 ceiling, so Matt sees what he still has to provision). - Frontend (UNVERIFIED —
dashboard-frontendnot in the code checkout; re-verifyChannelPicker/useFamilies/Notifications.tsxfirst):RegionRoutingMatrix.tsxtab — rows=families (fromuseFamilies), cols=regions (from/api/notifications/regions), each cell = MT ChannelPicker + MC ChannelPicker +min_severity(default routine) + enable; sparse persistence (only cells Matt fills). Optional manual convenience fills (apply-down-a-region, copy-a-region) are just clipboard helpers Matt chooses to click — never automatic. Live MT-budget guard (warn at 8 distinct MT indices).
5. MC + MT per cell + the Meshtastic 8-channel ceiling
Each cell carries both mt (index) and mc (name) in one object; dual transport = one cell. MT has a hard 8-slot firmware ceiling; MC names are unbounded. Matt's target (Roads/Fires/Ham × SC-ID/SW-ID/East-ID = 9+ cells) can't get distinct MT channels.
CHOSEN POLICY (Matt, 2026-07-07): MT axis = per-REGION "Alerts" channels; MC axis = fine per-region×family names. On Meshtastic, one catch-all channel per region — SWI Alerts, SCI Alerts, EI Alerts (+ Magic Valley) — each carrying ALL families for that region, families distinguished by the existing emoji/text prefix (🔥/🚧/🛰️). On MeshCore, keep the full region×family split by name. Keeps region separation on MT (what Matt wants), gives up only per-family separation on MT (MeshCore retains it). Budget: 3–4 region channels + primary + admin, comfortably under 8. Zero extra code — it's just how the MT column is filled: set each region's MT index once and use the "apply down the region" fill so every family in that region → that region's Alerts index; the mc side stays per-cell.
Channel provisioning is NOT eliminated — meshai only routes to channels; every named channel (the 3–4 MT region Alerts channels + each MC region×family name) must still be hand-created on the Meshtastic device + MeshCore companion and subscribers pointed at it. The matrix kills the rule-object sprawl, not radio-side setup; the preview endpoint lists exactly what to provision.
6. Satpass special-case
Satpass has no geometry → tag it upstream so the never-clobber rule protects it. Refactor env/satpass.py::to_event to bind-then-return, set regions=[entry_observer, exit_observer] (ordered, deduped; friendly labels already at satpass.py:164-165). Add "satpass" to VALID_TOGGLES (categories.py:26) — hard prerequisite: categories_for_toggle("satpass") returns [] today only because satpass isn't in the toggle set. Observer friendly names must exactly match coverage.areas[].name to line columns up (e.g. "Magic Valley"); unmatched labels render their own column, never dropped. Routine severity routes fine because the matrix resolves above the floor.
7. Migration & back-compat
No migration; three default-inert levers (MonitoringArea.name=None, region_tagging=False, RegionRouteMatrix.enabled=False). "Byte-identical" holds only while region_tagging=False. Flipping it on activates two behaviors with no code change:
- Dormant region-scope activation — every existing non-empty
toggle.regions/rule.region_scopeis dead today and activates. Rules path is unguarded → activated rules broadcast every sweep, replay backlog on restart. - Cooldown cardinality shift — the non-matrix cooldown key goes from global
"*"to per-region → more broadcasts for multi-region feeds on any toggle withcooldown_seconds>0.
Mandatory pre-flip audit (release gate): dump prod config; list/disable/re-point every non-empty rule.region_scope (unguarded) and review every toggle.regions; ensure no matrix-routed family is also routed by an enabled rule (exclusivity); confirm per-region cooldown splits are acceptable. This is why P1 ships dark and soaks.
8. Test + safe rollout on CT108
Unit: matching_area_names (in-one/overlap-ordered/on-edge/outside/null/unnamed), shared geom helper, coverage_filter tag+gate + off-parity + satpass-preserve, RegionRouteMatrix round-trip golden, region-routing-save golden (toggles/rules/destinations survive), dispatcher matrix cases (routine routes, overlap one-send-both-cooldowns, per-region cooldown independence, per-channel dedup retry, authoritative-on-match, no-cell fall-through, cold-start/staleness still drop). Note: test harness builds NO CoverageFilter — matrix tests set event.region/regions manually.
Live (pct exec 108 from root@utility, compose in /home/zvx/meshai/work/): (1) deploy P1 dark + restart → zero change; (2) run pre-flip audit; (3) flip region_tagging=True + restart, matrix disabled → confirm regions populate, soak; (4) enable matrix, ONE cell (roads × SC-ID → throwaway channel, enabled=false first, cooldown>0) → confirm one landing on both + cooldown_dropped increments; (5) point real cells one family at a time. Keep fire cooldown=0.
9. Phases
| Phase | Effort | Ships |
|---|---|---|
| P1 keystone tagging (dark) | S ~½d | events carry region/regions; dormant scopes usable; zero routing change; safe soak |
| P2 region source + one cell e2e (guarded) | S ~½d | /api/notifications/regions + repoint pickers; satpass in VALID_TOGGLES + observer tagging; prove one family×region via toggle.destinations on a throwaway channel |
| P3 compact primitive + manual matrix editor | M ~2–3d | Matt fills the grid himself (dual MT+MC per cell); one compact object, no rule sprawl, nothing auto-created |
10. Open decisions for Matt
- Meshtastic 8-channel policy — DECIDED: MT = per-region Alerts channels (SWI/SCI/EI [+ Magic Valley]), each carrying all families for its region; MC = fine per-region×family. Zero extra code (MT-column fill).
- Matrix severity semantics — accept cells route all events ≥ cell
min_severity(default routine) to both transports, bypassing per-severityseverity_channels(chattier, but makes routine routing work). - Cooldown posture — non-zero on roads/511/wzdx before go-live; fire stays 0.
- Observer naming — satpass observers named to match coverage area names.
Files: config.py, coverage_area.py, notifications/pipeline/coverage_filter.py, notifications/pipeline/__init__.py:121, notifications/pipeline/dispatcher.py, notifications/categories.py:26, env/satpass.py, dashboard/api/notification_routes.py, dashboard/api/mesh_routes.py:254, dashboard-frontend/src/pages/RegionRoutingMatrix.tsx (new, unverified repo).