Fixes#1902.
## The bug
`byPathHop` is keyed on the raw hop string from `path_json`, and both
relay-info paths look up the full pubkey **and** fold in `key[:2]` — the
1-byte wire prefix. `TransportedScopes` (#1751) accumulated over that
folded set, so every node sharing a pubkey first byte reported the same
scopes.
On the live network all four active nodes with prefix `f7` returned an
identical set:
```
f79616... BE repeater ['#be','#be-van','#de','#de-nw','#nl']
f7e718... BE repeater ['#be','#be-van','#de','#de-nw','#nl']
f788ad... BE repeater ['#be','#be-van','#de','#de-nw','#nl']
f752c2... DE/NRW repeat. ['#be','#be-van','#de','#de-nw','#nl']
```
Their real sets, from unambiguous full-pubkey hops over the same 7 days,
are disjoint:
```
f79616... (BE) #be 471, #eu 9, #nl 6, #de 3, #be-van 1
f752c2... (DE/NRW) #de 13, #de-nw 11
f7e718... (BE) (none)
```
A sysop reads a scope badge as a statement about how their repeater is
configured, so a Belgian repeater badged `#de-nw` is a wrong answer, not
an imprecise one.
## The change
The prefix fold stays for the counters — that is the documented #662
trade-off, "a possible over-count for clearly false zeros", and
`RelayCount1h/24h`, `LastRelayed` and `UnscopedRelayCount24h` are
magnitudes where an over-count is tolerable.
Scopes are not a magnitude. A 1-byte hop names one of N nodes and cannot
substantiate a categorical claim. Entries reached only through the
prefix bucket are now flagged (`relayEntry.viaPrefix` / a `viaPrefix`
argument to the bulk `visit` closure) and excluded from scope
accumulation only.
Both computation paths are changed together so `/api/nodes` (bulk) and
the node-detail endpoint (per-node) stay in parity:
- `cmd/server/repeater_liveness.go` — `collectRelayEntriesLocked` /
`computeRelayInfoFromEntries`
- `cmd/server/repeater_enrich_bulk.go` — `computeRepeaterRelayInfoMap`
The `public/nodes.js` tooltip is updated to describe what the field now
actually means.
Attribution does not collapse: `observations.resolved_path` carries full
pubkeys for ~27% of observations on the live instance (408k of 1.54M
over 7 days), and those rows produce the correct per-node sets above. A
node with no resolved hop yet shows no badge rather than a borrowed one.
## Tests
`TestTransportedScopes_CrossBucketFold` pinned the old behaviour ("a
scope seen only in the prefix bucket must surface on the full key"),
which is the bug. It is replaced by
`TestTransportedScopes_PrefixBucketNotAttributed`, which asserts on
**both** paths that:
1. a scope evidenced only by a 1-byte hop is not attributed;
2. a scope also present under the full key still is;
3. `RelayCount24h` still counts all three packets — narrowing scopes
must not narrow the counters, i.e. the #662 fold is untouched.
Red before the change, green after.
```
cd cmd/server && go test ./... ok github.com/corescope/server 98.2s
node test-packet-filter.js 92 passed, 0 failed
node test-aging.js 18 passed, 0 failed
node test-frontend-helpers.js 625 passed, 2 failed
```
The two frontend failures (`favStar returns filled star for favorite`,
`favStar returns empty star for non-favorite`) and `cmd/ingestor`'s
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced` are **pre-existing** — I
ran them on a pristine `upstream/master` worktree and got byte-identical
results (the ingestor one is a Windows symlink-privilege limitation, not
a code failure).
## Perf
No new work in any loop. The bulk path gains one bool argument to an
existing closure and one `&& !viaPrefix` on a branch that already ran;
the per-node path gains one bool field on `relayEntry`, which is
stack/slice-local and not retained. Same complexity, same allocations.
## What I could not verify end-to-end, and why
I built a fixture from live data (2512 nodes, 17k transmissions, 529k
observations, including all eight `f7` nodes) and ran the before/after
binaries against it. Neither reproduced the live field — both returned
no `transported_scopes` and `relay_count_24h: 0` for every node.
That turns out to be a **separate cold-start bug**: `LoadChunked` calls
`indexResolvedPathHops` per observation while scanning chunks, which
adds full-pubkey keys to `byPathHop`, and then the post-load block at
`cmd/server/chunked_load.go:459` calls `buildPathHopIndex()`, which
begins with `s.byPathHop = make(...)` and rebuilds from raw hops only.
Every resolved full-pubkey key from the scan is discarded:
```
[store] Built path-hop index: 2924 unique keys <- raw hops only
[store] LoadChunked: 17056 transmissions (527331 observations)
```
So on a freshly started server the full-pubkey buckets are empty and
only refill from live ingestion. That is being filed separately; it is
orthogonal to this change, but it does mean `transported_scopes` will be
sparse for a while after any restart until it is fixed.
This PR is therefore verified by unit tests on both computation paths
plus the live-data derivation above, not by a local end-to-end run.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>