From 0b4c0e281430dc658df3c273e099aa17376c0bec Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Sun, 5 Jul 2026 21:18:44 +0000 Subject: [PATCH] =?UTF-8?q?fix(env):=20leak-proof=20received-delta=20?= =?UTF-8?q?=E2=80=94=20durable=20persistent=20baseline=20+=20non-empty=20s?= =?UTF-8?q?eed=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live backlog-broadcast leaks traced to the in-memory first-poll seed: (1) incremental-fetch adapters (wzdx: registry tick [0 events] then feeds tick [many]) got marked _seeded on the EMPTY first tick, so the real batch next tick all looked "new" and broadcast; (2) in-memory seed lost on restart. Fix — durable baseline + guard: - _seed_from_persistent() at store init: pre-load already-received item keys from the persistent hazard tables into self._seen, so nothing ever received can re-broadcast (immune to fetch staging + restart). Only sources whose native emit key PROVABLY equals a persistent key are durably seeded: wzdx (traffic_events.external_id) + usgs_quake (quake_events.event_id). Resilient (per-table try/except; missing table -> skip). - _seen_key() now namespaces by evt["source"] (matches persistent tables), via shared _key_ext/_key_eid helpers used by both seed and live emit so they can't drift. - non-empty-seed guard: _ingest marks only sources that carried >=1 event this poll as _seeded -> an empty first tick can never seed-then-leak. This is the root-cause fix; covers all adapters (roads511/traffic fetch atomically per tick, so the guard fully protects them). - storage untouched (self._events populated for every event); Central path/deciders untouched. Live-DB verified: seed pre-loads 784 wzdx + 8 quake keys -> a live wzdx poll of 784 known zones broadcasts 0. +6 tests (incremental staging, restart, persistent-preseed, fresh-DB fallback); suite at 10-failure baseline. Co-Authored-By: Claude Opus 4.8 (1M context) --- work/meshai/env/store.py | 203 +++++++++++++++++--- work/tests/test_firms_native_fusion.py | 6 +- work/tests/test_store_received_delta.py | 235 ++++++++++++++++++++++++ 3 files changed, 419 insertions(+), 25 deletions(-) diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py index bdddb2e..a919953 100644 --- a/work/meshai/env/store.py +++ b/work/meshai/env/store.py @@ -12,6 +12,23 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# ── Received-delta key format ──────────────────────────────────────────────── +# A single, shared key format used by BOTH the live emit path (_seen_key) and +# the durable startup pre-seed (_seed_from_persistent). Keeping the two on one +# helper guarantees they can never drift apart — a drift would silently defeat +# the pre-seed (keys wouldn't match), which is exactly the leak we are fixing. +_SEP = "\x1e" + + +def _key_ext(source: str, external_id) -> str: + """Seen-key for an item identified by its upstream external_id.""" + return f"{source}{_SEP}ext:{external_id}" + + +def _key_eid(source: str, event_id) -> str: + """Seen-key for an item identified by the adapter's stable event_id.""" + return f"{source}{_SEP}eid:{event_id}" + class EnvironmentalStore: """Cache and tick-driver for all environmental feed adapters.""" @@ -32,19 +49,31 @@ class EnvironmentalStore: self._region_anchors = region_anchors or [] # ── Received-delta gate (NATIVE-only) ──────────────────────────── - # Per-adapter set of item keys seen in PRIOR polls, plus the set of - # adapters whose first poll has completed. The model the operator - # demanded: a native adapter broadcasts an item ONLY when it was newly - # RECEIVED from the API this poll — never by scanning an accumulated - # backlog. So the FIRST poll for an adapter records every current key - # as "seen" and emits NOTHING (that batch is pre-existing backlog, not - # ours to announce); every later poll emits only keys not already - # seen. In-memory only (per process) — a restart empties the sets, so - # the next poll is a fresh "first poll" that re-seeds silently. This - # makes broadcasting a backlog item structurally impossible on cold - # start, restart, or after the adapter was disabled for days. - self._seen: dict[str, set] = {} # adapter name -> set of item keys - self._seeded: set[str] = set() # adapters past their first poll + # The model the operator demanded: a native adapter broadcasts an item + # ONLY when it was newly RECEIVED from the API this poll — never by + # scanning an accumulated backlog. Two layers enforce this: + # + # 1. DURABLE pre-seed (_seed_from_persistent, below). meshai already + # has a durable record of everything it has ever received: the + # persistent hazard tables. At startup we load the identifying + # keys of every already-received item into `self._seen`, so nothing + # ever received can re-broadcast — immune to fetch staging AND to + # restarts. Keyed by the event's `source` (NOT adapter name) so the + # seed and the live-emit key line up on the same value. + # 2. IN-MEMORY first-poll seed. The FIRST *non-empty* poll for a + # source records every current key as "seen" and emits NOTHING + # (that batch is pre-existing backlog); later polls emit only keys + # not already seen. Belt-and-suspenders: a source is marked + # "seeded" ONLY after a non-empty ingest, so an empty first poll + # (e.g. wzdx's registry-only tick) can never mark it seeded and + # then leak the real batch on the next tick. + # + # `self._seen` is keyed by SOURCE (evt["source"]), matching the durable + # pre-seed. Durable keys added at startup are never removed, so they + # suppress a backlog item even AFTER the source is marked seeded (this + # is what closes cross-tick, non-empty staging leaks). + self._seen: dict[str, set] = {} # source -> set of item keys + self._seeded: set[str] = set() # sources past their first non-empty poll # Create adapter instances with error isolation self._register_adapter("nws", config.nws, ".nws", "NWSAlertsAdapter", @@ -97,6 +126,11 @@ class EnvironmentalStore: logger.warning("Failed adapters: %s", list(self._failed_adapters.keys())) logger.info(f"EnvironmentalStore initialized with {len(self._adapters)} adapters") + # Durable pre-seed: load every already-received item key from the + # persistent hazard tables into `self._seen` so nothing ever received + # can re-broadcast, regardless of fetch staging or process restarts. + self._seed_from_persistent() + def _register_adapter(self, name: str, cfg, module_path: str, class_name: str, args_fn): """Register a single adapter with error isolation.""" @@ -129,7 +163,7 @@ class EnvironmentalStore: self._purge_expired() return changed - def _seen_key(self, name: str, raw_evt: dict) -> str: + def _seen_key(self, raw_evt: dict) -> str: """Derive a STABLE per-item key for the received-delta gate. Stability across polls is the whole point: the SAME real-world item @@ -144,16 +178,23 @@ class EnvironmentalStore: ``avy_
_``, etc. — all stable across polls). 3. content hash — last-resort fallback if an item somehow carries no id at all. - Namespaced by adapter so two feeds never cross-contaminate. + + Namespaced by the event's ``source`` (NOT the adapter name). The + persistent hazard tables key on ``source`` (e.g. ``wzdx``), so keying + the live-emit path on the same value is what lets the durable startup + pre-seed line up with what the adapter emits. Every native raw event + carries a stable ``source``; if one somehow does not, fall back to a + neutral namespace so two feeds still never cross-contaminate. """ + source = raw_evt.get("source") or "?" ext = raw_evt.get("external_id") if ext: - return f"{name}\x1eext:{ext}" + return _key_ext(source, ext) eid = raw_evt.get("event_id") if eid: - return f"{name}\x1eeid:{eid}" + return _key_eid(source, eid) blob = json.dumps(raw_evt, sort_keys=True, default=str) - return f"{name}\x1ehash:" + hashlib.sha1(blob.encode()).hexdigest()[:16] + return f"{source}{_SEP}hash:" + hashlib.sha1(blob.encode()).hexdigest()[:16] def _delta_emit(self, name: str, adapter, raw_evt: dict, force: bool = False): """Received-delta gate — emit ONLY items newly received THIS poll. @@ -172,15 +213,16 @@ class EnvironmentalStore: content filtering (severity/threshold/impact, decider gates) still runs downstream in ``_emit_event``. """ - seen = self._seen.setdefault(name, set()) - key = self._seen_key(name, raw_evt) - first_poll = name not in self._seeded + source = raw_evt.get("source") or name + seen = self._seen.setdefault(source, set()) + key = self._seen_key(raw_evt) + first_poll = source not in self._seeded if first_poll: seen.add(key) # seed silently — this is backlog return if key in seen and not force: - return # already received in a prior poll + return # already received (prior poll OR durable pre-seed) seen.add(key) if self._event_bus is not None and hasattr(adapter, "to_event"): @@ -193,19 +235,30 @@ class EnvironmentalStore: adapter's FIRST data-bearing poll seeds the seen-set and broadcasts nothing; only items that newly appear on later polls are broadcast. ``self._events`` is still maintained for state/queries as before. + + Belt-and-suspenders: a source is marked "seeded" (past its first poll) + ONLY after a NON-EMPTY ingest for that source. An empty poll — e.g. + wzdx's registry-only tick that yields 0 events but returns ``tick()`` + True — must never mark a source seeded, or the next tick's real batch + would be treated as "new" and leak. We collect the sources that + actually carried ≥1 event this ingest and mark only those. """ + touched: set[str] = set() + if name == "swpc": self._swpc_status = adapter.get_status() # Also ingest any alert events (R-scale >= 3) for evt in adapter.get_events(): key = (evt["source"], evt["event_id"]) self._events[key] = evt + touched.add(evt.get("source") or name) self._delta_emit(name, adapter, evt) elif name == "ducting": self._ducting_status = adapter.get_status() for evt in adapter.get_events(): key = (evt["source"], evt["event_id"]) self._events[key] = evt + touched.add(evt.get("source") or name) self._delta_emit(name, adapter, evt) elif name == "avalanche": # Avalanche: re-emit on danger_level rise (Update:) not just new @@ -219,16 +272,118 @@ class EnvironmentalStore: level_rose = (prior is not None) and ( evt.get("danger_level", -1) > prior_level) evt["_is_update"] = level_rose # signal to to_event() + touched.add(evt.get("source") or name) self._delta_emit(name, adapter, evt, force=level_rose) self._events[key] = evt # always update stored state else: for evt in adapter.get_events(): key = (evt["source"], evt["event_id"]) self._events[key] = evt + touched.add(evt.get("source") or name) self._delta_emit(name, adapter, evt) - # First poll for this adapter is now complete: later polls may emit. - self._seeded.add(name) + # First (non-empty) poll for these sources is now complete: later polls + # may emit. Empty ingests touch nothing and so never mark a source + # seeded — the leak-proof invariant behind the wzdx staging fix. + self._seeded.update(touched) + + def _seed_from_persistent(self) -> None: + """Pre-seed ``self._seen`` from the durable hazard tables at startup. + + A row exists in these tables iff meshai has ALREADY RECEIVED that item, + so loading their identifying keys guarantees a native adapter can never + re-broadcast a previously-received item — immune to fetch staging and + to process restarts. Durable keys are added to ``self._seen`` and, + when ≥1 key is loaded for a source, that source is marked ``_seeded`` + so the durable set becomes its baseline: the very next poll emits ONLY + items NOT already received (the operator's "newly received → send it + now") and suppresses everything in the table — no silent first-poll + needed, and no dependency on how the fetch is staged. Because durable + keys are never removed, they keep suppressing backlog across every + later poll, which is what closes cross-tick, non-empty staging leaks. + (When a source has 0 durable rows we do NOT mark it seeded, so it falls + back to the leak-proof in-memory silent first-poll seed — a fresh DB + can never cause the first real batch to be treated as new.) + + Only sources whose native emit key PROVABLY equals the persistent key + are seeded here (verified against the live schema + adapter code): + + * ``wzdx`` — the native WZDx adapter carries a stable + ``external_id`` on its raw event and the incident decider persists + it as ``traffic_events(source='wzdx', external_id)``. Same value on + both sides → ``_key_ext('wzdx', external_id)`` matches exactly. + * ``usgs_quake`` — the native adapter's raw ``event_id`` is the bare + USGS id (e.g. ``us6000t9bn``) and the quake decider persists it as + ``quake_events.event_id`` verbatim → ``_key_eid('usgs_quake', id)`` + matches exactly. + + DELIBERATELY NOT seeded here (native emit key ≠ any persistent key — + see the report; these stay protected by the in-memory first-poll + + non-empty-seed guard, which is sufficient because each fetches + atomically per tick rather than across ticks): + roads511 / traffic (persistent rows are Central-keyed: + itd_511/tomtom_incidents with idaho_511:event:* external_ids, but the + native adapters emit source '511'/'traffic' with derived event_ids), + fires (native ``nifc__`` vs persistent IRWIN GUID), + firms (native ``firms____