feat(#1865): ingest observer /neighbors as confirmed scope evidence, and surface it on Reach - #1971
Conversation
…1865) The ESP32 observer firmware now emits a periodic /neighbors report carrying the observer's own configured region scopes (`self`) plus, for each zero-hop neighbor, the scopes fetched via an OTA scope query. This records that CONFIRMED configuration into a new nodes.configured_scope column, kept strictly separate from the existing inferred `default_scope` (observed advert transport scope) and transported_scopes (transmissions.scope_name). Provenance is modelled explicitly rather than overloading default_scope: default_scope is overwritten on every advert observation, so writing neighbor scopes there would let the next inferred observation clobber a confirmed value. A dedicated configured_scope (+ configured_scope_at) column preserves the distinction and structurally satisfies the report contract. Report semantics honored: - Only neighbors with status=="responded" update configured_scope. A timeout is NOT evidence the scopes were cleared, so it never writes. - Absence of a neighbor is never a signal: the report is size-capped and truncates by ordering, so missing != gone — no deletes ever happen. - A responded neighbor with empty scopes is a valid "no scopes configured" statement and IS stored (the handler gates on status, not emptiness). - self scopes are keyed by origin_id (the observer node pubkey) and need no OTA query. Report pubkeys are uppercase; nodes.public_key is lowercase hex, so keys are lowercased before the UPDATE. Unknown neighbors are a no-op until a later advert creates the node. - Out-of-order reports can't clobber newer data (last-write-wins on configured_scope_at). Changes: - dbschema: additive ensureConfiguredScopeColumns migration on nodes + inactive_nodes (marker nodes_configured_scope_v1), asserted via mustCol. - ingestor: handleNeighborsReport dispatch on topic meshcore/<region>/<observer_id>/neighbors (analogous to /status); Store.UpdateNodeConfiguredScope writer. - server: PRAGMA-detect configured_scope (hasConfiguredScope) and expose it + configured_scope_at on the node read path. - UI: node-detail (nodes.js + live.js) shows a "Configured scope" row marked confirmed, with last-confirmed timestamp, distinct from the observed scope. - tests: handleNeighborsReport (responded writes, timeout/absence never clears, empty-responded stored, unknown no-op) + last-write-wins. Topic format meshcore/<region>/<observer_id>/neighbors is assumed by analogy to the /status topic; noted for reviewer confirmation against the firmware. Co-Authored-By: Claude <noreply@anthropic.com>
…-wins (#1865) UpdateNodeConfiguredScope stored the report timestamp raw and compared it lexicographically in the last-write-wins guard. Real firmware emits e.g. "2026-07-26T09:43:48.000000+00:00" (microseconds + numeric offset), and other observers may emit "Z" or a non-UTC offset like "+02:00". String-comparing those raw diverges from chronological order across observers/formats, so a stale report could overwrite a newer confirmed scope. normalizeReportTS parses the timestamp (RFC3339Nano/RFC3339) and stores it as canonical UTC RFC3339 ("...Z"), used for both storage and the comparison, so every stored configured_scope_at is either canonical or empty — never a mix. An unparseable/empty input yields "" and skips the ordering guard (writes), preserving prior behavior. New test TestUpdateNodeConfiguredScopeNormalizesAndOrdersByInstant proves a "+02:00" report that is lexicographically greater but chronologically earlier than the stored UTC value does not win, that a later "+02:00" report does, and that the firmware's microsecond/"+00:00" form canonicalizes to "Z". Co-Authored-By: Claude <noreply@anthropic.com>
…vidence Carries SaarMesh-Bot's implementation from the closed #1867 forward onto current master, 67 commits later. The branch was +386/-0 and applied with two conflicts, both from master moving underneath it: - cmd/server/db.go: #1901 replaced the per-table inline PRAGMA loops with schemaColumns(ctx, q, table). Kept master's helper and set the new flag through it (db.hasConfiguredScope = nodes["configured_scope"]) instead of reinstating the old loops. - public/nodes.js: master rewrote the transported_scopes tooltip to explain the #1902 one-byte-hop exclusion. Kept master's wording and appended the new configured_scope row. No behaviour from #1867 was changed. Its five tests pass unmodified: TestHandleNeighborsReportWritesSelfAndResponded, TestHandleNeighborsReportUnknownNeighborIsNoop, TestHandleNeighborsReportRespondedEmptyScopeIsStored, TestUpdateNodeConfiguredScopeLastWriteWins, TestUpdateNodeConfiguredScopeNormalizesAndOrdersByInstant. Both modules build and gofmt is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
@cwichura raised this on #1865 and @dborup agreed, but #1867 was closed the next day and the change never landed: the OTA scope query returns bare region names ("dk,eu,dk-aarhus") while the rest of CoreScope carries the leading "#". The node detail page therefore showed "Own scope: #be" directly above "Configured scope: be,eu", two spellings of the same thing, and nothing could compare the two columns. Measured before choosing: every default_scope value on a live 1425-node instance starts with "#" (361 of 361). The hashRegions and hashChannels config paths in cmd/ingestor/main.go already prefix a missing "#" before deriving the region key as sha256("#name"), so the "#" is part of the name rather than display sugar. normalizeScopeList applies that same existing rule on write. Deliberate choices, each pinned by a test: - "*" is a wildcard, not a region name, and passes through untouched. - An empty value stays empty. It is a valid "responded, no scopes configured" statement and must not become "#". - Order is preserved and duplicates are not collapsed; the observer's ordering is not ours to reinterpret. - Case is left alone, because folding it would change sha256("#name") and silently retarget the scope. - Idempotent, so re-ingesting the same report after a reconnect is a no-op. CONTRACT CHANGE, flagged for review rather than buried: five assertions in issue1865_test.go were updated from "de" to "#de" and friends. They assert the stored spelling, which is exactly what this commit changes; the ordering and last-write-wins behaviour they exist to test is untouched. If the maintainers prefer to keep the raw OTA string, revert this commit alone and the merge of #1867 still stands. Not done: existing rows written before this commit keep the bare syntax. No backfill, because no instance has shipped this feature yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
The Reach page is the one view operators print and hand to someone else, and it did not say which region the node serves. With /neighbors ingestion landing in the same branch, that answer now exists and belongs here. Server: NodeReachInfo carries default_scope, configured_scope and configured_scope_at, all omitempty. reachNodeScopes does a single-row lookup gated on the schema flags the handle probed at open time, rather than widening buildNodeInfoMap's bulk SELECT, which every other consumer of that map would pay for. Every failure path returns empty strings: a reach report must still render for a node we know nothing else about. Frontend: one muted line under the subtitle. The two claims are kept apart because they are not the same statement. Scope #be observed inferred from observed advert transport Configured scope ✓ #be,#eu confirmed read back off the node itself Confirmed wins when both exist, and an empty confirmed value renders as "none configured" rather than falling back to the inferred guess: a node that answered "I have no scopes" must not be shown a contradicting guess instead. Colour is carried only by the tick, via var(--success), since the tick is the part that is evidence. Tests: seven cases in test-frontend-helpers.js, 666 to 673 passed, 0 failed. They assert the RENDERED markup through a new window.__meshcoreReachInternals handle, following map.js (#1356/#1933) rather than grepping the source. Covered: absent data renders nothing, inferred never gets the confirmed tick, confirmed wins, empty-confirmed is a statement and does not fall back, the instant lives in the title attribute, and node-controlled scope strings are HTML-escaped. Not done: the Live and Nodes views get configured_scope from #1867 as-is; no filtering or sorting by configured scope, which @cwichura raised on #1865 and explicitly called feature creep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
|
I raised the request and provided a few initial comments. All the code came from others. But reading over the description here, I think this makes sense. |
…ence red Two corrections from operator review, both of which change what the page is worth in practice. 1. The declared side was single-source and would have shipped an empty page AllCurrentDeclaredRegions read nodes.configured_scope only. That column is written by the observer /neighbors ingest (#1865/#1971), which requires the ESP32-native observer firmware: real, public, validated on two meshes, but deployed on a small minority of observers today. On the instance every number in this PR came from, configured_scope does not exist at all while a second collector holds 199 targets. So the page would have rendered empty on exactly the deployment that produced its evidence. The lookup now merges every confirmed-scope source the database carries, newest answer per node wins. Upstream behaviour is unchanged, because only configured_scope exists there and the merge degrades to the one-source case. node_declared_regions is probed like any other optional schema: absent means a false flag, not an error. @cwichura noted on #1865 that neighbor reports have also landed in meshcore-packet-capture and openHop, so one hard-coded source was the wrong shape regardless of which one it named. Precedence is "newest answer wins", not "source A beats source B", and is tested both ways round so nobody can satisfy it by accident. Both sources store canonical UTC RFC3339, so the lexicographic compare is chronological. An answer with no recorded instant loses to any dated answer and wins only against nothing at all. truncated survives from whichever source records it. 2. Absence is no longer red Declared-but-not-observed chips are neutral grey; only observed forwarding is green. On live data 78 of 197 rows have nothing observed, so red painted most of the table as an alarm for what is often a quiet region over a short window, which is the caveat the page header already states in words. Colour now marks the finding rather than its absence. The dead .sa-chip-missing rule is removed. Six new tests cover the merge: each source alone, newest-wins in both directions, dated beating undated while undated is still used when it is all there is, truncated surviving, and no sources being empty rather than an error. Fixtures re-probe the schema and assert the flag flipped, so a fixture that silently failed to create the table cannot make the assertions vacuous. Full cmd/server suite green. Frontend 681 passed, 0 failed. gofmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg
|
Merging with a merge commit, not a squash, so @SaarMesh-Bot's two commits keep their authorship. A squash would rewrite the larger half of this PR to my name. @SaarMesh-Bot @dborup: this goes in without a second reviewer, which #1922 said I would try to avoid. The reason it is going in anyway is the one @dborup gave when closing #1852: work that sits waiting for review gets rebuilt in forks instead, and this PR exists precisely because that already happened to #1867. Leaving it open longer to satisfy a process point would repeat the thing the process is meant to prevent. What I changed in carrying it forward, so you can check my work rather than take it: two conflicts from master moving underneath, both resolved toward master (#1901's If either of you thinks the normalisation is wrong, say so and I will revert that commit rather than argue about it. |
|
Correction to what I wrote a moment ago: I asked for a merge commit to preserve authorship, and that is not possible here. The repository allows squash only: So the choice is not mine to make and my earlier note on this PR was wrong about what was available. Squashing with |
…1976) One row per repeater whose configured region list is known, answering a question no other view answers: you declare these regions, but were you seen forwarding them? default_scope says what a node's adverts were observed under and transported_scopes (#1751) says what it carried, but nothing lined the declared list up against observed forwarding. The declared side merges every confirmed-scope source the database carries, newest answer per node wins, rather than naming one. On a stock install only nodes.configured_scope (#1865/#1971) exists and it degrades to the one-source case; deployments that collect the same fact another way keep working. Reading a single hard-coded source would have rendered an empty page on the very instance the evidence came from. Declared and observed are compared through normScope, so a leading "#" and a bare region name are one region. Unobserved regions render neutral, not red: absence over a short window is weak evidence, which the page header already states in words. Ported from a long-running fork deployment with its 17 server tests, rewired to the upstream data source, plus 14 frontend cases asserting rendered markup.
Closes #1865. Carries @SaarMesh-Bot's #1867 forward, adds the syntax fix that thread agreed on but never landed, and puts the result on the Reach report.
Please merge this with a merge commit, not a squash
Two of the five commits are @SaarMesh-Bot's, with their authorship intact. A squash rewrites both to me and erases the credit for the implementation, which is the larger half of this PR. This is the one case where our usual squash is the wrong button.
Why this was sitting closed
@SaarMesh-Bot opened #1867 on 2026-07-26. @dborup cherry-picked it onto master the same day, ran it against real MQTT traffic on stg.meshview.dk, and confirmed it end to end. @SaarMesh-Bot then validated it a second time on 2026-08-05 on unrelated hardware, a Heltec V4 on the SaarLorLux mesh: a
respondedneighbor wrote*,#de,#de-sl,#scn,#saarlorlux,#de-west,#de-rp, twotimeoutneighbors were correctly left untouched, and the observer's ownself.scopeslanded on its own node.Then #1867 was closed by its author, and #1852 followed on 2026-08-01. @dborup said why:
So two contributors validated a feature on two meshes and shipped it in their own forks because upstream review had stalled. That is the backlog costing us work rather than merely delaying it, and it is worth naming.
What I did
1. Rebased #1867 onto master, 67 commits later. It was +386/-0 and hit two conflicts, both from master moving underneath it:
cmd/server/db.go: bug(db): detectSchema() silently swallows PRAGMA failure — server runs v2 SQL against a v3 DB until restart (empty Packets, /channels/*/messages 500) #1901 replaced the inline per-tablePRAGMA table_infoloops withschemaColumns(ctx, q, table). Kept master's helper and set the new flag through it, rather than reinstating the old loops.public/nodes.js: master rewrote thetransported_scopestooltip to explain the fix(#1751): transported_scopes is shared across every node with the same 1-byte pubkey prefix #1902 one-byte-hop exclusion. Kept master's wording, appended the new row.No behaviour from #1867 changed. Its five tests pass unmodified.
2. Fixed the scope syntax @cwichura raised. He pointed out on the thread that the OTA query returns bare region names while everything else in CoreScope carries the leading
#, @dborup agreed, and then the PR closed before it landed. Without it the node page reads:Two spellings of the same thing, and nothing can compare the columns.
Measured before choosing a rule: on a live 1425-node instance 361 of 361
default_scopevalues start with#. Andcmd/ingestor/main.goalready prefixes a missing#forhashRegions/hashChannelsbefore deriving the key assha256("#name"), so the#is part of the name, not decoration.normalizeScopeListapplies that existing rule on write.*passes through as a wildcard, empty stays empty because "responded, no scopes" is a real answer, order and duplicates are preserved, case is untouched (folding it would retargetsha256("#name")), and it is idempotent so a reconnect re-ingesting the same report is a no-op.Contract change, flagged rather than buried: five assertions in
issue1865_test.gomove from"de"to"#de"and friends. They assert the stored spelling, which is exactly what changed; the ordering and last-write-wins behaviour they exist to test is untouched. If you would rather keep the raw OTA string, revertcfd9d468alone and the rest still stands.3. Put it on the Reach report, which is the page operators print and hand to someone else, and which never said which region the node serves. One muted line under the subtitle, keeping the two claims apart because they are different statements:
Confirmed wins when both exist. An empty confirmed value renders "none configured" and does not fall back to the inferred guess: a node that answered "I have no scopes" must not be shown a contradicting guess instead. Colour is carried only by the tick, via
var(--success).Server side,
reachNodeScopesdoes a single-row lookup gated on the schema flags, rather than wideningbuildNodeInfoMap's bulk SELECT that every other consumer would pay for. All three fields areomitempty, so an older schema sends nothing and the UI shows nothing.Verification
cmd/ingestor: the five feat: ingest observer /neighbors report as confirmed scope evidence (#1865) #1867 tests pass unmodified, plus 13 new cases pinningnormalizeScopeList(wildcard, empty, idempotence, order, duplicates, case, whitespace, empty entries).test-frontend-helpers.js: 666 → 673 passed, 0 failed. The seven new cases assert the RENDERED markup through a newwindow.__meshcoreReachInternals, following map.js (a11y(map): cluster bubbles + role pills + multi-byte hash labels encode signal by color only (WCAG 1.4.1) #1356/test(#1356): assert the rendered label, not where identifiers sit in map.js #1933) instead of grepping the source. They cover absent data, inferred never getting the tick, confirmed winning, empty-confirmed not falling back, the instant living in a title attribute, and HTML-escaping of node-controlled scope strings.gofmtclean.eslinton the changed files reports exactly what master reports (46 problems, 19 errors, 27 warnings, all pre-existing) — this PR adds none.cmd/serverfull suite: two clean runs. An earlier run failedTestHandleNodePaths_SortByRecency_1145andTestIssue1008_HandlerReturns503WhileSubpathIndexLoading; both pass in isolation and both passed on two subsequent full runs, so they are order-dependent flakes rather than anything this branch does. Worth a separate issue.cmd/ingestorfull suite fails onlyTestWriteStatsAtomic_SymlinkAtDestIsReplaced, which fails identically on unmodified master here (Windows symlink privileges).Not done
@SaarMesh-Bot @dborup @cwichura: this is your work and your review thread. If any of the three choices above is wrong, say so and I will change it rather than merge over you.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PQS3XLoPD98yu9pxdRujqg