* web,data: reconcile MeshCore stale same-name node duplicates * data: MeshCore roster sync must not warm last_heard
194 KiB
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) restatedata/mesh_ingestor/CONTRACTS.md. Those two files are authoritative if any wording here drifts.
How to use this document
- Do the one-time Setup below.
- Run each check in Layers A–D. Each check states a command and an Expected result. Commands are written for a POSIX shell at the repo root unless noted.
- Record PASS/FAIL per check, pasting the command output.
- 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).
A2 — Privacy & consent first — SPEC Invariant II
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/,*.lockfiles, 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 inweb/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'svia_mqttexemption wording. Treat these two files as documented exemptions until the A1b filter codifies them. - B1 — sandbox DNS breaks the
POST /api/instancesspec block. In sandboxed environments whose resolver maps the suite's test domains (mesh.example, …) into the SSRF guard's restricted address ranges, 17spec/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.pyfails 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 localDummyEventhelpers monkeypatch the globalthreading.Eventwith await(self, timeout)signature that requires an argument, and Python 3.14'sThread.start()callsself._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 naivetimeout=Nonedefault 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 globalthreading.Eventin those three tests; until then, judge C2 by the full-suite run.
Feature: Chat channel test-deprioritization
Maps to SPEC decisions F1–F4. 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 S1–S7. 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 True — reticulum 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 FS1–FS6 — 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_addresses → Addrinfo.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 FC1–FC7. 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 AV1–AV5. 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 2–3 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 BP1–BP9. ?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 PS1–PS8. 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/messagesstill 404s underPRIVATE, and the stream now additionally carries nomessagesevent (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/:idrequest is issued. - D1 (
/version) — still exposesrefresh_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/eventsand the new event-shape docs inCONTRACTS.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 VF1–VF7. 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/messagesstill 404s inPRIVATE, so the newnodes-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
nodespublish on message ingest (a new SSE event, documented inCONTRACTS.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 DM1–DM6. 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-A1 (§ HOT 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-configtileFiltershalf 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
(~30–60 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 30–60 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 HT1–HT8. 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-A2 (§ Bugfix: 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 thedark_allgrep 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-A1 (§ Bugfix: Basemap provider blend (chess-pattern fix)). The dark filter is intentionally no longer HOT-only:
.map-tiles-fallbacknow 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 staticbase.cssrule, the removed Ruby/contract machinery stays removed,resolve_initial_themeis 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-A5 (§ Feature: 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.jsand its test are deleted and the command below no longer resolves. SB-A1 (noFALLBACK_TIMEOUT_MS/fallback-tile-layersymbols 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-A5 (§ Feature: Dual stacked basemap layers). The fallback ladder is preserved but re-expressed: with two independent layers the single
tile-failure-policyis now fed by both (anytileloadfrom either latches "alive"; offline fires only on a comprehensive dual outage), andmain/fallback-tile-layer.jsis deleted — so the command below no longer resolves. SB-A5 (tile-failure-policy.test.js+ the newmain-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 BL1–BL4. 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-A4 (§ Feature: Dual stacked basemap layers). There is no longer a per-tile timeout:
FALLBACK_TIMEOUT_MSis deleted with the mechanism, so assertion (1) below (=== 2500) no longer exists — the rewrittenbasemap-blend.test.jscommand 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-paneopacity: 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-R1 (§ Feature: Dual stacked basemap layers). This clause predates the two-layer redesign and describes state that has since changed —
fallback-tile-layer.test.jsis now deleted (not "updated"), and the per-tile swap mechanism HT-A3 checked is gone. SB-R1 is the current regression authority (it re-runsnpm test+rspecand 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
(aggregateTelemetrySnapshots → mergeTelemetryIntoNodes), 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 RF1–RF8. 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 snr → nodes.snr,
path_len → nodes.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
1–4 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_voltage…ch8_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_dict → store_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 RT1–RT5. 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. 4s → 5s) 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 SB1–SB8. 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 nothing — fadeAnimation 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 MR1–MR6. 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.