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
+20 -1
View File
@@ -22,6 +22,12 @@ type KnoxDB struct {
db *sql.DB
nodeID string
clock *hlc.Clock
// writeMu serializes the check-then-insert dedup in RecordObservation within
// this process. Cross-process serialization comes from _txlock=immediate (the
// write lock is taken at BEGIN, before the dedup read) plus a single
// connection per pool.
writeMu sync.Mutex
}
type Observation struct {
@@ -93,10 +99,13 @@ func Open(path string) (*KnoxDB, error) {
return nil, fmt.Errorf("create db dir: %w", err)
}
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_txlock=immediate")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
// One connection per process: WAL has a single writer; serializing on one
// connection avoids pool contention surfacing as busy_timeout errors.
db.SetMaxOpenConns(1)
if _, err := db.Exec(Schema); err != nil {
return nil, fmt.Errorf("init schema: %w", err)
@@ -135,6 +144,13 @@ func Open(path string) (*KnoxDB, error) {
if _, err := db.Exec("UPDATE observations SET hcl=id WHERE hcl IS NULL"); err != nil {
return nil, fmt.Errorf("backfill hcl: %w", err)
}
// Resume this node's HLC from its persisted max: a restart with a regressed
// wall clock must not reissue already-persisted values (see hlc.SeekTo).
var maxHCL int64
if err := db.QueryRow(`SELECT COALESCE(MAX(hcl), 0) FROM observations WHERE node_id=?`, nodeID).Scan(&maxHCL); err != nil {
return nil, fmt.Errorf("seed hlc: %w", err)
}
kdb.clock.SeekTo(maxHCL)
// Locator uniqueness: (node_id, hcl) is the merge key for gossip; a given
// node's HCL is strictly monotonic so this never throws a false conflict.
@@ -204,6 +220,9 @@ func (k *KnoxDB) ObservationEntryEstimate() int {
// Idempotent: if the latest observation for this fingerprint has an identical
// content signature, nothing is recorded — re-ingesting unchanged content is a no-op.
func (k *KnoxDB) RecordObservation(o ObservationRecord) (obsID int64, isNew bool, err error) {
k.writeMu.Lock()
defer k.writeMu.Unlock()
tx, err := k.db.Begin()
if err != nil {
return 0, false, fmt.Errorf("begin tx: %w", err)