# The 20-Minute Hero — Plan Naprawczy Stacku

> Wygenerowany na podstawie analizy trzech modeli AI: Gemini 3 Flash, Gemini 3.1 Pro, GPT-5.4 Pro.
> Data: 2026-03-10

Problemy pogrupowane według priorytetu. **P0 = blokuje production**, **P1 = wysokie ryzyko**, **P2 = ważne**, **P3 = nice-to-have**.

---

## P0 — Krytyczne (napraw przed pierwszym userem)

### P0.1 — Photo verification nie może być wyrocznią AI

**Problem:** Gemini confidence score nie jest kalibrowaną prawdopodobnością. Threshold 0.95
to liczba estetyczna, nie empiryczna. AI klasyfikuje *podobieństwo narracyjne*, nie *fakt*.

**Naprawka: Policy Engine zamiast single threshold**

Zastąp:
```typescript
// PRZED: jedno wywołanie AI → jeden próg
if (aiResult.confidence >= 0.95) return "auto_complete"
if (aiResult.confidence >= 0.85) return "community_vote"
return "reject"
```

Zrób:
```typescript
// PO: policy engine z wieloma sygnałami
interface VerificationSignals {
  ai_visual_match:      number   // 0-1, Gemini confidence
  exif_gps_match:       boolean  // GPS z EXIF vs quest location (<500m)
  exif_time_match:      boolean  // timestamp w oknie questa
  upload_freshness_s:   number   // sekundy od created_at questa do upload
  account_age_days:     number
  account_quest_count:  number
  perceptual_hash_new:  boolean  // czy hash zdjęcia widziany wcześniej?
  device_trusted:       boolean  // Play Integrity / App Attest
  media_is_generated:   boolean  // detekcja AI-generated image
}

function computeVerdict(s: VerificationSignals): VerificationVerdict {
  // Natychmiastowe odrzucenie przy czerwonych flagach
  if (!s.perceptual_hash_new)  return { verdict: "reject", reason: "duplicate_media" }
  if (s.media_is_generated)    return { verdict: "reject", reason: "ai_generated_media" }
  if (s.upload_freshness_s > 7200) return { verdict: "reject", reason: "too_old" }

  // Score ważony
  const score =
    s.ai_visual_match     * 0.35 +
    (s.exif_gps_match ? 1 : 0)   * 0.20 +
    (s.exif_time_match ? 1 : 0)  * 0.15 +
    (s.device_trusted ? 1 : 0)   * 0.15 +
    (s.account_age_days > 7 ? 1 : 0.3) * 0.10 +
    (s.account_quest_count > 3 ? 1 : 0.5) * 0.05

  if (score >= 0.80) return { verdict: "auto_complete", score }
  if (score >= 0.55) return { verdict: "community_vote", score }
  return { verdict: "reject", score, reason: "low_confidence" }
}
```

Wagi możesz potem kalibrować na podstawie danych z labelowanych próbek.

**Dodaj challenge-response dla nowych kont (account_age < 7 dni):**
- Quest generuje losowy token (4-cyfrowy kod, kolor, kształt)
- Aplikacja overlay na viewfinderze: "Pokaż kartkę z '4729' na zdjęciu"
- Weryfikacja: Gemini sprawdza czy kod widoczny + zdjęcie pasuje do questa
- Po 10 wykonanych questach → challenge wyłączany

---

### P0.2 — Race condition w XP ledger

**Problem:** `characters.xp_total` (mutable) + `xp_ledger` (immutable) to dwa źródła prawdy.
Przy concurrent XP award → race condition → podwójne naliczenie.

**Naprawka: Jedna transakcja + idempotency key**

```sql
-- Dodaj do xp_ledger:
ALTER TABLE xp_ledger ADD COLUMN idempotency_key TEXT UNIQUE;
-- Format: "quest_complete:{quest_id}:{user_id}"
-- Zapobiega podwójnemu naliczeniu przy retry

-- Usuń characters.xp_total jako kolumnę obliczaną
-- Zastąp materialized view (refresh co 5 min lub po INSERT do ledgera)
CREATE MATERIALIZED VIEW character_xp AS
SELECT character_id, SUM(amount) AS xp_total
FROM xp_ledger
GROUP BY character_id;

CREATE UNIQUE INDEX ON character_xp(character_id);
-- Refresh triggerowany przez worker po każdym INSERT do xp_ledger
```

Albo prostszy wariant na MVP: zostaw `xp_total` jako denormalizację, ale:
1. INSERT do `xp_ledger` + UPDATE `xp_total` w **jednej transakcji**
2. `idempotency_key UNIQUE` na `xp_ledger` jako siatka bezpieczeństwa
3. Przy level-up: `characters.level` aktualizowany w tej samej transakcji

```typescript
// services/xp.service.ts
export async function awardXp(params: AwardXpParams): Promise<XPAwardResult> {
  const idempotencyKey = `quest_complete:${params.questId}:${params.userId}`

  return await db.transaction(async (tx) => {
    // Upsert z idempotency — drugi call jest no-op
    const inserted = await tx.insert(xpLedger).values({
      characterId:    params.characterId,
      eventType:      'quest_completed',
      amount:         params.xpAmount,
      idempotencyKey, // UNIQUE constraint → drugi insert rzuci conflict
      questId:        params.questId,
      breakdown:      params.breakdown,
    }).onConflictDoNothing().returning()

    if (inserted.length === 0) {
      // Idempotent — już naliczone
      return { xpEarned: 0, alreadyProcessed: true }
    }

    // Atomiczny update xp_total + level
    const [updated] = await tx
      .update(characters)
      .set({
        xpTotal: sql`xp_total + ${params.xpAmount}`,
        level:   sql`${levelForXp(params.newTotal, params.xpCoefficient)}`,
        updatedAt: new Date(),
      })
      .where(eq(characters.id, params.characterId))
      .returning()

    return { xpEarned: params.xpAmount, newTotal: updated.xpTotal, newLevel: updated.level }
  })
}
```

---

### P0.3 — device_tokens JSONB → osobna tabela

**Problem:** JSONB array na `users` = race conditions przy logowaniu z wielu urządzeń,
brak lifecycle (platform, app_version, last_seen, revoked), trudna migracja.

**Naprawka:**
```sql
CREATE TABLE user_devices (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  fcm_token    TEXT NOT NULL UNIQUE,
  platform     TEXT NOT NULL CHECK (platform IN ('android', 'ios')),
  app_version  TEXT NOT NULL,
  device_model TEXT,
  last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  revoked_at   TIMESTAMPTZ  -- NULL = aktywny
);

CREATE INDEX idx_user_devices_user ON user_devices(user_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_user_devices_token ON user_devices(fcm_token) WHERE revoked_at IS NULL;

-- Usuń z users:
ALTER TABLE users DROP COLUMN device_tokens;
```

Migracja: jednorazowy skrypt rozpakowujący istniejące JSONB do nowej tabeli.

---

### P0.4 — Explicit proof workflow state machine

**Problem:** 7-state ENUM bez jawnych przejść = "zombie proofs", brak audit trail kroków,
trudne debugowanie po stronie supportu.

**Naprawka: tabela kroków workflow zamiast prostego statusu**

```sql
CREATE TABLE proof_workflow_steps (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  proof_id     UUID NOT NULL REFERENCES proofs(id) ON DELETE CASCADE,
  step         TEXT NOT NULL,  -- 'upload_confirmed' | 'exif_checked' | 'ai_verified' |
                               --  'policy_scored' | 'vote_opened' | 'vote_closed' | 'xp_awarded'
  status       TEXT NOT NULL CHECK (status IN ('pending', 'running', 'done', 'failed', 'skipped')),
  input_data   JSONB,          -- co dostał ten krok
  output_data  JSONB,          -- co zwrócił
  error        TEXT,
  started_at   TIMESTAMPTZ,
  finished_at  TIMESTAMPTZ,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_pwf_proof ON proof_workflow_steps(proof_id, step);
```

BullMQ worker per krok, każdy zapisuje swój wynik do tej tabeli.
Jeśli worker crashuje i retry uruchamia go ponownie → sprawdza czy krok już `done` → skip.

---

## P1 — Wysokie ryzyko (napraw przed launch)

### P1.1 — Quest generation latency

**Problem:** GPS + geocoding (Nominatim) + weather (OpenWeatherMap) + Gemini = 3–8s latency.
User czeka na ekranie "Generowanie questa..." — złe UX.

**Naprawka: Optimistic pre-generation**

```
Kiedy user KOŃCZY questa → BullMQ job: generuj następny quest w tle
Kiedy user OTWIERA zakładkę Quests → quest już czeka (99% przypadków)
Jeśli brakuje → fallback na synchroniczne generowanie z loading screen

Cache pre-wygenerowanych questów w Redis per user:
  quest:pre:{userId} → {quest JSON, ttl: 4h}
```

```typescript
// Po zakończeniu questa — non-blocking
questQueue.add('pre-generate', { userId, characterId, lastLat, lastLng }, {
  delay: 2000,  // 2 sekundy po zakończeniu
  attempts: 1,  // nie retry — jeśli fail, user dostanie on-demand
})

// Handler GET /quests/active
async function getOrGenerateQuest(userId: string, lat: number, lng: number) {
  const cached = await redis.get(`quest:pre:${userId}`)
  if (cached) {
    await redis.del(`quest:pre:${userId}`)
    return JSON.parse(cached)
  }
  // Fallback synchroniczny
  return await generateQuestSync(userId, lat, lng)
}
```

**Nominatim rate limit** — dodaj geohash-level cache w Redis (TTL 24h):
```typescript
const cacheKey = `geo:${encodeGeohash(lat, lng, 5)}`  // ~5km grid
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
// ... call Nominatim ...
await redis.setex(cacheKey, 86400, JSON.stringify(result))
```

---

### P1.2 — GPS anti-spoofing

**Problem:** EXIF GPS łatwo edytowalny. Android Developer Options dają mock location w 3 kliknięciach.

**Naprawka: warstwowa obrona**

**Warstwa 1 — Device attestation (Expo Dev Build wymagany):**
```typescript
// Wymaga: expo-device + @react-native-google-signin/google-signin + native module

// Android: Google Play Integrity API
const integrityToken = await PlayIntegrity.requestIntegrityVerification()
// Backend weryfikuje token → czy urządzenie jest prawdziwe, niezrootowane, app jest oryginalna

// iOS: Apple App Attest
const attestation = await AppAttest.generateAssertion(challenge)
```

**Warstwa 2 — Behavioral plausibility (backend, bez native module):**
```typescript
// Przy każdym quest generate / proof submit — sprawdź:
async function checkLocationPlausibility(userId: string, lat: number, lng: number) {
  const lastKnown = await redis.get(`user:last_loc:${userId}`)
  if (!lastKnown) return { plausible: true }

  const { lat: lastLat, lng: lastLng, timestamp } = JSON.parse(lastKnown)
  const distanceKm = haversine(lastLat, lastLng, lat, lng)
  const deltaHours = (Date.now() - timestamp) / 3600000
  const maxSpeedKmh = 150  // samolot = ~900, auto = ~150, podejrzane > 150

  if (distanceKm / deltaHours > maxSpeedKmh) {
    await fraudService.flag(userId, 'impossible_travel', { distanceKm, deltaHours })
    return { plausible: false, reason: 'impossible_travel' }
  }
  return { plausible: true }
}
```

**Warstwa 3 — Perceptual hash + duplicate detection:**
```typescript
// W proof.worker.ts — przed AI verification:
import { imageHash } from 'image-hash'  // lub sharp + własny dhash

const hash = await computePerceptualHash(s3Key)
const existing = await db.select().from(proofHashes)
  .where(sql`hamming_distance(hash_bits, ${hash}) < 10`)  // próg podobieństwa
  .limit(1)

if (existing.length > 0) {
  return { verdict: 'reject', reason: 'duplicate_or_similar_media', matchedProofId: existing[0].proofId }
}

await db.insert(proofHashes).values({ proofId: proof.id, hashBits: hash })
```

---

### P1.3 — Auth uproszczenie

**Problem:** Firebase ID token (1h) + własny access token (15min) + własny refresh token (30d) = trzy tokeny, dwie powierzchnie błędów.

**Naprawka: Usuń własny access token**

```
PRZED:
  Firebase ID token → POST /auth/firebase → własny access JWT (15min) + refresh JWT (30d)
  Każdy request: własny access JWT → middleware → userId

PO:
  Firebase ID token → weryfikacja inline w middleware (offline JWKS cache)
  Własny refresh token (30d) tylko do jednego celu: długa sesja bez re-logowania przez Firebase

  Middleware:
    1. Sprawdź Authorization: Bearer {token}
    2. Jeśli token to Firebase ID JWT → zweryfikuj JWKS → userId
    3. Jeśli token to własny session token → zweryfikuj własny secret → userId
    Oba dają identyczne `c.set('userId', ...)` — reszta aplikacji nie wie różnicy
```

```typescript
// middleware/auth.ts
export const requireAuth = createMiddleware(async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token) throw new UnauthorizedError('Brak tokenu')

  let userId: string

  if (isFirebaseToken(token)) {
    // Weryfikacja offline przez firebase-admin (JWKS cache)
    const decoded = await firebaseAdmin.auth().verifyIdToken(token)
    userId = await userRepo.getByFirebaseUid(decoded.uid)
  } else {
    // Własny session token (refresh-based)
    const payload = await jwtVerify(token, JWT_SECRET)
    userId = payload.sub as string
  }

  c.set('userId', userId)
  await next()
})
```

---

### P1.4 — Voting cold start + sybil protection

**Problem:** Na początku nikt nie głosuje → proofs w limbo. Zorganizowane grupy mogą wzajemnie głosować.

**Naprawka 1: Auto-resolve timeout**
```typescript
// W game_config:
voting: {
  min_votes_to_resolve: 3,
  max_wait_hours: 24,
  // Po 24h bez wystarczającej liczby głosów → AI verdict jako tiebreaker
  timeout_fallback: "ai_verdict"
}
```

**Naprawka 2: Reviewer assignment (anty-sybil)**
```typescript
// Głosujący NIE wybiera co ocenia — system przypisuje
// Kryteria wykluczenia:
async function assignReviewers(proofId: string): Promise<string[]> {
  const proof = await proofRepo.get(proofId)

  return await db.execute(sql`
    SELECT u.id FROM users u
    JOIN characters c ON c.user_id = u.id
    WHERE u.id != ${proof.userId}
      -- Nie followerzy autora (mogą być stronniczy)
      AND u.id NOT IN (
        SELECT follower_id FROM follows WHERE following_id = ${proof.userId}
      )
      -- Nie osoby w tej samej grupie
      AND u.id NOT IN (
        SELECT gm.user_id FROM group_members gm
        WHERE gm.group_id IN (
          SELECT group_id FROM group_members WHERE user_id = ${proof.userId}
        )
      )
      -- Minimalne konto (anty-sybil)
      AND c.xp_total > 200
      AND u.created_at < NOW() - INTERVAL '3 days'
    ORDER BY RANDOM()
    LIMIT 5
  `)
}
```

**Naprawka 3: Reviewer reputation score**
```sql
-- Dodaj do users:
ALTER TABLE users ADD COLUMN reviewer_accuracy FLOAT DEFAULT 0.5;
-- Aktualizowany po każdym rozstrzygnięciu głosowania:
-- Twój głos = consensus? → accuracy += 0.01
-- Twój głos ≠ consensus? → accuracy -= 0.02
-- Głosy od accuracy > 0.7 mają wagę 2x przy agregacji
```

---

## P2 — Ważne (napraw w ciągu pierwszego miesiąca)

### P2.1 — Onboarding skrócenie

**Problem:** 6–9 tur przed pierwszym questa = 70%+ drop-off.

**Naprawka: 2-turowy fast-track + opcjonalny depth**

```
Tura 0: Aldric pyta 1 pytanie łączące wszystko:
  "Masz 20 minut. Wychodzisz z domu. Co najprawdopodobniej zrobisz?"
  [A] Idę się przejść i fotografuję co ciekawe
  [B] Szukam małej przygody — coś odkryć, komuś pomóc
  [C] Siedzę gdzieś z kawą i obserwuję ludzi / piszę
  [D] Trenuję, biegnę, ćwiczę

Tura 1: Jedno follow-up pytanie wyjaśniające klasę jeśli odpowiedź niejednoznaczna

Wynik: Aldric przypisuje wstępną klasę + informuje gracza
  "Na podstawie tego co powiedziałeś — wyglądasz na [Widmo-Biegacza].
   Możesz zmienić klasę ręcznie lub przekonaj mnie inaczej — masz jeszcze 3 minuty."

Jeśli gracz chce więcej → opcjonalny "Głębszy wywiad" (dodatkowe 4 tury)
Jeśli gracz pomija → klasa przypisana, gracz może zmienić w ustawieniach postaci do 3 razy
```

---

### P2.2 — Retention mechanics — minimum viable

Na MVP wystarczy jedna z trzech poniższych (wybierz):

**Opcja A: Dzienne wyzwanie (najprostsze)**
- Jeden specjalny quest per dzień per klasa, generowany o północy
- Wyższe XP (2x base), specjalny achievement "Wyzwanie #47 ukończone"
- Streak counter na tym wyzwaniu osobno od zwykłych questów

**Opcja B: Ścieżki tematyczne (średnio trudne)**
- Seria 5 powiązanych questów narracyjnie ("Rozdział 1: Pierwsze kroki w Gildii")
- Po ukończeniu całości: unikalna nagroda kosmetyczna + lore unlock
- Już jest `quest_paths` w API — tylko wypełnić contentem

**Opcja C: Globalna mapa aktywności (trudniejsze, ale viral)**
- Mapa wszystkich ukończonych questów w mieście (anonimizowane GPS)
- "17 osób wykonało questa w tym parku w tym tygodniu"
- Efekt social proof + motywacja do eksploracji nowych obszarów

---

### P2.3 — Safety policy engine dla quest generation

**Problem:** AI może wysłać gracza w niebezpieczne miejsce. Odpowiedzialność prawna.

**Naprawka: post-generation filter**

```typescript
// Po wygenerowaniu questa przez Gemini — przed zapisem do DB:
const safetyCheck = await validateQuestSafety(questData, locationContext)
if (!safetyCheck.safe) {
  // Regeneruj — max 2 próby, potem fallback na statyczny template
  logger.warn({ questData, violation: safetyCheck.violation }, 'Quest failed safety check')
  return await regenerateOrFallback(context)
}

async function validateQuestSafety(quest: QuestData, location: LocationContext): Promise<SafetyResult> {
  const violations = []

  // Słowa kluczowe absolutnie zakazane
  const BANNED_PATTERNS = [
    /wejd[zź] (na|do) (budynek|posesj|prywat)/i,  // wchodzenie na prywatny teren
    /nocna? (wyprawa|misja|aktywność)/i,             // nocne wyjście
    /podejd[zź] do (obcego|nieznajomego)/i,          // kontakt z nieznajomymi
    /przekrocz|przełaź|przeskocz (ogrodzenie|płot|barierk)/i,
    /wejd[zź] do wody|skocz (do|w) (rzek|jezior|staw)/i,
  ]

  for (const pattern of BANNED_PATTERNS) {
    if (pattern.test(quest.objective) || pattern.test(quest.description)) {
      violations.push({ type: 'banned_pattern', pattern: pattern.source })
    }
  }

  // Lokalizacje wysokiego ryzyka
  if (['industrial', 'waterfront'].includes(location.locationType)) {
    if (quest.difficulty === 'legendary') {
      violations.push({ type: 'risky_location_hard_quest' })
    }
  }

  return { safe: violations.length === 0, violations }
}
```

---

### P2.4 — Obserwability minimum

Bez tego nie wiesz co się dzieje na produkcji.

```typescript
// Metryki które MUSISZ mieć od dnia 1:

// 1. Proof funnel
metrics.increment('proof.submitted')
metrics.increment(`proof.verdict.${verdict}`)         // auto_complete | community_vote | reject
metrics.histogram('proof.ai_confidence', confidence)
metrics.histogram('proof.time_to_verdict_ms', elapsed)

// 2. Quest funnel
metrics.increment('quest.generated')
metrics.increment('quest.accepted')
metrics.increment('quest.completed')
metrics.increment('quest.abandoned')
metrics.increment('quest.expired')
metrics.histogram('quest.completion_time_min', minutes)

// 3. AI costs
metrics.increment('gemini.calls', { model: 'flash', task: 'quest_generation' })
metrics.increment('gemini.input_tokens', inputTokens, { task })
metrics.increment('gemini.output_tokens', outputTokens, { task })

// 4. Errors
metrics.increment('error', { type: errorType, endpoint })

// Stack: Prometheus + Grafana (Docker Compose już masz w sekcji 21)
// Albo prościej na MVP: PostHog (event-level analytics, SaaS, darmowy tier)
```

---

### P2.5 — GDPR minimum viable

```typescript
// 1. DELETE /api/users/me — już jest, ale upewnij się że usuwa:
async function deleteUser(userId: string) {
  await db.transaction(async (tx) => {
    // Anonimizacja PII
    await tx.update(users).set({
      email:       null,
      displayName: 'Usunięty Gracz',
      photoUrl:    null,
      deletedAt:   new Date(),
    }).where(eq(users.id, userId))

    // Anonimizacja GPS (nie usuwamy — potrzebne do statystyk gry)
    await tx.update(users).set({ lastLocation: null }).where(eq(users.id, userId))

    // Unieważnienie tokenów
    await tx.delete(refreshTokens).where(eq(refreshTokens.userId, userId))
    await tx.update(userDevices).set({ revokedAt: new Date() }).where(eq(userDevices.userId, userId))

    // Proofy: usuń media z S3, ale zostaw metadane (XP history, voting history)
    const proofList = await tx.select({ s3Key: proofs.s3Key }).from(proofs)
      .where(eq(proofs.userId, userId))
    await Promise.all(proofList.map(p => s3Service.delete(p.s3Key)))
    await tx.update(proofs).set({ s3Key: null, visibility: 'private' })
      .where(eq(proofs.userId, userId))
  })
}

// 2. Retencja GPS — nie przechowuj historii lokalizacji
// users.last_location: tylko OSTATNIA znana (już jest ✓)
// NIE loguj lat/lng do structured logs — tylko geohash (niezidentyfikowalny)

// 3. Media retention policy w S3 lifecycle:
// proofs-raw/* → delete after 90 days (po weryfikacji niepotrzebne)
// proofs-public/* → keep indefinitely (użytkownik może usunąć przez DELETE /api/users/me)
```

---

## P3 — Nice-to-have (po launch)

### P3.1 — CDN dla mediów

Przed S3 dodaj CloudFront lub Cloudflare:
- Presigned URLs → przez CDN (nie bezpośrednio S3)
- Cache dla awatarów (rzadko się zmieniają)
- Image transformations na żądanie (thumbnails, WebP conversion)

### P3.2 — Drizzle → raw SQL dla geospatial

Wydziel plik `packages/db/src/geo-queries.ts` z funkcjami używającymi `db.execute(sql`...`)`.
ORM dla CRUD, raw SQL dla:
- `findNearby()` — `ST_DWithin`
- `awardLeaderboard()` — rankingi z windowingiem
- `feedTrending()` — score z ważonym czasem
- Fraud analytics queries

### P3.3 — Hot-reload config → Redis Pub/Sub

Aktualny Redis TTL 5min = zmiany config propagują się do 5 minut.
Dla krytycznych zmian (rate limit exploit, safety fix):

```typescript
// Przy PATCH /api/admin/config/:key:
await redis.del(`cfg:${key}`)              // natychmiastowa inwalidacja lokalnego cache
await redis.publish('config:invalidate', key)  // broadcast do wszystkich instancji API

// Przy starcie każdej instancji:
const sub = redis.duplicate()
sub.subscribe('config:invalidate')
sub.on('message', (channel, key) => {
  configCache.delete(key)  // local in-memory cache flush
})
```

### P3.4 — PostgreSQL ENUM → text + constraint

```sql
-- Docelowo (migracja przy spokojniejszym czasie):
ALTER TABLE quests
  ALTER COLUMN status TYPE TEXT,
  ADD CONSTRAINT quest_status_check
    CHECK (status IN ('available','accepted','in_progress','proof_submitted','completed','failed','expired'));

-- Dodanie nowego statusu = ALTER TABLE ADD CHECK VALUE, bez ALTER TYPE + vacuum
```

---

## Kolejność implementacji (sugerowana)

```
Tydzień 1:
  ✓ P0.2 — idempotency key w xp_ledger (1h)
  ✓ P0.3 — migracja device_tokens → user_devices (2h)
  ✓ P0.4 — tabela proof_workflow_steps (3h)
  ✓ P1.3 — auth uproszczenie (4h)

Tydzień 2:
  ✓ P0.1 — policy engine dla verification (6h)
  ✓ P1.1 — pre-generation questów w BullMQ (4h)
  ✓ P2.4 — metryki (Prometheus/PostHog) (3h)

Tydzień 3:
  ✓ P1.2 — GPS anti-spoofing (behavior layer + perceptual hash) (8h)
  ✓ P1.4 — voting assignment + reviewer accuracy (6h)
  ✓ P2.3 — safety policy engine (4h)

Po launch:
  ✓ P2.1 — skrócony onboarding
  ✓ P2.2 — jeden retention mechanic (daily quest)
  ✓ P2.5 — GDPR delete pipeline
  ✓ P3.x — CDN, raw SQL, Redis Pub/Sub
```

---

## Co zostaje bez zmian

Poniższe decyzje architektoniczne są **dobre i nie wymagają zmiany**:

- ✓ Hono v4 — właściwy wybór dla tego projektu
- ✓ Drizzle ORM + migracje drizzle-kit — DX jest dobry
- ✓ BullMQ — właściwy dla worker architecture
- ✓ PostgreSQL + PostGIS — właściwa baza
- ✓ Turborepo monorepo — dobra struktura
- ✓ Gemini Flash dla onboardingu i quest generation — dobre
- ✓ game_config hot-reload z Redis cache — wzorcowe live-ops
- ✓ Immutable xp_ledger + token_ledger — poprawna decyzja
- ✓ Presigned S3 upload — właściwy pattern
- ✓ RFC 7807 error format — dobry standard
- ✓ Cursor pagination — właściwe dla feedów
- ✓ Expo React Native — właściwy wybór dla MVP
