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(Zustandpersist, localStorage keymesh-client:incidents, capMAX_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; originalsenderIdis kept and relays go inrelaySenderIds. 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).
resolvedTombstonessurvive incident prune/clearAllof live rows so hydration cannot reopen resolved MAYDAYs. Live path may reopen afterINCIDENT_REOPEN_GRACE_MS.- Seed (
fromSeed): applies the same own/history/S&F/tapback skips as the live watcher path, skips messages older thanINCIDENT_SEED_MAX_AGE_MS(24h), and honors tombstones. - Watcher:
useMecpAlertWatcher.tsupserts 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. PassesfromSeed, and usesresolveLastKnownfrom UI nodes when present so incidents without GPS in freetext can still map-pin. - UI:
components/incident/—IncidentPanel.tsx(lazy vialazyTabPanels.ts, mounted inApp.tsx, S8),EmergencyIncidentRow.tsx,AckIncidentButton.tsx,ResolveIncidentButton.tsx. Tab slotIncidentintabSlotIds.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 intabIcons.tsx. Panel intro + empty hint copy live underincidentPanel.*inen/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
IncidentMarkersLayerincomponents/map/emcommMapLayers.tsxon 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 fromincidentStore.
WS2 — Emergency-priority outbox
- Type / storage:
OutboxEntry.priority: 'normal' | 'emergency'(electron-api.types.ts);OutboxEntryInput.priorityis optional and defaults to'normal'. SQLitechat_outbox.priority TEXT NOT NULL DEFAULT 'normal'(canonical DDL +DESIRED_COLUMNSadditive upgrade indb-schema-sync.ts).chat:outbox:addcoerces anything other than'emergency'to'normal';rowToOutboxEntrymaps the same way. - Drain policy (
useChatOutbox.ts+ App-leveluseEmergencyOutboxDrain.ts): - Emergency rows skip the 24h
OUTBOX_MAX_AGE_MSdrain cutoff (still requirequeued/failed+ duenextRetryAt). - Emergency send failures always schedule
nextRetryAtwith the normal backoff (30s → 2m → 10m, then 10m forever) — noMAX_ATTEMPTSstop. Encryption-blocked errors still go toblockedwithout retry. - Soft cap:
EMERGENCY_OUTBOX_SOFT_CAP = 20counts non-blocked emergency rows only. Cap blocks the least-urgent non-MAYDAY row (never blocksMECP/0payloads); if only MAYDAYs remain, over-cap is allowed. Rows are never deleted. - Order:
drainChatOutboxOncesends emergency rows before normal, then oldest first. - Always-on drain:
useEmergencyOutboxDrainmounts once inApp.tsxfor all three protocols (registers drain listeners + wakes at earliest App-managednextRetryAt). Sends emergency rows and Incident ACK rows taggedackIncident:<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. SharedwithChatOutboxDrainLock+ per-rowOUTBOX_DRAIN_ROW_TIMEOUT_MSwatchdog 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
normalpriority withincidentAckViewKey;recordAck/confirmBeaconrun on live'sent'or when the tagged ACK row drains successfully (applyIncidentAckAfterOutboxSenddecides from the sent payload: B02 → confirmBeacon, else recordAck). - Send helper:
emergencySend.ts— Reticulum live sends wait for LXMF receipt (or remote PNpropagated) before reporting'sent'; mid-cascadestored_locallyis 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):R01general ACK (MECP/<sev>/R01 <echoed codes> [freetext] ~CALLSIGN),B01beacon,B02beacon ACK (reduce beacon rate),B03beacon cancel (sender OK).composeGeneralAcknever emits B02 (S7);composeBeaconAck/composeBeaconCancelemit only their marker. All composers stay withinMAX_MESSAGE_BYTES(freetext truncated first, then trailing echoed codes dropped; callsign suffix preserved). - Correlation:
findOpenIncidentForAckscores 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'srecordAckalso ignores the incident sender acking themselves. - Incident ACK button (
incidentAck.ts):incidentNeedsBeaconAck(active, unconfirmed beacon) → B02 Confirm, else R01 ACK.resolveIncidentAckRouteprefers 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 numericrelaySenderIdsentry, or falls back to the origin protocol.App.tsxhandleIncidentAcksends viasendTextWithOutboxFallback(..., 'normal')withincidentAckViewKey, thenconfirmBeacon/recordAckonly when outcome is'sent'. - Beacon cancel (B03) (
beaconCancel.ts): B03 means "I am OK". Ingest clears only unresolved beacons whosesenderIdmatches 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, orsenderIdin the local node-id set), the button is Cancel beacon andresolveIncidentWithBeaconCancelsends exactly onecomposeBeaconCancelviasendEmergencyText(emergency outbox) on the origin protocol and channel — repeatingbeaconCancelToNodewhen 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(ignores0"unknown" and>100charging; requireshasBatteryTelemetry),shouldFireLinkDown(requires was-connected, not manual disconnect, not reconnecting — S10/S11). - Hook (
useOperationalAlerts.ts), mounted once inApp.tsxwithnodesForUi, active capabilities, Meshtastic + MeshCore link states, anduseOperationalAlertSettings(): - Watched nodes only (
watchedNodesStore). Battery low fires once per cycle; re-arms after recoveringBATTERY_ALERT_RESET_HYSTERESIS(5 pts) above the threshold. - Silence escalation at 2×
nodeSilenceAlertMinutes; the first-level offline alert stays inuseNodeStatusNotifier(which receives the same setting assilenceThresholdMinutes). - Link-down:
connecting/reconnectingkeep 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.tsgetOperationalAlertSettings, defaults indefaultAppSettings.ts):nodeSilenceAlertMinutes(null= off / capability default),nodeBatteryLowThreshold(10),notifyOnLinkDown(true). Changes broadcast via themesh-client:appSettingswindow 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, canonicalTOPOLOGY_NODE_FIELDSfirst 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_eventswriter intentionally not in this PR). - Ops link-down: Meshtastic + MeshCore RF drivers only. Reticulum uses the sidecar (not an RF
ConnectionDriverlink), so link-down alerts intentionally omit it.
WS7 — SAR map tools (MGRS grid, measure, bearing)
lib/map/mgrsGrid.ts:pickMgrsPrecisionForBbox(finest precision underMGRS_GRID_MAX_SQUARES = 400),mgrsSquaresInBbox,mgrsSquareSizeMeters,estimateMgrsSquareCount.lib/map/measureMath.ts:polylineSegmentsKm,polylineLengthKm(haversine; invalid segments count 0).- Bearing:
bearingBetween/formatBearinginnodeStatus.ts(tested helpers; not yet surfaced in any UI). - Map wiring:
MgrsGridLayer(Layers → MGRS grid,mapLayerStore.showMgrsGrid, default off; labels only when ≤60 squares) andMeasureControlincomponents/map/emcommMapLayers.tsx.
WS8 — USGS topo, incident track exemption, prune
- USGS topo basemap:
usgs-topoin the offline-maps allowlist (basemapRegistry.ts) — fixedbasemap.nationalmap.govArcGIS host (z/y/x order),USGS_TOPO_MAX_NATIVE_ZOOM = 16(Leaflet overzooms), served throughmesh-tiles://usgs-topo/…and the shared tile cache. No user-supplied URL templates (S13). Renderer basemap entry inmapBasemapUtils.ts; selectable in the Map Layers control andleafletMapControls.tsx. See offline-maps.md. - Track exemption:
incidentTrackExemption.tsnodesExemptFromPositionPrunereturns sender ids ofopen/ackedincidents (resolvedreleases the hold).useAppStartupDbPrune.ts(incidentPruneOptions()) reads the incident store at each startup/session prune and passes them asexemptNodeIdstostartupDbPrune.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:prunePositionHistoryPerNodeaccept optionalexemptNodeIds; main validates withsanitizeExemptNodeIdsArgandnormalizePositionPruneExemptNodeIds(numbers, decimal,!hex,0xhex; capped atMAX_POSITION_PRUNE_EXEMPT_IDS) and excludes them viajson_eachindatabase.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 |