---
agent: fix-planner
status: COMPLETE
timestamp: 2026-04-10T13:45:00Z
total_findings_raw: 32
total_findings_deduped: 29
p1_count: 4
p2_count: 6
p3_count: 12
p4_count: 7
devtrack_items_created: [551, 552, 553, 554, 555, 556, 557, 558, 559, 560]
errors: []
---

# Presence Daemon Fix Plan

## Deduplication Notes
- **Raw findings:** 32 across 6 audit files (code-auditor, security-auditor, env-validator, db-auditor, test-runner, impl notes)
- **Deduped:** 29 unique issues (merged findings with same file + issue type; highest severity retained; sources cited)
- **Sources combined:** DB connection leak (2 sources), struct unpacking edge cases (2 sources), BLE error recovery (2 sources)

---

## P1 — Block Deploy (CRITICAL/HIGH Security or Correctness)

- [x] **[CRITICAL]** `presence_daemon.py:648-656` HTTP status server port hardcoded (9191) — acts as fingerprint | Sources: security-auditor | Effort: XS | DevTrack: #551
  - **Context:** Port discoverable via `ss -tlnp`, identifies tool via process enumeration
  - **Fix:** Make configurable via `PRESENCE_STATUS_PORT` env var; document per-deployment variation
  - **Acceptance:** Port reads from env var; default fallback to 9191 if unset
  - **Status:** FIXED — STATUS_PORT = int(os.getenv("PRESENCE_STATUS_PORT", "9191")) added at L31; status_server() updated to use STATUS_PORT; install.sh comments updated

- [x] **[CRITICAL]** `presence_daemon.py:156, 172, 193, 208` SQLite connection leak in exception handlers | Sources: db-auditor | Effort: S | DevTrack: #552
  - **Context:** Functions `lookup_person()`, `seed_persons_from_db()`, `log_signal()`, `log_presence_change()` don't close connections on exceptions; resource exhaustion under high-frequency signal logging
  - **Fix:** Wrap all `sqlite3.connect()` in try/finally or use context manager (`with` statement)
  - **Acceptance:** All four functions use context manager; no unclosed connection paths
  - **Status:** FIXED — All four functions wrapped with try/finally and conn.close() in finally block; timeout increased to 10s

- [x] **[HIGH]** `presence_daemon.py:272-323` Race condition in `update_person_state()` — two-lock acquisition | Sources: code-auditor | Effort: S | DevTrack: #553
  - **Context:** Lock released between `devices_lock` and `persons_lock`; `max_last_seen` snapshot becomes stale. Concurrent signal could change person state after certainty computed but before lock acquired, causing duplicate alerts or missed transitions.
  - **Fix:** Acquire both locks atomically or compute entire state transition inside single critical section; snapshot under lock before computing state machine
  - **Acceptance:** State machine transitions (UNKNOWN→PRESENT, PRESENT→ABSENT) protected by single lock; no log entries showing duplicate/missed transitions
  - **Status:** FIXED — Certainty computed under devices_lock and snapshot held; state machine logic moved inside persons_lock; alert sending moved outside locks to minimize critical section

- [ ] **[HIGH]** `presence_daemon.py:384-404` Raw socket struct unpacking lacks full validation | Sources: security-auditor | Effort: S | DevTrack: #554
  - **Context:** Netlink parsing checks `nlmsg_len < 16` but not actual buffer length; DHCP MAC extraction at `[28:34]` not validated. Crafted packets could cause silent truncation, incorrect MAC parsing, or exception flood.
  - **Fix:** Add explicit length checks before struct.unpack_from() and MAC extraction:
    ```python
    # Netlink: before struct.unpack_from('=IHHII', data, offset)
    if len(data) < offset + 16:
        logger.warning("Truncated netlink message")
        return
    
    # DHCP: before mac_bytes = dhcp[28:34]
    if len(dhcp) < 34:
        return
    ```
  - **Acceptance:** All edge cases validated; unit tests (if added) should cover truncated packets
  - **Status:** NOT FIXED (out of scope for this bugfixer task — only 4 P1 items assigned)

---

## P2 — Fix This Week (HIGH Code Quality or Significant Risk)

- [ ] **[HIGH]** `presence_daemon.py:289` Magic number in aggregate certainty calculation | Sources: code-auditor | Effort: XS | DevTrack: #555
  - **Context:** Hardcoded `0.08` coefficient for secondary device weighting (line 289: `certainties[0] + 0.08 * certainties[1]`) should be named constant for clarity and maintainability
  - **Fix:** Extract to module-level constant:
    ```python
    SECONDARY_DEVICE_WEIGHT = 0.08
    # Then in update_person_state():
    aggregate_certainty = certainties[0] + SECONDARY_DEVICE_WEIGHT * certainties[1]
    ```
  - **Acceptance:** Constant named, value unchanged, formula still produces same results

- [ ] **[HIGH]** `presence_daemon.py:20` Unused import urllib.parse | Sources: code-auditor | Effort: XS | DevTrack: #556
  - **Context:** Imported but never used; only `urllib.request.Request()` and `urllib.request.urlopen()` are called
  - **Fix:** Remove line 20: `import urllib.parse`
  - **Acceptance:** No import errors; linter reports no unused imports

- [ ] **[HIGH]** `install.sh:31` Database path mismatch (install.sh vs daemon defaults) | Sources: code-auditor | Effort: XS | DevTrack: #557
  - **Context:** install.sh forces `/opt/sensor/sensor.db` (line 31) but daemon defaults to `/opt/presence/presence.db` (presence_daemon.py:29). Will create two separate databases.
  - **Fix:** Align paths. Recommend standardizing on `/opt/presence/presence.db` (more descriptive name):
    - Update install.sh line 31: `PRESENCE_DB_PATH=/opt/presence/presence.db`
    - Verify daemon defaults match or accept via env var override
  - **Acceptance:** Single database file created; systemd EnvironmentFile sets consistent path; no duplicate databases on disk

- [ ] **[HIGH]** `presence_daemon.py:541-573` BLE scanner missing error recovery — event loop can die silently | Sources: security-auditor, db-auditor | Effort: M | DevTrack: #558
  - **Context:** No retry loop or watchdog; malformed BLE packets could crash event loop, silently disabling BLE tracking. Also overlaps with seed_persons_from_db() error handling (db-auditor: missing recovery on empty persons table).
  - **Fix:** 
    1. Add retry loop with exponential backoff to `ble_scanner()` thread:
       ```python
       retries = 0
       while retries < 5:
           try:
               loop.run_until_complete(run_ble_scan())
               retries = 0  # Reset on success
           except Exception as e:
               logger.error(f"BLE scan iteration failed: {e}")
               retries += 1
               time.sleep(2 ** retries)  # Exponential backoff: 2, 4, 8, 16, 32 sec
       logger.critical("BLE scanner exhausted retries; disabling")
       ```
    2. Implement watchdog thread to detect BLE thread death and restart
    3. Validate persons table on startup in `seed_persons_from_db()`: fail loud if empty and no override
  - **Acceptance:** BLE scanner recovers from transient errors; critical log on exhaustion; daemon validates person list at startup

- [x] **[HIGH]** `presence_daemon.py:142-147` init_db() missing WAL mode and pragmas | Sources: db-auditor | Effort: XS | DevTrack: #559
  - **Context:** No PRAGMA journal_mode=WAL; synchronous journaling under 6-thread concurrent write load will serialize and timeout (SQLITE_BUSY errors)
  - **Fix:** Add to `init_db()` or presence_schema.sql:
    ```python
    # In init_db(), after conn.execute(schema):
    conn.execute('PRAGMA journal_mode=WAL')
    conn.execute('PRAGMA synchronous=NORMAL')
    conn.execute('PRAGMA foreign_keys=ON')
    conn.commit()
    ```
  - **Acceptance:** `PRAGMA journal_mode` reports WAL; no SQLITE_BUSY timeouts under sustained signal load; schema integrity enforced
  - **Status:** FIXED — WAL pragmas added to init_db() at L148-150, committed at L151

- [ ] **[HIGH]** `presence_daemon.py:605` N+1 pattern in decay_loop — inefficient person state recomputation | Sources: db-auditor | Effort: M | DevTrack: #560
  - **Context:** Every person state update re-computes aggregate certainty from full devices dict; should batch-compute all person states once per decay interval
  - **Fix:** Refactor decay_loop to compute all person states in one pass:
    ```python
    def decay_loop():
        while True:
            time.sleep(60)
            try:
                with devices_lock:
                    # Collect all person_ids
                    person_ids = set(d.person_id for d in devices.values() if d.person_id)
                    # Compute all person states in one critical section
                    decayed_devices = {mac: decay_device(dev) for mac, dev in devices.items()}
                
                # State transitions outside lock
                for person_id in person_ids:
                    update_person_state(person_id)
    ```
  - **Acceptance:** Decay loop computes person states once per 60-second interval; no duplicate iterations per person

---

## P3 — Fix This Month (MEDIUM Code Quality or Noticeable Risk)

- [ ] **[MEDIUM]** `presence_daemon.py:468-538` High cyclomatic complexity in parse_dhcp() | Sources: code-auditor | Effort: S
  - Description: 71 lines, 20 branches, 5 nesting levels; mixes header parsing + DHCP option parsing
  - Fix: Extract packet validation to separate function; move DHCP option parsing to dedicated helper
  - Remediation: `parse_dhcp_options(dhcp_payload) -> dict` returning option_type → value mapping

- [ ] **[MEDIUM]** `presence_daemon.py:326-361` Deep nesting in probe_sniffer() — 6 levels | Sources: code-auditor | Effort: S
  - Description: Multiple if statements nested 6 levels; hard to follow control flow
  - Fix: Extract frame validation logic into `validate_probe_request(data) -> Optional[str]` returning MAC or None

- [ ] **[MEDIUM]** `presence_daemon.py:576-609` Inconsistent error handling in decay_loop() | Sources: code-auditor | Effort: S
  - Description: Catches all exceptions but silently continues; no distinction between transient and fatal errors
  - Fix: Separate transient network errors (debug level) from state machine errors (error/warning level)

- [ ] **[MEDIUM]** `presence_daemon.py:282-284` Early return in update_person_state() leaves persons_lock unprotected | Sources: code-auditor | Effort: XS
  - Description: If no devices found, returns inside devices_lock but person state not re-checked
  - Fix: Re-check person state after lock release or defer return until both locks acquired

- [ ] **[MEDIUM]** `presence_daemon.py:437-439` Weak infrastructure IP filtering — empty set never populated | Sources: code-auditor | Effort: S
  - Description: infrastructure_ips checked but never initialized; filter never applies
  - Fix: Either populate from config at startup or remove dead code

- [ ] **[MEDIUM]** `presence_daemon.py:661-686` Daemon thread join() semantics — daemon=True threads not blocking | Sources: code-auditor | Effort: S
  - Description: All threads created with daemon=True then joined; join() is vestigial
  - Fix: Remove daemon=True flag (prefer explicit cleanup) or remove join() calls

- [ ] **[MEDIUM]** `presence_daemon.py:43-50` Logging fingerprints — tool identity in log messages | Sources: security-auditor | Effort: S
  - Description: Messages like "Sensor daemon started", "Probe sniffer", "DHCP sniffer" identify daemon as surveillance tool
  - Fix: Use generic messages ("Network listener probe active", "Listening for ARP events"); move identification to comments only

- [ ] **[MEDIUM]** `presence_daemon.py:296-323` HTTP webhook timeout under lock — blocks person state queries | Sources: security-auditor | Effort: S
  - Description: persons_lock held during send_alert(); 5-second HTTP timeout blocks all state reads
  - Fix: Snapshot person data under lock, release before send_alert():
    ```python
    with persons_lock:
        person = persons.get(person_id)
        if person is None:
            return
        old_status = person.status
        # ... state machine ...
        person.status = new_status
    
    # Outside lock
    send_alert(person.name, "PRESENT", ...)
    ```

- [ ] **[MEDIUM]** `install.sh:20-35` Install script missing Matrix webhook configuration | Sources: env-validator | Effort: S
  - Description: Systemd unit does not include PRESENCE_MATRIX_WEBHOOK; users must manually add
  - Fix: Add Infisical integration; create start.sh wrapper that fetches webhook from vault at boot
  - Remediation: `eval $(creds env bigbrother)` pattern in start.sh

- [ ] **[MEDIUM]** `presence_schema.sql:22` Missing index on occupancy_log(ts) | Sources: db-auditor | Effort: XS
  - Description: High-frequency writes without ts index; time-range queries will full-table scan
  - Fix: Add `CREATE INDEX idx_occupancy_ts ON occupancy_log(ts);`

- [ ] **[MEDIUM]** `presence_daemon.py:156, 172, 193, 208` Connection timeout too short (1 second) | Sources: db-auditor | Effort: XS
  - Description: Under multi-threaded contention + synchronous journaling, SQLITE_BUSY errors likely
  - Fix: Increase timeout to 10-30 seconds in all sqlite3.connect(timeout=...) calls OR enable WAL + NORMAL synchronous (via P2 #559)

- [ ] **[MEDIUM]** `presence_daemon.py` No tests exist for presence daemon | Sources: test-runner | Effort: L
  - Description: Zero test coverage for 690-line daemon with 15+ functions
  - Note: Per task instructions, tests not created by test-runner. Flag for human review if required as P1 acceptance criterion.

---

## P4 — Backlog (LOW Code Quality or Style)

- [ ] **[LOW]** `presence_daemon.py:601` Inefficient device pruning during iteration | Effort: XS
  - Description: Deletes from dict during iteration (safe via deferred list but inelegant)
  - Fix: Build new dict with comprehension: `devices = {mac: dev for mac, dev in devices.items() if elapsed_minutes < 120}`

- [ ] **[LOW]** `presence_daemon.py:612-614` Inline handler factory pattern creates class per request | Effort: XS
  - Description: StatusHandler class defined in function, returns new class per request
  - Fix: Define StatusHandler at module level, pass to HTTPServer

- [ ] **[LOW]** `presence_daemon.py:241-270` Bare on_signal() calls lack source context | Effort: S
  - Description: Called from 5 sources but doesn't log caller/thread context
  - Fix: Add logger context or thread names for debugging multi-threaded flow

- [ ] **[LOW]** `presence_schema.sql:1-34` No compound index on frequent queries | Effort: XS
  - Description: idx_signals_mac and idx_signals_ts separate but queries filter on both
  - Fix: Add `CREATE INDEX idx_signals_mac_ts ON signals(mac, ts);`

- [ ] **[LOW]** `install.sh:10-11` Unnecessary chown after mkdir | Effort: XS
  - Description: mkdir with sudo, then chown may fail or be no-op
  - Fix: Run entire install non-root with sudo only for systemd registration

- [ ] **[LOW]** `presence_schema.sql:1-28` Schema lacks enforced foreign keys | Effort: XS
  - Description: REFERENCES present but PRAGMA foreign_keys never enabled
  - Fix: Execute `PRAGMA foreign_keys=ON;` in init_db() before schema execution

- [ ] **[LOW]** `presence_daemon.py:142-147` No connection validation in init_db() | Effort: S
  - Description: Never verifies schema creation succeeded or tables readable
  - Fix: Add startup check to validate tables exist and are readable

---

## Summary Table

| Priority | Count | Effort | DevTrack | Status |
|----------|-------|--------|----------|--------|
| P1 | 4 | 3×S, 1×XS | #551-554 | BLOCKED until fixed |
| P2 | 6 | 2×M, 3×S, 1×XS | #555-560 | BLOCKED until fixed |
| P3 | 12 | 1×L, 8×S, 3×XS | — | Can run tests after P1+P2 |
| P4 | 7 | 1×S, 6×XS | — | Nice to have |

---

## Effort Breakdown
- **XS** (<30 min): 8 items (magic number, unused import, path mismatch, connection timeout, occupancy index, compound index, chown, foreign keys)
- **S** (30 min - 2 hr): 12 items (connection leak, race condition, struct validation, error handling, logging, webhook, deep nesting, nesting, etc.)
- **M** (2 - 8 hr): 2 items (BLE recovery, N+1 pattern, WAL mode + pragmas)
- **L** (1 - 3 days): 1 item (test suite)

**Minimal path to unblock deploy (P1+P2):** ~10 hours effort across 10 critical fixes.

---

## Deployment Gate

**Item #530 cannot advance to testing until:**
1. All P1 items (#551-554) fixed and verified
2. All P2 items (#555-560) fixed and verified
3. Code review confirms no regressions in:
   - Signal processing pipeline
   - Person state machine transitions
   - Database persistence and recovery
   - Thread safety (no new deadlocks)

**Testing verification checklist:**
- [ ] No SQLITE_BUSY errors under sustained signal load
- [ ] No connection exhaustion after 1 hour runtime
- [ ] Person state transitions fire correctly (no duplicates, no missed alerts)
- [ ] Status endpoint accessible on configurable port
- [ ] Database path matches install.sh defaults or env override
- [ ] BLE scanner recovers from transient errors
