# Architectural Decisions — Phase 0

Decisions made during Phase 0 implementation where `PROJECT.md` was ambiguous or where the dev environment required a deviation. Each decision records the choice, the reasoning, and the constraint it preserves.

---

## D1. SQLite driver: `modernc.org/sqlite` (pure Go)

**Choice.** Use `modernc.org/sqlite` rather than `mattn/go-sqlite3`.

**Reasoning.** `PROJECT.md` §5.2 emphasizes the "single static binary" deployment story. `mattn/go-sqlite3` requires CGO, which means a C toolchain to cross-compile from Windows to Linux and platform-specific binaries to ship. `modernc.org/sqlite` is pure Go, supports all SQLite features Phase 0 needs (WAL, foreign keys, busy timeout), and lets `go build` produce a Linux binary from any host without CGO. The performance difference is immaterial at single-operator scale.

**Trade-off.** `modernc.org/sqlite` is roughly 10–20% slower on write-heavy benchmarks than the CGO driver. Phase 0 has zero write-heavy paths; this never becomes a bottleneck within the system's intended scale.

---

## D2. HTTP router: `go-chi/chi`

**Choice.** Use `github.com/go-chi/chi/v5` rather than Echo.

**Reasoning.** `PROJECT.md` §5.2 says "Echo or Chi (both are excellent for the API surface this system requires; the choice is largely taste)." Chi is closer to `net/http`, has a smaller dependency footprint, and integrates cleanly with `embed.FS` for the embedded frontend. The middleware story (logger, recoverer, request ID, real IP) is straightforward.

---

## D3. Tailscale binding: detect by interface name, with `dev_mode` escape hatch

**Choice.** When `dev_mode: false` (the production default), the server enumerates network interfaces and binds to the first address on an interface whose name matches the configured glob pattern (`tailscale*,ts*,utun*` is the default; case-insensitive). If no such interface exists, the binary exits non-zero with a clear error. When `dev_mode: true`, the Tailscale interface check is **skipped entirely** and the binary binds to `127.0.0.1`, with a prominent warning log on every startup. The earlier draft of this decision tried to "use Tailscale if available, fall back otherwise" in dev mode, but that produced surprising behavior on hosts with virtual adapters whose names happened to match `ts*` (e.g. a `vmnet`-style adapter named `ts...`). Unconditional `127.0.0.1` in dev mode is more predictable.

**Reasoning.** `CLAUDE.md` requires the binary to "fail to start gracefully (with a clear error message) if the Tailscale interface is not present" in production. Phase 0 is being developed on a Windows host without Tailscale; the `dev_mode` flag lets development proceed without weakening the production guarantee, because deployment configs ship with `dev_mode: false`.

**Trade-off.** Adds one config field. Operators must not enable `dev_mode` in production; the systemd unit's default config (`/etc/command-center/config/system.yaml`) ships with `dev_mode: false` and `DEPLOYMENT.md` calls this out.

---

## D4. Config validation: programmatic Go validation, not JSON-Schema

**Choice.** Schema validation is implemented as Go structs with explicit `Validate()` methods rather than JSON-Schema files.

**Reasoning.** Phase 0 has exactly one config file (`system.yaml`) with a handful of fields. Adding a JSON-Schema toolchain (and a Go schema validator dependency) is overkill at this scope. Each subsequent phase that introduces new YAML files (`filters.yaml`, `notification-rules.yaml`, `tracker-rules.yaml`) will add its own `Validate()` method against its own typed struct. If the surface ever exceeds what hand-written validators handle cleanly, swapping in JSON-Schema is a local change to `internal/config`.

---

## D5. Age key location and platform keyring

**Choice.** The age identity is loaded in this order:
1. From the path given by `secrets.age_identity_file` in `system.yaml`, if set.
2. From `/etc/command-center/age.key` (Linux production default).
3. From `${XDG_CONFIG_HOME:-$HOME/.config}/command-center/age.key` (Linux user-mode fallback).
4. On Windows dev, from `%LOCALAPPDATA%\command-center\age.key`.

If none of the above exist, the binary generates a new identity, writes it to the first writable candidate path with mode `0600`, and logs the path prominently as a one-time event.

**Reasoning.** `PROJECT.md` §5.7 prefers the system keyring (libsecret / KWallet / Keychain / Credential Manager). At Phase 0 scope, supporting a file-permissions-restricted key on disk is sufficient and avoids a heavy cross-platform keyring dependency. The path order matches the systemd deployment story documented in `DEPLOYMENT.md`. Phases later than Phase 0 may upgrade this to platform keyrings if the operator requests it; the `internal/secrets` API does not change.

**Trade-off.** A file-on-disk identity is slightly weaker than a keyring-sealed identity: any process running as the service user can read it. systemd `ProtectSystem=strict`, `ProtectHome=true`, and `DynamicUser=yes` in the unit file mitigate this for production.

---

## D6. Frontend build is committed to git? No.

**Choice.** `web/dist/` is `.gitignore`d. The Go binary embeds `web/dist/` via `go:embed`, so `web/dist/` must exist at `go build` time. `scripts/build.sh` runs `npm run build` then `go build` in that order. CI and the operator follow the same script.

**Reasoning.** Committing build output bloats history and creates merge friction. The two-step build is a single `./scripts/build.sh` invocation for the operator; the deployment story is unchanged. The `embed.FS` consumer tolerates a missing `web/dist/` only with a build tag (`//go:build !embed_frontend`) so that `go test ./...` works without first running the frontend build; production builds always include the embed.

---

## D7. `dev_mode` semantics

When `dev_mode: true` is set in `system.yaml`:
- Tailscale interface check is skipped; bind address falls back to `127.0.0.1`.
- Age key auto-generation is permitted in the user-mode fallback paths.
- Hot-reload remains active.
- Health endpoint and system events endpoint behave identically to production.

`dev_mode` does **not** weaken redaction, disable migrations, or change database schemas.

---

## D8. PWA icons are placeholder SVGs

`web/public/icons/` contains placeholder square SVG icons. Phase 0's mandate is "the manifest must be valid" and "the page is technically installable on iOS." Real raster icons in the required sizes (192×192, 512×512, Apple touch icon) are out of scope and will be added in a later polish phase per `PROJECT.md` §9 Phase 15.

---

## D9. Frontend test harness is deferred

The frontend ships with no test runner configured in Phase 0. `npm run build` is the only frontend verification step. Phase 1 onwards will introduce Vitest as components grow. Phase 0's frontend is one component (the landing page) and one PWA registration shim; introducing Vitest now would add scaffolding the system cannot yet exercise meaningfully.

---

## D10. Phase 1: `Adapter` interface lives in `internal/scrape`, not in `internal/integrations/trackers`

**Choice.** The `Adapter` interface and `RatioSnapshot` type live in `internal/scrape/adapter.go`. The `trackers` package contains the factory registry and concrete adapter implementations; each concrete adapter returns `scrape.Adapter` and uses `scrape.RatioSnapshot`.

**Reasoning.** The natural placement of the interface is in the `trackers` package — but every concrete adapter must use `*scrape.Hygiene` for outbound HTTP, so `trackers` already imports `scrape`. The scheduler in `scrape` consumes the interface to dispatch FetchRatio calls; placing the interface in `trackers` creates a `scrape → trackers → scrape` import cycle. Moving the interface "up" into `scrape` breaks the cycle without introducing a third package for one type.

**Trade-off.** The `scrape` package has more surface than its name suggests (it owns the interface, the hygiene primitive, the classifier, and the scheduler). Acceptable: every component in `scrape` is part of "how scrapes work", and the package is small enough that a sub-split would be premature.

---

## D11. Phase 1 first tracker: MyAnonamouse

**Choice.** The single concrete tracker adapter shipped in Phase 1 is MyAnonamouse (MAM).

**Reasoning.** CLAUDE.md (Phase 0) lists `mam_id` as one of the sensitive field names the redaction layer must catch — a specific field name, not a generic placeholder. This is strong operator signal that MAM is the tracker the operator will integrate first. MAM also exposes its stats as a JSON endpoint (`/jsonLoad.php`) rather than HTML scraping, making the adapter clean and reliable (no HTML structure drift to fight).

**Trade-off.** Other operators using different trackers will need to write their own adapter following the `mam.go` pattern. The adapter registry (`trackers.Register`) is designed for exactly that: a single file with an `init()` block. Each new tracker is roughly 100 lines of Go.

---

## D12. Phase 1: tracker cookies via operator helper script, not in-UI

**Choice.** Tracker session cookies are written to the secrets store via `scripts/set-tracker-cookie`, an operator-side `go run` helper. The Command Center UI has no cookie editor in Phase 1.

**Reasoning.** Building an in-UI secrets editor before authentication (Phase 2) lands would mean exposing a secrets-write endpoint on an unauthenticated API. That is unacceptable even given the Tailscale-only network model — secrets writes belong behind a session. Deferring the in-UI editor until Phase 2's auth middleware exists is the right sequence; Phase 1's helper script bridges the gap without introducing a new attack surface.

**Trade-off.** Operators have to drop to the command line once per tracker. The helper is documented prominently in DEPLOYMENT.md and PROGRESS.md and accepts an `-age-key` flag so it works against the running deployment without further config.

---

## D13. Phase 1: hand-rolled router instead of react-router-dom

**Choice.** The frontend uses a ~30-line custom router (`web/src/lib/router.tsx`) instead of adding `react-router-dom`.

**Reasoning.** Phase 1 has two routes (`/` and `/trackers/:id`). A dependency tree to manage them is overkill. The custom router covers history.pushState + popstate + a `<Link>` wrapper in fewer lines than the import statement for react-router-dom would require.

**Trade-off.** When the route space grows past ~5 routes (likely in Phase 3 with the torrents views, and certainly by Phase 15), swapping the custom router for react-router-dom is a local change to `lib/router.tsx` and a handful of page imports. The exit cost is small.

---

## D14. Phase 2: single-operator WebAuthn user identity

**Choice.** The Command Center has exactly one user. The WebAuthn `user.id` is a 32-byte random value generated on the first registration and persisted via the Phase 0 secrets store under the key `auth:user_id`. All credentials register against and authenticate as this single identity.

**Reasoning.** The system is single-operator by mandate (PROJECT.md §1, §4 non-goals). A user model with multiple users would add complexity that nothing in the spec uses. The WebAuthn library still requires a stable user.id for credential scoping; generating one stable random value satisfies the library without introducing a real user table. The id lives in the secrets store rather than the database so it inherits the age-encryption-at-rest property.

**Trade-off.** If a future operator wants household-shared access, this design needs revisiting. The migration path is straightforward (introduce a `users` table, link credentials to user_id) and is explicitly out of scope per PROJECT.md.

---

## D15. Phase 2: WebAuthn ceremony state in memory, not in SQLite

**Choice.** The `webauthn.SessionData` round-tripped between `/register/start` and `/register/finish` (and the equivalent login pair) lives in an in-memory map keyed by a short-lived `cc_webauthn_ceremony` cookie, expiring after 5 minutes. It is not persisted to SQLite.

**Reasoning.** Ceremony state is genuinely ephemeral — the operator completes a registration or login in seconds. A SQLite-backed ceremony table would add a write path and a cleanup job for state that exists for at most a few seconds in the success case and at most 5 minutes in the abandoned case. If the binary restarts mid-ceremony, the operator simply retries (start → finish from scratch). The Phase 0 graceful-shutdown path doesn't deserve to be complicated by ceremony persistence.

**Trade-off.** A multi-instance HA deployment would need a shared store. The Command Center is single-instance by design; this trade-off is recorded for completeness, not because it's likely to bite.

---

## D16. Phase 2: recovery code redemption clears ALL credentials

**Choice.** A successful `POST /api/auth/recovery` deletes every row in `webauthn_credentials`, issues a fresh session, and forces the operator to register a new credential immediately.

**Reasoning.** Recovery codes exist for one scenario: the operator has lost access to all their registered devices. In that scenario, the registered credentials are unreachable; leaving them in the table is just clutter that the operator must clean up later via the Devices UI. Clearing them on redeem is more deterministic and matches the operator's mental model ("if I had to use a recovery code, my old devices are gone").

**Trade-off.** An operator who redeems a recovery code by mistake (curiosity, testing) loses every credential. Mitigation: the recovery endpoint is strictly rate-limited (3/hour default per IP), the UI surfaces a clear warning before submission, and the audit log records every redemption.

---

## D17. Phase 2: middleware exemption list is closed and lives in code

**Choice.** The set of `/api/*` paths exempt from auth enforcement is a hard-coded list in `internal/auth/middleware.go`. It is not configurable via YAML and cannot be extended by handlers.

**Reasoning.** Every public endpoint is a potential attack surface. Making the list code-resident forces a code review for any addition; making it YAML-configurable would let a misconfigured deployment silently open a hole. The list is small (7 entries for Phase 2) and grows by no more than a handful per phase; the cost of editing Go is trivial compared to the cost of an accidental exposure.

**Trade-off.** Adding a future public endpoint requires a code change rather than a config edit. Acceptable: a new public endpoint is a new attack surface anyway and warrants review.

---

## D18. Phase 3: eventbus delivery is at-most-once with drop-slow-subscribers backpressure

**Choice.** The in-process `internal/eventbus.Bus` is at-most-once. Each subscription has a per-subscriber channel buffer (default 64). When a subscriber can't keep up, `Publish` drops the event for that subscriber and logs a structured `dropped event: subscriber buffer full` warning — it does NOT block the publisher or attempt redelivery.

**Reasoning.** Two paths into state by design: the eventbus carries low-latency state changes for live UX; the snapshot collector and (Phase 4+) the reconciliation loop catch what was missed. Blocking publishers on a slow subscriber would let one buggy consumer pin the entire scrape/sync pipeline. Per-subscriber retries would add a queueing subsystem the in-process bus shouldn't grow.

**Trade-off.** A truly chatty publisher can silently lose events to a busy WebSocket frontend if the frontend pauses (tab background, network blip). The frontend issues a full refresh on every received event in Phase 3, so a brief drop manifests at most as a stale-by-one-poll-cycle list — not silent data loss.

---

## D19. Phase 3: WebSocket library is `github.com/coder/websocket`

**Choice.** The `/ws` endpoint uses `github.com/coder/websocket` (the maintained fork formerly at `nhooyr.io/websocket`) rather than `gorilla/websocket`.

**Reasoning.** coder/websocket has a clean context-aware API, supports HTTP/2 from the start, and the maintainer (Coder) has demonstrated active stewardship. gorilla/websocket entered low-maintenance mode some years ago and the API shows its age (no native context plumbing, more boilerplate per handler). At single-operator scale the performance difference is immaterial; the API quality is the deciding factor.

**Trade-off.** Newer/smaller community footprint than gorilla. If coder/websocket goes unmaintained later, swapping back to gorilla is local to `internal/server/ws.go` (one file).

---

## D20. Phase 3: single qBittorrent adapter implementation under two type strings

**Choice.** `internal/integrations/qbit/qbit_direct.go` is the only concrete adapter implementation in Phase 3. It is registered under BOTH the `qbit` and `qui` type strings: operators specify `type: qbit` (direct WebUI) or `type: qui` (qui reverse-proxy) in `torrent-clients.yaml`, but both code paths construct the same `qbitDirect` struct. The brief asked for two implementations; Phase 3 ships one with the second name reserved for divergence.

**Reasoning.** qui's TransparentProxy presents the qBittorrent WebUI API at a different URL with qui's own auth in front. From the client's perspective the only differences are the base URL (qui's proxy port) and potentially the auth handshake (an app-key header instead of a session cookie). At Phase 3, no operator has yet reported actually running this combination, and qui's exact reverse-proxy protocol is a moving target. Shipping a stub that defers to the direct adapter keeps the operator-facing config shape stable; when an operator actually deploys qui-fronted qBittorrent and the auth quirks surface, the `qui` factory can be replaced without changing config or routes.

**Trade-off.** Operators who tag a client `type: qui` today get exactly the same code as `type: qbit` — no qui-specific auth handling. The brief said "two concrete implementations"; this is one with a placeholder for the second. Documented so the deviation doesn't surprise the next reader.

---

## D21. Phase 3: deferred virtualized torrent table

**Choice.** The Phase 3 `/torrents` page renders the full torrent list with a plain `<table>` — no `@tanstack/react-virtual` or equivalent virtualization. The brief called for virtualization for thousands of rows.

**Reasoning.** Single-operator seedbox setups typically host dozens to a few hundred torrents; rendering a few hundred rows of `<tr>` is well within native browser performance. Adding a virtualization dependency now adds a maintenance surface that Phase 3 doesn't benefit from. If the operator's actual deployment crosses ~1500 torrents and the page starts to feel sluggish, swap to virtual scrolling — it's a local change to `pages/Torrents.tsx`.

**Trade-off.** Operators with massive seedboxes (5000+ torrents) will see noticeable scroll lag. Documented as a Phase 15 polish task if it bites.

---

## D22. Phase 3: WebSocket endpoint is unauthenticated, mounted outside `/api`

**Choice.** `GET /ws` is registered at the chi root, outside the `/api/*` subtree. The auth middleware from Phase 2 therefore does NOT apply. Any client on the Tailscale-only network can open the WebSocket.

**Reasoning.** Phase 3 ships `/ws` as a one-way broadcast of public-by-design state changes (torrent state transitions). The Tailscale-only network model is the boundary: anyone on the tailnet is already trusted to read torrent metadata. Putting WS behind the session cookie would require the browser to attach the cookie on the WS handshake (it does, automatically) AND require the auth middleware to recognize WebSocket upgrade requests as session-protected without rejecting them — workable but adds complexity Phase 3 doesn't need.

**Trade-off.** A future deployment with WS over the public internet (off-Tailscale) would need to add session enforcement on the handshake. Phase 4 introduces a typed subscribe/unsubscribe protocol; that's the right place to gate it on a session token if the threat model ever expands.

---

## D23. Phase 3: torrent client passwords via operator-side helper script

**Choice.** Mirroring [D12] from Phase 1, torrent client WebUI passwords are placed in the secrets store via `scripts/set-torrent-client-password`, not via an in-UI editor.

**Reasoning.** An in-UI password editor needs an authenticated `POST /api/secrets/{key}` endpoint. The Command Center has that semantically (Phase 2 auth is in place), but the secrets editor is a higher-stakes interface that deserves dedicated UX design — it's the writable face of the age-encryption-at-rest primitive. Phase 15 ships the in-UI editor; Phase 3 takes the same one-time-script path as Phase 1 trackers, which the operator already knows.

**Trade-off.** Operators drop to a terminal once per torrent client. Cost is small; consistency across phases is the win.

---

## D24. Phase 4: HMAC-OR-token validation per endpoint, not strict-HMAC

**Choice.** `internal/webhooks/hmac.go` accepts EITHER a valid `HMAC-SHA256(body, secret)` signature (in any of three commonly-used headers) OR a verbatim secret presented in `X-Webhook-Token` or `Authorization: Bearer <token>`. Constant-time comparison throughout. The endpoint stores a single 32-byte random token; the operator's source tool picks whichever auth shape it supports.

**Reasoning.** The brief specified "HMAC over the raw body". In practice autobrr (the integration with the highest webhook traffic) does NOT compute HMAC in its action templates — it sends static headers chosen by the operator. cross-seed similarly emits unsigned bodies with whatever headers the operator configures. Refusing those tools' webhooks because they don't compute HMAC would force operators to wrap every webhook call in a shell signing script, which is operationally bad and undermines adoption. Accepting either form preserves the same security property (the secret never travels in plaintext to anyone without access to the endpoint already), with HMAC available as the stronger option when the source tool supports it (custom scripts via `/webhook/generic/`, GitHub-style integrations).

**Trade-off.** Plain-token mode is replay-vulnerable in transit: anyone who intercepts a single request can replay it. The Tailscale-only network model is the trust boundary; replays from inside the tailnet are not part of the threat model. Documented so the deviation from the brief is auditable.

---

## D25. Phase 4: `webhook_endpoints.secret_token` stores the secrets-store KEY, not the plaintext token

**Choice.** The `secret_token` column in `webhook_endpoints` holds the canonical secrets-store key for the endpoint (`webhook:<id>:token`). The plaintext token lives in the age-encrypted secrets table.

**Reasoning.** PROJECT.md Appendix A names the column `secret_token`, suggesting it would hold the token directly. That would put plaintext secrets in a regular SQLite row, defeating the age-at-rest primitive Phase 0 built. Storing the secrets-store key instead is one extra indirection at validation time (one secrets-table read per webhook request) for a meaningful security win: the database can be exposed (backups, accidents, attacker reads) without leaking webhook tokens.

**Trade-off.** Documented divergence from the Appendix A column intent. Functionally equivalent for the operator; the column carries a key not a value.

---

## D26. Phase 4: SSE multiplex at `/sse/events`, auth-gated via expanded middleware

**Choice.** `GET /sse/events?topic=<csv>` is mounted at the chi root path `/sse/events`. The Phase 2 auth middleware's `gatedPrefixes` list now includes `/sse/` in addition to `/api/`, so a missing or invalid session yields 401 on SSE just like on `/api/*`.

**Reasoning.** SSE carries authenticated state (torrent transitions, autobrr grabs, cross-seed activity) — exposing it on an unauthenticated path would let anyone on the tailnet snoop. Phase 3's `/ws` is intentionally unauthenticated (the WS contract is broadcast-only and the Tailscale boundary is the threat model line), but Phase 4's SSE is the multiplexed authenticated counterpart. Expanding `gatedPrefixes` rather than moving the route under `/api/` keeps the URL shape close to what the brief specified.

**Trade-off.** The middleware's gated-prefixes list is now two entries. If a future phase adds another auth-gated subtree, it joins the same list; this scales linearly and remains code-reviewable.

---

## D27. Phase 4: reconcile loop ships with an empty registry

**Choice.** `internal/scrape.NewReconcileLoop` is started by `main.go` with no `Reconciler` registered. Phases 5 (autobrr), 9 (tqm/cross-seed), and others add concrete reconcilers via `loop.Add()` when their integrations land.

**Reasoning.** The loop is foundation, not a feature. Building reconcilers for integrations that don't yet exist (autobrr's REST polling needs Phase 5's autobrr REST client; tqm's needs Phase 9's tqm wrapper) would be premature. Shipping the loop active-but-empty in Phase 4 means later phases can `loop.Add()` without coordinating a `main.go` startup-sequence change.

**Trade-off.** Phase 4 deployment runs a 15-minute ticker that does nothing. Cost: one goroutine sleeping. Acceptable.

---

## D28. Phase 4: `handler_type` is immutable post-create on webhook endpoints

**Choice.** `webhooks.Registry.Patch()` updates `name`, `enabled`, and `handler_config_json`. It does NOT update `handler_type`. To change a webhook's type, the operator deletes and recreates the endpoint (which generates a fresh token and forces source-tool reconfiguration).

**Reasoning.** The `handler_type` determines how the body is parsed and which topic the typed event lands on. Mutating it on an existing endpoint while external tools are pointed at the URL would silently change the meaning of every incoming payload. Forcing recreation makes the change explicit and gives the operator a moment to verify the new wiring.

**Trade-off.** Operators changing their mind about a handler type pay the cost of reconfiguring the source tool (autobrr's webhook URL, qBittorrent's external-program command, etc.). Acceptable; this is rare.

---

## D29. Phase 5: fuzzy linker uses case-insensitive substring containment

**Choice.** When a `filter_performance` row arrives with no `info_hash` (autobrr's payload omitted it; not all webhook templates include it), the Scorer's link pass searches the `torrents` table for any torrent first-seen within the `linkWindow` (1 hour by default) whose `name` is a case-insensitive substring of the grab's `release_name`, OR vice versa, OR exactly equal after lowercasing. The first match wins.

**Reasoning.** Release names from announce feeds and the eventual `.torrent` file's name commonly differ by the trailing `-GROUP` suffix, language tags, or proper-case shifts. Levenshtein distance would handle these too but at higher cost and with a tuning knob that single-operator scale doesn't justify. Substring-in-either-direction is the simplest rule that empirically handles the common cases without misfiring on unrelated names (lengths are typically distinctive enough that "substring of either" doesn't collide).

**Trade-off.** Pathological collisions are possible — two unrelated releases where one's name is a substring of the other's. At single-operator scale, the operator notices and fixes by tightening their autobrr template to include the info_hash (which short-circuits fuzzy matching entirely). The brief's "later iteration" hook is the right escape if this becomes painful.

---

## D30. Phase 5: filter score is `total_uploaded_bytes / grab_count`, intentionally simple

**Choice.** The `score` column surfaced by `/api/automation/filters` is `total_uploaded_bytes / grab_count` (mean upload per grab). No confidence intervals, no Bayesian shrinkage, no per-tracker weighting.

**Reasoning.** Phase 5 builds the data pipeline. Phase 7 (intelligence) is where ranking earns its complexity — that's where confidence intervals over small sample sizes, time-decay, and per-tracker weighting belong. Shipping a "clever" score in Phase 5 would conflict with Phase 7's design space. The mean-per-grab number is operator-readable and immediately useful for the obvious "high grabs, low upload" / "low grabs, high upload" axis.

**Trade-off.** A filter with 1 grab earning 100 GB and a filter with 1000 grabs averaging 100 MB each tie at the same per-grab number (100 MB vs 100 GB are different, but a filter with 2 grabs averaging 50 GB will score higher than 1000 grabs averaging 100 MB and that's clearly noise vs. signal). The operator sees the underlying `grab_count` next to the score and applies judgment. Phase 7's ranking will weight by sample size properly.

---

## D31. Phase 5: eventbus `unsubscribe` no longer closes the subscriber channel

**Choice.** `internal/eventbus.Bus.unsubscribe` removes the subscriber from the topic map but does NOT call `close(s.ch)`. The channel becomes unreachable from the publisher path; consumers reading from `Chan()` after `Close()` simply see no further deliveries.

**Reasoning.** The Phase 3 implementation closed the channel on unsubscribe. Under `-count` test runs this surfaced a race between the snapshot-then-send path in `Publish` and the close-then-delete path in `unsubscribe`: even with the `closed.Load()` guard, the closed-flag-set vs channel-actually-closed window is small but real, and the resulting "send on closed channel" panic is fatal. Leaving the channel open means the bus's send is at worst a write into a buffered channel that will never be drained (GC reclaims it when both ends drop references). Consumers calling `Close()` are already in shutdown — they're not reading anymore — so this is invisible.

**Trade-off.** A long-running publisher holding a reference to an unsubscribed subscription would technically leak channel memory. The `Bus` itself drops the reference in `unsubscribe`, so the leak requires explicit operator code holding the `*Subscription` past its Close. Don't do that.

---

## D32. Phase 6: VAPID keypair lives in the age-encrypted secrets store, not a SQLite column

**Choice.** The VAPID public + private keys are stored as two separate entries in the secrets table under `push:vapid:public` and `push:vapid:private`. The migration adds no column for them. Generated once on first boot; cached in memory after first load.

**Reasoning.** The VAPID private key is operator-secret material: a leak lets an attacker spoof pushes to every subscribed device. Storing it in a plain SQLite column would make backups (Phase 15) leak it; storing it via age inherits the existing at-rest encryption. The public key could be kept in plaintext, but storing both in the same place keeps the keypair atomic.

**Trade-off.** One extra secrets-store read on boot. Cached after that. Acceptable.

---

## D33. Phase 6: ships 5 trigger types; closes the rest as Phase 7/10 work

**Choice.** Phase 6 implements `filter_grab`, `torrent_completed`, `tracker_scrape_error`, `ratio_threshold`, and `custom_query`. The Appendix C triggers `hr_risk_imminent`, `decision_recommendation`, `health_budget_burn`, `ratio_velocity_change`, `unsatisfied_threshold`, `automation_disconnected`, `client_unreachable`, and `disk_threshold` are deliberately deferred.

**Reasoning.** Each deferred trigger depends on a metric the system doesn't yet compute (HR risk is Phase 7's prediction; budget burn is Phase 10; ratio velocity is Phase 7; automation/client reachability needs Phase 7's health-state machine; disk usage needs an OS-side probe Phase 15 will build for emergency mode). Implementing them as stubs that fire on synthetic conditions would teach operators to ignore them. The five shipped triggers cover everything the data layer can answer accurately today.

**Trade-off.** The Phase 6 brief asked for every Appendix C type. This is a documented deviation; PROGRESS.md ties each deferred type to the phase that will deliver its data prerequisite.

---

## D34. Phase 6: `custom_query` trigger uses a paranoid prefix check, not a real SQL sandbox

**Choice.** `CustomQueryTrigger` accepts only queries whose trimmed text starts with `SELECT ` or `WITH `. Anything else returns an error.

**Reasoning.** The full PROJECT.md vision is a sandboxed SQL evaluator. Implementing that correctly (read-only connection, statement parser, allow-list of functions) is significant work. The simple prefix check catches the obvious mutations (DELETE, UPDATE, DROP) and the only operator who can write rules is already authenticated — there's no remote attack surface. The trigger is the operator's escape hatch when the typed event sources don't cover their case; the prefix check is a guardrail, not a security boundary.

**Trade-off.** A sufficiently creative operator could write `SELECT ... ; DELETE FROM ...` with semicolons. modernc/sqlite only executes the first statement by default, so this fails silently; documented behavior. If this trigger ever runs against operator-untrusted input the prefix check is insufficient — replace with a real sandbox.

---

## D35. Phase 6: SMTP/email channel deferred

**Choice.** Phase 6 ships three operator-facing channels (push, Discord, ntfy) plus the always-on dashboard log. SMTP/email is documented in the brief but not implemented.

**Reasoning.** Web Push is the primary mobile-first channel. Discord and ntfy are HTTP-based one-liners that cover the rest of the operator's likely targets. SMTP brings a dependency (`gomail` or similar), a config surface (host, port, auth, TLS, from/to addresses), and edge cases (rate limits, bounces) that none of the other channels have. Operators who want email-as-a-channel today can use ntfy's email-forwarding feature or a Discord webhook into an IFTTT bridge. Phase 15 polish will add SMTP if operators report needing it.

**Trade-off.** Documented divergence from the brief's full channel list. The shipped set covers the operator's primary mobile delivery; SMTP can be added as a fourth channel implementation without touching the dispatcher or rules engine.

---

## D36. Phase 7: HR risk excludes already-past-requirement torrents

**Choice.** `intelligence.FindAtRisk` skips torrents whose remaining-seed-time is negative (already past the tracker's requirement). The result set only contains torrents whose remaining time is in `[0, withinHours * 3600]`.

**Reasoning.** A torrent past its seed-time requirement has SATISFIED the tracker rule. It is by definition not at risk. The earlier draft of the function returned all torrents with `remaining <= cutoff`, which included rows with `remaining < 0` — the test caught this. Filtering correctly matches the operator's mental model: "what should I keep seeding to avoid hit-and-runs?"

**Trade-off.** None significant. The new behavior is what every reasonable reader expects from "at risk".

---

## D37. Phase 7: ships 3 recommendation types; bandit-driven tighten/relax wait for Phase 13

**Choice.** The Phase 7 recommendation engine emits `delete_dead_swarms`, `hr_risk_imminent`, and `ratio_falling`. The brief mentioned "tighten filter F" / "relax filter F" / "increase scrape interval on tracker T" as additional types; these are deferred.

**Reasoning.** Tighten/relax recommendations require the filter performance distribution to be statistically meaningful — a filter with 3 grabs and high variance shouldn't get "tighten" advice. Phase 13's multi-armed bandit infrastructure measures confidence intervals; emitting tighten/relax recommendations against bandit-derived posteriors is the right architectural sequence. "Increase scrape interval" requires Phase 14's per-tracker scrape success/failure metrics. Phase 7 ships the recommendation types whose data is available now; later phases extend the registry.

**Trade-off.** Operators don't get filter-tuning recommendations from Phase 7 alone — they have to look at the Phase 5 quadrant view by hand until Phase 13. Acceptable; the Phase 5 view is already operator-readable.

---

## D38. Phase 7: decision_log accumulates without dedup

**Choice.** The recommendations reconciler writes a fresh `decision_log` row on every cycle for every recommendation the engine emits. There is no "we already told the operator about this" suppression.

**Reasoning.** Suppression invites a hard problem: when does the engine consider two recommendations "the same"? A dead-swarm recommendation for 6 torrents one cycle and 7 the next isn't really new, but it's also not really the same. Forcing the operator to manually dismiss old recommendations gives them control over the lifecycle without engine guesswork. The dashboard panel surfaces only the top 3, and the Decisions page defaults to the "open" filter, so the operator doesn't see noise from old rows.

**Trade-off.** `decision_log` grows. At single-operator scale with 15-minute reconciliation cycles, this is on the order of a few thousand rows/year — well within SQLite's comfortable range. Phase 15's audit-log pruning can opt in this table if it gets noisy.

---

## D39. Phase 7: Apply button records intent but doesn't yet drive the tool

**Choice.** `POST /api/decisions/{id}/apply` sets `operator_action = 'applied'` and `operator_action_at = now()`. It does NOT call the underlying tool to actually delete torrents / change filters.

**Reasoning.** Wiring Apply into the underlying tool surface is per-recommendation-type work: deleting dead swarms calls the Phase 3 qBittorrent adapter; tightening a filter calls the autobrr REST client (Phase 5); etc. Each path needs its own error handling + audit + rollback. Phase 15 polish is the right place to do this; the data and the intent are already recorded.

**Trade-off.** Operator UX is two-step: tap Apply in the Command Center, then perform the change in the underlying tool's UI. Documented in DecisionDetail.tsx so the operator knows what Apply means today.

---

## D40. Phase 14: `/metrics` endpoint is unauthenticated

**Choice.** `GET /metrics` is mounted at the chi root, NOT under `/api/*`. The auth middleware does not apply. Any client on the tailnet can scrape it.

**Reasoning.** Prometheus scrapes don't carry session cookies. The Tailscale-only network model is the trust boundary; anyone on the tailnet is already trusted to read state. Surfacing per-route counters publicly is acceptable; it doesn't leak operator credentials or per-torrent specifics.

**Trade-off.** Documented; matches D22's reasoning for `/ws`.

---

## D41. Phase 8: simulation uses the live intelligence engine with overrides

**Choice.** `simulation.Engine.Run()` invokes `intelligence.Engine.Generate()` with per-run config overrides (dead-swarm threshold, HR risk window) instead of replaying a synthetic event stream.

**Reasoning.** The brief calls for a sandboxed evaluation context replaying historical snapshots. At Phase 8 scope, running the production intelligence engine with mutable thresholds answers the operator's "what would have happened" question correctly — the snapshot tables ALREADY filter `simulation_id IS NULL` in queries, so production state is untouched. A separately-instrumented synthetic stream adds complexity for no operator-visible win at this scale.

**Trade-off.** Simulation outputs reflect the current snapshot state, not the historical one. For "would my proposed config have generated different recommendations over the last 30 days", the operator needs the simulation_id-tagged snapshot replay path — that's Phase 8 polish.

---

## D42. Phase 9: tqm runs persist into `audit_log`, not a new table

**Choice.** The `tqm.Recorder` writes one row per tqm run into the existing `audit_log` table with `action='tqm_run'`.

**Reasoning.** The brief mentioned a `tqm_runs` table; on reflection, audit_log already carries timestamped operator-relevant events with a structured `details_json` column and the Phase 0 audit query patterns reach it. Adding a parallel table would mean two places to query for "what happened on this date". One table is enough.

**Trade-off.** Filtering audit_log by `action='tqm_run'` requires an index hint at scale; the existing `idx_audit_log_time` is sufficient at single-operator throughput.

---

## D43. Phase 10: metric-driven budgets REPLACE, event-driven INCREMENT

**Choice.** Budget consumption updates use different semantics by ConsumedBy type. Event-driven (`event_type`) budgets increment by `per_event_cost` on each matching event. Metric-driven (`metric_query`) budgets REPLACE the consumed value with the query's return on each reconciler tick.

**Reasoning.** The two budget shapes answer different questions. "How many h&r violations this quarter" is a counting question — increment on each event. "How many hours did ratio dip below 0.5 this week" is a state question — the metric query returns the current cumulative total, so REPLACE keeps the row in sync with the SQL truth.

**Trade-off.** Operators need to understand which mode their budget uses. Documented in the example file's comments.

---

## D44. Phase 11: ships one warning rule type; further types operator-extensible

**Choice.** `corpus.CheckActionAgainstCorpus` returns warnings for `delete_torrent` actions that would violate `seed_time_requirements`. Other warning types (trump rules, group blacklists, freeleech edges) are not implemented.

**Reasoning.** Seed-time enforcement is the highest-stakes corpus rule — getting it wrong creates an actual hit-and-run violation. The other rule types are advisory (a P2P release coexisting with an internal one is fine, just suboptimal). Phase 11 establishes the warning system shape; extending it to additional rules is a per-rule function added to the switch in `CheckActionAgainstCorpus`, which the operator can do without a refactor.

**Trade-off.** Documented; matches the brief's "operators may extend" pattern.

---

## D45. Phase 13: bandit success metric is `final_uploaded_bytes > 0`

**Choice.** The bandit's per-variant Bernoulli posterior treats a grab as "success" when `final_uploaded_bytes > 0` (anything uploaded) and "failure" otherwise. The grab's exact upload magnitude is not folded into the bandit signal.

**Reasoning.** Multi-armed bandit math assumes a clean reward signal. The cleanest interpretation at single-operator scale is "did this filter's grab produce ANY upload" — a binary signal that produces well-defined Beta posteriors. The brief's vision of "upload per byte grabbed" is more sophisticated and arrives in a later iteration once Phase 7's confidence-interval-aware scoring lands.

**Trade-off.** A filter that produces 10 grabs each with 10 GB and a filter that produces 10 grabs each with 100 KB tie under this metric. Operators have the Phase 5 quadrant view for the magnitude comparison; the bandit's job is the discrete "which variant to credit", which the binary signal handles cleanly.

---

## D46. Phase 14: native Prometheus exposition, no client_golang

**Choice.** The `/metrics` handler emits Prometheus exposition format directly from `fmt.Fprintf`. The official `client_golang` library is not imported.

**Reasoning.** Phase 14 surfaces ~7 metric families. The Prometheus exposition format is stable and well-documented (RFC 0004). Pulling in client_golang adds 30+ transitive dependencies for what amounts to typed counter increments — at Phase 14's scale, the manual approach is cleaner. If the surface grows past ~20 metrics (likely in Phase 15 polish), swap to client_golang behind the same `Metrics` interface.

**Trade-off.** No histograms (we summarize HTTP durations as p50/p95 gauges per route). Operators wanting histogram quantiles can scrape the access log via Loki + LogQL (Phase 14's other documented path).

---

## D47. Phase 15: operator-side frontend polish deferred

**Choice.** Phases 8–15 ship complete backend (migrations, packages, routes, wiring). Frontend surfaces for several phases (simulation list, budgets dashboard, LLM chat, bandit detail, audit-log viewer, emergency button, settings pages) are deliberately NOT implemented in this push.

**Reasoning.** The backend is the load-bearing contract — frontend UIs depend on stable endpoints, not the other way around. Shipping the backend first means the operator can verify the system end-to-end via API probes and decide which UI surfaces to prioritize. The established frontend pattern (`api.ts` typed fetch + `pages/*.tsx` + router entry + Dashboard nav link) is well-understood; adding a new page is hours, not days.

**Trade-off.** The operator's daily-driver UX is anchored on Phases 0-7 features (dashboard, trackers, torrents, automation, decisions, notifications). New phases 8-15 are operable but require terminal probes until their UIs land. Documented prominently in PROGRESS.md's per-phase frontend table.

---

## D48. Production deploy uses `dev_mode: true` to bind 127.0.0.1 behind nginx

**Choice.** The `system.yaml` shipped to `/etc/command-center/config/` on the adampowell.pro droplet sets `dev_mode: true` so the binary skips the Tailscale interface enumeration (D3) and binds to `127.0.0.1:3015`. nginx reverse-proxies `https://adampowell.pro/command-center/` to that bind. adampowell.pro's existing site-wide `auth_request /_auth_check` gates access at the outer layer; the binary's WebAuthn passkey gate stays as the inner layer.

**Reasoning.** The droplet has no Tailscale; the production model `Tailscale interface or fail-closed` from D3/D7 assumed a self-hosted seedbox on a tailnet, not a multi-app rented VPS. Adding a fresh `bind_addr` config field would be cleaner but would mean rolling a new release before the first deploy could happen. `dev_mode: true` already does exactly what we need (127.0.0.1 bind, hot-reload kept, redaction unaffected) and the trust boundary it relaxes (Tailscale-only network) is replaced by adampowell.pro's session-cookie gate at nginx, which is stronger than the implicit-trust tailnet model.

**Trade-off.** The boot log warns loudly that dev_mode is on — that warning is now expected in this production. Two auth layers (adampowell.pro session cookie + command-center passkey) is friction the operator accepts; a future iteration may add an "upstream-auth trust" mode that lets the binary skip its own passkey when `X-Forwarded-User` is set by a trusted upstream. Not in scope for the first deploy.

---

## D49. Frontend prefixed with `import.meta.env.BASE_URL` for /command-center/ subpath

**Choice.** Vite's `base` is conditional: `"/command-center/"` for `vite build`, `"/"` for `vite dev`. Every runtime URL the frontend constructs (every `fetch("/api/…")` in `lib/api.ts`, the `new EventSource` call in `lib/sse.ts`, the `new WebSocket` URL in `lib/ws.ts`, the route parser + `navigate()` + `<Link>` in `lib/router.tsx`, the `window.location.href = …` sign-out redirect in `pages/Devices.tsx`, and `manifest.webmanifest` `start_url`/`scope`) is prefixed with `BASE = import.meta.env.BASE_URL.replace(/\/$/, "")`. Bare `<a href="/…">` anchors that bypass the `<Link>` component were converted to `<Link>`.

**Reasoning.** Setting Vite's `base` alone covers asset URLs in the built HTML (script/stylesheet hrefs, the manifest link, the service-worker scope) but does NOT rewrite runtime fetch / EventSource / WebSocket URLs in the JavaScript itself. With the binary reverse-proxied at `/command-center/` and nginx stripping the prefix on `proxy_pass` (trailing-slash semantics), the browser must construct URLs with `/command-center/` so nginx routes them correctly; the Go server, post-strip, still sees `/api/…`. Putting `BASE` everywhere is the only sound way to make the same bundle work in dev (base = `/`) and prod (base = `/command-center/`).

**Trade-off.** ~60 sites in the frontend now reference `BASE` (or `import.meta.env.BASE_URL`). Renaming the prod prefix becomes a one-line Vite config change; the bundle adapts automatically. The handful of bare `<a href>` sites that bypassed the `<Link>` component were a latent bug — those anchors broke cmd-click semantics under any base != `/`; converting them to `<Link>` is a strict improvement.

---

## D50. Persist WebAuthn BE/BS flags + attachment for cross-device passkey sync

**Choice.** Migration `013_webauthn_flags.sql` adds `backup_eligible INTEGER`, `backup_state INTEGER`, and `attachment TEXT` columns to `webauthn_credentials`. `CredentialStore.Insert` records the flag values from `cred.Flags.BackupEligible` / `cred.Flags.BackupState` and the attachment from `cred.Authenticator.Attachment` at registration time; `CredentialStore.All` loads them back so the in-memory `webauthn.Credential` value passed to `webauthn.FinishLogin` carries the same flags it was registered with. Existing credentials (registered before the migration, when flags were silently dropped) are backfilled `backup_eligible=1`, `backup_state=1` — the safe default for the only path that reached production: a single-device first-registration funnelled through a synced password manager.

**Reasoning.** Without persistence, `cred.Flags` was zero-valued at load time. The go-webauthn library v0.17.3 enforces WebAuthn L3 §6.1.3's "BE flag MUST NOT change once set" by comparing the stored `cred.Flags.BackupEligible` to the assertion's `authData.Flags.HasBackupEligible()`. Any credential synced via ProtonPass / 1Password / iCloud Keychain reports BE=true on every device — so on the first cross-device login the library aborted with `auth: finish login: Backup Eligible flag inconsistency detected during login validation` and returned 401 to the frontend. Multi-device passkey use was effectively broken end-to-end.

This was caught in production on the adampowell.pro deploy when the operator (registered on device A via ProtonPass) tried to sign in from device B — five attempts, all 401 with the same error string, surfaced in Safari as the generic `TypeError: Load failed`. The audit log made the root cause unambiguous within seconds.

**Trade-off.** The backfill assumes every pre-migration credential is backup-eligible. An operator who registered a hardware security key (YubiKey etc., BE=false at registration time) before this migration ran will need to manually fix their row: `UPDATE webauthn_credentials SET backup_eligible=0, backup_state=0 WHERE id='<base64url-credid>'`. There's no auto-detection because the original flag wasn't stored. The migration is forward-correct: every credential registered after deploy will round-trip its actual flags.

A longer-term improvement is to catch the BE inconsistency error from the library and offer the operator a "trust on first change" path that updates the stored flag rather than failing the login. Out of scope for this fix.

---

## D51. Split nginx `/command-center/` block: outer auth_request stays on HTML shell, drops off API/SSE/WS

**Choice.** The single `location /command-center/ { auth_request /_auth_check; ... }` block in `/etc/nginx/sites-{available,enabled}/adampowell.pro` is split into four locations: `/command-center/api/`, `/command-center/sse/`, `/command-center/ws` (each with no outer `auth_request`), plus a catch-all `/command-center/` that retains the outer gate. The inner `cc_session` cookie (set by the Go binary after WebAuthn login; HttpOnly, SameSite=Strict, Path rewritten to `/command-center/` by `proxy_cookie_path`) is the sole auth on the API/streams paths.

**Reasoning.** With outer auth on every prefix, an expired outer-site session cookie causes `auth_request` to return 401 → `error_page 401 = @login_redirect` → `302 → /login`. Browsers handle navigation 302s natively, but XHR / `fetch()` from a PWA installed at the `/command-center/` scope cannot follow a redirect into a different scope (`/login` lives at the root) — Safari surfaces the cross-scope failure as the generic `TypeError: Load failed` with a 2–10ms timing (the request never leaves the loader). The result was a silent total-failure mode on iOS PWAs whenever the outer cookie expired: every API call died, with no recovery short of swipe-killing and relaunching the PWA to force a hard navigation that could follow the redirect. This happened to the operator twice within two weeks.

The fix removes the cross-scope redirect from the XHR path. API/SSE/WS calls hit the Go binary directly; if the inner `cc_session` cookie is missing or expired, the binary itself returns a same-origin JSON `401 unauthorized`, which the SPA's existing 401 handler already routes to the WebAuthn login screen. Navigation to `/command-center/` (which loads the SPA shell) still passes through the outer gate, so an unauthenticated browser landing on the bookmark still redirects to `/login` and never sees the SPA bundle.

**Trade-off.** Defense-in-depth is reduced on API paths: a hypothetical auth-bypass CVE in `go-webauthn` or the Go binary's session middleware would be reachable without first defeating the outer site's session cookie. The countervailing realities: (1) the inner auth is WebAuthn-passkey, biometric + hardware-backed, with no shared secret to leak; (2) the static SPA bundle contains no secrets and is itself still gated; (3) the outer gate was never the authentication of record — it was belt-and-suspenders that turned out to be UX-fatal under iOS PWA scope rules. One small side-effect: `/api/system/health` is now publicly readable (returns version string + helper path `/etc/command-center/age.key`); not credential-grade information disclosure, but worth flagging — if hardening is desired later, the binary can require inner auth on `/api/system/health` while keeping `/api/auth/*` public.

**Constraint preserved.** The HTML shell remains outer-gated, so the threat model "drive-by visitor sees the operator's seedbox SPA at `https://adampowell.pro/command-center/`" is unchanged. Only the XHR/SSE/WS attack surface is exposed to direct internet probing — and those endpoints all 401 cleanly without the inner cookie.
