# Dataplatform — System & Data Rules

Acuan tunggal tentang **cara data masuk, dicocokkan, di-update, dan disajikan** di
Dataplatform. Ditulis supaya developer (dan agent) berperilaku konsisten. Kalau ada
keputusan baru soal data, **update file ini dulu**, baru kode.

> Status per 2026-09-24: codebase baru, belum ada kode. Aturan di bawah adalah aturan
> target yang wajib diikuti sejak commit pertama. Arah produk mengikuti
> [`PRD-DMP-Customer-Engagement-v0.2.md`](PRD-DMP-Customer-Engagement-v0.2.md).

---

## 1. Arsitektur inti

Satu **core Laravel 13**, dipakai oleh tiga "pintu":

```
┌──────────────────────┐  ┌─────────────────┐  ┌──────────────────────────┐
│ Web (React/Inertia)  │  │  REST API        │  │ Public (Experiences,     │
│ session auth         │  │  /api/* token    │  │ tracking) public key     │
└──────────┬───────────┘  └────────┬─────────┘  └────────────┬─────────────┘
           └───────────────┬───────┴─────────────────────────┘
                           ▼
        SHARED CORE: Queries/Services (baca) · Actions (tulis) · Eloquent Models
                           ▼
   PostgreSQL (JSONB, pgvector) · Redis (queue/cache/lock) · S3 storage
                           ▼
        AI layer: Ollama/Qwen (VPS yang sama)  ·  Python/FastAPI (opsional)
```

**Aturan #1 — logika bisnis tinggal di core, bukan di controller atau komponen React.**
- **Baca** → Query/Service class.
- **Tulis** → Action class.
- Inertia controller **dan** API controller hanya pemanggil tipis: validasi input
  (Form Request), panggil action/query, kembalikan response. **Jangan** duplikasi
  logika antara web & API.
- React hanya presentasi. Aturan bisnis, otorisasi, dan kalkulasi tidak boleh hanya
  ada di frontend.

**Aturan #2 — kerja berat lewat queue.** Parsing import, commit, sync connector,
rekalkulasi metrik/segment, pengiriman campaign, dan panggilan AI berjalan sebagai job
di Redis (Horizon), bukan di request HTTP.

---

## 2. Tenancy

Model produk: **satu instalasi = satu client** (PRD §4). Tiap client dapat database,
storage, dan AI context sendiri.

- Tabel bisnis tetap punya kolom `client_id` (satu baris `clients` per instalasi).
  Tujuannya murah: membuka jalan multi-workspace nanti tanpa migrasi besar.
- `client_id` diisi otomatis oleh model trait/global scope dari konteks instalasi,
  **tidak pernah** diterima dari input browser, API body, atau payload publik.
- Cache key, path file, dan log yang memuat data client menyertakan `client_id`.

---

## 3. Otentikasi & otorisasi

| Surface | Auth | Otorisasi |
|---------|------|-----------|
| Web | Session Laravel (cookie `HttpOnly`, `Secure`, `SameSite`) | Role + permission |
| API | Token (Sanctum) | `permission:<area>.<action>` per-route |
| Public | **public key** + **Origin allow-list** | rate limit per key, origin, IP |

- Login email + password, reset password via email. 2FA belum wajib.
- Browser tidak menyimpan token di `localStorage`.
- Secret connector **tidak pernah** dikirim balik ke browser, hanya flag `hasSecret`.
- Setiap aksi dicek di server (Policy/permission). Menyembunyikan tombol di React
  bukan otorisasi.

### Role tetap (PRD §5)

Role bersifat fixed. UI tidak menyediakan custom role builder.

| Role | Batas akses |
|---|---|
| `super_admin` | Semua, termasuk users & permission, AI, system settings, restore, permanent delete |
| `admin` | Operasional + sistem: users (kecuali akun `super_admin`), integrations, data, segment, campaign, analytics, review, soft/permanent delete |
| `spv` | Lihat customer/transaction, segment, campaign (buat, jalankan, pause/stop), analytics, soft delete & restore. **Tanpa** permanent delete |
| `data_analyst` | Data quality, review data ambigu, segment, Audience Copilot, campaign, analytics. **Tanpa** hapus customer, system settings, permission |
| `vip` | Read-only: dashboard, full customer info, transaction, segment, campaign, analytics, report; export kalau diizinkan |

### Permission matrix

Baris bertanda `*` adalah tafsiran dari PRD yang belum eksplisit. Konfirmasi sebelum
diimplementasi.

| Area | Super admin | Admin | SPV | Data analyst | VIP |
|---|---:|---:|---:|---:|---:|
| Dashboard & analytics read | Ya | Ya | Ya | Ya | Ya |
| Customer & transaction read | Ya | Ya | Ya | Ya | Ya |
| Data source / import (connect, mapping, commit) `*` | Ya | Ya | Tidak | Ya | Tidak |
| Customer & transaction create/update `*` | Ya | Ya | Tidak | Ya | Tidak |
| Review queue (identity & conflict) | Ya | Ya | Tidak | Ya | Tidak |
| Soft delete & restore | Ya | Ya | Ya | Tidak | Tidak |
| Permanent delete | Ya | Ya | Tidak | Tidak | Tidak |
| Segment create/update/delete | Ya | Ya | Ya | Ya | Tidak |
| Audience Copilot `*` | Ya | Ya | Ya | Ya | Tidak |
| Campaign create/run/pause/stop | Ya | Ya | Ya | Ya | Tidak |
| Experiences (form, survey, landing) `*` | Ya | Ya | Ya | Ya | Tidak |
| Export | Ya | Ya | Ya | Ya | Kalau diizinkan |
| Integrations / connector | Ya | Ya | Tidak | Tidak | Tidak |
| AI settings & Knowledge Base `*` | Ya | Ya | Tidak | Tidak | Tidak |
| Users & roles | Ya | Ya (kecuali `super_admin`) | Tidak | Tidak | Tidak |
| System settings (timezone, system) `*` | Ya | Read | Tidak | Tidak | Tidak |

### User management

Guard wajib:

- tidak boleh menonaktifkan atau menghapus akun sendiri;
- tidak boleh menonaktifkan, menghapus, atau menurunkan `super_admin` aktif terakhir;
- `admin` tidak boleh membuat, mengubah, atau menghapus akun `super_admin`;
- deactivation adalah aksi default; hard delete hanya kasus khusus;
- user inactive tidak bisa login; session dan token aktifnya dicabut;
- perubahan role, status, dan delete masuk audit log.

---

## 4. Cara data MASUK (staging-first)

Semua source (CSV/Excel, public Google Spreadsheet, connector lain) lewat jalur yang
sama:

```
SOURCE → STAGING (import_rows, belum jadi customer)
       → MAPPING (review user)
       → VALIDATION + NORMALIZATION
       → IDENTITY RESOLUTION
       → COMMIT → customers / transactions
       → METRICS UPDATE → SEGMENT MEMBERSHIP UPDATE
```

- Data mentah **tidak pernah** ditulis langsung ke tabel master.
- File asli disimpan di storage S3 privat; baris staging menyimpan nilai mentah +
  nomor baris asli supaya bisa ditelusuri dan diproses ulang.
- Tipe import (customer / transaction) dipilih user; deteksi otomatis dari kolom
  `order.*` hanya saran.
- Sync connector default **tiap 3 jam** dan **incremental**, bukan proses ulang seluruh
  data.

**Konvensi prefix mapping:**

| Prefix target | Tujuan |
|---------------|--------|
| `name`, `email`, `phone`, field standar lain | kolom **customers** |
| `attributes.*` | `customers.attributes` (JSONB) |
| `order.*` | kolom **transactions** (external_order_id, order_date, amount, status, channel) |
| `order.attributes.*` | `transactions.attributes` (product_name, sku, quantity) |
| `order.snapshot.*` | snapshot customer saat transaksi (nama, alamat kirim, kota kirim) |

Saat commit baris transaksi: **resolve customer** → **persist transaction** →
**hitung ulang metrik customer** (lihat §7).

**Delete import batch:** menghapus batch + staged rows. Customer dan transaction hasil
commit tetap ada; referensi `import_row_id` menjadi `NULL`.

---

## 5. Identitas customer & DEDUP (aturan inti)

**1 real customer = 1 canonical customer profile.** Satu customer bisa punya banyak
identifier di `customer_identifiers` (tipe: `phone` | `email` | `external` |
`composite`). Identifier unik per client.

```
Baris masuk (setelah normalisasi):
  HIGH CONFIDENCE → attach ke customer existing (otomatis)
     telepon cocok  → customer SAMA
     email cocok    → customer SAMA
     external ID (source + id) cocok → customer SAMA
     fallback, HANYA kalau telepon & email kosong:
       nama + kota (+ kecamatan) cocok → customer SAMA
  AMBIGUOUS → REVIEW QUEUE (tidak pernah auto-merge)
     telepon & email menunjuk customer BERBEDA
     kandidat mirip (similarity nama/lokasi, pgvector, atau AI Identity Resolver)
  NO MATCH → customer BARU
```

Aturan composite (nama + lokasi):
- Kunci = `lower(name) | lower(city) | lower(district)`, dari kolom lokasi customer
  (§10). `district` ikut kalau terisi.
- **Guard:** hanya jalan kalau **nama DAN kota dua-duanya terisi**. Provinsi saja
  tidak cukup, karena "Bengkel Jaya" di dua kota berbeda satu provinsi tidak boleh
  kegabung.
- Risiko yang diterima: dua pihak berbeda dengan nama + kota + kecamatan persis sama
  bisa kegabung.

Aturan normalisasi nilai identitas:
- **Placeholder = kosong.** Nilai seperti `invalid`, `invld`, `tidak valid`, `titik tidak ditemukan`, `data tidak valid`,
  `-`, `n/a`, `null` (case-insensitive) diperlakukan sebagai kosong di semua field.
  Untuk telepon, `0` juga placeholder.
- **Telepon harus valid** menurut libphonenumber (region default `ID`), disimpan E.164.
  Nomor yang tidak valid tidak menjadi identifier (nilai mentah tetap tersimpan di
  staging). Satu field boleh berisi beberapa nomor; semua nomor valid menjadi
  identifier, yang pertama menjadi nomor utama.
- **Kolom identitas harus benar-benar unik per orang.** Saat mapping import, kolom
  yang dipetakan ke email/telepon tapi rasio nilai uniknya rendah (mis. 50 email untuk
  1.962 baris = email surveyor) harus diberi peringatan keras dan secara default
  dipetakan ke `attributes.*`, bukan identitas.

Aturan **kontak bersama** (mencegah salah gabung):
- Kalau telepon/email cocok ke customer yang **namanya jelas beda**, kontak itu dianggap
  kontak bersama (mis. nomor surveyor, pemilik beberapa toko), bukan identitas.
  "Jelas beda" = setelah normalisasi (huruf kecil, hanya huruf/angka) tidak saling memuat
  dan kemiripan < 80%. "Bengkel Maju" vs "Bengkel Maju Jaya" dianggap sama; "Bengkel
  Maju" vs "Toko Sinar" beda.
- Record lalu dicocokkan lewat composite (nama + kota + kecamatan) atau jadi customer
  baru. Kontaknya tetap disimpan di profil, tapi **tidak** didaftarkan sebagai identifier.
- External ID tidak kena aturan ini (ID sistem tetap otoritatif walau nama berubah).
- Prinsip: duplikat masih bisa di-merge belakangan; salah gabung jauh lebih mahal.

Aturan AI Identity Resolver:
- Dipanggil **hanya** untuk kasus yang tidak selesai secara deterministic.
- Output AI hanya menghasilkan **saran** di Review Queue, bukan merge.

**Kasus duplikat yang masih bisa lolos (sadari):**
- Email/telepon typo → dianggap beda (bisa tertangkap sebagai kandidat mirip).
- Orang sama ganti nomor/email → profil bisa kepecah.
- Transaction tanpa `external_order_id` → tidak ada kunci dedup → selalu insert.

---

## 6. Aturan MERGE & KONFLIK (saat customer cocok)

Per-field (field standar dan tiap `attributes.*`):

| Kondisi | Aksi |
|---------|------|
| Nilai baru **kosong** | **Skip**: data lama tidak dihapus |
| Lama kosong, baru terisi | **Isi** |
| Lama & baru **sama** (setelah normalisasi) | No-op |
| Lama & baru **beda**, field standar | ⚠️ **Tidak ditimpa**: buat kandidat konflik → masuk **Review Queue** |
| Lama & baru **beda**, `attributes.*` | **Nilai terbaru menang**; nilai lama tersimpan di audit log (`customer.updated`) |

**Nilai kosong tidak pernah menghapus data yang sudah ada. Tidak ada silent overwrite:**
field standar lewat Review Queue; attributes (jawaban survey, foto, status toko, yang memang
berubah tiap kunjungan) ditimpa dengan jejak audit.

Setelah aturan identitas/merge berubah, data dev bisa dibangun ulang dari staging:
`php artisan customers:rebuild-from-imports` (dry run) lalu `--apply`. Ditolak di production.

Aksi di Review Queue: `Merge`, `Keep separate`, `Use incoming`, `Keep current`,
`Ignore`. Bulk action memakai validasi yang sama dengan aksi per-item dan hanya
memproses item yang masih pending.

Snapshot customer di transaksi (`order.snapshot.*`) **tidak** otomatis mengubah profil
customer.

---

## 7. Prinsip: data DIHITUNG vs DIKLAIM (paling penting)

| Jenis | Contoh | Perilaku |
|-------|--------|----------|
| **Diklaim** (bisa salah ketik) | nama, email, telepon, gender, alamat, lokasi, custom attributes | **Konflik-on-change** → review (§6) |
| **Dihitung** (fakta agregat) | total_orders, total_spend, AOV, first/last order, days_since_last_order, purchase_frequency, engagement, traits | **Otomatis, recompute-from-source**, tidak lewat antrian konflik |

- Metrik transaksi disimpan di tabel `customer_metrics` (kolom typed), bukan di
  `customers.attributes`.
- **Jangan** maintain counter yang di-increment manual. Selalu hitung ulang dari
  sumber (`transactions`, `campaign_events`).

---

## 8. Transactions

- Module opsional: client tanpa transaksi tetap bisa pakai platform.
- Terhubung ke customer via `customer_id`.
- **Dedup by `(source, external_order_id)`**: sudah ada → **update**, belum ada →
  **insert**. Re-import idempotent.
- Transaction tanpa `external_order_id` → selalu insert (risiko duplikat diketahui).
- Setelah persist atau edit/delete, hitung ulang metrik customer lama **dan** baru.

---

## 9. Customer lifecycle & penghapusan

- State: `active`, `suppressed`, `merged`, `trashed`, `anonymized`, `deleted`.
- Default delete = **soft delete ke Trash** (`spv` ke atas). Restore dari Trash.
- Permanent delete hanya `admin`/`super_admin`, pakai konfirmasi eksplisit, dan
  mengikuti retention rules.
- Customer `merged` menyimpan pointer ke customer yang bertahan.
- Delete customer tidak menghapus bukti import (`import_batches`, `import_rows`).

---

## 10. Location

- Field lokasi pada Experience memakai satu pilihan bertingkat dengan kode wilayah
  authoritative: provinsi → kota/kabupaten → kecamatan → desa/kelurahan. Nama yang
  masuk ke profil selalu di-resolve ulang di server dari kode dan hubungan parent
  wajib valid; label dari browser tidak dipercaya.
- Referensi pilihan disimpan lokal dari dataset `aliziodev/laravel-wilayah` agar
  form publik tidak bergantung pada API eksternal. Pembaruan dataset dilakukan
  eksplisit sebagai bagian deployment/migration dan tidak mengubah klaim lokasi
  customer yang sudah tersimpan.

- 4 level Indonesia: `province → city/regency → district → village`.
- Input asli selalu disimpan.
- Status resolusi: `resolved`, `partial`, `ambiguous`, `unknown`.
- **Jangan menebak.** Contoh "Bekasi" (Kota atau Kabupaten) tetap `ambiguous` kalau
  data tambahan tidak cukup.

---

## 11. Data Quality

1. **Completeness policy**: field + bobot yang dikonfigurasi, menghasilkan
   `completeness_score` dengan versi policy. Ganti policy → job rekalkulasi.
2. **Review Queue**: identity ambigu, konflik nilai, lokasi ambigu.

Semua surface (dashboard, API, customer detail) memakai definisi skor yang sama.

---

## 12. Segment

- Segment = definisi rules dinamis; anggotanya mengikuti perubahan data customer.
- Logic: `AND`, `OR`, `EXCLUDE`, nested groups.
- Satu query compiler (rules → SQL) dipakai untuk preview, count, dan audience
  campaign. Tidak ada filter in-memory PHP.
- Field dan operator divalidasi terhadap schema. Rules dari AI divalidasi dengan
  aturan yang sama.
- Audience Copilot tidak mengubah builder sebelum user menekan **Apply**.

---

## 13. Campaign & delivery

- Satu campaign = **satu channel** (WhatsApp, Email, SMS).
- Audience = include segments − exclude segments, lalu disaring consent channel dan
  status suppressed.
- **Recipient dedup:** customer di beberapa segment tetap satu recipient per campaign.
- Mode **one-time** (audience diambil saat run) dan **ongoing** (customer baru yang
  masuk segment eligible; yang sudah menerima tidak dikirim ulang).
- Status: `draft`, `scheduled`, `active`, `paused`, `completed`, `failed`,
  `cancelled`. Status tidak boleh diterima sebagai string bebas dari client.
- Sending window (hari aktif, jam mulai, jam selesai, timezone instalasi). Di luar
  window → antri.
- **Frequency cap global: maksimal 5 pesan per customer per minggu** (gabungan WA +
  Email + SMS), periode mingguan tetap sesuai timezone client. Priority `high` pun
  tidak boleh melewati cap.
- Consent per channel. Opt-out tidak menghapus customer, hanya mengecualikan channel.
- Tabel dipisah: `campaigns`, `campaign_runs`, `campaign_recipients`,
  `campaign_events`. Definisi campaign tidak dipakai sebagai log pengiriman.
- Pengiriman idempotent: retry tidak boleh mengirim dobel.

### Engagement → data

- Event (sent, delivered, open/read, click, reply, form submit, purchase) disimpan
  append-only di `campaign_events`, idempotent.
- Identitas submission Experience: token link campaign → form (email/phone/info lain)
  → Identity Resolver → existing / new / ambiguous.
- Writeback ke metrik engagement otomatis (§7). Field diklaim dari form tetap ikut
  aturan konflik (§6).

---

## 14. Connectors

- Konfigurasi di **Settings → Integrations**.
- Flow: `Connect → Test → Select/Detect data → Mapping → Preview → Activate sync`.
- Secret dienkripsi at rest. Field secret yang dikosongkan saat simpan **tidak**
  menghapus secret lama. Secret tidak pernah dirender balik.
- Ubah konfigurasi material → status test di-reset.
- Launch campaign butuh connector channel berstatus `connected`.

---

## 15. AI

- Provider utama: **OpenAI Responses API**. API key disimpan terenkripsi dari
  Settings, bersifat write-only, dan tidak pernah dikirim kembali ke browser atau log.
- Fallback otomatis: **Ollama (Qwen)** di VPS yang sama dengan app, diakses via
  `OLLAMA_BASE_URL`. Model lokal default `qwen2.5:3b`; satu model termuat dan satu
  request sekaligus agar import serta UI tetap responsif.
- Failover memakai circuit breaker per client. Timeout/koneksi gagal, rate limit,
  error server, kredit/spend/usage habis, atau konfigurasi OpenAI yang tidak dapat
  dipakai langsung mengalihkan request ke Ollama. Error request/prompt `400/422`
  tidak ditutupi dengan fallback. Kondisi sementara mengikuti `Retry-After` (dibatasi
  5 detik–15 menit) atau cooldown 60 detik; quota/config error menunggu Recheck atau
  perubahan setting.
- VPS tanpa GPU, jadi **semua inference fitur AI async lewat queue**. Connection test
  Settings boleh melakukan probe singkat tanpa prompt customer. UI menampilkan status
  proses/provider, tidak pernah menunggu inference dalam satu request.
- Setiap fitur AI punya timeout, retry terbatas, dan **fallback deterministic**.
  Aplikasi tetap jalan saat OpenAI dan Ollama mati.
- AI tidak dipanggil per-record kalau kasusnya bisa diselesaikan secara deterministic.
- Input AI memakai statistik agregat dan metadata; data customer mentah hanya dikirim
  kalau memang perlu (mis. Identity Resolver untuk kandidat spesifik).
- Knowledge Base disimpan sebagai chunk + embedding di pgvector.

**AI boleh otomatis:** normalisasi, high-confidence matching, dedup aman, enrichment
terpercaya, update metrik, update membership segment, deteksi anomali.

**AI tidak boleh otomatis:** merge customer ambigu, hapus customer, ubah rules segment
aktif, launch campaign, kirim blast, ubah priority/schedule campaign, override consent.

---

## 16. Audit & provenance

- Audit append-only: actor (user atau `system`/`ai`), action, resource, old value,
  new value, source, timestamp.
- Perubahan otomatis oleh AI dan job juga diaudit.
- Setiap nilai profil penting bisa ditelusuri ke sumbernya (batch/row/connector/form).
- Audit tidak boleh berisi secret.

---

## 17. Aturan testing

- **TDD**: tulis test yang gagal dulu, implementasi, lalu hijaukan.
- Tiap action/endpoint punya feature test, termasuk test otorisasi per role.
- Perubahan identity/dedup/merge wajib punya test (match, non-match, guard, ambiguous).
- Perintah:
  ```bash
  php artisan test
  npm run build        # typecheck + build frontend
  ```

---

## 18. Konvensi & rambu

- PHP 8.4, Laravel 13, React + Inertia + TypeScript, Tailwind + shadcn/ui,
  PostgreSQL 17 + pgvector, Redis + Horizon.
- **Jangan commit**: `.env`, dump database, `node_modules`, `vendor`, data ber-PII.
- Mutasi data besar selalu lewat **staging dulu**.
- Tambah tipe identifier baru? Update **§5** dan tambah test sebelum kode.
- Perilaku "auto-update vs review" baru harus mengikuti **§7**.

---

## 19. Deployment

- **Docker Compose** di VPS (`deploy/compose.production.yml`, install/update: `deploy/install.sh`, panduan
  `deploy/README.md`): `app` (FrankenPHP = Caddy + PHP, HTTPS Let's Encrypt otomatis), `horizon`, `scheduler`
  (satu image yang sama), `postgres`, `redis`, `ollama` (profile `ai`). File di volume dengan disk `local`
  (semua akses lewat PHP); S3 eksternal cukup lewat env. MinIO hanya untuk dev.
- **Tidak boleh diindeks mesin pencari:** semua response produksi membawa `X-Robots-Tag: noindex, nofollow,
  noarchive` (Caddyfile), termasuk login, form publik `/f/*`, file, dan halaman error. `robots.txt` sengaja tidak
  memblokir: crawler yang diblokir tidak pernah melihat `noindex` dan bisa tetap mencantumkan URL yang ditautkan.
- Semua jalan di satu VPS (4 vCPU, 16 GB RAM, 200 GB NVMe). Service `ollama` memakai
  profile `ai`: default mati di lokal, selalu nyala di production
  (`COMPOSE_PROFILES=ai`), dengan limit CPU (`OLLAMA_CPUS`, default 2) dan RAM 4,5 GB.
- `.env`, volume database, dan storage persisten di luar image.
- Deploy tidak boleh regenerate `APP_KEY` setelah deploy pertama.
- Migration dijalankan sebelum container app versi baru menerima traffic. Health check
  `/up` gagal → rollback ke image sebelumnya.
- **Anggaran resource 1 VPS (4 vCPU / 16 GB, DMP + AI satu mesin):** Postgres 4 GB (`shared_buffers` 2 GB),
  Redis 512 MB `noeviction` (antrian/session tidak boleh dibuang), PHP-FPM ~2 GB, Horizon ~1,2 GB (8 proses),
  scheduler ~0,2 GB, Ollama limit 4,5 GB + maks 2 vCPU, sisa ~2,5 GB + swap 4 GB.
  Ollama: `OLLAMA_NUM_PARALLEL=1`, `OLLAMA_MAX_LOADED_MODELS=1`, `OLLAMA_KEEP_ALIVE=10m`, model lokal ≤ 4B
  (kuantisasi 4-bit); model besar/interaktif lewat provider cloud. Tidak ada port publik untuk Postgres, Redis,
  Ollama: hanya jaringan internal Docker (+ web 80/443, dan 8081 untuk health check di loopback host).
- **Backup:** `RunBackup` tiap 02:00 WIB (pg_dump custom → `BACKUP_DISK`, retensi `BACKUP_KEEP`). Image app wajib
  punya pg_dump versi ≥ server (Postgres 17). Produksi wajib mengarahkan `BACKUP_DISK` ke penyimpanan di luar VPS.
- **Email:** semua email lewat connector SMTP di Settings (fallback `.env`); secret write-only seperti connector lain.
- **Export data:** hanya lewat `ExportCustomers` (izin `customers.export`, audit `customers.exported`). Teks selalu sel
  teks di Excel, dan di-escape untuk CSV (formula injection). Data di trash tidak pernah diekspor.
- **Trafik (aplikasi):** semua route login dibatasi `throttle:app` 600/menit per user; aksi tulis & endpoint publik punya
  throttle sendiri. Halaman agregat berat (dashboard, analytics, segments) lewat `MetricsCache::rememberStale`
  (nilai lama disajikan, satu refresh di belakang, miss dihitung satu proses saja) + `WarmMetrics` setelah import.
  Hitungan segment: pengecualian via `NOT EXISTS`, gabungan segment via `UNION`; jangan `NOT IN`/`OR … IN` subquery
  pada tabel customers (macet di jutaan baris).
- **Trafik (infrastruktur produksi):** body request maks 26 MB (CSV 20 MB); 16 thread PHP (`PHP_THREADS`),
  `max_execution_time` 120 s (export 600 s); `statement_timeout` 30 s untuk koneksi web lewat `PGOPTIONS` di container
  `app` (worker/migrasi tanpa batas). Rate limit hanya di aplikasi (Caddy standar tidak punya `limit_req`).
  PgBouncer ditunda (koneksi maks ±25 < `max_connections` 100); kalau nanti dipasang dengan transaction pooling,
  pindahkan `statement_timeout` ke level role, karena setting per sesi tidak aman di belakangnya.
- **Jalur queue:** `campaigns` (kirim), `imports` (commit/sync), `ai` (inference), `default`. Job berat wajib
  `onQueue(...)` ke jalurnya; scheduler hanya mengantrikan job. `retry_after` Redis harus > timeout job terpanjang.
- **Angka agregat** (dashboard/analytics/segment) lewat `MetricsCache`; model yang mengubahnya memakai `BumpsMetrics`,
  bulk write tanpa event memanggil `MetricsCache::bump()`.
- Update dari UI (Settings → Update & changelog) hanya lewat **bundle bertanda tangan Ed25519** yang
  mengunci image by digest; eksekusi oleh agent host (`deploy/updater`), bukan web app. Upload ZIP kode
  mentah tidak didukung. Migration wajib expand/contract supaya rollback (kode saja) aman.
