# Bug Hunt Report — Comprehensive Controller Audit

> **RECONCILED 2026-06-13 (controller now v0.59.0).** This report is from v0.30.3 and predates the
> Proxmox re-platform / slice-8C de-privileging — much of the audited code has since been rewritten,
> moved to `felhom-agent`, or deleted. A full reconciliation against current code lives at
> **`felhom.eu/documentation/audits/bughunt-reconcile-2026-06-13.md`** (authoritative). Summary:
> - **H1–H4, H12** (concurrency Highs): already FIXED (confirmed in the deep-sweep audit).
> - **H10** (plaintext secret on encrypt failure): the old `// H10 fix` only logged a WARN and still
>   persisted plaintext — now **FIXED fail-closed** in v0.59.0 (`5a80739`).
> - **C2, H5, H6, H7, H8**: verified FIXED. **C3** (SSD-only DB DR loss): **MOOT-by-architecture**
>   (DR is now whole-LXC PBS). **C1, H9, H11** + several file-gone Lows: **MOOT** (gone, not migrated-with-bug).
> - Remaining Mediums/Lows: mostly CODE-GONE; a small `[backlog]` set (M4/M5/M6, M18/M19, M25) survived
>   but is **not yet deep-verified** — see the reconciliation for the list.
>
> The original v0.30.3 content is preserved unchanged below for history.

**Date:** 2026-02-25
**Scope:** Full controller codebase (`controller/` — all ~80 Go files, all packages)
**Method:** Systematic code review of every file + live testing on demo node (192.168.0.162)
**Controller version:** v0.30.3

---

## Executive Summary

Reviewed all controller Go source files across 6 parallel deep-dive sessions covering: main/settings/config, backup (12 files), stacks (5 files), web/API/router (14 files), scheduler/monitor/metrics (10 files), and cloudflare/assets/storage/sync/setup/notify/report/recovery/selftest/crypto/util (32 files).

**Live testing result:** Demo node is healthy — zero errors, zero warnings, 18 MiB RAM, 0.11% CPU, all subsystems operational.

**Code review found 78 issues total:**

| Severity | Count |
|----------|-------|
| CRITICAL | 3 |
| HIGH     | 12 |
| MEDIUM   | 26 |
| LOW      | 37 |

---

## Table of Contents

- [CRITICAL Issues](#critical-issues)
- [HIGH Issues](#high-issues)
- [MEDIUM Issues](#medium-issues)
- [LOW Issues](#low-issues)
- [Fix Plan](#fix-plan)

---

## CRITICAL Issues

### C1: Watchdog mutex unlock/relock pattern — panic-unsafe

**File:** `internal/monitor/watchdog.go:234-238`
**Category:** concurrency

In `handleConnectedProbe()`, the mutex is unlocked before calling `handleDisconnect`, then re-locked to satisfy a deferred unlock:

```go
state.mu.Unlock()           // line 235
w.handleDisconnect(sp, state, result)  // line 236
state.mu.Lock()             // line 237 — re-acquire for deferred Unlock
```

If `handleDisconnect` panics, line 237 never executes, but the `defer state.mu.Unlock()` still fires — unlocking an already-unlocked mutex causes a **double-panic** (`sync: unlock of unlocked mutex`), crashing the watchdog goroutine entirely.

**Fix:** Refactor to update state inside the lock, then call `handleDisconnect` outside with an early return instead of re-locking. Or wrap `handleDisconnect` in a recover block.

---

### C2: SetGeoAppOverride nil pointer dereference

**File:** `internal/settings/settings.go:885`
**Category:** bug

`SetGeoAppOverride(stackName string, override *GeoAppOverride)` does not check for `override == nil` before accessing its fields. Callers who pass nil to "clear" an override would crash.

**Fix:** Add nil guard: `if override == nil { delete map entry; return }`.

---

### C3: SSD-only apps skip DB dump restoration during DR

**File:** `internal/backup/restore_app_linux.go:156-161`
**Category:** logic/bug

`restoreDBDumps` returns `nil` early if `app.HDDPath == ""`. SSD-only apps (no HDD) have DB dumps under `systemDataPath`, but this path is never considered. During disaster recovery, SSD-only apps with databases (e.g., Mealie, Vikunja) would **lose their DB dump backups**.

**Fix:** Fall back to `systemDataPath` when `HDDPath` is empty, mirroring the logic in `GetAppDrivePath()`.

---

## HIGH Issues

### H1: Double deploy race condition (TOCTOU)

**File:** `internal/stacks/deploy.go:110-285`
**Category:** concurrency

`DeployStack` reads the stack via `GetStack` (RLock, releases), checks `existing.Deployed == false` on disk, then 169 lines later acquires the write lock to set `Deploying = true`. Two concurrent deploy calls for the same stack can both pass the check and launch parallel `docker compose up -d` goroutines.

**Fix:** Atomically check-and-set `Deploying` under a single lock acquisition:
```go
m.mu.Lock()
if s.Deploying { m.mu.Unlock(); return error }
s.Deploying = true
m.mu.Unlock()
```

---

### H2: Delete/Remove during active deploy

**File:** `internal/stacks/delete.go:73, 226`
**Category:** concurrency

Neither `DeleteStack` nor `RemoveStack` checks `stack.Deploying`. A user could delete a stack while an async deploy goroutine is running, causing race between `docker compose down` and `docker compose up`, and the deploy goroutine would later modify a deleted stack.

**Fix:** Add `if stack.Deploying { return error }` checks in both functions.

---

### H3: ScanStacks can overwrite Deployed flag during async deploy

**File:** `internal/stacks/manager.go:232-248`
**Category:** concurrency

`ScanStacks` re-reads `app.yaml` from disk and overwrites the in-memory `Deployed` flag. If an async deploy fails and `runComposeDeploy` sets `Deployed=false` in memory, but disk still has the old state, `ScanStacks` could re-set `Deployed=true` creating a ghost deployment.

**Fix:** Skip overwriting `Deployed` and `AppConfig` when `existing.Deploying == true`.

---

### H4: Shared `appCfg` pointer between main thread and async deploy goroutine

**File:** `internal/stacks/deploy.go:283, 310`
**Category:** concurrency

The same `appCfg` pointer is stored in `s.AppConfig` (line 283) and passed to the async `runComposeDeploy` goroutine. The goroutine later mutates it (line 310: `appCfg.Deployed = false`). While the mutation happens under `m.mu.Lock`, readers who already got the pointer reference see mutation.

**Fix:** Clone `appCfg` before passing to the goroutine.

---

### H5: `SyncFileBrowserMounts` has no concurrency guard

**File:** `internal/web/handlers.go:1543-1589`
**Category:** concurrency

Called from 7 locations (4 via `go` goroutines). Two concurrent storage operations could trigger parallel file writes to `docker-compose.yml` and `config.yaml`, and concurrent `docker compose` restarts causing file corruption or Docker conflicts.

**Fix:** Add a `sync.Mutex` for the entire SyncFileBrowserMounts operation, or use a debounced channel to coalesce concurrent calls.

---

### H6: PushEvent never records to event history

**File:** `internal/notify/notifier.go:181-218`
**Category:** bug

`PushEvent()` fires notifications in a goroutine but never calls `recordHistory()`. Only `PushTestEventSync()` records. The debug event history page is blind to all real production events.

**Fix:** Add `n.recordHistory(...)` calls in the PushEvent goroutine on both success and failure paths.

---

### H7: PushOnce returns nil for non-2xx HTTP responses

**File:** `internal/report/pusher.go:194-224`
**Category:** bug

`PushOnce()` silently returns `nil` for 4xx/5xx responses. Callers believe the push succeeded. Compare with `Push()` and `PushInfraBackup()` which properly handle non-2xx.

**Fix:** Return `fmt.Errorf("hub push-once: HTTP %d", resp.StatusCode)` for non-2xx.

---

### H8: tmpFile not closed/synced before rename in DB dump

**File:** `internal/backup/dbdump.go:231-272`
**Category:** data integrity

`DumpOne` calls `os.Rename(tmpPath, finalPath)` while the file handle is still open (defer closes it later). Data may not be flushed to disk before rename. A crash after rename could leave a truncated/empty dump file.

**Fix:** Close `tmpFile` explicitly (checking the error) before `os.Rename`.

---

### H9: Restic retry uses possibly-expired context

**File:** `internal/backup/restic.go:142-156`
**Category:** logic

When a stale lock is detected, `Snapshot` calls `unlock` then retries with the same context (30-min timeout). If the first attempt consumed most of the timeout, the retry gets very little time and fails with a confusing timeout error.

**Fix:** Create a fresh context with its own timeout for the retry.

---

### H10: SaveAppConfig silently stores secrets in plaintext on encryption failure

**File:** `internal/stacks/deploy.go:542-549`
**Category:** security

When `crypto.Encrypt()` fails, the code silently falls through and saves the value unencrypted. No warning is logged. Passwords could be stored in plaintext on disk without any indication.

**Fix:** Log the encryption failure, or return an error to prevent saving unencrypted secrets.

---

### H11: `settingsCrossBackupHandler` missing destination path validation

**File:** `internal/web/handlers.go:947-994`
**Category:** security

The web form handler for cross-drive backup configuration does NOT validate the destination path against registered storage paths. The API handler (`router.go:823-837`) has explicit validation (C9 comment). A crafted form submission could set an arbitrary destination path.

**Fix:** Add the same validation as the API handler: check `destPath` against `GetStoragePaths()`.

---

### H12: deepCopyStack incomplete — OptionalConfig and HealthCheck not deep-copied

**File:** `internal/stacks/manager.go:474-517`
**Category:** concurrency

`deepCopyStack` copies `DeployFields` but not `Meta.OptionalConfig` (slice of slices) or `Meta.HealthCheck` (pointer). Mutations on the returned copy's OptionalConfig or HealthCheck would affect the original in the map.

**Fix:** Deep-copy `OptionalConfig` including nested `Fields` slices, and clone `HealthCheck` pointer.

---

## MEDIUM Issues

### M1: Non-constant-time API key comparison

**File:** `cmd/controller/main.go:820`
**Category:** security

API key comparison uses `==` instead of `subtle.ConstantTimeCompare`, making it vulnerable to timing attacks.

**Fix:** Use `crypto/subtle.ConstantTimeCompare`.

---

### M2: `stackProvider` read without mutex protection

**File:** `internal/backup/backup.go:172-176, 185-189, 481-482`
**Category:** concurrency

`SetStackProvider` writes under `m.mu`, but all reads of `m.stackProvider` happen without any lock. This is a Go data race per the memory model. In practice it's set once at startup, but the race detector would flag it.

**Fix:** Use `sync.Once` or `atomic.Value` for the provider.

---

### M3: `DrainPendingEvents` silently loses events on save failure

**File:** `internal/settings/settings.go:808-823`
**Category:** logic

If `s.save()` fails, events are drained from memory but never persisted. On restart, the events are lost.

**Fix:** Only drain events from memory after successful save.

---

### M4: `SubdomainInUse` performs disk I/O under RLock

**File:** `internal/stacks/deploy.go:58-82`
**Category:** concurrency/performance

Reads `app.yaml` from disk via `LoadAppConfig` for every deployed stack while holding `m.mu.RLock()`. Slow I/O blocks all writers.

**Fix:** Collect stack dirs under the lock, release it, then perform disk I/O.

---

### M5: `MigrateEncryption` reads `encKey` without lock

**File:** `internal/stacks/manager.go:123`
**Category:** concurrency

Checks `m.encKey == nil` before acquiring `m.mu.Lock()`. `SetEncryptionKey` writes `encKey` under the lock, creating a data race.

**Fix:** Move the nil check inside the lock.

---

### M6: `MigrateEncryption` holds lock during all disk I/O

**File:** `internal/stacks/manager.go:122-162`
**Category:** concurrency/performance

Holds `m.mu.Lock()` while performing `LoadAppConfig`, `LoadMetadata`, and `SaveAppConfig` for every stack. Blocks all operations during migration.

**Fix:** Collect stacks needing migration under lock, then do I/O outside. (Acceptable if only runs at startup before scheduler.)

---

### M7: `executeAllRestores` accesses `plan.Apps` without lock

**File:** `internal/web/handler_restore.go:115`
**Category:** concurrency

Directly accesses `plan.Apps` field without holding `restoreMu`. Concurrent reads from `apiRestoreStatus` could race.

**Fix:** Use the plan's thread-safe accessor instead of direct field access.

---

### M8: No login rate limiting

**File:** `internal/web/auth.go:91-128`
**Category:** security

No rate limiting on login attempts. While bcrypt is expensive, a persistent attacker could still brute-force weak passwords.

**Fix:** Add per-IP rate limiting (e.g., 5 attempts per minute).

---

### M9: Route handling — `hasSuffix` can match empty stack names

**File:** `internal/api/router.go:128`
**Category:** bug

Path `/stacks/deploy` matches `hasSuffix(path, "/deploy")`, yielding an empty stack name from `extractName`. The handler proceeds without validating the name is non-empty.

**Fix:** Add non-empty name validation in handlers that use `extractName`.

---

### M10: Jobs registered after `Start()` never execute

**File:** `internal/scheduler/scheduler.go:60-96`
**Category:** logic

`Start()` launches goroutines for existing jobs only. Jobs added via `Every()`/`Daily()` after `Start()` are appended but never get a goroutine.

**Fix:** Either reject late registrations or launch the goroutine immediately if already started.

---

### M11: `findStoragePath` returns pointer to copy, not to settings data

**File:** `internal/monitor/watchdog.go:890-897`
**Category:** logic

Returns `&sp` where `sp` is a range-loop copy. Mutations won't reflect in settings. All current callers are read-only so it's safe, but misleading.

**Fix:** Return value type, or document it's a snapshot.

---

### M12: Set* methods on watchdog lack synchronization

**File:** `internal/monitor/watchdog.go:113-125`
**Category:** concurrency

`SetAlertRefresh`, `SetHubReportPusher`, `SetRepoUnlocker` write fields without any mutex. If called after the scheduler starts, it's a data race.

**Fix:** Document they must be called before Start(), or protect with mutex.

---

### M13: SQLite WAL mode pragma not verified

**File:** `internal/metrics/store.go:26-36`
**Category:** data integrity

`PRAGMA journal_mode=WAL` is executed via `db.Exec()` but the returned mode is not verified. WAL could silently fail to engage on certain filesystems.

**Fix:** Use `db.QueryRow("PRAGMA journal_mode=WAL").Scan(&mode)` and verify `mode == "wal"`.

---

### M14: `sampleContainers` uses `context.Background()` instead of cancellable context

**File:** `internal/metrics/collector.go:100-101`
**Category:** bug

`docker stats` command ignores shutdown cancellation, potentially blocking for 30s after `Stop()` is called.

**Fix:** Pass the collector's context through to `sampleContainers`.

---

### M15: Telemetry scan error silently swallowed

**File:** `internal/metrics/telemetry.go:39-42`
**Category:** bug

Row scan failures are silently discarded with `continue`, masking data corruption or schema issues.

**Fix:** Log the scan error before continuing.

---

### M16: `copyStackDBDumps` reads entire files into memory

**File:** `internal/backup/crossdrive.go:478-484`
**Category:** optimization

Uses `os.ReadFile`/`os.WriteFile` for dump file copies. Large DB dumps (500MB+) cause full in-memory allocation.

**Fix:** Use `io.Copy` with buffered I/O, or shell out to `cp`/`rsync`.

---

### M17: `restic stats` called twice per drive per cache refresh

**File:** `internal/backup/backup.go:1070-1071`
**Category:** optimization

`aggregateRepoStats()` and `perDriveRepoStats()` both iterate drives and call `restic stats`. Each drives gets two subprocess invocations per refresh.

**Fix:** Compute per-drive stats once, then aggregate from the results.

---

### M18: `ListDumpFiles` re-validates every dump file every 5 minutes

**File:** `internal/backup/dbdump.go:448-451`
**Category:** optimization

`ValidateDump` opens and reads each `.sql` file line-by-line on every cache refresh, even for multi-hundred-MB dumps. Ignores the validation cache in `settings.json`.

**Fix:** Pass the validation cache to `ListDumpFiles`, skip re-validation if file size+modtime match.

---

### M19: Naive stack name derivation from container names

**File:** `internal/backup/dbdump.go:517-532`
**Category:** logic

`deriveStackName("my-cache")` strips "cache" suffix and returns "my" — potentially attributing DB dumps to wrong stacks.

**Fix:** Cross-reference with actual deployed stack names instead of pure suffix stripping.

---

### M20: `syncInfraConfig` controller.yaml copy is not atomic

**File:** `internal/backup/crossdrive.go:526-534`
**Category:** data integrity

Uses `os.ReadFile` + `os.WriteFile` directly. A crash mid-write corrupts the file. Compare with `local_infra.go` which uses `atomicWrite`.

**Fix:** Use `atomicWrite` for consistency.

---

### M21: `http.Get` without timeout in main.go

**File:** `cmd/controller/main.go:691`
**Category:** bug

Uses `http.Get` (no timeout). If the endpoint hangs, the goroutine leaks permanently.

**Fix:** Use `&http.Client{Timeout: 10 * time.Second}`.

---

### M22: Assets syncer holds mutex during entire network operation

**File:** `internal/assets/syncer.go:72-73`
**Category:** concurrency

`s.mu.Lock()` held for the entire `Sync()` call including HTTP downloads. `Status()` calls block for the entire sync duration (60+ seconds).

**Fix:** Lock only to set a `running` flag, then unlock for actual work.

---

### M23: Git sync logs credentials in plaintext

**File:** `internal/sync/sync.go:427`
**Category:** security

`runGitInDir` logs `strings.Join(args, " ")` which includes the repo URL with embedded credentials during `git clone`. The `maskRepoURL()` helper exists but is only used elsewhere.

**Fix:** Apply `maskRepoURL()` in `runGitInDir()` before logging.

---

### M24: Path prefix check in storageAttachBrowseHandler may match sibling directories

**File:** `internal/web/storage_handlers.go:953-958`
**Category:** security

`strings.HasPrefix(cleanPath, storage.RawMountBase)` — if `RawMountBase` doesn't end with `/`, a path like `/mnt/felhom-stage-evil/sensitive` would pass the check.

**Fix:** Ensure prefix check includes trailing `/`: `strings.HasPrefix(cleanPath, storage.RawMountBase + "/")`.

---

### M25: Set* methods on Server lack synchronization

**File:** `internal/web/server.go:114-172`
**Category:** concurrency

Multiple `Set*` methods write to Server fields without synchronization. The `SyncFileBrowserMounts` goroutine launched at startup could race with these calls.

**Fix:** Document init order or add a barrier, or pass all deps via `NewServer()`.

---

### M26: `crypto.DecryptMap` uses global `log.Printf`

**File:** `internal/crypto/crypto.go:112`
**Category:** consistency

Uses global `log` package instead of injected logger — inconsistent with rest of codebase.

**Fix:** Add a `logger` parameter.

---

## LOW Issues

### L1: Dead code: unused `fileExists` in main.go (line 1071)
### L2: Multiple goroutines launched without `sync.WaitGroup` for shutdown (main.go: multiple locations)
### L3: `0` treated as "unset" for all int config fields (config.go:230-234) — prevents intentional zero values
### L4: Hardcoded metrics DB path ignores `cfg.Paths.DataDir` (main.go:138)
### L5: Hardcoded config path in infra backup functions (main.go:1047, 1148)
### L6: String concat instead of `filepath.Join` for path (main.go:83)
### L7: Double-clear of `running` map in CrossDriveRunner (crossdrive.go:86-110) — defer + manual clear
### L8: uint64 to int64 cast in restic stats — overflow for repos >8 EB (restic.go:293)
### L9: `deriveStackName` error parsing uses `string(out)` which may be empty (restic.go:136-142)
### L10: infraPaths duplicated in every drive's backup (backup.go:366-369)
### L11: TOCTOU race on fstab in `addDRFstabEntries` (restore_drives_linux.go:213-262)
### L12: Non-atomic fallback fstab write (restore_drives_linux.go:256-258)
### L13: 10-second timeout may truncate DB discovery results (backup.go:1077-1081)
### L14: `restoreUserData` copies files with hardcoded 0644 permissions (restore_app_linux.go:139-148)
### L15: Merged snapshot history not saved to disk after merge (backup.go:984-1053)
### L16: `RunIntegrityCheck` doesn't acquire the `running` flag (backup.go:571-622)
### L17: Empty drive path propagates to path builders (backup.go:177-180)
### L18: `ScanDrivesForBackups` picks first found backup, not freshest (restore_scan.go:248)
### L19: Throwaway `exec.Cmd` for logging in MariaDB dump (dbdump.go:213-223)
### L20: `ValidateDump` uses global `log` instead of passed logger (dbdump.go:300+)
### L21: `methodOrEmpty` never returns empty despite its name (healthprobe.go:315-323)
### L22: `aggregateState` doesn't account for `StatePaused` containers (manager.go:387-441)
### L23: `composeExecCustomEnv` double-loads env when env is nil (manager.go:688-694)
### L24: `logPostStartStatus` goroutine has no cancellation (manager.go:775-799)
### L25: `ParseComposeHDDMounts` naive YAML parsing can be fooled (delete.go:398-460)
### L26: `getDirSizeHuman` has no timeout (delete.go:463-474)
### L27: Delete/Remove calls both manual state update AND ScanStacks (delete.go:162-168)
### L28: `DeployField.Options` not deep-copied in `deepCopyStack` (manager.go:510-514)
### L29: Double WriteHeader in `serveCatchAll` (server.go:355-360)
### L30: CSRF token never rotated during session lifetime (auth.go:144-161)
### L31: Content-Type exact match is fragile (storage_handlers.go:1490-1497)
### L32: `AlertManager.Refresh` confusing variadic API (alerts.go:54)
### L33: `json` template function suppresses marshaling errors (funcmap.go:329-332)
### L34: Pinger `send()` always returns nil (pinger.go:91)
### L35: No SQLite connection pool limits (metrics/store.go:19-23)
### L36: No WAL checkpoint after Prune (metrics/store.go:303-322)
### L37: `AllCountries()` rebuilds sorted list on every call (cloudflare/countries.go:253-261)

---

## Fix Plan

### Phase 1: Critical & High Bugs (do first)

These are bugs that could cause crashes, data loss, or security issues.

| # | Issue | Effort | Impact |
|---|-------|--------|--------|
| C1 | Watchdog mutex unlock/relock panic safety | 30min | Prevents watchdog crash |
| C2 | SetGeoAppOverride nil guard | 5min | Prevents nil panic |
| C3 | Restore: SSD-only apps DB dump fallback | 30min | Fixes DR for SSD-only apps |
| H1 | Double deploy race: atomic check-and-set | 30min | Prevents parallel deploys |
| H2 | Delete/Remove: check Deploying flag | 10min | Prevents delete-during-deploy |
| H3 | ScanStacks: skip Deployed overwrite during deploy | 15min | Prevents ghost deployments |
| H4 | Clone appCfg before async deploy goroutine | 15min | Prevents shared pointer mutation |
| H5 | SyncFileBrowserMounts: add mutex | 20min | Prevents concurrent file corruption |
| H6 | PushEvent: add recordHistory calls | 20min | Fixes event history debug page |
| H7 | PushOnce: return error for non-2xx | 5min | Fixes silent push failures |
| H8 | DumpOne: close tmpFile before rename | 10min | Prevents truncated dump files |
| H9 | Restic retry: fresh context | 10min | Prevents confusing timeout errors |
| H10 | SaveAppConfig: log/error on encrypt failure | 10min | Prevents silent plaintext secrets |
| H11 | settingsCrossBackupHandler: add path validation | 15min | Closes path traversal gap |
| H12 | deepCopyStack: complete deep copy | 20min | Prevents shared slice mutation |

**Estimated Phase 1 total: ~4 hours**

### Phase 2: Medium Security & Consistency

| # | Issue | Effort | Impact |
|---|-------|--------|--------|
| M1 | Constant-time API key comparison | 5min | Timing attack prevention |
| M8 | Login rate limiting | 1hr | Brute-force prevention |
| M9 | Empty stack name validation | 10min | Prevents invalid API calls |
| M23 | Mask git credentials in logs | 10min | Prevents credential leakage |
| M24 | Path prefix check trailing slash | 5min | Closes path matching gap |
| M21 | http.Get with timeout | 5min | Prevents goroutine leak |
| M25 | Server Set* methods: document init order | 10min | Prevents future races |

**Estimated Phase 2 total: ~2 hours**

### Phase 3: Medium Concurrency & Logic

| # | Issue | Effort | Impact |
|---|-------|--------|--------|
| M2 | stackProvider: use sync.Once | 15min | Fixes data race |
| M3 | DrainPendingEvents: drain after save | 10min | Prevents event loss |
| M4 | SubdomainInUse: I/O outside lock | 30min | Reduces lock contention |
| M5 | MigrateEncryption: encKey check inside lock | 5min | Fixes data race |
| M7 | executeAllRestores: use thread-safe accessor | 10min | Fixes race condition |
| M10 | Scheduler: reject/launch late registrations | 20min | Prevents silent job drops |
| M12 | Watchdog Set* methods: document or protect | 10min | Prevents data race |
| M13 | SQLite WAL verification | 10min | Confirms WAL engagement |
| M14 | sampleContainers: pass cancellable context | 15min | Clean shutdown |
| M15 | Telemetry scan error logging | 5min | Better diagnostics |
| M22 | Assets syncer: unlock during network I/O | 20min | Unblocks status API |
| M26 | crypto.DecryptMap: use injected logger | 10min | Logger consistency |

**Estimated Phase 3 total: ~3 hours**

### Phase 4: Medium Optimization

| # | Issue | Effort | Impact |
|---|-------|--------|--------|
| M16 | copyStackDBDumps: buffered I/O | 20min | Prevents OOM on large dumps |
| M17 | restic stats: compute once per drive | 30min | Halves subprocess calls |
| M18 | ListDumpFiles: use validation cache | 30min | Stops re-reading large dumps |
| M19 | deriveStackName: cross-reference with stacks | 30min | Correct DB-to-stack mapping |
| M20 | syncInfraConfig: atomic write | 10min | Consistent with local_infra.go |

**Estimated Phase 4 total: ~2 hours**

### Phase 5: Low Priority (opportunistic)

Address during related work or cleanup sprints. Key items:

- **L2**: Add `sync.WaitGroup` for background goroutines (shutdown cleanup)
- **L11/L12**: Atomic fstab writes in DR restore
- **L14**: Preserve file permissions in restore
- **L15**: Save merged snapshot history
- **L22**: Handle `StatePaused` in `aggregateState`
- **L25**: Replace naive YAML parsing with proper parser
- **L35**: Set `db.SetMaxOpenConns(2)` for SQLite

**Estimated Phase 5 total: ~4 hours (spread across multiple sessions)**

---

## Cross-Cutting Patterns to Standardize

### 1. Logger Inconsistency
Several functions use global `log.Printf` instead of injected loggers:
- `dbdump.go:ValidateDump` — global log
- `crypto.go:DecryptMap` — global log
- Fix: Add logger parameters for consistency

### 2. Atomic Write Inconsistency
Some file operations use atomic write (tmp + rename), others don't:
- `crossdrive.go:syncInfraConfig` — direct write
- `restore_drives_linux.go` — fallback non-atomic write
- Fix: Centralize atomic write helper, use everywhere

### 3. Context Propagation
Several operations use `context.Background()` instead of the parent context:
- `metrics/collector.go:sampleContainers`
- `main.go:http.Get`
- Fix: Thread context through for proper cancellation

### 4. Deep Copy Completeness
`deepCopyStack` misses several nested slices/pointers:
- `Meta.OptionalConfig`, `Meta.HealthCheck`, `DeployField.Options`
- Fix: Audit all nested types and ensure complete deep copy

### 5. Error Swallowing
Multiple locations silently discard errors:
- `PushOnce` non-2xx responses
- `PushEvent` delivery failures (not recorded)
- `Pinger.send` always returns nil
- `SaveAppConfig` encryption failures
- Fix: At minimum log errors; return where possible

---

## Live Testing Summary (192.168.0.162)

| Test | Result |
|------|--------|
| Container health | HEALTHY (v0.30.3, up, `(healthy)`) |
| Log errors | CLEAN (zero errors/warnings) |
| API health | OK (`{"ok":true}`) |
| Disk space | OK (21% root, 1% data drives) |
| Metrics DB | OK (7.2M DB, 1.4M WAL) |
| Restic locks | CLEAN (none) |
| Memory | 17.6 MiB / 15.4 GiB (0.11%) |
| CPU | 0.11% |
| Backup system | Working (4 DBs discovered, cache refresh OK) |

No runtime issues detected. All findings are from code review — latent bugs and race conditions that require specific conditions to trigger.
