Skip to content

Agent reference: EMCOMM (emergency communications)

Deep subsystem reference for AI assistants. Open when a task touches Incident Command, the emergency outbox, MECP send reliability, ACK/beacon, ops alerts, EMCOMM exports, SAR map tools, or track retention. MECP wire format, siren alerts, audit log, and RF rebroadcast live in mecp.md. Hard rules live in AGENTS.md.

Safety invariants (S1–S16)

Keep this list in sync with the header comment in emcommSafety.contract.test.ts. Every invariant has a source contract and/or behavioral test today; do not weaken one without updating both the test and this table.

ID Invariant Status / coverage
S1 MECP compose send path uses the emergency outbox (not bare handleSendChunk only) Enforced — contract + emergencySend.test.ts
S2 Emergency rows ignore the 24h age / 5-attempt stop; the soft row cap blocks (never deletes) active distress rows Enforced — contract + useChatOutbox.test.ts
S3 Offline / send-fail MAYDAY still enqueues and drains on reconnect Enforced — emergencySend.test.ts (enqueue paths) + useChatOutbox.test.ts (emergency drain)
S4 Watcher hydration seeds without alert/audit Enforced — useMecpAlertWatcher.test.tsx (hydrate seed + incident upsert without alert/audit)
S5 Sev 0/1 bypass mute; drills never alert Enforced — mecpAlert.test.ts
S6 Cross-protocol rebroadcast does not duplicate incidents Enforced — contract (fingerprint omits protocol) + incidentStore.test.ts
S7 B02/B03 do not open new incidents; B02 is not the general ACK compose Enforced — contract + incidentStore.test.ts, mecpAck.test.ts, useMecpAlertWatcher.test.tsx
S8 Incident tab lazy export is mounted in App Enforced — contract
S9 Tab badge counts open sev 0/1 only, drills included (incidentTabBadgeCount) Enforced — contract + incidentStore.test.ts (drills counted) + AppRail.test.tsx, navBadges.test.ts
S10 Manual disconnect does not fire link-down / does not cancel emergency outbox retries Enforced — useOperationalAlerts.test.ts, operationalAlerts.test.ts
S11 Link-down suppressed while RF reconnect is in progress Enforced — useOperationalAlerts.test.ts, operationalAlerts.test.ts
S12 Incident-open nodes exempt from position_history prune Enforced — position-history-prune.test.ts + useAppStartupDbPrune.test.ts
S13 USGS/topo tiles only via allowlisted hosts; no user URL template Enforced — contract + basemapRegistry.test.ts
S14 mecpComposeEnabled default remains false Enforced — contract + source-policy rule emcomm-mecp-compose-default-off
S15 Incident ACKs queue as normal priority; recordAck only on live 'sent' (drain tags ackIncident: viewKey) Enforced — contract + App handleIncidentAck + applyIncidentAckAfterOutboxSend
S16 App mounts emergency+ACK outbox drain once for all protocols Enforced — contract + useEmergencyOutboxDrain.test.ts

WS1 — Incident tab, store, watcher hydrate upsert

  • Types: incidentTypes.ts — EmergencyIncident (status: 'open' | 'acked' | 'resolved', protocolsSeen, ackPeerIds/ackCount, beaconActive/beaconAcked, lat/lon + coordsSource: 'message' | 'lastKnown' | null, isDrill).
  • Store: incidentStore.ts (Zustand persist, localStorage key mesh-client:incidents, cap MAX_INCIDENTS = 200).
  • Fingerprint strips GPS from freetext so a GPS update does not fork a row. Bridged copies from a different sender id merge within CROSS_PROTOCOL_MERGE_WINDOW_MS (10 min) only when the relay arrives on a new protocol, stripped text is non-empty, and the full payload (including coords) matches; original senderId is kept and relays go in relaySenderIds. GPS-only MAYDAYs from two victims never merge. Relays never overwrite victim coords / lastKnown.
  • Resolve / prune tombstones both the sender fingerprint and incidentPayloadMatchKey (when stripped freetext is non-empty) so seed cannot reopen via a relay or escalated copy. GPS-only MAYDAYs skip payload tombs so different victims do not collide.
  • Cap eviction: resolved → drills → higher severity number → oldest; never evict open non-drill sev 0/1 (temporary over-cap allowed).
  • resolvedTombstones survive incident prune/clearAll of live rows so hydration cannot reopen resolved MAYDAYs. Live path may reopen after INCIDENT_REOPEN_GRACE_MS.
  • Seed (fromSeed): applies the same own/history/S&F/tapback skips as the live watcher path, skips messages older than INCIDENT_SEED_MAX_AGE_MS (24h), and honors tombstones.
  • Watcher: useMecpAlertWatcher.ts upserts on live messages and on a one-shot hydration seed (S4). Seed applies the same own/history/S&F/tapback skips as live, except an own B01/B03 is still recorded (no alert/audit) so the originator can cancel the beacon — see WS3. Passes fromSeed, and uses resolveLastKnown from UI nodes when present so incidents without GPS in freetext can still map-pin.
  • UI: components/incident/ — IncidentPanel.tsx (lazy via lazyTabPanels.ts, mounted in App.tsx, S8), EmergencyIncidentRow.tsx, AckIncidentButton.tsx, ResolveIncidentButton.tsx. Tab slot Incident in tabSlotIds.ts / appTabMappings.ts (INCIDENT_PANEL_INDEX) sits just before App (always visible on all three protocols; rarely used day-to-day — badge still surfaces open MAYDAY/URGENT); icon in tabIcons.tsx. Panel intro + empty hint copy live under incidentPanel.* in en/translation.json. User-facing “what’s here” also in README EMCOMM / Incident Command and troubleshooting What is the Incident tab?.
  • Map: open incidents with coordinates render via IncidentMarkersLayer in components/map/emcommMapLayers.tsx on both the LoRa Map tab and the Reticulum Map tab (Layers → Emergency incidents, mapLayerStore.showIncidents, default on; drills dashed). Reticulum hears MECP over LXMF chat the same way as LoRa; markers are protocol-agnostic from incidentStore.

WS2 — Emergency-priority outbox

  • Type / storage: OutboxEntry.priority: 'normal' | 'emergency' (electron-api.types.ts); OutboxEntryInput.priority is optional and defaults to 'normal'. SQLite chat_outbox.priority TEXT NOT NULL DEFAULT 'normal' (canonical DDL + DESIRED_COLUMNS additive upgrade in db-schema-sync.ts). chat:outbox:add coerces anything other than 'emergency' to 'normal'; rowToOutboxEntry maps the same way.
  • Drain policy (useChatOutbox.ts + App-level useEmergencyOutboxDrain.ts):
  • Emergency rows skip the 24h OUTBOX_MAX_AGE_MS drain cutoff (still require queued/failed + due nextRetryAt).
  • Emergency send failures always schedule nextRetryAt with the normal backoff (30s → 2m → 10m, then 10m forever) — no MAX_ATTEMPTS stop. Encryption-blocked errors still go to blocked without retry.
  • Soft cap: EMERGENCY_OUTBOX_SOFT_CAP = 20 counts non-blocked emergency rows only. Cap blocks the least-urgent non-MAYDAY row (never blocks MECP/0 payloads); if only MAYDAYs remain, over-cap is allowed. Rows are never deleted.
  • Order: drainChatOutboxOnce sends emergency rows before normal, then oldest first.
  • Always-on drain: useEmergencyOutboxDrain mounts once in App.tsx for all three protocols (registers drain listeners + wakes at earliest App-managed nextRetryAt). Sends emergency rows and Incident ACK rows tagged ackIncident:<id>:…. ACK rows skip the 24h / 5-attempt caps like emergency. Pending ACK rows surface on the Incident row (cancel). ChatPanel still owns UI + other normal rows. Shared withChatOutboxDrainLock + per-row OUTBOX_DRAIN_ROW_TIMEOUT_MS watchdog prevent double-send / hung locks (timeout does not abort the in-flight TX — a late success may still air, which is acceptable for MECP).
  • Incident ACKs enqueue as normal priority with incidentAckViewKey; recordAck / confirmBeacon run on live 'sent' or when the tagged ACK row drains successfully (applyIncidentAckAfterOutboxSend decides from the sent payload: B02 → confirmBeacon, else recordAck).
  • Send helper: emergencySend.ts — Reticulum live sends wait for LXMF receipt (or remote PN propagated) before reporting 'sent'; mid-cascade stored_locally is not counted early (may not survive sidecar restart). Timeout/failure queues. DM-only with no destination throws (does not silently succeed).

WS3 — ACK (R01) vs beacon (B01 / B02 / B03)

  • Codes (mecpAck.ts): R01 general ACK (MECP/<sev>/R01 <echoed codes> [freetext] ~CALLSIGN), B01 beacon, B02 beacon ACK (reduce beacon rate), B03 beacon cancel (sender OK). composeGeneralAck never emits B02 (S7); composeBeaconAck / composeBeaconCancel emit only their marker. All composers stay within MAX_MESSAGE_BYTES (freetext truncated first, then trailing echoed codes dropped; callsign suffix preserved).
  • Correlation: findOpenIncidentForAck scores unresolved incidents by exact echoed-code set, overlap, active beacon (for B02), matching severity, then recency. Incidents raised by the ACK sender are never candidates; the store's recordAck also ignores the incident sender acking themselves.
  • Incident ACK button (incidentAck.ts): incidentNeedsBeaconAck (active, unconfirmed beacon) → B02 Confirm, else R01 ACK. resolveIncidentAckRoute prefers the active protocol if the incident was heard there; otherwise targets the victim's original protocol (protocolsSeen[0]). On a DM-only non-origin protocol, DMs the latest numeric relaySenderIds entry, or falls back to the origin protocol. App.tsx handleIncidentAck sends via sendTextWithOutboxFallback(..., 'normal') with incidentAckViewKey, then confirmBeacon / recordAck only when outcome is 'sent'.
  • Beacon cancel (B03) (beaconCancel.ts): B03 means "I am OK". Ingest clears only unresolved beacons whose senderId matches the B03 sender, and does not resolve the row. A third-party B03 would not stop the victim's beacon and would claim the helper is OK, so Incident Command does not offer Send cancel for other people's beacons. Resolve on those rows (and on any non-beacon) stays local. When the local operator resolves a beacon they originated (localOrigin, or senderId in the local node-id set), the button is Cancel beacon and resolveIncidentWithBeaconCancel sends exactly one composeBeaconCancel via sendEmergencyText (emergency outbox) on the origin protocol and channel — repeating beaconCancelToNode when the beacon was unicast. The row resolves only after that cancel is sent or queued; a second click while the send is in flight does not queue another B03. DM-only protocols with no stored destination do not queue (the row stays open). The watcher records the operator's own B01/B03 into the incident store without alert or audit so the originator has a row to cancel; other own traffic is still ignored.
  • ACK honesty: broadcast ACKs are heard-by-network, best effort — an ACK count means R01/B02 copies were overheard, not that the distressed operator read anything. UI copy must not imply read receipts.

WS4 — Operational alerts

  • Pure predicates (operationalAlerts.ts): shouldFireSilenceAlert, shouldFireSilenceEscalation (SILENCE_ESCALATION_MULTIPLIER = 2; never-heard nodes are not "silent"), shouldFireBatteryLow (ignores 0 "unknown" and >100 charging; requires hasBatteryTelemetry), shouldFireLinkDown (requires was-connected, not manual disconnect, not reconnecting — S10/S11).
  • Hook (useOperationalAlerts.ts), mounted once in App.tsx with nodesForUi, active capabilities, Meshtastic + MeshCore link states, and useOperationalAlertSettings():
  • Watched nodes only (watchedNodesStore). Battery low fires once per cycle; re-arms after recovering BATTERY_ALERT_RESET_HYSTERESIS (5 pts) above the threshold.
  • Silence escalation at 2× nodeSilenceAlertMinutes; the first-level offline alert stays in useNodeStatusNotifier (which receives the same setting as silenceThresholdMinutes).
  • Link-down: connecting/reconnecting keep the "was up" latch so reconnect exhaustion still alerts; manual disconnect (connectionLoss !== true) never alerts; LINK_DOWN_GRACE_MS (5s) debounce absorbs power-suspend blips. Reticulum is not a link entry (sidecar, not RF driver).
  • Settings UI: App → Notifications → ops alerts subsection in AppPanel.tsx.
  • Settings (appSettingsStorage.ts getOperationalAlertSettings, defaults in defaultAppSettings.ts): nodeSilenceAlertMinutes (null = off / capability default), nodeBatteryLowThreshold (10), notifyOnLinkDown (true). Changes broadcast via the mesh-client:appSettings window event. Sounds: batteryLow, connectionLost (notificationSounds.ts).

WS6 — Exports

  • UI: NodeListPanel Export JSON (topology envelope over nodesToExportRows) / Export CSV; DiagnosticsPanel Export JSON (visible rows for the active protocol); MECP audit log via App → MECP.
  • Serializers (exportFormats.ts, pure): nodesToCsv (RFC 4180, CSV-injection guarded, canonical TOPOLOGY_NODE_FIELDS first then extra keys), nodesToTopologyJson (format: 'mesh-client-topology', version), diagnosticsRowsToJson (format: 'mesh-client-diagnostics'), toJsonSafe (Maps/Sets/bigint/Dates/cycles).
  • After-action report: deferred to a follow-up (assembler + node_status_events writer intentionally not in this PR).
  • Ops link-down: Meshtastic + MeshCore RF drivers only. Reticulum uses the sidecar (not an RF ConnectionDriver link), so link-down alerts intentionally omit it.

WS7 — SAR map tools (MGRS grid, measure, bearing)

  • lib/map/mgrsGrid.ts: pickMgrsPrecisionForBbox (finest precision under MGRS_GRID_MAX_SQUARES = 400), mgrsSquaresInBbox, mgrsSquareSizeMeters, estimateMgrsSquareCount.
  • lib/map/measureMath.ts: polylineSegmentsKm, polylineLengthKm (haversine; invalid segments count 0).
  • Bearing: bearingBetween / formatBearing in nodeStatus.ts (tested helpers; not yet surfaced in any UI).
  • Map wiring: MgrsGridLayer (Layers → MGRS grid, mapLayerStore.showMgrsGrid, default off; labels only when ≤60 squares) and MeasureControl in components/map/emcommMapLayers.tsx.

WS8 — USGS topo, incident track exemption, prune

  • USGS topo basemap: usgs-topo in the offline-maps allowlist (basemapRegistry.ts) — fixed basemap.nationalmap.gov ArcGIS host (z/y/x order), USGS_TOPO_MAX_NATIVE_ZOOM = 16 (Leaflet overzooms), served through mesh-tiles://usgs-topo/… and the shared tile cache. No user-supplied URL templates (S13). Renderer basemap entry in mapBasemapUtils.ts; selectable in the Map Layers control and leafletMapControls.tsx. See offline-maps.md.
  • Track exemption: incidentTrackExemption.ts nodesExemptFromPositionPrune returns sender ids of open/acked incidents (resolved releases the hold). useAppStartupDbPrune.ts (incidentPruneOptions()) reads the incident store at each startup/session prune and passes them as exemptNodeIds to startupDbPrune.ts. Sender ids that do not normalize to a uint32 node id (e.g. a MeshCore pubkey prefix longer than 8 hex chars, Reticulum hashes) are dropped by main, so those senders are not yet exempt.
  • Prune IPC: db:prunePositionHistory / db:prunePositionHistoryPerNode accept optional exemptNodeIds; main validates with sanitizeExemptNodeIdsArg and normalizePositionPruneExemptNodeIds (numbers, decimal, !hex, 0x hex; capped at MAX_POSITION_PRUNE_EXEMPT_IDS) and excludes them via json_each in database.ts (S12).

File map

Area Path
Incident store / types src/renderer/stores/incidentStore.ts, src/renderer/lib/mecp/incidentTypes.ts
Incident UI src/renderer/components/incident/
Watcher (hydrate + live) src/renderer/hooks/useMecpAlertWatcher.ts
ACK / beacon src/renderer/lib/mecp/mecpAck.ts, src/renderer/lib/mecp/incidentAck.ts, src/renderer/lib/mecp/beaconCancel.ts
Outbox hook / policy src/renderer/hooks/useChatOutbox.ts
Emergency send helper src/renderer/lib/emergencySend.ts
Outbox IPC / schema src/main/index.ts (chat:outbox:*), src/main/db-schema-sync.ts (chat_outbox)
Ops alerts src/renderer/lib/operationalAlerts.ts, src/renderer/hooks/useOperationalAlerts.ts
Exports src/renderer/lib/exportFormats.ts
SAR map src/renderer/lib/map/mgrsGrid.ts, src/renderer/lib/map/measureMath.ts, src/renderer/components/map/emcommMapLayers.tsx
Topo / track retention src/shared/offlineMaps/basemapRegistry.ts, src/renderer/lib/incidentTrackExemption.ts, src/main/database.ts
Safety contract src/renderer/lib/mecp/emcommSafety.contract.test.ts