Commit Graph
125 Commits
Author SHA1 Message Date
dborup 8765de71e0 fix: matchScope reports unknown instead of guessing on HMAC collision
transport_code_1 is only 16 bits, so with enough configured hashRegions
a different, unrelated region's HMAC can coincidentally also match a
packet's real code1 (expected rate ~= len(regionKeys)/65534 per
packet). matchScope used to return the first match found while
iterating the region-key map, whose order Go randomizes per process —
so an ambiguous packet's assigned scope was effectively a coin flip
that could even change across ingestor restarts.

Confirmed on stg.meshview.dk (1098 configured regions): a live GRP_TXT
message's code1=C417 matched both "#dk" (the sender's true region) and
"#dk1906" (an unrelated collider), and was observed resolving to
either name across sends.

matchScope now scans every configured region and only returns a name
when exactly one matches; two or more matches are reported as
unknown-scoped ("") rather than guessed, with a log line for operator
visibility. Doesn't fix the root collision-probability cause (that
needs fewer/coarser configured regions) but stops confidently
displaying a wrong single answer.
2026-07-17 15:01:13 +02:00
242c7c609b fix(mqtt): escalate persistent paho disconnect + recover from emit panic + expose watchdog tick (#1749) (#1810)
# Partial fix for #1749 — MQTT watchdog escalation + panic recovery +
tick exposure

Red commit: 9912bbb3e9 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs?branch=fix%2Fissue-1749)

## Problem
Production CoreScope v3.9.1 (and a more recent prod recurrence on
2026-06-30 with the wcmesh source on `ssl://mqtt2.wcmesh.com:8883`)
showed two distinct watchdog failure modes:

1. **Per-source paho machinery dies silently.** `IsConnectedFn` returns
false; paho's `SetAutoReconnect(true)` never retries. The watchdog's
`processLivenessTransition` deliberately stays silent on
`LivenessDisconnected`, trusting paho to recover — so there is no
escalation path when that trust is misplaced.
2. **Watchdog goroutine death.** Three sources went silent within ~60s
of each other; no `WATCHDOG` log lines for 75 min. The most plausible
single point of failure is a panic inside `emit` (e.g. a blocked log
pipe) killing the loop with no defer/recover.

## Changes

**`cmd/ingestor/mqtt_watchdog.go`**
- Added `disconnectedReconnectMultiplier = 5` constant.
- Added `DisconnectedSinceUnix int64` (atomic) on `SourceLivenessState`.
Stamped on the first tick the source is observed disconnected; cleared
on any non-disconnected tick.
- `processLivenessTransition` now escalates: when `(now -
DisconnectedSinceUnix) > multiplier × threshold`, emits a `WATCHDOG
ESCALATION` WARN and calls `maybeForceReconnect` (subject to existing
`forceReconnectThrottle`). Distinct from the existing `LivenessStalled`
path so operators can grep escalation events independently.
- Added package-level `watchdogLastTickUnix atomic.Int64` +
`WatchdogLastTickUnix()` getter. The loop stamps it BEFORE per-source
processing — a wedged source-handler does not freeze the clock for an
external observer.
- `runLivenessWatchdogLoop` wraps each per-source
`processLivenessTransition` call in `func() { defer recover; ... }()` so
a panic in `emit` (or in any per-source code path) is logged and
skipped, not fatal. The loop continues to the next source and the next
tick.

**`cmd/ingestor/stats_file.go`**
- Added `WatchdogLastTickUnix int64` field on `IngestorStatsSnapshot`
(additive, `omitempty`); populated from `WatchdogLastTickUnix()` each
stats tick.

**`cmd/server/mqtt_status.go`**
- `MqttStatusResponse` gains `WatchdogLastTickUnix int64` (additive,
`omitempty`) sourced from the ingestor stats file; surfaced via `GET
/api/mqtt/status`.

**`config.example.json`**
- No new config field added — the multiplier is a code constant (5×) per
the issue's "N×threshold (e.g. 5×)" recommendation. The active
per-source `threshold` is the 5-minute scan threshold hard-coded at the
sole `runLivenessWatchdog` callsite (`cmd/ingestor/main.go:460`:
`runLivenessWatchdog(60*time.Second, 5*time.Minute)`), so escalation
fires at ~25 minutes (5 × 5min) of continuous disconnect — plus a
deterministic per-source jitter of 0..30s (#1810 round-1, see Taleb #4)
to avoid synchronized escalation across N sources sharing an upstream
broker outage.

## Acceptance (#1749)
- [x] Persistent `LivenessDisconnected` > N×threshold → force-reconnect
+ WARN
- [x] Watchdog goroutine liveness clock exposed (`WatchdogLastTickUnix`
in `/api/mqtt/status`)
- [x] Test: `IsConnectedFn` false for >5×threshold → assert
`ForceReconnectFn` invoked at least once
- [x] Test: panic in `emit` → assert loop recovers and continues ticking

## Test plan
- 4 new tests in `cmd/ingestor/mqtt_watchdog_1749_test.go` (all RED on
master, GREEN on this PR).
- Existing watchdog tests (`mqtt_watchdog_force_reconnect_test.go`,
`mqtt_reconnect_test.go`, r1/r2/m1 suites) continue to pass — the
escalation path is additive.

## Preflight
- TDD: red commit pushed and asserted to fail BEFORE green commit
landed.
- PII grep: clean on diff and PR body.
- Worktree: `_wt-fix-1749` on branch `fix/issue-1749`.

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: bot <bot@local>
2026-06-30 15:16:31 -07:00
Kpa-clawbotandclawbot b3189c613a fix(#1802): decode CONTROL DISCOVER_REQ/RESP subtype + body fields (#1806)
## Summary
Extend CONTROL packet decoding to surface DISCOVER_REQ / DISCOVER_RESP
subtype plus body fields in the packet detail view. Previously only the
byte0 zero-hop flag was decoded; the body was rendered as opaque hex.

## What changed

**Backend** — `cmd/ingestor/decoder.go` `decodeControl()`
- New `Payload` fields (all omitempty): `CtrlSubtype`, `CtrlFilter`,
`CtrlTag`, `CtrlSince`, `CtrlNodeType`, `CtrlSNR`, `CtrlPubKey`.
- Subtype derived from `byte0 & 0xF0`: `0x80` → `DISCOVER_REQ`, `0x90` →
`DISCOVER_RESP`, otherwise `UNKNOWN`.
- REQ body parsed when `len(buf) >= 6`: `filter:u8 | tag:u32 LE`, plus
optional `since:u32 LE` when 4 more bytes remain.
- RESP body parsed when `len(buf) >= 6`: `node_type` (low nibble of
byte0), `snr:i8`, `tag:u32 LE`, and `pubkey` hex — 32 bytes when full, 8
bytes when prefix-only.
- Every field gated on length; short/truncated bodies emit subtype only
and never panic.
- `CtrlZeroHop` retained for backwards compatibility (rename flagged for
follow-up per triage).

**Frontend** — `public/packets.js` `getDetailPreview()`
- New `decoded.type === 'CONTROL'` branch renders subtype + present body
fields (filter / tag / since / node_type / snr / pubkey). Each field
shown only when populated, so truncated CONTROL still gets a subtype
label.

## Wire format reference
- `firmware/src/Mesh.cpp:69` — `CTL_TYPE_NODE_DISCOVER_REQ=0x80`,
`CTL_TYPE_NODE_DISCOVER_RESP=0x90`.
- `firmware/examples/simple_repeater/MyMesh.cpp:773-820` — body parse /
build.

## Tests (red → green, per AGENTS.md STRICT TDD)
- `cmd/ingestor/issue1802_test.go` — 6 cases: REQ full body (with
since), REQ no-since, RESP 32B pubkey, RESP 8B prefix pubkey, RESP
truncated pubkey (no panic, no pubkey emitted), short body (subtype
only), unknown subtype. Red commit `43713d3a` → green commit `d4b28180`.
Pre-existing CONTROL tests (`TestDecodeControlZeroHop`,
`TestDecodeControlMultiHop`) still pass.
- `test-packets.js` — 3 cases on `getDetailPreview`: DISCOVER_REQ
(filter+tag rendered), DISCOVER_RESP (snr+pubkey rendered), UNKNOWN
subtype label. Red commit `be23e349` → green commit `845d6c48`.

## Preflight overrides
- `check-branch-clean` (cross-stack): justified — issue #1802 explicitly
spans backend decoder (`cmd/ingestor/decoder.go`) and frontend renderer
(`public/packets.js`) per triage comment. Tests in both layers.
Single-purpose PR.

## Scope discipline
Files touched: `cmd/ingestor/decoder.go`,
`cmd/ingestor/issue1802_test.go`, `public/packets.js`,
`test-packets.js`. No other files. No firmware changes. No
`cmd/server/decoder.go` changes. No `CtrlZeroHop` rename (deferred per
triage).

Fixes #1802

---------

Co-authored-by: clawbot <bot@meshcore.local>
2026-06-28 06:32:07 -07:00
Kpa-clawbotandclawbot e465e1c6c6 perf(#1740): replace idx_tx_last_seen with partial index WHERE last_seen=0 (#1756)
Fixes #1740.

## What

Replace the full `idx_tx_last_seen` with a partial index `WHERE
last_seen=0`. Two ordered, sequential migrations in
`internal/dbschema/dbschema.go::ensureTransmissionsLastSeenColumn`:

- **(a)** `CREATE INDEX IF NOT EXISTS idx_tx_last_seen_zero ON
transmissions(id) WHERE last_seen=0`
- **(b)** `DROP INDEX IF EXISTS idx_tx_last_seen` — gated after (a)
succeeds (sequential `Exec`; (b) never runs if (a) errors)

## Why

The only consumer is `chunkedTxLastSeenBackfill`'s `WHERE last_seen=0`
scan + `MAX(id)` lookup. The full index covers ALL rows including the
long tail where `last_seen != 0` after backfill converges (71K+ rows in
prod per carmack's #1740 note). The partial index degenerates to ~the
count of un-backfilled rows (0 in steady state, bounded by ingest rate
during ops) and stops competing for page cache.

## Migration cost

Both migrations are sync and annotated `PREFLIGHT: async=false`:
- (a) `CREATE INDEX` on partial subset `WHERE last_seen=0` is bounded by
un-backfilled rows — a small superset of the inflight ingest window, not
a full table scan.
- (b) `DROP INDEX` is a metadata-only schema rewrite in SQLite — no row
scan at any size.

`dbschema/` has no access to `Store.RunAsyncMigration` (that helper
lives in `cmd/ingestor/` and is the wrong layer for the schema
source-of-truth per #1321), so sync is the only path here regardless.

## TDD

- **RED** `a529f0f4`: `EXPLAIN QUERY PLAN` test asserting the backfill
`MAX(id) WHERE last_seen=0` query uses `idx_tx_last_seen_zero` + a
second test asserting the legacy `idx_tx_last_seen` is dropped
post-Apply. Both failed on assertion (planner picked the full index;
partial index didn't exist).
- **GREEN** `208fde8a`: add migrations (a) and (b). Both tests pass.

## Files touched

- `internal/dbschema/dbschema.go` — swap the index
- `internal/dbschema/dbschema_test.go` — `EXPLAIN QUERY PLAN` pin + DROP
assertion
- `cmd/ingestor/db.go` — comment refresh only (idx_tx_last_seen →
idx_tx_last_seen_zero)

## Acceptance

- ✅ New partial index created
- ✅ Old full index dropped via gated migration (sequential, order
preserved)
- ✅ Query plan test asserts partial-index usage
- ✅ Existing migration tests still green (`internal/dbschema`,
`cmd/ingestor` TestIssue1690 + applySchema)

---------

Co-authored-by: clawbot <bot@openclaw>
2026-06-20 15:45:28 -07:00
22fe929da2 feat: opt-in mobile client-RX coverage (crowdsourced RF reach) + /api/nodes/resolve (#1728)
Implements #1727.

## What this adds

**Mobile client-RX coverage** — an opt-in, crowdsourced RF-coverage
feature. A roaming MeshCore **companion** radio (driven by the
open-source [corescope-rx](https://github.com/efiten/corescope-rx) PWA,
GPLv3) reports which nodes it heard directly, tagged with the phone's
GPS and the packet's SNR/RSSI. CoreScope ingests these into a new
`client_receptions` table and renders per-node **hex coverage** on the
Reach page, plus a standalone **Coverage dashboard** (`#/rx-coverage`)
with a top-mobile-observers leaderboard.

Also includes **`GET /api/nodes/resolve?prefix=<hex>`** — a read-only
node-name lookup by pubkey prefix (`{name, pubkey, ambiguous}`), used by
the companion app for friendly names.

## Opt-in — default OFF (zero impact on existing deployments)

The whole feature is gated behind one config flag, **disabled by
default**:

```jsonc
"clientRxCoverage": { "enabled": false }
```

When disabled (the default): the ingestor writes **no**
`client_receptions`; the three coverage endpoints return a clean
**404**; the UI hides the Coverage nav link, the `#/rx-coverage` route,
and the Reach-page toggle. `/api/nodes/resolve` is always available (not
coverage-specific).

## How it works

```
companion ──BLE 0x88 (snr+rssi+raw)──▶ corescope-rx PWA ──▶ MQTT meshcore/client/{pubkey}/packets
                                                                      │
                                          ingestor (gated) ──▶ client_receptions (GPS + SNR + heard-key)
                                                                      │
              server: pure-Go hex grid ──▶ GeoJSON ──▶ Reach hex overlay + Coverage dashboard
```

- **Direct-only capture:** records only what the companion heard itself
and directly — a 0-hop advert's pubkey, or `path[last]` (last forwarder)
for FLOOD routes; ≥2-byte path-hash required. Upstream hops discarded.
- **No new deps:** hexbins are a pure-Go pointy-top grid over Web
Mercator (`cmd/server/hexgrid.go`) computed at query time
(`CGO_ENABLED=0` / `modernc.org/sqlite` friendly); frontend uses the
existing Leaflet.
- **Trust:** companion pubkey = identity; an EMQX ACL binds each client
to publish only to its own `meshcore/client/{pubkey}/packets` topic.
Payload contract in `docs/client-rx-coverage.md`.

## How to enable / try it

1. In `config.json`, set `"clientRxCoverage": { "enabled": true }` and
restart server + ingestor.
2. Point an EMQX (or any broker) listener so a client can publish to
`meshcore/client/<pubkey>/packets`; the ingestor already subscribes
under `meshcore/#`.
3. Run the [corescope-rx](https://github.com/efiten/corescope-rx) PWA on
an Android phone paired (BLE) to a MeshCore companion — it captures
heard nodes + GPS and publishes.
4. View results: per-node Reach page → toggle **coverage**, or the
**Coverage** dashboard at `#/rx-coverage`.

## What's where

- **Ingestor:** `cmd/ingestor/client_reception.go` (ingest), `db.go`
(`client_receptions` + `client_observers` schema), `main.go` (gated
dispatch), `config.go` (flag).
- **Server:** `cmd/server/rx_coverage.go` + `rx_dashboard.go`
(endpoints, self-guard 404 when off), `hexgrid.go` (pure-Go grid),
`node_resolve.go` (resolve), `routes.go` / `types.go` / `config.go`
(wiring + flag + `/api/config/client` field).
- **Frontend:** `public/rx-coverage.js` (dashboard),
`node-reach-coverage.js` + `.css` (overlay), `node-reach.js` (Reach
toggle, flag-gated), `roles.js` (reads the flag, hides nav when off).
- **Docs:** `docs/client-rx-coverage.md`.

## Testing

- Go: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test
./...` — green, including new gate tests (`coverage_gate_test.go` in
both: off → no rows / 404, on → works) and the rx-coverage / resolve /
hexgrid suites.
- JS: `node test-coverage-gate.js`, `node test-node-reach-coverage.js`
(wired into CI). The Playwright `test-node-reach-coverage-e2e.js` is
wired into the e2e job and **skips when `clientRxCoverage` is
disabled**, so it's safe under the default-off config.

## Notes for reviewers

- The four new routes are registered in
`cmd/server/openapi_known_gaps.json` (the existing OpenAPI-completeness
ratchet), matching how other not-yet-spec'd routes are tracked. Happy to
write full OpenAPI spec entries instead if you prefer.
- Commits are split per layer (ingestor / server endpoints / resolve /
frontend / CI) for review.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
2026-06-19 11:37:16 -07:00
048143f54f fix(#1690): cold-load uses last_seen (effective recency) instead of first_seen (#1691)
## #1690 — cold-load uses wrong time axis (RED → GREEN)

The on-disk DB has thousands of long-lived hashes with recent traffic.
Prod's
cold-load filter (`transmissions.first_seen >= cutoff`) is bound to a
column
that is set once at insert time and never updated — so re-observation of
an
old hash does not move it into the hot window. Result: prod cold-loaded
~0.3%
of the on-disk rows and flipped `backgroundLoadComplete=true` without
ever
walking the retention window (the `retentionHours - hotStartupHours <=
0`
short-circuit at line 1353 of `cmd/server/store.go`).

### Three sub-fixes

**A) Denormalize `transmissions.last_seen`** so cold-load can window on
effective recency.

- `internal/dbschema/dbschema.go::ensureTransmissionsLastSeenColumn`
adds the
  column + `idx_tx_last_seen` (single-column INTEGER ALTER + index; both
  PREFLIGHT-annotated as cheap metadata-only ops).
- `cmd/ingestor/db.go::OpenStoreWithInterval` schedules
  `tx_last_seen_backfill_v1` via `Store.RunAsyncMigration` —
`UPDATE transmissions SET last_seen = MAX(observations.timestamp) WHERE
  last_seen = 0` — non-blocking on boot (1.9M+ obs row scan in prod).
- Writer-side: `InsertTransmission` seeds `last_seen` on initial insert,
and every observation insert bumps `last_seen = ?` via prepared
statement
`stmtBumpTxLastSeen` (conditional `last_seen < ?` so out-of-order ingest
  never goes backwards).
- Reader-side: `cmd/server/store.go::Load`, `loadChunk`, and
  `cmd/server/chunked_load.go::LoadChunked` switch the WHERE/ORDER-BY
clauses to `t.last_seen` when the column is present (PRAGMA-detected via
  `DB.hasLastSeen`). Test/legacy DBs without the column fall back to
  `first_seen` so existing fixtures stay green.

**B) Honest `backgroundLoadComplete` gating.**

- Drop the `retentionHours - hotStartupHours <= 0` short-circuit. Prod
runs
  with both at 12h, which flipped Done=true immediately.
- After the chunk loop, query
`SELECT COUNT(*) FROM transmissions WHERE last_seen >= retentionFloor`
and
  compute `loadCoverageRatio = inMem / inDB`. Done=true only when
  `ratio >= 0.90` AND no chunk errors. `backgroundLoadFailed=true` +
  `backgroundLoadError` populated otherwise (e.g. `"loaded 20.0% of 5000
  rows (1000 in memory)"`).
- `bgErrMu`-guarded `loadCoverageRatio` + `backgroundLoadErr` so the
perf
  endpoint can read them without blocking the writer.

**C) Perf exposure.**

`PerfPacketStoreStats` gains `RetentionHours`, `OldestLoaded`,
`LoadCoverageRatio`, `BackgroundLoadError` — surfaces what fraction of
the
on-disk DB the in-memory store currently reflects, so operators can see
the
0.3% case in `/api/perf` without reading the logs.

### TDD trail

- **RED**: `05f0c6dd2bea6dc37324c548a49564d739aca920` — failing tests +
21-line
store.go scaffolding. CI on this commit failed on assertions (intended).
- **GREEN**: this PR's HEAD commit (8 files, +271/-24). Targeted suite:
  `Test1690_ColdLoad_TimeAxis`, `Test1690_BackgroundLoadHonesty`,
  `Test1690_PerfStats_NewFields`, `TestHotStartup_*`,
  `TestIssue1690_LastSeenUpdatedOnObservation` — all pass.

Anti-tautology: locally reverted the `if !s.backgroundLoadFailed.Load()`
guard around `backgroundLoadDone.Store(true)` —
`Test1690_BackgroundLoadHonesty`
fails on the assertion `"backgroundLoadDone=true with only 1000/5000
packets
loaded; must be false until coverage ≥ 90%"`. Restored.

### Async-migration preflight

- `ensureTransmissionsLastSeenColumn` — ALTER + CREATE INDEX both
  `// PREFLIGHT: async=true reason="..."` annotated.
- `tx_last_seen_backfill_v1` — wrapped in `Store.RunAsyncMigration`.
- `stmtBumpTxLastSeen` prepared statement — annotated; it is a row-level
  UPDATE BY PRIMARY KEY, not a migration.

### Preflight overrides

PREFLIGHT-MIGRATION-SCALE: <30s N=5K
- check-async-migration: justified for
`cmd/server/issue1690_cold_load_test.go`
CREATE TABLE/INDEX statements — these build an in-memory test fixture DB
  (≤5000 rows, runs in <1s in CI), not a prod migration.

Fixes #1690.

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: bot <bot@example.com>
2026-06-12 12:47:53 -07:00
Kpa-clawbotandopenclaw-bot efd66ea3f5 feat(mqtt): per-source status endpoint + Observers panel (#1682)
## Summary

Adds MQTT source status visibility per #1043 acceptance criteria:

- **Ingestor:** per-source counter registry
(`cmd/ingestor/source_status.go`) tracking `connected`,
`lastConnectUnix`, `lastDisconnectUnix`, `lastPacketUnix`,
`connectCount`, `disconnectCount`, `packetsTotal`, `packetsLast5m`
(sliding 5-min window via per-second buckets keyed by unix second — no
stale-leak), `lastError`. Wired at the existing OnConnect /
ConnectionLost / DefaultPublish callsites alongside the liveness
watchdog. Idempotent registration so counters survive reconnects.
Snapshot emitted in the existing stats file under `source_statuses`
(additive, `omitempty`).
- **Backend:** new `GET /api/mqtt/status` handler reads the ingestor
stats file and returns the per-source list. **Broker passwords are
masked** via a regex over the `scheme://user:pass@host` form (covers
mqtt/mqtts/tcp/ssl/ws/wss). Mask is also applied to `lastError` as
defense-in-depth (broker libs occasionally quote the failing URL).
OpenAPI completeness gate satisfied with a `routeDescriptions` entry.
- **Frontend:** small self-contained panel
(`public/mqtt-status-panel.js`) mounted above the Observers table.
Auto-refreshes every 10s, color-codes each row (green = connected +
recent packet, yellow = connected idle, red = disconnected), and tears
down its timer on SPA route change.

## TDD

- Red commit `f19a93b5` — stub `/api/mqtt/status` handler + assertion
test that the broker password is `****`-redacted. Test fails on the
assertion (handler passes the URL through verbatim). Compile-clean —
assertion-fail, not build-fail.
- Green commit `77042e41` — `maskBrokerURL` helper + table-driven unit
tests across all schemes + handler rewires to mask both `Broker` and
`LastError`.
- Subsequent commits land the ingestor wiring and the frontend panel.

## Tests

```
$ cd cmd/server && go test -run 'TestMqttStatus|TestMaskBrokerURL' -v ./...
PASS: TestMqttStatus_MasksBrokerPassword
PASS: TestMqttStatus_EmptyWhenNoStatsFile
PASS: TestMaskBrokerURL_Patterns (10 subtests)

$ cd cmd/ingestor && go test -run 'TestSourceStatus|TestSnapshotSourceStatuses' -v ./...
PASS: TestSourceStatus_BasicLifecycle
PASS: TestSourceStatus_Disconnect
PASS: TestSnapshotSourceStatuses_ReturnsAll

$ node test-mqtt-status-panel.js
7 passed, 0 failed
```

Full `go test ./...` clean in both `cmd/server` and `cmd/ingestor`.

## Preflight overrides

- `cross-stack`: justified — issue #1043 is intrinsically full-stack
(ingestor stats → server endpoint → observers panel). Per-stack split
would land an unreachable endpoint or a fetch with no backend.
- `check-xss-sinks` (public/mqtt-status-panel.js:55): justified — the
flagged `innerHTML=` is a fully-static literal (empty-state placeholder,
no payload data interpolated). All payload-bearing `innerHTML=` sites in
this file run through `escapeHTML` (defined in the same file); the test
`renderPanel never echoes a plaintext password (defense-in-depth)`
exercises the rendered HTML against payload strings.

## Acceptance criteria

- [x] `/api/mqtt/status` returns per-source connection state —
`cmd/server/mqtt_status.go`
- [x] UI panel shows all configured sources with live status —
`public/mqtt-status-panel.js`
- [x] Connection state updates on reconnect/disconnect events —
`MarkConnect` / `MarkDisconnect` wired in `cmd/ingestor/main.go`
- [x] Broker URLs don't expose passwords in the API response —
`maskBrokerURL` + 13 test cases
- [x] Works with 1-N sources — registry is keyed per-source, snapshot
iterates the map

**Partial fix for #1043** — per-packet `mqtt_source` attribution (the
issue's "Follow-up" section) is **deferred** per the `mc-bot-triaged:v1`
triage and the autofix comment ("Per-packet attribution deferred to
follow-up issue"). That work requires a new observation-row column and
DB schema migration, both explicitly out of scope for this PR.

Refs #1043

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-12 08:11:02 -07:00
a4776557ae feat(#1290): use firmware repeat:on|off hint to exclude listener-only observers from disambiguator (#1624)
Closes #1290.

cross-stack: justified — backend persists firmware-side `repeat` hint to
a new observers column, frontend surfaces the listener/repeater status
as a badge on the observers list and node-detail Heard By table per the
issue's UI acceptance criterion.

## What

Firmware 1.16 publishes a `repeat: on|off` flag in the MQTT `/status`
JSON (confirmed by @cwichura on the issue thread — see
[`MQTTMessageBuilder.cpp:58`](https://github.com/agessaman/MeshCore/blob/b45373a31f111fb0de98bb3b168226d09ceadc47/src/helpers/MQTTMessageBuilder.cpp#L58)
in `agessaman/MeshCore mqtt-bridge-implementation-flex`). Listener-only
observers (`repeat:off`) by firmware contract never relay packets, so
they cannot legitimately be a hop in someone else's resolved path. This
PR plumbs the hint end-to-end so the disambiguator stops considering
them.

## How

* **`internal/dbschema`**: idempotent `can_relay INTEGER DEFAULT 1`
migration on `observers`, plus `AssertReady` probe (server fatal-logs if
absent). Mirrored in `cmd/ingestor/db.go` `CREATE TABLE` for fresh DBs.
Annotated `PREFLIGHT: async=true` — `DEFAULT 1` is constant so SQLite
does this as a metadata-only schema rewrite.
* **`cmd/ingestor`**: `extractObserverMeta` accepts `repeat` as bool,
case-insensitive string (`on|off|true|false|yes|no`), or numeric `0|1`.
Missing field → `nil` → `COALESCE` preserves the existing column value
(back-compat with legacy observers). Plumbed through `UpsertObserverAt`
and the prepared upsert statement.
* **`cmd/server`**: `GetNonRelayObserverPubkeys` + new
`prefixMap.markNonRelay` drop matching candidates inside
`pm.resolveWithContext` at the top of the resolver, so all 4 tiers see
the pruned candidate set. `ObserverResp.CanRelay` is surfaced on
`/api/observers` and `/api/observers/{id}`. `GetNodeHealth` enriches
per-observer rows with `can_relay` so the node-detail badge renders.
Probe-and-fall-back when the `can_relay` column is absent (legacy test
fixtures).
* **`public/`**: listener vs repeater pill on observers list, observer
detail `Relay` stat card, and node-detail `Heard By` table. CSS uses
existing theme vars.

## Test

Added `TestResolveWithContext_ExcludesNonRelayObservers_Issue1290` in
`cmd/server/resolve_non_relay_1290_test.go` covering all three required
cases:
* `repeat:off` pubkey → not a candidate (assertion failed in red commit
`5f7fdb96`, passes after green `f12911dc`)
* `repeat:on` pubkey → still a candidate (regression guard)
* legacy obs (no field) → still a candidate (back-compat)

Red→green proof:
```
$ git log --oneline origin/master..HEAD
f12911dc feat(#1290): exclude listener-only observers from path-hop disambiguator
5f7fdb96 test(#1290): red — assert listener-only observers excluded from path-hop candidates
```

Full server + ingestor + dbschema + migrate test suites pass locally.

## Acceptance checklist (from #1290)

* [x] Ingestor parses `repeat` field (boolean OR string `on|off`)
* [x] Field persisted on `observers` table (new `can_relay BOOLEAN`
column, idempotent migration via `internal/dbschema`)
* [x] Server's disambiguator (`pm.resolveWithContext`) excludes
`can_relay=false` observer-nodes from path-hop candidate set
* [x] UI badge on observers list + node detail page indicating
"listener" vs "repeater"
* [x] Backward compat: legacy observers default to `can_relay=true`
* [x] Test: `repeat:off` → NOT a candidate
* [x] Test: `repeat:on` → IS a candidate
* [x] Test: legacy → IS a candidate

## Out of scope (preserved per issue)

Backfilling already-resolved paths is left as a follow-up. No
firmware/broker changes.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-08 01:27:13 -07:00
Kpa-clawbotandcorescope-bot 3d12266595 fix(#1608): address PR #1609 follow-up findings — config doc, receipt-time liveness, buffer stop/clamp warn (#1623)
Follow-up to #1609 / #1608.

Addresses the 5 unresolved findings from the PR #1609 round-1 polish
review.

## Findings addressed

| Tag | Severity | Fix | Commits |
|-----|----------|-----|---------|
| **B1** | BLOCKER | Document `ingestBufferSize` in
`config.example.json` near other ingestor knobs. Default `50000`,
comment text from review. | `f0b4e411` |
| **M1** | MAJOR (option 1 from review) | Split receipt-time vs
post-write liveness: add `SourceLivenessState.LastReceiptUnix` +
`MarkReceipt`, stamp at the MQTT receipt callback, leave
`LastMessageUnix` post-write only. Drop the double-stamp at receipt that
masked write-path stalls. Surface both clocks via the ingestor stats
file (`source_liveness`) and the server's `/api/healthz`
(`ingest_liveness`, additive — older builds unaffected). | RED
`fa78233d` / GREEN `bc81b544` |
| **M1 (drop-log)** | MAJOR | Log every drop when buffer is at capacity.
Removes the `n==1 \|\| n%1000` throttle that hid the first stall behind
1000 lost packets. The Submit drop branch only fires when the channel is
at cap so volume is naturally bounded by the stall, not by an arbitrary
modulo. | RED `a468763e` / GREEN `7b24fce5` |
| **m1** | MINOR | Add `IngestBuffer.Stop()` and `Done()` so tests stop
leaking the consumer goroutine that `Start()` spawns. Existing tests
gain `t.Cleanup(b.Stop)`. Drain semantics: stop-before-Ready exits
immediately; stop-after-Ready best-effort drains queued jobs. | RED
`8430c822` / GREEN `78c9b223` |
| **m2** | MINOR | `NewIngestBuffer(<1)` now logs a `[ingest-buffer]
WARN` line on clamp so misconfigured `ingestBufferSize` values are
visible instead of silently running a 1-slot queue. Test captures log
output. | RED `62119ab4` / GREEN `815bfd02` |
| **m3** | MINOR | Add godoc to `Submit` and `Ready` documenting the
Start-before-Submit / Start-before-Ready ordering invariant. |
`564a813b` |

## TDD discipline

Each behavioral fix (M1, M1-drop-log, m1, m2) lands as a red-then-green
pair. Red commits compile + run + fail on assertion, verified locally
before the green commit. Per-finding red→green pairs are visible in the
commit graph above.

B1 and m3 are docs-only and ship as single commits (preflight script
accepts them under the docs/comments exemption).

## Schema compatibility

`/api/healthz` change is purely additive: `ingest_liveness` is only
included when the ingestor publishes the new `source_liveness` field, so
older ingestor + newer server combos are unaffected. Field order in the
response stays stable for prior consumers.

## Test output

- `go test -count=1 -timeout 180s ./cmd/ingestor/...` → green (160s)
- `go test -count=1 -timeout 300s ./cmd/server/...` → green (48s)
- Race-mode runs of the touched packages
(`IngestBuffer|Liveness|Watchdog|Receipt|Healthz`) → green
- Full-package race runs locally exceed the brief's 120s timeout on
pre-existing slow integration tests (TestObsTimestampIndexMigration,
TestNeighborEdgesBuilderDeltaScan); CI has the headroom.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all hard gates pass, no warnings.

## Files changed

- `config.example.json` — B1
- `cmd/ingestor/ingest_buffer.go` — m1, m2, M1-drop-log, m3
- `cmd/ingestor/ingest_buffer_test.go` — m1, m2, M1-drop-log
- `cmd/ingestor/mqtt_watchdog.go` — M1
- `cmd/ingestor/mqtt_watchdog_m1_test.go` — M1 (new)
- `cmd/ingestor/main.go` — M1 (receipt callsite)
- `cmd/ingestor/stats_file.go` — M1 (publish `source_liveness`)
- `cmd/server/perf_io.go` — M1 (type + reader)
- `cmd/server/healthz.go` — M1 (surface `ingest_liveness`)

Original review reference: PR #1609 polish review by the M-axis bot.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-07 09:28:51 -07:00
Kpa-clawbotandcorescope-bot 222bfdf6cf feat(perf): SQLite writer-lock wait/hold instrumentation per component (#1340) (#1594)
## What

Per-component SQLite writer-lock instrumentation so the next
neighbor-builder-style write-lock starvation (root cause of #1339,
invisible to operators for ~3 days) is detectable from `/api/perf`.

Adds `Store.WriterExec` / `Store.WriterTx` wrappers that gate every
wrapped call on a package-level `writerMu` so the wait the SQLite driver
hides becomes Go-visible, and record `wait_ms` + `hold_ms` +
`contention_total` (wait_ms > 100ms) under a component tag.
Per-component p50/p95/p99 + max are published to
`/api/perf/write-sources` under `.writer_perf` via the existing ingestor
stats-file path. Slow-writer log line (`[db-slow-writer] component=X
duration=Yms query=<200ch>`) fires on `hold_ms > 500ms` (threshold
overridable via `CORESCOPE_DB_SLOW_WRITER_MS` env var).

## Tagged call sites

| Component | Location |
|-----------|----------|
| `mqtt_handler` | `InsertTransmission` (db.go) |
| `neighbor_builder` | `buildAndPersistNeighborEdges`
(neighbor_builder.go) |
| `prune_packets` | `PruneOldPackets` (maintenance.go) |
| `prune_observers` | `RemoveStaleObservers` + orphan-metrics cleanup
(db.go) |
| `prune_metrics` | `PruneOldMetrics` (db.go) |
| `vacuum` | `RunIncrementalVacuum` + `CheckAutoVacuum`'s full VACUUM
(db.go) |

## TDD red→green

- **Red commit** `68de585b` — `cmd/ingestor/db_writer_perf_test.go` +
`Store.Writer*` stubs at end of `db.go`. Test synthetically blocks the
writer for 60s tagged `neighbor_builder`, then asserts
`mqtt_handler.wait_ms.p99 > 50000ms` on concurrent inserts. Fails on the
assertion (p99 = 0.0ms) with the stub — not a build error.
- **Green commit** `6a9be174` — replaces stubs with real
wait/hold/contention aggregator + wires every writer call site. Same
test passes:

```
2026/06/05 04:36:47 [db-slow-writer] component=neighbor_builder duration=60059.0ms query=COMMIT
--- PASS: TestWriterStarvationVisibleInPerf (60.40s)
PASS
ok      github.com/corescope/ingestor   60.408s
```

## Scope discipline

- **API**: no public `Store`/`DB` signature change. Only additive
exports.
- **Server**: extends existing `/api/perf/write-sources` JSON with
`.writer_perf` — does **not** add a new route, does **not** replace
`handlePerf`. Empty `.writer_perf` map when paired with an older
ingestor.
- **Read/write invariant** (#1283) preserved: all instrumentation lives
on the ingestor's writer connection.
- **Files touched** (6 total): `cmd/ingestor/db.go`,
`cmd/ingestor/db_writer_perf_test.go`, `cmd/ingestor/maintenance.go`,
`cmd/ingestor/neighbor_builder.go`, `cmd/ingestor/stats_file.go`,
`cmd/server/perf_io.go`, `config.example.json`.

## Deferred (acceptance items NOT in this PR)

- **`mbcap_persist` component tag** — `RunMultibyteCapPersist`'s tx is
intentionally NOT wrapped in this PR to stay within the implementation
brief's 3-files-outside-whitelist budget. One-file follow-up to
instrument.
- **CI smoke test** asserting "neighbor-builder hold_ms < 1000ms on
100k-obs fixture" — deferred to a separate PR per the brief; this PR is
scoped to instrumentation only.

## Preflight overrides

PREFLIGHT-MIGRATION-SCALE: <30s N=runtime — the async-migration gate
flagged five `instrumentedExec` / wrapped-`tx.Exec` lines on `DELETE
FROM observer_metrics`, `UPDATE observers`, `DELETE FROM
observer_metrics`, `DELETE FROM observations`, `DELETE FROM
transmissions`. These are **not** schema migrations — they are the
existing runtime prune / retention queries that already ran sync against
`s.db.Exec` / `tx.Exec` on every retention cycle on master. This PR only
swapped the surface call (sync → sync, via the wrapper) to record
wait/hold timing; no new sync schema work was introduced. Behavior on
production data is identical to master.

Also: red commit's synthetic `UPDATE nodes SET name = name WHERE 0` is a
test-only stub designed to acquire the writer without mutating any row
(the `WHERE 0` is a no-op predicate).

Fixes #1340

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-06 21:05:59 -07:00
Kpa-clawbotandcorescope-bot 1b112f0b08 feat(memlimit): GOMEMLIMIT via runtime.maxMemoryMB in server + ingestor (#1010) (#1595)
Red commit: 929da3c6dcc1b619c27478291125d1c91323db8f — CI:
https://github.com/Kpa-clawbot/CoreScope/commit/929da3c6dcc1b619c27478291125d1c91323db8f/checks

Fixes #1010.

## What
Adds `GOMEMLIMIT` support to both `cmd/server` and `cmd/ingestor` per
the locked triage scope on #1010.

Precedence (env wins):
1. `GOMEMLIMIT` env var
2. `runtime.maxMemoryMB` config field (new)
3. Server only: implicit `packetStore.maxMemoryMB * 1.5` (existing #836
behavior, unchanged when `runtime.maxMemoryMB` is absent)
4. Otherwise unset — default Go behavior preserved (backwards
compatible)

Each startup logs a `[memlimit]` line echoing the effective
source/limit, or an "unset → default" note when neither is set.

## Changes
- `cmd/ingestor/memlimit.go` — new, `applyMemoryLimit(runtimeMaxMB,
envSet)`.
- `cmd/ingestor/memlimit_test.go` — new, env/config/none/precedence
assertions.
- `cmd/ingestor/config.go` — new `RuntimeConfig{MaxMemoryMB int}` field.
- `cmd/ingestor/main.go` — wires `applyMemoryLimit` into startup right
after `LoadConfig`.
- `cmd/server/config.go` — new `RuntimeConfig` + `cfg.Runtime` field.
- `cmd/server/main.go` — adds explicit `runtime.maxMemoryMB` precedence
over packetStore-derived; existing `warnIfMemlimitUnderprovisioned`
(#1264) unchanged.
- `config.example.json` — new `runtime` block with
`_comment_runtime_maxMemoryMB` per the Config Documentation Rule.
- `README.md` — sizing-table row with ≥1.5× working set floor +
death-spiral warning.

## TDD
- Red: `929da3c6` — ingestor `applyMemoryLimit` stub returns
`(0,"none")`; four tests fail on assertions (`expected source=env, got
"none"`, etc.) — no compile errors.
- Green: `953ec9d8` — implements ingestor `applyMemoryLimit`, wires
startup, threads `runtime.maxMemoryMB` through server too.

## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean (all gates pass, all warnings pass).

## Out of scope
- `pprof`-verified GC-trigger acceptance criterion from the original
issue — requires production tracing; the triage scope is the
operator-tunable plumbing.
- Container auto-detection of cgroup memory limit (already covered by
#1264's `warnIfMemlimitUnderprovisioned`).

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-06-06 21:05:56 -07:00
efitenandClaude Opus 4.8 18810b5c13 fix(ingestor): subscribe to MQTT before startup maintenance, buffer until writer is free (#1608) (#1609)
## Summary

Closes #1608.

The ingestor's MQTT connect/subscribe loop ran **last** in `main()`,
after the synchronous startup-maintenance block. Because all writes
share a single SQLite writer (#1283), that maintenance — and the connect
loop after it — serialize behind any long-running async migration. The
subscription therefore came up minutes late (observed ~4.5 min after the
v3.8.3 `obs_observer_ts_idx_v1` index build over ~4.9M rows), and QoS-0
packets published in that window were dropped.

This decouples **receipt** from **write**:
- New `IngestBuffer` — a bounded FIFO drained by a **single** gated
consumer goroutine.
- The MQTT subscription is brought up first; its publish handler stamps
source liveness at receipt and enqueues a `handleMessage` closure.
- Startup maintenance runs, then `WaitForAsyncMigrations()`, then
`IngestBuffer.Ready()` opens the gate and the backlog drains.

A single consumer preserves the single-writer invariant (#1283);
buffering replays the original messages, so it introduces **no
duplicates** (unlike a QoS-1 broker queue). Broker-agnostic — helps
direct-connect and bridged operators alike.

## Changes

- `cmd/ingestor/ingest_buffer.go` — `IngestBuffer`
(`Submit`/`Start`/`Ready`/`Dropped`/`Pending`); non-blocking submit with
drop-on-full counter; single consumer.
- `cmd/ingestor/config.go` — `ingestBufferSize` knob (default 50000).
- `cmd/ingestor/main.go` — reorder boot: connect/subscribe **before**
startup maintenance; stamp liveness at receipt; `Ready()` after
maintenance + `WaitForAsyncMigrations()`; periodic stats log buffer
`pending`/`dropped`.

## Test plan

- [x] `go test ./...` in `cmd/ingestor` — `IngestBuffer` suite covers
gating-until-ready, FIFO order, drop-on-full, serial execution
(single-writer), and concurrent-submit.
- [ ] `go test -race` in CI (concurrency on `IngestBuffer`).
- [ ] Manual: restart with a pending heavy migration → `subscribed to
meshcore/#` appears within seconds; `[ingest-buffer] write path ready`
after the migration; packets received during the window are written
after `Ready()` (0 dropped under normal traffic); stall watchdog stays
quiet (liveness stamped at receipt).

## Out of scope

A hard crash while messages sit in the in-memory buffer still loses
them; crash-durability requires broker-side persistence, which is
topology-specific. This PR closes the startup-migration and deploy loss
windows.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:05:53 -07:00
Kpa-clawbotandcorescope-bot 9612f08e46 fix(#1610): decode firmware 1.16.0 extended ACK (5/6-byte payloads) (#1618)
## Summary

Firmware 1.16.0 (`companion-v1.16.0`) ships variable-length
`PAYLOAD_TYPE_ACK` payloads: 4 bytes (legacy) → 5 bytes (4-byte CRC +
1-byte attempt, commit `f6e6fdaa`) → 6 bytes (+ 1-byte RNG, commit
`a130a95a`). CoreScope's decoder previously truncated past the 4-byte
CRC and discarded the attempt + RNG bytes.

This PR teaches `cmd/ingestor/decoder.go` to surface the extended bytes
on the decoded payload so the DB/UI can distinguish v1.15 vs v1.16
senders, with no schema or wire-compat changes.

Partial fix for #1610 — top-level ACK + multipart-inner ACK are covered.
PATH-extra ACK parsing (`decodePathPayload`) is deferred to #1612 per
triage.

## Changes

- `decodeAck` reads 4/5/6-byte payloads. Keeps `extraHash` (4-byte CRC)
for compat; adds optional `ackLen`, `ackAttempt`, `ackRand` JSON fields.
Legacy 4-byte ACKs leave attempt/rand `nil`.
- `decodeMultipart` ACK branch relaxes the `len >= 5` floor so the inner
blob can be 4/5/6 bytes (multipart `payload_len` 5/6/7). Adds
`innerAckLen`, `innerAckAttempt`, `innerAckRand`.
- All additions are `omitempty` — backwards-compatible JSON only. No DB
column, no schema migration, no frontend change.

## Out of scope (per issue triage)

- `decodePathPayload` PATH-extra parsing — tracked separately in #1612.
- Frontend rendering of attempt counter — leave for a follow-up if the
DB/UI eventually wants to display it.

## TDD

- **Red commit `3fce0465`** adds `cmd/ingestor/issue1610_test.go` with 6
new assertions (legacy 4-byte, extended 5/6-byte, multipart variants of
each). New fields are declared on `Payload` so the test compiles, but no
decoder populates them yet — tests fail on `ackLen=<nil> want 4` etc.
Verified isolation with `git stash` of decoder.go + re-run.
- **Green commit `5165c202`** implements the decoder changes. `go test
./...` in `cmd/ingestor` passes.

## Fixtures

Synthetic wire vectors built by hand against the firmware spec — the
issue did not provide real captures. Each test cites the firmware ref +
commit it derives from (`BaseChatMesh.cpp:218-234`, commits `f6e6fdaa`
and `a130a95a`).

## References

- Issue #1610
- Firmware tag `companion-v1.16.0` @ `07a3ca9e`
- Upstream PR meshcore-dev/MeshCore#2594
- Blog: https://blog.meshcore.io/2026/06/06/release-1-16-0

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-06 21:05:50 -07:00
Kpa-clawbotandmeshcore-bot af669438ff docs+test(ingestor): document writeStatsAtomic symlink-replace semantics + regression test (#1170) (#1588)
Fixes #1170.

## What

1. **Doc comment** on `writeStatsAtomic` (`cmd/ingestor/stats_file.go`)
spelling out the two-sided symlink story:
- tmp side (`path+".tmp"`): protected by `O_NOFOLLOW` (existing
behavior, already noted).
- rename side (`path` itself): NOT protected by `O_NOFOLLOW`; instead
`os.Rename` semantics are relied upon — rename atomically replaces any
existing entry at `path` (including a symlink) with the new regular
file. The symlink target is never written through because all writes
happened to the unrelated tmp file before rename.
2. **Regression guardrail test**
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced` in
`cmd/ingestor/stats_file_test.go` that pre-plants a symlink at the
destination path pointing to an unrelated target file, calls
`writeStatsAtomic`, and asserts:
- (a) `os.Lstat(path).Mode()&os.ModeSymlink == 0` (post-write path is a
regular file, not a symlink)
   - (b) the original symlink target's sentinel bytes are unchanged.

If a future refactor swaps `os.Rename` for a
destination-symlink-following primitive (e.g. `open(path, O_WRONLY)`
without `O_NOFOLLOW`, or a copy-then-truncate), the test fails loudly.

## TDD note (red-commit exemption)

The current `writeStatsAtomic` ALREADY satisfies the new test's
assertions — `os.Rename` does the right thing today. Per the fix-issue
skill's exemption for pure-documentation / guardrail tests on
already-correct behavior, no fabricated red commit was constructed; the
test stands as a pinning regression guard. The two commits are
therefore: (1) test addition, (2) doc comment.

## Scope

- `cmd/ingestor/stats_file.go` — doc comment only
- `cmd/ingestor/stats_file_test.go` — one new test function

No production behavior change. No public API change. No new
dependencies. No CI workflow changes. `O_NOFOLLOW` and the existing
tmp-side behavior are untouched.

## Preflight

All hard gates pass (PII, branch scope, red commit, CSS vars,
LIKE-on-JSON, sync/async migration, XSS sinks). No warnings.

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
2026-06-04 18:48:23 -07:00
Kpa-clawbotandKpa-clawbot cd19285f7f fix(ingestor): defense-in-depth empty-scope guard in UpdateNodeDefaultScope (#1534) (#1575)
## Summary

Follow-up to PR #1569 (merged). Adds defense-in-depth at the DB layer
for the #1534 default_scope-overwrite class of bug.

PR #1569 fixed #1534 by guarding the call site in `handleMessage` with
`if shouldUpdateDefaultScope(pktData)`. Adversarial review of #1569
flagged this as one-layer defense: a future refactor that drops the
call-site `if` and calls `store.UpdateNodeDefaultScope(pubkey,
pktData.ScopeName)` unconditionally would silently re-introduce the bug
— overwriting a previously-correct `default_scope` (e.g. `#belgium`)
with the empty string.

This PR adds the belt-and-braces guard recommended by that review:

- `Store.UpdateNodeDefaultScope(pk, "")` is now a silent no-op (early
`return nil`)
- New DB-layer regression test that fails on `master` and proves the DB
function used to write `""` straight through
- Two new call-site anchor tests that drive a transport-scoped ADVERT
end-to-end through `handleMessage` (matched + unmatched region key) so
the existing call-site guard from #1569 can't be deleted without a test
going red

Net production change: 8 lines in `cmd/ingestor/db.go`. No behavior
change for any non-empty scope.

## Why this is a follow-up, not a re-fix

Issue #1534 is already closed by #1569 and `master` no longer regresses
for users (the call-site guard is in place). This PR is purely
belt-and-braces — it adds the second layer of defense the adversarial
reviewer asked for and the test coverage that anchors both layers.

## Files changed

| File | Change |
|------|--------|
| `cmd/ingestor/db.go` | +8 — empty-scope early return in
`UpdateNodeDefaultScope` |
| `cmd/ingestor/db_test.go` | +43 —
`TestUpdateNodeDefaultScope_EmptyScopeIsNoop` |
| `cmd/ingestor/main_test.go` | +97 —
`TestHandleMessageAdvert_EmptyScopeSkipsDefaultScopeUpdate` +
`TestHandleMessageAdvert_MatchedScopeUpdatesDefaultScope` |

## Red → green commits

- **red** `c062af59` — `test(ingestor): red — DB-layer empty-scope guard
regression test for #1534`
- Adds three tests; `TestUpdateNodeDefaultScope_EmptyScopeIsNoop` fails
on assertion (`default_scope` overwritten with `""`)
- Two call-site tests pass already (call-site guard merged in #1569) —
they anchor that behavior against future refactors
- **green** `7ab12d53` — `fix(ingestor): defense-in-depth empty-scope
guard in UpdateNodeDefaultScope (#1534)`
  - Adds the early-return; all three tests green

## Operator remediation (from issue #1534)

Operators whose production DB still has rows where `default_scope` was
overwritten with the empty string before #1569 deployed can clean up
with:

```sql
-- Inspect affected rows first
SELECT public_key, name, default_scope
FROM nodes
WHERE default_scope = '';

SELECT public_key, name, default_scope
FROM inactive_nodes
WHERE default_scope = '';

-- Convert empty-string default_scope back to NULL so the next valid
-- matched-scope advert can re-populate it cleanly.
UPDATE nodes
SET default_scope = NULL
WHERE default_scope = '';

UPDATE inactive_nodes
SET default_scope = NULL
WHERE default_scope = '';
```

After #1569 + this PR are deployed, no new rows can be created with
`default_scope = ''` from this code path.

## Test plan

```bash
cd cmd/ingestor && go test ./... -count=1
# ok  github.com/corescope/ingestor  ~98s
```

## Preflight

Clean — PII, branch scope, red commit, CSS-var defined, CSS
self-fallback, LIKE-on-JSON, sync migration, async-migration gate, XSS
sinks all pass. No warnings.

---------

Co-authored-by: Kpa-clawbot <bot@meshcore-analyzer>
2026-06-04 10:35:46 -07:00
Kpa-clawbotandcorescope-bot 05af6c6ee5 fix(ingestor): skip default_scope update when ScopeName is empty (#1534) (#1569)
Red commit: e5668585da

Fixes #1534

## Problem
`cmd/ingestor/main.go:720` called `UpdateNodeDefaultScope` whenever a
packet was transport-scoped (`IsTransportScoped == true`), without
checking whether `matchScope()` actually returned a region match.
Transport-scoped adverts from non-matching regions carry `ScopeName=""`,
which then overwrote previously-correct `nodes.default_scope` values
with the empty string — surfacing as "unknown scope" / "--" in the node
sidebar.

## Fix
Extracted the guard into `shouldUpdateDefaultScope(pktData)` and added
the non-empty `ScopeName` check:

```go
return pktData.IsTransportScoped && pktData.ScopeName != ""
```

## TDD
- Red commit (`e5668585`): adds
`TestBuildPacketDataScopeMatchingNoMatch` + helper that mirrors the
buggy guard. CI must fail on assertion.
- Green commit (`aab7f5d7`): adds the `ScopeName != ""` check. Test
passes.

## Out of scope (deferred)
- The optional one-time backfill / migration marker removal described in
the issue — new matching adverts will self-correct existing rows.
- Refactor of `IsTransportScoped` + `ScopeName` into a typed wrapper.

## Files
- `cmd/ingestor/main.go` — guard + new helper
- `cmd/ingestor/main_test.go` — regression test

## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— clean.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-06-04 10:06:13 -07:00
3feb97f16f fix(ingestor): write resolved_path on new observations (regression from #1289) (#1548)
# fix(ingestor): write resolved_path on new observations (full restore —
closes #1547 + #1560)

Fixes #1547. Closes #1560.

## Root cause
PR #1289 (the "ingestor owns the neighbor graph; server is read-only"
refactor, ~2026-05-21) moved the neighbor graph + schema writes to the
ingestor, and as a side-effect removed the server-side writer that
populated `observations.resolved_path` AND the context-aware
`pm.resolveWithContext` that disambiguated 1-byte prefix collisions.
Result: every observation inserted after the deploy has `resolved_path =
NULL` (3.1M/6.3M NULL on staging; 100% NULL on fresh deploys; symptom on
Cascadia: hops fail to resolve because the small-mesh client-side
fallback breaks on prefix collisions).

## Full restore
This PR resolves both single-byte and multi-byte prefix paths.
Single-byte disambiguation uses NeighborGraph adjacency and ADVERT
`from_pubkey` anchoring, ported from pre-#1289 `pm.resolveWithContext`
logic (last good at cmd/server/store.go @ commit 450236d5) and the #1144
/ #1352 fixes.

New file `cmd/ingestor/path_resolver.go`:
- `NeighborGraph` + `neighborGraphHolder` — in-memory adjacency
snapshot, atomic-published.
- `loadNeighborGraph(db)` — one-shot SELECT from `neighbor_edges`.
- `resolveHopWithContext(hop, anchor, graph, idx, exclude) *string` —
single-hop, tier-1 disambiguator.
- `resolvePathWithContext(hops, fromPubkey, graph, idx) []*string` —
walks the path, anchoring hop 0 on `from_pubkey` (ADVERTs) and each
subsequent hop on the previous resolved hop, excluding already-resolved
pubkeys.
- `Store.RefreshNeighborGraph()` — called on warm-up and every 60s tick
in the neighbor-edges builder alongside `RefreshPrefixIndex`.

Existing file `cmd/ingestor/resolved_path.go` (PR #1547 base) is
untouched: `resolvePath` + `marshalResolvedPath` + the all-nil →
empty-string clobber-guard contract are preserved verbatim.

`cmd/ingestor/db.go` — `InsertTransmission` now calls
`resolvePathWithContext` instead of the naive `resolvePath`.

## Algorithm (per hop)
1. Look up candidate pubkeys by prefix-match (existing `prefixIndex`).
2. `len==0 → nil`; `len==1 → that pubkey`.
3. `len>1` → filter by `NeighborGraph` adjacency to the anchor. Anchor
is `from_pubkey` for hop 0 on ADVERTs, the previous resolved hop
otherwise. Exactly 1 surviving candidate → use it; else nil.
4. Previously resolved hops (and the originator) are excluded from
downstream candidate pools — a packet does not revisit a node.

Tier-2/3/4 from pre-#1289 (geo proximity, GPS preference,
observation-count fallback) are intentionally NOT ported — those were
noisy in practice and belong in a separate enhancement, not in this
regression restore.

## Out of scope
- The ~3.1M existing NULL rows from the regression window. Filed as a
follow-up backfill task — too risky to bundle here (touches a 6M-row
table).
- The dead-flag bug #1546 — separate concern.

## TDD red → green
- Red commit `80b0f476` — adds five new context-resolver tests; stub
`resolvePathWithContext` falls back to naive `resolvePath`. CI run
26946935615 → **failure** with assertion errors on the three collision
tests (`TestResolveHopWithContext_OneByteCollision_AdjacencyResolves`,
`TestResolvePathWithContext_TwoHopChainAnchoredOnFromNode`,
`TestResolvePathWithContext_AdvertAnchoring`); the two regression tests
(multi-byte still works + all-nil contract) stayed green.
- Green commit `7b4950ce` — real algorithm + InsertTransmission wiring +
RefreshNeighborGraph in the builder tick. All five new tests pass;
original four `resolved_path` tests stay green.

## Verification
- `go test -race ./cmd/ingestor/...` for the 11 affected tests — pass.
- `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
origin/master` — exit 0 (all gates clean).
- PII grep on body + diff: clean.

Tested with: existing `TestInsertTransmissionWritesResolvedPath` +
`TestInsertTransmissionDoesNotClobberResolvedPathOnAllNil` (PR #1547
base) plus the new collision-resolution suite:
- `TestResolveHopWithContext_OneByteCollision_AdjacencyResolves` —
3-of-5 nodes share `0x5c`, chain A↔B↔C↔D↔E; anchored on A, hop `5c` → B.
- `TestResolvePathWithContext_TwoHopChainAnchoredOnFromNode` — path
`[5c, 5c]` from_node A → `[B, C]`.
- `TestResolveHopWithContext_NoAdjacencyContext_ReturnsNil` — 3
ambiguous candidates, no anchor / non-adjacent anchor → nil.
- `TestResolvePathWithContext_AdvertAnchoring` — ADVERT,
`from_pubkey=A`, path `[5c]` → only-adjacent neighbor B.
- `TestResolvePathWithContext_RegressionMultiByteStillWorks` —
unique-prefix path with no graph context still resolves.
- `TestResolvePathWithContext_AllNilContractPreserved` — unresolvable
path → `marshalResolvedPath==""` (clobber-guard from PR #1548
untouched).

## Browser-validated
N/A — backend-only change. Frontend already handles populated
`resolved_path` via `getResolvedPath` in `cmd/server/db.go` and
`public/packets.js`.

## Round-1 fixes addressed
- **MUST-FIX #1 (data-loss clobber on all-nil resolution):** when every
hop fails to resolve, `marshalResolvedPath` returns `""` instead of
`"[null,null,...]"`, so `nilIfEmpty` → SQL NULL and the
`COALESCE(excluded.resolved_path, resolved_path)` UPSERT preserves any
previously stored good value on re-ingest. Regression test asserts:
insert a transmission, observe `resolved_path` populated, wipe the
prefix index, re-ingest the same packet, assert the existing
`resolved_path` is unchanged.

---------

Co-authored-by: corescope-bot <bot@corescope>
Co-authored-by: openclaw-bot <bot@openclaw>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-04 07:35:13 -07:00
Kpa-clawbotandclawbot 7b43045043 fix(security): sanitize 3 more log-injection sites missed by #1540 (#1544)
Follow-up to merged #1540. Self-review of #1540 found 3 additional
`log.Printf` sites interpolating MQTT-controlled strings without
`sanitizeLogString` — fixing here for completeness.

## Sites fixed

| File:line | Format | MQTT-controlled fields | Attacker scenario |
|---|---|---|---|
| `cmd/ingestor/main.go:531` | `status: %s (%s)` | `name`, `iata` |
Hostile node sends status with `name="evil\r\n[security] forged-line"` —
appears as a fake log line in operator dashboards / journalctl. |
| `cmd/ingestor/main.go:854` | `channel message: ch%s from %s` |
`channelIdx`, `sender` | Attacker spoofs `sender="evil\r\n[security]
backdoor-installed"` on any channel message — same forged-line outcome.
|
| `cmd/ingestor/main.go:940` | `direct message from %s` | `sender` | DM
injection via crafted sender field, same outcome. |

All three now route through `sanitizeLogString` from
`cmd/ingestor/sanitize_log.go` (added by #1540) which replaces
CR/LF/control bytes with `?`.

## TDD

Red commit (`8b3ad398`) adds 3 testable format helpers
(`formatStatusLog`, `formatChannelMessageLog`, `formatDirectMessageLog`)
plus tests pinning CR/LF stripping. Helpers return raw `fmt.Sprintf`
output, so tests fail on assertion (not build).

Green commit applies `sanitizeLogString` inside the helpers and swaps
the 3 call sites in `main.go` to use them.

Tests red-on-revert (verified locally).

## Scope

Strictly the 3 sites above. No other refactors. No changes to
`sanitizeLogString` itself.

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-03 15:01:51 -07:00
e438451dc9 feat(preflight): hard-fail gate on sync schema migrations + async runner (#1541)
Closes the recurring "sync migration on large table" regression class
(#791-style, #1483-style).

## Problem

Pattern that keeps repeating:

1. A perf/feature PR adds `CREATE INDEX` / `ALTER TABLE` / `UPDATE ...
WHERE` in a migration file (typically `cmd/ingestor/db.go`).
2. Local dev DB has ~100 rows. Migration returns in milliseconds. CI is
green.
3. Reviewers approve on plan correctness; nobody knows what the prod
table size is.
4. First prod boot at scale (Cascadia: ~2600 nodes, 80K+ obs; previous
prod: 1.9M+ obs) pins the ingestor at `[migration] Adding index...` for
minutes.
5. Healthcheck times out → container restart → loop. Operator pages.
Hotfix.

Most recent case: `obs_observer_ts_idx_v1` in v3.8.3 — release notes
already document an "expect a longer first boot" warning because we knew
it would hit prod hard.

## What this PR adds

**Async helper (`cmd/ingestor/async_migration.go`):**
- `Store.RunAsyncMigration(ctx, name, fn)` — registers the migration as
`pending_async` in a new `_async_migrations` bookkeeping table, returns
to caller immediately, schedules `fn` in a goroutine on the shared
backfill `WaitGroup`, transitions to `done` (or `failed` with error
captured) on completion.
- `Store.AsyncMigrationStatus(name)` and
`Store.WaitForAsyncMigrations()` for tests/shutdown.
- Idempotent: `done` rows short-circuit; `pending_async`/`failed` rows
are retried on next boot.

**Retroactive #1483 conversion (`cmd/ingestor/db.go`):**
- `obs_observer_ts_idx_v1` (the composite `(observer_idx, timestamp)`
index build on `observations`) is now scheduled via `RunAsyncMigration`
from `OpenStore()` so the ingestor accepts packets immediately while the
index builds in the background.
- Legacy `_migrations` gate is preserved by the async fn → DBs that
already completed the sync build stay no-op.

**Annotation convention (`MIGRATIONS.md`):**
Every new `CREATE INDEX` / `ALTER TABLE` / data-rewrite in a migration
file must do ONE of:
1. Run via `Store.RunAsyncMigration(...)` (preferred for backfills).
2. Carry a `// PREFLIGHT: async=true reason="..."` comment directly
above the migration block.
3. Include a `PREFLIGHT-MIGRATION-SCALE: <30s N=<scale>` line in the PR
body.

**TDD pair:**
- Red commit `2c6744cc` — `TestRunAsyncMigration_PendingThenDone`
against a stub helper. Build passes, assertion fails (`async migration
fn did not start within 2s`).
- Green commit `38354f32` — real helper + retroactive fix + docs. Test
green.

**Fixtures (`cmd/ingestor/testdata/preflight-migrations/`):**
- `bad_sync_migration.go` — known-bad sample with no annotation.
- `good_annotated_migration.go` — known-good sample with annotation.
The preflight gate script can be unit-tested against these.

## Gate location (NOT in this PR)

The actual `check-async-migrations.sh` lives in the OpenClaw skills
directory at `~/.openclaw/skills/pr-preflight/scripts/` (separate from
the repo) and is wired into `run-all.sh`. It greps the diff for
new/modified migration blocks and hard-fails (exit 1) on any sync schema
mutation lacking one of the three opt-outs above. The fixtures in this
PR give maintainers a reproducible target.

## Why annotation-discipline, not size detection

You cannot determine table size from a diff. The gate enforces that
every author who adds a schema migration must consciously decide which
bucket it falls into and write that down. That is the cheapest possible
intervention that breaks the cycle.

## Testing

- `go test ./...` in `cmd/ingestor` — all tests pass including the new
`TestRunAsyncMigration_PendingThenDone`.
- Manual: red commit fails on assertion (not build), green commit passes
— verifiable by `git checkout 2c6744cc --
cmd/ingestor/async_migration.go && go test -run TestRunAsync
./cmd/ingestor` from the green commit.

## Preflight overrides

None — clean run after the convention is applied.

---------

Co-authored-by: clawbot <bot@openclaw.local>
Co-authored-by: clawbot <bot@openclaw>
2026-06-03 21:03:59 +00:00
Kpa-clawbotandclawbot 800d61c382 fix(security): uniform limit-clamp, log-injection sanitization, SPA path validation (#1540)
Follow-up to v3.8.3 security train. Found by non-XSS input-validation
audit.

Three findings closed in one PR — all defense-in-depth: medium is
genuinely DoS-only (no data exposure), lows tighten log hygiene and SPA
path handling so future router changes can't silently expose the
filesystem.

## Findings addressed

### MEDIUM — unbounded `limit` on list endpoints
- **What:** four list endpoints accepted `limit=999999999` and passed
the value straight to SQL `LIMIT ?` and Go `make(..., 0, limit)`.
- **Where:** `cmd/server/routes.go` — handlePackets (incl. multi-node
branch), handleNodes, handleChannelMessages, handleAnalyticsSubpaths,
handleAnalyticsSubpathsBulk per-group lim, handleDroppedPackets.
- **Fix:** new `clampLimit(raw, def, max)` helper in
`cmd/server/clamp_limit.go` plus `queryLimit(r, def, max)` HTTP wrapper.
Caps: packets/nodes/channels/dropped = 500, analytics buckets /
bulk-health = 200. Already-clamped endpoints (handleBulkHealth) migrated
to the helper for uniformity. Silent clamp — no response-shape change.
Negative / zero / non-numeric → default.

### LOW — log injection via newline in advert name
- **What:** advert `name` field allows `\n` / `\t` (sanitizeName
intentionally preserves them for display). Logged at two MQTT-ingest
sites, an attacker with publish ACL could forge log lines.
- **Where:** `cmd/ingestor/main.go:659,690`.
- **Fix:** new `sanitizeLogString` in `cmd/ingestor/sanitize_log.go`
strips control bytes < 0x20 and DEL with `?`. Wrapped at the two log
call sites that interpolate `name=` and `observer=`. Stored display
values untouched.

### LOW — SPA static handler depends on default mux path-cleaning
- **What:** `cmd/server/main.go:469` joins `r.URL.Path` to root; safe
today only because gorilla/mux runs `path.Clean` and `http.FileServer`
rejects `..`. A future `SkipClean(true)` or router swap would silently
expose the filesystem.
- **Where:** `cmd/server/main.go` (spaHandler).
- **Fix:** new `isSafeStaticPath` rejects requests whose decoded or raw
path contains `..`, `%2e%2e`, `\\`, or `%5c` with a 400. Legit asset
names with dots (`/app.js`, `/customize-v2.js`, `/themes/dark.css`) are
unaffected.

## TDD

- Commit 1 (red): adds `TestClampLimit`, `TestSpaHandlerPathTraversal`,
`TestSanitizeLogString` with stub helpers — tests fail on assertions
(not build errors), proving they gate the change.
- Commit 2 (green): production fix. Revert the green commit and the red
commit's assertions fail.

## Audit reference

Source: non-XSS input-validation audit dated 2026-06-03 (workspace).
Sibling PR `fix/xss-r2-trace-obs-anl` owns the XSS findings — not
included here.

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-03 13:58:04 -07:00
13bdee57d4 perf: P0 hot-path fixes (observers, neighbor-graph, observer-analytics) (#1481) (#1483)
## What

Three of the four P0s from #1481's scale-test findings. Each cuts a
distinct
hot path; together they target /api/observers,
/api/analytics/neighbor-graph,
and /api/observers/{id}/analytics — the top three live offenders.

### P0-1: 5-min atomic-pointer cache for default neighbor-graph response
- Live p95 10.8s on the most-trafficked organic endpoint.
- Background recomputer (5-min cadence per operator directive) builds
the
  default-filter (`minCount=5 minScore=0.1`, no region, no role)
  `NeighborGraphResponse` and stores it via `atomic.Pointer`.
- `handleNeighborGraph` short-circuits on the default shape; non-default
filters take the extracted `computeNeighborGraphResponse` path
(identical
  semantics to the previous inline build).

### P0-2: cache parsed `StoreObs.Timestamp` + drop RLock window
- `handleObserverAnalytics` re-parsed the RFC3339 timestamp three times
  per observation, for 60k+ observations per active observer, under
  `s.store.mu.RLock` — blocking writers for the full scan.
- `StoreObs.ParsedTime()` parses once via `sync.Once` (mirrors
  `StoreTx.ParsedDecoded`).
- Handler snapshots the `byObserver[id]` pointer slice, releases the
  RLock immediately, then iterates locally.

### P0-3: 30s cache for `/api/observers` + sargable `IN` + covering
index
- Three SQL queries on every request → ~1.7s p50 at 50-concurrent.
- Atomic-pointer 30s cache for the default (no-filter) query.
- `GetNodeLocationsByKeys` drops `LOWER(public_key) IN (...)`
(non-sargable);
  callers pre-lowercase in Go and the plain `IN` matches the existing
  `public_key` index.
- New ingestor migration `obs_observer_ts_idx_v1` adds composite index
  `idx_observations_observer_idx_timestamp(observer_idx, timestamp)` so
  `GetObserverPacketCounts` can resolve its GROUP-BY + range filter from
  the index without scanning the 1.9M-row observations table.

### P0-4: deferred
`perfMiddleware`'s global mutex was claimed to serialize every API
request.
A direct test (`50 concurrent requests through the middleware, handler
sleeps 20ms each`) shows total elapsed ≈ 25ms, not 1s — the lock is held
only for the post-handler bookkeeping (a few µs). Real impact is below
measurement noise. Skipping to avoid invasive churn on PerfStats
consumers
without a demonstrable win.

## Test plan

Red → green per P0:
- `observers_cache_test.go` — handler reads `s.observersCache` before
SQL,
  TTL boundary, atomic.Pointer (no mutex contention).
- `storeobs_parsedtime_test.go` — parses three timestamp shapes, caches
  result, no race under concurrent readers.
- `neighbor_graph_cache_test.go` — handler serves from atomic pointer
  when set, bypasses cache when `?region=` (or any non-default filter)
  is passed.

Full server + ingestor suites pass: `go test -count=1 ./...`.

## Perf proof

Before/after p50/p95/p99 (50 requests × 50 concurrent) against prod
(before)
and staging once CI deploys (after) will be posted as a PR comment per
the
operator's "no merge without proof of improvement" gate.

Closes #1481


## TDD exemption — P0-1 and P0-2 (net-new surfaces, AGENTS.md)

Per CoreScope `AGENTS.md` § "Exemptions": **net-new code surfaces with
no
prior tests to break** may land tests in the same PR without a strict
test-first → impl commit split.

- **P0-1 (neighbor-graph atomic-pointer cache)** — `neighborGraphCache`,
  `recomputeNeighborGraphCache`, `loadNeighborGraphCacheBytes`,
  `startNeighborGraphRecomputer` and the default-shape short-circuit in
  `handleNeighborGraph` were brand-new code with no pre-existing
  assertions covering them. There was no green test to first turn red.
- **P0-2 (cached `StoreObs.Timestamp` + RLock window drop)** —
  `StoreObs.ParsedTime()` and the snapshot+release pattern in
  `handleObserverAnalytics` were new surfaces; the prior code did the
  parse inline per call with no behavioural test to break.

P0-3 was authored properly red-then-green (commit `6e63ec6a` red, then
`83ae129b` green) and does NOT use this exemption.

## Default-filter detection vs frontend reality (#1483 follow-up)

The Neighbor Graph analytics tab in `public/analytics.js` fetches
`/analytics/neighbor-graph?min_count=1&min_score=0` because the
client-side sliders need the full edge set to filter from. That shape
did NOT match the `(5, 0.1)` cached default, so the UI tab still paid
the cold compute cost despite #1481 P0-1.

The #1483 follow-up commit caches BOTH shapes in the same recomputer
pass:
- `(minCount=5, minScore=0.1, no region, no role)` — `live.js`
  affinity-scoring consumer.
- `(minCount=1, minScore=0, no region, no role)` — analytics tab.

Both are served from `atomic.Pointer` with an `X-Cache-Age-Seconds`
header. The per-shape cost in the background goroutine is roughly
linear in edge count; total recompute time stays well under the
5-minute cadence on prod-scale graphs.

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
Co-authored-by: mc-bot <mc-bot@users.noreply.github.com>
2026-05-29 02:42:21 -07:00
Kpa-clawbotandopenclaw 43b93c6bb9 feat(observers): surface naive-clock observers as ⚠️ chip + detail banner (#1478) (#1480)
## Summary

Issue #1478 — surface observers whose envelope timestamps are being
clamped because they're emitting zone-less local-time strings (UTC-N
observers showed up perpetually as "Stale" before #1466, and per-packet
rxTime is still clamped to ingest time for them, muddying
propagation-delay analytics).

Now the UI tells operators which observers are misconfigured + how to
fix it.

## What changed

### Ingestor (cmd/ingestor)
- New `observers_clock_naive_v1` migration adds three columns to
`observers`:
- `clock_skew_seconds INTEGER` (signed: negative = behind UTC, positive
= ahead)
  - `clock_skew_count_24h INTEGER` (rolling 24h event count)
  - `clock_last_naive_at TEXT` (RFC3339 timestamp of last clamp)
- `resolveRxTime` now returns `(rxTime, naiveSkewSec)`. The
packet-handler call site invokes `store.RecordNaiveSkew(observerID,
deltaSec)` whenever a naive envelope is clamped (the existing >15 min
naive-tolerance path). The counter resets to 1 if no event in the prior
24h, else increments. Single INSERT-or-UPDATE round trip per clamp.

### Server (cmd/server)
- `Observer` struct + `GetObservers` / `GetObserverByID` extended to
scan the three new columns.
- `ObserverResp` gains four JSON fields exposed by `/api/observers` and
`/api/observers/{id}`:
- `clock_naive` (bool, derived from `clock_last_naive_at` being within
24h)
  - `clock_skew_seconds`, `clock_skew_count_24h`, `clock_last_naive_at`
- Decay is **read-side**: a stale event yields `clock_naive=false` with
zero counts. No background sweep, no writes from the read-only server,
no race with the ingestor.

### Frontend (public)
- `window.ObserversNaiveChip.render(o)` — total render helper, returns
⚠️ chip HTML when `o.clock_naive===true`, `""` otherwise. Used inline in
the observers-list `name` cell and in the row-detail slide-over. Tooltip
explains magnitude + direction + count + fix.
- `window.ObserverDetailNaiveBanner.render(obs)` — yellow alert banner
at the top of the observer-detail page with the skew magnitude,
last-event timestamp, and the actionable fix ("Set host clock to UTC, OR
emit Z-suffixed/offset-aware timestamps from the observer script").

## TDD trail
- `5ddd5b42` red: backend `cmd/server/observer_naive_clock_1478_test.go`
(3 tests asserting JSON fields + 24h decay) + frontend
`test-observer-naive-clock-1478.js` (8 jsdom-style tests asserting
helpers exist and render correctly). Both failed on master with
field-missing / export-missing assertions.
- `4ecc79c8` green backend: schema + Observer / GetObservers /
ObserverResp / handler decay.
- `2137ab81` green frontend: chip + banner helpers and call sites.

## Tests
- `cd cmd/server && go test ./...` → all green (full suite, 46s)
- `cd cmd/ingestor && go test ./...` → all green (full suite, 98s)
- `node test-observer-naive-clock-1478.js` → 8/8 pass
- `node test-frontend-helpers.js` → unchanged from master (pre-existing
failures only)

## Acceptance (issue #1478)
- ✅ Observer running with `python datetime.now().isoformat()` (naive,
off by N hours) → `clock_naive=true` after the next clamp → UI shows ⚠️
chip + banner.
- ✅ Observer with `datetime.now(timezone.utc).isoformat()` (Z-suffixed)
→ never clamped → never flagged.
- ✅ Observer that fixed its clock → `clock_naive` returns to `false` 24h
after the last clamp event (read-side decay).

Closes #1478.

---------

Co-authored-by: openclaw <bot@openclaw.local>
2026-05-29 01:08:12 -07:00
Kpa-clawbotandopenclaw-bot 0a58aa146a fix(ingestor): silence per-message naive-timestamp log (#1478 followup) (#1479)
Operator on prod reports the per-message naive-timestamp warning drowns
the log when an observer's local clock isn't UTC.

Since observer.last_seen already uses ingest time regardless of envelope
(#1466), and per-packet rxTime is already clamped (#1464), the
per-message console log adds nothing actionable.

This PR silences the log. #1478 tracks the proper followup: surface
broken observers in the UI (chip + banner on observer detail).

Backend-only, hot-deployable via image pull (no API/schema change).

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-29 06:27:58 +00:00
efitenandClaude Sonnet 4.6 196f1c6720 fix(ingestor): don't stamp timestamp in procIO snapshot on os.Open failure (#1428)
## Summary

- `readProcSelfIO()` stamped `at=time.Now()` before attempting to open
`/proc/self/io`
- On non-Linux hosts or when the kernel file is unavailable, it returned
a snapshot with `ok=false` but a fresh timestamp
- The rate calculator used `prevIO.at` for delta computation, so the
next successful read produced a phantom rate spike spanning the entire
failure interval
- Fix: move the timestamp stamp to after successful `os.Open`, so failed
opens return a zero-value snapshot with no timestamp — `procIORate`
short-circuits on `prev.ok=false` and returns nil

## Test plan

- [ ] `go test ./...` in `cmd/ingestor` — both new unit tests pass:
- `TestProcIORate_ZeroValuePrevSuppressesRate` — asserts nil rate when
prev is zero-value
- `TestProcIORate_NormalPath` — asserts correct rate for valid prev/cur
pair
- [ ] On Linux: confirm `procIO` block still appears in the stats file
after 2 ticks

Closes #1169

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

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:50:23 -07:00
Kpa-clawbotandyou 451b5e8848 fix: add default Public channel key to rainbow table (#897)
## Problem
The MeshCore default `Public` channel uses the well-known PSK
`8b3387e9c5cdea6ac9e5edbaa115cd72` (channel-hash byte `0x11`) per the
[companion protocol
spec](https://github.com/ripplebiz/MeshCore/blob/main/docs/companion_protocol.md#default-public-channel).

This key is **missing from `channel-rainbow.json`** in the repo. As a
result, the ingestor sees GRP_TXT messages on the default Public channel
(the most common channel on the mesh), can't find a key for hash `0x11`
(the only entry that hashes to 0x11 in the current rainbow is `#bogota`,
which obviously isn't the right key), and reports `decryption_failed`.
Fresh deploys see almost no decrypted public traffic.

## Fix
Add the well-known Public channel key to the rainbow as `"Public":
"8b3387e9c5cdea6ac9e5edbaa115cd72"`.

## Verification
```
python3 -c "import hashlib; print(hex(hashlib.sha256(bytes.fromhex('8b3387e9c5cdea6ac9e5edbaa115cd72')).digest()[0]))"
# 0x11
```

Matches the channel-hash byte we observe on incoming Public channel
GRP_TXT packets.

## Discovered via
Fresh MikroTik container deploy with no local channel additions — every
Public message showed up as `decryption_failed` while `#LongFast` etc
decrypted fine.

---------

Co-authored-by: you <you@example.com>
2026-05-28 22:50:20 -07:00
efitenandClaude Sonnet 4.6 52f131e2dc fix(ingestor): add hourly WAL checkpoint to prevent unbounded WAL growth (#1435)
Fixes #1434.

## Problem

The ingestor's `Checkpoint()` (`PRAGMA wal_checkpoint(TRUNCATE)`) was
only called on shutdown. SQLite's built-in auto-checkpoint runs in
PASSIVE mode which cannot truncate the WAL while the server holds an
active read connection. Result: the WAL grows at ~40–50 MB/hour and is
never reset during a running instance.

Observed on analyzer.on8ar.eu: **183.4 MB WAL** after ~4h uptime.

## Changes

**`cmd/ingestor/main.go`**
- Add a periodic goroutine that calls `Checkpoint()` every hour,
staggered 30s after startup
- Hoist `walCheckpointTicker` to function scope so it is stopped cleanly
at shutdown alongside all other tickers

**`cmd/ingestor/db.go`**
- Switch `Checkpoint()` from `Exec` to `QueryRow(...).Scan` to capture
SQLite's 3-column result (`busy`, `log`, `checkpointed`)
- Return the checkpointed frame count (callers that discard it are
unaffected)
- Log only when `walFrames > 0` — silent when WAL is already empty,
avoiding log spam
- Log `blocked=true/false` instead of raw `busy` integer to make it
clear when the server's read lock is preventing full truncation

## Behaviour after fix

Each hourly tick flushes all WAL frames not held by an active server
reader. Worst-case WAL size is now bounded to roughly one hour of write
traffic (~45 MB) instead of unbounded growth. If the server holds a read
lock at checkpoint time, the log shows `blocked=true` and remaining
frames are retried on the next tick.

## Test plan

- [x] `go build ./...` (ingestor module)
- [x] `go test ./...` passes
- [x] Code review addressed (ticker stop on shutdown, log message
clarity)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:01:54 -07:00
Eric Muehlstein 29432d4fe0 feat(ingestor): document and test ws:// / wss:// WebSocket MQTT broker support (#902)
## Summary

CoreScope's ingestor already supports WebSocket MQTT connections today —
`paho.mqtt.golang` v1.5.0 handles `ws://` and `wss://` natively via
gorilla/websocket. However this support was **undocumented, untested,
and had a TLS gap** for `wss://` connections.

This PR closes those gaps without any breaking changes.

## Changes

### `cmd/ingestor/config.go`
- Added godoc comment to `ResolvedSources()` explaining all four
supported schemes and which ones require translation vs. pass-through
- `ws://` and `wss://` explicitly documented as native paho schemes
requiring no mapping

### `cmd/ingestor/main.go`
- Extended TLS config to cover `wss://` in addition to `ssl://`
- Before: `wss://` connections would use paho's default TLS (no explicit
`tls.Config` set), which works for valid certs but doesn't apply the
same predictable setup as `ssl://`
- After: both `ssl://` and `wss://` get `tls.Config{}` (system CA pool),
matching behavior; `rejectUnauthorized: false` still works for
self-signed certs on both schemes

### `cmd/ingestor/config_test.go`
Two new tests:
- `TestResolvedSourcesSchemeMapping`: validates all six scheme
variations (`mqtt://`, `mqtts://`, `tcp://`, `ssl://`, `ws://`,
`wss://`) including paths like `wss://host/mqtt`
- `TestLoadConfigWSSource`: full round-trip of a dual-source config (TCP
+ wss:// with username/password), verifies scheme unchanged through
`LoadConfig` and `ResolvedSources`

### `config.example.json`
- Added `wsmqtt` example entry showing `wss://` with username/password
- Updated `_comment_mqttSources` to enumerate all supported schemes:
`mqtt://`, `mqtts://`, `ws://`, `wss://`

## Motivation

We run
[meshcore-mqtt-broker](https://github.com/andrewjfreyer/meshcore-mqtt-broker)
(a WebSocket MQTT bridge with JWT auth) alongside Mosquitto, and
subscribe to both via `mqttSources`. The dual-source config works in
production but nothing in the docs or example config made this
discoverable for other operators.

## Testing

```
cd cmd/ingestor && go test ./...
ok    github.com/corescope/ingestor  1.568s
```

All existing tests pass. Two new tests added.

## No breaking changes

- Existing configs: no change in behavior
- `ws://` / `wss://` configs that were already working: same behavior +
explicit TLS setup for `wss://`
2026-05-28 14:58:52 -07:00
Kpa-clawbotandopenclaw-bot 2627bd053b fix(#1465): observer.last_seen always uses ingest time, not envelope (#1466)
## Summary

`observer.last_seen` (and `last_packet_at`) answer "when did the
analyzer last hear from this observer" — fundamentally an ingest-time
question. Previously both the status-message handler and the
packet-message handler passed the MQTT envelope timestamp into
`UpsertObserverAt` / `stmtUpdateObserverLastSeen`, which let buggy
observer clocks drag `last_seen` hours into the past even when the
timestamp parsed cleanly as RFC3339 (so #1464's naive-clamp didn't catch
it).

California observers on `analyzer.00id.net` consistently appeared 3-7h
stale for this reason.

## Fix

- `cmd/ingestor/main.go` status handler: pass `""` to `UpsertObserverAt`
so it falls back to `time.Now()`.
- `cmd/ingestor/main.go` packet-path observer upsert: same.
- `cmd/ingestor/db.go` `InsertTransmission`'s
`stmtUpdateObserverLastSeen.Exec` call: use `ingestNow` for both
`last_seen` and `last_packet_at` (was `rxTime`).

Per-packet rxTime semantics (`transmissions.first_seen`,
`observations.timestamp`) are unchanged — those continue to use envelope
time with the naive-clamp / 14h-future / 30d-past guards from #1463 /
#1464. Per-hop SNR-vs-time analysis still works.

## TDD

- Red: `test(#1465): observer.last_seen uses ingest time even with
well-formed envelope (red)`
- 3 new tests in `observer_lastseen_1465_test.go`: status-past,
status-future, packet-path-past.
- Status-past and packet-path-past assertions failed on master (envelope
time stored verbatim).
- Green: `fix(#1465): observer.last_seen always uses ingest time, not
envelope`
  - All 3 new tests pass.
- Pre-existing `TestInsertTransmissionUpdatesObserverLastSeen` and
`TestLastPacketAtUpdatedOnPacketOnly` were encoding the buggy behavior;
updated to assert ingest-time semantics.
  - Full `go test ./cmd/ingestor/...` green.

## Refs

- Refs #1463 (root-cause investigation)
- Refs #1464 (naive-clamp fix that handled malformed timestamps)
- Closes #1465

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-28 12:16:29 -07:00
Kpa-clawbotandopenclaw-bot 7106e1921e fix(#1463): clamp naive envelope timestamps symmetrically (#1464)
Red commit: fc6ed65f (CI fails on
`TestResolveRxTimeNaiveTimestampClamp`)
Green commit: 80bf1285

## Problem

California observers (UTC−7) had `last_seen` perpetually pinned ~7h
behind wall-clock and rendered "Stale" in the UI despite active MQTT
status traffic. Root cause: `parseEnvelopeTime` parses zone-less ISO
timestamps (python `datetime.now().isoformat()`) as UTC, leaving a
residual offset equal to the observer's UTC offset. The existing
soft-clamp at `resolveRxTime` only caught the future-skew (UTC+N) mirror
case.

## Fix — Option B (symmetric clamp)

- `parseEnvelopeTime` now returns a `(time.Time, naive bool, error)`
tuple so callers can tell zone-aware from zone-less parses.
- `resolveRxTime` applies a 15-minute symmetric tolerance window for
`naive==true` values: anything further off than 15 min collapses to
ingest time and emits a warning log.
- Well-behaved observers (Z-suffixed or explicit `±HH:MM` offset) are
completely untouched regardless of skew — legitimate buffered uploads
remain accurate to the second.

Chose option B over option A (reject naive outright) because some
observers may be sending naive *UTC* strings — those would suddenly lose
their own time. Symmetric clamp preserves the well-synced naive case (<
15 min off) and rescues every other zone.

## Tests

- New `TestResolveRxTimeNaiveTimestampClamp` covers naive past, naive
future, naive w/ microseconds, Z-suffixed past (verbatim),
offset-suffixed (canonicalized to UTC), naive within tolerance
(verbatim).
- `TestParseEnvelopeTime` updated for new signature, asserts `naive`
flag.
- All existing rxtime tests preserved (factory date, 30-day floor, 14h
future, plausible past).
- Red commit ran first, failed on assertions, then green commit makes
everything pass.

## Operator visibility

`naive timestamp "..." off by 7h, using ingest time` now appears in the
ingestor log so operators can identify upstream observer scripts that
should switch to `datetime.now(timezone.utc).isoformat()`.

Fixes #1463

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-28 09:00:12 -07:00
Kpa-clawbotandclaw f15d2efe81 fix(#1386): #1324 follow-up — test coverage + RWMutex + lock-hold-time + dead code + cadence (#1390)
# #1324 follow-up — test coverage + RWMutex + lock-hold-time + dead code
+ cadence

Addresses the post-merge audit findings in #1386 on PR #1324
(multi-byte capability persistence). Two independent audits (Kent
Beck test-quality + Carmack perf) surfaced one top-level
test-coverage gap and three perf concerns. This PR closes all of
them; cadence cleanup is included.

Red commit: `<RED_SHA>` (CI: `<RED_URL>`)

## What

1. **Tests** (`cmd/ingestor/multibyte_persist_test.go`):
   - `TestRunMultibyteCapPersist_RoundTrip` — end-to-end persist →
     close store → reopen → assert DB state survived.
   - `TestRunMultibyteCapPersist_MalformedSnapshot` — corrupt
     snapshot must log + no-op, not crash.
   - `TestRunMultibyteCapPersist_MissingSchemaColumns` — legacy DB
     without `multibyte_sup` cols must skip with explicit log, not
     panic / silently swallow.
   - `TestRunMultibyteCapPersist_PreservesConfirmedOnUnknown` —
     status=`unknown` MUST NOT clobber an existing `confirmed` row
     (mutation guard for the data-destruction check).
2. **`cmd/server/store.go`**
   - `cacheMu sync.Mutex` → `sync.RWMutex`. The per-node
     `GetMultibyteCapFor` read path in `/api/nodes` (`routes.go:1215`)
     uses `RLock` now; no longer serializes against itself or
     against analytics readers.
   - Build the multi-byte index map OUTSIDE `cacheMu`, then swap the
     pointer inside. Removes a 2400-iteration allocation hold from
     the analytics-cycle critical section.
   - Drop the dead `GetMultiByteCapMap` (zero callers confirmed by
     `rg`) and the stale `multibyteStatusToInt` tombstone comment.
3. **`cmd/ingestor/multibyte_persist.go`**
- Replace the per-entry pair of `UPDATE nodes` + `UPDATE inactive_nodes`
     (50% guaranteed-miss) with a single dispatch-by-table-membership
     `UPDATE` per entry. ~50% fewer prepared-stmt round-trips.
   - Explicit `MalformedSnapshot` log line distinct from cold-start.
   - Defensive schema-presence check via `PRAGMA table_info` once at
     start; logs `[multibyte-persist] schema missing` and returns
     clean stats on legacy DBs.
4. **`cmd/server/analytics_recomputer.go` / `config.example.json`** —
   bump default snapshot cadence from 15s to 1m (the snapshot is a
   derived cache the ingestor only reads every 5 min; 4× less disk
   churn, no observable freshness loss).

## Why

Direct quotes from the audit (#1386):

> *"No end-to-end persist→restart→load round-trip — the documented
> value prop of the PR ('survives restart') has no single test
> exercising the full path."* (Kent Beck)

> *"`cacheMu` is `sync.Mutex` not `sync.RWMutex` + per-node read in
> `handleNodes` — 2400 serialized lock acquisitions per `/api/nodes`
> call, contended against every analytics-cache reader/writer.
> The O(1) win is consumed by lock contention."* (Carmack #1)

> *"Map construction held under shared `cacheMu` — every 15s
> analytics cycle blocks every API cache read for the duration of a
> 2400-entry map build. Build outside the lock, swap pointer
> inside."* (Carmack #2)

> *"`UPDATE nodes` + `UPDATE inactive_nodes` per entry … 4800
> prepared-stmt round-trips, 2400 guaranteed-empty."* (Carmack #3)

> *"Server writes 20 snapshots for every one the ingestor reads.
> Cadence mismatch — server could publish every 1 min and lose
> nothing."* (Carmack §2)

## TDD

Red commit adds the four tests above. Two of the four
(`MalformedSnapshot`, `MissingSchemaColumns`) fail on assertions
against the pre-fix `multibyte_persist.go`; the other two
(`RoundTrip`, `PreservesConfirmedOnUnknown`) are regression coverage
of behaviour the original implementation already honoured but never
exercised — they exist to guard future mutation (the audit's
mutation-suggestion lens). Green commit lands the implementation.

## Bench

`go test -bench BenchmarkGetMultibyteCapFor -benchmem -count=10`
(local, idle laptop, n=2400-entry index, 8 reader goroutines vs. one
analytics writer):

| variant            | ns/op | allocs/op |
|--------------------|------:|----------:|
| `sync.Mutex` (pre) | n/a — see note | — |
| `sync.RWMutex`     | n/a — see note | — |

Note: did not produce a concurrent benchmark in this PR (would
require non-trivial test scaffolding around the cache lifecycle).
The win is structural — `RLock` allows the ~2400 per-`/api/nodes`
reads to proceed in parallel rather than serializing on the same
mutex held by every analytics writer. Documenting honestly per
AGENTS.md "perf claims require proof": full microbench deferred to
a follow-up.

## Manual verification (staging)

- New tests: `go test ./... -count=1 -timeout 300s` in `cmd/ingestor`
  and `cmd/server` — green.
- All multibyte-area tests (`#1366`, `#1368`, `#1372` regression
  suites in `multibyte_capability_test.go`, `multibyte_enrich_test.go`,
  `multibyte_region_filter_test.go`): green.
- Preflight: `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
  origin/master` — exit 0.

Fixes #1386

---------

Co-authored-by: claw <claw@openclaw.local>
2026-05-25 23:29:35 -07:00
Joel Claw 95d7916530 fix(channels): normalize known channel display names (public → Public) (#777)
Normalizes well-known channel display names (currently only `public` → `Public`) so existing deployments with pre-#761 lowercase config keys show the canonical firmware-default name `Public` in the UI.

Behavior:
- `knownChannelCasing` lookup (`decoder.go`) — single-entry map, easy to extend.
- `normalizeChannelName()` applied at config load (`loadChannelKeys`) AND at decode time (defense in depth).
- One-shot SQLite migration `channel_hash_casing_v1` backfills `channel_hash='public'` → `'Public'` on `payload_type=5` rows so channel-grouping queries don't split across the upgrade boundary.
- Hardcoded list intentionally tiny (1 entry); custom/user channels left untouched.

Safety:
- Channel-hash derivation (`SHA256(channelName)[:16]` for `#`-prefixed `HashChannels`) is unchanged — normalization only renames map keys for explicit `ChannelKeys` entries (which don't feed `deriveHashtagChannelKey`).
- PSK lookup is by hash byte, not by name — mesh interop preserved.
- Migration is gated by `_migrations.name='channel_hash_casing_v1'`, idempotent.

Tests (`cmd/ingestor/normalize_channel_test.go`):
- `TestNormalizeChannelName` covers known + hashtag + custom + empty.
- `TestLoadChannelKeys_NormalizesKnownDisplayNames` — verifies `public` → `Public` at load.
- `TestLoadChannelKeys_LeavesCustomNamesUntouched` — custom names not auto-capitalized.
- `TestLoadChannelKeys_DuplicateCasingLogsWarning` — config containing both casings resolves deterministically (canonical wins).

Mutation test confirmed: reverting load-time normalize → `TestLoadChannelKeys_NormalizesKnownDisplayNames` and `_DuplicateCasingLogsWarning` both fail on assertions.

Related: #761
2026-05-25 23:05:07 -07:00
0b35c7eef3 feat(server): persist multi-byte capability across restart + O(1) per-key lookup (#903) (#1324)
## Summary

Follows the reconciliation recommendation in #916 — extracts only the
NET-NEW persistence layer from that PR (which is now superseded by #1002
for the overlay UI) into a focused 6-file change against current master.

**What this adds:**
- `multibyte_sup_v1` migration: `multibyte_sup INTEGER NOT NULL DEFAULT
0` + `multibyte_evidence TEXT` on `nodes`/`inactive_nodes` so capability
survives restart
- `hasMultibyteSupCols` schema detection gates the persist/load paths
- `loadMultibyteCapFromDB()`: pre-populates `mbCapSnapshot`/`mbCapIndex`
at startup — cold starts serve last-known capability without waiting for
the first ~15s analytics cycle
- `maybePersistMultibyteCapability()` + `persistMultibyteCapability()`:
after each analytics cycle; TryLock-gated (concurrent cycles coalesce);
skips `sup==0` entries (data-destruction guard)
- `GetMultibyteCapFor(pk)`: O(1) map lookup; both `handleNodes` and
node-detail call sites updated from the O(N)-alloc
`GetMultiByteCapMap()`

**What this explicitly does NOT change:**
- API field names (`multi_byte_status`, `multi_byte_evidence`,
`multi_byte_max_hash_size`)
- `EnrichNodeWithMultiByte` — unchanged
- `GetMultiByteCapMap` — still present for any external callers
- `public/map.js`, `public/live.css`, `Dockerfile`, `docs/` — zero
frontend churn

## Test plan

- [x] `TestMultibyteCapPersistRoundTrip` — confirmed values survive
persist → fresh-store load
- [x] `TestMultibyteCapPersistSkipsUnknown` — data-destruction guard:
`sup==0` entry does not overwrite DB-confirmed value
- [x] `TestMultibyteCapMaybePersistCoalesces` — TryLock coalesces 10
concurrent callers without deadlock
- [x] `TestMultibyteCapGetMultibyteCapForO1` — O(1) index returns
correct entry / false for unknown pubkey
- [x] `TestMultibyteCapLoadFromDB` — only `sup>0` rows loaded; `sup==0`
row excluded
- [x] `TestSchemaMultibyteSupColumns` — migration adds columns to both
tables; idempotent on second `OpenStore`
- [x] All existing `TestMultiByteCapability_*` tests pass unchanged
- [x] Full ingestor test suite: `ok` in 27s
- [x] `go build ./cmd/server/ && go build ./cmd/ingestor/` clean

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-25 22:35:35 -07:00
Kpa-clawbotandopenclaw-bot dc6c79cff8 fix(mqtt): watchdog forces paho reconnect on stall — recovers from half-open TCP (closes #1335) (#1336)
RED `f06887` — GREEN `8f53c1`. CI: (will populate on PR open)

`Fixes #1335`

## Problem
PR #1216 added per-source stall **detection** (`LivenessStalled`) but
only **logged**. Staging's `lincomatic` source has been silently losing
~14k pkts/hr behind a half-open TCP socket the Azure NAT abandons: paho
reports `IsConnected==true`, no messages arrive for 1h+, container
restart is the only known recovery. Prod (MikroTik networking) doesn't
see it.

## Fix
Make the watchdog actually recover.

- **`SourceLivenessState.ForceReconnectFn`** — per-source closure wired
in `main.go` next to `IsConnectedFn`, wraps `client.Disconnect(250) +
client.Connect()`.
- **`processLivenessTransition`** — on the `LivenessStalled` edge AND on
every heartbeat re-emit while still Stalled, invoke
`maybeForceReconnect`. `LivenessNeverReceived` (cold-start ACL deny /
wrong hash) is **deliberately not** force-reconnected — a new TCP socket
won't fix an ACL deny and would just churn the broker.
- **`maybeForceReconnect`** — throttled at `forceReconnectThrottle =
60s` per source so a stall→reconnect→re-stall loop self-recovers without
hammering the broker. The Disconnect+Connect runs in a goroutine so a
single slow source can't stall the watchdog tick.
- **`buildMQTTOpts`** — explicit `SetKeepAlive(30 * time.Second)`.
paho's default happens to be 30s, but the #1335 RCA called this out —
making it explicit so it can't drift and so operators reading the code
know it's intentional.
- **Telemetry** — `WATCHDOG forcing reconnect` (intent), `WATCHDOG
reconnect attempt issued` (post-goroutine), `WATCHDOG suppressing forced
reconnect` (throttle window).

## TDD
- **RED** `f06887` — `mqtt_watchdog_force_reconnect_test.go`. Stub field
+ constant added so the file compiles; assertions fail because
`processLivenessTransition` never invokes `ForceReconnectFn`. Reverting
just the `s.ForceReconnectFn()` call line from GREEN re-fails the same
assertion (mutation verified).
- **GREEN** `8f53c1` — wiring + throttle + keepalive.

## Scope discipline
Additive only. No regression to currently-flowing sources: `LivenessOK`,
`LivenessRecovered`, `LivenessDisconnected`, `LivenessHeartbeat`, and
`LivenessNeverReceived` transitions are unchanged. Throttle bound = ≤1
reconnect/min/source = ≤60/hr worst-case across all sources, well within
any broker rate limit.

Preflight: clean (all gates pass).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 22:31:56 -07:00
Kpa-clawbotandcorescope-bot 0f7cce3a5f fix(#1370): revert ingestor envelope-timestamp path — server ingest time for packet/observation storage (counters #1233) (#1372)
## Summary

Reverts the part of PR #1233 (commit `498fbc03`) that routed the MQTT
envelope's `timestamp` field into `PacketData.Timestamp` for
`transmissions.first_seen` and `observations.timestamp`. Packet
ordering is restored to server ingest time — the client clock is
untrusted.

`UpsertObserverAt` + `MAX(MIN(existing, ingestNow), rxTime)` for
observer/node `last_seen` (PR #1233's other half) is preserved
unchanged. `parseEnvelopeTime` / `resolveRxTime` helpers are
preserved — they still feed the observer.last_seen path.

## Diagnosis — Voodoo3 tx 304114 on staging

Staging `tx_id = 304114` in channel `#test` has 5 observations:

| # | observer  | reported timestamp | comment |
|---|-----------|--------------------|---------|
| 1 | Voodoo3 | 18:42 | broken client RTC — ingested first, locks
`first_seen` |
| 2 | Voodoo3   | 18:42  | broken client RTC |
| 3 | Voodoo3   | 18:42  | broken client RTC |
| 4 | Voodoo3   | 18:42  | broken client RTC |
| 5 | other obs | 01:42  | genuine receive time |

4 of 5 observations carry stale 18:42 timestamps from Voodoo3's own
broken clock. Because Voodoo3 ingested first, PR #1233's code wrote
`transmissions.first_seen = 18:42` (envelope value). Downstream
aggregators that compute `MAX(first_seen)` per channel saw 18:42 as
the latest activity, and `/api/channels` for `#test` displayed
`lastActivity` ~7h+ in the past plus a stale heartbeat in the row
preview — hiding the genuinely-newest message (Voodoo3's `tst hmdpt`
at 01:42).

## Why PR #1233's premise fails

PR #1233 assumed:
> Uploaders stamp `timestamp` when the radio receives the frame and
> freeze it; the MQTT message is published late, but the timestamp
> field is not re-stamped at publish. A buffered packet uploaded
> hours late still carries its true receive time.

That holds ONLY when the uploader's wall clock is correct. Observers
in the field (Voodoo3 here, surely others) have broken local clocks.
Their envelope timestamps are not a true receive time — they're a
broken-clock receive time, which is just garbage with extra steps.
The server clock is the only one we control, so packet ordering must
use it.

## Fix

### `cmd/ingestor/db.go`
- `BuildPacketData`: `PacketData.Timestamp =
time.Now().UTC().Format(time.RFC3339)`,
  NOT `msg.Timestamp`. Docstring updated to cite #1370 and explain
  why `msg.Timestamp` is no longer read here.

### `cmd/ingestor/main.go`
- Channel-companion path: `Timestamp: ingestNow` (was `rxTime`).
- DM-companion path: `Timestamp: ingestNow` (was `rxTime`).
- Local `rxTime := resolveRxTime(msg, tag)` removed from both paths
  (no remaining consumers in those scopes).

### Preserved (NOT touched)
- `resolveRxTime`, `parseEnvelopeTime` — still used by `handleMessage`
  to populate `mqttMsg.Timestamp` and to call `UpsertObserverAt`,
  which feeds `observer.last_seen` and `observer.last_packet_at`.
- All three `MAX(MIN(existing, ingestNow), rxTime)` guards (#1233
  observer.last_seen, observer.last_packet_at, node.last_seen).
- `MQTTPacketMessage.Timestamp` struct field.

## Tests

| File | Asserts |
|------|---------|
| `cmd/ingestor/ingest_time_regression_1370_test.go` (3 cases) |
Raw-packet, channel-companion, and DM-companion `handleMessage` paths.
Feed envelope `timestamp = T_now - 7h`; assert stored
`transmissions.first_seen` (RFC3339) and `observations.timestamp`
(epoch) are server wall clock (±5s). Each case fails on master under PR
#1233's premise. |

### Adjusted test
- `cmd/ingestor/db_test.go::TestBuildPacketData` — PR #1233 had asserted
  `pkt.Timestamp == "2026-05-16T10:00:00Z"` (the envelope value
  propagating). Now asserts the opposite: `pkt.Timestamp` is non-empty
  AND is NOT the envelope value. Comment cites #1370 and why the
  expectation flipped.

### Verified still-green
- `cmd/ingestor/rxtime_test.go` (`TestParseEnvelopeTime`,
  `TestResolveRxTime`) — helpers untouched, still cover envelope
  parsing for the observer.last_seen path.
- `cmd/server/channels_message_order_1366_test.go` (#1366).
- `cmd/server/db_channel_messages_perf_test.go` (#1368 perf budget).

## Commits

- `a9b7efc3` — RED: 3 `handleMessage` assertion-fail tests + test name
  collision check.
- `5a0891f0` — GREEN: revert envelope→PacketData.Timestamp plumbing in
  `cmd/ingestor/{db,main}.go` + flip `TestBuildPacketData`.

Fixes #1370

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
2026-05-25 19:56:49 -07:00
Kpa-clawbotandopenclaw-bot eeddf46bc9 fix(ingestor): neighbor-builder delta scan + watermark — recovers 97% packet loss from #1289 (fixes #1339) (#1341)
## Summary
PR #1289 moved neighbor-graph construction into the ingestor with a 60s
ticker. `buildAndPersistNeighborEdges` then issued an **unbounded**
`SELECT … FROM observations o JOIN transmissions t …` every tick. On
staging (3.7M observations) one tick took ~2 minutes; with
`max_open_conns=1`, the SQLite single-writer was held continuously and
MQTT ingest collapsed (~6,500 tx/day → ~180 tx/day, 97% loss).

## Fix
Watermark-bounded delta scan. Each call derives the watermark from
`MAX(neighbor_edges.last_seen)` and restricts the SELECT to `WHERE
o.timestamp > ? ORDER BY o.timestamp LIMIT 50000`. `neighbor_edges`
itself is the persistence — no new metadata table, no in-memory state,
restarts resume cleanly from whatever the table reflects.

- Empty edges table → watermark 0 → full warm-up scan (preserves #1289's
synchronous warm-up intent).
- Warm-up loops the builder until a call returns fewer than the batch
cap, so the first server snapshot load sees a fully-populated table even
on fresh DBs.
- 50k batch cap stops any single tick from monopolising the writer; a
backlog drains over successive ticks.
- Per-tick wallclock is logged (`tick: N edges in DUR`); a tick >5s is
logged loudly as a possible regression of #1339. Broader instrumentation
is tracked in #1340.
- Output schema unchanged — server's `neighbor_recomputer.go` is
unaffected.

## Trade-off
An anomalously-old observation that arrives after its timestamp has been
crossed by the watermark will be skipped. Acceptable for an approximate
neighbor graph; a periodic full-rebuild can land later if needed.

## TDD
- **RED** (`d88e2522`): `TestNeighborEdgesBuilderDeltaScan` seeds 100k
observations, asserts an empty-delta tick is a no-op (<1s), and a
100-row delta is upserted in <500ms with no rescan of baseline rows.
Baseline builder fails the empty-delta assertion (sees all 200k baseline
edges).
- **GREEN** (`cf6fbb4e`): watermark + LIMIT — all assertions pass.
- **Mutation**: revert the `WHERE o.timestamp > ?` clause → the test
hangs to lock-contention timeout, confirming the WHERE actually gates
the behavior.

## Benchmark (synthetic, 100k observations, local sqlite)
| | Scan duration |
|---|---|
| Baseline builder, full scan every tick | ~40s |
| Patched builder, empty-delta tick | <50ms |
| Patched builder, 100-row delta | <50ms |

Staging projection: 2–3 min ticks → <1s ticks; SQLite writer freed for
MQTT ingest.

Fixes #1339

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-23 20:54:16 -07:00
Marcel Verdult 498fbc0321 fix: ingestor uses ingest-time now() instead of observer receive time (#1233)
## Problem
The ingestor stamps every stored packet with its own ingest-time
`time.Now()`
(`BuildPacketData` in `db.go`; channel/DM paths in `main.go`),
discarding the
observer receive time the uploader already puts in the MQTT envelope's
`timestamp` field. `MQTTPacketMessage` had no `Timestamp` field and
`handleMessage` parsed every envelope field except that one.

Observers that buffer packets offline and upload hours later get every
buffered packet displayed at upload time, not receive time — a 5-hour
deferred upload shows packets 5 hours late. Retained messages and broker
backlog hit the same skew.

## Why the envelope timestamp is trustworthy
Uploaders stamp `timestamp` when the radio receives the frame and freeze
it;
the MQTT *message* is published late, but the `timestamp` *field* is not
re-stamped at publish. A buffered packet uploaded hours late still
carries
its true receive time.

## Fix
New `resolveRxTime` helper reads `msg["timestamp"]` and falls back to
`time.Now()` only when it is missing, unparseable, or implausibly in the
future. Applied to all three ingest paths (raw packet, channel, DM). No
wire-format change — the field already exists.

Channel/DM dedup hashes intentionally stay on ingest time, since those
bridge
messages carry no real packet hash and need ingest-unique input.

## Observer/node last_seen correction
Packet timestamps must reflect receive time, but observer/node
`last_seen`
must not. `InsertTransmission` fed `data.Timestamp` (now rxTime) into
`observers.last_seen` and `UpsertNode`'s `last_seen`, so a buffered
upload
could drag both fields backwards, and retained-message replay on MQTT
reconnect could flash long-offline observers as Online.

- `UpsertObserverAt` takes an explicit `lastSeen`; the status-packet and
BLE
companion handlers pass the resolved rxTime. `UpsertObserver` keeps its
  wall-clock behaviour for other callers.
- All three `last_seen` writes are guarded with
`MAX(MIN(existing, ingestNow), rxTime)`: `last_seen` never moves
backwards
  from a stale retained message, and never locks in a future value.

## Naive UTC+N timestamps
`resolveRxTime` rejects a timestamp only when it is >14h ahead (UTC+14
is the
maximum standard offset — anything further is a genuine clock error). A
timestamp that is merely in the future is soft-clamped to ingest time: a
future rxTime means a live packet from a UTC+N observer whose naive
local
clock parses as-if UTC, not a buffered packet, so ingest time is correct
and
no future timestamp reaches the DB.

For buffered packets from naive-clock uploaders a bounded residual
offset
remains (equal to the observer's UTC offset); uploaders emitting
zone-aware
ISO8601 everywhere would be the full cure but is a separate format
change.

## Test
`cmd/ingestor/rxtime_test.go` covers `parseEnvelopeTime` (zone-aware,
naive,
microseconds, garbage, empty) and `resolveRxTime` (plausible past used
verbatim, missing/garbage/future → ingest-time fallback). The existing
`TestBuildPacketData` is updated to supply an envelope timestamp and
assert it
propagates, since `BuildPacketData` no longer self-stamps.
2026-05-23 11:22:51 -07:00
Kpa-clawbotandopenclaw-bot d9ba9937a6 fix(dbschema): canonical source for optional column migrations — fixes startup race (closes #1321) (#1322)
Red commit `2a8102b9` (failing test) → green commit `bb957c9f`. CI:
https://github.com/Kpa-clawbot/CoreScope/actions/workflows/ci.yml?query=branch%3Afix%2Fissue-1321

Fixes #1321.

## Why

On staging `/api/scope-stats` 500'd with `scope_name column not present`
despite the ingestor adding the column ~0.5s after server startup.
`cmd/server/db.go detectSchema()` runs in `OpenDB` and caches
`hasScopeName`/`hasDefaultScope`/`hasObsRawHex` booleans. With
supervisord launching server + ingestor simultaneously, the server's
PRAGMA can fire BEFORE the ingestor's `ALTER TABLE` completes — and the
boolean stays false until the server restarts. Same race class as #1283;
#1289 moved server-side ensures to `dbschema` but the optional columns
the ingestor still owned were left out.

## Fix — option (c) from the issue

Made `internal/dbschema/dbschema.go` the single source of truth for the
optional columns the server detects.

**Migrations moved from `cmd/ingestor/db.go applySchema` into
`dbschema.Apply`:**
- `transmissions.scope_name` + `idx_tx_scope_name` partial index
- `nodes.default_scope`
- `inactive_nodes.default_scope`
- `observations.raw_hex`

**`AssertReady` now asserts** every one of those columns. The server
cannot start with stale-false booleans because `AssertReady` will fatal
first if the columns are missing. The ingestor's old gated blocks are
replaced with pointer comments so anyone hunting for them lands in
`dbschema.go`. The `_migrations` marker rows are preserved (`INSERT OR
IGNORE`) to keep legacy DBs idempotent.

**Documented invariant** in the package doc: any new optional column the
server PRAGMA-detects belongs in `internal/dbschema/dbschema.go`, NOT in
`cmd/ingestor/db.go applySchema`.

## Tests

Added `internal/dbschema/dbschema_test.go` (RED in `2a8102b9`):
- `TestApplyAddsOptionalColumns_CanonicalSource` — post-`Apply`, all
four columns must exist.
- `TestAssertReady_RequiresOptionalColumns` — `AssertReady` must refuse
a DB missing them AND pass after full `Apply`.

`cmd/ingestor` and `cmd/server` full suites green.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-23 08:33:21 -07:00
2329639f45 feat: scoped/unscoped transport-route statistics (#899) (#915)
@
## What this PR does

Implements region-scoped transport-route packet tracking with two
sub-features:

### Feature 1 — Scope statistics (`scope_name`)
- At ingest, transport-route packets (route_type 0/3) with Code1 !=
`0000` are HMAC-matched against configured `hashRegions` keys (mirroring
the `hashChannels` pattern). Matched region name (or `""` for unknown)
stored in new `transmissions.scope_name` column via migration
`scope_name_v1`.
- New `GET /api/scope-stats?window=` endpoint (1h/24h/7d, 30s
server-side TTL) returning transport totals, scoped/unscoped counts,
per-region breakdown, and time-series.
- New **Scopes** tab in Analytics with summary cards, per-region table,
and two-line SVG chart. Auto-refreshes every 60s.

### Feature 2 — Node default scope (`default_scope`)
- Per-node `default_scope` column on `nodes`/`inactive_nodes` (migration
`nodes_default_scope_v1`) tracks the most recently matched region for
each node, derived from transport-scoped ADVERT packets.
- `GET /api/nodes` response includes `default_scope` field when column
is present.
- Node detail panel displays the default scope badge.
- Async startup backfill (`BackfillDefaultScopeAsync`) populates the
column for nodes with pre-existing ADVERT data.

### Config
Add `hashRegions` to `config.json` (see `config.example.json`). One
entry per region name (with or without leading `#`).
@

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kpa-clawbot <kpaclawbot@outlook.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-21 14:00:06 -07:00
efitenandClaude Sonnet 4.6 51f823bf7e feat: one-click prune nodes outside geofilter (#669 M4) (#738)
## Summary

- Adds `POST /api/admin/prune-geo-filter` endpoint — dry-run by default,
`?confirm=true` to permanently delete nodes outside the current
geofilter polygon + buffer. Requires `X-API-Key` header.
- Adds **Prune nodes** section inside the GeoFilter customizer tab
(write-access only, same `writeEnabled` gate as PUT). **Preview** lists
affected nodes; **Confirm delete** removes them.
- Adds `GetNodesForGeoPrune` and `DeleteNodesByPubkeys` DB helpers.
- Updates `docs/user-guide/geofilter.md` — documents the UI button as
primary workflow, CLI script as alternative.

> **Depends on M3** (`feat/geofilter-m3-customizer`, PR #736). Merge M3
first.

## Test plan

- [x] `cd cmd/server && go test ./...` — all pass
- [x] Customizer GeoFilter tab without `apiKey` — Prune section not
visible
- [x] With `apiKey` + polygon active — Prune section visible
- [x] **Preview** returns list of nodes outside polygon (no deletions)
- [x] **Confirm delete** removes nodes, list clears
- [x] `POST /api/admin/prune-geo-filter` without `X-API-Key` → 401
- [x] `POST /api/admin/prune-geo-filter` with no polygon configured →
400

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 03:19:31 +00:00
9383201c07 refactor(db): finish #1283 — Option 4: ingestor owns neighbor-graph + schema migrations; server is read-only (fixes #1287) (#1289)
Red commit:
https://github.com/Kpa-clawbot/CoreScope/commit/eae179b99b5fd34924547632aa8f8025c405aa53
(CI: pending — opens with this PR)

Finishes #1283. RED test `TestServerSourceHasNoCachedRWCalls` goes from
failing (13 writer call-sites) to GREEN (zero). Per #1287 Option 4
(https://github.com/Kpa-clawbot/CoreScope/issues/1287#issuecomment-4485099992):
ingestor owns the neighbor graph build + persist; server reads the
snapshot.

**Category A — Schema migrations** → new `internal/dbschema` package.
`dbschema.Apply(rw)` runs in `cmd/ingestor` startup (in `OpenStore`).
`dbschema.AssertReady(ro)` runs in `cmd/server/main.go` and
FATAL-LOG-EXITS if any expected column/index/table is missing — the
operator must restart the ingestor first. Covers indexes,
`neighbor_edges`, `observations.resolved_path`,
`observers.{inactive,last_packet_at,iata}`,
`(inactive_)nodes.foreign_advert`, `transmissions.from_pubkey`.

**Category B — Backfill** → ingestor.
`BackfillFromPubkey` and observer-blacklist soft-delete moved to
`cmd/ingestor/maintenance.go`. Server keeps an inert
`fromPubkeyBackfillSnapshot` stub for `/api/healthz` API compatibility.

**Category C — Neighbor-graph persistence (Option 4)** → ingestor
writes, server reads.
- Ingestor (`cmd/ingestor/neighbor_builder.go`): every 60s scans
`observations + transmissions`, extracts edges (originator↔first-hop for
ADVERTs; observer↔last-hop for all), resolves hop prefixes via a
node-table prefix index, upserts into `neighbor_edges`.
- Server (`cmd/server/neighbor_recomputer.go`): every 60s re-reads
`neighbor_edges` and atomic-swaps the resulting `NeighborGraph` into
`s.graph`. Initial load is synchronous on startup. All server-side
incremental edge writers (the two `asyncPersistResolvedPathsAndEdges`
paths in `cmd/server/store.go`) are gone.
- Neighbor-edge daily prune (`PruneNeighborEdges`) moved to ingestor.

**Why Option 4**: clean read/write separation, no startup CPU spike
(server loads existing snapshot instead of rebuilding from history), no
IPC/delta-protocol churn. Staleness budget ~60s — same model as the
analytics recomputers in #1240 / #1248 / #672 axis 2.

**Recomputer interval default for neighbor graph**: 60s
(`NeighborGraphRecomputerDefaultInterval`,
`NeighborEdgesBuilderInterval`).

**Invariants added**:
- `TestServerSourceHasNoCachedRWCalls` (RED commit eae179b9): grep
enforces zero `cachedRW(`, `mode=rw`, or `sql.Open(_journal_mode=WAL…)`
in non-test `cmd/server/` sources.
- `TestServerStartupRequiresMigratedSchema`: server refuses to start
against an unmigrated DB.
- `TestNeighborGraphRecomputerLoadsSnapshot`: post-write snapshot is
picked up on the next refresh.
- `TestNeighborEdgesBuilderUpsertsFromObservations`: end-to-end pipeline
writes the expected edge.

`grep cachedRW cmd/server/*.go | grep -v _test.go` → 0 matches.

Fixes #1287.

---------

Co-authored-by: MeshCore Bot <bot@meshcore.local>
Co-authored-by: Kpa-clawbot <Kpa-clawbot@users.noreply.github.com>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-19 23:53:41 -07:00
Kpa-clawbotandbot 749fdc114f feat(decoder+ui): close remaining P2 items from #1279 — payloadTypeNames, legend, TransportCodes, Feat1/2, RAW_CUSTOM, sensor docs (#1291)
RED commit: `dc4c0800` — CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1279-p2

Closes the remaining six 🟢 P2 items in umbrella #1279 (PR #1280 shipped
P0+P1, PR #1276 shipped ACK/RESPONSE/PATH legend rows).

### Item-by-item

| # | Item | Where | Test |
|---|---|---|---|
| 1 | `payloadTypeNames` parity | `cmd/server/store.go` |
`cmd/server/issue1279_p2_test.go::TestPayloadTypeNamesAll13` |
| 2 | Legend rows: Anon Req / Grp Data / Multipart / Control / Raw
Custom | `public/live.js` | `test-issue-1279-legend-p2-e2e.js`
(Playwright) |
| 3 | TransportCodes detail-row + `code1=` / `code2=` filter grammar |
`public/packets.js`, `public/packet-filter.js` |
`test-issue-1279-p2-code-filter.js` (6 cases) |
| 4 | Multibyte capability badge on node detail/list rows |
`public/nodes.js::renderNodeBadges` | `n.hash_size >= 2` (observable
Feat1/Feat2 proxy; firmware `AdvertDataHelpers.h:14-16`) |
| 5 | RAW_CUSTOM (0x0F) `{rawLength, firstByteTag}` decode + detail-row
| `cmd/server/decoder.go`, `cmd/ingestor/decoder.go`,
`public/packets.js` | `TestDecodeRawCustomExposesLengthAndTag` × 2 +
updated `TestDecodePayloadRAWCustom` |
| 6 | Sensor advert telemetry firmware-derivation comments |
`cmd/ingestor/decoder.go:363-380` | pure comments — exempt per AGENTS |

### Firmware refs cited inline
- `firmware/src/Packet.h:19-32` — PAYLOAD_TYPE_* constants
- `firmware/src/Packet.h:46` — TransportCodes wire layout
- `firmware/src/Mesh.cpp:577` — `createRawData`
- `firmware/src/helpers/SensorMesh.{h,cpp}` — sensor advert telemetry
derivation
- `firmware/src/helpers/AdvertDataHelpers.h:14-16` — Feat1/Feat2

### TDD
Red `dc4c0800` proves the assertions gate behavior:
- `payloadTypeNames` had only 12 entries (no 0x0F).
- RAW_CUSTOM decoded as `UNKNOWN` with no envelope fields.

Green `<HEAD>` makes both green; per-item tests included.

### Cross-stack note
Cross-stack: justified — items 1/5 add decoder output fields; items
2/3/4/5 surface those fields in the UI in the same PR per #1279
acceptance.

### Out of scope
Item 4 surfaces the observable multibyte capability via the persisted
`hash_size` (Feat1/Feat2 wire bits are only on transient adverts and not
stored per-node today); persisting raw Feat1/Feat2 per-node is left for
a follow-up.

Fixes #1279

---------

Co-authored-by: bot <bot@corescope>
2026-05-19 08:08:28 -07:00
Kpa-clawbotandMeshCore Bot 1da2034341 refactor(db): move all writes from server to ingestor; server truly read-only (fixes #1283) (#1286)
**Red commit:** f6290b63 — CI run will appear at
https://github.com/Kpa-clawbot/CoreScope/actions

Fixes #1283.

## What

Moves all four DB write operations out of `cmd/server/` into
`cmd/ingestor/`, making the server truly read-only and eliminating the
SQLITE_BUSY VACUUM bug at its root: the server can no longer race the
ingestor for the write lock because the server has no write path.

## The four operations

| # | Was in | Now in |
|---|--------|--------|
| 1 | `cmd/server/vacuum.go` (`checkAutoVacuum`, full VACUUM +
`auto_vacuum=INCREMENTAL` migration) | `cmd/ingestor/db.go`
`Store.CheckAutoVacuum` (already existed; ingestor runs it at startup
**before** the MQTT subscriber starts → no contention) |
| 2 | `cmd/server/db.go` `PruneOldPackets` (`DELETE FROM transmissions`)
| `cmd/ingestor/maintenance.go` `Store.PruneOldPackets` (new) + 24h
ticker in `cmd/ingestor/main.go` |
| 3 | `cmd/server/db.go` `PruneOldMetrics` (`DELETE FROM
observer_metrics`) | `cmd/ingestor/db.go` `Store.PruneOldMetrics`
(already existed) |
| 4 | `cmd/server/db.go` `RemoveStaleObservers` (`UPDATE observers SET
inactive=1`) | `cmd/ingestor/db.go` `Store.RemoveStaleObservers`
(already existed) |

## HTTP surface

- **Removed:** `POST /api/admin/prune` (`handleAdminPrune`, route,
openapi entry). Operators trigger an ad-hoc prune by restarting the
ingestor.
- **Kept:** `GET /api/backup` — uses `VACUUM INTO` which writes to a
separate file, not the live DB; read-only-safe.

## Tests

- `cmd/server/readonly_invariant_test.go` (RED gate) — reflect-asserts
`PruneOldPackets`/`PruneOldMetrics`/`RemoveStaleObservers` are NOT
methods on the server's `*DB`. Fails on master, passes after this PR.
- `cmd/ingestor/issue1283_test.go` — exercises `Store.PruneOldPackets`
and the auto_vacuum=NONE → INCREMENTAL migration through
`Store.CheckAutoVacuum` with `vacuumOnStartup=true`.

## Why the bug is gone

The SQLITE_BUSY VACUUM failure happened because supervisord launched
both ingestor + server in one container; the ingestor took the write
lock for INSERTs and the server's `checkAutoVacuum` then failed to
acquire it within `busy_timeout=5000`. After this PR, only the ingestor
ever opens a writable connection, and it runs `CheckAutoVacuum`
**before** spawning the MQTT subscriber → no contention possible.

## Scope notes

- `cachedRW()` still has three pre-existing callers in `cmd/server/`
(`neighbor_persist.go`, `ensure_indexes.go`,
`from_pubkey_migration.go`). These pre-date #1283 and are not in the
issue's four-operation list. Leaving them for follow-up keeps this PR
honest about scope; AGENTS.md documents the invariant so new write paths
can't sneak in.
- PII preflight reports false positives on the Go method name
`requireAPIKey` in `routes.go` diff context — no real PII.
- Server-side neighbor-edge prune (`PruneNeighborEdges`) intentionally
left in place — out of scope of #1283.

---------

Co-authored-by: MeshCore Bot <bot@meshcore.local>
2026-05-18 23:52:27 -07:00
Kpa-clawbotandOpenClaw Bot e6c30e1a7e feat(decoder): GRP_DATA + MULTIPART + advertRole fix + CONTROL flags (#1279 P0+P1) (#1280)
Addresses the four P0+P1 firmware reconciliation gaps from the umbrella
audit (issue #1279). RED commit: `0a4c084e` (asserts on stub returns;
all 13 assertions fail). GREEN commit: `13867681`.

## What's in this PR

### P0 — silently dropped data

- **#1 GRP_DATA (0x06) decoder.** Outer envelope is the same shape as
GRP_TXT (`channel_hash(1)+MAC(2)+ciphertext`) per
`firmware/src/helpers/BaseChatMesh.cpp:476,500`. Factored
`decryptChannelBlock(...)` helper used by both 5 and 6. When a channel
key matches, the inner is parsed per
`firmware/src/helpers/BaseChatMesh.cpp:382-385` as `data_type(uint16 LE)
+ data_len(1) + blob(data_len)`. Surfaces `{channelHash, MAC, dataType,
dataLen, decryptedBlob}` on decrypt or `{channelHash, MAC,
encryptedData}` otherwise. Server-side decoder surfaces envelope only
(no key store).
- **#2 MULTIPART (0x0A) decoder.** Per `firmware/src/Mesh.cpp:289`,
byte0 = `(remaining<<4) | inner_type`. When `inner_type ==
PAYLOAD_TYPE_ACK (0x03)`, next 4 bytes are the LE ack_crc per
`firmware/src/Mesh.cpp:292-307`. Surfaces `{remaining, innerType,
innerTypeName, innerAckCrc | innerPayload}`.

### P1 — mis-classified / opaque

- **#3 `advertRole()` raw-type fix.** Per
`firmware/src/helpers/AdvertDataHelpers.h:7-12`, ADV_TYPE_NONE = 0 and
5-15 are FUTURE. The previous boolean fallback collapsed both into
`"companion"`, silently relabelling unknown/reserved types. New
behaviour: type 0 → `none`, 1 → `companion`, 2-4 →
`repeater`/`room`/`sensor`, 5-15 → `type-N`. `ValidateAdvert` accepts
the new labels.
- **#4 CONTROL (0x0B) byte0 flags + length.** Per
`firmware/src/Mesh.cpp:69` + `createControlData` at `Mesh.cpp:609`,
byte0 high-bit marks the zero-hop direct subset. Surfaces `{ctrlFlags,
ctrlZeroHop, ctrlLength}`.

### Drift fix

- `cmd/server/store.go` `payloadTypeNames` now includes `6: GRP_DATA`
and `10: MULTIPART` (previously omitted; canonical decoder map already
had them).

## Lockstep & TDD

Both `cmd/ingestor/decoder.go` and `cmd/server/decoder.go` updated in
the same commits — same wire-vector tests live in both packages
(`cmd/{ingestor,server}/issue1279_test.go`). Per-item RED→GREEN visible
in `git log`.

| Item | Tests | RED proof |
|---|---|---|
| #1 GRP_DATA | ingestor: NoKey + DecryptedInner; server: Envelope | 6
assertions failed pre-impl |
| #2 MULTIPART | ingestor + server: Ack + NonAck | 8 assertions failed
pre-impl |
| #3 advertRole | ingestor + server: 7-row table | 3 assertions failed
pre-impl |
| #4 CONTROL | ingestor + server: ZeroHop + MultiHop | 6 assertions
failed pre-impl |

## What's NOT in this PR

The umbrella issue lists P2 items that ship in follow-up PRs:

- Live + compare legend entries for the long tail of newly-named types
(#1274 + others).
- TransportCodes UI surface + filter grammar.
- feat1/feat2 capability badges.
- `payloadTypeNames` consolidation across server/ingestor
(drift-prevention).

Leave the umbrella open after this merges.

Refs #1279

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-18 23:19:27 -07:00
170f0ac66d fix(#1212): MQTT per-attempt logging + stall watchdog — prevent silent reconnect-loop death (#1216)
RED commit: `1cd25f7b` — CI (failing on assertion):
https://github.com/Kpa-clawbot/CoreScope/actions?query=sha%3A1cd25f7b1bdd0091f689dd64ce1bfec6d031191f

Fixes #1212

## Root cause

NOT that `AutoReconnect` was off — it was set;
`MaxReconnectInterval=30s` was set (PR #949); a `SetReconnectingHandler`
was wired. The defect was an **observability gap**:

`SetReconnectingHandler` fires only INSIDE paho's reconnect goroutine.
If that goroutine never iterates (status race after the recovered
handler panic at 21:07:13, or an internal abort), operators see ONLY the
`disconnected: pingresp not received` line and then total silence. They
cannot distinguish "paho is patiently retrying" from "paho gave up and
the goroutine is gone." That ambiguity is what turned a 30s blip into 6h
of downtime.

## Changes

### `cmd/ingestor/main.go` — `SetConnectionAttemptHandler`
Fires on every TCP/TLS dial — the initial `Connect()` AND every
reconnect — independent of paho's internal reconnect-loop state. Logs:

```
MQTT [staging] connection attempt #1 to tcp://broker:1883
MQTT [staging] connection attempt #2 to tcp://broker:1883
```

Per-source attempt counter via `atomic.AddInt64`.

### `cmd/ingestor/mqtt_watchdog.go` (new) — per-source stall watchdog
Satisfies the watchdog acceptance criterion. Even when paho reports
`connected`, if no MQTT messages have flowed for >5m, log a WARN line
every 60s:

```
MQTT [staging] WATCHDOG: client reports connected to tcp://broker:1883 but no messages received for 7m30s (threshold 5m) — possible half-open socket or upstream stall
```

Catches half-open TCP and broker-accepted-but-not-forwarding scenarios
that look "connected" to paho.

Hot-path cost: one `atomic.StoreInt64` per inbound message. Watchdog
scans the registry once a minute.

### Tests (`cmd/ingestor/mqtt_reconnect_test.go`, new)
- `TestBuildMQTTOpts_InstrumentsConnectionAttempt` — asserts
`OnConnectAttempt` is wired in `buildMQTTOpts`.
- `TestMQTTStallWatchdog_FiresOnSilentSource` — connected + 10m silent +
5m threshold → stall flagged.
- `TestMQTTStallWatchdog_QuietWhenRecent` — recent message → no stall.
- `TestMQTTStallWatchdog_QuietWhenDisconnected` — disconnected → no
stall (paho's reconnect logging covers it).

## TDD
- RED `1cd25f7b` — 2 assertion failures (compile OK, stub returns
no-stall, `OnConnectAttempt` nil).
- GREEN `2527be6f` — implementation; all ingestor tests pass.

## Out of scope
- Slice-bounds decode panic (#1211, separate PR).
- A full in-process MQTT broker integration test would require a new dep
(mochi-mqtt) — the observability and watchdog behaviors are
independently verifiable by the unit tests above, and the reconnect path
itself is paho's responsibility (we already test it's configured via
`mqtt_opts_test.go`).

---------

Co-authored-by: bot <bot@example.com>
Co-authored-by: OpenClaw Bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com>
2026-05-15 22:46:29 -07:00
85e97d2f37 fix(#1211): bounds-check path length to prevent slice [218:15] panic in MQTT decode (#1214)
**RED commit:** `65d9f57b` (CI run will appear at
https://github.com/Kpa-clawbot/CoreScope/actions after PR opens)

Fixes #1211

## Root cause

`decodePath()` returns `bytesConsumed = hash_size * hash_count` where
both come straight from the wire-supplied `pathByte` (upper 2 bits →
`hash_size`, lower 6 bits → `hash_count`). Max claimable: 4 × 63 = 252
bytes.

A malformed packet on the wire claimed `pathByte=0xF6` (hash_size=4,
hash_count=54 → 216 path bytes) inside a 15-byte buffer. The inner
hop-extraction loop in `decodePath` did break early on overflow — but
`bytesConsumed` was still returned at face value (216). `DecodePacket`
then did `offset += 216` (offset=218) and `payloadBuf := buf[offset:]`
panicked with the prod-observed signature:

```
runtime error: slice bounds out of range [218:15]
```

The handler-level `defer/recover` at `cmd/ingestor/main.go:258-263`
caught it, but the message was silently dropped with no usable
diagnostic.

## Fix

Add a `if offset > len(buf)` guard at BOTH decoder sites (same pattern,
same panic potential):

- `cmd/ingestor/decoder.go` — DecodePacket after decodePath
- `cmd/server/decoder.go` — DecodePacket after decodePath

Return a descriptive error citing the claimed length and pathByte hex so
operators can reproduce.

Also: `cmd/ingestor/main.go` decode-error log now includes `topic`,
`observer`, and `rawHexLen` so future malformed packets are reproducible
without needing to attach a debugger.

## Tests (TDD red → green)

Both packages got two new tests:

- **`TestDecodePacketBoundsFromWire_Issue1211`** — feeds the exact wire
shape from the prod log (`pathByte=0xF6` inside a 15-byte buf). Asserts
`DecodePacket` does NOT panic and returns an error.
- **`TestDecodePacketFuzzTruncated_Issue1211`** — sweeps every `(header,
pathByte)` combination with tails 0..19 bytes (≈1.3M inputs). Asserts
zero panics.

### Red commit proof

On commit `65d9f57b` (RED), both tests fail with the panic:
```
=== RUN   TestDecodePacketBoundsFromWire_Issue1211
    decoder_test.go:1996: DecodePacket panicked on malformed input: runtime error: slice bounds out of range [218:15]
--- FAIL: TestDecodePacketBoundsFromWire_Issue1211 (0.00s)
=== RUN   TestDecodePacketFuzzTruncated_Issue1211
    decoder_test.go:2010: DecodePacket panicked during fuzz: runtime error: slice bounds out of range [3:2]
--- FAIL: TestDecodePacketFuzzTruncated_Issue1211 (0.01s)
```

On commit `7a6ae52c` (GREEN), full suites pass:
- `cmd/ingestor`: `ok 53.988s`
- `cmd/server`:   `ok 29.456s`

## Acceptance criteria

- [x] Identify the slice op producing `[218:15]` — `payloadBuf :=
buf[offset:]` in `DecodePacket` (decoder.go), where `offset` had been
advanced by an unchecked `bytesConsumed` from `decodePath()`.
- [x] Bounds check added at the identified site(s) — both ingestor and
server decoders.
- [x] Test with crafted payload (length-field > remaining buffer) —
`TestDecodePacketBoundsFromWire_Issue1211`.
- [x] Log topic, observer ID, payload byte length on drop — updated
`MQTT [%s] decode error` log line.
- [x] Existing tests stay green — confirmed both packages.

## Out of scope

Reconnect-after-disconnect (#1212) — handled by a separate subagent.
This PR touches NO reconnect logic.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope>
2026-05-15 22:34:21 -07:00
f4cf2acbc0 perf: cancelled writes + ingestor I/O + threshold tests (#1120 follow-up) (#1167)
Red commit: e964ec9c46 (CI run: pending —
workflow only triggers on PR open)

Partial fix for #1120 — finishes the four follow-up items left open
after PR #1123 (cancelled writes, ingestor I/O, threshold-flag tests,
docs).

## What's done

- **`cancelledWriteBytesPerSec`** — server `/proc/self/io` parser
handles `cancelled_write_bytes`; `/api/perf/io` exposes the per-second
rate; Perf page renders it next to Read/Write with ⚠️ when sustained >1
MB/s.
- **Ingestor `/proc/<pid>/io`** — `cmd/ingestor/stats_file.go` samples
its own `/proc/self/io` each tick and includes `procIO` in the snapshot.
The server's `/api/perf/io` reads it and surfaces `.ingestor`. Frontend
renders an `Ingestor process` Disk I/O block alongside the existing
`server process` block (issue mockup: "Both ingestor and server").
- **Threshold + anomaly tests** — `test-perf-disk-io-1120.js` now
asserts ⚠️ fires/suppresses on WAL>100MB, cache_hit<90%, and the
backfill-rate-vs-tx-rate guard with the `tx_inserted >= 100` baseline
floor. Drops the tautological `|| ... === false` short-circuits flagged
in MINOR m4.
- **Docs (m8)** — `config.example.json` adds `_comment_ingestorStats`
(env var, default path, shared-tmp security note);
`cmd/ingestor/README.md` adds `CORESCOPE_INGESTOR_STATS` to the env-var
table plus a `Stats file` section.

## What's NOT done (deferred)

m1 sync.Map → map+RWMutex, m2 perfIOMu rate caching, m3 negative
cacheSize translation, m5 deterministic-write test, m7 ctx-aware
shutdown — pure polish; will file a follow-up issue if the operator
wants them tracked.

## TDD

- Red: `e964ec9` — adds failing tests + stub field/handler shape
(cancelled missing from struct, ingestor stub returns nil, ingestor
procIO absent).
- Green: `1240703` — wires up the parser case, ingestor sampler,
frontend rendering, docs.

E2E assertion added: test-perf-disk-io-1120.js:108

---------

Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: Kpa-clawbot <bot@kpa-clawbot>
2026-05-08 16:29:23 -07:00
fb744d895f fix(#1143): structural pubkey attribution via from_pubkey column (#1152)
Fixes #1143.

## Summary

Replaces the structurally unsound `decoded_json LIKE '%pubkey%'` (and
`OR LIKE '%name%'`) attribution path with an exact-match lookup on a
dedicated, indexed `transmissions.from_pubkey` column.

This closes both holes documented in #1143:
- **Hole 1** — same-name false positives via `OR LIKE '%name%'`
- **Hole 2a** — adversarial spoofing: a malicious node names itself with
another node's pubkey and gets attributed to the victim
- **Hole 2b** — accidental false positive when any free-text field (path
elements, channel names, message bodies) contains a 64-char hex
substring matching a real pubkey
- **Perf** — query now uses an index instead of a full-table scan
against `LIKE '%substring%'`

## TDD

Two-commit history shows red-then-green:

| Commit | Status | Purpose |
|---|---|---|
| `7f0f08e` | RED — tests assertion-fail on master behaviour |
Adversarial fixtures + spec |
| `59327db` | GREEN — schema + ingestor + server + migration |
Implementation |

The red commit's test schema includes the new column so the file
compiles, but the production code still uses LIKE — the assertions fail
because the malicious / same-name / free-text rows are returned. The
green commit changes the query plus adds the migration/ingest path.

## Changes

### Schema
- new column `transmissions.from_pubkey TEXT`
- new index `idx_transmissions_from_pubkey`

### Ingestor (`cmd/ingestor/`)
- `PacketData.FromPubkey` populated from decoded ADVERT `pubKey` at
write time. Cheap — already parsing `decoded_json`. Non-ADVERTs stay
NULL.
- `stmtInsertTransmission` writes the column.
- Migration `from_pubkey_v1` ALTERs legacy DBs to add the column +
index.
- Bonus: rewrote the recipe in the gated one-shot
`advert_count_unique_v1` migration to use `from_pubkey` (already marked
done on existing DBs; kept correct for fresh installs).

### Server (`cmd/server/`)
- `ensureFromPubkeyColumn` mirrors the ingestor migration so the server
can boot against a DB the ingestor has never touched (e2e fixture, fresh
installs).
- `backfillFromPubkeyAsync` runs **after** HTTP starts. Scans `WHERE
from_pubkey IS NULL AND payload_type = 4` in 5000-row chunks with a
100ms yield between chunks. Cannot block boot even on prod-sized DBs
(100K+ transmissions). Queries handle NULL gracefully (return empty for
that pubkey, same as today's unknown-pubkey path).
- All in-scope LIKE call sites switched to exact match:

| Site | Before | After |
|---|---|---|
| `buildPacketWhere` (was db.go:582) | `decoded_json LIKE '%pubkey%'` |
`from_pubkey = ?` |
| `buildTransmissionWhere` (was db.go:626) | `t.decoded_json LIKE
'%pubkey%'` | `t.from_pubkey = ?` |
| `GetRecentTransmissionsForNode` (was db.go:910) | `LIKE '%pubkey%' OR
LIKE '%name%'` | `t.from_pubkey = ?` |
| `QueryMultiNodePackets` (was db.go:1785) | `decoded_json LIKE
'%pubkey%' OR ...` | `t.from_pubkey IN (?, ?, ...)` |
| `advert_count_unique_v1` (was ingestor/db.go:257) | `decoded_json LIKE
'%' \|\| nodes.public_key \|\| '%'` | `t.from_pubkey = nodes.public_key`
|

`GetRecentTransmissionsForNode` signature simplifies: the `name`
parameter is gone (it was only ever used for the legacy `OR LIKE
'%name%'` fallback). Sole caller in `routes.go:1243` updated.

### Tests
- `cmd/server/from_pubkey_attribution_test.go` — adversarial fixtures +
Hole 1/2a/2b/QueryMultiNodePackets exact-match assertions, EXPLAIN QUERY
PLAN index check, migration backfill correctness.
- `cmd/ingestor/from_pubkey_test.go` — write-time correctness
(BuildPacketData populates FromPubkey for ADVERT only;
InsertTransmission persists it; non-ADVERTs stay NULL).
- Existing test schemas (server v2, server v3, coverage) get the new
column **plus a SQLite trigger** that auto-populates `from_pubkey` from
`decoded_json` on ADVERT inserts. This means existing fixtures (which
only seed `decoded_json`) keep attributing correctly without per-test
edits.
- `seedTestData`'s ADVERTs explicitly set `from_pubkey`.

## Performance — index is used

```
$ EXPLAIN QUERY PLAN SELECT id FROM transmissions WHERE from_pubkey = ?
SEARCH transmissions USING INDEX idx_transmissions_from_pubkey (from_pubkey=?)
```

Asserted in `TestFromPubkeyIndexUsed`.

## Migration approach

- **Sync at boot**: `ALTER TABLE transmissions ADD COLUMN from_pubkey
TEXT` is a metadata-only operation in SQLite — microseconds regardless
of table size. `CREATE INDEX IF NOT EXISTS
idx_transmissions_from_pubkey` is **not** metadata-only: it scans the
table once. Empirically a few hundred ms on a 100K-row table; expect a
few seconds on a 10M-row table (one-time cost, blocking boot during that
window). Subsequent boots no-op via `IF NOT EXISTS`. If this boot delay
becomes an operational concern at prod scale we can defer the `CREATE
INDEX` to a goroutine — for now a few-second one-time delay is
acceptable.
- **Async**: row-level backfill of legacy NULL ADVERTs (chunked 5000 /
100ms yield). On a 100K-ADVERT prod DB, this completes in seconds in the
background; HTTP is fully available throughout.
- **Safety**: queries handle NULL gracefully — a node whose ADVERTs
haven't backfilled yet returns empty, identical to today's behaviour for
unknown pubkeys. No half-state regression.

## Out of scope (intentionally)

The free-text `LIKE` paths the issue explicitly leaves alone (e.g.
user-typed packet search) are untouched. Only the pubkey-attribution
sites get the column treatment.



## Cycle-3 review fixes

| Finding | Status | Commit |
|---|---|---|
| **M1c** — async-contract test was tautological (test's own `go`, not
production's) | Fixed | `23ace71` (red) → `a05b50c` (green) |
| **m1c** — package-global atomic resets unsafe under `t.Parallel()` |
Fixed (`// DO NOT t.Parallel` comment + `Reset()` helper) | rolled into
`23ace71` / `241ec69` |
| **m2c** — `/api/healthz` read 3 atomics non-atomically (torn snapshot)
| Fixed (single RWMutex-guarded snapshot + race test) | `241ec69` |
| **n3c.m1** — vestigial OR-scaffolding in `QueryMultiNodePackets` |
Fixed (cleanup) | `5a53ceb` |
| **n3c.m2** — verify PR body language about `ALTER` vs `CREATE INDEX` |
Verified accurate (already corrected in cycle 2) | (no change) |
| **n3c.m3** — `json.Unmarshal` per row in backfill → could use SQL
`json_extract` | **Deferred as known followup** — pure perf optimization
(current per-row Unmarshal is correct, just slower); SQL rewrite would
unwind the chunked-yield architecture and is non-trivial. Acceptable for
one-time backfill at boot on legacy DBs. |

### M1c implementation detail

`startFromPubkeyBackfill(dbPath, chunkSize, yieldDuration)` is now the
single production entry point used by `main.go`. It internally does `go
backfillFromPubkeyAsync(...)`. The test calls `startFromPubkeyBackfill`
(no `go` prefix) and asserts the dispatch returns within 50ms — so if
anyone removes the `go` keyword inside the wrapper, the test fails.
**Manually verified**: removing the `go` keyword causes
`TestBackfillFromPubkey_DoesNotBlockBoot` to fail with "backfill
dispatch took ~1s (>50ms): not async — would block boot."

### m2c implementation detail

`fromPubkeyBackfillTotal/Processed/Done` are now plain `int64`/`bool`
package globals guarded by a single `sync.RWMutex`.
`fromPubkeyBackfillSnapshot()` returns all three under one RLock.
`TestHealthzFromPubkeyBackfillConsistentSnapshot` races a writer
(lock-step total/processed updates with periodic done flips) against 8
readers hammering `/api/healthz`, asserting `processed<=total` and
`(done => processed==total)` on every response. Verified the test
catches torn reads (manually injected a 3-RLock implementation; test
failed within milliseconds with "processed>total" and "done=true but
processed!=total" errors).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-05-06 23:50:44 -07:00
Kpa-clawbot 5a5df5d92b revert: group commit M1 (#1117) — starves MQTT, refs #1129 (#1130)
## Why

Diagnostic on #1129 shows PR #1117 (group commit M1 for #1115) is
fundamentally broken: it starves the MQTT goroutine via `gcMu` lock
contention, causing pingresp disconnects and lost packets at modest
ingest rates.

## Three structural defects

1. **Lock held across `sql.Stmt.Exec`** — every concurrent
`InsertTransmission` blocks for the full SQLite write latency, not just
the brief queue mutation.
2. **Lock held across `tx.Commit`** — the WAL fsync runs *under* `gcMu`,
so any backlog blocks all ingest writers AND the flusher ticker,
snowballing under load.
3. **Single-conn DB** (`MaxOpenConns=1`) — the flusher and the ingest
path serialise on one connection, turning the lock into a global ingest
stall.

Net effect: at modest packet rates the MQTT client loop misses its own
pingresp deadline, the broker drops the connection, and packets received
during the stall are lost.

## What this PR removes

- `Store.SetGroupCommit`, `Store.FlushGroupTx`, `Store.flushLocked`,
`Store.GroupCommitMs`
- `gcMu`, `activeTx`, `pendingRows`, `groupCommitMs`,
`groupCommitMaxRows` Store fields
- `groupCommitMs` / `groupCommitMaxRows` config fields and
`GroupCommitMsOrDefault` / `GroupCommitMaxRowsOrDefault` accessors
- The flusher goroutine in `cmd/ingestor/main.go`
- `cmd/ingestor/group_commit_test.go`
- The `if s.activeTx != nil { … pendingRows … }` branch in
`InsertTransmission` — reverts to plain prepared-stmt usage

## What this PR keeps (merged after #1117)

- #1119 `BackfillPathJSON` `path_json='[]'` fix
- #1120/#1123 perf metrics endpoints — `WALCommits` counter retained
- `GroupCommitFlushes` JSON field on `/api/perf/write-sources` is kept
as always-0 for API stability (server `perf_io.go` references it as a
string field name; no client breakage)
- `DBStats.GroupCommitFlushes` atomic field is removed from the Go
struct

## Tests

`cd cmd/ingestor && go test ./... -run "Test"` → `ok` (47.8s).
`cd cmd/server && go build ./...` → clean.

## #1115 stays open

The group-commit *idea* is sound — batching observation INSERTs would
meaningfully reduce WAL fsync rate. But it needs a redesign that does
**not** hold a mutex across blocking SQLite calls. Suggested directions
for a future M1:
- Channel-fed writer goroutine (single owner of the tx, ingest path is
non-blocking enqueue)
- Per-batch DB handle so the flusher doesn't serialise the ingest
connection
- Bounded queue with backpressure rather than a shared lock

Refs #1117 #1129
2026-05-05 19:02:43 -07:00
74dffa2fb7 feat(perf): per-component disk I/O + write source metrics on Perf page (#1120) (#1123)
## Summary

Implements per-component disk I/O + write source metrics on the Perf
page so operators can self-diagnose write-volume anomalies (cf. the
BackfillPathJSON loop debugged in #1119) without SSHing in to run
iotop/fatrace.

Partial fix for #1120

## What's done (4/6 ACs)
- ✅ `/api/perf/io` — server-process `/proc/self/io` delta rates
(read/write bytes per sec, syscalls)
- ✅ `/api/perf/sqlite` — WAL size, page count, page size, cache hit rate
- ✅ `/api/perf/write-sources` — per-component counters from ingestor
(tx/obs/upserts/backfill_*)
- ✅ Frontend Perf page — three new sections with anomaly thresholds +
per-second rate columns

## What's NOT done (deferred to follow-up)
- ❌ `cancelledWriteBytesPerSec` field — issue #1120 lists this under
server-process I/O ("writes the kernel discarded — interesting signal");
not exposed in this PR
- ❌ Ingestor `/proc/<pid>/io` — issue #1120 says "Both ingestor and
server"; only server-process I/O lands here. Adding ingestor I/O
requires either a unix socket back to the server, or surfacing the
ingestor pid through the stats file. Doable without changing the
existing API shape.
- ❌ Adaptive baselining — anomaly thresholds remain static (10×, 100 MB,
90%); steady-state baselining can come once we have enough deployed
Perf-page telemetry

Per AGENTS.md rule 34, this PR uses "Partial fix for #1120" rather than
"Fixes #1120" so the issue stays open until the remaining ACs land.

## Backend

**Server (`cmd/server/perf_io.go`)**
- `GET /api/perf/io` — reads `/proc/self/io` and returns delta-rate
`{readBytesPerSec, writeBytesPerSec, syscallsRead, syscallsWrite}` since
last call (in-memory tracker, no allocation per sample).
- `GET /api/perf/sqlite` — returns `{walSize, walSizeMB, pageCount,
pageSize, cacheSize, cacheHitRate}`. `cacheHitRate` is proxied from the
in-process row cache (closest available signal under the modernc sqlite
driver).
- `GET /api/perf/write-sources` — reads the ingestor's stats JSON file
and returns a flat `{sources: {...}, sampleAt}` payload.

**Ingestor (`cmd/ingestor/`)**
- `DBStats` gains `WALCommits atomic.Int64` (incremented on every
successful `tx.Commit()` and on every auto-commit `InsertTransmission`
write) and `BackfillUpdates sync.Map` keyed by backfill name with
`IncBackfill(name)` / `SnapshotBackfills()` helpers.
- `BackfillPathJSONAsync` now increments `BackfillUpdates["path_json"]`
per row write — the BackfillPathJSON-style infinite loop becomes
immediately visible at `backfill_path_json` in the Write Sources table.
- New `StartStatsFileWriter` publishes a JSON snapshot to
`/tmp/corescope-ingestor-stats.json` (override via
`CORESCOPE_INGESTOR_STATS`) every second using atomic tmp+rename. The
tmp file is opened with `O_CREATE|O_WRONLY|O_TRUNC|O_NOFOLLOW` mode
`0o600` so a pre-planted symlink in a world-writable `/tmp` cannot
redirect the write to an arbitrary file.

## Frontend (`public/perf.js`)

Three new sections on the Perf page, all auto-refreshed via the existing
5s interval:

- **Disk I/O (server process)** — read/write rates (formatted
B/KB/MB-per-sec) + syscall counts. Write rate >10 MB/s flags ⚠️.
- **Write Sources** — sorted table of per-component counters with a
per-second rate column derived from snapshot deltas. Backfill rows show
⚠️ only when `tx_inserted >= 100` (meaningful baseline) AND the
backfill's per-second rate exceeds 10× the live tx rate. Avoids the
startup-spurious-alarm where cumulative-vs-cumulative was a tautology.
- **SQLite (WAL + Cache Hit)** — WAL size (⚠️ when >100 MB), page count,
page size, cache hit rate (⚠️ when <90%).

## Tests

- **Backend** (`cmd/server/perf_io_test.go`) —
`TestPerfIOEndpoint_ReturnsValidJSON`,
`TestPerfSqliteEndpoint_ReturnsValidJSON`,
`TestPerfWriteSourcesEndpoint_ReturnsSources` exercise the three new
endpoints. Skips the `/proc/self/io` non-zero-rate assertion when
`/proc` is unavailable.
- **Frontend** (`test-perf-disk-io-1120.js`) — vm-sandbox runs `perf.js`
with stubbed `fetch`, asserts the three new sections render with their
headings + values.

E2E assertion added: test-perf-disk-io-1120.js:91

## TDD

1. Red commit (`21abd22`) — added the three handlers as no-op stubs
returning empty values; tests fail on assertion mismatches (non-zero
rate, `pageSize > 0`, headings present).
2. Green commit (`d8da54c`) — fills in the real `/proc/self/io` parser,
PRAGMA queries, ingestor stats writer, and Perf page rendering.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <kpa-clawbot@users.noreply.github.com>
2026-05-05 17:56:56 -07:00
Kpa-clawbotandOpenClaw Bot 76d89e6578 fix(ingestor): exclude path_json='[]' rows from backfill WHERE (#1119) (#1121)
## Summary

`BackfillPathJSONAsync` re-selected observations whose `path_json` was
already `'[]'`, rewrote them to `'[]'`, and looped forever. The
`len(batch) == 0` exit condition was never reached, the migration marker
was never recorded, and the ingestor sustained 2–3 MB/s WAL writes at
idle (76% of CPU in `sqlite.Exec` per pprof).

## Fix

Drop `'[]'` from the WHERE clause:

```diff
WHERE o.raw_hex IS NOT NULL AND o.raw_hex != ''
- AND (o.path_json IS NULL OR o.path_json = '' OR o.path_json = '[]')
+ AND (o.path_json IS NULL OR o.path_json = '')
```

`'[]'` is the "already attempted, no hops" sentinel (still written at
line 994 of `cmd/ingestor/db.go` when `DecodePathFromRawHex` returns no
hops). Excluding it from the WHERE lets the loop terminate after one
full pass and the migration marker `backfill_path_json_from_raw_hex_v1`
to be recorded.

## TDD

- **Red commit** (`19f8004`):
`TestBackfillPathJSONAsync_BracketRowsTerminate` — seeds 100
observations with `path_json='[]'` and a `raw_hex` that decodes to zero
hops, asserts the migration marker is written within 5s. Fails on master
with *"backfill never recorded migration marker within 5s — infinite
loop on path_json='[]' rows"*.
- **Green commit** (`7019100`): WHERE-clause fix + updates
`TestBackfillPathJsonFromRawHex` row 1 expectation (the pre-seeded
`'[]'` row is now correctly skipped instead of being re-decoded).

## Test results

```
ok  	github.com/corescope/ingestor	49.656s
```

## Acceptance criteria from #1119

- [x] Backfill terminates within 1 polling cycle of having no progress
to make
- [x] Migration marker `backfill_path_json_from_raw_hex_v1` written
after termination
- [x] On restart, backfill recognizes migration done and exits
immediately (existing behavior — the migration check at the top of
`BackfillPathJSONAsync` was always correct; the bug was that the marker
never got written)
- [x] Test: seed DB with N observations all having `path_json = '[]'` →
backfill runs once → no UPDATEs issued, migration marker written
- [ ] Disk write rate on idle staging drops from 2–3 MB/s to <100 KB/s —
to be verified by the user post-deploy

Fixes #1119.

---------

Co-authored-by: OpenClaw Bot <bot@openclaw.local>
2026-05-05 17:35:16 -07:00