Files
potato-mesh/ACCEPTANCE.md
T

118 KiB
Raw Blame History

PotatoMesh — Acceptance Criteria

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

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

How to use this document

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

Setup (one-time)

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

Test server helpers

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

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

Verdict rule

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


Layer A — Invariant conformance

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

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

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

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

Expected: no output.

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

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

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

Run the server with PRIVATE=1.

A2a. Message API is disabled in private mode.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A3c. Federation behavior is covered by tests.

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

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

A4 — Protocol parity & pluggability — SPEC Invariant IV

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

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

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

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

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

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

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

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

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

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

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

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


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.

See § Known gaps: the patch block is currently missing.

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.

  • B2 — .codecov.yml has no patch block. It defines only coverage.status.project.default (target 100% / threshold 10%); CLAUDE.md requires the same on patch. Fix tracked in the Phase 2 audit.
  • B4 — header-check exemptions are conventional, not codified. Formats without comment syntax (JSON fixtures under tests/, *.lock files, binary assets) cannot carry the notice; there is no committed allow-list or CI check asserting headers. The B4 commands above are the interim verification.

Feature: Chat channel test-deprioritization

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

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

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

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

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

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

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

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

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

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

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

F-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

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

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

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

Run the server with PRIVATE=1.

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

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

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

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

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

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

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

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

S-R1 — Regression: prior acceptance still holds

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

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


Bugfix: API casing consistency

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

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

BF-A1 — /version response is snake_case

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

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

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

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

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

BF-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

IC-R1 — Regression

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

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


Bugfix/Migration: Federation signature v2

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

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

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

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

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

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

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

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

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

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

FS-A4 — wire surfaces are snake_case

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

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

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

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

FS-R1 — Regression

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

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


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

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

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

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

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

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

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

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

PL-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

MC-R1 — Regression: prior acceptance still holds

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

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


Bugfix: Federation peer DNS failure must not 500

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

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

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

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

FD-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

FH-A2 — Federation is observable at default log level

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

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

FH-A3 — Federation workers shut down in bounded time

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

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


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

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

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

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

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

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

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

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

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

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

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

CR-R1 — Regression: prior acceptance still holds

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

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


Feature: Frontend persistent data cache

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

FC-R1 — Regression: prior acceptance still holds

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

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


Feature: Asset cache-busting (versioned static assets)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

AV-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

MP-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

EF-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

BP-R1 — Regression: prior acceptance still holds

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

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

Feature: Live updates (SSE change pub/sub)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Run the server with PRIVATE=1.

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

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

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

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

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

PS-A8 — Graceful degradation; engineering bar — PS8

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

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

PS-R1 — Regression: prior acceptance still holds

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

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

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

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

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

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

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

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

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

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

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

LH-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

VF-A7 — Engineering bar — VF7

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

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

VF-R1 — Regression: prior acceptance still holds

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

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

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

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

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

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

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

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

MD-R1 — Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

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

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

LD-R1 -- Regression: prior acceptance still holds

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

LV-A7 -- the Log tab logs every live-event class incl. plaintext messages -- LV7

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

Expected: pass. buildChatTabModel(...).logEntries includes a plaintext message entry (previously only encrypted messages reached the Log), so every live collection - nodes, messages (plain + encrypted), positions, telemetry, neighbors, traces - has a Log representation. Hidden-protocol and PRIVATE gates already applied to the chat are unchanged.

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

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

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

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

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

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

LV-R1 -- Regression: prior acceptance still holds

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

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


Bugfix: SSE stream must not block graceful shutdown

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

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

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

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

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

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

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

SD-R1 -- Regression: prior acceptance still holds

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

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

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

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

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

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

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

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

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

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

TS-R1 -- Regression: prior acceptance still holds

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

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


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

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

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

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

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: 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.