Files
potato-mesh/ACCEPTANCE.md
T
l5y b013faf13d web,data: reconcile MeshCore stale same-name node duplicates (#857)
* web,data: reconcile MeshCore stale same-name node duplicates

* data: MeshCore roster sync must not warm last_heard
2026-07-24 23:25:48 +02:00

194 KiB
Raw Permalink Blame History

PotatoMesh — Acceptance Criteria

Purpose. Precise, command-backed pass/fail criteria for the invariants and decisions in SPEC.md. A reviewer with zero context from the design session can judge a result against this file alone: run the command, compare to the expected result, record PASS/FAIL.

Format sources (cited per the kickoff protocol). The engineering-bar criteria (Layer B) restate CLAUDE.md; the API/event-contract criteria (Layer C) restate data/mesh_ingestor/CONTRACTS.md. Those two files are authoritative if any wording here drifts.

How to use this document

  1. Do the one-time Setup below.
  2. Run each check in Layers AD. Each check states a command and an Expected result. Commands are written for a POSIX shell at the repo root unless noted.
  3. Record PASS/FAIL per check, pasting the command output.
  4. Apply the Verdict rule. Pre-existing, tracked deviations are listed under § Known gaps; they remain FAIL until fixed.

Setup (one-time)

# Web (Ruby + JS)
( cd web && bundle install && npm ci )
# Python ingestor
python -m venv .venv && . .venv/bin/activate \
  && pip install -r data/requirements.txt black pytest pytest-cov
# Rust bridge: stable toolchain + cargo (rustup)            # for Layer B/D
# Flutter app: flutter SDK on PATH                          # for Layer B/D

Test server helpers

Some checks need a running web app. Start it with the env the check specifies, then kill it afterward. Examples:

# Privacy checks (Layer A2): private mode, federation off
( cd web && API_TOKEN=acctest PRIVATE=1  FEDERATION=0 bundle exec ruby app.rb ) &  SRV=$!
# Auth / contract checks (Layer C): public mode, known token
( cd web && API_TOKEN=acctest PRIVATE=0  FEDERATION=0 bundle exec ruby app.rb ) &  SRV=$!
# ... run curl checks ...
kill "$SRV"

Verdict rule

A result PASSES acceptance only when every check in Layers A, B, and C passes and every Layer-D check matches documented behavior. Any FAIL not already listed in § Known gaps blocks acceptance. The apex check A1 is a hard gate: a FAIL there fails the whole review regardless of anything else (SPEC §1).


Layer A — Invariant conformance

Maps to SPEC §1–§2 and decisions D2, D3, D4.

A1 — Apex: no MQTT / cloud data path (hard gate) — SPEC Invariant I

A1a. No broker/cloud-bus dependency in any manifest.

git grep -niE 'mqtt|mosquitto|paho|amqp|kafka|broker' -- \
  web/Gemfile web/Gemfile.lock data/requirements.txt \
  matrix/Cargo.toml matrix/Cargo.lock app/pubspec.yaml app/pubspec.lock

Expected: no output.

A1b. No broker connection in code (provenance flag excepted).

git grep -niE 'mqtt|mosquitto|paho|amqp|kafka|broker' -- \
  '*.rb' '*.py' '*.rs' '*.dart' '*.js' | grep -viE 'via_?mqtt'

Expected: no output. The only legitimate matches are Meshtastic's via_mqtt / viaMqtt provenance flag (data/mesh_ingestor/handlers/nodeinfo.py), which is filtered out here and is explicitly permitted by SPEC §1 (it is metadata about a foreign node, not PotatoMesh acting as an MQTT client).

Run the server with PRIVATE=1.

A2a. Message API is disabled in private mode.

curl -s -o /dev/null -w 'GET  %{http_code}\n' http://127.0.0.1:41447/api/messages
curl -s -o /dev/null -w 'POST %{http_code}\n' -X POST \
  -H 'Authorization: Bearer acctest' http://127.0.0.1:41447/api/messages -d '[]'

Expected: both 404 (the before "/api/messages*" filter halts 404 in private mode — web/lib/potato_mesh/application/routes/api.rb:49).

A2b. Private flag is advertised (the client uses it to hide chat).

curl -s http://127.0.0.1:41447/version | grep -o '"private_mode":true'

Expected: prints "private_mode":true (snake_case as of 0.7.0 — see § Bugfix: API casing consistency).

A2c. Node opt-out marker is honored wherever data is listed/exported.

git grep -lE 'opt_out_self_filter|opt_out_node_id_filter|NODE_OPT_OUT_MARKER' -- web/lib | sort

Expected: the opt-out filter appears in the read/export paths — at minimum application/queries/chat_queries.rb, application/identity.rb, and application/federation/instance_metrics.rb. Behavior is covered by the Ruby suite (Layer B1).

A3 — Decentralized, opt-in federation; PRIVATE > FEDERATION — SPEC Invariant III, D4

A3a. federation_enabled? is opt-in and overridden by privacy. Open both definitions and confirm the predicate is true only when FEDERATION is on and the instance is not private:

git grep -nA12 'def federation_enabled\?' -- \
  web/lib/potato_mesh/config.rb web/lib/potato_mesh/application/helpers/config_helpers.rb

Expected: the logic requires federation enabled and !private_mode? (concrete form of Privacy > Federation, SPEC §3.1).

A3b. No central authority / hardcoded directory host. Peers are discovered by crawl, not from a baked-in registry:

git grep -nhoE 'https?://[A-Za-z0-9.-]+' -- web/lib/potato_mesh/application/federation \
  | grep -viE 'apache\.org|w3\.org|schema|example|localhost|127\.0\.0\.1' | sort -u

Expected: no hardcoded third-party "central" host (matches are only standards URLs in comments, if any).

A3c. Federation behavior is covered by tests.

( cd web && bundle exec rspec spec -e federation )

Expected: federation specs pass (opt-in, isolation when FEDERATION=0, privacy override, staleness eviction).

A4 — Protocol parity & pluggability — SPEC Invariant IV

A4a. Both protocols are first-class, neither privileged.

git grep -n 'KNOWN_PROTOCOLS' -- web/lib/potato_mesh/application/routes/api.rb

Expected: the whitelist is exactly meshcore + meshtastic (KNOWN_PROTOCOLS = Set.new(%w[meshcore meshtastic])); classification is data-driven, not a per-protocol control-flow fork.

A4b. A protocol plugs in behind MeshProtocol without touching the read-side.

. .venv/bin/activate && pytest -q tests/test_provider_unit.py

Expected: pass (includes an isinstance(..., MeshProtocol) conformance check and error/retry paths). The contract that new protocols must preserve — and the fact that the Ruby/DB/UI read-side stays unchanged — is documented in CONTRACTS.md and the "Adding a New Ingestor Protocol" section of CLAUDE.md.

A4c — Chat name resolution honors protocol (no cross-protocol quoting)

( cd web && node --test public/assets/js/app/__tests__/meshcore-chat-helpers.test.js \
                       public/assets/js/app/__tests__/chat-entry-renderer.test.js )

Expected: pass. In the chat UI a MeshCore message resolves a sender/quote/ mention name only to a MeshCore node — never to a same-named Meshtastic node (names collide across protocols, so the lookup must filter by the message's protocol instead of taking the first match). When no same-protocol node matches, a synthetic node carrying the message's protocol is rendered rather than borrowing a node from another protocol (findNodeByLongName(longName, nodesById, protocol) + chat-entry-renderer.js). Concrete UI form of SPEC Invariant IV (protocol parity; neither protocol privileged in the data model or UI).

A4d — Custom radio-config label is protocol-neutral (regression: c8668a7)

( . .venv/bin/activate && pytest -q tests/test_interfaces_unit.py::TestCustomPresetLabelParity )

Expected: pass. A Meshtastic custom LoRa config (use_preset=False) renders the same compact SF/BW/CR label as MeshCore's _derive_modem_preset for identical SF/BW/CR — no protocol-specific "Custom " prefix — and returns None (not a bare "Custom") when the parameters are unreported, so one radio config never displays as two different strings depending on protocol (SPEC Invariant IV).

A4e — MeshCore captures adverts from other nodes (regression: adverts gap)

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py \
    -k "advert or is_known_contact or auto_update" )

Expected: pass. The MeshCore provider does not depend on the radio's auto-add setting to learn about other nodes. _run_meshcore sets mc.auto_update_contacts = True (so the library re-fetches changed contacts on every ADVERTISEMENT / PATH_UPDATE push — a re-advert from a known node refreshes its position / last_advert without a reconnect) and subscribes an ADVERTISEMENT handler that, for a public key not in the contact roster, upserts a minimal "heard now" node (_advert_to_node_dict: lastHeard + protocol + user.shortName/publicKey, no name/type/position) while skipping keys already tracked (_MeshcoreInterface.is_known_contact). This surfaces nodes the radio will not auto-add (manual-add / observer mode) without clobbering richer records. Local-LoRa RX only — no broker, no new ingest path (SPEC Invariants I/IV). Documented under "MeshCore advert sourcing" in CONTRACTS.md.


Layer B — Engineering bar (restated from CLAUDE.md)

Maps to decision D9. Commands mirror the CI workflows so local results match CI.

B1 — All test suites green

( cd web && bundle exec rspec )                          # Ruby
( cd web && npm test )                                    # JavaScript
( . .venv/bin/activate && pytest -q tests/ )              # Python
( cd matrix && cargo test --all --all-features )          # Rust
( cd app && flutter test )                                # Flutter

Expected: every suite exits 0.

B2 — Coverage: 100% target, 10% threshold, on project and patch

grep -A14 '^coverage:' .codecov.yml

Expected: status.project.default and status.patch.default each set target: 100% and threshold: 10%. Per-language coverage is produced by the suites in B1 (SimpleCov for Ruby, pytest-cov, cargo llvm-cov, flutter --coverage, V8 for JS) and enforced server-side by Codecov.

B3 — 100% API documentation (language standard)

( cd matrix && RUSTDOCFLAGS='-D warnings' cargo doc --no-deps )   # Rust: no doc warnings

Expected: cargo doc builds with no warnings. For Ruby (RDoc), Python (PDoc), JS (JSDoc), and Dart (dartdoc) there is no single gating command, so the criterion is: every public module/class/method/function carries a doc comment in the language standard (plus inline comments where logic is non-obvious). A reviewer confirms by opening each file changed in the diff; existing files such as web/lib/potato_mesh/application/data_processing/request_helpers.rb show the expected @param/@return RDoc density.

B4 — Apache v2 notice on every file (exact string)

B4a. Source files carry the full header.

git ls-files '*.rb' '*.py' '*.js' '*.rs' '*.dart' \
  | grep -vE '(^|/)(vendor|node_modules|build|\.dart_tool)/' \
  | xargs grep -L 'Copyright © 2025-26 l5yth & contributors'

Expected: no output (every source file contains the exact notice Copyright © 2025-26 l5yth & contributors).

B4b. Non-source text files carry the 2-line notice (where the format allows comments):

git ls-files '*.yml' '*.yaml' '*.toml' 'Dockerfile' '*/Dockerfile' '*.md' '*.sh' '*.nix' \
  | xargs grep -L 'Copyright © 2025-26 l5yth & contributors'

Expected: no output, except the documented exemptions in § Known gaps / exemptions (formats without comment syntax — e.g. JSON fixtures, *.lock files — are exempt).

B5 — Formatters & linters clean

( . .venv/bin/activate && black --check ./ )                                   # Python
( cd web && bundle exec rufo --check . )                                        # Ruby
( cd matrix && cargo fmt --all -- --check \
            && cargo clippy --all-targets --all-features -- -D warnings )       # Rust
( cd app && dart format --set-exit-if-changed . && flutter analyze )            # Flutter

Expected: every command exits 0.

B6 — CI runs on PRs to main and pushes to main

for w in python ruby rust mobile javascript; do
  echo "== $w =="; grep -A8 '^on:' ".github/workflows/$w.yml"
done

Expected: each workflow triggers on pull_request and on push to main, and covers the relevant suite(s) for the component(s) it touches.

B7 — Weekly Dependabot for every ecosystem

grep -E 'package-ecosystem|directory|interval' .github/dependabot.yml

Expected: entries for ruby (/web), npm (/web), python (/data), cargo (/matrix), pub (/app), and github-actions (/) — every language in the repo present, each with interval: "weekly".


Layer C — API & event contracts (restated from CONTRACTS.md)

Maps to decision D8. Run the server with PRIVATE=0 and API_TOKEN=acctest.

C1 — POST routes require a valid bearer token

curl -s -o /dev/null -w 'no-token   %{http_code}\n' \
  -X POST http://127.0.0.1:41447/api/nodes -d '{}'
curl -s -o /dev/null -w 'wrong-token %{http_code}\n' \
  -X POST -H 'Authorization: Bearer wrong' http://127.0.0.1:41447/api/nodes -d '{}'
curl -s -o /dev/null -w 'good-token  %{http_code}\n' \
  -X POST -H 'Authorization: Bearer acctest' http://127.0.0.1:41447/api/nodes -d '{}'

Expected: 403 for missing and wrong tokens (constant-time compare in require_token!); the valid-token request is not 403 (it is accepted, or 400 only if the body is malformed).

C2 — Canonical payload shapes validated by the integration suite

. .venv/bin/activate && pytest -q tests/test_mesh.py

Expected: pass. CONTRACTS.md states the POST shapes (nodes/messages/positions/telemetry/neighbors/traces/ingestors), sentinel normalization (issue #782), protocol stamping/propagation, and dedup are "validated by existing tests (notably tests/test_mesh.py)."

C3 — Canonical node id is !%08x on both sides

git grep -nE '_canonical_node_id' -- data/mesh_ingestor/serialization.py
git grep -nE 'canonical_node_parts' -- web/lib/potato_mesh/application/data_processing.rb
. .venv/bin/activate && pytest -q tests/test_node_identity_unit.py tests/test_serialization_unit.py

Expected: both normalizers exist; the id unit tests pass (lowercase 8-hex !abcdef01 form; dual numeric/canonical addressing).

C4 — GET window floors cannot be widened by the caller

git grep -nE 'week_seconds|four_weeks_seconds' -- web/lib/potato_mesh/config.rb

Expected: the 7-day / 28-day window constants exist. Per CONTRACTS.md ("GET endpoint time windows"), ?since=<n> is clamped to MAX(since, floor); this clamp is exercised by the Ruby suite (B1).

C5 — Cross-ingestor dedup by id

git grep -nE 'MESHCORE_CONTENT_DEDUP_WINDOW_SECONDS' -- web/lib

Expected: the content-dedup window constant exists. messages.id PRIMARY-KEY collapse and the MeshCore content-dedup (issue #756) are covered by tests/test_mesh.py (C2). Ids must fit in 53 bits (JS-safe).

C6 — Per-record protocol stamp precedence

Expected (covered by C2 + A4): an explicit per-record protocol (in the {meshtastic, meshcore} whitelist) wins over the ingestor-heartbeat default, which wins over meshtastic as the final fallback — exactly as CONTRACTS.md ("Protocol propagation") specifies. Values outside the whitelist fall through.

C7 — Chat feed is fully paginable within the window (issue #796 regression)

( cd web && bundle exec rspec spec/app_spec.rb -e "backward pagination" )

Expected: pass. GET /api/messages accepts a before=<rx_time> upper-bound cursor that only narrows the result set (the 7-day floor and the per-request MAX_QUERY_LIMIT cap are unchanged, so C4 still holds). With more than MAX_QUERY_LIMIT messages inside the seven-day window, paging backward by before recovers every in-window message instead of stalling at the newest 1000 — the landing page and /chat subpage page until the window is exhausted.


Layer D — Operator-facing behavior

Maps to decisions D10, D11 and the README. Server env per check.

D1 — Documented config surfaces through /version

curl -s http://127.0.0.1:41447/version

Expected: a JSON config block exposing site_name, channel, frequency, contact_link, map_center (lat/lon), max_distance_km, instance_domain, and private_mode, reflecting the env vars set at boot (README "Web App" table). Keys are snake_case as of 0.7.0 (see § Bugfix: API casing consistency).

D2 — ALLOWED_CHANNELS / HIDDEN_CHANNELS enforced (ingestor)

. .venv/bin/activate && pytest -q tests/test_channels_unit.py

Expected: pass. The allow-list discards all other channels before the hidden filter; hidden channels are dropped (data/mesh_ingestor/channels.py).

D3 — Opt-out marker excludes nodes from public listings

git grep -lE 'opt_out_self_filter|NODE_OPT_OUT_MARKER' -- web/lib | sort

Expected: the opt-out filter is applied across listing/export/federation queries (same artifact as A2c); behavior covered by the Ruby suite (B1).

D4 — Retention & staleness windows are wired in

git grep -nE 'start_retention_worker|retention_thread|def .*retention' -- \
  web/lib/potato_mesh/application/retention.rb web/lib/potato_mesh/application.rb

Expected: a retention worker is started by the app. Combined with the GET floors (C4) and the README's federation windows (8 h peer refresh, 72 h staleness eviction), stale data is bounded. Federation freshness lives in application/federation/validation.rb.

D5 — WIP components are read-only (no radio, no new ingest path) — D10

D5a. Matrix bridge touches no radio and posts to no ingest route.

git grep -niE 'serial|bluetooth|/dev/tty|meshtastic|meshcore' -- matrix/src
git grep -niE '/api/(nodes|messages|positions|telemetry|neighbors|traces|ingestors)' -- matrix/src

Expected: first command: no output (no radio). Second: only read usage of the public API (the bridge consumes messages); no POST to ingest routes.

D5b. Mobile app is a GET-only reader.

git grep -niE '\.post\(|/dev/tty|serial|bluetooth' -- app/lib

Expected: no ingest POST, no radio interface — the app only GETs from the public API.

D6 — Stack frozen per component (SPEC §3.2) — D7

grep -E 'gem "sinatra"'        web/Gemfile          # Ruby + Sinatra ~> 4
grep -E 'meshtastic|meshcore'  data/requirements.txt # Python: both libs
grep -E 'axum|reqwest|tokio'   matrix/Cargo.toml     # Rust bridge
grep -E '^\s*flutter:'         app/pubspec.yaml      # Flutter app

Expected: each manifest matches the locked stack; no language/framework swap.


Known gaps (pre-existing, tracked — not introduced by work under review)

These deviate from the bar above and are surfaced by the Phase 2 environment audit. They are FAIL until fixed, but a reviewer should attribute them to the existing codebase, not to the change under review.

  • B4 — header-check exemptions are conventional, not codified. Formats without comment syntax (JSON fixtures under tests/, *.lock files, binary assets) cannot carry the notice; there is no committed allow-list or CI check asserting headers. The B4 commands above are the interim verification.
  • A1b — two benign textual matches in the broker grep. The repo-wide A1b command matches .claude/hooks/guard-edits.py (the anti-broker edit guard's own pattern list) and the "no broker" documentation comment in web/lib/potato_mesh/application/pubsub.rb (PS1). Both are descriptive or defensive text about the apex ban — neither is a broker dependency or connection — but they sit outside A1b's via_mqtt exemption wording. Treat these two files as documented exemptions until the A1b filter codifies them.
  • B1 — sandbox DNS breaks the POST /api/instances spec block. In sandboxed environments whose resolver maps the suite's test domains (mesh.example, …) into the SSRF guard's restricted address ranges, 17 spec/app_spec.rb "POST /api/instances" examples fail with {"error":"restricted domain"} (400 instead of 201). Environmental only: the failures reproduce identically with and without any change under review and do not occur where the test domains resolve normally (CI). Attribute to the environment, not the codebase or the change.
  • C2 — tests/test_mesh.py fails in isolation on Python 3.14. Running the C2 command alone (pytest -q tests/test_mesh.py) fails 3 daemon reconnect-loop tests (test_main_retries_interface_creation, test_main_reconnects_when_connection_event_clears, test_main_recreates_interface_after_snapshot_error): their local DummyEvent helpers monkeypatch the global threading.Event with a wait(self, timeout) signature that requires an argument, and Python 3.14's Thread.start() calls self._started.wait() with none → TypeError. Pre-existing and independent of any change under review (reproduces on a clean tree), and the full suite (pytest -q tests/) passes — the failure is an isolation/collection-order artifact of the global patch. A naive timeout=None default is not a fix: it lets the patched-Event path run further and breaks thread startup in the full suite too ("cannot join thread before it is started"). Needs a proper follow-up that stops monkeypatching the global threading.Event in those three tests; until then, judge C2 by the full-suite run.

Feature: Chat channel test-deprioritization

Maps to SPEC decisions F1F4. The ordering logic lives in web/public/assets/js/app/chat-log-tabs.js (buildChatTabModel); behavior is verified by the JS unit suite.

F-A1 — Three-tier channel ordering (default → custom → test) — F1

( cd web && node --test public/assets/js/app/__tests__/chat-log-tabs.test.js )

Expected: pass. Given a default/primary channel (index 0, e.g. "Public"), a custom channel (index > 0, e.g. "#BerlinMesh"), and a test channel (index > 0, e.g. "#test"), buildChatTabModel(...).channels returns them in the order [default, custom, test] — every test channel sorts after every non-test channel regardless of 7-day activity. Within each tier the prior ordering (message-count descending, then label alphabetical) is unchanged.

F-A2 — Word-boundary test detection (ping/test/bot), no false positives — F2

( cd web && node --test public/assets/js/app/__tests__/chat-log-tabs.test.js )

Expected: pass. A channel label is classified test iff it contains the standalone word ping, test, or bot (case-insensitive, matched at word boundaries). So "#test", "Ping", "my bot", "test channel" are test; "Camping", "Robotics", "Contest", "Botswana" are NOT and keep their custom-tier position.

F-A3 — Primary/default channel is never demoted — F3

Expected (covered by the F-A1 suite): an index-0 channel whose label matches a keyword (e.g. a primary literally named "test") still sorts in the default tier (first), never the test tier — the main community feed always leads.

F-A4 — Presentation-only, protocol-neutral — F4

Expected (covered by the F-A1 suite + A4c): reordering changes only tab order — each channel's messageCount, entries, and id are unchanged, and the default-active tab stays the primary. Detection is by channel name, so a MeshCore "#test" and a Meshtastic "#test" are demoted identically (no protocol privileged).

F-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. At risk and explicitly required to remain green: A4c (chat name resolution honors protocol — same render path) and B1 (all suites). The existing two-tier ordering assertions in chat-log-tabs.test.js are updated to the three-tier order, not removed.


Feature: /api/stats activity counts (messages & telemetry)

Maps to SPEC decisions S1S7. The counts are produced by query_active_node_stats (web/lib/potato_mesh/application/queries/node_queries.rb), serialized by the GET /api/stats route (application/routes/api.rb), and consumed for federation by application/federation/crawl.rb. Unless a check says otherwise, start the server in public mode (API_TOKEN=acctest PRIVATE=0 FEDERATION=0 bundle exec ruby app.rb).

S-A1 — Breaking, versioned response shape (scope × metric tree) — S1, S2, S3

curl -s http://127.0.0.1:41447/api/stats \
  | python3 -c 'import sys,json; d=json.load(sys.stdin); \
SC=("total","meshcore","meshtastic","reticulum"); ME=("nodes","messages","telemetry"); WI=("hour","day","week","month"); \
print(all(isinstance(d[s][m][w],int) for s in SC for m in ME for w in WI) and d["sampled"] is False and "active_nodes" not in d)'
git grep -nA2 'def version_fallback' -- web/lib/potato_mesh/config.rb
. .venv/bin/activate && pytest -q tests/test_version_sync.py

Expected: the Python check prints True — the payload is the tree { total, meshcore, meshtastic, reticulum }, each scope carrying { nodes, messages, telemetry }, each metric carrying integer { hour, day, week, month }, with sampled still present and false. The old flat keys (active_nodes, integer-valued meshcore/meshtastic) are gone — this is the intended, versioned break. version_fallback returns "0.7.3", and test_version_sync.py passes — the bump is applied in lockstep across all five language manifests (data.VERSION, Config.version_fallback, web/package.json, app/pubspec.yaml, matrix/Cargo.toml; matrix/Cargo.lock is updated to match). The matching git tag v0.7.0 is the maintainer release step. data/mesh_ingestor/CONTRACTS.md documents the new GET /api/stats shape and notes the 0.7.0 break. Full shape is asserted by the Ruby suite (S-A2/S-A3).

S-A2 — total is unfiltered; protocol scopes are subsets; node counts preserved — S2

( cd web && bundle exec rspec spec/queries_spec.rb -e "active_node_stats" )

Expected: pass. With nodes seeded across protocols, query_active_node_stats returns total.<metric> = counts over all rows and meshcore/meshtastic/reticulum = protocol = ? subsets (so total ≥ Σ named protocols). total.nodes.{hour,day,week,month} equals the counts the prior active_nodes returned, and meshcore.nodes/meshtastic.nodes equal the prior flat per-protocol counts (relocation, identical values). Every metric honors the node opt-out marker using the filter appropriate to its table — opt_out_self_filter for nodes, and opt_out_node_id_filter / opt_out_node_num_filter for the message and telemetry-umbrella tables — consistent with the existing list endpoints.

S-A3 — telemetry umbrella + unchanged windows — S3, S4

( cd web && bundle exec rspec spec/queries_spec.rb -e "telemetry umbrella" )

Expected: pass. With one row inside the window in each of positions, telemetry, neighbors, and traces, the telemetry metric counts all four (positions + telemetry + neighbors + traces, by each table's rx_time); the messages metric counts the messages table by rx_time; nodes counts nodes by last_heard. Window cutoffs are unchanged — hour 3600s, day 86 400s, week week_seconds, month four_weeks_seconds — so a row older than four_weeks_seconds is excluded from month (28-day floor, preserves C4).

S-A4 — Privacy: messages zeroed in private mode — S5

Run the server with PRIVATE=1.

curl -s http://127.0.0.1:41447/api/stats \
  | python3 -c 'import sys,json; d=json.load(sys.stdin); \
SC=("total","meshcore","meshtastic","reticulum"); WI=("hour","day","week","month"); \
print(all(d[s]["messages"][w]==0 for s in SC for w in WI))'

Expected: prints True — every messages count (in total and all protocol scopes) is 0 under PRIVATE=1, mirroring the message-API 404 (A2a). nodes and telemetry counts are unaffected by privacy mode (only /api/messages* is gated). Behavior is also covered by a Ruby example (bundle exec rspec spec/app_spec.rb -e "/api/stats" exercising private mode).

S-A5 — reticulum forward-looking zero stub — S6

curl -s http://127.0.0.1:41447/api/stats \
  | python3 -c 'import sys,json; d=json.load(sys.stdin); r=d["reticulum"]; \
ME=("nodes","messages","telemetry"); WI=("hour","day","week","month"); \
print(all(r[m][w]==0 for m in ME for w in WI))'
git grep -niE 'reticulum' -- web/lib/potato_mesh/application/queries/node_queries.rb

Expected: the Python check prints Truereticulum is present with every count 0. The grep shows the reticulum block carries an in-code comment marking it a stub (always-zero until a Reticulum ingestor exists). reticulum is not added to KNOWN_PROTOCOLS (still meshcore + meshtastic; verified by A4a).

S-A6 — One-way federation compatibility (new reads old) — S7

( cd web && bundle exec rspec spec/federation_spec.rb -e "stats" )

Expected: pass. The consumer resolves remote activity counts by trying the new shape first (total.nodes[window], meshcore.nodes.day, meshtastic.nodes.day) and falling back to the old shape (active_nodes[window], meshcore.day, meshtastic.day), then to the existing node-list fallback. The pre-existing federation specs that feed the old flat shape continue to pass unchanged — they are the regression proof that a new instance still reads an old peer. New unit coverage asserts remote_active_node_count_from_stats handles both shapes (and prefers new).

S-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: every prior check still passes. At risk and explicitly required to remain green: A3c (federation specs — the old-shape stats specs must stay green, proving one-way new-reads-old); A2 / A2a (privacy — /api/messages still 404s in private mode and message counts are now zeroed, S-A4); and B1 (all suites). The JS stats assertions in stats.test.js / main-stats.test.js (normaliseActiveNodeStatsPayload, fetchActiveNodeStats) and the dashboard consumer (stats.js) are updated to read total.nodes from the new shape, not removed. No POST/event contract changes, so C2 and the Python suite are unaffected.


Bugfix: API casing consistency

Two casing inconsistencies on the HTTP API, fixed as a versioned breaking change (0.7.0). The /version JSON response moves to snake_case (matching every other read response and /api/stats); POST /api/nodes additionally accepts snake_case node fields so the ingest contract is no longer Meshtastic-camelCase only. The signed federation wire (/.well-known, /api/instances) is deliberately unchanged (camelCase — its keys are part of the instance signature, federation/signature.rb).

Run the server in public mode (API_TOKEN=acctest PRIVATE=0 FEDERATION=0 bundle exec ruby app.rb).

BF-A1 — /version response is snake_case

( cd web && bundle exec rspec spec/app_spec.rb -e "exposes the /version config block in snake_case" )

Expected: pass. GET /version returns a config block keyed in snake_case (site_name, map_center {lat,lon}, private_mode, instance_domain, contact_link, contact_link_url, max_distance_km, refresh_interval_seconds) plus a top-level last_node_update. The pre-0.7.0 camelCase keys (siteName, mapCenter, privateMode, …, lastNodeUpdate) are gone. The federation wire (/.well-known, /api/instances) stays camelCase (signed).

BF-A2 — POST /api/nodes accepts snake_case node fields

( cd web && bundle exec rspec spec/app_spec.rb -e "accepts snake_case node fields on POST /api/nodes" )

Expected: pass. A node POSTed with snake_case fields (last_heard, user.short_name/long_name/hw_model, device_metrics.battery_level, position.latitude/longitude) is stored and surfaces on GET /api/nodes. camelCase Meshtastic input (lastHeard, user.shortName, …) continues to work unchanged — acceptance is additive, so the existing Python ingestor is unaffected.

BF-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: every prior check still passes. Updated for the /version break: A2b now asserts "private_mode":true (was "privateMode":true) and D1 lists the snake_case config keys. The deployed Flutter app reads the new /version keys (app/lib/main.dart); older app builds break until updated (the accepted one-way cost of the clean break). data-app-config (the server→frontend DOM channel) is intentionally out of scope and stays camelCase.


Bugfix: API consistency cleanups (I2/I3/I5/I6)

Four small API consistency fixes shipped in 0.7.0 alongside the casing change above. The signed federation wire (/.well-known, /api/instances output, the canonical signed payload) stays untouched throughout.

IC-A1 — POST /api/instances accepts both key casings (I6)

( cd web && bundle exec rspec spec/app_spec.rb -e "accepts snake_case optional fields on POST /api/instances" )

Expected: pass. Optional fields (contact_link, nodes_count, …) accept snake_case in addition to camelCase; the camelCase keys and the camelCase signed canonical payload are unchanged.

IC-A2 — Only position_time, no ISO twin (I2)

( cd web && bundle exec rspec spec/app_spec.rb -e "/api/nodes" -e "/api/positions" )

Expected: pass. GET /api/nodes and /api/positions emit position_time (unix int) and no pos_time_iso / position_time_iso.

IC-A3 — POST ingest routes return 201 (I3)

( cd web && bundle exec rspec spec/app_spec.rb -e "POST ingest status codes" )

Expected: pass. Every POST /api/* ingest route returns 201 Created (matching /api/instances). The ingestor treats any 2xx as success.

IC-A4 — List POST routes reject malformed payloads (I5)

( cd web && bundle exec rspec spec/app_spec.rb -e "POST payload validation" )

Expected: pass. /api/messages|positions|telemetry|neighbors|traces return 400 {"error":"invalid payload"} for a non-array/non-object body, matching the /api/nodes Hash check.

IC-R1 — Regression

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ ) && ( cd matrix && cargo test --all --all-features )

Expected: all green. POST be_ok assertions were updated to 201 (not removed); the ingestor is unaffected (2xx success); the matrix bridge is GET-only.


Bugfix/Migration: Federation signature v2

Maps to SPEC FS1FS6 — federation wire migrated to snake_case with signed counts and v1-backward-compatible verification.

FS-A1 — v2 sign/verify round-trip + v1 backward-accept

( cd web && bundle exec rspec spec/federation_spec.rb -e "signature" )

Expected: pass. A v2 (snake) instance signature verifies; a legacy v1 (camelCase, no signature_version) signature still verifies via fallback. verify_instance_signature accepts both; instances sign/send v2.

FS-A2 — all announced counts are signed (tamper-evident)

( cd web && bundle exec rspec spec/federation_spec.rb -e "signed counts" )

Expected: pass. The announcement canonical covers nodes_count, meshcore_nodes_count, meshtastic_nodes_count, reticulum_nodes_count; altering any count invalidates the v2 signature. Nothing in the announced payload sits outside the signed canonical except signature / signature_version.

FS-A3 — well-known v2 snake + version marker, accepts v1+v2

( cd web && bundle exec rspec spec/app_spec.rb -e "well-known" )

Expected: pass. /.well-known/potato-mesh emits snake_case (public_key, last_update, signature_algorithm, signed_payload, signature_version); the validator accepts both v2 and legacy v1 documents.

FS-A4 — wire surfaces are snake_case

( cd web && bundle exec rspec spec/app_spec.rb -e "/api/instances" )

Expected: pass. GET /api/instances and the announce payload use public_key, last_update, is_private, contact_link, *_nodes_count — no camelCase keys.

FS-A5 — activity gate is intended behavior (not a regression)

Expected (covered by federation_spec): an instance with 0 nodes active in 7 days is not federated — validate_remote_nodes rejects it ("node data is stale" / below remote_instance_min_node_count). By design.

FS-R1 — Regression

( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ ) && ( cd matrix && cargo test --all --all-features )

Expected: all green. The pre-existing camelCase federation specs are retargeted to v2 or kept as the v1-backward-accept proof, not removed.


Bugfix: Chat first-paint latency (progressive load, issue #802)

PR #800 (issue #796) made the initial chat load page the entire seven-day window before rendering anything — on a busy instance up to ~10k messages across several sequential /api/messages pages, leaving the chat blank for 10-20s. The fix renders the newest page immediately and streams the older history in the background (deduplicated by id), so the chat fills progressively while staying responsive. The change is to when rows render, not which rows are reachable: the background pager keeps the same backward before-cursor semantics as the pre-fix #796 walk, so it reaches the same rows C7 does. Frontend-only: no API/DB change, so the C4/C7 window floors, MAX_QUERY_LIMIT, and privacy are untouched.

PL-A1 — Newest page renders without blocking on the full window

( cd web && node --test public/assets/js/app/__tests__/main-progressive-load.test.js )

Expected: pass. On first load the newest MESSAGE_LIMIT messages are committed and rendered even while an older page is still in flight (the chat does not wait for the whole backward pagination); once the background page resolves it is merged in by id, extending the loaded set backward through the window with the same reachability as the C7 walk. A failed background page is swallowed (logged, not rethrown) and leaves the rendered newest page intact.

PL-A2 — Backward pager yields progressively and de-duplicates by id

( cd web && node --test public/assets/js/app/main/__tests__/data-fetchers.test.js )

Expected: pass. paginateMessages() yields one batch per page (newest → oldest), seeds its cursor from an optional before, de-duplicates by id across pages, and stops on a short page / no-progress / missing cursor / maxPages. Its eager wrapper fetchAllMessages() preserves its existing semantics (concatenation of the generator's batches).

PL-R1 — Regression: prior acceptance still holds

( cd web && npm test )
( cd web && bundle exec rspec spec/app_spec.rb -e "backward pagination" )

Expected: all green. C7 (issue #796 backward pagination) is unchanged — the server still clamps before/since to the seven-day floor and MAX_QUERY_LIMIT, and the client reaches the same in-window messages C7 covers (identical backward-cursor semantics), now progressively rather than in one blocking burst.


Bugfix: MeshCore synthetic chat-node naming & reconciliation (issue #803)

A MeshCore channel message carries its sender as a "SenderName: body" text prefix (and quotes/mentions as @[Name]); the sender's from_id is a name-derived synthetic id. The web app's generic ensure_unknown_node minted a "MeshCore <hex>" placeholder marked synthetic=0 (real) for that id, which (a) showed the wrong name, (b) blocked the correctly-named synthetic=1 upsert via the real-node guard, and (c) was invisible to the long-name merge with the real contact — so messages were permanently mis-attributed. Mention-only names got no node at all. Fixed web-side (Ruby): MeshCore channel messages now synthesize/repair placeholder nodes named from the message text and marked synthetic=1, so the existing #755 merge machinery reconciles them with real contacts. No ingestor/API/DB-schema change; the apex (I) and privacy (II) invariants are untouched.

MC-A1 — Sender & mention placeholders are named from the chat text, reconcile, and self-heal

( cd web && bundle exec rspec spec/data_processing_spec.rb -e "meshcore synthetic chat nodes" )

Expected: pass. For a MeshCore channel message (protocol=meshcore, to_id="^all"): the sender's from_id node is named from the "Name:" prefix with synthetic=1 (never "MeshCore <hex>"); when a real node of that long_name already exists the placeholder is merged away and the message redirected to it; a pre-existing generic "MeshCore <hex>" synthetic=0 placeholder is repaired (renamed + demoted to synthetic) when a naming message arrives; and each @[Name] mention gets its own synthetic=1 placeholder (derive(name) = "!" + sha256(name)[0,8], matching the ingestor and frontend) even when that name never sent a message.

MC-A2 — Text-parsing & id-derivation helpers

( cd web && bundle exec rspec spec/data_processing_spec.rb -e "meshcore chat text parsing" )

Expected: pass. parse_meshcore_sender_name returns the trimmed name before the first : (nil when absent/blank); extract_meshcore_mentions returns the trimmed, de-duplicated @[Name] list; meshcore_synthetic_node_id reproduces the ingestor/frontend derivation (derive("DWeb 0229") == "!0f6de6b3").

MC-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. The pre-existing synthetic-merge specs (issues #755 / #756 in database_spec.rb / data_processing_spec.rb) still pass — the fix only changes how the placeholder is named/flagged at message-ingest time; merge_synthetic_nodes / merge_into_real_node are unchanged. The Python ingestor is untouched (it still emits the same name-derived synthetic upsert, now redundant-but-harmless with the web-side path).


Bugfix: Federation peer DNS failure must not 500

A peer registering via POST /api/instances (and the periodic crawl) is verified by fetching its /.well-known/potato-mesh and /api/nodes. The fetch path (federation/instance_fetcher.rb#perform_instance_http_request) resolves the peer's domain via resolve_remote_ip_addressesAddrinfo.getaddrinfo before the wrapped HTTP attempt, but its method-level rescue caught only ArgumentError. A peer whose domain fails DNS raises Socket::ResolutionError (a SocketError), which escaped past fetch_instance_json (rescues only JSON::ParserError / InstanceFetchError) to the route as an unhandled HTTP 500. The intended behavior — documented in-code at the registration pre-check ("DNS lookups that fail to resolve are handled later") and already realized on the announce path — is a graceful rejection. Fix: perform_instance_http_request wraps SocketError (alongside ArgumentError) as InstanceFetchError. Frontend/API-shape unaffected; the apex (I) and privacy (II) invariants are untouched.

FD-A1 — DNS resolution failures are wrapped, not leaked

( cd web && bundle exec rspec spec/federation_spec.rb -e "wraps DNS resolution failures" -e "fails DNS resolution" )

Expected: pass. perform_instance_http_request raises InstanceFetchError (not a raw Socket::ResolutionError) when Addrinfo.getaddrinfo fails, and fetch_instance_json returns [nil, errors] (recording the failure) instead of raising — so a peer with an unresolvable domain is rejected with a 4xx rather than crashing the request with a 500.

FD-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec spec/federation_spec.rb )
( cd web && bundle exec rspec )

Expected: all green, including A3c (federation specs: opt-in, isolation, privacy override, staleness eviction). The change only converts a previously uncaught resolution error into the InstanceFetchError every fetch_instance_json caller already handles; the restricted-address ArgumentError path, connection-error retry/fallback, and announce path are unchanged.


Bugfix: Federation hygiene (HTTP fallback, observability, shutdown)

Three small federation defects discovered while investigating a same-key collision between two v0.7.0-rc2 peers. The fixes are independent of one another; each ships its own regression line below.

FH-A1 — HTTPS responses don't trigger an HTTP fallback

( cd web && bundle exec rspec spec/federation_spec.rb \
    -e "does not fall back to HTTP after HTTPS returned an HTTP response" \
    -e "still falls back to HTTP when HTTPS connection itself fails" )

Expected: pass. When an HTTPS request to /api/instances returns any HTTP status (success or error — e.g. 400 from an older v0.6.x peer rejecting the v2 signature, SPEC FS5), the http://…:80 candidate is not attempted and no warn_log is emitted. The HTTP fallback only fires when HTTPS failed at the transport layer (Errno::ECONNREFUSED / EHOSTUNREACH / ENETUNREACH etc.), preserving the dev-instance fallback. Implemented via PotatoMesh::App::InstanceHttpResponseError < InstanceFetchError (application/errors.rb), raised by perform_single_http_request for non-2xx responses and matched ahead of the generic InstanceFetchError in fetch_instance_json; announce_instance_to_domain breaks the URI loop explicitly on a non-success HTTP response.

FH-A2 — Federation is observable at default log level

( cd web && bundle exec rspec spec/app_spec.rb -e "defaults to INFO" \
                            spec/federation_spec.rb -e "logs cycle start and end at info level" )

Expected: pass. With DEBUG=0 the structured logger defaults to INFO (not WARN), restoring visibility for operational milestones that are already authored as info_log (notably application/retention.rb purges and the new federation cycle entries). On every announcement cycle, federation emits one info line at start carrying target_count and one at end carrying success_count + failure_count. The boot path emits a one-shot "Federation enabled" info line with seed_count, announcement_interval_seconds, and worker_pool_size when federation is active. Per-peer announce success/failure stays at debug to keep cycle logs to ~3 lines/8h on a busy fleet. Inbound peer registrations (routes/ingest.rb "Registered remote instance") are also promoted to info since they are bounded by federation_max_domains_per_crawl.

FH-A3 — Federation workers shut down in bounded time

( cd web && bundle exec rspec spec/worker_pool_spec.rb \
    -e "reaps workers that ignore STOP_SIGNAL within force_kill_after" \
    -e "rejects pending tasks that have not started yet"
  cd web && bundle exec rspec spec/federation_spec.rb \
    -e "uses federation_shutdown_timeout_seconds (not the task timeout)" )

Expected: pass. shutdown_federation_worker_pool! budgets the pool shutdown by federation_shutdown_timeout_seconds (default 3s — env-tunable via FEDERATION_SHUTDOWN_TIMEOUT) and arms a matching force_kill_after, so a worker mid-task that ignores STOP_SIGNAL is hard-killed within that window rather than waiting out the 120s task timeout per thread serially. Pending queued tasks that have not yet started are rejected with ShutdownError during shutdown rather than executed. Thread#kill runs Ruby ensure blocks, so SQLite handles opened inside crawl/announce tasks (guarded by ensure db&.close) still close cleanly. Net effect: CTRL+C on a running instance reaps potato-mesh-fed-N workers in seconds, not minutes.


Bugfix: Chat-log incremental render & per-node hydration storm

The dashboard rebuilt the entire chat log from HTML strings on every refresh tick (element.innerHTML = … per entry — ~77% of a refresh's main-thread time in the deployed profile) and the message-node hydrator backfilled each unknown sender with a separate GET /api/nodes/:id (hundreds of round trips, many 404 for RF-only nodes, on every cold load). The render now memoises each entry's DOM node and reuses it while its rendered HTML is unchanged, so an idle tick parses nothing; the hydrator resolves senders from the already-loaded bulk node map and renders an !id placeholder on a miss, issuing zero per-node requests. Frontend-only (vanilla JS, existing stack); no API/DB/ingestor change, so the apex (I) and privacy (II) invariants are untouched.

CR-A1 — Idle re-render materialises no entries; content preserved; no per-node fetch

( cd web && node --test public/assets/js/app/__tests__/main-chat-render-incremental.test.js )

Expected: pass. After the initial render fills the entry cache, calling rerenderChatLog again with unchanged state materialises 0 entries (getChatRenderStats().materialized stays 0 — the brief's "idle page renders ~0 entries per cycle" gate) and the rendered chat still contains every message. A refresh whose sender is absent from the bulk /api/nodes payload issues no GET /api/nodes/!… request (the hydration storm is gone).

CR-A2 — Entry-node cache memoises, namespaces, prunes, and releases tabs

( cd web && node --test public/assets/js/app/main/__tests__/chat-entry-cache.test.js \
                       public/assets/js/app/main/__tests__/chat-entry-keys.test.js )

Expected: pass. createChatEntryCache reuses a node while its HTML is unchanged, rebuilds it when the HTML changes (e.g. a renamed sender), keeps a distinct node per tab namespace for the same key (a message renders in both the Log and its channel tab), prunes entries that aged out of a tab's window, and releases caches for tabs no longer present. The stable per-entry keys cover messages (by id, with a timestamp/sender/text fallback) and every log-entry type (including encrypted).

CR-A3 — Hydration is map-only by default; per-node fetch is opt-in

( cd web && node --test public/assets/js/app/__tests__/message-node-hydrator.test.js )

Expected: pass. With no fetchNodeById injected (the dashboard default) the hydrator binds senders from nodesById and emits a protocol-stamped !id placeholder on a miss, performing zero network lookups. applyNodeFallback remains mandatory; fetchNodeById is now optional, and supplying it re-enables the bounded per-node backfill (worker-pool + negative cache) for a deliberate, opt-in batched refresh path.

CR-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. The public createMessageChatEntry / createAnnouncementEntry test surface is unchanged (now thin wrappers over the pure parts builders), so A4c (chat name resolution honours protocol) and the chat-entry / progressive-load suites (PL-A1, PL-A2) stay green. No POST/GET contract change, so the Ruby and Python suites are unaffected.


Feature: Frontend persistent data cache

Maps to SPEC decisions FC1FC7. The dashboard persists its read-side data in the browser (IndexedDB) keyed by canonical id, paints from cache on load, and fetches only misses (absent or stale rows) and incremental deltas. Frontend-only (vanilla JS); no API/DB/ingestor change. New modules live under web/public/assets/js/app/main/ (e.g. data-cache.js for the store and a lifetime/TTL helper) with co-located __tests__.

FC-A1 — Persistent, id-keyed store round-trips every collection — FC1

( cd web && node --test public/assets/js/app/main/__tests__/data-cache.test.js )

Expected: pass. The store reads/writes nodes, messages (incl. encrypted), positions, telemetry, neighbors, and traces keyed by the canonical record id (neighbors by the composite (node_id, neighbor_id) key), backed by IndexedDB; values written in one session are retrievable from a fresh store instance over the same backing database (the reload/revisit path). Reads of an absent id return a miss.

FC-A2 — Seed-from-cache, fetch only the delta — FC2

( cd web && node --test public/assets/js/app/__tests__/main-cache-refresh.test.js )

Expected: pass. On a warm start (cache populated) the app paints from cache and each collection's first refresh requests only rows newer than the newest cached row (since=<newest cached ts>); rows already present and fresh in the cache are not re-requested. On a cold start (empty cache) it fetches the full window as today. New rows returned by the delta are merged by id and written back to the cache. The auto-refresh cadence is unchanged.

FC-A3 — Two-tier lifetime: staleness refetches, eviction deletes — FC3, FC5

( cd web && node --test public/assets/js/app/main/__tests__/cache-lifetime.test.js )

Expected: pass. Given the per-collection windows — nodes stale 24 h / evict 7 d; traces & neighbors stale + evict 28 d; messages, positions, telemetry stale + evict 7 d — the helper reports an entry stale past its staleness TTL (so it is a fetch candidate) but retains it until its (longer or equal) eviction window. A node last updated 26 h ago is stale yet not evicted (still served); a node 8 d old is evicted; no entry younger than 7 days is ever evicted; a trace 20 d old is retained, a trace 29 d old is evicted. No staleness/eviction window exceeds the server's visibility floor (7-day bulk; 28-day per-id/trace), preserving C4.

FC-A4 — Privacy: PRIVATE disables + wipes the cache; clear control empties it — FC4

( cd web && node --test public/assets/js/app/__tests__/main-cache-privacy.test.js )

Expected: pass. When the instance reports PRIVATE mode the cache performs no writes and any existing cached data is wiped on init; only data the API actually returns is ever stored (opt-out / CLIENT_HIDDEN rows are excluded server-side, so they never reach the cache; a node opt-out propagates to clients within the 24 h node staleness window). The clear-cache operation (clearDataCache — the action a "clear cached data" control invokes) empties the store on demand; the visible UI control is a deferred follow-up, but the capability ships and is covered here. This is the client-side realisation of the FC4 amendment to Invariant II; combined with A2a (message API still 404s in private mode) no message content is cached or served when private.

FC-A5 — Versioned schema & graceful degradation — FC6, FC7

( cd web && node --test public/assets/js/app/main/__tests__/data-cache.test.js )

Expected: pass. A cache carrying a different schema version — or a different instance identity (instance_domain) — is discarded on open rather than served, so a data-shape change can never surface mis-shaped entries. When the storage backend is unavailable, throws, or exceeds quota, every store operation degrades silently to a no-op and the app falls back to today's network-only behavior (the cache is never load-bearing). The cache feeds no POST/ingest path and alters no API response (read-side only).

FC-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and explicitly required to remain green: A2 / A2a / A2b (privacy — no cached messages surface in private mode); C4 / C7 (7-day GET floor and #796 backward pagination — the cache never serves beyond-window rows); PL-A1 / PL-A2 (#802 progressive load + backward pager — caching seeds, it does not replace, the pager); CR-A1 … CR-R1 (#813 incremental render + map-only hydration — the cache seeds nodesById and feeds the same render path, so idle re-renders still materialise 0 entries and no per-node /api/nodes/:id request is issued); A4c (protocol parity — cache keyed by canonical id, never mixing protocols); and B1 (all suites). No POST/GET contract change, so the Ruby and Python suites are unaffected.


Feature: Asset cache-busting (versioned static assets)

Maps to SPEC decisions AV1AV5. The helper + import-map builder live under web/lib/potato_mesh/application/helpers/; asset references live in views/layouts/app.erb, views/charts.erb, views/federation.erb, views/node_detail.erb. Unless noted, run the server in public mode and leave it running for the curl checks:

( cd web && API_TOKEN=acctest PRIVATE=0 FEDERATION=0 \
    bundle exec ruby app.rb -p 41447 -o 127.0.0.1 ) &  SRV=$!
# ... run the AV-A* curl checks below, then: kill "$SRV"

(This repo has no config.ru; it is launched via app.rb — see app.sh — not rackup.)

AV-A1 — Template-written JS & CSS carry ?v=<version> — AV1, AV2

curl -s http://127.0.0.1:41447/ \
  | grep -oE "/assets/(js|styles)/[A-Za-z0-9/_.-]+\?v=[^\"']+" | sort -u

Expected: every template-written JS <script src> and the base.css <link href> carry a ?v=<APP_VERSION> query — at minimum /assets/js/theme.js?v=…, /assets/js/background.js?v=…, /assets/js/app/index.js?v=…, and /assets/styles/base.css?v=…. None of those four is emitted without the query.

AV-A2 — Exactly one import map, covering the deep module graph — AV3

curl -s http://127.0.0.1:41447/ | grep -c '<script type="importmap">'
curl -s http://127.0.0.1:41447/ \
  | grep -oE '"/assets/js/app/main\.js": *"/assets/js/app/main\.js\?v=[^"]+"'

Expected: the first command prints 1 (a single import map, emitted in <head> before any module loads); the second matches. main.js is imported only through a relative specifier inside index.js and is never written in any template, so its presence in the map with a ?v= URL proves the transitive module graph is busted — not just the entry points.

AV-A3 — Inline-import page versions its entry specifier — AV2

curl -s http://127.0.0.1:41447/charts \
  | grep -oE "from '/assets/js/app/charts-page\.js\?v=[^']+'"

Expected: the inline <script type="module"> import specifier carries ?v=<APP_VERSION>. federation.erb and node_detail.erb use the identical pattern (reachable directly only with FEDERATION=1 / a known node id; both are covered by the view/app specs in AV-A6).

AV-A4 — Scope boundary: images & favicons are NOT versioned — AV4

curl -s http://127.0.0.1:41447/ \
  | grep -oE "(potatomesh-logo\.svg|favicon\.[a-z]+|/assets/img/[A-Za-z0-9._-]+)\?v=" \
  && echo "UNEXPECTED: image carries ?v=" || echo "OK: no image versioned"

Expected: prints OK: no image versioned. Image / favicon / SVG-icon URLs carry no ?v= query — they keep today's Last-Modified/ETag revalidation, pinning the JS+CSS-only scope of AV4.

AV-A5 — No asset-pipeline dependency; native import map — AV4, D7

git grep -niE 'importmap-rails|sprockets|propshaft|webpacker|shakapacker' -- \
  web/Gemfile web/Gemfile.lock web/package.json

Expected: no output. The import map is emitted directly from Ruby using the native browser feature; no asset-pipeline gem or npm package is introduced (the locked stack, D7, is unchanged).

AV-A6 — Helper + builder unit-tested; web suites green — AV5

( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: pass. Includes new specs covering asset_url (appends ?v=<APP_VERSION>) and the import-map builder (enumerates served .js, excludes __tests__, stamps the version, emits valid JSON). RDoc + the full Apache header are present on every new/edited source file (Layer B3/B4 still hold).

AV-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: every prior check still passes. At risk and explicitly required to remain green: B1 (all suites); B4a (no new unheadered source file — any new helper file must carry the full Apache block); D1 (/version config block — the shared layout's behavior is unchanged). Any existing view/app spec that asserted an exact unversioned asset string (e.g. src="/assets/js/app/index.js") is updated to the ?v= form, not removed.


Bugfix: Initial-load module-graph waterfall (slow first data paint)

The dashboard's first /api/* fetch is gated behind the entire 89-module ES-module graph loading, and that graph was discovered one import-tier at a time (index.js{config,main,settings} → main's 33 imports → … ≈ 5 serial round trips) because nothing told the browser the deeper modules up-front. On a real connection each tier costs a full RTT, so data did not paint for 23 s (measured: ~3.7 s to the first /api/nodes request at 150 ms RTT / 4× CPU; the server itself answers every endpoint in <250 ms). The fix emits one <link rel="modulepreload"> per served app ES module in <head> — the same set the AV3 import map versions — so the whole graph downloads in parallel (one round trip over HTTP/2) instead of tier-by-tier. Native browser feature, no build step or dependency (D7/AV4); read-side only (apex/privacy/parity untouched); a module absent from the preloads still loads normally (AV3's degradation property). Built by PotatoMesh::App::AssetImportMap.preload_html (web/lib/potato_mesh/application/helpers/asset_helpers.rb), rendered after the import map in views/layouts/app.erb.

Run the server in public mode (as in AV-A1) and leave it running for the curl check.

MP-A1 — The head preloads the whole app ES-module graph (busted URLs)

curl -s http://127.0.0.1:41447/ \
  | grep -oE '<link rel="modulepreload" href="/assets/js/app/[A-Za-z0-9/_.-]+\?v=[^"]+">' \
  | grep -E 'app/(index|main)\.js'

Expected: matches a <link rel="modulepreload"> for both the entry point index.js and the transitively-imported main.js, each carrying the ?v=<APP_VERSION> query — i.e. the preloaded URL equals the import-map target, so the preload and the eventual import resolve to the same cache entry. Every served /assets/js/app/** module is preloaded; the classic non-module scripts (/assets/js/theme.js, /assets/js/background.js) and __tests__ files are not preloaded.

MP-A2 — Preloads sit after the import map, before the module entry; unit-tested

( cd web && bundle exec rspec spec/asset_versioning_spec.rb -e "modulepreload" \
                            spec/asset_import_map_spec.rb -e "preload" )

Expected: pass. The rendering spec asserts the modulepreload block is emitted after the <script type="importmap"> and before the <script type="module" src="…index.js"> entry (so resolution order is correct), that classic scripts and __tests__ are excluded, and the unit specs cover AssetImportMap.preload_paths (app modules only) and .preload_html (one version-stamped link per module, memoized).

MP-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: every prior check still passes. At risk and explicitly required to remain green: AV-A2 (still exactly one import map, still busting the deep graph — the preloads are additive, not a replacement); AV-A1/AV-A4 (asset versioning + image-scope boundary unchanged); D1 (the shared layout's /version-fed config behavior is unchanged); B1 (all suites). The preloads are purely additive head markup — no existing asset URL, the import map, or any /api/*//version shape changes.


Bugfix: Initial-load data prefetch (cold-load early fetch)

Second phase of the initial-load fix (after the module-graph preload above). Even with the graph preloaded, the first /api/* fetch still waits for the ~806 KB bundle to download, parse, and boot. An early <script type="module" async> boot module (web/public/assets/js/app/main/boot-prefetch.js) now fires the first-load (since=0) API requests in parallel with the module graph (at priority:'high', so they out-prioritise the parallel module preloads) and stashes the in-flight Response promises on window.__PM_BOOT__; the app's first refresh() consumes them via a new responsePromise option on the data-fetchers instead of issuing its own requests. It runs only on cold loads — a synchronous localStorage marker (pm:cache-present, maintained by the cache write-back / clear / disable paths) suppresses it on warm revisits, leaving the FC2 seed-then-delta path untouched. Message endpoints are skipped in private mode (data-pm-chat="false"), mirroring the /api/messages 404 (Invariant II / PS6). Pure pre-warm: an absent or rejected prefetch re-fetches (a captured error response surfaces and the next auto-refresh recovers), so it is never load-bearing (FC7). Read-side only; no API/DB/ingestor change, no new dependency (D7).

Run the server in public mode (as in AV-A1) for the curl check.

EF-A1 — The head emits the cold-load boot-prefetch module (gated by privacy)

curl -s http://127.0.0.1:41447/ \
  | grep -oE '<script type="module" async[^>]*boot-prefetch\.js[^>]*' | head

Expected: matches an async ES-module <script> whose src is the versioned /assets/js/app/main/boot-prefetch.js?v=<APP_VERSION>, carrying data-pm-prefetch and data-pm-chat="true" in public mode. Under PRIVATE=1 the same tag carries data-pm-chat="false" (no message prefetch) — covered by the Ruby suite (bundle exec rspec spec/app_spec.rb -e "cold-load boot prefetch").

EF-A2 — Cold load consumes the prefetch; warm load keeps the FC2 delta path

( cd web && node --test \
    public/assets/js/app/main/__tests__/boot-prefetch.test.js \
    public/assets/js/app/__tests__/main-boot-prefetch.test.js \
    public/assets/js/app/main/__tests__/data-fetchers.test.js )

Expected: pass. On a cold load (no pm:cache-present marker) the boot module issues the seven first-load requests and the app consumes the stashed responses on its first refresh — no duplicate cold /api/nodes//api/messages fetch is issued (the __PM_BOOT__ global is one-shot, cleared on read). A successful cache write-back sets the marker; clearDataCache and a disabled cache (PRIVATE / no-IndexedDB) clear it. The data-fetchers accept a responsePromise and fall back to a fresh fetch if it is absent or rejected (so a failed prefetch never loses data). coldLoadUrls mirrors the data-fetchers' first-load URLs (no drift).

EF-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: every prior check still passes. At risk and explicitly required to remain green: MP-A1/MP-A2 (the module-graph preload is unchanged; the boot module is itself one of the preloaded app modules); the FC-A2 warm seed-delta behaviour (main-cache-refresh.test.js — a warm load still seeds from cache and delta-fetches, because the marker suppresses the cold prefetch); A2/PS6 (privacy — no message prefetch under PRIVATE); B1 (all suites). No /api/*//version shape changes; the prefetch only changes when the first requests fire, not which rows are reachable.


Feature: Uniform backward pagination (?before=) for bulk collection APIs

Maps to SPEC decisions BP1BP9. ?before=<unix_seconds> is added as an inclusive upper-bound keyset cursor to the six bulk collection GETs — /api/nodes, /api/positions, /api/telemetry, /api/neighbors, /api/traces, /api/ingestors — mirroring the existing /api/messages cursor (C7). The logic lives in web/lib/potato_mesh/application/routes/api.rb and the query_* helpers under web/lib/potato_mesh/application/queries/; the cursor is documented in data/mesh_ingestor/CONTRACTS.md. Unless a check says otherwise, start the server in public mode (API_TOKEN=acctest PRIVATE=0 FEDERATION=0 bundle exec ruby app.rb).

BP-A1 — Every bulk collection pages backward through the full window — BP1, BP2, BP3

( cd web && bundle exec rspec spec/app_spec.rb -e "before pagination" )

Expected: pass. For each of /api/nodes, /api/positions, /api/telemetry, /api/neighbors, /api/traces, and /api/ingestors, seeding more than MAX_QUERY_LIMIT (1000) rows inside the route's window and walking newest → oldest — each page limit=MAX_QUERY_LIMIT, then before=<oldest primary-sort value seen>, de-duplicating by id — recovers every in-window row (the walk does not stall at the newest 1000). No single response exceeds MAX_QUERY_LIMIT. The cursor bounds the route's primary sort column inclusively: rx_time for positions/telemetry/neighbors/traces, last_heard for nodes, last_seen_time for ingestors.

BP-A2 — before only narrows; the floor still bounds the window — BP2

( cd web && bundle exec rspec spec/app_spec.rb -e "before cannot widen the window" )

Expected: pass. A before newer than now returns the same rows as no before (a no-op upper bound). A before older than the route's floor, combined with the floor-clamped lower bound, returns nothing beyond the floor — a row older than the 7-day / 28-day floor stays excluded, so before cannot reach past it (preserves C4). A non-positive or non-integer before (0, -5, abc) is ignored as absent (parity with the messages coerce_positive_or_nil), so the unfiltered newest page is returned.

BP-A3 — Inclusive boundary, protocol-neutral cursor — BP3, BP5

( cd web && bundle exec rspec spec/app_spec.rb -e "before pagination boundary" )

Expected: pass. Two rows sharing the exact boundary second are both returned when that second is passed as before (the inclusive <= ceiling never skips a boundary row — client dedup collapses the one-row overlap between pages). ?before= composes with ?protocol=: a backward walk filtered by protocol=meshcore returns only MeshCore rows and still recovers all of them, with neither protocol privileged.

BP-A4 — History pages bypass the response cache — BP7

( cd web && bundle exec rspec spec/app_spec.rb -e "before bypasses the response cache" )

Expected: pass. A request carrying before is served from a fresh query, not the short-lived ApiCache newest-page entry, and issuing it does not overwrite or evict that hot entry — a subsequent no-before request still returns the cached newest page. Matches the established /api/messages behavior (a since > 0 or before request skips the cache; the cache key for the default path is unchanged).

BP-A5 — Privacy, opt-out, and apex are untouched — BP6

( cd web && bundle exec rspec spec/app_spec.rb -e "before pagination honors privacy" )
git grep -niE 'mqtt|mosquitto|paho|amqp|kafka|broker' -- web/Gemfile web/Gemfile.lock | grep -viE 'via_?mqtt'

Expected: the rspec passes and the grep prints nothing. A backward walk over /api/nodes still excludes opted-out nodes (NODE_OPT_OUT_MARKER) and, in private mode, CLIENT_HIDDEN nodes — before only narrows, so it can never surface a row the route would otherwise hide (A2c, Invariant II). No manifest gains a broker dependency (Invariant I / A1a): the change is a read-side query param only.

BP-A6 — Cursor documented; deferred scope recorded — BP1, BP8, BP9

git grep -n 'before' -- data/mesh_ingestor/CONTRACTS.md

Expected: the CONTRACTS.md "GET endpoint time windows" section documents the ?before= inclusive upper-bound cursor and names the six collections that accept it. The deferred items in BP9 are out of scope and must not appear in this change: /api/instances still lacks limit/since/protocol, and /api/telemetry/aggregated still uses camelCase windowSeconds/bucketSeconds (no snake_case alias) — these stay tracked follow-ups, not regressions.

BP-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ )

Expected: every prior check still passes. At risk and explicitly required to remain green: C7 (messages backward pagination — its keyset mechanism is now shared by six more routes, but /api/messages behavior is unchanged); C4 (window floors — before only narrows, never widens); A2 / A2a / A2c (privacy & opt-out — a narrowing upper bound exposes no hidden row, and /api/messages still 404s in private mode); A4a (KNOWN_PROTOCOLS unchanged); PL-A1 / PL-A2 and FC-A2 (the frontend message pager and cache seed-then-delta are untouched — frontend before adoption is deferred per BP9); and B1 (all suites). No POST/event contract changes, so C2 and the Python suite are unaffected (the only data/ touch is the CONTRACTS.md GET-window documentation).

Feature: Live updates (SSE change pub/sub)

Maps to SPEC decisions PS1PS8. An in-process, in-memory pub/sub registry (web/lib/potato_mesh/application/pubsub.rb) emits a thin per-collection change event when an ingest POST writes; the new GET /api/events route streams those events as Server-Sent Events; the frontend SSE client (a new module under web/public/assets/js/app/main/, e.g. event-stream.js, with co-located __tests__) reacts by running its existing delta fetch and merging by id. The event shape is documented in data/mesh_ingestor/CONTRACTS.md. Unless a check says otherwise, run the server in public mode and leave it running for the curl checks:

( cd web && API_TOKEN=acctest PRIVATE=0 FEDERATION=0 \
    bundle exec ruby app.rb -p 41447 -o 127.0.0.1 ) &  SRV=$!
# ... run the PS-A* curl checks below, then: kill "$SRV"

PS-A1 — Apex: the pub/sub adds no broker and no external client — PS1

# (1) No broker dependency anywhere (re-runs the A1a/A1b hard-gate greps).
git grep -niE 'mqtt|mosquitto|paho|amqp|kafka|rabbitmq|broker' -- \
  web/Gemfile web/Gemfile.lock data/requirements.txt \
  matrix/Cargo.toml matrix/Cargo.lock app/pubspec.yaml app/pubspec.lock
# (2) The pub/sub registry pulls in NO networking/broker client library.
git grep -nE '^\s*require\b.*\b(socket|net/http|net/|faraday|httparty|excon|redis|bunny|kafka|mqtt|amqp|stomp)\b' -- \
  web/lib/potato_mesh/application/pubsub.rb

Expected: (1) no output (apex hard gate A1 still holds — no broker added). (2) no output: pubsub.rb requires no networking or broker client — it uses only in-process Ruby concurrency primitives (Mutex / ConditionVariable, which need no require) and opens no socket or external connection. The fan-out is a local, single-process registry (PS1). A FAIL here is an apex FAIL (SPEC §1).

PS-A2 — GET /api/events is a read-only SSE stream, never an ingest path — PS2

# It streams text/event-stream (cut the long-lived connection after 2s).
curl -s -N --max-time 2 -D - -o /dev/null http://127.0.0.1:41447/api/events \
  | grep -i '^content-type:'
# It is read-only: POST is not accepted as an ingest path.
curl -s -o /dev/null -w 'POST %{http_code}\n' -X POST \
  -H 'Authorization: Bearer acctest' http://127.0.0.1:41447/api/events -d '{}'

Expected: the first command prints Content-Type: text/event-stream (the subscribe surface is SSE). The second prints 404 or 405/api/events accepts no body and is not an ingest route (§3.3); it writes nothing and SQLite stays the system of record. The endpoint is additive — no existing /api/* response shape changes (D8), confirmed by the unchanged Layer C checks.

PS-A3 — Thin per-collection event on ingest; client delta-fetches — PS3

( cd web && bundle exec rspec spec/pubsub_spec.rb -e "publishes a thin per-collection event" )
( cd web && node --test public/assets/js/app/main/__tests__/event-stream.test.js )

Expected: pass. Server side: a subscriber to the registry, after a successful ingest POST, receives an event whose payload names only the changed collection (one of nodes/messages/positions/telemetry/neighbors/ traces), optionally with a newest-rx_time/last_heard skip-hint, and carries no row fields (no body text, sender, position, etc.). Client side: on an SSE event for collection X the SSE client invokes the existing delta fetch for X with since=<cached high-water> and merges by id through the FC2 cache — it issues no broadcast re-fetch of unrelated collections and adds no new privacy or window logic of its own. Protocol-neutral: the event names the collection, never the protocol (Invariant IV).

PS-A4 — Publish-on-change at all six ingest routes, coalesced — PS4

( cd web && bundle exec rspec spec/pubsub_spec.rb -e "publishes on every ingest route" -e "coalesces bursts" )

Expected: pass. Each of the six dashboard ingest routes — POST /api/nodes, /messages, /positions, /telemetry, /neighbors, /traces — publishes its collection's change event after a successful write, co-located with the existing ApiCache.invalidate_prefix calls in routes/ingest.rb. A burst of writes to one collection within the debounce window is coalesced into a bounded number of emitted events (not one event per row), so a message flood cannot stampede subscribers.

PS-A5 — Push replaces the 60 s poll; reconnect-resync + slow safety poll — PS5

( cd web && node --test public/assets/js/app/__tests__/main-sse-refresh.test.js )

Expected: pass. The frontend no longer drives refreshes from a fixed 60 s timer: (a) an SSE event triggers the matching collection's delta fetch immediately; (b) on every SSE (re)connect the client runs a full delta resync across collections to recover anything missed during the gap; (c) a slow safety poll (default 5 min, configurable; surfaced via refresh_interval_seconds/settings) still runs as a fallback and is the only timer-driven path. The fast 60 s cadence is gone (no setInterval at 60 000 ms as the primary driver).

PS-A6 — Privacy: no messages events when PRIVATE — PS6

Run the server with PRIVATE=1.

# The event stream must never carry a messages event in private mode.
curl -s -N --max-time 3 http://127.0.0.1:41447/api/events | grep -i 'messages' \
  && echo "UNEXPECTED: messages event in private mode" || echo "OK: no messages event"
( cd web && bundle exec rspec spec/pubsub_spec.rb -e "suppresses messages events in private mode" )

Expected: the curl prints OK: no messages event (within the 3 s sample the stream emits no messages event under PRIVATE=1), and the rspec example passes: the registry/route suppress messages change events in private mode, mirroring the /api/messages 404 (A2a). Non-message collections (nodes, positions, telemetry, neighbors, traces) still emit. Because events are thin and the client re-fetches through the already-filtered /api, opt-out / CLIENT_HIDDEN rows never traverse the push (Invariant II).

PS-A7 — Cache mechanism intact under the event-driven trigger — PS7

( cd web && node --test public/assets/js/app/__tests__/main-cache-refresh.test.js )

Expected: pass. The seed-then-delta cache contract (FC-A2) is unchanged: on a warm start the first fetch still requests only since=<newest cached ts>, fresh cached rows are not re-requested, and new rows merge by id and write back. Only the trigger differs (SSE ping / reconnect resync / safety poll instead of the 60 s timer) — the delta/merge/cache logic is the same path. This is the realisation of the PS7 amendment to FC-A2/FC-R1's "cadence unchanged" wording.

PS-A8 — Graceful degradation; engineering bar — PS8

( cd web && node --test public/assets/js/app/main/__tests__/event-stream.test.js )
( cd web && bundle exec rspec ) && ( cd web && npm test )
git ls-files 'web/lib/potato_mesh/application/pubsub.rb' \
  'web/public/assets/js/app/main/event-stream.js' \
  | xargs grep -L 'Copyright © 2025-26 l5yth & contributors'

Expected: pass / no output. When EventSource is unavailable, the stream errors, or the feature is disabled by config, the client silently falls back to the safety poll and behaves exactly as today's network-only path — the push is never load-bearing (no thrown error reaches the app, no blank UI). The Ruby and JS suites are green with new unit coverage for pubsub.rb, the /api/events route, and the SSE client (100% lines/branches). The grep -L prints no output: every new source file carries the exact Apache header (B4a) and is RDoc/JSDoc-documented (B3).

PS-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: every prior check still passes. At risk and explicitly required to remain green:

  • A1 / A1a / A1b (apex) — no broker dependency or external client is introduced by the pub/sub (also asserted by PS-A1); a FAIL is a hard-gate FAIL.
  • A2 / A2a / A2b (privacy) — /api/messages still 404s under PRIVATE, and the stream now additionally carries no messages event (PS-A6).
  • FC-A2 / FC-R1 (frontend cache) — the seed-then-delta delta/merge/cache contract is unchanged; only their "auto-refresh cadence is unchanged" wording is amended per PS7 (PS-A7). Cache tests are updated to the event-driven trigger, not removed.
  • PL-A1 / PL-A2 (progressive load) and CR-A1 / CR-A2 / CR-A3 (incremental render + map-only hydration) — an SSE-triggered delta flows through the same render/merge/hydration path, so idle re-renders still materialise 0 entries and no per-node /api/nodes/:id request is issued.
  • D1 (/version) — still exposes refresh_interval_seconds (now the safety-poll cadence); the config block is otherwise unchanged.
  • B1 (all suites). No existing POST/GET contract changes (only the additive GET /api/events and the new event-shape docs in CONTRACTS.md), so C2 and the Python suite are unaffected.

Bugfix: MeshCore chat messages must advance node last_heard through the synthetic→real merge

A MeshCore channel chat message names its sender via a synthetic, name-derived placeholder node. Once the real contact advertisement reconciles that placeholder (issues #803 / #755), the merge_into_real_node / merge_synthetic_nodes helpers migrated the message rows but dropped the placeholder's last_heard, and the subsequent touch_node_last_seen in insert_message then targeted the just-deleted synthetic id — so a node heard only via channel chat showed a stale "last seen". The merge now carries the synthetic's last_heard onto the real node, advancing it but never moving it backward. Web-side only (Ruby web/lib/potato_mesh/application/data_processing/node_writes.rb); no ingestor / API / DB-schema change. Meshtastic messages and MeshCore direct messages were already correct (their from_id is the real node id, so no synthetic merge intervenes).

LH-A1 — A reconciled MeshCore chat message advances the real node's last_heard

( cd web && bundle exec rspec spec/data_processing_spec.rb \
    -e "advances the reconciled real node's last_heard when a chat message arrives" )

Expected: pass. With a real MeshCore contact already on record (last_heard = T0), ingesting a channel message (to_id="^all", protocol="meshcore", sender named in the text) whose synthetic placeholder reconciles to that contact advances the real node's last_heard to the message rx_time (> T0), instead of leaving it pinned at the advertisement time.

LH-A2 — Both merge directions carry the synthetic's last_heard, never backward

( cd web && bundle exec rspec spec/data_processing_spec.rb \
    -e "carries a merged synthetic's newer last_heard onto the real node" \
    -e "carries the synthetic's newer last_heard onto the real node" \
    -e "never moves the real node's last_heard backward when the synthetic is older" )

Expected: pass. merge_synthetic_nodes (a real advertisement absorbing a chattier synthetic) and merge_into_real_node (a synthetic placeholder folding into an existing real contact) both advance the real node's last_heard to MAX(real, synthetic); when the synthetic is older the real node's last_heard is left unchanged — the merge never moves "last seen" backward.

LH-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and explicitly required to remain green: MC-A1 / MC-A2 (#803 synthetic chat-node naming, merge, and redirect — unchanged; the fix only adds a last_heard carry to the same merge helpers), the #755 / #756 synthetic-merge specs in database_spec.rb / data_processing_spec.rb, and B1 (all suites). No POST/GET/event contract change, so the Python ingestor and CONTRACTS.md are unaffected.


Feature: Live-update visual feedback (flash + control cleanup)

Maps to SPEC decisions VF1VF7. Live SSE updates now flash the affected element white (<100 ms); the poll-era Refresh button and "last updated" field are removed (play/pause stays). The flash-trigger logic + a flash helper live under web/public/assets/js/app/main/ (with co-located __tests__); the highlight keyframe lives in web/public/assets/styles/base.css; the only server change is an additive nodes publish on POST /api/messages (web/lib/potato_mesh/application/routes/ingest.rb). Run the server in public mode for the curl checks; run JS suites from web/.

VF-A1 — Poll-era controls removed; play/pause kept — VF1

git grep -nE 'id="refreshBtn"|id="status"' -- web/views
git grep -nE 'id="autorefreshToggle"' -- web/views/layouts/app.erb
( cd web && bundle exec rspec spec/app_spec.rb -e "does not render the Refresh button or last-updated field" )

Expected: the first grep prints no output — the #refreshBtn button and the #status "last updated" field are gone from the views. The second prints the #autorefreshToggle line — the play/pause control remains. The rspec example passes: the rendered dashboard contains no id="refreshBtn" and no id="status" refresh-timestamp element, and still contains id="autorefreshToggle". main.js no longer writes refreshing… / updated <time> status text (it has no #status element to write to).

VF-A2 — Flash fires only on SSE-ping deltas, never on load/resync/poll — VF2

( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )

Expected: pass. With a fake EventSource + stub fetch: the initial load applies no flash (no strobe on paint); a subsequent SSE change ping for a collection flashes the affected element; a reconnect (open → resync) and a safety-poll refresh apply no flash. The flash is driven only from the SSE-ping-driven targeted refresh (runLiveRefresh), confirmed by asserting a resync/poll-shaped refresh leaves the flash count unchanged.

VF-A3 — Correct element flashes per collection (incl. message⇒node) — VF3

( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )
( cd web && bundle exec rspec spec/pubsub_spec.rb -e "publishes nodes on a message ingest" )

Expected: pass. A nodes/positions/telemetry ping flashes the affected node's node-table row ([data-node-id]) and map marker. A messages ping flashes the message row and the channel tab header; and because POST /api/messages also publishes nodes (extends PS4 — verified by the rspec example: a single message POST publishes both messages and nodes), the author node's row + marker flash too. neighbors / traces pings flash nothing (the documented out-of-scope boundary). Detection is by id/collection and identical for both protocols (Invariant IV).

VF-A4 — Flash is applied after render, never to an unrendered element — VF4

( cd web && node --test public/assets/js/app/__tests__/main-flash.test.js )

Expected: pass. The flash is applied in a post-render step: a ping for a node not yet present in the DOM first renders/positions the row + marker (and a message renders its row + tab), and only then is the highlight applied — asserted by checking the flashed element exists and is the final rendered node at flash time (the render call precedes the flash call within the tick).

VF-A5 — White, reduced-motion-aware highlight (now ~1.2 s; see LV-A1) — VF5

( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
grep -nE '@media \(prefers-reduced-motion: reduce\)' web/public/assets/styles/base.css
grep -nE '(animation|transition)[^;]*(1\.2s|120[0-9]ms)' web/public/assets/styles/base.css  # amended by LV-A1

Expected: pass / non-empty. The flash helper applies a one-shot highlight class and clears it (or relies on a self-completing CSS animation) with no layout shift. base.css carries the highlight keyframe/rule with a duration ~1.2 s (amended from the original <100 ms by LV-A1 below) and a @media (prefers-reduced-motion: reduce) guard that suppresses the animation (data still updates; only the visual is withheld). The white onset and the fade duration are confirmed by reading the rule.

VF-A6 — Render & cache invariants preserved; #822 holds — VF6

( cd web && node --test public/assets/js/app/__tests__/main-chat-render-incremental.test.js )
( cd web && bundle exec rspec spec/app_spec.rb -e "updates node last_heard for plaintext messages" )

Expected: pass. With the flash code present, an idle re-render still materialises 0 entries and issues 0 per-node /api/nodes/:id requests (CR-A1 unchanged — the flash touches only already-rendered/cached DOM and never re-materialises). The existing #822 example confirms a message ingest still bumps the author node's last_heard (also covered at the unit level by data_processing_spec.rb "advances the reconciled real node's last_heard when a chat message arrives"), which is what makes the message⇒node flash reflect real data. The seed-then-delta cache (FC-A2) is untouched.

VF-A7 — Engineering bar — VF7

( cd web && bundle exec rspec ) && ( cd web && npm test )
git ls-files 'web/public/assets/js/app/main/flash.js' \
  'web/public/assets/js/app/__tests__/main-flash.test.js' \
  | xargs grep -L 'Copyright © 2025-26 l5yth & contributors'

Expected: pass / no output. The Ruby and JS suites are green with new coverage for the flash trigger (changed-id selection, after-render ordering, ping-only gating, message⇒node fan-out), the flash helper, and the nodes-on-message publish. Every new source file carries the exact Apache header (B4a) and JSDoc (B3).

VF-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: every prior check still passes. At risk and explicitly required to remain green:

  • CR-A1 / CR-A2 / CR-A3 (incremental render + map-only hydration — the flash never re-materialises or fetches per node).
  • PS-A5 / PS-A7 (SSE targeted fetch + cache delta) and FC-A2 (seed-then-delta — flashing is gated to SSE-ping deltas, so warm-start/resync/poll never flash).
  • PS-A6 / A2 / A2a (privacy — /api/messages still 404s in PRIVATE, so the new nodes-on-message publish is moot there; node events are not privacy-gated).
  • PL-A1 / PL-A2 (progressive load), A4c (chat parity — same render path), and the autorefresh/pause specs (the toggle still pauses live + poll after the Refresh/status controls are removed).
  • B1 (all suites). The only contract change is the additive nodes publish on message ingest (a new SSE event, documented in CONTRACTS.md); no POST/GET shape changes, so C2 and the Python suite are unaffected.

Bugfix: MeshCore cross-ingestor dedup keys on the stable channel name

A single physical MeshCore channel message heard by two ingestors that store the same logical channel at different local channel-slot indices was stored twice. The per-receiver channel index is not stable across ingestors (e.g. #bot sits at slot 4 on one device and slot 6 on another), yet it fed both the ingestor fingerprint discriminator (c<N> → two different messages.id values) and the #756 web content-dedup SELECT (AND channel = ? → no match), so neither dedup layer collapsed the duplicate. Fix (web-only, no wire change): the content-dedup matches on the sender-stable channel_name (NULL-safe) instead of the local channel index, so the safety net collapses the duplicate at the system of record regardless of differing ids/slots. Strengthens C5.

MD-A1 — Same message on different local channel slots collapses to one row

( cd web && bundle exec rspec spec/data_processing_spec.rb -e "meshcore content dedup" )

Expected: pass, including "collapses the same meshcore channel message heard on different local channel indices": two meshcore messages with identical from_id / to_id / text / in-window rx_time and the same channel_name ("#bot") but different channel indices (4 vs 6) and different ids collapse to a single stored row. Companion examples still hold: messages with a different channel_name are kept separate (the legitimate distinct-channel case), and different text / to_id / beyond-window rx_time stay separate.

MD-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and required to remain green: C5 (cross-ingestor dedup by id — now strengthened), the other #756 content-dedup examples (window inclusivity, different text/recipient), and B1. The pre-existing "does not collapse two meshcore messages on different channels" example is updated to use different channel names (the stable identifier) rather than different local indices — it is updated, not removed. No POST/GET/event contract change and no ingestor change, so C2, CONTRACTS.md, and the Python suite are unaffected.


Bugfix: Live-update DOM handling (map overlay, chat-tab scroll, last_heard fan-out)

Three defects in how a live SSE update touches the DOM, fixed independently of the (separately specced) flash visual redesign: (1) a positions / telemetry ingest advances the affected node's last_heard server-side (touch_node_last_seen) but published only its own collection, so the live dashboard never re-pulled the node row and the node table's "last seen" stayed stale until the safety poll; (2) the channel-tab list's horizontal scroll reset to the first tab on every refresh because renderChatTabs rebuilds the whole subtree (replaceChildren) and force-scrolled the active tab into view; (3) an open map-marker short-info overlay closed on every refresh because renderMap clears and rebuilds all markers (clearLayers), orphaning the overlay's anchor so cleanupOrphans closed it. Web-side only (Ruby publish fan-out + frontend JS); no POST/GET shape change, so the apex (I) and privacy (II) invariants are untouched (the new nodes publish is moot under PRIVATE, mirroring #822 / PS6).

LD-A1 -- positions/telemetry ingest also publishes nodes (live last_heard refresh)

( cd web && bundle exec rspec spec/pubsub_spec.rb \
    -e "publishes nodes on a positions ingest" \
    -e "publishes nodes on a telemetry ingest" \
    -e "does not publish nodes on a neighbors or traces ingest" )

Expected: pass. POST /api/positions and POST /api/telemetry each publish both their own collection and nodes (the telemetry route also now invalidates api:nodes:), so the dashboard re-fetches /api/nodes and the node-table "last seen" refreshes and flashes live -- mirroring the #822 messages-to-nodes fan-out. POST /api/neighbors and /api/traces deliberately do not publish nodes, honoring the VF3 boundary that neighbors/traces flash nothing (their last_heard refresh is surfaced silently by the safety poll).

LD-A2 -- channel-tab horizontal scroll is preserved across a refresh

( cd web && node --test public/assets/js/app/__tests__/chat-tabs.test.js )

Expected: pass. renderChatTabs captures the channel-tab list's scrollLeft before rebuilding the subtree and restores it afterward, and scrolls the active tab into view only on an explicit user tab switch (not on a passive refresh) -- so a live update no longer yanks the user back to the first tab while they scroll the channel list. A re-render yields a fresh tab-list element whose scrollLeft equals the pre-render value, and a passive render performs zero scrollIntoView calls.

LD-A3 -- an open map-marker overlay survives a live re-render

( cd web && node --test public/assets/js/app/__tests__/short-info-overlay-manager.test.js \
                       public/assets/js/app/main/__tests__/marker-overlay-preservation.test.js )

Expected: pass. The overlay stack gains reanchor(oldAnchor, newAnchor), which carries an open overlay onto a replacement anchor so a subsequent cleanupOrphans keeps it open (it closed it before). renderMap snapshots the node ids whose marker hosts an open overlay before clearLayers() and re-anchors each onto the rebuilt marker (captureOpenMarkerOverlays / restoreMarkerOverlays), so an overlay opened on the map stays open while live updates fire instead of snapping shut on every refresh.

LD-R1 -- Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and explicitly required to remain green: PS-A3 / PS-A4 (per-collection publish + coalescing -- the PS3 "thin event" and burst-coalescing examples are updated to a single-collection route (neighbors) since positions now also publishes nodes, not removed); VF-A2 / VF-A3 (flash gating + message-to-node fan-out -- the new positions/telemetry-to-node fan-out reuses the same flash path, and neighbors/ traces still flash nothing); CR-A1 (an idle re-render still materialises 0 entries -- the scroll/overlay preservation touches only already-built DOM); A2 / A2a / PS-A6 (privacy -- the new nodes publish is moot under PRIVATE); and B1 (all suites).


Feature: Live-update feedback v2 (fade, stacking, map wave, dedup, full log)

Maps to SPEC decisions LV1-LV9, which deliberately amend VF2/VF3/VF5. The <100 ms white strobe becomes a ~1.2 s white->role-colour fade with per-element stacked timers; a node highlight also emits a map-marker wave; the message highlight blinks only the message's own channel tab; the pub/sub gains a 1 s per-collection publish cooldown; the Log tab logs every live-event class; and a channel-tab dropdown selector is added. Run JS suites from web/; run the server in public mode for the curl/rspec checks.

LV-A1 -- ~1.2 s white->role-colour fade replaces the <100 ms strobe -- LV1, LV3

( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
grep -nE '@media \(prefers-reduced-motion: reduce\)' web/public/assets/styles/base.css
grep -nE '(animation|transition)[^;]*(1\.2s|120[0-9]ms)' web/public/assets/styles/base.css
grep -nE -- '--flash-role-color' web/public/assets/styles/base.css

Expected: pass / non-empty. The highlight keyframe runs ~1.2 s (not <100 ms), starts white and fades through the element's role colour (var(--flash-role-color, ...)) with increasing transparency to nothing, with no layout shift and a prefers-reduced-motion: reduce guard that suppresses it. The flash helper's FLASH_DURATION_MS is ~1200 and only toggles a class.

LV-A2 -- per-element stacked timers; a re-flash restarts cleanly -- LV2

( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )

Expected: pass. flashElement runs each element on its own timer and, when re-flashed mid-fade, cancels the prior removal timer before re-arming so the class is never cleared early; two distinct elements flashed in the same tick each keep an independent timer (no shared/global clock).

LV-A3 -- role colour is stamped on the element at render -- LV3

( cd web && node --test public/assets/js/app/__tests__/node-rendering.test.js \
                       public/assets/js/app/__tests__/main-flash.test.js )

Expected: pass. A rendered node-table row and chat message row carry --flash-role-color set from getRoleColor(role, protocol) (so the fade lands on the correct role colour for both protocols); the flash helper performs no colour lookup of its own.

LV-A4 -- a message fades its row and ONLY its own channel tab -- LV4

( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js \
                       public/assets/js/app/__tests__/main-flash.test.js )

Expected: pass. A messages ping fades the message row(s) and highlights the header of only the message's own channel tab (resolved via the message->tab map), never merely the active tab; the author node's row + marker fade via the existing message->nodes publish.

LV-A5 -- a node highlight emits a map-marker wave -- LV5

( cd web && node --test public/assets/js/app/main/__tests__/flash.test.js )
grep -nE 'live-flash-wave|@keyframes .*wave' web/public/assets/styles/base.css

Expected: pass / non-empty. Flashing a marker creates a transient expanding wave overlay (from ~12 px, growing and fading toward the role colour over ~1.2 s) added to the map and removed after the animation; neighbors/traces emit no wave (VF3 boundary). The wave is non-interactive and causes no layout shift.

LV-A6 -- per-collection 1 s publish cooldown dedups duplicate events -- LV6

( cd web && bundle exec rspec spec/pubsub_spec.rb -e "cooldown" )

Expected: pass. A burst of publish(...) calls is coalesced by the settle window in Subscriber#drain (default 1 s, env-tunable SSE_PUBLISH_COOLDOWN): once a change is pending the drain waits out the window, then returns each changed collection once (the structural pending-map coalescing), so N ingestors hearing a single packet produce one client refresh/flash. Collections that change during the same window each emit once (not suppressed). In-process only (no broker; apex-safe); settle: 0 disables it.

LV-A7 -- the Log tab is node-centric; message bodies never reach it -- LV7 (amended)

( cd web && node --test public/assets/js/app/__tests__/chat-log-tabs.test.js \
                       public/assets/js/app/__tests__/main-log-render.test.js \
                       public/assets/js/app/main/__tests__/chat-entry-keys.test.js )

Expected: pass. buildChatTabModel(...).logEntries carries no plaintext message entry: a decrypted message is recorded as a node-info update (reason message) for its sender, so the body lives only in its channel tab. Every live collection still has a Log representation -- new node, advert / node-info update ("Updated node info (advert)"), decrypted message ("Updated node info (message)"), position ("Broadcasted position info: ..." with a colon), neighbour, telemetry, trace, and encrypted message. The generic "updated node info ()" is emitted only when no more-specific event already claims that heard (a position/telemetry/neighbour/trace/message suppresses a redundant advert line). Amends the prior LV-A7, which required a plaintext message entry in the Log -- the oversight corrected here. Hidden-protocol and PRIVATE gates already applied to the chat are unchanged.

LV-A8 -- channel-tab dropdown selector -- LV8

( cd web && node --test public/assets/js/app/__tests__/chat-tabs.test.js )

Expected: pass. renderChatTabs renders a compact selector listing every tab that, when a channel is chosen, activates that tab - independent of the preserved horizontal scroll (LD-A2). Tab order, the default-active tab, and all data surfaces are unchanged.

LV-A9 -- engineering bar; invariants untouched -- LV9

( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: pass. New code carries the exact Apache header + JSDoc/RDoc and is 100% unit-tested; prefers-reduced-motion suppresses both the fade and the wave. Apex (I), privacy (II - messages still 404 under PRIVATE, so message fades/log are moot there; the LV6 cooldown is in-process with no broker), and parity (IV - role colours via getRoleColor for both protocols) are untouched.

LV-R1 -- Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. VF-A5 is amended (the duration grep now matches ~1.2 s, not <100 ms) - updated, not removed. At risk and required to remain green: VF-A2 (flash still fires only on SSE-ping deltas), VF-A4 (render before flash), VF-A6 / CR-A1 (idle re-render still materialises 0 entries), LD-A1 (positions/telemetry->nodes fan-out feeds the fade), LD-A2 (tab scroll preserved - the LV8 dropdown composes with it), A2 / A2a / PS-A6 (privacy), and B1 (all suites).


Bugfix: SSE stream must not block graceful shutdown

On Ctrl+C the dashboard hung ~30-45s before exiting: an open GET /api/events SSE stream held a Puma worker thread in its pump loop (which exited only on socket close or the 600s lifetime deadline), so Puma's graceful shutdown waited for it -- which in turn gated the at_exit federation/retention teardown (FH-A3). The federation announce (remote_instance_request_timeout, 30s) and the retention thread kept logging because the process could not exit. Pre-existing since the SSE pub/sub feature (#821), not the LV6 settle window. Fix (web-only): (1) the SSE pump exits when its subscriber is closed; (2) INT/TERM handlers close the live-update subscribers on shutdown (chained ahead of Sinatra's trap, since Puma Server#stop is async), so the streams end and Puma drains promptly; (3) a Puma force_shutdown_after backstop (default 3s, env PUMA_FORCE_SHUTDOWN) force-terminates anything still in flight. The apex (I) and privacy (II) invariants are untouched.

SD-A1 -- the SSE pump stops when its subscriber is closed (shutdown)

( cd web && bundle exec rspec spec/routes_events_spec.rb -e "stops pumping once the subscriber is closed" )

Expected: pass. Events.pump returns as soon as its subscriber is closed -- without writing further keepalives -- even while the stream is still open and the lifetime deadline is far off, so closing subscribers on shutdown ends every /api/events request instead of busy-looping or blocking for a heartbeat.

SD-A2 -- shutdown closes SSE subscribers and Puma is bounded

( cd web && bundle exec rspec spec/app_spec.rb -e "live-update shutdown handling" )
( cd web && bundle exec rspec spec/config_spec.rb -e "puma_force_shutdown_seconds" )

Expected: pass. close_live_update_subscribers! closes every open subscriber; install_pubsub_shutdown_signal_handlers! traps INT and TERM and its handler closes the subscribers; server_settings carries force_shutdown_after (= puma_force_shutdown_seconds; default 3s, env PUMA_FORCE_SHUTDOWN). Together these make Ctrl+C reap the SSE stream so Puma's graceful shutdown finishes and the at_exit federation/retention teardown (FH-A3) runs in seconds, not tens of them.

SD-R1 -- Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and required to remain green: PS-A2 / PS-A5 (the /api/events SSE stream + reconnect-resync still work -- the pump only gains a subscriber-closed exit), PS-A4 / LV-A6 (publish + 1s settle window are unchanged), FH-A3 (federation reaps in seconds -- now actually reachable on Ctrl+C because the SSE no longer blocks Puma), and B1 (all suites). No POST/GET/event contract change.

Bugfix: SSE streams must not starve the request-thread pool

The live production instance went unresponsive: every request 502'd, including the instance's own federation self-fetch of /api/nodes, and at shutdown exactly five /api/events connections closed (durations 45-160s). Root cause: a GET /api/events SSE stream pins one Puma worker thread for its whole lifetime (the pump loop runs synchronously on the request thread; SD-A1), but the subscriber cap (MAX_SUBSCRIBERS = 64) sat far above Puma's pool. With no thread config the app ran on Puma's MRI default of 5 threads, so ~5 dashboard clients holding an EventSource occupied every worker thread and no other request -- API read, ingest POST, or federation self-fetch -- could be served. The cap never tripped before the pool starved; live updates became load-bearing, violating PS8. Pre-existing since the SSE pub/sub feature (#821). Fix (web-only): (1) size Puma's thread pool in code via server_settings[:Threads] (Config.puma_threads_setting, default 16:96, env MIN_THREADS/MAX_THREADS); (2) clamp the SSE subscriber cap to puma_max_threads - sse_thread_reserve (env SSE_THREAD_RESERVE, default 32) so at least the reserve always remains for non-SSE traffic -- the defaults reconcile to the original 64 (96 - 32). New decision PS9 names the budget invariant (max_threads > MAX_SUBSCRIBERS + reserve). The apex (I), privacy (II), and parity (IV) invariants are untouched; no POST/GET/event contract changes.

TS-A1 -- SSE can never consume the whole request-thread pool

( cd web && bundle exec rspec spec/sse_thread_budget_spec.rb )

Expected: pass. Boots a real Puma with a small fixed pool (Threads "6:6", SSE_THREAD_RESERVE=4) and opens pool-many /api/events connections: at most pool - reserve are accepted (the rest get 503 and fall back to the safety poll, PS8), and a plain GET /version is still served promptly while SSE clients are connected. Against the unfixed code all six connections are accepted and the ordinary request times out (the outage).

TS-A2 -- thread budget exceeds the SSE subscriber cap by the reserve

( cd web && bundle exec rspec spec/config_spec.rb -e "puma thread budget" )
( cd web && bundle exec rspec spec/pubsub_spec.rb -e "effective subscriber cap" )
( cd web && bundle exec rspec spec/app_spec.rb -e "request-thread budget" )

Expected: pass. Config.puma_max_threads (default 96, env MAX_THREADS), Config.puma_min_threads (default 16, env MIN_THREADS), and Config.sse_thread_reserve (default 32, env SSE_THREAD_RESERVE) resolve and clamp sanely (min <= max); Config.puma_threads_setting returns "min:max"; PubSub.effective_max_subscribers equals min(MAX_SUBSCRIBERS, max_threads - reserve) (= 64 at defaults) and shrinks when the pool shrinks; and the application server_settings[:Threads] is present with max > MAX_SUBSCRIBERS (the invariant that was silently false before, when no :Threads was set at all).

TS-R1 -- Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and required to remain green: PS-A2 / PS-A5 (the /api/events SSE stream + reconnect-resync still work), PS-A3 (the subscriber cap still returns 503 at capacity -- now at the clamped value), SD-A1 / SD-A2 (shutdown still reaps SSE; server_settings still carries force_shutdown_after alongside the new Threads), and B1 (all suites). No POST/GET/event contract change.


Feature: Reliable dark basemap (CARTO Dark Matter) + tolerant tile loading

Maps to SPEC decisions DM1DM6. The basemap URL + tolerant-load policy live in web/public/assets/js/app/main.js (dashboard) and web/public/assets/js/app/federation-page.js (federation); the offline fallback in web/public/assets/js/app/main/offline-tile-layer.js; the now-removed tile filter in web/lib/potato_mesh/config.rb, web/lib/potato_mesh/application/helpers/config_helpers.rb, and web/public/assets/styles/base.css. Unless noted, run JS checks from web/ and shell checks from the repo root.

DM-A1 — Both maps use CARTO Dark Matter; HOT is gone — DM1

⚠️ Superseded by HT-A1HOT primary basemap (dark-filtered) with per-tile CARTO fallback). HOT is intentionally restored as the primary basemap, so the "HOT is gone" expectation below no longer holds by design; CARTO is retained as the per-tile fallback. HT-A1 is the authoritative check.

git grep -nE "basemaps\.cartocdn\.com/dark_all" -- web/public/assets/js
git grep -niE "openstreetmap\.fr|/hot/" -- web/public/assets/js web/lib web/views

Expected: the first prints the CARTO Dark Matter URL ({s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png) from one shared constant referenced by both the dashboard and federation maps; the second prints nothing — no openstreetmap.fr / /hot/ reference remains anywhere. The layer options (subdomains abcd, detectRetina, crossOrigin:'anonymous', maxZoom) are asserted by the JS map-init / DM-A3 suite.

DM-A2 — Tile-filter pipeline fully removed (native dark) — DM2

⚠️ Partially superseded by HT-A2. A single static dark filter is intentionally reintroduced for HOT tiles (CSS/JS constant only). The Ruby tile_filters / data-app-config tileFilters half of this check still holds (that plumbing stays removed), and none of the removed per-theme machinery (resolveTileFilter / applyFiltersToAllTiles / MutationObserver / --map-tile*-filter) returns. HT-A2 is the authoritative check.

git grep -niE "tile_filters|DEFAULT_TILE_FILTER|map_tile_filter|tileFilters|map-tile-filter|map-tiles-filter|resolveTileFilter|applyTileFilter|applyFiltersToAllTiles|applyFilterToTile|ensureTileHasCurrentFilter" -- web/lib web/public/assets web/views
git grep -n -A2 "def resolve_initial_theme" -- web/lib/potato_mesh/application/routes/root.rb

Expected: the first prints no output — every artifact of the per-theme grayscale/invert filter is gone from Ruby, JS (incl. settings.js and the theme.js applyFiltersToAllTiles hook), and CSS. The .map-tiles class may remain (it tags the tile layer) but carries no filter: rule and no --map-tile*-filter custom property. The second shows resolve_initial_theme still returns "dark" (the theme system was already dark-only; unchanged).

DM-A3 — Dashboard tolerates isolated tile errors — DM3

( cd web && node --test public/assets/js/app/main/__tests__/tile-failure-policy.test.js )

Expected: pass. The extracted, Leaflet-free basemap-liveness policy (main/tile-failure-policy.js) decides: (a) a tileerror — one or many — that arrives after at least one successful tileload does not request the offline fallback; (b) when the initial viewport yields zero successful loads and the layer signals load-complete (or the no-success error count crosses the threshold), the offline fallback is requested exactly once; (c) once latched "alive," later errors never re-request the fallback. The dashboard wires this policy to tiles.on('tileload'|'tileerror'|'load') so an isolated failed tile no longer flips the whole map to the offline placeholder.

DM-A4 — Adjacent light remnants removed — DM4

git grep -nE 'content="dark light"' -- web/views
git grep -nE "f6f3ee" -- web/public/assets

Expected: no output for either — the color-scheme meta is content="dark" and background.js resolves the dark background colour unconditionally ('#0e1418'), with no light-mode branch.

DM-A5 — Clean map: no attribution overlay — DM5

git grep -nE "attributionControl:\s*false" -- web/public/assets/js
git grep -nE "\battribution:" -- web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js

Expected: the first prints attributionControl: false on both the dashboard and federation maps (unchanged from today); the second prints nothing — no attribution: credit string was added.

DM-A6 — Apex/contract untouched — DM6

git grep -niE 'mqtt|mosquitto|paho|amqp|kafka|broker' -- web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js
git grep -nE "tileFilters" -- web/lib/potato_mesh/application/helpers/config_helpers.rb

Expected: no output for either. The basemap host is not a broker, so the apex check A1 stays green; and frontend_app_config no longer emits tileFilters, confirming nothing leaked into the data-app-config / /version surface (the /version config block — D1 / BF1 keys — is unchanged, so no /api/* or /version contract moves).

DM-A7 — Dead light CSS palette collapsed (dark-only) — DM7

git grep -niE "color-scheme:\s*light|f6f3ee|#0c0f12|#2b6cb0|fff4d6|#7a3f00|f0c05b" -- web/public/assets/styles/base.css
git grep -nE "^html \{|color-scheme: dark|^body\.dark \{" -- web/public/assets/styles/base.css

Expected: the first prints nothing — no light-palette hex values and no color-scheme: light remain (the dead light :root tokens, the always-overridden body.dark token block, and the light color-scheme are all gone). The second shows html { color-scheme: dark } and no body.dark { … } token-definition block — the :root block now carries the dark palette directly, so html itself resolves dark tokens; body.dark survives only as a prefix on component rules, which still apply because body always carries the class. The rendered dark UI is unchanged (confirmed by screenshot).

DM-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. At risk and explicitly required to stay green: B1 (all suites — the JS map/tile tests and the Ruby config/app specs), B4 (the exact Apache header on the new main/tile-failure-policy.js and its test), A1 (apex — the basemap CDN is not a broker), and D1 / BF1 (the /version config block is unchanged). The existing tile-filter assertions are updated or removed as dead, never left dangling: __tests__/config.test.js (drops the tileFilters expectation), __tests__/federation-page.test.js (drops tileFilters / themechange), the theme.js test (drops the applyFiltersToAllTiles hook), and the Ruby config/app specs that asserted data-app-config tileFilters. main/__tests__/offline-tile-layer.test.js stays green — the fallback layer is retained, now reached only per DM-A3.


Bugfix: Progressive backfill for every bulk collection (issue #832)

The server pages every bulk collection backward via ?before= (SPEC BP1-BP8), but only the message feed wired it on the client (the deferred follow-up BP9a). So the node table — and positions, telemetry, neighbors, traces — stalled at the newest MAX_QUERY_LIMIT (1000) rows the server returns in one page (the reported symptom: "the node table only lists 1000 items"). The fix mirrors the proven chat backfill (issue #802) across all five collections: the newest page paints first, then a one-shot background pager walks each collection's inclusive before cursor newest → oldest, de-duplicating by id and committing+rendering each page, until the visibility window is exhausted. The client row-caps on positions/telemetry/traces are lifted from a fixed count to the server's own window bound (so a backfilled page is not trimmed straight back out on the next refresh). Frontend-only: no API/DB/ingestor change, so the C4/C7 window floors, MAX_QUERY_LIMIT, and privacy are untouched.

CB-A1 — Every bulk collection pages backward past the first 1000-row page

( cd web && node --test public/assets/js/app/__tests__/main-collection-backfill.test.js )

Expected: pass. On a cold load whose newest page is full (=== the per-collection cap), each of nodes, positions, telemetry, neighbors, and traces issues at least one GET /api/<collection>?…&before=<cursor> request and merges the older rows in — so the loaded node set grows past NODE_LIMIT (1000) instead of stalling at it. The newest page is rendered before any backward paging starts (the page is never blank/blocking), matching the #802 progressive-load contract. A short newest page (window already exhausted) records no frontier and fires no backward request.

CB-A2 — Generic backward pager + before cursor on every fetcher

( cd web && node --test public/assets/js/app/main/__tests__/data-fetchers.test.js )

Expected: pass. paginateCollection(fetchPage, {limit, before, idOf, cursorOf}) generalises the message walk (paginateMessages now delegates to it): it pages newest → oldest, de-duplicates by idOf, advances an inclusive before cursor to the oldest cursorOf value of each page, and stops on a short page / no-progress / missing cursor / maxPages. fetchNodes/fetchPositions/fetchTelemetry/ fetchNeighbors/fetchTraces each forward a positive before and omit a non-positive one (mirroring the existing fetchMessages before contract, C7); fetchTraces accepts applyAgeFilter:false so the pager sees the server's raw page length and terminates correctly.

CB-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. At risk and explicitly required to stay green: C7 / PL-A1 / PL-A2 (the message pager is unchanged — paginateMessages delegates to the new generic pager with identical observable behavior), B1 (all suites), and B4 (the exact Apache header on the new test). The cursor columns match the server's ORDER BY per collection (last_heard for nodes, rx_time for the rest), so no widening of the C4 window floor is possible; the backfill only ever narrows (BP2).


Bugfix: MeshCore dedup window vs inter-ingestor clock skew; warm-cache chat gap

Two chat defects found on production potatomesh.net (v0.7.1-rc0) with two live MeshCore ingestors. (2) Duplicates: 28% of MeshCore rows were distinct-id copies of the same transmission from two ingestors whose host clocks differ by a consistent ~126 s (median 126 s, p90 133 s). The content dedup (data_processing/messages.rb) keys correctly on channel_name (#825, MD-A1) but bounded the match to rx_time ± 30 s, so 89.6% of dup pairs fell outside the window and persisted; the one-shot #756 purge additionally keyed on the per-receiver channel index (not channel_name), so it could not collapse the cross-slot copies even when it ran. Fix: widen MESHCORE_CONTENT_DEDUP_WINDOW_SECONDS 30→300 (covers ~99.5% of the observed skew; accepted tradeoff: a sender's identical text repeated within 300 s collapses — chosen over a 28% dup rate; the one-shot purge applies this transitively, so a chain of such repeats spanning longer than 300 s also collapses — a deliberately aggressive one-time cleanup, gentler per-insert guard governs new rows), key the purge on channel_name, and bump MESHCORE_CONTENT_DEDUP_BACKFILL_VERSION so the purge re-runs once to clear the accumulated duplicates. (1) Missing messages: on a warm revisit the cache (FC2) seeds an older contiguous block, but the delta since-fetch is capped at MESSAGE_LIMIT and returns the newest page (ORDER BY rx_time DESC LIMIT), which need not reach the cache — orphaning the window between the cache's newest row and the newest page's oldest row. backfillChatHistory anchored at the global-oldest loaded row and paged further into the past, so it never bridged the gap. Fix: anchor the backfill at the live frontier (the oldest row of the newest delta page). The duplicate inflation (defect 2) widened the gap, so the two interact, but each has a distinct root cause. Web-only; no wire/contract change; apex (I)/privacy (II) untouched.

MW-A1 — Dedup spans the observed inter-ingestor clock skew (runtime + purge)

( cd web && bundle exec rspec spec/data_processing_spec.rb -e "meshcore content dedup" \
                            spec/database_spec.rb -e "cross-ingestor meshcore pair" )

Expected: pass. Runtime: two MeshCore copies with identical from_id / to_id / text / channel_name ("#ping") but different channel slots (10 vs 18) and rx_time 126 s apart collapse to one row (was two — the 30 s window). The one-shot purge collapses the same cross-slot, clock-skewed pair to a single row by keying on channel_name and spanning the widened window. MESHCORE_CONTENT_DEDUP_WINDOW_SECONDS == 300 and MESHCORE_CONTENT_DEDUP_BACKFILL_VERSION is bumped so the purge re-runs once. Companion #756/#825 examples still hold (different channel_name / text / to_id stay separate; beyond-window — now > 300 s — stays separate).

MW-A2 — Warm-cache load bridges the orphaned middle gap

( cd web && node --test public/assets/js/app/__tests__/main-cache-refresh.test.js )

Expected: pass, including "warm cache + capped since-page bridges the orphaned middle gap": with a seeded cache whose newest row predates the newest since-page by more than one page, the background backfill fetches the in-between rows (anchored at the live frontier) so every in-window message loads — no orphaned hole. The cold-load path is unchanged (live frontier == global-oldest when there is no cache), so the existing seed-then-delta examples (FC-A2) and the progressive-load walk (PL-A1/PL-A2) stay green.

MW-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and required to remain green: C5 / MD-A1 (cross-ingestor dedup — strengthened, not weakened), the #756 backfill examples (within-window collapse, beyond-window preserve — now measured against 300 s, idempotent, user_version-gated), FC-A2 (seed-then-delta — the warm delta contract is unchanged; only the backfill anchor moved), PL-A1/PL-A2 (progressive load), and B1. No POST/GET/event contract change and no ingestor change, so C2, CONTRACTS.md, and the Python suite are unaffected.


Bugfix: Chat-log entry retention, advert suppression, and chat vertical scroll

Three independent chat-panel defects, all frontend-only (no API/DB/ingestor change, so the apex (I) and privacy (II) invariants are untouched):

(A1) rebuildNodeDerivedState stored the aggregated snapshot arrays back into the raw accumulators (allTelemetryEntries / allPositionEntries / allNeighbors), which are also the merge targets for every refresh + backfill page. Re-aggregating an already-aggregated array is lossy (aggregateSnapshots clones with {...snapshot}, dropping the non-enumerable snapshots history, and merges oldest-last so the stalest reading's rx_time/id win), collapsing each node's history to {stale-first, newest} — so a telemetry/position Log entry appeared for one refresh tick and vanished on the next (no scrolling involved). The accumulators now stay raw; the aggregated forms are locals used only to enrich node records, so every packet keeps a stable, id-keyed Log entry.

(A2) The advert-suppression claim key folded in node_num and required BOTH node_id and node_num to match. Specific events (telemetry/positions/ neighbors) frequently carry only node_id (node_num is int|nil per CONTRACTS, commonly nil for MeshCore) while the node record carries a node_num, so the combined key failed to match and a redundant "Updated node info (advert)" line leaked alongside the specific entry (violating LV7/LV-A7). Suppression now keys on the canonical !%08x id alone (which normaliseNodeId derives from node_num when needed), so the id identifies a node across every event shape.

(B) Every chat render force-scrolled the active panel to the bottom (in setActiveTab, plus a second scrollActiveChatPanelToBottom call), so a live update (40-80/hr in production) yanked the reader back to the bottom and made upward scrolling impossible. The prior LD-A2 fix preserved only the horizontal tab-list scroll. renderChatTabs now captures the active panel's vertical scrollTop before the subtree rebuild and restores it.

CL-A1 -- telemetry/position Log entries survive successive refreshes

( cd web && node --test public/assets/js/app/__tests__/main-log-snapshot-retention.test.js )

Expected: pass. After one node emits three telemetry packets across three refreshes, all three stay loaded (getLoadedTelemetryCount() === 3) and the rendered Log shows all three "Broadcasted telemetry" entries — the raw accumulator is no longer collapsed to a single per-node aggregate by the next tick's re-aggregation.

CL-A2 -- the advert is suppressed when a specific event omits node_num, and for encrypted-message hears

( cd web && node --test public/assets/js/app/__tests__/chat-log-tabs.test.js )

Expected: pass. When the node record carries a node_num but the telemetry/ position rows carry only node_id, buildChatTabModel(...).logEntries still emits the telemetry and position entries and no redundant node-info (advert) entry. An id-less heard (no node_id, no derivable node_num) claims nothing and is never suppressed. An encrypted message (in either the messages or the logOnlyMessages feed) claims its sender's heard, so a node heard only via a 🔒 encrypted message on channel <id> line shows no redundant Updated node info (advert) beneath it — the encrypted-message line is that heard's Log representation, mirroring how a decrypted message becomes a (message) node-info. Realises LV-A7 ("a position/telemetry/.../message suppresses a redundant advert line") across the node_num-nil and encrypted- message shapes that previously slipped through.

CL-A3 -- a passive chat re-render preserves the reader's vertical scroll

( cd web && node --test public/assets/js/app/__tests__/chat-tabs.test.js )

Expected: pass. renderChatTabs captures the active panel's scrollTop before the replaceChildren rebuild and restores it on the fresh panel: a reader scrolled up keeps their exact offset across a passive refresh, a bottom-pinned reader stays pinned to the new bottom (tail-follow), and an initial render (no prior panel) pins to the bottom. The per-render force-scroll (and the redundant scrollActiveChatPanelToBottom) are gone; panel scroll-to-bottom now fires only on an explicit tab switch (click/dropdown). Composes with the LD-A2 horizontal scroll preservation and the LV8 dropdown.

CL-R1 -- Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and explicitly required to stay green: LV-A7 (node-centric Log; the advert-suppression rule is strengthened, not weakened), LD-A2 (horizontal tab scroll still preserved -- the new vertical-scroll preservation composes with it), LV-A8 (the channel dropdown still jumps tabs), VF-A6 / CR-A1 (an idle re-render still materialises 0 entries -- the scroll capture touches only already-built DOM), CB-A1 (every bulk collection still backfills; the accumulators it merges into are raw, which is the shape the model already expects), and B1 (all suites). Frontend-only: no POST/GET/event contract change, so CONTRACTS.md and the Python suite are unaffected.


Bugfix: UDP-transport hardening & bridge failure-tracker coverage

Four small defects fixed as a batch. The first two live on the passive UDP-transport surface (PR #838), which shipped with no SPEC/ACCEPTANCE feature section — the contract was silent there, so these are its first command-backed checks. The third closes a test-coverage gap (D9/B1) in the Matrix bridge's poison-message tracker (PR #839). The fourth repairs the Python CI dependency drift that turned main red after #838. Formatting drift found alongside (rufo on web/views/layouts/app.erb, the cause of the red Ruby workflow on main) is covered by the existing B5, no new check needed.

UH-A1 — malformed PRIMARY_CHANNEL_KEY fails at import, not in the retry loop

( . .venv/bin/activate && pytest -q tests/test_config_unit.py -k PrimaryChannelKey )
PRIMARY_CHANNEL_KEY='not-base64!!' python -c 'import data.mesh_ingestor.config'  # exits non-zero, names the var

Expected: the unit tests pass; the one-liner fails with ValueError: PRIMARY_CHANNEL_KEY is not valid base64: 'not-base64!!'. …. config.py validates the key as base64 at import time (decoding exactly as meshtastic_udp_decode.expand_default_key later would), matching the existing TRANSPORT/PROTOCOL import-time validation. Previously the raw value was stored unchecked and only decoded lazily inside channel_hash / decrypt_meshpacket, so with PRIMARY_CHANNEL_NAME set a malformed key raised binascii.Error out of connect() — caught by daemon._try_connect's generic except Exception, which logged only "Failed to create mesh interface" and retried forever: the service never ingested and never surfaced the cause. Valid keys of any decodable length (1-byte default AQ==, 16/32-byte PSKs) are accepted unchanged; blank still falls back to AQ==.

UH-A2 — UDP multicast sockets bind the group address, never all interfaces

( . .venv/bin/activate && pytest -q tests/test_meshtastic_udp_socket_unit.py tests/test_capture_udp_fixtures_unit.py )
git grep -n 'bind(("", ' -- data/

Expected: tests pass; the grep prints nothing. Both data/mesh_ingestor/protocols/meshtastic_udp_socket.py and its documented mirror data/tools/capture_udp_fixtures.py bind (group, port) instead of the wildcard ("", port) (CodeQL py/bind-socket-all-network-interfaces): the kernel then delivers only datagrams addressed to the multicast group, so unicast traffic sent to the port on any local interface never reaches the socket. Receive behavior for "Mesh via UDP" traffic is unchanged (the transport is multicast-only); binding a group address is POSIX behavior (Linux/macOS, the platforms the transport targets). The capture tool, previously untested, gains unit coverage of its socket plumbing.

UH-A3 — bridge failure-tracker success-path reset is covered — D9/B1

( cd matrix && cargo test poll_once_clears_failure_tracker_when_failed_message_recovers )

Expected: pass. The most common real-world sequence — a message fails a poll transiently, then succeeds on the next — executes the success-path reset in poll_once (matrix/src/main.rs: clear failing_msg_id / failing_msg_attempts after a successful handle_message), which no prior test reached: the watermark test stops at the first failure and the poison test's tracker is already cleared by the skip before the next success. The test arms the tracker with a 500 node lookup, swaps the mock to 200, re-polls, and asserts the tracker is cleared, the watermark advances through the recovered message to the batch tail, and the message is not reprocessed. Verified by mutation: with the reset disabled (if false && …) only this test fails — every other test stays green, which is the coverage gap this closes.

UH-A4 — Python CI installs the ingestor deps from the manifest

grep -n 'pip install -r data/requirements.txt' .github/workflows/python.yml

Expected: one match in the workflow's install step. The workflow previously hand-listed packages (black pytest pytest-cov meshtastic meshcore), which silently drifted from data/requirements.txt when PR #838 added cryptography>=42.0.0 — every Python CI run on main since then failed test collection with ModuleNotFoundError: No module named 'cryptography'. Installing from the manifest (which also carries the dev deps) keeps CI in lockstep with the documented Setup command and removes the drift channel.

UH-R1 — Regression: prior acceptance still holds

( . .venv/bin/activate && pytest -q tests/ ) && ( . .venv/bin/activate && black --check ./ )
( cd matrix && cargo test --all --all-features && cargo fmt --all -- --check \
            && cargo clippy --all-targets --all-features -- -D warnings )
( cd web && bundle exec rspec ) && ( cd web && npm test ) && ( cd web && bundle exec rufo --check . )

Expected: all green, including B5 (rufo/black — views/layouts/app.erb re-formatted). At risk and explicitly required to stay green: the UDP provider suite (test_meshtastic_udp_unit.py — the provider consumes the validated key and group-bound socket unchanged), test_config_unit.py's UDP-var defaults (blank-fallback semantics unchanged), and the bridge watermark/poison tests (the new test only adds coverage; poll_once is untouched). The web app, federation wire, and Flutter app are behaviorally untouched by this batch — the only edits outside the four fixes are the lockstep 0.7.2 version-bump stamps (manifests, lockfiles, iOS plist, README pinned tags, S-A1), verified by tests/test_version_sync.py.


Bugfix: MeshCore ghost nodes (stale contact enrichment discarded)

A MeshCore node first seen via a bare ADVERTISEMENT push was upserted as a minimal placeholder stamped lastHeard = now (receiver wall clock). The follow-up roster contact record — carrying the real name/role/public key — is stamped lastHeard = last_advert, the sender-side advert-creation time, which is always older than the placeholder's receive time (seconds for healthy clocks, years for broken ones). upsert_node's row-level freshness guard (WHERE excluded.last_heard >= nodes.last_heard) therefore discarded the entire named update, permanently: every later contact re-post (auto-update, periodic snapshot, restart) is also sender-stamped and also lost, while each advertised-position ingest re-bumps the row's last_heard. Result: nameless "ghost" nodes with a hex short_name, NULL role (displayed as the CLIENT default), and an advert-stamped position_time — violating the reconciliation promise in CONTRACTS.md ("a later full contact advertisement reconciles it", SPEC A4e). Fixed web-side (Ruby): after the guarded upsert, a non-synthetic record additionally fills identity columns that are still NULL (num, short_name, long_name, macaddr, hw_model, role, public_key, is_unmessagable) regardless of staleness — stale data can fill gaps but can never overwrite fresher values, and synthetic placeholders remain barred from real rows. No ingestor/API/DB-schema change; protocol-neutral (Invariant IV).

GH-A1 — Stale contact records name advert-placeholder ghosts

( cd web && bundle exec rspec spec/data_processing_spec.rb -e "stale contact record enrichment" )

Expected: pass. Replaying the ingestor's wire sequence — bare-advert placeholder (lastHeard = now, no name) followed by the roster contact record (lastHeard = last_advert, older by 17 s and by ~2 years in a second example) — leaves the node named with its real role and public key. The stale record never regresses last_heard, never overwrites an existing name/role, empty strings never fill long_name / short_name (the other identity fields of the same record still fill), and a stale synthetic=1 chat placeholder still cannot touch a real row.

GH-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test ) && ( cd web && bundle exec rufo --check . )
( . .venv/bin/activate && pytest -q tests/ )

Expected: all green. At risk and explicitly required to stay green: the pre-existing upsert_node guard specs (data_processing_spec.rb — role/ identity preservation, generic-name fallback, synthetic flag + merge #755/#803) and database_spec.rb's node-merge suites, since the fix appends a second NULL-fill statement inside the same upsert_node transaction; the Python ingestor is untouched (A4e's advert-capture suite unchanged).


Bugfix: Docker release builds on 32-bit ARM (fail-fast teardown + missing armv7 toolchain)

The v0.7.2 release build (run 28775124854) failed twice the same way: PR #838 added cryptography>=42.0.0 (AES-CTR for the passive UDP transport), which — like its C dependency cffi — publishes no 32-bit ARM wheels (neither musllinux nor manylinux armv7l), so the python:*-alpine armv7 image build compiles both from source. The Dockerfile's throwaway .build-deps lacked the required toolchain, dying at src/c/_cffi_backend.c:15:10: fatal error: ffi.h: No such file or directory. Because the build matrix left fail-fast at its default (true), that one leg cancelled all eight healthy publish jobs — web and matrix-bridge images for every architecture were never pushed, and GitHub's carried-over-failure semantics make re-running any job of the run impossible (new attempts are cancelled within seconds by the failed sibling). UH-A4 fixed the same #838 dependency drift for python.yml; the image-build half was uncovered — no prior criterion asserted that container images build. Fix: fail-fast: false on the build-and-push matrix (one architecture's breakage must never withhold the other architectures' images), and the armv7 compile toolchain (libffi-dev openssl-dev pkgconfig rust cargo) added to the .build-deps that are removed again after pip install (image size unchanged). Cold armv7 builds compile cryptography's Rust extension under QEMU (~3060 min), amortised by the workflow's per-service/arch GHA layer cache.

DK-A1 — one failing architecture cannot tear down the release matrix

grep -n 'fail-fast: false' .github/workflows/docker.yml

Expected: exactly one match, inside the build-and-push job's strategy block — sibling matrix jobs keep building and pushing when one leg fails, so a single-architecture defect degrades the release to 8/9 images instead of 2/9.

DK-A2 — ingestor image builds for linux/arm/v7 (cryptography from source)

docker buildx build --platform linux/arm/v7 -f data/Dockerfile --target production .

Expected: exit 0 (requires QEMU binfmt: docker run --privileged --rm tonistiigi/binfmt --install arm; a cold build compiles cffi + cryptography from source and may take 3060 min emulated). Zero-docker fallback (static form, suitable for sandboxes without a daemon):

sed -n '/virtual .build-deps/,/pip install/p' data/Dockerfile \
  | grep -v '^[[:space:]]*#' | grep -cE 'libffi-dev|openssl-dev|pkgconfig|rust|cargo'

Expected: prints 5 — the armv7 source-build toolchain is present in .build-deps (comment lines excluded; the packages are still removed by the trailing apk del .build-deps). Rust-drift caveat, so the next failure of this class is recognised quickly: a future cryptography bump may require a newer Rust than the pinned Alpine release ships; the failure mode is this same job failing with a Rust version error, and the remedies are bumping PYTHON_VERSION (newer Alpine) or capping cryptography in data/requirements.txt.

DK-R1 — Regression: prior acceptance still holds

grep -nA3 '^on:' .github/workflows/docker.yml
git ls-files '.github/workflows/docker.yml' 'data/Dockerfile' \
  | xargs grep -L 'Copyright © 2025-26 l5yth & contributors'

Expected: the workflow still triggers on v* tag pushes and workflow_dispatch (release flow unchanged); the license-notice grep prints nothing (B4 intact). No source code, dependency manifest, or test suite is touched by this fix — B1 suites are unaffected by construction; the only behavioral deltas are matrix cancellation policy and armv7 build-stage packages.


Feature: HOT primary basemap (dark-filtered) with per-tile CARTO fallback

Maps to SPEC decisions HT1HT8. The shared basemap factory lives in web/public/assets/js/app/basemap-config.js; the per-tile timeout→CARTO tile layer in web/public/assets/js/app/main/fallback-tile-layer.js; the dashboard wiring in web/public/assets/js/app/main.js and federation wiring in web/public/assets/js/app/federation-page.js; the static dark filter in web/public/assets/styles/base.css; the offline last-resort tier in web/public/assets/js/app/main/offline-tile-layer.js (dashboard only). Run JS checks from web/, shell checks from the repo root.

HT-A1 — HOT is the primary basemap on both maps; CARTO retained as fallback — HT1

⚠️ CARTO-URL half superseded by BL-A2Bugfix: Basemap provider blend (chess-pattern fix)). The CARTO fallback source is intentionally migrated from the natively-dark Dark Matter (dark_all) to the colored Voyager (rastertiles/voyager), so the dark_all grep below no longer matches by design. The HOT-primary half stands (HOT is still the primary on both maps); BL-A2 is the authoritative check for the fallback source.

git grep -nE "tile\.openstreetmap\.fr/hot" -- web/public/assets/js
git grep -nE "basemaps\.cartocdn\.com/dark_all" -- web/public/assets/js

Expected: the first prints the HOT URL ({s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png) from one shared basemap module (basemap-config.js) referenced by both the dashboard and federation maps; the second still prints the CARTO Dark Matter URL ({s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png) — retained, now as the per-tile fallback source, not the primary. HOT options (subdomains:'abc', maxZoom:19, crossOrigin:'anonymous') and CARTO options (subdomains:'abcd', detectRetina, crossOrigin:'anonymous') are asserted by the HT-A3/HT-A5 suites. Supersedes DM-A1 (which required the HOT reference to be absent).

HT-A2 — Dark filter reintroduced for HOT only; static, dark-only, off the contract — HT2

⚠️ Filter-scope half superseded by BL-A1Bugfix: Basemap provider blend (chess-pattern fix)). The dark filter is intentionally no longer HOT-only: .map-tiles-fallback now carries the same filter as .map-tiles-hot (BL3), so the two providers blend. The greps below still pass unchanged (the filter is still one static base.css rule, the removed Ruby/contract machinery stays removed, resolve_initial_theme is still "dark"); only the scope prose ("HOT-only", ".map-tiles-fallback { filter: none }") is amended. BL-A1 is the authoritative check for the shared filter. Offline placeholder tiles still stay unfiltered.

git grep -nE "grayscale\(1\) invert\(1\)" -- web/public/assets/styles/base.css
git grep -niE "tile_filters|DEFAULT_TILE_FILTER|map_tile_filter|tileFilters|resolveTileFilter|applyFiltersToAllTiles|--map-tile" -- web/lib web/public/assets/js web/public/assets/styles web/views
git grep -n -A2 "def resolve_initial_theme" -- web/lib/potato_mesh/application/routes/root.rb

Expected: the first prints the reintroduced dark filter (grayscale(1) invert(1) brightness(0.9) contrast(1.08)) as a static rule on the per-tile class .map-tiles-hot in base.css (Leaflet puts a layer's className on the tile container, not each tile, so per-tile filtering uses a per-tile class). The second prints nothing — none of the removed per-theme machinery returns: no Ruby tile_filters/DEFAULT_TILE_FILTER_*, no data-app-config tileFilters, no JS resolveTileFilter/applyFiltersToAllTiles, and no --map-tile*-filter custom property. The filter is one static CSS rule (shared by .map-tiles-hot and .map-tiles-fallback per BL3; offline placeholder tiles carry neither class and stay unfiltered); the third shows resolve_initial_theme still returns "dark" (app stays dark-only, so no light filter exists). Supersedes the CSS/JS half of DM-A2; the Ruby/contract half of DM-A2 still holds.

HT-A3 — Per-tile 1000 ms timeout swaps HOT→CARTO — HT3

⚠️ Superseded by SB-A1 / SB-A5Feature: Dual stacked basemap layers (HOT over CARTO, no timeout)). The per-tile timeout-and-swap mechanism this criterion checks was removed by design: HOT and CARTO now load as two always-on stacked layers with no per-tile deadline, so main/fallback-tile-layer.js and its test are deleted and the command below no longer resolves. SB-A1 (no FALLBACK_TIMEOUT_MS / fallback-tile-layer symbols remain) and SB-A5 (both layers feed one liveness policy) are the authoritative checks. Retained for historical context only — do not run the command below.

( cd web && node --test public/assets/js/app/main/__tests__/fallback-tile-layer.test.js )

Expected: pass. The Leaflet-free fallback logic decides: (a) a tile whose HOT image loads before 1000 ms keeps the HOT source (filtered) and cancels its timer; (b) a tile whose HOT image fires error is swapped to the CARTO URL for the same {z}/{x}/{y} immediately; (c) a tile whose HOT image neither loads nor errors within 1000 ms is swapped to CARTO on timeout; (d) a swapped tile is marked .map-tiles-fallback (unfiltered) and requests the CARTO subdomain/retina URL. The 1000 ms threshold is a single named constant (the source of truth).

HT-A4 — Offline placeholder only when BOTH providers fail (dashboard) — HT4

⚠️ Superseded by SB-A5Feature: Dual stacked basemap layers). The fallback ladder is preserved but re-expressed: with two independent layers the single tile-failure-policy is now fed by both (any tileload from either latches "alive"; offline fires only on a comprehensive dual outage), and main/fallback-tile-layer.js is deleted — so the command below no longer resolves. SB-A5 (tile-failure-policy.test.js + the new main-app-map-init.test.js) is the authoritative check; the federation map still keeps no offline tier. Retained for historical context only — do not run the command below.

( cd web && node --test public/assets/js/app/main/__tests__/fallback-tile-layer.test.js \
                       public/assets/js/app/main/__tests__/tile-failure-policy.test.js )

Expected: pass. The fallback layer signals Leaflet tileload when either HOT or the CARTO fallback serves a tile, and signals tileerror only when the CARTO fallback tile also fails (covered by fallback-tile-layer.test.js). The DM3 tile-failure-policy is unchanged and stays green: the offline GridLayer (main/offline-tile-layer.js) activates only on comprehensive both-provider failure (zero successful loads across the initial viewport), preserving DM-A3 tolerance one tier lower. The federation map has no offline tier (unchanged from DM3).

HT-A5 — Both maps use the one shared basemap factory — HT5

git grep -nE "createBasemapLayer" -- web/public/assets/js/app/basemap-config.js web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js
git grep -nE "createOfflineTileLayer|activateOfflineTiles" -- web/public/assets/js/app/federation-page.js

Expected: the first shows createBasemapLayer defined once in basemap-config.js and called by both main.js and federation-page.js — one basemap definition, both maps identical (HOT-primary + CARTO fallback). The second prints nothing — the offline GridLayer tier is dashboard-only (federation gains no kill-basemap/offline logic, per DM3).

HT-A6 — No attribution overlay (reaffirms DM5) — HT6

git grep -nE "attributionControl:\s*false" -- web/public/assets/js
git grep -nE "\battribution:" -- web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js web/public/assets/js/app/basemap-config.js

Expected: the first prints attributionControl: false on both maps (unchanged from DM-A5); the second prints nothing — no attribution: credit string was added for HOT or CARTO.

HT-A7 — Apex/contract untouched — HT7

git grep -niE 'mqtt|mosquitto|paho|amqp|kafka|broker' -- web/public/assets/js/app/basemap-config.js web/public/assets/js/app/main/fallback-tile-layer.js web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js
git grep -nE "tileFilters" -- web/lib/potato_mesh/application/helpers/config_helpers.rb
git diff --name-only HEAD -- web/Gemfile web/package.json data/requirements.txt matrix/Cargo.toml app/pubspec.yaml

Expected: the first two print nothing — the basemap hosts are not brokers (apex A1 stays green) and frontend_app_config emits no tileFilters (no /version / data-app-config contract move). The third prints nothing — no dependency manifest changed, so guard-edits.py never triggers and the frozen stack (D6) is unaffected.

HT-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. Explicitly amended and required to stay green: DM-A1 (superseded by HT-A1 — HOT is intentionally back), DM-A2 (CSS/JS half superseded by HT-A2 — the static dark filter is intentionally back; the Ruby/contract half still holds), DM-A3 (extended by HT-A4 — tolerance preserved behind the CARTO tier). Still green unchanged: DM-A5 / DM-A6 / DM-A7, A1 (apex — no broker), B1 (all suites), B4 (exact Apache header on the new main/fallback-tile-layer.js and its test), and D1 / BF1 (the /version config block is unchanged). The DM-era JS tests are updated to the HOT-primary

  • CARTO-fallback wiring, not removed: __tests__/config.test.js, __tests__/federation-page.test.js, and the leaflet-stub map-init harness.

Bugfix: Basemap provider blend (chess-pattern fix)

Maps to SPEC decisions BL1BL4. The graceful timeout and colored CARTO source live in web/public/assets/js/app/basemap-config.js; the shared dark filter in web/public/assets/styles/base.css; both are locked by web/public/assets/js/app/__tests__/basemap-blend.test.js. The per-tile HOT vs CARTO looks (dark-filtered HOT tiles beside unfiltered CARTO tiles, on a routine 1000 ms fallback) rendered the basemap as a light/dark checkerboard; the fix makes fallback rare (2500 ms) and blends the two providers to one dark look (colored Voyager source + shared filter). Run JS checks from web/, shell checks from the repo root.

BL-A1 — Graceful 2500 ms timeout + colored Voyager fallback + shared filter

⚠️ Timeout half superseded by SB-A1; blend half by SB-A3/SB-A4Feature: Dual stacked basemap layers). There is no longer a per-tile timeout: FALLBACK_TIMEOUT_MS is deleted with the mechanism, so assertion (1) below (=== 2500) no longer exists — the rewritten basemap-blend.test.js command still passes but now verifies only the colored-Voyager source and the shared per-layer filter (assertions (2)/(3) below), plus the single pane veil. The Voyager source and the shared filter remain valid and are now the authoritative checks under SB-A3 (shared filter on both .leaflet-layer.map-tiles-hot / -fallback) and SB-A4 (single .leaflet-tile-pane opacity: 0.5625); SB-A1 covers the absence of the timeout constant. Read assertion (1) below as historical only.

( cd web && node --test public/assets/js/app/__tests__/basemap-blend.test.js )

Expected: pass. Asserts (1) FALLBACK_TIMEOUT_MS === 2500 (raised from the aggressive 1000 ms, so a slow-but-arriving HOT tile beats the deadline and fallback stays rare); (2) CARTO_TILE_URL targets the colored CARTO Voyager style (/rastertiles/voyager/), not the natively-dark dark_all; and (3) base.css applies the same grayscale(1) invert(1) … dark filter to .map-tiles-fallback as to .map-tiles-hot (no longer filter:none). Together these make a viewport mixing HOT and CARTO tiles render as one coherent dark basemap instead of a checkerboard.

BL-A2 — No Dark Matter reference remains; Voyager is the sole fallback source

git grep -n "dark_all" -- web/public
git grep -nE "rastertiles/voyager" -- web/public/assets/js/app/basemap-config.js

Expected: the first prints nothing — the natively-dark Dark Matter source is fully replaced (production constant and test fixtures alike); the second prints the Voyager fallback URL from the one shared basemap module. Supersedes the dark_all half of HT-A1; the HOT-primary half of HT-A1 is unchanged (HOT is still the primary basemap on both maps).

BL-R1 — Regression: prior acceptance still holds

⚠️ Superseded by SB-R1Feature: Dual stacked basemap layers). This clause predates the two-layer redesign and describes state that has since changed — fallback-tile-layer.test.js is now deleted (not "updated"), and the per-tile swap mechanism HT-A3 checked is gone. SB-R1 is the current regression authority (it re-runs npm test + rspec and enumerates every amended prior criterion, including these). The command below still holds — both suites stay green — so it is safe to run; only the per-criterion prose beneath is historical.

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. Explicitly amended and required to stay green: HT-A1 (the CARTO fallback URL is now Voyager, not dark_all — the basemap-config.test.js / fallback-tile-layer.test.js fixtures are updated, not removed); HT-A2 (the dark filter now also covers .map-tiles-fallback — still one static base.css rule; the removed Ruby/contract tileFilters machinery stays removed, offline tiles stay unfiltered); HT-A3 (the per-tile swap mechanism is unchanged — only the timeout constant and the swapped-in URL differ). Still green unchanged: HT-A4 / A5 / A6 / A7 (fallback ladder, one shared factory on both maps, no attribution, apex/contract untouched), A1 (no broker — the basemap hosts are raster CDNs), B1 (all suites), and B4 (exact Apache header on the new basemap-blend.test.js).


Bugfix: Node-table telemetry hidden by newer packets of another type

Meshtastic telemetry is a protobuf oneof — each packet carries exactly one metric family (device / environment / power / air-quality; data/mesh_ingestor/handlers/telemetry.py). The node table's environment columns exist only through the client-side per-node telemetry merge (aggregateTelemetrySnapshotsmergeTelemetryIntoNodes), which merged a fixed SNAPSHOT_WINDOW = 7 packet window: seven newer device/power packets evicted the last environment packet wholesale, hiding temperature / humidity / pressure (and, on the node detail page, IAQ etc.) although the rows were still in the accumulator and the DB. Selection and precedence were also array-order driven (first-7-encountered, position-0 wins), which is wrong for warm IndexedDB cache seeds (key order) and incremental mergeById appends — stale values could beat fresh ones. Fix: aggregateTelemetrySnapshots now performs a per-field latest-non-null merge — each field takes the value from the node's newest packet (by rx_time, falling back to telemetry_time) that carries it non-null, order-independently, bounded by the caller's existing 7-day accumulator window instead of a packet count. A null/absent field never clears an older valid value. Frontend read-side only — no API/DB/ingestor change; apex (I) and privacy (II) untouched; protocol-neutral (IV). The raw accumulators stay raw (CL-A1/bugfix A1 unchanged).

TM-A1 — per-field latest-non-null telemetry merge

( cd web && node --test public/assets/js/app/__tests__/snapshot-aggregator.test.js )

Expected: pass. With one environment packet followed by more than SNAPSHOT_WINDOW newer device/power packets for the same node, the aggregate retains the environment metrics (temperature / humidity / pressure) alongside the newest device metrics; the newest non-null value per field wins regardless of input array order (inputs that differ only in order produce identical aggregates whenever timestamps differ; an equal-timestamp conflict resolves deterministically to the row later in the input); a null/absent field never overwrites an older valid value; the hidden snapshots history is chronological and latestSnapshot is the newest packet by timestamp, not by array position.

TM-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. At risk and explicitly required to remain green: CL-A1 (the Log's raw-accumulator retention — main-log-snapshot-retention.test.js — the fix changes only the aggregated locals, never the accumulators), the node detail page and chart suites (node-details.test.js, node-page chart tests — the aggregate keeps its snapshots / latestSnapshot shape), and data-merge.test.js (mergeTelemetryIntoNodes consumes one aggregate per node unchanged). Node / position / neighbor aggregation keep their existing SNAPSHOT_WINDOW semantics — only telemetry aggregation changes. No Ruby/Python surface is touched (C2 and the Python suite unaffected).


Feature: MeshCore RF metrics (RSSI/SNR/hops/path) & roster-eviction assertion

Maps to SPEC decisions RF1RF8. Ingestor-side logic lives in data/mesh_ingestor/protocols/meshcore/ (runner, handlers, decode) and the Meshtastic hops computation in the packet store path; web-side, one additive migration adds messages.hops, messages.path, and nodes.rssi, mapped in data_processing/ and serialized by the existing GET routes. Store + API only — no dashboard rendering (RF7). Unless a check says otherwise, Python commands assume the repo venv (. .venv/bin/activate).

RF-A1 — hops-travelled stored on messages, both protocols — RF1

( . .venv/bin/activate && pytest -q tests/ -k "hops" )
( cd web && bundle exec rspec spec -e "message hops" )

Expected: pass. MeshCore: a CHANNEL_MSG_RECV/CONTACT_MSG_RECV payload with path_len: N (N ≤ 63) yields a stored packet with hops == N; the 255 "direct" sentinel yields hops == 0; an absent path_len omits the field. Meshtastic: a packet carrying both hopStart and hopLimit yields hops == hopStart hopLimit; either absent → field omitted. Web: the messages table has an additive hops INTEGER column (NULL for legacy rows), POST /api/messages accepts it, GET /api/messages serializes it, and the existing hop_limit column/semantics are untouched.

RF-A2 — channel-message RSSI + path via the decrypt_channels join — RF2

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py -k "decrypt or path or rssi" )
( cd web && bundle exec rspec spec -e "message path" )

Expected: pass. _run_meshcore sets mc.decrypt_channels = True before mc.connect() returns. A channel-message payload carrying joined RSSI/path stores both (rssi → existing column; path → additive messages.path TEXT, lowercase hex, hashes in travel order); a payload without them (join miss, RX-log-less firmware) stores the message identically with the fields absent — never an error. DMs never carry path/rssi (E2E, no join — RF2's documented boundary). The message id (_derive_message_id inputs) is byte-identical with and without the new fields.

RF-A3 — RX-log ADVERT frames upsert full node identity + signal — RF3

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py -k "rx_log or advert" )
( cd web && bundle exec rspec spec -e "node rssi" )

Expected: pass. An RX_LOG_DATA event with payload_typename == "ADVERT" upserts a node keyed by the canonical id derived from the full adv_key (_meshcore_node_id), carrying adv_name (long name), the _MESHCORE_ADV_TYPE_ROLE role for adv_type, a position when adv_lat/adv_lon are present, and per-reception snrnodes.snr, path_lennodes.hops_away, rssi → the additive nodes.rssi INTEGER column. A malformed advert (missing/short adv_key, absent parse fields) is tolerated without raising. Non-ADVERT RX-log frames produce no upsert and remain in the DEBUG-only capture; RX_LOG_DATA itself no longer lands in ignored-meshcore.txt. With zero RX-log frames the provider still passes RF-A1/RF-A4 behavior (graceful degradation). Web: POST /api/nodes accepts rssi, GET /api/nodes serializes it, and it stays NULL for Meshtastic nodes (no source).

RF-A4 — roster-eviction assertion: read-modify-write, skip, tolerate — RF4

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py -k "autoadd" )

Expected: pass. After connect the runner calls get_autoadd_config: when bit 0x01 is already set → no set_autoadd_config call (no flash write); when unset → exactly one set_autoadd_config(config | 0x01) (type-filter bits 14 preserved, one-byte payload so autoadd_max_hops is untouched); when the query/set errors or times out (pre-1.16 firmware) → a warning is logged and startup continues (the connection still succeeds, mirroring _ensure_channel_names tolerance). No env/config knob gates the behavior (RF4: always-on, README-documented).

RF-A5 — CONTACT_DELETED is an explicit debug no-op — RF5

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py -k "contact_deleted" )

Expected: pass. CONTACT_DELETED appears in the subscribed handler map; on event it debug-logs and performs no node deletion, no POST, and no ignored- file write — the web DB retains evicted nodes (retention.rb remains the only data-expiry authority).

RF-A6 — contract documented; migration additive; dedup frozen — RF6

git grep -nE 'hops|path|rssi' -- data/mesh_ingestor/CONTRACTS.md | head
grep -nE 'ALTER TABLE (messages|nodes) ADD COLUMN' data/migrations/*rf_metric*.sql
grep -nE 'hops|path' data/messages.sql; grep -n 'rssi' data/nodes.sql
( . .venv/bin/activate && pytest -q tests/ -k "derive_message_id or dedup" )

Expected: CONTRACTS.md documents messages.hops/messages.path (with the 255→direct rule and the path hex format) and nodes.rssi (advert→node mapping). The migration contains only additive ALTER TABLE … ADD COLUMN statements (no drops/rewrites); the base schema files carry the new columns for fresh databases. The dedup tests pass unchanged — the fingerprint inputs are byte-identical to pre-feature (MD-A1/MW-A1 hold).

RF-R1 — Regression: prior acceptance still holds

( . .venv/bin/activate && pytest -q tests/ )
( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: every prior check still passes. At risk and explicitly required to remain green: A4e (the MeshCore adverts-gap checks — the bare-ADVERTISEMENT minimal-upsert fallback must keep working alongside the new RX-log enrichment; its assertions are updated, not removed), C2 (test_mesh.py POST shapes — all field additions are additive), MD-A1 / MW-A1 (MeshCore dedup — id derivation byte-identical), MC-A1 / LH-A1 / GH-A1 (MeshCore message/contact machinery — naming, last_heard, and stale-contact behavior unchanged), and B1/B4/B5 (all suites, headers, formatters). The JS suite is exercised for regression only — RF7 adds no frontend behavior.


Bugfix: Missing telemetry at ingest (all families, both protocols)

Two ingest-time data losses. Meshtastic: the telemetry protobuf oneof has eight variants, but extraction targeted only deviceMetrics.* / environmentMetrics.* paths — PowerMetrics (16 fields), AirQualityMetrics (25, incl. PM series, particle counts, CO2, formaldehyde, VOC/NOx), HealthMetrics (3), LocalStats (15), HostMetrics (9), TrafficManagementStats (7), and the repeated oneWireTemperature were dropped; the last four families were not even recognised by the discriminator, landing as rows with no telemetry_type and no metrics. The web app mirrored the drop (no columns, no metric definitions, power_metrics/air_quality_metrics consulted only for type inference). MeshCore: telemetry was structurally unreachable — no subscription to TELEMETRY_RESPONSE/STATUS_RESPONSE/BATTERY, no telemetry commands issued, no CayenneLPP mapping — although the meshcore library (≥2.3.5) exposes self battery/sensors and per-contact pulls, violating Invariant IV (protocol parity; the web/DB side was already protocol-ready). Fix: the ingestor extracts every field of all eight Meshtastic families (telemetry_type gains local_stats/health/host/traffic; body temperature stays distinct as health_temperature; one_wire_temperature is a JSON float list), the web app stores and serves all new columns (schema + boot auto-migration + insert/upsert; GET /api/telemetry is SELECT *), and the MeshCore provider collects host self-telemetry (no airtime) plus round-robin contact telemetry/status polls (conservative, env-tunable, disableable). Frontend intentionally untouched. CONTRACTS.md amended additively (D8); apex (I) and privacy (II) untouched.

TI-A1 — Meshtastic ingestor extracts every telemetry family

( . .venv/bin/activate && pytest -q tests/test_handlers_unit.py -k "ExtendedTelemetry" )

Expected: pass. For each oneof family the queued /api/telemetry payload carries the family's snake_case metric keys and the correct telemetry_type: power (ch1_voltagech8_current), air_quality (pm*_standard/environmental, particles_*, co2*, form_*, pm_voc_idx, pm_nox_idx, particles_tps), health (heart_bpm, spo2, health_temperature — never the ambient temperature key), local_stats (counters + reuse of uptime_seconds/channel_utilization/air_util_tx), host (freemem_bytes, diskfree*_bytes, load*, user_string), traffic (packets_inspected, …), and environment's one_wire_temperature list.

TI-A2 — Web app stores and serves the extended metrics

( cd web && bundle exec rspec spec/data_processing_spec.rb -e "extended metric families" )

Expected: pass. insert_telemetry persists values from the power_metrics / air_quality_metrics / health_metrics / local_stats / host_metrics / traffic_management_stats sub-objects (and their flat snake_case keys) into real columns; the diagnostics telemetry_type values are accepted; one_wire_temperature round-trips as a JSON array; user_string stores text. Existing databases gain the columns via the boot auto-migrator (ensure_schema_upgrades), fresh installs via data/telemetry.sql.

TI-A3 — MeshCore provider collects telemetry

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py -k "telemetry" )

Expected: pass. The MeshCore event-handler map subscribes TELEMETRY_RESPONSE, STATUS_RESPONSE, and BATTERY; CayenneLPP entries map to the canonical metric keys (temperature, relative_humidity, barometric_pressure, voltage, current, lux, battery_level); status responses map bat (mV) → voltage (V) and uptime; events resolve pubkey_pre to the contact's canonical node id (host prefix → host node); resulting packets flow through store_packet_dictstore_telemetry_packet with protocol="meshcore". The poll loop honours MESHCORE_TELEMETRY_POLL_SECONDS (0 disables contact polling) and MESHCORE_SELF_TELEMETRY_SECONDS, one on-air request at a time (local LoRa only — no broker, Invariant I). Each contact is additionally capped by a fixed 24 h per-node cooldown (stamped at the poll attempt; an all-fresh roster tick transmits nothing, and departed contacts are pruned from the stamp table). RX_ONLY=1 forbids every ingestor-initiated transmission: contact polls stop entirely while the airtime-free companion-link self reads continue.

TI-R1 — Regression: prior acceptance still holds

( . .venv/bin/activate && pytest -q tests/ ) && ( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: every prior check still passes. At risk and explicitly required to remain green: C2 (canonical POST shapes — the metric additions are additive, existing keys unchanged), A4b/A4e (MeshCore provider conformance and advert handling — new subscriptions must not disturb existing handlers), A2/A2a (privacy — telemetry remains ungated by PRIVATE, unchanged), D2 (channel filters unaffected), and the host-telemetry suppression window (self-poll responses are throttled by the existing store_telemetry_packet host gate). The frontend is intentionally untouched (TM-A1 unchanged); tests/ fixtures are unmodified so CI replay (C2) is unaffected.


Feature: Live relative-time tick (dynamic timers)

Maps to SPEC decisions RT1RT5. Every rendered relative-time field — the node-table "last seen" / "last position" cells, an open map popup/tooltip "Last seen" line, the node-detail (/n/:id) last-seen / last-position rows, and the federation instances "last update" column — counts up in real time between data refreshes instead of holding the value stamped at render. The core is a new shared ticker module (web/public/assets/js/app/main/relative-time-ticker.js); the wired surfaces are main.js (table + map overlays), node-page/single-node-table.js, and federation-page.js. Frontend-only: no server, API, or ingestor change, so all checks are JS unit suites run at the repo root.

RT-A1 — Shared ticker: ~1 s cadence, write-on-change, hidden-tab idle — RT2, RT3

( cd web && node --test public/assets/js/app/main/__tests__/relative-time-ticker.test.js )

Expected: pass. One shared ~1 s interval drives every registered field: a tick recomputes the age string with the existing formatters and writes the DOM only when the string changed (a field still reading 3d 4h is not rewritten); opt-in is attribute-based (data-ts-ago), so double-registration is impossible by construction — removing the attribute (or the element) stops its writes, and stopping the ticker clears the interval. While the document is hidden the ticker idles (no writes); on visibilitychange back to visible every field snaps to its correct current value in one pass. The ticker never consults the auto-refresh play/pause toggle — pausing data updates does not stop the clock (RT3) — and it performs no fetch of any kind (RT1).

RT-A2 — Dashboard ages tick in place: table cells + open map overlays — RT1, RT2

( cd web && node --test public/assets/js/app/__tests__/main-relative-time.test.js )

Expected: pass. With node-table rows rendered, advancing the clock ~1 s updates the "last seen" / "last position" cell text (e.g. 4s5s) in place — the row and cell element identities are unchanged (no re-materialization), and an open marker popup/tooltip's "Last seen:" line ticks while it stays open. Ticks issue zero network requests and materialize zero chat entries (CR-A1 posture preserved).

RT-A3 — Node-detail + federation ages tick; one shared formatter home — RT1, RT2

( cd web && node --test public/assets/js/app/__tests__/node-page.test.js \
                       public/assets/js/app/__tests__/federation-page.test.js )

Expected: pass. The node-detail last-seen / last-position cells and the federation "last update" cell carry the tick opt-in markup (data-ts-ago + their format variant) and each page arms the shared ticker on init. federation-page.js no longer defines its own local relative-time formatter: its historical distinct format (5m ago — coarse, suffixed; not the dashboard's 5m 0s) is hoisted verbatim into main/format-utils.js as timeAgoSuffixed (one definition repo-wide, RT2) and preserved exactly (RT4) via the ticker's ago-suffixed variant.

RT-A4 — Format unchanged — RT4

( cd web && node --test public/assets/js/app/main/__tests__/format-utils.test.js )

Expected: pass with the pre-existing expectations unchanged — the suite's original format fixtures (50s, 2m 5s, 1h 1m, 1d 1h, the empty-string cases for missing/invalid timestamps; SPEC RT4's 4s / 3m 12s / 5h 2m / 3d 4h are canonical examples of the same branches) still hold verbatim: the diff to this suite deletes or edits zero assertions (it only adds timeAgoSuffixed coverage). The feature adds no format branch; only when the strings are recomputed changes.

RT-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. At risk and explicitly required to remain green: CR-A1 (main-chat-render-incremental.test.js — an idle tick still materializes 0 entries; the ticker must never re-render), LD-A2 (channel-tab scroll) and CL-A3 (chat vertical scroll — in-place text writes must not reset either), LD-A3 (marker-overlay-preservation.test.js — an open overlay survives refreshes and ticking), LV-A1/LV-A2 (flash.test.js / main-flash.test.js — a tick write must never restart or truncate a role-colour fade), TM-A1 (snapshot-aggregator.test.js — the node-table render path gains only tick registration), and B1 (all suites). No Ruby/Python/Rust/Flutter surface is touched, so rspec, the Python suite (C2), cargo test, and flutter test are unaffected by construction — rspec is still run to prove it.


Feature: Dual stacked basemap layers (HOT over CARTO, no timeout)

Maps to SPEC decisions SB1SB8. The two-layer factory lives in web/public/assets/js/app/basemap-config.js; the shared dark filter and the single pane-dimming veil in web/public/assets/styles/base.css; the dashboard policy wiring in web/public/assets/js/app/main.js; the federation wiring in web/public/assets/js/app/federation-page.js. The prior per-tile timeout+swap module (web/public/assets/js/app/main/fallback-tile-layer.js) and its test are removed. Run JS checks from web/, shell checks from the repo root.

SB-A1 — Two always-on stacked layers from one factory; no timeout — SB1

( cd web && node --test public/assets/js/app/__tests__/basemap-config.test.js )
git grep -nE "tile\.openstreetmap\.fr/hot" -- web/public/assets/js/app/basemap-config.js
git grep -nE "rastertiles/voyager" -- web/public/assets/js/app/basemap-config.js
git grep -nE "FALLBACK_TIMEOUT_MS|fallback-tile-layer|wireTileFallback|buildFallbackTileUrl|prefersRetinaTiles" -- web/public/assets/js

Expected: the unit suite passes; the first grep prints the HOT URL ({s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png) and the second the CARTO Voyager URL ({s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png) from the one shared basemap-config.js. The fourth grep prints nothing — the per-tile timeout constant, the retired fallback-tile-layer module, and its helpers (wireTileFallback / buildFallbackTileUrl / prefersRetinaTiles) are gone. createBasemapLayer(L) returns the base + overlay pair (CARTO base className:'map-tiles-fallback' zIndex:1 detectRetina:true; HOT overlay className:'map-tiles-hot' zIndex:2 maxZoom:19), each a plain L.tileLayer (no TileLayer.extend subclass), and createBasemapLayer(null) degrades to a null-ish/empty result the callers guard. Amends the URL/mechanism half of HT-A1 and supersedes HT-A3 (there is no per-tile swap to exercise); the HOT-primary intent survives as HOT being the opaque top layer.

SB-A2 — HOT overlay opaque over CARTO; Leaflet-native per-tile fade kept — SB2

( cd web && node --test public/assets/js/app/__tests__/basemap-config.test.js )
git grep -nE "fadeAnimation\s*:\s*false" -- web/public/assets/js

Expected: the unit suite asserts the HOT overlay option set carries no layer-opacity reduction (HOT renders opaque, zIndex:2, above the CARTO base zIndex:1), so a loaded HOT tile fully covers the CARTO cell beneath it. The grep prints nothingfadeAnimation is never disabled, so Leaflet's native ~200 ms per-tile opacity fade drives the CARTO→HOT dissolve, and no competing custom tile-opacity transition is introduced to fight it. A slow HOT tile shows the already-present CARTO tile underneath rather than a blank cell.

SB-A3 — Shared dark filter on the per-layer containers (blend) — SB3

( cd web && node --test public/assets/js/app/__tests__/basemap-blend.test.js )
git grep -niE "tile_filters|DEFAULT_TILE_FILTER|map_tile_filter|tileFilters|resolveTileFilter|applyFiltersToAllTiles|--map-tile" -- web/lib web/public/assets/js web/public/assets/styles web/views

Expected: the blend suite passes — base.css applies the same grayscale(1) invert(1) brightness(0.9) contrast(1.08) filter (with its -webkit- twin) to both .map-tiles-hot and .map-tiles-fallback, in one rule, and the CARTO fallback filter equals the HOT filter (never none). The second grep prints nothing: the removed per-theme Ruby/JS/data-app-config tile-filter machinery stays removed (the filter is one static CSS constant). The selectors now target the layer containers (#map .leaflet-layer.map-tiles-hot / .map-tiles-fallback) rather than individual <img> tiles, because with no per-tile swap Leaflet stamps the className on the layer container. Amends the filter-selector half of HT-A2 / BL-A1(3); the filter value and its single-rule home are unchanged.

SB-A4 — Single pane dimming veil; brightness parity — SB4

git grep -nE "leaflet-tile-pane" -- web/public/assets/styles/base.css
git grep -nE "\.leaflet-tile\.map-tiles\b" -- web/public/assets/styles/base.css

Expected: the first prints a single #map .leaflet-tile-pane { opacity: 0.5625 } rule (0.5625 = 0.75 × 0.75, the effective brightness the single pre-SB layer rendered at). The second prints nothing — the former #map .leaflet-tile.map-tiles { opacity: 0.75 } selector and the bare map-tiles container class are gone (the filter now sits on the .leaflet-layer.map-tiles-hot / .leaflet-layer.map-tiles-fallback per-layer containers, never on .leaflet-tile.map-tiles). (The grep is anchored to .leaflet-tile.map-tiles on purpose — a bare \.map-tiles\b would false-match at the hyphen inside the surviving .map-tiles-hot / -fallback class names.) Dimming once at the pane makes brightness independent of the layer count, so the two stacked layers (and the offline placeholder as a possible third) composite to today's look.

SB-A5 — One liveness policy fed by both layers; dual-outage-only offline — SB5

( cd web && node --test public/assets/js/app/main/__tests__/tile-failure-policy.test.js )
( cd web && node --test public/assets/js/app/__tests__/main-app-map-init.test.js )

Expected: both pass. The Leaflet-free policy (main/tile-failure-policy.js, unchanged) is wired on the dashboard so that a tileload from either layer latches the basemap "alive" and activateOfflineTiles fires only when the initial viewport produced zero successes across both layers: with HOT down but CARTO up (or vice-versa) the map stays live and the placeholder never shows; only a both-providers outage reaches it, and the offline switch removes both online layers. The federation map keeps no kill-basemap/offline logic (unchanged from DM3/HT5). Extends HT-A4 (the ladder's top rung is now two parallel providers).

SB-A6 — Always-on dual egress documented; no phone-home — SB6

git grep -niE "carto|cartocdn|openstreetmap\.fr|both (tile )?providers|two CDNs|third-party tile" -- README.md
git grep -niE "\bapi[_-]?key\b|\btoken\b|\banalytics\b|\bcookie\b" -- web/public/assets/js/app/basemap-config.js

Expected: the README documents, operator-visibly, that both basemap CDNs (HOT + CARTO) are requested on every viewport (the doubled third-party tile egress is disclosed, not silent). The second grep prints nothing: basemap-config.js sends no API key, token, cookie, or analytics parameter to either CDN — only {z}/{x}/{y} tile coordinates — so D11 (no phone-home) holds. (The alternatives are \b-anchored on purpose — an un-anchored cookie would false-match the doc word cookieless, which asserts the very absence being checked.) Both providers keep attributionControl:false (no attribution overlay; reaffirms HT-A6/DM-A5). The basemap hosts are raster CDNs, not brokers (apex A1 holds; guard-edits.py untriggered — no manifest change).

SB-A7 — Stack & contract untouched; both maps share the factory — SB7

git grep -nE "createBasemapLayer" -- web/public/assets/js/app/main.js web/public/assets/js/app/federation-page.js
git grep -nE "tileFilters|map-tile" -- web/lib/potato_mesh/application/helpers/config_helpers.rb

Expected: the first prints createBasemapLayer called from both main.js and federation-page.js (one shared factory owns the whole basemap on both maps; HT5/BL4 preserved). The second prints nothing — no tile config leaks into /version or data-app-config; the filter, pane opacity, and layer z-indices are frontend constants, so there is no contract change and no version bump (D7/D8). Native Leaflet only — two L.tileLayers, no custom subclass, no new dependency or build step.

SB-A8 — Retired module gone; suites green; exact headers — SB8

test ! -e web/public/assets/js/app/main/fallback-tile-layer.js && echo "module removed"
test ! -e web/public/assets/js/app/main/__tests__/fallback-tile-layer.test.js && echo "test removed"
( cd web && npm test )
head -n 15 web/public/assets/js/app/basemap-config.js

Expected: both echos print (the retired module and its test are deleted together — never left dangling); npm test is fully green with the JS coverage floor held; the header check shows the exact Apache block with Copyright © 2025-26 l5yth & contributors. Every new/changed unit ships full JSDoc and clean linters (black/rufo untouched — no Python/Ruby change).

SB-R1 — Regression: prior acceptance still holds

( cd web && npm test ) && ( cd web && bundle exec rspec )

Expected: every prior check still passes. Explicitly amended and required to stay green: HT-A1 (HOT is still the top/primary-visible basemap on both maps; its URL half holds — only the CARTO-as-per-tile-fallback framing is amended to CARTO-as-base-layer), HT-A2 / BL-A1 (the shared dark filter is unchanged in value and still one static base.css rule — only the selector granularity moves from per-tile to per-layer; the removed Ruby/contract tileFilters machinery stays removed; offline tiles stay unfiltered), HT-A3 (superseded — the per-tile swap mechanism it checked no longer exists; the checkerboard it guarded against is removed structurally), BL-A1(1) (the FALLBACK_TIMEOUT_MS === 2500 assertion is deleted with the constant — there is no timeout), BL-A2 (no dark_all reference; Voyager remains the CARTO source). Still green unchanged: HT-A4 / SB-A5 (fallback ladder → offline last tier), HT-A5 / SB-A7 (one shared factory on both maps), HT-A6 / SB-A6 (no attribution), HT-A7 / DM-A4 (apex/contract untouched), A1 (no broker — the basemap hosts are raster CDNs), B1 (all suites), and B4 (exact Apache header on the changed basemap-config.js / basemap-config.test.js / basemap-blend.test.js). No Ruby/Python/Rust/Flutter production surface is touched, so rspec (run above), the Python suite, cargo test, and flutter test are unaffected by construction.


Bugfix: MeshCore duplicate-node reconciliation (stale same-name identities)

Maps to SPEC decisions MR1MR6. One physical node had surfaced as three rows (!ae46e493 live real, !25ee3330 retired real still name-resolved by a stale roster, !f0b61f1e name-derived synthetic): the #755/#803 merge deadlocked on the absolute same-name-real ambiguity guard, duplicate message copies granted the retired identity eternal last_heard liveness, and one advert flood minted four position rows. Reproduced deterministically before fixing; the checks below are the regression captures.

MR-A1 — Keyed-evidence tracking (nodes.last_advert_heard) — MR1

( cd web && bundle exec rspec spec/data_processing_spec.rb -e "keyed-evidence tracking" )

Expected: pass. A non-synthetic upsert carrying user.publicKey records its own heard time in last_advert_heard and advances it forward-only; a message touch (touch_node_last_seen) advances last_heard but never the evidence column; a synthetic placeholder upsert records no evidence (NULL).

MR-A2 — Positive-staleness merge ambiguity, both directions — MR2

( cd web && bundle exec rspec spec/data_processing_spec.rb \
    -e "stale keyed evidence" -e "fresh keyed evidence" -e "evidence-fresh real" \
    -e "legacy row" -e "no evidence either way" )

Expected: pass. merge_synthetic_nodes absorbs the synthetic although a same-name real row exists that is positively stale — both when its last_advert_heard is old and when it is a legacy row whose only signal is an old position_time (the production shape), in each case even though message touches polluted that row's last_heard to "now". It still refuses when the rival is evidence-fresh, and — critically — when the rival has no evidence either way (NULL / NULL, the state of every row right after the migration): absence of evidence is never treated as staleness. merge_into_real_node folds the synthetic into the survivor when the other candidate is positively stale, and still refuses when both are live. The retired real row itself is never deleted (retention stays the only expiry authority).

MR-A3 — Duplicate-copy sender resolution (no steal, no phantom liveness) — MR3

( cd web && bundle exec rspec spec/data_processing_spec.rb \
    -e "duplicate-copy sender resolution" -e "ConstraintException recovery" )

Expected: pass. For MeshCore copies of one message (same id, divergent from_id): two evidence-fresh reals keep the existing attribution (no last-writer-wins); a stale-keyed copy neither steals attribution from an evidence-fresh real nor advances its own node's last_heard (the reception is credited to the resolved winner instead); a keyed real copy still upgrades a synthetic-attributed row; and a nil/blank/unknown sender ranks 0 (never supersedes). The same rank rule holds on the ConstraintException insert-race fallback — the path MR3 names as "where two ingestors' copies meet" — in both hash- and array-row DB modes, and a Meshtastic message keeps last-writer-wins (the rule is MeshCore-scoped).

MR-A4 — One advert flood → one position identity — MR5

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py \
    -k "adv_timestamp or flood or falls_back_to_recv" )

Expected: pass. Four RX-log copies of one advert (same adv_key + adv_timestamp, distinct recv_time) hand the sender-side timestamp to the position store and collapse to a single /api/positions id; _rx_advert_to_node_dict anchors position.time on adv_timestamp while lastHeard stays receiver-side; absent/zero adv_timestamp degrades to recv_time.

MR-A5 — synthetic flag on the node API — MR4

( cd web && bundle exec rspec spec/app_spec.rb -e "synthetic flag" )

Expected: pass. GET /api/nodes/:id and GET /api/nodes emit synthetic: true on placeholder rows and omit the key entirely on real rows (compact convention, no synthetic: false noise).

MR-R1 — Regression: prior acceptance still holds

( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ )

Expected: every prior check still passes. At risk and explicitly required to remain green: MC-A1/MC-A2 (#803 placeholder naming/repair — unchanged paths), the #755/#756 merge and dedup suites (database_spec.rb, data_processing_spec.rb — the pre-existing "refuses when two reals share the long_name" examples stay green because raw-seeded rows carry no keyed evidence and two-candidate/zero-fresh ambiguity still refuses), LH-A1/LH-A2 (last-heard carry through both merge helpers), A4e/RF3 (RX-advert node upserts — only the position anchor moved), MD-A1/MW-A1 (message dedup fingerprint untouched), and B1 (all suites). MC-R1's wording "the merge helpers are unchanged" is superseded by SPEC MR2 for the ambiguity bound; everything else it protects still holds.


Bugfix: MeshCore roster sync must not warm last_heard (issue #853)

Maps to SPEC decision RS1. Loading the MeshCore contact roster (ensure_contacts() at launch and on every reconnect; auto_update_contacts re-fetches on adverts) re-POSTed each positioned contact's position with rx_time = now, and the web folds rx_time into last_heard via MAX (update_node_from_position), so a contact that was actually last heard months ago was stamped active on every sync — a long-dead node reappearing in the 7-day list. The node-upsert path already used the contact's real last_advert (MR1's stated intent); only the position path violated it. Fix is ingestor-side: the two roster-sync callers stamp the position rx_time from the contact's last_advert, so last_heard = MAX(last_advert, last_advert) = last_advert. Genuinely-live paths (self-info, RX-log adverts) keep rx_time = now.

RS-A1 — Roster-sync positions carry the contact's real reception time

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py \
    -k "rx_time_uses_last_advert or honours_rx_time_override or defaults_rx_time_to_now" )

Expected: pass. _process_contacts (bulk) and _process_contact_update (per-contact NEW_CONTACT/NEXT_CONTACT) queue /api/positions with rx_time == last_advert (not the wall clock), so the web-side last_heard = MAX(rx_time, position_time) resolves to last_advert rather than now. _store_meshcore_position accepts an explicit rx_time override and, absent one, still defaults to the wall clock (live-path behavior unchanged).

RS-A2 — Live advert paths still stamp now (fix is roster-scoped)

( . .venv/bin/activate && pytest -q tests/test_provider_unit.py \
    -k "rx_log_data_advert_position_rx_time_is_now or self_info" )

Expected: pass. An on-air RX-log ADVERT (on_rx_log_data) and the host SELF_INFO position keep rx_time = now — they are genuinely-live receptions, so their last_heard must still advance to now. Only the roster-replay paths change; MR5's sender-side position_time anchor for RX-log adverts is untouched.

RS-R1 — Regression: prior acceptance still holds

( . .venv/bin/activate && pytest -q tests/ )
( cd web && bundle exec rspec ) && ( cd web && npm test )

Expected: every prior check still passes. At risk and explicitly required to remain green: A4e/RF3 and MR-A4/MR5 (RX-log advert node upserts and the adv_timestamp position anchor — the RX path keeps rx_time = now), the existing _store_meshcore_position / _process_contacts / _process_contact_update position specs (updated to assert the roster rx_time, not removed), and C2 (test_mesh.py end-to-end). No web/DB/API shape changes, so the Ruby and JS suites are unaffected by construction.