● Zion Boggan
repos/Drophawk/CONTRACTS.md
zionboggan.com ↗
2032 lines · markdown
History for this file →
1
# DropHawk - Binding Contracts (Addendum to SPEC.md)
2
 
3
> **Status:** BINDING. Where this file and SPEC.md disagree, **this file wins**.
4
> **Purpose:** Close every ambiguity in SPEC.md. Pin every version. Specify every schema byte. No settling.
5
> **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.
6
 
7
This document corrects the original spec to current upstream versions (verified June 2026):
8
- **Next.js 16.2.9** (not 14) - App Router, Server Components, React 19
9
- **Tailwind CSS 4.3.1** (not v3) - CSS-first config via `@theme`, **no `tailwind.config.ts`**
10
- **React 19.2** - Server Actions, `use()` hook, `useFormState`
11
- **Grafana 13.0.2**, **NATS 2.14.2**, **Caddy 2.11.4**, **Axum 0.8.9**, **Meilisearch 1.47.0**
12
 
13
---
14
 
15
## §A. Pinned Versions (exact - do not bump without re-running CI matrix)
16
 
17
### A.1 Backend (Rust) - `backend/Cargo.toml`
18
```toml
19
[package]
20
name = "drophawk-backend"
21
version = "0.1.0"
22
edition = "2021"
23
rust-version = "1.96"
24
 
25
[dependencies]
26
tokio = { version = "=1.45.1", features = ["full", "parking_lot", "tracing"] }
27
axum = { version = "=0.8.4", features = ["macros", "tokio", "ws"] }
28
tower = "=0.5.2"
29
tower-http = { version = "=0.6.6", features = ["cors", "trace", "compression-br", "compression-gzip", "timeout", "request-id"] }
30
hyper = { version = "=1.6.0", features = ["full"] }
31
reqwest = { version = "=0.12.22", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls", "http2", "stream"] }
32
rquest = { version = "=5.0.0", features = ["impersonate", "json"] }   # TLS fingerprinting, Phase 3+
33
sqlx = { version = "=0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "chrono", "json", "ipnetwork"] }
34
async-nats = "=0.41.0"
35
redis = { version = "=0.32.3", features = ["tokio-rustls-comp", "connection-manager"] }
36
serde = { version = "=1.0.219", features = ["derive"] }
37
serde_json = { version = "=1.0.140", features = ["preserve_key_order", "raw_value"] }
38
chrono = { version = "=0.4.41", features = ["serde", "clock"], default-features = false }
39
uuid = { version = "=1.17.0", features = ["v7", "serde"] }
40
sha2 = "=0.10.9"
41
hex = "=0.4.3"
42
tracing = "=0.1.41"
43
tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "json", "registry"] }
44
opentelemetry = "=0.29.0"
45
opentelemetry-otlp = "=0.29.0"
46
opentelemetry_sdk = { version = "=0.29.0", features = ["rt-tokio"] }
47
thiserror = "=2.0.12"
48
async-trait = "=0.1.88"
49
validator = { version = "=0.20.0", features = ["derive"] }
50
figment = { version = "=0.10.19", features = ["env", "toml"] }
51
anyhow = "=1.0.98"        # binary entry only
52
prometheus = "=0.14.0"
53
once_cell = "=1.21.3"
54
parking_lot = "=0.12.3"
55
futures = "=0.3.31"
56
bytes = "=1.10.1"
57
mime = "=0.3.17"
58
url = { version = "=2.5.4", features = ["serde"] }
59
 
60
[dev-dependencies]
61
wiremock = "=0.6.3"
62
tokio-test = "=0.4.4"
63
pretty_assertions = "=1.4.1"
64
insta = "=1.43.1"
65
```
66
 
67
**Build profile:**
68
```toml
69
[profile.release]
70
lto = "fat"
71
codegen-units = 1
72
strip = "symbols"
73
panic = "abort"
74
opt-level = 3
75
```
76
 
77
### A.2 Python scrapers - `scrapers/pyproject.toml`
78
```toml
79
[project]
80
name = "drophawk-scrapers"
81
version = "0.1.0"
82
requires-python = "==3.11.*"
83
 
84
[tool.uv]
85
dev-dependencies = [
86
    "pytest==8.4.1",
87
    "pytest-asyncio==0.26.0",
88
    "pytest-cov==6.2.1",
89
    "vcrpy==7.0.0",
90
    "respx==0.22.0",
91
    "ruff==0.12.0",
92
    "mypy==1.16.0",
93
]
94
 
95
dependencies = [
96
    "httpx==0.28.1",
97
    "curl-cffi==0.11.1",
98
    "selectolax==0.3.29",
99
    "beautifulsoup4==4.13.4",
100
    "playwright==1.61.0",
101
    "playwright-stealth==1.0.6",
102
    "async-nats==0.41.0",
103
    "pydantic==2.11.7",
104
    "tenacity==9.1.2",
105
    "python-dotenv==1.1.0",
106
    "orjson==3.11.0",
107
    "structlog==25.4.0",
108
    "fake-useragent==2.2.0",
109
    "cssselect==1.3.0",
110
]
111
```
112
 
113
**Lockfile:** Commit `uv.lock`. CI runs `uv sync --frozen`.
114
 
115
### A.3 Frontend - `frontend/package.json`
116
```json
117
{
118
  "name": "drophawk-frontend",
119
  "private": true,
120
  "type": "module",
121
  "engines": { "node": ">=20.9.0", "pnpm": ">=9.0.0" },
122
  "packageManager": "pnpm@9.18.2",
123
  "scripts": {
124
    "dev": "next dev --turbopack",
125
    "build": "next build",
126
    "start": "next start -H 0.0.0.0 -p 3000",
127
    "lint": "next lint",
128
    "typecheck": "tsc --noEmit",
129
    "test": "vitest run",
130
    "test:e2e": "playwright test",
131
    "format": "prettier --write ."
132
  },
133
  "dependencies": {
134
    "next": "16.2.9",
135
    "react": "19.2.0",
136
    "react-dom": "19.2.0",
137
    "@tanstack/react-query": "5.101.0",
138
    "@tanstack/react-virtual": "3.13.12",
139
    "zustand": "5.0.8",
140
    "react-hook-form": "7.62.0",
141
    "@hookform/resolvers": "3.10.0",
142
    "zod": "3.25.76",
143
    "framer-motion": "12.40.0",
144
    "lucide-react": "0.475.0",
145
    "cmdk": "1.1.1",
146
    "sonner": "1.7.4",
147
    "date-fns": "4.1.0",
148
    "clsx": "2.1.1",
149
    "tailwind-merge": "3.3.1",
150
    "class-variance-authority": "0.7.1",
151
    "@radix-ui/react-dialog": "1.1.17",
152
    "@radix-ui/react-dropdown-menu": "2.1.16",
153
    "@radix-ui/react-popover": "1.1.16",
154
    "@radix-ui/react-select": "2.1.16",
155
    "@radix-ui/react-switch": "1.1.16",
156
    "@radix-ui/react-tabs": "1.1.16",
157
    "@radix-ui/react-tooltip": "1.1.17",
158
    "@radix-ui/react-toast": "1.2.16",
159
    "@radix-ui/react-label": "2.1.14",
160
    "@radix-ui/react-slot": "1.3.0",
161
    "recharts": "3.2.1"
162
  },
163
  "devDependencies": {
164
    "@types/node": "22.19.0",
165
    "@types/react": "19.2.0",
166
    "@types/react-dom": "19.2.0",
167
    "typescript": "5.9.2",
168
    "tailwindcss": "4.3.1",
169
    "@tailwindcss/postcss": "4.3.1",
170
    "postcss": "8.5.6",
171
    "autoprefixer": "10.4.21",
172
    "prettier": "3.6.2",
173
    "prettier-plugin-tailwindcss": "0.7.1",
174
    "eslint": "9.32.0",
175
    "eslint-config-next": "16.2.9",
176
    "@eslint/js": "9.32.0",
177
    "typescript-eslint": "8.38.0",
178
    "vitest": "3.2.4",
179
    "@vitejs/plugin-react": "5.0.0",
180
    "@testing-library/react": "16.3.0",
181
    "@testing-library/jest-dom": "6.6.3",
182
    "@playwright/test": "1.61.0"
183
  }
184
}
185
```
186
 
187
> **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.
188
 
189
### A.4 Infrastructure - `docker-compose.yml` image tags
190
```yaml
191
postgres:        postgres:16.10-bookworm       # with timescaledb-tsl via build
192
redis:           redis:7.4.3-alpine3.21
193
nats:            nats:2.14.2-alpine3.21        # with -js flag for JetStream
194
meilisearch:     getmeili/meilisearch:v1.47.0
195
ntfy:            binwiederhier/ntfy:v2.14.0
196
minio:           minio/minio:RELEASE.2026-01-15T01-00-00Z
197
prometheus:      prom/prometheus:v3.4.1
198
grafana:         grafana/grafana-oss:13.0.2
199
loki:            grafana/loki:3.5.5
200
caddy:           caddy:2.11.4-alpine
201
```
202
 
203
### A.5 Tools
204
- Rust: `1.96.0` via `rustup toolchain install 1.96.0`
205
- Node: `20.20.2` (LTS Iron, already installed) - frontend requires `>=20.9.0` ✓
206
- pnpm: `9.18.2` (install via `corepack enable && corepack prepare pnpm@9.18.2 --activate`)
207
- Python: `3.11.2` (already installed)
208
- uv: `0.7.0` (install via `curl -LsSf https://astral.sh/uv/0.7.0/install.sh | sh`)
209
- Docker: `>=24.0` on homelab host
210
- Docker Compose: `v2.30+` (plugin form)
211
 
212
---
213
 
214
## §B. Exact API Contracts
215
 
216
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.
217
 
218
### B.1 Response envelope
219
 
220
**Success (single resource):**
221
```json
222
{ "data": { /* resource */ }, "meta": { "request_id": "uuid-v7", "version": "v1" } }
223
```
224
 
225
**Success (collection):**
226
```json
227
{
228
  "data": [ /* resources */ ],
229
  "meta": {
230
    "request_id": "01HXY...",
231
    "version": "v1",
232
    "page": 1,
233
    "per_page": 50,
234
    "total": 1234,
235
    "total_pages": 25
236
  }
237
}
238
```
239
 
240
**Error:**
241
```json
242
{
243
  "error": {
244
    "code": "SOURCE_NOT_FOUND",
245
    "message": "No source with id 'stubhub'",
246
    "request_id": "01HXY...",
247
    "details": { /* optional, schema-specific */ }
248
  }
249
}
250
```
251
 
252
### B.2 Error code → HTTP status table (complete)
253
 
254
| Code | HTTP | When |
255
|---|---|---|
256
| `BAD_REQUEST` | 400 | Malformed JSON, missing required field |
257
| `VALIDATION_FAILED` | 422 | Zod-equivalent schema fail; `details.fields[]` lists errors |
258
| `UNAUTHORIZED` | 401 | Missing/invalid session token (Phase 7+ auth) |
259
| `FORBIDDEN` | 403 | Authenticated but not allowed |
260
| `NOT_FOUND` | 404 | Resource id does not exist |
261
| `CONFLICT` | 409 | Watchlist rule duplicate |
262
| `RATE_LIMITED` | 429 | Client rate limited (per IP, 100 req/min) |
263
| `INTERNAL` | 500 | Unhandled; full trace in logs only, never response |
264
| `UPSTREAM_UNAVAILABLE` | 503 | DB/NATS/Redis unreachable |
265
| `NOT_IMPLEMENTED` | 501 | v2+ endpoint called in v1 build |
266
 
267
### B.3 Endpoints - full contracts
268
 
269
#### `GET /api/events`
270
Query params (all optional):
271
| Param | Type | Default | Notes |
272
|---|---|---|---|
273
| `page` | int ≥1 | 1 | |
274
| `per_page` | int 1-100 | 50 | |
275
| `category` | enum | - | `concert,sports,festival,theater,other` (comma list) |
276
| `sport` | enum | - | `nfl,nba,nhl,mlb,tennis,f1,soccer,other` |
277
| `source_id` | enum | - | comma list |
278
| `status` | enum | - | `announced,presale,onsale,soldout,cancelled` |
279
| `price_min_usd_cents` | int | - | filters `price_min >=` |
280
| `price_max_usd_cents` | int | - | filters `price_max <=` |
281
| `starts_after` | RFC3339 | - | `starts_at >=` |
282
| `starts_before` | RFC3339 | - | `starts_at <=` |
283
| `on_sale_after` | RFC3339 | - | `on_sale_at >=` |
284
| `q` | string | - | trigram search on title |
285
| `sort` | enum | `on_sale` | `on_sale,-starts_at,price_min,-first_seen_at` |
286
 
287
Response `data[]` element:
288
```json
289
{
290
  "id": 12345,
291
  "source_id": "ticketmaster",
292
  "source_event_id": "Z1p2q3r4s5t6",
293
  "title": "Taylor Swift | The Eras Tour",
294
  "subtitle": "with Paramore",
295
  "category": "concert",
296
  "sport": null,
297
  "starts_at": "2026-08-12T23:59:00Z",
298
  "venue_name": "Hard Rock Stadium",
299
  "city": "Miami Gardens",
300
  "country": "US",
301
  "latitude": 25.958,
302
  "longitude": -80.239,
303
  "image_url": "https://.../eras.jpg",
304
  "on_sale_at": "2026-06-22T15:00:00Z",
305
  "presale_on_sale_at": "2026-06-19T15:00:00Z",
306
  "status": "onsale",
307
  "price_min": 4950,
308
  "price_max": 89900,
309
  "currency": "USD",
310
  "inventory_remaining": 15234,
311
  "buy_url": "https://www.ticketmaster.com/event/Z1p2q3r4s5t6",
312
  "first_seen_at": "2026-06-15T10:00:00Z",
313
  "updated_at": "2026-06-22T15:00:03Z"
314
}
315
```
316
 
317
#### `GET /api/events/:id`
318
Single event + `price_history[]` (last 30 days, max 1000 points) + `cross_source_links[]` (see §Q).
319
 
320
#### `GET /api/drops`
321
Query params: `page`, `per_page`, `drop_type` (`new,status,price,inventory,announcement`), `category`, `source_id`, `since` (RFC3339), `until`. Sort fixed `detected_at DESC`.
322
 
323
Response `data[]` element:
324
```json
325
{
326
  "id": 987654,
327
  "event_id": 12345,
328
  "drop_type": "status",
329
  "from_state": { "status": "announced" },
330
  "to_state":   { "status": "onsale" },
331
  "detected_at": "2026-06-22T15:00:01.234Z",
332
  "notified": true,
333
  "notified_at": "2026-06-22T15:00:02.891Z",
334
  "event": { /* embedded Event, same shape as B.3 */ }
335
}
336
```
337
 
338
#### `GET /api/drops/stream` (SSE)
339
Response headers:
340
```
341
Content-Type: text/event-stream; charset=utf-8
342
Cache-Control: no-cache, no-transform
343
Connection: keep-alive
344
X-Accel-Buffering: no        # disable proxy buffering
345
```
346
 
347
Wire protocol - each event:
348
```
349
event: drop.status
350
id: 987654
351
data: {"id":987654,"event_id":12345,"drop_type":"status","detected_at":"2026-06-22T15:00:01.234Z","event":{...}}
352
 
353
```
354
 
355
(trailing blank line mandatory)
356
 
357
Heartbeat every 15s:
358
```
359
: heartbeat 1719064800
360
 
361
```
362
 
363
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.
364
 
365
Event types (complete enum): `drop.new`, `drop.status`, `drop.price`, `drop.inventory`, `drop.announcement`, `source.health` (status changes), `system.info`.
366
 
367
#### `GET /api/watchlist`
368
Response `data[]`:
369
```json
370
{
371
  "id": 12,
372
  "kind": "artist",
373
  "pattern": "(?i)\\btaylor\\s+swift\\b",
374
  "label": "Taylor Swift",
375
  "active": true,
376
  "priority": "critical",
377
  "matches_30d": 47,
378
  "created_at": "2026-06-01T00:00:00Z"
379
}
380
```
381
 
382
#### `POST /api/watchlist`
383
Request:
384
```json
385
{
386
  "kind": "artist",
387
  "pattern": "(?i)\\btaylor\\s+swift\\b",
388
  "label": "Taylor Swift",
389
  "priority": "critical"
390
}
391
```
392
- `kind` ∈ {`artist`, `team`, `venue`, `sport`, `category`, `keyword`, `twitter_account`}
393
- `pattern` is a Rust regex (`regex` crate syntax). Server validates by compiling it; 422 on failure.
394
- `priority` ∈ {`low`, `normal`, `high`, `critical`}, default `normal`.
395
- 409 if `(kind, pattern)` already exists.
396
 
397
#### `PATCH /api/watchlist/:id`
398
Partial update: `active`, `priority`, `label`. Cannot change `kind` or `pattern` (delete and recreate).
399
 
400
#### `DELETE /api/watchlist/:id` → 204 No Content
401
 
402
#### `POST /api/watchlist/bulk`
403
Request: `{ "rules": [{ "kind":"keyword","label":"Coldplay" }, ...] }`
404
For each rule, server auto-generates pattern as `(?i)\b<escaped_label>\b`. Returns `{ "data": { "created": N, "skipped_duplicates": M } }`.
405
 
406
#### `GET /api/sources`
407
Response `data[]`:
408
```json
409
{
410
  "id": "ticketmaster",
411
  "name": "Ticketmaster Discovery API",
412
  "category": "primary",
413
  "enabled": true,
414
  "status": "healthy",
415
  "last_poll_at": "2026-06-22T15:05:01Z",
416
  "last_success_at": "2026-06-22T15:05:01Z",
417
  "last_error": null,
418
  "consecutive_failures": 0,
419
  "poll_interval_s": 300,
420
  "tight_interval_s": 10,
421
  "latency_ms_p50": 142,
422
  "latency_ms_p95": 380,
423
  "events_seen_24h": 1842
424
}
425
```
426
`status` ∈ {`healthy`, `degraded`, `down`, `disabled`}.
427
 
428
#### `GET /api/sources/:id`
429
One source + timeseries `health[]` (last 24h, 5-min buckets) for sparkline.
430
 
431
#### `PATCH /api/sources/:id`
432
Body: `{ "enabled": true }`. Toggles polling.
433
 
434
#### `GET /api/stats`
435
Response:
436
```json
437
{
438
  "drops_24h": 87,
439
  "drops_7d": 612,
440
  "sources_healthy": 14,
441
  "sources_total": 18,
442
  "sources_down": 1,
443
  "watchlist_matches_24h": 23,
444
  "detection_latency_ms_p50": 412,
445
  "detection_latency_ms_p99": 8200,
446
  "events_tracked": 28471,
447
  "events_onsale_now": 318
448
}
449
```
450
 
451
#### `GET /api/explore?q=`
452
Proxy to Meilisearch `POST /indexes/events/search` with `{ "q": "<q>", "limit": 25 }`. Return matches with highlight.
453
 
454
#### `GET /api/health` → `200 {"status":"ok"}`
455
#### `GET /api/ready` → 200 only if Postgres + NATS + Redis all reachable; else 503.
456
 
457
#### `POST /api/notifications/test`
458
Triggers a test push to ntfy. Returns `{ "data": { "sent": true, "topic": "drophawk-drops" } }`.
459
 
460
---
461
 
462
## §C. Exact Polling Algorithm
463
 
464
Per-source task pseudocode (Rust). This is the canonical scheduler loop. Implement exactly.
465
 
466
```rust
467
// pseudo-Rust, every detail matters
468
async fn run_source_loop(adapter: Arc<dyn SourceAdapter>, state: Arc<AppState>) {
469
    let mut backoff = ExponentialBackoff::new(Duration::from_secs(2), Duration::from_secs(600));
470
    let mut last_known_drops_within_window: Vec<DateTime<Utc>> = vec![];
471
 
472
    loop {
473
        // 1. Compute next interval using adapter hint
474
        let now = Utc::now();
475
        let ctx = PollContext { now, /* ... */ };
476
        let base_interval = adapter.next_poll_hint(&ctx);
477
        let interval = backoff.next_or(base_interval);
478
 
479
        // 2. Acquire rate-limit token from Redis (block until available)
480
        state.cache.acquire_token(&format!("rl:{}", adapter.id())).await;
481
 
482
        // 3. Poll
483
        let t0 = Instant::now();
484
        let result = adapter.poll(&ctx).await;
485
        let latency = t0.elapsed();
486
 
487
        match result {
488
            Ok(candidates) => {
489
                backoff.reset();
490
 
491
                // 4. Persist source_health row (TimescaleDB)
492
                state.store.record_source_health(SourceHealth {
493
                    source_id: adapter.id().into(),
494
                    observed_at: now,
495
                    ok: true,
496
                    latency_ms: latency.as_millis() as i32,
497
                    events_returned: candidates.len() as i32,
498
                    error: None,
499
                }).await;
500
 
501
                // 5. Detect + emit (see §D, §E)
502
                for candidate in candidates {
503
                    detect::ingest_candidate(&state, &candidate).await;
504
                }
505
 
506
                // 6. Update sources table
507
                state.store.mark_source_success(adapter.id(), now).await;
508
 
509
                // 7. Reset circuit breaker
510
                state.metrics.source_poll_total
511
                    .with_label_values(&[adapter.id(), "ok"]).inc();
512
            }
513
            Err(SourceError::RateLimited { retry_after }) => {
514
                // Honor Retry-After, even if it overrides base interval
515
                backoff.force_retry_after(retry_after.unwrap_or(Duration::from_secs(60)));
516
                state.store.mark_source_failure(adapter.id(), "rate_limited").await;
517
                state.metrics.source_poll_total
518
                    .with_label_values(&[adapter.id(), "rate_limited"]).inc();
519
            }
520
            Err(e) => {
521
                let failures = state.store.mark_source_failure(adapter.id(), &e.to_string()).await;
522
                state.metrics.source_poll_total
523
                    .with_label_values(&[adapter.id(), "error"]).inc();
524
                // Circuit breaker: 3 → degraded, 10 → down + alert
525
                if failures == 3 {
526
                    state.store.set_source_status(adapter.id(), "degraded").await;
527
                    state.bus.publish("source.health", SourceHealthEvent::degraded(adapter.id()));
528
                } else if failures == 10 {
529
                    state.store.set_source_status(adapter.id(), "down").await;
530
                    state.bus.publish("source.health", SourceHealthEvent::down(adapter.id()));
531
                    notify::push_critical(&state, format!("Source {} is DOWN", adapter.name())).await;
532
                }
533
                tokio::time::sleep(backoff.next()).await;
534
                continue;
535
            }
536
        }
537
 
538
        tokio::time::sleep(interval).await;
539
    }
540
}
541
```
542
 
543
`ExponentialBackoff`:
544
- start: 2s, factor: 2, max: 600s, jitter: ±15% (use `rand` crate `SmallRng` seeded from `Instant::now()`)
545
- `force_retry_after(d)` overrides next sleep to exactly `d` (no jitter).
546
 
547
### C.1 Adaptive interval rule (`next_poll_hint` default impl)
548
 
549
```rust
550
fn next_poll_hint(&self, ctx: &PollContext) -> Duration {
551
    // Find nearest known on_sale_at for this source within ±24h
552
    let nearest = ctx.upcoming_drops_for(self.id())
553
        .min_by_key(|d| (d - ctx.now()).abs());
554
 
555
    match nearest {
556
        Some(t) if (t - ctx.now()).num_seconds().abs() <= 900 => Duration::from_secs(10),
557
        Some(t) if (t - ctx.now()).num_seconds().abs() <= 3600 => Duration::from_secs(30),
558
        Some(t) if (t - ctx.now()).num_seconds().abs() <= 14400 => Duration::from_secs(60),
559
        _ => Duration::from_secs(300),
560
    }
561
}
562
```
563
 
564
Distance-to-drop → interval:
565
| Distance | Interval |
566
|---|---|
567
| ≤ 15 min | **10 s** |
568
| ≤ 1 h | 30 s |
569
| ≤ 4 h | 60 s |
570
| else | 5 min |
571
 
572
### C.2 Fast-path shock drop detection (Phase 6+)
573
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).
574
 
575
---
576
 
577
## §D. Hash Canonicalization (exact bytes)
578
 
579
The `content_hash` is SHA-256 over the canonical JSON of a **fixed field set** in **alphabetical key order** with **specific normalizations**. Implement exactly.
580
 
581
### D.1 Field set (these and ONLY these participate in hash)
582
1. `source_event_id` (string, trimmed)
583
2. `status` (lowercase enum string)
584
3. `on_sale_at` (RFC3339 UTC, millisecond precision, `null` if None)
585
4. `presale_on_sale_at` (same format)
586
5. `price_min` (int cents, `null` if None)
587
6. `price_max` (int cents, `null` if None)
588
7. `inventory_remaining` (int, `null` if None)
589
 
590
### D.2 Normalization rules
591
- Datetime → format as `YYYY-MM-DDTHH:MM:SS.sssZ` (always 3 ms digits, always `Z`). Strip sub-millisecond precision.
592
- Integers stay as integers, never scientific notation.
593
- None → JSON `null`.
594
- Strings → UTF-8, trimmed of leading/trailing whitespace.
595
- All keys sorted lexicographically (use `BTreeMap<&str, Value>`).
596
- No whitespace between tokens: `serde_json::to_string` with compact formatter.
597
- Output hex lowercase.
598
 
599
### D.3 Reference implementation
600
```rust
601
use serde_json::{json, Value};
602
use sha2::{Digest, Sha256};
603
 
604
pub fn content_hash(c: &EventCandidate) -> String {
605
    let canonical = json!({
606
        "inventory_remaining": c.inventory_remaining.map(|v| v as i64),
607
        "on_sale_at": c.on_sale_at.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)),
608
        "presale_on_sale_at": c.presale_on_sale_at.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true)),
609
        "price_max": c.price_max,
610
        "price_min": c.price_min,
611
        "source_event_id": c.source_event_id.trim(),
612
        "status": c.status.as_str().to_lowercase(),
613
    });
614
    // serde_json::Map preserves insertion order with preserve_key_order feature.
615
    // We built it in alphabetical order above to guarantee.
616
    let bytes = serde_json::to_vec(&canonical).expect("canonical serializable");
617
    let mut hasher = Sha256::new();
618
    hasher.update(&bytes);
619
    hex::encode(hasher.finalize())
620
}
621
```
622
 
623
### D.4 Golden test (must pass)
624
Add `backend/tests/hash_golden.rs` with vectors:
625
```json
626
{"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"}
627
```
628
→ expected hash: `a3f1e8...` (commit the exact hash once the impl is verified; this is the regression gate).
629
 
630
---
631
 
632
## §E. Drop Detection & Classification
633
 
634
On every `EventCandidate` ingest:
635
 
636
```rust
637
async fn ingest_candidate(state: &AppState, c: &EventCandidate) {
638
    let new_hash = content_hash(c);
639
 
640
    // 1. Lookup existing event by (source_id, source_event_id)
641
    let existing = state.store.find_event(c.source_id, &c.source_event_id).await?;
642
 
643
    match existing {
644
        None => {
645
            // NEW EVENT
646
            let event_id = state.store.insert_event(c, &new_hash).await?;
647
            emit_drop(state, DropEvent {
648
                event_id,
649
                drop_type: "new",
650
                from_state: json!(null),
651
                to_state: json!({"status": c.status, "on_sale_at": c.on_sale_at}),
652
            }).await;
653
        }
654
        Some(prev) if prev.content_hash == new_hash => {
655
            // UNCHANGED - no-op, just touch updated_at? No, don't even touch.
656
            return Ok(());
657
        }
658
        Some(prev) => {
659
            // CHANGED - diff fields, emit one or more drops
660
            state.store.update_event(prev.id, c, &new_hash).await?;
661
 
662
            if prev.status != c.status {
663
                emit_drop(state, DropEvent {
664
                    event_id: prev.id,
665
                    drop_type: "status",
666
                    from_state: json!({"status": prev.status}),
667
                    to_state:   json!({"status": c.status}),
668
                }).await;
669
            }
670
            if let (Some(prev_min), Some(new_min)) = (prev.price_min, c.price_min) {
671
                if new_min < prev_min && (prev_min - new_min) * 100 / prev_min.max(1) >= 10 {
672
                    emit_drop(state, DropEvent {
673
                        event_id: prev.id,
674
                        drop_type: "price",
675
                        from_state: json!({"price_min": prev_min}),
676
                        to_state:   json!({"price_min": new_min}),
677
                    }).await;
678
                }
679
            }
680
            if let (Some(prev_inv), Some(new_inv)) = (prev.inventory_remaining, c.inventory_remaining) {
681
                if new_inv < prev_inv && (prev_inv - new_inv) * 100 / prev_inv.max(1) >= 25 {
682
                    emit_drop(state, DropEvent {
683
                        event_id: prev.id,
684
                        drop_type: "inventory",
685
                        from_state: json!({"inventory_remaining": prev_inv}),
686
                        to_state:   json!({"inventory_remaining": new_inv}),
687
                    }).await;
688
                }
689
            }
690
        }
691
    }
692
}
693
```
694
 
695
`emit_drop` writes to `drops` table, publishes to NATS `drop.{type}`, broadcasts via SSE channel, and triggers `notify::on_drop`.
696
 
697
A single candidate change can emit multiple drops (e.g. status + price). That's intentional.
698
 
699
---
700
 
701
## §F. TLS Fingerprint & Header Coherence
702
 
703
### F.1 Sources requiring browser impersonation (use `rquest`)
704
- FIFA portal, AXS, StubHub, Vivid Seats, Twitter/X scrape, festival portals, all per-club soccer adapters.
705
 
706
### F.2 Impersonation target rotation
707
Use `rquest::Impersonate`:
708
- `Chrome131` (default)
709
- `Chrome130`
710
- `Safari18`
711
- `Safari17`
712
- `Firefox133`
713
 
714
**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.
715
 
716
### F.3 Header coherence (CRITICAL)
717
The following must always come from the SAME impersonation set - never mix:
718
- TLS JA3/JA4 (controlled by rquest)
719
- User-Agent
720
- `sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform`
721
- `accept-language`
722
- `accept-encoding`
723
- HTTP/2 settings (controlled by rquest)
724
 
725
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).
726
 
727
### F.4 Proxy rotation (Phase 6+)
728
- Interface: `ProxyPool.pick() -> ProxySpec`.
729
- Default impl: single proxy from env (`PROXY_POOL_URL`), or `Direct` if unset.
730
- Future impls: BrightData, Smartproxy, self-hosted rotating.
731
- 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.
732
 
733
### F.5 robots.txt enforcement
734
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.
735
 
736
---
737
 
738
## §G. Complete Database DDL
739
 
740
Replace §8 of SPEC.md with this complete, runnable SQL.
741
 
742
### G.1 `backend/migrations/20260101000000_extensions.sql`
743
```sql
744
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
745
CREATE EXTENSION IF NOT EXISTS timescaledb;
746
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
747
```
748
 
749
### G.2 `20260101000001_sources.sql`
750
```sql
751
CREATE TABLE sources (
752
    id                   TEXT PRIMARY KEY,
753
    name                 TEXT NOT NULL,
754
    category             TEXT NOT NULL CHECK (category IN ('primary','resale','sports','festival','social')),
755
    enabled              BOOLEAN NOT NULL DEFAULT TRUE,
756
    status               TEXT NOT NULL DEFAULT 'healthy'
757
                         CHECK (status IN ('healthy','degraded','down','disabled')),
758
    poll_interval_s      INTEGER NOT NULL DEFAULT 300 CHECK (poll_interval_s BETWEEN 1 AND 86400),
759
    tight_interval_s     INTEGER NOT NULL DEFAULT 10  CHECK (tight_interval_s BETWEEN 1 AND 3600),
760
    last_poll_at         TIMESTAMPTZ,
761
    last_success_at      TIMESTAMPTZ,
762
    last_error           TEXT,
763
    consecutive_failures INTEGER NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0),
764
    created_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
765
    updated_at           TIMESTAMPTZ NOT NULL DEFAULT NOW()
766
);
767
 
768
CREATE TRIGGER sources_set_updated_at
769
    BEFORE UPDATE ON sources
770
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();
771
```
772
 
773
### G.3 `20260101000002_set_updated_at_fn.sql`
774
```sql
775
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
776
BEGIN
777
    NEW.updated_at := NOW();
778
    RETURN NEW;
779
END;
780
$$ LANGUAGE plpgsql;
781
```
782
 
783
### G.4 `20260101000003_events.sql`
784
```sql
785
CREATE TABLE events (
786
    id                   BIGSERIAL PRIMARY KEY,
787
    source_id            TEXT NOT NULL REFERENCES sources(id) ON DELETE RESTRICT,
788
    source_event_id      TEXT NOT NULL,
789
    title                TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 512),
790
    subtitle             TEXT,
791
    category             TEXT NOT NULL CHECK (category IN ('concert','sports','festival','theater','other')),
792
    sport                TEXT CHECK (sport IS NULL OR sport IN ('nfl','nba','nhl','mlb','tennis','f1','soccer','golf','boxing','other')),
793
    starts_at            TIMESTAMPTZ,
794
    venue_name           TEXT,
795
    city                 TEXT,
796
    country              CHAR(2),                -- ISO 3166-1 alpha-2
797
    latitude             DOUBLE PRECISION CHECK (latitude IS NULL OR (latitude BETWEEN -90 AND 90)),
798
    longitude            DOUBLE PRECISION CHECK (longitude IS NULL OR (longitude BETWEEN -180 AND 180)),
799
    image_url            TEXT,
800
    on_sale_at           TIMESTAMPTZ,
801
    presale_on_sale_at   TIMESTAMPTZ,
802
    status               TEXT NOT NULL CHECK (status IN ('announced','presale','onsale','soldout','cancelled')),
803
    price_min            INTEGER CHECK (price_min IS NULL OR price_min >= 0),
804
    price_max            INTEGER CHECK (price_max IS NULL OR price_max >= 0),
805
    currency             CHAR(3),                -- ISO 4217
806
    inventory_remaining  INTEGER CHECK (inventory_remaining IS NULL OR inventory_remaining >= 0),
807
    buy_url              TEXT NOT NULL,
808
    raw                  JSONB,
809
    content_hash         CHAR(64) NOT NULL,
810
    first_seen_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
811
    updated_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
812
    UNIQUE (source_id, source_event_id),
813
    CONSTRAINT price_range CHECK (price_min IS NULL OR price_max IS NULL OR price_min <= price_max)
814
);
815
 
816
CREATE INDEX idx_events_status           ON events(status);
817
CREATE INDEX idx_events_on_sale          ON events(on_sale_at) WHERE on_sale_at IS NOT NULL;
818
CREATE INDEX idx_events_category         ON events(category);
819
CREATE INDEX idx_events_sport            ON events(sport) WHERE sport IS NOT NULL;
820
CREATE INDEX idx_events_starts_at        ON events(starts_at) WHERE starts_at IS NOT NULL;
821
CREATE INDEX idx_events_source_status    ON events(source_id, status);
822
CREATE INDEX idx_events_updated_at       ON events(updated_at DESC);
823
CREATE INDEX idx_events_title_trgm       ON events USING gin (title gin_trgm_ops);
824
CREATE INDEX idx_events_starts_at_source ON events(starts_at, source_id);
825
 
826
CREATE TRIGGER events_set_updated_at
827
    BEFORE UPDATE ON events
828
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();
829
```
830
 
831
### G.5 `20260101000004_drops.sql`
832
```sql
833
CREATE TABLE drops (
834
    id              BIGSERIAL PRIMARY KEY,
835
    event_id        BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
836
    drop_type       TEXT NOT NULL CHECK (drop_type IN ('new','status','price','inventory','announcement')),
837
    from_state      JSONB,
838
    to_state        JSONB NOT NULL,
839
    detected_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
840
    detected_at_ms  BIGINT GENERATED ALWAYS AS (EXTRACT(EPOCH FROM detected_at) * 1000) STORED,
841
    notified        BOOLEAN NOT NULL DEFAULT FALSE,
842
    notified_at     TIMESTAMPTZ
843
);
844
 
845
CREATE INDEX idx_drops_detected_desc  ON drops(detected_at DESC);
846
CREATE INDEX idx_drops_event          ON drops(event_id);
847
CREATE INDEX idx_drops_type           ON drops(drop_type);
848
CREATE INDEX idx_drops_type_detected  ON drops(drop_type, detected_at DESC);
849
 
850
-- TimescaleDB hypertable (drops grow fast; partition by time)
851
SELECT create_hypertable('drops', 'detected_at', chunk_time_interval => INTERVAL '1 day');
852
```
853
 
854
### G.6 `20260101000005_watchlist.sql`
855
```sql
856
CREATE TABLE watchlist (
857
    id          BIGSERIAL PRIMARY KEY,
858
    kind        TEXT NOT NULL CHECK (kind IN ('artist','team','venue','sport','category','keyword','twitter_account')),
859
    pattern     TEXT NOT NULL,                          -- Rust regex syntax
860
    label       TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 128),
861
    active      BOOLEAN NOT NULL DEFAULT TRUE,
862
    priority    TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','critical')),
863
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
864
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
865
    UNIQUE (kind, pattern)
866
);
867
 
868
CREATE INDEX idx_watchlist_active_kind ON watchlist(active, kind);
869
CREATE TRIGGER watchlist_set_updated_at
870
    BEFORE UPDATE ON watchlist
871
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();
872
```
873
 
874
### G.7 `20260101000006_notifications.sql`
875
```sql
876
CREATE TABLE notifications_log (
877
    id          BIGSERIAL PRIMARY KEY,
878
    drop_id     BIGINT REFERENCES drops(id) ON DELETE SET NULL,
879
    channel     TEXT NOT NULL CHECK (channel IN ('ntfy','telegram','email','browser')),
880
    title       TEXT NOT NULL,
881
    body        TEXT NOT NULL,
882
    priority    TEXT NOT NULL CHECK (priority IN ('min','low','default','high','urgent')),
883
    sent_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
884
    success     BOOLEAN NOT NULL,
885
    response    TEXT,
886
    topic       TEXT
887
);
888
 
889
CREATE INDEX idx_notif_sent_desc ON notifications_log(sent_at DESC);
890
CREATE INDEX idx_notif_drop      ON notifications_log(drop_id);
891
SELECT create_hypertable('notifications_log', 'sent_at', chunk_time_interval => INTERVAL '7 days');
892
```
893
 
894
### G.8 `20260101000007_timeseries.sql`
895
```sql
896
CREATE TABLE price_history (
897
    event_id    BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
898
    source      TEXT NOT NULL,
899
    observed_at TIMESTAMPTZ NOT NULL,
900
    price_min   INTEGER,
901
    price_max   INTEGER,
902
    currency    CHAR(3)
903
);
904
SELECT create_hypertable('price_history', 'observed_at', chunk_time_interval => INTERVAL '7 days');
905
CREATE INDEX idx_price_event_time ON price_history(event_id, observed_at DESC);
906
 
907
CREATE TABLE source_health (
908
    source_id       TEXT NOT NULL,
909
    observed_at     TIMESTAMPTZ NOT NULL,
910
    ok              BOOLEAN NOT NULL,
911
    latency_ms      INTEGER NOT NULL CHECK (latency_ms >= 0),
912
    events_returned INTEGER NOT NULL DEFAULT 0,
913
    error           TEXT
914
);
915
SELECT create_hypertable('source_health', 'observed_at', chunk_time_interval => INTERVAL '1 day');
916
CREATE INDEX idx_health_source_time ON source_health(source_id, observed_at DESC);
917
 
918
-- Continuous aggregates for UI sparklines
919
CREATE MATERIALIZED VIEW source_health_5min
920
WITH (timescaledb.continuous) AS
921
SELECT
922
    source_id,
923
    time_bucket('5 minutes', observed_at) AS bucket,
924
    AVG(latency_ms)    AS latency_ms_p50,
925
    MAX(latency_ms)    AS latency_ms_p95,
926
    COUNT(*) FILTER (WHERE ok)       AS ok_count,
927
    COUNT(*) FILTER (WHERE NOT ok)   AS err_count
928
FROM source_health
929
GROUP BY source_id, bucket
930
WITH NO DATA;
931
 
932
SELECT add_continuous_aggregate_policy('source_health_5min',
933
    start_offset => INTERVAL '1 hour',
934
    end_offset   => INTERVAL '5 minutes',
935
    schedule_interval => INTERVAL '1 minute');
936
```
937
 
938
### G.9 `20260101000008_detection_latency.sql`
939
```sql
940
-- Histogram for the detection_latency_ms metric (SLO §O.3)
941
CREATE TABLE detection_latency (
942
    drop_id    BIGINT PRIMARY KEY REFERENCES drops(id) ON DELETE CASCADE,
943
    latency_ms INTEGER NOT NULL CHECK (latency_ms >= 0)
944
);
945
CREATE INDEX idx_latency ON detection_latency(latency_ms);
946
```
947
 
948
### G.10 `20260101000009_cross_source_links.sql` (see §Q)
949
```sql
950
CREATE TABLE cross_source_links (
951
    cluster_id    BIGSERIAL PRIMARY KEY,
952
    event_id      BIGINT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
953
    confidence    REAL NOT NULL CHECK (confidence BETWEEN 0 AND 1),
954
    match_basis   TEXT NOT NULL,   -- 'date+venue' | 'performer_id' | 'title+date'
955
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
956
    UNIQUE (cluster_id, event_id)
957
);
958
CREATE INDEX idx_xsrc_event     ON cross_source_links(event_id);
959
CREATE INDEX idx_xsrc_cluster   ON cross_source_links(cluster_id);
960
```
961
 
962
### G.11 `20260101000010_seed_sources.sql`
963
```sql
964
INSERT INTO sources (id, name, category, enabled, poll_interval_s, tight_interval_s) VALUES
965
    ('ticketmaster', 'Ticketmaster Discovery API', 'primary', TRUE, 300, 10),
966
    ('seatgeek',     'SeatGeek',                   'primary', TRUE, 300, 10),
967
    ('fifa',         'FIFA World Cup Portal',      'sports',  TRUE,  60, 30),
968
    ('axs',          'AXS',                        'primary', FALSE, 300, 10),
969
    ('seetickets',   'See Tickets',                'primary', FALSE, 300, 10),
970
    ('stubhub',      'StubHub (resale)',           'resale',  FALSE, 600, 30),
971
    ('vividseats',   'Vivid Seats (resale)',       'resale',  FALSE, 600, 30),
972
    ('twitter',      'Twitter/X Shock Drop',       'social',  FALSE, 120, 15),
973
    ('nfl',          'NFL (Ticketmaster Sports)',  'sports',  FALSE, 300, 10),
974
    ('nba',          'NBA',                        'sports',  FALSE, 300, 10),
975
    ('nhl',          'NHL',                        'sports',  FALSE, 300, 10),
976
    ('mlb',          'MLB',                        'sports',  FALSE, 300, 10),
977
    ('atp_wta',      'ATP / WTA Tennis',           'sports',  FALSE, 600, 30),
978
    ('f1',           'Formula 1',                  'sports',  FALSE, 600, 30),
979
    ('uefa',         'UEFA',                       'sports',  FALSE, 600, 30),
980
    ('epl',          'Premier League (Big 6)',     'sports',  FALSE, 600, 30),
981
    ('coachella',    'Coachella',                  'festival',FALSE, 600, 30),
982
    ('glastonbury',  'Glastonbury',                'festival',FALSE, 600, 30),
983
    ('tomorrowland', 'Tomorrowland',               'festival',FALSE, 600, 30),
984
    ('edc',          'EDC',                        'festival',FALSE, 600, 30),
985
    ('lollapalooza', 'Lollapalooza',               'festival',FALSE, 600, 30)
986
ON CONFLICT (id) DO NOTHING;
987
 
988
INSERT INTO watchlist (kind, pattern, label, priority) VALUES
989
    ('keyword', '(?i)\btaylor\s+swift\b',     'Taylor Swift',   'critical'),
990
    ('keyword', '(?i)\bworld\s+cup\b',        'World Cup',      'critical'),
991
    ('keyword', '(?i)\bsuper\s+bowl\b',       'Super Bowl',     'critical'),
992
    ('keyword', '(?i)\bwimbledon\b',          'Wimbledon',      'high'),
993
    ('keyword', '(?i)\bchampions\s+league\s+final\b', 'UCL Final', 'high')
994
ON CONFLICT (kind, pattern) DO NOTHING;
995
```
996
 
997
### G.12 Retention policy
998
```sql
999
-- Drop old data automatically (TimescaleDB)
1000
SELECT add_retention_policy('drops',              INTERVAL '90 days');
1001
SELECT add_retention_policy('price_history',      INTERVAL '1 year');
1002
SELECT add_retention_policy('source_health',      INTERVAL '30 days');
1003
SELECT add_retention_policy('notifications_log',  INTERVAL '90 days');
1004
```
1005
 
1006
### G.13 Migration runner
1007
- sqlx migrate run on startup, before Axum binds.
1008
- Migrations are immutable. Never edit. Always forward-only.
1009
 
1010
---
1011
 
1012
## §H. Design Tokens (exact, OKLCH)
1013
 
1014
### H.1 Tailwind 4 setup - `frontend/app/globals.css`
1015
**Tailwind v4 is CSS-first. There is no `tailwind.config.ts`.** Configuration lives in `@theme` blocks. This is the full theme file.
1016
 
1017
```css
1018
@import "tailwindcss";
1019
 
1020
@theme {
1021
  /* === Color system (OKLCH, dark-first) === */
1022
  /* Backgrounds */
1023
  --color-bg-base:        oklch(0.16 0.012 264);   /* page bg, deep navy-black */
1024
  --color-bg-elevated:    oklch(0.20 0.014 264);   /* card bg */
1025
  --color-bg-overlay:     oklch(0.23 0.014 264);   /* modal/sheet */
1026
  --color-bg-inset:       oklch(0.14 0.012 264);   /* recessed area */
1027
 
1028
  /* Foregrounds */
1029
  --color-fg-primary:     oklch(0.96 0.005 264);
1030
  --color-fg-secondary:   oklch(0.78 0.008 264);
1031
  --color-fg-tertiary:    oklch(0.60 0.010 264);
1032
  --color-fg-disabled:    oklch(0.42 0.008 264);
1033
 
1034
  /* Borders */
1035
  --color-border-subtle:  oklch(0.28 0.014 264 / 0.6);
1036
  --color-border-default: oklch(0.34 0.014 264);
1037
  --color-border-strong:  oklch(0.46 0.014 264);
1038
 
1039
  /* Accents (semantic) */
1040
  --color-accent-teal:    oklch(0.78 0.16 184);    /* primary action */
1041
  --color-accent-teal-hover: oklch(0.72 0.18 184);
1042
  --color-accent-teal-soft:  oklch(0.78 0.16 184 / 0.12);
1043
  --color-accent-amber:   oklch(0.82 0.16 75);     /* warnings */
1044
  --color-accent-red:     oklch(0.66 0.22 25);     /* sold out / error */
1045
  --color-accent-violet:  oklch(0.70 0.18 300);    /* presale */
1046
  --color-accent-green:   oklch(0.74 0.18 145);    /* healthy / on-sale */
1047
  --color-accent-blue:    oklch(0.70 0.16 240);    /* informational */
1048
 
1049
  /* Status pills map */
1050
  --color-status-announced: var(--color-accent-blue);
1051
  --color-status-presale:   var(--color-accent-violet);
1052
  --color-status-onsale:    var(--color-accent-green);
1053
  --color-status-soldout:   var(--color-accent-red);
1054
  --color-status-cancelled: var(--color-fg-tertiary);
1055
 
1056
  /* === Typography === */
1057
  --font-sans: "Inter", "Inter Placeholder", ui-sans-serif, system-ui, sans-serif;
1058
  --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
1059
 
1060
  /* Type scale (1.250 major third) */
1061
  --text-xs:   0.64rem;  --text-xs--line-height:   1rem;
1062
  --text-sm:   0.80rem;  --text-sm--line-height:   1.25rem;
1063
  --text-base: 1.00rem;  --text-base--line-height: 1.5rem;
1064
  --text-lg:   1.25rem;  --text-lg--line-height:   1.75rem;
1065
  --text-xl:   1.56rem;  --text-xl--line-height:   2rem;
1066
  --text-2xl:  1.95rem;  --text-2xl--line-height:  2.25rem;
1067
  --text-3xl:  2.44rem;  --text-3xl--line-height:  2.75rem;
1068
  --text-4xl:  3.05rem;  --text-4xl--line-height:  3.25rem;
1069
 
1070
  /* Letter spacing for headings */
1071
  --tracking-tight: -0.02em;
1072
  --tracking-display: -0.04em;
1073
 
1074
  /* === Spacing scale (4px base) === */
1075
  --spacing: 0.25rem; /* 4px - Tailwind v4 default, do not change */
1076
 
1077
  /* === Radii === */
1078
  --radius-sm: 0.375rem;
1079
  --radius:    0.5rem;
1080
  --radius-md: 0.625rem;
1081
  --radius-lg: 0.875rem;
1082
  --radius-xl: 1.25rem;
1083
 
1084
  /* === Shadows (use sparingly; prefer borders) === */
1085
  --shadow-card: 0 1px 2px 0 oklch(0 0 0 / 0.06), 0 1px 3px 0 oklch(0 0 0 / 0.10);
1086
  --shadow-pop:  0 8px 24px -8px oklch(0 0 0 / 0.40);
1087
  --shadow-glow-teal: 0 0 0 1px var(--color-accent-teal-soft), 0 0 24px -4px var(--color-accent-teal-soft);
1088
 
1089
  /* === Motion === */
1090
  /* Framer Motion springs (exported to TS) */
1091
  --motion-spring-snappy:   cubic-bezier(0.22, 1, 0.36, 1);   /* ease-out-expo */
1092
  --motion-spring-natural:  cubic-bezier(0.16, 1, 0.3, 1);
1093
  --motion-spring-entrance: cubic-bezier(0.34, 1.56, 0.64, 1); /* slight overshoot */
1094
 
1095
  /* Durations */
1096
  --duration-instant: 80ms;
1097
  --duration-fast:    150ms;
1098
  --duration-base:    240ms;
1099
  --duration-slow:    400ms;
1100
  --duration-cinematic: 800ms;
1101
 
1102
  /* === Z-index scale === */
1103
  --z-base:    0;
1104
  --z-sticky:  10;
1105
  --z-dropdown:1000;
1106
  --z-sheet:   2000;
1107
  --z-modal:   3000;
1108
  --z-toast:   4000;
1109
  --z-tooltip: 5000;
1110
}
1111
 
1112
/* === Base layer === */
1113
@layer base {
1114
  html { color-scheme: dark; }
1115
  body {
1116
    background-color: var(--color-bg-base);
1117
    color: var(--color-fg-primary);
1118
    font-family: var(--font-sans);
1119
    font-feature-settings: "cv11", "ss01", "ss03";
1120
    -webkit-font-smoothing: antialiased;
1121
    text-rendering: optimizeLegibility;
1122
  }
1123
 
1124
  /* Numeric data uses tabular figures */
1125
  .tabular { font-variant-numeric: tabular-nums; }
1126
 
1127
  /* Selection */
1128
  ::selection { background-color: var(--color-accent-teal-soft); }
1129
 
1130
  /* Scrollbar (WebKit) */
1131
  ::-webkit-scrollbar { width: 10px; height: 10px; }
1132
  ::-webkit-scrollbar-track { background: var(--color-bg-base); }
1133
  ::-webkit-scrollbar-thumb {
1134
    background: var(--color-border-default);
1135
    border-radius: 999px;
1136
    border: 2px solid var(--color-bg-base);
1137
  }
1138
  ::-webkit-scrollbar-thumb:hover { background: var(--color-border-strong); }
1139
 
1140
  /* Reduced motion */
1141
  @media (prefers-reduced-motion: reduce) {
1142
    *, *::before, *::after {
1143
      animation-duration: 0.01ms !important;
1144
      animation-iteration-count: 1 !important;
1145
      transition-duration: 0.01ms !important;
1146
    }
1147
  }
1148
}
1149
 
1150
/* === Utilities === */
1151
@layer utilities {
1152
  .animate-shimmer {
1153
    background: linear-gradient(90deg,
1154
      transparent 0%,
1155
      var(--color-accent-teal-soft) 50%,
1156
      transparent 100%);
1157
    background-size: 200% 100%;
1158
    animation: shimmer 1.2s ease-out;
1159
  }
1160
  @keyframes shimmer {
1161
    0%   { background-position: 200% 0; }
1162
    100% { background-position: -200% 0; }
1163
  }
1164
 
1165
  .pulse-ring::after {
1166
    content: "";
1167
    position: absolute;
1168
    inset: 0;
1169
    border-radius: inherit;
1170
    box-shadow: 0 0 0 0 var(--color-accent-green);
1171
    animation: pulse-ring 2s var(--motion-spring-natural) infinite;
1172
  }
1173
  @keyframes pulse-ring {
1174
    0%   { box-shadow: 0 0 0 0 oklch(0.74 0.18 145 / 0.6); }
1175
    100% { box-shadow: 0 0 0 12px oklch(0.74 0.18 145 / 0); }
1176
  }
1177
}
1178
```
1179
 
1180
### H.2 Type scale → Tailwind utility mapping
1181
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.
1182
 
1183
### H.3 Framer Motion spring presets (`frontend/lib/motion.ts`)
1184
```ts
1185
export const springs = {
1186
  snappy:    { type: "spring", stiffness: 580, damping: 32, mass: 0.8 },
1187
  natural:   { type: "spring", stiffness: 320, damping: 28, mass: 1.0 },
1188
  entrance:  { type: "spring", stiffness: 240, damping: 18, mass: 1.2 }, // slight overshoot
1189
  sheet:     { type: "spring", stiffness: 280, damping: 34, mass: 1.0 },
1190
  stagger:   { type: "spring", stiffness: 480, damping: 30, mass: 0.6 },
1191
} as const;
1192
 
1193
export const dropCardVariants = {
1194
  initial:   { opacity: 0, y: -16, scale: 0.96 },
1195
  animate:   { opacity: 1, y: 0,   scale: 1, transition: springs.entrance },
1196
  exit:      { opacity: 0, x: 24,  scale: 0.92, transition: { duration: 0.18 } },
1197
} as const;
1198
 
1199
export const flashAccentKeyframes = {
1200
  boxShadow: [
1201
    "0 0 0 0 oklch(0.78 0.16 184 / 0)",
1202
    "0 0 0 4px oklch(0.78 0.16 184 / 0.5)",
1203
    "0 0 0 0 oklch(0.78 0.16 184 / 0)",
1204
  ],
1205
  transition: { duration: 1.6, ease: "easeOut", times: [0, 0.2, 1] },
1206
};
1207
```
1208
 
1209
### H.4 Status pill component (`components/ui/StatusPill.tsx`)
1210
- Padding `px-2.5 py-0.5`, radius `--radius-sm`, font `text-xs font-medium`, tabular nums off.
1211
- Bg = `color-status-{status}` at 12% alpha. Text = same color at 100%.
1212
- Border = same color at 25% alpha.
1213
- Includes leading 6px dot.
1214
- All variants tested against contrast ratio ≥ 4.5:1 against `--color-bg-elevated` (WCAG §U).
1215
 
1216
### H.5 DropCard layout (canonical)
1217
```
1218
┌──────────────────────────────────────────────────────┐
1219
│ [●] On Sale                              3s ago    │   ← status pill + relative time (mono)
1220
├──────────────────────────────────────────────────────┤
1221
│ [IMG 64×64]  Taylor Swift | The Eras Tour         │
1222
│              with Paramore                          │
1223
│              Hard Rock Stadium · Miami Gardens      │
1224
├──────────────────────────────────────────────────────┤
1225
│ $49.50 - $899.00        [TM]    [ Buy → ]          │   ← price + source chip + button
1226
└──────────────────────────────────────────────────────┘
1227
```
1228
- Card padding `p-3.5`, gap between sections `gap-2`.
1229
- New cards: animate in from top with `entrance` spring; flash teal ring for 1.6s.
1230
- Source chip: 24×24 logo or initials; tooltip shows source name.
1231
 
1232
### H.6 Motion budget (do not exceed)
1233
- Max simultaneous animations on dashboard: **40** (use `will-change: transform` sparingly).
1234
- No animation longer than 1.6s except skeleton shimmer (3s loop max).
1235
- Exit animations: ≤200ms.
1236
 
1237
---
1238
 
1239
## §I. ntfy Payload Templates (exact)
1240
 
1241
### I.1 Per drop type - full HTTP request
1242
 
1243
**Status flip (Announced → OnSale), watchlist match:**
1244
```http
1245
POST /drophawk-drops HTTP/1.1
1246
Host: ntfy
1247
Authorization: Bearer tk_xxxxxxxxxxxxxxxxxxxxxxxx
1248
Title: 🎟️ ON SALE NOW: Taylor Swift - Miami
1249
Priority: urgent
1250
Tags: ticket,ticket,concert,ticketmaster
1251
Click: https://www.ticketmaster.com/event/Z1p2q3r4s5t6?drophawk=1
1252
Actions: [{"action":"view","label":"Buy","url":"https://www.ticketmaster.com/event/Z1p2q3r4s5t6?drophawk=1"}]
1253
Cache: no
1254
 
1255
Floor seats from $49.50 · 15,234 remaining
1256
Hard Rock Stadium · Aug 12 2026, 7:00 PM ET
1257
Source: Ticketmaster · detected in 1.2s
1258
```
1259
 
1260
**New event (matching watchlist):**
1261
```
1262
Title: 🆕 NEW: FIFA World Cup 2026 Final - NYC
1263
Priority: high
1264
Tags: soccer,fifa,world_cup
1265
Click: https://tickets.fifa.com/...
1266
```
1267
 
1268
**Price drop ≥25%:**
1269
```
1270
Title: 📉 -32%: Adele - Munich (StubHub)
1271
Priority: high
1272
Tags: arrow_down,concert,stubhub
1273
```
1274
 
1275
**Shock drop (Twitter-triggered, on_sale_at within ±5min of now):**
1276
```
1277
Title: ⚡ SHOCK DROP: Beyoncé added second London show
1278
Priority: urgent
1279
Tags: zap,concert,twitter
1280
```
1281
 
1282
**Test push (`POST /api/notifications/test`):**
1283
```
1284
Title: ✅ DropHawk ntfy test
1285
Priority: default
1286
Tags: white_check_mark
1287
Body: If you can read this, your pipeline works. Detected at {RFC3339 now}.
1288
```
1289
 
1290
### I.2 Priority mapping (LOCKED)
1291
| Drop signal | Watchlist priority | ntfy Priority |
1292
|---|---|---|
1293
| status→onsale | critical | `urgent` |
1294
| status→onsale | high | `high` |
1295
| status→onsale | normal/low | `default` |
1296
| new (watchlist match) | critical | `urgent` |
1297
| new (watchlist match) | high | `high` |
1298
| new (watchlist match) | normal | `default` |
1299
| new (no match) | - | **not sent** (UI only) |
1300
| price ≥25% drop | any | `high` |
1301
| price 10-25% drop | any | `default` |
1302
| inventory ≥25% drop | any | `default` |
1303
| shock drop (Twitter) | any | `urgent` |
1304
 
1305
### I.3 Dedupe rules (anti-spam)
1306
- One ntfy push per `(event_id, drop_type)` per **10 minutes** (Redis key `notif:dedupe:{event_id}:{drop_type}` with TTL 600s).
1307
- Max 5 notifications per minute total (token bucket).
1308
- Max 50 per hour.
1309
- If exceeded: drop quietly, log to `notifications_log` with `success=false, response='rate_limited_self'`.
1310
- Quiet hours (configurable in Settings): default OFF (operator wants to wake up). When on, only `urgent` priority breaks through.
1311
 
1312
### I.4 ntfy server config (`infra/ntfy.server.yml`)
1313
```yaml
1314
base_url: "http://ntfy"
1315
listen: ":80"
1316
cache_file: "/var/lib/ntfy/cache.db"
1317
auth_file: "/var/lib/ntfy/user.db"
1318
auth_default_access: "deny-all"
1319
 
1320
# Behind Caddy already terminates TLS, so internal is HTTP
1321
behind_proxy: true
1322
 
1323
# Attachment limits (we don't use attachments for pushes)
1324
attachment_total_size_limit: "1M"
1325
attachment_file_size_limit: "512K"
1326
 
1327
# Rate limit (per-IP; we don't actually want to limit our own internal publisher,
1328
# so set high)
1329
global_topic_limit: 1000
1330
visitor_request_limit_burst: 100
1331
visitor_request_limit_replenish: "1s"
1332
 
1333
# Enable SMTP? No, not for v1.
1334
```
1335
 
1336
Create admin + publisher tokens at deploy time via `ntfy user add` and `ntfy token create` (one-shot, persisted in MinIO backup bucket for restore).
1337
 
1338
---
1339
 
1340
## §J. Prometheus Metrics Catalog
1341
 
1342
Every metric prefixed `drophawk_`. All histograms use these standard buckets (ms): `[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000]`.
1343
 
1344
### J.1 Counters
1345
| Metric | Labels | When |
1346
|---|---|---|
1347
| `drophawk_source_poll_total` | `source`, `result`(ok/error/rate_limited) | end of every poll |
1348
| `drophawk_drops_total` | `type`, `category`, `source` | every emit_drop |
1349
| `drophawk_notifications_total` | `channel`, `priority`, `result`(ok/failed/skipped) | every notify attempt |
1350
| `drophawk_sse_connections` | - | gauge (current count) |
1351
| `drophawk_events_total` | - | gauge (current row count in events) |
1352
 
1353
### J.2 Histograms
1354
| Metric | Labels | When |
1355
|---|---|---|
1356
| `drophawk_source_poll_duration_ms` | `source` | every poll |
1357
| `drophawk_detection_latency_ms` | `type` | drop → notification |
1358
| `drophawk_notify_latency_ms` | `channel` | notify send → ntfy ack |
1359
| `drophawk_http_request_duration_ms` | `method`, `route`, `status` | every HTTP request |
1360
| `drophawk_scraper_round_trip_ms` | `source` | every NATS RPC |
1361
 
1362
### J.3 Gauges
1363
| Metric | Labels | When |
1364
|---|---|---|
1365
| `drophawk_source_up` | `source`, `status`(healthy/degraded/down) | on status change |
1366
| `drophawk_watchlist_rules_active` | - | on rule change |
1367
| `drophawk_db_connections_in_use` | - | every 5s |
1368
| `drophawk_nats_unprocessed_messages` | `stream`, `consumer` | every 5s |
1369
| `drophawk_redis_ops_per_sec` | - | every 5s |
1370
 
1371
### J.4 SLOs
1372
- **Detection latency p99 ≤ 15s** (alert at p99 > 30s for 5m)
1373
- **Source health: ≥ 90% of enabled sources `healthy`** (alert at <75%)
1374
- **Notification success rate ≥ 99.5%** over 1h (alert at <98%)
1375
- **SSE uptime 99.9%** (alert on 0 connected clients for 5m when backend is up)
1376
 
1377
### J.5 Alertmanager rules (`infra/alerts.yml`)
1378
```yaml
1379
groups:
1380
  - name: drophawk
1381
    rules:
1382
      - alert: DropHawkHighDetectionLatency
1383
        expr: histogram_quantile(0.99, sum(rate(drophawk_detection_latency_ms_bucket[5m])) by (le)) > 30000
1384
        for: 5m
1385
        annotations:
1386
          summary: "p99 detection latency > 30s"
1387
 
1388
      - alert: DropHawkSourcesDown
1389
        expr: count(drophawk_source_up{status="down"} == 1) > 0
1390
        for: 10m
1391
        annotations:
1392
          summary: "{{ $value }} source(s) are DOWN"
1393
 
1394
      - alert: DropHawkNotifyFailures
1395
        expr: rate(drophawk_notifications_total{result="failed"}[5m]) / rate(drophawk_notifications_total[5m]) > 0.02
1396
        for: 10m
1397
        annotations:
1398
          summary: "Notification failure rate > 2%"
1399
 
1400
      - alert: DropHawkPostgresDown
1401
        expr: pg_up == 0
1402
        for: 1m
1403
        labels: { severity: critical }
1404
```
1405
 
1406
---
1407
 
1408
## §K. Error Taxonomy (Rust)
1409
 
1410
```rust
1411
use thiserror::Error;
1412
 
1413
#[derive(Debug, Error)]
1414
pub enum AppError {
1415
    #[error("database error: {0}")]
1416
    Db(#[from] sqlx::Error),
1417
 
1418
    #[error("nats publish error: {0}")]
1419
    Bus(#[from] async_nats::PublishError),
1420
 
1421
    #[error("redis error: {0}")]
1422
    Cache(#[from] redis::RedisError),
1423
 
1424
    #[error("http client error: {0}")]
1425
    Http(#[from] reqwest::Error),
1426
 
1427
    #[error("json error: {0}")]
1428
    Json(#[from] serde_json::Error),
1429
 
1430
    #[error("validation failed: {0}")]
1431
    Validation(String),
1432
 
1433
    #[error("not found: {0}")]
1434
    NotFound(String),
1435
 
1436
    #[error("conflict: {0}")]
1437
    Conflict(String),
1438
 
1439
    #[error("rate limited")]
1440
    RateLimited,
1441
 
1442
    #[error("upstream unavailable: {0}")]
1443
    Upstream(String),
1444
 
1445
    #[error("internal error: {0}")]
1446
    Internal(#[from] anyhow::Error),
1447
}
1448
 
1449
impl AppError {
1450
    pub fn status_code(&self) -> u16 {
1451
        use AppError::*;
1452
        match self {
1453
            Validation(_) => 422,
1454
            NotFound(_)   => 404,
1455
            Conflict(_)   => 409,
1456
            RateLimited   => 429,
1457
            Upstream(_)   => 503,
1458
            _             => 500,
1459
        }
1460
    }
1461
    pub fn code(&self) -> &'static str {
1462
        use AppError::*;
1463
        match self {
1464
            Db(_)        => "DB_ERROR",
1465
            Bus(_)       => "BUS_ERROR",
1466
            Cache(_)     => "CACHE_ERROR",
1467
            Http(_)      => "HTTP_ERROR",
1468
            Json(_)      => "JSON_ERROR",
1469
            Validation(_) => "VALIDATION_FAILED",
1470
            NotFound(_)   => "NOT_FOUND",
1471
            Conflict(_)   => "CONFLICT",
1472
            RateLimited   => "RATE_LIMITED",
1473
            Upstream(_)   => "UPSTREAM_UNAVAILABLE",
1474
            Internal(_)   => "INTERNAL",
1475
        }
1476
    }
1477
}
1478
 
1479
// IntoResponse impl for Axum
1480
impl IntoResponse for AppError {
1481
    fn into_response(self) -> Response {
1482
        let code = self.code();
1483
        let status = StatusCode::from_u16(self.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1484
        let body = Json(json!({ "error": { "code": code, "message": self.to_string() } }));
1485
        (status, body).into_response()
1486
    }
1487
}
1488
 
1489
pub type AppResult<T> = Result<T, AppError>;
1490
```
1491
 
1492
### K.1 Source errors (separate, internal)
1493
```rust
1494
#[derive(Debug, Error)]
1495
pub enum SourceError {
1496
    #[error("network error: {0}")]
1497
    Network(String),
1498
    #[error("parse error: {0}")]
1499
    Parse(String),
1500
    #[error("rate limited; retry after {retry_after:?}")]
1501
    RateLimited { retry_after: Option<Duration> },
1502
    #[error("blocked by source (403/429): {0}")]
1503
    Blocked(String),
1504
    #[error("source returned invalid auth")]
1505
    Auth,
1506
    #[error("upstream error {status}: {body}")]
1507
    Upstream { status: u16, body: String },
1508
}
1509
```
1510
 
1511
---
1512
 
1513
## §L. Backup & Disaster Recovery
1514
 
1515
### L.1 What to back up
1516
- **Postgres** (full dump + WAL archive) - single source of truth.
1517
- **MinIO bucket** (scraped HTML screenshots for debug, can be regenerated).
1518
- **ntfy user.db** (auth tokens - annoying to recreate, do back up).
1519
- **Meilisearch index** - can be rebuilt from Postgres; skip from backups.
1520
- **Configs**: `.env`, `infra/Caddyfile`, `infra/ntfy.server.yml`, dashboards.
1521
 
1522
### L.2 Schedule
1523
- **Postgres nightly** `pg_dump` at 03:00 homelab TZ → `/backups/postgres/drophawk-YYYYMMDD.sql.gz`.
1524
- **WAL archiving** via `pgBackRest` or `wal-g` continuous to MinIO bucket `pg-wal`.
1525
- **MinIO lifecycle**: nightly dumps retained 30 days, weekly dumps 90 days, monthly 1 year.
1526
- **Config bundle**: every git push triggers GitHub Action that tarballs `infra/` and `.env.example` to MinIO.
1527
 
1528
### L.3 Restore runbook
1529
1. `docker compose down`
1530
2. Wipe `pg-data` volume.
1531
3. `docker compose up -d postgres` and wait for healthy.
1532
4. `gunzip -c drophawk-YYYYMMDD.sql.gz | docker compose exec -T postgres psql -U drophawk`
1533
5. Replay WAL since dump (if using continuous archive): `wal-g backup-fetch /var/lib/postgresql/data LATEST && wal-g wal-fetch …`
1534
6. `docker compose up -d` (everything).
1535
7. Verify `/api/ready` returns 200.
1536
8. Verify latest `drops.detected_at` matches expected.
1537
 
1538
**RTO target: 30 minutes. RPO target: 15 minutes** (with WAL archiving) or 24 hours (nightly only).
1539
 
1540
### L.4 Backup verification
1541
- 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`.
1542
 
1543
---
1544
 
1545
## §M. Security Model
1546
 
1547
### M.1 Threat surface
1548
- **Internal network only.** No public exposure. Operator accesses via Tailscale or LAN.
1549
- If public exposure is ever needed, **Caddy must enforce basic auth + IP allowlist**. Not in v1 scope.
1550
 
1551
### M.2 Service-to-service auth
1552
- All internal services on `drophawk-net` Docker network. No mTLS in v1 (trusted network).
1553
- Postgres: single user `drophawk` with random password from `.env`. No app-level users.
1554
- Redis: require `REDIS_PASSWORD`. No ACL subusers.
1555
- NATS: require `NATS_USER` + `NATS_PASSWORD`. Enable JetStream.
1556
- Meilisearch: require `MEILI_MASTER_KEY`. Frontend never talks to Meilisearch directly; always via backend.
1557
- ntfy: ACL-protected. Publisher token has write-only on `drophawk-drops`. iOS subscribe uses read-only token.
1558
 
1559
### M.3 API auth (frontend → backend)
1560
- v1: **no auth** (single user, LAN-only). Caddy IP allowlist optional.
1561
- v7: optional bearer token via `DROPHAWK_API_TOKEN`. Frontend sends `Authorization: Bearer`.
1562
 
1563
### M.4 Secret management
1564
- `.env` is the canonical secret store. **Never committed**. Mode 0600.
1565
- `.env.example` documents every key with placeholder values.
1566
- CI step: `gitleaks detect` runs on every PR. Pre-commit hook rejects if `.env` pattern is staged.
1567
- Backups of `.env` stored in MinIO encrypted bucket, accessible only to operator.
1568
 
1569
### M.5 Input validation
1570
- Every POST/PATCH body validated by Zod (frontend) AND `validator` crate (backend). Never trust client.
1571
- Regex patterns in watchlist are compiled server-side; reject if compilation > 1ms (ReDoS guard). Limit pattern length to 256 chars.
1572
- Query params: integers bounded (e.g. `per_page ≤ 100`), timestamps parsed strictly.
1573
- All URLs returned to user are validated to be `https://` (no `javascript:`).
1574
 
1575
### M.6 Audit log
1576
- Every mutation (POST/PATCH/DELETE on watchlist, PATCH on sources) writes to a `mutation_log` table:
1577
```sql
1578
CREATE TABLE mutation_log (
1579
    id BIGSERIAL PRIMARY KEY,
1580
    action TEXT NOT NULL,         -- 'watchlist.create', 'source.toggle', etc.
1581
    target TEXT,
1582
    actor TEXT,                   -- 'operator' (single user) for now
1583
    payload JSONB,
1584
    at TIMESTAMPTZ NOT NULL DEFAULT NOW()
1585
);
1586
```
1587
 
1588
### M.7 Anti-bot / ethical scraping (reaffirmed)
1589
- Honest UA on API sources: `DropHawk/0.1 (+https://your-homelab/drophawk)`.
1590
- Browser impersonation only on Cloudflare-protected public pages, only for read.
1591
- **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.**
1592
 
1593
---
1594
 
1595
## §N. Testing Strategy
1596
 
1597
### N.1 Pyramid
1598
- **Unit (70%)**: pure functions - hash canonicalization, status inference, price-drop classification, regex compilation.
1599
- **Integration (25%)**: DB access (testcontainers-postgres), NATS round-trip, ntfy mock (wiremock).
1600
- **E2E (5%)**: Playwright through the full stack via `docker compose`.
1601
 
1602
### N.2 Coverage gates
1603
- Rust: `cargo tarpaulin` → **≥ 80% line coverage** on `backend/src/{detect,sources,store,notify}`.
1604
- Python: `pytest --cov=scrapers --cov-fail-under=75`.
1605
- TypeScript: Vitest → **≥ 75% line** on `lib/` and `hooks/`.
1606
 
1607
### N.3 Fixtures
1608
- **Golden JSON**: `backend/tests/fixtures/ticketmaster/sample_event.json` (real Ticketmaster response, sanitized). Hash test vectors locked against this.
1609
- **HTTP replay**: `wiremock` for Rust, `vcrpy` for Python. Cassettes checked in under `tests/cassettes/`.
1610
- **DB snapshot**: tiny Postgres dump with seed data, loaded via testcontainers in `conftest.rs`.
1611
- **SSE fixture**: a recorded stream of 50 drop events for frontend testing.
1612
 
1613
### N.4 Test commands (run all before merge)
1614
```bash
1615
make check              # runs all of below
1616
cargo clippy -- -D warnings
1617
cargo fmt --check
1618
cargo test --all
1619
cd scrapers && uv run pytest
1620
cd frontend && pnpm typecheck && pnpm lint && pnpm test
1621
cd frontend && pnpm test:e2e   # only in CI, needs running stack
1622
```
1623
 
1624
### N.5 Property tests (Rust, `proptest`)
1625
- `content_hash` is deterministic: same input → same hash, across runs.
1626
- `content_hash` differs when ANY participating field differs.
1627
- Hash is stable across serializer versions (golden vectors).
1628
 
1629
### N.6 Load test
1630
- `backend/benches/scheduler.rs`: simulate 50 sources × 1s interval × 1k events/poll → must sustain <60% CPU single core.
1631
- `scripts/loadtest.sh`: 100 concurrent SSE clients, 1000 events/s broadcast → no SSE drops.
1632
 
1633
---
1634
 
1635
## §O. Performance Budgets
1636
 
1637
### O.1 Latency budgets
1638
| Path | p50 | p99 |
1639
|---|---|---|
1640
| Poll → drop detected | 500ms | 2s |
1641
| Drop detected → ntfy sent | 200ms | 1s |
1642
| Drop detected → SSE fanout | 50ms | 250ms |
1643
| HTTP API read | 30ms | 200ms |
1644
| HTTP API write | 80ms | 400ms |
1645
| Frontend TTFB (Next.js SSR) | 100ms | 400ms |
1646
| Frontend TTI | 800ms | 2.5s |
1647
| End-to-end (poll start → phone buzz) | 5s | 15s |
1648
 
1649
### O.2 Memory budgets (Docker container ceilings)
1650
| Service | Limit | Notes |
1651
|---|---|---|
1652
| backend | 512MB | |
1653
| scrapers | 1.5GB | Playwright hungry; cap concurrency to 4 tabs/worker |
1654
| frontend (SSR) | 384MB | |
1655
| postgres | 2GB | `shared_buffers=512MB`, `work_mem=16MB` |
1656
| redis | 256MB | `maxmemory=200mb`, allkeys-lru |
1657
| nats | 256MB | JetStream file storage, memory stream disabled |
1658
| meilisearch | 1GB | |
1659
| ntfy | 64MB | |
1660
| minio | 256MB | |
1661
| prometheus | 512MB | retention 30d |
1662
| grafana | 256MB | |
1663
| loki | 512MB | retention 14d |
1664
 
1665
Total ceiling: ~7.6 GB. Operators with 16 GB homelab have ample headroom.
1666
 
1667
### O.3 SLOs (measured by Prometheus)
1668
- **API availability**: 99.9% over 30 days rolling.
1669
- **API p99 latency** ≤ 500ms on hot paths.
1670
- **SSE reconnect rate** ≤ 0.5/min/client (bad if proxies drop constantly).
1671
 
1672
### O.4 Frontend bundle budget
1673
- Initial JS for `/` route: **≤ 250 KB** gzipped.
1674
- Initial CSS: **≤ 60 KB** gzipped.
1675
- No single image > 200 KB without `next/image` optimization.
1676
 
1677
---
1678
 
1679
## §P. Idempotency Rules
1680
 
1681
### P.1 NATS JetStream deduplication
1682
- 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**).
1683
- Scraper round-trip messages carry `Nats-Msg-Id: scrape-{source_id}-{epoch_ms_floor_1000}` (1s buckets). Idempotent.
1684
 
1685
### P.2 Notification dedup (Redis)
1686
- Key: `notif:dedupe:{event_id}:{drop_type}` (TTL 600s). SETNX. Skip if exists.
1687
- Cross-type guard: `notif:dedupe:{event_id}:any` with TTL 60s - no more than one push per event per minute even across types.
1688
 
1689
### P.3 Drop dedup (DB)
1690
- `drops` has no unique constraint (same event can legitimately emit two status drops minutes apart if status oscillates).
1691
- But the **detection layer** only emits when `content_hash` actually changes. So oscillation is naturally bounded to source's actual polling cadence.
1692
 
1693
### P.4 Insert race (upsert)
1694
- `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.
1695
 
1696
---
1697
 
1698
## §Q. Cross-Source Entity Resolution
1699
 
1700
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.
1701
 
1702
### Q.1 Algorithm (deterministic, runs in `detect::link_event`)
1703
 
1704
For each new/updated `events` row, attempt to link to existing clusters:
1705
 
1706
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`.
1707
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.
1708
3. **Title + date match**: tokenized title similarity (Jaccard ≥ 0.7 on lowercased tokens, ignoring stopwords) + same date ± 1h. Confidence = 0.65.
1709
 
1710
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).
1711
 
1712
### Q.2 When used
1713
- `GET /api/events/:id` returns `cross_source_links[]` so the UI can show "Also on SeatGeek ($X), StubHub ($Y)".
1714
- The buy_url shown in drops is always the canonical source's URL, but the UI surfaces alternatives.
1715
 
1716
### Q.3 Implementation notes
1717
- Run linking **synchronously** during `ingest_candidate` (it's fast - 3 indexed queries).
1718
- Re-run linking when venue names get normalized (write a `venue_normalization` table mapping messy names to canonical - populated manually over time).
1719
 
1720
---
1721
 
1722
## §R. Buy URL Canonicalization
1723
 
1724
### R.1 Rules
1725
- Always `https://`.
1726
- Strip tracking params added by our scrapers (`?utm_*`, `?ref=*`, `?clickId=*`, `?_branch_match_id=*`).
1727
- Preserve source-required params (e.g. Ticketmaster's `event/id` path segment is essential).
1728
- **Add** DropHawk's own ref param if affiliate program is set: `?drophawk=1`. (No affiliate abuse; honest disclosure in UA.)
1729
- Sort query params alphabetically. (Stable URLs.)
1730
- If URL has fragment, preserve.
1731
 
1732
### R.2 Reference implementation
1733
```rust
1734
pub fn canonicalize_buy_url(raw: &str) -> Result<String, AppError> {
1735
    let mut url = url::Url::parse(raw).map_err(|e| AppError::Validation(e.to_string()))?;
1736
    if url.scheme() != "https" {
1737
        return Err(AppError::Validation("buy_url must be https".into()));
1738
    }
1739
    let drop_keys = ["utm_source","utm_medium","utm_campaign","utm_term","utm_content","ref","clickId","_branch_match_id"];
1740
    let keys_to_remove: Vec<String> = url.query_pairs()
1741
        .filter(|(k, _)| drop_keys.contains(&k.as_ref()))
1742
        .map(|(k,_)| k.into_owned())
1743
        .collect();
1744
    for k in keys_to_remove {
1745
        url.query_pairs_mut().remove_pair(&k);
1746
    }
1747
    url.query_pairs_mut().append_pair("drophawk", "1");
1748
    url.query_pairs_mut().clear().extend_pairs(
1749
        url.query_pairs().collect::<std::collections::BTreeMap<_,_>>()
1750
    ); // alphabetical
1751
    if url.query().map(|q| q.is_empty()).unwrap_or(true) {
1752
        url.set_query(None);
1753
    }
1754
    Ok(url.to_string())
1755
}
1756
```
1757
 
1758
### R.3 Per-source overrides
1759
- Ticketmaster: respect `event/<id>` URL format. Affiliate param `tm_link` allowed if operator has affiliate ID (env `TICKETMASTER_AFFILIATE_ID`).
1760
- SeatGeek: same, `aid` param if `SEATGEEK_AFFILIATE_ID` set.
1761
 
1762
---
1763
 
1764
## §S. Mobile / PWA Requirements
1765
 
1766
### S.1 Installability
1767
- `frontend/public/manifest.webmanifest` with name, short_name, theme_color (teal), background_color (bg-base), display `standalone`, scope `/`, start_url `/?source=pwa`.
1768
- Icons: 192×192, 512×512, 512×512 maskable, all PNG. Generated from one SVG via `scripts/generate-pwa-icons.sh`.
1769
- Apple touch icon: `apple-touch-icon.png` 180×180.
1770
- `<meta name="apple-mobile-web-app-capable" content="yes">`.
1771
- `<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`.
1772
 
1773
### S.2 Service worker (`frontend/public/sw.js`)
1774
- Use **Serwist** (modern Workbox alternative, Next 16 compatible).
1775
- Cache strategy:
1776
  - HTML navigations: **NetworkFirst** with 5s timeout, fallback to cached `/`.
1777
  - Static assets (`/_next/static/*`): **CacheFirst** with stale-while-revalidate for fonts.
1778
  - API GETs: **NetworkFirst** (no caching of live data).
1779
  - SSE: bypass SW (browser handles natively).
1780
- Update flow: SW detects new version → shows toast "Update available" → user clicks → SW takes over on next navigation.
1781
 
1782
### S.3 Touch interactions
1783
- All tap targets ≥ 44×44 pt (WCAG 2.5.5).
1784
- Pull-to-refresh on dashboard (use `react-pull-to-refresh` or custom Framer Motion).
1785
- Long-press DropCard → context menu (Pin, Hide, Add to watchlist).
1786
- Swipe-left on DropCard → dismiss.
1787
 
1788
### S.4 Push on PWA
1789
- For v1, push is via **ntfy app** (already in scope). Web Push API integration is Phase 7+.
1790
 
1791
---
1792
 
1793
## §T. Accessibility - WCAG 2.2 AA (binding)
1794
 
1795
### T.1 Perceivable
1796
- 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.
1797
- Non-text contrast (UI components, borders): ≥ 3:1.
1798
- All form inputs have associated `<label>` (Radix `Label`).
1799
- Images have `alt`; decorative images have `alt=""`.
1800
- Status pills: text label AND color. Never color alone.
1801
 
1802
### T.2 Operable
1803
- 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`.
1804
- Skip-to-main link at top of every page.
1805
- All interactive elements reachable in ≤ 6 tabs from page load.
1806
- Cmd+K command palette has `role="dialog"`, `aria-modal`, focus trap, Escape to close.
1807
- No keyboard trap.
1808
 
1809
### T.3 Understandable
1810
- Form validation errors are announced via `aria-live="polite"`.
1811
- Time formats include timezone suffix (e.g. `7:00 PM ET`).
1812
- Relative time component has `title` with absolute timestamp.
1813
 
1814
### T.4 Robust
1815
- Valid HTML5; tested with `axe-core` in Playwright suite. Zero critical or serious violations.
1816
 
1817
### T.5 Motion
1818
- All animations honor `prefers-reduced-motion: reduce` (see globals.css).
1819
 
1820
### T.6 Audit
1821
- Run `axe-playwright` against every route in e2e. Fail CI on new violations.
1822
 
1823
---
1824
 
1825
## §U. i18n
1826
 
1827
### U.1 v1 scope
1828
- English only, but **structure for i18n from day one**.
1829
- All UI strings via `next-intl` (`frontend/messages/en.json`).
1830
- No hardcoded user-facing strings in components.
1831
 
1832
### U.2 Currency
1833
- Each event carries `currency` (ISO 4217). Display via `Intl.NumberFormat` with user's preferred currency (Settings).
1834
- Default user currency: `USD`. Operator can switch to `EUR`, `GBP`, etc.
1835
- Conversion rates: fetched daily from `open.er-api.com` (free), cached in Redis.
1836
 
1837
### U.3 Dates / times
1838
- All times stored UTC. Display in user's local timezone via `Intl.DateTimeFormat` with `timeZone` from `Intl.DateTimeFormat().resolvedOptions().timeZone`.
1839
- Format pattern: `Aug 12 2026, 7:00 PM ET` (date, time, tz abbreviation).
1840
 
1841
---
1842
 
1843
## §V. Image Pipeline
1844
 
1845
### V.1 Source images
1846
- Ticketmaster/SeatGeek provide `image_url`. Cache via Next.js `next/image` with remote pattern allow-list.
1847
- For sources without images (FIFA), use a generated gradient placeholder keyed off `source_id` (deterministic hue).
1848
 
1849
### V.2 Allowed remote patterns (`next.config.mjs`)
1850
```js
1851
images: {
1852
  remotePatterns: [
1853
    { protocol: "https", hostname: "**.ticketmaster.com" },
1854
    { protocol: "https", hostname: "**.seatgeek.com" },
1855
    { protocol: "https", hostname: "cdn.fifa.com" },
1856
    { protocol: "https", hostname: "**. axs.com" },
1857
  ],
1858
  formats: ["image/avif", "image/webp"],
1859
  minimumCacheTTL: 86400,
1860
}
1861
```
1862
 
1863
### V.3 Generated placeholders
1864
- Function `placeholderFor(seed: string): { from: string, to: string }`.
1865
- Hash `seed` → pick hue from a curated 12-color palette.
1866
- CSS gradient `linear-gradient(135deg, hsl1, hsl2)`.
1867
- Cache the gradient in component state; no network.
1868
 
1869
### V.4 Lazy loading
1870
- All `<Image>` below the fold use `loading="lazy"` (default in Next 16 Image).
1871
- DropCard images are 64×64 (tiny); always load eagerly for top-of-feed.
1872
 
1873
---
1874
 
1875
## §W. Failure Mode Runbooks
1876
 
1877
For each service: what fails, what the system does, what the operator does.
1878
 
1879
### W.1 Postgres down
1880
- **Detection**: `/api/ready` 503; `pg_up == 0` alert.
1881
- **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.
1882
- **Operator**: investigate (`docker compose logs postgres`), restore from backup if needed (§L.3), restart. On recovery, JetStream replays unprocessed messages.
1883
 
1884
### W.2 Redis down
1885
- **Detection**: `/api/ready` 503.
1886
- **System behavior**: rate-limit tokens can't be acquired - pollers pause. Notification dedup breaks - temporarily allow duplicate notifications (safer than missing one). Cache misses.
1887
- **Operator**: `docker compose restart redis`. Data is non-critical (cache only).
1888
 
1889
### W.3 NATS down
1890
- **Detection**: backend startup fails `nats.connect`.
1891
- **System behavior**: pollers for scrape-based sources can't RPC → they wait + retry. API-based sources keep working (direct fetch).
1892
- **Operator**: restart NATS. JetStream persists to disk; no data loss.
1893
 
1894
### W.4 Meilisearch down
1895
- **Detection**: `/api/explore` returns 503.
1896
- **System behavior**: search UI disabled; everything else works. Backend falls back to Postgres trigram search (`idx_events_title_trgm`).
1897
- **Operator**: restart. Rebuild index from Postgres via `POST /api/admin/reindex` (Phase 7).
1898
 
1899
### W.5 ntfy down
1900
- **Detection**: `notify::push` returns error; metric counter.
1901
- **System behavior**: notifications fail silently to user. Drops still flow to SSE → UI still works.
1902
- **Operator**: restart ntfy. Missed drops: UI has them; user can scroll back.
1903
 
1904
### W.6 Scraper worker down (Playwright crash)
1905
- **Detection**: source's poll round-trip times out → `SourceError::Network`.
1906
- **System behavior**: 3 timeouts → source `degraded`. 10 → `down` + alert.
1907
- **Operator**: `docker compose restart scrapers`. Source resumes.
1908
 
1909
### W.7 Source blocks us (403/429)
1910
- **Detection**: `SourceError::Blocked`.
1911
- **System behavior**: circuit opens after 3, source → `degraded`. Alert at 10.
1912
- **Operator**: investigate robots.txt change, enable proxy pool (Phase 6), or disable source temporarily.
1913
 
1914
### W.8 Caddy down
1915
- **Detection**: nothing accessible from operator.
1916
- **System behavior**: backend still runs, scrapers still run, but no UI access.
1917
- **Operator**: `docker compose restart caddy`. Caddy auto-HTTPS state in volume.
1918
 
1919
### W.9 Disk full
1920
- **Detection**: Postgres write fails; Loki rejects logs.
1921
- **System behavior**: cascade failure.
1922
- **Operator**: clean old DropHawk data (`SELECT drop_chunks('drops', older_than => '30 days')`), prune Docker images, expand disk.
1923
 
1924
### W.10 Backend OOM
1925
- **Detection**: container restarts; `drophawk_backend_oom_total` metric.
1926
- **System behavior**: brief outage. JetStream replays missed messages on startup.
1927
- **Operator**: investigate memory leak; raise memory limit; file issue.
1928
 
1929
---
1930
 
1931
## §X. CORS Configuration
1932
 
1933
`backend/src/api/mod.rs`:
1934
```rust
1935
let cors = CorsLayer::new()
1936
    .allow_origin(["http://drophawk.lan", "http://localhost:3000"].map(|s| s.parse().unwrap()))
1937
    .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
1938
    .allow_headers([
1939
        header::AUTHORIZATION,
1940
        header::CONTENT_TYPE,
1941
        header::ACCEPT,
1942
    ])
1943
    .allow_credentials(false)
1944
    .max_age(Duration::from_secs(600));
1945
```
1946
SSE handler bypasses CORS layer (handled separately to allow `text/event-stream`).
1947
 
1948
---
1949
 
1950
## §Y. Configuration Surface (env var reference)
1951
 
1952
Complete list. `.env.example` must contain every key below with placeholder values. CI fails if `.env.example` is missing any key.
1953
 
1954
### Y.1 Backend
1955
```
1956
# Required
1957
DATABASE_URL=postgres://drophawk:CHANGE_ME@postgres:5432/drophawk
1958
REDIS_URL=redis://:CHANGE_ME@redis:6379/0
1959
NATS_URL=nats://drophawk:CHANGE_ME@nats:4222
1960
MEILI_URL=http://meilisearch:7700
1961
MEILI_MASTER_KEY=CHANGE_ME
1962
NTFY_BASE_URL=http://ntfy:80
1963
NTFY_TOPIC=drophawk-drops
1964
NTFY_TOKEN=tk_CHANGE_ME
1965
LOG_LEVEL=info                # trace|debug|info|warn|error
1966
OTEL_EXPORTER_OTLP_ENDPOINT=http://loki:4317
1967
PUBLIC_BASE_URL=https://drophawk.lan
1968
 
1969
# Source credentials (optional per source)
1970
TICKETMASTER_API_KEYS=key1,key2,key3
1971
SEATGEEK_CLIENT_ID=
1972
TICKETMASTER_AFFILIATE_ID=
1973
SEATGEEK_AFFILIATE_ID=
1974
 
1975
# Future
1976
PROXY_POOL_URL=
1977
TELEGRAM_BOT_TOKEN=
1978
TWITTER_BEARER_TOKEN=
1979
 
1980
# Operational
1981
API_RATE_LIMIT_PER_MIN=100
1982
SSE_MAX_CLIENTS=200
1983
SCRAPER_RPC_TIMEOUT_S=30
1984
WATCHLIST_MAX_RULES=500
1985
POLL_DEFAULT_INTERVAL_S=300
1986
POLL_TIGHT_INTERVAL_S=10
1987
POLL_TIGHT_WINDOW_S=900
1988
```
1989
 
1990
### Y.2 Frontend (build-time)
1991
```
1992
NEXT_PUBLIC_API_BASE_URL=https://drophawk.lan/api
1993
NEXT_PUBLIC_NTFY_PUBLIC_URL=https://drophawk.lan/ntfy
1994
NEXT_PUBLIC_NTFY_PUBLIC_TOPIC=drophawk-drops
1995
NEXT_PUBLIC_SENTRY_DSN=           # optional
1996
```
1997
 
1998
### Y.3 Python scrapers
1999
```
2000
DATABASE_URL=                    # informational only; not used directly
2001
NATS_URL=nats://drophawk:CHANGE_ME@nats:4222
2002
REDIS_URL=redis://:CHANGE_ME@redis:6379/1
2003
SCRAPER_CONCURRENCY=4
2004
SCRAPER_LOG_LEVEL=INFO
2005
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
2006
FIFA_BASE_URL=https://tickets.fifa.com
2007
AXS_BASE_URL=https://www.axs.com
2008
STUBHUB_API_KEY=
2009
VIVIDSEATS_PROXY=
2010
TWITTER_ACCOUNTS=               # comma list of @handles to watch
2011
```
2012
 
2013
---
2014
 
2015
## §Z. Final Implementation Directives
2016
 
2017
These are the **non-negotiable directives** for any executing agent reading this contract:
2018
 
2019
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.
2020
2. **Run `make check` before declaring any task done.** Zero warnings, zero errors.
2021
3. **Commit per logical unit**, conventional commits, never amend shared branches.
2022
4. **If this contract is silent on a decision, ask the operator.** Do not improvise on security, data loss, or notification correctness.
2023
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.
2024
6. **Test fixtures are golden**. Hash vectors in §D.4 are regression gates.
2025
7. **The OKLCH palette in §H.1 is the entire color system.** Do not introduce ad-hoc hex codes.
2026
8. **Notification correctness is sacred.** False positives erode trust fastest. The dedupe + filter pipeline (§I.3 + §5.5) must be tested exhaustively.
2027
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.
2028
10. **When in doubt, choose reliability over feature richness.** The dashboard showing 5 working sources is better than 25 half-broken ones.
2029
 
2030
---
2031
 
2032
**End of binding contracts. Return to SPEC.md §14 for the phased roadmap and §17 for per-phase verification.**