Skip to content

feat(#1865): ingest observer /neighbors as confirmed scope evidence, and surface it on Reach - #1971

Merged
efiten merged 5 commits into
masterfrom
feat/1865-neighbors
Sep 6, 2026
Merged

feat(#1865): ingest observer /neighbors as confirmed scope evidence, and surface it on Reach#1971
efiten merged 5 commits into
masterfrom
feat/1865-neighbors

Conversation

@efiten

@efiten efiten commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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 responded neighbor wrote *,#de,#de-sl,#scn,#saarlorlux,#de-west,#de-rp, two timeout neighbors were correctly left untouched, and the observer's own self.scopes landed on its own node.

Then #1867 was closed by its author, and #1852 followed on 2026-08-01. @dborup said why:

Went ahead and built this ourselves rather than wait on a separate PR — PR review throughput here has been pretty backed up lately.

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:

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:

Own scope:        #be
Configured scope: be,eu

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_scope values start with #. And cmd/ingestor/main.go already prefixes a missing # for hashRegions/hashChannels before deriving the key as sha256("#name"), so the # is part of the name, not decoration. normalizeScopeList applies 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 retarget sha256("#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.go move 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, revert cfd9d468 alone 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:

Scope #be observed                    inferred from observed advert transport scope
Configured scope ✓ #be,#eu confirmed  read back off the node itself

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, reachNodeScopes does a single-row lookup gated on the schema flags, rather than widening buildNodeInfoMap's bulk SELECT that every other consumer would pay for. All three fields are omitempty, 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 pinning normalizeScopeList (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 new window.__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.
  • gofmt clean. eslint on the changed files reports exactly what master reports (46 problems, 19 errors, 27 warnings, all pre-existing) — this PR adds none.
  • cmd/server full suite: two clean runs. An earlier run failed TestHandleNodePaths_SortByRecency_1145 and TestIssue1008_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/ingestor full suite fails only TestWriteStatsAtomic_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

SaarMesh-Bot and others added 5 commits July 26, 2026 06:48
…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
@cwichura

cwichura commented Sep 5, 2026

Copy link
Copy Markdown

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.

efiten added a commit that referenced this pull request Sep 6, 2026
…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
@efiten

efiten commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

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 schemaColumns helper instead of the old inline PRAGMA loops, and master's transported_scopes tooltip wording from #1902); and one behaviour change I made deliberately, normalising configured_scope to the leading-# form so it is comparable with default_scope, which is the thing @cwichura raised and you agreed with before the PR closed. That is commit cfd9d468 on its own and reverting it leaves the rest standing.

If either of you thinks the normalisation is wrong, say so and I will revert that commit rather than argue about it.

@efiten

efiten commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

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:

merge_commit: false
squash:       true
rebase:       false

So the choice is not mine to make and my earlier note on this PR was wrong about what was available.

Squashing with Co-authored-by: trailers instead, which is the mechanism that exists for exactly this case: GitHub renders co-authors on the resulting commit and counts the contribution. @SaarMesh-Bot's two commits become co-authorship on one commit rather than two commits under their own name. That is a real loss of granularity, not an equivalent, and it is worth someone with repository settings access deciding whether squash-only is the right default for a project that takes work carried forward from closed PRs.

@efiten
efiten merged commit 9d6f08c into master Sep 6, 2026
7 checks passed
@efiten
efiten deleted the feat/1865-neighbors branch September 6, 2026 19:06
efiten added a commit that referenced this pull request Sep 6, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IMPROVEMENT: Utilize new /neighbors MQTT message for concrete evidence of region scopes configured

3 participants