Commit Graph
11 Commits
Author SHA1 Message Date
n30nex 77c0392947 fix(reach): avoid caching interrupted query results (#2277)
Reach now propagates failed scope lookups, rejects cancelled results
before caching, and retains the prior complete degree snapshot when
scanning or row iteration fails. Missing rows and optional scope columns
still produce the legitimate empty result.

Fixes #2258.

Validation: RED reproduces partial degree snapshots and cached success
after failed/cancelled follow-up queries. The full server and unchanged
frontend suites pass; focused race and browser Reach checks pass.
Handler regressions verify that failed builds do not replace caches and
that a later request rebuilds successfully.

The shared-build cancellation policy and query count are unchanged. This
is independent of #2249's admission/queue work. Common ingestor
validation is covered by the separate #2272 test-stability change.
2026-10-25 12:10:58 +01:00
7c00a42947 perf(reach): answer /nodes/{pk}/reach from a rolling hop index, not a path_json LIKE scan (#2214)
Refs #2146 (reach item: "an indexed hop table would remove it"). Follows
#2155.

## What changes

Reach finds the observations that relayed through a node with `path_json
LIKE '%"XX"%'` over every observation in the window. The cost follows
the window, not the node: a quiet repeater costs about as much as a busy
one. When the database does not fit in the page cache this outlives the
60 s `WriteTimeout`, the request 500s, and nothing is cached. #2146
reports this endpoint at 14.9 s average and 74.7 s max on a 10.9 GB
instance. Its 1.9 s figure after #2155 is a warm-cache timing; the scan
reads the whole window, so cold it is far slower (below), and it holds
one of the 4 read-pool connections the whole time.

This PR adds an inverted index and uses it only when it is complete for
the requested window:

- `observation_hops(hop, timestamp, observation_id)`, `WITHOUT ROWID`. A
token lookup is a seek and the window bound is a range on the same
b-tree.
- Triggers on `observations` maintain it (insert, delete, `path_json`
update), so prune and path-repair need no changes.
- `observation_hops_coverage.since` marks how far back the index is
complete. If the window starts before `since`, or the table is missing
(older DB, backfill unfinished), reach runs the existing `LIKE` scan
unchanged.
- The ingestor owns the schema, a resumable newest-first backfill, and a
daily trim to `retention.hopIndexDays` (default 14). The server stays
read-only and `AssertReady` does not require the table.

The backfill starts after the ingest gate opens rather than inside
`OpenStore`. My first version did the latter and held live ingest in the
buffer for about 15 minutes on a 13.7 GB database (nothing dropped).
Chunks are 15 minutes because one-hour chunks held the writer for 2 to 7
s per commit on EBS.

## Results

### Real data: vanilla vs this branch

One snapshot of a real instance (11.5 GB, 10.7M observations over 14
days) served by both builds on the same machine (8 cores, 30 GB RAM,
NVMe, warm cache). One cold request per cell, seconds, repeaters from
busiest to quietest by 24 h relay count.

| node (relays / 24 h) | 7 d vanilla | 7 d branch | 14 d vanilla | 14 d
branch |
|---|---|---|---|---|
| 7,314 | 24.5 | 4.5 | 8.3 | 9.6 |
| 7,307 | 1.7 | 2.4 | 1.3 | 3.1 |
| 7,047 | 3.3 | 2.2 | 2.0 | 2.3 |
| 2,151 | 20.0 | 0.44 | 29.6 | 2.0 |
| 673 | 23.7 | 0.18 | 28.5 | 0.62 |
| 240 | 21.8 | 0.01 | 27.8 | 0.04 |
| 52 | 21.6 | 0.00 | 28.4 | 0.00 |

- Nodes with few relays drop from 20 to 30 s to under a second.
- The busiest relays are about the same, sometimes slower: both builds
spend their time loading tens of thousands of matching rows by primary
key, which the index does not speed up.
- Single runs, and page-cache state moves them a lot: an earlier run,
with the file freshly cached, had vanilla's quiet nodes at 4.5 to 5 s
for 7 d, close to #2155's warm figure. Read orders of magnitude, not
exact figures. Nothing failed on this fast machine.

**Same answers.** Unit tests check indexed rows equal `LIKE` rows,
fallback on partial coverage, and trigger upkeep. On real data, 21
node/window pairs (3 to 14 days) requested from both builds at the same
moment were all identical once links were compared by public key. `LIKE`
ignores case and the index does not; 0 of 10.1M observations in the
window have a lower-case hop. Above `reachScanRowLimit` both paths
truncate, and they can keep different subsets.

### Production instance

t4g.large (2 vCPU, 8 GB, EBS), 13.7 GB database, about 4.5M observations
per 7 days, so the cache held a fraction of it.

- Before: `days=7` on a busy node returned 500 (`context canceled`), and
`days=1` took 58.5 s.
- With the index (38 probes of four nodes over 18 hours): `days=7` on
that node returned 200 in 23 s. The busiest probe node had a median of
0.7 s and p95 18 s. The hottest (127k relay observations in 7 d) had a
median of 1.3 s, p95 52 s, and one probe past 60 s at evening peak. The
quietest had a median of 0.04 s.
- On 2026-10-04 the host was resized to 4 vCPU and 16 GB so the cache
holds the database. Same index afterwards (493 probes): hottest node
median 1.4 s, p95 1.7 s, max 38 s; quietest median 0.05 s. (From
2026-10-03 the instance also ran a local change, not in this PR, that
lets reach requests continue in the background, which only affects
requests over 45 s.)

So on a small cold host the index turned "always fails" into "usually
works" but did not make the hottest relays reliably fast. That needs
cheaper row loading (a compact side table measured 4 to 10x on cold
reads; kept out of this PR) or background computation.

### Benchmarks (included)

```
go test ./cmd/server -run xxx -bench ScanReachRows -benchtime 5x
  # 600k observations, 11 hops avg, 7 d window in 14 d, warm cache
  hot_token/like     816 ms     rare_token/like     486 ms
  hot_token/indexed  293 ms     rare_token/indexed  1.7 ms

go test ./internal/dbschema -run xxx -bench ObservationInsert -benchtime 20000x
  # one observation per transaction, WriterDSN (WAL, synchronous=FULL), 11 hops
  without_index  23 us/op      with_index  127 us/op
```

The triggers add about 100 us per observation, roughly 0.1% of a core at
the production instance's 8 observations per second. They cap one writer
at about 8k observations per second instead of 43k. The benchmark ran in
a temp directory, so it shows CPU and b-tree cost, not disk sync cost.
Over 13 days on the production instance, 0 observations were dropped and
newest-observation age was p99 4 s. Four of 623 samples were 20 s or
more: 91 s and 20 s came within six minutes of a container restart, 31 s
came 24 hours after one (when the ingestor's daily tickers fire), and 23
s had no restart nearby. Index coverage stayed between 14.0 and 15.2
days.

**Disk.** On the snapshot (`dbstat`) the index is 1.98 GB for 10.1M
observations in the window, about 196 bytes each, next to 6.81 GB for
`observations`. It scales with hops per observation and `hopIndexDays`.
Backfill of 77M rows took 15.5 min on the production instance.

**Perf justification (AGENTS.md).** Reach goes from a scan over the
whole window to a seek over the matching rows. Everything new is
bounded: the index by `hopIndexDays` and the daily trim, the backfill by
chunk size and a cancellable context, and the server adds no in-memory
state. The ingest hot path pays the trigger cost above.

## Design notes and open questions

- Why not the in-memory store: it defaults to 7 days (`retentionHours:
168`) and reach offers up to 30, so it could not answer the longer
windows. The other option listed in #2146, driving the query from
`observations.timestamp`, is what #2155 already did.
- SQLite only: the triggers use `json_each`. The optional PostgreSQL
backend in #2145 would need its own equivalent.
- No off switch: the table and triggers are always installed and
`hopIndexDays` only sets the window. If you would rather have this
opt-in, or want a smaller default for small disks, I'll change it.

## Operator notes

- Upgrade: the ingestor installs the table and triggers on start and
backfills. Until a window is covered, reach behaves as today.
- Rollback: an older binary keeps indexing new rows with no trim. To
remove it with the ingestor stopped: `DROP TRIGGER
trg_observation_hops_ai; DROP TRIGGER trg_observation_hops_ad; DROP
TRIGGER trg_observation_hops_au; DROP TABLE observation_hops; DROP TABLE
observation_hops_coverage;`
- New key `retention.hopIndexDays`, documented in `config.example.json`.

## Tests

`go test ./...` passes in `cmd/server`, `cmd/ingestor` and
`internal/dbschema`. New: `node_reach_hops_test.go`,
`observation_hops_test.go`, `observation_hops_backfill_test.go`, and the
two benchmarks.

## Update after review (commit `2291bea2`)

The text above is what was reviewed at `95e17976`. Where they differ,
this section is current:

- **Opt-in.** `retention.hopIndexDays` unset or 0 (the default, and the
value in `config.example.json`) installs nothing. A value above 0
installs the table and triggers and backfills that many days. This
replaces "default 14", the "No off switch" note and the Upgrade note
above.
- **Changing the setting** applies at the next ingestor start: raising
it backfills the gap, lowering it trims, and 0 removes the triggers and
both tables. On a 10.5M-observation copy the removal took 45 to 71 s, at
startup before MQTT connects.
- **Case.** Hops are stored and matched upper-cased, so the index
ignores ASCII case the way LIKE does. This replaces the sentence on case
under "Same answers".
- **Rollback.** Set it to 0 and start the ingestor once, then roll back.
The manual `DROP` list above still works with the ingestor stopped.
- **Disk.** A full 14-day backfill on that copy built 2.17 GB for 9.68M
observations, about 224 bytes each, in 13 min 37 s.
- Backfill and trim are serialized, and the chunk SQL lives in
`dbschema` next to the triggers.

## Update after review, round 2 (commit `95690e8c`)

Where this differs from the two sections above, this one is current:

- **Startup order.** The index is installed or removed in the startup
block, after the MQTT subscription is up and before the ingest gate
opens, so packets arriving during a removal are buffered
(`ingestBufferSize`), not lost. This replaces "at startup before MQTT
connects" in the first update.
- **Mark moves cover their range.** Each backfill and trim transaction
indexes or deletes everything between the old mark and the new one, so a
row arriving between the chunk-picking read and the transaction is
accounted for either way.
- **Negative `hopIndexDays`** is a config error at startup; the ingestor
refuses to run. This replaces "−5 is off" in the first update.
- **Server.** If the index disappears between the coverage check and the
scan, the request is answered from the `LIKE` scan instead of a 500.

---

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

---------

Co-authored-by: nullrouten <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 00:36:51 +02:00
efitenandClaude Opus 5.5 3e21b179ec fix(server): drive the reach scan from the observations time window (#2155)
Refs #2146 (item: `/api/nodes/:pubkey/reach`).

## Situation

The reach scan joins `observations` to `transmissions` and filters on
`o.timestamp >= ?` plus one `o.path_json LIKE '%"XX"%'` per token. With
the planner statistics `ANALYZE` produces on a large database, SQLite
runs it as `SCAN t`: every transmission, with an index probe into
`observations` (`idx_observations_tx_ts`) per row. The cost is then the
size of `transmissions`, whatever node is asked for and whatever the
window.

## My change in this PR

`scanReachRows` uses `CROSS JOIN` for the transmissions join. In SQLite
that fixes the join order: `observations` is the outer loop, read
through `idx_observations_timestamp` for the window, and each row looks
up its transmission by primary key. Results are the same rows; it is
still an inner join.

The query text moves into `reachScanSQL(nTokens)`, so a test can inspect
the plan.

## Measurements

Read-only on a database with 18.3M observations and 1.5M transmissions
(Python `sqlite3` 3.45.1 on the host), 7-day window:

| node (rows returned) | `JOIN`, first run | `JOIN`, warm | `CROSS
JOIN`, both runs |
|---|---|---|---|
| busiest relay (54,387) | 10.29 s | 3.27 s | 1.95 s |
| quartile (3,913) | 3.16 s | 3.15 s | 1.81 s |
| median (1,310) | 3.17 s | 3.18 s | 1.82 s |
| a repeater reported as timing out in the browser (34,209) | 14.11 s |
3.25 s | 1.93 s |

On that instance `/api/nodes/:pubkey/reach` reports p95 188.6 s and max
192.6 s over 34 calls in `/api/perf`; a single uncached call from the
host took 3.4 to 3.6 s, so most of that is waiting for the 2 build slots
and the read pool.

On a staging instance (smaller database), with unique cache keys and
`days` 6 or 7, one cycle per build after the ingestor's startup
`ANALYZE` had finished:

| | without this PR | with this PR |
|---|---|---|
| sequential, 12 calls, p50 | 3402-3801 ms | 2078-2467 ms |
| 2 concurrent clients (= `reachMaxConcurrentBuilds`), p50 / p95 |
3575-4087 / 4049-5291 ms | 2271-2462 / 2814-2928 ms |
| calls completed in 120 s | 58-68 | 96-101 |

The staging builds also contain #2147.

## Verification

- `TestReachScanStartsFromObservationsTimeIndex` creates the two
production indexes, loads the `sqlite_stat1` values from the
18.3M-observation database, and checks that the plan starts with `SEARCH
o USING INDEX idx_observations_timestamp`. With the old `JOIN` it fails
with the production plan (`SCAN t`, then `SEARCH o USING INDEX
idx_observations_tx_ts`).
- The existing reach tests (`TestScanReachRows_DecodesRows`,
`TestScanReachRows_CapTruncates`, attribution tests) pass unchanged.
- `cmd/server`: `go test ./...` green.

## Not in this PR

- The scan still reads every observation in the window and applies the
`LIKE` per row, so its cost grows with the window and with traffic.
Removing the scan needs an index on hop tokens (for example a table the
ingestor maintains). I have not measured what that would cost in
storage.
- The build-slot limit (2) and the 5-minute cache are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:20:34 +02:00
nullrouten0andClaude Mythos 5.1 caa66356bd fix(api): cap concurrent cold reach scans at 2, answer 429 beyond that (#2127)
`GET /api/nodes/{pubkey}/reach` is unauthenticated and a cold-cache
request runs a full scan. `singleflight` only collapses identical keys,
so distinct `(pubkey, days)` requests each start a scan, and they share
the 4-connection SQLite pool with every other handler. A handful of
requests for different nodes can hold every reader and stall the whole
API.

**Fix:** a small semaphore allows two cold scans at once. A cold request
that finds both slots busy gets `429` with `Retry-After: 5` instead of
queueing. Cached answers are unaffected, and the two in-flight scans
still complete normally.

**Tests:** `node_reach_concurrency_test.go` — with both slots held a
cold request returns 429 with `Retry-After`; after release the same
request runs normally. Full server suite passes.

**Note:** our instance runs a fork that already bounds reach work
differently (an async job queue), so this exact change is not what we
run in production. The test suite is the verification here.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 16:26:15 +02:00
efitenandSaarMesh-Bot 9d6f08c144 feat(#1865): ingest observer /neighbors as confirmed scope evidence (#1971)
Carries SaarMesh-Bot's implementation from the closed #1867 forward onto current
master, 67 commits later, and surfaces the result on the per-node Reach report.

The declared region list a repeater answers with is now stored on the node as
configured_scope, normalised to the same leading-# syntax default_scope already
uses so the two are directly comparable. That normalisation is the point
@cwichura raised on #1865 and @dborup agreed with before the original PR closed.

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
2026-09-06 21:06:12 +02:00
Kpa-clawbot 825b26485c fix(#1181): hide nodes whose name starts with a configured prefix (#1655)
Fixes #1181.

## Summary

Adds operator-configurable name-prefix hiding for nodes. When a node's
name starts with any prefix listed in the new `hiddenNamePrefixes`
config field (default `["🚫"]`), it is omitted from `/api/nodes`,
`/api/nodes/search`, and `/api/nodes/{pubkey}`. DB rows are preserved —
the filter runs at the API layer only, so observation history (paths,
hops, distances) stays intact and the node simply re-appears if the
operator clears the prefix list.

This mirrors the convention already in use on other MeshCore map
dashboards: an operator who wants their node hidden renames it with the
🚫 prefix and sends an advert; the next advert is then dropped from the
dashboard. The node is **not** hidden from the mesh itself — only from
this dashboard. This is documented inline in `config.example.json`.

Implementation follows the existing `IsBlacklisted` pattern exactly: a
new `Config.IsNameHidden(name)` method, and three filters in `routes.go`
placed alongside the corresponding blacklist filters. No DB schema,
public API, or websocket changes.

## Files changed

- `cmd/server/config.go` — new `HiddenNamePrefixes []string` field +
`IsNameHidden` method
- `cmd/server/routes.go` — filters in `handleNodes`, `handleNodeSearch`,
`handleNodeDetail`
- `config.example.json` — new field + `_comment_hiddenNamePrefixes`
operator doc
- `cmd/server/hidden_name_prefix_1181_test.go` — new test file (red →
green)

## Test plan

Two new subtests in `TestHiddenNamePrefix_1181_*`:

1. `_NodesList` — inserts a node named `🚫 ban me`, asserts it is present
when `HiddenNamePrefixes` is empty and absent when set to `["🚫"]`.
2. `_Search` — inserts `🚫 search me`, asserts
`/api/nodes/search?q=search` does not surface it when the prefix is
configured.

Verified red→green:

- Red commit `d0903852`: `go test -run TestHiddenNamePrefix_1181` fails
on the leak assertion (`hidden_name_prefix_1181_test.go:94`).
- Green commit `e79a0d8d`: same command passes.

```
$ cd cmd/server && go test -run TestHiddenNamePrefix_1181 -count=1 .
ok  	github.com/corescope/server	0.060s
```

## Out of scope

- Auto-purging DB rows for hidden nodes — left to existing retention.
The triage was explicit: hide, do not delete.
- Live websocket broadcast: nodes are not broadcast via websocket (only
packets), so no separate emit path needs filtering. Frontend reads nodes
via `/api/nodes`, which is filtered.
- Frontend customizer for the prefix list — operators configure via
`config.json` like every other knob.
2026-06-11 10:10:12 -07:00
8295c2115c fix(reach): bust response cache on blacklist change (#1629) (#1636)
Red commit: 178617ca7b6a3b34597ae59d83e15801985be949 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/27191921487 —
red-state was verified locally; CI on this branch runs against green
HEAD per pull_request triggers)

Fixes #1629

## Summary

`/api/nodes/{pubkey}/reach` cached responses survived blacklist
mutations for up to the 5-minute TTL. A node added to `NodeBlacklist`
after a recent reach request was still served the cached non-blacklisted
payload until the entry expired.

## Fix (per triage)

Per @Kpa-clawbot's locked fix path on the issue:

1. Add a monotonic `BlacklistGeneration()` counter on `*Config`.
2. `SetNodeBlacklist` (new setter) atomically replaces the slice,
rebuilds the lookup set under an `RWMutex`, and bumps the generation via
`atomic.AddUint64`.
3. `cmd/server/node_reach.go` folds the generation into the cache key
(`"<pubkey>|<days>|g<gen>"`) so any mutation invalidates prior entries
on the next request — no callbacks bolted onto the setter, no
cache-layer surgery, no TTL change.

While here, the latent bug in `blacklistSet()` is also fixed:
`sync.Once` locked in the initial set, so a later `SetNodeBlacklist` was
invisible to `IsBlacklisted`. The `Once` still gates the lock-free
initial build; mutations rebuild under `RWMutex` and reads take an
`RLock` around the map handoff.

## Files

- `cmd/server/config.go` — `SetNodeBlacklist`, `BlacklistGeneration`,
`rebuildBlacklistSetLocked`, `RWMutex`. `IsBlacklisted` reads the
rebuilt set (no stale-slice short-circuit).
- `cmd/server/node_reach.go` — `cacheKey` includes `|g<gen>`.
- `cmd/server/node_reach_blacklist_cache_test.go` — new regression test
(the red commit).
- `cmd/server/node_reach_endpoint_test.go` — existing cache-hit
assertion updated to the generation-suffixed key.

## TDD evidence

- Red commit `178617ca` adds the test + a deliberate `SetNodeBlacklist`
stub that only reassigns the slice. The test fails on the post-blacklist
assertion: `status=200 want 404 (cached payload was served — #1629)`.
- Green commit `257c104f` replaces the stub with the real
implementation; full `go test ./...` and `go test -race -run
"TestNodeReach|TestNodeBlacklist|TestConfig"` pass locally.

## Scope

- One narrow PR. Backend only — no frontend or API response-shape
change.
- No public type signatures touched beyond the new exported
`SetNodeBlacklist` / `BlacklistGeneration` on `*Config`.
- Preflight: all hard gates pass (PII, branch scope, red commit, CSS,
LIKE/JSON, sync/async migration, XSS).

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-09 03:23:48 -07:00
Kpa-clawbotandclawbot 43be1bb76a fix(reach): scanReachRows DB errors must surface as 500 not 404 (#1631) (#1635)
Red commit: 67088342ec (CI run: pending)

## Summary

Fixes #1631 — `scanReachRows` swallowed `QueryContext` / `rows.Err()`
failures and returned `nil`. The handler treated that as "genuinely no
reach" and rendered a 200 with empty arrays (or 404 in some flows), so
transient SQLite failures surfaced to operators as "this node has no
reach" — misleading and undiagnosable without log access.

## Fix

`cmd/server/node_reach.go`:
- `scanReachRows` now returns `([]pathRow, error)`; propagates
`QueryContext` + `rows.Err()` failures.
- `computeNodeReach` signature gains an error return: non-nil error
means real backend failure (NOT "unknown node").
- `handleNodeReach` renders **500** on that error path and does **NOT**
cache the failure (next request retries cleanly). Genuinely-empty reach
still renders **200** with empty arrays; unknown/blacklisted nodes still
render 404.

## TDD

- Red commit `67088342`: adds `TestNodeReach_ScanDBErrorReturns500` —
warms the integration DB, drops the `observations` table, asserts
handler returns 500. Pre-fix this got 200 with empty arrays.
- Green commit `5408be3a`: the fix + caller updates. Adds
`TestScanReachRows_ErrorReturn` (unit-level: closed-DB → non-nil err).
- `TestNodeReach_ShapeAndClamp` had to be tightened: the v2 fixture's
`observations` table was missing `observer_idx`; the swallowed error
masked that schema gap. Now rebuilt with the right shape.

## Scope

- `cmd/server/node_reach.go` — fix.
- `cmd/server/node_reach_endpoint_test.go` — new red test +
ShapeAndClamp fixture fix.
- `cmd/server/node_reach_test.go`, `node_reach_bench_test.go` — caller
updates for new signature + one new unit assertion test.

No cache changes (#1629 is separate). No sibling refactors. No frontend.

## Verification

- `go test ./cmd/server/...` — green (48s, all tests).
- pr-preflight — clean (PII, scope, red-commit, CSS vars, LIKE-on-JSON,
async-migration, XSS).

---------

Co-authored-by: clawbot <bot@kpa-clawbot.local>
2026-06-09 00:27:56 -07:00
e2212f5015 feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (v2, review-complete) (#1627)
Re-submission of #1625 (which was merged early, then reverted in #1626)
— now with **all three round-1 reviews addressed** so it lands in one
hardened state instead of as post-merge follow-ups.

## What

Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) +
a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which
nodes a node has a **stable two-way RF link** with, derived from raw
`path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B
heard A). A link is bidirectional when both directions have
observations; the **bottleneck** (weaker direction) rates two-way
reliability. Nodes are identified only by **unique 2–3 byte** path
prefixes (1-byte collides → excluded).

## Review fixes folded in vs #1625

**Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc;
`json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row
scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps
(attribution over 100k rows: 102 allocs); `context.Context` plumbed;
cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup;
degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs,
1k/10k/100k, mixed-payload, isolated attribution).

**Correctness/safety + tests (Independent + Kent Beck):** pubkey
validation → 400; error logging instead of silent swallow (first_seen /
degree / marshal→500 / discarded rows); `public_key=?` index use;
canonical `PayloadADVERT`; `min()` builtin; documented cache-slice
immutability; mux ordering comment. New tests: scanReachRows decode,
3-byte token branch, non-advert first-hop guard, observer SNR
aggregation across rows, HTTP-level attribution (asserts non-zero
we_hear/they_hear), 400/404/blacklist/cache-hit.

**UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the
colour+width double-encoding (constant width, colour-only); colour-blind
glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme
`--link-*`; lighter table (horizontal rules, sentence-case headers); map
built once + link layer updated in place on toggle (no flicker);
time-range no longer flashes a loader; `destroy()` generation guard;
statCard escaping; scoped `@media print` to `#nq-report`;
`fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` /
back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS
note; inline styles → CSS; decorative emoji removed.

**Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400.

## Testing
- `cmd/server` full suite green; reach unit + endpoint + bench all pass.
- `eslint public/*.js` (no-undef) and the XSS-sink gate clean.
- E2E updated: request status checks + exact (non-tautological) toggle
assertions + hard map-render assert.

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


---

## TDD-history note (Kent Beck gate)

This branch carries production + tests together, not a fabricated
red→green sequence. That's deliberate: the branch was rebased onto
upstream and the intermediate SHAs were squashed, so reconstructing a
"failing-test-first" commit after the fact would be theatre, not
evidence — and rewriting history to stage it would be dishonest. The
behaviour is instead covered by a comprehensive, anti-tautological suite
(directional attribution edges, 3-byte token branch, non-advert
first-hop guard, observer SNR aggregation, HTTP-level attribution
asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404,
companion mis-attribution, cache eviction). Requesting maintainer
acceptance of the work on test *substance* rather than commit
*choreography*; the net-new-UI exemption is not claimed for the server
endpoint.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-08 22:13:02 -07:00
efiten 9c5faab1e4 Revert "feat(nodes): per-node Reach page (#1625)" (#1626)
Reverts #1625.

#1625 was merged before the round-1 reviews (Independent / Kent Beck /
Tufte) were addressed. Reverting to land it cleanly: a fresh PR will
re-add the feature with the perf pass, the backend correctness/safety +
test-coverage fixes, and the UI/a11y (Tufte) batch folded in, so it goes
through review in a single hardened state rather than as a string of
post-merge follow-ups.

No functional loss — the feature returns in the replacement PR.
2026-06-08 12:35:12 +00:00
efitenandClaude Opus 4.8 47f85f6c4c feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (directional link quality) (#1625)
## What

Adds a per-node **Reach** view that answers "how well does this specific
node hear, and get heard by, its neighbours?" — both as a standalone
page (`#/nodes/{pubkey}/reach`) and as a section on the node detail
page.

New endpoint: **`GET /api/nodes/{pubkey}/reach`**.

## What it measures

For the target node it derives, from raw `path_json` adjacency (a path
travels origin→observer, so in `[A,B]` B received A directly):

- **Directional link counts** per neighbour: `we_hear` (how often we
received them) vs `they_hear` (how often they received us).
- **Bidirectional / bottleneck**: a link is two-way stable when both
directions > 0; the weaker direction is the bottleneck and rates real
two-way reliability.
- **Importance**: neighbour degree + rank, relay-observation volume,
bidirectional-link count, direct-observer count.
- **Direct observers**: who received the node at 0 hops, with SNR.

Reliability rule: a neighbour is only attributed when its pubkey
**prefix is unique** at the path's byte length (collisions are skipped,
never misattributed).

## UI

- Standalone Reach page + node-detail section.
- Reusable bidirectional link map (OSM) with links coloured by
bottleneck.
- Incoming/outgoing toggles to isolate each direction.

## Naming note (deliberate, no collision)

This is distinct from the existing **per-observer reachability** in
topology analytics (`ReachNode` / `ObserverReach` / `perObserverReach`).
This PR adds its own `NodeReach*` response structs in a new
`node_reach.go` and a new `/api/nodes/{pubkey}/reach` route — there are
no symbol or route collisions (verified: `go build ./...` clean). Happy
to rename to disambiguate further (e.g. "Link Quality") if you'd prefer
to reserve "Reach" for the per-observer feature.

## Testing

- `cmd/server`: endpoint shape/404/limit-clamp + unit tests for token
derivation and directional attribution, plus a scan benchmark — all
pass.
- Frontend: helper tests + Reach-page E2E (`test-node-reach-e2e.js`),
standalone route + incoming/outgoing toggles.
- `go build ./...` and `eslint public/*.js` (no-undef) clean.

## Docs

Design spec, implementation plan, and the `GET
/api/nodes/{pubkey}/reach` API contract are included under `docs/`.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:11:06 +02:00