> **REMEDIATION STATUS — updated 2026-06-13 (controller v0.59.0, deployed to demo guest 9201).**
> Authoritative copy of the BUGHUNT reconciliation. Statuses for the actioned findings:
>
> | Finding | Reconcile verdict | Remediation |
> |---|---|---|
> | H10 (plaintext secret on encrypt failure) | LIVE/PARTIAL | **FIXED** (fail-closed) — controller `5a80739` (v0.59.0) |
> | M2 (unlocked stackProvider read) | LIVE (benign) | **FIXED** (init-only, lock removed) — controller `092cbbe` (v0.59.0) |
> | C2,H5,H6,H7,H8 | FIXED (pre-existing) | confirmed already fixed |
> | C3 (SSD-only DB DR loss) | **MOOT-by-architecture** | whole-LXC PBS DR; no action needed |
> | C1,H9,H11 + file-gone Lows | MOOT | confirmed gone-and-not-migrated |
> | `[backlog]` Mediums M4/M5/M6, M18/M19, M25 | survived, NOT verified | recorded for a future deep pass |
>
> Fresh findings actioned: CTRL-001 **FIXED** (`c20ff56`), CTRL-T2-1 **FIXED** (`5a80739`),
> AGENT-001 **FIX PREPARED/PENDING** (agent `d96e5bd`).

# BUGHUNT RECONCILIATION — v0.30.3 findings vs current code — 2026-06-13

**Task:** Reconcile the stale `BUGHUNT.md` (v0.30.3, 67 findings) against current code. Verdict each: FIXED / MOOT / LIVE with file:line evidence. NOT a new audit, NOT a fix pass.
**Branch:** `audit/2026-06-13-bughunt-reconcile` (off current `main`)

| Repo | HEAD commit | Version |
|---|---|---|
| felhom-controller | `eea235bd6952184c4681b4b133396d6b8b0aaf33` | v0.58.0 |
| felhom-agent | `d17b5ab45dc7df51836e26d7a38450cd391b94b2` | v0.29.1 |

**Inputs:** `BUGHUNT.md` (v0.30.3, full), `AUDIT-2026-06-13.md` (fresh audit, full). **Cardinal rule applied:** a fix-tag comment (`// H10 fix`) is a CLAIM — verified the mechanism in every case; it caught H10 (the tag added a log but did NOT close the bug).

## Progress log

- 19:20 — Branch created off main (eea235b). Skeleton committed + pushed. Agent migration targets located.
- 19:25 — Parts 1/2/3 dispatched in parallel (3 auditors). All returned.
- 19:45 — **Verified by hand:** C3 MOOT-by-architecture (the priority verdict); H10 PARTIAL (read deploy.go:641-666 — `// H10 fix` only adds a WARN, still persists plaintext). Wrote + ran failing evidence test `internal/stacks/saveappconfig_h10_reconcile_test.go` — FAILS, app.yaml contains the plaintext secret.
- 19:55 — Report assembled (Parts 1-5). Committed.

## Executive summary

Of the **62 findings not previously closed** (67 − the 5 concurrency Highs H1–H4/H12 already confirmed fixed in the fresh audit):
- **C3 verdict (priority): MOOT-by-architecture.** DR was re-platformed to whole-LXC PBS restore (the original `restore_*_linux.go` files are gone, not migrated). DB volumes ride inside the block image; the per-app/dump paths route through `GetAppDrivePath` which explicitly falls back to `systemDataPath` when `HDDPath==""`, and DB discovery is docker-ps-driven, never HDD-gated. An SSD-only app's database cannot be silently dropped on any current restore path. No silent DR data-loss.
- **7 HIGH survivors:** C2, H5, H6, H7, H8 → **FIXED** (mechanisms verified, not just the tags). C3 → **MOOT**. **H10 → PARTIAL/LIVE** — the tagged fix only logs a WARN and still writes the secret in plaintext on a `crypto.Encrypt` failure (failing evidence test written).
- **MOOT bucket:** C1 (agent watchdog uses the panic-safe inverse pattern — checked the migrated code), H9 (restic retired; agent backup has no retry-reusing-context), H11 (handler gone; replacement `tier2_config_handler.go` DOES validate the dest against the registered-drive allowlist), and file-gone Lows L7/L8/L9/L11/L12/L14/L18 — all confirmed gone-and-not-migrated-with-bug.
- **Tallies (verdicted findings):** FIXED = 5 (C2,H5,H6,H7,H8) · MOOT = 11 (C3,C1,H9,H11,L7,L8,L9,L11,L12,L14,L18) · LIVE/PARTIAL = 1 (H10). Plus M2 (=fresh-audit CTRL-T3-1) is the one LIVE survivor among the mechanically-triaged Mediums.
- **Mechanical M/L triage:** ~half the Mediums and Lows are CODE-GONE (restic/crossdrive/monitor/restore_*_linux deleted); the survivors form a known backlog (deep-verify deferred per the task). Several incidentally show their fix already in place (M1 ConstantTimeCompare, M3/M13/M14/M15/M23 addressed).

**Net actionable:** the 3 validated fresh findings + **H10** (plaintext-secret-on-encrypt-failure) + **M2** (unlocked stackProvider read). Everything else is FIXED, MOOT, or a not-yet-verified backlog Medium/Low.

## Part 1 — The 7 surviving HIGH findings

### C2 — SetGeoAppOverride nil-override deref
BUGHUNT ref: line 58 (v0.30.3)
Verdict:     **FIXED**
Current:     controller/internal/settings/settings.go:1013-1030 (commit eea235b)
Evidence:    The method nil-checks `override` FIRST, before any field access:
```go
if override == nil {                       // :1016
    if s.GeoRestriction != nil && s.GeoRestriction.AppOverrides != nil { delete(...) }
    return s.save()                        // returns — no deref
}
if s.GeoRestriction == nil { ... }          // :1023 nil-GeoRestriction handled separately
// override.AllowedCountries accessed only at :1029-1030, after the guard
```
The sole caller (api/geo.go:136-137) always passes a non-nil `&settings.AppGeoOverride{}` (clear uses the separate `RemoveGeoAppOverride`). Both nil-override and nil-GeoRestriction are safe.
Confidence:  verified-static

### C3 — SSD-only apps skip DB dump restoration during DR
BUGHUNT ref: line 69 (v0.30.3)  •  **PRIORITY**
Verdict:     **MOOT (architecture now restores DB unconditionally)**
Current:     backup.go:88 (`GetAppDrivePath`), appbackup/restore.go:431, appexport/restore.go:712, export.go:562, setup/handlers.go:88 (commit eea235b)
Evidence:    Original DR files (restore_drives_linux.go, restore_app_linux.go, restore_scan.go) are gone (slice 8C de-privileged the controller). Every current restore path was enumerated; none is HDD-gated to silently omit an SSD-only DB:
1. **Whole-LXC / setup restore** — disk-recovery + infra-backup restore moved to the host agent (PBS whole-LXC vzdump; setup/handlers.go:88 comment). The setup "restore" mode only pulls config from the hub; there is NO selective DB-dump-restore step at all — DB volumes ride inside the block image.
2. **DB dump flow** (`runDBDumpsInternal`, backup.go:182) iterates `DiscoverDatabases` (docker ps), not HDD presence. `GetAppDrivePath` (backup.go:88-98) returns the HDD path if set, **else explicitly falls back to `systemDataPath`** — SSD-only dumps ARE written/restored, never dropped.
3. **Native + recovery-unit restore** (restore.go:85 `restoreDockerVolumes`, restore_unit.go:74) restore the DB's named postgres/mariadb volume tar regardless of HDD.
4. **`.fab` import** — `restoreDatabase` (appexport/restore.go:712) is gated on `manifest.HasDatabase` (set from docker-ps discovery at export.go:562), **not** HDD.
The HDDPath=="" early-return class of bug cannot occur — the fallback is in the single chokepoint and DB discovery is docker-ps-driven. Bug gone, not migrated.
Confidence:  verified-static

### H5 — SyncFileBrowserMounts no concurrency guard
BUGHUNT ref: line 132 (v0.30.3)
Verdict:     **FIXED**
Current:     controller/internal/web/handlers.go:1366-1439 (commit eea235b)
Evidence:    All public entry points (`SyncFileBrowserMounts`:1366, `SyncFileBrowserMountsReset`:1373) funnel into one private `syncFileBrowserMounts`, whose first two lines are `s.fileBrowserMu.Lock(); defer s.fileBrowserMu.Unlock()`. A real `sync.Mutex` is held across the entire body (config.yaml write, compose write, `down -v`/`up -d`) — not a racy flag. Every caller, including the 4 `go`-launched ones, reaches the file writes only through this function, so it genuinely serializes them.
Confidence:  verified-static

### H6 — PushEvent never records to event history
BUGHUNT ref: line 143 (v0.30.3)
Verdict:     **FIXED**
Current:     controller/internal/notify/notifier.go:213, 223 (commit eea235b)
Evidence:    Inside the `PushEvent` goroutine, `recordHistory` is now called on BOTH terminal outcomes: success (2xx) at :213, and failure (after 3 attempts) at :223. `recordHistory` (:550) writes the ring buffer under `historyMu`; `GetEventHistory` (:526) reads it. The history page now sees real PushEvent traffic.
Confidence:  verified-static

### H7 — PushOnce returns nil for non-2xx
BUGHUNT ref: line 154 (v0.30.3)
Verdict:     **FIXED**
Current:     controller/internal/report/pusher.go:197-227 (commit eea235b)
Evidence:    `if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil }` then `return fmt.Errorf("hub push-once: HTTP %d", resp.StatusCode)` at :227. A 4xx/5xx now yields a real error. The earlier `return nil` (:200) is only the legitimate "hub not configured" short-circuit.
Confidence:  verified-static

### H8 — tmpFile not closed/synced before rename in DB dump
BUGHUNT ref: line 165 (v0.30.3)
Verdict:     **FIXED**
Current:     controller/internal/appbackup/dbdump.go:267-298 (commit eea235b)
Evidence:    Ordering is now Sync → Close → Stat → Rename, errors checked at each step BEFORE rename:
```go
if err := tmpFile.Sync(); err != nil { os.Remove(tmpPath); return result }   // :267
if err := tmpFile.Close(); err != nil { os.Remove(tmpPath); return result }  // :273
stat, err := os.Stat(tmpPath) ...                                            // :281
if err := os.Rename(tmpPath, finalPath); err != nil { ... }                  // :293
```
The deferred `Close` (:244) is now a harmless double-close. This is the controller's only DB-dump writer (`internal/backup/` delegates to this same `appbackup.DumpOne`), so no second un-fixed copy exists.
Confidence:  verified-static

### H10 — SaveAppConfig stores secrets in plaintext on encryption failure
BUGHUNT ref: line 187 (v0.30.3)
Verdict:     **PARTIAL / LIVE** (the tagged fix added a WARN log but did NOT close the bug — cardinal-rule catch)
Current:     controller/internal/stacks/deploy.go:654-666 (commit eea235b)
Evidence:
```go
if enc, err := crypto.Encrypt(encKey, v); err == nil {
    saveCfg.Env[k] = enc; encryptedCount++; continue
} else {
    // H10 fix: log encryption failure — value will be saved in plaintext.
    log.Printf("[WARN] [stacks] Failed to encrypt env var %q: %v — saving as plaintext", k, err)  // :662
}
}
saveCfg.Env[k] = v   // :665 — FALLS THROUGH: plaintext IS persisted
```
The original complaint had two parts: (a) silent, (b) plaintext-on-disk. (a) is fixed (explicit WARN). (b) is NOT — the code still writes the secret unencrypted to app.yaml. The `// H10 fix` comment is a claim that does not match the mechanism.
Severity (reassessed by consequence): **Low–Medium.** `crypto.Encrypt` (AES-GCM, with `encKey != nil` already required to enter the branch) realistically fails only on a malformed/short key or RNG failure — rare. But when it does, that secret lands in plaintext in app.yaml (0600).
Trigger:    `crypto.Encrypt` returns an error for a sensitive var while `encKey` is non-nil (malformed/wrong-length key, RNG failure).
Impact:     One or more app secrets (DB/admin passwords) stored unencrypted in app.yaml, with only a WARN log.
Fix sketch (NOT applied): replace the fallthrough with a fail-closed `return fmt.Errorf("encrypting %q: %w", k, err)` so no plaintext is persisted (or skip the var). Note this is co-located with the CTRL-T2-1 deploy-lifecycle change — sequence them together (see Part 4).
Confidence:  **verified-by-test** — `internal/stacks/saveappconfig_h10_reconcile_test.go` (this branch). With a 5-byte key, `SaveAppConfig` logs the WARN and writes `DB_PASSWORD: supersecret-pw-do-not-leak` to app.yaml; the test asserting no-plaintext FAILS. Run: `cd controller && go test ./internal/stacks/ -run H10 -v`.

## Part 2 — MOOT bucket confirmations (+ migration checks)

### C1 — Watchdog unlock/relock panic-unsafe → checked the agent migration
Verdict:  **MOOT** (migrated, but WITHOUT the bug)
Evidence: Controller `internal/monitor/watchdog.go` is gone. The agent watchdog `/e/git/felhom-agent/internal/storage/watchdog.go` was read in full: `tick()` probes liveness OUTSIDE the lock (:250-257), takes `w.mu.Lock()` only for the in-memory state diff/debounce (:261-327), `Unlock()`s once at :327, then runs all side-effects (report trigger :336, `Remount` dispatched to a goroutine via `w.spawn` :342, `onAbsent` :349) AFTER unlocking. There is no `Unlock(); call(); Lock()` sequence and no `defer Unlock()` over a panicking call. The one IO/panic-prone op (remount) is handed to a background goroutine off the lock path. The double-unlock-on-panic pattern does not exist in the migrated code.

### H9 — Restic retry reused a possibly-expired context → checked agent backup
Verdict:  **MOOT** (restic retired; no equivalent retry migrated)
Evidence: Controller `internal/backup/restic.go` gone. Agent backup `/e/git/felhom-agent/internal/backup/runner.go:100-130` is a single linear flow (`Vzdump` → one `WaitTask(ctx, upid, {Timeout:30m})` → `latestArchive`); on error it returns immediately — no retry-after-unlock, no retry reusing a parent context. `watchForSnapshot` re-polls but returns on `ctx.Done()`. `internal/pbs/` has no retry/backoff at all. The H9 expired-context-retry pattern did not survive.

### H11 — settingsCrossBackupHandler missing dest validation
Verdict:  **MOOT** (handler gone; replacement validates)
Evidence: The original unvalidated cross-drive-backup web handler (web/handlers.go:947-994) is gone — no `CrossBackup`/`settingsCross*` HTTP handler exists in controller/internal/web or /api. The replacement `internal/web/tier2_config_handler.go` validates the POSTed `target` (:59) against the eligible registered-drive allowlist before saving (:61-77), with a runtime re-validation in the runner. Remaining `CrossDrive` references are settings/config/notify plumbing, not path-accepting handlers.

### File-gone Lows (one stroke)
| ID | Original | Verdict |
|---|---|---|
| L7 | backup/crossdrive.go double-clear running map | CODE-GONE (not migrated) |
| L8 | backup/restic.go uint64→int64 cast | CODE-GONE (restic retired) |
| L9 | backup/restic.go empty error parse | CODE-GONE |
| L11 | backup/restore_drives_linux.go fstab TOCTOU | CODE-GONE (agent has read-only mounts only; fstab deferred in agent docs) |
| L12 | backup/restore_drives_linux.go non-atomic fstab | CODE-GONE |
| L14 | backup/restore_app_linux.go hardcoded 0644 | CODE-GONE (the 0644 in backup/recovery_unit.go is new Tier-2 code using atomicWrite, unrelated) |
| L18 | backup/restore_scan.go picks first not freshest | CODE-GONE (no restore-scan/freshest logic in agent) |

## Part 3 — Medium/Low mechanical triage (existence only; survivors NOT deep-verified)

**Cross-ref:** survivors that overlap the fresh audit's "What was NOT covered" are flagged **[backlog]** — genuinely unexamined. Survivors the fresh audit already verified/fixed are flagged **[audit:…]**.

### Mediums
| ID | survives? | current file:line | note |
|---|---|---|---|
| M1 | SURVIVES | cmd/controller/main.go:769 | **already uses `subtle.ConstantTimeCompare`** (also csrf.go:40,65) → effectively addressed |
| M2 | SURVIVES | internal/backup/backup.go:122,259,331 | **LIVE = fresh-audit CTRL-T3-1** (unlocked `stackProvider` read; benign, init-only write) |
| M3 | SURVIVES | internal/settings/settings.go:934 | **[audit: FIXED]** drains only after successful save |
| M4 | SURVIVES | internal/stacks/deploy.go:59 (`SubdomainInUse`) | **[backlog]** I/O under RLock — not re-verified |
| M5 | SURVIVES | internal/stacks/manager.go:128 (`MigrateEncryption`) | **[backlog]** encKey lock — not re-verified |
| M6 | SURVIVES | internal/stacks/manager.go:128 | **[backlog]** lock-during-I/O (startup-only; likely benign) |
| M7 | GONE | — | `executeAllRestores`/web/handler_restore.go deleted (8C) |
| M8 | SURVIVES | internal/web/auth.go:113 | **[audit: partial — CTRL-009]** a limiter now exists but is XFF-spoofable |
| M9 | SURVIVES | internal/api/router.go:161+ | **[audit: addressed]** `extractName` rejects empty/`..` (fresh audit confirmed) |
| M10 | SURVIVES | internal/scheduler/scheduler.go:76,102,130 | **[audit: FIXED]** late-registration launches goroutine immediately |
| M11 | GONE | — | monitor/watchdog.go deleted; no `findStoragePath` |
| M12 | GONE | — | monitor/watchdog.go deleted |
| M13 | SURVIVES | internal/metrics/store.go:27 | **[audit: FIXED]** WAL mode now verified |
| M14 | SURVIVES | internal/metrics/collector.go:99 | **[audit: FIXED]** uses parent ctx, not Background |
| M15 | SURVIVES | internal/metrics/telemetry.go:42 | scan error now logged (not silent) |
| M16 | GONE | — | crossdrive.go deleted (`copyStackDBDumps`) |
| M17 | GONE | — | restic stats removed from backup.go |
| M18 | SURVIVES | internal/appbackup/dbdump.go:425 (`ListDumpFiles`) | **[backlog]** re-validates every dump — moved, not re-verified |
| M19 | SURVIVES | internal/appbackup/dbdump.go:536 (`deriveStackName`) | **[backlog]** naive suffix-strip — moved, not re-verified |
| M20 | GONE | — | crossdrive.go deleted (`syncInfraConfig`) |
| M21 | GONE | — | no `http.Get` anywhere |
| M22 | SURVIVES | internal/assets/syncer.go:78 | **[audit: FIXED]** lock is a running-guard; download lock-free |
| M23 | SURVIVES | internal/sync/sync.go:433 | `maskRepoURL` now applied at the log site → addressed |
| M24 | GONE | — | old storage_handlers.go:953 path-prefix code gone (only `/mnt/` guards remain) |
| M25 | SURVIVES | internal/web/server.go:128+ (`Set*`) | **[backlog]** init-order, like CTRL-T3-1/M2 — not re-verified |
| M26 | SURVIVES | internal/crypto/crypto.go:103,112 (`DecryptMap` global `log`) | **[backlog]** cosmetic logger inconsistency |

### Lows
| ID | survives? | current file:line | note |
|---|---|---|---|
| L1 | SURVIVES | cmd/controller/main.go:1172 (`fileExists`) | **[audit: dead-code]** unused (also in fresh audit's dead-code inventory) |
| L2 | SURVIVES | cmd/controller/main.go (multiple `go func`) | **[backlog]** no WaitGroup for shutdown |
| L3 | SURVIVES | internal/config/config.go:258 | `0`=unset sentinel present |
| L4 | SURVIVES | cmd/controller/main.go:189 | hardcoded metrics DB path |
| L5 | SURVIVES | cmd/controller/main.go:57 | hardcoded config-flag default |
| L6 | SURVIVES | cmd/controller/main.go:117 | string-concat path |
| L7–L9, L11, L12, L14, L18 | GONE | — | (Part 2 file-gone bucket) |
| L10 | GONE | — | infraPaths gone from backup.go |
| L13 | SURVIVES | internal/backup/backup.go:484 | 10s DB-inspect timeout present |
| L15 | GONE | — | snapshot/restic history gone |
| L16 | GONE | — | `RunIntegrityCheck` gone (restic tier removed) |
| L17 | SURVIVES | internal/backup/backup.go:95 | empty drive-path warn present |
| L19 | SURVIVES | internal/appbackup/dbdump.go:478,517 | throwaway exec for logging |
| L20 | SURVIVES | internal/appbackup/dbdump.go:320,333 (`ValidateDump` global `log`) | **[backlog]** |
| L21 | SURVIVES | internal/stacks/healthprobe.go:337 (`methodOrEmpty`) | moved to stacks/ |
| L22 | SURVIVES | internal/stacks/manager.go:468 (`aggregateState`) | **[backlog]** StatePaused handling — not re-verified |
| L23 | SURVIVES | internal/stacks/manager.go:830 | **[backlog]** composeExecCustomEnv env double-load |
| L24 | SURVIVES | internal/stacks/manager.go:930 (`logPostStartStatus`) | **[backlog]** goroutine no cancel |
| L25 | SURVIVES | internal/stacks/delete.go:510 (`ParseComposeHDDMounts`) | **[audit: mitigated]** traversal cleaned (C10 fix), naive-YAML still present |
| L26 | SURVIVES | internal/stacks/delete.go:574 (`getDirSizeHuman`) | **LIVE = fresh-audit CTRL-T2-5** (no timeout) |
| L27 | SURVIVES | internal/stacks/delete.go:80/283 + ScanStacks | **[backlog]** double update path |
| L28 | SURVIVES | internal/stacks/manager.go:591-593 | **[audit: FIXED]** Options now deep-copied (H12 fix) |
| L29 | SURVIVES | internal/web/server.go:327,363 (`serveCatchAll`) | **[backlog]** double WriteHeader |
| L30 | SURVIVES | internal/web/auth.go:219,236 | **[backlog]** CSRF token not rotated |
| L31 | GONE | — | old storage_handlers.go:1490 Content-Type code gone |
| L32 | SURVIVES | internal/web/alerts.go:54 | variadic API (cosmetic) |
| L33 | SURVIVES | internal/web/funcmap.go:329 | **[audit: reviewed]** `json` func suppresses marshal error (no XSS — html-escaped) |
| L34 | GONE | — | monitor/pinger.go deleted |
| L35 | SURVIVES | internal/metrics/store.go:19 | **[backlog]** no SQLite conn-pool limits |
| L36 | SURVIVES | internal/metrics/store.go:312 | **[backlog]** no WAL checkpoint after Prune |
| L37 | SURVIVES | internal/cloudflare/countries.go:253 | rebuilds sorted list (cosmetic perf) |

## Part 4 — Merged, severity-ordered fix list

Combines the 3 validated fresh findings with every LIVE old finding (H10) + the LIVE survivor M2. Severity reassessed by consequence.

| # | ID | Title | Repo | Location (file:line @ commit) | Severity (reassessed) | Effort | Fix sketch |
|---|---|---|---|---|---|---|---|
| 1 | CTRL-001 | Import path traversal via unvalidated `manifest.AppName` | controller | appexport/restore.go:339,365,401 @ eea235b | **High (Critical-adjacent)** | S | Reject `AppName` unless single safe segment (`^[a-z0-9][a-z0-9-]*$`); same for archive subdir/config-file names |
| 2 | CTRL-T2-1 | `app.yaml` persists `Deployed:true` before `compose up -d` → ghost-deployed stuck stack on crash | controller | stacks/deploy.go:294-330 @ eea235b | High | M | Persist `Deployed:false`/a `deploying` flag before compose; flip true only after success; or startup-reconcile zero-container recent-deploy → failed |
| 3 | H10 | SaveAppConfig persists secret in PLAINTEXT on encrypt failure | controller | stacks/deploy.go:654-665 @ eea235b | Low–Medium | S | Fail-closed: `return err` instead of falling through to plaintext write |
| 4 | AGENT-001 | Inline customer-confirmed wipe formats mutable `/dev` path (classify→mkfs TOCTOU) → wrong-disk wipe | agent | localapi/disks.go:429-471 @ d17b5ab | Medium (data-loss consequence) | M | Re-resolve durable→path + re-inspect immediately before Format (mirror `WipeExecutor.Execute`) |
| 5 | M2 / CTRL-T3-1 | `backup.Manager.stackProvider` read without the mutex that guards its write | controller | backup/backup.go:89,122,259,331,400,491 @ eea235b | Low (benign; init-only write) | S | Drop the pointless lock on the init write, or add a locked accessor for the 11 reads |

**CTRL-001 severity note (Critical vs High):** it is an arbitrary-directory write as the controller process (root-in-container). On the no-password demo posture, import is reachable unauthenticated, and a written `docker-compose.yml` placed in a stacks path can later be deployed → effectively code-execution-within-container. That argues **Critical**. It is bounded by the de-privileged container and (on password-set deployments) RequireAuth+CSRF, which argues **High**. Verdict: **High, treat as Critical operationally** — and it is the cheapest fix on the list, so it goes first regardless.

### Recommended fix order + rationale
1. **CTRL-001 first** — trivial (one validator) for the highest security consequence; remove the arbitrary-write primitive before anything else.
2. **Deploy-lifecycle slice: CTRL-T2-1 + H10 together** — both edit `stacks/deploy.go` (the deploy goroutine + `SaveAppConfig`). **They INTERACT — sequence them in one slice/PR to avoid merge conflict.** H10 is the smaller change (fail-closed return); fold it in while restructuring the deploy/save ordering for CTRL-T2-1. Net: deploy state is honest on crash AND no plaintext secret can be written.
3. **AGENT-001 — spike-first, isolated review.** It is the only change touching the DESTRUCTIVE wipe path; do a focused spike (adopt the `WipeExecutor` resolve→re-derive→re-inspect-before-Format structure) and review it in isolation on the agent repo. Do NOT bundle with controller work. The live USB-reenumeration race is hard to reproduce — fix by code-structure parity with the already-correct signed-jobs path, not by chasing a live repro.
4. **M2 / CTRL-T3-1 last** — trivial cleanup; benign today (init-only write). Opportunistic, or fold into a `-race`-driven cleanup pass.

## Part 5 — Next-session plan

First fix slice: **CTRL-001** (add the path-segment validator — minutes, highest consequence) then the **deploy.go slice (CTRL-T2-1 + H10)** since they are co-located and both touch deploy/SaveAppConfig. Hold **AGENT-001** for a separate spike + isolated review on the agent repo (structure-parity with `WipeExecutor`, no live-repro dependency). Runtime confirmation still wanted before/after: a one-shot CTRL-T2-1 repro (kill the controller during a large-image deploy, confirm the ghost-deployed state, then re-confirm the fix clears it) — AGENT-001 and H10 need no runtime repro (both have static/structural fixes; H10 has a failing unit test that will flip green).

## Session notes, assumptions, what was NOT verified

- **Cardinal rule paid off on H10:** the `// H10 fix` tag claimed a fix that the mechanism does not deliver. All other fix-tags (C2/C03, H5, H6, H7, H8) were verified to match their mechanism.
- **MOOT proofs included migration checks** (per the mandate): C1 → agent `storage/watchdog.go` read in full (panic-safe inverse pattern, no bug); H9 → agent `backup/runner.go` + `pbs/` (no retry-reusing-context); H11 → replacement `tier2_config_handler.go` validates dest.
- **Part 3 is existence-only** (per task) — survivors flagged `[backlog]` are NOT verdicted FIXED/LIVE; they are the genuinely-unexamined set for a future deep pass. The `[backlog]` Mediums most worth a look: M4/M5/M6 (stacks lock-during-I/O), M18/M19 (appbackup dump validation/naming), M25 (web Server Set* init-order, same class as M2).
- **Assumption:** "one customer per host" / de-privileged single-tenant container (affects CTRL-001's Critical-vs-High framing and the exposure of M2/CTRL-T3-1).
- **Not verified (out of scope for reconciliation):** the deep mechanism of every surviving Medium/Low (Part 3 is a triage, not an audit); any runtime/`-race` confirmation (none run this session).
- Two evidence tests written, both FAIL at the recorded commit: controller `internal/stacks/saveappconfig_h10_reconcile_test.go` (H10). (The fresh-audit branch already carries CTRL-001 and AGENT-T2-1 evidence tests; not duplicated here.)
- No production source modified; BUGHUNT.md / AUDIT-2026-06-13.md / CHANGELOG / README / CONTEXT / REPORT untouched.
