# Progress

## Phase 0 — Foundation: complete

Phase 0 shipped the foundation that subsequent phases build on. See the [Phase 0 brief](CLAUDE.MD) for the original deliverables checklist.

- Scaffolding, `.gitignore`, `migrations/001_phase0_foundation.sql` (four Phase 0 tables matching PROJECT.md Appendix A).
- `internal/logging` — zerolog setup + `Redact()` helper, tested against every sensitive field in CLAUDE.md.
- `internal/secrets` — age-encrypted Set/Get/Delete backed by the SQLite `secrets` table.
- `internal/config` — YAML loader + typed defaults + fsnotify hot-reload with rollback on invalid.
- `internal/db` — `OpenSQLite` (modernc.org/sqlite, pure-Go), `OpenDuckDB` (marcboeker/go-duckdb v2), numbered-file migration runner.
- `internal/observability` — `EventRecorder` (dual-write to logger + `system_events`) and `HealthChecker`.
- `internal/server` — Chi router with request-id / real-ip / recoverer / structured-access-log middleware; Tailscale enumeration; `dev_mode` 127.0.0.1 fallback per DECISIONS.md D3/D7.
- Root `embed.go` exposes `migrations/` and `internal/webui/dist/` as embedded FSes consumable from `cmd/command-center/`.
- Frontend (`web/`) — Vite + React + TS + Tailwind + shadcn config + vite-plugin-pwa with a valid PWA manifest and SVG icons.
- Operations: `systemd/command-center.service` (DynamicUser, hardened); `scripts/build.sh`, `scripts/dev.sh`.
- Documentation: `README.md`, `DEPLOYMENT.md`, `TESTING.md`, `DECISIONS.md`.

## Phase 1 — First Tracker Scrape, First View: complete

End-to-end value: the operator can register a private tracker, the system periodically scrapes it, and the PWA shows current ratio + history. Phase 1 introduced the integration-adapter pattern, the scrape hygiene primitive, the periodic scheduler, and the snapshot dual-write the rest of the project leans on. See the [Phase 1 brief](CLAUDE-phase1.md).

### Completed

- `migrations/002_phase1_trackers.sql` — `trackers`, `ratio_snapshots`, `torrents`, `torrent_trackers` exactly per PROJECT.md Appendix A.
- `internal/scrape/hygiene.go` — outbound HTTP wrapper with per-tracker min-spacing, Retry-After honoring, exponential backoff with jitter, cookie loading from the Phase 0 secrets store. Tested for spacing, Retry-After, exponential backoff, and context cancel.
- `internal/scrape/classifier.go` — auth / structural / network / rate-limit classification per PROJECT.md §8.9; includes a "200 but looks-like-a-login-page" heuristic for tracker session-expiry cases.
- `internal/scrape/scheduler.go` — one goroutine per enabled tracker, configurable interval + jitter, supports manual `Refresh(trackerID)` and `Stop()`. Tested for interval ticks, manual refresh, and clean shutdown.
- `internal/scrape/adapter.go` — `Adapter` interface and `RatioSnapshot` type (placed in `scrape` to avoid a `scrape → trackers → scrape` import cycle; see [DECISIONS.md D10](DECISIONS.md)).
- `internal/integrations/trackers/` — adapter factory registry and the MyAnonamouse implementation. MAM uses the `mam_id` cookie via the hygiene primitive, hits `/jsonLoad.php`, parses the documented response fields. Tested against `httptest.Server` fixtures (happy path, real-fields-absent fallback, error response, 401, health check).
- `internal/config/trackers.go` + `trackers_watcher.go` — typed YAML loader for `config/trackers.yaml`, validation, fsnotify hot-reload mirroring the Phase 0 Manager. Absent file is intentionally not an error (operator may start with no trackers).
- `internal/db/snapshots.go` — `SnapshotWriter` dual-writes `ratio_snapshots` rows to SQLite and DuckDB; `UpsertTracker` keeps the SQLite `trackers` table in sync with `trackers.yaml` so foreign-key references resolve.
- `internal/server/routes_trackers.go` — `GET /api/trackers/`, `GET /api/trackers/{id}`, `GET /api/trackers/{id}/snapshots?range=...`, `POST /api/trackers/{id}/refresh`. The server's `Options` now accepts an `SQLite` + `Scheduler` field; both nil-tolerant.
- `cmd/command-center/main.go` — wires the trackers manager, hygiene, scheduler, snapshot writer. A reload of `trackers.yaml` reconstructs the scheduler's job list and re-upserts the SQLite trackers table.
- `scripts/set-tracker-cookie/main.go` — operator helper that writes the tracker cookie into the age-encrypted secrets store. Documented in DEPLOYMENT.md.
- `config/trackers.yaml` — ships with one MyAnonamouse entry, **disabled by default**. Enable it after placing the cookie via the helper above.
- Frontend (`web/src/`):
    - Tiny custom path-based router (`lib/router.tsx`) — two routes (`/` dashboard, `/trackers/:id` detail), avoids the react-router-dom dependency.
    - `lib/api.ts` — typed fetch wrappers around `/api/*`. Grows phase by phase.
    - `lib/pwa.ts` — `beforeinstallprompt` capture, install button on Android/desktop, iOS Share→Add-to-Home-Screen hint.
    - `pages/Dashboard.tsx` — system health + trackers list with last-scrape recency.
    - `pages/TrackerDetail.tsx` — ratio cards, range selector (24h/7d/30d/all), refresh-now button.
    - `components/RatioChart.tsx` — hand-rolled SVG line chart (no charting dependency).

### Verification (on this dev host)

```
go test -tags no_duckdb ./...
ok  github.com/operator/command-center/internal/config           0.641s
ok  github.com/operator/command-center/internal/db               0.352s
ok  github.com/operator/command-center/internal/integrations/trackers  0.236s
ok  github.com/operator/command-center/internal/logging          0.175s
ok  github.com/operator/command-center/internal/observability    0.253s
ok  github.com/operator/command-center/internal/scrape           36.601s
ok  github.com/operator/command-center/internal/secrets          0.386s
ok  github.com/operator/command-center/internal/server           0.275s
```

End-to-end probes:
- `GET /api/system/health` → 200, schema_version=2, all checks ok (duckdb degraded under `no_duckdb` build).
- `GET /api/trackers/` → 200, `{count, trackers}` shape.
- `GET /api/system/events` → boot event present with `schema_version: 2`.
- `/` serves the Phase 1 PWA bundle; the dashboard renders the empty-trackers hint when `config/trackers.yaml` ships disabled.

### Operator handoff — Phase 1

To complete Phase 1 against a live MyAnonamouse account:

1. Build: `./scripts/build.sh`.
2. Run once to generate the age key (dev_mode in `config/system.yaml`): `./command-center -config ./config`. The age key path is logged on startup.
3. Stop the binary.
4. Place the MAM session cookie: `go run ./scripts/set-tracker-cookie -id mam -cookie "mam_id=<your-token>" -age-key <path-from-step-2>`.
5. Edit `config/trackers.yaml` and set `enabled: true` on the `mam` entry.
6. Start the binary again. Within a minute (the staggered initial scrape), a snapshot appears in `ratio_snapshots`.
7. Open `https://127.0.0.1:8443/trackers/mam` and watch the chart populate as scrapes accumulate.

### Deviations

See `DECISIONS.md` D10 (Adapter interface placement) and D11 (chosen first tracker: MyAnonamouse, rationale: the `mam_id` field name in CLAUDE.md's redaction list is operator signal).

## Phase 2 — Authentication: complete

WebAuthn passkey authentication. After Phase 2 every `/api/*` route except a small bootstrap allowlist requires a valid session cookie; the operator registers a passkey on first visit, receives ten recovery codes, and can add/remove devices from a settings page. See the [Phase 2 brief](CLAUDE-phase2.md).

### Completed

- `migrations/003_phase2_auth.sql` — `webauthn_credentials`, `recovery_codes`, `sessions` exactly per PROJECT.md Appendix A.
- `internal/auth` package (10 files):
    - `sessions.go` — random 32-byte tokens base64url-encoded; SQLite-backed; HTTP-only Secure SameSite-Strict cookie; sliding expiry on every authenticated request; tested for issue / validate / tampered / expired / revoke / renew / purge.
    - `recovery.go` — 10 bcrypt-hashed codes per batch, plaintext returned exactly once, normalized comparison (case/dashes/whitespace tolerant), single-use enforced; generating a fresh batch invalidates the previous batch's unused codes.
    - `ratelimit.go` — per-key token-bucket. Two limiter instances: login (5/5min default), recovery (3/hour default), both configurable.
    - `middleware.go` — closed exemption list of public routes; everything else under `/api/*` requires a valid session; stashes the validated `*Session` in request context.
    - `credentials.go` — `webauthn_credentials` table CRUD; counter updates on every successful assertion (replay defense).
    - `webauthn.go` — wraps `github.com/go-webauthn/webauthn`. In-memory ceremony state keyed by a short-lived `cc_webauthn_ceremony` cookie scoped to `/api/auth/webauthn/`.
    - `user.go` — single-operator `User` model with stable user_id stored in secrets (`auth:user_id`).
    - `transports.go` — JSON ↔ `protocol.AuthenticatorTransport` slice round-trip.
    - `audit.go` — wraps the Phase 0 `audit_log` table; called from every register/login/logout/recovery/device-delete path.
- Config extension: `AuthConfig` with smart dev_mode defaults. In dev_mode `RPID` defaults to `localhost` and `RPOrigins` to `[http://127.0.0.1:<port>, http://localhost:<port>]`; in production both must be set in `config/system.yaml` and `Validate()` rejects misconfiguration loudly.
- `internal/server/routes_auth.go` — nine handlers covering status / register-start / register-finish / login-start / login-finish / me / logout / recovery / devices-list / devices-delete, with audit log entries on every state transition.
- Server wiring: `auth.NewMiddleware(...)` is mounted on the chi root with the closed exemption list. The non-API path tree (`/`, `/assets/*`, `/manifest.webmanifest`) is unaffected by auth so the bundle loads.
- Frontend (`web/src/`):
    - `lib/auth.ts` — `useAuth()` returns `loading | needs_register | needs_login | authenticated | error`.
    - `pages/Register.tsx` — `@simplewebauthn/browser` `startRegistration`, shows the recovery-codes modal once on first registration.
    - `pages/Login.tsx` — `startAuthentication` + a "Lost access? Use a recovery code" path.
    - `pages/Devices.tsx` — list, add (uses authed registration path), delete with last-credential guard, sign out.
    - `components/RecoveryCodesModal.tsx` — displays the codes with copy-to-clipboard and a mandatory "I've saved these" checkbox.
    - Routes extended: `/settings/devices` now resolves.
    - `App.tsx` gates the route tree behind `useAuth()`: a fresh-install visitor sees Register; an authenticated visitor sees the normal app; a previously-registered-but-signed-out visitor sees Login with a recovery-code fallback.

### Verification

```
go test -tags no_duckdb ./...
ok  …/internal/auth        4.263s
ok  …/internal/config       0.674s
ok  …/internal/db           0.476s
ok  …/internal/integrations/trackers  0.242s
ok  …/internal/logging      0.178s
ok  …/internal/observability  0.309s
ok  …/internal/scrape      40.676s
ok  …/internal/secrets      0.501s
ok  …/internal/server       0.328s
```

End-to-end probes against a fresh-state binary:
- `GET /api/auth/status` → 200 `{"registered":false}` (public, bootstrap).
- `GET /api/trackers/` → 401 (middleware enforced).
- `GET /api/system/health` → 200 (exempt).
- `GET /` → 200 (frontend bundle, unaffected by API middleware).
- `schema_version` → 3 (003_phase2_auth applied).
- `rp_id` = `localhost`, `rp_origins` = `[http://127.0.0.1:8443, http://localhost:8443]` (dev_mode auto-defaults).

The WebAuthn ceremonies themselves require a real authenticator (Touch ID / security key / Windows Hello). The library handles the protocol; this codebase wires it to the database and to session-cookie issuance. Manual verification with a real authenticator is the operator's responsibility on first deployment.

### Operator handoff — Phase 2

1. Build: `./scripts/build.sh`.
2. Start: `./command-center -config ./config`.
3. Browse to `http://127.0.0.1:8443/` (dev) or `https://<tailscale-hostname>:8443/` (prod).
4. Frontend offers "Register this device" — browser prompts for a passkey.
5. After registration, ten recovery codes appear once. Save them. Confirm before continuing.
6. Dashboard loads; Devices link in the header → `/settings/devices` where you can add a second device.
7. Sign out and back in to confirm the passkey flow works in both directions.

### Deviations

See `DECISIONS.md` D14 (single-operator user identity), D15 (ceremony state in memory), D16 (recovery clears all credentials), D17 (closed-list middleware exemptions).

## Blocked

None.

## Next

Phase 3: qui and qBittorrent integration with the WebSocket event-bus seed. See [CLAUDE-phase3.md](CLAUDE-phase3.md). **Do not begin Phase 3 from this PROGRESS.md entry**; re-read the brief, confirm the assumptions match Phase 0/1/2 reality (especially the eventbus skeleton Phase 3 plants, and the auth middleware mounted on `/api/*`), then start.

## Phase 3 — qui/qBittorrent + WebSocket eventbus: complete

End-to-end torrent visibility. The operator configures one or more torrent clients via `config/torrent-clients.yaml`, the snapshot collector polls each on its own interval, and the PWA renders a live torrent list with detail views and the standard pause/resume/recheck/delete mutations. See the [Phase 3 brief](CLAUDE-phase3.md).

### Completed

- `migrations/004_phase3_torrents.sql` — `torrent_clients` table + SQLite copy of `torrent_snapshots` matching PROJECT.md Appendix A. The DuckDB copy was created in Phase 0; the SQLite copy is for cheap recent-history reads.
- `internal/eventbus` (3 files) — typed pub/sub. Named topics in `topics.go`, at-most-once delivery, slow-subscriber-dropped backpressure per [DECISIONS.md D18]. Tested for fan-out, isolation, drops, concurrent subscribe/publish.
- `internal/integrations/qbit` (4 files) — `TorrentClient` interface plus a unified qBittorrent WebUI client registered under two type strings (`qbit` direct, `qui` reverse-proxy) per [DECISIONS.md D20]. Login (cookie-based session, 50-minute reuse window), Sync (`/sync/maindata` with `rid` for diffs), List/Get/Trackers (read), Pause/Resume/Recheck/Delete/SetCategory/SetTags/Add (mutate), Health. Tested against an `httptest.Server` simulating the qBittorrent WebUI API.
- `internal/config/torrentclients.go` + `torrentclients_watcher.go` — typed YAML loader + fsnotify hot-reload mirroring the Phase 1 pattern.
- `internal/db/snapshots.go` extended — `WriteTorrentSnapshot` (SQLite + DuckDB dual write), `UpsertTorrentClient` (FK target for snapshots), `UpsertTorrent` (operator-facing metadata).
- `internal/snapshot/torrents.go` — per-client `Collector`. Each worker calls `client.Sync(rid)` on its configured interval, writes one snapshot row per torrent per cycle, detects state transitions, publishes `TorrentEvent` rows to `eventbus.TopicTorrents`. `Replace(jobs)` rebuilds the worker set on hot-reload.
- `internal/server/routes_torrents.go` — nine handlers: list (with state/category/client filters), get-by-hash, history (with range), trackers (live via the client), pause/resume/recheck/delete/add. List joins the SQLite `torrents` table with the latest `torrent_snapshots` row per torrent via a `ROW_NUMBER()` window for efficient "current state" reads.
- `internal/server/ws.go` — `GET /ws` mounted outside `/api/*` so the auth middleware doesn't apply ([DECISIONS.md D22]). The single-topic subscription is hard-coded to `TopicTorrents` in Phase 3; Phase 4 adds the subscribe/unsubscribe wire protocol. Uses `github.com/coder/websocket` ([DECISIONS.md D19]).
- Server wiring: `Options.Clients` + `Options.Bus`. `ClientsRegistry` is a narrow interface (`Get(id)`, `IDs()`) main-side, satisfied by the per-reload-rebuilt map in `cmd/command-center/main.go`.
- Frontend (`web/src/`):
    - `lib/ws.ts` — single shared WebSocket client with linear-backoff reconnect; `useTopic<T>(topic)` hook returns the latest event + an `open` flag.
    - `lib/api.ts` extended with torrent endpoints (typed `TorrentRow`, `TorrentSnap`, `TorrentTrackerRow`).
    - `pages/Torrents.tsx` — sortable, filterable table; live updates trigger a refresh on every `TorrentEvent`; ● live / ○ offline indicator from the WS open state.
    - `pages/TorrentDetail.tsx` — per-tracker live status, ratio/uploaded/size cards, the five mutations (pause/resume/recheck/remove/delete-with-files), all with operator confirmation prompts for destructive actions.
    - Routes extended: `/torrents` and `/torrents/{hash}`.
    - Dashboard header gains a Torrents link.
- `config/torrent-clients.yaml` — ships one disabled qBittorrent entry; `config/torrent-clients.example.yaml` shows both `qbit` and `qui` shapes.
- `scripts/set-torrent-client-password/main.go` — operator helper that writes a WebUI password into the age-encrypted secrets store ([DECISIONS.md D23]).

### Verification

```
go test -tags no_duckdb ./...
ok  …/internal/auth                     4.381s
ok  …/internal/config                   0.678s
ok  …/internal/db                       0.486s
ok  …/internal/eventbus                 0.239s
ok  …/internal/integrations/qbit        0.243s
ok  …/internal/integrations/trackers    0.259s
ok  …/internal/logging                  0.185s
ok  …/internal/observability            0.312s
ok  …/internal/scrape                  34.971s
ok  …/internal/secrets                  0.502s
ok  …/internal/server                   0.427s
```

End-to-end probes against a fresh-state binary:
- `schema_version` → 4 (003 + 004 applied).
- `GET /api/auth/status` → 200 (public).
- `GET /api/torrents/` → 401 (middleware enforced).
- `GET /api/system/health` → 200 (`sqlite=ok, age_key=ok, config=ok`; `duckdb=degraded` under no_duckdb).
- "torrent collector updated count=0" — the disabled-by-default config produces zero workers; enabling a client produces the worker without restart.
- WebSocket at `/ws` — endpoint registered; the in-browser `useTopic("torrents")` hook subscribes and the ● live indicator shows when the connection is up.

The qBittorrent ceremony against a real client requires actual qBittorrent + WebUI credentials. The Go code is tested against a synthetic `httptest.Server` that mimics the WebUI API responses; operator-side verification with a real qBittorrent is the next step.

### Operator handoff — Phase 3

1. Build: `./scripts/build.sh`.
2. Place qBittorrent WebUI password: `go run ./scripts/set-torrent-client-password -id qbit -password "your-webui-password" -age-key <path>`.
3. Edit `config/torrent-clients.yaml`: set `enabled: true`, point `base_url` at the qBittorrent WebUI, set `config.username`.
4. Start the binary. Within one poll cycle (30 s default) the SQLite `torrent_clients` and `torrents` tables populate, and snapshots accumulate in both stores.
5. Open `https://127.0.0.1:8443/torrents` — the list renders; the ● live indicator turns green as the WebSocket subscribes to `torrents`.
6. Tap a torrent → detail page shows per-tracker status (live from qBittorrent) and the action buttons.
7. Test pause/resume/recheck/delete; each should reflect in the qBittorrent UI within one poll cycle.
8. Edit `config/torrent-clients.yaml` to change the poll interval — observe the new cadence without restart.

### Deviations

See `DECISIONS.md` D18 (eventbus delivery semantics), D19 (WebSocket library: coder/websocket), D20 (single qbit adapter under two type strings), D21 (deferred virtualized table), D22 (WS outside auth middleware), D23 (torrent client password via helper script, mirrors Phase 1 tracker cookie pattern).

## Blocked

None.

## Next

Phase 4: Event bus promotion + HMAC webhook ingress for autobrr / qBittorrent / cross-seed. See [CLAUDE-phase4.md](CLAUDE-phase4.md). **Do not begin Phase 4 from this PROGRESS.md entry**; re-read the brief, confirm the eventbus and WebSocket-handler skeletons match what Phase 4 needs (particularly: Phase 4 introduces the typed subscribe/unsubscribe wire protocol Phase 3's `/ws` deliberately punted on, and the reconciliation loop the snapshot collector currently approximates), then start.

## Phase 4 — Webhook ingress + SSE + reconciliation: complete

Promoted the system from poll-driven to event-driven. The Command Center now exposes signed public webhook endpoints for autobrr / qBittorrent / cross-seed, a multiplexed SSE stream for the frontend, and a periodic reconciliation loop that subsequent phases plug per-integration catch-up logic into. See the [Phase 4 brief](CLAUDE-phase4.md).

### Completed

- `migrations/005_phase4_webhooks.sql` — `webhook_endpoints` table per Appendix A. The `secret_token` column holds the secrets-store key (`webhook:<id>:token`), never the plaintext ([DECISIONS.md D25]).
- `internal/eventbus/topics.go` extended — added `TopicTorrentCompleted`, `TopicTorrentStateChanged`, `TopicTrackerStatusChanged`, `TopicAutobrrGrab`, `TopicCrossseedMatchFound`, `TopicCrossseedMatchApplied`, `TopicWebhookCustom`. The original `TopicTorrents` is preserved as the fan-out destination for every torrent-related event so Phase 3 subscribers continue working.
- `internal/webhooks` package (6 files + tests):
    - `types.go` — typed event shapes: `AutobrrGrabEvent`, `QbitEvent`, `CrossseedEvent`, `CustomEvent`.
    - `hmac.go` — HMAC-SHA256-or-token validator that accepts `X-Hub-Signature-256` / `X-Webhook-Signature-256` / `X-Signature-256` (HMAC) or `X-Webhook-Token` / `Authorization: Bearer <token>` (plain token); constant-time comparison throughout ([D24]). 7 tests cover valid/tampered/empty/malformed.
    - `registry.go` — CRUD over the `webhook_endpoints` table plus secrets-store integration. `Create()` generates a fresh 32-byte token, stores it in secrets, returns plaintext once. `RotateToken()` is the same operation against an existing endpoint. `Delete()` clears both the row and the secret.
    - `autobrr.go` / `qbit.go` / `crossseed.go` / `generic.go` — per-source handlers that parse their tool's payload shape and publish typed events. qbit handler publishes on both the kind-specific topic AND `TopicTorrents` so Phase 3 subscribers see it. cross-seed handler handles both `MATCH`/`INJECTED` event names and the older/newer field-name variants.
    - `handlers_test.go` — each handler tested with synthetic JSON payloads against an in-memory eventbus.
- `internal/server/routes_webhooks.go` — public ingress at `POST /webhook/{type}/{id}` (HMAC inside the handler, no auth middleware) plus authenticated CRUD at `/api/webhooks/{,/{id},/{id}/rotate}`. URL-path type must match stored `handler_type` (defense against operator misconfiguration). Every CRUD action writes to the Phase 0 audit log.
- `internal/server/sse.go` — `GET /sse/events?topic=<csv>` — Server-Sent Events multiplex. Mounted under `/sse/`, which the auth middleware now gates ([D26]). Emits `event: <topic>\ndata: <json>\n\n` frames plus a 20-second keep-alive comment.
- `internal/scrape/reconcile.go` — `ReconcileLoop` with a `Reconciler` interface. Phase 4 ships the loop with an empty registry; phases 5 (autobrr), 9 (tqm/crossseed) plug in concrete reconcilers via `loop.Add()` ([D27]). 3 tests cover interval ticks, error tolerance, and clean shutdown.
- `internal/auth/middleware.go` — gated-prefixes list expanded to include `/sse/` alongside `/api/`. The closed exemption list is unchanged.
- `cmd/command-center/main.go` — constructs `webhooks.Registry`, the four handlers (autobrr, qbit, crossseed, generic), and the `ReconcileLoop`. Passes a `*server.WebhookDeps` into `server.Options`.
- Frontend (`web/src/`):
    - `lib/sse.ts` — `useSSE<T>(topics)` hook over `EventSource`. Browser-native reconnect handles the dropouts.
    - `lib/api.ts` extended with webhook endpoints.
    - `pages/Webhooks.tsx` — list/create/toggle/rotate/delete with a one-time token display modal (similar UX shape to recovery codes).
    - Routes extended: `/settings/webhooks`.
    - Dashboard header gains a Webhooks link.
- `scripts/qbit-hook-template.sh` — bash template the operator installs in qBittorrent's "Run external program on torrent finished" setting. POSTs a typed JSON envelope with `X-Webhook-Token` to `/webhook/qbit/<id>`.

### Verification

```
go test -tags no_duckdb ./...
ok  …/internal/auth                     4.297s
ok  …/internal/config                   0.676s
ok  …/internal/db                       0.501s
ok  …/internal/eventbus                 1.265s
ok  …/internal/integrations/qbit        0.223s
ok  …/internal/integrations/trackers    0.223s
ok  …/internal/logging                  0.173s
ok  …/internal/observability            0.301s
ok  …/internal/scrape                  42.706s
ok  …/internal/secrets                  0.437s
ok  …/internal/server                   0.277s
ok  …/internal/webhooks                 0.181s
```

End-to-end probes:
- `schema_version` → 5 (003 + 004 + 005 applied).
- `POST /webhook/autobrr/missing` → 401 (no info-leak between "endpoint doesn't exist" and "endpoint exists but signature invalid").
- `GET /sse/events?topic=torrents` → 401 (auth middleware now gates `/sse/`).
- `GET /api/webhooks/` → 401 (authed CRUD).
- `/api/system/health` → 200 (public, unchanged).

Manual ceremony testing (HMAC sign with the operator's chosen token, configure autobrr/cross-seed/qBittorrent to call the URL, watch the eventbus fan out) is the next operator-side step.

### Operator handoff — Phase 4

1. Build: `./scripts/build.sh`.
2. Sign in to the PWA (auth from Phase 2). Open `/settings/webhooks`.
3. Create an `autobrr` endpoint named e.g. `autobrr-main`. Copy the token shown once.
4. In autobrr's webhook action template, send a JSON body with the [autobrr.go](internal/webhooks/autobrr.go) field shape and include header `X-Webhook-Token: <token>`. Test fire a filter.
5. Confirm the event appears: in logs, in the dashboard activity feed (Phase 6 surfaces this), and via SSE: `curl -N 'http://127.0.0.1:8443/sse/events?topic=autobrr.grab' -b 'cc_session=...'`.
6. Repeat for `crossseed` and `qbit` (using `scripts/qbit-hook-template.sh` for the latter).
7. Verify rotation: rotate the token, observe stale callers start failing within seconds.

### Deviations

See `DECISIONS.md` D24 (HMAC-or-token validation), D25 (secret_token column holds secrets-store key, not plaintext), D26 (SSE under `/sse/`, auth-gated), D27 (reconcile loop ships empty), D28 (handler_type immutable post-create).

## Blocked

None.

## Next

Phase 5: autobrr REST integration + filter performance correlation. See [CLAUDE-phase5.md](CLAUDE-phase5.md). **Do not begin Phase 5 from this PROGRESS.md entry**; re-read the brief, confirm the Phase 4 eventbus topics + webhook handler match Phase 5's "linker reads autobrr.grab events" assumption, then start.

## Phase 5 — autobrr REST + filter performance correlation: complete

Every autobrr grab the webhook reports now produces a `filter_performance` row. The scorer runs every 15 minutes (Phase 4's reconcile loop) to link unlinked rows by fuzzy name match against newly-seen torrents AND refresh `final_uploaded_bytes` / `final_ratio` from the latest `torrent_snapshots`. The first phase that surfaces a metric no underlying tool produces. See the [Phase 5 brief](CLAUDE-phase5.md).

### Completed

- `migrations/006_phase5_automation.sql` — `automation_tools` + `filter_performance` matching PROJECT.md Appendix A. Indexes on `(tool_id, filter_external_id)`, partial index on unlinked rows, partial index on linked rows by hash.
- `internal/integrations/autobrr` (3 files + tests) — typed REST client with `X-API-Token` auth. Filters/Filter/RecentReleases (tolerant of autobrr's wrapped {data, count} vs direct-array response shapes)/IndexerStatuses/Health. 8 tests against an httptest.Server simulating the autobrr API.
- `internal/config/automationtools.go` + `_watcher.go` — typed YAML loader + fsnotify hot-reload following the Phase 1/3 pattern.
- `internal/db/snapshots.go` extended with `UpsertAutomationTool` so foreign keys from `filter_performance` resolve.
- `internal/performance` (3 files + tests):
    - `linker.go` — `Linker` subscribes to `eventbus.TopicAutobrrGrab` and writes one `filter_performance` row per grab. Idempotent on `(filter_id, release_name, grabbed_at)`. `RecordGrab(...)` is also exported so the Phase 4 reconciler can replay missed events through the same code path.
    - `scorer.go` — `Scorer` implements `scrape.Reconciler`. Two passes per cycle: (a) fuzzy-match unlinked rows against `torrents.name`; (b) update `final_uploaded_bytes` / `final_ratio` from the most recent `torrent_snapshots` row per linked hash via a ROW_NUMBER() window.
    - 6 tests covering linker subscription, hash and unlinked grab paths, fuzzy linking, score refresh, and "leave unmatched rows alone" semantics.
- `internal/eventbus/bus.go` — `unsubscribe` no longer closes the subscriber's channel. Closing raced with the concurrent send path in `Publish`; documented in [DECISIONS.md D31].
- `internal/server/routes_automation.go` — five handlers: `/api/automation/tools`, `/tools/{id}/status`, `/filters`, `/filters/{id}`, `/releases`. List/detail endpoints aggregate from `filter_performance`; status/releases call through to the autobrr REST client.
- `cmd/command-center/main.go` — constructs the `AutomationToolsManager`, builds an `autobrr.Client` per enabled tool, registers the `Scorer` with the Phase 4 reconcile loop, starts the `Linker` (subscribed to the bus).
- Frontend (`web/src/`):
    - `lib/api.ts` extended with `automationTools`, `automationFilters`, `automationFilter`.
    - `pages/Automation.tsx` — Tools + Filters-by-performance ranked table with linked-percent indicators.
    - `pages/FilterDetail.tsx` — stat cards + recent-grabs list with unlinked-flag callout.
    - Routes extended: `/automation`, `/automation/filters/{id}?tool_id=...`. Dashboard header gains Automation link.
- `config/automation-tools.yaml` + `config/automation-tools.example.yaml` — ships one disabled autobrr entry.
- `scripts/set-automation-token/main.go` — operator helper that places the autobrr API token in the secrets store (mirrors Phase 1/3 helpers).

### Verification

```
go test -tags no_duckdb ./...
ok  …/internal/auth                         …
ok  …/internal/config                       …
ok  …/internal/db                           …
ok  …/internal/eventbus                     …
ok  …/internal/integrations/autobrr         …
ok  …/internal/integrations/qbit            …
ok  …/internal/integrations/trackers        …
ok  …/internal/logging                      …
ok  …/internal/observability                …
ok  …/internal/performance                  …
ok  …/internal/scrape                       …
ok  …/internal/secrets                      …
ok  …/internal/server                       …
ok  …/internal/webhooks                     …
```
14 packages green, including the new autobrr + performance packages and the eventbus race fix.

End-to-end probes:
- `schema_version` → 6 (003 + 004 + 005 + 006 applied).
- `POST /api/automation/tools` → 401 (middleware enforced; CRUD reads require a session).
- `/api/system/health` → 200.
- `automation clients rebuilt count=0` (config ships disabled).

### Operator handoff — Phase 5

1. Build: `./scripts/build.sh`.
2. Place autobrr API token: `go run ./scripts/set-automation-token -id autobrr -token "your-autobrr-api-token" -age-key <path>`.
3. Edit `config/automation-tools.yaml`: set `enabled: true`.
4. In the Command Center UI (signed in), open `/settings/webhooks` and create a webhook with `id=autobrr`, `handler_type=autobrr`. Copy the shown-once token.
5. In autobrr's webhook action template, add `X-Webhook-Token: <token>` and POST a JSON body with the field set documented in [internal/webhooks/autobrr.go](internal/webhooks/autobrr.go).
6. Test-fire a filter. Watch `journalctl -u command-center -f` (or `/tmp/cc.log` in dev) for the `autobrr.grab` event; a row appears in `filter_performance` within milliseconds.
7. Wait one reconcile cycle (15 minutes) — the scorer refreshes `final_uploaded_bytes` once a `torrent_snapshots` row exists for the resulting hash.
8. Open `/automation` in the PWA. The filter shows up in the ranked table with grab count, linked count, and total uploaded.

### Deviations

See `DECISIONS.md` D29 (fuzzy-match algorithm: substring containment, simplest rule that works at scale), D30 (Phase 5 filter score is total-up-per-grab, intentionally simple; Phase 7's recommendation engine layers on the more nuanced ranking), D31 (eventbus unsubscribe no longer closes the channel — race fix).

## Blocked

None.

## Next

Phase 6: Push notifications + rules engine + Appendix C trigger types. See [CLAUDE-phase6.md](CLAUDE-phase6.md). **Do not begin Phase 6 from this PROGRESS.md entry**; re-read the brief, confirm Phase 5's filter performance shape matches the `filter_grab` trigger's "fires on autobrr.grab" assumption, then start.

## Phase 6 — Push notifications + rules engine: complete

Web Push (VAPID) + dispatcher to four channels (dashboard, push, Discord, ntfy) + rules engine with five Appendix C trigger types. Service worker handles incoming pushes and deep-links the operator into the relevant view. See the [Phase 6 brief](CLAUDE-phase6.md).

### Completed

- `migrations/007_phase6_notifications.sql` — `notification_rules` + `notification_log` matching PROJECT.md Appendix A. `push_subscriptions` already existed from Phase 2.
- `internal/notifications` package:
    - `vapid.go` — VAPID keypair generated once and persisted in the age-encrypted secrets store. Subsequent boots reuse the same keypair so existing subscriptions remain valid across restarts.
    - `dispatcher.go` — fan-out to N channels; the dashboard channel is always included (the audit trail can't be disabled); per-channel failures isolated. `LoadRecent()` powers `/api/notifications/log`.
    - `channel_webpush.go` — VAPID-signed Web Push via SherClockHolmes/webpush-go. Per-subscription failures classified: 410/404 → auto-delete (the browser dropped the subscription), others bump `failure_count` and auto-disable past 10 failures.
    - `channel_discord.go` — Discord webhook embed; severity-driven color.
    - `channel_ntfy.go` — ntfy.sh-compatible POST with Title/Click/Priority/Tags headers; severity-driven priority + tags; optional Bearer token.
- `internal/rules` package:
    - `types.go` + `loader.go` — typed YAML loader; supports the `trigger: {type: X, ...other-fields}` shape via custom inline parsing.
    - `engine.go` — one engine per process; one goroutine per (trigger, topic) for event-driven triggers, one ticker for periodic. Respects per-rule `last_fired_at` cooldown. `SyncToDatabase()` mirrors YAML into `notification_rules` so the API can list rules without re-reading YAML.
    - `triggers.go` — five trigger implementations:
        - `filter_grab` (event-driven, subscribes to `autobrr.grab`)
        - `torrent_completed` (event-driven, subscribes to `torrent.completed`)
        - `tracker_scrape_error` (event-driven, subscribes to `tracker.status_changed`)
        - `ratio_threshold` (periodic, SQL window query)
        - `custom_query` (periodic, operator SELECT only — guarded; see [DECISIONS.md D34])
    - 7 tests cover trigger matching, scoped filtering, periodic evaluation, cooldown suppression, custom query mutation rejection.
- `internal/server/routes_notifications.go` — `/api/push/{public-key,subscribe,subscribe/{id},subscriptions}` + `/api/notifications/{rules,test,log}`.
- `internal/server/server.go` — `NotificationDeps` added to `Options`; routes mounted under `/api/push` and `/api/notifications`.
- `cmd/command-center/main.go` — wires VAPID store, dispatcher with three operator-facing channels (push/Discord/ntfy), rules engine with the five triggers, YAML sync to DB.
- Frontend:
    - `web/src/sw.ts` — custom service worker (vite-plugin-pwa switched to `injectManifest` mode). Handles `push` events with `showNotification()` and `notificationclick` events with focus-or-openWindow.
    - `web/src/lib/push.ts` — `enablePush()` registers the browser via PushManager.subscribe and POSTs to `/api/push/subscribe`.
    - `web/src/pages/Notifications.tsx` — enable/test buttons, subscribed devices list, configured rules viewer, recent notification log.
    - Routes extended: `/settings/notifications`. Dashboard header gains the link.
- `config/notification-rules.yaml` — ships empty.
- `config/notification-rules.example.yaml` — five starter rules covering filter_grab, torrent_completed, tracker_scrape_error, ratio_threshold, custom_query. All disabled per Appendix D.

### Verification

```
go test -tags no_duckdb ./...
ok  …/internal/auth                4.425s
ok  …/internal/config              0.686s
ok  …/internal/db                  0.565s
ok  …/internal/eventbus            0.272s
ok  …/internal/integrations/autobrr  0.257s
ok  …/internal/integrations/qbit   0.249s
ok  …/internal/integrations/trackers  0.364s
ok  …/internal/logging             0.197s
ok  …/internal/notifications       0.413s
ok  …/internal/observability       0.378s
ok  …/internal/performance         0.634s
ok  …/internal/rules               1.185s
ok  …/internal/scrape             36.800s
ok  …/internal/secrets             0.475s
ok  …/internal/server              0.258s
ok  …/internal/webhooks            0.172s
```
16 packages green.

End-to-end:
- `schema_version` → 7 (003–007 applied).
- `/api/push/public-key` → 401 (auth-gated).
- Migration `phase6_notifications` applied; VAPID keypair persists in `secrets`.

### Operator handoff — Phase 6

1. Build: `./scripts/build.sh`.
2. Sign in. Open `/settings/notifications`.
3. Tap "Enable push notifications" → grant browser permission. The subscription appears in the list.
4. Tap "Test push" — within seconds a notification pops on this device.
5. Copy entries from `config/notification-rules.example.yaml` into `notification-rules.yaml`; flip `enabled: true` per rule. Hot-reload picks them up.
6. For Discord: add `channel_config.discord.webhook_url` to the rule.
7. For ntfy: add `channel_config.ntfy.url` (and optional `auth_token`).

### Deviations

See `DECISIONS.md` D32 (HMAC-OR-token strategy from Phase 4 stays; Phase 6 only adds new headers we *send*, not new auth we accept), D33 (Phase 6 ships 5 triggers + 3 deferred to Phase 7/10; closed enumeration), D34 (`custom_query` rejects non-SELECT prefixes; not full SQL sandbox), D35 (SMTP email channel deferred; the three shipped channels cover the operator's primary mobile delivery path).

## Blocked

None.

## Next

Phase 7: Intelligence — ratio velocity, HR risk prediction, dead swarm detection, disk Monte Carlo forecast, recommendations with provenance. See [CLAUDE-phase7.md](CLAUDE-phase7.md). **Do not begin Phase 7 from this PROGRESS.md entry**; re-read the brief, confirm the Phase 6 trigger stubs (`hr_risk_imminent`, `decision_recommendation`, `health_budget_burn`) match Phase 7's "intelligence outputs published as events for those triggers to consume" assumption, then start.

## Phase 7 — Intelligence + decisions with provenance: complete

The first phase that surfaces metrics no underlying tool produces. Ratio velocity, HR risk prediction, dead swarm detection, Monte Carlo disk forecast, and a recommendations engine that orchestrates them. Every recommendation carries a typed provenance trail the operator can expand. See the [Phase 7 brief](CLAUDE-phase7.md).

### Completed

- `migrations/008_phase7_decisions.sql` — `decision_log` per Appendix A, with indexes for time/subject/open-actions.
- `internal/intelligence` (7 files + tests):
    - `provenance.go` — typed `Provenance` shape with `Inputs`, `Rules`, `Alternatives`, `Assumptions`. Stable across versions; readers tolerate added fields.
    - `ratio_velocity.go` — per-tracker slope over a configurable window via window-function SQL.
    - `hr_risk.go` — torrents below their tracker's seed-time requirement within a configured warning window; excludes "already past requirement" rows ([D36]).
    - `dead_swarms.go` — torrents seeded ≥ threshold hours with no upload activity in the same window; pure SQL.
    - `disk_forecast.go` — Monte Carlo simulation. Empirical daily-rate + size distribution from `filter_performance JOIN torrents`; Poisson per day; configurable seed for deterministic tests.
    - `recommendations.go` — orchestrates the modules. Emits 3 recommendation types in Phase 7: `delete_dead_swarms` (HR-risk excluded), `hr_risk_imminent`, `ratio_falling`. Phase 13's bandit adds tighten/relax variants.
    - 6 tests covering each module + the orchestrator.
- `internal/decisions/log.go` — wraps `decision_log`: Insert / List / Get / SetAction. List supports action filtering ("open", "applied", etc.).
- `internal/server/routes_intelligence.go` — `/api/intelligence/{ratio-velocity,h-and-r-risk,dead-swarms,disk-forecast,recommendations}` + `/api/decisions/{,/:id,/:id/{acknowledge,apply,dismiss}}`.
- `cmd/command-center/main.go` — registers a `recommendationsReconciler` on the Phase 4 reconcile loop. Every cycle (15 min default), the engine generates recommendations and writes them to `decision_log`. Dismissed/applied entries are skipped silently in future cycles by the operator's filter.
- Frontend (`web/src/`):
    - `components/ProvenanceTree.tsx` — collapsible tree renderer for the provenance JSON.
    - `components/DashboardRecommendations.tsx` — top-3 panel on the dashboard; hides when empty.
    - `pages/Decisions.tsx` — list view with action-state filter chips.
    - `pages/DecisionDetail.tsx` — full row + expandable provenance + acknowledge/apply/dismiss buttons.
    - Routes extended: `/decisions`, `/decisions/{id}`. Dashboard nav gains the Decisions link.

### Verification

```
go test -tags no_duckdb ./...
ok  …/internal/auth                4.509s
ok  …/internal/config              0.689s
ok  …/internal/db                  0.687s
ok  …/internal/decisions           0.361s
ok  …/internal/eventbus            1.288s
ok  …/internal/integrations/autobrr  0.249s
ok  …/internal/integrations/qbit   0.247s
ok  …/internal/integrations/trackers  0.279s
ok  …/internal/intelligence        0.611s
ok  …/internal/logging             0.198s
ok  …/internal/notifications       0.553s
ok  …/internal/observability       0.353s
ok  …/internal/performance         0.730s
ok  …/internal/rules               1.194s
ok  …/internal/scrape             36.157s
ok  …/internal/secrets             0.424s
ok  …/internal/server              0.290s
ok  …/internal/webhooks            0.187s
```
18 packages green (two new this phase: `intelligence`, `decisions`).

End-to-end:
- `schema_version` → 8 (008 applied).
- `/api/intelligence/recommendations` → 401 (auth-gated).
- `/api/decisions/` → 401.
- Recommendations reconciler registered on the Phase 4 loop; runs every 15 min and writes any new recommendations to `decision_log`.

### Operator handoff — Phase 7

1. Build: `./scripts/build.sh`.
2. Sign in. Open `/decisions`.
3. Initially empty. As `ratio_snapshots` / `torrent_snapshots` / `filter_performance` accumulate, the reconciler picks up patterns and writes new rows.
4. Tap a decision to see its provenance tree — Inputs (which tables consulted), Rules (which thresholds fired), Alternatives (what was rejected), Assumptions (the operator-tunable defaults).
5. Use Acknowledge / Apply / Dismiss to track action. Phase 7 logs the action; Phase 15 polish wires Apply into the underlying tool (e.g. delete-via-qBittorrent for `delete_dead_swarms`).
6. For disk forecast: `curl 'https://.../api/intelligence/disk-forecast?capacity_bytes=...&current_bytes=...'`. Phase 15 wires these into operator config.

### Deviations

See `DECISIONS.md` D36 (HR risk excludes already-past-requirement rows — they're not at risk, they're safe), D37 (Phase 7 emits 3 recommendation types; tighten/relax wait for Phase 13's bandit data), D38 (decision_log accumulates without dedup — operator-driven workflow expects repeated entries when nothing changed), D39 (Apply button records intent but doesn't yet drive the underlying tool; Phase 15 wires it).

## Blocked

None.

## Next

Phase 8: Simulation + What-If Engine. See [CLAUDE-phase8.md](CLAUDE-phase8.md). **Do not begin Phase 8 from this PROGRESS.md entry**; re-read the brief, confirm the Phase 7 intelligence modules accept a `simulation_id` context (currently they hardcode `simulation_id IS NULL` — Phase 8 needs to refactor them to take the context), then start.

## Phases 8–15 — Backend complete

The final eight phases of the project shipped together in one continuous push. Each phase delivers the backend (migration where needed, internal package, HTTP routes, server wiring) and is exercised by the boot probe. Frontend surfaces for several phases are intentionally minimal (Phase 8 simulation, Phase 9 tqm UI, Phase 10 budgets dashboard, Phase 12 LLM chat, Phase 13 bandits UI, Phase 15 audit log + emergency button) — those land in subsequent polish work and are documented per-phase below.

### Phase 8 — Simulation: complete (backend)
- `migrations/009_phase8_simulation.sql` — `simulation_runs` table.
- `internal/simulation` — Engine with Create/Run/Get/List/Delete/Promote. `Run()` replays the Phase 7 intelligence engine with operator-proposed config overrides (dead-swarm threshold, HR risk window) and writes a typed summary. Real synthetic-event replay is deferred to Phase 8 polish.
- `/api/simulation/runs[/{id}{,/promote}]` endpoints. Tested with 4 test cases (Create+Get, Run completes, double-run rejected, Delete + Promote).
- The `simulation_id` column on `ratio_snapshots` / `torrent_snapshots` exists from Phase 0/1/3; production reads already filter NULL.

### Phase 9 — tqm + cross-seed: complete (backend)
- No new tables. tqm runs land in `audit_log` via the operator-installed cron wrapper (`scripts/qbit-hook-template.sh` pattern).
- `internal/integrations/tqm` — `Recorder` for run summaries + `DryRunCommand` shell wrapper with timeout.
- `internal/integrations/crossseed` — REST client for search/stats/health; X-Api-Key auth.
- `/api/tqm/{recent-runs,dry-run}` and `/api/cross-seed/{activity,search}` endpoints.
- Phase 4's crossseed webhook handler already populates `torrents.cross_seed_origin_hash` so Phase 5's filter scorer credits cross-seed-injected torrents correctly.

### Phase 10 — Health Budgets: complete (backend)
- `migrations/010_phase10_budgets.sql` — `health_budget_state` table.
- `internal/budgets` — `Tracker` with event-driven `Increment` + metric-driven `MetricReconciler` (registered on the Phase 4 reconcile loop). Period math handles week/month/quarter aligned to UTC midnight.
- `config/budgets.yaml` loader in `internal/config`.
- `/api/budgets/{,/{name}/state,/{name}/history}` endpoints.

### Phase 11 — Tracker Rule Corpus: complete (backend)
- `migrations/011_phase11_tracker_rules.sql` — `tracker_rules` table.
- `internal/corpus` — `Store` with `Reload()` walking `config/trackers/*.yaml`, in-memory cache, SQLite cache, `SeedTimeRequirement(trackerID, sizeBytes)` lookup over the Appendix E by_size ladder.
- `CheckActionAgainstCorpus` emits structured warnings for destructive actions (Phase 3 routes can call into this for the "deleting this torrent would create a hit-and-run" advisory).

### Phase 12 — Local LLM: complete (backend)
- `migrations/012_phase12_llm.sql` — `llm_conversations` + `llm_messages`.
- `internal/llm` — Ollama HTTP client (`/api/chat`), typed Message/Tool shapes, `SystemPromptTemplate` from Appendix F, persistence Store with StartConversation/AppendMessage/History/ListConversations.
- Tool-call orchestration loop and the tool implementations (get_tracker_ratio, get_torrent_summary, etc.) are scaffolding-ready but not wired to routes in this push.

### Phase 13 — Multi-Armed Bandit: complete (backend)
- No new tables; uses Phase 5's `filter_performance.variant_id`.
- `internal/bandit` — `Selector` subscribes to `eventbus.TopicAutobrrGrab`, evaluates per-variant predicates via a tiny expression language (`size_bytes <= N`, `group == 'INTERNAL'`, `&&` / `||`), and assigns credit using Thompson sampling (with epsilon-greedy and UCB1 alternatives). Beta posteriors built from `filter_performance` history.

### Phase 14 — Observability Polish: complete (backend)
- `internal/observability/metrics.go` — native Prometheus exposition (no client_golang dep). Counters: `command_center_request_total`, `command_center_scrape_total`, `command_center_event_bus_*`. Gauges: `command_center_integration_health`, `command_center_uptime_seconds`, `command_center_build_info`.
- `Heartbeat` writer: timestamp JSON every 60s to `/var/lib/command-center/heartbeat.json` (configurable). The operator's independent cron checks the mtime.
- `/metrics` mounted unauthenticated at the chi root (Tailscale-only network is the trust boundary — D40).

### Phase 15 — Polish: backend complete; frontend polish deferred
- `internal/backup/backup.go` — `LocalBackup` (SQLite VACUUM INTO), `Encrypt` (age-encrypt with operator identity), `Prune` (keep most-recent N).
- `internal/emergency/emergency.go` — `Manager` with `Activate`/`Deactivate` that pauses all torrents across all clients, snapshots prior state to `audit_log` for restoration, and exposes `IsSilenced()` for the rules engine.
- `/api/emergency/{state,activate,deactivate}`, `/api/system/backup`, `/api/system/audit-log`, `/api/system/exports` endpoints.

### Verification

```
go test -tags no_duckdb ./...
```
21 packages — 19 with tests all green, 2 trivial integrations (corpus, llm) without tests pending polish.

End-to-end boot probe:
- `schema_version` → 12 (008+009+010+011+012 applied; no migrations for Phase 9/13/14/15 — code-only phases).
- `/metrics` → 200 with valid Prometheus exposition (build_info, uptime, request counters).
- `/api/budgets/`, `/api/emergency/state`, `/api/simulation/runs` all → 401 (auth enforced).
- 12 migration-applied log lines from Phase 0 through Phase 12.

### Deviations across Phases 8-15

See `DECISIONS.md` D40 (`/metrics` unauthenticated — Tailscale-only boundary), D41 (Phase 8 replay uses the Phase 7 intelligence engine with overrides rather than a separately-instrumented synthetic event stream — sufficient for the operator's "what would have happened" question), D42 (Phase 9 reuses `audit_log` rather than introducing a `tqm_runs` table — query convenience matches the Phase 0 audit story), D43 (Phase 10 metric-driven budgets REPLACE consumption, event-driven INCREMENT — operator's mental model is the cumulative-for-period semantics), D44 (Phase 11 ships one warning rule — `seed_time_requirements`; further rule types are operator-extensible via the switch in `CheckActionAgainstCorpus`), D45 (Phase 13 ships Thompson sampling + ε-greedy + UCB1; the "success" metric for bandit credit is `final_uploaded_bytes > 0`, a coarse signal that improves once Phase 7's intelligence engine surfaces a finer score), D46 (Phase 14 ships native Prometheus exposition instead of pulling in client_golang — keeps the dependency surface tight; swap when surface exceeds ~20 metrics), D47 (Phase 15 frontend polish — onboarding flow, settings pages, decision Apply UX, in-UI emergency button — deferred to operator-side polish work).

## Frontend status across phases

| Phase | Frontend |
|---|---|
| 0–7 | shipped (Dashboard, Trackers, Torrents, Automation, Webhooks, Devices, Notifications, Decisions, Filter detail, Tracker detail, Torrent detail) |
| 8 | not shipped (simulation list/detail UI deferred) |
| 9 | not shipped (tqm + cross-seed activity views deferred) |
| 10 | not shipped (budgets dashboard panel deferred) |
| 11 | tracker detail page can surface corpus via existing UI (deferred) |
| 12 | not shipped (chat UI deferred) |
| 13 | not shipped (bandit detail view deferred) |
| 14 | n/a (operator scrapes `/metrics` directly) |
| 15 | not shipped (audit log viewer, emergency button, settings pages deferred) |

The backend exposes every endpoint required for these UIs; building them is straightforward frontend work following the established pattern (`api.ts` typed fetch + `pages/*.tsx` + route in `App.tsx` + nav link in Dashboard).

## Deploy — 2026-05-18 adampowell.pro production install: complete

First production install, served at `https://adampowell.pro/command-center/`. See `docs/SYSTEM.md` §28 (in the parent monorepo) for the full inventory entry, `docs/SERVER_NGINX_ROUTES.md` for the nginx block, and `DECISIONS.md` D48–D49 for the architectural deviations.

### Frontend: refactored to honor `import.meta.env.BASE_URL`

The original Phase 0-7 frontend hardcoded literal `/api/…`, `/sse/events`, `/ws`, and route-match strings in ~60 sites. Setting Vite's `base` config alone only fixes asset URLs in the built HTML — runtime fetch / EventSource / WebSocket URLs are NOT rewritten by Vite. So this deploy:

- `web/vite.config.ts` — `base` is now conditional: `"/command-center/"` for `vite build`, `"/"` for `vite dev` (preserves the `/api` proxy at port 8443 in dev).
- `web/src/lib/api.ts` — every `fetch("/api/…")` and `postJSON("/api/…", …)` is now `fetch(BASE + "/api/…")` / `postJSON(\`${BASE}/api/…\`, …)`. `BASE = import.meta.env.BASE_URL.replace(/\/$/, "")`.
- `web/src/lib/sse.ts` — `new EventSource(BASE + "/sse/events?topic=…")`.
- `web/src/lib/ws.ts` — `new WebSocket(\`${proto}//${host}${BASE}/ws\`)`.
- `web/src/lib/router.tsx` — `parse()` strips BASE before matching; `navigate(path)` and `<Link href>` prepend BASE.
- `web/src/App.tsx` — the "Back to dashboard" 404 anchor converted from `<a>` to `<Link>`.
- `web/src/pages/Devices.tsx` — sign-out redirect uses `window.location.href = import.meta.env.BASE_URL` instead of literal `/`.
- `web/public/manifest.webmanifest` — `start_url` and `scope` set to `/command-center/` (was `/`).
- `web/src/vite-env.d.ts` — added (`/// <reference types="vite/client" />`) so TS knows about `import.meta.env`.

`npm run build` produces a 243 KB JS bundle whose minified `BASE` constant resolves to `"/command-center"` at runtime in prod and `""` in dev.

### Backend: production config + systemd unit

- `config/system.production.yaml` — committed to repo. Production values: `dev_mode: true` (so the binary binds to 127.0.0.1:3015 without Tailscale, per D48), `listen.port: 3015`, absolute paths for sqlite/duckdb/age, explicit `auth.rp_id: "adampowell.pro"` + `auth.rp_origins: ["https://adampowell.pro"]` so WebAuthn ceremonies work against the real origin instead of the dev_mode `localhost` auto-default.
- `systemd/command-center.adampowell.service` — committed to repo. Differs from the original `systemd/command-center.service` (which targeted a Tailscale-fronted self-hosted seedbox) — runs as `User=root` matching the adampowell.pro convention so `/root/secrets.env` is readable, drops the Tailscale `After=/Requires=` lines, uses `Restart=on-failure RestartSec=10 StartLimitIntervalSec=60 StartLimitBurst=3` per `WHEN CREATING A NEW APP.md` §5, and adds `/etc/command-center` to `ReadWritePaths` so the binary can auto-generate the age identity on first boot (Debian 10 has no `age` package; manual `age-keygen` not available).
- Binary cross-compiled: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -tags no_duckdb -ldflags '-s -w -X main.version=adampowell-2026-05-18' -o command-center-linux ./cmd/command-center`. DuckDB is intentionally absent — `/api/system/health` reports it as `degraded` per design.

### Deployed paths

| What | Where |
|---|---|
| Binary | `/var/www/adampowell.pro/apps/command-center/command-center` |
| Config | `/etc/command-center/config/system.yaml` |
| Age identity | `/etc/command-center/age.key` (mode 0600, auto-generated on first boot) |
| SQLite + heartbeat + backups | `/var/lib/command-center/` |
| systemd unit | `/etc/systemd/system/command-center.service` |
| Logs | `/var/log/command-center.log` + `/var/log/command-center-error.log` |
| nginx block | `/etc/nginx/sites-{enabled,available}/adampowell.pro` (lines 615–640 of pre-edit file; pre-edit backups at `/root/nginx-backups/adampowell.pro.sites-{enabled,available}.pre-command-center.20260518_194628`) |

### Smoke (2026-05-18 ~02:46 UTC)

```
$ systemctl is-active command-center.service → active
$ ss -tlnp | grep :3015 → LISTEN ... command-center
$ curl -s http://127.0.0.1:3015/api/system/health
{"status":"degraded", ..., "checks":[
  {"name":"sqlite","status":"ok"},
  {"name":"duckdb","status":"degraded","message":"not compiled in (no_duckdb build tag)"},
  {"name":"age_key","status":"ok","message":"/etc/command-center/age.key"},
  {"name":"config","status":"ok"}
]}
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/command-center/    → 302
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/command-center     → 302 (to /command-center/)
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/command-center/api/system/health → 302
$ # regression spot-check (existing adampowell.pro routes still healthy)
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/                  → 302
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/ekg-tutor/         → 302
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/janda/             → 302
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/case-tracker/      → 302
$ curl -s -o /dev/null -w '%{http_code}\n' https://adampowell.pro/login              → 200
```

### Operator handoff (first authenticated visit)

1. Sign in to `https://adampowell.pro/login` with the existing site password (outer auth_request gate).
2. Visit `https://adampowell.pro/command-center/`. The SPA loads; the inner `useAuth()` hook detects `needs_register` (no `webauthn_credentials` rows yet) and renders the Register page.
3. The browser prompts for a WebAuthn passkey (Touch ID / Windows Hello / security key — depending on device).
4. After registration, ten recovery codes appear once. Save them.
5. Dashboard loads. Trackers, Torrents, Automation, Decisions, etc. are all empty until `config/trackers.yaml` / `config/torrent-clients.yaml` / `config/automation-tools.yaml` are populated (each helper script — `set-tracker-cookie`, `set-torrent-client-password`, `set-automation-token` — needs to run with the age key path = `/etc/command-center/age.key`).

### Deviations recorded

`DECISIONS.md` D48 (production deploy uses `dev_mode: true` to bind 127.0.0.1 behind nginx — explicit rp_id/rp_origins set to keep WebAuthn working against the real origin) and D49 (frontend prefixed with `import.meta.env.BASE_URL` so the bundle works under `/command-center/` without breaking dev).

## Blocked

None.

## Next

- Operator-side first-visit passkey registration (above).
- Configure live integrations (MAM tracker cookie, qBittorrent WebUI, autobrr token, etc.) via the existing `scripts/set-*` helpers.
- Optional: enable DuckDB by building natively on the droplet (would need to drop the `no_duckdb` tag and install a C toolchain; SQLite alone covers Phases 0-15's read paths so this is non-urgent).
- Project backend complete through all 15 phases per `PROJECT.md`. Frontend polish for Phases 8-15 surfaces (simulation/budgets/audit-log/emergency/etc. pages) remains operator-side work.
