Commit Graph
2915 Commits
Author SHA1 Message Date
nullrouten0 c283f42c1f channels: 23 more names in the rainbow guess list (#2007)
Verified: every value is SHA-256(name)[:16] per internal/channel.DeriveKey, 319/320 exact (Public is the fixed firmware default), no duplicate hashes, file parses at 320 entries.
2026-09-12 11:49:27 +02:00
efiten 51a2a7dd2f fix(scope-audit): ship the stylesheet the page paints its badges with (#2005)
Fixes #2004. Two review rounds; findings and evidence on the PR. Verified by injecting the stylesheet into a running deployment and reading computed styles before and after: the badges gain background, size, uppercase and padding; at 430px the Config column stays visible; the sorted column keeps its accent. The new test fails on the eight unstyled classes against the pre-fix tree, on .ns-truncated against the first fix, and on a re-added column-hiding rule.
2026-09-11 15:35:52 +02:00
efiten 296456f9c1 feat(map): colour and filter repeaters by scope-configuration state (#2006)
Closes #2001. Two review rounds plus a re-review; findings and evidence on the PR. Verified on a deployment against live data: the field over 1653 nodes, marker tints per filter state, the marker title and popup Scope row reaching the DOM, and the colorblind-preset cascade. The audit and the map are held to the same classification by an end-to-end test that fails when either side's wildcard handling drifts.
2026-09-11 15:07:31 +02:00
liquidraver fe37f1060c fix(ingestor): delete aged packets in bounded batches so prune stops stalling ingest (#2000)
Reviewed at ecf0b371: query plans dumped and confirmed index-driven for all three statements, termination proven against concurrent ingest (first_seen is always time.Now()), FK child-first ordering required and correct, writer-stats assertions non-racy. Two low findings noted on the PR for follow-up: the dropped RowsAffected error now gates the loop, and ~0.53s batches will trip defaultSlowWriterMs=500.
2026-09-11 11:29:49 +02:00
efiten 02feb2a88e test(ingestor): join the watchdog loop goroutine instead of only asking it to stop (#2003)
Verified before merging: on upstream/master `go test ./cmd/ingestor -run TestMQTTStallWatchdog -count=20` fails; on this branch the same command passes. The flake blocked CI on #2000.
2026-09-11 10:58:56 +02:00
efiten 0c7f2306f6 feat(ingestor): keep the scope-match tally across restarts (#2002)
## What

`scopeMatchCounters` (unique / explicit-over-derived / ambiguous / none)
lives only in the ingestor process, and the only way to read it is the
periodic log line. A restart zeroes the counters, and recreating the
container removes that log with it, so a measurement in progress cannot
be recovered afterwards.

That happened here on 2026-09-10: a 24h ambiguity measurement completed,
two deploys followed before it was read, and nothing on disk held the
number. `/var/lib/docker/containers/*/*-json.log` had no earlier copy.

The tally gates a real decision (whether the collision tie-break from
the `autoRegionKeys` design is worth building), and that needs days of
traffic, so it has to survive the process counting it.

## How

A single-row table, `scope_match_totals`:

- `OpenStore` restores the counters from it, so counting continues
instead of restarting.
- The 5-minute stats ticker writes them back, and so does the shutdown
path, which is what a deploy triggers.
- `since_unix` carries the window start across restarts, so the ratio
keeps a denominator. The periodic log line now prints it.

Saving rides the **stats** ticker, not the region-refresh ticker:
matches are recorded for every transport-scoped packet, including on
instances that never enable `autoRegionKeys` and so never start the
refresh loop.

The table is **not** in `internal/dbschema` on purpose. The server
neither reads nor PRAGMA-detects it; putting it in `AssertReady` would
make an older DB fail the server's startup check over data the server
never looks at.

A failed restore is logged and ingestion continues. Losing an
observability counter is not a reason to stop ingesting.

## Tests

Four, all in `cmd/ingestor/scope_match_tally_test.go`:

- totals and `since_unix` restored across a close/reopen
- a fresh DB gets its anchor row immediately, so the first window has a
start time
- recording after a restore adds to the carried total instead of
counting from zero
- repeated saves keep exactly one row

Full `cmd/ingestor` suite green locally except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails on Windows
only (`os.Symlink` needs a privilege this account lacks) and predates
this branch. `go vet` and `gofmt` clean. The race detector was not run
locally (no cgo toolchain here); the `race-test` job covers it, since
this branch touches `cmd/ingestor`.

## Not done

No UI or API surface for the tally. It is still read from the log line
or straight from the table.
2026-09-11 00:00:45 +02:00
efiten 6c92a8b612 test(ingestor): make the suite race-clean, and run the detector when it matters (#1994)
`go test -race ./...` on `cmd/ingestor` reports **six data races** on
master. None is in production logic. All six come from test helpers that
outlive the test that started them, which is why the detector blames
whichever test happens to be running: two different tests failed on two
consecutive runs of the same code.

## What was racing

**`StartStatsFileWriter` had no way to stop.** Two tests start it at a
50ms interval, and its goroutine then runs for the rest of the process.
It reads the package-level `readProcSelfIOFn` hook, which a later test
replaces to inject a fake, so the write and the read race. The same leak
explains the stray log lines about writing stats into temp directories
that were already cleaned up.

It now returns a stop function that closes the goroutine and waits for
it to exit. Production ignores the return value and runs for the process
lifetime exactly as before; the two tests call it through `t.Cleanup`.

**The migration test read a log buffer while a goroutine wrote to it.**
`log.Logger` serialises its own writes, but `logContains` read
`buf.String()` outside that lock while `RunAsyncMigration` kept logging
after the call that started it had returned. The capture helper now uses
a mutex-protected buffer.

That one is worth calling a real race rather than a test artefact: a
concurrent read during a buffer grow can panic outright with "concurrent
map read and map write"-class behaviour, not merely trip `-race`.

## Measured

`go test -race ./...` on linux/arm64 under go1.27.1: **exit 0, zero
races, 704s**.

## The CI job, and why it is shaped this way

The server has had `-race` since #1208. This closes the same gap for the
ingestor, which carries an `atomic.Pointer` snapshot (the region key set
from #1989) whose safety has been an argument rather than a measurement.

Two deliberate choices, because a check that costs too much gets
switched off:

- **Its own job, not a step inside "Go Build & Test".** Appending
`-race` there puts its ten-odd minutes on the critical path, taking the
pipeline from roughly 20 minutes to roughly 32. As a separate job it
runs beside the E2E job (15-17 minutes) and hides inside that window.
- **Only when `cmd/ingestor/**.go` changed**, decided by the existing
change-scope job, which already gates the heavy jobs on
documentation-only PRs. A frontend or docs PR cannot introduce a data
race in the ingestor. Pushes to master always run it, as they already do
for `code`.

Nothing `needs:` the new job. Adding it to `build-and-publish` would
mean a skipped race job skips everything downstream, which is the
opposite of what a conditional check should do. It reports as its own
check; whether that blocks a merge is a repository setting rather than
workflow logic.

## Scope

Test helpers, one production signature (`StartStatsFileWriter` now
returns a stop function), and the workflow. No change to what the
ingestor does at runtime.
2026-09-10 22:30:27 +02:00
efiten 675c576fea fix(scope-audit): bound the verifier's payload, and fix two tests that proved less than they claimed (#1993)
Three leftovers from reviewing the scope-audit series (#1986, #1987,
#1990). None is urgent; all three are the kind of thing that gets harder
to explain the longer it sits.

## `scopeHMACInputs` accepted a payload `DecodePacket` rejects

Its comment says it walks the same offsets as the decoder, and it does,
minus the `maxPacketPayload` bound the decoder enforces. Unreachable in
practice: such a packet never reaches the database with an empty
`scope_name` in the first place, so the verifier never sees one.

Worth closing anyway, because the comment claims the two agree. A
verifier that accepts what the decoder refuses is a small divergence
today and an hour of confusion on the day it matters.

## The corroboration test seeded the same packet twice

The threshold of two rests on `code1` being two bytes: one match is
1/65536 by chance, two on the same region is (1/65536)². That argument
needs two **independent** observations.

The test seeded `realTransportFloodPacket` twice. Identical payloads
derive identical codes, so it was one observation counted twice, and it
would have passed just as happily against an implementation that counted
rows rather than deriving anything.

It now seeds the real captured packet plus a second one built for a
different payload, both deriving to `#fm-112` on their own.

**The feature was never wrong here.** `transmissions.hash` is unique and
`ComputeContentHash` is path-independent, so two rows always mean two
distinct payloads in production. Only the test failed to demonstrate the
property it is named for.

## Naming at ingest and verifying at read time had no test together

They were built separately, in #1990 and #1989, and the interaction
between them is not exotic: with derived region keys enabled, a packet
that used to be stored unnameable now arrives with a name. The audit
must then report that region as observed by the ordinary route:

- present in `agg.scopes`
- absent from `notObserved`
- **not** claimed by `regionEvidence`, which exists to explain regions
that could only be established by verification

Getting that wrong is quiet. The chip stays green while the reason
underneath it is wrong, and a reader asking "how do we know this" gets
the wrong story.

## Verification

`cd cmd/server && go test ./...` passes (254s), `go vet` and `gofmt -l`
clean. No production behaviour changes beyond the payload bound, which
rejects input that cannot occur.
2026-09-10 22:30:24 +02:00
efiten b5c7612166 fix(live): use the shared WebSocket instead of opening a second one (#1991)
Closes #1980.

`app.js` opens a WebSocket on every page load and fans messages out
through `onWS()`/`offWS()`. `live.js` ignored that channel and opened
its own socket to the same endpoint. `Hub.Broadcast` does no per-client
filtering, so both carried the identical full packet stream and **every
viewer on the live map pulled it twice**. That page is the one people
leave open for hours, so it was a standing multiplier on origin
bandwidth rather than a burst.

## Measured, before and after

Against a running instance, leaving the live map and returning while
counting WebSocket constructions:

| | new sockets on re-entry | constructed by |
|---|---|---|
| before | 1 | `at connectWS (live.js:3315)` |
| after | 0 | nothing |

And with one viewer on the live map, the server now reports **one**
WebSocket client for that viewer, with the live feed counter climbing
normally (7 to 31 over one navigation cycle, 71 on a fresh load).

## The change

The live map subscribes to the shared channel like every other view, and
unsubscribes in `destroy()` rather than closing a socket the rest of the
app still needs.

`connectWS()` drops any existing registration before adding a fresh one.
An early return looks like the natural guard against double
subscription, but it keeps the previous visit's closure registered, and
re-registering without dropping the old one renders every packet twice.
Dropping first is idempotent either way and always binds the current
page.

Reconnection becomes `app.js`'s business, since it owns the socket. That
moved `WS_RECONNECT_MS` out of the only place that honoured it, so
`app.js` now uses it too. It comes from `roles.js` and operators set it
as `wsReconnectMs`; after this change it applies to the one socket
everyone shares, or to nothing at all.

## Tests

Three, in the sandbox that already loads `live.js` with a Leaflet mock:

- the page registers exactly one listener on the shared channel and
constructs no WebSocket of its own
- re-entering leaves exactly one listener, and it is the new one rather
than the previous visit's
- the handler ignores messages that carry no packet

`node test-frontend-helpers.js` passes (726 assertions), `node
test-packet-filter.js` passes.

## One note on the history

The second commit on this branch claims the early-return guard broke
rendering on re-entry, citing a real measurement. The measurement
happened, the attribution was wrong: the zero counter came from the
WebSocket constructor patch I had installed to count sockets, which
interfered with the page it was measuring. The third commit records that
rather than rewriting it away. The change is kept because dropping the
old registration first is the clearer contract, not because the guard
was broken.

## Rule 0

Strictly less work than before: one socket per viewer instead of two,
one JSON parse instead of two per packet, and no second reconnect loop.
Nothing is added to the hot path; a listener already existed for every
other view.
2026-09-09 23:16:13 +02:00
efiten 8c164c5315 feat(scope-audit): verify a declared region against the repeater's own traffic (#1990)
Follow-up to #1987, and the point of counting that traffic in the first
place.

A region this instance holds no `hashRegions` key for is **unnameable,
not absent**. #1987 says so with a caveat chip. This settles it wherever
the evidence allows: derive `SHA256("#region")[:16]` from the repeater's
own declaration and HMAC that repeater's own unmatched packets with it.
Same computation the ingestor performs at ingest, with the candidate set
narrowed from every configured key to this repeater's handful of
declarations.

Where it fires, a grey "declared but not observed" chip becomes a green
one and the caveat count shrinks by the packets it explained.

## Two packets, not one

`code1` is two bytes, so an unrelated name matches a given packet with
probability 1/65536. Across ~400 unmatched packets and ~124 declared
names, chance alone produces roughly one false match per refresh. Two
matches on the same region for the same repeater is (1/65536)², about
one in four billion.

Lowering the threshold to one would not make this noisy, it would make
it **unsound**, so the constant carries that arithmetic and a test
rather than a comment. A region with exactly one hit stays grey and
reports its single hit, so the page can say why it is still shown as not
observed instead of leaving the reader to wonder.

## What it deliberately does not do

**It writes nothing.** Read-time only. A wrong answer expires with the
window instead of sitting in `transmissions.scope_name` until someone
runs a repair, and `cmd/server` stays read-only per the invariant in
AGENTS.md.

**`notObserved` remains the single source of chip colour.**
`regionEvidence` says only HOW a region was established. Two fields that
can disagree about the same fact is how this column got confusing in the
first place.

## Rule 0, including the part that was wrong at first

The naive shape is `targets × names × packets` HMACs: 205 × 124 × 400 ≈
10M.

Caching per `(region, transmission)` pair cuts the HMACs to ~50k. **That
measured 501ms**, because the HMACs had become a rounding error while
the *iteration* stayed cubic at 10.2M map lookups. Re-keyed per region,
holding the set of matching transmissions, it is **36ms** at the same
worst-case shape: a region is HMACed over every packet once, and a
target then asks one question per declared region instead of one per
(region, packet). Most declared regions match nothing, so the common
case is a single map lookup and no packet loop at all.

`hmacCount` exists so a test can assert the first mistake cannot come
back; the benchmark exists because only it caught the second.

## Both axes are bounded, because neither is bounded by the data

The "~400 packets in a 7 day window" this was sized against is a
property of one instance's configuration, not of the feature: the
ingestor stores the unnameable state for every transport-scoped packet
no configured key names, so an instance with few or no `hashRegions`
entries — the stock state, and the one this helps most — has **every**
scoped packet in that set.

- the window query takes the 4096 most recent candidates and reports
truncation, which the handler logs, so a grey chip on a sampled refresh
is not read as "not forwarded"
- the declared list is capped at 32 names per repeater: it arrives from
a collector that validates each entry's shape but never how many entries
there are
- measured at the cap: **306ms** for 205 targets over 124 names, against
29ms for the shape a real network produces

Because both caps make the evidence a sample, the response carries
`observedUnmatchedSampled`. Without it a client subtracts a capped
numerator from an uncapped total and overstates the unexplained traffic
with no way to know it is doing so. The chip subtracts only evidence for
regions **absent** from `notObserved` — a single-hit region the server
refused to accept is not called explained either — and says "at most N"
when the count was sampled.

## Verified on live data

Six repeaters clear the threshold in a 7d window on a real instance. One
of them: `nl-nb` green with 3 corroborating packets and the tooltip
stating the count, `belml` still grey on 1, and the caveat chip reading
31 of 34 packets unexplained rather than 30.

## Tests

`scope_verify_test.go` covers the HMAC-input walk against a real
transport-flood packet captured from a live instance (a hand-built
fixture would only prove the parser agrees with itself), that
`regionCode` does not fold case, the threshold in both directions, the
memo's HMAC count, both bounds with their truncation flag, and the
benchmark at cap size. Handler-level tests cover a region verified into
green, a single hit left grey with its count reported, and the
sample-size field.

`cd cmd/server && go test ./...` passes (77s), frontend 723 assertions
pass, `go vet` and `gofmt -l` clean.
2026-09-09 17:58:09 +02:00
efiten 0e607c1d01 feat(ingestor): derive region keys from what nodes declare, opt-in (#1989)
Follow-up to #1988, which made an ambiguous match deterministic. This
adds the second tier of keys that ambiguity rule was needed for.

A transport-scoped packet can only be named by a region key this
instance holds. `hashRegions` is a hand-maintained list, so every region
a node forwards that nobody typed into the config is stored unmatched,
and everything downstream reports that region as **absent** rather than
as **unnameable**.

The instance already knows the names, though: `nodes.configured_scope`
holds what the observer `/neighbors` ingestion (#1865) confirmed each
node is configured for. This derives keys from those names, on top of
the explicit list rather than instead of it.

**Default off.** An absent config block leaves behaviour byte-for-byte
unchanged, asserted by tests rather than argued.

## Sources

Two, mirroring what the server's `AllCurrentDeclaredRegions` already
merges, so the derived tier sees exactly what the Scope Audit sees:

| source | availability |
|---|---|
| `nodes.configured_scope` | always — the column is part of the schema,
written by the `/neighbors` path |
| `node_declared_regions` | optional, where a deployment fills it by
other means |

The optional table is probed via `sqlite_master` before it is read. A
stock install does not have it, and its absence must not abort a refresh
the first source could answer on its own.

## The two spellings

The sources spell the same region differently, and both are accepted:

- `configured_scope` carries the leading `#` that `normalizeScopeList`
adds, because every other stored scope value has one
- an OTA answer in the optional table carries the bare name
- `loadRegionKeys` already prefixes a missing `#` before hashing

So `regionNameAcceptable` canonicalises before judging, and hands back
the bare name the caller re-prefixes. What it rejects is what cannot be
a region name at all: `*` (the flood wildcard, not a region), a comma
(it would split the name on the next round-trip through a
comma-separated column), a second `#`, whitespace, non-ASCII, NUL
padding from a stale client, and anything past 32 characters.

The rules are deliberately structural rather than about meaning. A real
declared set contains entries that look like junk, but a blocklist on
string values is unmaintainable, and the cost of one bad name is a
single slot out of the cap plus a 1-in-65536 collision chance.

`*` is skipped in **two** places on purpose: in the filter, so no key is
ever derived for it, and in the source count, because nearly every node
declares it and counting it would overstate both the cap arithmetic and
the refresh log on every deployment.

## Rule 0

Each derived key costs one HMAC per transport-scoped packet and raises
the random 2-byte collision rate by 1/65536. So:

- the tier is **capped** (default 256)
- over the cap, names are kept by how many distinct nodes declare them,
so a one-off local name is dropped before a region half the network uses
- benchmarked linear at ~0.65µs per key: at 314 keys that is 217µs per
packet, 0.0008% of one core at the 0.037 transport-scoped packets/s this
network produces

There is no indexable shortcut to reach for, for the same reason as in
#1988: `code1` is an HMAC over the payload, so nothing is
payload-independent.

The set is an `atomic.Pointer` to an immutable snapshot. A refresh
builds the replacement off to the side and swaps the pointer, so the
ingest path never blocks on a rebuild. `refreshDerived` is a
load-then-store rather than a CAS loop, which is safe only because
exactly one goroutine calls it: the refresh ticker, plus one synchronous
call at startup. The comment says so, rather than leaving the type
looking as though it tolerates concurrent refreshers.

## Tier 2, and the counters

When several keys match one packet and exactly one of them is explicit
operator config, the explicit one wins: an operator who typed a region
into `hashRegions` outranks a name overheard on the air. Ambiguity
between two equally-sourced keys still stores unmatched, unchanged from
#1988.

Counters tally how each packet was decided (unique /
explicit-over-derived / ambiguous / none) and are logged on the refresh
tick. They exist to answer one question with data rather than
estimation: whether a third tier that breaks ties on path evidence is
worth building at all. Measured on a live instance at 159 keys over 41.7
hours and 147,535 packets: **0.253% ambiguous**, with
explicit-over-derived at zero because that instance has exactly one
derived key.

## Rule 8

`maxDerived` and `refreshMinutes` are configurable values and belong in
the customizer eventually. Documented in `config.example.json` for now,
flagged here so it is tracked rather than forgotten.

## Tests

- default off, and a refresh that is a no-op while disabled
- the name filter across both spellings, the wildcard, and each
structural rejection
- ranking by declarer count, then recency, then name, so the result is
deterministic rather than churning between refreshes
- `configured_scope` as a source, including that a node counts once per
name and the newest answer wins
- both sources merged, neither dropped
- the explicit-over-derived tie-break
- the derived tier replaced rather than merged on refresh, so a region
that stops being declared leaves the key set and the cap keeps meaning
something

`cd cmd/ingestor && go test ./...` passes apart from
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which needs
`SeCreateSymbolicLinkPrivilege` and fails on Windows on master too. `go
vet` and `gofmt -l` clean, `config.example.json` still parses.
2026-09-09 17:58:04 +02:00
efiten fd825a779c fix(ingestor): name a region scope deterministically, or not at all (#1988)
`matchScope` returns the first configured region whose derived code
equals the packet's `code1` and stops there. Go randomises map iteration
order per `range`, so when two configured regions collide on a payload,
the stored region name depends on which key the runtime happened to
visit first. **The same packet can be named differently on two runs of
the same binary**, and neither answer is evidence of anything.

## How often this actually happens

`code1` is two bytes, so any two configured regions collide on a given
payload with probability 1/65536. That is a curiosity at 5 configured
regions and routine at 150.

Measured on a live instance carrying 159 configured regions, over 41.7
hours and 147,535 transport-scoped packets: **374 collisions, 0.253% of
decisions.** They concentrate on four key pairs rather than scattering,
because `code1` is an HMAC over the payload: a payload that collides
collides every time it is seen, and a flooded packet is seen by many
observers.

The existing comment sizes the function for "≤ 50 regions". A live BE/NL
instance declares 126 distinct region names across its repeaters, so
operators are already past that.

## The rule

`matchingRegions` returns every match; `matchScope` applies one rule:

- exactly one match names the packet
- several matches name nothing

The candidates are equally sourced, there is no principled winner
between them, and storing a wrong region name is worse than storing
none. `""` is already the ingestor's "transport-scoped but unnameable"
state (`scopeNameForDB`), so an ambiguous packet lands in a state the
rest of the system already understands rather than in a new one. Nothing
downstream needs to learn a new value.

The collision is logged, because it is otherwise invisible: such a
packet is stored exactly like one whose region this instance holds no
key for. An operator watching an unnameable count grow deserves to see
which of their own configured regions are colliding, since the fix is
theirs to make.

## Rule 0

Cost is unchanged: the same single pass over the same keys, it just no
longer stops early. The early exit was worth nothing on the common path,
where zero or one key matches and the loop runs to the end either way.
Worst case is unchanged at one HMAC per key per transport-scoped packet.

There is no indexable shortcut to reach for. `code1` is an HMAC over the
packet payload, so nothing is payload-independent to index on, and the
old comment suggesting a "pre-indexed lookup table" is removed rather
than left as a false lead for the next reader.

## Tests

Three, and the fixture matters:

- an unambiguous packet still gets its region name
- a genuinely colliding payload stores the unmatched state instead of a
coin flip
- the ambiguous case run 50 times, because a first-match matcher passes
a single iteration roughly half the time

The collision is **found by searching payloads** (~65k tries, fractions
of a second) rather than asserting on a hand-picked `code1`. The case
only exists when the matcher genuinely finds two names for one packet,
and a fabricated code would only prove the test agrees with itself.

`cd cmd/ingestor && go test ./...` passes apart from
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which needs
`SeCreateSymbolicLinkPrivilege` and fails on Windows on master too. `go
vet` and `gofmt -l` clean.
2026-09-09 17:18:54 +02:00
efiten 0605b1703a feat(scope-audit): count and surface the traffic this instance cannot name (#1987)
Follow-up to #1986, and the second half of the same problem.

`ScopeAuditForwarding` drops rows whose `scope_name` is the empty string
with a bare `continue`. That empty string is the ingestor's
"transport-scoped, but no configured region key matched `code1`" state
(`scopeNameForDB`), so those packets name no region and can never
satisfy a declared one. The consequence is on the page: **a repeater
forwarding a region this instance holds no `hashRegions` key for is
reported exactly like a repeater forwarding nothing at all.** The audit
presents a gap in the reader's own configuration as a finding about
someone else's hardware.

This counts them per target, exposes the count as
`observedUnmatchedPackets`, and renders it as a caveat chip beside the
scope chips.

## Why it is not a rare edge

Measured on a live instance before this landed: of 613 `notObserved`
entries across 205 repeaters, **260 named a region that never appeared
under any name in the whole 7-day window**. Two of them (`behss`,
`fm-112`) were hash-verified as genuinely forwarded traffic the instance
simply could not name: packet `0a065d41d51f1f77` decodes to
`code1=9209`, which is exactly the code `#fm-112` derives over that
packet's own payload.

That instance had 16 region keys configured against 124 distinct region
names its repeaters declare. A stock install has fewer.

## What the counter is not

It is deliberately **not** folded into `unscopedPackets`. The two are
opposites:

| | meaning | what governs it |
|---|---|---|
| `unscopedPackets` | the packet carried no scope at all (`scope_name`
SQL NULL) | the `*` wildcard |
| `observedUnmatchedPackets` | the packet IS scoped, this instance holds
no key for that region | nothing the repeater declares |

For the same reason the new count never feeds `wildcardContradiction`,
which counts only plain unscoped floods. `scopeNameForDB` in the
ingestor is the source of truth for that three-state encoding, and the
comments point there rather than restating it.

It is also distinct from `ambiguousHops`, and the distinction is the
point of the chip: that one is a pubkey-prefix collision between two
repeaters and is nobody's fault, this one is a missing entry in the
reader's own configuration and they can act on it. Saying which is which
is what stops someone investigating an innocent repeater.

## Frontend

The chip reuses the muted dashed treatment of `.sa-chip-ambiguous` on
purpose: both are caveats on the row's finding rather than findings
themselves, and neither may compete visually with the red/green scope
chips beside them.

It renders nothing for a non-numeric count. The value is
server-supplied, and a truthiness check would put the literal string
`NaN forwarded packets` on the page if that ever stopped holding.

## Docs

`docs/api-spec.md` had **no entry for `GET /api/scope-audit` at all**,
so this adds one: query parameter, full response shape, and the notes a
client needs (the three traps the per-node endpoint documents apply here
identically, `*` is never a scope, and "never asked" is not "declared
nothing"). The new field is documented there rather than in isolation.

## Tests

- the counter on a last-hop and on a mid-path hop
- an unmatched packet enters neither `agg.scopes` nor `unscopedPackets`,
which is the confusion this field exists to prevent
- the field on the API row
- six frontend cases: zero renders nothing, a missing field renders
nothing (older server), the chip carries its count and class, singular
and plural are both grammatical, the title names the cause and the fix,
and a non-numeric count renders nothing rather than `NaN`

`cd cmd/server && go test ./...` passes, frontend 712 assertions pass,
`go vet` and `gofmt -l` clean.

Rule 0: the counter is one increment on a branch that already existed as
a `continue`, inside a loop this PR does not change. No new query, no
new pass over the data.
2026-09-09 17:05:29 +02:00
efiten 079e73aa4c fix(scope-audit): attribute forwarding to every hop, and pay for the wider scan (#1986)
The Scope Audit credits a transmission to `path[last]` only. On a
flood-family route every forwarder appends its own hash to the END of
the path (`internal/packetpath/route.go`), so `path[last]` does not mean
"forwarded this packet", it means "was the transmission an uplinked
observer heard directly". Every earlier hop forwarded the same packet
and is discarded.

The last-hop rule is genuinely required for DIRECT routes, which consume
hops from the front, so their `path[last]` is the route’s far end rather
than the transmitter. But `scopeAuditForwarderScanQuery` already
restricts to `route_type IN (0, 1)` via
`scopeConformanceForwarderRouteTypesSQL`, where that hazard cannot
arise, so inside this query the restriction only throws evidence away.

## What it costs the page today

Measured on a live-shaped instance, 206 declared repeaters, 965k
transmissions, 7d window:

| | before | after |
|---|---|---|
| repeaters with no attributable evidence of any kind | 133 of 205 (65%)
| 30 of 206 (15%) |

On a 1000-packet flood sample the mean path length is 7.08 hops, so the
last-hop rule keeps 394 of 2789 hop observations (14%), and 85% of the
nodes seen forwarding never appear as a last hop at all. Those repeaters
have every region they declare reported as "declared, not observed",
which is the page presenting a gap in our own attribution as a finding
about someone else’s repeater.

## Rule 0: what widening it costs, and what pays for it

Reading every hop multiplies the rows the scan returns: a 7d window
yields **3,470,188 hop rows** from 1,368,761 observations carrying a
path. Cold cost before this change was 16.7s for 7d and 4.0s for 24h, of
which SQLite accounts for 2.7s. The rest was the Go side reading rows.

Three changes, in order of what they bought:

1. **The scan carried `scope_name` and `first_seen` on every hop row.**
Both are columns of `transmissions`, and at 43 hop rows per transmission
the same two values were re-read that many times. They now come from one
query over the same window keyed by transmission id, both inside one
read transaction so a transmission arriving between them cannot appear
in the hop scan with no metadata to attribute it by. The hop scan
carries two columns instead of four.
2. **The hop is lower-cased into a stack buffer** instead of through
`strings.ToLower`. 1,026,814 of the 1,284,897 hops in a 24h window are
stored uppercase, because `packetpath.DecodePathFromRawHex` writes them
that way, and the great majority match no declared target, so that
allocation was paid millions of times to answer "no". The `(target,
txID)` de-duplication key became a struct for the same reason.
3. **The compute ran outside the cache mutex**, so every request
arriving on a cold window ran its own full scan concurrently. It now
sits behind a singleflight, the same treatment `/api/observers` and
`/api/nodes/{pubkey}/reach` already have, and the 7d window gets a 5
minute TTL while 1h and 24h keep 30s. At 30s a single reader with 7d
open keeps the instance recomputing more than half the time, for an
aggregate that moves at the pace of a week of traffic.

Result, warm process:

| window | before | after |
|---|---|---|
| 1h | 0.155s | 0.140s |
| 24h | 4.04s | 2.79-2.89s across six samples |
| 7d | 16.7s | 11.6s |
| repeat inside TTL | ~1ms | ~1ms |

**Rejected alternatives, measured on the same database**, so the next
reader does not have to re-derive them:

| approach | rows returned | time in SQLite |
|---|---|---|
| the query as written | 3,470,188 | 2.7s |
| pre-filter on the declared targets’ first 4 hex chars | 1,971,126 |
20.9s |
| `GROUP BY t.id, hop` | 965,025 | 38.0s |
| `SELECT DISTINCT t.id, path_json` | 1,229,966 | 17.7s |

The query plan is already index-driven (`idx_transmissions_first_seen`,
then `idx_observations_tx_ts`), so there is no missing index behind
this: the rows are inherent to the data. Note for anyone attempting a
hop comparison in SQL: a case-sensitive comparison silently drops most
attributable hops, per the 80% figure above.

## Tests

- a mid-path hop is attributed (the case behind the 65% blind spot)
- a DIRECT transmission whose `path[last]` **is** the target is still
not attributed. With the last-hop rule gone this is the only thing
standing between the audit and misattribution, so it gets its own test
rather than relying on the route filter being obvious
- one transmission counted once per target even when it appears on
several hops of the same path, which the `(target, txID)` de-duplication
now carries alone
- a hop longer than the 4-char floor resolved by its own length, which
nothing pinned before: every other test seeds 4-char hops
- the per-window TTL, so collapsing it back to one constant has to
delete the reason
- a second request inside the TTL served from cache rather than
recomputed. The cache path had no test at all

`cd cmd/server && go test ./...` passes (168s), `go vet` and `gofmt -l`
clean. Server-side only, no API shape change, no frontend change.

Browser validation: run against a live instance carrying this change,
the Scope Audit renders 220 rows matching the API row for row, and the
per-node scopes page still answers with its route-type mix.
2026-09-09 16:00:15 +02:00
Anupam MedirattaandClaude Opus 5 de237fc29c fix(qa): bind TEST_PUBKEY as a SQLite parameter instead of interpolating it (#1982)
Closes #1977. Supersedes #1952. Follow-up filed as #1983.

## What §10.2 did

```bash
q="SELECT COUNT(*) FROM transmissions WHERE from_node = '$TEST_PUBKEY';"
qq=$(printf %q "$q")
if ! count=$(ssh_t "docker exec … sqlite3 … $qq" 2>/dev/null); then
  count=$(ssh_t "sqlite3 … $qq" 2>/dev/null || echo "")
fi
```

The injection is not reachable today — `TEST_PUBKEY` is hex-gated and
the
script `exit 2`s before the SQL is built. The problem is that the SQL
layer's
safety rests entirely on that outer gate rather than on the SQL layer
itself.
#1952 proposed doubling embedded quotes; that is string escaping, not
parameterisation, which is why it was withdrawn in favour of this.

## What this does

Per the four points in the sign-off on #1977:

**1. Bind the value.** A constant `SELECT` and a bound `:pubkey`, fed to
sqlite3 on stdin. The SQL no longer crosses the remote shell as a
command
word, so there is no `printf %q` on the query at all any more.

**Why hex rather than `.parameter set :pk '<value>'`.** Dot-command
arguments
are split on whitespace, so a payload containing a space produces too
many
arguments — and sqlite3 responds by printing the `.parameter` help to
**stdout**, exiting **0**, and leaving `:pk` **unbound**. `COUNT(*)`
then
returns 0, which reads exactly like a passing security fix. `-bail` does
not
catch it. Verified on 3.51.0:

```
$ printf ".parameter set :pk '' OR 1=1 --'\nSELECT COUNT(*) FROM transmissions WHERE from_node = :pk;\n" \
    | sqlite3 -bail ptest.db
.parameter CMD ...       Manage SQL parameter bindings     # <- help, on stdout
   …
0                                                          # <- :pk never bound
$ echo $?
0
```

`.parameter set :pk 1+1` also binds the integer `2` — the value is
evaluated
as an SQL expression and only falls back to a text literal when
evaluation
fails. So interpolating into the `.parameter set` line trades one hazard
for
another.

Hex-encoding removes the quoting layer instead of adding one: the value
is
bound as `cast(x'<hex>' as text)`, so its contribution to the SQL text
is
drawn from the alphabet `[0-9a-f]` only. Nothing to quote, no tokenizer
arity
hazard, and it holds for **arbitrary** input rather than only for
hex-gated
input — which is the point.

Verified against a fixture table holding two rows, one of them
`deadbeef`:

| value | result | exit |
|---|---|---|
| `deadbeef`, bound as `cast(x'6465616462656566' as text)` | `1` | 0 |
| `' OR 1=1 --`, bound the same way | `0` | 0 |
| `' OR 1=1 --`, interpolated the current way | `2` (whole table) | 0 |
| query against a DB with no `transmissions` table, `-bail` | `Parse
error … no such table` on **stderr** | **1** |

**2. Probe the capability, not a version.** `resolve_sqlite_runner`
binds
`corescope-probe-ok` and asserts it comes back — a round trip, not a
bare
`.parameter init`, so the positive control runs against the operator's
actual
binary rather than one we pin. If neither the container nor the host
qualifies,
it fails loudly and names what is needed:

```
  ❌ retain-failed: no sqlite3 able to bind a parameter on the target
     tried: docker exec -i corescope-prod sqlite3, then sqlite3 on runner@example
     need:  the sqlite3 CLI reachable over ssh, supporting '.parameter set'
OCI runtime exec failed: exec: "sqlite3": executable file not found in $PATH
bash: line 1: sqlite3: command not found
```

There is deliberately **no** interpolating fallback. That would leave
the
vulnerable path in place under a nicer name.

**3. The hex gate is kept**, with its comment updated to say why: for
the SQL
layer it is now defence in depth rather than the only guard. Redundant
is not
the same as wrong.

**4. The exit status and stderr survive.** `-batch -bail -init /dev/null
-noheader -list` (stop at the first SQL error; ignore the operator's
`~/.sqliterc`, where a stray `.mode` would make the count unparseable;
stdout
is exactly the number). Query stderr is captured and printed on failure
rather
than sent to `/dev/null`, so a broken query is distinguishable from a
legitimately empty result. Probe stderr is collected too, and printed
only if
*both* probes fail — the container miss is the known-normal case, so
surfacing
it on every run would be noise.

## Also fixed

An existing double-count in §10.2: the `TARGET_DB_PATH unset` branch
incremented `$fails` and then left `count=""`, so the generic branch
incremented it a **second** time for the same failure.
`read_retain_count` now
gives §10.2 exactly one increment point. Opportunistic cleanup in a file
already being touched (AGENTS.md line 318).

## Tests

New `qa/scripts/test-blacklist-sql.sh`, wired into the `go-test` job. 24
assertions, modelled on `scripts/staging/test-disk-monitor.sh`.

Both directions are asserted, because a zero from a command that failed
proves
nothing:

- **Positive control** — `deadbeef` still returns its row (`1`, exit 0),
and so
  does `cafebabe`; an absent pubkey returns `0`.
- **Negative** — `' OR 1=1 --` returns `0` while the table demonstrably
holds
2 rows, and the old interpolated form is asserted to leak all `2`. That
last
  assertion is what makes the `0` above worth something.
- **Error surfacing** — the same SQL against a DB with no
`transmissions` table
  exits non-zero with a message on stderr and nothing on stdout.
- **Alphabet** — `sql_hex_literal` output matches `^x'[0-9a-f]*'$` for
the SQL
payloads, a backslash, `$(id)` / backticks, an embedded newline,
`héllo`, and
a 4096-byte repetitive string. That last one is a regression guard for
`od
  -v`: without the flag `od` collapses repeated identical lines to `*`.
- `run_sqlite` with no resolved runner refuses rather than guessing.

Group 2 skips loudly (rather than silently) if `sqlite3` is not on PATH;
group
1 needs no sqlite3 and always runs.

**Mutation-tested** — each of these breaks the suite, so the assertions
have
teeth:

| mutation | caught by |
|---|---|
| restore full interpolation | `injection payload → 0 rows — expected
'0' got '2'` |
| naive `.parameter set '%s'` | `expected '0' got '.parameter CMD ...'`
|
| drop `od -v` | alphabet failure on `*`, plus `expected '8192' got
'33'` |

Commit 1 is a behaviour-neutral refactor that moves the imperative body
into
`main()` behind a `BASH_SOURCE` guard, so the test can source the script
and
exercise individual helpers. Same idiom as
`scripts/staging/disk-monitor.sh:99`.

## Verification

- `bash qa/scripts/test-blacklist-sql.sh` → 24 passed, 0 failed
- `bash -n` on both scripts
- All three runtime paths exercised end to end with PATH shims for
  `ssh`/`docker`/`sqlite3` against a real fixture DB: success
  (`sqlite3 runner: host`, count 2), query failure (classified message +
`Parse error … no such table`, `fails=1`), and no-capability (the loud
block
  above, both probe stderrs, `fails=1` — not 2)
- The new step lands inside `go-test`, which runs when
`changes.outputs.code ==
  'true'`; `qa/scripts/*.sh` does not match that job's
  `^docs/|[.]md$|^LICENSE$` documentation filter, so it is not skipped

## Deliberately out of scope

- **The `docker exec` branch is dead on current images** → filed as
#1983. The
app container has no `sqlite3` at all: `Dockerfile:15` is pure-Go SQLite
with
  no CGO, and the `apk add` installs only `mosquitto mosquitto-clients
supervisor caddy wget`. So the host "fallback" is the only path that has
ever
executed, silently, because both branches discarded stderr. This change
keeps
both branches and merely makes the outcome visible (`sqlite3 runner: …`
on
  every run).
- **`-readonly` on the target DB.** Tempting, and verified compatible
with
  `.parameter` (the binding table lives in the TEMP database), but a WAL
database needing journal recovery can refuse a read-only open. Adding it
here
risks exactly the "trades an unreachable injection for a script that
does not
  run" outcome flagged in the #1952 thread. Worth its own issue.
- **The other `2>/dev/null` sites** in this file, which also sit
awkwardly with
`qa/README.md`'s "Don't silence stderr". Only the §10.2 lines named in
the
  sign-off are touched.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:37:40 +02:00
Sylvain Rabot 3fbff01f64 build: upgrade Go toolchain to 1.27 (#1946)
## Summary
- Bumps the Go toolchain used to build/test to 1.27:
`golang:1.27-alpine` in `Dockerfile` and `Dockerfile.go`, and
`go-version: '1.27'` in the three `actions/setup-go` steps in
`.github/workflows/deploy.yml`.
- Each module's `go.mod` `go` directive is intentionally left at `1.22`
— no 1.27-only language features are being adopted, and a 1.27 toolchain
builds a `go 1.22`-declared module without issue.

## Test plan
- [x] `go build ./...` + `go vet ./...` for all 13 modules
(`cmd/server`, `cmd/ingestor`, `cmd/migrate`, `cmd/decrypt`, 10
`internal/*` packages) under Go 1.27.0
- [x] `go test ./...` passes for `cmd/server`, `cmd/ingestor`,
`cmd/migrate`, `cmd/decrypt`
- [ ] `docker build` against the new `golang:1.27-alpine` base (Docker
wasn't available in the sandbox this change was prepared in — needs a
check in CI or locally)
2026-09-09 11:49:33 +02:00
efiten 2c8c1161b5 feat(#1975): network-wide Scope Audit page, fed by confirmed scopes (#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.
2026-09-06 23:04:42 +02:00
efitenandClaude Opus 5 5b689f75fe feat(#1845): filter nodes by how long they have been silent (#1973)
Closes #1845 for the question in its title. The alerting ask that came
later in the thread is deliberately **not** in here; see the bottom.

### The gap

@Jonher937 asked to flag repeaters that stopped communicating for x
days, to find remote gear that has died. Today the Nodes page has
Active/Stale with thresholds fixed at 72h for infra and 24h for
everything else, plus a Last Heard filter that selects nodes heard
**within** a window. Neither answers "show me what has been quiet for
over a week".

### What this adds

A `Silent for` select beside Last Heard: 1d, 3d, 7d, 14d, 30d, each
labelled with the number of nodes it would select.

```
[ All ] [ Active ] [ Stale ]   Last Heard: Any v    Silent for: over 7d (23) v
```

Counts are computed **before** the silence filter is applied, so the
dropdown keeps showing what the other windows would select instead of
collapsing to the one already chosen. The choice persists in
`localStorage` like the neighbouring filters and is mirrored into the
URL as `?silent=7d`, so the view can be pasted to whoever owns the
silent gear. The URL sync is wrapped in try/catch, because it is a
convenience and must never stop the filter working.

### The part worth reviewing: one definition of freshness, not two

`getNodeStatus` has been relay-aware since #1598, while `nodes.js`
separately computed `statusAge` from the ADVERT timestamp alone.
Filtering on the latter would have listed a repeater as silent for ten
days while its own badge on the same row said active, and it would have
done so for **exactly** the nodes #1598 exists to protect.

So the freshness rule is extracted into `window.getEffectiveHeardMs` in
`roles.js`, and `getNodeStatus` now calls it. Behaviour is unchanged,
there is now one source. Reviewers should look hardest at that refactor
rather than at the select.

A node never heard from at all scores `Infinity`, so it matches every
window instead of silently dropping out of the filter.

### Why the thresholds are fixed values and not derived

I measured the alternative before writing this, on a 1179-repeater mesh,
and posted it on #1611: replacing a fixed threshold with `3 x per-node
advert median` fixes 8 false "silent" flags and newly mis-flags **28
currently-active nodes**, because a 3h-median node gets a 9h threshold.
Raising the global default to 144h rescues 7 and hides 27 genuinely dead
repeaters. Both are net-negative. A user-chosen window sidesteps the
whole question: the operator picks what "too long" means for their mesh,
which is what @Jonher937 asked for in the first place.

### Verification

- `test-frontend-helpers.js`: **666 → 680 passed, 0 failed**. Fourteen
cases covering `NaN` rather than `0` when nothing is known (0 is a real
timestamp and would sort as very old rather than unknown), the full
`_liveSeen > _lastHeard > last_heard > last_seen` precedence, a recent
relay beating a stale advert, a stale relay **not** dragging a fresh
advert backwards, relay alone sufficing, `room` counting as infra while
`companion` does not (a `last_relayed` on a companion is meaningless and
must not rescue it), case-insensitive roles, the legacy `(role, ms)`
call shape, and the 72h boundary asserted at 71h and 73h.
- One of those failed on first run and **the test was wrong, not the
code**: `9e7` ms is 25h, which is correctly active for infra. Fixed, and
the boundary is now asserted explicitly so nobody repeats it.
- `eslint` on the changed files: 0 errors. The warnings present are the
same ones master already reports.
- No server change, no new API, no new column. No cache-buster bump
needed: `__BUST__` is substituted at startup in
`cmd/server/main.go:570`.

### Not done

**No alerting.** @fokcuk asked on the thread for notification when a
repeater they look after goes silent, which is a different product:
subscriber identity, an evaluation loop and delivery, none of which
exist today. That deserves its own issue and a design call rather than
being shimmed into `nodes.js`, which is also what the triage concluded.
This PR gives the operator the view; it does not push to them.

Sizes and boundaries (1d/3d/7d/14d/30d) are a judgement call. Say the
word if a different set fits real operator habits better.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 21:43:20 +02:00
n30nex 2288e28d4e fix: publish release artifacts after successful image retagging (#1964)
Successful release fast paths publish image tags but never dispatch the
job that creates the GitHub release and decrypt binaries. Dispatch
`deploy.yml` from both image routes. A default-off `images_published`
input skips E2E and image rebuilding only for an already-published tag;
missing or mismatched images keep the complete fallback without
requiring new inputs on older workflow definitions.

Go validation still gates the release binaries, checkout and version
flags retain the tagged source, and the existing release action uploads
both architectures before publication. Missing binary files now fail
publication.

Fixes #1956.

Validation:

- `node test-issue-1956-release-routing.js` executes the actual workflow
shell steps with registry and dispatch commands stubbed. Covers
matching, missing and mismatched images; failed retag and Go validation;
branch/PR boundaries; and both tagged binary commands.
- The original test commit fails because a matching image dispatches
zero artifact workflows; the fix passes the same assertion.
- Existing release workflow Go checks, decrypt/channel tests, YAML
parsing and actionlint pass.
- Both static Linux amd64 and arm64 binaries cross-build with verified
architecture and version metadata.

Actual registry publication and GitHub release creation were not
exercised. Existing immutable releases and old tags that contain older
workflow definitions are outside this fix.

Following #1922, this is a focused release-routing PR. A separate repair
for #1858 rewrites the shared frontend test runner; merging this first
lets that repair retain this regression in its authoritative list.
Please assess current Go and E2E job results separately from
workflow-approval or staging-runner state.
2026-09-06 21:20:16 +02:00
efitenandClaude Opus 5 1ffaad8eb1 feat(#1794): per-IP limits and a deny list on the /ws upgrade (#1974)
Closes #1794. Follow-up to #1793, decided **before** the upgrade because
the handshake is the resource being protected.

- Deny list of addresses and CIDRs → 403
- Per-IP concurrent connection cap → 403
- Per-IP upgrade rate limit over a rolling minute → **429**, not 403: a
temporary refusal should not read as "never come back"
- Rejection counters split by cause in `/api/stats` under `websocket`

### The decision this feature lives or dies on

Most CoreScope installs sit behind nginx, Caddy, Traefik or an ingress.
`cdn_detection.go` says so in as many words: it deliberately excludes
`X-Forwarded-For` from its CDN signals precisely because *every*
reverse-proxied install sets it. For those deployments `r.RemoteAddr` is
the proxy, `127.0.0.1` for every visitor on earth. A per-IP cap keyed on
that address protects nobody and hands the sixth legitimate browser tab
a 403. That is a self-inflicted outage wearing the costume of hardening.

So:

- **`X-Forwarded-For` is believed only from an address listed in
`webSocket.trustedProxies`.** From anywhere else it is
attacker-supplied, and trusting it would let anyone mint a fresh source
IP per connection, which is strictly worse than having no limit at all.
- **When the peer looks like a local reverse proxy and no
`trustedProxies` is set, the per-IP limits are skipped**, and one
warning names the setting that fixes it. Silently refusing real users is
the worse failure.
- **The deny list still applies there**, because it is the operator's
explicit instruction rather than an inference.

That is the answer to @mcode6726's question on the thread: it is neither
"always the socket address" nor "always the header", and the operator
decides which by naming their proxy.

### Two deliberate departures from the issue body

**`maxConnsPerIP` ships as 0 (off), not 5.** Carrier-grade NAT puts
thousands of unrelated mobile subscribers behind a single public IPv4. A
cap of 5 refuses real visitors on phones while a scraper simply rents
more addresses: all of the cost, none of the benefit.
`upgradesPerMinPerIP` ships at **30 and on**, because that one *is* safe
under CGNAT: a real client upgrades a handful of times per minute even
while reconnecting, so 30 leaves ordinary traffic untouched while
flattening a reconnect loop. A pointer type distinguishes "unset" from
an explicit `0` that turns it off.

**The default deny list is not shipped.** The thread proposed seeding 44
CIDRs for one VPS provider after a single scraper was seen at
`23.111.177.6`. I have left it out: blanket-blocking a hosting provider
by default breaks legitimate operators who host there, is undiscoverable
by the person locked out (they see a bare 403), and ages badly as ranges
get reassigned. The mechanism is here and `config.example.json` shows
exactly how to configure it, so any operator who wants that list can
have it in one line. If you want it shipped as a default anyway, that is
your call as maintainer and it is a one-line change.

### Verification

19 tests, including all five the issue specifies as TDD requirements,
each marked with the issue's own wording. Beyond those five:

- a **bare address** in the deny list works, not just CIDR form.
Operators write `1.2.3.4`, and silently ignoring that would be the worst
possible failure for a deny list: it looks configured and blocks nothing
- an unparseable deny entry is skipped and logged, not fatal. One typo
must not take the server down
- one client behind a trusted proxy does **not** exhaust another
client's budget behind the same proxy, which is the entire point of
honouring XFF
- changing a forged XFF from an untrusted peer buys no fresh budget
- `release` frees a slot and is **idempotent**, because `Unregister` can
run twice for one client and double-crediting would leak slots
- a **rejected** upgrade does not consume rate budget, or a retrying
client could never recover once its window cleared
- limits skipped for loopback and private peers; deny list applies
anyway
- a nil limiter allows everything, so a `Hub` built without
`ConfigureLimits` behaves exactly as before
- idle per-IP state is collected, while a record with a live connection
never is

Full `cmd/server` suite green, `gofmt` clean.

### Not done

- No runtime config reload; restart required. Listed as optional in the
issue.
- No `WS_DENY_IPS` env override. Also listed as optional.
- From the OWASP expansion in the first comment: `maxPayload` and the
idle/read timeout are **already in master** (`SetReadLimit`,
`SetReadDeadline`). The ping/pong heartbeat is not, and is not in this
PR either; it is a separate change to the read/write pumps and belongs
in its own review.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 21:11:29 +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
n30nex 43d83ee8ca fix(nodes): dispose map timers with their owning view (#1970)
Red commit: `2f1a50e` (local Chromium: 2 passed, 7 assertion failures).
Ownership regression: `059ab49` (9 passed, 4 assertion failures). CI:
[run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/33988139479)
awaits maintainer approval (`action_required`); 0 jobs started.

Rapid navigation or closing node detail could leave a delayed resize
targeting a removed or replacement map. Disposal now cancels its timer,
each resize captures its own map, and delayed responses respect the view
owning the current map. Stale side-pane responses are ignored before
rendering.

Fixes #1972.

- E2E assertion added: `test-issue-1206-resize-observer-leak-e2e.js:216`
and `:292`. This existing CI-selected suite covers navigation,
replacement deadlines, close/Escape, no-location rendering, and late
error/success responses. Existing observer-growth assertions remain
intact; readiness waits replace fixed sleeps.
- Browser verified: local fixture with real Chromium and Leaflet; 13
browser checks passed after push. Evidence:
`data/node-map-validation/post-push-browser.log` and
`data/node-map-validation/evidence.md`.
- Validation: frontend unit suites 99/18/666 passed; JavaScript syntax,
CSS variables, whitespace, PII and XSS checks passed. Backend unchanged;
Go suites not rerun.
- Independent adversarial, expert and TDD reviews found no required
changes. Two original navigation checks initially timed out; the
unchanged parent rerun passed 13/13 (`parent-browser-confirm.log`).
- Performance/config: one timer handle, no packet/node loops or new
requests. Tests enforce zero stale invalidations and one resize at the
surviving map's deadline. Existing 100ms delay retained; no new settings
or throughput claim.

Fix commits: `2492a65`, `1dc090d`.

## Preflight overrides

- External `run-all.sh` is unavailable. Scoped branch, red/green, PII,
CSS, XSS and whitespace checks were run directly; no migrations, SQL
attribution or image markup are added.
2026-09-06 21:04:09 +02:00
n30nex a938176f83 fix(nodes): clearly mark the selected node in path chains (#1968)
Red commit: `0988bc0` (local browser: 3 passed, 8 behavior assertion
failures before the fix).

The selected node now has a compact outline in long “Paths Through This
Node” chains, in the side panel and full detail page. Matching uses
complete public keys without case sensitivity; same-prefix siblings and
unresolved hops stay unmarked. Existing links, escaped names, warnings
and ambiguity underlines are preserved.

Fixes #1153. Its prerequisite #1144 is already merged.

- E2E assertion added: `test-issue-1146-path-link-contrast-e2e.js:220`.
The existing CI-selected harness passes 11 checks across 18-hop paths,
both themes, desktop/mobile, and the renderer fallback. Review follow-up
`bd8118f` verifies the marked ambiguous hop's dashed underline.
- Browser verified: `http://127.0.0.1:55635`; desktop/mobile path
screenshots were inspected. The broader smoke runner exited successfully
with fixture-dependent skips.
- Required frontend checks pass: 99 filter, 18 aging, 666 helpers. CSS
variables, seven CSS self-tests, 31 XSS sink checks, 17 XSS gate
self-tests and XSS diff preflight pass.
- Three independent reviews found no blocking issues. Traversal remains
linear with no new requests, settings, dependencies or cache
invalidation; styling uses the existing customizer token.

## Preflight overrides

- The external preflight runner is absent; corresponding scoped gates
passed. Red browser evidence is local, with upstream CI approval tracked
separately under the process in #1922.
- Existing rapid-navigation map resize timer errors remain visible in
browser logs and are outside this change.
2026-09-06 21:04:03 +02:00
n30nex 108ea020f7 fix(nodes): remove misleading aggregate SNR headlines (#1969)
Red commit: `2bf9be8` (local Chromium: 3 passed, 3 intended assertion
failures). CI:
[run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/33988112224)
awaits maintainer approval (`action_required`); 0 jobs started.

Remove the unqualified aggregate Avg SNR row from node side-panel
Overview and full-detail stats, following option 3 in #1149. Heard By
retains each observer's SNR reading.

Fixes #1149.

- E2E assertion added: `test-issue-1281-location-row-e2e.js:224`. Three
new browser cases cover desktop side/full and mobile full views with a
numeric aggregate and distinct positive/negative observer readings.
Existing packet-location assertions remain intact.
- Browser verified: local Chromium; 6 cases passed after push.
Screenshots: `coverage/issue-1149/issue-1149-desktop-side-panel.png`,
`coverage/issue-1149/issue-1149-desktop-full-detail.png`, and
`coverage/issue-1149/issue-1149-mobile-full-detail.png`.
- Validation: packet filter 99/99, aging 18/18, frontend helpers
666/666; XSS, CSS-variable, syntax, whitespace and PII checks passed.
- Independent reviews: adversarial, lifecycle expert and TDD reviewers
found no required changes. One initial browser navigation timed out; the
unchanged parent rerun passed 6/6.
- Performance/config: two production row deletions; no new requests,
loops, timers, settings or customizer implications. Backend unchanged;
Go suites were not rerun.

Fix commit: `d7c68f3`.

## Preflight overrides

- External `run-all.sh` is unavailable on this host. Scoped branch,
red/green, PII, CSS, XSS and whitespace checks were run directly. The
diff adds no migrations, SQL attribution or image markup.
2026-09-06 21:03:58 +02:00
n30nex eb1d733998 fix(analytics): preserve selected hash size in links (#1967)
Red commit: `5a5ecb6` (local browser: 14 passed, 8 behavior assertion
failures before the fix).

Hash Issues links now restore `bytes=1|2|3` for the selected control and
its matrix/collision data. Missing or malformed values default to one
byte. Selector clicks, section/top links, tab-bar changes, filters and
theme refreshes retain the chosen view through the existing URL helper.

Fixes #1914.

- E2E assertion added:
`test-issue-1306-collisions-terminology-e2e.js:242`. The existing
CI-selected harness passes 23 checks, including distinct nonempty
collision rows for each byte size. Its original assertions remain.
- Browser verified: `http://127.0.0.1:55634` with the local fixture API,
plus reviewed matrix/risk screenshots. Region refresh passed; area
coverage skips because the fixture has no areas.
- Required frontend checks pass: 99 filter, 18 aging, 666 helpers; URL
helpers pass 18. Three independent reviews found no blocking issues;
their coverage suggestion is included in `fb482fe`.
- Added work parses URL state and updates six links. Rendering and bulk
requests are reused; no backend, configuration, dependency or CI-list
changes.
- A broader smoke run timed out at Live autocomplete (#1110); full-suite
success is not established.

## Preflight overrides

- The external `run-all.sh` is absent. Corresponding scope, PII, syntax,
whitespace and CSS checks passed; no SQL, migration or image changes
require those gates.
- Red browser evidence is local. Upstream CI execution remains a
separate approval gate, as discussed in #1922.
2026-09-06 21:02:14 +02:00
n30nex cf67a5e5ec fix: remove evicted resolved path hops and preserve relay snapshots (#1966)
Eviction removes raw wire hops but leaves resolved full-key entries in
`byPathHop`, retaining expired transmissions and stale relay
counts/scopes. Filter every hop bucket once per eviction batch using the
existing evicted-ID set, remove duplicate references and empty buckets,
and clear discarded pointer slots.

Bulk relay aggregation now owns its bucket snapshots before releasing
the read lock, so eviction and raw-path updates cannot mutate an
in-flight reader. Three existing handler test fixtures also wait for
index readiness or explicitly simulate not-ready state, preserving their
original 200/503 assertions.

Fixes #1908.

Validation:

- Regression commits fail before their corresponding fixes: resolved
keys/counts/scopes remain after eviction, and saved relay snapshots
change during eviction.
- Targeted eviction, relay, scope, cache and concurrent-reader checks
pass under `-race`; coverage includes time/cap eviction, missing
resolved-path prefetch, disabled membership indexing, duplicate
references, retained backing arrays and surviving entries.
- Local browser smoke: nodes, node details/path attribution and
analytics render using the fixture-backed Go server.
- The last full Windows server race run, before the final snapshot-copy
correction, had one remaining DB-only timing failure
(`TestGetChannelMessagesPerfLargeChannel`: 2.198s against a 1.5s
budget). The final correction was checked with focused race tests. The
unchanged ingestor suite also cannot create one symlink without Windows
privileges. These thresholds/assertions were preserved; full Linux
Go/E2E results still require upstream CI approval.

Performance tradeoff: cleanup is O(total indexed pointers) per nonempty
eviction batch, under the existing write lock. The minute-based ticker
pays for one sweep instead of repeated scans of shared raw buckets. No
per-transmission string index or dependency is added. Synthetic
benchmark medians (three single-iteration runs, shared Windows host):

| Transmissions | Evicted | Before | After |
|---:|---:|---:|---:|
| 30,000 | 1 | 1.07 ms | 14.19 ms |
| 30,000 | 3,000 | 56.27 ms | 61.68 ms |
| 30,000 | 7,500 | 83.58 ms | 65.54 ms |
| 100,000 | 1 | 0.30 ms | 56.95 ms |
| 100,000 | 10,000 | 949.93 ms | 190.71 ms |
| 100,000 | 25,000 | 1,834.48 ms | 320.38 ms |

Fixture: eight raw plus eight resolved hops per transmission, two
observations, 2,048 relays; 480,000/1,600,000 hop entries. Timing
includes acquiring the store lock and omits unrelated secondary indexes.
Small batches now pay for the complete sweep; shared-host timing is
noisy.

Owning the bulk reader's arrays also has a measured cost on cold/bulk
recomputation, rather than cached hits. Snapshot medians from three
samples of ten iterations:

| Transmissions / relay nodes | Before time / bytes per operation |
After time / bytes per operation |
|---|---:|---:|
| 30,000 / 50 | 0.0068 ms / 5,416 B | 23.65 ms / 4,101,435 B |
| 30,000 / 2,000 | 0.1374 ms / 196,768 B | 26.77 ms / 4,274,336 B |
| 100,000 / 2,000 | 0.1376 ms / 196,768 B | 27.89 ms / 13,959,337 B |

These are total snapshot costs, comparing the unsafe header-only
snapshot with owned pointer arrays. Cleanup guarantees here apply to
`byPathHop`; other indexes and existing periodic bulk-cache freshness
are outside this change.

Following #1922, this runtime fix is separate from the release-routing
and frontend-runner PRs. Current Go and E2E job results should be
assessed separately from workflow-approval or staging-runner state.
2026-09-06 21:00:44 +02:00
TeTeHacko 6ae7971da0 test(#1356): assert the rendered label, not where identifiers sit in map.js (#1933)
Follow-up to the review on #1912, where this assertion cost a round
trip. Independent of that PR — this branch is off current `master` and
touches no code path it changes.

## The problem

`#1356 V3.e`, `V3.f` and `V3.g` all describe what
`makeRepeaterLabelIcon` **produces**, but all three assert it by
grepping `public/map.js`. V3.e also bounds the distance between two
identifiers:

```js
assert(/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/.test(mapSrc),
  'makeRepeaterLabelIcon prepends MB_GLYPHS glyph to the hash text');
```

Three separate failure modes, all observed:

**1. It fails on edits that change nothing.** #1912 inserts one variable
declaration in that function; the markup is byte-identical and the build
went red.

**2. It cannot tell code from prose about code.** My first attempt at
fixing #1912 added a comment explaining the constraint — and the comment
mentioned both identifiers, so it satisfied the grep by itself. With
that comment present I moved the hash assignment away from the glyph,
reintroducing the exact defect, and the test still reported green. A
check that a comment can satisfy is worse than one that is merely
brittle.

**3. It does not assert the thing it is named after.** On `master` the
match is not the declaration order at all. It is `MB_GLYPHS[...]`
reaching the *later* `shortHash` inside `ariaStatus`, 212 characters
downstream. Whether the glyph is actually prepended to the hash is
incidental to whether this passes.

That third point also corrects something I said on #1912, and it
corrects it against myself: both the 312 you quoted and the 299 I
"corrected" it to are the distance between the two **declarations**,
which is not the distance the regex uses. Measuring the one it does use:

| tree | `MB_GLYPHS[` → next `shortHash` | assertion |
|---|---|---|
| `master` | 212 | pass |
| #1912 before the fix | 277 | fail |
| moving `unknownWidth` below the glyph | **343** | fail |
| moving `shortHash` below the glyph | 54 | pass |

So moving `unknownWidth` down does not merely fall short — it makes the
gap *worse*, because it lands between the glyph and `ariaStatus`. My
earlier "233, still 33 over" was the wrong metric on the wrong pair.
Apologies; the conclusion happened to hold but the reasoning did not.

## What this does

Loads `map.js` in the same DOM-less `vm` sandbox
`test-map-clustering.js` already uses, exposes `makeRepeaterLabelIcon`
through the existing `window.__meshcoreMapInternals` hook, and asserts
the emitted markup:

- glyph, `U+2009` thin space, hash — in that order and adjacent;
- no glyph and no thin space when there is no multi-byte status;
- `aria-label` exactly `multi-byte <status>, hash <ID>`, and `repeater
hash <ID>` without one;
- the visible span carries `aria-hidden`.

No browser, so it stays in the JS-unit-tests step rather than moving to
Playwright.

## Mutation-tested, not eyeballed

| mutation | old V3.e/f/g | new |
|---|---|---|
| glyph moved after the hash | **all silent** | caught |
| plain space instead of `U+2009` | **all silent** | caught |
| span loses `aria-hidden` | V3.g caught | caught |
| `aria-label` loses its comma | **all silent** | caught |
| 200 chars inserted between the identifiers (no behaviour change) |
V3.e **fails** | passes |

Full JS unit list from `.github/workflows/deploy.yml`: 65/65.

## Notes for review

- V3.a–V3.d (MB_GLYPHS definitions, CSS variables, the border rule) are
left as source/CSS greps. The glyph values are now covered implicitly by
the rendered-output assertions, but converting the CSS ones needs a
different approach and did not belong here.
- The sandbox loader has no `try`/`catch` that warns and continues. If
`map.js` stops loading, the suite must fail rather than silently skip
every assertion below it.
- If this lands, the ordering comment in #1912 becomes obsolete and I
will drop it there. I deliberately did not touch it from this branch so
the two do not conflict textually.
2026-09-05 15:05:43 +02:00
TeTeHackoandClaude Opus 5 75dfa1f4fb fix(map): render an unobserved hash size as unknown, not as 1 byte (#1912)
## The bug

`map.js` turns a missing `hash_size` into `1`:

```js
var hs = node.hash_size || 1;
```

That field is **evidence**, not a default — `computeNodeHashSizeInfo`
populates it only from adverts it could read a size out of, so a node
with no countable advert in the retention window has no value at all.
Rendering that absence as `1` states a 1-byte configuration nobody
observed, and it does so in the one place where a reader is most likely
to act on it.

It is also inconsistent with the rest of the UI for the *same field on
the same node*:

| view | code | renders |
|---|---|---|
| node detail | `nodes.js:683` | `Hash Prefix: **Unknown**` |
| analytics prefix table | `analytics.js:1553` | `(**?**B)` |
| map popup + label + filter | `map.js:140`, `:1588`, `:1775` | `C8
**(1B)**` |

On analyzer.meshcore.cz right now, **701 of 1007 nodes** have
`hash_size: null`, so the map's 1-byte bucket is mostly nodes that were
never measured. The Byte Size filter has the same problem from the other
end: picking "1-byte" returns measured 1-byte nodes *and* every unheard
node, which makes it hard to use for the thing it exists for.

## The fix

- `roles.js`: shared `hashPrefixInfo(node)` → `{known, bytes, prefix}`,
so the map stops re-deriving the prefix in three places and the
"unknown" rule lives in one.
- `map.js`:
- **label** still draws a 1-byte prefix (it has to draw *something*) but
carries `.hash-unconfirmed` and its `aria-label` says `…, hash size
unknown`;
  - **popup** says `Unknown`, matching `nodes.js` wording;
- **filter** gets its own `Unknown` bucket instead of folding unmeasured
nodes into 1-byte.
- `style.css`: dotted underline for the unconfirmed prefix — a shape cue
rather than a colour one, so it survives forced-colors and colour-vision
differences, consistent with the #1356 approach for these labels.

`nodes.js` and `analytics.js` are left alone: they already behave
correctly, and switching them to the helper would widen the diff without
changing behaviour. Happy to do it in a follow-up if you'd rather have
the call site count at zero.

## Tests

`node test-frontend-helpers.js` → **635 passed, 2 failed**; the two
failures are `favStar`, pre-existing on master (baseline run before this
change: 625 passed, 2 failed — same two).

10 new cases: `hashPrefixInfo` across missing / null / 0 / 1 / 2 /
3-byte inputs plus missing pubkey and a null node, and a guard asserting
`map.js` contains no bare `hash_size || 1` so this cannot quietly come
back.

## Browser validation

Headless Chromium against a live instance carrying real mesh data, same
viewport (`#/map?lat=50.038502&lon=14.570556&zoom=17`), unpatched vs
patched:

| | before | after |
|---|---|---|
| `aria-label` | `repeater hash C8` | `repeater hash C8, hash size
unknown` |
| label class | `mc-mb-label` | `mc-mb-label hash-unconfirmed` |
| filter buttons | `all,1,2,3` | `all,1,2,3,unknown` |

The four nodes in that viewport that *do* have evidence (`157E`, `381E`,
`FA74`, `C029`) render exactly as before.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:59:20 +02:00
efitenandClaude Opus 5 eb3d71f8f6 perf(#1910): collapse concurrent /stats work and serve the count cache stale (#1963)
Addresses #1910. The Observers page hangs on "Loading..." for 10-20s;
the reporter measured `/stats` at 10-17s under the mixed load that page
produces, while the same endpoint stays under 70ms at 8x concurrency
when it is the only one being hit.

## Cause

Two cache layers guard the expensive work and **neither has
single-flight**:

| | | |
|---|---|---|
| `handleStats` | 10s cache | releases `statsMu` before rebuilding
(`routes.go:774`) |
| `GetStoreStats` | 30s cache | releases `statsCacheMu` before scanning
(`store.go:2048`) |

Both do check, release, then work. The moment either window expires,
**every in-flight request does the whole thing itself**.

The expensive part is a range scan over 24h of `observations` with two
`SUM(CASE...)` over it. The column is indexed
(`idx_observations_timestamp`), but the scan still visits every row in
the window, and at 18k observers that is millions. The pool is
`SetMaxOpenConns(4)` (`db.go:111`), and the page fires stats, observers,
nodes, channels and clock-skew at once, so one cache miss turns a single
scan into a queue of them.

That is exactly the reported profile: fast alone, slow only when mixed.

## Changes

1. **Single-flight both layers.** Concurrent callers that miss the cache
wait for the first one's result instead of each running the same
queries.
2. **Serve the observation counts stale while refreshing in the
background.** An expired cache answers from the previous value and kicks
off one refresh, so a miss is never a wait.

Single-flight alone would not have fixed the hang: the first caller
still waits for the full scan. The second change is what removes it.

## Contract change, stated plainly

`TestGetStoreStats_CacheExpiry` asserted that an expired cache returns
**fresh DB values on the same call**. It no longer does.

For `packetsLastHour` / `packetsLast24h` on a dashboard, answering with
a value up to ~30s older instead of blocking for seconds looks like the
right trade to me. But that is a judgement, not a bug fix, and **a
reviewer should be able to reject it**. I did not quietly delete the
test: it now asserts what still has to hold, that the refresh happens,
and the new behaviour is pinned separately by
`TestGetStoreStats_StaleCacheServedWithoutBlocking`.

If you would rather keep the old contract, drop change 2 and keep change
1; the diff separates cleanly.

## Verification

The new test **fails without the change**:

```
stale cache not served: got (0, 2), want (424242, 434343). An expired cache must
answer from the previous value and refresh in the background, not block the
request on the observations scan
```

`gofmt` clean, `go vet` clean, `cmd/server` suite ok in 267s.

## Two things I did not verify

**The race detector.** It needs cgo and there is no gcc on this machine,
so `go test -race` cannot run here. This change adds a background
goroutine writing the cache under `statsCacheMu`, so that check matters.
CI runs `go test -timeout 20m -race` for `cmd/server`
(`deploy.yml:134`), which covers it before merge.

**The 10-17s itself.** I have no database with 18k observers. The
mechanism above explains the reported profile, including why the
endpoint is fast in isolation, but I did not measure the figure.
@dborup, if you can run a build from this branch, the number to watch is
`/stats` under the same mixed-load command from your issue.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 09:57:44 +02:00
efitenandClaude Opus 5 83134d6e70 fix(#1896): say that the naive-clock notice clears itself (#1962)
Closes #1896.

The banner told operators their clock is naive but not that the notice
goes away on its own, so people went looking for #1480 to find out. One
sentence:

> Clock is naive — per-packet timing clamped to ingest time. **Clears
itself 24h after the last skew event.**

## Verified before writing it into the UI

The issue asserts the 24h self-clear. Rather than repeat that, I checked
it:

- `cmd/server/observer_naive_clock.go:8` — `const
observerNaiveClockWindow = 24 * time.Hour`
- `applyObserverNaiveClock` applies the decay at read time and leaves
`clock_naive` false once the last event is older than the window
- its own comment: *"any event older than observerNaiveClockWindow is
treated as absent so the chip and banner clear automatically without a
background sweep"*

So "24h after the last skew event" is accurate, including the fact that
it needs no sweep and no restart.

No test pinned the old string.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:05:01 +02:00
efitenandClaude Opus 5 71b892ba95 fix(#1899): scope the channel preview to the region filter (#1961)
Closes #1899.

The Channels sidebar preview (`lastMessage` / `lastSender`) ignored the
active region filter, so an operator filtering on their own region saw a
preview line from a message their observers never heard.

## Cause

`GetChannels` scoped `msg_count` and `last_activity` correctly: the
outer query joins `observations` and `observers` and filters on IATA.
The `sample_json` subquery that feeds the preview joined neither, so it
always returned the globally newest message on the channel.

## Fix

Both region-filtered branches now scope the subquery the way the outer
query does: v3 through `observations`/`observers` on `observer_idx`, v2
through the `EXISTS` on `observer_id`. The unfiltered branch is
untouched, since there is no filter for it to respect.

**One thing that is easy to get wrong here:** the subquery sits in the
SELECT list, *ahead of* the WHERE, so its placeholders bind first. The
region codes are appended twice, subquery set first, or every filtered
call binds the wrong values.

**Scope checked rather than assumed:** `GetEncryptedChannels` has the
same shape and the same `regionPlaceholder` pattern, but selects no
`sample_json`, so it does not have this bug and is left alone.

## Verification

The regression test **fails on unmodified master**, with the reported
symptom:

```
db_test.go:2465: preview sender = Bob, want Alice: SJC must not be shown Bob's
                 message, which only SFO heard
db_test.go:2469: preview message = heard in SFO, want "heard in SJC"
```

It asserts both directions, so it cannot pass by always picking the
oldest row, and it asserts the unfiltered call still shows the globally
newest message, which was never in question.

`gofmt` clean, `go vet` clean, `cmd/server` suite ok in 98.5s.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:04:54 +02:00
efitenandClaude Opus 5 e6b31fc764 fix(#1927): stop plotting the usefulness composite on the traffic axis (#1960)
Closes #1927.

The scatter's **"Traffic share"** axis and the **"Traffic"** table
column both fell back to `usefulness_score` when `traffic_share_score`
was absent, with nothing marking the substitution. Two points on one
axis could therefore measure different things.

## They really are different metrics

Checked rather than assumed, in `cmd/server/usefulness_composite.go`:

```go
node["traffic_share_score"] = trafficRaw   // :147  the single traffic axis
node["usefulness_score"]    = composite    // :152  0.30*bridge + 0.25*coverage + ...
```

`openapi.go:178,182` describes them the same way. So the fallback put a
**composite** under a column and an axis that both promise share of
non-advert traffic.

Worth flagging, because it is easy to conclude the opposite: **#1456,
which introduced the fallback, was a rename of the display label** from
"Usefulness" to "Traffic share". That makes the two field names look
interchangeable, and I nearly stopped there. They are not: #672 later
gave `usefulness_score` its own composite meaning.

## Fix

Your first preference in the issue: drop the fallback rather than mark
it or relabel the axis.

A node with no `traffic_share_score` now reads as unknown, so the table
shows an em dash and the point is dropped from the plot by the existing
`plottable` filter (`analytics.js:2728`), exactly the way a node with no
bridge score already is. **No other change was needed** for that, which
is what makes this the cheap option of the three.

## Tests

The unit test that pinned the old behaviour is updated rather than
deleted, so the expectation is now recorded the right way round:

```js
assert(mapped[1].traffic === null && mapped[1].fav === false,
  'a node with only usefulness_score has no traffic value; it must not be substituted');
```

`test-repeater-metric-scatter.js`: 31 passed, 0 failed.

Also corrected a comment above `_toScatterPoints` that still documented
the removed fallback chain.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 17:04:48 +02:00
efitenandClaude Opus 5 e7b3a2e77f chore(#1856): remove POST /api/packets, which has never worked (#1959)
Part 1 of #1856. Part 2 (the hash migration reporting false success) is
#1958.

## It has never worked

`handlePostPacket` writes to the server's DB handle, and that handle is
read-only. `cmd/server/db.go:106`:

```go
dsn := fmt.Sprintf("file:%s?mode=ro&_journal_mode=WAL&_busy_timeout=5000", path)
```

Every call answered `500 attempt to write a readonly database`.

**This is the second report.** #1196 raised it on 2026-06-13, a fix was
merged that corrected v2 column names to v3, and the issue was closed.
That fix could not have worked, because the column names were never why
the write failed. Its comment is still sitting at `routes.go:1288`, next
to code that has never executed successfully in production.

## Why remove rather than build a handoff

**It cannot break a caller.** An endpoint that has only ever returned
500 has no working consumer. This is not a breaking API change, it is
documentation catching up with reality. Nothing in `public/` calls it.

**It was actively misleading.** `openapi.go` advertised it as "Ingest a
packet" and it sits behind `requireAPIKey`, which reads as a live,
protected write endpoint.

**Its test hid the breakage.** `TestPostPacketPersistsV3Schema` asserted
the observation row is written and passed for four months, because the
test DB is opened read-write while production is not. That is how #1196
came to be closed as fixed.

**Ingest is MQTT-only by design since #1283.** Re-adding an HTTP write
path re-opens the invariant that change established. If manual injection
is wanted later for testing or replay, it belongs on the ingestor side
and deserves its own issue. The repository already has the handoff shape
for that: the server writes `request-<id>.json` and the ingestor
consumes it (`cmd/ingestor/prune_geofilter.go`).

## What went

The route, `handlePostPacket` (103 lines), the now-unused
`PacketIngestResponse` type, the `openapi.go` entry, the round-trip
test, and the section plus table-of-contents line in `docs/api-spec.md`.
The `packetpath` import in `routes.go` became unused and went with it.

`+4/-225` across 6 files.

## The auth tests

The four `requireAPIKey` tests used `"/api/packets"` only as a request
path while building their own handler with `s.requireAPIKey(...)`, so
they never touched the route.

I checked that by **running them**, not by reading the code:

```
--- PASS: TestRequireAPIKey_RejectsWeakKey
--- PASS: TestRequireAPIKey_AcceptsStrongKey
--- PASS: TestRequireAPIKey_EmptyKeyDisablesEndpoints
--- PASS: TestRequireAPIKey_WrongKeyUnauthorized
```

Their paths now point at `/api/admin/prune-geo-filter`, which still
exists, so they no longer name a removed endpoint. Re-ran after that
change: still 4 of 4.

`/api/packets/observations` is a different endpoint and is untouched.

Verified: `gofmt` clean, `go vet` clean, `cmd/server` suite ok in 62.9s.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:02:59 +02:00
efitenandClaude Opus 5 56d6d4c722 fix(#1856): stop the hash migration reporting success it never achieved (#1958)
Part 2 of #1856. **Part 1 is deliberately not fixed here** and the issue
stays open for it; reasoning at the end.

## The bug

`migrateContentHashesAsync` set `store.hashMigrationComplete` in a
deferred func that ran unconditionally. Every DB failure inside the loop
takes a `continue` (begin tx, prepare, commit), so the loop always
reaches that defer, **including when not a single batch was written**.

That is not hypothetical. The server has held a `mode=ro` handle since
#1283, so `Begin`, `Prepare` and `Commit` all fail, every batch is
skipped, and `/api/stats` then answers `hashMigrationComplete: true`
after migrating nothing. The migration is started unconditionally on
every boot at `main.go:546`.

## The fix

The three failure paths now count, and the defer only claims completion
when the count is zero. When it is not, it logs once, naming the
read-only handle as the expected cause and pointing at this issue, so an
operator can tell "no work to do" apart from "could not do the work".

**Nothing waits on the flag.** The only reader is `routes.go:828`, which
reports it in `/api/stats`. Leaving it false on failure blocks nothing;
it just stops the endpoint from lying.

The in-memory index is untouched on failure. That was already true,
because the index update runs only after a successful commit, and the
test now asserts it so memory and disk cannot drift apart.

## Verification

The regression test **fails on unmodified master**:

```
hash_migrate_test.go:115: hashMigrationComplete must stay false when no batch
could be written; reporting true here is what #1856 called self-reported success
```

It closes the DB handle to make writes fail. That is deterministic and
exercises the identical path as a read-only handle (`Begin` errors,
batch skipped); the in-memory test DB cannot be reopened read-only.

The existing happy-path test still passes, so the flag still turns true
on a real migration. `gofmt` clean, `go vet` clean, `cmd/server` suite
ok in 59.7s.

## Why part 1 is not in here

`handlePostPacket` writes to the same read-only handle and therefore
always answers 500. I checked the error path before assuming it was
misleading: it already returns `"transmission insert: attempt to write a
readonly database"`, so the message is accurate. The endpoint is not
confusing, it is simply dead.

The issue asks maintainers directly: *"is this endpoint still wanted? If
ingestion is MQTT-only now, deleting it is simpler than routing it
through a handoff."* That is a product decision, not a fix, and
inventing a middle answer would only add code without settling it. Worth
noting the repository already has a precedent for the handoff shape: the
server writes `request-<id>.json` and the ingestor consumes it
(`cmd/ingestor/prune_geofilter.go`).

Two things a decision should account for: the endpoint is documented in
`openapi.go:69` and guarded by `requireAPIKey`, and
`routes_test.go:4850` asserts it writes an observation row using the v3
schema, which passes only because the test DB is read-write.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:15:16 +02:00
efitenandClaude Opus 5 9488e9c31c fix(#1898, #1900): carry observer_id into replayed packets (#1957)
Closes #1898. Closes #1900.

Two issues, one omission.

With a region filter active, VCR replay on the Live map rendered
**nothing at all** (#1898), and the Replay button on packet detail
**silently did nothing** (#1900).

## Cause

`packetMatchesRegion` (`public/live.js:80-92`) matches a packet group by
looking up `packets[i].observer_id` in the observer roster map. A packet
whose `observer_id` is null is skipped, and when none match it returns
`false` and the caller drops the whole group (`live.js:3374`).

`dbPacketToLive()` returned `observer` (the resolved name) but never
`observer_id`. So every replayed packet was skipped, and every group was
dropped. The Replay button had the same gap: both branches passed
`obsName(o.observer_id)` and threw the id itself away.

**The value was there the whole time.** The VCR builds its entries with
`Object.assign({}, p, obs, ...)`, so the observation's `observer_id` is
on the input, and the server has returned `observer_id`, `observer_name`
and `observer_iata` per packet since `cmd/server/db.go:345-347`. Only
the object literal dropped it.

## Fix

Carry `observer_id`, and `observer_iata` alongside it so
`obsIataBadgeHtml` (`live.js:102-108`) can use the direct field for
replayed packets instead of falling back to the roster map.

Three lines of behaviour, in two files.

## Verification

Four regression tests in `test-live-region-filter.js`. **Three fail
without the fix**, checked by reverting `live.js` and re-running:

```
❌ #1898: dbPacketToLive carries observer_id through
❌ #1898: a replayed packet survives an active region filter
✅ #1898: dropping observer_id is what broke it (guards the regression)
❌ #1898: observer_iata is carried so the badge needs no roster lookup
```

The one that passes either way does so on purpose: it asserts a packet
carrying **no** `observer_id` is still dropped, pinning the mechanism so
a future change cannot make the filter match everything.

That test's sandbox needed `getParsedDecoded` and `getParsedPath`.
`live.js:14` captures those from `packet-helpers.js` at load time and
the sandbox does not load it, so they are stubbed in the sandbox
definition rather than assigned afterwards. Assigning later is too late
for that capture, which cost me two attempts.

Other suites unaffected: `test-live.js` 95 passed,
`test-packet-filter.js` 99, `test-frontend-helpers.js` 656.
`test-1110-live-filter.js` fails identically on unmodified master with
`ERR_CONNECTION_REFUSED`; it is an E2E test needing a server on port
13581.

## Note

Both issues were filed separately and neither names the other. They are
the same root cause in sibling code paths, which is why they are fixed
together rather than in two PRs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:03:08 +02:00
efitenandClaude Opus 5 cb6d230573 docs: renumber the release to v3.10.1 (#1953)
v3.10.0 was tagged and then withdrawn. **Nothing was ever available
under that number**: no container image and no release asset was ever
published, so no user could have pulled it.

This renames the notes and the CHANGELOG section. No product code
changes.

## Why it had to be renumbered

Three things, in the order they bit.

**1. The image never built.** `release-fast-path.yml` re-tags `:edge` to
`:vX.Y.Z` when the `:edge` revision label matches the tagged commit, and
dispatches `deploy.yml` when it does not. The tagged commit was
documentation-only, so the `paths-ignore` from #1949 meant no `:edge`
existed for it and the fallback ran. That part behaved correctly. The
fallback then published nothing, because every GHCR step was gated on
`github.event_name == 'push'` and a dispatch is not a push. It built
locally, reported `success`, and pushed nothing.

Fixed in #1951, but that fix is not in the `v3.10.0` tag, and a
`workflow_dispatch` runs the workflow file **from the ref it targets**.
So the existing tag could not be made to publish.

**2. The assets never uploaded.** I created the GitHub release by hand
before the workflow reached it, and `action-gh-release` cannot update an
immutable release. The correct procedure is to push the tag and let the
workflow create the release.

**3. The tag name cannot be reused.** GitHub's immutable releases keep a
tag name reserved even after the release is deleted:

```
remote: - Cannot create ref due to creations being restricted.
```

I established that only after deleting the release, which is the wrong
order. The lesson, written into the commit message so it survives: check
whether a tag can be rewritten before removing anything that depends on
it.

## What is in v3.10.1

The same 111 commits, plus the three CI fixes that landed after the
v3.10.0 tag (#1949, #1950, #1951). Those are listed in their own section
in the notes. **No product code differs** from what was tagged as
v3.10.0.

All 69 SHA references in the notes were re-verified after the rename.

## Procedure for this tag

Push the tag and stop. The workflow creates the release and attaches the
assets. Do not create it by hand.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
v3.10.1
2026-09-04 09:46:06 +02:00
efitenandClaude Opus 5 a3ee37011a ci: scope docs-only skipping to jobs, so required checks still report (#1955)
Same change as #1954, opened from a branch on this repository instead of
from a fork. #1954 never received a workflow run: zero runs and zero
check suites for its head commit, and closing and reopening it changed
nothing. A manual `workflow_dispatch` on master ran immediately, so
Actions itself is working; the `pull_request` event from the fork is
what produces nothing. See the note at the end.

Replaces the trigger-level `paths-ignore` from #1949 and #1950. That
approach was wrong, and it is currently blocking #1953 from merging.

## What was wrong

GitHub documents the distinction I had backwards:

> a workflow skipped by path filtering keeps its checks **pending** and
blocks the merge, while a **job** skipped by an `if:` conditional
reports **Success** and does not.

So the filtering has to live on the jobs, not on the trigger.

I compounded it by claiming, in both the commit and the description of
#1949, that master had no required checks: *"verified: the branch
protection endpoint returns 404"*. **That verification was invalid.** A
404 there means the token cannot read protection details, not that none
exist. The branch reports `protected=true`, and #1953 was refused with
`the base branch policy prohibits the merge`.

## What this does

A `🔎 Change scope` job computes whether anything outside `docs/`, `*.md`
and `LICENSE` changed. `go-test`, `e2e-test`, `build-and-publish` and
`release-artifacts` are gated on its output. A documentation-only pull
request skips those jobs, they report Success, and the PR can merge.

**Only pull requests are scoped.** A push or a dispatch always runs the
full pipeline.

That second part is deliberate and it fixes a separate failure.
`release-fast-path.yml` re-tags `:edge` to `:vX.Y.Z` only when the
`:edge` revision label matches the tagged commit. A master commit with
no image breaks tagging, which is what happened to the v3.10.0 tag: the
tagged commit was documentation-only, the fast path could not re-tag, it
fell back to a dispatch, and the dispatch published nothing (#1951).
Master pushes now always produce an image. The cost is running the
pipeline on documentation commits to master; pull requests are where the
queue pressure was.

Two conservative defaults in the scope check: a non-`pull_request` event
and an empty diff both count as code, so an unexpected shape runs
everything rather than silently skipping.

## Note for whoever has repository settings access

Fork pull requests stopped getting workflow runs between 22:36 and
05:40. #1949, #1950 and #1951 all came from the same fork and each got a
run; #1954 got none, with no check suite created at all, which is
different from a skipped run. `repos/.../actions/permissions` returns
403 for a non-admin token, so this could not be confirmed from the API.
If the "Fork pull request workflows from outside collaborators" setting
was tightened, that would explain it, and it would affect every outside
contributor, not just this branch.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 09:44:18 +02:00
efitenandClaude Opus 5 bb8634312d ci: publish images on a tag ref, not only on a push event (#1951)
**v3.10.0 produced no container image.** The build job reported
`success` and pushed nothing.

## What happened

`release-fast-path.yml` re-tags `:edge` to `:vX.Y.Z` when the `:edge`
revision label matches the tagged commit, and dispatches `deploy.yml`
when it does not.

For v3.10.0 the tagged commit was documentation-only. The `paths-ignore`
added in #1949 means documentation-only commits skip `deploy.yml`, so no
`:edge` image was ever built for that commit, the labels did not match,
and the fallback ran. **That part worked exactly as designed** and
correctly refused to re-tag an image built from a different commit.

Then `deploy.yml` skipped all five GHCR steps, because each was gated on
`github.event_name == 'push'` and a `workflow_dispatch` is not a push:

```
4. Build Go Docker image (local staging):  success
5. Set up Docker Buildx:                   skipped
7. Log in to GHCR:                         skipped
9. Build and push to GHCR:                 skipped
```

**So the fallback has never been able to publish an image.** It
dispatches a pipeline that cannot push. That stayed invisible for as
long as the fast path kept succeeding, which it did until a release note
happened to be the last commit before the tag.

## Fix

Gate those five steps on a push **or** a tag ref:

```yaml
if: ${{ github.event_name == 'push' || startsWith(github.ref, 'refs/tags/v') }}
```

A dispatch aimed at a tag now publishes. A dispatch aimed at a branch
still does not, so this does not turn every manual run into a release.

## The version stamp needed no change

Verified rather than assumed. `Compute build metadata` keys on
`GITHUB_REF`, not on the event:

```bash
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then APP_VERSION="${GITHUB_REF#refs/tags/}"; else APP_VERSION="edge"; fi
```

The failed v3.10.0 run already logged `Build: version=v3.10.0
commit=5bad23b`. Only the publishing was missing.

## Not covered here

`Release Artifacts` failed on the same run for an unrelated reason: the
GitHub release had been created by hand before the workflow reached it,
and `action-gh-release` cannot update an immutable release. That one is
process, not code. Push the tag and let the workflow create the release.

Once this merges, re-dispatching `deploy.yml` against `v3.10.0`
publishes the images for the existing tag. No re-tagging needed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 23:05:16 +02:00
efitenandClaude Opus 5 5bad23b46a docs: release notes for v3.10.0 (#1948)
Release notes for the first tag since `v3.9.2` on 2026-06-13. **111
commits**, and no auto-generated coverage bumps fall in this range, so
all 111 are substantive.

Nothing here changes behaviour. It is `docs/release-notes/v3.10.0.md`
plus a `CHANGELOG.md` section.

## Verification

The header promises that every bullet ends with a SHA you can `git
show`. That is checked mechanically rather than trusted: all **69**
references were confirmed to point at a commit that exists and whose
subject line contains the issue or PR number cited beside it. Zero
mismatches.

## Two things operators need, and both are silent failures

The urgency line leads with the first one on purpose.

1. **CARTO requires an API key** on its raster basemaps since 2026-08.
Without one every tile is served watermarked with HTTP 200. Nothing
errors, no healthcheck fires, and the only way to notice is to look at a
tile. Anyone upgrading needs to set `map.tiles.providers.carto.key`.
2. **`pathTrust.minHashBytesForMapping` ships at 1**, which is the
existing behaviour, so an upgrade changes nothing on its own. The note
states what raising it to 2 would actually cost, with numbers from a
live instance (56% of path-hop observations are 1-byte, 41% of repeaters
use a 1-byte hash), because there is no UI to undo it.

The relay `last_seen` fix is quantified the same way rather than
described as "improved": for repeaters that relayed within the last
hour, the gap between `last_relayed` and `last_seen` drops from a median
of 12,062 s to 193 s, and the share more than five minutes behind falls
from 96% to 39%.

## A theme worth naming

Three of the highlights are the same defect in three places: something
is operable before its own setup has finished. The Live view toggles are
inert for about 100 ms after paint, the colour picker's deferred focus
undid arrow-key navigation so Enter assigned the wrong colour, and an
analytics theme-refresh discarded the filter you had just applied. All
three were first written off as flaky tests, twice by me. Each is now
fixed with a regression test that fails on the previous commit.

## Sequencing

This should land, and the `v3.10.0` tag be cut, **before** the Go 1.27
upgrade in #1946. A toolchain bump changes the compiler, the runtime and
`gofmt` for everything at once; landing it on top of 111 unpublished
commits means a later regression cannot be separated from the toolchain.
#1946 itself says no 1.27-only features are being adopted, so there is
no cost to waiting one release. A tag first also gives a known-good
bisect point.

## Not done

The CHANGELOG has no `3.9.2` section and did not have one before this
change. I left that gap alone rather than reconstructing it
retroactively.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:27:32 +02:00
efitenandClaude Opus 5 6ccef5053b ci: match root-level markdown in the docs paths-ignore (#1950)
Follow-up to #1949, correcting a pattern I did not check before
proposing it.

#1949 used `'**/*.md'`. That reads as requiring a directory component,
so it covers `docs/release-notes/v3.10.0.md` but not `CHANGELOG.md` or
`README.md` at the repository root. Since `paths-ignore` skips only when
**every** changed file matches, one uncovered root file is enough to run
the whole pipeline anyway.

GitHub's own example for "any file with this extension" is `'**.js'`,
with no slash. `'**/*.md'` does not appear in their documentation at
all. `'**.md'` covers root and subdirectories both.

## What this does not establish

#1948 (`CHANGELOG.md` plus a release note) did start a full run after
#1949 landed, and that is what prompted this. But there is a second
candidate explanation I did not rule out: that PR's branch predates
#1949, so its workflow file may simply not have carried the filter yet.

I am not claiming to have proven which one it was. The fix is correct
either way, and shipping the documented pattern is better than defending
the first cause I noticed. The real test is the next docs-only PR opened
from a branch that already contains the filter.

The reasoning is in a comment above the `on:` block, not only in this
description.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:27:00 +02:00
efitenandClaude Opus 5 55aabaa325 ci: skip the pipeline for documentation-only changes (#1949)
Two documentation-only PRs were running the full pipeline simultaneously
this afternoon: #1948 (`CHANGELOG.md` plus a release note) and #1947
(deleting a stale `docs/DEPLOYMENT.md`). Each spends about 12 minutes on
Go Build & Test and about 16 minutes on Playwright to establish that a
text file does not break a browser.

**The cost is the queue, not the minutes.** On the same afternoon a
`pull_request` run was created at 12:13 and its first job did not start
until 16:19. Four hours in the queue. Every unnecessary run pushes the
ones that matter further back, and this repository has been merging
heavily today.

## Checked before adding the filter

Rather than assumed:

- **Nothing reads markdown at build or test time.** Grepping every Go
and JS source for a runtime read (`ReadFile`, `readFileSync`, `os.Open`)
of a `.md` path returns nothing. The `docs/` matches in `cmd/` and
`test-*.js` are all comments pointing at documentation.
- `/api/docs` serves Swagger UI generated from `cmd/server/openapi.go`,
not from `docs/`.
- `docs/` holds markdown plus screenshots (`png`, `gif`) and no build
input.
- **This workflow has no tag trigger**, so release tagging is
unaffected; that runs from `release-fast-path.yml`. Worth stating
explicitly given a `v3.10.0` tag is imminent.

`paths-ignore` skips only when **every** changed file matches, so a PR
touching both code and documentation still runs the full pipeline.

## The trap, stated in the file

If required status checks are ever enabled on master, a skipped workflow
never reports, and a docs-only PR would wait forever on a check that
cannot arrive. At that point this needs to become a change-detection job
with conditional heavy jobs rather than a trigger filter.

Master has no required checks today. Verified: the branch protection
endpoint returns 404.

That caveat is in a comment above the `on:` block, not just in this
description, because the person who enables required checks in six
months will be reading the workflow and not this PR.

## Note

This PR itself changes only `.github/workflows/deploy.yml`, so it is not
documentation-only and will run the full pipeline, as it should.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 19:16:43 +02:00
Sylvain Rabot b5dfac85dd fix(docs): remove stale docs/DEPLOYMENT.md duplicate (#1947)
## Summary
- `docs/DEPLOYMENT.md` and `docs/deployment.md` were both tracked in
git, colliding into a single file on case-insensitive filesystems
(default on macOS/Windows) and causing `git status` to report spurious
modifications.
- `docs/deployment.md` is the actively maintained guide (linked from
`README.md` and `docs/deployment-behind-cdn.md`); `docs/DEPLOYMENT.md`
was a stale duplicate untouched since the MeshCore → CoreScope rename.
- Removed `docs/DEPLOYMENT.md` from the index, keeping
`docs/deployment.md`.

## Test plan
- [x] `git status` is clean on a case-insensitive checkout with no
spurious modification
- [x] Confirmed no remaining references to `docs/DEPLOYMENT.md` in the
repo
2026-09-03 19:15:55 +02:00
40f664c587 chore(#1859): gofmt sweep + gofmt/go vet CI gate (rebase of #1881) (#1941)
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three
commits are preserved, two of them cherry-picked with authorship intact;
the sweep itself had to be regenerated. Opened as a new PR rather than
force-pushing their branch.

Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2
landed as #1937.

## Why regenerated rather than merged

The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs
landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on
current master is cheaper and less error-prone than resolving 72
conflicts that are all whitespace. The drift it fixes also grew in the
meantime: 66 files now, against 72 then, but spread differently.

## The three commits

1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files.
2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet`
copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range
variable copied a `Config` embedding `sync.Once`. Cherry-picked
unchanged.
3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift
or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one
change, noted in the commit message: the ignore file pointed at
`04bc80ee`, the sweep commit on their branch, which does not exist on
this base and would make `git blame --ignore-revs-file` error. Repointed
at `d3a02599`, the sweep here.

## Verification

The claim "formatting only" is checked twice rather than asserted:

- Every changed file is byte-identical to `gofmt(previous content)`. 0
of 66 deviate.
- With line comments and all whitespace stripped, 0 of 66 files differ,
so no code outside comments changed.

14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt`
re-indents indented comment blocks to tabs and inserts a blank comment
line before them; the behavior matrix above `resolveHopWithContext` in
`cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own
output, not an edit, but it is worth naming because it makes the diff
look larger than "whitespace" suggests.

The gate was run locally exactly as the workflow runs it: `gofmt` clean,
and `go vet` clean in all 14 modules, including `cmd/ingestor` which is
what commit 2 fixes.

Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s),
`cmd/ingestor` passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically
on bare master with "A required privilege is not held by the client"
(Windows symlink privilege on my host, not code).

## Sequencing

This should go last in the queue. The sweep touches 66 files, so merging
it before the remaining open Go PRs gives each of them a conflict about
nothing but formatting. After it lands the gate is active, and any PR
with drift fails CI until it runs `gofmt -w`.

Excluded from the sweep: the misnamed `Dockerfile.go`, which is a
Dockerfile that gofmt cannot parse (the workflow excludes it too), and
`docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a
spurious modification against `docs/deployment.md` and is unrelated.

---------

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:52:03 +02:00
TeTeHacko f167d6f338 test(#1923 follow-up): pin the packets window in the munger slide-over step (#1942)
Follow-up to #1923/#1924 — one of the row-dependent packets navigations
the pin sweep did not reach.

## The gap

`test-slideover-1168-munger-e2e.js` navigates to bare `#/packets` and
waits for `#pktTable tbody tr[data-action]` with an 8 s budget, on the
default client-side window (`since = now − 15 min`). It is one of the
row-dependent packets navigations #1924 did not reach. Most are already
immune: #1924 pinned `?timeWindow=1440` in `test-slideover-1056-e2e.js`,
`test-e2e-playwright.js` sets `meshcore-time-window=525600` in
`gotoPackets()` and in the #1791 Group-Data step, and
`test-issue-1122`/`1128` widen the window through the UI dropdown.

Three other row-dependent packets navigations in the same job share the
exposure and are **not** in this PR, to keep it to one file:
- `test-gestures-1062-e2e.js` and `test-touch-gestures-coverage-e2e.js`
also assert on the URL after in-app navigation, so a bare `?timeWindow=`
query leaks into those assertions (`#/packets?hash=…` becomes
`#/packets?timeWindow=1440&hash=…`); they want the localStorage window
path.
- the mobile branch of `test-observer-iata-1188-e2e.js` pins via
localStorage, which `packets.js:736` clamps back to 15 min above 180 on
a mobile viewport; switching it to the URL-param idiom this PR uses
(which is applied after that clamp) would fix it, but it is a separate
file.

I can send those separately.

(This step itself runs at an 800px viewport — `isMobile`, since the
breakpoint is `innerWidth <= 1024` — and the pin still works precisely
because the URL param is read after the mobile clamp, at
`packets.js:1091-1094`, not from the clamped localStorage value.)

## Why it matters now, with numbers

On two of the four master runs of 2026-09-02 this step executed at
**freshen+8:06** (run 33678488159) and **freshen+10:13** (run
33684614144) — margins of 6:54 and 4:47 before the fixture's newest rows
age out of the window. Every test added ahead of it shrinks that. The
suite as a whole is closer still: in run 33684614144 the third
repetition of the #1616 flake-gate ran **21:48:21→21:48:45 =
freshen+14:45→+15:09**, i.e. already past the 15-minute mark — it
survives only because of the #1924 pin.

## Verification

Reproduced without waiting for the clock: shift the freshened fixture 20
minutes back (`first_seen` and `observations.timestamp`) and start the
server on it —

| | aged fixture | fresh fixture |
|---|---|---|
| step without the pin (master) | **fails** (selector timeout) | passes
|
| step with the pin (this PR) | passes | passes 3/3 |

Same idiom and value as #1924, same caveat baked into the comment: the
value must be > 0, because `packets.js` only reads the param under
`_urlTimeWindow > 0`, so `timeWindow=0` silently keeps the default.

One observation from the same investigation, offered separately from
this PR: `tools/freshen-fixture.sh` computes its shift as `now −
MAX(first_seen)`; if the max ever sits in the future, the offset goes
negative and `printf('+%d seconds')` produces the invalid `'+-N
seconds'`. On the NOT NULL `transmissions.first_seen` that aborts the
script (set -e); on the nullable columns (`nodes.last_seen`, observers,
neighbor_edges) `strftime` silently returns NULL. A one-line clamp to ≥0
would make it safe to run around an insert. Happy to send that
separately if wanted.
2026-09-03 18:21:47 +02:00
TeTeHacko ab62e86d2d fix(live): wire the view toggles before init() awaits — they are inert for ~100 ms (#1940)
Follow-up to the #1939 discussion, where @efiten asked for this PR. The
multibyte E2E assertion that has been failing intermittently on master
is a symptom of this; with this change the unmodified test passes
reliably (3/3 idle, 8/8 under a 24-core load run that previously failed
it 2 in 6).

## The defect

`init()` writes the whole controls panel with `app.innerHTML` and only
restores toggle state and attaches the `change` listeners ~330 lines
later, behind two awaits (line numbers on master, as verified in the
#1939 thread):

| line | |
|---|---|
| 1104 | `app.innerHTML = …` — the checkboxes are in the DOM, clickable
|
| 1256 | `await (await fetch('/api/config/map')).json()` |
| 1543 | `await loadNodes()` |
| 1612–1614 | `.checked = <pref>` and `addEventListener('change', …)` |

**A click inside that window is silently lost.** Measured on master,
localhost, clicking `#liveMultibyteToggle` on the first animation frame
in which it exists:

| run | click at | immediately after | after 2.5 s |
|---|---|---|---|
| 1 | 481 ms | `checked=true`, `localStorage=null` | `checked=false`,
`localStorage=null` |
| 2 | 505 ms | `checked=true`, `localStorage=null` | `checked=false`,
`localStorage=null` |
| 3 | 438 ms | `checked=true`, `localStorage=null` | `checked=false`,
`localStorage=null` |

No handler runs, nothing reaches localStorage, and the later `.checked =
<pref>` reverts the click with no feedback. Separately, the restored
state itself appears only **93–112 ms (3–5 rendered frames)** after the
control is painted — `ghost` and `colorHash` default ON, so they visibly
flick on for every visitor.

## The fix

- **`wireLiveControls()`** — synchronous, right after `app.innerHTML`:
restores `.checked` and attaches listeners for the eight persisted
toggles, as one table instead of eight near-identical blocks. The
matrix↔heat interlock applies from the first paint too.
- **`applyLiveControlEffects()`** — after the awaits: applies the
effects that need state built there (matrix theme, rain canvas).
- **`syncHeatToggleToMatrix()`** — the interlock, extracted; it
previously existed as two identical copies.
- Heat gets a module-level mirror (`heatEnabled`) like the other seven
toggles, so the layer is only built when wanted. Previously it was built
unconditionally and torn down ~270 lines later — invisible (no await in
between, so no frame composited; the cost is only ~9 ms at 1000 nodes),
but any throw between the two calls left the layer visible against the
stored preference. `showHeatMap()` now guards on the map existing
instead of relying on `nodeData` being empty at that moment.

## Verification

- Click on the first painted frame now persists, 3/3 (`localStorage`
written, survives).
- Restored state present on the first painted frame: 0 unchecked frames
in 5 runs (was 3–5).
- All four heat×matrix load combinations render identically to master
(layer present/absent, checked, disabled).
- `test-live-multibyte-only-e2e.js` unmodified: 3/3, plus 8/8 under CPU
load.
- With stored matrix ON, the heat toggle is `checked=false,
disabled=true` from the first frame.

## The probe (as requested)

<details><summary>~30-line Playwright harness that demonstrates the
inert control</summary>

```js
const { chromium } = require('playwright');
(async () => {
  const b = await chromium.launch();
  for (let run = 0; run < 3; run++) {
    const ctx = await b.newContext({ viewport: { width: 1400, height: 900 } });
    const p = await ctx.newPage();
    await p.addInitScript(() => {
      window.__r = { clickedAt: null, afterClick: null, lsAfterClick: null, final: null, lsFinal: null };
      const tick = () => {
        const el = document.getElementById('liveMultibyteToggle');
        if (el && window.__r.clickedAt === null) {
          window.__r.clickedAt = performance.now();
          el.click();
          window.__r.afterClick = el.checked;
          window.__r.lsAfterClick = localStorage.getItem('live-multibyte-only');
          return;
        }
        requestAnimationFrame(tick);
      };
      requestAnimationFrame(tick);
    });
    await p.goto('http://localhost:13581/#/live', { waitUntil: 'domcontentloaded' });
    await p.waitForTimeout(2500);
    const r = await p.evaluate(() => {
      const el = document.getElementById('liveMultibyteToggle');
      window.__r.final = el ? el.checked : null;
      window.__r.lsFinal = localStorage.getItem('live-multibyte-only');
      return window.__r;
    });
    console.log(`run ${run+1}: click at ${Math.round(r.clickedAt)}ms -> checked=${r.afterClick}, ls=${r.lsAfterClick}` +
                ` || after 2.5s: checked=${r.final}, ls=${r.lsFinal}`);
    await ctx.close();
  }
  await b.close();
})();
```
</details>

## Deliberately out of scope (each verified, none regressed here)

- `#liveAudioToggle` has the same window (MeshAudio persists
`live-audio-enabled`), but its restore runs through
`MeshAudio.restore()` and a slider panel — its own change.
- `#liveGeoFilterToggle` stays hidden until its own config fetch, so its
window is not user-reachable; the fullscreen control is created by
Leaflet after the map exists.
- Pre-existing: `clearNodeMarkers()` (VCR resume path) drops the heat
layer and nothing rebuilds it. `heatEnabled` is the right gate for
fixing that, but it is a separate behaviour change.

Happy to also submit the deterministic version of the multibyte test (it
forces the window open by delaying `/api/config/map`, so it fails on
this bug 3/3 instead of intermittently) as a follow-up if wanted.
2026-09-03 18:19:54 +02:00
efitenandClaude Opus 5 5d2e14aba2 fix(#1943): cancel the deferred swatch focus so arrow keys are not undone (#1945)
Closes #1943. The colour picker's keyboard navigation is broken, and the
E2E flake that has been failing unrelated PRs (#1940, #1941, and master
pushes `589fa987` and `859173f1`) was reporting it correctly.

## Cause

`showPopover` deferred focusing the first swatch with an uncancellable
`setTimeout(..., 0)` at `channel-color-picker.js:146`, and nothing
cleared it on hide. The file contained **zero** `clearTimeout` calls.
Reopen the popover while a swatch still holds focus and that timer lands
after the user has already pressed an arrow key, pulling focus back to
the first swatch.

Proven, not argued. Instrumenting `HTMLElement.prototype.focus` with a
stack trace, on one open:

```
focus(#f97316) @10205ms  <- the keydown handler
focus(#ef4444) @10208ms  <- channel-color-picker.js:146:58
```

Three milliseconds apart.

## The user-visible bug

Worse than a flaky test. **Open the picker, arrow to a colour, press
Enter, and the first colour is assigned instead of the one you chose.**
Holding the timing still made the existing suite say so directly:

```
✗ Enter should assign focused color (#f97316), got #ef4444
```

## Why the test looked flaky

The revert happens on **every** open. Only whether the assertion reads
before or after it varies, which is why an idle machine passes and a
loaded runner does not.

#1939 (mine) assumed the opposite: a race in which the handler had not
yet moved focus, cured by waiting for it. #1943 has the measurement that
disproves it. The failing step took **16 ms** while that wait has a **3
second** budget, so the wait was resolving successfully and then the
value was reverted underneath it. It never helped. Its comment is
corrected in this PR rather than left to mislead the next reader.

## Fix

Keep a handle for the timer, cancel a pending one on both show and hide,
and inside it do nothing when the popover has since been hidden or when
focus already sits inside it.

A fresh open still focuses the first swatch, which is what the
accessibility behaviour is for. An open that inherits focus, or a user
who has already navigated, is left alone.

## Verification

- The **regression test added here fails on unmodified master** with `a
late focus timer must not move focus after the user did` and passes with
the fix.
- It is **deterministic, not load-dependent**: it reproduces the exact
sequence the stack trace identified (open, Escape, reopen, ArrowRight
before the timer lands) rather than waiting for contention. It also
asserts the Enter path, so the user-visible half is covered and not just
focus position.
- Full suite: 10 of 10, three consecutive runs.

## Note on the other flake

This is one of two E2E failures blocking the queue. The other, #1925, is
a different mechanism in a different file and is fixed separately in
#1944. Together they should leave the E2E suite deterministic again.

Same shape as @TeTeHacko's finding in #1940: something is operable
before its setup has finished. That is now three instances in this
codebase, so it may be worth a look as a pattern rather than three
separate fixes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:42:10 +02:00
efitenandClaude Opus 5 e2df9bbd3e fix(#1925): stop theme-refresh from discarding the neighbor-graph filter (#1944)
Closes #1925. This is the flake that failed #1942 and #1871, neither of
which touches the feature. It is not a test problem.

## Cause

Every page load fires exactly one delayed, full re-render of the active
analytics tab:

1. `app.js` starts `/api/config/theme` without gating navigation on it,
deliberately.
2. When it resolves, `_customizerV2.init()` runs `applyCSS()`, which
dispatches `theme-changed`.
3. `app.js:1188` debounces that by 300 ms and dispatches
`theme-refresh`.
4. `analytics.js:230` answered it with `renderTab(_currentTab)`.

For the neighbor-graph tab step 4 is destructive. `renderTab` replaces
`el.innerHTML`, so the role checkboxes are recreated with their defaults
and companion is silently re-checked, and `_ngState` is rebuilt from the
full 1400-node graph. The count is back over the 1000 limit, so
`#ngSkipMsg` returns and the canvas is hidden.

The re-entrancy epoch guard cannot prevent it. That guard stops a
superseded `tick()` loop; this is a legitimate new top-level render pass
that resets the very inputs the guard protects downstream of.

**One mechanism produces both documented failure modes**, decided only
by where that single re-render lands:

| lands | result |
|---|---|
| before the first uncheck | harmless, test passes |
| between an uncheck and the next `waitForFunction` poll | mode 1, the
15 s timeout |
| after `waitForFunction` succeeded, before the follow-up `evaluate` |
mode 2, "expected #ngSkipMsg gone again" (#1942) |

Measured on an idle machine: the test's final evaluate at 542 ms,
`theme-refresh` at 636 ms, `#ngSkipMsg` re-added at 667 ms. It passes
locally by about 90 ms. On a loaded runner the test's Playwright round
trips stretch while `theme-refresh` still lands at theme-fetch latency
plus 300 ms, so it arrives mid-test.

## Fix

On `theme-refresh`, restart the renderer instead of rebuilding the tab
when the neighbor-graph tab is active and has state. Four lines.

This stays theme-correct: node colors are read live per frame from
`window.ROLE_COLORS`, role swatches use `.role-swatch--{role}` CSS
tokens, stats and the skip message use CSS variables, and the one cached
theme value, `_labelColor = cssVar('--text-primary')`, is re-read on
restart at `analytics.js:3375`, inside `startGraphRenderer`. When
`_ngState` is null it falls through to the old path.

## Verification

Measured, not asserted:

- **Deterministic reproduction** (hold `/api/config/theme` until just
before the second filter-down, then stall 500 ms before the final
evaluate; no synthetic events dispatched): **2 of 2 fail** on unmodified
master with the exact #1942 message, **3 of 3 pass** with this fix.
- The **unmodified** E2E test passes against the fixed build.
- With the tab open and a filter applied, a `theme-refresh` leaves the
filter intact, the canvas present, and produces no page errors.
- The **regression test added here fails on unmodified master** with
`theme-refresh reset the role filter (companion re-checked)` and passes
with the fix. It dispatches the event directly, so it tests the cause
instead of waiting for the race to appear.

## What this does not cover

The same startup re-render silently discards user interaction in the
first second or so on **any** analytics tab, not just this one. A user
who clicks quickly after load loses that click. This change covers the
neighbor-graph tab, because that is what #1925 is about and what is
failing CI. The general fix, for example skipping the startup refresh
when the effective config changed nothing, deserves its own issue rather
than being smuggled in here.

Side observation while tracing: `theme-changed` fires twice at startup,
at about 311 ms and 323 ms. The debounce collapses them, so it is
harmless, but it means the customizer pipeline runs twice. I did not
identify the second dispatcher.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:41:36 +02:00
efitenandClaude Opus 5 9ae3387416 feat(ingestor): RF environment samples from mobile clients (#1906)
> **Stacked on #1905.** This branch contains #1905's commits; review and
merge that one first. The diff unique to this PR is the
`client_rf_samples` table, its handler, the delta query and its
retention.

## What

Everything CoreDrive RX records today is anchored to a *packet*. But a
drive also passes through RF conditions that exist whether or not a
packet arrives: the noise floor, how busy the channel is, how many
receptions fail CRC. The radio measures all three and was never asked.

This samples the companion's own counters along the GPS track and stores
them, so the server can render a noise-floor map, a channel-utilisation
map and a CRC-error-rate map. A fixed observer cannot produce those — it
measures one point forever.

**Zero airtime:** `CMD_GET_STATS` is a local Bluetooth query to the
attached radio. Nothing is transmitted.

## Design points worth knowing

- **Absolutes are stored; deltas are derived at query time.** A lost or
reordered sample then costs one interval rather than corrupting a
running total. `ClientRfDeltas` breaks the chain whenever `uptime_secs`
fails to increase — that is the reboot and counter-wrap detector.
- **Absent is not zero, end to end.** Firmware predating the
`recv_errors` field cannot count CRC errors at all, and a stored `0`
would read downstream as "a perfectly clean channel" — the opposite of
"we don't know". Presence/absence is preserved through the app parser,
the wire payload, a nullable column, and the delta view, which returns
`nil` rather than `0` when either endpoint is unknown. Each of those
five layers has its own test.
- **`sampled_at` is millisecond precision, and it is load-bearing.**
SQLite compares these strings lexicographically and `.` (0x2E) sorts
before `Z` (0x5A), so a second-resolution retention cutoff would delete
rows *inside* the window. The prune formats its cutoff with the same
layout.

## Performance justification (touches the ingest hot path)

- One INSERT per sample, gated behind an opt-in flag that defaults off.
Sample rate is 15 s while moving and 5 min while parked, so roughly 240
rows per hour per active driver.
- The delta query is a single `LAG(...) OVER` pass with no nested query
inside the loop, so it cannot deadlock the single writer connection.
Window functions are already used elsewhere in this codebase.
- Retention has its own key and index (`sampled_at`); without it the
table would grow unbounded, so `config.example.json` documents it
inline.

## Safety for existing deployments

Opt-in and default off on both sides (`clientRfSamples.enabled`, and
`rfSampler` in the app). The coverage path is untouched —
`Publisher.buildPayload` is byte-identical and a record with no `kind`
field still routes to `/packets` unchanged.

The MQTT dispatch was reshaped so that **anything on `meshcore/client/…`
returns from that branch in every config state**, with the enable-gates
inside rather than in the topic match. Previously a disabled gate let
the message fall through to the observer path, where `parts[1]` — the
literal string `client` — was read as a region and the phone's pubkey
registered as an observer. The blacklist check now also runs ahead of
the sub-topic switch, so it covers every present and future client
sub-topic.

## Testing

Full ingestor suite green. Notable coverage: a `/rf` message with the
gate off writes nothing anywhere and does not fall through; a sample
missing `uptime_secs` is rejected rather than stored as an unusable row;
two samples 40 ms apart remain two rows; and the retention test seeds a
row with a non-zero millisecond component inside the cutoff second,
which is the only row that distinguishes a correct cutoff from an
RFC3339 one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:22:07 +00:00
Joel ClawandJoel Claw ac6fbaf9f3 perf: reuse ctx buffer in resolvePathForObs, cache ReadMemStats per store (#1873)
## Problem

Three hot-path inefficiencies causing excess CPU and memory allocations:

### 1. `filterTxSlice` starts with nil slice
`filterTxSlice` is called on the full `s.packets` slice (50k+ packets)
for every query that doesn't hit a fast-path index. Starting with `var
result []*StoreTx` means Go's append does ~15 growth+copy cycles
(1→2→4→8→...→32768→65536) before reaching steady state.

### 2. `resolvePathForObs` allocates per hop
Each hop in the path resolution loop allocates a new `ctx` slice
(`make([]string, len(contextPKs), len(contextPKs)+2)`). For a 5-hop
path, that's 5 allocations per observation. With 500+ observations per
ingest batch, that's 2500+ small allocations.

### 3. `estimatedMemoryMB` calls `runtime.ReadMemStats` without caching
`runtime.ReadMemStats()` triggers a STW (stop-the-world) pause. It's
called from stats/debug endpoints (`GetStoreStats`, `GetPerfStoreStats`)
that may be polled frequently. The routes.go layer already caches this
with a 5s TTL, but the store layer doesn't.

## Fix

1. **Pre-allocate `filterTxSlice`**: `make([]*StoreTx, 0, n/2)` — the 2x
over-allocation is cheaper than repeated growth+copy.

2. **Reuse ctx buffer**: Allocate one `ctx` buffer before the hop loop,
reset to base length each iteration with `ctx = ctx[:ctxLen]`.

3. **Cache `ReadMemStats`**: 5-second TTL cache matching the routes.go
pattern. Uses a package-level mutex (not on `PacketStore`) to avoid
adding a field.

## Testing
- `go build` passes
- No behavior change — same results, fewer allocations

---------

Co-authored-by: Joel Claw <358739783+Joel-Claw@users.noreply.github.com>
2026-09-02 23:20:39 +02:00
efitenandClaude Opus 5 a8e8449170 test(#1616): wait for swatch focus to move instead of reading it immediately (#1939)
This is the test that has been keeping master red on both sides of
today's queue run.

## The failure

```
✗ ArrowRight cycles focus across swatches:
  ArrowRight should move focus to next swatch (was #ef4444, now #ef4444)
```

Observed on:

| where | when |
|---|---|
| master push `589fa987` | 2026-08-31 — the last completed master run
before today |
| master push `859173f1` | 2026-09-02 — the first completed master run
after #1938 |
| PR #1884 | 2026-09-02, passed unchanged on a re-run |

**Two out of two completed master runs.** Master has produced exactly
two finished pipelines since 2026-08-31 and this test failed both, which
is why the branch has had no green badge either side of a day of merges.

## The cause

```js
await page.keyboard.press('ArrowRight');
const nextColor = await page.evaluate(() =>
  document.activeElement.getAttribute('data-color'));
```

It reads `document.activeElement` on the tick after the key press. The
keydown handler moves focus, but under CI load that can land after the
evaluate has already run, so the assertion compares the swatch against
itself and reports the same colour twice.

**This file already knows about this.** The "outside click" step below
carries a long comment about exactly this macrotask race for #1317, and
the conclusion there was to wait on the real condition instead of a
proxy. That step got the treatment and this one did not.

## The fix

Wait for `activeElement` to be a `.cc-swatch` whose `data-color` differs
from the one focused before the key press, with a 3s budget. The wait is
wrapped so that a timeout falls through to the original assertion, which
then reports the value actually observed rather than a bare Playwright
timeout — a failing test should still say what it saw.

**No product code changed.** One file, +18 lines, all of it the wait and
the reasoning.

## What this does not claim

It does not prove the focus handler is correct, only that the test stops
racing it. If ArrowRight is ever genuinely broken, this still fails, and
now with a useful message.

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

https://claude.ai/code/session_01Wzwr3eXseyNM7Xj598djjE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 20:18:25 +00:00