# Codex Handoff - QRIS Soundbox Platform

Tanggal update: 2026-06-08, Asia/Jakarta.

Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat.

## Update Terbaru - 2026-06-08

- Commit terbaru yang sudah dipush ke `origin/main`:
  - `4bbe3f6 Polish device registry identity displays`;
  - `67dc286 Improve soundbox ops dashboard and registry editing`.
- `soundbox-backend-mqtt-spec.md` sekarang mendokumentasikan device command QF100 category `5` untuk `reboot` dan `poweroff`.
- Backend sudah sinkron dengan spec command tersebut:
  - `POST /admin/devices/{id}/commands` menerima `device.poweroff`;
  - publisher MQTT membuat payload QF100 category `5` dengan `data.command = "poweroff"`;
  - downlink tercatat sebagai `poweroff_command` di `mqtt_messages`.
- `scripts/smoke-qf100-adapter.mjs` sudah menambahkan assertion untuk command `device.poweroff`.
- Dashboard `/ui/soundbox-ops` dirapikan untuk operator:
  - KPI warning sekarang menunjukkan breakdown stale vs degraded;
  - KPI card bisa dipakai sebagai quick filter;
  - tabel Fleet Status menampilkan health bar, reason, signal, dan battery;
  - kolom utama Fleet Status sekarang memakai Serial Number sebagai identitas utama, dengan device code hanya sebagai detail sekunder;
  - Remote Actions menambahkan tombol `Power Off Device`;
  - layout mobile header/filter dibuat full-width agar tidak overflow.
- Registry `/ui/device-registry-monitoring` sekarang mendukung koreksi device metadata:
  - tabel dan detail mengutamakan Serial Number, bukan internal device id;
  - menu row punya `Edit Device`;
  - menu row baris bawah otomatis membuka ke atas agar tidak tertutup pagination;
  - modal edit bisa koreksi `serial_number/dev-sn`, vendor, model, communication mode, status, dan firmware version;
  - perubahan model ikut memperbarui `capability_profile_json` dari katalog model aktif;
  - backend menolak duplicate `serial_number` supaya config pull/MQTT lookup tidak ambigu.
- Device Technical Detail mengutamakan Serial Number di breadcrumb/title, menaruh device code sebagai detail, dan remote action menambahkan `Power Off Device`.
- Search UI yang sebelumnya dekoratif sudah mulai difungsikan:
  - Admin Dashboard global search route ke Device Registry, Merchant List, atau Transaction History dengan `?q=`;
  - Transaction History dan Merchant List membaca `?q=` sebagai initial search;
  - Admin Reconciliation top search route ke Transaction History;
  - Settlement Batch search live-filter batch table;
  - Merchant Settlement History search live-filter disbursement table dan membaca `?q=`;
  - Merchant Dashboard search route ke Merchant Settlement History;
  - Fee Pricing search route ke Audit Logs, dan Audit Logs membaca `?q=`;
  - Merchant Detail search route ke Merchant List;
  - Device QR Payment Display search live-filter transaction rows.
- Verifikasi lokal update ini:
  - `npm run typecheck`: pass;
  - `node --check scripts/smoke-qf100-adapter.mjs`: pass.
  - `node scripts/ui-qa-check.mjs`: pass;
  - direct script parse `ui/device-registry-monitoring/index.html`: pass;
  - headless Chrome screenshot desktop/mobile `/ui/soundbox-ops/?preview=1`: pass visual sanity.
- Panduan update server yang benar untuk production/pilot:
  - jangan pakai PM2; app server menggunakan Debian 13 + systemd service `qris-soundbox`;
  - app directory: `/opt/qris-soundbox`;
  - app user: `qrisapp`;
  - env file: `/etc/qris-soundbox/qris-soundbox.env`;
  - domain utama: `https://sms.bizone.id`.
- Command update server:

```bash
cd /opt/qris-soundbox
sudo -u qrisapp git pull origin main
sudo -u qrisapp npm ci
sudo -u qrisapp npm run build
sudo -u qrisapp bash -lc 'cd /opt/qris-soundbox && set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run db:migrate'
sudo systemctl restart qris-soundbox
sudo systemctl status qris-soundbox --no-pager
curl -fsS https://sms.bizone.id/health
curl -fsS https://sms.bizone.id/health/deep
curl -I https://sms.bizone.id/ui/soundbox-ops
journalctl -u qris-soundbox -n 100 --no-pager
```

## Update Terbaru - 2026-06-07

- Production saat ini fokus ke portal Soundbox Ops di `sms.bizone.id`, dengan MQTT broker `broker.bizone.id`.
- Commit terakhir yang sudah dipush sebelum update handoff ini:
  - `1e0f36f Split MQTT trace and commands pages`
  - `ef23b09 Parse QF100 heartbeat time as WIB`
  - `e3d7e60 Complete QF100 ops commands and detail UI`
- Perubahan UI/ops terbaru:
  - menu `MQTT Trace` sekarang halaman sendiri: `/ui/mqtt-trace`;
  - menu `Config & Commands` sekarang halaman sendiri: `/ui/config-commands`;
  - sidebar tidak lagi lompat ke anchor dashboard untuk dua menu tersebut;
  - dashboard tetap ada summary ringkas, tetapi menu operasional utama pindah ke screen masing-masing.
- Halaman `/ui/mqtt-trace`:
  - menampilkan trail uplink/downlink ala audit trail;
  - filter direction `uplink/downlink`;
  - filter message type;
  - search topic/device/type/payload;
  - limit 100/250/500;
  - auto refresh 10 detik;
  - klik event untuk lihat full payload JSON.
- Endpoint `/admin/mqtt/status` sekarang mendukung:
  - `limit` sampai 500;
  - `direction`;
  - `message_type`;
  - `device_id`.
- Halaman `/ui/config-commands`:
  - menampilkan status database, pending/failed notification, export worker;
  - menampilkan MQTT publisher/subscriber runtime;
  - dropdown device dengan SN, model, connection, last seen;
  - tombol `Reboot Device`;
  - tombol `Open Device Detail`.
- Fix dashboard terbaru:
  - kolom `Last Seen` di `/ui/soundbox-ops` tidak lagi menampilkan `[object Object]`;
  - formatter sekarang membaca `latest_heartbeat.received_at`, `latest_heartbeat.timestamp`, lalu fallback `last_seen_at`.
- QF100 firmware categories yang sudah disiapkan:
  - category `1`: payment sound, payload `data.pay-amount`;
  - category `3`: heartbeat dari device;
  - category `4`: dynamic QR display, payload `data.qr-url`, `data.amount`, `data.expire-seconds`;
  - category `5`: reboot command, payload `data.command = "reboot"`.
- Dynamic QR:
  - modal preview di Device Technical Detail sudah generate QR dari `data["qr-url"]`;
  - tombol `Send Test QR` mengirim command `dynamic_qr.display`;
  - backend menerima payload nested `header/data` dan publish category `4` ke `soundbox/{serial_number}/down`.
- Reboot:
  - command `device.reboot` publish category `5` ke `soundbox/{serial_number}/down`;
  - bisa dikirim dari Device Technical Detail, Device Registry drawer, dan `/ui/config-commands`.
- Heartbeat:
  - QF100 mengirim `data.time` sebagai WIB/UTC+7;
  - backend sekarang parse waktu itu sebagai WIB lalu simpan UTC;
  - contoh `20260607023400` disimpan menjadi `2026-06-06T19:34:00.000Z`.
- Device Registry dan Device Detail:
  - SN ditampilkan di tabel Device Registry;
  - SN ditampilkan di drawer Device Detail;
  - SN ditampilkan di header Device Technical Detail.
- Verifikasi lokal terakhir:
  - `npm run typecheck`: pass;
  - `node scripts/ui-qa-check.mjs`: pass;
  - `node --check scripts/smoke-qf100-adapter.mjs`: pass pada patch QF100 sebelumnya;
  - `git diff --check`: pass.

## Update Terbaru - 2026-06-06

- Fokus produk sekarang: `sms.bizone.id` menjadi portal utama Soundbox Ops / Monitoring, bukan katalog UI atau dashboard admin campuran.
- Default route sudah disesuaikan:
  - `GET /` redirect ke `/ui/soundbox-ops`;
  - `GET /ui` redirect ke `/ui/soundbox-ops`;
  - halaman Soundbox Ops tetap berada di `/ui/soundbox-ops`.
- Server target deploy diganti menjadi Debian 13, memakai user khusus `qrisapp`.
- Dokumen deploy:
  - `DEBIAN12_APP_SERVER_SETUP.md` diganti menjadi `DEBIAN13_APP_SERVER_SETUP.md`;
  - README sudah menunjuk ke dokumen Debian 13;
  - install dari server kosong mencakup package dasar, Node.js 22, PostgreSQL, Nginx, Certbot, UFW, env production, migration, systemd, TLS, dan verification;
  - semua proses app/repo/build/migration/service dijalankan sebagai `qrisapp`;
  - root/sudo hanya untuk install package, direktori, env, Nginx, systemd.
- Env deploy sekarang mencantumkan `DATABASE_URL` selain `PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE`, supaya `npm run deploy:check-env` tidak gagal dengan error `DATABASE_URL is required`.
- Endpoint config device sudah men-support:
  - `GET /speaker/dev-config`;
  - `POST /speaker/dev-config`;
  - lookup device berdasarkan `dev-sn` ke `devices.serial_number`;
  - device tidak dikunci model tertentu, sehingga tipe device asli bisa diinput lewat master data/model dan matching tetap berdasarkan SN.
- Response config device tetap vendor-compatible:
  - `error-code`;
  - `mqtt.broker-ip`;
  - `mqtt.broker-port`;
  - `mqtt.client-id`;
  - `mqtt.user-name`;
  - `mqtt.password`;
  - `mqtt.subscribe-topic`;
  - `mqtt.keep-alive`.
- Production target:
  - HTTP config URL device: `https://sms.bizone.id/speaker/dev-config`;
  - MQTT broker: `broker.bizone.id`;
  - MQTT TLS default: `mqtts://broker.bizone.id:8883`;
  - jika firmware belum support TLS, siapkan listener non-TLS terbatas atau patch firmware.
- Dashboard Soundbox Ops sudah dirapikan untuk fokus operasional:
  - sidebar hanya menu ops: Monitoring, Registry, MQTT Trace, Config & Commands, Catalog;
  - menu Register Device dihapus dari sidebar, action register ada di Device Registry;
  - Technical Detail dihapus dari sidebar, akses detail lewat row action di Device Registry;
  - footer/dev nav UI katalog dihapus dari halaman ops yang relevan.
- Master data `Catalog` sudah ditambahkan:
  - vendor create/edit/delete;
  - model create/edit/delete;
  - model code generated backend;
  - model thumbnail upload;
  - country dropdown searchable dengan Palestine/Palestina masuk list;
  - structured vendor contact: PIC name, phone/WhatsApp, email, notes.
- Device Registry sudah dibuat live dari API, bukan dummy:
  - KPI dari data device;
  - search berfungsi;
  - pagination nyata;
  - filter reset page;
  - row action 3-dot untuk View Device Detail / Quick Inspect.
- Device Detail sudah dibuat lebih operasional:
  - tombol refresh, retry push, test QR, copy payload, clear console, reboot, firmware update, unbind, rotate MQTT credential, decommission punya action;
  - Dynamic QR preview dibuka sebagai modal inline, bukan pindah ke dashboard lain.
- Verifikasi terakhir lokal:
  - `npm run typecheck`: pass.

## Status Terakhir

- Fokus sebelumnya bergeser dari production readiness umum ke integrasi device soundbox QF100.
- Folder SDK lokal `QF100-60s-l511-SecondApp-260107/` ditemukan dan dianalisis. Folder ini sengaja tidak dimasukkan git.
- Backend sekarang sudah punya adapter awal untuk firmware QF100 sample:
  - config server vendor-compatible;
  - payload MQTT payment success format QF100;
  - smoke script backend untuk static + dynamic QF100.
- Target milestone berikutnya: dua soundbox real, satu static dan satu dynamic, bisa ambil config dari backend, connect MQTT, lalu static payment test bunyi dan tercatat di dashboard.
- Worktree aktif/dirty karena perubahan backend QF100 dan update handoff. Jangan revert perubahan yang tidak eksplisit diminta.

## Verifikasi Terakhir

- `npm run typecheck`: pass setelah perubahan QF100.
- `npm install`: sudah dijalankan untuk memasang dependency lokal; perubahan lockfile accidental sudah dibalik.
- `npm run smoke:qf100`: script sudah dibuat, belum dijalankan end-to-end di DB/server lokal pada turn ini.
- Verifikasi lama yang masih relevan:
  - `npm run db:migrate`: sebelumnya pass dan idempotent sampai migration `003_export_job_storage.sql`.
  - `npm audit --json`: sebelumnya pass, 0 vulnerability.
  - `npm run ui:qa`: sebelumnya pass.
  - `npm run smoke:e2e`: sebelumnya pass.
  - Real MQTT smoke sebelumnya pernah pass terhadap `mqtts://broker.bizone.id:8883`.

## SDK QF100 Yang Ditemukan

- Folder lokal: `QF100-60s-l511-SecondApp-260107/`.
- Sudah ditambahkan ke `.gitignore`:
  - `QF100-60s-l511-SecondApp-260107/`
- Struktur penting:
  - `docs/Config Server.docx`: kontrak config server vendor.
  - `docs/Cloud Speaker API Spec V2.8.7.pdf`: API spec cloud speaker.
  - `app/source/MainApp/globalDefine.h`: device model, demo mode, `CONFIG_ADDR`.
  - `app/source/MainApp/demo.c`: alur config server, MQTT connect/subscribe, parse payment payload.
  - `app/source/MainApp/demo.h`: MQTT TLS/QoS/clean session/cert config.
  - `app/source/MainApp/main.c`: boot flow dan monitor network/MQTT.
  - `app/inc/MercuryMqtt.h`: header MQTT SDK.
  - `app/release/user_app.bin`: firmware build existing.

## Kesimpulan SDK QF100

- SDK ini memungkinkan develop/patch aplikasi firmware sendiri, tetapi bentuknya embedded C firmware app, bukan SDK backend.
- Strategi dipilih: firmware hanya hardcode URL config server backend kita, bukan hardcode broker MQTT.
- Firmware sample boot lalu call `CONFIG_ADDR` ke endpoint vendor `/speaker/dev-config`.
- Request config berisi field seperti:
  - `dev-model`
  - `item-number`
  - `dev-sn`
  - `fw-version`
  - `fw-build`
  - `app-config-version`
  - `imei`
  - `imsi`
  - `iccid`
- Response yang firmware cari memiliki blok:
  - `mqtt.broker-ip`
  - `mqtt.broker-port`
  - `mqtt.client-id`
  - `mqtt.user-name`
  - `mqtt.password`
  - `mqtt.subscribe-topic`
  - `mqtt.keep-alive`
- Payment success payload yang firmware sample bisa bunyikan:
  ```json
  {
    "header": {
      "category": 1
    },
    "data": {
      "pay-amount": 15000
    }
  }
  ```
- `MQTT_TLS_ENABLE` di SDK sample masih `0`. Jika broker production memakai `mqtts://...:8883`, firmware perlu patch TLS atau pilot perlu listener non-TLS terbatas.

## Implementasi QF100 Backend Hari Ini

### 1. Config Server Vendor-Compatible

- Route baru:
  - `GET /speaker/dev-config`
- File:
  - `src/routes/speaker.ts`
  - mounted di `src/app.ts`
- Perilaku:
  - menerima query/body vendor;
  - lookup device memakai `dev-sn` -> `devices.serial_number`;
  - jika device aktif, update:
    - `vendor`
    - `model`
    - `communication_mode`
    - `last_seen_at`
    - `firmware_version`
  - mencatat heartbeat `state: config_pull`;
  - balas JSON vendor top-level, bukan wrapper internal `successResponse`.
- Error vendor style:
  - `1001`: `dev-sn` kosong;
  - `1002`: device tidak terdaftar atau inactive;
  - `1003`: broker MQTT belum dikonfigurasi.

### 2. Env QF100

- Env baru di `src/config/env.ts` dan `.env.example`:
  - `QF100_MQTT_BROKER_HOST`
  - `QF100_MQTT_BROKER_PORT`
  - `QF100_MQTT_USERNAME`
  - `QF100_MQTT_PASSWORD`
  - `QF100_MQTT_KEEP_ALIVE_SECONDS`
- Jika host/port QF100 kosong, endpoint mencoba parse dari `MQTT_BROKER_URL`.
- Catatan penting: password MQTT yang dikirim ke QF100 diambil dari `QF100_MQTT_PASSWORD` atau fallback `MQTT_PASSWORD`. Sistem saat ini menyimpan credential device sebagai fingerprint, jadi plaintext per-device tidak bisa dibaca ulang dari DB.

### 3. Payload MQTT QF100

- File:
  - `src/shared/services/mqttPublisher.ts`
  - `src/shared/orchestrators/notificationOrchestrator.ts`
- Topic QF100:
  - `soundbox/{dev-sn}/down`
- Adapter memilih format QF100 jika:
  - `device.model` mengandung `QF100`; atau
  - `capability_profile_json.mqtt_payload_profile`, `protocol_profile`, atau `vendor_protocol` bernilai `qf100`.
- Downlink payment sekarang juga dicatat ke `mqtt_messages`, sehingga dashboard device detail bisa melihat topic/payload yang dikirim.

### 4. Device Store dan Schema

- Lookup baru:
  - `getDeviceBySerialNumber(serialNumber)` di `src/shared/store/deviceStore.ts`.
- Schema bootstrap menambahkan index:
  - `idx_devices_serial_number`.

### 5. Smoke QF100

- Script baru:
  - `scripts/smoke-qf100-adapter.mjs`
- Package script baru:
  - `npm run smoke:qf100`
- Script ini:
  - create merchant/outlet/terminal/device static;
  - create merchant/outlet/terminal/device dynamic MQTT;
  - set `model: QF100` dan `capability_profile_json.mqtt_payload_profile: qf100`;
  - hit `/speaker/dev-config` untuk dua SN;
  - validasi MQTT config vendor;
  - trigger static QRIS paid callback;
  - validasi `mqtt_messages` downlink QF100 payload `category: 1`;
  - trigger backend dynamic QR MQTT flow untuk device dynamic.
- Untuk memakai SN real:
  ```bash
  QF100_STATIC_SN=<sn-static> \
  QF100_DYNAMIC_SN=<sn-dynamic> \
  BASE_URL=http://127.0.0.1:3000 \
  npm run smoke:qf100
  ```

## Device Flow Yang Disepakati

### Static Soundbox

1. Device boot.
2. Firmware call backend `/speaker/dev-config`.
3. Backend balas MQTT config.
4. Device connect MQTT dan subscribe `soundbox/{dev-sn}/down`.
5. QRIS callback paid masuk backend.
6. Backend publish payload QF100 `category: 1`.
7. Device bunyi nominal.
8. Dashboard melihat transaction, notification, mqtt message, last seen/config pull.

### Dynamic Soundbox

- Prinsip: trigger tetap via MQTT, bukan polling terus-menerus.
- HTTP polling dynamic QR dianggap tidak cocok untuk skala 20 ribu device karena `gap=30s` berarti sekitar 40 ribu request/menit.
- Desain lanjutan:
  1. Backend/admin/merchant membuat dynamic QR request.
  2. Backend publish MQTT command ke device.
  3. Device menampilkan QR langsung atau HTTP fetch QR detail dari URL.
  4. Payment callback tetap jadi source of truth.
  5. Payment success tetap via MQTT `category: 1`.
- Firmware dynamic handler belum dipatch. Perlu tambah handler misalnya `category == 10` di `demo.c`.

## Dashboard Yang Perlu Disiapkan Berikutnya

Prioritas UI/dashboard untuk test dua soundbox real:

1. Device detail QF100 panel:
   - SN;
   - model/profile;
   - config pull terakhir;
   - broker host/port yang dikirim;
   - client-id;
   - subscribe-topic;
   - keepalive.
2. Test payment button:
   - nominal quick actions, misalnya Rp1.000 dan Rp15.000;
   - create dummy tx/callback internal;
   - tampilkan notification dan MQTT payload result.
3. MQTT timeline:
   - direction;
   - topic;
   - message_type;
   - payload JSON;
   - publish_status/reason;
   - timestamp.
4. Dynamic QR panel:
   - input nominal;
   - create dynamic QR;
   - tampilkan QR payload/status;
   - nanti `Send to Soundbox` via MQTT command setelah firmware handler siap.
5. Ops summary:
   - total soundbox;
   - online/stale/offline;
   - config pull terbaru;
   - payment notification sent/failed;
   - dynamic QR active/paid/expired.

## Endpoint Penting Saat Ini

- Health:
  - `GET /health`
  - `GET /health/deep`
- QF100 vendor config:
  - `GET /speaker/dev-config`
- Admin auth/session:
  - `POST /admin/login`
  - `POST /admin/logout`
  - `GET /admin/me`
- Admin device:
  - `POST /admin/devices`
  - `GET /admin/devices`
  - `GET /admin/devices/{id}`
  - `POST /admin/devices/{id}/credentials/rotate`
  - `POST /admin/devices/{id}/bind`
  - `POST /admin/devices/{id}/unbind`
  - `GET /admin/devices/{id}/mqtt-messages`
  - `GET /admin/devices/{id}/notifications`
- Device API:
  - `POST /device/heartbeat`
  - `POST /device/transactions/dynamic-qr`
  - `POST /device/mqtt/uplink/dynamic-qr/request`
  - `GET /device/config`
  - `POST /device/config/ack`
- Integrations:
  - `POST /integrations/qris/callback`
- Observability:
  - `GET /admin/observability/summary`
  - `GET /admin/observability/mqtt-status`

## Package Scripts Penting

- `npm run typecheck`
- `npm run db:migrate`
- `npm run smoke:qf100`
- `npm run smoke:e2e`
- `npm run smoke:mqtt-real`
- `npm run smoke:mqtt-acl`
- `npm run ui:qa`
- `npm run deploy:check-env`
- `npm run load:test`
- `npm run load:test:staging`
- `npm run mqtt:provision-device`
- `npm run mqtt:check-acl`
- `npm run admin:create-user`
- `npm run merchant:create-user`

## File Kunci Yang Sering Disentuh

- App bootstrap: `src/app.ts`
- Env config: `src/config/env.ts`
- QF100 config route: `src/routes/speaker.ts`
- Device route: `src/routes/device.ts`
- Admin route: `src/routes/admin.ts`
- MQTT publisher: `src/shared/services/mqttPublisher.ts`
- MQTT subscriber: `src/shared/services/mqttSubscriber.ts`
- Notification orchestrator: `src/shared/orchestrators/notificationOrchestrator.ts`
- Device store: `src/shared/store/deviceStore.ts`
- MQTT message store: `src/shared/store/mqttMessageStore.ts`
- Heartbeat store: `src/shared/store/heartbeatStore.ts`
- Schema bootstrap: `src/shared/db/pool.ts`
- QF100 smoke: `scripts/smoke-qf100-adapter.mjs`
- Env sample: `.env.example`

## Sisa Gap Utama

1. Jalankan `npm run smoke:qf100` terhadap backend + DB lokal/staging.
2. Register dua device real:
   - static SN -> device static;
   - dynamic SN -> device dynamic.
3. Patch firmware QF100:
   - `CONFIG_ADDR` ke backend kita;
   - TLS setting sesuai broker.
4. Test static device real:
   - config pull;
   - MQTT connect;
   - payment success bunyi;
   - dashboard melihat downlink.
5. Patch firmware dynamic:
   - tambah handler command dynamic QR, kemungkinan `category == 10`;
   - pilih direct QR payload vs HTTP fetch detail.
6. Siapkan dashboard QF100 detail/test panel.
7. Putuskan credential strategy production:
   - shared pilot password saat ini cukup untuk lab;
   - production sebaiknya credential per-device yang bisa diprovisioning aman.
8. Tetap perlu staging rehearsal, restore drill, export storage strategy, dan manual visual QA dari handoff lama.

## Catatan Penting

- Folder SDK QF100 adalah artefak lokal dan jangan dimasukkan git.
- Jangan hardcode broker credential production di firmware.
- Firmware cukup hardcode config server URL; broker/topic/credential dikirim dari backend.
- Jangan gunakan wildcard MQTT subscribe di production kecuali maintenance terkontrol.
- Jika memakai MQTT TLS (`mqtts://...:8883`), firmware QF100 perlu `MQTT_TLS_ENABLE=1` dan sertifikat yang sesuai.
- Rate limiting aktif default; `/speaker` memakai limiter device.
- `CODEX_HANDOFF.md` ini adalah snapshot operasional terbaru; untuk detail historis keputusan, baca `DECISIONS_LOG.md`.
