● Zion Boggan zionboggan.com ↗

docs: DropHawk specification package - SPEC + binding CONTRACTS + bootstrap CLAUDE.md + env + README

445e343   op committed on Jun 22, 2026 (1 month ago)
.env.example +112 -0
@@ -0,0 +1,112 @@
+# .env.example - DropHawk
+#
+# Copy to `.env` and fill in real values. NEVER commit `.env`.
+# Pre-commit hook (gitleaks) will reject secrets.
+#
+# Reference: CONTRACTS.md §Y for the canonical list of every key.
+
+# ════════════════════════════════════════════════════════════════════
+# Postgres 16 + TimescaleDB
+# ════════════════════════════════════════════════════════════════════
+POSTGRES_USER=drophawk
+POSTGRES_PASSWORD=__CHANGE_ME__
+POSTGRES_DB=drophawk
+DATABASE_URL=postgres://drophawk:__CHANGE_ME__@postgres:5432/drophawk
+
+# ════════════════════════════════════════════════════════════════════
+# Redis 7
+# ════════════════════════════════════════════════════════════════════
+REDIS_PASSWORD=__CHANGE_ME__
+REDIS_URL=redis://:__CHANGE_ME__@redis:6379/0
+
+# ════════════════════════════════════════════════════════════════════
+# NATS 2.14 (JetStream)
+# ════════════════════════════════════════════════════════════════════
+NATS_USER=drophawk
+NATS_PASSWORD=__CHANGE_ME__
+NATS_URL=nats://drophawk:__CHANGE_ME__@nats:4222
+
+# ════════════════════════════════════════════════════════════════════
+# Meilisearch 1.47
+# ════════════════════════════════════════════════════════════════════
+MEILI_URL=http://meilisearch:7700
+MEILI_MASTER_KEY=__CHANGE_ME__
+
+# ════════════════════════════════════════════════════════════════════
+# ntfy 2.14 (self-hosted push)
+# ════════════════════════════════════════════════════════════════════
+NTFY_BASE_URL=http://ntfy:80
+NTFY_TOPIC=drophawk-drops
+NTFY_TOPIC_INFO=drophawk-info
+# Generate: docker compose exec ntfy token create --user admin
+NTFY_TOKEN=tk___CHANGE_ME__
+
+# ════════════════════════════════════════════════════════════════════
+# MinIO (debug screenshots)
+# ════════════════════════════════════════════════════════════════════
+MINIO_ROOT_USER=drophawk
+MINIO_ROOT_PASSWORD=__CHANGE_ME__
+MINIO_ENDPOINT=http://minio:9000
+
+# ════════════════════════════════════════════════════════════════════
+# Observability
+# ════════════════════════════════════════════════════════════════════
+LOG_LEVEL=info # trace|debug|info|warn|error
+OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
+GRAFANA_ADMIN_PASSWORD=__CHANGE_ME__
+
+# ════════════════════════════════════════════════════════════════════
+# Public URL (homelab)
+# ════════════════════════════════════════════════════════════════════
+PUBLIC_BASE_URL=https://drophawk.lan
+
+# ════════════════════════════════════════════════════════════════════
+# Source credentials
+# ════════════════════════════════════════════════════════════════════
+# Ticketmaster Discovery API (https://developer.ticketmaster.com)
+# Free; up to 5 keys per account. Comma-separated for rotation.
+TICKETMASTER_API_KEYS=
+# SeatGeek (https://seatgeek.com/developers)
+SEATGEEK_CLIENT_ID=
+# Optional affiliate IDs (honest disclosure in UA)
+TICKETMASTER_AFFILIATE_ID=
+SEATGEEK_AFFILIATE_ID=
+
+# ════════════════════════════════════════════════════════════════════
+# Python scrapers (Phase 3+)
+# ════════════════════════════════════════════════════════════════════
+SCRAPER_CONCURRENCY=4
+SCRAPER_LOG_LEVEL=INFO
+PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+FIFA_BASE_URL=https://tickets.fifa.com
+AXS_BASE_URL=https://www.axs.com
+SEETICKETS_BASE_URL=https://www.seetickets.com
+STUBHUB_API_KEY=
+VIVIDSEATS_PROXY=
+TWITTER_ACCOUNTS= # comma list of @handles
+TWITTER_BEARER_TOKEN=
+
+# Proxy pool (Phase 6+, optional)
+PROXY_POOL_URL=
+
+# Telegram fallback (optional)
+TELEGRAM_BOT_TOKEN=
+
+# ════════════════════════════════════════════════════════════════════
+# Operational knobs (defaults are sensible)
+# ════════════════════════════════════════════════════════════════════
+API_RATE_LIMIT_PER_MIN=100
+SSE_MAX_CLIENTS=200
+SCRAPER_RPC_TIMEOUT_S=30
+WATCHLIST_MAX_RULES=500
+POLL_DEFAULT_INTERVAL_S=300
+POLL_TIGHT_INTERVAL_S=10
+POLL_TIGHT_WINDOW_S=900 # ±15 min around known drop
+
+# ════════════════════════════════════════════════════════════════════
+# Frontend (build-time, expose to browser via NEXT_PUBLIC_)
+# ════════════════════════════════════════════════════════════════════
+NEXT_PUBLIC_API_BASE_URL=https://drophawk.lan/api
+NEXT_PUBLIC_NTFY_PUBLIC_URL=https://drophawk.lan/ntfy
+NEXT_PUBLIC_NTFY_PUBLIC_TOPIC=drophawk-drops
+NEXT_PUBLIC_SENTRY_DSN= # optional
.gitignore +67 -0
@@ -0,0 +1,67 @@
+# DropHawk .gitignore
+
+# Secrets
+.env
+.env.*
+!.env.example
+*.pem
+*.key
+*.crt
+
+# Rust
+target/
+**/*.rs.bk
+Cargo.lock.bak
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+.mypy_cache/
+.ruff_cache/
+.pytest_cache/
+.vcr/
+
+# Node / Next.js
+node_modules/
+.next/
+out/
+build/
+dist/
+.vercel/
+*.tsbuildinfo
+next-env.d.ts
+
+# Playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+
+# Editor
+.vscode/
+.idea/
+*.swp
+*.swo
+.DS_Store
+
+# Docker
+docker-compose.override.yml
+
+# Backups
+backups/
+*.sql.gz
+*.dump
+
+# Coverage
+coverage/
+*.lcov
+.tarpaulin/
+
+# OS
+Thumbs.db
+
+# Local data (never commit volumes)
+data/
+volumes/
CLAUDE.md +118 -0
@@ -0,0 +1,118 @@
+# CLAUDE.md - Agent Bootstrap for DropHawk
+
+You are about to build **DropHawk**, an always-on, self-hosted, multi-source ticket-drop monitor. The operator is disabled and relies on this working. Read this file in full, then load the two authoritative documents below.
+
+## Authoritative documents (read in order)
+
+| Order | File | Role |
+|---|---|---|
+| 1 | `SPEC.md` | Architecture & intent. Section 4 = repo layout. Section 14 = phased roadmap. |
+| 2 | `CONTRACTS.md` | **Binding implementation contracts.** Versions, schemas, DDL, colors, algorithms, runbooks. This file wins on disagreement. |
+
+Do not write any code until both are read.
+
+## What DropHawk is
+
+A homelab Docker service that continuously polls dozens of ticket sources (Ticketmaster, SeatGeek, FIFA, AXS, StubHub, NFL/NBA/NHL/MLB, F1, UEFA, festivals, Twitter/X shock-drop monitor), detects the instant an event's status flips to "on sale" (or a new event appears, or a resale price drops ≥10%), and pushes a notification to the operator's phone via self-hosted ntfy within 15 seconds - with a one-tap link to buy.
+
+## What DropHawk is NOT (hard guardrails)
+
+- ❌ Not a bulk auto-purchaser. (US BOTS Act / UK law)
+- ❌ Not a CAPTCHA solver. If a source requires CAPTCHA, mark it `down`.
+- ❌ Not a ticket marketplace.
+- ❌ Not a scalping bot. Personal-use monitoring + manual purchase only.
+
+## Tech stack (locked - see CONTRACTS §A for exact pins)
+
+- **Backend core**: Rust 1.96 + Tokio + Axum 0.8 + sqlx + async-nats + reqwest
+- **Scrapers**: Python 3.11 + httpx + curl_cffi + Playwright + async-nats
+- **Frontend**: Next.js 16 + React 19 + Tailwind 4 (CSS-first!) + Radix + Recharts + Framer Motion
+- **Data**: PostgreSQL 16 + TimescaleDB + Redis 7 + Meilisearch 1.47
+- **Bus**: NATS 2.14 + JetStream
+- **Notifications**: ntfy 2.14 (self-hosted, free iPhone push)
+- **Edge**: Caddy 2.11
+- **Observability**: Prometheus + Grafana 13 + Loki
+
+## Hard rules (violations are bugs)
+
+1. **Implement Phase 1 end-to-end before touching Phase 2.** Thin working slice > wide unfinished.
+2. **No `unwrap()`/`any`/`TODO`/`console.log`/`print` in committed code.**
+3. **Run `make check` before any merge.** Clippy + ruff + mypy + tsc + eslint + tests all green.
+4. **Tailwind is v4 → no `tailwind.config.ts`.** Config lives in `app/globals.css` via `@theme`. Read CONTRACTS §H.1.
+5. **Every notification must pass dedupe + filter pipeline** (CONTRACTS §I.3). False positives erode trust fastest.
+6. **OKLCH palette in CONTRACTS §H.1 is the entire color system.** No ad-hoc hex.
+7. **Hash canonicalization** (CONTRACTS §D) is the regression gate. Golden vectors locked.
+8. **Migrations are forward-only and immutable.** Never edit applied.
+9. **No third-party deps beyond pinned list.** Adding one requires updating CONTRACTS §A.
+10. **`.env` never committed.** Pre-commit `gitleaks` enforces.
+11. **Conventional commits.** `feat:`, `fix:`, `chore:`, `docs:`.
+12. **No direct `fetch` in React components.** All API calls via `lib/api.ts`.
+13. **All forms validated by Zod (client) AND `validator` crate (server).**
+14. **Server Components by default.** `"use client"` only where needed (SSE consumers, interactive state).
+15. **Accessibility: WCAG 2.2 AA.** `axe-core` runs in e2e; CI fails on new violations.
+
+## Phase 1 deliverable (your first milestone)
+
+A vertical slice that proves the architecture:
+- Repo scaffold per SPEC §4.
+- Rust backend: config, DB pool, NATS, Axum, scheduler, **Ticketmaster adapter only** (CONTRACTS §A.1, §B.3, §C, §D, §E).
+- Postgres 16 + TimescaleDB with migrations from CONTRACTS §G.1-§G.10.
+- ntfy container + publisher (CONTRACTS §I).
+- Next.js 16 + Tailwind 4 (CONTRACTS §H.1) with: dashboard, LiveDropFeed (real SSE), DropCard, StatsRow, watchlist CRUD.
+- Caddy + `docker-compose.yml` for this subset.
+- **Success criterion**: operator can add "Taylor Swift" to watchlist, get an ntfy push when a Ticketmaster event flips on-sale, see it stream into the dashboard in real time. `make check` green. `docker compose up -d` healthy.
+
+Do NOT start Phase 2 (SeatGeek, Meilisearch, source grid) until Phase 1 is verified working end-to-end by the operator.
+
+## Where to look things up
+
+| Need | File | Section |
+|---|---|---|
+| Exact dependency version | CONTRACTS | §A |
+| API request/response shape | CONTRACTS | §B |
+| Polling algorithm pseudocode | CONTRACTS | §C |
+| How content_hash is computed | CONTRACTS | §D |
+| Drop detection + classification | CONTRACTS | §E |
+| TLS impersonation rules | CONTRACTS | §F |
+| SQL schema (every table, index, constraint) | CONTRACTS | §G |
+| Color palette / type scale / motion | CONTRACTS | §H |
+| ntfy payload templates | CONTRACTS | §I |
+| Prometheus metrics + SLOs | CONTRACTS | §J |
+| Error taxonomy | CONTRACTS | §K |
+| Backup / DR | CONTRACTS | §L |
+| Security model | CONTRACTS | §M |
+| Testing strategy | CONTRACTS | §N |
+| Performance budgets | CONTRACTS | §O |
+| Idempotency rules | CONTRACTS | §P |
+| Cross-source event linking | CONTRACTS | §Q |
+| Buy URL canonicalization | CONTRACTS | §R |
+| PWA / mobile | CONTRACTS | §S |
+| WCAG 2.2 AA | CONTRACTS | §T |
+| i18n | CONTRACTS | §U |
+| Image pipeline | CONTRACTS | §V |
+| Failure mode runbooks | CONTRACTS | §W |
+| CORS | CONTRACTS | §X |
+| Every env var | CONTRACTS | §Y |
+
+## Make targets to implement
+
+```make
+make up # docker compose up -d
+make down # docker compose down
+make logs # docker compose logs -f
+make check # clippy + ruff + mypy + tsc + eslint + tests
+make migrate # sqlx migrate run (against $DATABASE_URL)
+make seed # apply seed migrations
+make psql # docker compose exec postgres psql -U drophawk
+make redis-cli # docker compose exec redis redis-cli
+make nats-sub # docker compose exec nats nats sub '>'
+make health # scripts/healthcheck.sh
+make backup # scripts/pg-backup.sh
+make bench # cargo bench + pnpm test:e2e
+```
+
+## Final note to the executing agent
+
+The operator wants "the best of the best." This means: production-grade reliability, beautiful dense UI, no shortcuts, no settling. But it also means: **don't over-engineer Phase 1**. The Phase 1 slice must be unambiguously working before breadth is added. Reliability > features. A working Ticketmaster-only slice the operator can trust beats a half-built 25-source monster that crashes nightly.
+
+Begin now. Read SPEC.md §4, then CONTRACTS.md §A and §G, then scaffold the repo. First commit: empty directory structure with placeholder files matching SPEC §4 + this CLAUDE.md + SPEC.md + CONTRACTS.md + `.env.example` + `Makefile` + `docker-compose.yml`. Then implement Ticketmaster adapter. Then notification. Then UI. Verify end-to-end. Iterate.
CONTRACTS.md +2032 -0
@@ -0,0 +1,2032 @@
+# DropHawk - Binding Contracts (Addendum to SPEC.md)
+
+> **Status:** BINDING. Where this file and SPEC.md disagree, **this file wins**.
+> **Purpose:** Close every ambiguity in SPEC.md. Pin every version. Specify every schema byte. No settling.
+> **Reading order:** Read SPEC.md sections 1-6 for context, then read this entire file before writing any code. Every section here overrides any soft language in SPEC.md.
+
+This document corrects the original spec to current upstream versions (verified June 2026):
+- **Next.js 16.2.9** (not 14) - App Router, Server Components, React 19
+- **Tailwind CSS 4.3.1** (not v3) - CSS-first config via `@theme`, **no `tailwind.config.ts`**
+- **React 19.2** - Server Actions, `use()` hook, `useFormState`
+- **Grafana 13.0.2**, **NATS 2.14.2**, **Caddy 2.11.4**, **Axum 0.8.9**, **Meilisearch 1.47.0**
+
+---
+
+## §A. Pinned Versions (exact - do not bump without re-running CI matrix)
+
+### A.1 Backend (Rust) - `backend/Cargo.toml`
+```toml
+[package]
+name = "drophawk-backend"
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.96"
+
+[dependencies]
+tokio = { version = "=1.45.1", features = ["full", "parking_lot", "tracing"] }
+axum = { version = "=0.8.4", features = ["macros", "tokio", "ws"] }
+tower = "=0.5.2"
+tower-http = { version = "=0.6.6", features = ["cors", "trace", "compression-br", "compression-gzip", "timeout", "request-id"] }
+hyper = { version = "=1.6.0", features = ["full"] }
+reqwest = { version = "=0.12.22", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "http2", "stream"] }
+rquest = { version = "=5.0.0", features = ["impersonate", "json"] } # TLS fingerprinting, Phase 3+
+sqlx = { version = "=0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "chrono", "json", "ipnetwork"] }
+async-nats = "=0.41.0"
+redis = { version = "=0.32.3", features = ["tokio-rustls-comp", "connection-manager"] }
+serde = { version = "=1.0.219", features = ["derive"] }
+serde_json = { version = "=1.0.140", features = ["preserve_key_order", "raw_value"] }
+chrono = { version = "=0.4.41", features = ["serde", "clock"], default-features = false }
+uuid = { version = "=1.17.0", features = ["v7", "serde"] }
+sha2 = "=0.10.9"
+hex = "=0.4.3"
+tracing = "=0.1.41"
+tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "json", "registry"] }
+opentelemetry = "=0.29.0"
+opentelemetry-otlp = "=0.29.0"
+opentelemetry_sdk = { version = "=0.29.0", features = ["rt-tokio"] }
+thiserror = "=2.0.12"
+async-trait = "=0.1.88"
+validator = { version = "=0.20.0", features = ["derive"] }
+figment = { version = "=0.10.19", features = ["env", "toml"] }
+anyhow = "=1.0.98" # binary entry only
+prometheus = "=0.14.0"
+once_cell = "=1.21.3"
+parking_lot = "=0.12.3"
+futures = "=0.3.31"
+bytes = "=1.10.1"
+mime = "=0.3.17"
+url = { version = "=2.5.4", features = ["serde"] }
+
+[dev-dependencies]
+wiremock = "=0.6.3"
+tokio-test = "=0.4.4"
+pretty_assertions = "=1.4.1"
+insta = "=1.43.1"
+```
+
+**Build profile:**
+```toml
+[profile.release]
+lto = "fat"
+codegen-units = 1
+strip = "symbols"
+panic = "abort"
+opt-level = 3
+```
+
+### A.2 Python scrapers - `scrapers/pyproject.toml`
+```toml
+[project]
+name = "drophawk-scrapers"
+version = "0.1.0"
+requires-python = "==3.11.*"
+
+[tool.uv]
+dev-dependencies = [
+ "pytest==8.4.1",
+ "pytest-asyncio==0.26.0",
+ "pytest-cov==6.2.1",
+ "vcrpy==7.0.0",
+ "respx==0.22.0",
+ "ruff==0.12.0",
+ "mypy==1.16.0",
+]
+
+dependencies = [
+ "httpx==0.28.1",
+ "curl-cffi==0.11.1",
+ "selectolax==0.3.29",
+ "beautifulsoup4==4.13.4",
+ "playwright==1.61.0",
+ "playwright-stealth==1.0.6",
+ "async-nats==0.41.0",
+ "pydantic==2.11.7",
+ "tenacity==9.1.2",
+ "python-dotenv==1.1.0",
+ "orjson==3.11.0",
+ "structlog==25.4.0",
+ "fake-useragent==2.2.0",
+ "cssselect==1.3.0",
+]
+```
+
+**Lockfile:** Commit `uv.lock`. CI runs `uv sync --frozen`.
+
+### A.3 Frontend - `frontend/package.json`
+```json
+{
+ "name": "drophawk-frontend",
+ "private": true,
+ "type": "module",
+ "engines": { "node": ">=20.9.0", "pnpm": ">=9.0.0" },
+ "packageManager": "pnpm@9.18.2",
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build",
+ "start": "next start -H 0.0.0.0 -p 3000",
+ "lint": "next lint",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:e2e": "playwright test",
+ "format": "prettier --write ."
+ },
+ "dependencies": {
+ "next": "16.2.9",
+ "react": "19.2.0",
+ "react-dom": "19.2.0",
+ "@tanstack/react-query": "5.101.0",
+ "@tanstack/react-virtual": "3.13.12",
+ "zustand": "5.0.8",
+ "react-hook-form": "7.62.0",
+ "@hookform/resolvers": "3.10.0",
+ "zod": "3.25.76",
+ "framer-motion": "12.40.0",
+ "lucide-react": "0.475.0",
+ "cmdk": "1.1.1",
+ "sonner": "1.7.4",
+ "date-fns": "4.1.0",
+ "clsx": "2.1.1",
+ "tailwind-merge": "3.3.1",
+ "class-variance-authority": "0.7.1",
+ "@radix-ui/react-dialog": "1.1.17",
+ "@radix-ui/react-dropdown-menu": "2.1.16",
+ "@radix-ui/react-popover": "1.1.16",
+ "@radix-ui/react-select": "2.1.16",
+ "@radix-ui/react-switch": "1.1.16",
+ "@radix-ui/react-tabs": "1.1.16",
+ "@radix-ui/react-tooltip": "1.1.17",
+ "@radix-ui/react-toast": "1.2.16",
+ "@radix-ui/react-label": "2.1.14",
+ "@radix-ui/react-slot": "1.3.0",
+ "recharts": "3.2.1"
+ },
+ "devDependencies": {
+ "@types/node": "22.19.0",
+ "@types/react": "19.2.0",
+ "@types/react-dom": "19.2.0",
+ "typescript": "5.9.2",
+ "tailwindcss": "4.3.1",
+ "@tailwindcss/postcss": "4.3.1",
+ "postcss": "8.5.6",
+ "autoprefixer": "10.4.21",
+ "prettier": "3.6.2",
+ "prettier-plugin-tailwindcss": "0.7.1",
+ "eslint": "9.32.0",
+ "eslint-config-next": "16.2.9",
+ "@eslint/js": "9.32.0",
+ "typescript-eslint": "8.38.0",
+ "vitest": "3.2.4",
+ "@vitejs/plugin-react": "5.0.0",
+ "@testing-library/react": "16.3.0",
+ "@testing-library/jest-dom": "6.6.3",
+ "@playwright/test": "1.61.0"
+ }
+}
+```
+
+> **Note on Tremor:** Original spec mentioned `@tremor/react` but it's stale (last release Jan 2025). Use **Recharts 3.2.1 + custom shadcn-style chart wrappers** instead. This is the upgraded choice.
+
+### A.4 Infrastructure - `docker-compose.yml` image tags
+```yaml
+postgres: postgres:16.10-bookworm # with timescaledb-tsl via build
+redis: redis:7.4.3-alpine3.21
+nats: nats:2.14.2-alpine3.21 # with -js flag for JetStream
+meilisearch: getmeili/meilisearch:v1.47.0
+ntfy: binwiederhier/ntfy:v2.14.0
+minio: minio/minio:RELEASE.2026-01-15T01-00-00Z
+prometheus: prom/prometheus:v3.4.1
+grafana: grafana/grafana-oss:13.0.2
+loki: grafana/loki:3.5.5
+caddy: caddy:2.11.4-alpine
+```
+
+### A.5 Tools
+- Rust: `1.96.0` via `rustup toolchain install 1.96.0`
+- Node: `20.20.2` (LTS Iron, already installed) - frontend requires `>=20.9.0` ✓
+- pnpm: `9.18.2` (install via `corepack enable && corepack prepare pnpm@9.18.2 --activate`)
+- Python: `3.11.2` (already installed)
+- uv: `0.7.0` (install via `curl -LsSf https://astral.sh/uv/0.7.0/install.sh | sh`)
+- Docker: `>=24.0` on homelab host
+- Docker Compose: `v2.30+` (plugin form)
+
+---
+
+## §B. Exact API Contracts
+
+All paths under `/api`. All request bodies `application/json; charset=utf-8`. All responses `application/json; charset=utf-8` (except SSE). Timestamps RFC3339 UTC with `Z` suffix. Money fields in **minor units** (cents) as integer. Money formatting for display happens client-side.
+
+### B.1 Response envelope
+
+**Success (single resource):**
+```json
+{ "data": { /* resource */ }, "meta": { "request_id": "uuid-v7", "version": "v1" } }
+```
+
+**Success (collection):**
+```json
+{
+ "data": [ /* resources */ ],
+ "meta": {
+ "request_id": "01HXY...",
+ "version": "v1",
+ "page": 1,
+ "per_page": 50,
+ "total": 1234,
+ "total_pages": 25
+ }
+}
+```
+
+**Error:**
+```json
+{
+ "error": {
+ "code": "SOURCE_NOT_FOUND",
+ "message": "No source with id 'stubhub'",
+ "request_id": "01HXY...",
+ "details": { /* optional, schema-specific */ }
+ }
+}
+```
+
+### B.2 Error code → HTTP status table (complete)
+
+| Code | HTTP | When |
+|---|---|---|
+| `BAD_REQUEST` | 400 | Malformed JSON, missing required field |
+| `VALIDATION_FAILED` | 422 | Zod-equivalent schema fail; `details.fields[]` lists errors |
+| `UNAUTHORIZED` | 401 | Missing/invalid session token (Phase 7+ auth) |
+| `FORBIDDEN` | 403 | Authenticated but not allowed |
+| `NOT_FOUND` | 404 | Resource id does not exist |
+| `CONFLICT` | 409 | Watchlist rule duplicate |
+| `RATE_LIMITED` | 429 | Client rate limited (per IP, 100 req/min) |
+| `INTERNAL` | 500 | Unhandled; full trace in logs only, never response |
+| `UPSTREAM_UNAVAILABLE` | 503 | DB/NATS/Redis unreachable |
+| `NOT_IMPLEMENTED` | 501 | v2+ endpoint called in v1 build |
+
+### B.3 Endpoints - full contracts
+
+#### `GET /api/events`
+Query params (all optional):
+| Param | Type | Default | Notes |
+|---|---|---|---|
+| `page` | int ≥1 | 1 | |
+| `per_page` | int 1-100 | 50 | |
+| `category` | enum | - | `concert,sports,festival,theater,other` (comma list) |
+| `sport` | enum | - | `nfl,nba,nhl,mlb,tennis,f1,soccer,other` |
+| `source_id` | enum | - | comma list |
+| `status` | enum | - | `announced,presale,onsale,soldout,cancelled` |
+| `price_min_usd_cents` | int | - | filters `price_min >=` |
+| `price_max_usd_cents` | int | - | filters `price_max <=` |
+| `starts_after` | RFC3339 | - | `starts_at >=` |
+| `starts_before` | RFC3339 | - | `starts_at <=` |
+| `on_sale_after` | RFC3339 | - | `on_sale_at >=` |
+| `q` | string | - | trigram search on title |
+| `sort` | enum | `on_sale` | `on_sale,-starts_at,price_min,-first_seen_at` |
+
+Response `data[]` element:
+```json
+{
+ "id": 12345,
+ "source_id": "ticketmaster",
+ "source_event_id": "Z1p2q3r4s5t6",
+ "title": "Taylor Swift | The Eras Tour",
+ "subtitle": "with Paramore",
+ "category": "concert",
+ "sport": null,
+ "starts_at": "2026-08-12T23:59:00Z",
+ "venue_name": "Hard Rock Stadium",
+ "city": "Miami Gardens",
+ "country": "US",
+ "latitude": 25.958,
+ "longitude": -80.239,
+ "image_url": "https://.../eras.jpg",
+ "on_sale_at": "2026-06-22T15:00:00Z",
+ "presale_on_sale_at": "2026-06-19T15:00:00Z",
+ "status": "onsale",
+ "price_min": 4950,
+ "price_max": 89900,
+ "currency": "USD",
+ "inventory_remaining": 15234,
+ "buy_url": "https://www.ticketmaster.com/event/Z1p2q3r4s5t6",
+ "first_seen_at": "2026-06-15T10:00:00Z",
+ "updated_at": "2026-06-22T15:00:03Z"
+}
+```
+
+#### `GET /api/events/:id`
+Single event + `price_history[]` (last 30 days, max 1000 points) + `cross_source_links[]` (see §Q).
+
+#### `GET /api/drops`
+Query params: `page`, `per_page`, `drop_type` (`new,status,price,inventory,announcement`), `category`, `source_id`, `since` (RFC3339), `until`. Sort fixed `detected_at DESC`.
+
+Response `data[]` element:
+```json
+{
+ "id": 987654,
+ "event_id": 12345,
+ "drop_type": "status",
+ "from_state": { "status": "announced" },
+ "to_state": { "status": "onsale" },
+ "detected_at": "2026-06-22T15:00:01.234Z",
+ "notified": true,
+ "notified_at": "2026-06-22T15:00:02.891Z",
+ "event": { /* embedded Event, same shape as B.3 */ }
+}
+```
+
+#### `GET /api/drops/stream` (SSE)
+Response headers:
+```
+Content-Type: text/event-stream; charset=utf-8
+Cache-Control: no-cache, no-transform
+Connection: keep-alive
+X-Accel-Buffering: no # disable proxy buffering
+```
+
+Wire protocol - each event:
+```
+event: drop.status
+id: 987654
+data: {"id":987654,"event_id":12345,"drop_type":"status","detected_at":"2026-06-22T15:00:01.234Z","event":{...}}
+
+```
+
+(trailing blank line mandatory)
+
+Heartbeat every 15s:
+```
+: heartbeat 1719064800
+
+```
+
+Reconnect: client reads `Last-Event-ID` header → server replays from that drop id forward on reconnect (query `drops WHERE id > $last_event_id ORDER BY id ASC LIMIT 500`), then switches to live tail.
+
+Event types (complete enum): `drop.new`, `drop.status`, `drop.price`, `drop.inventory`, `drop.announcement`, `source.health` (status changes), `system.info`.
+
+#### `GET /api/watchlist`
+Response `data[]`:
+```json
+{
+ "id": 12,
+ "kind": "artist",
+ "pattern": "(?i)\\btaylor\\s+swift\\b",
+ "label": "Taylor Swift",
+ "active": true,
+ "priority": "critical",
+ "matches_30d": 47,
+ "created_at": "2026-06-01T00:00:00Z"
+}
+```
+
+#### `POST /api/watchlist`
+Request:
+```json
+{
+ "kind": "artist",
+ "pattern": "(?i)\\btaylor\\s+swift\\b",
+ "label": "Taylor Swift",
+ "priority": "critical"
+}
+```
+- `kind` ∈ {`artist`, `team`, `venue`, `sport`, `category`, `keyword`, `twitter_account`}
+- `pattern` is a Rust regex (`regex` crate syntax). Server validates by compiling it; 422 on failure.
+- `priority` ∈ {`low`, `normal`, `high`, `critical`}, default `normal`.
+- 409 if `(kind, pattern)` already exists.
+
+#### `PATCH /api/watchlist/:id`
+Partial update: `active`, `priority`, `label`. Cannot change `kind` or `pattern` (delete and recreate).
+
+#### `DELETE /api/watchlist/:id` → 204 No Content
+
+#### `POST /api/watchlist/bulk`
+Request: `{ "rules": [{ "kind":"keyword","label":"Coldplay" }, ...] }`
+For each rule, server auto-generates pattern as `(?i)\b<escaped_label>\b`. Returns `{ "data": { "created": N, "skipped_duplicates": M } }`.
+
+#### `GET /api/sources`
+Response `data[]`:
+```json
+{
+ "id": "ticketmaster",
+ "name": "Ticketmaster Discovery API",
+ "category": "primary",
+ "enabled": true,
+ "status": "healthy",
+ "last_poll_at": "2026-06-22T15:05:01Z",
+ "last_success_at": "2026-06-22T15:05:01Z",
+ "last_error": null,
+ "consecutive_failures": 0,
+ "poll_interval_s": 300,
+ "tight_interval_s": 10,
+ "latency_ms_p50": 142,
+ "latency_ms_p95": 380,
+ "events_seen_24h": 1842
+}
+```
+`status` ∈ {`healthy`, `degraded`, `down`, `disabled`}.
+
+#### `GET /api/sources/:id`
+One source + timeseries `health[]` (last 24h, 5-min buckets) for sparkline.
+
+#### `PATCH /api/sources/:id`
+Body: `{ "enabled": true }`. Toggles polling.
+
+#### `GET /api/stats`
+Response:
+```json
+{
+ "drops_24h": 87,
+ "drops_7d": 612,
+ "sources_healthy": 14,
+ "sources_total": 18,
+ "sources_down": 1,
+ "watchlist_matches_24h": 23,
+ "detection_latency_ms_p50": 412,
+ "detection_latency_ms_p99": 8200,
+ "events_tracked": 28471,
+ "events_onsale_now": 318
+}
+```
+
+#### `GET /api/explore?q=`
+Proxy to Meilisearch `POST /indexes/events/search` with `{ "q": "<q>", "limit": 25 }`. Return matches with highlight.
+
+#### `GET /api/health` → `200 {"status":"ok"}`
+#### `GET /api/ready` → 200 only if Postgres + NATS + Redis all reachable; else 503.
+
+#### `POST /api/notifications/test`
+Triggers a test push to ntfy. Returns `{ "data": { "sent": true, "topic": "drophawk-drops" } }`.
+
+---
+
+## §C. Exact Polling Algorithm
+
+Per-source task pseudocode (Rust). This is the canonical scheduler loop. Implement exactly.
+
+```rust
+// pseudo-Rust, every detail matters
+async fn run_source_loop(adapter: Arc<dyn SourceAdapter>, state: Arc<AppState>) {
+ let mut backoff = ExponentialBackoff::new(Duration::from_secs(2), Duration::from_secs(600));
+ let mut last_known_drops_within_window: Vec<DateTime<Utc>> = vec![];
+
+ loop {
+ // 1. Compute next interval using adapter hint
+ let now = Utc::now();
+ let ctx = PollContext { now, /* ... */ };
+ let base_interval = adapter.next_poll_hint(&ctx);
+ let interval = backoff.next_or(base_interval);
+
+ // 2. Acquire rate-limit token from Redis (block until available)
+ state.cache.acquire_token(&format!("rl:{}", adapter.id())).await;
+
+ // 3. Poll
+ let t0 = Instant::now();
+ let result = adapter.poll(&ctx).await;
+ let latency = t0.elapsed();
+
+ match result {
+ Ok(candidates) => {
+ backoff.reset();
+
+ // 4. Persist source_health row (TimescaleDB)
+ state.store.record_source_health(SourceHealth {
+ source_id: adapter.id().into(),
+ observed_at: now,
+ ok: true,
+ latency_ms: latency.as_millis() as i32,
+ events_returned: candidates.len() as i32,
+ error: None,
+ }).await;
+
+ // 5. Detect + emit (see §D, §E)
+ for candidate in candidates {
+ detect::ingest_candidate(&state, &candidate).await;
+ }
+
+ // 6. Update sources table
+ state.store.mark_source_success(adapter.id(), now).await;
+
+ // 7. Reset circuit breaker
+ state.metrics.source_poll_total
+ .with_label_values(&[adapter.id(), "ok"]).inc();
+ }
+ Err(SourceError::RateLimited { retry_after }) => {
+ // Honor Retry-After, even if it overrides base interval
+ backoff.force_retry_after(retry_after.unwrap_or(Duration::from_secs(60)));
+ state.store.mark_source_failure(adapter.id(), "rate_limited").await;
+ state.metrics.source_poll_total
+ .with_label_values(&[adapter.id(), "rate_limited"]).inc();
+ }
+ Err(e) => {
+ let failures = state.store.mark_source_failure(adapter.id(), &e.to_string()).await;
+ state.metrics.source_poll_total
+ .with_label_values(&[adapter.id(), "error"]).inc();
+ // Circuit breaker: 3 → degraded, 10 → down + alert
+ if failures == 3 {
+ state.store.set_source_status(adapter.id(), "degraded").await;
+ state.bus.publish("source.health", SourceHealthEvent::degraded(adapter.id()));
+ } else if failures == 10 {
+ state.store.set_source_status(adapter.id(), "down").await;
+ state.bus.publish("source.health", SourceHealthEvent::down(adapter.id()));
+ notify::push_critical(&state, format!("Source {} is DOWN", adapter.name())).await;
+ }
+ tokio::time::sleep(backoff.next()).await;
+ continue;
+ }
+ }
+
+ tokio::time::sleep(interval).await;
+ }
+}
+```
+
+`ExponentialBackoff`:
+- start: 2s, factor: 2, max: 600s, jitter: ±15% (use `rand` crate `SmallRng` seeded from `Instant::now()`)
+- `force_retry_after(d)` overrides next sleep to exactly `d` (no jitter).
+
+### C.1 Adaptive interval rule (`next_poll_hint` default impl)
+
+```rust
+fn next_poll_hint(&self, ctx: &PollContext) -> Duration {
+ // Find nearest known on_sale_at for this source within ±24h
+ let nearest = ctx.upcoming_drops_for(self.id())
+ .min_by_key(|d| (d - ctx.now()).abs());
+
+ match nearest {
+ Some(t) if (t - ctx.now()).num_seconds().abs() <= 900 => Duration::from_secs(10),
+ Some(t) if (t - ctx.now()).num_seconds().abs() <= 3600 => Duration::from_secs(30),
+ Some(t) if (t - ctx.now()).num_seconds().abs() <= 14400 => Duration::from_secs(60),
+ _ => Duration::from_secs(300),
+ }
+}
+```
+
+Distance-to-drop → interval:
+| Distance | Interval |
+|---|---|
+| ≤ 15 min | **10 s** |
+| ≤ 1 h | 30 s |
+| ≤ 4 h | 60 s |
+| else | 5 min |
+
+### C.2 Fast-path shock drop detection (Phase 6+)
+For sources where shock drops are likely (FIFA last-minute, Twitter monitor), the source adapter may publish a "wake" signal on NATS subject `wake.{source_id}`. Any Rust poll loop subscribed to its own wake channel short-circuits its sleep. This gives **sub-10-second** detection when a shock drop is detected by a different signal (e.g. tweet lands).
+
+---
+
+## §D. Hash Canonicalization (exact bytes)
+
+The `content_hash` is SHA-256 over the canonical JSON of a **fixed field set** in **alphabetical key order** with **specific normalizations**. Implement exactly.
+
+### D.1 Field set (these and ONLY these participate in hash)
+1. `source_event_id` (string, trimmed)
+2. `status` (lowercase enum string)
+3. `on_sale_at` (RFC3339 UTC, millisecond precision, `null` if None)
+4. `presale_on_sale_at` (same format)
+5. `price_min` (int cents, `null` if None)
+6. `price_max` (int cents, `null` if None)
+7. `inventory_remaining` (int, `null` if None)
+
+### D.2 Normalization rules
+- Datetime → format as `YYYY-MM-DDTHH:MM:SS.sssZ` (always 3 ms digits, always `Z`). Strip sub-millisecond precision.
+- Integers stay as integers, never scientific notation.
+- None → JSON `null`.
+- Strings → UTF-8, trimmed of leading/trailing whitespace.
+- All keys sorted lexicographically (use `BTreeMap<&str, Value>`).
+- No whitespace between tokens: `serde_json::to_string` with compact formatter.
+- Output hex lowercase.
+
+### D.3 Reference implementation
+```rust
+use serde_json::{json, Value};
+use sha2::{Digest, Sha256};
+
+pub fn content_hash(c: &EventCandidate) -> String {
+ let canonical = json!({
+ "inventory_remaining": c.inventory_remaining.map(|v| v as i64),
+ "on_sale_at": c.on_sale_at.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)),
+ "presale_on_sale_at": c.presale_on_sale_at.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)),
+ "price_max": c.price_max,
+ "price_min": c.price_min,
+ "source_event_id": c.source_event_id.trim(),
+ "status": c.status.as_str().to_lowercase(),
+ });
+ // serde_json::Map preserves insertion order with preserve_key_order feature.
+ // We built it in alphabetical order above to guarantee.
+ let bytes = serde_json::to_vec(&canonical).expect("canonical serializable");
+ let mut hasher = Sha256::new();
+ hasher.update(&bytes);
+ hex::encode(hasher.finalize())
+}
+```
+
+### D.4 Golden test (must pass)
+Add `backend/tests/hash_golden.rs` with vectors:
+```json
+{"inventory_remaining":15234,"on_sale_at":"2026-06-22T15:00:00.000Z","presale_on_sale_at":null,"price_max":89900,"price_min":4950,"source_event_id":"Z1p2q3r4s5t6","status":"onsale"}
+```
+→ expected hash: `a3f1e8...` (commit the exact hash once the impl is verified; this is the regression gate).
+
+---
+
+## §E. Drop Detection & Classification
+
+On every `EventCandidate` ingest:
+
+```rust
+async fn ingest_candidate(state: &AppState, c: &EventCandidate) {
+ let new_hash = content_hash(c);
+
+ // 1. Lookup existing event by (source_id, source_event_id)
+ let existing = state.store.find_event(c.source_id, &c.source_event_id).await?;
+
+ match existing {
+ None => {
+ // NEW EVENT
+ let event_id = state.store.insert_event(c, &new_hash).await?;
+ emit_drop(state, DropEvent {
+ event_id,
+ drop_type: "new",
+ from_state: json!(null),
+ to_state: json!({"status": c.status, "on_sale_at": c.on_sale_at}),
+ }).await;
+ }
+ Some(prev) if prev.content_hash == new_hash => {
+ // UNCHANGED - no-op, just touch updated_at? No, don't even touch.
+ return Ok(());
+ }
+ Some(prev) => {
+ // CHANGED - diff fields, emit one or more drops
+ state.store.update_event(prev.id, c, &new_hash).await?;
+
+ if prev.status != c.status {
+ emit_drop(state, DropEvent {
+ event_id: prev.id,
+ drop_type: "status",
+ from_state: json!({"status": prev.status}),
+ to_state: json!({"status": c.status}),
+ }).await;
+ }
+ if let (Some(prev_min), Some(new_min)) = (prev.price_min, c.price_min) {
+ if new_min < prev_min && (prev_min - new_min) * 100 / prev_min.max(1) >= 10 {
+ emit_drop(state, DropEvent {
+ event_id: prev.id,
+ drop_type: "price",
+ from_state: json!({"price_min": prev_min}),
+ to_state: json!({"price_min": new_min}),
+ }).await;
+ }
+ }
+ if let (Some(prev_inv), Some(new_inv)) = (prev.inventory_remaining, c.inventory_remaining) {
+ if new_inv < prev_inv && (prev_inv - new_inv) * 100 / prev_inv.max(1) >= 25 {
+ emit_drop(state, DropEvent {
+ event_id: prev.id,
+ drop_type: "inventory",
+ from_state: json!({"inventory_remaining": prev_inv}),
+ to_state: json!({"inventory_remaining": new_inv}),
+ }).await;
+ }
+ }
+ }
+ }
+}
+```
+
+`emit_drop` writes to `drops` table, publishes to NATS `drop.{type}`, broadcasts via SSE channel, and triggers `notify::on_drop`.
+
+A single candidate change can emit multiple drops (e.g. status + price). That's intentional.
+
+---
+
+## §F. TLS Fingerprint & Header Coherence
+
+### F.1 Sources requiring browser impersonation (use `rquest`)
+- FIFA portal, AXS, StubHub, Vivid Seats, Twitter/X scrape, festival portals, all per-club soccer adapters.
+
+### F.2 Impersonation target rotation
+Use `rquest::Impersonate`:
+- `Chrome131` (default)
+- `Chrome130`
+- `Safari18`
+- `Safari17`
+- `Firefox133`
+
+**Rotation rule:** Each scraper process picks ONE impersonation per session at startup (process-wide). Rotate at restart, not per request - fingerprint stability per IP is what looks human.
+
+### F.3 Header coherence (CRITICAL)
+The following must always come from the SAME impersonation set - never mix:
+- TLS JA3/JA4 (controlled by rquest)
+- User-Agent
+- `sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform`
+- `accept-language`
+- `accept-encoding`
+- HTTP/2 settings (controlled by rquest)
+
+Source `scrapers/anti_bot/fingerprints.py` defines named bundles. Each bundle is one tuple. Bundles are checked into the repo as `fingerprints.json`. Update bundles **monthly** by running `scripts/refresh-fingerprints.py` (pulls latest from `wrappers.json` or hand-curated).
+
+### F.4 Proxy rotation (Phase 6+)
+- Interface: `ProxyPool.pick() -> ProxySpec`.
+- Default impl: single proxy from env (`PROXY_POOL_URL`), or `Direct` if unset.
+- Future impls: BrightData, Smartproxy, self-hosted rotating.
+- Per-source proxy affinity: hash `source_id` → fixed proxy within a 24h window. This gives IP stability per source (looks human) while distributing load across sources.
+
+### F.5 robots.txt enforcement
+On first poll of any source, fetch and parse `/robots.txt`. Cache in Redis for 24h. If the requested path is disallowed for our UA, **mark source `disabled` and alert operator**. Do not bypass.
+
+---
+
+## §G. Complete Database DDL
+
+Replace §8 of SPEC.md with this complete, runnable SQL.
+
+### G.1 `backend/migrations/20260101000000_extensions.sql`
+```sql
+CREATE EXTENSION IF NOT EXISTS "pg_trgm";
+CREATE EXTENSION IF NOT EXISTS timescaledb;
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+```
+
+### G.2 `20260101000001_sources.sql`
+```sql
+CREATE TABLE sources (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ category TEXT NOT NULL CHECK (category IN ('primary','resale','sports','festival','social')),
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ status TEXT NOT NULL DEFAULT 'healthy'
+ CHECK (status IN ('healthy','degraded','down','disabled')),
+ poll_interval_s INTEGER NOT NULL DEFAULT 300 CHECK (poll_interval_s BETWEEN 1 AND 86400),
+ tight_interval_s INTEGER NOT NULL DEFAULT 10 CHECK (tight_interval_s BETWEEN 1 AND 3600),
+ last_poll_at TIMESTAMPTZ,
+ last_success_at TIMESTAMPTZ,
+ last_error TEXT,
+ consecutive_failures INTEGER NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TRIGGER sources_set_updated_at
+ BEFORE UPDATE ON sources
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+```
+
+### G.3 `20260101000002_set_updated_at_fn.sql`
+```sql
+CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at := NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+```
+
+### G.4 `20260101000003_events.sql`
+```sql
+CREATE TABLE events (
+ id BIGSERIAL PRIMARY KEY,
+ source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE RESTRICT,
+ source_event_id TEXT NOT NULL,
+ title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 512),
+ subtitle TEXT,
+ category TEXT NOT NULL CHECK (category IN ('concert','sports','festival','theater','other')),
+ sport TEXT CHECK (sport IS NULL OR sport IN ('nfl','nba','nhl','mlb','tennis','f1','soccer','golf','boxing','other')),
+ starts_at TIMESTAMPTZ,
+ venue_name TEXT,
+ city TEXT,
+ country CHAR(2), -- ISO 3166-1 alpha-2
+ latitude DOUBLE PRECISION CHECK (latitude IS NULL OR (latitude BETWEEN -90 AND 90)),
+ longitude DOUBLE PRECISION CHECK (longitude IS NULL OR (longitude BETWEEN -180 AND 180)),
+ image_url TEXT,
+ on_sale_at TIMESTAMPTZ,
+ presale_on_sale_at TIMESTAMPTZ,
+ status TEXT NOT NULL CHECK (status IN ('announced','presale','onsale','soldout','cancelled')),
+ price_min INTEGER CHECK (price_min IS NULL OR price_min >= 0),
+ price_max INTEGER CHECK (price_max IS NULL OR price_max >= 0),
+ currency CHAR(3), -- ISO 4217
+ inventory_remaining INTEGER CHECK (inventory_remaining IS NULL OR inventory_remaining >= 0),
+ buy_url TEXT NOT NULL,
+ raw JSONB,
+ content_hash CHAR(64) NOT NULL,
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (source_id, source_event_id),
+ CONSTRAINT price_range CHECK (price_min IS NULL OR price_max IS NULL OR price_min <= price_max)
+);
+
+CREATE INDEX idx_events_status ON events(status);
+CREATE INDEX idx_events_on_sale ON events(on_sale_at) WHERE on_sale_at IS NOT NULL;
+CREATE INDEX idx_events_category ON events(category);
+CREATE INDEX idx_events_sport ON events(sport) WHERE sport IS NOT NULL;
+CREATE INDEX idx_events_starts_at ON events(starts_at) WHERE starts_at IS NOT NULL;
+CREATE INDEX idx_events_source_status ON events(source_id, status);
+CREATE INDEX idx_events_updated_at ON events(updated_at DESC);
+CREATE INDEX idx_events_title_trgm ON events USING gin (title gin_trgm_ops);
+CREATE INDEX idx_events_starts_at_source ON events(starts_at, source_id);
+
+CREATE TRIGGER events_set_updated_at
+ BEFORE UPDATE ON events
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+```
+
+### G.5 `20260101000004_drops.sql`
+```sql
+CREATE TABLE drops (
+ id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ drop_type TEXT NOT NULL CHECK (drop_type IN ('new','status','price','inventory','announcement')),
+ from_state JSONB,
+ to_state JSONB NOT NULL,
+ detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ detected_at_ms BIGINT GENERATED ALWAYS AS (EXTRACT(EPOCH FROM detected_at) * 1000) STORED,
+ notified BOOLEAN NOT NULL DEFAULT FALSE,
+ notified_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_drops_detected_desc ON drops(detected_at DESC);
+CREATE INDEX idx_drops_event ON drops(event_id);
+CREATE INDEX idx_drops_type ON drops(drop_type);
+CREATE INDEX idx_drops_type_detected ON drops(drop_type, detected_at DESC);
+
+-- TimescaleDB hypertable (drops grow fast; partition by time)
+SELECT create_hypertable('drops', 'detected_at', chunk_time_interval => INTERVAL '1 day');
+```
+
+### G.6 `20260101000005_watchlist.sql`
+```sql
+CREATE TABLE watchlist (
+ id BIGSERIAL PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('artist','team','venue','sport','category','keyword','twitter_account')),
+ pattern TEXT NOT NULL, -- Rust regex syntax
+ label TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 128),
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','critical')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (kind, pattern)
+);
+
+CREATE INDEX idx_watchlist_active_kind ON watchlist(active, kind);
+CREATE TRIGGER watchlist_set_updated_at
+ BEFORE UPDATE ON watchlist
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+```
+
+### G.7 `20260101000006_notifications.sql`
+```sql
+CREATE TABLE notifications_log (
+ id BIGSERIAL PRIMARY KEY,
+ drop_id BIGINT REFERENCES drops(id) ON DELETE SET NULL,
+ channel TEXT NOT NULL CHECK (channel IN ('ntfy','telegram','email','browser')),
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ priority TEXT NOT NULL CHECK (priority IN ('min','low','default','high','urgent')),
+ sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ success BOOLEAN NOT NULL,
+ response TEXT,
+ topic TEXT
+);
+
+CREATE INDEX idx_notif_sent_desc ON notifications_log(sent_at DESC);
+CREATE INDEX idx_notif_drop ON notifications_log(drop_id);
+SELECT create_hypertable('notifications_log', 'sent_at', chunk_time_interval => INTERVAL '7 days');
+```
+
+### G.8 `20260101000007_timeseries.sql`
+```sql
+CREATE TABLE price_history (
+ event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ source TEXT NOT NULL,
+ observed_at TIMESTAMPTZ NOT NULL,
+ price_min INTEGER,
+ price_max INTEGER,
+ currency CHAR(3)
+);
+SELECT create_hypertable('price_history', 'observed_at', chunk_time_interval => INTERVAL '7 days');
+CREATE INDEX idx_price_event_time ON price_history(event_id, observed_at DESC);
+
+CREATE TABLE source_health (
+ source_id TEXT NOT NULL,
+ observed_at TIMESTAMPTZ NOT NULL,
+ ok BOOLEAN NOT NULL,
+ latency_ms INTEGER NOT NULL CHECK (latency_ms >= 0),
+ events_returned INTEGER NOT NULL DEFAULT 0,
+ error TEXT
+);
+SELECT create_hypertable('source_health', 'observed_at', chunk_time_interval => INTERVAL '1 day');
+CREATE INDEX idx_health_source_time ON source_health(source_id, observed_at DESC);
+
+-- Continuous aggregates for UI sparklines
+CREATE MATERIALIZED VIEW source_health_5min
+WITH (timescaledb.continuous) AS
+SELECT
+ source_id,
+ time_bucket('5 minutes', observed_at) AS bucket,
+ AVG(latency_ms) AS latency_ms_p50,
+ MAX(latency_ms) AS latency_ms_p95,
+ COUNT(*) FILTER (WHERE ok) AS ok_count,
+ COUNT(*) FILTER (WHERE NOT ok) AS err_count
+FROM source_health
+GROUP BY source_id, bucket
+WITH NO DATA;
+
+SELECT add_continuous_aggregate_policy('source_health_5min',
+ start_offset => INTERVAL '1 hour',
+ end_offset => INTERVAL '5 minutes',
+ schedule_interval => INTERVAL '1 minute');
+```
+
+### G.9 `20260101000008_detection_latency.sql`
+```sql
+-- Histogram for the detection_latency_ms metric (SLO §O.3)
+CREATE TABLE detection_latency (
+ drop_id BIGINT PRIMARY KEY REFERENCES drops(id) ON DELETE CASCADE,
+ latency_ms INTEGER NOT NULL CHECK (latency_ms >= 0)
+);
+CREATE INDEX idx_latency ON detection_latency(latency_ms);
+```
+
+### G.10 `20260101000009_cross_source_links.sql` (see §Q)
+```sql
+CREATE TABLE cross_source_links (
+ cluster_id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ confidence REAL NOT NULL CHECK (confidence BETWEEN 0 AND 1),
+ match_basis TEXT NOT NULL, -- 'date+venue' | 'performer_id' | 'title+date'
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (cluster_id, event_id)
+);
+CREATE INDEX idx_xsrc_event ON cross_source_links(event_id);
+CREATE INDEX idx_xsrc_cluster ON cross_source_links(cluster_id);
+```
+
+### G.11 `20260101000010_seed_sources.sql`
+```sql
+INSERT INTO sources (id, name, category, enabled, poll_interval_s, tight_interval_s) VALUES
+ ('ticketmaster', 'Ticketmaster Discovery API', 'primary', TRUE, 300, 10),
+ ('seatgeek', 'SeatGeek', 'primary', TRUE, 300, 10),
+ ('fifa', 'FIFA World Cup Portal', 'sports', TRUE, 60, 30),
+ ('axs', 'AXS', 'primary', FALSE, 300, 10),
+ ('seetickets', 'See Tickets', 'primary', FALSE, 300, 10),
+ ('stubhub', 'StubHub (resale)', 'resale', FALSE, 600, 30),
+ ('vividseats', 'Vivid Seats (resale)', 'resale', FALSE, 600, 30),
+ ('twitter', 'Twitter/X Shock Drop', 'social', FALSE, 120, 15),
+ ('nfl', 'NFL (Ticketmaster Sports)', 'sports', FALSE, 300, 10),
+ ('nba', 'NBA', 'sports', FALSE, 300, 10),
+ ('nhl', 'NHL', 'sports', FALSE, 300, 10),
+ ('mlb', 'MLB', 'sports', FALSE, 300, 10),
+ ('atp_wta', 'ATP / WTA Tennis', 'sports', FALSE, 600, 30),
+ ('f1', 'Formula 1', 'sports', FALSE, 600, 30),
+ ('uefa', 'UEFA', 'sports', FALSE, 600, 30),
+ ('epl', 'Premier League (Big 6)', 'sports', FALSE, 600, 30),
+ ('coachella', 'Coachella', 'festival',FALSE, 600, 30),
+ ('glastonbury', 'Glastonbury', 'festival',FALSE, 600, 30),
+ ('tomorrowland', 'Tomorrowland', 'festival',FALSE, 600, 30),
+ ('edc', 'EDC', 'festival',FALSE, 600, 30),
+ ('lollapalooza', 'Lollapalooza', 'festival',FALSE, 600, 30)
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO watchlist (kind, pattern, label, priority) VALUES
+ ('keyword', '(?i)\btaylor\s+swift\b', 'Taylor Swift', 'critical'),
+ ('keyword', '(?i)\bworld\s+cup\b', 'World Cup', 'critical'),
+ ('keyword', '(?i)\bsuper\s+bowl\b', 'Super Bowl', 'critical'),
+ ('keyword', '(?i)\bwimbledon\b', 'Wimbledon', 'high'),
+ ('keyword', '(?i)\bchampions\s+league\s+final\b', 'UCL Final', 'high')
+ON CONFLICT (kind, pattern) DO NOTHING;
+```
+
+### G.12 Retention policy
+```sql
+-- Drop old data automatically (TimescaleDB)
+SELECT add_retention_policy('drops', INTERVAL '90 days');
+SELECT add_retention_policy('price_history', INTERVAL '1 year');
+SELECT add_retention_policy('source_health', INTERVAL '30 days');
+SELECT add_retention_policy('notifications_log', INTERVAL '90 days');
+```
+
+### G.13 Migration runner
+- sqlx migrate run on startup, before Axum binds.
+- Migrations are immutable. Never edit. Always forward-only.
+
+---
+
+## §H. Design Tokens (exact, OKLCH)
+
+### H.1 Tailwind 4 setup - `frontend/app/globals.css`
+**Tailwind v4 is CSS-first. There is no `tailwind.config.ts`.** Configuration lives in `@theme` blocks. This is the full theme file.
+
+```css
+@import "tailwindcss";
+
+@theme {
+ /* === Color system (OKLCH, dark-first) === */
+ /* Backgrounds */
+ --color-bg-base: oklch(0.16 0.012 264); /* page bg, deep navy-black */
+ --color-bg-elevated: oklch(0.20 0.014 264); /* card bg */
+ --color-bg-overlay: oklch(0.23 0.014 264); /* modal/sheet */
+ --color-bg-inset: oklch(0.14 0.012 264); /* recessed area */
+
+ /* Foregrounds */
+ --color-fg-primary: oklch(0.96 0.005 264);
+ --color-fg-secondary: oklch(0.78 0.008 264);
+ --color-fg-tertiary: oklch(0.60 0.010 264);
+ --color-fg-disabled: oklch(0.42 0.008 264);
+
+ /* Borders */
+ --color-border-subtle: oklch(0.28 0.014 264 / 0.6);
+ --color-border-default: oklch(0.34 0.014 264);
+ --color-border-strong: oklch(0.46 0.014 264);
+
+ /* Accents (semantic) */
+ --color-accent-teal: oklch(0.78 0.16 184); /* primary action */
+ --color-accent-teal-hover: oklch(0.72 0.18 184);
+ --color-accent-teal-soft: oklch(0.78 0.16 184 / 0.12);
+ --color-accent-amber: oklch(0.82 0.16 75); /* warnings */
+ --color-accent-red: oklch(0.66 0.22 25); /* sold out / error */
+ --color-accent-violet: oklch(0.70 0.18 300); /* presale */
+ --color-accent-green: oklch(0.74 0.18 145); /* healthy / on-sale */
+ --color-accent-blue: oklch(0.70 0.16 240); /* informational */
+
+ /* Status pills map */
+ --color-status-announced: var(--color-accent-blue);
+ --color-status-presale: var(--color-accent-violet);
+ --color-status-onsale: var(--color-accent-green);
+ --color-status-soldout: var(--color-accent-red);
+ --color-status-cancelled: var(--color-fg-tertiary);
+
+ /* === Typography === */
+ --font-sans: "Inter", "Inter Placeholder", ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
+
+ /* Type scale (1.250 major third) */
+ --text-xs: 0.64rem; --text-xs--line-height: 1rem;
+ --text-sm: 0.80rem; --text-sm--line-height: 1.25rem;
+ --text-base: 1.00rem; --text-base--line-height: 1.5rem;
+ --text-lg: 1.25rem; --text-lg--line-height: 1.75rem;
+ --text-xl: 1.56rem; --text-xl--line-height: 2rem;
+ --text-2xl: 1.95rem; --text-2xl--line-height: 2.25rem;
+ --text-3xl: 2.44rem; --text-3xl--line-height: 2.75rem;
+ --text-4xl: 3.05rem; --text-4xl--line-height: 3.25rem;
+
+ /* Letter spacing for headings */
+ --tracking-tight: -0.02em;
+ --tracking-display: -0.04em;
+
+ /* === Spacing scale (4px base) === */
+ --spacing: 0.25rem; /* 4px - Tailwind v4 default, do not change */
+
+ /* === Radii === */
+ --radius-sm: 0.375rem;
+ --radius: 0.5rem;
+ --radius-md: 0.625rem;
+ --radius-lg: 0.875rem;
+ --radius-xl: 1.25rem;
+
+ /* === Shadows (use sparingly; prefer borders) === */
+ --shadow-card: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 3px 0 oklch(0 0 0 / 0.10);
+ --shadow-pop: 0 8px 24px -8px oklch(0 0 0 / 0.40);
+ --shadow-glow-teal: 0 0 0 1px var(--color-accent-teal-soft), 0 0 24px -4px var(--color-accent-teal-soft);
+
+ /* === Motion === */
+ /* Framer Motion springs (exported to TS) */
+ --motion-spring-snappy: cubic-bezier(0.22, 1, 0.36, 1); /* ease-out-expo */
+ --motion-spring-natural: cubic-bezier(0.16, 1, 0.3, 1);
+ --motion-spring-entrance: cubic-bezier(0.34, 1.56, 0.64, 1); /* slight overshoot */
+
+ /* Durations */
+ --duration-instant: 80ms;
+ --duration-fast: 150ms;
+ --duration-base: 240ms;
+ --duration-slow: 400ms;
+ --duration-cinematic: 800ms;
+
+ /* === Z-index scale === */
+ --z-base: 0;
+ --z-sticky: 10;
+ --z-dropdown:1000;
+ --z-sheet: 2000;
+ --z-modal: 3000;
+ --z-toast: 4000;
+ --z-tooltip: 5000;
+}
+
+/* === Base layer === */
+@layer base {
+ html { color-scheme: dark; }
+ body {
+ background-color: var(--color-bg-base);
+ color: var(--color-fg-primary);
+ font-family: var(--font-sans);
+ font-feature-settings: "cv11", "ss01", "ss03";
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ }
+
+ /* Numeric data uses tabular figures */
+ .tabular { font-variant-numeric: tabular-nums; }
+
+ /* Selection */
+ ::selection { background-color: var(--color-accent-teal-soft); }
+
+ /* Scrollbar (WebKit) */
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
+ ::-webkit-scrollbar-track { background: var(--color-bg-base); }
+ ::-webkit-scrollbar-thumb {
+ background: var(--color-border-default);
+ border-radius: 999px;
+ border: 2px solid var(--color-bg-base);
+ }
+ ::-webkit-scrollbar-thumb:hover { background: var(--color-border-strong); }
+
+ /* Reduced motion */
+ @media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+ }
+}
+
+/* === Utilities === */
+@layer utilities {
+ .animate-shimmer {
+ background: linear-gradient(90deg,
+ transparent 0%,
+ var(--color-accent-teal-soft) 50%,
+ transparent 100%);
+ background-size: 200% 100%;
+ animation: shimmer 1.2s ease-out;
+ }
+ @keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+ }
+
+ .pulse-ring::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ box-shadow: 0 0 0 0 var(--color-accent-green);
+ animation: pulse-ring 2s var(--motion-spring-natural) infinite;
+ }
+ @keyframes pulse-ring {
+ 0% { box-shadow: 0 0 0 0 oklch(0.74 0.18 145 / 0.6); }
+ 100% { box-shadow: 0 0 0 12px oklch(0.74 0.18 145 / 0); }
+ }
+}
+```
+
+### H.2 Type scale → Tailwind utility mapping
+Use Tailwind v4's automatic theme→utility mapping: `text-xs`, `text-sm`, …, `text-4xl` are auto-generated from the `--text-*` vars above. No `tailwind.config.ts` needed.
+
+### H.3 Framer Motion spring presets (`frontend/lib/motion.ts`)
+```ts
+export const springs = {
+ snappy: { type: "spring", stiffness: 580, damping: 32, mass: 0.8 },
+ natural: { type: "spring", stiffness: 320, damping: 28, mass: 1.0 },
+ entrance: { type: "spring", stiffness: 240, damping: 18, mass: 1.2 }, // slight overshoot
+ sheet: { type: "spring", stiffness: 280, damping: 34, mass: 1.0 },
+ stagger: { type: "spring", stiffness: 480, damping: 30, mass: 0.6 },
+} as const;
+
+export const dropCardVariants = {
+ initial: { opacity: 0, y: -16, scale: 0.96 },
+ animate: { opacity: 1, y: 0, scale: 1, transition: springs.entrance },
+ exit: { opacity: 0, x: 24, scale: 0.92, transition: { duration: 0.18 } },
+} as const;
+
+export const flashAccentKeyframes = {
+ boxShadow: [
+ "0 0 0 0 oklch(0.78 0.16 184 / 0)",
+ "0 0 0 4px oklch(0.78 0.16 184 / 0.5)",
+ "0 0 0 0 oklch(0.78 0.16 184 / 0)",
+ ],
+ transition: { duration: 1.6, ease: "easeOut", times: [0, 0.2, 1] },
+};
+```
+
+### H.4 Status pill component (`components/ui/StatusPill.tsx`)
+- Padding `px-2.5 py-0.5`, radius `--radius-sm`, font `text-xs font-medium`, tabular nums off.
+- Bg = `color-status-{status}` at 12% alpha. Text = same color at 100%.
+- Border = same color at 25% alpha.
+- Includes leading 6px dot.
+- All variants tested against contrast ratio ≥ 4.5:1 against `--color-bg-elevated` (WCAG §U).
+
+### H.5 DropCard layout (canonical)
+```
+┌──────────────────────────────────────────────────────┐
+│ [●] On Sale 3s ago │ ← status pill + relative time (mono)
+├──────────────────────────────────────────────────────┤
+│ [IMG 64×64] Taylor Swift | The Eras Tour │
+│ with Paramore │
+│ Hard Rock Stadium · Miami Gardens │
+├──────────────────────────────────────────────────────┤
+│ $49.50 - $899.00 [TM] [ Buy → ] │ ← price + source chip + button
+└──────────────────────────────────────────────────────┘
+```
+- Card padding `p-3.5`, gap between sections `gap-2`.
+- New cards: animate in from top with `entrance` spring; flash teal ring for 1.6s.
+- Source chip: 24×24 logo or initials; tooltip shows source name.
+
+### H.6 Motion budget (do not exceed)
+- Max simultaneous animations on dashboard: **40** (use `will-change: transform` sparingly).
+- No animation longer than 1.6s except skeleton shimmer (3s loop max).
+- Exit animations: ≤200ms.
+
+---
+
+## §I. ntfy Payload Templates (exact)
+
+### I.1 Per drop type - full HTTP request
+
+**Status flip (Announced → OnSale), watchlist match:**
+```http
+POST /drophawk-drops HTTP/1.1
+Host: ntfy
+Authorization: Bearer tk_xxxxxxxxxxxxxxxxxxxxxxxx
+Title: 🎟️ ON SALE NOW: Taylor Swift - Miami
+Priority: urgent
+Tags: ticket,ticket,concert,ticketmaster
+Click: https://www.ticketmaster.com/event/Z1p2q3r4s5t6?drophawk=1
+Actions: [{"action":"view","label":"Buy","url":"https://www.ticketmaster.com/event/Z1p2q3r4s5t6?drophawk=1"}]
+Cache: no
+
+Floor seats from $49.50 · 15,234 remaining
+Hard Rock Stadium · Aug 12 2026, 7:00 PM ET
+Source: Ticketmaster · detected in 1.2s
+```
+
+**New event (matching watchlist):**
+```
+Title: 🆕 NEW: FIFA World Cup 2026 Final - NYC
+Priority: high
+Tags: soccer,fifa,world_cup
+Click: https://tickets.fifa.com/...
+```
+
+**Price drop ≥25%:**
+```
+Title: 📉 -32%: Adele - Munich (StubHub)
+Priority: high
+Tags: arrow_down,concert,stubhub
+```
+
+**Shock drop (Twitter-triggered, on_sale_at within ±5min of now):**
+```
+Title: ⚡ SHOCK DROP: Beyoncé added second London show
+Priority: urgent
+Tags: zap,concert,twitter
+```
+
+**Test push (`POST /api/notifications/test`):**
+```
+Title: ✅ DropHawk ntfy test
+Priority: default
+Tags: white_check_mark
+Body: If you can read this, your pipeline works. Detected at {RFC3339 now}.
+```
+
+### I.2 Priority mapping (LOCKED)
+| Drop signal | Watchlist priority | ntfy Priority |
+|---|---|---|
+| status→onsale | critical | `urgent` |
+| status→onsale | high | `high` |
+| status→onsale | normal/low | `default` |
+| new (watchlist match) | critical | `urgent` |
+| new (watchlist match) | high | `high` |
+| new (watchlist match) | normal | `default` |
+| new (no match) | - | **not sent** (UI only) |
+| price ≥25% drop | any | `high` |
+| price 10-25% drop | any | `default` |
+| inventory ≥25% drop | any | `default` |
+| shock drop (Twitter) | any | `urgent` |
+
+### I.3 Dedupe rules (anti-spam)
+- One ntfy push per `(event_id, drop_type)` per **10 minutes** (Redis key `notif:dedupe:{event_id}:{drop_type}` with TTL 600s).
+- Max 5 notifications per minute total (token bucket).
+- Max 50 per hour.
+- If exceeded: drop quietly, log to `notifications_log` with `success=false, response='rate_limited_self'`.
+- Quiet hours (configurable in Settings): default OFF (operator wants to wake up). When on, only `urgent` priority breaks through.
+
+### I.4 ntfy server config (`infra/ntfy.server.yml`)
+```yaml
+base_url: "http://ntfy"
+listen: ":80"
+cache_file: "/var/lib/ntfy/cache.db"
+auth_file: "/var/lib/ntfy/user.db"
+auth_default_access: "deny-all"
+
+# Behind Caddy already terminates TLS, so internal is HTTP
+behind_proxy: true
+
+# Attachment limits (we don't use attachments for pushes)
+attachment_total_size_limit: "1M"
+attachment_file_size_limit: "512K"
+
+# Rate limit (per-IP; we don't actually want to limit our own internal publisher,
+# so set high)
+global_topic_limit: 1000
+visitor_request_limit_burst: 100
+visitor_request_limit_replenish: "1s"
+
+# Enable SMTP? No, not for v1.
+```
+
+Create admin + publisher tokens at deploy time via `ntfy user add` and `ntfy token create` (one-shot, persisted in MinIO backup bucket for restore).
+
+---
+
+## §J. Prometheus Metrics Catalog
+
+Every metric prefixed `drophawk_`. All histograms use these standard buckets (ms): `[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000]`.
+
+### J.1 Counters
+| Metric | Labels | When |
+|---|---|---|
+| `drophawk_source_poll_total` | `source`, `result`(ok/error/rate_limited) | end of every poll |
+| `drophawk_drops_total` | `type`, `category`, `source` | every emit_drop |
+| `drophawk_notifications_total` | `channel`, `priority`, `result`(ok/failed/skipped) | every notify attempt |
+| `drophawk_sse_connections` | - | gauge (current count) |
+| `drophawk_events_total` | - | gauge (current row count in events) |
+
+### J.2 Histograms
+| Metric | Labels | When |
+|---|---|---|
+| `drophawk_source_poll_duration_ms` | `source` | every poll |
+| `drophawk_detection_latency_ms` | `type` | drop → notification |
+| `drophawk_notify_latency_ms` | `channel` | notify send → ntfy ack |
+| `drophawk_http_request_duration_ms` | `method`, `route`, `status` | every HTTP request |
+| `drophawk_scraper_round_trip_ms` | `source` | every NATS RPC |
+
+### J.3 Gauges
+| Metric | Labels | When |
+|---|---|---|
+| `drophawk_source_up` | `source`, `status`(healthy/degraded/down) | on status change |
+| `drophawk_watchlist_rules_active` | - | on rule change |
+| `drophawk_db_connections_in_use` | - | every 5s |
+| `drophawk_nats_unprocessed_messages` | `stream`, `consumer` | every 5s |
+| `drophawk_redis_ops_per_sec` | - | every 5s |
+
+### J.4 SLOs
+- **Detection latency p99 ≤ 15s** (alert at p99 > 30s for 5m)
+- **Source health: ≥ 90% of enabled sources `healthy`** (alert at <75%)
+- **Notification success rate ≥ 99.5%** over 1h (alert at <98%)
+- **SSE uptime 99.9%** (alert on 0 connected clients for 5m when backend is up)
+
+### J.5 Alertmanager rules (`infra/alerts.yml`)
+```yaml
+groups:
+ - name: drophawk
+ rules:
+ - alert: DropHawkHighDetectionLatency
+ expr: histogram_quantile(0.99, sum(rate(drophawk_detection_latency_ms_bucket[5m])) by (le)) > 30000
+ for: 5m
+ annotations:
+ summary: "p99 detection latency > 30s"
+
+ - alert: DropHawkSourcesDown
+ expr: count(drophawk_source_up{status="down"} == 1) > 0
+ for: 10m
+ annotations:
+ summary: "{{ $value }} source(s) are DOWN"
+
+ - alert: DropHawkNotifyFailures
+ expr: rate(drophawk_notifications_total{result="failed"}[5m]) / rate(drophawk_notifications_total[5m]) > 0.02
+ for: 10m
+ annotations:
+ summary: "Notification failure rate > 2%"
+
+ - alert: DropHawkPostgresDown
+ expr: pg_up == 0
+ for: 1m
+ labels: { severity: critical }
+```
+
+---
+
+## §K. Error Taxonomy (Rust)
+
+```rust
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum AppError {
+ #[error("database error: {0}")]
+ Db(#[from] sqlx::Error),
+
+ #[error("nats publish error: {0}")]
+ Bus(#[from] async_nats::PublishError),
+
+ #[error("redis error: {0}")]
+ Cache(#[from] redis::RedisError),
+
+ #[error("http client error: {0}")]
+ Http(#[from] reqwest::Error),
+
+ #[error("json error: {0}")]
+ Json(#[from] serde_json::Error),
+
+ #[error("validation failed: {0}")]
+ Validation(String),
+
+ #[error("not found: {0}")]
+ NotFound(String),
+
+ #[error("conflict: {0}")]
+ Conflict(String),
+
+ #[error("rate limited")]
+ RateLimited,
+
+ #[error("upstream unavailable: {0}")]
+ Upstream(String),
+
+ #[error("internal error: {0}")]
+ Internal(#[from] anyhow::Error),
+}
+
+impl AppError {
+ pub fn status_code(&self) -> u16 {
+ use AppError::*;
+ match self {
+ Validation(_) => 422,
+ NotFound(_) => 404,
+ Conflict(_) => 409,
+ RateLimited => 429,
+ Upstream(_) => 503,
+ _ => 500,
+ }
+ }
+ pub fn code(&self) -> &'static str {
+ use AppError::*;
+ match self {
+ Db(_) => "DB_ERROR",
+ Bus(_) => "BUS_ERROR",
+ Cache(_) => "CACHE_ERROR",
+ Http(_) => "HTTP_ERROR",
+ Json(_) => "JSON_ERROR",
+ Validation(_) => "VALIDATION_FAILED",
+ NotFound(_) => "NOT_FOUND",
+ Conflict(_) => "CONFLICT",
+ RateLimited => "RATE_LIMITED",
+ Upstream(_) => "UPSTREAM_UNAVAILABLE",
+ Internal(_) => "INTERNAL",
+ }
+ }
+}
+
+// IntoResponse impl for Axum
+impl IntoResponse for AppError {
+ fn into_response(self) -> Response {
+ let code = self.code();
+ let status = StatusCode::from_u16(self.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+ let body = Json(json!({ "error": { "code": code, "message": self.to_string() } }));
+ (status, body).into_response()
+ }
+}
+
+pub type AppResult<T> = Result<T, AppError>;
+```
+
+### K.1 Source errors (separate, internal)
+```rust
+#[derive(Debug, Error)]
+pub enum SourceError {
+ #[error("network error: {0}")]
+ Network(String),
+ #[error("parse error: {0}")]
+ Parse(String),
+ #[error("rate limited; retry after {retry_after:?}")]
+ RateLimited { retry_after: Option<Duration> },
+ #[error("blocked by source (403/429): {0}")]
+ Blocked(String),
+ #[error("source returned invalid auth")]
+ Auth,
+ #[error("upstream error {status}: {body}")]
+ Upstream { status: u16, body: String },
+}
+```
+
+---
+
+## §L. Backup & Disaster Recovery
+
+### L.1 What to back up
+- **Postgres** (full dump + WAL archive) - single source of truth.
+- **MinIO bucket** (scraped HTML screenshots for debug, can be regenerated).
+- **ntfy user.db** (auth tokens - annoying to recreate, do back up).
+- **Meilisearch index** - can be rebuilt from Postgres; skip from backups.
+- **Configs**: `.env`, `infra/Caddyfile`, `infra/ntfy.server.yml`, dashboards.
+
+### L.2 Schedule
+- **Postgres nightly** `pg_dump` at 03:00 homelab TZ → `/backups/postgres/drophawk-YYYYMMDD.sql.gz`.
+- **WAL archiving** via `pgBackRest` or `wal-g` continuous to MinIO bucket `pg-wal`.
+- **MinIO lifecycle**: nightly dumps retained 30 days, weekly dumps 90 days, monthly 1 year.
+- **Config bundle**: every git push triggers GitHub Action that tarballs `infra/` and `.env.example` to MinIO.
+
+### L.3 Restore runbook
+1. `docker compose down`
+2. Wipe `pg-data` volume.
+3. `docker compose up -d postgres` and wait for healthy.
+4. `gunzip -c drophawk-YYYYMMDD.sql.gz | docker compose exec -T postgres psql -U drophawk`
+5. Replay WAL since dump (if using continuous archive): `wal-g backup-fetch /var/lib/postgresql/data LATEST && wal-g wal-fetch …`
+6. `docker compose up -d` (everything).
+7. Verify `/api/ready` returns 200.
+8. Verify latest `drops.detected_at` matches expected.
+
+**RTO target: 30 minutes. RPO target: 15 minutes** (with WAL archiving) or 24 hours (nightly only).
+
+### L.4 Backup verification
+- Every Sunday 04:00, restore latest nightly dump into a temporary Postgres container, run smoke queries, alert if any fail. Cron job in `scripts/verify-backup.sh`.
+
+---
+
+## §M. Security Model
+
+### M.1 Threat surface
+- **Internal network only.** No public exposure. Operator accesses via Tailscale or LAN.
+- If public exposure is ever needed, **Caddy must enforce basic auth + IP allowlist**. Not in v1 scope.
+
+### M.2 Service-to-service auth
+- All internal services on `drophawk-net` Docker network. No mTLS in v1 (trusted network).
+- Postgres: single user `drophawk` with random password from `.env`. No app-level users.
+- Redis: require `REDIS_PASSWORD`. No ACL subusers.
+- NATS: require `NATS_USER` + `NATS_PASSWORD`. Enable JetStream.
+- Meilisearch: require `MEILI_MASTER_KEY`. Frontend never talks to Meilisearch directly; always via backend.
+- ntfy: ACL-protected. Publisher token has write-only on `drophawk-drops`. iOS subscribe uses read-only token.
+
+### M.3 API auth (frontend → backend)
+- v1: **no auth** (single user, LAN-only). Caddy IP allowlist optional.
+- v7: optional bearer token via `DROPHAWK_API_TOKEN`. Frontend sends `Authorization: Bearer`.
+
+### M.4 Secret management
+- `.env` is the canonical secret store. **Never committed**. Mode 0600.
+- `.env.example` documents every key with placeholder values.
+- CI step: `gitleaks detect` runs on every PR. Pre-commit hook rejects if `.env` pattern is staged.
+- Backups of `.env` stored in MinIO encrypted bucket, accessible only to operator.
+
+### M.5 Input validation
+- Every POST/PATCH body validated by Zod (frontend) AND `validator` crate (backend). Never trust client.
+- Regex patterns in watchlist are compiled server-side; reject if compilation > 1ms (ReDoS guard). Limit pattern length to 256 chars.
+- Query params: integers bounded (e.g. `per_page ≤ 100`), timestamps parsed strictly.
+- All URLs returned to user are validated to be `https://` (no `javascript:`).
+
+### M.6 Audit log
+- Every mutation (POST/PATCH/DELETE on watchlist, PATCH on sources) writes to a `mutation_log` table:
+```sql
+CREATE TABLE mutation_log (
+ id BIGSERIAL PRIMARY KEY,
+ action TEXT NOT NULL, -- 'watchlist.create', 'source.toggle', etc.
+ target TEXT,
+ actor TEXT, -- 'operator' (single user) for now
+ payload JSONB,
+ at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+```
+
+### M.7 Anti-bot / ethical scraping (reaffirmed)
+- Honest UA on API sources: `DropHawk/0.1 (+https://your-homelab/drophawk)`.
+- Browser impersonation only on Cloudflare-protected public pages, only for read.
+- **Never solve CAPTCHA, never bypass rate limits via IP rotation that targets a specific account, never submit forms on behalf of any user, never store 3rd party credentials.**
+
+---
+
+## §N. Testing Strategy
+
+### N.1 Pyramid
+- **Unit (70%)**: pure functions - hash canonicalization, status inference, price-drop classification, regex compilation.
+- **Integration (25%)**: DB access (testcontainers-postgres), NATS round-trip, ntfy mock (wiremock).
+- **E2E (5%)**: Playwright through the full stack via `docker compose`.
+
+### N.2 Coverage gates
+- Rust: `cargo tarpaulin` → **≥ 80% line coverage** on `backend/src/{detect,sources,store,notify}`.
+- Python: `pytest --cov=scrapers --cov-fail-under=75`.
+- TypeScript: Vitest → **≥ 75% line** on `lib/` and `hooks/`.
+
+### N.3 Fixtures
+- **Golden JSON**: `backend/tests/fixtures/ticketmaster/sample_event.json` (real Ticketmaster response, sanitized). Hash test vectors locked against this.
+- **HTTP replay**: `wiremock` for Rust, `vcrpy` for Python. Cassettes checked in under `tests/cassettes/`.
+- **DB snapshot**: tiny Postgres dump with seed data, loaded via testcontainers in `conftest.rs`.
+- **SSE fixture**: a recorded stream of 50 drop events for frontend testing.
+
+### N.4 Test commands (run all before merge)
+```bash
+make check # runs all of below
+cargo clippy -- -D warnings
+cargo fmt --check
+cargo test --all
+cd scrapers && uv run pytest
+cd frontend && pnpm typecheck && pnpm lint && pnpm test
+cd frontend && pnpm test:e2e # only in CI, needs running stack
+```
+
+### N.5 Property tests (Rust, `proptest`)
+- `content_hash` is deterministic: same input → same hash, across runs.
+- `content_hash` differs when ANY participating field differs.
+- Hash is stable across serializer versions (golden vectors).
+
+### N.6 Load test
+- `backend/benches/scheduler.rs`: simulate 50 sources × 1s interval × 1k events/poll → must sustain <60% CPU single core.
+- `scripts/loadtest.sh`: 100 concurrent SSE clients, 1000 events/s broadcast → no SSE drops.
+
+---
+
+## §O. Performance Budgets
+
+### O.1 Latency budgets
+| Path | p50 | p99 |
+|---|---|---|
+| Poll → drop detected | 500ms | 2s |
+| Drop detected → ntfy sent | 200ms | 1s |
+| Drop detected → SSE fanout | 50ms | 250ms |
+| HTTP API read | 30ms | 200ms |
+| HTTP API write | 80ms | 400ms |
+| Frontend TTFB (Next.js SSR) | 100ms | 400ms |
+| Frontend TTI | 800ms | 2.5s |
+| End-to-end (poll start → phone buzz) | 5s | 15s |
+
+### O.2 Memory budgets (Docker container ceilings)
+| Service | Limit | Notes |
+|---|---|---|
+| backend | 512MB | |
+| scrapers | 1.5GB | Playwright hungry; cap concurrency to 4 tabs/worker |
+| frontend (SSR) | 384MB | |
+| postgres | 2GB | `shared_buffers=512MB`, `work_mem=16MB` |
+| redis | 256MB | `maxmemory=200mb`, allkeys-lru |
+| nats | 256MB | JetStream file storage, memory stream disabled |
+| meilisearch | 1GB | |
+| ntfy | 64MB | |
+| minio | 256MB | |
+| prometheus | 512MB | retention 30d |
+| grafana | 256MB | |
+| loki | 512MB | retention 14d |
+
+Total ceiling: ~7.6 GB. Operators with 16 GB homelab have ample headroom.
+
+### O.3 SLOs (measured by Prometheus)
+- **API availability**: 99.9% over 30 days rolling.
+- **API p99 latency** ≤ 500ms on hot paths.
+- **SSE reconnect rate** ≤ 0.5/min/client (bad if proxies drop constantly).
+
+### O.4 Frontend bundle budget
+- Initial JS for `/` route: **≤ 250 KB** gzipped.
+- Initial CSS: **≤ 60 KB** gzipped.
+- No single image > 200 KB without `next/image` optimization.
+
+---
+
+## §P. Idempotency Rules
+
+### P.1 NATS JetStream deduplication
+- Every message published by `emit_drop` carries `Nats-Msg-Id: drop-{drop_id}`. JetStream dedupes within the stream's duplicate window (set to **10 minutes**).
+- Scraper round-trip messages carry `Nats-Msg-Id: scrape-{source_id}-{epoch_ms_floor_1000}` (1s buckets). Idempotent.
+
+### P.2 Notification dedup (Redis)
+- Key: `notif:dedupe:{event_id}:{drop_type}` (TTL 600s). SETNX. Skip if exists.
+- Cross-type guard: `notif:dedupe:{event_id}:any` with TTL 60s - no more than one push per event per minute even across types.
+
+### P.3 Drop dedup (DB)
+- `drops` has no unique constraint (same event can legitimately emit two status drops minutes apart if status oscillates).
+- But the **detection layer** only emits when `content_hash` actually changes. So oscillation is naturally bounded to source's actual polling cadence.
+
+### P.4 Insert race (upsert)
+- `events` has `UNIQUE (source_id, source_event_id)`. Use `INSERT … ON CONFLICT (source_id, source_event_id) DO UPDATE SET … WHERE events.content_hash != EXCLUDED.content_hash RETURNING id, content_hash`. This makes ingest atomic and race-safe.
+
+---
+
+## §Q. Cross-Source Entity Resolution
+
+The same physical event (e.g. Taylor Swift Aug 12 Miami) appears on Ticketmaster, SeatGeek, StubHub (resale), Vivid Seats. We want to link these so the UI shows aggregated info.
+
+### Q.1 Algorithm (deterministic, runs in `detect::link_event`)
+
+For each new/updated `events` row, attempt to link to existing clusters:
+
+1. **Exact performer ID match**: if `raw.tm_attraction_ids[]` and `raw.seatgeek_performer_ids[]` intersect (after a normalization map), confidence = 1.0, basis = `performer_id`.
+2. **Date + venue match**: `WHERE starts_at BETWEEN new.starts_at - 1h AND new.starts_at + 1h AND venue_name ILIKE new.venue_name AND city = new.city AND country = new.country`. Confidence = 0.85.
+3. **Title + date match**: tokenized title similarity (Jaccard ≥ 0.7 on lowercased tokens, ignoring stopwords) + same date ± 1h. Confidence = 0.65.
+
+If any match found, **insert into `cross_source_links` with the matched cluster_id** (create new cluster if no existing one). If multiple clusters match, merge them (lowest id wins).
+
+### Q.2 When used
+- `GET /api/events/:id` returns `cross_source_links[]` so the UI can show "Also on SeatGeek ($X), StubHub ($Y)".
+- The buy_url shown in drops is always the canonical source's URL, but the UI surfaces alternatives.
+
+### Q.3 Implementation notes
+- Run linking **synchronously** during `ingest_candidate` (it's fast - 3 indexed queries).
+- Re-run linking when venue names get normalized (write a `venue_normalization` table mapping messy names to canonical - populated manually over time).
+
+---
+
+## §R. Buy URL Canonicalization
+
+### R.1 Rules
+- Always `https://`.
+- Strip tracking params added by our scrapers (`?utm_*`, `?ref=*`, `?clickId=*`, `?_branch_match_id=*`).
+- Preserve source-required params (e.g. Ticketmaster's `event/id` path segment is essential).
+- **Add** DropHawk's own ref param if affiliate program is set: `?drophawk=1`. (No affiliate abuse; honest disclosure in UA.)
+- Sort query params alphabetically. (Stable URLs.)
+- If URL has fragment, preserve.
+
+### R.2 Reference implementation
+```rust
+pub fn canonicalize_buy_url(raw: &str) -> Result<String, AppError> {
+ let mut url = url::Url::parse(raw).map_err(|e| AppError::Validation(e.to_string()))?;
+ if url.scheme() != "https" {
+ return Err(AppError::Validation("buy_url must be https".into()));
+ }
+ let drop_keys = ["utm_source","utm_medium","utm_campaign","utm_term","utm_content","ref","clickId","_branch_match_id"];
+ let keys_to_remove: Vec<String> = url.query_pairs()
+ .filter(|(k, _)| drop_keys.contains(&k.as_ref()))
+ .map(|(k,_)| k.into_owned())
+ .collect();
+ for k in keys_to_remove {
+ url.query_pairs_mut().remove_pair(&k);
+ }
+ url.query_pairs_mut().append_pair("drophawk", "1");
+ url.query_pairs_mut().clear().extend_pairs(
+ url.query_pairs().collect::<std::collections::BTreeMap<_,_>>()
+ ); // alphabetical
+ if url.query().map(|q| q.is_empty()).unwrap_or(true) {
+ url.set_query(None);
+ }
+ Ok(url.to_string())
+}
+```
+
+### R.3 Per-source overrides
+- Ticketmaster: respect `event/<id>` URL format. Affiliate param `tm_link` allowed if operator has affiliate ID (env `TICKETMASTER_AFFILIATE_ID`).
+- SeatGeek: same, `aid` param if `SEATGEEK_AFFILIATE_ID` set.
+
+---
+
+## §S. Mobile / PWA Requirements
+
+### S.1 Installability
+- `frontend/public/manifest.webmanifest` with name, short_name, theme_color (teal), background_color (bg-base), display `standalone`, scope `/`, start_url `/?source=pwa`.
+- Icons: 192×192, 512×512, 512×512 maskable, all PNG. Generated from one SVG via `scripts/generate-pwa-icons.sh`.
+- Apple touch icon: `apple-touch-icon.png` 180×180.
+- `<meta name="apple-mobile-web-app-capable" content="yes">`.
+- `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`.
+
+### S.2 Service worker (`frontend/public/sw.js`)
+- Use **Serwist** (modern Workbox alternative, Next 16 compatible).
+- Cache strategy:
+ - HTML navigations: **NetworkFirst** with 5s timeout, fallback to cached `/`.
+ - Static assets (`/_next/static/*`): **CacheFirst** with stale-while-revalidate for fonts.
+ - API GETs: **NetworkFirst** (no caching of live data).
+ - SSE: bypass SW (browser handles natively).
+- Update flow: SW detects new version → shows toast "Update available" → user clicks → SW takes over on next navigation.
+
+### S.3 Touch interactions
+- All tap targets ≥ 44×44 pt (WCAG 2.5.5).
+- Pull-to-refresh on dashboard (use `react-pull-to-refresh` or custom Framer Motion).
+- Long-press DropCard → context menu (Pin, Hide, Add to watchlist).
+- Swipe-left on DropCard → dismiss.
+
+### S.4 Push on PWA
+- For v1, push is via **ntfy app** (already in scope). Web Push API integration is Phase 7+.
+
+---
+
+## §T. Accessibility - WCAG 2.2 AA (binding)
+
+### T.1 Perceivable
+- Color contrast: text ≥ 4.5:1, large text (≥18pt) ≥ 3:1. Tested against `--color-bg-base` and `--color-bg-elevated`. The OKLCH palette above is pre-verified; do not introduce new colors without re-test.
+- Non-text contrast (UI components, borders): ≥ 3:1.
+- All form inputs have associated `<label>` (Radix `Label`).
+- Images have `alt`; decorative images have `alt=""`.
+- Status pills: text label AND color. Never color alone.
+
+### T.2 Operable
+- Keyboard nav: tab order follows visual order; visible focus ring using `outline: 2px solid var(--color-accent-teal)` with `outline-offset: 2px`. Never `outline: none`.
+- Skip-to-main link at top of every page.
+- All interactive elements reachable in ≤ 6 tabs from page load.
+- Cmd+K command palette has `role="dialog"`, `aria-modal`, focus trap, Escape to close.
+- No keyboard trap.
+
+### T.3 Understandable
+- Form validation errors are announced via `aria-live="polite"`.
+- Time formats include timezone suffix (e.g. `7:00 PM ET`).
+- Relative time component has `title` with absolute timestamp.
+
+### T.4 Robust
+- Valid HTML5; tested with `axe-core` in Playwright suite. Zero critical or serious violations.
+
+### T.5 Motion
+- All animations honor `prefers-reduced-motion: reduce` (see globals.css).
+
+### T.6 Audit
+- Run `axe-playwright` against every route in e2e. Fail CI on new violations.
+
+---
+
+## §U. i18n
+
+### U.1 v1 scope
+- English only, but **structure for i18n from day one**.
+- All UI strings via `next-intl` (`frontend/messages/en.json`).
+- No hardcoded user-facing strings in components.
+
+### U.2 Currency
+- Each event carries `currency` (ISO 4217). Display via `Intl.NumberFormat` with user's preferred currency (Settings).
+- Default user currency: `USD`. Operator can switch to `EUR`, `GBP`, etc.
+- Conversion rates: fetched daily from `open.er-api.com` (free), cached in Redis.
+
+### U.3 Dates / times
+- All times stored UTC. Display in user's local timezone via `Intl.DateTimeFormat` with `timeZone` from `Intl.DateTimeFormat().resolvedOptions().timeZone`.
+- Format pattern: `Aug 12 2026, 7:00 PM ET` (date, time, tz abbreviation).
+
+---
+
+## §V. Image Pipeline
+
+### V.1 Source images
+- Ticketmaster/SeatGeek provide `image_url`. Cache via Next.js `next/image` with remote pattern allow-list.
+- For sources without images (FIFA), use a generated gradient placeholder keyed off `source_id` (deterministic hue).
+
+### V.2 Allowed remote patterns (`next.config.mjs`)
+```js
+images: {
+ remotePatterns: [
+ { protocol: "https", hostname: "**.ticketmaster.com" },
+ { protocol: "https", hostname: "**.seatgeek.com" },
+ { protocol: "https", hostname: "cdn.fifa.com" },
+ { protocol: "https", hostname: "**. axs.com" },
+ ],
+ formats: ["image/avif", "image/webp"],
+ minimumCacheTTL: 86400,
+}
+```
+
+### V.3 Generated placeholders
+- Function `placeholderFor(seed: string): { from: string, to: string }`.
+- Hash `seed` → pick hue from a curated 12-color palette.
+- CSS gradient `linear-gradient(135deg, hsl1, hsl2)`.
+- Cache the gradient in component state; no network.
+
+### V.4 Lazy loading
+- All `<Image>` below the fold use `loading="lazy"` (default in Next 16 Image).
+- DropCard images are 64×64 (tiny); always load eagerly for top-of-feed.
+
+---
+
+## §W. Failure Mode Runbooks
+
+For each service: what fails, what the system does, what the operator does.
+
+### W.1 Postgres down
+- **Detection**: `/api/ready` 503; `pg_up == 0` alert.
+- **System behavior**: backend stops accepting requests (returns 503). Pollers pause (no DB to write to). NATS messages accumulate in JetStream (24h retention). Scrapers keep working but Rust can't ingest.
+- **Operator**: investigate (`docker compose logs postgres`), restore from backup if needed (§L.3), restart. On recovery, JetStream replays unprocessed messages.
+
+### W.2 Redis down
+- **Detection**: `/api/ready` 503.
+- **System behavior**: rate-limit tokens can't be acquired - pollers pause. Notification dedup breaks - temporarily allow duplicate notifications (safer than missing one). Cache misses.
+- **Operator**: `docker compose restart redis`. Data is non-critical (cache only).
+
+### W.3 NATS down
+- **Detection**: backend startup fails `nats.connect`.
+- **System behavior**: pollers for scrape-based sources can't RPC → they wait + retry. API-based sources keep working (direct fetch).
+- **Operator**: restart NATS. JetStream persists to disk; no data loss.
+
+### W.4 Meilisearch down
+- **Detection**: `/api/explore` returns 503.
+- **System behavior**: search UI disabled; everything else works. Backend falls back to Postgres trigram search (`idx_events_title_trgm`).
+- **Operator**: restart. Rebuild index from Postgres via `POST /api/admin/reindex` (Phase 7).
+
+### W.5 ntfy down
+- **Detection**: `notify::push` returns error; metric counter.
+- **System behavior**: notifications fail silently to user. Drops still flow to SSE → UI still works.
+- **Operator**: restart ntfy. Missed drops: UI has them; user can scroll back.
+
+### W.6 Scraper worker down (Playwright crash)
+- **Detection**: source's poll round-trip times out → `SourceError::Network`.
+- **System behavior**: 3 timeouts → source `degraded`. 10 → `down` + alert.
+- **Operator**: `docker compose restart scrapers`. Source resumes.
+
+### W.7 Source blocks us (403/429)
+- **Detection**: `SourceError::Blocked`.
+- **System behavior**: circuit opens after 3, source → `degraded`. Alert at 10.
+- **Operator**: investigate robots.txt change, enable proxy pool (Phase 6), or disable source temporarily.
+
+### W.8 Caddy down
+- **Detection**: nothing accessible from operator.
+- **System behavior**: backend still runs, scrapers still run, but no UI access.
+- **Operator**: `docker compose restart caddy`. Caddy auto-HTTPS state in volume.
+
+### W.9 Disk full
+- **Detection**: Postgres write fails; Loki rejects logs.
+- **System behavior**: cascade failure.
+- **Operator**: clean old DropHawk data (`SELECT drop_chunks('drops', older_than => '30 days')`), prune Docker images, expand disk.
+
+### W.10 Backend OOM
+- **Detection**: container restarts; `drophawk_backend_oom_total` metric.
+- **System behavior**: brief outage. JetStream replays missed messages on startup.
+- **Operator**: investigate memory leak; raise memory limit; file issue.
+
+---
+
+## §X. CORS Configuration
+
+`backend/src/api/mod.rs`:
+```rust
+let cors = CorsLayer::new()
+ .allow_origin(["http://drophawk.lan", "http://localhost:3000"].map(|s| s.parse().unwrap()))
+ .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
+ .allow_headers([
+ header::AUTHORIZATION,
+ header::CONTENT_TYPE,
+ header::ACCEPT,
+ ])
+ .allow_credentials(false)
+ .max_age(Duration::from_secs(600));
+```
+SSE handler bypasses CORS layer (handled separately to allow `text/event-stream`).
+
+---
+
+## §Y. Configuration Surface (env var reference)
+
+Complete list. `.env.example` must contain every key below with placeholder values. CI fails if `.env.example` is missing any key.
+
+### Y.1 Backend
+```
+# Required
+DATABASE_URL=postgres://drophawk:CHANGE_ME@postgres:5432/drophawk
+REDIS_URL=redis://:CHANGE_ME@redis:6379/0
+NATS_URL=nats://drophawk:CHANGE_ME@nats:4222
+MEILI_URL=http://meilisearch:7700
+MEILI_MASTER_KEY=CHANGE_ME
+NTFY_BASE_URL=http://ntfy:80
+NTFY_TOPIC=drophawk-drops
+NTFY_TOKEN=tk_CHANGE_ME
+LOG_LEVEL=info # trace|debug|info|warn|error
+OTEL_EXPORTER_OTLP_ENDPOINT=http://loki:4317
+PUBLIC_BASE_URL=https://drophawk.lan
+
+# Source credentials (optional per source)
+TICKETMASTER_API_KEYS=key1,key2,key3
+SEATGEEK_CLIENT_ID=
+TICKETMASTER_AFFILIATE_ID=
+SEATGEEK_AFFILIATE_ID=
+
+# Future
+PROXY_POOL_URL=
+TELEGRAM_BOT_TOKEN=
+TWITTER_BEARER_TOKEN=
+
+# Operational
+API_RATE_LIMIT_PER_MIN=100
+SSE_MAX_CLIENTS=200
+SCRAPER_RPC_TIMEOUT_S=30
+WATCHLIST_MAX_RULES=500
+POLL_DEFAULT_INTERVAL_S=300
+POLL_TIGHT_INTERVAL_S=10
+POLL_TIGHT_WINDOW_S=900
+```
+
+### Y.2 Frontend (build-time)
+```
+NEXT_PUBLIC_API_BASE_URL=https://drophawk.lan/api
+NEXT_PUBLIC_NTFY_PUBLIC_URL=https://drophawk.lan/ntfy
+NEXT_PUBLIC_NTFY_PUBLIC_TOPIC=drophawk-drops
+NEXT_PUBLIC_SENTRY_DSN= # optional
+```
+
+### Y.3 Python scrapers
+```
+DATABASE_URL= # informational only; not used directly
+NATS_URL=nats://drophawk:CHANGE_ME@nats:4222
+REDIS_URL=redis://:CHANGE_ME@redis:6379/1
+SCRAPER_CONCURRENCY=4
+SCRAPER_LOG_LEVEL=INFO
+PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+FIFA_BASE_URL=https://tickets.fifa.com
+AXS_BASE_URL=https://www.axs.com
+STUBHUB_API_KEY=
+VIVIDSEATS_PROXY=
+TWITTER_ACCOUNTS= # comma list of @handles to watch
+```
+
+---
+
+## §Z. Final Implementation Directives
+
+These are the **non-negotiable directives** for any executing agent reading this contract:
+
+1. **Implement Phase 1 first, end-to-end, no detours.** A thin working slice beats a wide unfinished one. The operator is disabled and depends on this working.
+2. **Run `make check` before declaring any task done.** Zero warnings, zero errors.
+3. **Commit per logical unit**, conventional commits, never amend shared branches.
+4. **If this contract is silent on a decision, ask the operator.** Do not improvise on security, data loss, or notification correctness.
+5. **Every file in §4 of SPEC.md exists in Phase 1 with a working implementation, not a stub.** Stub = `unimplemented!()` or `TODO` = merge blocked.
+6. **Test fixtures are golden**. Hash vectors in §D.4 are regression gates.
+7. **The OKLCH palette in §H.1 is the entire color system.** Do not introduce ad-hoc hex codes.
+8. **Notification correctness is sacred.** False positives erode trust fastest. The dedupe + filter pipeline (§I.3 + §5.5) must be tested exhaustively.
+9. **No third-party dependencies beyond those pinned in §A.** If a new one is truly required, bump the spec, justify, and pin a version.
+10. **When in doubt, choose reliability over feature richness.** The dashboard showing 5 working sources is better than 25 half-broken ones.
+
+---
+
+**End of binding contracts. Return to SPEC.md §14 for the phased roadmap and §17 for per-phase verification.**
README.md +39 -0
@@ -0,0 +1,39 @@
+# DropHawk
+
+Always-on, self-hosted ticket drop monitor. Polls dozens of primary + resale sources, pushes to your phone within 15 seconds when tickets go on sale.
+
+> **Status:** Specification complete. Implementation Phase 1 not yet started.
+
+## Documents
+
+| File | Role |
+|---|---|
+| [`CLAUDE.md`](./CLAUDE.md) | Agent bootstrap - start here |
+| [`SPEC.md`](./SPEC.md) | Architecture & intent |
+| [`CONTRACTS.md`](./CONTRACTS.md) | **Binding implementation contracts** (versions, schemas, DDL, colors, algorithms, runbooks) |
+
+## Quickstart (once Phase 1 ships)
+
+```bash
+cp .env.example .env
+# edit .env - set every __CHANGE_ME__
+docker compose up -d
+# open https://drophawk.lan (or http://localhost)
+```
+
+## What it does
+
+- Monitors Ticketmaster, SeatGeek, FIFA, AXS, See Tickets, StubHub, Vivid Seats, NFL/NBA/NHL/MLB, ATP/WTA, F1, UEFA, Premier League, major festivals, and Twitter/X for shock drops.
+- Detects: new events, status flips (Announced → OnSale), price drops (≥10%), inventory drops (≥25%), shock drops.
+- Pushes to your iPhone via self-hosted ntfy (free) within 15 seconds.
+- Beautiful dense dashboard with live drop feed, source health grid, price sparklines, drop heatmap.
+
+## What it does NOT do
+
+- Auto-purchase tickets (illegal under BOTS Act / UK law).
+- Solve CAPTCHAs.
+- Facilitate resale of restricted tickets (FIFA, paperless, etc.).
+
+## License
+
+Personal use. See `CONTRACTS.md §16` for legal guardrails.
SPEC.md +1226 -0
@@ -0,0 +1,1226 @@
+# DropHawk - Master Build Specification
+
+> **Working name:** DropHawk (rename freely)
+> **Purpose:** Always-on, multi-source ticket drop monitor with real-time UI, instant push notifications, and shock-drop detection across concerts, festivals, sports (NFL/NBA/NHL/MLB/tennis/F1/soccer), and exclusive events (FIFA World Cup, etc.).
+> **Audience:** This document is the **architectural source of truth** for any executing agent (Claude, Codex, etc.).
+>
+> **⚠️ COMPANION FILE IS BINDING:** `CONTRACTS.md` lives alongside this file and contains the **exact pinned versions, exact API schemas, exact SQL DDL, exact design tokens, exact algorithms, and exact failure runbooks**. Where CONTRACTS.md and this file disagree, **CONTRACTS.md wins**. Read both before writing any code.
+>
+> **Loading order for any agent:**
+> 1. `CLAUDE.md` (this repo) - bootstrap
+> 2. `SPEC.md` (this file) - architecture & intent
+> 3. `CONTRACTS.md` - binding implementation contracts
+>
+> When something is ambiguous, the answer is in CONTRACTS.md first, then this file. If both are silent, **ask the operator** - do not improvise on security, data integrity, or notification correctness.
+
+---
+
+## 0. How to use this spec
+
+- Read sections 1-6 before writing any code. They are non-negotiable.
+- Section 7 (Source Adapters) defines an interface; implement v1 sources (7.1, 7.2) fully, then expand per the roadmap (§14).
+- Section 15 (Coding Standards) lists hard rules. Violations are bugs.
+- Section 17 (Execution Checklist) is the per-phase verification gate. Do not merge a phase until its checklist passes.
+- File paths in this doc are **absolute within the repo root** (e.g. `backend/Cargo.toml`).
+
+---
+
+## 1. Project Overview & Vision
+
+### 1.1 What DropHawk is
+A self-hosted service that continuously polls dozens of primary and resale ticket sources, detects the instant an event's status flips to "on sale" (or a new event appears, or a resale price drops), and pushes a notification to the user's phone within seconds - with a one-tap link to buy.
+
+### 1.2 What DropHawk is NOT
+- **Not a bulk auto-purchaser.** It surfaces drops; the human taps the link and buys. This keeps the tool legal under the US BOTS Act and equivalent EU/UK laws.
+- **Not a scalping bot.** Personal-use monitoring + manual purchase only. (See §16 for legal guardrails.)
+- **Not a ticket marketplace.** No transactions occur in DropHawk.
+
+### 1.3 User
+- Single user (the operator). No auth system, no multi-tenancy. The "watchlist" is global.
+- Runs on the operator's homelab, accessed via LAN or Tailscale.
+
+### 1.4 Quality bar
+- **Sub-15-second detection-to-notification latency** for adaptive-poll sources.
+- **Zero false-positive notifications** - every alert must correspond to a real status change.
+- **Visually stunning UI** - dark, dense, animated, real-time. Treat the dashboard like a Bloomberg terminal for tickets, not a CRUD app.
+
+---
+
+## 2. Tech Stack (LOCKED - do not substitute)
+
+### 2.1 Rationale
+The operator asked for "fastest and most cutting edge." This means: Rust for the hot path (polling, scheduling, API), Python for scraping (because Playwright + anti-bot libraries only exist maturely in Python), TypeScript for the UI.
+
+### 2.2 Stack table
+
+| Layer | Technology | Version | Why |
+|---|---|---|---|
+| Core engine + API | **Rust** | 1.96+ | Fastest, lowest memory, async-native |
+| Rust web framework | **Axum** | 0.7 | Tower middleware, typed handlers, de-facto standard |
+| Rust async runtime | **Tokio** | 1.x | Required by Axum |
+| Rust HTTP client | **reqwest** + **rquest** (for TLS fingerprinting) | latest | rquest impersonates Chrome TLS JA3 |
+| Rust DB | **sqlx** (compile-time checked) | 0.8 | No ORM, type-safe queries |
+| Rust messaging | **async-nats** | latest | JetStream for durable event bus |
+| Rust scheduler | Custom Tokio task per source (no cron crate) | - | Each source owns its poll loop; clean backoff |
+| Rust serialization | **serde** + **serde_json** | 1.x | Standard |
+| Rust tracing | **tracing** + **tracing-subscriber** + **opentelemetry-otlp** | latest | Structured logs to Loki |
+| Scraper workers | **Python** | 3.11 | Required for Playwright |
+| Python HTTP | **httpx** + **curl_cffi** | latest | curl_cffi for browser TLS impersonation |
+| Python browser | **Playwright** | 1.40+ | Headless Chromium for JS-rendered sources |
+| Python HTML | **selectolax** (fast) + **BeautifulSoup** (fallback) | latest | Selectolax is 10x faster |
+| Python task runner | **Celery** + **Redis broker** | 5.3 | Distributed scraper workers |
+| Frontend framework | **Next.js** | 14.2+ (App Router) | RSC, streaming, edge-ready |
+| Frontend lang | **TypeScript** | 5.4+ | Strict mode |
+| Styling | **Tailwind CSS** | 3.4+ | Utility-first |
+| Component lib | **shadcn/ui** | latest | Owned, composable, beautiful |
+| Animation | **Framer Motion** | 11+ | Spring physics for drop alerts |
+| Dashboard widgets | **Tremor** | latest | Charts, KPIs, sparklines |
+| Data fetching | **TanStack Query** | 5+ | Cache + invalidation |
+| Client state | **Zustand** | 5+ | Minimal, no boilerplate |
+| Forms | **React Hook Form** + **Zod** | latest | Type-safe validation |
+| Real-time push (server→client) | **Server-Sent Events (SSE)** via Axum `axum::response::sse` | - | Simpler than WebSocket, auto-reconnects |
+| Database | **PostgreSQL** | 16 | Primary store |
+| Time-series | **TimescaleDB** (Postgres extension) | 2.14+ | hypertables for price/source-health history |
+| Cache + queue | **Redis** | 7.2+ | Hot cache, Celery broker, rate-limit tokens |
+| Search | **Meilisearch** | 1.6+ | Fuzzy event/artist search, typo-tolerant |
+| Message bus | **NATS** + **JetStream** | 2.10+ | Durable, fast pub/sub between Rust core and Python workers |
+| Notifications | **ntfy** (self-hosted) | 2.x | Free push to iPhone (ntfy iOS app), self-hosted |
+| Object storage | **MinIO** | latest | Screenshots of scraped pages for debug |
+| Reverse proxy | **Caddy** | 2.7+ | Auto-HTTPS, simple config |
+| Observability | **Prometheus** + **Grafana** + **Loki** + **OpenTelemetry** | latest | Metrics, dashboards, logs |
+| Containerization | **Docker** + **Docker Compose** | 24+ / v2 | Homelab deploy |
+| Python package mgr | **uv** | latest | 10x faster than pip |
+| Node package mgr | **pnpm** | 9+ | Disk-efficient, deterministic |
+
+### 2.3 Hard "do not use" list
+- ❌ Go (not available in build env, no benefit over Rust here)
+- ❌ MongoDB (use Postgres)
+- ❌ Django/Flask for scrapers (use Celery workers; no web server in Python land)
+- ❌ create-react-app (use Next.js App Router)
+- ❌ Redux (use Zustand)
+- ❌ Material-UI / Chakra (use shadcn/ui)
+- ❌ Bootstrap (use Tailwind)
+- ❌ Prisma on backend (use sqlx raw)
+- ❌ GraphQL (YAGNI for single-user)
+- ❌ WebSockets for server→client push (use SSE; simpler, auto-reconnect)
+
+---
+
+## 3. High-Level Architecture
+
+### 3.1 Service map
+
+```
+ ┌─────────────────────────────────────────────┐
+ │ HOMELAB HOST │
+ │ │
+ Phone (ntfy app) ◄──┤ ntfy ◄──┐ │
+ │ │ │
+ Browser ◄── Caddy ──┤ ┌────────┴────────┐ │
+ │ │ Next.js (UI) │ │
+ │ └────────┬────────┘ │
+ │ │ SSE │
+ │ ┌────────▼────────────────────────┐ │
+ │ │ Rust core (Axum API) │ │
+ │ │ - REST handlers │ │
+ │ │ - SSE broadcaster │ │
+ │ │ - Scheduler (per-source tasks) │ │
+ │ │ - Drop detector (hash diff) │ │
+ │ └──┬──────────┬───────────┬────────┘ │
+ │ │ │ │ │
+ │ ┌──▼───┐ ┌───▼────┐ ┌───▼─────────┐ │
+ │ │ NATS │ │ Redis │ │ PostgreSQL │ │
+ │ │ JetS │ │ │ │ +Timescale │ │
+ │ │ tr │ │ │ │ │ │
+ │ └──┬───┘ └────────┘ └─────────────┘ │
+ │ │ publish scraped_payload │
+ │ ┌──▼──────────────────────┐ │
+ │ │ Python scraper workers │ │
+ │ │ (Celery, Playwright) │ │
+ │ │ - ticketmaster │ │
+ │ │ - seatgeek │ │
+ │ │ - fifa │ │
+ │ │ - axs, seetickets │ │
+ │ │ - stubhub, vividseats │ │
+ │ │ - twitter shock-drop │ │
+ │ │ - sports (nfl/nba/nhl…) │ │
+ │ │ - festivals │ │
+ │ └───────────┬──────────────┘ │
+ │ │ │
+ │ ┌───────────▼───────┐ ┌─────────────┐ │
+ │ │ Meilisearch index │ │ MinIO (screenshot)│ │
+ │ └───────────────────┘ └─────────────┘ │
+ │ │
+ │ Observability: Prometheus / Grafana / Loki │
+ └─────────────────────────────────────────────┘
+```
+
+### 3.2 Data flow - happy path for a drop detection
+
+1. Rust scheduler spawns one Tokio task per source adapter.
+2. Each task polls on its adaptive interval (see §11) - for API-backed sources (Ticketmaster, SeatGeek), the Rust task calls directly. For scrape-backed sources (FIFA, AXS, StubHub), the Rust task publishes a `scrape_request` to NATS subject `scrape.{source_id}`.
+3. A Python Celery worker subscribed to `scrape.{source_id}` runs Playwright/httpx, returns a `ScrapedPayload` JSON to NATS subject `scrape.{source_id}.result`.
+4. Rust task ingests payload, normalizes into the `Event` shape, computes a `content_hash`.
+5. Rust compares hash against `events.content_hash` in Postgres.
+ - If new event → insert, emit `event.new` on NATS, broadcast `drop` via SSE, push ntfy.
+ - If hash changed (status flip, price change, inventory change) → update row, emit appropriate event.
+6. SSE broadcaster fans out to all connected browser clients.
+7. ntfy publisher POSTs to local ntfy server → user's iPhone buzzes.
+
+**Latency budget:** source-poll-response → hash-diff → SSE+ntfy must complete in <2 seconds. End-to-end notification (poll → phone) target <15 seconds, dominated by poll interval.
+
+### 3.3 Bounded context boundaries
+
+| Service | Owns | Does NOT touch |
+|---|---|---|
+| Rust core | Scheduling, DB, hash-diff, SSE, ntfy, REST API | Browser automation, HTML parsing |
+| Python scrapers | HTTP fetch, JS rendering, parsing, anti-bot | DB, scheduling, notifications |
+| Next.js | UI rendering, watchlist forms, SSE consumption | Direct DB access, business logic |
+| NATS | Message delivery only | Persistence beyond JetStream retention |
+| Redis | Cache, rate-limit tokens, Celery broker | Source of truth (that's Postgres) |
+
+---
+
+## 4. Repository Structure
+
+```
+drophawk/
+├── SPEC.md ← this file
+├── CLAUDE.md ← condensed agent instructions (extract of this)
+├── README.md ← operator quickstart
+├── docker-compose.yml ← all services
+├── docker-compose.prod.yml ← homelab prod overrides
+├── .env.example ← all required env vars (no secrets)
+├── .gitignore
+│
+├── backend/ ← Rust core
+│ ├── Cargo.toml
+│ ├── Cargo.lock
+│ ├── Dockerfile
+│ ├── migrations/ ← sqlx migrations (timestamped, .sql files)
+│ │ ├── 20260101000000_init.sql
+│ │ ├── 20260101000001_sources.sql
+│ │ ├── 20260101000002_events.sql
+│ │ ├── 20260101000003_drops.sql
+│ │ ├── 20260101000004_watchlist.sql
+│ │ ├── 20260101000005_timescale.sql
+│ │ └── 20260101000006_seed_sources.sql
+│ └── src/
+│ ├── main.rs ← tokio entry, service wiring
+│ ├── config.rs ← env config (figment or config crate)
+│ ├── error.rs ← AppError, thiserror
+│ ├── state.rs ← AppState (DB pool, NATS, Redis, ntfy handle)
+│ ├── model/
+│ │ ├── mod.rs
+│ │ ├── source.rs ← Source struct
+│ │ ├── event.rs ← Event struct (the normalized shape)
+│ │ ├── drop.rs ← DropEvent struct
+│ │ └── watchlist.rs
+│ ├── store/ ← DB access layer (sqlx)
+│ │ ├── mod.rs
+│ │ ├── source.rs
+│ │ ├── event.rs
+│ │ ├── drop.rs
+│ │ └── watchlist.rs
+│ ├── api/ ← Axum routes
+│ │ ├── mod.rs ← router assembly
+│ │ ├── events.rs ← GET /api/events, filters
+│ │ ├── drops.rs ← GET /api/drops (paginated)
+│ │ ├── watchlist.rs ← CRUD
+│ │ ├── sources.rs ← GET /api/sources (health)
+│ │ ├── stats.rs ← GET /api/stats (dashboard KPIs)
+│ │ └── sse.rs ← GET /api/stream (SSE broadcaster)
+│ ├── scheduler/
+│ │ ├── mod.rs ← spawns one task per source
+│ │ └── poller.rs ← the poll loop (adaptive interval)
+│ ├── sources/ ← adapters - Rust-side, for API sources
+│ │ ├── mod.rs ← SourceAdapter trait
+│ │ ├── ticketmaster.rs
+│ │ └── seatgeek.rs
+│ ├── scrapers/ ← bridge to Python workers via NATS
+│ │ ├── mod.rs ← ScraperBridge (publishes, awaits reply)
+│ │ └── req.rs ← ScrapeRequest / ScrapedPayload types
+│ ├── detect/ ← change detection
+│ │ ├── mod.rs
+│ │ ├── hash.rs ← content_hash computation
+│ │ └── classify.rs ← NewEvent / StatusFlip / PriceDrop / InventoryDrop
+│ ├── notify/
+│ │ ├── mod.rs
+│ │ ├── ntfy.rs ← ntfy HTTP publish
+│ │ └── filter.rs ← watchlist filter, dedupe, quiet hours
+│ ├── bus/ ← NATS wrapper
+│ │ ├── mod.rs
+│ │ └── subjects.rs ← constants
+│ ├── cache/ ← Redis wrapper
+│ │ └── mod.rs
+│ └── metrics.rs ← Prometheus counter/histogram defs
+│
+├── scrapers/ ← Python workers
+│ ├── pyproject.toml ← uv-managed
+│ ├── uv.lock
+│ ├── Dockerfile
+│ ├── celery_app.py ← Celery instance, NATS broker config
+│ ├── worker.py ← task dispatcher
+│ ├── base.py ← BaseScraper ABC + anti-bot helpers
+│ ├── anti_bot/
+│ │ ├── __init__.py
+│ │ ├── fingerprints.py ← UA, sec-ch-ua, JA3 rotation
+│ │ ├── proxies.py ← proxy pool client
+│ │ └── retry.py ← exponential backoff + jitter
+│ ├── sources/
+│ │ ├── __init__.py
+│ │ ├── fifa.py
+│ │ ├── axs.py
+│ │ ├── seetickets.py
+│ │ ├── stubhub.py
+│ │ ├── vividseats.py
+│ │ ├── twitter.py ← shock-drop monitor
+│ │ ├── nfl.py
+│ │ ├── nba.py
+│ │ ├── nhl.py
+│ │ ├── mlb.py
+│ │ ├── atp_wta.py ← tennis
+│ │ ├── f1.py
+│ │ ├── uefa.py ← Champions League, Euros
+│ │ ├── premier_league.py
+│ │ └── festivals/
+│ │ ├── __init__.py
+│ │ ├── coachella.py
+│ │ ├── glastonbury.py
+│ │ ├── tomorrowland.py
+│ │ ├── edc.py
+│ │ └── lollapalooza.py
+│ └── tests/
+│ ├── conftest.py ← VCR cassettes for replay
+│ └── test_*.py
+│
+├── frontend/ ← Next.js
+│ ├── package.json
+│ ├── pnpm-lock.yaml
+│ ├── next.config.mjs
+│ ├── tailwind.config.ts
+│ ├── tsconfig.json
+│ ├── Dockerfile
+│ ├── app/
+│ │ ├── layout.tsx ← root layout, theme provider, fonts
+│ │ ├── globals.css ← tailwind directives, CSS vars
+│ │ ├── page.tsx ← Dashboard / live drop feed
+│ │ ├── drops/
+│ │ │ └── page.tsx ← searchable drop history
+│ │ ├── watchlist/
+│ │ │ └── page.tsx ← manage tracked entities
+│ │ ├── sources/
+│ │ │ └── page.tsx ← source health grid
+│ │ ├── explore/
+│ │ │ └── page.tsx ← Meilisearch-powered browse
+│ │ ├── settings/
+│ │ │ └── page.tsx ← ntfy topic, quiet hours
+│ │ └── api/ ← route handlers (proxy to backend, optional)
+│ ├── components/
+│ │ ├── ui/ ← shadcn primitives (button, card, sheet, etc.)
+│ │ ├── layout/
+│ │ │ ├── Sidebar.tsx
+│ │ │ ├── Topbar.tsx
+│ │ │ └── CommandPalette.tsx ← Cmd+K, fuzzy jump
+│ │ ├── dashboard/
+│ │ │ ├── LiveDropFeed.tsx ← SSE consumer, animated cards
+│ │ │ ├── DropCard.tsx ← per-drop, shimmer-in animation
+│ │ │ ├── StatsRow.tsx ← Tremor KPI cards
+│ │ │ ├── SourceGrid.tsx ← health/latency per source
+│ │ │ ├── DropHeatmap.tsx ← calendar of drop density
+│ │ │ └── PriceSparkline.tsx
+│ │ ├── drops/
+│ │ │ ├── DropTable.tsx
+│ │ │ └── DropFilters.tsx
+│ │ ├── watchlist/
+│ │ │ ├── WatchlistForm.tsx
+│ │ │ └── WatchlistItem.tsx
+│ │ └── shared/
+│ │ ├── ThemeToggle.tsx
+│ │ ├── RelativeTime.tsx
+│ │ └── EmptyState.tsx
+│ ├── hooks/
+│ │ ├── useDropsStream.ts ← SSE subscription
+│ │ ├── useWatchlist.ts
+│ │ └── useSources.ts
+│ ├── lib/
+│ │ ├── api.ts ← typed fetch wrappers (ky or fetch)
+│ │ ├── sse.ts ← SSE client
+│ │ ├── types.ts ← TS types mirroring Rust models
+│ │ ├── utils.ts ← cn(), formatters
+│ │ └── query.ts ← TanStack Query client
+│ ├── stores/
+│ │ └── ui.ts ← Zustand: sidebar collapsed, filters
+│ └── public/
+│ └── icons/
+│
+├── infra/ ← homelab deploy artifacts
+│ ├── Caddyfile
+│ ├── ntfy.server.yml ← ntfy config (auth, attachment size)
+│ ├── prometheus.yml
+│ ├── grafana/
+│ │ ├── dashboards/
+│ │ │ ├── drophawk-overview.json
+│ │ │ └── source-health.json
+│ │ └── datasources.yml
+│ ├── loki/
+│ │ └── loki-config.yml
+│ └── backup/
+│ └── pg-backup.sh ← nightly pg_dump cron
+│
+└── scripts/
+ ├── seed-watchlist.ts ← bootstrap common artists/teams
+ ├── healthcheck.sh ← curl all services, exit 0 if green
+ └── dev-up.sh ← tmux split: backend logs, frontend, nats
+```
+
+---
+
+## 5. Backend Services (Rust core) - detailed contracts
+
+### 5.1 `SourceAdapter` trait (`backend/src/sources/mod.rs`)
+
+```rust
+#[async_trait]
+pub trait SourceAdapter: Send + Sync {
+ /// Stable identifier, matches `sources.slug` in DB.
+ fn id(&self) -> &str;
+
+ /// Human label, e.g. "Ticketmaster Discovery API".
+ fn name(&self) -> &str;
+
+ /// Category for UI grouping: "primary", "resale", "sports", "festival".
+ fn category(&self) -> SourceCategory;
+
+ /// Poll once. Returns a vec of normalized `Event` candidates.
+ /// Errors must be retried by the scheduler's backoff, not propagated up.
+ async fn poll(&self, ctx: &PollContext) -> Result<Vec<EventCandidate>, SourceError>;
+
+ /// Adaptive interval hint based on `ctx.now` and any known drop window.
+ /// Default: 5 minutes. Tighten to 10s inside ±15min of known drop.
+ fn next_poll_hint(&self, ctx: &PollContext) -> Duration {
+ Duration::from_secs(300)
+ }
+}
+
+pub struct PollContext {
+ pub now: DateTime<Utc>,
+ pub http: reqwest::Client, // shared, pre-configured (TLS impersonation)
+ pub scraper_bridge: ScraperHandle, // for sources that need Python
+ pub cache: RedisHandle,
+ pub metrics: MetricsHandle,
+}
+
+pub struct EventCandidate {
+ pub source_id: String,
+ pub source_event_id: String, // ID from the source's namespace
+ pub title: String,
+ pub subtitle: Option<String>, // tour name, support act, etc.
+ pub category: EventCategory, // Concert, Sports, Festival, Theater, Other
+ pub sport: Option<Sport>, // NFL/NBA/NHL/MLB/Tennis/F1/Soccer/...
+ pub starts_at: Option<DateTime<Utc>>,
+ pub venue_name: Option<String>,
+ pub city: Option<String>,
+ pub country: Option<String>,
+ pub latitude: Option<f64>,
+ pub longitude: Option<f64>,
+ pub image_url: Option<String>,
+ pub on_sale_at: Option<DateTime<Utc>>, // announced sale start
+ pub presale_on_sale_at: Option<DateTime<Utc>>,
+ pub status: SaleStatus, // Announced, Presale, OnSale, SoldOut, Cancelled
+ pub price_min: Option<i64>, // cents (USD or source currency)
+ pub price_max: Option<i64>,
+ pub currency: Option<String>, // ISO 4217
+ pub inventory_remaining: Option<u32>,
+ pub buy_url: String, // canonical, with affiliate tags where allowed
+ pub raw: serde_json::Value, // full original payload, for debug
+}
+```
+
+### 5.2 Scheduler (`backend/src/scheduler/`)
+
+- One `tokio::task` per source.
+- Loop body: `sleep(next_poll_hint) → poll() → detect::diff() → store + emit`.
+- On error: exponential backoff capped at 10 minutes; emit `source.unhealthy` after 3 consecutive failures.
+- Use `tokio::select!` to also listen on a `shutdown` channel for clean exit.
+- Persist `last_poll_at`, `last_success_at`, `last_error`, `consecutive_failures` in `sources` table.
+
+### 5.3 Change detector (`backend/src/detect/`)
+
+- `content_hash` = SHA-256 of canonical JSON of `(source_event_id, status, on_sale_at, presale_on_sale_at, price_min, price_max, inventory_remaining)`. Fields are normalized (None → null, datetimes in RFC3339 UTC, prices in minor units). Render with `serde_json::to_string` after sorting keys (BTreeMap).
+- Diff outcomes → emit different NATS subjects + different SSE event types:
+ - **New** (source_event_id never seen) → `event.new` → SSE `drop.new`
+ - **StatusFlip** (status changed) → `event.status_changed` → SSE `drop.status`
+ - Most-wanted transition: `Announced → OnSale`
+ - **PriceDrop** (price_min decreased ≥10%) → `event.price_drop` → SSE `drop.price`
+ - **InventoryDrop** (inventory_remaining decreased ≥25%) → `event.inventory` → SSE `drop.inventory`
+ - **Unchanged** (hash same) → no-op
+
+### 5.4 SSE broadcaster (`backend/src/api/sse.rs`)
+
+- Maintain a `tokio::sync::broadcast::Sender<ServerEvent>` in `AppState`.
+- Each `/api/stream` connection spawns a task that subscribes and writes `event: <type>\ndata: <json>\n\n` lines.
+- Heartbeat every 15s (comment line `:\n\n`) to keep proxies from closing idle conns.
+- Backpressure: if a client's receiver falls >50 events behind, drop oldest.
+
+### 5.5 Notification pipeline (`backend/src/notify/`)
+
+Order of operations on a new `DropEvent`:
+1. **Filter** against watchlist (`notify::filter`):
+ - Does the event match any watchlist rule? (artist/team/venue/sport/category keywords, regex)
+ - Inside quiet hours? (configurable; default: never quiet - operator wants to wake up)
+ - Already notified for this `source_event_id` in last 10 min? (dedupe)
+2. If pass → build notification payload (title, body with markdown, click URL = `buy_url`, tags = `[source_id, category]`).
+3. POST to ntfy at `http://ntfy:80/internal-{topic}` with `Title`, `Message`, `Click`, `Tags`, `Priority` (elevated for status flips), `Actions` (a "Buy" button via ntfy action URLs).
+4. Insert row into `notifications_log`.
+
+### 5.6 REST API surface (all under `/api`)
+
+| Method | Path | Purpose |
+|---|---|---|
+| GET | `/api/events` | Paginated, filterable (category, sport, source, status, price range, date range). Default sort: `on_sale_at asc nulls last`. |
+| GET | `/api/events/:id` | Single event with price history. |
+| GET | `/api/drops` | Drop history, paginated, filterable by type/category/source. |
+| GET | `/api/drops/stream` | **SSE** - live drops. |
+| GET | `/api/watchlist` | List rules. |
+| POST | `/api/watchlist` | Create rule (validated by Zod-equivalent: serde + validator crate). |
+| PATCH | `/api/watchlist/:id` | Update rule (toggle active, edit pattern). |
+| DELETE | `/api/watchlist/:id` | Delete rule. |
+| GET | `/api/sources` | Source health grid (status, latency p50/p95, last_success_at, consecutive_failures). |
+| GET | `/api/stats` | Dashboard KPIs (drops last 24h, sources healthy, watchlist matches, etc.). |
+| GET | `/api/explore?q=` | Proxy to Meilisearch for fuzzy event/artist search. |
+| GET | `/api/health` | Liveness (200). |
+| GET | `/api/ready` | Readiness (DB + NATS + Redis reachable). |
+
+**Response shape (consistent):**
+```json
+{ "data": [...], "meta": { "page": 1, "per_page": 50, "total": 1234 } }
+```
+Errors: `{ "error": { "code": "STRING", "message": "human-readable" } }` with appropriate HTTP status.
+
+---
+
+## 6. Scraper Workers (Python) - detailed contracts
+
+### 6.1 Worker model
+- One Celery worker process per Docker replica. Replica count per source is configurable.
+- Tasks are published by Rust via NATS → a small bridge (the Celery worker subscribes to NATS and enqueues a Celery task) OR - simpler - Rust publishes scrape_request to NATS, a single Python "dispatcher" subscribes and calls Celery tasks; results come back via NATS reply subject.
+- **Preferred pattern (simpler):** Use NATS directly as the Celery-less RPC bus. The Python process is a long-running asyncio loop subscribing to `scrape.>` subjects; each message is a `ScrapeRequest`, each reply is a `ScrapedPayload`. This avoids Celery's complexity for v1. (Migrate to Celery in v2 if scale demands.)
+
+**Decision: use asyncio + NATS, NOT Celery, for v1.** Simpler, fewer services, faster cold-path.
+
+### 6.2 `BaseScraper` (`scrapers/base.py`)
+
+```python
+from typing import Protocol, Sequence
+from .types import ScrapeRequest, ScrapedPayload
+
+class BaseScraper(Protocol):
+ source_id: str # matches Rust SourceAdapter id
+
+ async def scrape(self, req: ScrapeRequest) -> ScrapedPayload: ...
+
+ async def health(self) -> ScraperHealth: ...
+```
+
+- Each scraper lives in `scrapers/sources/<name>.py` and is registered in a registry keyed by `source_id`.
+- The dispatcher reads NATS messages, looks up the scraper, calls `scrape()`, publishes reply.
+- Use `httpx.AsyncClient` for HTML sources; `playwright.async_api` for JS-rendered sources (lazy-import per source to keep memory down).
+
+### 6.3 Anti-bot module (`scrapers/anti_bot/`)
+- `fingerprints.py`: rotating pool of (User-Agent, sec-ch-ua, accept-language, JA3) tuples, sourced from a checked-in JSON. Update monthly.
+- `proxies.py`: optional proxy rotation. For v1, support a static list of `http://user:pass@host:port` from env. (Future: integrate with rotating residential proxy.)
+- `retry.py`: exponential backoff with jitter; honor `Retry-After` on 429/503; circuit-open after 5 consecutive failures in 60s.
+- `playwright_stealth`: use `playwright-stealth` plugin to defeat basic bot detection.
+- **Never** solve CAPTCHAs. If a source starts requiring CAPTCHA, mark it unhealthy and alert the operator.
+
+### 6.4 ScrapedPayload schema (must match Rust)
+
+```python
+@dataclass
+class ScrapedPayload:
+ source_id: str
+ fetched_at: str # RFC3339 UTC
+ ok: bool
+ error: Optional[str]
+ events: List[EventCandidateDict] # same shape as Rust EventCandidate
+ raw_html_sha256: Optional[str] # for fast "page unchanged" skip
+ latency_ms: int
+```
+
+---
+
+## 7. Source Adapters - full spec for v1 + interface for v2
+
+Each adapter section specifies: auth, endpoint, rate limit, parser approach, gotchas.
+
+### 7.1 Ticketmaster Discovery API ✅ v1 (Rust)
+- **Auth:** API key via `apikey` query param. Free, register at `developer.ticketmaster.com`.
+- **Base:** `https://app.ticketmaster.com/discovery/v2/`
+- **Endpoints:**
+ - `GET /events.json?keyword={artist}&countryCode={CC}&size=200&page={n}`
+ - `GET /events.json?segmentName=Music|Sports|Arts&countryCode={CC}`
+ - `GET /events.json?attractionId={id}` (after resolving attraction IDs)
+- **Rate limit:** 5000 req/day per key. With multiple keys (allowed up to 5 free keys/account), rotate.
+- **Parsing notes:**
+ - `sales.public.startDateTime` → `on_sale_at`
+ - `sales.presale.startDateTime` → `presale_on_sale_at`
+ - `dates.start.dateTime` → `starts_at`
+ - `_embedded.venues[0]` → venue, city, country, lat/lon
+ - `priceRanges[0].min`/`.max` → prices (already in major units, multiply by 100 for cents)
+ - `url` → `buy_url`
+ - Status inference: if `on_sale_at` is past and no `sold_out` flag → `OnSale`; if `dates.status.code == "sold"` → `SoldOut`.
+- **Gotchas:**
+ - Pagination: cap `size=200`, follow `page.total` to know how many pages.
+ - Some events have null `priceRanges` - leave prices as None.
+ - `_embedded.attractions` gives canonical artist/team IDs - store these for watchlist resolution.
+
+### 7.2 SeatGeek API ✅ v1 (Rust)
+- **Auth:** Client ID via `client_id` query param. Free at `seatgeek.com/developers`.
+- **Base:** `https://api.seatgeek.com/2/`
+- **Endpoints:**
+ - `GET /events?client_id={id}&performers.slug={slug}&type={concert|sports|...}`
+ - `GET /events?client_id={id}&listing_count.gt=0&q={query}`
+ - `GET /performers?client_id={id}&slug={slug}` to resolve IDs
+- **Rate limit:** not officially stated; stay under 1 req/sec per endpoint.
+- **Parsing notes:**
+ - `time_utc` → `starts_at`
+ - `score` → relevance score (use to rank)
+ - `stats.lowest_price` / `.highest_price` / `.average_price` → prices (in dollars → cents)
+ - `url` → `buy_url` (SeatGeek affiliate handles resale listings too)
+ - `venue.city`, `venue.country`, `venue.location.lat`/`.lon`
+ - Status inference: `announced` field; `visible_at_utc` → `on_sale_at`.
+- **Gotchas:**
+ - SeatGeek aggregates resale - `listing_count` flips >0 when tickets hit secondary market.
+
+### 7.3 FIFA World Cup portal ✅ v1 (Python scraper)
+- **URL:** `https://tickets.fifa.com` (rotates per tournament - make this configurable).
+- **Approach:** Playwright headless. Render the sales phases page, extract:
+ - Active phase name (Random Selection Draw / First Come First Served / Last Minute)
+ - Phase window (`start`, `end`)
+ - Per-match availability indicator (often color-coded)
+- **Detection:** When phase transitions from "closed/coming" → "open" → emit drop. When a match goes from "sold out" → "available" (last-minute sales) → emit `drop.status`.
+- **Gotchas:**
+ - FIFA uses heavy anti-bot (Cloudflare). Use `playwright-stealth` + realistic TLS (curl_cffi for any XHR you can identify).
+ - The site is localized; parameterize language to `en`.
+ - Phase dates are announced but actual opening minute varies - poll every 30s within ±1h of announced start.
+- **Legal:** Display only. Link to official portal for purchase. Do not attempt to auto-submit applications.
+
+### 7.4 AXS ✅ v2 (Python scraper)
+- `https://www.axs.com/events` - JSON behind the search endpoint; reverse-engineer XHR. Use `curl_cffi` with browser impersonation.
+- Look for `onSaleDate` field.
+
+### 7.5 See Tickets ✅ v2 (Python scraper)
+- `https://www.seetickets.com/events` - server-rendered HTML; use selectolax.
+
+### 7.6 StubHub ✅ v2 (Python scraper, resale)
+- Target: detect price drops. StubHub has GraphQL; reverse-engineer carefully.
+- Track `lowestPrice` per event; emit `drop.price` when it drops ≥10%.
+
+### 7.7 Vivid Seats ✅ v2 (Python scraper, resale)
+- HTML scrape; track lowest listing price.
+
+### 7.8 Twitter/X shock-drop monitor ✅ v2 (Python)
+- Use the official X API v2 filtered stream (paid) OR scrape via Nitter instances (fragile) OR RSS of artist/team accounts via rsshub.
+- Track a curated list of accounts (configured in watchlist with `kind: twitter_account`).
+- Pattern match tweets for: "tickets", "on sale", "presale code", "shock drop", "available now", "link in bio".
+- Emit `drop.announcement` with the tweet URL.
+
+### 7.9 NFL ✅ v1 via Ticketmaster segment + SeatGeek
+- Use Ticketmaster `segmentName=Sports&sport=NFL` (or genre ID). Cross-reference with SeatGeek `performers.slug` for known teams.
+
+### 7.10 NBA ✅ v1 via Ticketmaster + SeatGeek - same pattern as NFL.
+
+### 7.11 NHL ✅ v1 - same pattern.
+
+### 7.12 MLB ✅ v1 - same pattern (note: MLB uses Tickets.com / SeatGeek heavily).
+
+### 7.13 ATP/WTA Tennis ✅ v2
+- Scrape `https://www.atptour.com/en/tournaments` and `https://www.wtatennis.com/tournaments`.
+- Cross-reference Ticketmaster for slams (US Open, Wimbledon via official + Ticketmaster).
+
+### 7.14 F1 ✅ v2
+- Scrape `https://www.formula1.com/en/tickets.html` - Playwright.
+- Grand Prix tickets are sold in waves; detect wave open.
+
+### 7.15 UEFA (Champions League, Europa, Euros) ✅ v2
+- `https://www.uefa.com/tickets/` - Playwright.
+- UEFA uses ballot + FCFS phases.
+
+### 7.16 Premier League ✅ v2
+- Each club has its own ticket portal. v2 implements a per-club adapter interface; v2 ships with the Big 6 (Man City, Man Utd, Liverpool, Chelsea, Arsenal, Tottenham).
+
+### 7.17 Festivals ✅ v2-v3 (one adapter each)
+- Coachella, Glastonbury, Tomorrowland, EDC, Lollapalooza, Austin City Limits, Bonnaroo, Rolling Loud, Ultra, Boom, Burning Man (lottery).
+- Each festival has unique sales mechanics (lottery, registration, FCFS waves). Each adapter documents its phase model.
+
+---
+
+## 8. Data Model & Schema (PostgreSQL + TimescaleDB)
+
+### 8.1 Tables
+
+```sql
+-- sources: one row per registered source adapter
+CREATE TABLE sources (
+ id TEXT PRIMARY KEY, -- e.g. "ticketmaster", "fifa"
+ name TEXT NOT NULL,
+ category TEXT NOT NULL, -- primary|resale|sports|festival|social
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ poll_interval_s INTEGER NOT NULL DEFAULT 300,
+ tight_interval_s INTEGER NOT NULL DEFAULT 10,
+ last_poll_at TIMESTAMPTZ,
+ last_success_at TIMESTAMPTZ,
+ last_error TEXT,
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
+ config JSONB NOT NULL DEFAULT '{}', -- per-source: api keys live here? NO - env only
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- events: normalized events across all sources
+CREATE TABLE events (
+ id BIGSERIAL PRIMARY KEY,
+ source_id TEXT NOT NULL REFERENCES sources(id),
+ source_event_id TEXT NOT NULL, -- ID in source's namespace
+ title TEXT NOT NULL,
+ subtitle TEXT,
+ category TEXT NOT NULL, -- Concert|Sports|Festival|Theater|Other
+ sport TEXT, -- NFL|NBA|NHL|MLB|Tennis|F1|Soccer|...
+ starts_at TIMESTAMPTZ,
+ venue_name TEXT,
+ city TEXT,
+ country TEXT,
+ latitude DOUBLE PRECISION,
+ longitude DOUBLE PRECISION,
+ image_url TEXT,
+ on_sale_at TIMESTAMPTZ,
+ presale_on_sale_at TIMESTAMPTZ,
+ status TEXT NOT NULL, -- Announced|Presale|OnSale|SoldOut|Cancelled
+ price_min INTEGER, -- cents
+ price_max INTEGER,
+ currency TEXT,
+ inventory_remaining INTEGER,
+ buy_url TEXT NOT NULL,
+ raw JSONB,
+ content_hash TEXT NOT NULL, -- SHA-256, see §5.3
+ first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (source_id, source_event_id)
+);
+CREATE INDEX idx_events_status ON events(status);
+CREATE INDEX idx_events_on_sale ON events(on_sale_at);
+CREATE INDEX idx_events_category ON events(category);
+CREATE INDEX idx_events_starts_at ON events(starts_at);
+CREATE INDEX idx_events_title_trgm ON events USING gin (title gin_trgm_ops); -- needs pg_trgm
+
+-- drops: every detected change. Append-only.
+CREATE TABLE drops (
+ id BIGSERIAL PRIMARY KEY,
+ event_id BIGINT NOT NULL REFERENCES events(id),
+ drop_type TEXT NOT NULL, -- new|status|price|inventory|announcement
+ from_state JSONB, -- prior values
+ to_state JSONB, -- new values
+ detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ notified BOOLEAN NOT NULL DEFAULT FALSE,
+ notified_at TIMESTAMPTZ
+);
+CREATE INDEX idx_drops_detected ON drops(detected_at DESC);
+CREATE INDEX idx_drops_event ON drops(event_id);
+CREATE INDEX idx_drops_type ON drops(drop_type);
+
+-- watchlist: user-defined rules
+CREATE TABLE watchlist (
+ id BIGSERIAL PRIMARY KEY,
+ kind TEXT NOT NULL, -- artist|team|venue|sport|category|keyword|twitter_account
+ pattern TEXT NOT NULL, -- regex or glob
+ label TEXT NOT NULL, -- friendly name
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ priority TEXT NOT NULL DEFAULT 'normal', -- low|normal|high|critical (drives ntfy priority)
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX idx_watchlist_active ON watchlist(active);
+
+-- notifications_log: every ntfy push
+CREATE TABLE notifications_log (
+ id BIGSERIAL PRIMARY KEY,
+ drop_id BIGINT REFERENCES drops(id),
+ channel TEXT NOT NULL, -- ntfy|telegram|email
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ priority TEXT NOT NULL,
+ sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ success BOOLEAN NOT NULL,
+ response TEXT
+);
+CREATE INDEX idx_notif_sent ON notifications_log(sent_at DESC);
+
+-- price_history (TimescaleDB hypertable)
+CREATE TABLE price_history (
+ event_id BIGINT NOT NULL REFERENCES events(id),
+ observed_at TIMESTAMPTZ NOT NULL,
+ price_min INTEGER,
+ price_max INTEGER,
+ currency TEXT,
+ source TEXT -- 'primary' | 'stubhub' | 'vividseats' | ...
+);
+SELECT create_hypertable('price_history', 'observed_at');
+CREATE INDEX idx_price_event_time ON price_history(event_id, observed_at DESC);
+
+-- source_health (TimescaleDB hypertable)
+CREATE TABLE source_health (
+ source_id TEXT NOT NULL,
+ observed_at TIMESTAMPTZ NOT NULL,
+ ok BOOLEAN NOT NULL,
+ latency_ms INTEGER NOT NULL,
+ events_returned INTEGER NOT NULL,
+ error TEXT
+);
+SELECT create_hypertable('source_health', 'observed_at');
+CREATE INDEX idx_health_source_time ON source_health(source_id, observed_at DESC);
+```
+
+### 8.2 Extensions required in migrations
+```sql
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+CREATE EXTENSION IF NOT EXISTS timescaledb;
+```
+
+### 8.3 Seed data
+- Seed `sources` rows for every adapter listed in §7 (with `enabled = TRUE` for v1 sources, `FALSE` for v2-v3).
+- Seed a starter `watchlist` with a few example rules the operator can edit (e.g. `"Taylor Swift"`, `"World Cup"`, `"Super Bowl"`, `"Wimbledon"`).
+
+---
+
+## 9. Notification System (ntfy, self-hosted, free iPhone push)
+
+### 9.1 Why ntfy
+- Free, self-hosted, no third-party relay.
+- iOS app supports instant push via APNs (ntfy's public APNs key - no Apple Developer account needed by you).
+- HTTP-only API: `POST http://ntfy.your_homelab/topic { Title, Message, Click, Priority, Tags, Actions }`.
+
+### 9.2 Setup
+- Run `ntfy` container in `docker-compose.yml`.
+- Topic naming: `drophawk-drops` (high-priority), `drophawk-info` (low).
+- **Auth:** generate an access token; persist in `.env` as `NTFY_TOKEN`. Subscribe the iOS app with the token.
+- iOS app: add subscription to `https://ntfy.your_homelab/drophawk-drops`.
+
+### 9.3 Notification payload spec
+```
+POST /drophawk-drops HTTP/1.1
+Host: ntfy
+Authorization: Bearer {NTFY_TOKEN}
+Title: 🎟️ On Sale: Taylor Swift - Miami
+Tags: ticket,concert,concerts,ticketmaster
+Priority: high
+Click: https://www.ticketmaster.com/event/...
+Actions: view, Buy now, https://www.ticketmaster.com/event/...
+
+Eras Tour floor seats from $49.50. 15,000+ remaining.
+Venue: Hard Rock Stadium · Jun 22 2026
+```
+
+### 9.4 Priority mapping
+- `drop.new` for a watchlist match → `urgent`
+- `drop.status` (Announced→OnSale) → `high`
+- `drop.price` ≥25% drop → `high`
+- `drop.price` <25% drop → `default`
+- `drop.inventory` → `default`
+- Non-watchlist events → not notified (still visible in UI).
+
+### 9.5 Fallback channels (optional, future)
+- Telegram bot (set `TELEGRAM_BOT_TOKEN` to enable).
+- Email via Postfix relay.
+
+---
+
+## 10. Frontend - detailed UI spec
+
+### 10.1 Design language
+- **Theme:** Dark by default (light available). Use OKLCH colors.
+- **Type:** Inter for UI, JetBrains Mono for numbers/timestamps.
+- **Density:** High. Card-based grid for drops, no whitespace waste.
+- **Motion:** Spring physics on drop cards (slide-in + shimmer), gentle parallax on stats. Respect `prefers-reduced-motion`.
+- **Identity accent:** electric teal (#14b8a6) + amber for warnings + red for sold out.
+
+### 10.2 Layout
+- **Sidebar (left, collapsible):** Dashboard, Live Drops, Explore, Watchlist, Sources, Settings.
+- **Topbar:** global Cmd+K palette, theme toggle, source health dot, "watchlist active" badge.
+- **Main area:** per-route content.
+
+### 10.3 Routes & components
+
+**`/` (Dashboard) - the wow page**
+- Row 1: Tremor KPI cards - Drops (24h), Active Sources, Watchlist Matches, Avg Detection Latency.
+- Row 2 (3/4 width): **LiveDropFeed** - SSE-driven, animated cards. Each `DropCard` shows: source icon, title, status pill, price range, time-since-detected (RelativeTime), "Buy" button (opens `buy_url` in new tab), category tag. New cards slide in from top; flash accent border for 2s.
+- Row 2 (1/4 width): **SourceGrid** - health dots (green/amber/red), latency sparkline per source, click → sources page filtered.
+- Row 3: **DropHeatmap** - calendar of last 90 days, color intensity = drop count per day. Click a day → drops page filtered.
+- Row 4: **Top Drops** - table of highest-signal drops this week.
+
+**`/drops` - Drop history**
+- Filters: type (new/status/price/inventory), category, source, date range, search (Meilisearch-backed).
+- Virtualized table (TanStack Virtual) - handle 100k+ rows.
+- Each row expandable → event detail + price history sparkline.
+
+**`/explore` - Browse**
+- Search box (debounced 200ms → `/api/explore?q=`).
+- Results grouped by category. Click → modal with detail + "Add to watchlist" CTA.
+
+**`/watchlist` - Manage**
+- Form (React Hook Form + Zod): kind (select), pattern (input), label, priority.
+- List of rules with toggle, edit, delete, "matched N events" count.
+- Bulk import: paste a list of artist/team names → creates `keyword` rules in batch.
+
+**`/sources` - Source health**
+- Grid of cards, one per source. Shows: status, latency p50/p95 (last hour), events seen (24h), last error, poll interval.
+- Toggle enable/disable per source (writes to `sources.enabled`).
+
+**`/settings` - Settings**
+- ntfy topic + token (test button sends a push).
+- Quiet hours (time range, days of week).
+- Theme + density preferences (persisted in localStorage).
+
+### 10.4 SSE handling (`hooks/useDropsStream.ts`)
+- Open EventSource on `/api/drops/stream`.
+- On message: parse, push to Zustand store (cap buffer at 200, FIFO).
+- On error: close, exponential backoff reconnect (max 30s).
+- On `drop.new` for a watchlist match: trigger browser notification (request permission on first dashboard mount).
+
+### 10.5 Accessibility
+- All interactive elements keyboard-navigable.
+- ARIA labels on icon-only buttons.
+- Color contrast ≥ 4.5:1.
+- Status pills have text, not color alone.
+
+### 10.6 PWA
+- `manifest.json` + service worker (via `next-pwa` or `serwist`).
+- Installable to home screen; works offline for cached views.
+
+---
+
+## 11. Polling & Anti-Ban Strategy
+
+### 11.1 Adaptive intervals (per-source, in Rust scheduler)
+- **Baseline:** `poll_interval_s` from `sources` table (default 300s = 5min).
+- **Tight window:** from `T-15min` to `T+15min` around a known `on_sale_at`, switch to `tight_interval_s` (default 10s).
+- **Pre-announcement:** for sources where the next drop time is unknown (e.g. FIFA last-minute sales), stay at 30s baseline.
+- Source adapters can override `next_poll_hint` for source-specific intelligence (e.g. F1 wave patterns).
+
+### 11.2 Polite scraping rules
+- Honor `robots.txt`. If a path is disallowed, do not scrape it; mark source as `disabled` and alert.
+- Identify DropHawk with a stable User-Agent: `DropHawk/1.0 (+https://your-homelab/drophawk)`. Do not impersonate browsers for *API* sources (only for sources that require it, e.g. FIFA behind Cloudflare).
+- Default `Accept-Encoding: gzip, br`.
+- Cache aggressively: if `raw_html_sha256` unchanged, skip parsing entirely.
+
+### 11.3 Rate limiting (per-source token bucket in Redis)
+- Key: `ratelimit:{source_id}`. Refill rate from source config.
+- Acquire token before each poll. If empty, wait (don't skip).
+- Honors 429s: on 429 with `Retry-After`, set bucket to empty for that duration.
+
+### 11.4 Circuit breaker (per source)
+- 3 consecutive failures → status `degraded` (still try, but at half rate).
+- 10 consecutive failures → status `down` (pause, alert operator).
+- First success after degraded/down → status `healthy`.
+
+### 11.5 Anti-bot resilience (Python scrapers)
+- TLS fingerprint rotation via `curl_cffi` (impersonates Chrome 120, Firefox 121, Safari 17).
+- Header rotation: matching (UA + sec-ch-ua + accept-language) tuples.
+- Optional residential proxy pool (env: `PROXY_POOL_URL`). For v1, no proxy - start clean.
+- `playwright-stealth` for JS-rendered sources.
+- **Never** attempt CAPTCHA bypass. Fail loudly instead.
+
+### 11.6 Distributed polling
+- v1 is single-instance (one Rust process). The architecture supports horizontal scaling later by sharding sources across instances via a Redis lease (`source:{id}:owner` with TTL).
+
+---
+
+## 12. Real-Time Pipeline
+
+```
+[source poll] → Rust detect::diff → Postgres write →
+ ├→ NATS publish "drop.{type}" (durable, JetStream)
+ ├→ SSE broadcast to browsers (in-process broadcast channel)
+ └→ ntfy publish (if watchlist match)
+```
+
+- JetStream retention: `drop.*` subjects retained 7 days (for replay on restart).
+- On Rust startup: replay any unacked drops newer than `MAX(last_browser_seen, now-1h)`. (Browsers also reconnect via SSE and resync from `/api/drops?since=`.)
+
+---
+
+## 13. Docker Compose Layout
+
+### 13.1 Services (`docker-compose.yml`)
+
+| Service | Image / Build | Ports | Depends on |
+|---|---|---|---|
+| `caddy` | `caddy:2.7` | 80, 443 | `frontend`, `backend`, `ntfy` |
+| `frontend` | build `./frontend` | internal | `backend` |
+| `backend` | build `./backend` | internal | `postgres`, `redis`, `nats` |
+| `scrapers` | build `./scrapers` | internal | `nats`, `redis` |
+| `postgres` | `postgres:16` + Timescale | internal | - |
+| `redis` | `redis:7.2-alpine` | internal | - |
+| `nats` | `nats:2.10` (with JetStream) | internal | - |
+| `meilisearch` | `getmeili/meilisearch:v1.6` | internal | - |
+| `ntfy` | `binwiederhier/ntfy:2` | internal | - |
+| `minio` | `minio/minio` | internal | - |
+| `prometheus` | `prom/prometheus` | internal | - |
+| `grafana` | `grafana/grafana` | 3000 (LAN only) | `prometheus`, `loki` |
+| `loki` | `grafana/loki` | internal | - |
+
+### 13.2 Networking
+- One internal network `drophawk-net`.
+- Only `caddy` exposes ports to the host. Everything else is internal.
+- Caddy routes:
+ - `drophawk.lan` → `frontend:3000`
+ - `drophawk.lan/api/*` → `backend:8080`
+ - `drophawk.lan/ntfy/*` → `ntfy:80`
+- For homelab DNS: operator adds `drophawk.lan` to their Pi-hole/AdGuard (or uses Tailscale MagicDNS).
+
+### 13.3 Volumes
+- `pg-data`, `redis-data`, `nats-data`, `meili-data`, `ntfy-data`, `minio-data`, `grafana-data`, `loki-data`, `prometheus-data`.
+
+### 13.4 Resource guidance
+- Postgres: 2GB memory ceiling. Backend: 512MB. Scrapers: 1GB (Playwright is hungry). Frontend (SSR): 256MB.
+
+### 13.5 `.env.example` (template - copy to `.env`, fill in)
+```
+# Postgres
+POSTGRES_PASSWORD=change_me
+POSTGRES_DB=drophawk
+DATABASE_URL=postgres://drophawk:change_me@postgres:5432/drophawk
+
+# Redis
+REDIS_URL=redis://redis:6379/0
+
+# NATS
+NATS_URL=nats://nats:4222
+
+# Sources - Ticketmaster
+TICKETMASTER_API_KEYS=key1,key2,key3
+# Sources - SeatGeek
+SEATGEEK_CLIENT_ID=xxx
+
+# ntfy
+NTFY_BASE_URL=http://ntfy:80
+NTFY_TOPIC=drophawk-drops
+NTFY_TOKEN=
+
+# Meilisearch
+MEILI_URL=http://meilisearch:7700
+MEILI_MASTER_KEY=change_me
+
+# Optional
+PROXY_POOL_URL=
+TELEGRAM_BOT_TOKEN=
+```
+
+**Secrets never go in the repo.** `.env` is gitignored. Document keys in `.env.example` only.
+
+---
+
+## 14. Phased Delivery Roadmap
+
+Each phase ends with the §17 checklist passing.
+
+### Phase 1 - Vertical slice (1 source end-to-end)
+Scope:
+- Repo scaffold per §4.
+- Rust backend: config, DB pool, NATS connect, Axum server, `health`/`ready`, scheduler skeleton with **Ticketmaster adapter only**.
+- Postgres + Timescale + migrations through §8.
+- ntfy container + publisher.
+- Next.js: layout, dashboard, LiveDropFeed (real SSE), DropCard, StatsRow (basic), watchlist page (CRUD).
+- Caddy + Docker Compose for this subset.
+- Deliverable: operator can add "Taylor Swift" to watchlist, get an ntfy push when a Ticketmaster event flips on-sale, see it in the UI.
+
+### Phase 2 - Second source + resale + Meilisearch
+- SeatGeek adapter (Rust).
+- Meilisearch integration, `/explore` page.
+- `price_history` writes, PriceSparkline component.
+- SourceGrid + `/sources` page.
+- DropHeatmap.
+- Deliverable: two primary sources feeding the UI; search works.
+
+### Phase 3 - Python scraper bridge
+- NATS-based RPC to Python workers.
+- FIFA adapter (Python, Playwright).
+- AXS adapter (Python).
+- Scraper health surfacing in `/sources`.
+- MinIO screenshot capture on parse failure (debug).
+- Deliverable: FIFA + AXS drops flow through the same pipeline.
+
+### Phase 4 - Sports expansion
+- NFL, NBA, NHL, MLB via Ticketmaster segment + SeatGeek cross-reference.
+- Sport-aware UI badges.
+- Watchlist `kind: team` with team slug resolution.
+- Deliverable: major sports covered.
+
+### Phase 5 - Resale price tracking
+- StubHub + Vivid Seats Python scrapers.
+- Price-drop detection (≥10% / ≥25% thresholds).
+- Resale price sparkline overlay on event detail.
+- Deliverable: alerts when resale prices drop.
+
+### Phase 6 - Shock drops + extras
+- Twitter/X monitor.
+- Tennis (ATP/WTA), F1, UEFA, Premier League (Big 6).
+- Festival adapters (Coachella, Glastonbury, Tomorrowland, EDC, Lollapalooza first).
+- Command palette (Cmd+K).
+- PWA manifest.
+- Deliverable: full breadth.
+
+### Phase 7 - Polish & resilience
+- Grafana dashboards + alerts (Prometheus alertmanager).
+- Backup automation.
+- Source sharding for horizontal scale.
+- i18n (en first; structure for more).
+- Accessibility audit.
+- Deliverable: production-grade.
+
+---
+
+## 15. Coding Standards (HARD RULES)
+
+### 15.1 Rust
+- `#![forbid(unsafe_code)]` at crate root.
+- `clippy::pedantic` enabled; fix all warnings before merge.
+- `rustfmt` with edition 2021.
+- Errors: `thiserror` for error enums, `?` propagation, no `unwrap()`/`expect()` outside tests and main startup.
+- Async: `tokio` runtime only. No `async_std`.
+- DB: sqlx with `query!` macros (compile-time checked). Migrations under `migrations/`, never edit applied.
+- Logging: `tracing::{info, warn, error, debug}` with structured fields. No `println!`.
+- Config: load from env via `figment` or hand-rolled; fail fast on missing required keys.
+- Tests: `#[tokio::test]` for async; use `wiremock` for HTTP mocking.
+
+### 15.2 Python
+- Python 3.11+.
+- `ruff` (lint + format). `mypy --strict` passes.
+- `uv` for env + lockfile.
+- All scrapers typed (Protocol return types).
+- Async-first (`async def` everywhere in hot path).
+- No global mutable state.
+- Tests: `pytest` + `vcr.py` cassettes for HTTP replay.
+
+### 15.3 TypeScript / Next.js
+- `"strict": true` in tsconfig.
+- `eslint` + `prettier`.
+- App Router conventions: server components by default, `"use client"` only where needed (SSE consumers, forms with state).
+- All API calls go through `lib/api.ts` - no raw `fetch` in components.
+- All server actions / route handlers validate input with Zod.
+- No `any`. No `// @ts-ignore`.
+- Tailwind only for styling - no inline `style={}` except dynamic values.
+- Tests: Vitest for unit, Playwright for e2e (one happy-path per phase).
+
+### 15.4 Cross-cutting
+- **No secrets in repo.** `.env` gitignored. CI fails if a secret-looking string is committed (use `gitleaks`).
+- **Every PR runs:** `cargo clippy`, `cargo test`, `ruff`, `mypy`, `pytest`, `tsc --noEmit`, `eslint`, `prettier --check`. Wire these into a root `Makefile` target `make check`.
+- **Commit messages:** conventional commits (`feat:`, `fix:`, `chore:`, `docs:`).
+- **Branching:** `main` is deployable. Feature branches off `main`. No direct pushes to `main`.
+
+---
+
+## 16. Legal & Ethical Guardrails
+
+- **No bulk auto-purchase.** DropHawk only notifies and links. The human completes purchase.
+- **No CAPTCHA solving.** If a source requires CAPTCHA, mark unhealthy.
+- **No credential stuffing / account takeover.** Never store third-party site credentials.
+- **No circumvention of purchase limits.** DropHawk surfaces availability; it does not bypass per-account caps.
+- **Honor `robots.txt`.**
+- **Honor rate limits and `Retry-After`.**
+- **Resale compliance:** DropHawk does not facilitate resale of restricted tickets (e.g. FIFA, paperless tickets). Notifications for such events link only to the official sales channel. Resale of personal tickets is the operator's responsibility and must comply with local law.
+- **Identify the bot honestly** with a stable UA on API sources. Impersonation only where strictly required (Cloudflare-protected portals) and only for read-only public pages.
+- **Data minimization:** do not collect or store personal data of other users.
+
+---
+
+## 17. Execution Checklist (per phase)
+
+Before declaring a phase done, every box must be ticked:
+
+- [ ] All files in the phase's scope exist with the names/paths from §4.
+- [ ] `make check` passes clean (clippy, ruff, mypy, tsc, eslint, tests).
+- [ ] `docker compose up -d` brings up all services for that phase without errors.
+- [ ] `scripts/healthcheck.sh` returns 0.
+- [ ] DB migrations apply cleanly on a fresh database.
+- [ ] The phase's stated deliverable works end-to-end (manually verified: trigger a poll, see the SSE event, see the UI update, see the ntfy push).
+- [ ] No `unwrap`/`any`/`console.log`/`print` left in committed code.
+- [ ] `.env.example` updated with any new vars introduced.
+- [ ] README.md updated with how to run this phase.
+- [ ] Grafana dashboard for the new sources renders.
+
+---
+
+## 18. Reference Decisions (FAQ for the executing agent)
+
+**Q: Why not use Celery?**
+A: For v1, NATS-as-RPC is simpler (one less service, less moving parts). Celery is reserved for Phase 8 if worker fan-out demands it.
+
+**Q: Why SSE not WebSocket?**
+A: SSE is one-way (all we need), auto-reconnects in browsers, works through Caddy without upgrade dance, simpler server code.
+
+**Q: Why Rust for the scheduler at all - why not Python end-to-end?**
+A: The operator asked for "fastest." Rust's async scheduler comfortably handles hundreds of concurrent source polls at <1% CPU. Python's GIL would bottleneck. The split is principled: Rust for orchestration/detection/storage/notify (hot path), Python for browser-automation-heavy scraping (mature ecosystem).
+
+**Q: Why TimescaleDB?**
+A: Price + source-health are time-series. TimescaleDB gives us compression + continuous aggregates without adding InfluxDB.
+
+**Q: Why ntfy not Pushover/Bark?**
+A: Self-hosted, free, APNs-backed iPhone push, supports action buttons. Pushover costs $5 once (fine) but is third-party. Bark is iOS-only and simpler but ntfy is cross-platform and richer.
+
+**Q: Where do I get artist/team IDs for watchlist?**
+A: Use the explore endpoint (Meilisearch) to resolve names → IDs. Watchlist stores patterns (regex/keyword), not IDs - more resilient to source churn.
+
+**Q: How do I add a new source?**
+A: 1) Add row to `sources` migration (or call POST /api/sources if you build that). 2) Implement `SourceAdapter` (Rust, if API-based) or `BaseScraper` (Python, if scrape-based). 3) Register in the appropriate registry. 4) Restart backend / scrapers.
+
+**Q: The operator mentioned iPhone - what about Android?**
+A: ntfy is cross-platform. Same topic works on Android's ntfy app, no extra code.
+
+**Q: What if a source gets me IP-banned?**
+A: Circuit breaker trips after 10 failures, source goes `down`, operator is alerted. Recovery is manual (investigate, possibly enable proxy pool).
+
+**Q: How is "shock drop" defined technically?**
+A: A `drop.new` where `on_sale_at` is within ±5 minutes of `now()` (i.e. the sale was not pre-announced with a future timestamp, or the announcement came via Twitter/X monitor with no prior listing).
+
+---
+
+## 19. File-by-File Starter Notes (for the executing agent)
+
+For each file below, the agent should produce a working implementation, not a stub. If a file is marked `v2`, skip it in Phase 1.
+
+### 19.1 Rust backend - minimum viable implementations per file
+
+- `backend/Cargo.toml`: deps = `tokio (full)`, `axum`, `hyper`, `tower`, `tower-http` (cors, trace, compression), `reqwest` (json, gzip, brotli, rustls-tls), `rquest` (for impersonation, optional Phase 3), `sqlx` (postgres, runtime-tokio-rustls, macros, migrate, chrono, json), `async-nats`, `redis`, `serde` (derive), `serde_json`, `chrono` (serde), `uuid`, `sha2`, `hex`, `tracing`, `tracing-subscriber` (env-filter, json), `opentelemetry-otlp` (optional), `thiserror`, `async-trait`, `validator` (derive), `figment` (env, toml), `anyhow` (bin only).
+- `backend/src/main.rs`: init tracing, load config, build AppState, run migrations, spawn scheduler, start Axum server on 0.0.0.0:8080, await shutdown signal.
+- `backend/src/config.rs`: struct `Config` with all env vars; `figment::Jail` for testing; fail-fast on missing required.
+- `backend/src/api/sse.rs`: `axum::response::sse::Sse` over a `BroadcastStream<ServerEvent>`; serialize as `event: {type}\ndata: {json}\n\n`.
+- `backend/src/sources/ticketmaster.rs`: implement `SourceAdapter` for `TicketmasterAdapter`. Use `?` and `SourceError::Network`/`Parse`/`RateLimited`. Walk pagination.
+
+### 19.2 Python scrapers
+
+- `scrapers/pyproject.toml`: deps = `httpx`, `curl_cffi`, `selectolax`, `beautifulsoup4`, `playwright`, `playwright-stealth`, `async-nats`, `pydantic` (v2), `tenacity`, `python-dotenv`, `orjson`, `structlog`.
+- `scrapers/worker.py`: `asyncio.run(main())`; subscribe to `scrape.>`; per-message dispatch to registered scraper; publish reply with `orjson.dumps`.
+- `scrapers/base.py`: `BaseScraper` Protocol + `ScraperRegistry` global.
+- Each `sources/<name>.py`: a class implementing the protocol, registered at import.
+
+### 19.3 Frontend
+
+- `frontend/package.json`: deps = `next@14`, `react@18`, `react-dom@18`, `tailwindcss`, `@tremor/react`, `framer-motion`, `@tanstack/react-query`, `@tanstack/react-virtual`, `zustand`, `react-hook-form`, `zod`, `@hookform/resolvers`, `lucide-react`, `clsx`, `tailwind-merge`, `cmdk`, `sonner` (toasts), `date-fns`. DevDeps: `typescript`, `eslint`, `eslint-config-next`, `prettier`, `prettier-plugin-tailwindcss`, `vitest`, `@testing-library/react`, `@playwright/test`.
+- `frontend/app/layout.tsx`: Geist fonts (or Inter + JetBrains Mono via `next/font`), ThemeProvider, TanStackQueryProvider, Toaster, Sidebar+Topbar shell.
+- `frontend/lib/types.ts`: mirror Rust models as TS types - keep in sync manually.
+- `frontend/components/dashboard/DropCard.tsx`: Framer Motion `motion.div` with `initial={{opacity:0, y:-20}} animate={{opacity:1, y:0}}`. Accent ring flashes for 2s on mount via keyframe variant.
+
+### 19.4 Infra
+
+- `infra/Caddyfile`: one site block, `reverse_proxy` for `/api/*`, `/ntfy/*`, and the rest. Auto-HTTPS on real domains, internal CA for `.lan`.
+- `docker-compose.yml`: see §13. Add `restart: unless-stopped` to every service. Healthchecks on Postgres/Redis/NATS/Meilisearch before backend starts.
+
+---
+
+## 20. Final notes to the executing agent
+
+1. **Do not deviate from this spec.** If you believe something is wrong, stop and ask - do not silently substitute.
+2. **Implement Phase 1 first, end to end, before touching Phase 2.** A thin working slice beats a wide unfinished one.
+3. **Every phase must run via `docker compose up`.** No "works on my machine" host dependencies.
+4. **Commit often** - conventional commits, one logical change per commit.
+5. **Update SPEC.md** if a decision changes during implementation - keep this doc as the living source of truth.
+6. **The operator is disabled and depends on this working.** Reliability > features. A working Phase 1 they can rely on is worth more than a half-built Phase 6.