Skip to content

fix(liveness): count only evidence that can be the node alone (#2260) - #2261

Merged
efiten merged 2 commits into
Kpa-clawbot:masterfrom
efiten:fix/certain-liveness
Oct 22, 2026
Merged

efiten merged 2 commits into
Kpa-clawbot:masterfrom
efiten:fix/certain-liveness

Conversation

@efiten

@efiten efiten commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2260 (part 1 of 2: liveness; the adaptive offline threshold follows in a separate PR).

Problem

A repeater silent since 2026-10-18 showed Active — Last relayed 4m ago and Last Heard 36m, and its offline mail never fired. Every hit after that date was the 1-byte hop 67, shared by nine nodes:

Fix

The store keeps certainHeard per node: the newest transmission that can only be that node.

Evidence Counts?
Own packet (pubKey, srcPubKey) ✅
Flood relay hop whose prefix, at the packet's path hash size, matches exactly one path-capable node ✅
1-byte hop shared with other repeaters ❌
Direct-route path (hops still to come, Mesh::removeSelfFromPath) ❌
TRACE (path bytes are SNR) ❌
Destination (destPubKey) ❌

A multibyte repeater legitimately appears as a 1-byte hop (the originator picks the hash size, Mesh::routeRecvPacket appends at that size), so only uniqueness decides. The rule is applied in every load path through indexResolvedPathHops and the new indexColdLoadRelayHops: Load, loadChunk, scanAndMergeChunk, live ingest, late observations.

Liveness reads only that:

  • /api/nodes and the node detail: last_heard is the newer of last_seen and the certain last heard. Before, it was a plain copy of last_seen.
  • health / bulk-health stats.lastHeard.
  • LastHeardMap → notify offline check. The separate relay input (lastRelayed, relayTimes, notifyInput.Relayed) is removed.
  • roles.js getEffectiveHeardMs: newest known timestamp, without last_relayed. The side pane no longer says "Last relayed"; the Last Relayed row stays (analytics) with an updated tooltip.
  • New status uncertain (repeaters and rooms): nothing certain within the silent window, but the shared prefix (last_relayed) still in paths. Grey badge with explanation, an Uncertain filter on the nodes list and the map, dimmed like stale. The offline mail for such a node says only its shared prefix was still seen.
  • A prefix also held by a path-capable node in inactive_nodes is not unique; adverts with signatureValid: false are no evidence; /api/nodes/search reports the same last_heard.

Attribution for analytics (byNode, packet counts, last_relayed, relay counters, usefulness) is unchanged.

Performance

Hot path: noteCertainRelays runs per observation on ingest under the write lock: two hex bytes of header parsing plus one prefix-map lookup per resolved hop.

BenchmarkNoteCertainRelays-16   10307640   116.2 ns/op   0 B/op   0 allocs/op   (8-hop 2-byte path, 2,000 repeaters)
BenchmarkLastHeardMap-16            1902   0.77 ms/op                            (5,000 nodes; was 3.7 ms scanning byNode)

Memory: one string per node ever certainly heard, dropped together with the node's byNode list on eviction.

Tests

  • certain_heard_loadpaths_test.go: every load path (Load, loadChunk, LoadChunked — each with and without resolved_path — live ingest, late observations) feeds certainHeard; each of the 8 call sites verified red with a nil prefix map.
  • certain_heard_test.go: hop width per route/payload type; retired namesake prefix; failed signature; uncertain offline mail; shared vs unique 1-byte vs 2-byte hops; direct/TRACE/nil prefix map; cold-load path; sender vs destination; eviction; lastHeardValue; /api/nodes, detail and health agree.
  • Regression for Node liveness (Last Heard, Active status, Notify-me) counts ambiguous 1-byte hops: a silent repeater shows "Active, last relayed 4m ago" #2260: last certain packet on day X, 20 shared-prefix hops after it → LastHeardMap stays at X and node.offline goes bad after 72 h. Verified red with the uniqueness check mutated out.
  • Notify: TestEvaluateCertainHeard replaces the relay-input test.
  • Frontend: last_relayed alone no longer keeps a repeater or room active; newest timestamp wins (an old _liveSeen can't hide a fresh certain relay).
  • cmd/server go test ./... and test-all.sh pass locally, also on a trial merge into a downstream fork with 457 extra commits.

Validation

Ran the server on the freshened e2e fixture: list, detail and health report the same last heard; e.g. a repeater whose last_relayed is 3 min newer than anything certain keeps its certain time. No browser screenshot: Playwright isn't installed on this machine; CI's E2E covers the pages.

Not changed

🤖 Generated with Claude Code

@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review notes and a measurement on live data

Reviewed commit 1b40d8ac against upstream/master and the firmware (src/Mesh.cpp, src/Packet.h, src/helpers/CommonCLI.cpp). The firmware basis holds: hash size (path_len>>6)+1, flood routes 0/1 vs direct 2/3, a direct path lists the hops still to come (removeSelfFromPath), and a flood relay appends at the originator's size. Tests and the benchmark were not re-run for this review.

1. Nodes with no certain relay fall back to adverts only

After this change a repeater is heard only through its own packets or through hops whose prefix is unique. The firmware default flood.advert.interval is 47 h (examples/simple_repeater/MyMesh.cpp:905) and infraSilentHours is 72, so one missed advert puts such a node at about 94 h of silence.

Measured on a live instance (1678 repeaters and rooms, flood observations of the last 72 h, TRACE excluded, "unique" taken as unique among repeater and room rows in nodes):

Hop width Share of observations Hops with a unique prefix
1 byte 25.3 % 0 % (none of the 254 1-byte prefixes is held by exactly one node)
2 bytes 72.6 % 97.2 %
3 bytes 2.1 % 94.8 %
Newest certain evidence per node Nodes
certain relay < 24 h 1091 (65.0 %)
certain relay 24 to 72 h only 162 (9.7 %)
no certain relay, advert < 24 h 146 (8.7 %)
no certain relay, advert 24 to 72 h 95 (5.7 %)
no certain relay, advert 72 h to 7 d (offline under this PR) 184 (11.0 %)

Multibyte traffic covers most nodes. The 184 are an upper bound for false offline: the data cannot separate dead nodes from nodes that only relay 1-byte traffic. The 241 nodes in the two middle rows depend on adverts alone, so a missed advert takes them offline. This is one 72 h snapshot, network wide.

2. Uniqueness is decided against the nodes table only

certain_heard.go:78, store.go:7371. A repeater that shares a prefix but has never been heard advertising, or was removed by retention while it still relays, is not seen by the check, so its relays count for the other node. The check runs once at ingest and is not re-evaluated when a colliding node appears later.

3. No test covers the wiring in the five load paths

TestNodeEndpointsLastHeardIsCertain seeds certainHeard directly (certain_heard_test.go:196); the other tests call indexResolvedPathHops / indexColdLoadRelayHops themselves. Passing a nil prefix map in Load (store.go:1099/1112), loadChunk (1589/1594), scanAndMergeChunk (chunked_load.go:638/650), live ingest (store.go:3230) or late observations (3544) would leave every test green. One test per path through the real Load or ingest, asserting LastHeardMap, would close that.

Checked without finding a problem: locking (writes under s.mu.Lock, LastHeardMap under RLock), eviction (certainHeard deleted with byNode at store.go:5457), noteCertainHeard keeps the newest time, TRACE and direct routes excluded.

Question

Merge order: this PR on its own moves the 241 advert-only nodes onto a single missed advert. Should #2261 and #2262 land together (with adaptiveOffline still off by default that does not help these nodes), or should the fixed window for nodes without any certain relay stay on the previous evidence until there is a better signal?

@efiten
efiten force-pushed the fix/certain-liveness branch from 1b40d8a to f7a809d Compare October 22, 2026 11:06
@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed (commit f7a809d9, amended)

  1. Side pane / node page showed the first timestamp present, not the newest. n.last_heard || stats.lastHeard could show an older cached value. Both places and getStatusInfo now take the newest via getEffectiveHeardMs, the same timestamp the status uses. Test: getStatusInfo shows the newest of _lastHeard and last_heard.
  2. Adverts whose signature failed counted as certain. indexByNode now skips signatureValid: false (absent = not checked, still counts). signatureValid is taken off decodedCacheDroppedKeys so the cached decode keeps it (one bool per advert). Test: TestIndexByNodeFailedSignatureIsNotCertain.
  3. /api/nodes/search still returned last_heard = last_seen. It now folds in the certain last heard like /api/nodes and the detail. Test: TestNodeSearchLastHeardIsCertain.

Not changed, with reasons:

  • Ingest-time vs current prefix map (certainHeard vs analytics in Nodes table has dead 'Regions' column — always shows — #26): one rule at two moments. Recording a per-(tx, node) flag to make them identical costs a map entry per byNode pair, tens of MB on large instances. They only diverge when a new repeater with the same prefix appears. Documented on certainFor.
  • A shared-contact advert re-sent by another device usually dedups by hash with the original. When the original was already evicted, nodes.last_seen has the same issue today.

cmd/server go test ./... and test-all.sh pass, also on a trial merge into this fork's master.

@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up on the review notes above

Read the delta 1b40d8ac..f7a809d9; tests were not re-run, CI is green.

The three changes in f7a809d9 (failed signatures, /api/nodes/search, newest timestamp in the side pane) each come with a test and read correctly.

Still open from the review notes:

  1. Advert-only nodes. No change for the 241 nodes that depend on adverts alone.
  2. Uniqueness against nodes only. The "Not changed" note covers when the check runs (ingest vs now). It does not cover a colliding repeater that never advertised or was removed by retention while it still relays.
  3. Load-path wiring. Still no test that goes through the real Load, loadChunk, scanAndMergeChunk, live ingest or late observations.

Question

For points 1 to 3, which of these do you prefer: address them in this PR, land this PR as is and track them in #2260, or land this PR only together with #2262?

…awbot#2260)

A repeater silent since 18 Oct showed "Active, last relayed 4m ago" with
Last Heard 36m, and its offline mail never fired. Every hit after that
date was the 1-byte hop 67, shared by nine nodes: the live resolver's
affinity tiebreak put it in byNode, and last_relayed folds in the 1-byte
prefix bucket on purpose (Kpa-clawbot#662).

The store now keeps certainHeard per node: the newest transmission that
can only be that node. Its own packets (pubKey / srcPubKey) count, and a
flood relay hop counts when its prefix at the packet's path hash size
matches exactly one path-capable node. Direct-route paths (hops still to
come, Mesh::removeSelfFromPath) and TRACE (SNR bytes) never count. The
same rule feeds every load path: Load, loadChunk, scanAndMergeChunk,
live ingest and late observations.

Liveness reads only that:
- /api/nodes and the node detail: last_heard is the newer of last_seen
  and the certain last heard, no longer a copy of last_seen;
- health and bulk-health stats.lastHeard;
- LastHeardMap, so the notify offline check; the separate relay input
  (lastRelayed / relayTimes) is gone;
- roles.js getEffectiveHeardMs: the newest known timestamp, without
  last_relayed; the side pane no longer says "Last relayed".

Attribution for analytics (byNode, packet counts, last_relayed, relay
counters) is unchanged.

Cost: noteCertainRelays 116 ns per observation for 8 hops, 0 allocs;
LastHeardMap 0.77 ms for 5,000 nodes (was 3.7 ms), one map lookup each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@efiten
efiten force-pushed the fix/certain-liveness branch from f7a809d to b5556bd Compare October 22, 2026 12:22
@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review notes addressed (commit b5556bdc, amended)

Answers to the question: everything is addressed in this PR; it lands first, #2262 right after.

  1. Advert-only nodes (241 / 184). New third status uncertain for repeaters and rooms: nothing that can only be this node within the silent window, but its hop prefix (shared with other repeaters, last_relayed) still appears in paths. It is neither proof of life nor of an outage, so:
    • UI: grey "Uncertain" badge with the explanation "Last certainly heard X — its shared hop prefix was last in a path Y, possibly another node"; an Uncertain button in the status filter on the nodes list and the map; map/live dim it like stale; map legend counts it separately.
    • Notify: still node.offline (an owner should know), but the mail line says why: "offline (nothing that can only be this node; its hop prefix, shared with other repeaters, was last in a path …, possibly another node)". The shared-prefix time only words the mail; it never decides the state.
    • getNodeStatus(role, ms) (legacy form) never returns uncertain. Tests for each case.
  2. Uniqueness against nodes only. uniqueHopPrefix now also rejects a prefix held by a path-capable node in inactive_nodes (moved there by retention but may still relay): prefixMap.retired, loaded with the node cache. A repeater that never advertised is unknown to both and cannot be ruled out; that is stated on the function. Test: TestRetiredNamesakeKeepsHopUncertain.
  3. Load-path wiring. TestCertainHeardFedByEveryLoadPath goes through the real Load (resolved_path and path_json), loadChunk (both), LoadChunked/scanAndMergeChunk (both), IngestNewFromDB and IngestNewObservations, asserting LastHeardMap. Verified: passing nil as prefix map at each of the 8 call sites in turn fails exactly that path's subtest.

cmd/server go test ./... and test-all.sh pass.

@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review of b5556bdc: the retired-prefix rule removes most certain relays on live data

Read the delta f7a809d9..b5556bdc; tests were not re-run. The uncertain status, the mail wording and the load-path test read correctly. One problem blocks the merge.

inactive_nodes still holds nodes that came back

MoveStaleNodes copies rows into inactive_nodes (cmd/ingestor/db.go:2160), and nothing deletes a row there when the node is heard again: there is no DELETE FROM inactive_nodes in cmd/. uniqueHopPrefix rejects any prefix in pm.retired, including one that only the node itself put there. Such a node gets no certain relay at all, at any width.

Measured on a live instance (read-only, same method as the earlier comment: repeater and room rows, flood observations of the last 72 h, TRACE excluded):

  • inactive_nodes: 7877 repeater and room rows, of which 838 are also in nodes.
  • Hops with a unique prefix:
Hop width Before b5556bdc With b5556bdc Retired rule without the node itself
2 bytes 97.1 % 59.3 % 84.9 %
3 bytes 94.9 % 57.5 % 94.9 %
  • Repeaters and rooms with an advert in the last 7 days (1678):
Before With b5556bdc Without the node itself
certain relay < 72 h 1256 629 1206
advert only < 72 h 240 865 290
nothing certain < 72 h 182 184 182

So 627 nodes lose their certain relays, and depend on one advert per 47 h again. TestRetiredNamesakeKeepsHopUncertain has no case where the node itself is in inactive_nodes.

Excluding the node itself (skip retired rows whose public_key is also in nodes, or test the retired set against pk like relayCandidates is) brings it back to 1206. The remaining 50 come from the other 7039 retired rows, many of which may have been silent for months.

Question

Which bound should the retired set get: none beyond excluding the node itself, only rows whose last_seen is within a window (for example 30 days), or only rows still referenced as a hop in the store's packets?

…lawbot#2260)

Retention copies a node into inactive_nodes and nothing removes the row
when the node is heard again. markRetired loaded those rows too, so such a
node's own prefix counted as a retired namesake and it got no certain
relay at any width. markRetired now skips rows whose public_key is also in
nodes.

On a live instance 838 of the 7877 repeater and room rows in
inactive_nodes are also in nodes; repeaters and rooms with a certain relay
in the last 72 h went from 1256 to 629 with the rule, 1206 with this fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@efiten

efiten commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed (commit 6e956f05)

  1. A returned node is no longer its own retired namesake. markRetired skips inactive_nodes rows whose public_key is also in nodes (lower() on both sides). Test: TestReturnedNodeIsNotItsOwnRetiredNamesake (red before the change, green after).

Answer to the question: no further bound for now. The other retired rows keep counting, which costs 50 nodes on live (1256 to 1206) and keeps the protection against a retired repeater that still relays.

Cost: the query runs per node-cache rebuild (every 30 s). On a live database with 12382 inactive_nodes rows it takes about 24 ms against 16 ms before, measured from the sqlite3 CLI including process start.

cmd/server gofmt, vet and go test ./... pass locally. test-all.sh was not run locally (no Python on this machine); the change is Go only.

@efiten
efiten merged commit a08f2f4 into Kpa-clawbot:master Oct 22, 2026
12 checks passed
efiten added a commit that referenced this pull request Oct 22, 2026
…2260) (#2262)

Part 2 of #2260. **Builds on #2261** (certain liveness): review/merge
that first; until then this diff also shows its commit.

## Adaptive offline threshold

`userManagement.notifications.adaptiveOffline` — **off by default**:

```json
"notifications": {
  "enabled": true,
  "adaptiveOffline": { "enabled": true, "floorMinutes": 30, "minObservers": 3 }
}
```

The fixed `infraSilentHours` (72 h) treats every repeater the same. With
this on, a watched node's `node.offline` window is learned from its own
**certain** traffic (the #2261 rule):

1. Certain packets per hour over the week **before its last certain
packet**. Ending there, not at now, keeps an outage from dragging the
rate down until the fixed window returns and the node reads "back
online".
2. Empty runs are classified: ≤ 6 h and recurring on another day at the
same hours = normal silence (kept; the window is never shorter than the
longest + 1 h); any other run = outage, left out of the learning so it
neither stretches the window nor blocks learning.
3. λ = 10th percentile of the remaining hourly counts, so quiet nights
don't alert.
4. Window = 14/λ hours: silence at rate λ has a < 10⁻⁶ chance (Poisson,
ln 10⁶ ≈ 13.8).
5. Clamped to [`floorMinutes`, the role's silent window].

Kept on the fixed window: λ < 1/hour, under 24 h of history in memory,
or a 10th-percentile hour heard by fewer than `minObservers` observers
(one observer dropping out must not read as the node going offline).
Silence is measured up to the newest packet in the store, so a stalling
feed stops every node's clock. A node stored offline comes back only on
evidence from the moment it went offline on. The notifier caches each
learned window for 10 minutes and drops unwatched nodes.

For the #2260 repeater (~840 packets/day) the mail would have gone out
about 30 minutes into the outage, instead of never (or 72 h with #2261
alone).

## Certain node analytics

- Each `activityTimeline` bucket carries `certain`: the part that can
only be this node (`certainFor`, the packet-level form of the
`certainHeard` rule). `TimeBucket.Certain` is `omitempty`, so other
endpoints are unchanged.
- Availability and longest silence use certain buckets only; the longest
silence now includes one **still running** (an outage in progress is the
silence worth seeing).
- The chart stacks certain (`--accent`) and uncertain (`--text-muted`) —
existing theme variables, so no new customizer entry. Descriptions
updated.

## Performance

`CertainActivity` walks a week of packets per watched node, at most once
per node per 10 minutes, with the read lock taken **per node**:

```
BenchmarkCertainActivity-16   12.1 ms/op   (50 watched repeaters × 6,000 packets; ~0.25 ms per lock hold)
BenchmarkNoteCertainRelays-16  121 ns/op   0 allocs
```

Node analytics adds one `certainFor` per packet of the requested node (a
prefix-map lookup, or the cached decoded JSON for its own packets).

## Tests

- `adaptiveOfflineAfter`: busy repeater hits the floor; 5/hour; quiet
nights set the rate; > 10 % empty hours, short history, too few
observers → fixed.
- `CertainActivity` ends at the last certain packet and counts only
certain packets — verified red by mutating the window to end at now.
- Notifier end to end: learned window passed → bad; inside → good; too
few observers / setting off → fixed 72 h; cache refresh and prune.
- `certainFor`, `longestSilence` (incl. running silence), config
defaults, `/api/nodes/{pk}/analytics` splits a shared-prefix bucket.
- Frontend: `NodeAnalyticsTimeline.series` (new
`tests/unit/test-node-analytics-certain-timeline.js`).
- `cmd/server` `go test ./...` and `test-all.sh` pass locally, also on a
trial merge into a downstream fork.

## Validation

Server on the freshened e2e fixture: analytics buckets carry `certain`
(e.g. 89 of 90 packets certain for one repeater). **No browser
screenshot**: Playwright isn't installed on this machine.

## Later (rule 8)

`floorMinutes` / `minObservers` are server config, not theme settings;
an admin UI for notification settings would be the place, not the
customizer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
efiten added a commit that referenced this pull request Oct 22, 2026
Release notes for v3.15.0: `docs/release-notes/v3.15.0.md` and the
`CHANGELOG.md` entry. The release workflow uses the notes file as the
GitHub release body.

## What is in it

- 77 commits since v3.14.0 (76 merged pull requests plus this one): 27
fix, 23 perf, 19 feat, 3 test, 2 ci, 2 docs, 1 chore. Nineteen feats, so
minor.
- "Read this before upgrading" first. It covers:
  - the liveness change (#2261, #2262);
- the `users.db` v6 migration and what it means for a rollback (#2174);
- the backfill at every ingestor start (#2133) and the 24 h
planner-statistics refresh (#2158);
- the caches that trade freshness for latency (#2165, #2222, #2147,
#2156);
- changes in hop names and region placement (#2188, #2190, #2234, #2245,
#2220);
  - the role thresholds on favorites (#2215);
  - the geo-filter save on a single-file mount (#2137);
  - the `/api/stats` polling in hidden tabs (#2162).
- Features, fixes, performance, then tests/CI/docs.
- 29 issues closed, taken from each pull request's
`closingIssuesReferences`.
- Contributors: pull request authors and issue reporters with counts.
The commit co-author trailers only name the pull request's own author or
tooling.

## How it was checked

- I read every pull request body. Every number in the notes comes from a
pull request body.
- I checked the config keys and defaults against `config.example.json`
on `2bb000f2`.
- I checked the UNASKED and opt-in claims for #2125, #2133, #2235 and
#2239 against the source.
- For the other pull requests I relied on their bodies and did not open
the diffs.

## Not covered

- The uncertain count is a single count on one staging instance on the
day of the change (1,682 repeaters and rooms). It is not a before/after
comparison.
- #2133 gives no measured cost for its backfill on a large database, and
the notes say so.

## After merge

Tag `v3.15.0` on the merge commit once its `:edge` image exists. Then
check in the registry that `v3.15.0`, `v3.15`, `v3` and `latest` share
one digest, and that the release has the notes as its body and both
`corescope-decrypt` binaries.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

Node liveness (Last Heard, Active status, Notify-me) counts ambiguous 1-byte hops: a silent repeater shows "Active, last relayed 4m ago"

1 participant