fix: harden gossip, HLC restarts, watcher races, MCP args, pagination (#3)

Implements the top findings from the codebase review, verified with tests and live CLI/MCP checks.

**Gossip integrity**
- Push validation: 4 MiB body cap, 1000-row batch cap; rows claiming the local node id (vector-poisoning), empty node ids, and negative HCLs rejected (internal/watch/gossip.go, internal/db/gossip.go)
- Reconcile-on-pull: Run returns the pulled count, syncGossip rebuilds derived state when > 0 — entry-count comparison could never fire, so synced observations never materialized into searchable entries

**Data-layer safety**
- HLC resumed from MAX(hcl) at Open (hlc.SeekTo): a restart with a regressed wall clock cannot reissue values the (node_id, hcl) locator and pull cursors depend on
- Writer serialization: _txlock=immediate DSN + SetMaxOpenConns(1) + per-KnoxDB mutex around RecordObservation's check-then-insert dedup (closes duplicate-row race)

**Watch daemon**
- Ticker guard flags now atomic.Bool (was a cross-goroutine data race)
- Trailing-edge per-path debounce (timer-based, pruned on fire/delete)
- Recursive watches (startup tree walk + watcher.Add on dir Create); Rename re-ingests, Remove cancels pending ingests

**MCP + CLI**
- Strict arg validation, no silent clamping: thread_id 0 errors instead of renaming thread #1; empty knox_thread_link {} errors instead of false success; thread existence checked before writes; golden-thread tool nil-safe
- --page 0 errors instead of panicking; query/recent pagination actually pages (page x limit)

**Tests** (new internal/hlc and internal/db packages): SeekTo monotonicity, concurrent dedup race, push validation, reopen HCL monotonicity, batch caps, self-spoof rejection, idempotency on observation counts.

Verified: go build, go vet, full suite with -race, live MCP stdio transcripts against a scratch DB.
Reviewed-on: #3
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-09-17 09:06:08 +00:00
committed by david
parent d6d2a24ddc
commit bb852faa27
10 changed files with 733 additions and 116 deletions
+15 -2
View File
@@ -98,10 +98,16 @@ func nextCursor(rows []db.GossipObservation) int64 {
return rows[len(rows)-1].HCL
}
// maxBatchRows bounds the number of observations a peer may push in one POST.
// Pull already pages at 500 rows, so any larger batch is at best redundant and
// at worst a flood; capping keeps memory and insert work bounded.
const maxBatchRows = 1000
func (n *Node) handleBatch(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 4<<20) // 4 MiB
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, "batch too large or unreadable", http.StatusBadRequest)
return
}
var rows []db.GossipObservation
@@ -109,6 +115,10 @@ func (n *Node) handleBatch(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
if len(rows) > maxBatchRows {
http.Error(w, fmt.Sprintf("batch too large: %d rows (max %d)", len(rows), maxBatchRows), http.StatusBadRequest)
return
}
inserted, err := n.Kdb.PushObservations(rows)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -273,7 +283,7 @@ func (c *Client) Diff() (*DiffSummary, error) {
// node pulls/pushes directly with every other node it learns about.
//
// m, when non-nil, receives gossip event counters (nil for one-shot CLI runs).
func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) int {
myID := kdb.NodeID()
// Seed the work queue with static config plus persisted discoveries.
@@ -284,6 +294,7 @@ func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
seen := make(map[string]bool) // addr → handled (also suppresses self)
queue := 0
pulledTotal := 0
for queue < len(work) {
addr := strings.TrimSpace(work[queue])
queue++
@@ -338,6 +349,7 @@ func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
pulled += n
}
}
pulledTotal += pulled
if m != nil {
m.IncrementPull(pulled)
}
@@ -356,6 +368,7 @@ func Run(kdb *db.KnoxDB, m *metrics.Metrics, static []string) {
log.Printf("[gossip] synced with %s (%s): in sync (pushed %d)", p.NodeID, addr, pushed)
}
}
return pulledTotal
}
func kdbVectorGet(kdb *db.KnoxDB, nodeID string) int64 {