# Spesifikasi Fitur — Project Management System Scuto

| | |
| --- | --- |
| **Versi** | 3.5 — **seksi 01.7: foto profil dipilih dari koleksi tetap milik aplikasi, bukan diunggah sendiri.** Kolom `users.avatar` menyimpan kunci preset, bukan path maupun URL, dan berkasnya statis di `public/avatars/{kunci}.svg` yang ikut repo — tidak ada berkas milik pengguna yang perlu disimpan, dipangkas, atau dibersihkan saat akun mati. Daftar sahnya satu, `User::AVATAR_PRESETS`, dipakai validasi sekaligus dikirim ke layar pemilih; daftar tertutup itu bukan kerapian melainkan penjagaan, sebab nilainya berakhir di atribut `src` dan tanpa itu kiriman `../` atau URL luar ikut dirender. `avatarRules()` sengaja **tidak** masuk `profileRules()`, dengan alasan yang persis sama seperti `nikRules()`: form admin dan perintah CLI ikut memakai trait itu, padahal tidak satu pun dari mereka punya kolom avatar. Inisial tetap menjadi fallback di seluruh aplikasi, jadi `null`, berkas yang hilang, maupun preset yang kelak dicabut tidak pernah menyisakan kotak kosong. Yang mahal dari pekerjaan ini bukan fiturnya melainkan sebarannya: tujuh tempat menggambar orang, dua di antaranya merakit lingkaran inisial sendiri tanpa komponen `Avatar` sehingga lolos dari penyisiran pertama, dan satu — tabel tim project — ternyata sudah lebih dulu punya field bernama `avatar` yang isinya satu huruf inisial. **Jebakan yang perlu diingat untuk kolom user berikutnya:** dua mapper frontend menyusun ulang objek user field demi field, jadi kolom baru hilang diam-diam di sana tanpa satu pun error — avatar sempat begitu, dan hanya tertutupi karena fallback inisialnya bekerja terlalu rapi. Di bawahnya masih berdiri seluruh isi 3.4 — **seksi 04.6: ketergantungan antar task dan tab Gantt, dengan otorisasi yang sengaja tidak mengikuti `TaskPolicy::update()`.** Menyusun urutan kerja dijaga izin **project** — `Gate::authorize('create', [Task::class, $project])`, owner/PIC — bukan izin task, karena `update()` untuk subtask menuntut si pengklik menjadi pelaksana task induk; memakainya membuat owner project melihat tombolnya lalu ditolak 403 di subtask yang bukan pekerjaannya sendiri, jebakan "flag halaman vs gate per task" yang sama dengan 09.1. Task di luar project ditolak lebih dulu, sebab `TaskPolicy::create()` justru memulangkan `true` begitu project-nya `null` — tanpa penjagaan itu task divisi malah jadi yang paling longgar. Relasinya finish-to-start tunggal tanpa jeda, lingkaran ditolak saat disimpan termasuk lewat rantai panjang, dan panah hanya digambar bila **kedua** ujungnya lolos `Task::scopeVisibleTo()`. **Hanya task induk yang berketergantungan** — subtask ditolak di kedua ujung, sebab ia memecah pekerjaan induknya sendiri, bukan unit perencanaan project. Bagannya dibuka harian dan digulir ke hari ini, dan batangnya membuka detail task tanpa memindahkan tab. Fiturnya **pasif dan tetap pasif**: successor bisa mulai walau predecessor belum selesai, dan tidak ada tanggal yang bergeser sendiri — penegakan urutan serta penggeseran tenggat dicatat ⬜, bukan dijanjikan. Task tanpa tanggal mulai jatuh jadi milestone dan task tanpa tenggat tidak digambar sama sekali, karena tidak ada kolom durasi maupun persentase kemajuan per task yang bisa disandari; kemajuan di batang hanya muncul untuk task yang punya subtask, selesai ÷ total. **Kebutuhan fiturnya sendiri masih ditimbang** — yang dicatat di sini keadaan kode, bukan alur kerja yang sudah menuntutnya. Di bawahnya masih berdiri seluruh isi 3.3 — **enam penutupan seksi 03–06: pekerjaan tanpa pemilik berhenti membebani papan orang lain.** Root task tanpa assignee menyempit ke pembuatnya, owner/PIC project, dan admin/supervisor divisi — dan angka di kartu project ikut disaring rumus yang sama lewat `Project::scopeWithTaskProgress()`, yang sekaligus menggantikan tiga blok `withCount` identik; tanpa itu kartu menjanjikan baris yang tidak pernah muncul. Deadline berhenti opsional begitu assignee dipasang, karena task tanpa tenggat tidak pernah tersaring pengingat maupun terhitung overdue. Kunci bukti dibalik dari daftar larangan menjadi daftar izin — hanya `in_progress` — sehingga `waiting`, `on_hold`, dan `cancelled` berhenti meloloskan bukti yang terunggah sebelum pekerjaannya dimulai, dan `TaskProofController::store()` mendapat otorisasi yang selama ini tidak pernah ada di sana sama sekali. Sub-tugas mewarisi tenggat induknya apa adanya, termasuk yang sudah lewat: aturan "harus di masa depan" tidak berlaku untuk nilai warisan, karena formnya tidak punya kolom tenggat dan induk yang telat tetap harus bisa dipecah — kelonggarannya sempit, hanya tanggal yang persis sama dengan tenggat induk; pelaksananya berhenti diisi diri sendiri diam-diam, deskripsi opsional masuk ke formnya, dan pengajuan perpanjangan tenggatnya dicabut — himpunan tanggal yang lolos "lebih lambat dari tenggat sendiri" sekaligus "tidak melewati tenggat induk" memang kosong, jadi yang dibuang hanya tombol yang tidak pernah bisa berhasil. Nama project di-uppercase mutator sebagai kemudahan mengetik, **tanpa backfill**: baris lama dibiarkan campur huruf dengan sengaja. Di bawahnya masih berdiri seluruh isi 3.2 — **K9 direvisi: pengelolaan akun seluruhnya milik super admin.** Penghapusan akun berhenti dicoret dan menjadi pekerjaan yang menunggu prasyaratnya — enam foreign key `users.id` dibereskan lebih dulu, `projects.creator_id` paling awal — sementara penonaktifan tetap berdiri berdampingan, bukan digantikan. Jalur hapus akun *sendiri* di halaman profil tetap dicabut, dan pencabutannya tidak menunggu pekerjaan skema mana pun. Antrean kerja karena itu menyimpan tiga baris kode sekaligus, dan urutannya mengikat. Di bawahnya masih berdiri seluruh isi 3.1 — temuan halaman profil: **jalur hapus akun sendiri ternyata ada dan hidup**, bertentangan dengan K9 yang menolaknya sejak 1.2 — `DELETE settings/profile` menghapus permanen, dan karena `projects.creator_id` ber-cascade, ia membawa serta seluruh project yang pernah dibuat orang itu; dicatat di 01.6. Di bawahnya masih berdiri seluruh isi 3.0 — pencatatan seksi 11: gerbang izin notifikasi browser, yang selama ini menahan seluruh layar sampai pengguna mengizinkannya, akhirnya punya tempat di dokumen ini — lengkap dengan apa yang sebenarnya dilayaninya (notifikasi OS) dan apa yang tetap berjalan tanpanya (lonceng, badge, riwayat). **Tidak ada kode yang berubah pada versi ini**; yang berubah dokumennya, yang selama ini diam soal satu-satunya layar yang bisa menahan seorang pengguna sebelum ia melihat apa pun. Di bawahnya masih berdiri seluruh isi 2.9 — sisiran seksi 13: pembuatan berhenti menjadi baris audit tanpa isi sehingga peran yang diberikan sejak akun lahir tidak lagi tak berjejak, penanda notifikasi dikenali lewat pola alih-alih daftar nama, penambahan anggota project punya satu pintu yang mencatatnya, dan tiga controller tanpa route dihapus. Di bawahnya masih berdiri seluruh isi 2.8 — sisiran seksi 12: rumus visibilitas project berhenti ditulis empat kali dan menurut pada satu `Project::scopeVisibleTo()`, kartu project dashboard menyaring status tertutup sesuai judulnya sendiri, dan daftarnya berhenti memuat seluruh project yang diikuti seseorang. Di bawahnya masih berdiri seluruh isi 2.7 (seksi 10) — sisiran seksi 10: berkas media berhenti punya rumus izinnya sendiri dan menurut pada `TaskPolicy::view()`, nama asli berkas sampai ke layar untuk lampiran maupun bukti, halaman index berhenti menarik seluruh repositori media untuk dua angka, dan satu jalur validasi mati dihapus — ditambah satu batas unggahan yang ternyata tidak pernah ada di kode |
| **Tanggal** | 4 September 2026 |
| **Basis kode** | branch `dev`. Visibilitas task kini punya satu rumus, `Task::scopeVisibleTo()`, dan `TaskPolicy::view()` tidak lagi menyimpan rumusnya sendiri melainkan memanggil scope itu — sehingga bentuk per-baris (gate) dan bentuk per-daftar (saringan query) tidak bisa berselisih. Yang berselisih sebelumnya adalah halaman Media: ia menghitung sendiri siapa yang berhak, berhenti di "anggota divisi", dan karena itu meloloskan bukti routine work milik orang lain lewat `media/files/{kind}/{id}` — id numerik berurutan, dan yang didapat sesudahnya tautan Drive permanen yang bekerja tanpa login. Route bukti lama ikut disamakan: `tasks/proofs/{proof}` dulu memakai `updateStatus`, gerbang yang lebih ketat untuk berkas yang sama. Lampiran diskusi kini menyimpan `original_name` seperti bukti, dan halaman Media membacanya — kolom itu sudah lama terisi di sisi bukti tetapi yang tampil tetap UUID-nya. Halaman index berhenti memuat baris media sama sekali: jumlah dan waktu terbarunya diminta sebagai agregat, 1.200 baris yang dulu di-hydrate untuk 20 project menjadi nol. `StoreAttachmentRequest` dihapus — tanpa pemanggil sejak refactor DDD, dan satu-satunya request unggah tanpa daftar putih maupun daftar hitam, jadi ia terbaca sebagai lubang yang sebenarnya tidak pernah terbuka. Di bawahnya masih berdiri seluruh isi 2.6 (seksi 09), K19–K20 (2.4–2.5), K15–K18 (2.3), dan empat penutupan seksi 08 (2.2). Seluruh keputusan K1–K23 terpasang **kecuali K9, yang direvisi pada 3.2 menjadi 🔨: pengelolaan akun seluruhnya milik super admin, penghapusan akun menunggu enam foreign key `users.id` dibereskan, dan jalur hapus akun sendiri di halaman profil dicabut (01.6)**; seksi 10 tidak menyisakan penanda ⬜ maupun 🔨 untuk kode, dan yang menggantung tinggal dua pertanyaan cakupan di 07.2, satu batas unggahan yang tempatnya di konfigurasi server, serta tiga baris kode yang lahir dari K9 revisi. Panel detail berhenti menghitung sendiri siapa yang boleh berkomentar: `TaskResource` mengirim `can_discuss` dari `TaskPolicy::discuss()`, dan kolom tulis, tombol sunting, serta tombol hapus ketiganya menurut pada nilai itu — menutup dua arah sekaligus, member project yang dulu kehilangan kolomnya dan tombol yang dulu tampil di project beku. K21 membangun penyuntingan komentar yang route-nya selama ini hidup tanpa satu pun tombol, dengan penanda *diedit* yang diturunkan dari timestamp tanpa kolom baru. K22 menyaring penerima notifikasi task lewat policy `view` di `TaskNotifier::dispatchTo`, satu pintu untuk semua jenis, sehingga mantan anggota berhenti menerima cuplikan isi komentar. Daftar sebutan `@` pada routine work disempitkan ke rumus `view` yang sama — sebelumnya ia menawarkan seluruh anggota divisi. Tiga ketimpangan kecil ikut ditutup di 09.3: daftar hitam lampiran kini membaca isi berkas dan bukan hanya namanya, alasan penolakan lampiran sampai ke pengguna alih-alih toast generik, dan email penulis komentar berhenti dikirim ke setiap pembaca diskusi. 09.4 menutup dua berikutnya: plafon 2 MB milik jalur bukti berhenti menolak foto di kolom diskusi, dan panel membuka 30 pesan terakhir dengan pemuatan potongan lama saat digulir ke puncak — sementara jalan pintas kompresi gambar sempat dipasang lalu dibatalkan pada versi yang sama, karena encode ulang itulah yang membuang EXIF. 09.5 menyimpan nama yang disebut bersama pesannya, sehingga sorotan `@Nama` berhenti mengikuti keanggotaan hari ini; K23 memutuskan suntingan tidak memanggil yang disebut, dan **seksi 09 tidak menyisakan satu pun penanda ⬜ maupun 🔨** |
| **Menggantikan** | draf 0.5, selaras 0.6, keputusan 0.7, K7 pada 0.8, dua penjagaan pada 0.9, K6 pada 1.0, K3 revisi pada 1.1, K8 & K9 pada 1.2, K9 terpasang pada 1.3, K10 pada 1.4, K11–K14 pada 1.5, push mobile pada 1.6, tiga penjagaan invariant task pada 1.7, tiga penutupan bukti pada 1.8, empat penutupan deadline pada 1.9, empat penutupan recurring pada 2.0, hari terbit Daily pada 2.1, empat penutupan task pribadi pada 2.2, K15–K18 pada 2.3, K19 pada 2.4, K20 pada 2.5, sisiran seksi 09 beserta K21–K23 pada 2.6, sisiran seksi 10 pada 2.7, sisiran seksi 12 pada 2.8, sisiran seksi 13 pada 2.9, pencatatan gerbang izin notifikasi pada 3.0, temuan hapus akun sendiri pada 3.1, K9 revisi pada 3.2, enam penutupan seksi 03–06 pada 3.3, ketergantungan task & tab Gantt pada 3.4 |

Dokumen acuan cakupan fitur dan aturan hak akses untuk PM Scuto, diselaraskan baris
demi baris dengan kode yang benar-benar berjalan, lalu ditutup dengan keputusan atas
hal-hal yang selama ini menggantung.

---

## Kunci baca

Draf 0.5 ditulis sebagai rencana, dan sebagian isinya tidak pernah sampai ke kode.
Dokumen ini memisahkan keduanya secara tegas. Penanda di tiap sub-bagian menyatakan
keadaan implementasi di branch `dev`, bukan keinginan.

| Penanda | Arti |
| --- | --- |
| ✅ **TERPASANG** | Berjalan persis seperti yang tertulis. |
| 🟡 **SEBAGIAN** | Ada, tapi cakupan atau aturannya berbeda — bedanya dijelaskan. |
| 🔨 **DIPUTUSKAN** | Sudah disepakati, belum dibangun. **Ini antrean kerja.** |
| ⬜ **BELUM** | Belum ada di kode dan belum diputuskan akan ada. |
| ❌ **DICORET** | Ada di draf lama, sengaja tidak dibangun. |

Penandanya sengaja dibuat seragam supaya bisa dicari langsung dari terminal:

```bash
# Apa saja yang menunggu dikerjakan?
grep -n DIPUTUSKAN docs/for-developers/spesifikasi-fitur.md

# Apa yang aturannya berbeda antara dokumen dan kode?
grep -n SEBAGIAN docs/for-developers/spesifikasi-fitur.md

# Apa yang sengaja tidak dibangun, supaya tidak dikerjakan ulang?
grep -n DICORET docs/for-developers/spesifikasi-fitur.md
```

Kata penandanya sengaja huruf kapital tanpa emoji dalam pola pencarian, karena sebagian
ditulis `**DIPUTUSKAN**` (tebal) dan sebagian polos di dalam judul.

Tiga selisih terbesar dengan draf 0.5, karena ketiganya mengubah cara membaca hampir
seluruh dokumen: peran divisi **tidak** memberi akses ke project di divisinya (00.3),
**tidak ada** pengecualian pengawasan yang membuat admin divisi bisa me-review task
project orang lain (03.5), dan seluruh notifikasi berjalan **in-app plus push mobile** —
tidak ada satu pun email (11). Ketiganya sekarang sudah diputuskan, bukan lagi selisih yang
menggantung.

Satu aturan yang lahir dari keputusan itu dan menyentuh paling banyak tempat:
**viewer tidak pernah menjadi assignee, dan tidak berkomentar.** Peran lain — owner, PIC,
admin, supervisor — tetap boleh ditugaskan task. Aturannya sudah ada di kode; penjelasan
lengkapnya di 03.4 dan 04.1, konsekuensinya yang sengaja dibiarkan terbuka di 05 dan K14.

### Catatan arsitektur yang perlu dipegang sejak awal

`permission_group.json` adalah **dokumen**, bukan mesin izin. Dari seluruh isinya, hanya
nama scope `system` yang benar-benar dipakai, lewat gate `administer-system`. Seluruh
otorisasi divisi dan project sesungguhnya hidup di `ProjectPolicy`, `TaskPolicy`,
`DivisionPolicy`, dan `PersonalTaskPolicy`. **Kalau kedua sumber ini berbeda, yang
berlaku adalah policy.**

---

## Daftar isi

| # | Bagian |
| --- | --- |
| 00 | [Model Peran](#00-model-peran) |
| 01 | [Autentikasi & Akun](#01-autentikasi--akun) |
| 02 | [Divisi](#02-divisi) |
| 03 | [Project](#03-project) |
| 04 | [Task](#04-task) |
| 05 | [Review & Bukti Pengerjaan](#05-review--bukti-pengerjaan) |
| 06 | [Perpanjangan Deadline](#06-perpanjangan-deadline) |
| 07 | [Recurring Task](#07-recurring-task) |
| 08 | [Personal Task](#08-personal-task) |
| 09 | [Diskusi & Komentar](#09-diskusi--komentar) |
| 10 | [Media & Lampiran](#10-media--lampiran) |
| 11 | [Notifikasi](#11-notifikasi) |
| 12 | [Dashboard & Laporan](#12-dashboard--laporan) |
| 13 | [Activity Log](#13-activity-log) |
| 14 | [Aturan Lintas Modul](#14-aturan-lintas-modul) |
| 15 | [Keputusan & Pertanyaan Terbuka](#15-keputusan--pertanyaan-terbuka) |

Legenda matriks perizinan: **●** penuh · **◐** bersyarat · **○** tidak ada akses.

---

## 00. Model Peran

Hak akses di Scuto tidak ditentukan oleh satu kolom peran saja. Ada tiga lapis yang
dievaluasi berurutan, dan lapis terdalam selalu menjadi penentu akhir.

### 00.1 Tiga lapis otorisasi — ✅ TERPASANG

1. **Peran global** — `super_admin` atau `user` biasa. Melekat pada akun
   (`users.role`, enum `SystemRole`), mengatur user dan divisi, tidak ikut campur
   operasional project.
2. **Peran divisi** — `admin`, `supervisor`, `member`, `viewer`. Disimpan di
   `division_members.role_type`. Satu user bisa punya peran berbeda di divisi berbeda.
3. **Peran project** — `owner`, `person-in-charge`, `member`, `viewer`. Disimpan di
   `project_members.role`. Menentukan apa yang bisa dilakukan di dalam satu project.

> **Aturan pokok.** Peran divisi menentukan *ruang kerja mana yang terlihat* — divisinya,
> task divisinya, template recurring-nya. Peran project menentukan *apa yang boleh
> dilakukan di dalam sebuah project*, dan sekaligus menentukan apakah project itu terlihat
> sama sekali. Keduanya tidak bertumpuk: memimpin divisi tidak dengan sendirinya membuka
> project di dalamnya.

**Yang berjalan hari ini.** Lapis global sudah utuh: seluruh route `/admin/*` berada di
balik gate `can:administer-system`, dan menu Administration di sidebar hanya dirender bila
peran akun `super_admin`. Menyembunyikan menu bukan pengamanannya — gate itulah yang
menolak, dengan `403`, siapa pun yang mengetik URL-nya.

Akses baca menyeluruh super admin dipasang lewat `Gate::before` yang **hanya** meloloskan
ability `view` dan `viewAny`. Gate tulis — `update`, `delete`, `review`, `discuss`,
`manageMembers` — tidak masuk daftar, jadi super admin bisa membuka apa pun tanpa bisa
mengubahnya. Query daftar (project, media, recurring) punya cabang `isSuperAdmin()`
tersendiri karena scope-nya ada di query, bukan di gate.

### 00.2 Karakter peran global & divisi

| Peran | Karakter |
| --- | --- |
| **Super Admin** | Penjaga data master: user, divisi, arsip project, jejak audit. Tidak membuat, mengubah, atau menghapus project. Akses baca penuh ke seluruh Scuto — kecuali task pribadi orang lain, yang tidak pernah masuk query mana pun. |
| **Admin Divisi** | Pemilik operasional divisi. Membuat project di divisinya, mengelola task divisi dan template recurring, me-review task divisi. Tidak mengelola keanggotaan divisi, dan tidak punya akses ke project yang tidak mengikutsertakannya. |
| **Supervisor** | Setara Admin Divisi, dan itu **disengaja**: setiap pemeriksaan menulis `whereIn ['admin','supervisor']`. Perbedaannya murni label organisasi, bukan hak akses. Keputusan K2. |
| **Member** | Satu-satunya peran yang **mengerjakan** task. Melihat divisinya dan task divisi, serta project yang melibatkannya secara eksplisit. |
| **Viewer** | Pemantau murni: melihat, tidak berkomentar, dan tidak dapat ditugaskan task. Keduanya sudah ditegakkan di kode — lihat K3. |

Peran project dibahas terpisah di 03.4 karena aturannya berdiri sendiri.

### 00.3 Cakupan visibilitas — 🟡 SEBAGIAN

| Peran | Cakupan data yang terlihat | Dasar filter |
| --- | --- | --- |
| Super Admin | Seluruh divisi, project, task, media, jejak audit — baca saja. | tanpa filter |
| Admin / Supervisor divisi | Divisinya, seluruh task divisi tanpa project, seluruh template recurring dan routine work milik semua anggota. Project: **hanya yang ia ikuti atau ia buat**. | `division_members` untuk task divisi; `project_members` untuk project |
| Member / Viewer divisi | Divisinya dan task divisi tanpa project. Routine work: hanya yang ditugaskan kepadanya. Template recurring: tidak terlihat sama sekali. | `division_members` + `task_assignees` |
| Anggota project | Seluruh task project tersebut, apa pun peran project-nya — termasuk `viewer`. | `project_id IN (...)` |

Filter visibilitas memang diterapkan di query, bukan di response — `Task::scopeVisibleTo()`,
cabang keanggotaan pada daftar project, dan scope divisi pada media. Jumlah total,
pagination, dan ringkasan dashboard karena itu ikut benar.

> **Keputusan — perilaku sekarang dipertahankan.**
> Draf 0.5 menulis Admin dan Supervisor melihat *semua* project di divisinya lewat
> `division_id IN (...)`. Itu tidak pernah dibangun, dan **tidak akan dibangun**.
> `ProjectPolicy::view()` hanya mengizinkan pembuat project dan anggota project.
>
> Alasannya tegas: begitu seseorang masuk ke sebuah project, yang berlaku adalah **peran
> project**-nya, bukan peran divisinya. Admin divisi yang bergabung sebagai `member`
> tunduk pada aturan member. Dua lapis peran dijaga terpisah supaya kewenangan tidak
> merembes. Keputusan K1.
>
> Konsekuensinya diterima: admin divisi yang tidak dimasukkan ke sebuah project di
> divisinya sendiri memang tidak dapat membukanya. Opsi `assign_to_admin` saat pembuatan
> project tetap tersedia bagi yang menginginkannya.

> **Keputusan — daftar divisi memang terbuka. ✅ TERPASANG**
> Halaman `/divisions` merender `Division::all()`: setiap user yang login melihat nama,
> deskripsi, dan daftar anggota *seluruh* divisi, termasuk yang bukan divisinya. **Ini
> disengaja**, bukan kebocoran. Scuto adalah sistem internal satu kantor, dan direktori ini
> yang membuat seseorang tahu ada siapa saja di divisi lain beserta perannya — prasyarat
> untuk menyusun project lintas divisi. Keputusan K8.
>
> Yang terbuka hanya **direktori**-nya. Isi divisi — tasks, projects, members, recurring —
> tetap dijaga `DivisionPolicy::view` dan wajib anggota, dan sidebar tetap hanya menampilkan
> divisi miliknya. Membuka daftar nama bukan membuka pekerjaannya.

---

## 01. Autentikasi & Akun

Scuto adalah sistem tertutup. Tidak ada pendaftaran mandiri yang ditawarkan — akun dibuat
super admin.

### 01.1 Login — 🟡 SEBAGIAN

- Kredensial berupa **NIK dan password**. `fortify.username` disetel ke `nik`, dan
  `lowercase_usernames` dimatikan agar NIK tersimpan persis seperti yang tercatat di HR.
- Rate limit `5 percobaan / 60 detik` per kombinasi NIK + IP. Kunci throttle di-lowercase,
  jadi pembatasan tidak bisa dielakkan dengan mengganti kapitalisasi.
- Pesan error login generik untuk kredensial salah. Akun nonaktif memperoleh pesan
  tersendiri, tetapi **hanya setelah password terbukti benar** — status akun tidak bocor
  ke penebak kredensial.
- Halaman login tidak menautkan pendaftaran maupun reset password. Rute reset tetap hidup
  karena `Features::resetPasswords()` — kini **satu-satunya** fitur Fortify yang tersisa,
  lihat K11.
- ✅ **TERPASANG** `Features::registration()` sudah **dicabut** dari `config/fortify.php`.
  Route `register` dan `register.store` tidak lagi terdaftar sama sekali, `Fortify::registerView()`
  dilepas, dan halaman `auth/register.tsx` dihapus bersama `CreateNewUser` yang hanya melayaninya.
  Scuto sistem tertutup; satu-satunya jalan lahirnya akun ada di 01.2.
- Session mengikuti default Laravel: cookie `HttpOnly`, `SameSite=Lax`, regenerasi ID
  setiap login sukses.
- Middleware `EnsureUserIsActive` ikut menendang keluar sesi yang akunnya dinonaktifkan
  setelah login, bukan hanya menolak di form.

> **Keputusan — bentuk NIK dikunci. ✅ TERPASANG**
> HR menyahkan polanya: **huruf kapital, tanda hubung, lalu angka** — `^[A-Z]+-[0-9]+$`,
> misalnya `SK-000123`. Panjang kedua bagian tidak dibatasi, jadi `S-7` maupun
> `ABCDEF-99999999` sama-sama sah. Keputusan K10.
>
> **Huruf kecil ditolak, bukan dinaikkan diam-diam.** Menaikkan huruf secara otomatis membuat
> nilai tersimpan berbeda dari yang diketik, sedangkan pencocokan login bersandar pada nilai
> persis — `fortify.lowercase_usernames` sengaja dimatikan justru agar NIK tersimpan seperti
> yang tercatat di HR. Menolak lebih jujur daripada memperbaiki.
>
> Draf 0.5 menyebut `SI|SK|ST-000000`, yakni daftar prefix tertutup dan angka enam digit.
> Keduanya **tidak** ditegakkan: prefix apa pun diterima selama huruf kapital, dan jumlah
> digitnya bebas. Pola `SK-######` di factory dan seeder tetap sekadar konvensi data.
>
> Tempat penegakannya **satu**: `ProfileValidationRules::nikRules()`. Form buat user, form
> ubah user, dan perintah CLI super admin sama-sama bermuara ke sana. Uji yang menjaganya:
> `tests/Feature/Admin/UserCreationTest.php`.
>
> **Data lama tidak menyisakan pekerjaan.** Aturan ini memang hanya menjaga tulisan
> berikutnya, tetapi seluruh NIK yang sudah tersimpan sudah mengikuti pola ini — dikonfirmasi
> 3 Agustus 2026. Jadi tidak ada akun yang tiba-tiba gagal disimpan ulang lewat form ubah
> user, dan tidak ada pendataan yang perlu dijalankan. Berbeda dari K13, K10 tidak meninggalkan
> baris lama yang menyimpang sama sekali.

> **Keputusan — pembagian tugas NIK dan email.**
> **NIK adalah identitas masuk, email adalah kanal pemulihan.** Keduanya tetap wajib dan
> unik, dan pembagian ini permanen — bukan tahap transisi. Email menopang reset password,
> verifikasi akun, dan kelak notifikasi; ia tidak pernah lagi dipakai untuk login.
> Keputusan K5.
>
> Kolom NIK sendiri *nullable* di database: akun lama yang NIK-nya belum diisi tidak dapat
> masuk sampai super admin mengisinya.
>
> Satu syarat teknis yang belum terpenuhi: `.env.example` masih menyetel `MAIL_MAILER=log`.
> Selama transport surat sungguhan belum dipasang di production, tautan reset password
> hanya jatuh ke berkas log dan tidak pernah sampai ke siapa pun.

### 01.2 Pembuatan & aktivasi akun — ✅ TERPASANG

Draf membayangkan email undangan bertanda tangan. Yang dibangun lebih sederhana dan
tidak bergantung pada email sama sekali — cocok untuk sistem yang transport suratnya belum
terpasang.

```
Super admin isi form user baru
  → set password awal + wajib ganti
  → user login dengan password sementara
  → dipaksa ganti password
```

- ✅ **TERPASANG** Super admin membuat user baru **dari antarmuka**, lewat `POST /admin/users`.
  Isinya nama, NIK, email, password awal, dan penempatan divisi. Validasinya di
  `StoreUserRequest`, yang memakai ulang trait `ProfileValidationRules` dan
  `PasswordValidationRules` — trait yang sama yang dipakai form edit user dan ganti password,
  jadi aturannya tidak bercabang.
- **Penempatan divisi pada form ini dibatasi satu divisi**, dan opsional. Pemetaan ke lebih
  dari satu divisi tetap lewat dialog Division Memberships di 01.4, yang memang sudah
  menangani banyak baris sekaligus. Attach-nya menulis `ActivityLog` manual, sebab pivot
  tidak memicu event model.
- **`must_change_password` dinyalakan tanpa opsi** pada setiap akun yang lahir dari form ini.
  Password awal diketahui super admin, jadi tidak ada kondisi di mana ia layak dipertahankan.
- ✅ **TERPASANG** Kolom `must_change_password` plus middleware `MustChangePassword`
  mengunci seluruh aplikasi ke `/auth/must-change-password` sampai password diganti.
  Inilah mekanisme aktivasinya, dan ia sudah berjalan.
- ✅ **TERPASANG** Super admin dapat menyetel password (`PUT …/password`, otomatis
  menyalakan wajib-ganti) atau hanya mewajibkan penggantian pada login berikutnya
  (`PATCH …/password-requirement`).
- ❌ **DICORET** Verifikasi email. `Features::emailVerification()` dicabut lewat K11: aktivasi
  akun sudah dipegang `must_change_password`, dan model `User` memang tidak pernah
  mengimplementasikan `MustVerifyEmail` sehingga verifikasinya tidak pernah menahan siapa pun.
- ❌ **DICORET** Login via Google (OAuth). Tidak pernah dibangun, dan setelah K7 tidak ada lagi
  kebutuhan yang dilayaninya — akun hanya lahir dari form super admin. Keputusan K11.

> **Yang dicabut, bukan dipakai ulang.**
> Halaman `/register` **bukan** cikal bakal form ini. Ia rute tamu tanpa autentikasi — siapa
> pun yang tahu URL-nya bisa membuat akun untuk dirinya sendiri. Yang dipakai ulang hanya
> aturan validasinya, lewat trait bersama; halaman, action `CreateNewUser`, dan
> `Features::registration()` dihapus sekaligus. `pages/welcome.tsx` ikut dihapus karena ia
> satu-satunya pemanggil helper route `register()` dan tidak pernah dirender oleh route mana
> pun — `/` selalu mengalihkan ke login.

### 01.3 Pengelolaan user — 🟡 SEBAGIAN

Seluruh operasi tulis maupun baca daftar user adalah wewenang eksklusif super admin. Peran
lain hanya dapat melihat dan mengubah profilnya sendiri.

| Aksi | SA | ADM | SPV | MBR | Keadaan di kode |
| --- | :-: | :-: | :-: | :-: | --- |
| Buat user | ● | ○ | ○ | ○ | Form di `/admin/users`. Password awal selalu sementara. Form ini **tidak dapat membuat super admin** — lihat catatan di bawah. |
| Ubah user | ● | ○ | ○ | ○ | Nama, NIK, email. Perubahan email mengosongkan `email_verified_at`. |
| Nonaktifkan akun | ◐ | ○ | ○ | ○ | Pengganti "hapus user". Tombolnya mati untuk akun sendiri. ✅ **TERPASANG** ditolak juga bila user masih owner tunggal sebuah project aktif; project terarsip tidak menahan. |
| Hapus user | ● | ○ | ○ | ○ | 🔨 **DIPUTUSKAN, belum dibangun.** Belum ada route `destroy` di `/admin/users`. Wewenangnya super admin, dan prasyarat skemanya wajib selesai lebih dulu — lihat catatan di bawah. K9 revisi. |
| Hapus akun **sendiri** | ○ | ○ | ○ | ○ | 🔨 **DIPUTUSKAN dicabut, belum dicabut.** Jalurnya ada dan hidup di halaman profil — pengelolaan akun bukan wewenang pemiliknya. Lihat 01.6. |
| Daftar user | ● | ○ | ○ | ○ | Pencarian nama/NIK/email dan saringan aktif–nonaktif, keduanya di server. |
| Kandidat penugasan | ● | ◐ | ◐ | ○ | Bukan daftar user, melainkan tiga endpoint terpisah — lihat catatan di bawah. |
| Ubah profil sendiri | ● | ● | ● | ● | Nama, email, password, dan foto profil (01.7). NIK serta peran global tidak dapat diubah sendiri. |

> **Dari mana super admin lahir. ✅ TERPASANG**
> **Bukan dari form `/admin/users`.** `UserController::store()` sama sekali tidak menyentuh
> kolom `role`, dan `role` sengaja berada di luar `$fillable`, jadi setiap akun yang lahir
> dari antarmuka selalu `SystemRole::USER`. K7 menyangkut *super admin membuat user*, bukan
> *membuat super admin*. Ada **satu kursi** super admin, dan ketiga jalur di bawah
> menjaganya.
>
> | Jalur | Perintah | Dipakai untuk |
> | --- | --- | --- |
> | CLI, akun baru | `user:create-super-admin {nik} {name} {email}` | Instalasi baru. Password diminta lewat prompt tersembunyi, bukan argumen, agar tidak tersimpan di riwayat shell maupun daftar proses. |
> | CLI, akun yang sudah ada | `user:set-role {nik} super_admin` | Mengangkat penerus. Menolak bila kursinya masih terisi; turunkan pemegang lama dulu dengan `user:set-role {nik} user`. |
>
> **Tidak ada jalur seeder, dan itu disengaja.** Menaruh super admin di
> `migrate:fresh --seed` berarti menuliskan password akun paling berwenang ke dalam
> repositori — satu `--force` yang keliru sudah cukup untuk menanamnya di production. Jalur
> CLI dipertahankan karena passwordnya tidak pernah ada di berkas mana pun. Langkahnya
> ditulis di `README.md`.

> **Keputusan — pengelolaan akun milik super admin: nonaktifkan sekarang, hapus setelah
> skemanya siap. 🔨 DIPUTUSKAN — K9 revisi pada 3.2.**
> Yang berubah pada 3.2 bukan siapa yang berwenang, melainkan apakah penghapusan pernah
> dibangun sama sekali. Sampai 3.1 jawabannya "tidak akan pernah"; sekarang **akan**, di
> tangan super admin, setelah skemanya dibereskan lebih dulu. Yang **tidak** berubah: user
> tidak pernah menghapus akunnya sendiri — jalur itu justru sedang dicabut (01.6).
>
> Alasan penundaannya tetap sama dan ada di skema: sebagian besar jejak seseorang
> menggantung pada `users.id` lewat foreign key yang perilakunya bertolak belakang.
>
> | Kolom | `onDelete` | Akibat bila user dihapus |
> | --- | --- | --- |
> | `projects.creator_id` | `cascade` | **Seluruh project yang pernah ia buat ikut terhapus**, beserta task di dalamnya. |
> | `recurring_tasks.creator_id` | `cascade` | Template recurring divisi ikut hilang. |
> | `personal_tasks.user_id` | `cascade` | Wajar — task pribadi memang miliknya sendiri. |
> | `tasks.creator_id`, `comments.user_id`, `proofs.user_id`, `attachments.user_id` | restrict | Penghapusan **gagal** dengan galat foreign key. |
> | `activity_logs.actor_id` | `nullOnDelete` | Aman: `actor_name` sudah disalin saat penulisan, jejak audit tetap terbaca. |
>
> Menghapus akun hari ini karena itu menghasilkan salah satu dari dua keadaan buruk: project
> satu divisi lenyap karena pembuatnya resign, atau penghapusannya berhenti di tengah jalan.
> **Tabel di atas bukan lagi alasan menolak, melainkan daftar pekerjaannya** — dan urutannya
> tidak boleh dibalik: tombolnya baru boleh ada setelah keenam foreign key itu dibereskan.
>
> **Polanya sudah ada di baris terakhir tabel.** `activity_logs` selamat bukan karena
> beruntung, melainkan karena `actor_name` disalin saat penulisan dan FK-nya `nullOnDelete`:
> jejaknya tetap terbaca meski pelakunya hilang. Itu bentuk yang perlu ditiru tabel-tabel
> lain — nama pelaku didenormalisasi, FK dilepas ke `nullOnDelete`, bukan `cascade` yang
> menyeret pekerjaan orang lain ikut hilang. `projects.creator_id` adalah yang paling
> berbahaya dan harus dijawab lebih dulu: sebuah project tidak boleh mati bersama akun
> pembuatnya, sementara owner lain masih mengerjakannya.
>
> **Sampai itu selesai, nonaktifkan akun tetap jalur yang berlaku**, dan ia sudah berjalan:
> `is_active` dimatikan, `EnsureUserIsActive` menendang sesinya keluar, seluruh data dan
> riwayatnya utuh, dan namanya tetap muncul pada task lama. Penjagaan owner tunggal di bawah
> menutup satu-satunya efek samping yang berbahaya. Penonaktifan **tidak** dicabut setelah
> penghapusan dibangun: keduanya melayani hal berbeda — yang satu menutup akses, yang lain
> membuang data.
>
> Pemicu yang mempercepatnya tetap sama seperti yang dicatat sejak K9 lahir: kewajiban hukum
> menghapus data pribadi (UU PDP).

> **Selisih dengan draf 0.5 — kandidat penugasan.**
> Draf mengusulkan endpoint `GET /divisions/{id}/assignable-users`. Endpoint itu tidak ada.
> Yang terpasang tiga, semuanya di `ProjectController`: `users/search`,
> `users/eligible-members`, dan `projects/{project}/eligible-members`.
>
> Dua yang pertama menentukan divisi target dari `divisionMembers()->first()` — divisi
> *pertama* yang kebetulan tersimpan, bukan divisi yang sedang dikerjakan. Untuk user yang
> tergabung di lebih dari satu divisi, hasilnya praktis acak. Ketiganya juga ikut
> menyertakan siapa pun yang menjadi `admin` di divisi mana pun, sehingga daftar kandidat
> bisa melebihi batas divisi pemilik project.

> **Penjagaan yang menutup lubang project yatim.**
> Keputusan K1 menutup satu-satunya jalan darurat bagi project yang kehilangan
> pengelolanya, jadi jalan menuju keadaan itu harus ditutup dari hulu. `isLastOwner` sudah
> mencegah owner terakhir diturunkan atau dikeluarkan, sehingga tiap project selalu punya
> baris owner. Yang belum dijaga adalah **penonaktifan akun**: nonaktifkan owner tunggal
> sebuah project, dan project itu kehilangan seluruh pengelolanya tanpa ada yang bisa
> menggantikan.
>
> Aturannya sebenarnya sudah tertulis sejak draf 0.5 — *"ditolak jika user masih owner
> project aktif"* — dan sekarang ditegakkan di `UserController::toggleActivation()`:
> penonaktifan ditolak selama user masih menjadi satu-satunya owner sebuah project yang
> belum terhapus, dengan pesan yang menyebut project mana saja yang menahan. Project
> terarsip tidak ikut menahan, dan **reaktivasi** tidak diperiksa sama sekali.

### 01.4 Halaman `/admin/users` — ✅ TERPASANG

| Aksi | Route | Catatan |
| --- | --- | --- |
| Daftar & detail user | `GET /admin/users` | 15 baris per halaman, diurutkan nama. |
| Buat user | `POST /admin/users` | Tombol **Add User** di header. Satu transaksi: buat akun, nyalakan wajib-ganti, attach divisi bila diisi. |
| Ubah user | `PUT /admin/users/{user}` | Nama, NIK, email lewat dialog. |
| Penempatan divisi | `PUT …/memberships` | Dikirim sebagai satu *sync*; divisi yang tidak ikut dikirim dilepas. |
| Nonaktifkan akun | `PATCH …/activation` | Ditolak untuk akun sendiri. |
| Password | `PUT …/password`, `PATCH …/password-requirement` | Ganti langsung, atau wajibkan ganti saat login berikutnya. |

Seluruh alur di 01.2 kini dapat dijalankan tanpa menyentuh terminal, dan
`Features::registration()` dicabut pada perubahan yang sama sehingga tidak ada jeda di mana
pendaftaran publik masih terbuka. Uji yang menjaganya: `tests/Feature/Admin/UserCreationTest.php`
— termasuk satu uji yang gagal bila route `register` kembali terdaftar.

### 01.5 Password & sesi — 🟡 SEBAGIAN

- Aturan password bersifat **kondisional terhadap environment**. Di production: minimal
  `12` karakter, campuran huruf besar-kecil, angka, simbol, dan dicek terhadap daftar
  password bocor (`uncompromised()`). Di luar production: **tidak ada aturan sama sekali**,
  agar seeding dan pengujian lokal tidak terhambat.
- Lupa password memakai alur Fortify standar: token sekali pakai dengan masa berlaku
  bawaan `60 menit`.
- Ganti password mandiri di `/settings/password` mewajibkan password lama dan dibatasi
  `throttle:6,1`. Alur wajib-ganti setelah reset admin tidak memintanya.
- ❌ **DICORET** Autentikasi dua faktor. Sempat menyala penuh beserta layar tantangannya tanpa
  pernah disebut di dokumen mana pun; dicabut lewat K11 karena pemulihannya bersandar pada
  email, sedangkan transport surat production belum berdiri. Kolom `two_factor_*` sengaja
  dibiarkan di tabel `users` supaya menyalakannya kembali cukup satu baris di
  `config/fortify.php`.
- ❌ **DICORET** Opsi *keluar dari semua perangkat*. **Sengaja tidak dibangun.** Logout sudah
  bekerja per-sesi seperti yang diinginkan: `SESSION_DRIVER=database` menyimpan tiap perangkat
  sebagai baris sesi tersendiri, dan middleware `AuthenticateSession` tidak dipasang di rute
  web — jadi logout di laptop hanya mengeluarkan laptop, sesi HP tetap hidup. Tombol "keluar
  dari perangkat lain" adalah aksi proaktif terpisah (mis. untuk perangkat hilang); tidak ada
  kebutuhan itu, jadi tidak dibuat.

### 01.6 Jalur hapus akun sendiri yang melanggar K9 — 🔨 DIPUTUSKAN

**K9 memutuskan akun tidak pernah dihapus, hanya dinonaktifkan, dan 01.3 sampai 3.0 menulis
"tidak ada route `destroy`, dan tidak akan ada". Kalimat itu keliru.** Jalurnya ada, hidup,
dan terbuka untuk setiap user — bukan hanya super admin, yang justru satu-satunya peran yang
*tidak* punya tombolnya karena tombol itu ada di halaman profil, bukan di area admin.

| | |
| --- | --- |
| **Route** | `DELETE settings/profile` → `ProfileController::destroy()`, didaftarkan di `Shared/Routes/settings.php` yang di-`require` dari `Shared/Routes/web.php` — jadi ia benar-benar terdaftar, bukan berkas mati. |
| **Antarmuka** | `pages/settings/profile.tsx` merender komponen `DeleteUser`: kartu *"Delete account — Delete your account and all of its resources"*. Konfirmasinya hanya password sendiri (`ProfileDeleteRequest`), tanpa pemeriksaan lain. |
| **Perilakunya** | `$user->delete()` — dan `User` **tidak** memakai `SoftDeletes`, jadi ini penghapusan permanen, bukan penandaan. |

**Yang terjadi persis seperti yang dipakai K9 sebagai alasan menolak jalur ini**, dan itulah
yang membuatnya lebih dari sekadar dokumen yang tertinggal:

- `projects.creator_id` memakai `onDelete('cascade')`, jadi **seluruh project yang pernah ia
  buat ikut terhapus** beserta task di dalamnya. Satu orang resign, satu klik di halaman
  profilnya sendiri, dan pekerjaan satu divisi hilang tanpa satu pun konfirmasi yang
  menyebutnya.
- `tasks.creator_id`, `comments.user_id`, dan `proofs.user_id` memakai `constrained()` tanpa
  `onDelete`, yang berarti `restrict` — jadi bagi user yang pernah membuat task, komentar,
  atau bukti, penghapusannya **gagal di tengah jalan** dengan galat foreign key.

Hasilnya salah satu dari dua keadaan buruk yang sudah tertulis di K9 — **error mentah, atau
project lenyap** — dan mana yang didapat ditentukan oleh riwayat orangnya, bukan oleh aturan
mana pun.

**Yang diputuskan: jalur ini dicabut, bukan diperbaiki.** Bukan karena penghapusan akun
sendiri mustahil dibuat aman, melainkan karena **pengelolaan akun bukan wewenang pemiliknya**
— itu pokok K9 revisi (3.2): menonaktifkan maupun menghapus sama-sama duduk di super admin.
Seorang user yang menghapus akunnya sendiri memutuskan nasib pekerjaan orang lain tanpa
seorang pun meninjau, dan tidak ada satu pun aturan di dokumen ini yang memberinya kewenangan
sebesar itu.

Yang dihapus: route `DELETE settings/profile`, `ProfileController::destroy()`,
`ProfileDeleteRequest`, dan komponen `DeleteUser` beserta pemanggilnya di halaman profil.
Pencabutan ini **tidak menunggu** pembenahan foreign key yang menjadi prasyarat penghapusan
oleh super admin (01.3): keduanya pekerjaan terpisah, dan yang satu ini justru mendesak
karena jalurnya terbuka hari ini. Sampai penghapusan versi super admin berdiri, penonaktifan
akun lewat `/admin/users` adalah satu-satunya jalan yang tersedia.

> **Kenapa ini tidak pernah ketahuan.** Halaman profil adalah satu-satunya area yang tidak
> pernah masuk sisiran mana pun: ia bukan milik seksi fitur apa pun di dokumen ini, dan
> satu-satunya baris yang menyinggungnya — *"Ubah profil sendiri"* di 01.3 — saat itu menyebut nama,
> email, dan password, tiga hal yang memang ada di sana. Kartu keempat di halaman yang sama
> tidak pernah ditanyakan. Ini pola yang sama dengan gerbang izin notifikasi pada 3.0: yang
> luput bukan aturan yang salah dibaca, melainkan layar yang tidak pernah dibuka saat
> menyisir.

---

### 01.7 Foto profil dari koleksi preset — ✅ TERPASANG

Setiap orang memilih foto profilnya sendiri di **Settings → Profile**, tetapi hanya dari
koleksi yang sudah disediakan aplikasi. **Tidak ada unggah berkas**, dan itu keputusan
bentuk, bukan kekurangan yang menunggu ditambal.

- ✅ **TERPASANG** Kolom `users.avatar` (nullable) menyimpan **kunci** preset — `aria`,
  `milo`, dan seterusnya — bukan path, bukan URL, bukan berkas. Gambarnya statis di
  `public/avatars/{kunci}.svg` dan ikut repo. Konsekuensinya avatar adalah **aset kode,
  bukan data pengguna**: tidak butuh `storage:link`, ikut hadir di setiap environment tanpa
  langkah rilis tambahan, dan tidak pernah meninggalkan berkas yatim saat akun dinonaktifkan
  atau kelak dihapus. Berkasnya juga tidak lewat domain Media — di sana tempat berkas yang
  benar-benar diunggah orang.
- ✅ **TERPASANG** `User::AVATAR_PRESETS` adalah **satu-satunya daftar sah**. Ia dipakai dua
  arah: menjadi `Rule::in(...)` di `ProfileValidationRules::avatarRules()`, dan dikirim ke
  layar pemilih sebagai prop `avatarPresets` dari `ProfileController::edit()`. Menambah
  avatar karena itu cukup dua langkah — taruh berkasnya, tambahkan kuncinya — dan tidak ada
  tempat kedua yang bisa berselisih dengannya.
- ✅ **TERPASANG** **Daftar tertutup itu penjagaan, bukan kerapian.** Nilai kolom ini berakhir
  sebagai atribut `src` di layar setiap pembaca, jadi tanpa daftar sah, kiriman berbentuk
  `../../` maupun URL host luar ikut dirender. Diuji dengan tiga bentuk kiriman sekaligus:
  path traversal, URL luar, dan kunci asing.
- ✅ **TERPASANG** `avatarRules()` sengaja **tidak** ikut `profileRules()`, dengan alasan yang
  persis sama seperti `nikRules()` di 01.3: `StoreUserRequest`, `UpdateUserRequest`, dan
  `CreateSuperAdminCommand` sama-sama memakai ulang trait itu, padahal tidak satu pun dari
  mereka punya kolom avatar. Aturannya dipasang eksplisit di `ProfileUpdateRequest` saja.
- ✅ **TERPASANG** `null` berarti kembali ke inisial, dan inisial tetap menjadi fallback di
  **seluruh** aplikasi lewat `AvatarFallback`. Tiga keadaan karena itu jatuh mulus ke tempat
  yang sama: belum pernah memilih, berkasnya hilang dari `public/avatars/`, dan preset yang
  kelak dicabut dari daftar. Tidak ada kotak kosong, tidak ada gambar rusak. Satu uji menjaga
  arah sebaliknya — setiap kunci di `AVATAR_PRESETS` wajib punya berkas SVG-nya.
- ✅ **TERPASANG** Avatarnya tampil di **tujuh** tempat: tumpukan assignee di daftar task
  (tabel dan kartu), menu akun di sidebar dan di topbar, gelembung pesan diskusi, daftar
  kandidat sebutan `@`, panel detail task (assignee, pembuat, dan assignee subtask), tabel
  tim project, serta daftar anggota divisi. Payloadnya menumpang resource yang sudah ada —
  `AssigneeResource`, `UserResource`, `CommentResource`, `DivisionMemberResource`, dan
  endpoint `mentionables` — sehingga tidak ada jalur data baru yang lahir untuk fitur ini.
- ✅ **TERPASANG** Dua penjaga bentuk payload yang lahir dari 09.3 — pada
  `TaskDiscussionFlowTest` dan `CommentMentionTest` — ikut menyebut `avatar` di daftar kunci
  yang diizinkan. Ini sengaja dan bukan pelonggaran: yang dicabut pada 09.3 adalah **email**,
  keterangan yang bisa dipakai menghubungi orangnya, sementara kunci preset tidak menerangkan
  apa pun tentang si pemilik selain gambar yang ia pilih sendiri.
- ✅ **TERPASANG** Admin **tidak** menyetel avatar orang lain. Ia satu-satunya atribut profil
  yang sepenuhnya milik pemiliknya, dan `/admin/users` tidak punya kolomnya — sejalan dengan
  K9 revisi yang memusatkan *pengelolaan akun* di super admin tanpa ikut mengambil alih
  *tampilan diri*.

> **Jebakan yang wajib diingat saat menambah kolom user berikutnya. ✅ TERPASANG**
> Dua mapper di frontend — `mapAssignee()` di `lib/task-mappers.ts` dan
> `mapGeneratedTaskToTaskoTask()` — **tidak menyalin objek user, melainkan menyusun ulang
> objek baru field demi field.** Kolom yang tidak disebut di sana hilang diam-diam: tanpa
> error, tanpa peringatan TypeScript, dan tanpa layar yang rusak. Avatar sempat persis
> begitu — dashboard, divisi, dan project sudah menerima `avatar` dari server lalu
> membuangnya di mapper, dan kejadiannya tidak kentara justru karena fallback inisialnya
> bekerja terlalu rapi. Jalur yang sama menyentuh `creator`, bukan hanya `assignees`.
>
> Jebakan keduanya di tabel tim project: `pages/projects/show.tsx` sudah lebih dulu punya
> field bernama `avatar` yang isinya **satu huruf inisial**, dan field itu menimpa avatar
> asli yang sebenarnya sudah dikirim server. Nama yang sama untuk dua arti yang berbeda —
> sekarang field itu benar-benar berisi kunci preset.
>
> Patokan menyisir yang benar karena itu **bukan** "siapa memakai komponen `Avatar`",
> melainkan "siapa menggambar orang": dua dari tujuh tempat di atas merakit lingkaran
> inisialnya sendiri dari `<div>` biasa, dan keduanya lolos dari penyisiran yang hanya
> menghitung `AvatarFallback`.

- ⬜ **BELUM** Unggah foto sendiri, pemangkasan gambar, dan avatar yang berbeda per project.
  Ketiganya menuntut penyimpanan berkas milik pengguna, yang justru satu-satunya hal yang
  sengaja dihindari bentuk ini.
- ✅ **TERPASANG** `TeamSeeder` membagikan preset ke user contoh, deterministik dari NIK
  supaya seed ulang tidak mengocok wajah orang. Diisi **hanya saat kolomnya masih `null`**:
  seeder ini idempotent dan kerap dijalankan ulang di basis data yang sudah dipakai, jadi
  pilihan yang sudah dibuat pemiliknya tidak boleh ditimpa — beda dengan nama dan password
  yang memang sengaja ditimpa ulang dari CSV. Ditulis dengan `saveQuietly()`: `User` diaudit
  (13.1), dan lewat save biasa tiap baris CSV meninggalkan satu `user.updated` beraktor
  kosong yang isinya hanya seeder membagi nilai bawaan.

Uji: `tests/Feature/Settings/ProfileUpdateTest.php` — preset tersimpan, tiga bentuk kiriman
di luar daftar ditolak, dan setiap kunci preset punya berkasnya.

---

## 02. Divisi

Divisi adalah unit organisasi yang menjadi wadah project. Project tidak dapat berdiri
tanpa divisi, dan divisi hanya dibuat oleh super admin.

### 02.1 Aturan struktural — ✅ TERPASANG

- Satu project selalu dimiliki tepat satu divisi — `projects.division_id` tidak boleh
  kosong. Tidak ada project lintas divisi.
- Satu user dapat menjadi anggota beberapa divisi dengan peran berbeda; `division_members`
  unik per pasangan user–divisi.
- Nama divisi unik dalam lingkup Scuto. Slug dibangkitkan sekali saat dibuat dan sengaja
  **tidak ikut berubah** saat nama diperbarui, agar URL yang sudah dibagikan tidak mati.
- Setiap divisi punya **prefix** unik — wajib, maksimum 10 karakter huruf/angka tanpa
  spasi. Prefix bukan label: ia menjadi awalan kode task yang dibangkitkan
  `Task::generateCode()`, sehingga divisi berprefix `SK` menghasilkan `SK-000012`.
- **Prefix terkunci begitu divisi punya task.** Mengubahnya setelah kode terbit akan
  membuat satu divisi memiliki dua pola kode, jadi validasinya menolak nilai lain.
- Divisi tidak dapat dihapus selama masih memegang **task, project, atau template recurring
  apa pun** — termasuk routine work hasil template. Penghapusan bersifat soft delete, dan
  keanggotaannya ikut dilepas dalam satu transaksi.
- ✅ **TERPASANG** Template ikut menahan meski belum pernah menerbitkan apa-apa. Karena
  penghapusan divisi hanya soft delete, cascade foreign key tidak jalan: templatnya tetap
  aktif dan scheduler terus menerbitkan task ke divisi yang sudah hilang — tanpa satu pun
  error, dengan kode task jatuh ke awalan `TASK-` karena relasi divisinya sudah tidak
  terbaca, dan tanpa satu pun anggota tersisa yang bisa melihatnya.

### 02.2 Matriks divisi — 🟡 SEBAGIAN

| Aksi | SA | ADM | SPV | MBR | Keadaan di kode |
| --- | :-: | :-: | :-: | :-: | --- |
| Buat divisi | ● | ○ | ○ | ○ | Nama, prefix, deskripsi. Penunjukan admin pertama dilakukan terpisah lewat pemetaan anggota. |
| Ubah divisi | ● | ○ | ○ | ○ | Draf memberi Admin hak mengubah deskripsi divisinya. Belum dibangun. |
| Hapus divisi | ◐ | ○ | ○ | ○ | Menu mati selama divisi masih punya task, project, atau template recurring. |
| Daftar divisi | ● | ● | ● | ● | Halaman `/divisions` menampilkan seluruh divisi ke semua orang, **disengaja** sebagai direktori lintas divisi — K8, lihat 00.3. |
| Buka isi divisi | ● | ◐ | ◐ | ◐ | Tab tasks/projects/members/recurring dijaga `DivisionPolicy::view` — wajib anggota. |
| Tambah / keluarkan anggota | ● | ○ | ○ | ○ | Halaman Members di dalam divisi baca-saja. Satu-satunya jalur adalah `PUT /admin/divisions/{id}/members`. |
| Ubah peran anggota | ● | ○ | ○ | ○ | Ikut dalam *sync* yang sama; termasuk jabatan bebas `role_label`. |

> **Apa yang sebenarnya dijaga DivisionPolicy.**
> `DivisionPolicy` hanya punya dua method. `view` — wajib anggota divisi — menjaga keempat
> tab isi divisi. `manage` — admin atau supervisor — sama sekali tidak dipakai untuk CRUD
> divisi maupun keanggotaan; satu-satunya pemakaiannya adalah **template recurring task**:
> buat, ubah, hapus, aktif-nonaktifkan, dan terbitkan manual.

> **Keputusan — pelepasan anggota divisi ditolak selama ia masih memegang task. ✅ TERPASANG**
> Mengeluarkan seseorang dari divisi lewat *sync* hanya menyentuh `division_members`.
> Keanggotaan project-nya, penugasan task-nya, dan template recurring yang menunjuk padanya
> tetap utuh — orang yang sudah keluar dari divisi masih memegang pekerjaan di dalamnya.
>
> Yang dibangun adalah **penjagaan, bukan pembersihan otomatis**: pelepasan ditolak selama
> user masih menjadi assignee task terbuka di divisi itu, dengan pesan yang menyebut task
> mana saja yang menahan. Admin memindahkan tugasnya lebih dulu, baru melepasnya. Pola ini
> sama persis dengan `isLastOwner` dan penjagaan owner tunggal di 01.3 — menolak di hulu
> alih-alih memperbaiki di hilir, supaya pelepasan anggota menjadi keputusan sadar dan tidak
> ada assignee yang lenyap diam-diam. Keputusan K9.
>
> **Tempatnya satu, bukan dua.** Ada dua jalur yang sama-sama memanggil `sync()` langsung —
> `DivisionController::syncMembers()` (dari sisi divisi) dan `UserController::syncMemberships()`
> (dari sisi user). Penjagaan yang dipasang di salah satu controller saja meninggalkan jalur
> satunya tetap bolong, jadi ia ditulis sekali di `Team\Rules\ReleasedMemberTasks` dan dipanggil
> dari hook `after()` milik `SyncDivisionMembersRequest` dan `SyncUserMembershipsRequest`.
>
> **Yang diperiksa selisihnya, bukan isi payload.** Kedua payload bersifat menyeluruh — baris
> yang *tidak* dikirim itulah yang dilepas, jadi guard-nya membandingkan keanggotaan sekarang
> dengan payload lebih dulu. Task penahan adalah task berstatus selain `completed`/`cancelled`
> di divisi itu yang meng-assign user bersangkutan; kodenya ikut disebut di pesan error.
> **Task di project terarsip tidak menahan** — task tidak ikut terhapus saat project di-soft
> delete, jadi tanpa pengecualian ini pekerjaan yang tidak bisa dipindahkan siapa pun akan
> menahan selamanya. Aturannya sengaja disamakan dengan penjagaan owner tunggal di 01.3.
> Karena penolakannya menempel pada atribut induk (`members` / `memberships`) dan bukan pada
> baris mana pun, kedua dialog admin merender error itu di luar daftar barisnya — tanpa itu
> penolakannya tidak terlihat sama sekali.

> **Penjagaan kedua — pelaksana template recurring. ✅ TERPASANG**
> Kalimat di atas menyebut template yang menunjuk orang itu "tetap utuh", dan selama beberapa
> versi itu memang dibiarkan: penjagaannya hanya menghitung **task**, sehingga anggota yang
> belum memegang task terbuka tetap bisa dilepas walau ia satu-satunya pelaksana sebuah
> template. Akibatnya tidak berupa penolakan yang kelihatan — template tetap aktif, scheduler
> terus menerbitkan task untuk orang yang sudah tidak berhak, dan karena `TaskPolicy::view`
> mensyaratkan keanggotaan divisi, **orang itu tidak bisa membuka task-nya sendiri**.
> Pekerjaan yang tidak terlihat siapa pun, lahir lagi tiap periode.
>
> Bentuk penjagaannya sama: menolak di hulu, dari kedua jalur *sync*, lewat
> `Team\Rules\DisqualifiedTemplateAssignees` — pesannya menyebut nama template, bukan kode task.
> Dua hal yang sengaja berbeda dari penjagaan task:
>
> - **Penurunan peran ikut ditahan.** Viewer tidak pernah menjadi pelaksana (K3), jadi
>   menurunkan seseorang menjadi `viewer` sama saja dengan melepasnya sejauh menyangkut
>   templatnya. Payload utuh yang dipakai, bukan sekadar daftar id yang ikut dikirim.
> - **Template `Paused` tetap menahan.** Menjeda automation cuma satu klik dan bisa dibatalkan
>   kapan saja, jadi ia bukan padanan task yang sudah `completed` — melepaskannya hanya menunda
>   masalah yang sama sampai seseorang menekan "Resume".

---

## 03. Project

Project adalah wadah kerja utama. Super admin sengaja dikecualikan dari operasi tulis
project agar tanggung jawab eksekusi tetap berada di divisi.

### 03.1 Atribut — 🟡 SEBAGIAN

| Kolom | Aturan sebenarnya |
| --- | --- |
| `name` | Wajib, maksimum 255 karakter. Di-uppercase oleh mutator model, sehingga create, edit, duplicate, seeder, dan jalur backend lain seragam tanpa pengguna perlu menahan Caps Lock — form juga meng-uppercase sambil diketik agar yang di layar sama dengan yang tersimpan. Berlaku **sejak penyimpanan berikutnya saja**: baris lama sengaja tidak di-backfill, karena ini kemudahan mengetik dan bukan invariant data, dan nama campur huruf pada project lama diterima apa adanya. **Tidak divalidasi unik** — dua project bernama sama dalam satu divisi saat ini diterima. |
| `description` | Opsional, teks bebas tanpa batas panjang dan tanpa sanitasi HTML di server. Aman di layar karena React meng-escape saat render, bukan karena isinya dibersihkan. |
| `division_id` | Dikirim dari klien, lalu diverifikasi: pembuat harus terdaftar di divisi itu *dan* berperan admin/supervisor. Bila tidak dikirim, jatuh ke divisi pertama pembuat — asal yang perlu diperketat. |
| `due_date` | Wajib diisi saat buat maupun ubah (dikirim sebagai `deadline`). Belum ada aturan yang melarang tanggal di masa lalu. |
| `status` | Dikirim klien dan dipetakan lewat `mapStatusToBackend()`; nilai yang tidak dikenali jatuh ke `pending`, bukan `draft`. |
| `creator_id` | Diisi dari user yang login dan otomatis menjadi `owner` project. |
| `start_date` | Ada di tabel, tidak pernah diisi maupun ditampilkan. |

Draf 0.5 menyebut project punya kolom `code` yang dibangkitkan sistem. **Tidak ada.** Kode
berformat prefix hanya dimiliki *task* (04.1).

### 03.2 Status & lifecycle — 🟡 SEBAGIAN

Status yang dapat dicapai dari antarmuka:

`draft` · `todo` · `pending` · `in_progress` · `on_hold` · `completed` · `cancelled`

Ada di enum tetapi tidak terjangkau pemetaan status, jadi praktis mati:
`active` · `archived` · `inactive`

- **Tidak ada mesin status.** Owner dan PIC bebas memindahkan project ke status mana pun
  yang tersedia; hanya dua penjagaan yang berlaku.
- Penjagaan pertama: `completed` ditolak selama masih ada task — termasuk subtask — yang
  belum `completed` atau `cancelled`. Pesan errornya menyebut berapa tugas utama dan
  berapa sub-tugas yang menahan.
- Penjagaan kedua: kembali ke `draft` atau `todo` ditolak bila sudah ada task yang pernah
  dikerjakan.
- Ada pula **dua transisi otomatis** yang dipicu perubahan status task: seluruh task
  selesai atau dibatalkan → project menjadi `completed`; satu task mulai dikerjakan
  sementara project masih `draft`/`todo`/`completed` → project menjadi `in_progress`.
- ❌ **DICORET** Syarat "minimal satu anggota dan satu task" sebelum project aktif.
  **Sengaja tidak dibangun.** Separuh syarat mustahil terjadi: pembuat selalu otomatis jadi
  `owner` dan `isLastOwner` melarang owner terakhir dikeluarkan, jadi project tidak pernah
  punya nol anggota. Separuh sisanya (harus ada task) hanya mencegah keadaan kosmetik —
  transisi otomatis ke `in_progress` sudah mensyaratkan sebuah task mulai dikerjakan — sambil
  menambah friksi. YAGNI.

> **Status terminal membekukan isi project — ✅ TERPASANG**
> Project `completed` dan `cancelled` bersifat **baca-saja**: task tidak bisa dibuat, diubah,
> dihapus, digerakkan statusnya, diunggahi bukti, di-review, dikomentari, maupun diajukan
> perpanjangan deadline. `Project::isFrozen()` adalah sumber kebenarannya, dan penjagaannya
> disebar ke jalur tulis lewat `TaskPolicy` (`create`, `update`, `delete`, `updateStatus`,
> `uploadProof`, `discuss`, `review`/`decideDeadline`, `requestDeadline`) — satu guard di tiap
> ability, karena semua tulis task/komentar/bukti bermuara ke sana.
>
> **Satu-satunya jalan menggarapnya lagi: owner mencairkan project lewat perubahan status.**
> Perubahan status project adalah aksi level-project (bukan konten), jadi ia sengaja tidak
> ikut dibekukan — itulah pintu keluarnya. Buka-beku ini dipersempit dua lapis di
> `ProjectController::updateStatus`:
> - **Owner saja** — lebih ketat dari gate `update` (owner/PIC). PIC tidak dapat mencairkan
>   project terminal; keputusan ini menjaga buka-ulang tetap di bawah pengawasan owner.
> - **Ditolak bila deadline sudah lewat** — keluar dari terminal ke status aktif dilarang
>   selama `due_date` project ada di masa lalu, agar project tidak terbuka dalam keadaan
>   langsung-overdue. Owner memperbarui deadline ke masa depan dulu, baru membuka.
>
> `cancelled` **reversible** — owner boleh menghidupkannya lagi, tunduk dua lapis yang sama.
> Buka-ulang **task** individual setelah project cair tetap wewenang owner/PIC (04.3), bukan
> assignee. Uji yang menjaganya: `tests/Feature/ProjectTerminalFreezeTest.php`.

> **Jeda `on_hold` menggeser deadline — ✅ TERPASANG**
> `on_hold` menahan pekerjaan, tetapi **tidak menahan kalender**. Tanpa penjagaan ini deadline
> diam di tanggalnya sementara waktu berjalan, sehingga task pelaksana jatuh overdue karena
> keputusan organisasi menjeda project — bukan karena ia lambat.
>
> Karena "overdue" di seluruh aplikasi dihitung murni dari `due_date` versus waktu sekarang
> (`ProjectTaskSummary`, `ProjectTaskFilters`), memperbaikinya cukup di satu titik: saat
> project dilanjutkan, **deadline project dan seluruh task terbuka digeser maju sebesar durasi
> jeda**. Badge, ringkasan dashboard, filter, dan notifikasi H-1 ikut adil tanpa perubahan apa
> pun di tempat-tempat itu. Delta yang sama dipakai untuk project dan task, jadi invariant
> deadline (task ≤ project, subtask ≤ induk) tetap terjaga.
>
> Kolom `projects.on_hold_at` mencatat awal jeda; `Project::resumeFromHold()` yang menggeser
> lalu mengosongkannya. Pemicunya **satu**, yaitu `ProjectController::updateStatus` — transisi
> otomatis di `Task` sengaja tidak pernah memindahkan project keluar dari `on_hold` (hanya dari
> `draft`/`todo`/`completed`), jadi tidak ada jalur kedua yang perlu dijaga.
>
> Yang **tidak** ikut digeser: task `completed`/`cancelled`, task tanpa deadline, dan **task
> yang lahir di tengah jeda** — deadline task terakhir itu ditetapkan dengan sadar saat jeda
> berlangsung, jadi ia tidak pernah kehilangan waktu. Jeda yang berakhir di status terminal
> hanya membersihkan penandanya, sebab tidak ada pekerjaan tersisa yang perlu dibayar balik.
>
> **Batas yang disadari.** Durasi dihitung dalam hari penuh (`due_date` memang date-granular),
> jadi jeda di bawah 24 jam tidak menggeser apa pun. Deadline digeser di tempat sehingga
> tanggal aslinya hanya tersisa di jejak audit, dan tidak ada ledger akumulasi waktu jeda —
> naikkan ke tabel `project_holds` (start, end) bila kelak butuh laporan durasi jeda. Task yang
> lahir sebelum jeda tetapi deadline-nya diedit saat jeda tetap ikut tergeser; melacaknya butuh
> waktu-edit-deadline dan tidak sebanding ongkosnya. Uji yang menjaganya:
> `tests/Feature/ProjectHoldDeadlineShiftTest.php`.

> **Project terhapus — `/admin/archives`.**
> Penghapusan project bersifat *soft delete*. `/admin/archives` menampilkan project
> terhapus beserta divisi, jumlah task, waktu penghapusan, dan pelakunya — nama pelaku
> diambil dari jejak audit karena tabel project tidak menyimpannya.
>
> **Pulihkan** cukup mengembalikan barisnya; task memang tidak pernah ikut terhapus, hanya
> tersembunyi lewat relasi project. **Hapus permanen** menghapus task induk satu per satu
> agar tiap penghapusan tercatat di audit dan cascade membersihkan komentar, bukti, serta
> lampiran; berkas fisiknya baru dihapus setelah transaksi commit.
>
> Keduanya eksklusif super admin lewat gate `administer-system`. Method `restore` dan
> `forceDelete` di `ProjectPolicy` tidak dipakai jalur ini.

> **Keputusan — retensi 30 hari. ✅ TERPASANG**
> Project terhapus **dibersihkan permanen setelah 30 hari**. Command
> `projects:purge-archived` berjalan `dailyAt('02:00')` dengan `withoutOverlapping()`,
> terdaftar di `routes/console.php`. Angkanya konstanta `RETENTION_DAYS` di command itu —
> tidak ada opsi CLI untuk menimpanya, karena satu-satunya pemanggil adalah scheduler.
> Keputusan K6.
>
> **Jebakannya ditutup dengan tidak menulis jalur kedua.** Isi
> `ArchivedProjectController::destroy()` dipindah ke `PurgeProjectAction`, dan command
> memanggil action yang sama persis: kumpulkan path berkas lebih dulu, hapus task induk satu
> per satu lewat model supaya tiap penghapusan tercatat di audit dan cascade membersihkan
> turunannya, baru `forceDelete` project-nya, dan berkas Drive dihapus setelah transaksi
> commit. Memanggil `Project::onlyTrashed()->forceDelete()` secara langsung akan
> meninggalkan bukti dan lampiran yatim di Drive tanpa satu pun baris yang menunjuk padanya
> — karena itu jalur ini sengaja hanya ada di satu tempat.
>
> Iterasinya `eachById()` — bukan `each()` — supaya baris yang hilang di tengah jalan tidak
> menggeser potongan berikutnya. Tiap project punya transaksinya sendiri, jadi satu
> kegagalan tidak membatalkan sisanya.

### 03.3 Matriks project — 🟡 SEBAGIAN

| Aksi | SA | ADM | SPV | MBR | Catatan |
| --- | :-: | :-: | :-: | :-: | --- |
| Daftar & detail project | ● | ◐ | ◐ | ◐ | Selain SA: wajib pembuat atau anggota project. Peran divisi tidak membuka apa pun. |
| Buat project | ○ | ◐ | ◐ | ○ | Harus admin/supervisor pada divisi tujuan. Pembuat otomatis menjadi `owner`. |
| Ubah project | ○ | ◐ | ◐ | ◐ | Bergantung peran *project*: owner atau PIC — bukan owner saja. |
| Hapus project | ○ | ◐ | ◐ | ◐ | Owner project saja. Soft delete; pemulihan lewat `/admin/archives`. |
| Duplikat project | ○ | ◐ | ◐ | ◐ | Owner atau PIC — bukan owner saja. |
| Kelola anggota project | ○ | ◐ | ◐ | ◐ | Owner atau PIC. Kandidat = divisi pemilik + divisi tiap anggota yang sudah masuk + admin divisi mana pun, dan **ditegakkan server-side** — lihat handshake lintas-divisi di 03.4. |
| Ubah status project | ○ | ◐ | ◐ | ◐ | Owner atau PIC, dengan dua penjagaan di 03.2. |

Kolom ADM/SPV di tabel ini menyesatkan kalau dibaca sendirian: setelah *buat project*,
peran divisi berhenti berpengaruh. Semua baris di bawahnya sebenarnya ditentukan peran
*project*, dan seorang member divisi yang menjadi owner sebuah project memegang seluruh
haknya.

### 03.4 Peran di dalam project — 🟡 SEBAGIAN

Keanggotaan project disimpan di `project_members` dan berdiri sendiri dari peran divisi.
Seorang Admin divisi bisa saja hanya menjadi `viewer` pada satu project, dan seorang member
divisi bisa menjadi `person-in-charge` di project lain.

| Peran | Arti |
| --- | --- |
| `owner` | Penanggung jawab project, otomatis melekat pada pembuatnya. Satu-satunya peran yang boleh menghapus project dan satu-satunya yang boleh menyentuh owner lain. **Tidak dijamin tunggal**: opsi `assign_to_admin` mengangkat seluruh admin divisi sebagai owner sekaligus. Yang dijaga hanya owner *terakhir* — ia tidak bisa diturunkan atau dikeluarkan. |
| `person-in-charge` | Pengelola harian eksekusi. Membuat dan menugaskan task, menyetujui review, memutuskan pengajuan perpanjangan deadline, menggerakkan status project — dan di kode saat ini **juga boleh mengubah identitas project serta menduplikasinya**. Yang tertutup baginya hanya menghapus project dan menyentuh anggota berperan owner. |
| `member` | Peran pelaksana utama. Mengerjakan yang ditugaskan kepadanya, mengunggah bukti, berdiskusi, dan mengajukan perpanjangan deadline. Tidak membuat task, tidak menugaskan orang lain, tidak menyetujui apa pun. |
| `viewer` | Pemantau murni: membaca, tanpa satu pun aksi tulis — tidak berkomentar, tidak dapat ditugaskan task. ✅ **TERPASANG** keduanya. Lihat K3. |

> **Owner dan PIC boleh merangkap pelaksana.**
> Ini hasil revisi K3: yang tertutup dari penugasan hanya `viewer`. Owner dan PIC tetap
> dapat ditugaskan task di project yang mereka kelola, jadi project berisi dua orang dengan
> satu owner dan satu PIC tetap punya pelaksana.
>
> Harganya adalah perangkapan pengawas–pelaksana: karena keduanya juga reviewer, seorang PIC
> dapat menugaskan task kepada dirinya sendiri lalu menyetujuinya sendiri. `TaskPolicy::review()`
> tidak membandingkan pemutus dengan daftar assignee, dan itu **disahkan** lewat K14 — bukan
> celah yang tertinggal.

**Matriks peran project** — sesuai kode setelah K3 terpasang:

| Aksi di dalam project | Owner | PIC | Member | Viewer |
| --- | :-: | :-: | :-: | :-: |
| Baca project & task | ● | ● | ● | ● |
| Ubah nama / deskripsi / deadline project | ● | ● | ○ | ○ |
| Ubah status project | ● | ● | ○ | ○ |
| Duplikat project | ● | ● | ○ | ○ |
| Hapus project | ● | ○ | ○ | ○ |
| Kelola anggota project | ● | ◐ | ○ | ○ |
| Buat / hapus task | ● | ● | ○ | ○ |
| Tugaskan assignee | ● | ● | ○ | ○ |
| Ditugaskan task, kerjakan, unggah bukti | ◐ | ◐ | ◐ | ○ |
| Setujui review | ● | ● | ○ | ○ |
| Putuskan perpanjangan deadline | ● | ● | ○ | ○ |
| Berkomentar | ● | ● | ● | ○ |

Seluruh tabel ini sudah sesuai kode — **termasuk di layar**, yang sampai 2.6 tidak benar untuk
baris *Berkomentar*: panel detail menyembunyikan kolom komentarnya dari member yang tidak
ditugaskan, jadi hak yang tertulis ● di sini tidak pernah sampai ke tangannya. Lihat 09.1.
*Ditugaskan task* bersyarat bagi owner, PIC, dan member
karena yang menentukan adalah apakah ia terdaftar sebagai assignee task itu, bukan perannya
semata; hanya viewer yang tertutup mutlak. *Kelola anggota* bersyarat bagi PIC hanya dalam
satu hal: ia tidak boleh menyentuh anggota berperan owner. Selebihnya ia bebas, termasuk
mengangkat PIC baru.

**Siapa boleh menetapkan peran apa**

| Pemberi peran | Peran yang boleh ia berikan | Batasan yang benar-benar ditegakkan |
| --- | --- | --- |
| Owner project | `person-in-charge`, `member`, `viewer` | Peran `owner` tidak dapat diberikan lewat endpoint mana pun — kepemilikan hanya lahir saat project dibuat. |
| Person-in-charge | `person-in-charge`, `member`, `viewer` | Tidak boleh mengubah atau mengeluarkan anggota berperan owner. Selain itu tidak dibedakan dari owner. |
| Admin divisi (bukan anggota project) | — | Tidak berwenang. Draf 0.5 memberinya hak ini; dicoret lewat K1. |
| Member / Viewer | — | Tidak berwenang. |

`permission_group.json` mencantumkan batasan `must_be_inviter` untuk PIC dan
`same_division_only` untuk penambahan anggota. Kolom `invited_by` memang tersimpan, tetapi
tidak ada satu pun pemeriksaan yang membacanya — `must_be_inviter` **belum ditegakkan**.
`same_division_only` **digantikan** oleh aturan pool lintas-divisi di bawah, bukan lagi
"divisi yang sama saja".

> **Keanggotaan project boleh lintas divisi — lewat handshake admin. ✅ TERPASANG**
> Ini menyempurnakan model dua-lapis, bukan melanggarnya: `projects.division_id` tetap
> tunggal, yang menyeberang divisi hanya *keanggotaan* project-nya, dan itu terjadi lewat
> jalur eksplisit (project membership), bukan karena peran divisi menembus project (K1 aman).
>
> Seorang user boleh ditambahkan sebagai anggota project bila salah satu terpenuhi:
> **(a)** anggota divisi pemilik project, **(b)** anggota divisi yang sudah terwakili di
> antara anggota project saat ini, atau **(c)** admin divisi mana pun. Klausa (c) adalah
> *pintu masuk*-nya: owner/PIC meng-invite admin divisi lain lebih dulu (umumnya sebagai
> PIC), lalu divisi itu "terbuka" lewat (b) sehingga admin tersebut dapat menarik anggota
> divisinya sendiri. Tanpa (c), langkah pertama handshake mustahil.
>
> **Ditegakkan server-side, bukan sekadar disaring di dropdown.** Aturannya hidup di satu
> tempat — `Project\Rules\EligibleProjectMember` — dan dipanggil dua jalur tulis:
> `ProjectController::store()` (array `members` saat create, pool = divisi tujuan + admin)
> dan `ProjectController::addMember()` (pool `forProject`). Query kandidat
> `getEligibleMembersForProject` memakai `allowedDivisionIds()` yang sama agar dropdown dan
> penegakan tidak pernah berbeda. Uji yang menjaganya:
> `tests/Feature/ProjectCrossDivisionMemberFlowTest.php`.
>
> Sebelum ini, `addMember` dan `store` hanya memvalidasi `exists:users,id` — siapa pun yang
> boleh `manageMembers` dapat menambahkan user mana pun di seluruh kantor. Batas divisi
> keanggotaan project dulu hanya sugesti UI; kini benar-benar dijaga.

### 03.5 Hubungan peran divisi dan peran project — ✅ TERPASANG

Ini titik yang paling banyak berubah dari draf 0.5, dan sekarang sudah selesai diputuskan:
dua lapis peran memang berdiri terpisah, dan kode yang ada sudah benar. Yang berubah adalah
dokumennya.

- **Peran divisi tidak memberi visibilitas atas project.** Admin dan Supervisor hanya
  melihat project yang mengikutsertakan mereka atau yang mereka buat sendiri.
- **Aksi tulis butuh keanggotaan project** — tanpa pengecualian. Admin divisi yang bukan
  anggota project tidak bisa membaca, apalagi menulis.
- **Begitu masuk project, yang berlaku adalah peran project.** Admin divisi yang bergabung
  sebagai `member` tunduk penuh pada aturan member — jabatannya di divisi tidak ikut
  terbawa.
- ❌ **DICORET** *Pengecualian pertama* pada draf: admin divisi selalu boleh mengelola
  keanggotaan project di divisinya. Tidak dibangun, dan tidak akan dibangun — kewenangan
  tidak boleh merembes antarlapis.
- ❌ **DICORET** *Pengecualian kedua* pada draf: admin dan supervisor boleh menyetujui
  review dan perpanjangan deadline pada project di divisinya. Tidak dibangun.
  `TaskPolicy::review()` memang hanya jatuh ke peran divisi ketika task *tidak* punya
  project, dan itu perilaku yang benar.
- **Peran project tidak pernah melampaui project itu.** Menjadi owner di satu project tidak
  memberi hak apa pun di project lain, sekalipun satu divisi.
- **Peran lebih tinggi di satu lapis tidak menurunkan pembatasan di lapis lain.** Super
  admin tetap tidak bisa menulis project meski melihat semuanya.

> **Harga dari kemurnian ini.**
> Memisahkan kedua lapis secara ketat berarti tidak ada jalan darurat sama sekali bagi
> project yang kehilangan pengelolanya. Karena itu penjagaannya dipindahkan ke hulu — 01.3
> menolak penonaktifan akun yang masih menjadi owner tunggal sebuah project, dan
> `isLastOwner` sudah menolak penurunan maupun pengeluaran owner terakhir. Selama dua
> penjagaan itu berdiri, keadaan yatim tidak bisa tercapai.

> **Urutan pemeriksaan izin — yang berlaku sekarang.**
> Draf mengusulkan tiga langkah dengan `404` untuk objek di luar cakupan, supaya keberadaan
> project tidak bocor lewat pesan error. Yang berjalan hanya dua langkah, dan keduanya
> menjawab `403`. Perbedaan `403` versus `404` memang membocorkan bahwa sebuah project ada
> — perlu diputuskan apakah itu masalah nyata di sistem internal tertutup, atau aturan yang
> lebih baik dicoret dari dokumen.

### 03.6 Duplikat project — 🟡 SEBAGIAN

Duplikasi menyalin kerangka kerja, bukan riwayatnya — tetapi apa yang ikut tersalin lebih
banyak diatur pengguna daripada yang dibayangkan draf.

| Elemen | Ikut disalin | Perlakuan sebenarnya |
| --- | --- | --- |
| Nama | Diisi ulang | Wajib diketik pengguna. Tidak ada sufiks `(Copy)` otomatis, dan keunikannya tidak diperiksa. |
| Deskripsi | Ya | Disalin apa adanya. |
| Deadline & start date | **Tidak** | Sengaja dikeluarkan dari `replicate()`, jadi project baru lahir tanpa deadline. |
| Task & subtask | Ya | Seluruh pohon disalin, status direset ke `waiting`, kode task dibangkitkan ulang. |
| Assignee task | Opsional | Ikut hanya bila `copyTaskAssignees` dicentang. |
| Anggota project | Opsional | Ikut bila `copyMembers` dicentang, lengkap dengan perannya. Bila tidak, hanya pembuat duplikat sebagai `owner`. |
| Komentar & diskusi | Tidak | Tidak disalin. |
| Lampiran & bukti | Tidak | Tidak disalin. Menghindari duplikasi berkas di storage. |
| Activity log | Tidak | Project baru memulai log sendiri. |
| Status | Reset | Selalu `pending`, bukan `draft`. |

Duplikasi berjalan dalam satu transaksi database, tetapi **sinkron** — tidak ada job
antrean untuk project besar. Untuk project berisi ratusan task, permintaannya akan menahan
koneksi HTTP sampai selesai.

---

## 04. Task

Unit pekerjaan terkecil yang dapat ditugaskan. Task hidup di dua konteks: di dalam project,
atau langsung di divisi tanpa project.

### 04.1 Atribut — ✅ TERPASANG

| Kolom | Aturan |
| --- | --- |
| `name` | Wajib, maksimum 255 karakter. Kolomnya bernama `name`, bukan `title`. |
| `description` | Opsional, maksimum 10.000 karakter, tanpa sanitasi HTML di server. |
| `code` | Dibangkitkan sistem per divisi: `PREFIX-000001`. Nomornya dihitung dari jumlah task divisi, dan penyisipan diulang hingga tiga kali bila tabrakan `unique` terjadi. |
| `status` | `waiting` · `in_progress` · `review` · `completed` · `on_hold` · `cancelled` |
| `priority_level` | `1` Low · `2` Medium · `3` High · `4` Urgent. Default `2`. |
| `assignee` | Pivot `task_assignees`, dibatasi **maksimum satu orang**. Boleh kosong. Wajib anggota konteksnya, dan ✅ **TERPASANG** tidak boleh berperan `viewer` di konteks itu. Peran lain — termasuk owner, PIC, admin, dan supervisor — tetap boleh. Lihat K3. |
| `due_date` | Opsional selama task belum punya assignee; wajib ketika assignee dipasang. Saat diubah: wajib di masa depan — kecuali nilai warisan subtask, yaitu tanggal yang persis sama dengan deadline induknya, agar induk yang telat tetap bisa dipecah — tidak boleh mendahului waktu mulai, tidak boleh melewati deadline task induk maupun deadline project, dan ✅ **TERPASANG** tidak boleh lebih awal dari deadline subtask yang masih terbuka. Terkunci saat task `review` atau `completed`. |
| `required_proof_type` | **Wajib** pada task utama: `file`, `image`, atau `link`. Dipaksa `null` pada subtask. Inilah jawaban atas "wajib bukti diatur di mana" — per task. |
| `parent_id` | Menunjuk task induk. Hanya satu tingkat, dan tidak dapat diubah lewat endpoint update. |
| `recurring_task_id` | Terisi pada routine work hasil template. Task project tidak boleh memilikinya — kombinasinya ditolak validasi. |

> **Di mana aturan "assignee bukan viewer" ditegakkan.**
> Di **satu titik**, yaitu penugasan: `validateProjectAssignees()` dan
> `validateDivisionAssignees()` pada `TaskController`, ditambah `AssigneesBelongToDivision`
> yang dipakai kedua request recurring. Ketiga jalur penugasan task — buat, ubah, dan subtask
> — bermuara ke dua fungsi pertama, jadi penjagaannya tidak perlu disebar ke `updateStatus`,
> `uploadProof`, maupun `requestDeadline`. Larangan berkomentar bagi viewer berdiri terpisah
> di `TaskPolicy::discuss()`.

> **Assignee selalu membawa deadline.** Setiap request yang memasang assignee
> ditolak bila deadline final kosong, apa pun status task-nya. Pengecualian hanya
> untuk perubahan status tanpa payload assignee: data lama yang telanjur
> berassignee tanpa deadline tetap boleh dibatalkan atau diselesaikan agar tidak
> terkunci selamanya. Uji: `tests/Feature/TaskDateValidationFlowTest.php`.

> **Aturan deadline induk–subtask berlaku dua arah.**
> Sampai 1.6 aturan "subtask ≤ induk" hanya diperiksa saat yang diedit **subtask**-nya.
> Memajukan deadline **induk** ke sebelum deadline subtask lolos tanpa perlawanan, dan
> hasilnya bukan sekadar angka janggal: task induk tidak pernah bisa masuk `review` selama
> subtask belum selesai (04.2), jadi deadline seperti itu **dijamin terlewat sejak
> disimpan** — sementara subtask-nya sendiri baru ditandai overdue belakangan, jadi tidak
> ada satu pun sinyal yang sampai ke pelaksana. Cerminnya sekarang ada di
> `TaskController::validateTaskDates()`, tepat di bawah penjagaan arah sebaliknya.
>
> **Subtask yang sudah `completed`/`cancelled` sengaja tidak ikut menahan.** Jadwalnya sudah
> jadi riwayat, mengikuti rumus overdue di seluruh aplikasi (04.3). Memblokir penjadwalan
> ulang induk karena subtask yang selesai bulan lalu hanya membuat PIC mentok tanpa alasan.
> Jangan diperketat diam-diam sebagai "kebocoran yang terlewat" — ada uji yang mengunci
> perilaku ini. Uji: `tests/Feature/TaskDateValidationFlowTest.php`.
>
> Alur perpanjangan deadline (06) tidak menyisakan lubang yang sama: di sana tanggal baru
> wajib **lebih lambat** dari yang berlaku, jadi tidak ada jalan memajukan lewat sana.

### 04.2 Hierarki subtask — ✅ TERPASANG

- Kedalaman maksimum **satu tingkat**: task induk → subtask. Percobaan menyusun lebih dalam
  ditolak dengan pesan validasi yang jelas.
- Subtask wajib memakai konteks yang persis sama dengan induknya — divisi, project, dan
  recurring template yang sama.
- ✅ **TERPASANG** Memindahkan task induk antarkonteks **membawa seluruh subtask-nya ikut**,
  di dalam transaksi yang sama. Sebelumnya subtask tertinggal di project lama sementara
  induknya sudah pindah — dan karena detail task memuat subtask lewat `parent_id`, isinya
  ikut terlihat oleh member project tujuan yang bukan anggota project asal. Invariant di
  atas dengan begitu tidak bisa lagi dibobol dari sisi induk.
  - **Assignee diperiksa ulang terhadap konteks tujuan, termasuk yang tidak dikirim.**
    Keanggotaan yang sah di konteks asal belum tentu sah di konteks baru, dan request yang
    memindahkan task tanpa menyertakan `assignee_ids` dulu meninggalkan assignee lama di
    project yang tidak memuatnya. Yang diperiksa adalah assignee *final* — payload bila
    dikirim, isi tabel bila tidak — pola yang sama dengan penjagaan assignee di 04.3.
  - **Hanya saat berpindah, bukan di tiap update.** Menyalakan pemeriksaan itu di setiap
    penyuntingan membuat task lama ber-assignee viewer tidak bisa disentuh sama sekali,
    bahkan untuk ganti nama — bertentangan dengan K13 yang membiarkan baris lama apa adanya.
  - Penolakan membatalkan **seluruh** perpindahan, bukan menyisakan pohon yang setengah
    pindah. Uji: `tests/Feature/TaskContextFlowTest.php`.
- **Hanya assignee task induk yang boleh membuat subtask.** Subtask adalah breakdown kerja
  pelaksananya, bukan alat pembagian tugas oleh atasan.
- Assignee subtask boleh siapa pun anggota konteks task induk, tidak harus assignee
  induknya.
- Subtask tidak mengenal review: memindahkannya ke `review` ditolak, ia diselesaikan
  langsung ke `completed`.
- Task induk tidak dapat masuk `review` maupun `completed` selama masih ada subtask yang
  belum `completed` atau `cancelled`.
- Membatalkan task induk otomatis membatalkan seluruh subtask yang masih terbuka, rekursif,
  dalam satu transaksi.
- Seluruh pohon **terkunci** selama ada leluhur yang berstatus `review`; subtask tidak bisa
  dibuat, diubah, maupun dihapus sampai atasan memutuskan.
- **Routine work tidak boleh punya subtask.** Ditegakkan di validasi pembuatan. Lihat 07.2.

### 04.3 Alur status — 🟡 SEBAGIAN

`waiting → in_progress → review → completed`

- ✅ **TERPASANG** Task **selalu lahir `waiting`**. Endpoint pembuatan tidak menerima
  `status` sama sekali; nilainya datang dari default kolomnya. Sampai 1.6 field itu diterima
  bebas, dan karena ketiga penjagaan status di bawah hanya terpasang di `update()` dan
  `updateStatus()`, pembuatan task adalah **jalur ketiga yang melewati semuanya**: task bisa
  lahir langsung `completed` tanpa review dan tanpa `completed_date`, atau `in_progress`
  tanpa assignee — persis pekerjaan yatim yang ditutup penjagaan di bawah.
  - **Yang benar di sini adalah menghapus, bukan menambah guard.** Antarmuka tidak pernah
    menawarkan pilihan status saat membuat task, jadi begitu field-nya tidak diterima,
    ketiga penjagaan itu tidak perlu disalin ke jalur pembuatan. Subtask yang memang selalu
    dipaksa `waiting` ikut kehilangan baris pemaksaannya.
  - Uji: `tests/Feature/TaskContextFlowTest.php`.
- `completed` **tidak dapat disetel langsung oleh siapa pun**, termasuk owner project.
  Satu-satunya jalan adalah persetujuan review.
- Perpindahan ke `review` ditolak bila `required_proof_type` terisi dan belum ada satu pun
  bukti terunggah.
- Penolakan review mengembalikan task ke `in_progress` dan mewajibkan alasan.
- Deadline terkunci begitu task masuk `review` atau `completed`.
- Assignee tidak dapat membuka kembali task yang sudah `completed`; owner/PIC atau
  admin/supervisor divisi masih bisa.
- ✅ **TERPASANG** `in_progress` mensyaratkan adanya assignee. Task tanpa assignee ditolak
  saat dipindahkan ke `in_progress`, sebab pekerjaan berjalan tanpa pemilik tidak muncul di
  "task saya" siapa pun dan notifikasi H-1-nya tidak sampai ke siapa pun — pekerjaan yatim
  yang tidak terlihat. Pola penjagaannya sama dengan owner tunggal project (01.3) dan
  pelepasan anggota divisi (02.2): tutup dari hulu.
  - **Ditulis sekali, dipakai dua jalur.** Status task berubah lewat `update()` *dan*
    `updateStatus()`; guard di salah satunya saja meninggalkan jalur satunya bolong, jadi
    aturannya hidup di `TaskController::assigneeRequiredError()`.
  - **Assignee boleh disetel di request yang sama.** Yang diperiksa adalah assignee *final* —
    payload bila `assignee_ids` dikirim, isi tabel bila tidak — sehingga menugaskan orang
    sekaligus memulai task dalam satu simpan tetap lolos selama deadline ikut tersedia.
  - **Yang dijaga transisi, bukan keadaan.** Task lama yang sudah terlanjur `in_progress`
    tanpa assignee tetap dapat diedit, sejalan dengan K13 yang membiarkan baris lama apa
    adanya. Uji: `tests/Feature/TaskAssigneeRequiredForProgressTest.php`.
- ❌ **DICORET** `on_hold` yang "kembali ke status sebelumnya". **Sengaja tidak dibangun.**
  Menyimpan status sebelumnya menambah kolom dan logika hanya untuk menghemat satu klik, dan
  hasilnya bisa janggal — task yang sebelumnya `review` akan kembali ke `review` tanpa bukti
  baru. Memilih status tujuan secara manual lebih prediktabel. `on_hold` tetap perpindahan
  biasa.
  - **Task `on_hold` tetap dihitung overdue, dan itu diterima.** Rumus overdue di seluruh
    aplikasi adalah `status bukan completed/cancelled DAN due_date < now`, jadi task yang
    dijeda ikut terhitung. Penggeseran deadline sepadan `on_hold` memang dibangun di tingkat
    **project** (03.2), tetapi tidak diturunkan ke tingkat task karena jedanya jarang dan
    pendek. Jangan menutupnya diam-diam sebagai bug yang terlewat.

### 04.4 Matriks task — 🟡 SEBAGIAN

Kolom *ADM / SPV* merujuk peran divisi dan **hanya berlaku pada task divisi tanpa project**.
Untuk task di dalam project, peran divisi tidak memberi apa pun — yang berlaku kolom PIC.
`owner` project mengikuti kolom PIC.

| Aksi | ADM / SPV | PIC | MBR | Catatan |
| --- | :-: | :-: | :-: | --- |
| Buat task | ◐ | ● | ○ | ADM/SPV hanya untuk task divisi. Task project butuh peran owner/PIC di project itu. |
| Ubah isi task | ◐ | ◐ | ◐ | Member hanya lewat jalur subtask, dan hanya sebagai assignee task induk. Routine work tertutup untuk semua peran. |
| Pindahkan task antarkonteks | ◐ | ◐ | ○ | Konteks tujuan diotorisasi terpisah dari konteks asal, dan seluruh subtask ikut pindah (04.2). PIC boleh, berbeda dari draf 0.5. |
| Tugaskan assignee | ◐ | ● | ○ | Maksimum satu orang, wajib anggota konteks task dan bukan `viewer` di sana. |
| Ubah status | ◐ | ● | ◐ | Member hanya pada task yang di-assign kepadanya, dan tidak dapat membuka kembali task selesai. |
| Setujui review | ◐ | ● | ○ | Reviewer **boleh** merangkap pelaksana sejak K3 direvisi, dan tidak ada pemeriksaan yang mencegahnya menyetujui pekerjaannya sendiri — disahkan lewat K14. |
| Hapus task | ◐ | ◐ | ◐ | Ditolak saat status `review`/`completed`. Task yang sudah punya jejak kerja dibatalkan, bukan dihapus. Routine work tidak dapat dihapus siapa pun. |

> **Penghapusan yang mengubah dirinya sendiri.**
> `destroy` memeriksa apakah task sudah punya jejak: status bukan lagi `waiting`, sudah
> pernah dimulai, atau sudah punya komentar, subtask, bukti, lampiran, atau review. Bila
> ada, seluruh pohonnya **dibatalkan** alih-alih dihapus, sehingga riwayatnya tidak lenyap.
> Baru task yang benar-benar kosong yang dihapus dari tabel.

### 04.5 Daftar dan penyaringan — 🟡 SEBAGIAN

- Tampilannya **tabel, bukan papan kanban**. Tidak ada pengelompokan kolom per status
  maupun urutan manual yang tersimpan.
- Filter yang tersedia — sama di halaman project, divisi, dan dashboard: pencarian, status,
  rentang deadline, *hanya task saya*, dan sort. Tab routine work menambahkan filter
  prioritas.
- Pencarian mencakup **nama dan kode task**, bukan deskripsi.
- Task lewat deadline ditandai visual, bukan diubah statusnya secara otomatis.
- ✅ **TERPASANG** Root task tanpa assignee hanya terlihat oleh pembuatnya,
  owner/PIC project, atau admin/supervisor divisi. Member biasa baru melihatnya
  setelah assignee dipasang. Subtask mengikuti kesiapan parent agar direct URL
  dan halaman Media tidak membuka rincian dari parent yang tersembunyi. Seluruh
  policy dan daftar memakai `Task::scopeVisibleTo()` sebagai rumus tunggal.
- ✅ **TERPASANG** **Angka kartu project ikut disaring rumus yang sama.** Jumlah
  task, jumlah yang selesai, persentase progress, dan ikon urgent dihitung lewat
  `Project::scopeWithTaskProgress($user)`, yang membungkus ketiga agregatnya
  dengan `Task::scopeVisibleTo()`. Tanpa itu kartu menjanjikan baris yang tidak
  pernah muncul: seorang member membaca "5/12 tasks completed" lalu membuka
  project itu dan menemukan sembilan baris — dan progress serta ikon urgent
  bohong dengan cara yang sama. Satu scope menggantikan tiga blok `withCount`
  yang sebelumnya ditulis identik di `/projects`, daftar project divisi, dan
  kartu dashboard; `scopeWithUrgentTaskFlag()` dilebur ke dalamnya karena
  penanda urgent tunduk pada saringan yang persis sama. Uji:
  `tests/Feature/ProjectCardCountTest.php`.
- ❌ **DICORET** Filter assignee di halaman task. **Sengaja tidak dibangun.** Kebutuhan
  pelaksana sudah dilayani saringan *hanya task saya*, dan pengelola melihat beban timnya lewat
  daftar task divisi yang memang sudah menampilkan assignee tiap baris. Menambah saringan
  per-orang berarti memuat daftar anggota konteks di setiap halaman task untuk kebutuhan yang
  belum terbukti menuntutnya.
- ✅ **TERPASANG** Filter prioritas di halaman task, di dropdown *Filters* bersama status dan
  deadline. Nilainya dikirim sebagai level angka (`1`–`4`); nilai di luar enum diabaikan
  alih-alih mengosongkan daftar, sama seperti perlakuan filter status. Aturannya ditaruh di
  `ProjectTaskFilters` sehingga ketiga pemanggilnya — halaman project, task divisi
  (skema `task_priority`), dan dashboard — mendapatkannya sekaligus tanpa penulisan ulang.
  Uji: `tests/Feature/TaskPriorityFilterTest.php`.
- ❌ **DICORET** Fitur label pada task. **Sengaja tidak dibangun**: menuntut tabel sendiri
  beserta CRUD dan antarmukanya, sementara belum ada kebutuhan yang menuntutnya. Penyaringan
  yang benar-benar dipakai sudah dilayani status, deadline, prioritas, dan pencarian
  nama/kode.

### 04.6 Ketergantungan antar task & tab Gantt — ✅ TERPASANG

Tab **Gantt** di halaman project menggambar seluruh task project pada sumbu waktu, beserta
relasi *finish-to-start* antar task: "A harus selesai sebelum B mulai".

> **Status pemakaian:** fiturnya berjalan penuh di `dev`, tapi kebutuhannya masih ditimbang.
> Dipertahankan sebagai bahan menilai kebutuhan ke depan, bukan karena sudah ada alur kerja
> yang menuntutnya (2 September 2026).

- ✅ **TERPASANG** Relasi disimpan di tabel `task_dependencies` (`predecessor_id` →
  `successor_id`), unique per pasang, kedua foreign key `cascade on delete` sehingga task yang
  dihapus tidak meninggalkan panah menggantung. **Hanya satu tipe relasi**: finish-to-start,
  tanpa kolom jeda (`lag`). Tiga tipe lain (SS/FF/SF) belum pernah diminta.
- ✅ **TERPASANG** **Otorisasinya izin PROJECT, bukan izin task.** Menambah dan melepas
  ketergantungan dijaga `Gate::authorize('create', [Task::class, $project])` — owner/PIC
  project — bukan `TaskPolicy::update()`. **Ini beda yang mudah salah, dan sudah pernah
  salah:** `update()` untuk subtask menuntut si pengklik menjadi *pelaksana task induk*
  (04.2), sementara tombolnya di UI tumbuh dari izin project. Memakai `update()` membuat owner
  project melihat tombol lalu ditolak 403 pada subtask yang bukan pekerjaannya sendiri —
  jebakan "flag halaman vs gate per task" yang sama dengan 09.1. Alasannya juga bukan sekadar
  menambal: menyusun urutan kerja adalah keputusan **perencanaan project**, bukan penyuntingan
  isi satu task.
- ✅ **TERPASANG** Task di luar project ditolak 403 sebelum sampai ke gate.
  `TaskPolicy::create()` memulangkan `true` begitu argumen project-nya `null`, jadi tanpa
  penjagaan itu task divisi justru menjadi yang paling longgar.
- ✅ **TERPASANG** **Hanya task induk yang boleh berketergantungan.** Subtask ditolak di
  **kedua** ujung — sebagai successor maupun predecessor — karena ia memecah pekerjaan
  induknya sendiri (04.2), bukan unit perencanaan project. Penjagaannya di controller, bukan
  sekadar penyaringan daftar kandidat di popover: request-nya bisa dikirim langsung tanpa
  lewat UI. Di bagan, tombol pengaturnya karena itu hanya tumbuh pada baris ber-`parent_id`
  `null` — penjaganya `parent_id`, bukan kedalaman baris, sebab subtask yang induknya
  tersaring `scopeVisibleTo()` naik menjadi baris tingkat atas tapi tetap subtask di server.
- ✅ **TERPASANG** Lingkaran ditolak saat disimpan, termasuk yang tertutup lewat rantai
  panjang (A→B→C, lalu C→A). Penelusurannya BFS per lapisan di `TaskDependencyGraph` — satu
  query per kedalaman graf, bukan satu query per simpul.
- ✅ **TERPASANG** Isi tab disaring `Task::scopeVisibleTo()`, rumus yang sama dengan daftar
  task (04.5). Panah hanya digambar bila **kedua** ujungnya terlihat: relasi yang salah satu
  ujungnya tersembunyi akan menggambar panah menuju baris yang tidak ada, sekaligus
  membocorkan keberadaan task yang justru sedang disembunyikan.
- ✅ **TERPASANG** Datanya prop `Inertia::optional`, dibangun hanya ketika tab Gantt dibuka
  dan meminta partial reload `only: ['gantt']`. Kunjungan ke tab lain tidak membayar query
  seluruh task project.
- ✅ **TERPASANG** Task **tanpa `start_date`** digambar sebagai milestone di tenggatnya, dan
  task **tanpa `due_date`** tidak digambar sama sekali. Keduanya sengaja: tidak ada kolom
  durasi maupun persentase kemajuan per task, jadi menurunkannya dari tanggal pembuatan atau
  dari status hanya melahirkan angka presisi yang tidak pernah diisi siapa pun. Kemajuan di
  batang karena itu **hanya muncul untuk task yang punya subtask**, dihitung selesai ÷ total —
  rumus yang sama dengan `progress_percentage` project.
- ✅ **TERPASANG** Keterlambatan di bagan dihitung **pada tingkat hari** dan mengecualikan
  `completed`/`cancelled`, sejalan dengan pewarnaan baris di daftar task. Kartu metrik
  *Overdue* di tab Info memakai batas detik, jadi untuk task yang jatuh tempo hari ini kedua
  angka bisa berselisih satu. Sumbu Gantt memang hari; selisih ini disadari dan dibiarkan.
- ✅ **TERPASANG** Bagan selalu dibuka pada skala **harian** dan digulir ke posisi hari ini;
  mingguan tinggal pilihan manual. Ambang otomatis 90 hari yang sempat dipakai dicabut — pada
  rentang panjang sekalipun, yang dicari orang justru harinya. Kolom hariannya diberi nama
  hari bentuk `short` ("Sen"), bukan `narrow`, sebab bentuk narrow memulangkan satu huruf dan
  Senin/Selasa/Sabtu sama-sama jadi "S".
- ✅ **TERPASANG** Batang dan nama task adalah tombol yang membuka sheet detail milik tab
  Tasks **tanpa memindahkan tab**: `TabsContent` daftar task memakai `forceMount` agar tetap
  hidup, dan sheet-nya dirender ke portal sehingga tampil penuh walau panel induknya sedang
  disembunyikan. Task yang tidak termuat di halaman aktif diambil `fetchTaskById` — jalur yang
  sama dengan deep-link `?task=` dari lonceng. Pembukaan dari Gantt sengaja **tidak** menulis
  query itu: `router.replace` adalah kunjungan penuh, dan prop `Inertia::optional` seperti
  `gantt` tidak ikut terkirim di luar partial reload, jadi bagan yang baru dipakai itu sendiri
  yang rontok. Konsekuensinya sheet dari Gantt tidak bisa di-*refresh* lewat URL.
- ⬜ **BELUM** Penegakan urutan kerja. Ketergantungan hari ini **pasif**: successor tetap bisa
  berpindah ke `in_progress` walau predecessor masih `waiting`, dan
  `TaskController::updateStatus()` tidak pernah membaca relasi ini — satu-satunya pembacanya
  di backend adalah `ProjectGanttData`. Kalau nanti diminta, guard-nya satu tempat di sana.
- ⬜ **BELUM** Penggeseran `start_date`/`due_date` otomatis saat predecessor mundur, dan
  perhitungan *critical path*. Keduanya menuntut penjadwalan yang mengubah tanggal milik orang
  lain, jadi bukan tambahan kecil — dan critical path baru berarti setelah relasinya rutin
  diisi.

Uji: `tests/Feature/TaskDependencyFlowTest.php` (12 kasus, termasuk task yang bukan pekerjaan
owner, subtask yang ditolak di kedua ujung, dan lingkaran tidak langsung) serta
`npm run gantt:check` untuk perhitungan sumbu waktu, durasi, kemajuan, keterlambatan, dan
titik sambung panah pada milestone.

---

## 05. Review & Bukti Pengerjaan

Mekanisme kontrol kualitas: pekerjaan tidak dinyatakan selesai oleh pengerjanya sendiri.

1. Saat task `in_progress`, assignee mengunggah bukti sesuai `required_proof_type` —
   dokumen, gambar, atau tautan — lalu memindahkan task ke `review`.
2. Sistem memberi tahu para reviewer berwenang: owner/PIC project, atau admin/supervisor
   divisi untuk task divisi. In-app saja.
3. Reviewer menyetujui atau menolak; alasan wajib bila menolak. Task hanya dapat dinilai
   saat berstatus `review`.
4. Persetujuan memindahkan task ke `completed` dan mengunci buktinya. Penolakan
   mengembalikan ke `in_progress` dan membuka kunci bukti agar dapat diperbaiki.

- Satu task menyimpan **riwayat review**, bukan hanya hasil terakhir — tiap keputusan
  menjadi baris tersendiri di `task_reviews`.
- ✅ **TERPASANG** Kunci bukti mengikuti allowlist **status**, bukan riwayat review:
  bukti hanya dapat ditambah, diubah, atau dihapus saat `in_progress`. Status `waiting`,
  `review`, `completed`, `on_hold`, dan `cancelled` semuanya terkunci; penolakan review
  membuka bukti lagi karena task kembali ke `in_progress`. Rumus yang sama dipakai di tiga
  tempat — `TaskPolicy::uploadProof()` sebagai gerbang sesungguhnya,
  `TaskProofController::proofIsLocked()` sebagai pemberi pesan, dan
  `TaskProofSection.tsx` untuk menyembunyikan tombolnya.
  - **Sampai 1.7 kuncinya menghitung riwayat**, yaitu "`review` tanpa penolakan" dan
    "`completed` dengan persetujuan". Karena baris `rejected` tidak pernah dihapus, pengajuan
    review **kedua** lolos dari kedua lapisan itu: antarmuka kembali menampilkan form unggah
    beserta tombol ubah/hapus, lalu policy menolaknya dengan **403 telanjang** — bukan pesan.
    Yang kena persis pelaksana yang pekerjaannya pernah ditolak.
  - **Bukan kebocoran, melainkan antarmuka yang berbohong.** Policy tidak pernah longgar, jadi
    bukti memang tidak pernah bisa diganti di tengah penilaian. Yang diperbaiki adalah
    janji layarnya.
  - **Yang benar di sini adalah menghapus, bukan menambah cabang.** Status sudah membawa
    informasinya sendiri — penolakan memindahkan task ke `in_progress`, dan itulah yang
    membuka kuncinya. Uji: `tests/Feature/TaskProofFlowTest.php`.
- Bukti berupa berkas disimpan di Google Drive dengan nama acak; nama aslinya disimpan
  sebagai metadata untuk ditampilkan. Bukti gambar dikonversi ke WebP.
- Batas ukuran berbeda per tipe: **gambar 2 MB** (lalu dikompres hingga di bawah 200 KB),
  **dokumen 10 MB** dengan ekstensi terbatas pada PDF/Word/Excel/PowerPoint/TXT/CSV, dan
  tautan maksimum 2.048 karakter, ✅ **TERPASANG** hanya berskema `http`/`https`.
- ✅ **TERPASANG** Task **tanpa** `required_proof_type` menolak unggahan bukti sama sekali.
  - **Ini menutup jalan pintas ke seluruh daftar putih ekstensi.** Aturan `mimes:` hanya hidup
    di cabang tipe `file`; bila tipenya kosong, validasinya jatuh ke cabang terakhir yang dulu
    hanya membatasi ukuran. Dan tipe kosong bukan kasus langka — **setiap subtask dipaksa
    `null`** (04.1), sehingga assignee subtask mana pun dapat menyimpan berkas apa saja hingga
    10 MB ke Drive perusahaan, otomatis publik dan bertautan permanen. Antarmuka memang tidak
    pernah menawarkan unggahan di sana; endpoint-nya yang terbuka.
  - **Bukan eksekusi kode di server kita** — berkasnya mendarat di Drive dan tidak pernah
    disajikan aplikasi. Yang didapat adalah hosting berkas sembarang atas nama perusahaan,
    plus klaim daftar putih di seksi 10 yang batal.
  - **Ditutup di pangkalnya, bukan dengan menyalin `mimes:`.** Task yang tidak mensyaratkan
    bukti memang tidak punya alasan menerimanya, sejalan dengan subtask yang tidak mengenal
    review (04.2). Cabang terakhir `rules()` tetap ikut dipersempit ke daftar putih yang sama
    sebagai lapis kedua. Uji: `tests/Feature/TaskProofFlowTest.php`.
- ✅ **TERPASANG** Batas **10 bukti per task**, ditegakkan di `StoreProofRequest`. Angkanya
  mengikuti draf lama sekaligus batas lampiran komentar yang sudah berlaku, dan menahan plafon
  penyimpanan terburuk di 10 × 10 MB per task.
  - **Hanya menahan penambahan, bukan perbaikan.** `StoreProofRequest` dipakai bersama oleh
    unggah dan ganti bukti; tanpa pembedaan, task yang menyentuh batas justru tidak bisa lagi
    memperbaiki buktinya sendiri. Pembedanya parameter rute — jalur unggah membawa `{task}`,
    jalur ganti membawa `{proof}`.
  - **Batas yang bisa dipulihkan sendiri.** Selama task belum masuk `review`, assignee boleh
    menghapus buktinya, jadi pesan errornya menyebut cara keluar dari batas itu.
  - **Yang sengaja tidak dibangun:** jalan keluar bagi assignee baru yang mewarisi 10 bukti
    milik orang lain — ia tidak dapat menghapus bukti yang bukan miliknya (04.4) dan karenanya
    mentok. Butuh dua keadaan langka sekaligus: task yang benar-benar terisi penuh, lalu
    assignee-nya diganti. Bila suatu saat muncul, jawabannya menaikkan batas, bukan menambah
    aturan kepemilikan. Uji: `tests/Feature/TaskProofFlowTest.php`.

> **Pengawas boleh merangkap pelaksana — dan itu belum dijaga.**
> Versi pertama K3 menutup perangkapan ini di titik penugasan: reviewer tidak boleh
> ditugaskan task sama sekali. Revisi K3 mencabutnya — sekarang hanya `viewer` yang tertutup,
> sehingga owner, PIC, admin, dan supervisor boleh mengerjakan task di konteks yang mereka
> awasi.
>
> Akibatnya, satu-satunya tempat yang bisa mencegah orang menyetujui pekerjaannya sendiri
> adalah titik persetujuan — dan di sana belum ada apa-apa. `TaskPolicy::review()` hanya
> memeriksa peran, tanpa satu pun perbandingan dengan daftar assignee, sehingga seorang PIC
> dapat menugaskan task kepada dirinya sendiri, mengunggah buktinya, lalu menyetujuinya
> sendiri. Hal yang sama berlaku bagi admin/supervisor pada task divisi. Ini **disengaja** dan
> disahkan lewat K14; jangan menutupnya diam-diam.

---

## 06. Perpanjangan Deadline

Perubahan deadline task oleh pelaksana melalui pengajuan, bukan pengeditan langsung — agar
keterlambatan tetap terekam.

`pending → approved` / `rejected`

- Pengaju: assignee task **utama**. Penyetuju: sama persis dengan reviewer task — owner/PIC
  project, atau admin/supervisor divisi.
- Pengajuan wajib memuat tanggal usulan dan alasan.
- Hanya satu pengajuan `pending` per task pada satu waktu, dijaga di lapisan aplikasi.
- Tanggal usulan divalidasi berlapis oleh `TaskDeadlineValidator`: harus **lebih lambat dari
  deadline task yang berlaku**, berada di masa depan, tidak mendahului waktu mulai, tidak
  melewati deadline task induk, dan tidak melewati deadline project.
- Pengajuan ditolak sejak awal bila task sudah `review`, `completed`, atau `cancelled`.
- Persetujuan **memvalidasi ulang seluruh lapisan yang sama**, karena deadline task atau
  project bisa berubah sejak permintaan dibuat. Setelah lolos, deadline task diperbarui dan
  deadline yang digantikan direkam ke kolom `current_due_date` permintaan itu — **diambil
  saat keputusan, bukan saat pengajuan**.
- Penolakan tidak mengubah apa pun selain status pengajuan, dan wajib disertai alasan.
- Keputusannya dikabarkan ke seluruh assignee task **dan pengajunya**. Keduanya tidak selalu
  beririsan — assignee task bisa berganti setelah pengajuan dibuat, dan permintaan lama pada
  subtask masih menyimpan pengaju yang bukan assignee.
- **Routine work dikecualikan.** Deadline task hasil terbitan dihitung dari jadwal
  templatnya, jadi pengajuan ditolak di tingkat otorisasi. Lihat 07.2.
- ✅ **TERPASANG** **Subtask dikecualikan.** Deadline subtask adalah warisan induknya, dan
  `TaskDeadlineValidator` menjepitnya dari dua sisi sekaligus: tanggal usulan wajib **lebih
  lambat** dari deadline subtask yang berlaku, sekaligus **tidak boleh melewati** deadline
  induk. Karena subtask lahir dengan deadline yang persis sama dengan induknya, himpunan
  tanggal yang lolos keduanya kosong — jalur itu sudah selalu buntu jauh sebelum ditutup, dan
  yang dibuang di sini hanya tombol yang tidak pernah bisa berhasil. Yang diperpanjang
  induknya, dan satu persetujuan di sana melonggarkan seluruh subtask di bawahnya sekaligus.
  Uji: `tests/Feature/TaskDeadlineRequestFlowTest.php`.

> **Pengajuan lama tetap bisa diputuskan.**
> Kedua penutupan di atas — routine work maupun subtask — hanya berlaku untuk pengajuan
> *baru*. Pengajuan `pending` yang sudah tercatat masih dapat disetujui atau ditolak
> atasannya, supaya tidak ada permintaan yang menggantung tanpa jalan keluar.
> `decideDeadline` karena itu sengaja tidak ikut menyaring subtask. Persetujuan pada task yang sudah final
> tetap ditolak; penolakan selalu diizinkan agar antreannya bisa dibersihkan.

> **Perpanjangan tidak pernah boleh memperpendek — ✅ TERPASANG**
> Aturan "lebih lambat dari deadline yang berlaku" dulu hanya dipasang di controller
> pengajuan, sementara persetujuan memanggil `TaskDeadlineValidator` yang tidak memuatnya.
> Selisih itu bisa dieksploitasi tanpa niat: deadline task bisa **maju** setelah permintaan
> dibuat — lewat penggeseran pasca jeda project (03.2) atau edit langsung owner/PIC — dan
> menyetujui angka lama akan menariknya kembali ke belakang. Yang diminta pelaksana adalah
> tambahan waktu; yang ia terima justru potongan, tanpa satu pun peringatan.
>
> Aturannya sekarang tinggal di `TaskDeadlineValidator`, satu-satunya tempat yang dilewati
> **kedua** jalur, dan duplikatnya di controller dihapus. Satu efek sampingnya sengaja
> dimanfaatkan: selama deadline hanya boleh maju, invariant "induk ≥ subtask terbuka" (04.1)
> ikut aman di jalur ini tanpa perlu cek subtask tersendiri — jalur permintaan deadline
> memang tidak pernah melewati `TaskController::validateTaskDates`.
>
> `current_due_date` ikut diluruskan: sebelumnya ia menyalin deadline **saat pengajuan
> dibuat**, sehingga riwayat "X → Y" bisa menceritakan perubahan yang tidak pernah terjadi
> bila deadline sempat bergeser di antaranya. Sekarang ia direkam saat keputusan diambil.
> Uji yang menjaganya: `tests/Feature/TaskDeadlineRequestFlowTest.php`.

> **Izin pengajuan diambil dari policy, bukan dihitung ulang di layar. ✅ TERPASANG**
> `TaskDetailSheet` dulu menurunkan sendiri hak mengajukan dari `isRoutineWork`, `isAssignee`,
> dan `parentId` — rumus `TaskPolicy::requestDeadline` yang ditulis ulang dalam TypeScript,
> **minus** pemeriksaan project beku. Akibatnya form pengajuan tetap tampil di project
> `completed`/`cancelled`, dan kiriman pelaksana ditolak `403` tanpa penjelasan yang masuk
> akal baginya.
>
> Sisi keputusan pada section yang sama sudah benar sejak awal — ia memakai
> `can_decide_deadline` dari `TaskResource`. Perbaikannya karena itu menyamakan keduanya:
> `can_request_deadline` ditambahkan ke resource yang sama, dan derivasi lokalnya dihapus.
> Ini persis pola yang dicatat pada penutup 1.8 — aturan yang hidup di lebih dari satu tempat
> harus dicocokkan **rumusnya**, bukan sekadar keberadaannya.

---

## 07. Recurring Task

Template task yang dibangkitkan sistem secara berkala untuk pekerjaan rutin seperti laporan
mingguan atau pemeliharaan bulanan.

### 07.1 Template — ✅ TERPASANG

- Pola pengulangan: **Daily** (pilih hari-hari terbitnya), **Weekly** (pilih hari),
  **Bi-weekly** (pilih hari), dan **Monthly** (pilih tanggal 1–28; 29–31 tidak disediakan agar
  jadwal tidak lewat di bulan pendek).
- Template menyimpan nama, deskripsi, prioritas, jenis bukti wajib, jam deadline
  (`due_time`), penerima tugas, dan status aktif.
- ✅ **TERPASANG** **Daily boleh dibatasi ke hari tertentu**, termasuk hanya Sabtu–Minggu.
  Pilihannya tersimpan di kolomnya sendiri, `schedule_days` (JSON), dan **kosong berarti
  setiap hari** — arti yang sama dipakai template Daily yang dibuat sebelum pilihan ini ada,
  sehingga tidak ada jadwal lama yang berubah diam-diam. Frekuensi lain tidak pernah
  menyimpannya: berpindah dari Daily ke frekuensi mana pun mengosongkannya di
  `prepareForValidation()`, jadi tidak ada sisa pilihan yang menghantui kalau kelak template
  dikembalikan ke Daily.
- **Penerima wajib, tepat satu orang** — saat dibuat maupun saat diubah, dan harus anggota
  divisi yang sama. Alasannya ada di 07.2: task hasil terbitan tidak bisa di-assign ulang,
  sehingga template tanpa penerima hanya melahirkan pekerjaan tanpa pemilik.
- ✅ **TERPASANG** Penerima tidak boleh berperan `viewer` di divisi itu, mengikuti aturan
  umum K3. Ditegakkan lewat `AssigneesBelongToDivision`, rule yang sama yang sudah memeriksa
  keanggotaan divisi. Admin dan supervisor tetap boleh menjadi penerima, jadi divisi tanpa
  anggota berperan `member` masih dapat memiliki template.
- ✅ **TERPASANG** Aturan yang sama dijaga **dari sisi keanggotaan divisi**: seorang penerima
  tidak bisa dilepas dari divisinya, dan tidak bisa diturunkan menjadi `viewer`, selama ia
  masih tercatat sebagai penerima template di sana. Penolakannya menyebut nama templatnya.
  Tempatnya `Team\Rules\DisqualifiedTemplateAssignees`, dipanggil dari kedua jalur *sync*
  seperti `ReleasedMemberTasks` — lihat 02.2.
- Template melekat pada **divisi**, bukan project. Task yang terbit adalah task divisi tanpa
  project.
- **Hari terbit dan hari tenggat itu dua hal berbeda.** Task selalu terbit di **awal
  periode** — Senin untuk mingguan, tanggal 1 untuk bulanan — supaya pelaksana punya waktu
  sepanjang periode. Hari yang dipilih pengguna (`schedule_day`) menentukan **tenggat** di
  dalam periode itu, bukan hari terbit. Template "Weekly, Jumat, 09:00" terbit Senin dengan
  tenggat Jumat 09:00. Tanpa `due_time`, tenggat jatuh ke akhir hari.
- **Dua kolom hari, dua arti — jangan tertukar.** `schedule_day` (tunggal) adalah hari
  **tenggat** di dalam periode, milik Weekly/Bi-weekly/Monthly. `schedule_days` (jamak) adalah
  hari-hari **terbit**, dan hanya milik Daily. Keduanya tidak pernah hidup bersamaan pada satu
  template. Perbedaannya masuk akal begitu periodenya diingat: periode Daily panjangnya satu
  hari, jadi hari terbit dan hari tenggatnya memang hari yang sama.
- Bila hari tenggat pada periode berjalan sudah lewat saat template dibuat (mis. template
  Weekly-Jumat dibuat hari Sabtu), periode itu dilewati dan task pertama baru terbit pada
  periode berikutnya. Task dengan tenggat kedaluwarsa tidak pernah diterbitkan.
- ✅ **TERPASANG** `next_run_at` yang tampil di daftar template adalah **kapan task berikutnya
  benar-benar terbit**. Selama periode berjalan masih sempat menerbitkan — belum terpakai dan
  hari tenggatnya belum lewat — angkanya menunjuk **sekarang**, bukan periode berikutnya;
  template harian yang dibuat pagi ini menerbitkan task menit itu juga, jadi menampilkan
  "besok" adalah salah. Awal periode berikutnya baru muncul ketika periode berjalan memang
  dilewati. Perhitungannya satu, di `RunRecurringTaskAction::nextPublishAtFor()`, memakai
  gerbang yang sama dengan penerbitannya — termasuk `schedule_day`, karena hari pilihan itulah
  yang menentukan periode ini masih terbit atau tidak. Untuk Daily berhari-pilihan, angkanya
  menunjuk **hari terpilih berikutnya**: template Sabtu–Minggu yang dilihat hari Jumat
  menampilkan Sabtu, bukan besok secara buta.
- Admin dan supervisor divisi dapat menerbitkan manual, per template atau seluruh template
  aktif sekaligus. Keduanya dijaga `DivisionPolicy::manage`.
- ✅ **TERPASANG** Menonaktifkan template menghentikan pembangkitan berikutnya tanpa menyentuh
  task yang sudah lahir — **termasuk lewat "Run Now"**. Penjagaannya ada di
  `RunRecurringTaskAction::execute()`, satu titik yang dilewati scheduler, "Run All Active",
  dan penerbitan per template; saringan `status = Active` di dua query pertama tinggal
  penyempit query, bukan lagi aturan. Menghapus template pun tidak menghapus terbitan
  sebelumnya — penghapusannya soft delete.
- Template hanya terlihat oleh **admin dan supervisor divisi, serta super admin**. Member
  tidak melihat daftar template sama sekali.

> **Selisih dengan draf 0.5 — irama scheduler.**
> Draf menyebut "scheduler harian". Yang terdaftar adalah
> `Schedule::command(RunRecurringTasks::class)->everyMinute()`. Perintahnya berjalan tiap
> menit; yang menjaga agar satu periode hanya menghasilkan satu task adalah
> `alreadyRanThisPeriod()`, yang membandingkan `last_run_at` dengan definisi periode per
> frekuensi — hari yang sama untuk Daily, minggu yang sama untuk Weekly, dua minggu terakhir
> untuk Bi-weekly, bulan yang sama untuk Monthly.
>
> Idempotensinya karena itu nyata, dan penerbitan manual di tengah periode tidak
> menghasilkan duplikat. Yang perlu disadari: irama satu menit berarti template aktif
> dipindai 1.440 kali sehari.

### 07.2 Routine work — hasil terbitan — ✅ TERPASANG

Task yang lahir dari template adalah *pekerjaan*, bukan objek yang dikelola lagi. Jadwal,
isi, prioritas, deadline, dan penerimanya sudah ditetapkan manajemen di templatnya.

- Routine work **tidak dapat diubah isinya, dihapus, dipecah jadi subtask, atau diajukan
  perpanjangan deadline** — berlaku untuk pelaksananya maupun admin/supervisor divisi.
- Yang tetap terbuka bagi pelaksana adalah pekerjaannya sendiri: menggerakkan status,
  mengunggah bukti, dan berdiskusi. Atasan tetap me-review seperti task biasa.
- Koreksi jadwal atau isi dilakukan di templatnya; satu terbitan yang keliru dihentikan
  dengan mengubah statusnya menjadi `cancelled`.
- Visibilitasnya lebih sempit dari task divisi biasa: hanya pelaksananya, ditambah
  admin/supervisor divisi dan super admin.
- Penutupan ini ditegakkan di lapisan otorisasi *dan* validasi, bukan sekadar disembunyikan
  di antarmuka — tombolnya hilang, endpoint-nya menolak, dan ada uji khusus yang menjaganya
  (`RoutineWorkImmutableTest`).

> **Masih perlu keputusan.**
>
> **Project tujuan.** Draf menyebut template menyimpan project tujuan. Di aplikasi,
> recurring task selalu milik divisi dan task yang terbit tidak pernah punya project.
> Perlu diputuskan: fitur ini memang belum dibuat, atau cakupannya turun jadi divisi-saja
> dan kalimatnya dicoret.
>
> **Ekspresi kustom (cron).** Belum ada, dan tidak ada tanda akan dibuat. Yang tersedia
> hanya empat pola tetap di 07.1.
>
> Keduanya masuk antrean kerja di 15 pada 2.0, supaya tidak lagi hanya hidup di sini.

> **Notifikasi routine work — sudah diputuskan, ❌ DICORET.** Task yang terbit dari scheduler
> dibuat tanpa user login, dan `RunRecurringTaskAction` sengaja **tidak** memanggil
> `TaskNotifier::taskAssigned()`. Memberi tahu saat task terbit berarti mengirim push pukul
> 00:00, karena di situlah scheduler menerbitkannya. Yang dikirim justru pengingat menjelang
> tenggat — lihat 11, yang memang sudah mencatatnya sebagai dicoret. Dipindahkan keluar dari
> blok "masih perlu keputusan" pada 2.0: ia sudah lama tidak menunggu siapa pun.

> **Catatan operasional — sudah diterapkan.**
> Pendaftaran jadwal berada di `Routes/console.php` yang di-`require` dari
> `SchedulerServiceProvider` hanya saat berjalan di konsol — **bukan** lewat
> `loadRoutesFrom()`. Ini yang menjaga jadwal tetap hidup ketika `route:cache` diaktifkan
> di production. **Jangan dikembalikan ke pemuatan rute.**

---

## 08. Personal Task

Catatan kerja pribadi yang tidak terikat project — ruang privat setiap pengguna. Disimpan
di tabelnya sendiri agar aturannya tidak pernah bercampur dengan task kerja.

- Status sederhana: `todo` · `in_progress` · `done`. Tidak ada review dan tidak ada bukti.
- Selain status, tersedia catatan, prioritas, deadline, dan **satu tingkat subtask**
  sebagai checklist.
- Kepemilikan adalah satu-satunya aturan otorisasi: `PersonalTaskPolicy` hanya
  membandingkan `user_id`.
- **Super admin tidak punya halaman ini sama sekali** (K15). Bukan sekadar daftarnya kosong —
  seluruh route `/my-tasks` dijaga gate `use-personal-tasks`, jadi membuat, mengubah, dan
  menghapus pun tertutup, bukan cuma membaca.
- **Tiga aturan status, semuanya menolak dan tidak memperbaiki diam-diam.** Ketiganya hidup di
  hook `saving` model `PersonalTask`, jadi berlaku di jalur mana pun:
  1. **Deadline wajib sebelum mulai** (K20). `due_date` boleh kosong saat membuat catatan,
     tetapi tidak boleh kosong saat status berpindah ke `in_progress`. Diperiksa dari nilai
     final, sehingga menetapkan deadline dan menekan mulai dalam satu kiriman tetap lolos.
     Hanya `in_progress` yang dituntut: menandai `done` langsung tetap boleh, karena menagih
     tenggat untuk pekerjaan yang sudah kelar tidak ada gunanya.
  2. **Subtask tidak mendahului induk** (K19). Subtask tidak bisa keluar dari `todo` selama
     induknya masih `todo`. Berlaku untuk `in_progress` maupun `done` — kalau mengerjakan saja
     belum boleh, menyelesaikan apalagi.
  3. **Induk tidak selesai mendahului subtask** (K19). Induk tidak bisa menjadi `done` selama
     masih ada subtask yang belum `done`. Pesannya menyebut jumlahnya, mengikuti gaya
     penjagaan yang sama pada task kerja (04.2).
- Aturan 1 berlaku juga saat pembuatan; aturan 2 dan 3 tidak. Bedanya disengaja: yang pertama
  soal kelayakan satu baris terhadap dirinya sendiri, dua sisanya soal hubungan antarbaris
  yang boleh disusun dalam urutan apa pun oleh seeder maupun factory. Tanpa perkecualian itu,
  aturan 1 bisa dilangkahi hanya dengan membuat task yang langsung berstatus berjalan.
- Membuka kembali induk yang sudah selesai tidak mengubah status subtask-nya.
- Daftar di `/my-tasks` hanya memuat task induk. Subtask adalah checklist di dalam sebuah
  task, tempatnya di panel detail — bukan baris tersendiri di tabel.
- Tidak masuk ke laporan divisi maupun agregat dashboard, dan tidak diaudit — `PersonalTask`
  sengaja tidak memakai trait `Auditable` (13.2).
- ❌ **DICORET** Promosi task pribadi menjadi task project (K16). Sempat berdiri sebagai
  rencana sejak draf 0.5 dan tidak pernah ada peminatnya. Jalur pemindahannya bukan cuma
  menyalin baris: task project butuh divisi, project, assignee, tipe bukti, dan alur review —
  semua yang justru sengaja tidak ada di sini. User yang butuh cukup membuat task kerjanya
  langsung di project tujuan.
- ❌ **DICORET** Notifikasi task pribadi (K17). Boleh punya `due_date` dan ditandai merah saat
  lewat, tetapi sengaja tidak mengirim apa pun — tidak ada padanan `due_soon_notified_at`
  seperti pada task kerja (11). Alasannya beda dari K16: ini bukan ide yang ditolak, melainkan
  ditunda sampai ada yang memintanya. Catatan pribadi tidak punya siapa pun yang menunggu, jadi
  pengingatnya tidak menagih tanggung jawab ke siapa-siapa.
- ❌ **DICORET** Pengurutan manual alias drag & drop (K18). Kolom `position` tetap ada dan
  tetap menjadi kunci urutan default, tetapi hanya pernah ditulis saat pembuatan — tidak ada
  endpoint yang menggesernya. Yang tersaji karena itu adalah **urutan pembuatan**, dengan task
  selesai didorong ke bawah. Pilihan sort-nya dulu berlabel "My Order" dengan nilai `manual`;
  keduanya diganti menjadi "Default" dan `default` pada 2.3, karena label lama menjanjikan
  kendali yang tidak pernah ada.

> **Satu celah teoretis yang tertutup oleh query.**
> `Gate::before` meloloskan super admin pada ability `view` untuk model apa pun — termasuk
> `PersonalTask`. Yang menyelamatkan privasinya adalah controller: daftar task pribadi
> selalu di-scope ke `user_id` pemilik sesi, dan tidak ada satu pun route yang memanggil
> `Gate::authorize('view', $personalTask)`. Jadi super admin memang tidak bisa melihat
> catatan orang lain hari ini — tetapi karena tidak ada jalannya, bukan karena gate-nya
> menolak. Menambahkan route detail suatu saat akan membuka celah itu tanpa peringatan.
>
> Batas kelolosannya sudah diperiksa ulang pada 2.2 dan memang hanya sampai `view`:
> `Gate::before` menyaring `['view', 'viewAny']`, sehingga `update` dan `delete` atas catatan
> orang lain tetap 403 bahkan untuk super admin.

### 08.1 Empat penutupan pada 2.2 — ✅ TERPASANG

Hasil menyisir seksi ini baris demi baris terhadap kodenya.

| | |
| --- | --- |
| **Aturan status pindah ke model** | Aturan hierarki induk–subtask dulu hanya hidup di `PersonalTaskController::toggle`, dan **tidak ada satu pun komponen yang memanggil route itu**. Tombol "Mark as Done" dan form edit keduanya menembak `update`, yang tidak tahu apa-apa soal aturan itu. Sekarang seluruhnya tinggal di hook `saving` pada model `PersonalTask`, bersebelahan dengan penstempelan `completed_at` yang sudah terbukti dilewati semua jalur. `toggle` menyusut jadi satu panggilan `update` dan ikut mewarisi aturan yang sama. Isi aturannya sendiri berubah pada 2.4 — lihat K19. |
| **`status` & `priority_level` tidak boleh null** | Kedua kolomnya `NOT NULL`, tetapi rules-nya `nullable`. `store` tertolong `?? TODO` untuk status — `priority_level` tidak, dan `update` tidak untuk keduanya. Kiriman bernilai null lolos validasi lalu meledak sebagai 500 di lapisan database. Sekarang `sometimes`: boleh tidak dikirim, tidak boleh dikirim kosong, dan yang kosong ditolak 422. |
| **Pencarian meng-escape wildcard** | `%` dan `_` diteruskan mentah ke `LIKE`, sehingga mengetik `%` mencocokkan seluruh catatan. Escape char-nya `!`, bukan `\`, karena backslash butuh jumlah lolos yang berbeda di MySQL dan SQLite — dan test berjalan di SQLite sementara production MySQL. |
| **Agregat ringkasan memakai binding** | `PersonalTaskSummary` merangkai nilai ke `selectRaw` lewat interpolasi string. Isinya enum internal dan `now()`, jadi tidak pernah bisa diinjeksi — tetapi melanggar aturan 14 dan ongkos memperbaikinya nol. |

**Yang sengaja belum disentuh.** Filter status dan pencarian hanya menyaring task induk; sejak
2.2 subtask tidak lagi tampil di tabel, jadi selisihnya tidak lagi terlihat — yang tersisa
adalah kata kunci yang hanya cocok di judul subtask tidak menemukan induknya. Dan
`PersonalTaskSummary` menghitung seluruh baris termasuk subtask, sementara daftar dan
pagination hanya menghitung induk. Keduanya keputusan tampilan, bukan invariant yang bocor.

### 08.2 Penjagaan status dan penyeragaman panel — ✅ TERPASANG

Datang dari pemakaian langsung, bukan dari menyisir kode — dan salah satunya **membatalkan
aturan yang baru dipasang satu versi sebelumnya**. Tiga baris pertama terpasang pada 2.4,
baris deadline menyusul pada 2.5.

| | |
| --- | --- |
| **Cascade diganti penolakan** (K19) | Pada 2.2, menyelesaikan induk ikut menyelesaikan seluruh subtask-nya. Itu keliru arah: yang diinginkan justru sebaliknya — induk **tidak boleh** selesai selama masih ada subtask terbuka. Sistem tidak lagi merapikan keadaan diam-diam, ia menolak dan menyebut jumlah yang tersisa. Hook `saved` yang melakukan cascade dicabut; penggantinya penjagaan di `saving`. |
| **Subtask tidak mendahului induk** (K19) | Subtask tidak bisa keluar dari `todo` selama induknya masih `todo`. Ditegakkan untuk `in_progress` **dan** `done` sekaligus, karena membatasi satu saja meninggalkan pintu belakang: kalau mengerjakan belum boleh, menyelesaikan langsung apalagi. |
| **Deadline wajib sebelum mulai** (K20) | Task pribadi boleh lahir tanpa `due_date` — ia memang sering cuma catatan. Tetapi begitu hendak dikerjakan, tenggatnya harus ditetapkan lebih dulu. Ditegakkan juga pada pembuatan, karena kalau tidak, aturannya cukup dilangkahi dengan membuat task yang langsung berstatus `in_progress`. |
| **Lebar panel detail disamakan** | `MyTaskDetailSheet` memakai `sm:max-w-lg` sementara `TaskDetailSheet` — yang dipakai dashboard, task divisi, dan task project — memakai `sm:max-w-3xl`. Yang disamakan hanya ukurannya; komponennya tetap terpisah, karena isi kedua panel itu memang berbeda (task pribadi tidak punya bukti, review, komentar, maupun lampiran). |

**Antarmuka memakai rumus yang sama, bukan rumus yang mirip.** Nasihat 1.8 memperingatkan
aturan yang ditulis ulang dengan rumus berbeda di policy, controller, dan antarmuka — di sini
pencegahnya struktural: seluruh sisi frontend memanggil satu fungsi,
`myTaskStatusBlockReason()` di `resources/js/lib/my-task-status.ts`, yang menjadi cerminan
tunggal `PersonalTask::guardStatusTransition()`. Dipakai dua tempat sekaligus:

- **Panel detail** mematikan tombol aksinya beserta alasannya.
- **Form edit** mematikan pilihan status yang terlarang di dropdown, dan ikut mematikan
  tombol Simpan bila status yang sedang terpilih berubah menjadi terlarang — misalnya
  deadline dikosongkan selagi "In Progress" aktif. Tanpa itu, penolakannya baru muncul
  sesudah dikirim.

Form-nya menilai memakai **isian yang sedang diketik**, bukan yang tersimpan, sehingga
menetapkan deadline lalu memilih "In Progress" dalam satu sesi pengisian langsung terbuka
tanpa perlu menyimpan dua kali. Konteksnya juga meniru pembagian di server: saat membuat task
baru, hanya aturan deadline yang menutup pilihan — dua aturan hierarki tidak, persis seperti
yang dilakukan `guardStatusTransition()`.

---

## 09. Diskusi & Komentar

Percakapan menempel pada task. Bagian ini yang paling jauh dari draf 0.5.

- Komentar hanya dapat dilampirkan pada **task**. Tidak ada komentar tingkat project.
- Sebuah pesan valid bila punya teks, lampiran, atau keduanya. Teks maksimum 5.000
  karakter; lampiran maksimum 10 berkas per pesan, 10 MB per berkas — **termasuk gambar**,
  lihat 09.4.
- Panel membuka **30 pesan terakhir**; menggulir ke puncak memuat potongan sebelumnya, dan
  mengirim pesan selalu menurunkan tampilan ke bawah. Lihat 09.4.
- Lampiran gambar dikompres ke WebP; tipe lain disimpan apa adanya. Ekstensi eksekusi —
  `php`, `exe`, `sh`, `js`, dan sejenisnya — ditolak lewat daftar hitam, yang membaca **nama
  berkasnya sekaligus tebakan dari isinya**; lihat 09.3.
- Pesan baru ter-broadcast realtime lewat Reverb, dan memicu notifikasi in-app serta push
  mobile ke assignee task ditambah semua orang yang pernah berkomentar di sana — **yang masih
  boleh membuka task itu**, lihat K22.
- Penyuntingan dan penghapusan komentar: **penulisnya saja**, dan hanya selama ia masih boleh
  menulis di task itu. Komentar yang sudah disunting diberi penanda *diedit* (K21).
- Perlindungan XSS datang dari React yang meng-escape saat render, bukan dari sanitasi di
  server. Tidak ada pustaka pembersih HTML di proyek ini.

> **Empat aturan draf yang belum ada.**
>
> **Balasan bertingkat.** Tabel `comments` tidak punya `parent_id`. Diskusi berbentuk satu
> untaian datar; tidak ada balasan sama sekali.
>
> **Mention `@nama`.** ✅ **TERPASANG.** Mengetik `@` di kolom komentar membuka daftar orang
> yang boleh disebut pada task itu — member project untuk task project, member divisi untuk
> task divisi — dan memilih salah satunya menyisipkan namanya. Yang disebut mendapat
> notifikasi meski ia tidak terlibat sama sekali di task tersebut.
>
> Sebutan disimpan sebagai **teks polos** `@Nama`, bukan token ber-ID. Alasannya isi
> komentar mengalir apa adanya ke badan notifikasi dan push mobile; token akan sampai ke HP
> orang sebagai `@[Budi](12): tolong cek ini`. Konsekuensinya pencocokan dilakukan di
> backend terhadap daftar orang yang boleh disebut, **nama terpanjang lebih dulu**, karena
> nama mengandung spasi sehingga batas akhir sebutan tidak bisa dibaca dari teksnya sendiri.
> Sebutan juga harus berhenti di pembatas kata, supaya "@Budiman" tidak tercocok sebagai
> "@Budi". Jika seseorang berganti nama, sebutan di komentar lama tetap menampilkan nama
> saat itu — ia catatan sejarah, bukan tautan hidup.
>
> Yang disebut namanya **tidak** ikut menerima notifikasi komentar biasa, meski ia assignee.
> Satu komentar menghasilkan satu pemberitahuan per orang.
>
> Sebutan disorot warna saat komentar dirender. Karena batas akhir sebuah sebutan hanya bisa
> ditentukan dengan mengetahui daftar nama, penyorotan mengikuti aturan yang sama dengan
> pencocokan di backend — nama terpanjang lebih dulu, berhenti di pembatas kata. Aturannya
> karena itu ada di dua tempat: `CommentMentionParser` di backend dan
> `resources/js/lib/mention-utils.ts` di frontend. Backend tetap pemegang keputusan siapa yang
> dinotifikasi, jadi selisih di antara keduanya paling jauh membuat satu sebutan tidak
> tersorot — bukan salah kirim. Yang di frontend dijaga `npm run mention:check`.
>
> **Daftar nama yang dipakai menyorot bukan keanggotaan hari ini**, melainkan nama-nama yang
> tercocok saat pesan itu ditulis dan ikut tersimpan bersamanya — lihat 09.5.
>
> **Jendela sunting 15 menit.** Tidak ada, dan tidak akan ada — penulis dapat menyunting
> komentarnya kapan saja. **Penanda *diedit* sekarang ada** (K21). Menyebut seseorang lewat
> suntingan **tidak** memanggilnya (K23).
>
> **Penghapusan sebagai penanda terhapus.** Penghapusan bersifat permanen: barisnya hilang
> dan berkas lampirannya ikut dihapus dari Drive. Admin divisi juga tidak dapat menghapus
> komentar orang lain — hanya penulisnya.

> **Keputusan — viewer tidak berkomentar. ✅ TERPASANG**
> Viewer adalah peran baca-saja yang ditegakkan: tidak berkomentar, tidak menyunting, tidak
> menghapus, tidak melampirkan berkas. Keputusan K3.
>
> `TaskPolicy::discuss()` tidak lagi mendelegasikan mentah ke `view`: setelah lolos
> visibilitas, ia menolak peran `viewer` — peran project untuk task project, peran divisi
> untuk task divisi. Satu penjagaan itu menutup ketiga jalur tulis sekaligus karena
> `store`, `update`, dan `destroy` komentar semuanya memanggil gate yang sama. Membaca
> diskusi tetap terbuka lewat `view`. Komentar lama milik viewer ikut terkunci — ia tidak
> dapat lagi menyunting atau menghapusnya sendiri.

### 09.1 Izin berkomentar diambil dari policy, bukan ditebak ulang — ✅ TERPASANG

Penjagaan di atas benar sejak awal **di server**, dan justru itu yang menyembunyikan
masalahnya: panel detail menghitung sendiri siapa yang boleh berkomentar, dengan rumus yang
sama sekali lain — `isAssignee || isParentAssignee || isOwner || isPIC`. Dua rumus untuk satu
aturan, dan keduanya meleset ke arah berlawanan sekaligus:

- **Ke arah terlalu ketat.** Matriks 03.4 menulis *Berkomentar* ● untuk `member`, dan
  `TaskPolicy::discuss()` memang mengizinkannya. Tetapi anggota project yang tidak kebetulan
  menjadi assignee tidak pernah melihat kolom tulisnya — bukan disabled, tidak dirender sama
  sekali. Hal yang sama menimpa member divisi pada task divisi. **Hak yang hilang tanpa satu
  pun penolakan**, karena tidak ada penolakan: permintaannya tidak pernah dikirim.
- **Ke arah terlalu longgar.** Rumus itu tidak tahu apa-apa soal project beku (03.2), jadi
  composer tetap tampil di project `completed`, dan pesan yang sudah diketik panjang berakhir
  sebagai 403 dengan toast generik. Tombol hapus lebih longgar lagi — ia hanya memeriksa
  kepemilikan, sehingga **penguncian komentar lama milik viewer yang dijanjikan K3 tampil
  sebagai tombol yang bisa diklik lalu gagal**.

Perbaikannya bukan memperbaiki rumusnya, melainkan **membuang rumus keduanya**. `TaskResource`
kini mengirim `can_discuss` yang diambil langsung dari policy — pola yang sudah dipakai
`can_request_deadline`/`can_decide_deadline` — dan panel detail hanya menurut. Kolom tulis,
tombol sunting, dan tombol hapus ketiganya bersandar pada satu nilai itu.

**Dua mapper, bukan satu.** Task sampai ke panel detail lewat `lib/task-mappers.ts` untuk
project/divisi/dashboard, dan lewat `mapGeneratedTaskToTaskoTask` untuk routine work. Nilai
yang hilang di mapper kedua tidak muncul sebagai error, hanya sebagai `false` — dan pelaksana
routine work, yang memang berhak berdiskusi (07.2), akan kehilangan kolomnya diam-diam. Setiap
`can_*` yang ditambahkan setelah ini harus lewat keduanya. Uji yang menjaganya:
`tests/Feature/TaskDiscussionFlowTest.php`.

> **Keputusan — penyuntingan komentar dibangun, beserta penandanya. ✅ TERPASANG (K21)**
> Route `PUT tasks/comments/{comment}` sudah berdiri sejak lama, lengkap dengan
> controller dan event realtime-nya — tetapi **tidak ada satu pun tombol yang memanggilnya**.
> Satu-satunya pemanggil di seluruh repositori adalah `TaskAuthorizationFlowTest`, dan test
> itu lulus hijau setiap kali. Persis pelajaran 2.2: aturan yang hanya dipanggil test-nya
> sendiri tidak membuktikan fiturnya tersambung. Selama itu dokumen ini menulis "penulis dapat
> menyunting komentarnya kapan saja", yang bagi pengguna tidak pernah benar.
>
> Yang dipilih **membangun**, bukan mencabut seperti K18 — mesinnya sudah lengkap dan yang
> kurang cuma tombolnya, sedangkan mengoreksi komentar yang salah ketik adalah kebutuhan
> nyata. Bentuknya penyuntingan di tempat pada balon pesannya: Enter menyimpan, Escape batal.
>
> **Penandanya diturunkan dari timestamp, tanpa kolom baru.** `is_edited` adalah
> `updated_at > created_at`. Keduanya diisi nilai yang sama persis saat baris lahir, dan
> Eloquent tidak menyentuh `updated_at` bila isinya tidak berubah — jadi menekan Save tanpa
> mengubah apa pun tidak ikut menandai. Kolom `edited_at` tersendiri tidak menambah satu pun
> informasi yang tidak sudah ada di sana.
>
> Dua ekor yang ikut ditutup. **Aturan isinya disamakan dengan jalur kirim**: `update` dulu
> mewajibkan teks, padahal pesan boleh berisi lampiran saja — sehingga pesan berisi foto saja
> tidak akan pernah bisa disunting. Sekarang teks hanya wajib bila pesannya tidak punya
> lampiran, dan batas 5.000 karakternya satu konstanta dengan `StoreCommentRequest`. Dan
> **event `TaskCommentUpdated` kini ikut memuat lampiran**: penerima event mengganti komentar
> lama dengan payload itu apa adanya, jadi tanpa lampirannya foto pada pesan yang baru diedit
> lenyap dari layar orang lain sampai halaman dimuat ulang.

### 09.2 Daftar sebutan disamakan dengan batas visibilitas — ✅ TERPASANG

`Task::mentionableUsers()` mengembalikan **seluruh** anggota divisi untuk task divisi. Untuk
task divisi biasa itu tepat — semua anggotanya memang boleh melihatnya. Untuk **routine work**
tidak: `TaskPolicy::view()` mempersempitnya ke pelaksana, pelaksana task induk, dan
admin/supervisor divisi (07.2). Dua sumber untuk satu batas, dan yang satu tidak tahu
pengetatan itu ada.

Yang membuatnya bertahan lama layak dicatat: **docblock-nya sudah menulis aturan yang benar**
— *"mengikuti TaskPolicy::view()"* — sementara kodenya tidak pernah memuat cabang recurring-nya.
Komentar yang menyebut nama aturan yang diikuti terbaca seperti jaminan, padahal ia cuma niat;
yang mengikat hanya kalau rumusnya benar-benar dipanggil dari sana.

Akibatnya autocomplete `@` menawarkan nama orang yang tidak berhak membuka task tersebut.
Sejak K22 sebutan itu tidak lagi mengirimkan isi komentarnya, jadi kebocorannya lebih dulu
tertutup — dan yang tersisa justru bentuk kegagalan yang lain: penyebutnya mengira ia sudah
memanggil seseorang, dan orang itu tidak pernah menerima apa-apa. **Penawaran yang tidak
mungkin dipenuhi lebih menyesatkan daripada nama yang memang tidak muncul di daftar**, jadi
menutup separuhnya justru membuat separuh sisanya wajib.

Cakupannya **disamakan persis dengan `view`**, bukan dipersempit ke pelaksana saja: pelaksana,
pelaksana task induk (untuk subtask), dan admin/supervisor divisi. Alasannya satu — begitu
daftar sebutan memakai rumus yang berbeda dari rumus visibilitas, dokumen ini akan mengulang
persoalan yang sama dengan angka yang lain. Atasan tetap bisa dipanggil saat pelaksana butuh
keputusan, dan itu memang kebutuhan nyata pada routine work.

Tempat penegakannya satu, `Task::mentionableUsers()`, karena endpoint autocomplete dan
`CommentMentionParser` sama-sama bersandar padanya — daftar yang dilihat pengguna dan daftar
yang dipakai backend untuk memutuskan siapa yang dinotifikasi tidak pernah bisa berbeda. Uji
yang menjaganya: `tests/Feature/CommentMentionTest.php`, termasuk satu uji yang memastikan
subtask routine work tetap bisa menyebut pelaksana task induknya.

### 09.3 Tiga ketimpangan kecil yang ikut ditutup — ✅ TERPASANG

**Daftar hitam lampiran membaca nama, penyimpanannya membaca isi.** Validasi memeriksa
`getClientOriginalExtension()` — ekstensi yang diketik pengguna — sementara jalur simpan
memilih ekstensi dari `guessExtension()`, yakni tebakan atas isi berkasnya. Berkas bernama
`catatan.txt` yang isinya terbaca sebagai PHP karena itu lolos, lalu mendarat di Drive sebagai
`.php`. Sekarang keduanya diperiksa.

Satu perkecualian yang perlu ada: `application/octet-stream` adalah jawaban *"tipenya tidak
dikenali"*, dan tebakannya jatuh ke `bin` yang kebetulan ada di daftar hitam. Memeriksanya
tanpa pengecualian akan menolak setiap berkas biner sah yang tipenya asing bagi penebak MIME —
`.dwg`, `.psd` lama, arsip yang jarang. **Menutup celah dengan menolak lebih banyak hanya
benar selama yang ikut tertolak memang tidak sah**; di sini tidak, jadi jawabannya bukan daftar
yang lebih galak melainkan satu perkecualian bernama.

Risikonya sendiri sedang, bukan besar: Drive tidak menjalankan apa pun. Yang diperbaiki adalah
**janji daftar hitam itu terhadap apa yang benar-benar tersimpan** — dan seksi 10 sudah lama
menandai daftar hitam ekstensi sebagai lapisan yang paling lemah di antara ketiga jalur unggah.

**Alasan penolakan lampiran tidak pernah sampai ke pengguna.** Server sudah mengirim pesan yang
spesifik — *"Maksimal 10 lampiran per pesan."*, *"Jenis file ini tidak diizinkan."* — tetapi
`onError` membuangnya dan menampilkan *"Failed to send discussion message"*. Sekarang pesan
pertama dari server yang ditampilkan. **Aturannya sengaja tidak disalin ke klien**: batas
jumlah, daftar hitam, dan ukuran tetap tinggal di satu tempat, karena rumus kedua di frontend
persis yang baru saja dibuang di 09.1.

**Email penulis komentar ikut terkirim ke setiap pembaca.** `CommentResource` memakai
`UserResource` yang membawa email, padahal endpoint `mentionables` di seksi yang sama sengaja
menahannya dan `AssigneeResource` juga sudah menanggalkannya. Sekarang seragam: id dan nama.

Lampirannya lebih jauh lagi. `AttachmentResource` mengirim pengunggahnya lewat
`UserDataResource` — resource berbentuk *dashboard* yang memuat email, `email_verified_at`,
`must_change_password`, `is_active`, beserta dua larik kosong `quick_links` dan
`recent_activities`. Bentuk bersarangnya bahkan tidak cocok dengan tipe di frontend, sehingga
tidak pernah ada yang membacanya — **terkirim tanpa pernah terbaca**. Fieldnya dicabut, bukan
diperkecil: pengunggah lampiran diskusi selalu penulis komentarnya, dan itu sudah tertulis di
balon pesannya. Eager load `attachments.user` ikut turun.

Yang layak dicatat dari yang terakhir: bocornya bukan karena seseorang memilih mengirim data
itu, melainkan karena **satu resource dipakai ulang di tempat yang bentuknya kebetulan tidak
menolak**. Resource bernama umum (`UserResource`, `UserDataResource`) adalah tempat paling
gampang bagi field sensitif untuk menumpang ke halaman yang tidak memerlukannya.

### 09.4 Lampiran gambar dan panjang percakapan — ✅ TERPASANG

Ketiga penutupan di bawah tidak lahir dari sisiran dokumen, melainkan dari menelusuri jalur
lampiran dan membayangkan diskusi yang berumur panjang.

> **Aturan bukti yang menyeberang ke diskusi.**
> Lampiran gambar di kolom diskusi dioper ke `optimizeImageProofFile()` — helper milik jalur
> **bukti pengerjaan** — yang menolak gambar mentah di atas **2 MB**. Angka itu aturan bukti
> (10), bukan aturan diskusi: seksi ini menulis 10 MB per berkas, dan server memang menegakkan
> 10 MB. Akibatnya **foto kamera HP, yang rutin 3–5 MB, ditolak di kolom diskusi** — padahal
> fungsi yang menolaknya justru akan mengecilkannya ke bawah 200 KB kalau dibiarkan lewat.
>
> Plafon mentahnya kini parameter: jalur bukti tetap 2 MB, jalur diskusi memakai 10 MB yang
> sama dengan servernya, dan pesan errornya menyebut angka yang benar-benar berlaku.
>
> Polanya sama dengan yang dikejar 09.1, hanya berpindah lapis: bukan aturan yang ditulis dua
> kali, melainkan **aturan satu fitur yang ikut terbawa ke fitur lain lewat helper bersama**.
> Yang membuatnya sulit terlihat, helper itu bernama benar untuk pemakai aslinya — `Proof` ada
> di namanya sejak awal, dan pemakai keduanya tetap memanggilnya tanpa curiga. **Nama yang
> menyebut fitur asalnya adalah peringatan, bukan jaminan.**

> **Gambar tetap dikompres dua kali, dan itu disengaja.**
> Klien meng-encode ke WebP sampai di bawah 200 KB, lalu server men-decode dan meng-encode ke
> WebP lagi. Terbaca boros: kualitas turun dua kali untuk ukuran yang praktis sama, ditambah
> kerja GD yang seolah tidak menghasilkan apa pun. Jalan pintas yang melewatkan biner WebP
> kecil karena itu sempat dipasang pada 2.6 — **lalu dibatalkan pada versi yang sama.**
>
> Sebabnya encode ulang bukan cuma mengecilkan berkas: ia **membuang metadata**, termasuk EXIF
> berisi koordinat GPS. Kompresi di klien bukan batas kepercayaan — endpoint-nya menerima
> unggahan apa pun, jadi foto ber-GPS yang dikirim langsung ke API akan tersimpan utuh lalu
> dibagikan ke seluruh peserta diskusi. Satu encode yang terlihat mubazir ternyata satu-satunya
> yang menyaring.
>
> **Pelajarannya melampaui gambar:** langkah yang tampak murni pemborosan sering memikul
> properti kedua yang tidak tertulis di namanya. Sebelum melewati sebuah langkah demi kecepatan,
> tanyakan apa lagi yang ia lakukan selain yang disebut namanya.
>
> Satu batas yang **tidak** ikut terjaga dan perlu disadari: penurunan dimensi hanya terjadi
> saat berkasnya masih di atas plafon, sehingga gambar berdimensi raksasa berwarna polos —
> berkasnya kecil — tetap lolos, dan justru menghabiskan memori server saat didekode. Ditandai
> `ponytail:` di `MediaStorage`; jalan keluarnya batas piksel sebelum decode, dikerjakan kalau
> unggahan dari luar antarmuka mulai jadi perhatian. Uji yang menjaganya:
> `tests/Unit/MediaStorageWebpTest.php`.

> **Panel membuka percakapan terakhir, bukan seluruhnya.**
> Daftar komentar dulu dimuat tanpa batas: setiap kali panel dibuka, seluruh pesan beserta
> lampirannya ikut terbawa. Untuk diskusi sepuluh pesan tidak terasa; untuk task berumur
> panjang, panelnya makin berat justru seiring dipakai.
>
> Sekarang `GET tasks/{task}/comments` mengirim **30 pesan terakhir** beserta `has_more`, dan
> potongan sebelumnya diminta lewat `?before=` saat pengguna menggulir mendekati puncak.
> Kursornya **id, bukan offset**: komentar bisa terhapus di tengah jalan, dan offset akan
> menggeser potongan berikutnya sehingga ada pesan yang terlewat atau muncul dua kali. Batas
> "masih ada yang lebih lama" dijawab dengan mengambil satu baris lebih banyak dari yang
> dikirim, bukan query hitung tersendiri.
>
> Dua perilaku gulir menyertainya. Saat potongan lama tersisip di atas, posisi baca ditahan
> dengan mencatat jarak dari dasar sebelum render lalu memulihkannya di `useLayoutEffect` —
> tanpa itu daftar melompat setiap kali memuat. Dan **mengirim pesan sendiri selalu menurunkan
> tampilan ke bawah**, berapa pun jauhnya pengguna sedang membaca ke atas, karena di situlah
> pesannya mendarat. Auto-scroll untuk pesan orang lain tetap seperti sebelumnya: hanya bila
> pengguna memang sedang berada di dekat dasar.
>
> Bentuk respons daftar berubah dari larik telanjang menjadi `{ data, has_more }`. Pembaca di
> frontend tetap menerima larik telanjang apa adanya, supaya tab yang belum dimuat ulang
> setelah deploy tidak mendapati panelnya kosong.

### 09.5 Sorotan sebutan berhenti mengikuti keanggotaan hari ini — ✅ TERPASANG

Sebutan disimpan sebagai teks polos, jadi penyorotannya harus mencocokkan nama ke dalam teks —
dan daftar nama yang dipakai selama ini adalah **daftar orang yang bisa disebut sekarang**.
Selama semua orang masih di tempatnya, itu terlihat benar. Begitu seseorang keluar dari
project, namanya hilang dari daftar, pencocokannya gagal, dan `@Budi` di komentar bulan lalu
berubah menjadi teks abu-abu biasa.

Yang keliru bukan warnanya, melainkan **siapa yang mengubahnya**: isi komentar tidak disentuh
siapa pun, tetapi tampilannya berubah karena keanggotaan bergeser di tempat lain. Dokumen ini
sendiri sudah menyatakan sebutan adalah *"catatan sejarah, bukan tautan hidup"* — teksnya
memang begitu, sorotannya tidak.

Kolom `comments.mentioned_names` kini menyimpan nama-nama yang **tercocok saat pesan itu
ditulis**, diisi di jalur kirim maupun sunting. Penyorotan memakai daftar itu; keanggotaan
sekarang hanya dipakai sebagai cadangan untuk baris yang lahir sebelum kolomnya ada. Kedipan
saat panel dibuka ikut hilang, karena sorotan tidak lagi menunggu request daftar sebutan.

**Isi komentar tetap teks polos.** Yang disimpan pendamping di kolom tersendiri, bukan token di
dalam kalimat — keputusan yang melahirkan seluruh kerumitan pencocokan ini tidak dibatalkan,
sebab alasannya masih berdiri: isi komentar mengalir apa adanya ke badan notifikasi dan push
mobile.

Satu beda yang harus dijaga saat menyentuh bagian ini: **daftar kosong bukan daftar yang tidak
ada.** `[]` berarti "pesan ini memang tidak menyebut siapa pun" dan harus menghasilkan nol
sorotan; `null` berarti "lahir sebelum kolom ini ada" dan barulah jatuh ke daftar keanggotaan
sekarang. Memeriksanya dengan `.length` menyamakan keduanya dan menghidupkan lagi persoalan
yang baru ditutup.

> **Keputusan — menyebut seseorang lewat suntingan tidak memanggilnya. ✅ TERPASANG (K23)**
> `update` menghitung ulang sebutan supaya sorotannya mengikuti teks terbaru, tetapi **tidak**
> memanggil `TaskNotifier`. Menyunting "oke siap" menjadi "oke siap @Budi tolong lanjutkan"
> karena itu tidak memberi tahu Budi.
>
> Sebelum K21 perilaku ini tidak pernah dirancang — jalur suntingnya memang tidak terjangkau
> pengguna. Begitu tombolnya ada, ia berubah menjadi pertanyaan yang harus dijawab, dan
> jawabannya **tidak**: notifikasi pada suntingan berarti satu pesan yang disunting berulang
> kali menjadi alat memberondong orang lain, dan tidak ada jendela sunting yang membatasinya
> (lihat 09). Notifikasi mengikuti *pesan yang dikirim*, bukan *teks yang berubah*.
>
> Yang perlu dipegang pembaca berikutnya, sama seperti K14: **jangan menutup ini diam-diam
> sebagai "notifikasi yang terlupa".** Kalau kelak dibalik, yang perlu dipasang lebih dulu
> adalah pembatas — hanya menotifikasi nama yang *baru* muncul, dan hanya sekali per orang per
> komentar. Uji yang menjaganya: `tests/Feature/CommentMentionTest.php`.

---

## 10. Media & Lampiran

Berkas pendukung task, bukti pengerjaan, dan lampiran diskusi. Seluruhnya disimpan di Google
Drive lewat satu titik: `MediaStorage`.

- Nama berkas selalu ditulis ulang sebagai UUID. Nama asli disimpan sebagai metadata pada
  bukti **dan lampiran diskusi**, lalu dipakai di mana pun namanya ditampilkan (10.2).
- Gambar dikompres ke WebP sampai di bawah 200 KB — kualitas diturunkan bertahap, lalu
  dimensinya diperkecil bila masih terlalu besar. **Setiap** gambar melewatinya, termasuk yang
  sudah WebP dan sudah kecil, karena encode ulang itulah yang membuang EXIF; lihat 09.4.
- Batas ukuran unggahan: 10 MB per berkas. Tambahan **2 MB untuk bukti bergambar sebelum
  kompresi** hanya berlaku di jalur bukti — lampiran diskusi memakai 10 MB yang sama dengan
  berkas lain (09.4). **Tidak ada** batas 50 MB per unggahan sekaligus. Angka-angka ini
  aturan aplikasi, dan **aturan aplikasi bukan batas yang sesungguhnya berlaku** — yang
  memutuskan lebih dulu adalah `post_max_size` dan `upload_max_filesize` milik PHP; lihat 10.4.
- Halaman Media dibatasi cakupan: project yang diikuti atau dibuat pengguna, ditambah divisi
  tempat ia terdaftar. Super admin membaca seluruhnya. Di dalam cakupan itu, isinya disaring
  sekali lagi oleh rumus `TaskPolicy::view()` — berkas mengikuti izin task yang memuatnya, dan
  itu berlaku sama untuk daftar maupun untuk route yang membuka berkasnya (10.1).
- Media **routine work tidak pernah muncul di halaman Media**, untuk siapa pun termasuk
  pelaksananya sendiri: `Division::tasks()` mengecualikan task hasil template. Buktinya tetap
  terbuka dari panel task-nya. Ini perilaku lama yang baru tercatat di sini; bukan aturan yang
  diputuskan, melainkan akibat dari relasi yang dipakai halaman itu.
- Menghapus komentar atau bukti **langsung menghapus berkasnya**. Pada penghapusan permanen
  project, berkas dikumpulkan lebih dulu lalu dihapus setelah transaksi commit.
- ⬜ **BELUM** Turunan berukuran kecil untuk pratinjau. Yang ada adalah satu berkas hasil
  kompresi, dipakai untuk pratinjau maupun tampilan penuh.

> **Dua klaim keamanan yang perlu dikoreksi.**
>
> **Daftar putih MIME.** Tidak seragam. Bukti berjenis `file` memakai `mimes:` yang menebak
> tipe dari isi berkas — ini yang paling ketat. Bukti gambar memakai aturan `image`, yang di
> Laravel 12 sudah **tidak** memuat SVG kecuali diminta eksplisit, dan isinya ditulis ulang
> oleh GD menjadi WebP sehingga payload maupun EXIF ikut hilang. Lampiran diskusi memakai
> **daftar hitam ekstensi**, yang secara prinsip tetap lebih lemah — tetapi sejak 09.3 ia
> membaca nama berkas sekaligus tebakan atas isinya, jadi nama yang menyamar tidak lagi
> meloloskan apa pun. Jalur bukti tanpa tipe, yang dulu melewati ketiganya, sudah ditutup di 05.
> Jalur keempat yang tidak menyaring apa pun — `StoreAttachmentRequest` — dihapus pada 2.7,
> karena ternyata memang tidak pernah dipanggil siapa pun (10.3).
>
> **URL bertanda tangan berumur pendek.** Tidak ada. Berkas diunggah ke Drive dengan
> visibilitas `public`, dan yang dibagikan adalah tautan Drive permanen —
> `lh3.googleusercontent.com/d/{id}` untuk gambar,
> `drive.google.com/file/d/{id}/view` untuk dokumen. Route aplikasi memang memeriksa izin
> sebelum mengarahkan, tetapi **tautannya juga tertanam di payload halaman** lewat `file_url`,
> sehingga siapa pun yang bisa melihat task itu memegangnya — dan tautan itu tetap bekerja
> tanpa login, untuk siapa pun yang ia teruskan. Artinya kerahasiaan bukti sama dengan
> kerahasiaan tautannya, dan gate pada route `show` bukan lapis yang menahannya. Ini keputusan
> arsitektur yang perlu disadari.
>
> **Bukti berupa tautan adalah pengalihan terbuka, dan itu memang fiturnya.** Route
> `tasks/proofs/{proof}` meneruskan ke URL mana pun yang diisi assignee, jadi tautan berdomain
> perusahaan bisa mendarat di situs luar. Skemanya kini dibatasi `http`/`https` — sebelumnya
> aturan `url` bawaan Laravel meloloskan `data://` dan `view-source://`, yang tersimpan lalu
> berakhir 404. Membatasi tujuannya berarti mencabut fitur "bukti berupa tautan"; bila suatu
> saat perlu diredam, jawabannya halaman antara, bukan daftar putih domain.

### 10.1 Berkas media berhenti punya rumus izinnya sendiri — ✅ TERPASANG

Pola 1.8 muncul lagi, dan kali ini pada berkas: satu aturan, tiga rumus, dan yang paling
longgar yang menentukan siapa memegang apa.

`MediaController::authorizeTaskMediaAccess()` menghitung sendiri siapa yang berhak membuka
sebuah berkas — anggota atau creator project, atau anggota divisi. Rumus itu berhenti tepat
sebelum cabang terakhir `TaskPolicy::view()`: **routine work divisi hanya boleh dilihat
pelaksananya**, pelaksana task induknya, dan admin/supervisor divisi (07.2). Cabang yang sama
yang sudah terlewat sekali di 09.2 pada daftar sebutan, terlewat lagi di sini.

Akibatnya setiap anggota divisi bisa membuka bukti routine work milik siapa pun lewat
`media/files/proofs/{id}`. Id-nya numerik dan berurutan, jadi tidak perlu tautan dari siapa
pun untuk sampai ke sana. Dan yang didapat sesudahnya bukan tampilan berkas melainkan
**pengalihan ke tautan Drive permanen** yang, seperti dicatat di atas, bekerja tanpa login
untuk siapa pun yang ia teruskan — jadi satu kali berhasil membuka sama dengan memegangnya
selamanya.

Route ketiga menegaskan pola yang sama dari arah berlawanan: `tasks/proofs/{proof}` memakai
`updateStatus`, gerbang yang **lebih ketat** — hanya assignee dan atasan. Berkas yang sama,
dua pintu, dua jawaban berbeda. Yang longgar menentukan kerahasiaan, yang ketat cuma
melahirkan 403 di salah satu tombol.

Perbaikannya sama dengan 09.1: **buang rumus keduanya, jangan cocokkan.** Aturan visibilitas
task sekarang hidup di satu tempat, `Task::scopeVisibleTo()`, dan `TaskPolicy::view()` memanggil
scope itu alih-alih menyimpan salinannya. Bentuk per-baris dipakai gate (`media.files.show`,
`tasks.proofs.show`), bentuk per-daftar dipakai halaman Media dan dashboard sebagai saringan
query. Keduanya tidak bisa berselisih karena keduanya kalimat SQL yang sama.

**Satu jebakan yang muncul saat mengerjakannya, dan layak dicatat karena bentuknya umum.**
Lolosnya super admin mula-mula ditaruh di dalam scope — terbaca wajar, karena super admin
memang boleh melihat segalanya. Yang tidak terlihat: `TaskPolicy::discuss()` bersandar pada
`view()`, jadi kelonggaran itu menular ke gate **tulis**, dan super admin berhenti menjadi
baca-saja. `SuperAdminVisibilityTest` menangkapnya seketika. Keistimewaan super admin karena
itu tetap tinggal di satu tempat, `Gate::before`, yang memang hanya melepas ability baca;
pemanggil yang menyusun daftar baca melewatkannya sendiri. **Hak istimewa yang disalin ke
lapisan lebih dalam akan menetes ke setiap aturan yang kebetulan memanggil lapisan itu** —
tempatnya di pintu, bukan di rumusnya.

Uji yang menjaganya: `tests/Feature/MediaRepositoryTest.php`, termasuk satu uji bahwa anggota
divisi biasa ditolak pada bukti routine work orang lain sementara pelaksana dan supervisornya
tetap masuk, dan satu lagi untuk route bukti lama.

### 10.2 Nama asli berkas sampai ke layar — ✅ TERPASANG

Setiap unggahan ditulis ulang sebagai UUID, jadi nama simpanannya tidak pernah bisa
ditampilkan. Bukti sudah menyimpan `original_name` sejak lama untuk itu. Dua hal yang tidak
mengikutinya:

- **Lampiran diskusi tidak punya kolomnya sama sekali**, sehingga `laporan-agustus.pdf` tampil
  di panel diskusi sebagai deretan heksadesimal.
- **Halaman Media tidak membaca kolom itu** bahkan untuk bukti, melainkan `basename()` path —
  yang isinya justru UUID yang ingin disembunyikan.

Yang kedua ini bentuk lain dari pelajaran 2.2: **kolom yang terisi bukan bukti datanya sampai
ke layar.** Jalur tulisnya benar, jalur bacanya tidak pernah dipasang, dan tidak ada yang
gagal — hanya nama yang tidak berarti bagi siapa pun yang membacanya.

Sekarang `attachments.original_name` ada dan diisi jalur unggah lampiran, dan kedua pembacanya
memakainya. Untuk berkas bergambar ekstensinya dibuang, karena berkas fisiknya sudah menjadi
WebP — aturan yang sudah dipakai bukti bergambar, kini berlaku sama di dua jalur. Lampiran lama
**tidak di-backfill**: nama aslinya memang tidak pernah tercatat, jadi yang bisa dilakukan cuma
menebak, dan pembacanya jatuh ke basename seperti sebelumnya.

### 10.3 Halaman index berhenti menarik seluruh repositori, dan satu jalur mati dihapus — ✅ TERPASANG

Halaman index Media menampilkan dua angka per folder: jumlah berkas dan waktu terbarunya.
Untuk mendapatkannya ia memuat `tasks.attachments` dan `tasks.proofs` **setiap project dan
setiap divisi**, lalu membangun setiap barisnya menjadi item media lengkap — URL Drive, tipe,
ukuran terformat, thumbnail, kode task — dan membuang semuanya kecuali `count()` dan
`max('updated_at')`. Untuk super admin, yang cakupannya seluruh sistem, itu berarti menarik
seluruh isi repositori media setiap kali halaman dibuka.

Angkanya diukur pada 20 project × 10 task × 6 berkas: **1.200 baris media di-hydrate menjadi
nol**, memori pemuatan data 2.976 KB menjadi 556 KB, dan seluruh halaman selesai dalam 7 query.
Yang diminta sekarang agregatnya (`withCount` + `withMax`), bukan barisnya; saringan
`visibleTo` tetap ikut sehingga angkanya menghormati batas yang sama dengan isinya.

Pada sisiran yang sama, **`StoreAttachmentRequest` dihapus**. Ia tidak punya satu pun pemanggil
sejak refactor DDD (`ddf6fdf`, 4 Juni 2026) — sisa jalur "lampiran task" yang sudah tidak ada
sejak lampiran pindah ke bawah komentar. Yang membuatnya layak dihapus bukan sekadar tidak
terpakai, melainkan isinya: `['required', 'file', 'max:10240']`, tanpa daftar putih maupun
daftar hitam. **Kode mati yang terbaca sebagai lubang keamanan menghabiskan waktu orang yang
mengauditnya**, dan itu biaya yang nyata meski runtime-nya nol.

### 10.4 Batas unggahan yang sesungguhnya ada di server, bukan di kode — 🔨 DIPUTUSKAN

Laporan dari lapangan: dokumen 2,3 MB ditolak pada unggahan bukti, padahal batas aplikasi
10 MB. Penyebabnya bukan aturan di repositori ini melainkan PHP, yang `post_max_size` dan
`upload_max_filesize`-nya masih di angka bawaan 2M — request-nya berakhir sebelum Laravel
sempat melihatnya.

Dua hal yang membuatnya sulit dikenali, dan keduanya layak dicatat:

- **Gambar tidak pernah kena.** Bukti bergambar dikompres di browser menjadi WebP di bawah
  200 KB sebelum dikirim, jadi yang menyentuh batas server hanya dokumen. Gejalanya karena itu
  terbaca terbalik — seolah aturan dokumen lebih ketat daripada aturan gambar, padahal justru
  dokumen satu-satunya yang dikirim dalam ukuran aslinya.
- **Kegagalannya tidak meninggalkan pesan.** `post_max_size` yang terlampaui menghasilkan
  `PostTooLargeException` (413) dari middleware global, dan Inertia hanya memanggil `onError`
  untuk 422 — jadi toast di panel bukti tidak pernah menyala dan yang muncul modal error
  mentah. **Batas yang ditegakkan di luar aplikasi gagal di luar jalur pesan aplikasi juga.**

Yang perlu diselaraskan ada di konfigurasi, bukan kode: `upload_max_filesize = 10M` menyamai
batas aplikasi, `post_max_size` menampung satu pesan berisi 10 lampiran (`110M`),
`memory_limit` cukup untuk mendekode gambar sebelum dikompres (`256M`), dan
`client_max_body_size` nginx disamakan dengan `post_max_size`. Nilainya harus dibaca dari
PHP-FPM, bukan CLI — keduanya kerap berbeda, dan membaca yang salah adalah cara termudah
menyimpulkan tidak ada masalah.

Dua penutup di sisi kode sudah terpasang. `StoreProofRequest` kini punya pesan
`file.uploaded` — kegagalan yang datang dari `upload_max_filesize` bukan pelanggaran aturan
`max`, jadi ia sebelumnya jatuh ke pesan bawaan Laravel yang tidak menyebut ukuran sama
sekali. Dan 413 ditangani sebagai toast lewat satu pendengar `httpException` di `app.tsx`:
respons itu bukan respons Inertia dan bukan 422, sehingga `onError` panel yang sedang dipakai
memang tidak akan pernah menyala — yang bisa dibatalkan hanyalah modal errornya, dan tempatnya
satu untuk seluruh jalur unggah karena batasnya juga satu, milik server. **Yang tersisa di
seksi ini murni konfigurasi server**, dan sampai ia dinaikkan kedua penutup di atas hanya
membuat kegagalannya terbaca, bukan hilang.

---

## 11. Notifikasi

Pemberitahuan mengikuti keterlibatan, bukan jabatan — dan berjalan di dalam aplikasi web
serta diteruskan ke aplikasi mobile kantor.

> **Keputusan — in-app dulu, email menyusul.**
> Notifikasi web berjalan **in-app**. `TaskNotification::via()` mengembalikan
> `['database', 'broadcast']`: riwayatnya tersimpan di tabel `notifications` untuk dropdown
> lonceng, dan broadcast realtime memakai `ShouldBroadcastNow` sehingga tidak bergantung
> pada worker queue. Setiap baris "email" pada draf 0.5 diganti in-app. Keputusan K4.
>
> Email *akan* ditambahkan, tetapi bukan sekarang dan bukan keputusan tim ini sendirian: ia
> menunggu worker antrean production yang dipegang tim lain. Sampai worker itu benar-benar
> berjalan, **jangan** mengubah `ShouldBroadcastNow` menjadi `ShouldQueue`.
>
> Reset password adalah pengecualian: notifikasinya dikirim sinkron oleh Laravel, tidak
> lewat antrean. Ia hanya butuh transport surat yang benar — lihat 01.1 — bukan worker.

> **Push mobile.** Selain in-app, setiap peristiwa di tabel bawah juga diteruskan ke gateway
> push aplikasi mobile kantor (`MOBILE_PUSH_URL`). Gateway menerima banyak penerima dalam
> satu array, jadi `MobilePushNotifier` mengirim satu request per peristiwa — bukan satu per
> orang seperti kalau ini dibuat sebagai notification channel Laravel. Penerima dipetakan
> lewat `users.nik` → `empnik`; user yang NIK-nya masih kosong hanya dapat notifikasi in-app.
> Pengirimannya sinkron dengan timeout 5 detik dan kegagalannya hanya dicatat ke log, karena
> in-app tetap kanal utama. Kosongkan `MOBILE_PUSH_URL` untuk mematikannya.

> **Izin notifikasi browser adalah gerbang masuk aplikasi. ✅ TERPASANG**
> Seluruh isi aplikasi di balik login dibungkus `NotificationPermissionGate` — pembungkus
> terluar di `layouts/app/app-tasko-layout.tsx`, jadi ia berlaku di setiap halaman kecuali
> layar autentikasi yang memakai layout sendiri. Selama izin notifikasi browser belum
> `granted`, sebuah overlay menutup layar dan **tidak ada opsi melewatinya**.
>
> Tiga status, tiga perlakuan. `default` mendapat tombol *Enable notifications* yang memicu
> prompt OS — ia sengaja dipanggil dari handler klik, sebab browser tidak menampilkan prompt
> yang dipicu di luar interaksi pengguna. `denied` mendapat panduan unblock manual beserta
> tombol *Re-check permission*, karena izin yang sudah diblokir tidak dapat dibuka lagi lewat
> kode; hook-nya memberi tahu pendengarnya setiap kali status dibaca ulang, sehingga membuka
> izin dari pengaturan browser cukup diikuti satu klik tombol itu tanpa reload. Browser tanpa
> Notification API dilewatkan begitu saja — tidak ada yang bisa dipaksa di sana.
>
> **Yang dijaganya notifikasi OS, bukan notifikasi in-app.** Izin itu dipakai di satu tempat:
> `showOsNotification()` pada `hooks/use-notifications.tsx`, yang memunculkan notifikasi
> desktop saat siaran realtime masuk — ber-`tag` per objek dan jenis agar pesan sejenis saling
> menimpa alih-alih menumpuk, dan kliknya memfokuskan tab lalu membuka `openNotification`,
> endpoint yang sama dengan klik di dropdown, sehingga notifikasinya ikut ditandai terbaca.
> Lonceng, badge belum-dibaca, riwayat di tabel `notifications`, dan push mobile **tidak
> bergantung padanya sama sekali**: keempatnya tetap berjalan meski izin ditolak. Gerbang ini
> karena itu lebih keras daripada yang dituntut mekanismenya sendiri — bukan syarat teknis
> melainkan pilihan produk, dan itu yang perlu disadari sebelum seseorang mencabutnya sebagai
> "penghalang yang tidak ada gunanya" atau mempertahankannya sebagai "syarat agar realtime
> jalan". Keduanya salah baca.
>
> **Ini penghalang di layar, bukan penjagaan.** `children` tetap dirender di belakang overlay
> dan tidak ada satu pun pemeriksaan di sisi server; menghapus simpul overlay-nya lewat
> devtools sudah cukup untuk memakai aplikasi seperti biasa. Polanya sama dengan menu
> Administration yang disembunyikan sementara yang benar-benar menolak adalah gate
> `administer-system` (00.1) — bedanya di sini memang **tidak ada** lapis kedua, sebab yang
> ditahan kesediaan pengguna, bukan kewenangannya.
>
> Perilaku ini sudah lama berjalan dan tidak pernah tertulis di dokumen mana pun sampai 3.0.
> Ia dicatat apa adanya, bukan diputuskan ulang — sejalan dengan cara media routine work
> dicatat di 10.

| Peristiwa | Penerima | Keadaan |
| --- | --- | --- |
| Ditugaskan ke task | Assignee yang baru ditambahkan, tanpa si pelaku | ✅ **TERPASANG** in-app + push mobile |
| Task diajukan review | Owner/PIC project, atau admin/supervisor divisi | ✅ **TERPASANG** in-app + push mobile |
| Review ditolak | Seluruh assignee task | ✅ **TERPASANG** in-app + push mobile |
| Pengajuan perpanjangan deadline | Reviewer yang sama | ✅ **TERPASANG** in-app + push mobile |
| Keputusan perpanjangan | Seluruh assignee task | ✅ **TERPASANG** in-app + push mobile |
| Deadline H-1 | Seluruh assignee task, **kecuali** recurring task | ✅ **TERPASANG** in-app + push mobile, command tiap jam, ditandai `due_soon_notified_at` |
| Komentar baru | Assignee ∪ semua yang pernah berkomentar, tanpa pengirim | ✅ **TERPASANG** in-app + push mobile, disaring K22 |
| Recurring T-2 jam, belum dikerjakan | Assignee task, saat status masih `waiting`/`in_progress` | ✅ **TERPASANG** in-app + push mobile, command tiap jam, ditandai `reminder_notified_at` |
| Recurring T-2 jam, menunggu approval | Admin/supervisor divisi, saat status sudah `review` | ✅ **TERPASANG** in-app + push mobile, command yang sama |
| Routine work terbit | Pelaksana template | ❌ **DICORET** scheduler menerbitkan pukul 00:00, diganti pengingat T-2 jam di atas |
| Task terlambat | Assignee ∪ atasan yang berwenang | ✅ **TERPASANG** in-app + push mobile, command tiap jam, ditandai `overdue_notified_at`, hanya keterlambatan 24 jam terakhir |
| Project berubah status | Seluruh anggota project, tanpa si pelaku | ✅ **TERPASANG** in-app + push mobile, hanya untuk `completed`/`cancelled`/`on_hold` dan keluar darinya |
| Disebut dalam komentar | User yang disebut, tanpa si penulis | ✅ **TERPASANG** in-app + push mobile, menggantikan notifikasi komentar biasa bagi orang itu |
| Akun dibuat | User bersangkutan | ❌ **DICORET** password awal diserahkan super admin, lihat 01.2 |

- Badge jumlah belum dibaca ikut di setiap request Inertia dan diperbarui realtime tanpa
  reload.
- Sebuah notifikasi menunjuk **salah satu**: task atau project. Yang menunjuk task dibuka
  lewat slug divisi dan `?task=`; yang menunjuk project mendarat di halaman project. Kedua
  jalur memeriksa ulang izin saat diklik, jadi notifikasi lama tidak pernah menjadi pintu
  belakang ke objek yang aksesnya sudah dicabut.

> **Keputusan — penerima disaring ke yang masih boleh membuka task-nya. ✅ TERPASANG (K22)**
> Pemeriksaan ulang saat diklik di atas menjaga pintunya, bukan isi pemberitahuannya — dan
> pada notifikasi komentar, **badan pesannya memuat cuplikan isi komentar**. Selama daftar
> penerima disusun dari riwayat (*siapa yang pernah berkomentar*, *siapa yang tercatat sebagai
> assignee*) sementara riwayat tidak ikut berubah saat seseorang dikeluarkan dari project atau
> divisi, mantan anggota terus menerima potongan diskusi di layar kunci HP-nya — untuk task
> yang kalau dibuka menjawab 403.
>
> `TaskNotifier` karena itu menyaring penerima lewat policy `view` sebelum menyerahkannya ke
> `NotificationDispatcher`. Tempatnya sengaja **di `dispatchTo`, satu pintu yang dilewati
> seluruh jenis notifikasi task**, bukan di jalur komentar saja: jenis notifikasi baru ikut
> terjaga tanpa siapa pun perlu mengingatnya, sejalan dengan cara K15 dipasang di grup route.
> Rumusnya tidak ditulis ulang — yang dipakai policy yang sama dengan yang menolak di HTTP.
>
> **Efeknya melampaui mantan anggota, dan itu disengaja.** Notifikasi yang penerimanya tidak
> bisa membuka task-nya tidak pernah berguna; paling ringan ia gangguan, paling berat ia
> kebocoran. Satu bagian dari 09.2 ikut tertutup karenanya: sebutan `@nama` kepada orang yang
> tidak berhak melihat routine work tidak lagi mengirimkan isi komentarnya. Sisanya — daftar
> autocomplete yang masih menawarkan nama itu — ditutup di 09.2 pada versi yang sama, karena
> saringan ini mengubah kebocorannya menjadi sebutan yang gagal diam-diam.
>
> Harganya satu pemeriksaan izin per penerima; ditandai `ponytail:` di `TaskNotifier` dengan
> jalan keluarnya (batch per task) kalau loop scheduler mulai terasa. Uji yang menjaganya:
> `tests/Feature/TaskDiscussionFlowTest.php`.
>
> **Fixture test lama ikut dibetulkan, bukan penjagaannya yang dilonggarkan.** Lima test
> notifikasi menugaskan task divisi kepada user yang tidak pernah dimasukkan ke divisinya —
> keadaan yang tidak bisa terjadi di produksi (kandidat penugasan diambil dari anggota, dan
> K9 menolak melepas anggota yang masih memegang task terbuka). Test yang hijau di atas
> fixture mustahil tidak membuktikan apa pun.
- Perpindahan status project sehari-hari — draft, todo, in_progress — sengaja tidak
  diberitakan. Hanya yang mengubah apa yang boleh dikerjakan: `completed` dan `cancelled`
  membekukan isi project, `on_hold` menghentikannya sementara. Keluar dari ketiganya
  diberitakan juga, karena itulah tanda pekerjaan boleh dilanjutkan.
- Notifikasi keterlambatan hanya menyentuh task yang tenggatnya lewat dalam **24 jam
  terakhir**. Tumpukan lama tidak pernah ditagih — saat fitur ini dipasang ada 98 task
  terlambat, 32 di antaranya lewat lebih dari sebulan; menyapu semuanya berarti ratusan push
  tentang pekerjaan basi. Batas yang sama melindungi kalau scheduler sempat mati berhari-hari.
- Recurring task dikecualikan dari H-1 karena ia terbit pukul 00:00 dengan tenggat di hari
  yang sama — jendela 24 jam sudah lewat sejak task itu lahir, sehingga notifikasinya akan
  selalu jatuh tengah malam. Penggantinya `tasks:notify-recurring-deadline`, yang memilih
  penerima menurut posisi pekerjaan: masih `waiting`/`in_progress` menagih assignee, sudah
  `review` menagih atasan yang belum memutuskan.
- ❌ **DICORET** Preferensi per pengguna untuk mematikan jenis peristiwa tertentu. Ditunda
  sampai ada keluhan nyata; penerima setiap peristiwa sudah sempit dan mengikuti
  keterlibatan, bukan jabatan.
- ❌ **DICORET** Pengelompokan agar satu batch perubahan tidak mengirim puluhan pesan. Setiap
  peristiwa mengirim satu notifikasi per penerima. Ditinjau ulang kalau push mobile terbukti
  berisik di lapangan.
- ❌ **DICORET** Seluruh notifikasi task pribadi, termasuk pengingat deadline (K17). Tabel di
  atas hanya menyangkut task kerja; `personal_tasks` tidak punya padanan
  `due_soon_notified_at` dan tidak disentuh satu pun command penjadwalan. Lihat 08.

---

## 12. Dashboard & Laporan

Draf membayangkan empat dashboard berbeda menurut peran. Yang ada satu, dan isinya
menyesuaikan diri lewat query, bukan lewat percabangan peran.

### 12.1 Dashboard yang berjalan — 🟡 SEBAGIAN

Isinya dua bagian, bukan satu: **daftar task** beserta ringkasannya, lalu **kartu project**
di bawahnya. Bagian kedua tidak pernah tertulis di dokumen ini sampai 2.8, dan ketiga
selisih yang ditutup di 12.3 semuanya hidup di sana.

**Daftar task:**

- Task yang di-assign kepada saya, digabung dengan seluruh task pada divisi yang saya pimpin
  sebagai admin atau supervisor.
- Gabungan itu tetap disaring `visibleTo()`, jadi memimpin divisi tidak membuka task project
  yang tidak mengikutsertakan saya.
- Subtask dikeluarkan dari daftar agar yang tampil adalah pekerjaan tingkat atas.
- Routine work ikut **hanya lewat jalur pertama** — yang di-assign kepada saya — dan hanya
  selama belum selesai. Lewat jalur "divisi yang saya pimpin" ia sengaja tidak ikut: routine
  work terbit berulang untuk setiap anggota dan akan menenggelamkan dashboard seorang lead,
  sementara pengingat tenggatnya memang ditujukan kepada pelaksananya. Sebelumnya ia dibuang
  seluruhnya, dan karena ringkasan memakai cakupan yang sama, kartu *Overdue* berbunyi nol
  tepat setelah pengingat itu berbunyi.
- Secara default hanya task aktif yang tampil; memilih status tertentu — termasuk
  `completed` — mematikan penyaringan itu.
- Urutan bawaannya membawa maksud: task **urgent** lebih dulu, lalu yang **menunggu
  review**, baru di dalam tiap kelompok diurutkan tenggat terdekat. Begitu pengguna memilih
  sort sendiri, prioritas itu tidak dipaksakan.
- Di atas daftar ada ringkasan hitungan per status dari cakupan yang sama. Ringkasan itu
  sengaja dihitung dari cakupan **sebelum** filter dipasang — sama seperti halaman project dan
  halaman divisi — supaya angkanya tetap memberi tahu ada apa saja untuk disaring. Tiga kartu
  di atasnya (*My Tasks*, *Due Soon*, *Overdue*) memasang filter saat diklik, dan rumus tiap
  kartu cocok cabang per cabang dengan rumus filter yang dipasangnya.
- *Due Soon* dihitung dari **sekarang** sampai tujuh hari ke depan, bukan dari awal hari ini,
  supaya rentangnya tidak beririsan dengan *Overdue*. Satu task karena itu hanya masuk ke satu
  kartu dan angka ketiganya bisa dijumlahkan; sebelumnya task yang jatuh tempo pagi tadi
  dihitung dua kali sepanjang sisa hari itu. Rumus ini dipakai bersama halaman project dan
  halaman divisi.
- Ketiga kartu berdiri sendiri: menyalakan satu tidak mematikan filter kartu lain, dan tiap
  kartu hanya menulis ulang parameter miliknya sendiri.

**Kartu project:**

- Project yang saya buat atau saya ikuti, lewat rumus yang sama dengan `ProjectPolicy::view()`
  — lihat 12.3.
- Yang berstatus `completed` dan `cancelled` tidak ikut, karena keduanya membekukan isi
  project. `on_hold` tetap ikut: pekerjaannya berhenti sementara, bukan ditutup.
- Paling banyak delapan kartu, diurutkan tenggat terdekat lebih dulu dan yang tanpa tenggat
  paling akhir. Selebihnya dibuka lewat tautan ke `/projects`, yang memang berpaginasi.

### 12.2 Yang belum ada — ⬜ BELUM

- Dashboard khusus super admin — jumlah user, divisi, project per status, akun belum aktif.
  Super admin melihat dashboard yang sama dengan semua orang, dan karena biasanya bukan
  anggota divisi mana pun, isinya kosong.
- Kartu khusus admin divisi: antrean review, beban per anggota.
- Ekspor laporan ke CSV. Satu-satunya ekspor CSV di seluruh aplikasi adalah jejak audit di
  13.3.
- Cache untuk perhitungan berat beserta penanda waktu pembaruannya. Semua agregat dihitung
  langsung tiap request.

### 12.3 Rumus visibilitas project berhenti ditulis empat kali — ✅ TERPASANG

Pertanyaan "project mana yang boleh saya lihat" dijawab di empat tempat, dengan **tiga rumus
berbeda**:

| Tempat | Rumusnya | Jawabannya |
| --- | --- | :-: |
| `ProjectPolicy::view()` | pembuat **atau** anggota | boleh |
| Daftar project di halaman divisi | anggota **atau** pembuat | muncul |
| Halaman `/projects` | anggota saja | hilang |
| Kartu project di dashboard | anggota saja | hilang |

Kolom terakhir diisi satu project yang sama: yang **pembuatnya sudah dikeluarkan dari
keanggotaannya**. Keadaan itu bisa dicapai — `removeMember` hanya menjaga agar owner terakhir
tidak dilepas, jadi selama ada owner lain, pembuatnya boleh keluar atau dikeluarkan. Sesudah
itu ia tetap boleh membuka project-nya lewat tautan, tetap melihatnya di halaman divisi, dan
tidak menemukannya di dua daftar yang justru paling sering dipakai untuk mencarinya.

Penyelesaiannya menyalin cara 2.7 menutup hal yang sama pada task: satu
`Project::scopeVisibleTo()`, dipanggil `ProjectPolicy::view()` maupun ketiga daftar, sehingga
bentuk per-baris dan bentuk per-daftar tidak bisa berselisih. Kelonggaran super admin **tidak**
ikut masuk ke dalam scope — ia tetap di `Gate::before` yang hanya melepas ability baca, dan dua
daftar yang memang perlu membaca seluruh project menuliskan `isSuperAdmin()`-nya sendiri.
Menaruhnya di dalam scope akan menetes ke setiap aturan yang memanggil scope itu, persis
kekeliruan yang dicatat 2.7.

Dua ketimpangan lain ikut ditutup pada versi yang sama, keduanya di kartu project:

- **Judulnya "Active Projects", query-nya tidak menyaring apa pun.** Project `completed` dan
  `cancelled` ikut tampil di sana, lengkap dengan keadaan kosong yang berbunyi *"You have no
  active projects right now"*. Yang membuatnya bertahan justru penanganan yang setengah benar:
  `getProjectDeadlineState()` **sudah tahu** project tertutup bisa lewat situ dan merapikan
  labelnya menjadi badge status alih-alih tanggal — sehingga kartunya tidak pernah terlihat
  rusak. `Project::scopeActive()` ada sejak lama dan tidak pernah dipanggil dari sini.
- **Tidak ada batas jumlah.** Seluruh project yang diikuti seseorang di-hydrate tiap kali
  dashboard dibuka, dan seluruhnya dirender. Bentuknya sama persis dengan halaman index Media
  yang baru ditutup di 10.3, dan obatnya sama: batas yang jelas, sisanya lewat halaman yang
  memang berpaginasi.

---

## 13. Activity Log

Jejak perubahan yang dapat dipertanggungjawabkan — bukan lagi milik satu project, melainkan
seluruh aplikasi.

### 13.1 Aturan pokok — ✅ TERPASANG

- Dicatat: pembuatan, perubahan (dengan diff kolom), penghapusan, pemulihan, dan penghapusan
  permanen — untuk setiap model yang memakai trait `Auditable`.
- Setiap entri menyimpan pelaku, waktu, objek, label objek, konteks divisi/project, serta
  nilai sebelum dan sesudah — termasuk pada pembuatan, yang sampai 2.9 tidak menyimpan nilai
  apa pun. Lihat 13.4.
- Log bersifat hanya-tambah — modelnya tidak punya `updated_at`, dan tidak ada endpoint yang
  mengubah atau menghapusnya.
- Kolom sensitif — apa pun yang ada di `$hidden` model, termasuk password dan token —
  diganti `[redacted]`. Nama kolomnya tetap dicatat.
- Perubahan yang hanya menyentuh kolom teknis (`updated_at`, `deleted_at`,
  `due_soon_notified_at`) tidak menghasilkan baris.

> **Satu klaim yang perlu dikoreksi.**
> Draf menyebut log "terlihat oleh Super Admin, Admin, dan Supervisor; member melihat log
> terbatas pada task miliknya". Yang ada hanya satu halaman, `/admin/activities`, dan
> seluruhnya tertutup untuk selain super admin. Permission `read-division-activity-log` dan
> `read-my-activity-log` tercantum di `permission_group.json` tetapi belum punya halaman
> maupun route. Yang masih dilihat non-super-admin adalah timeline per project di tab Info,
> yang dibaca dari tabel lama `project_activities`.

### 13.2 Cara pencatatannya — ✅ TERPASANG

Baris audit ditulis oleh `AuditObserver` yang menempel pada event model, bukan oleh panggilan
manual yang tersebar di controller. Konsekuensinya penting: perubahan yang lewat jalur mana
pun — controller, command, seeder — tetap tercatat, dan menambah model baru ke dalam audit
cukup dengan memasang trait.

| | |
| --- | --- |
| **Tabel** | `activity_logs`. Append-only, dengan index pada `created_at`, `(division_id, created_at)`, `action`, dan `(subject_type, subject_id)`. |
| **Kolom inti** | `actor_id`, `actor_name`, `action`, `subject_type`, `subject_id`, `subject_label`, `division_id`, `project_id`, `changes`, `description`. |
| **Diaudit** | User, Division, DivisionMember, Project, RecurringTask, Task, serta turunan task: Comment, Attachment, Proof, TaskReview, dan TaskDeadlineRequest. Turunan task memakai `AuditsThroughTask` agar konteks divisi/project-nya diambil dari task induk. |
| **Sengaja di luar** | `PersonalTask` tidak diaudit, dan itu bukan kelalaian: task pribadi tidak pernah masuk query siapa pun termasuk super admin (lihat 08), sementara halaman `/admin/activities` justru dibaca super admin. Mengauditnya berarti membocorkan isinya lewat pintu belakang. `ProjectMember` juga tidak memakai `Auditable`, karena pivot tidak memicu event model sama sekali — jejaknya ditulis `Project::attachMember()`, lihat 13.5. |
| **Di luar event model** | Login, logout, dan percobaan login gagal dicatat lewat listener terpisah di `AppServiceProvider`; pada login gagal, yang disimpan hanya NIK yang dicoba. Perubahan keanggotaan lewat tabel pivot juga dicatat manual lewat `ActivityLog::record()`, sebab `attach`/`sync`/`detach` tidak memicu event model sama sekali. Untuk keanggotaan project, pencatatan itu tidak lagi tersebar di pemanggil: `Project::attachMember()` yang melakukan keduanya sekaligus — lihat 13.5. |
| **Subjek yang hilang** | Kolom morph sengaja tanpa foreign key: subjek boleh sudah dihapus permanen, dan justru baris penghapusannya yang paling dicari. Nama pelaku dan label subjek disalin saat penulisan. |
| **Data lama** | Baris `project_activities` lama sudah di-*backfill* dengan pemetaan nama aksi ke penamaan baru. Baris hasil backfill membawa kalimat bebas di `description`; baris baru membawa diff `field: sebelum → sesudah` di `changes`. |

### 13.3 Halaman `/admin/activities` — ✅ TERPASANG

- Dijaga gate `administer-system`, sama seperti seluruh area admin.
- Penyaringan: kata kunci (aksi, pelaku, label subjek, deskripsi), divisi, jenis aksi, dan
  rentang tanggal berformat `Y-m-d` agar tanggal rancu tidak diterjemahkan diam-diam.
- Daftar jenis aksi datang dari data yang benar-benar pernah tercatat, bukan konstanta di
  frontend.
- Diurutkan menurun berdasarkan `id`, bukan `created_at` — urutannya sama tapi primary key
  sudah ter-index.
- Ekspor CSV mengikuti filter yang sedang aktif, di-stream per 500 baris dengan
  `lazyByIdDesc`, diawali BOM UTF-8, dan sel yang diawali `=`, `+`, `-`, `@`, tab, atau
  carriage return diberi kutip satu untuk meredam CSV formula injection. Dua yang terakhir
  tidak pernah tertulis di dokumen ini sampai 2.9 — di sini kodenya yang lebih ketat daripada
  yang dijanjikan, dan yang dibetulkan kalimatnya.

### 13.4 Pembuatan berhenti menjadi baris tanpa isi — ✅ TERPASANG

`AuditObserver::created()` memanggil `record($model, 'created')` tanpa argumen ketiga, sehingga
`changes` selalu `null` untuk setiap pembuatan. Yang tercatat hanya bahwa sesuatu lahir, bukan
lahir sebagai apa.

Akibatnya paling terasa pada dua model yang keadaan awalnya justru paling berkonsekuensi.
**Sebuah akun yang dibuat langsung berperan `super_admin` tidak meninggalkan satu baris pun
yang menyebut perannya** — selama peran itu tidak pernah diubah lagi sesudahnya, tidak ada
`updated` yang menuliskannya, dan log kehilangan justru kejadian yang paling ingin ditanyakan
di kemudian hari. Hal yang sama berlaku bagi project: divisi dan status awalnya tidak pernah
terekam.

Nilai awal kini ditulis dalam bentuk diff yang sama dengan `updated` — `[null, nilai]` —
sehingga pembacanya tidak perlu mengenal dua format. Redaksi `$hidden` dan daftar
`IGNORED_FIELDS` sengaja **rumus yang sama** dengan yang dipakai `changes()`: kolom sensitif
tidak boleh bocor lewat pintu yang baru dibuka, dan uji khusus menjaga bahwa hash password
tidak pernah muncul di badan log. Primary key dilewati karena sudah tersimpan sebagai
`subject_id`.

Harganya satu baris `changes` per objek yang dibuat, sekali seumur objek — bukan per
perubahan. Itu diterima justru karena K12 memutuskan log ini tidak dibersihkan: yang mahal
bagi log semacam ini adalah baris yang lahir berulang, bukan baris yang lahir sekali.

### 13.5 Tiga daftar yang berhenti setengah jalan — ✅ TERPASANG

Ketiganya ditemukan pada sisiran yang sama, dan ketiganya berbentuk sama: sebuah daftar yang
benar untuk anggota pertamanya lalu tidak pernah ditumbuhkan.

**Penanda notifikasi kini dikenali lewat pola, bukan lewat nama.** `IGNORED_FIELDS` memuat
`due_soon_notified_at`, tetapi tidak `reminder_notified_at` maupun `overdue_notified_at` —
padahal ketiga command terjadwal menulisnya dengan cara yang persis sama,
`forceFill([...])->save()`, yang memicu observer. Setiap stempel anti-spam karena itu melahirkan
satu baris log yang tidak pernah ingin dibaca siapa pun, dan `reminder_notified_at` yang paling
cepat menumpuk karena routine work terbit setiap hari. Komentar di kelas itu sendiri sudah
menyebut alasan penjagaannya — *"tanpa penjagaan ini log penuh baris kosong"* — sehingga yang
kurang bukan pemahamannya melainkan kelengkapan daftarnya. Menambahkan dua nama akan
mengulang kekeliruan yang sama pada penanda keempat, jadi yang dipasang `IGNORED_SUFFIXES`
berisi `_notified_at`: **penanda berikutnya ikut terjaga tanpa siapa pun perlu mengingatnya.**

**Tiga `ActivityLogController` tanpa route dihapus.** Isinya satu baris yang mengembalikan nama
permission-nya sendiri sebagai string — sisa rencana `permission_group.json` yang tidak pernah
dibangun. Ia bukan bahaya dan bukan pula route mati, sebab tidak ada aturan yang menumpang di
sana; yang dibawanya kesalahpahaman, karena kerangka yang tertinggal membuat sebuah fitur
terbaca seolah tinggal disambungkan padahal belum dimulai.

**Penambahan anggota project punya satu pintu.** Yang dicatat manual hanya `addMember`,
`updateMember`, dan `removeMember`; tiga `attach` saat project dibuat dan dua saat project
diduplikasi tidak — sehingga **siapa anggota awal sebuah project tidak pernah tercatat di mana
pun**. Bandingkan dengan divisi, yang punya dua lapis sekaligus: `DivisionMember` memakai
`Auditable` *dan* ada `division.members_synced` yang manual. Kelimanya tidak ditambal satu per
satu — menambal hanya menjamin jalur kesembilan kelak terlewat lagi. Yang dipasang
`Project::attachMember()`, satu-satunya pintu yang melakukan `attach` sekaligus mencatatnya,
dan seluruh jalur dialihkan ke sana. Peran pivot yang tidak pernah memicu event model tetap
tidak memicunya; bedanya sekarang tidak ada tempat yang bisa lupa.

> **Sisi rawan.**
> Log yang tumbuh tanpa batas akan memperlambat halamannya sendiri lebih dulu sebelum
> memenuhi penyimpanan — dan percobaan login gagal membuatnya tumbuh paling cepat. Retensi
> project terhapus sudah diputuskan 30 hari (K6), tetapi log ini **sengaja tidak dibersihkan**
> — K12. Yang memicu peninjauan ulang bukan kalender melainkan halaman ini sendiri mulai
> terasa lambat.

---

## 14. Aturan Lintas Modul

Berlaku untuk seluruh endpoint. Yang sudah dipatuhi dan yang belum, dipisah.

### Otorisasi

- ✅ **TERPASANG** Otorisasi dievaluasi di server pada setiap permintaan. UI yang
  menyembunyikan tombol bukan pengamanan.
- ✅ **TERPASANG** Endpoint yang memindahkan task antarkonteks mengotorisasi **asal dan
  tujuan** secara terpisah — invariant yang sengaja disamakan antara `store()` dan
  `update()`.
- 🟡 **SEBAGIAN** Identitas divisi dan project **dikirim klien** pada pembuatan task dan
  project, lalu diverifikasi terhadap keanggotaan pelaku. Aman, tetapi berbeda dari aturan
  draf yang menyebutnya diambil dari objek yang diakses.

### Validasi & input

- ✅ **TERPASANG** Seluruh input divalidasi di lapisan request atau di awal controller
  sebelum menyentuh domain.
- ✅ **TERPASANG** Query database selalu memakai Eloquent atau parameter binding.
  `orderByRaw` yang ada pun memakai binding, bukan interpolasi.
- 🟡 **SEBAGIAN** Sanitasi HTML tidak ada di server; keamanannya bersandar pada escaping
  React. Cukup untuk render di aplikasi ini, tidak cukup bila isi deskripsi kelak dikirim ke
  email atau PDF.
- ⬜ **BELUM** "Tanggal disimpan UTC, ditampilkan pada zona waktu pengguna." Aplikasi
  berjalan penuh di `Asia/Jakarta` — `config('app.timezone')`. Ini keputusan yang sah untuk
  organisasi satu zona waktu, tapi kalimat draf perlu diganti, bukan dipertahankan.

### Respons

- 🟡 **SEBAGIAN** Daftar selalu terpaginasi, tetapi ukurannya berbeda per halaman: 10
  (task), 12 (project), 15 (admin user, divisi, arsip), 25 (activity log). Tidak ada
  parameter `per_page`.
- ✅ **TERPASANG** Kesalahan validasi memakai `422` dengan pesan per kolom; ketiadaan izin
  memakai `403`.
- ⬜ **BELUM** Objek yang ada tetapi di luar cakupan dijawab `404`. Semuanya `403` — lihat
  03.5.

### Kinerja

- ✅ **TERPASANG** Kolom yang dipakai memfilter cakupan sudah berindeks; ada migrasi khusus
  untuk index task, notifikasi, dan timeline project.
- ✅ **TERPASANG** Relasi dimuat eager pada daftar. Halaman task memuat pohon relasinya
  sekaligus untuk menghindari N+1 saat detail sheet dibuka.
- ✅ **TERPASANG** Halaman yang cuma menampilkan angka meminta angka, bukan baris. Index Media
  memakai `withCount`/`withMax` alih-alih memuat seluruh lampiran dan buktinya (10.3). Eager
  loading menghilangkan N+1, tetapi **tidak** menghilangkan biaya menarik baris yang tidak
  pernah dibaca — dan yang kedua itulah yang membesar seiring usia data.
- ⬜ **BELUM** Operasi massal lewat antrean. Duplikasi project berjalan sinkron; ekspor CSV
  memang di-stream, tapi tetap di dalam request.

---

## 15. Keputusan & Pertanyaan Terbuka

Dua puluh tiga keputusan telah diambil: tujuh pada 2 Agustus 2026, tujuh menyusul pada 3
Agustus, K15–K22 pada 11 Agustus, dan K23 pada 12 Agustus. **Kedua puluh tiganya sudah terpasang**, dan uraiannya
sengaja tidak lagi disimpan di bab ini — masing-masing sudah dijalarkan ke bagian isinya, dan
bab ini menyisakan tabel rujukan saja supaya tidak ada aturan yang ditulis dua kali.

Yang benar-benar dibahas di sini tinggal dua: pertanyaan yang masih terbuka, dan sisa antrean
kerja yang jatuh darinya — yang sejak 2.7 berisi kode lagi, dua baris dari 10.4 ditambah satu
baris konfigurasi server dari sumber yang sama. T3 sudah terjawab dan pindah menjadi K10 tanpa
meninggalkan ekor.

### Riwayat keputusan yang sudah terpasang

Tabel ini gunanya satu: menerjemahkan nomor keputusan yang dirujuk di seluruh dokumen
menjadi tempat membacanya.

| # | Keputusan | Aturannya hidup di |
| --- | --- | --- |
| K1 | Peran divisi tidak menembus project — dua "pengecualian pengawasan" draf 0.5 dicoret | 00.3, 03.5 |
| K2 | Supervisor setara Admin Divisi; bedanya label organisasi, bukan hak akses | 00.2, 02.2 |
| K3 | Assignee tidak boleh berperan `viewer` di konteksnya; viewer juga tidak berkomentar | 03.4, 04.1, 05 |
| K4 | Notifikasi in-app + push mobile; email menunggu worker antrean tim lain | 11 |
| K5 | NIK untuk masuk, email untuk pemulihan — pembagian permanen | 01.1 |
| K6 | Project terhapus dibersihkan permanen setelah 30 hari | 03.2 |
| K7 | Super admin membuat user dari antarmuka; `Features::registration()` dicabut | 01.2, 01.4 |
| K8 | Daftar divisi sengaja terbuka sebagai direktori lintas divisi | 00.3, 02.2 |
| K9 | Akun tidak pernah dihapus; pelepasan anggota divisi ditolak selama ia pegang task terbuka atau template recurring | 01.3, 02.2, 07.1 |
| K10 | Bentuk NIK dikunci `^[A-Z]+-[0-9]+$`; huruf kecil ditolak, bukan dinaikkan | 01.1 |
| K11 | Permukaan autentikasi diciutkan: hanya reset password; 2FA, verifikasi email, dan rencana login Google dicoret | 01.1, 01.2, 01.5 |
| K12 | Jejak audit tidak dibersihkan otomatis; pertumbuhannya diterima sebagai risiko kapasitas | 13.3 |
| K13 | Task lama yang assignee-nya viewer dibiarkan apa adanya | 04.1 |
| K14 | Pengawas boleh menyetujui pekerjaannya sendiri; tidak ada guard di `TaskPolicy::review()` | 03.4, 04.4 |
| K15 | Super admin tidak punya task pribadi; seluruh route `/my-tasks` dijaga gate `use-personal-tasks` | 08 |
| K16 | Promosi task pribadi menjadi task project dicoret | 08 |
| K17 | Task pribadi tidak mengirim notifikasi apa pun, termasuk pengingat deadline | 08, 11 |
| K18 | Pengurutan manual task pribadi dicoret; label "My Order" diganti "Default" | 08 |
| K19 | Induk dan subtask task pribadi tidak saling mendahului; cascade selesai pada 2.2 dibatalkan dan diganti penolakan | 08, 08.2 |
| K20 | Task pribadi tidak bisa mulai dikerjakan sebelum deadline-nya ditetapkan; membuatnya tanpa deadline tetap boleh | 08, 08.2 |
| K21 | Penyuntingan komentar dibangun (bukan dicabut seperti K18), dengan penanda *diedit* yang diturunkan dari timestamp | 09, 09.1 |
| K22 | Penerima notifikasi task disaring lewat policy `view`; riwayat keterlibatan saja tidak cukup | 09, 11 |
| K23 | Menyebut seseorang lewat suntingan komentar tidak memanggilnya; notifikasi mengikuti pesan yang dikirim, bukan teks yang berubah | 09, 09.5 |

Empat di antaranya perlu dibaca bersama catatan kakinya. **K3 direvisi sebelum dibangun** —
versi pertamanya menutup penugasan bagi seluruh pengawas, bukan hanya viewer; pencabutan itu
yang melahirkan pertanyaan yang kemudian dijawab K14. **K2, K8, K12, dan K13 nol baris kode**:
yang berubah dokumennya, bukan kodenya — dan untuk K12 serta K13, *tidak menulis kode* itulah
keputusannya. **K10 menjawab format NIK dan menutupnya bersih**: aturannya memang hanya
menjaga tulisan berikutnya, tetapi NIK yang sudah tersimpan seluruhnya sudah mengikuti pola
itu. **K14 mengesahkan keadaan yang sudah berjalan**, jadi ia pun tidak menghasilkan diff.

**K15–K18 lahir bersama pada 2.3, dari membereskan penanda ⬜ yang menumpuk di seksi 08.**
Keempatnya menyempitkan cakupan, bukan menambahnya — dan seksi 08 kini tidak menyisakan satu
pun pertanyaan terbuka. Yang perlu dibedakan adalah alasan mencoretnya: **K16 ditolak** karena
jalur pemindahannya menuntut semua yang sengaja tidak ada di task pribadi (divisi, project,
assignee, tipe bukti, review), sedangkan **K17 ditunda** karena belum ada yang memintanya —
catatan pribadi tidak punya siapa pun yang menunggu, jadi pengingatnya tidak menagih tanggung
jawab ke siapa-siapa. Membalik K17 murni soal permintaan; membalik K16 berarti membangun ulang
jembatan antar dua model yang aturannya memang dirancang berlawanan.

**K18 menutup celah yang paling halus dari keempatnya**, dan bentuk penutupannya layak
diperhatikan: yang diperbaiki bukan perilakunya, melainkan **namanya**. Urutan yang tersaji
tidak berubah sedikit pun — yang berubah label "My Order" menjadi "Default", beserta nilai
internal `manual` menjadi `default`. Nama lama itulah satu-satunya yang menjanjikan kendali
yang tidak pernah ada, dan ia bertahan lama justru karena didukung kolom `position` yang
sungguhan terisi. Nilai internalnya sengaja ikut diganti: menyisakan `manual` di kode sambil
menampilkan "Default" di layar berarti memindahkan kebohongan, bukan menghapusnya.

**K15 satu-satunya yang berkode, dan bentuk penjagaannya disengaja.** Aturannya dipasang
sebagai gate di grup route, bukan sebagai cabang di `PersonalTaskPolicy` maupun `abort_if` di
tiap method. Sebabnya ini bukan pertanyaan kepemilikan — policy tetap hanya membandingkan
`user_id` — melainkan pertanyaan apakah fiturnya ada untuk peran itu. Menaruhnya di grup juga
membuat route yang ditambahkan nanti ikut terjaga tanpa siapa pun perlu ingat memasangnya;
pelajaran 2.2 tentang aturan yang berhenti di satu jalur berlaku ke depan, bukan cuma ke
belakang. Perlu dicatat: gate ini **tidak** melewati `Gate::before`, karena penyaring super
admin di sana hanya mengenal ability `view` dan `viewAny`.

> **Satu efek samping yang perlu diketahui sebelum rilis.** Kalau di production ada akun super
> admin yang terlanjur punya task pribadi, barisnya tetap tersimpan tetapi tidak lagi bisa
> dibuka oleh siapa pun. K15 sengaja tidak menghapusnya — menghapus data pengguna sebagai efek
> samping perubahan izin adalah hal yang justru dihindari K9. Kalau ternyata ada isinya dan
> perlu diselamatkan, itu pekerjaan migrasi data tersendiri, bukan bagian dari gate ini.

### K9 revisi. Pengelolaan akun milik super admin — 🔨 DIPUTUSKAN

Dua hal yang selama ini tercampur, dan sekarang dipisah tegas.

> **Direvisi pada 3.2, dan yang berubah cakupannya — bukan pemegang wewenangnya.**
> Versi pertama K9 (1.2) mencoret penghapusan akun **selamanya** dan menjadikan penonaktifan
> penggantinya. Revisi ini mempertahankan seluruh alasan teknisnya tetapi mengubah
> kesimpulannya: penghapusan **akan** dibangun, di tangan super admin, setelah enam foreign
> key di 01.3 dibereskan. Penonaktifan tidak dicabut — keduanya melayani hal berbeda: yang
> satu menutup akses, yang lain membuang data.
>
> Yang tidak pernah berubah, dan justru baru ketahuan dilanggar pada 3.1: **user tidak pernah
> mengelola nasib akunnya sendiri.** Jalur `DELETE settings/profile` di halaman profil
> melanggar itu, dan pencabutannya berdiri sendiri di 01.6 — tidak menunggu pekerjaan skema
> mana pun.

**Penghapusan permanen user ditunda, bukan lagi dicoret.** Alasan penundaannya ada di skema:
`projects.creator_id` dan `recurring_tasks.creator_id` ber-`cascade`, sehingga menghapus
seorang pembuat project ikut menghapus project beserta task-nya; sementara `tasks.creator_id`,
`comments`, `proofs`, dan `attachments` justru restrict, sehingga penghapusannya gagal di
tengah. Tabel foreign key lengkapnya di 01.3, dan sejak 3.2 ia dibaca sebagai **daftar
pekerjaan**, bukan sebagai alasan menolak. Polanya sudah tersedia di `activity_logs`: nama
pelaku disalin saat penulisan, FK-nya `nullOnDelete`, dan jejaknya tetap terbaca meski
pelakunya hilang.

**Yang sungguhan bermasalah adalah pelepasan anggota divisi**, dan itu masalah terpisah dari
penghapusan akun. `sync()` hanya menyentuh `division_members`, jadi orang yang sudah keluar
tetap memegang task, keanggotaan project, dan template di divisi itu. Penjagaannya dipasang
di titik pelepasan: **ditolak selama user masih menjadi assignee task terbuka di divisi
tersebut**, dengan pesan yang menyebut task penahannya.

Menolak dipilih di atas membersihkan otomatis — mengembalikan task ke `waiting` tanpa
assignee akan menghapus penugasan tanpa ada yang menyadarinya, sedangkan penolakan memaksa
pemindahan tugas dilakukan sadar oleh admin. Ini pola yang sama dengan `isLastOwner` dan
penjagaan owner tunggal: tutup dari hulu, jangan perbaiki di hilir.

Penjagaannya ditulis **sekali** di `Team\Rules\ReleasedMemberTasks`, karena ada dua jalur sync
yang setara — `DivisionController::syncMembers()` dan `UserController::syncMemberships()`.
Guard di satu controller saja meninggalkan jalur satunya terbuka. Uji yang menjaga keduanya:
`tests/Feature/Admin/DivisionMemberReleaseTest.php`.

**Diperluas pada 2.0 ke pelaksana template recurring**, lewat pendampingnya
`Team\Rules\DisqualifiedTemplateAssignees` di kedua jalur yang sama. Kalimat "template tetap
utuh" di atas dulu hanya mencatat kenyataan; sekarang template ikut menahan, dan penurunan
peran menjadi `viewer` diperlakukan sama dengan pelepasan. Uraiannya di 02.2 dan 07.1.

Dijalarkan ke 01.3, 02.2, dan 07.1.

---

### Yang dulu terbuka, dan bagaimana ditutup

Kelima pertanyaan pada versi-versi sebelumnya sudah terjawab pada 3 Agustus 2026. Bagian ini
menyimpan **jawabannya beserta alasan menolak alternatifnya**, karena itu yang tidak bisa
dibaca ulang dari kode.

**T1 → K12 · Jejak audit tidak dibersihkan.** `activity_logs` tetap append-only tanpa batas,
dan **itu keputusan sadar, bukan kelalaian**. Tidak ada command pembersih, tidak ada jadwal.
Yang dipertimbangkan dan ditolak: retensi 365/180/90 hari ala K6 — ditolak karena belum ada
kewajiban audit yang menuntut batas, dan menghapus jejak lebih mahal daripada menyimpannya
selama volumenya belum terbukti mengganggu.

Konsekuensinya diterima dengan mata terbuka: tabel ini tumbuh paling cepat dari percobaan
login gagal, dan yang pertama melambat adalah halaman `/admin/activities` sendiri — bukan
penyimpanan. Pemicu untuk meninjau ulang karena itu bukan kalender melainkan **halamannya
mulai terasa lambat**. Ekspor CSV sudah ada bila sewaktu-waktu perlu memindahkan sebagian
keluar.

**T2 → K11 · Permukaan autentikasi diciutkan.** Dari empat fitur Fortify, tersisa satu:
`resetPasswords()`. Uraian lengkapnya di 01.1, 01.2, dan 01.5.

| Fitur | Nasib | Alasan |
| --- | --- | --- |
| `resetPasswords` | **Dipertahankan** | Kanal pemulihan satu-satunya, sesuai K5. |
| `emailVerification` | Dicabut | Sudah tidak menopang apa pun sejak K7, dan `User` tidak pernah mengimplementasikan `MustVerifyEmail` — jadi ia tidak pernah menahan siapa pun. |
| `twoFactorAuthentication` | Dicabut | Pemulihannya bersandar pada email, sedangkan `MAIL_MAILER=log` di production. Menyalakan 2FA sebelum transport surat berdiri berarti mengunci orang tanpa jalan keluar. |
| Login Google (rencana) | Dicoret | Tidak pernah dibangun; setelah K7 tidak ada kebutuhan yang dilayaninya. |

Ikut terbawa: middleware `verified` dilepas dari `web.php` dan `settings.php` — ia memang
tidak pernah menahan apa pun, jadi membiarkannya hanya menyesatkan pembaca berikutnya. Kolom
`two_factor_*` **tidak** di-drop; menyalakan kembali cukup satu baris di `config/fortify.php`,
sedangkan migrasi penghapusan kolom bersifat merusak dan tidak menghasilkan apa-apa.

**T3 → K10 · Format NIK.** HR menyahkan polanya; aturannya di 01.1. Tidak menyisakan ekor —
NIK yang sudah tersimpan seluruhnya sudah sesuai.

**T4 → K13 · Task lama ber-assignee viewer dibiarkan.** K3 hanya menjaga penugasan berikutnya,
dan baris lama sengaja tidak disentuh. Yang ditolak: command pembersih yang melepas assignee
viewer dan mengembalikan task ke `waiting` — ditolak dengan alasan yang sama seperti pada K9,
yaitu penugasan yang hilang tanpa ada yang menyadarinya lebih berbahaya daripada segelintir
baris lama yang tidak sesuai aturan baru. Task-task itu akan lurus sendiri begitu ada yang
mengubah assignee-nya, karena jalur penugasannya sudah dijaga.

**T5 → K14 · Pengawas boleh menyetujui pekerjaannya sendiri.** Ini kelanjutan sadar dari revisi
K3, bukan celah yang terlewat. Admin, supervisor, owner project, dan PIC **boleh** menyetujui
task yang mereka kerjakan sendiri; `TaskPolicy::review()` sengaja tidak membandingkan pemutus
dengan daftar assignee, dan **tidak ada guard yang akan ditambahkan**.

Yang ditolak: guard yang menolak reviewer yang menjadi assignee. Ditolak karena harganya nyata
— pada tim yang pengawasnya satu orang dan orang itu juga pelaksananya, task akan mentok di
`review` tanpa ada yang bisa menyetujui, sedangkan susunan tim seperti itu memang ada di
Scuto. Membangun pemutus cadangan hanya memindahkan kerumitan tanpa menambah kepercayaan yang
nyata di kantor sebesar ini.

Yang perlu dipegang pembaca berikutnya: **jangan menutup celah ini diam-diam sebagai "bug
yang terlewat".** Kalau kelak dibalik, yang berubah lebih dulu adalah susunan tim — bukan
policy-nya.

---

### Antrean kerja yang jatuh dari keputusan ini

Yang sudah selesai tidak dicatat di sini — tabel ini hanya berisi yang belum.

| Pekerjaan | Dari | Tempatnya | Keadaan |
| --- | :-: | --- | --- |
| Pasang transport surat production | K5 | Konfigurasi `MAIL_*`, bukan kode | 🔨 antre, bukan kode |
| Naikkan batas unggahan PHP-FPM & nginx agar menyamai batas aplikasi | 10.4 | Konfigurasi server, bukan kode | 🔨 antre, bukan kode |
| Putuskan cakupan recurring task: project tujuan dibangun, atau kalimatnya dicoret | 07.2 | Keputusan produk, bukan kode | 🔨 antre, menunggu keputusan |
| Putuskan nasib ekspresi cron kustom — dibangun, atau dicoret seperti rencana lain yang tidak berpeminat | 07.2 | Keputusan produk, bukan kode | 🔨 antre, menunggu keputusan |
| **Cabut jalur hapus akun sendiri** — route `DELETE settings/profile`, `ProfileController::destroy()`, `ProfileDeleteRequest`, dan komponen `DeleteUser` di halaman profil | 01.6 / K9 revisi | Kode | 🔨 antre, **pekerjaan kode** |
| **Bereskan enam foreign key `users.id`** — `nullOnDelete` plus nama pelaku yang didenormalisasi, mengikuti pola `activity_logs`; `projects.creator_id` lebih dulu | 01.3 / K9 revisi | Migrasi + model | 🔨 antre, **prasyarat** |
| **Bangun penghapusan akun oleh super admin** di `/admin/users` — hanya setelah baris di atas selesai | 01.3 / K9 revisi | Kode | 🔨 antre, **pekerjaan kode** |

**Antreannya tujuh baris, dan tiga di antaranya kode — pertama kalinya tabel ini menyimpan
pekerjaan kode sejak 2.6.** Ketiganya lahir dari satu tempat yang sama, halaman profil, dan
tiga wataknya berbeda: yang pertama **menegakkan keputusan lama yang ternyata tidak pernah
dijalankan** (K9 menolak penghapusan oleh pemilik akun sejak 1.2, sementara jalurnya berdiri
di sana selama itu juga), yang kedua **prasyarat skema**, dan yang ketiga fitur yang baru
boleh dibangun sesudahnya. **Urutannya mengikat**: membangun tombol hapus sebelum foreign
key-nya dibereskan hanya memindahkan ledakan yang sama ke tangan super admin.
Tiga baris sempat berdiri di sini pada 2.9
sebagai temuan seksi 13 yang menunggu keputusan cakupan; ketiganya turun lagi pada versi yang
sama begitu keputusannya diambil — apa yang layak dicatat dijawab sekaligus, bukan
sepotong-sepotong. **Seksi 13 tidak menyisakan satu pun penanda ⬜ maupun 🔨.** Seluruh
keputusan K1–K23 sudah terpasang. Dua baris 07.2 sengaja diangkat pada 2.0: blok "masih perlu keputusan" di sana
sudah berdiri sejak versi-versi awal, tetapi tidak pernah masuk tabel ini — sehingga bagian
atas dokumen sempat mengklaim tidak ada pertanyaan terbuka sementara 07.2 menyatakan
sebaliknya. Keduanya keputusan produk: yang dibutuhkan jawaban, bukan waktu koding.

Empat baris sempat berdiri di sini pada 2.6, ketika sisiran seksi 09 mengisi tabel ini dengan
kode untuk pertama kalinya sejak 2.0 — daftar sebutan routine work, dua ketimpangan lampiran,
dan email penulis komentar. Keempatnya turun lagi pada versi yang sama. **Seksi 09 tidak
menyisakan satu pun penanda ⬜ maupun 🔨.**

Baris pengurutan manual task pribadi sempat berdiri di sini pada 2.2 dan turun lagi pada 2.3
sebagai K18. **Seksi 08 kini tidak menyisakan satu pun penanda ⬜.**

Transport surat production adalah sisa yang paling tua: selama `MAIL_MAILER=log`, tautan
reset password jatuh ke berkas log dan tidak pernah sampai ke siapa pun. Setelah K11 mencabut
verifikasi email dan dua faktor, reset password menjadi **satu-satunya** hal yang bergantung
pada surat — sehingga memasangnya sekarang menutup seluruh utang email sekaligus.

Tabel ini kembali tidak menyimpan pekerjaan kode. Empat temuan seksi 13 tidak satu pun lahir
dari membaca dokumen: semuanya ditemukan dengan menulis sesuatu lalu membaca tabel log-nya,
persis cara yang dinasihatkan 2.8.
**Seksi 11 tidak pernah menyisakan penanda ⬜ maupun 🔨**, sehingga sisiran sesudahnya jatuh
ke seksi 12 — dan dugaan yang membawanya ke sana ternyata benar, meski bukan pada tempat yang
diperkirakan: rumus yang ditulis ulang memang ada, tetapi rumus **visibilitas project**, bukan
agregat task. Yang tersisa di 12.2 tetap ⬜ dan tetap bukan antrean, karena belum diputuskan
akan dibangun.

**Tapi ada sumber pekerjaan kedua, dan 1.7 lahir darinya:** membaca ulang satu seksi baris
demi baris terhadap kodenya. Ketiga penjagaan pada 1.7 tidak pernah muncul di tabel mana pun
— semuanya invariant yang **sudah tertulis di dokumen ini** tapi hanya ditegakkan dari satu
sisi. Polanya sama ketiganya: aturan dipasang di jalur yang jelas, lalu jalur kedua yang
menyentuh keadaan yang sama terlewat. Seksi 04 sudah disisir dengan cara itu; seksi lain
belum, dan tidak ada alasan menganggap ia satu-satunya yang menyimpan pola ini.

**1.8 lahir dari sisiran yang sama atas seksi 05,** dan menambah satu variasi pola tersebut:
aturannya tidak terlewat di jalur kedua, melainkan **ditulis ulang dengan rumus berbeda** di
tiga tempat — policy, controller, dan antarmuka. Yang paling longgar menentukan apa yang
dilihat pengguna, yang paling ketat menentukan apa yang terjadi, dan selisih keduanya keluar
sebagai 403. Saat menyisir seksi berikutnya, curigai setiap aturan yang hidup di lebih dari
satu tempat: cocokkan rumusnya, bukan hanya keberadaannya.

**1.9 lahir dari sisiran atas seksi 06, dan kedua pola itu muncul sekaligus** — bukti bahwa
mencarinya memang berulang, bukan kebetulan. Yang baru di sini adalah **cara pola pertama
menjadi berbahaya**: aturan yang terlewat di jalur kedua tidak menghasilkan penolakan yang
kelihatan, melainkan **penulisan data yang salah tanpa satu pun error**. Deadline mundur, dan
semua orang di layar mengira barusan menyetujui perpanjangan.

Yang membuatnya bisa terjadi juga layak dicatat: penjagaan itu aman selama tidak ada yang
mengubah `due_date` di belakang layar — lalu 1.7 memasang penggeseran deadline pasca jeda,
dan asumsi itu diam-diam gugur. **Fitur baru bisa membatalkan asumsi penjagaan lama tanpa
menyentuh satu baris pun kodenya.** Saat menyisir seksi berikutnya, telusuri juga apa yang
menulis ke kolom yang dipegang seksi itu — bukan cuma siapa yang membacanya.

**2.0 lahir dari sisiran atas seksi 07, dan menambah satu bentuk lagi dari pola pertama:
jalur yang terlewat bukan jalur kedua, melainkan jalur ketiga.** Aturan "template yang dijeda
tidak menerbitkan" berdiri di dua tempat sekaligus — query scheduler dan query "Run All
Active" — dan justru karena keduanya sudah benar, jalur "Run Now" per template tidak pernah
terlihat kosong. **Aturan yang ditulis sebagai saringan query tidak ikut terbawa ke jalur yang
tidak memakai query itu**; kalau ia memang aturan, tempatnya di action yang dilewati semuanya.
Antarmuka ikut menyembunyikannya, tetapi baru sesudah server menolak.

Dua sisanya menegaskan nasihat versi sebelumnya. Penjagaan pelepasan anggota (K9) menghitung
task tapi tidak menghitung template — **satu penjagaan bisa lengkap untuk satu tabel dan
bolong untuk tabel tetangganya yang menyimpan hubungan yang sama**. Dan penjagaan penghapusan
divisi lupa bahwa penghapusannya soft delete, sehingga cascade foreign key yang seolah-olah
menutup celahnya tidak pernah jalan: **jangan mengandalkan constraint database di jalur yang
tidak menyentuh database seperti yang dibayangkan constraint itu.**

Satu lagi yang khas seksi ini: `next_run_at` bukan penggerak apa pun, hanya angka yang
ditampilkan — dan justru karena tidak ada yang membacanya kembali, kesalahannya bisa hidup
lama. **Nilai yang cuma dipajang tetap perlu dihitung dengan rumus yang sama seperti yang
mengerjakannya**, kalau tidak ia berubah menjadi dokumentasi yang berbohong.

**2.2 lahir dari sisiran atas seksi 08, dan menemukan bentuk paling ekstrem dari pola
pertama: jalur yang memegang aturan bukan cuma terlewat, melainkan mati.** `toggle` menyimpan
satu-satunya penegakan cascade "induk selesai, subtask ikut selesai" — rapi, bertransaksi,
berkomentar panjang soal kenapa aturan itu perlu — dan tidak ada satu pun komponen yang
memanggilnya. Yang dipakai antarmuka adalah `update`, yang tidak tahu apa-apa soal aturan itu.
Nasihat 1.8 perlu satu tambahan karena ini: setelah mencocokkan rumus sebuah aturan di semua
tempat ia hidup, **periksa juga apakah tempat-tempat itu benar-benar terjangkau.** Aturan yang
dipasang di route mati sama saja dengan aturan yang tidak pernah ditulis.

Yang membuatnya bertahan lama justru perangkat yang seharusnya menangkapnya:
`test_completing_parent_also_completes_its_subtasks` memanggil route `toggle` langsung, lulus
hijau setiap kali, dan dengan begitu **menjaga satu-satunya pengguna route itu tetap ada di
repositori.** Test yang menembak controller langsung tidak pernah bisa membuktikan tombolnya
tersambung. Saat menyisir seksi berikutnya, untuk setiap aturan yang punya test, tanyakan
sekali lagi: yang dipanggil test ini, dipanggil siapa lagi selain test ini?

Dan satu pelajaran yang bukan tentang kode: dokumen menjanjikan "urutan manual", sementara
komentar di migrasinya sendiri sudah menulis *"drag & drop di kemudian hari"*. Kolomnya ada,
query pengurutannya ada, yang tidak pernah ada cuma endpoint yang menggesernya — cukup untuk
membuat seksi itu terbaca seolah fiturnya jalan. **Kolom yang terisi bukan bukti fitur ada;
yang membuktikannya adalah jalur tulis yang bisa dipanggil pengguna.**

**2.4 tidak lahir dari sisiran, melainkan dari memakai aplikasinya — dan ia membatalkan
aturan yang baru dipasang satu versi sebelumnya.** Cascade "induk selesai, subtask ikut
selesai" pada 2.2 dikerjakan dengan benar menurut setiap ukuran yang dipakai bab ini: satu
tempat, dilewati semua jalur, ada testnya, terdokumentasi. Yang salah bukan penempatannya,
melainkan **arah aturannya**. Sisiran kode hanya bisa menemukan aturan yang tidak konsisten
dengan dirinya sendiri; ia tidak pernah bisa menemukan aturan yang konsisten tetapi bukan
yang diinginkan. **Untuk itu satu-satunya alat yang bekerja adalah memakai produknya.**

Bentuk kekeliruannya juga layak dicatat karena menggoda: cascade terasa membantu — pengguna
menekan sekali, sistem merapikan sisanya. Tetapi merapikan diam-diam berarti **menulis
keadaan yang tidak pernah dinyatakan siapa pun**. Subtask yang berpindah menjadi selesai
tanpa ada yang mengerjakannya adalah data yang salah, betapapun rapinya ia terlihat. Aturan
penggantinya menolak dan menyebut berapa yang tersisa. Bandingkan dengan 1.9, yang persoalannya
justru serupa dari sisi lain: di sana penulisan data yang salah lolos tanpa error sama sekali.
**Kalau sebuah aturan bisa dipilih antara memperbaiki atau menolak, menolak hampir selalu yang
benar** — kecuali nilai penggantinya memang tidak mungkin salah, seperti `completed_at` yang
diturunkan langsung dari status.

K20 menyumbang satu catatan kecil yang berlaku umum: **penjagaan perpindahan status tidak
lengkap tanpa menjaga pembuatan.** Aturan "tidak boleh mulai tanpa deadline" mula-mula
dipasang hanya pada perpindahan, dan langsung bisa dilangkahi dengan membuat task yang sudah
berstatus `in_progress` sejak lahir. Setiap kali sebuah aturan berbentuk "status X menuntut
syarat Y", periksa apakah baris bisa lahir langsung dalam status X — kalau bisa, pintunya
masih terbuka. Perkecualiannya tetap ada dan disengaja untuk aturan hierarki, karena hubungan
antarbaris memang boleh disusun dalam urutan apa pun; bedanya dicatat di 08.

**2.6 lahir dari sisiran atas seksi 09, dan pola 1.8 muncul di sana dalam bentuk yang paling
sulit dilihat: rumus kedua yang salahnya bukan menolak, melainkan tidak pernah bertanya.**
`TaskPolicy::discuss()` benar sejak awal, dan justru karena itu tidak ada yang curiga — panel
detail menghitung sendiri siapa yang boleh berkomentar, dan bagi anggota project yang bukan
assignee hasilnya kolom tulis yang tidak dirender. Tidak ada 403, tidak ada baris log, tidak
ada keluhan yang bisa dicari kata kuncinya. **Aturan izin yang ditulis ulang di frontend bisa
gagal dua arah, dan arah "terlalu ketat" tidak meninggalkan jejak apa pun** — yang terlihat
hanya fitur yang seolah memang tidak ada. Karena itu jawabannya bukan mencocokkan rumusnya,
melainkan menghapus rumus keduanya: kirim hasil policy-nya, biarkan layar menurut.

Satu tambahan untuk nasihat 2.2 soal route mati. Route edit komentar di sini **lebih hidup**
daripada `toggle` pada task pribadi: controllernya benar, event realtime-nya di-broadcast, dan
frontend bahkan sudah mendengarkan event itu. Semua bagian ada kecuali tombolnya. **Kelengkapan
sebuah jalur di sisi server tidak pernah menjadi bukti fiturnya terjangkau**, dan makin lengkap
jalur itu makin meyakinkan ia terbaca di dokumen. Cara memeriksanya tetap yang paling murah:
cari pemanggilnya di frontend, bukan di test.

K22 menyumbang satu lagi yang lebih umum dari kelihatannya: **daftar penerima yang disusun dari
riwayat akan basi, karena riwayat memang tidak boleh berubah.** "Pernah berkomentar" dan
"tercatat sebagai assignee" adalah fakta masa lalu; izin adalah keadaan sekarang. Setiap kali
keduanya dipakai sebagai satu hal yang sama, hasilnya pemberitahuan kepada orang yang pintunya
sudah ditutup — dan bila badan pesannya memuat isi, pintunya tidak lagi menahan apa pun.

Sisiran ini juga menemukan bentuk paling halus dari "aturan yang ditulis dua kali", dan
bentuknya bukan kode: **docblock `mentionableUsers()` sudah menyebut aturan yang benar** —
*"mengikuti TaskPolicy::view()"* — sementara kodenya tidak pernah memuat cabang recurring-nya.
Komentar yang menamai aturan yang diikuti terbaca seperti jaminan bahwa keduanya sinkron,
padahal ia hanya niat penulisnya saat itu. **Yang mengikat cuma pemanggilan; sisanya prosa.**
Saat menyisir seksi berikutnya, jangan pakai komentar sebagai bukti sebuah rumus dipakai
bersama — buka rumus yang dirujuknya dan cocokkan cabang per cabang.

Urutan penutupannya pun menyisakan satu catatan. K22 menutup kebocorannya lebih dulu, dan itu
mengubah bentuk sisa masalahnya: sebutan yang tadinya sampai ke orang yang salah berubah
menjadi sebutan yang tidak sampai ke siapa pun. **Menutup separuh sebuah celah bisa menukar
kegagalan yang kelihatan dengan kegagalan yang diam**, jadi separuh sisanya bukan pekerjaan
yang boleh menunggu lama — di sini keduanya sengaja diselesaikan pada versi yang sama.

Tiga penutupan terakhir (09.4) tidak datang dari membaca dokumen melainkan dari menelusuri
jalur lampiran, dan menambah satu bentuk lagi pada pola 1.8: **aturan satu fitur menumpang ke
fitur lain lewat helper bersama.** Batas 2 MB milik bukti pengerjaan ikut menolak foto di
kolom diskusi, dan helper-nya sudah menyebut asalnya di namanya sendiri —
`optimizeImageProofFile`. Pemakai keduanya tetap memanggilnya tanpa curiga, karena nama fungsi
dibaca sebagai *apa yang ia lakukan*, bukan *untuk siapa ia ditulis*. **Nama yang menyebut
fitur asalnya adalah peringatan, bukan jaminan**: saat memakai ulang helper lintas fitur,
periksa konstanta yang ia tegakkan, bukan cuma keluarannya.

Dan satu catatan tentang test, kelanjutan dari 2.2. Lima test notifikasi mendadak merah saat
saringan K22 dipasang, semuanya karena fixture-nya menugaskan task divisi kepada user yang
tidak pernah dimasukkan ke divisinya — keadaan yang produksi sendiri tidak izinkan. Godaannya
melonggarkan penjagaannya supaya test hijau lagi. **Test yang berdiri di atas keadaan yang
mustahil bukan test yang lulus, ia test yang tidak menguji apa-apa**; yang dibetulkan
fixture-nya.

**2.7 lahir dari sisiran atas seksi 10, dan menemukan pola 1.8 pada permukaan yang paling
mahal: berkas.** Aturan izin yang ditulis dua kali menghasilkan 403 atau hak yang hilang;
aturan izin yang ditulis dua kali **di depan sebuah berkas** menghasilkan tautan permanen di
tangan orang yang salah. Selisihnya bukan pada apa yang boleh dilihat sekali, melainkan pada
apa yang bisa disimpan dan diteruskan sesudahnya — dan seksi ini sudah lama mencatat bahwa
tautan Drive-nya bekerja tanpa login. **Saat menyisir seksi berikutnya, dahulukan aturan yang
menjaga sesuatu yang bisa disalin**; kegagalan di sana tidak bisa ditarik kembali dengan
memperbaiki policy-nya besok.

Sisiran ini juga menambah satu bentuk pada nasihat 2.2 soal "kolom terisi bukan bukti fitur
ada". Di sana yang hilang jalur tulisnya; di sini kebalikannya — `proofs.original_name` diisi
rapi bertahun-tahun oleh jalur unggah, dan tidak ada satu pun pembaca yang memakainya di
halaman Media. **Data yang benar di basis data tidak menjanjikan apa pun tentang apa yang
terbaca di layar**, dan bentuk ini lebih sulit dilihat daripada kebalikannya: tidak ada error,
tidak ada kolom kosong yang mencurigakan, hanya nama yang tidak berarti bagi pembacanya.

Satu lagi tentang keistimewaan, dan ini yang paling mudah terulang. Lolosnya super admin sempat
dipindahkan ke dalam rumus visibilitas — tempat yang terasa benar, karena super admin memang
melihat segalanya. Yang tidak terlihat: gate tulis `discuss()` memanggil `view()`, jadi
kelonggaran itu ikut membuka jalur menulis dan mencabut sifat baca-saja yang dijaga sejak
K8. **Hak istimewa yang disalin ke lapisan yang lebih dalam akan menetes ke setiap aturan yang
memanggil lapisan itu**, termasuk aturan yang tidak pernah dimaksudkan menerimanya. Tempatnya
di pintu — `Gate::before`, yang hanya melepas ability baca — dan pemanggil yang butuh
kelonggaran itu menuliskannya sendiri.

Dan yang terakhir bukan dari membaca kode sama sekali, melainkan dari satu laporan pengguna:
dokumen 2,3 MB yang ditolak padahal dokumen ini menulis 10 MB. **Angka yang ditulis di
spesifikasi adalah aturan aplikasi, bukan batas yang berlaku** — di atasnya masih ada
`post_max_size`, `upload_max_filesize`, dan `client_max_body_size`, dan ketiganya bisa lebih
kecil tanpa satu baris pun di repositori ini berubah. Bandingkan dengan 2.4, yang juga lahir
dari memakai produknya: di sana kode konsisten tetapi aturannya salah; di sini kode benar dan
lingkungannya yang tidak sanggup menjalankannya. **Sisiran kode tidak bisa menemukan keduanya**
— untuk yang pertama alatnya memakai produk, untuk yang kedua alatnya mendengarkan orang yang
memakainya di tempat kerjanya sendiri.

**2.8 lahir dari sisiran atas seksi 12, dan yang ditemukannya bukan aturan yang salah
melainkan bagian yang tidak pernah ditulis.** Dashboard punya dua bagian; dokumen ini hanya
mengenal satu. Ketiga selisih yang ditutup di 12.3 semuanya hidup di bagian kedua — kartu
project — dan tidak satu pun dari ketiganya bisa ditemukan dengan cara yang dipakai sejak 1.7,
karena **sisiran bekerja dengan membandingkan dua sisi, dan yang cuma ada di satu sisi tidak
pernah ikut dibandingkan.** Perangkat kedua ikut diam karena alasan yang sama: tiga belas uji
dashboard, seluruhnya tentang daftar task, nol tentang kartu project. Dokumen memandu uji, uji
memandu rasa aman, dan ketiganya melewatkan hal yang sama. Saat menyisir seksi berikutnya,
mulailah dengan menghitung apa yang dirender halamannya, bukan apa yang dijanjikan paragrafnya.

Bentuk penyembunyiannya juga layak dicatat karena berlawanan dengan dugaan.
`getProjectDeadlineState()` sudah menangani project tertutup dengan benar — ia menampilkan
badge status alih-alih tanggal, tepat supaya "Completed" tidak terbaca sebagai tanggal
penyelesaian. Justru penanganan itu yang membuat kartunya **tidak pernah terlihat rusak**,
sehingga tidak ada yang bertanya mengapa project selesai muncul di bawah judul *Active
Projects*. **Penanganan yang setengah benar menyembunyikan cacatnya lebih rapat daripada tidak
ada penanganan sama sekali**: yang separuh salah tampil wajar, yang seluruhnya salah cepat
dilaporkan.

Dan satu tambahan untuk nasihat 2.6 soal komentar yang menamai aturan. Dari empat tempat yang
menjawab "project mana yang boleh saya lihat", satu di antaranya benar **dan menyebut
sumbernya di komentarnya sendiri** — *"agar konsisten dengan ProjectPolicy::view()"*. Kalimat
itu tidak berbohong; ia benar untuk tempat itu. Yang menyesatkan adalah keberadaannya: satu
tempat yang jelas-jelas sudah dicocokkan membuat tiga tempat lain terbaca seolah ikut
dicocokkan. **Bukti bahwa satu pemanggil sinkron bukan bukti apa pun tentang pemanggil
lainnya**, dan justru pemanggil yang paling rapi komentarnya adalah yang paling jarang
diperiksa ulang.

**2.9 lahir dari sisiran atas seksi 13, dan ia menguji nasihat 2.8 pada seksi yang paling
tidak mungkin menyimpan cacat: catatan audit.** Yang ditemukannya bukan aturan yang salah,
melainkan **kebiasaan mencatat yang berhenti setengah jalan**, dan ketiganya berbentuk sama —
sebuah daftar yang benar untuk anggota pertamanya lalu tidak pernah ditumbuhkan. Satu penanda
notifikasi diabaikan, dua saudaranya yang lahir belakangan tidak. Tiga jalur keanggotaan project
dicatat, lima tidak. Satu jenis kejadian menyimpan nilai, satu lagi tidak. **Daftar yang benar
saat ditulis akan salah begitu anggota keduanya lahir**, dan tidak ada yang memberitahu — karena
daftar yang kurang lengkap tidak pernah melempar error, ia hanya diam.

Yang paling mahal justru yang paling sunyi. Baris `created` yang kosong berarti **akun yang
lahir langsung sebagai super admin tidak meninggalkan jejak tentang perannya** — dan cacat
semacam ini tidak pernah ditemukan dengan memakai produknya seperti 2.4, karena tidak ada yang
terlihat salah di layar mana pun. Ia baru terasa pada hari seseorang bertanya *"sejak kapan dia
punya akses ini?"*, dan hari itu jawabannya sudah tidak ada. **Log tidak pernah memberitahu apa
yang tidak dicatatnya**; satu-satunya cara memeriksanya adalah menulis sesuatu lalu membaca
tabelnya, bukan membaca kode yang menulisnya.

Dan satu catatan tentang kerangka yang ditinggalkan. Tiga `ActivityLogController` berdiri tanpa
route, masing-masing mengembalikan nama permission-nya sendiri sebagai string. Ia bukan bahaya,
dan bukan pula pola 2.2 soal route mati — tidak ada aturan yang menumpang di sana. Bahayanya
lebih halus: **kerangka yang tertinggal membuat sebuah fitur terbaca seolah setengah jadi
padahal belum dimulai**, dan pembaca berikutnya akan mengira tinggal menyambungkan route-nya.
Kalau memang belum diputuskan akan dibangun, yang paling jujur adalah tidak menyisakan apa pun.

Cara ketiganya ditutup menyisakan satu nasihat tersendiri, dan ia berlaku setiap kali sebuah
daftar terbukti kurang lengkap: **menambahkan anggota yang hilang memperbaiki hari ini dan
mengulang kekeliruan yang sama besok.** Dua penanda notifikasi tidak ditambahkan ke daftar
namanya melainkan diganti pola akhiran; lima jalur `attach` tidak ditambal satu per satu
melainkan dialihkan ke satu pintu. Keduanya diff yang lebih besar daripada menambal, dan
keduanya menghapus kemungkinan terlewat berikutnya alih-alih menundanya. Pertanyaan yang layak
diajukan bukan *"apa lagi yang kurang di daftar ini"*, melainkan *"kenapa daftar ini bisa
kurang"*.

---

*Project Management System Scuto · Spesifikasi Fitur v2.9 · basis branch `dev` setelah K7,
penjagaan owner tunggal, viewer baca-saja, pembersih arsip 30 hari, K3 revisi, penjagaan
pelepasan anggota divisi, penguncian format NIK, penciutan permukaan autentikasi, keanggotaan
project lintas divisi, pembekuan project terminal, penggeseran deadline pasca jeda, assignee
wajib untuk memulai task, penerusan notifikasi ke push mobile, kelahiran status task,
perpindahan konteks yang membawa subtask, deadline induk–subtask dua arah, kunci bukti yang
mengikuti status, batas 10 bukti per task, penutupan unggahan bukti pada task tanpa tipe
bukti, persetujuan deadline yang memvalidasi penuh, izin pengajuan deadline yang diambil
dari policy, penghentian template yang dijeda di semua jalur, penjagaan pelaksana template
recurring, penguncian penghapusan divisi bertemplate, pilihan hari terbit untuk
frekuensi Daily, pemindahan cascade task pribadi ke hook model, dan penutupan halaman
task pribadi bagi super admin, penjagaan hierarki induk–subtask task pribadi,
deadline wajib sebelum task pribadi dikerjakan, izin berkomentar yang diambil dari policy,
penyuntingan komentar beserta penanda diedit, penyaringan penerima notifikasi lewat
policy `view`, penyempitan daftar sebutan pada routine work, penutupan tiga ketimpangan
lampiran serta payload komentar, pemuatan diskusi per potongan, dan sorotan sebutan yang
berhenti mengikuti keanggotaan hari ini, penyatuan rumus visibilitas task ke satu scope yang
dipanggil policy maupun daftar, penyamaan gerbang berkas media dan route bukti lama, nama asli
berkas untuk lampiran diskusi sekaligus halaman Media, index Media yang berhenti menarik baris
untuk menampilkan angka, penghapusan jalur validasi lampiran yang sudah mati, pesan unggahan
yang menyebut batas server, penyatuan rumus visibilitas project ke satu scope, dan kartu
project dashboard yang menyaring status tertutup sekaligus membatasi jumlahnya,
penyempitan task tanpa assignee ke pembuat dan atasannya, angka kartu project yang tunduk pada
rumus visibilitas yang sama, deadline yang wajib begitu assignee dipasang, kunci bukti yang
berpindah dari daftar larangan ke daftar izin beserta otorisasi unggahannya, pewarisan tenggat
sub-tugas yang mengambil tenggat induk apa adanya termasuk yang sudah lewat, dan pencabutan pengajuan
perpanjangan tenggat pada sub-tugas · keputusan
2 Agustus 2026, ditambah K8–K14 pada 3 Agustus 2026, K15–K22 pada 11 Agustus 2026, dan K23
pada 12 Agustus 2026 · penyelarasan terakhir 26 Agustus 2026 ·
**dua pertanyaan terbuka, keduanya di 07.2: project tujuan dan ekspresi cron kustom**, ditambah
satu batas unggahan di 10.4 yang menunggu konfigurasi server — antreannya tidak lagi memuat
pekerjaan kode.*
