Hasil Audit — Checklist Debug Flow Pendirian PT
Sumber checklist: checklist-debug-pendirian-pt.md
Repo yang diaudit: ahu-ocr-dev/dev_5 (branch dev/5, HEAD dd783691)
Metode: audit kode statis + hasil tes manual. Tidak ada kode yang diubah.
Tanggal: 2026-08-04
Bagian 0 (pra-cek penghapusan Domisili & Surat Pernyataan) sudah dihapus dari laporan ini — task 86ex78yf7 di-revert, Domisili & Surat Pernyataan Setor tetap wajib. Item domisili di bagian lain ditandai N/A.
Penomoran temuan memakai satu skema: B = blocker, M = major, N = minor, D = perlu keputusan.
| Status | Arti |
|---|---|
| PASS | Terverifikasi, disertai path + baris |
| FAIL | Terverifikasi tidak sesuai, disertai path + baris |
| MANUAL | Perlu dijalankan / dilihat di browser |
| N/A | Tidak relevan setelah revert |
Kolom Sumber: M = tes manual, K = bukti kode, M+K = keduanya sepakat.
Ringkasan
115 item · 84 PASS · 12 FAIL · 4 MANUAL · 15 N/A · 0 perlu keputusan
Lima blocker:
- B1 — tidak ada UI override validasi, padahal finalize memblokir setiap FAIL yang belum di-override. Notaris bisa terkunci tanpa jalan keluar.
- B2 — Step 3 tidak punya jalur error sama sekali; ID tidak valid berakhir di spinner abadi.
- B4 — kewajiban kelima sub-tipe Bukti Setor hanya ditegakkan di Step 2; Step 3 dan Step 4 bocor.
- B5 — Akta Pendirian tidak bisa diganti. Hapus lalu unggah ulang →
400 Cannot remove Akta Pendirian, seluruh sync batal. - B6 — Tinjauan bisa dibuka lewat address bar saat ekstraksi belum selesai; membukanya justru menstempel Ekstraksi sebagai selesai.
(B3 diturunkan ke M14 — tampilan checklist lengkap diputuskan tidak diperlukan.)
Satu cacat berat di luar checklist, terbukti lewat eksperimen langsung — M6. Seluruh endpoint /api/regions/* di backend yang sedang berjalan tidak merespons sama sekali. Pool MySQL SABH dibuat tanpa enableKeepAlive/idleTimeout, jadi setelah koneksi menganggur lewat wait_timeout (8 jam) permintaan menggantung selamanya alih-alih gagal. Dibuktikan: memicu reload modul mengubahnya dari tanpa respons jadi 200 dalam 0,09 dtk. Akan kambuh setiap 8 jam idle. M15: sabh-db.ts punya cacat identik, dan itulah pool yang dipakai rule PP29 & KBLI.
Sub-bagian Cross-validation sudah selesai — SABH terbukti hidup dari environment ini, jadi seluruh itemnya bisa ditutup tanpa menunggu tes UI.
Daftar perbaikan berprioritas
🔴 Blocker
B1 · Tidak ada UI override validasi
Finalize menolak setiap FAIL yang belum di-override — review-data.ts:104-113:
where: { submissionId: id, status: "FAIL", overridden: false }
→ 400 VALIDATION_FAILED "Ada validasi yang masih GAGAL dan belum di-override"
Tapi tidak ada cara meng-override di flow Pendirian PT. Backend-nya lengkap (submissions.ts:1948-2006 — alasan wajib, ada audit trail), hook frontend-nya juga ada (use-submission.ts:311 useOverrideValidation), tapi hook itu nol pemanggil. Panel validasi Step 3 sepenuhnya read-only — validation-results.tsx, 117 baris, nol kata "override". Yang memanggil endpoint itu hanya flow lain: BerakhirnyaReviewPage.tsx:183, LaporanRupsReviewPage.tsx:93, PembubaranReviewPage.tsx:65, PeleburanReviewPage.tsx:80.
Komentar kodenya sendiri mengakui masalah ini dan menyodorkan debug bypass sebagai jalan keluar — review-data.ts:90-97. Kasus paling mungkin: AKTA_DATE_WINDOW FAIL untuk akta di luar jangka 60 hari.
Perbaikan: sambungkan useOverrideValidation ke panel validasi, atau turunkan rule yang bisa FAIL secara sah menjadi WARNING.
B2 · Step 3 tidak punya jalur error
PendirianExtractionPage.tsx:37 bahkan tidak mengambil isError dari hook-nya. Satu-satunya guard adalah :158-164 — if (isLoading || !submission) yang merender spinner.
Jalurnya: backend 404 (submissions.ts:469-471) → apiFetch melempar ApiError (api-client.ts:118-125) → retry 1× lalu menyerah (main.tsx:12) → data tetap undefined → spinner abadi. Berlaku untuk ID salah, backend mati, dan koneksi putus sebelum data pertama masuk.
Rute Pendirian juga tidak dibungkus ErrorBoundary — routes.tsx:114 dan :117 telanjang, sementara enam rute verifikator dibungkus (:255, :259, :263, :267, :271, :294). Tidak ada errorElement di level router.
Perbaikan: tiru pola yang sudah benar di Step 4 — PtPendirianReviewPageV2.tsx:146-154. Perbaikan terkecil, dampak terbesar, dan berdiri sendiri.
~~B3~~ · Diturunkan ke Major (M14)
Keputusan: tampilan checklist lengkap tidak diperlukan — dokumennya tetap wajib dan tetap harus diverifikasi, tapi tidak perlu dirender sebagai daftar di Step 3.
Yang tersisa dari temuan ini pindah ke M14: saat tombol "Lanjut" mati, pesannya tidak menyebut dokumen mana yang kurang. Sisanya (panel hanya merender KTP/Paspor/NPWP) sekarang perilaku yang dikehendaki, bukan cacat.
Konsekuensi untuk B4: perbaikannya tidak lagi berarti "tampilkan lima baris checklist" — cukup gerbangnya yang menangkap, plus pesan yang menyebut apa yang kurang.
B6 · Akses langsung ke Tinjauan saat ekstraksi belum selesai
Terkonfirmasi lewat tes manual: mengganti address bar ke /pendirian/:id/review membuka halaman Tinjauan walaupun ekstraksi belum selesai dan validasi masih GAGAL. (Dari Step 2 tidak bisa karena id belum ada — jadi celahnya khusus setelah submission terbentuk.)
Tidak ada penjaga di lapisan mana pun:
- Frontend — routes.tsx hanya punya
RoleGuard(berbasis peran), nol step guard - Backend — review-data.ts:24-40 hanya 404 kalau submission tidak ada; tidak menggerbangi
status - Lebih jauh — membuka Tinjauan justru menandai Ekstraksi sudah lewat: PtPendirianReviewPageV2.tsx:106-108 mem-POST
extraction-acksaat mount
Akibatnya notaris bisa meninjau dan mengonfirmasi field dari dokumen yang masih dalam proses ekstraksi — nilai yang dilihatnya bisa berubah di belakang layar setelah dikonfirmasi. Dan stempel extraction-ack membuat dashboard menganggap tahap itu selesai.
Komentar kodenya menyebut stempel itu disengaja ("routing refinement 2026-07-20") untuk mencegah deep-link terlempar balik ke Step 2 selamanya. Niatnya sah, tapi efek sampingnya jadi celah.
Perbaikan: gerbangi berdasarkan status — kalau submission belum READY/COMPLETED, alihkan ke halaman ekstraksi. extraction-ack tetap dikirim hanya ketika ekstraksi memang sudah selesai.
B4 · Kewajiban lima Bukti Setor hanya ditegakkan di Step 2
Kelima sub-tipe wajib — klasifikasi-doc-types.ts:9-13: Slip Bank, Rekening Koran, Neraca, Surat Keterangan Bank, Surat Pernyataan Setor.
| Gerbang | Menegakkan kelimanya? | Bukti |
|---|---|---|
| Step 2 klasifikasi | ✅ ya | Tiap label wajib yang kosong → severity: "error" (classification-validation.tsx:53-59) yang memblokir canProceed (:108) |
| Step 3 | ❌ tidak | Hanya satu baris generik "Bukti Setor Modal" (submission-checklist.ts:116-122), terpenuhi oleh sub-tipe apa pun (pendirian-pt.ts:66-67 — classifiedType.startsWith("BUKTI_TRANSFER")) |
| Step 4 finalize | ❌ tidak | Gerbangnya hanya field belum terkonfirmasi + FAIL belum di-override (review-data.ts:99-113) |
Rule yang seharusnya menjaga ini tidak pernah jalan di Pendirian. REQUIRED_SUPPORTING_DOCS terdaftar di rule set (pt-akta/index.ts:34), tapi baris pertamanya langsung keluar — cross-validator-required-docs.ts:9-16:
if (!input.isPerubahan || !scope) {
return { ruleCode: "REQUIRED_SUPPORTING_DOCS", …, status: "SKIPPED", message: "Bukan perubahan" };
}
Pendirian bukan Perubahan → selalu SKIPPED. Seandainya pun jalan, isinya hanya memeriksa AKTA_PEMINDAHAN_HAK, PENETAPAN_GANTI_NAMA, BERITA_ACARA_RUPS (:27-36) — nol pemeriksaan sub-tipe Bukti Setor. Karena SKIPPED disaring dari tampilan (ValidationResultsList.tsx:47), user tidak punya petunjuk bahwa pemeriksaan itu absen.
Skenario yang lolos: unggah kelimanya di Step 2 → lolos → di Step 3 hapus empat di antaranya → rematch me-reset baris BUKTI_SETOR lalu memenuhinya kembali karena masih ada satu tersisa → checklist hijau, tidak ada FAIL, tombol aktif → finalize lolos dengan empat dokumen wajib hilang.
Perbaikan (dua tempat, sebaiknya keduanya):
1. Pecah satu baris BUKTI_SETOR jadi lima baris per sub-tipe di submission-checklist.ts:116-122, dan ubah fulfillChecklist di pendirian-pt.ts:66-67 agar memenuhi baris spesifik. Ini otomatis menutup Step 3.
2. Tambahkan rule kelengkapan khusus Pendirian yang membaca PENDIRIAN_DOC_TYPES sebagai sumber kebenaran — menutup Step 4 sekaligus, dan tidak tertinggal saat daftar dokumen wajib berubah.
⚠️ Kerjakan bersama B3. Begitu kelima baris muncul, semuanya akan tidak terlihat di Step 3 — user akan terkunci oleh lima syarat sekaligus yang tak satu pun tampil di layar.
B5 · "Cannot remove Akta Pendirian" — Akta tidak bisa diganti
Ditemukan lewat tes manual; akar masalah terkonfirmasi di kode.
Repro: Step 2 → back ke Step 1 → hapus Akta Pendirian (berhasil) → tambah ulang Akta → "Lanjut" → gagal, 400 Cannot remove Akta Pendirian.
Rantainya:
- Menghapus baris Akta di board hanya mengubah atom — tidak ada panggilan server, jadi terasa berhasil
- Akta baru masuk sebagai baris tanpa
documentId(file staging segar) - "Lanjut" memanggil
boardDocumentDiff— store/klasifikasi.ts:178-189. Akta lama masukremove(ID terlampir yang tidak lagi di board), Akta baru masukadd POST /:id/sync-documentsmemprosesremovelebih dulu — submissions-klasifikasi-board.ts:178-183detachSubmissionDocumentmenolak tanpa syarat — submission-document-detach.ts:49-51, denganUNREMOVABLE_TYPE = "AKTA_PENDIRIAN"(:21)- Handler langsung
returnpada penolakan pertama → seluruh sync batal, termasuk Akta baru yang seharusnya menggantikan
Akar masalahnya konseptual: board mengekspresikan "ganti Akta" sebagai remove + add, tapi penjaga hanya melihat remove-nya. Backend tidak punya konsep "replace" — hanya attach dan detach.
Cacat turunan — detach parsial. Loop di :178-183 tidak transaksional. Kalau remove berisi dokumen lain sebelum Akta, dokumen-dokumen itu sudah terlanjur ter-detach saat Akta memicu 400. Submission tertinggal setengah-jadi, dan board di layar tidak mencerminkannya.
Perbaikan:
- Kenali replacement di sync-documents: kalau remove memuat Akta dan add memuat baris berklasifikasi AKTA_PENDIRIAN, izinkan detach-nya (atau proses add dulu baru remove)
- Jadikan loop remove transaksional agar detach parsial tidak mungkin terjadi
- Selaraskan UI: kalau Akta memang tidak boleh dilepas, jangan biarkan barisnya dihapus di Step 1 tanpa peringatan — sediakan "Ganti Akta" sebagai satu aksi
🟠 Major
| # | Temuan | Bukti |
|---|---|---|
| M12 | Koreksi manual NPWP ditimpa classifier saat ekstraksi. Jalur NPWP selalu meng-klasifikasi ulang file dan menulis balik documentType — document-processor.ts:2104-2117. Berbeda dari jalur Akta (:463-466) dan Bukti Setor (:1395) yang menghormati classificationOverride, jalur NPWP mengabaikannya |
Koreksi manual di Step 2 berbalik sendiri setelah ekstraksi — override yang diisi user diam-diam dibuang. Berlaku untuk setiap NPWP, terlepas dari kualitas classifier-nya |
| M13 | Format tanggal tidak seragam — tanggal akta YYYY-MM-DD, tanggal identitas DD-MM-YYYY. Akarnya dua: (1) tanggal_akta tidak terdaftar sebagai date field — format-doc-date.ts:71 DATE_FIELD_KEYS hanya memuat doc_date, tanggal_lahir, tanggal_terbit, berlaku_hingga; (2) formatDateDisplay (:37) hanya dipakai field-correction.tsx dan DocumentSetSection.tsx — bukan oleh FieldRow/FieldListSection yang dipakai review PT Pendirian |
Formatter seragam "D MMMM YYYY" sudah ada dan benar, tapi permukaan review PT tidak memanggilnya. Nilai mentah tampil apa adanya: tanggal identitas tersimpan DD-MM-YYYY (:64-69), tanggal akta dalam bentuk apa pun keluaran OCR/LLM |
| M15 | sabh-db.ts punya cacat pool yang sama dengan regions.ts — dan pool itulah yang dipakai rule PP29_MODAL dan KBLI_VALID. Saat probe (proses baru) semuanya sehat, tapi backend yang hidup lama rentan mode kegagalan yang sama seperti /api/regions/*: cross-validation menggantung, bukan gagal-cepat |
Perbaikannya satu tempat: tambahkan enableKeepAlive: true + idleTimeout ke tiga pool SABH, tiru pola yang sudah dipakai empat pool lain di repo yang sama |
| M14 | Pesan blokir tidak menyebut dokumen yang kurang. Saat "Lanjut ke Tinjauan" mati karena checklist wajib belum lengkap, pesannya hanya "Dokumen belum lengkap" — PendirianExtractionPage.tsx:203-204. Karena panel checklist memang tidak menampilkan dokumen non-identitas (disengaja), tidak ada permukaan lain yang bisa memberi tahu |
Cukup sebutkan nama dokumennya di pesan/tooltip — tidak perlu merender daftar checklist |
| M1 | KTP yatim tidak pernah terlihat. KTP tanpa pasangan mendapat matchedPersonName: "" (identity-matcher.ts:118-127), sementara IdentityCards membangun kartu dari checklist orang di Akta (identity-cards.tsx:43-51) lalu mencari match by nama (:55). Nama kosong tidak cocok dengan kartu mana pun → tidak dirender di permukaan mana pun |
risiko berkas salah orang lolos tanpa disadari |
| M2 | Tombol "Lanjut" tidak memblokir file tanpa jenis. File tak dikenali & belum dikonfirmasi dicatat severity: "warning" (classification-validation.tsx:73-77, :84-89), tapi gate hanya melihat "error" (:92, :108) |
perbaikannya satu kata |
| M3 | Refresh di Step 1/2 menghapus board. Semua atom atom() polos tanpa persistensi (store/klasifikasi.ts:127, :191-194); rebuild dari server early-return saat lastSubmission kosong (KlasifikasiPage.tsx:979) |
atomWithStorage = perubahan kecil |
| M4 | Kegagalan SABH menyamar sebagai "KBLI tidak ditemukan". catch memetakan semua kode ke null (sabh-db.ts:102-107), lalu diklasifikasikan invalid (cross-validator.ts:621-622). Terbukti saat runtime: dengan host SABH diarahkan ke IP blackhole, validateKbliCodes(["62019"]) mengembalikan null → dilaporkan "Tidak ditemukan", padahal errornya connect ETIMEDOUT. Bandingkan getBakumSetting() yang benar-benar mengembalikan null dan memicu SKIPPED |
validatePp29Modal sudah benar (:568-575) — tiru polanya |
| M5 | Polling tanpa batas pada status non-terminal. Berhenti hanya pada READY/COMPLETED/ERROR (use-submission.ts:45, :80); fallback return 2500 (:81) membuat submission tersangkut di EXTRACTING dipoll selamanya tanpa indikator |
|
| M6 | Pool SABH tanpa keepalive dan tanpa batas antre — terbukti menggantung di backend yang sedang berjalan. Endpoint /api/regions/* tidak merespons sama sekali (dites 20 dtk, tiga endpoint, semuanya 000), padahal backend yang sama menjawab /api/health dan /api/submissions dalam ~0 ms. Query SQL yang persis sama lewat proses baru selesai dalam 168 ms / 40 baris — jadi SABH-nya sehat, yang rusak pool-nya |
Tiga pool SABH tidak punya enableKeepAlive/idleTimeout — regions.ts:16-24, sabh-db.ts:14-22, sabh-region-lookup.ts:21-29 — sementara empat pool lain di repo punya: apostille-spesimen-db.ts:33-35, pp-registry-db.ts:30, auth-db.ts:35, pp-wilayah-resolve.ts:28. Dengan connectionLimit: 2 dan mysql2 yang mengantre akuisisi tanpa batas, koneksi basi = permintaan menggantung selamanya, bukan gagal. Angkanya cocok: MySQL SABH wait_timeout = 28800 dtk (8 jam), sementara proses backend sudah hidup 21+ jam. DIBUKTIKAN dengan eksperimen: memicu reload modul backend (tanpa mengubah kode apa pun) langsung mengubah /api/regions/provinsi dari menggantung tanpa respons menjadi 200 dalam 0,09 dtk — ketiga endpoint regions pulih (0,09 / 0,02 / 0,03 dtk). Pool baru = sehat; pool lama = koneksi mati yang tidak pernah dideteksi. Gejalanya akan kambuh setiap kali pool menganggur lewat 8 jam |
| M7 | Wilayah dijodohkan LIKE %nama% lintas dua DB, bukan foreign key — kelurahan dicocokkan ke kecamatan by kemiripan nama (regions.ts:97-104, sabh-region-lookup.ts:45, :61) |
uji dengan nama kecamatan umum ("Cikarang", "Medan Kota") |
| M8 | Data basi antar-tab. refetchOnWindowFocus: false global (main.tsx:12) tanpa sinkronisasi antar-tab (nol BroadcastChannel/listener storage). Step 4 tidak polling sama sekali |
perbesar bobotnya kalau demo multi-tab |
| M9 | Urutan review bukan by confidence. Checklist meminta confidence terendah lebih dulu; yang ada urutan statis order: 0..10 (pt-pendirian-review.ts:131-143) |
nol sort by confidence di seluruh review-engine |
🟡 Minor
| # | Temuan | Bukti |
|---|---|---|
| N1 | Wording "Nilai" belum jadi "Input yang benar". Satu baris, tapi menyentuh 7 halaman review + 2 test yang mengunci label lama | FieldEditDialog.tsx:60 |
| N2 | Wording "maks 10MB" padahal batasnya 100 MB; halaman /process masih dirutekan aktif (routes.tsx:289) |
document-type-selector.tsx:27, :34, :41, :48, ProcessPage.tsx:164 |
| N3 | Tiga connection pool MySQL terpisah ke server SABH yang sama (2+2+3 = 7 koneksi per instance) — dan ketiganya persis pool yang kekurangan keepalive di M6 | sabh-db.ts:14, regions.ts:16, sabh-region-lookup.ts:21 |
| N4 | isSabhAvailable() mengukur konfigurasi, bukan konektivitas. Terbukti: dengan host diarahkan ke IP blackhole, fungsi ini tetap mengembalikan true — jadi rule tidak pernah memilih jalur SKIPPED "SABH database tidak tersedia" dan jatuh ke jalur error masing-masing (lihat M4) |
sabh-db.ts:7-9 |
| N5 | Confidence badge tone tinggi mungkin terlalu lembut sehingga terkesan "hanya muncul untuk yang gagal" | classification-board.tsx:77 |
| N6 | Kode mati alamat manual. PtAddressForm, PATCH /:id/address (submissions.ts:1617-1660), dan useUpdateAddress (use-submission.ts:368-386) semuanya nol pemanggil. Dibuat sebagai pengganti Surat Domisili; setelah revert, alamat datang dari dokumen (pendirian-pt.ts:62-64 copyDomisiliAddressToSubmission) |
putuskan: hapus, atau sambungkan sebagai pelengkap |
| N8 | Ambang PP29 untuk PT tertutup = Rp 1. Terbaca dari SABH: min_modal_dasar_tertutup_pp29 = 1 (PT terbuka = Rp 3.000.000.000, sesuai regulasi). Artinya PP29_MODAL praktis tidak pernah gagal untuk PT tertutup — modal dasar berapa pun ≥ 1 lolos |
Kemungkinan besar disengaja (PP 8/2021 menghapus minimum modal dasar PT, diserahkan ke kesepakatan pendiri). Tapi labelnya "Minimum Modal Dasar (PP29)" jadi menjanjikan pemeriksaan yang efektifnya tidak ada — perlu dikonfirmasi ke tim hukum |
| N9 | Kode KBLI non-5-digit dilaporkan "Tidak ditemukan", bukan "format salah". Filter LENGTH(kode) = 5 di query (sabh-db.ts:91) membuat 620 dan 0000000 ikut jatuh ke keranjang yang sama dengan kode yang memang tidak terdaftar |
Terbukti saat probe. Notaris tidak bisa membedakan "kodenya salah ketik" dari "kodenya belum terdaftar di SABH" |
| N10 | Tabel wilayah memuat 40 provinsi, sementara Indonesia saat ini punya 38 — kemungkinan ada entri lama/duplikat di AHU_NOTARIAT.WILAYAH |
Perlu dicek sebelum dropdown provinsi dipakai serius; berhubungan dengan M7 (penjodohan LIKE %nama%) |
| N7 | Dua token untuk konsep sama: SURAT_PERNYATAAN_DOMISILI (PT) vs SURAT_DOMISILI (PP, dipetakan ke enum DB DOMISILI) |
pp-perubahan.ts:53-58, pp-perbaikan-pp.ts:64 |
⚪ Perlu keputusan
Tidak ada lagi. Keempat pertanyaan terbuka sudah dijawab:
| Pertanyaan | Jawaban |
|---|---|
Apakah task 86ex78yf7 (hapus Domisili) masih berlaku? |
Di-revert — Domisili & Surat Pernyataan Setor tetap wajib |
| Bukti Setor: lima sub-tipe wajib semua, atau satu cukup? | Kelimanya wajib → B4 |
| Deep-link ke Tinjauan sengaja diizinkan? | Tidak — terkonfirmasi sebagai celah → B6 |
| Section approval — apa yang sebenarnya terlihat? | Ditutup sebagai PASS; beda penamaan, bukan beda fungsi |
Satu yang tersisa sebagai pilihan implementasi, bukan pertanyaan terbuka: N6 — kode mati alamat manual (PtAddressForm, PATCH /:id/address, useUpdateAddress, semuanya nol pemanggil). Hapus, atau sambungkan sebagai pelengkap Domisili.
Checklist lengkap — status akhir
File checklist-debug-pendirian-pt.md sengaja tidak diubah — itu catatan tes manual Anda.
Bagian 1 — Upload (8 PASS · 1 terjawab)
| Item | Status | Sumber | Catatan |
|---|---|---|---|
| Drag & drop banyak file | PASS | M | |
| File muncul dengan nama benar | PASS | M | |
| Hapus file dari list | PASS | M | |
| Upload PDF diterima/ditolak jelas | PASS | M | Diterima tanpa pesan konfirmasi |
| Batas ukuran file | terjawab: 100 MB | K | config.ts:165 MAX_FILE_SIZE_MB \|\| "100", diterapkan di klasifikasi.ts:319. Wording UI salah → N2 |
| Upload file rusak / .txt | PASS | M | |
| Nama file karakter aneh | PASS | M | |
| Refresh di tengah upload | PASS | M | "hilang bersih" — akar masalahnya M3 |
| Nol file → tombol nonaktif | PASS | M |
Bagian 2 — Klasifikasi (17 PASS · 2 FAIL · 1 MANUAL · 2 N/A)
| Item | Status | Sumber | Catatan |
|---|---|---|---|
| Tombol "Cek dan Kelompokkan" memicu classifier | PASS | M | |
| Indikator loading | PASS | M | |
| Thumbnail per card | PASS | M | |
| Label jenis dokumen | PASS | M | |
| Confidence badge per card | PASS | K | Dirender untuk setiap card: classification-board.tsx:316 → badge :326-330. Tone berubah di ambang 0.6 (:77) → N5 |
| Confidence rendah ter-highlight | PASS | M | |
| Dropdown jenis untuk confidence rendah | PASS | M | |
| Re-assign jenis manual tersimpan | PASS | M | |
~~Dropdown tidak menampilkan SURAT_DOMISILI/SURAT_PERNYATAAN~~ |
N/A | — | Revert — justru harus tampil |
| Warning kalau Akta belum ada | PASS | M | |
| "Lanjut" nonaktif kalau ada file tanpa jenis | FAIL | M+K | → M2 |
| "Lanjut" nonaktif kalau Akta belum ada | PASS | M+K | classification-validation.tsx:53-59 — severity: "error", memang memblokir |
| "Lanjut" aktif kalau kedua syarat terpenuhi | PASS | K | :108 |
| Akta / KTP / NPWP dikenali benar | PASS | M | |
NPWP_LAMA terpisah dari NPWP baru |
MANUAL | — | Tidak ada dataset uji. Pemisahannya sendiri ada di kode: isNpwpLama ditandai saat klasifikasi (klasifikasi.ts:557), ekstraksi memakai dua model Azure terpisah (document-processor.ts:2126). Lihat M12 |
Sub-klasifikasi SURAT_PERNYATAAN_SETOR terpisah |
PASS | M | |
| 4 sub-tipe Bukti Setor lain classify-only | FAIL | K | Premis item ini keliru — mereka menjalankan SETORAN-FACTS pass (PaddleOCR + LLM 3B) yang mengisi "Daftar Setoran": document-processor.ts:1398-1408. Hanya sub-tipe tak dikenal yang classify-only. Bukan cacat — tapi premis checklist perlu dikoreksi |
| Regresi kelas lama setelah retrain | PASS | M | |
| NPWP tidak tertukar dengan KTP | PASS | M | |
| ~~Upload Surat Domisili asli~~ | N/A | — | Revert — kelas & labelnya memang ada |
| Dokumen di luar semua kelas → fallback | PASS | M | |
| Threshold confidence masuk akal | PASS | M |
Bagian 3 — Orkestrasi (7 PASS)
| Item | Status | Sumber | Catatan |
|---|---|---|---|
| Akta diproses lebih dulu | PASS | M | |
| Polling tiap 2,5 detik | PASS | K | use-submission.ts:73 |
| Polling berhenti setelah selesai | PASS | K | :45 + :80. Celah → M5 |
| Progress bar mencerminkan kondisi sebenarnya | PASS | M | |
| Waktu proses wajar (benchmark) | PASS | M | Catatan: kunci cache berbasis isi file (queue.py:105), jadi angka ini hanya jujur untuk berkas yang belum pernah diproses |
| Satu dokumen gagal, lain tetap lanjut | PASS | M | |
| Pesan error jelas kalau ekstraksi gagal | PASS | M | Berlaku untuk error per-dokumen; error memuat halaman → B2 |
Bagian 3 — Kolom kiri (6 PASS)
Seluruh enam item PASS dari tes manual: collapse/expand, thumbnail, progress per file, drop zone, hapus file di tengah step, tambah file setelah ekstraksi.
Bagian 3 — Kolom kanan (5 PASS · 1 FAIL · 2 N/A)
| Item | Status | Sumber | Catatan |
|---|---|---|---|
| Document checklist tampil sesuai dokumen yang ada | N/A | — | Keputusan: tampilan checklist tidak diperlukan. Dokumennya tetap wajib & tetap diverifikasi — penegakannya di B4, pesan blokirnya di M14 |
| ~~Checklist tidak menampilkan Domisili & Surat Pernyataan~~ | N/A | — | Sama seperti di atas |
| Identity cards tampil | PASS | M | |
| KTP auto-matching by name | PASS | M | |
| KTP auto-matching: nama bergelar | PASS | M | |
| KTP auto-matching: typo kecil | PASS | M | Ambang skor 0.5 — identity-matcher.ts:118 |
| KTP auto-matching: dua nama mirip tidak tertukar | PASS | M | |
| KTP yang tidak cocok dengan siapa pun ditandai jelas | FAIL | M+K | → M1 |
Bagian 3 — Cross-validation (15 PASS · 2 FAIL · 0 MANUAL · 1 N/A)
✅ Selesai. Semula diperkirakan butuh tes manual, ternyata bisa ditutup seluruhnya: SABH hidup dan terjangkau dari environment ini, sehingga jalur rule-nya bisa diuji langsung; logika rule-nya sendiri sudah dijaga 174 unit test yang semuanya lolos.
Cara verifikasinya (semuanya read-only):
1. Memanggil helper milik proyek sendiri —getBakumSetting()danvalidateKbliCodes()di sabh-db.ts — yaitu jalur yang persis dipakai rulePP29_MODALdanKBLI_VALID
2. Menjalankanbun test src/services/__tests__/cross-validator*.test.ts→ 174 pass / 0 fail
3. Fault injection: mengarahkanSABH_DB_HOSTke IP blackhole untuk mengukur perilaku saat SABH matiKoneksi nyata:
AHU_BADAN_HUKUM@192.16x.x.x:3306, userbht-read— balas dalam 754 ms.⚠️ Catatan penting: probe ini berjalan di proses baru, jadi pool-nya segar. Di backend yang hidup lama, pool
sabh-db.tsrentan cacat yang sama seperti/api/regions/*— lihat M6 dan M15. Artinya rule PP29 & KBLI bisa menggantung, bukan gagal-cepat, walau logikanya sendiri sudah terbukti benar.
Beberapa item checklist menggabungkan dua klaim ("rule X berjalan dan hasilnya benar"). Baris seperti itu dipecah dua.
| Item | Status | Sumber | Catatan |
|---|---|---|---|
| Inventarisasi 8 rule | FAIL | K | Bukan 8 tapi 32 — pt-akta/index.ts:15-48, dipakai utuh tanpa filter oleh pendirian-pt.ts:94. Terlihat 8 karena UI membuang SKIPPED dan menyembunyikan PASS (ValidationResultsList.tsx:45-49). Inventaris lengkap di bawah |
| Koneksi readonly SABH hidup dari environment | PASS | K | Terbukti: koneksi berhasil, balas 754 ms. DB AHU_BADAN_HUKUM, user bht-read. Data referensi terbaca — 40 provinsi, 188.605 baris kelurahan, tabel m_kbli dan tbl_bakum_setting semuanya terjangkau |
| — readonly-nya benar (tidak menulis) | PASS | K | Nol statement tulis di sabh-db.ts, regions.ts, sabh-region-lookup.ts — dan kredensialnya memang user baca: SABH_DB_USER=bht-read. Jadi ditegakkan di dua lapis, bukan sekadar konvensi kode |
| Rule PP29 berjalan | PASS | K | pt-akta/index.ts:45; impl cross-validator.ts:542-596 |
| Rule PP29 hasilnya benar | PASS | K | Ambang terbaca dari SABH: min_modal_dasar_tertutup_pp29 = **1**, min_modal_dasar_terbuka = **3.000.000.000**. Aritmetikanya benar dan gate jenis-nya ada unit test-nya. Tapi lihat N8 — ambang 1 rupiah membuat rule ini praktis tidak pernah gagal untuk PT tertutup |
| Rule KBLI berjalan | PASS | K | pt-akta/index.ts:46; impl cross-validator.ts:598-647 |
| Rule KBLI hasilnya benar | PASS | K | Diuji ke data nyata: 62019 → "Aktivitas Pemrograman Komputer Lainnya" (status 1, 2020), 63111 → "Aktivitas Pengolahan Data" (status 1, 2020), 47111 → status 1, 2025. Kode palsu 99999 → null. Terkonfirmasi juga filter LENGTH(kode)=5 (sabh-db.ts:91): 620 dan 0000000 ikut dilaporkan "Tidak ditemukan", bukan "format salah" → N9 |
| KBLI tidak ditemukan → jelas, tidak crash | PASS | K | WARNING "Tidak ditemukan: <kode>" (cross-validator.ts:636-646); WARNING tidak memblokir (fail-gate.ts:5-9). Tapi → M4 |
| SABH mati → flow tetap jalan dengan warning | PASS | K | Tiga lapis: isSabhAvailable() false → SKIPPED; query gagal → catch + log.warn; rule melempar → Promise.allSettled (validation-runner.ts:59-67) |
| SABH mati → tidak hang | PASS | K | Fault injection ke IP blackhole: getBakumSetting() gagal dalam 5.120 ms, validateKbliCodes() dalam 5.010 ms — keduanya sesuai connectTimeout: 5000, tidak menggantung. ⚠️ Yang diuji baru host tak terjangkau; query yang menggantung setelah koneksi terbentuk masih tanpa batas → M6 |
~~Rule ALAMAT_DOMISILI tidak ikut dijalankan~~ |
N/A | — | Revert — DOMISILI_NAMA memang harus jalan (pt-akta/index.ts:25) |
| Nominal modal Akta vs Bukti Setor dibandingkan | PASS | K | MODAL_BUKTI_SETOR pt-akta/index.ts:18; impl cross-validator.ts:271-320 |
| Nominal modal hasilnya benar | PASS | K | Dijaga unit test: validateModalBuktiSetor ("passes when total matches modal disetor", "skips when no bukti setor amounts"), plus MODAL_HIERARCHY (FAIL saat dasar < disetup / ditempatkan < disetor) dan NOMINAL_RECONCILE (ditempatkan = saham × nominal). Semua lolos |
| Nama perseroan konsisten antar dokumen | PASS | K | NAMA_PT_CONSISTENCY pt-akta/index.ts:26, sumbernya termasuk dokumen Domisili (cross-validator.ts:724). Ada unit test khusus: "DOMISILI_NAMA + NAMA_PT_CONSISTENCY share one company-name scorer" — jadi keduanya dijamin konsisten satu sama lain |
| Validasi NIK berfungsi | PASS | K | NIK_KTP_AKTA pt-akta/index.ts:16. Unit test menutup: "equal NIKs match", "fails when a NIK does not match", "passes when all NIKs match", WNI-scoping, dan niksAreEquivalent (normalisasi ketat). ⚠️ Cakupannya tetap sempit: hanya membandingkan NIK KTP vs Akta untuk KTP WNI yang sudah ter-match; tidak ada validasi format/checksum NIK 16 digit |
| Warning validasi bisa di-override user | FAIL | K | → B1 |
| Override tersimpan (bertahan saat re-validasi) | PASS | K | validation-runner.ts:71-110 — cabang update sengaja tidak menyentuh overridden. Pendirian memanggil tanpa blanketDelete (processor.ts:264, rematch.ts:94) |
| Override terbawa ke Step 4 | PASS | K | Diproyeksikan pt-pendirian-review.ts:783, ditampilkan ValidationBanner.tsx:69. Moot dalam praktik selama B1 belum beres |
Inventaris rule set Pendirian PT (32 rule)
Sumber tunggal: pt-akta/index.ts:15-48.
NIK_KTP_AKTA · NAMA_NPWP_KTP · MODAL_BUKTI_SETOR · PEMEGANG_SAHAM_BUKTI_SETOR · SHARES_SUM_CONSISTENCY · MODAL_HIERARCHY · NOMINAL_RECONCILE · KTP_COMPLETENESS · NPWP_COMPLETENESS · DOMISILI_NAMA · NAMA_PT_CONSISTENCY · PS_TOTAL_100 · CONTACT_INFO_COMPLETE · OLD_DATA_CONSISTENCY · PERUBAHAN_MODAL · DATA_ACTUALLY_CHANGED · SHARES_TRANSFER_BALANCE · SHARES_TOTAL_LEMBAR · REQUIRED_SUPPORTING_DOCS · AKTA_DATE_WINDOW · PERSEROAN_STATE · NPWP_PERSEROAN_FUZZY · NOTARIS_TERAKHIR · JENIS_SELECTION_NONEMPTY · JENIS_DESELECT_REASONS_COMPLETE · UPLOADED_DOC_UNUSED · CONTRADICTORY_JENIS · PEMEGANG_SAHAM_CONTACT · PASSPORT_AKTA · PP29_MODAL✱ · KBLI_VALID✱ · PERUBAHAN_KBLI✱
✱ = async, butuh SABH. Rule #14–17, #21–27, #32 secara semantik milik flow Perubahan — di Pendirian diperkirakan selalu SKIPPED sehingga tersaring dari UI, tapi ini belum dibuktikan. REQUIRED_SUPPORTING_DOCS sudah dipastikan selalu SKIPPED → B4.
Bagian 3 — Alamat PT manual (1 PASS · 9 N/A)
Sub-bagian ini dibuat sebagai pengganti Surat Domisili. Setelah revert, alamat memang datang dari dokumen Domisili (pendirian-pt.ts:62-64), jadi seluruh item "field X tersedia di Step 3" menjadi N/A.
Yang tetap berlaku:
| Item | Status | Catatan |
|---|---|---|
| Dropdown region readonly terhadap DB | PASS | Seluruh handler regions.ts SELECT-only, semuanya regions.get(...), nol POST/PATCH. Idem sabh-region-lookup.ts |
| Sisa 9 item (field alamat/rt/rw/kode_pos, 4 dropdown, cascading, tersimpan & muncul di Step 4) | N/A | Komponennya ada dan lengkap tapi tanpa pemanggil → N6 |
Bagian 4 — Review & Submit (12 PASS · 2 FAIL · 1 N/A)
| Item | Status | Sumber | Catatan |
|---|---|---|---|
| Smart Review menampilkan field satu per satu | N/A — desain berbeda | K | Tidak ada mode satu-per-satu: FieldListSection me-map seluruh section.fieldRefs sekaligus (FieldListSection.tsx:49-53), dan nol jejak wizard/stepper di seluruh review-engine (grep currentFieldIndex/nextField/stepIndex → 0 hasil). Yang ada: dikelompokkan per seksi, semua field terlihat, konfirmasi per field. Kalau review terpandu satu-per-satu memang diinginkan, itu permintaan fitur, bukan bug — dan berpasangan dengan M9 (urutan by confidence) |
| Urutan: confidence terendah lebih dulu | FAIL | K | → M9 |
| Bounding box menyorot lokasi field | PASS (sebagian) | M | "masih ada yg error" |
| Edit nilai field tersimpan | PASS | M | |
| Field locking UX konsisten dengan Akta | PASS | K | Keduanya memakai semantik yang sama persis — status === "LOCKED": panel lama extraction-fields-panel.tsx:42, review baru FieldRow.tsx:43. Konsekuensinya juga sama: field terkunci tidak bisa diedit (:48) dan tidak menuntut konfirmasi (:47). Beda afordans: panel lama menampilkan penghitung eksplisit "N/M terkunci" (:61), review baru hanya memberi opasitas lebih redup (:70) — mekanismenya konsisten, penandanya kurang tegas |
| Sidebar checklist sinkron | PASS | M | |
| Sidebar identity cards sinkron | PASS | M | |
| Sidebar validation warnings sinkron | PASS | M | Read-only — tanpa kontrol override → B1 |
| Approve per section berfungsi | PASS | M | Catatan kode: SectionApprovalBar tidak dirender halaman PT Pendirian — hanya PtPerubahanReviewPageV2.tsx:220, PtAkuisisiReviewPageV2.tsx:233, PtPerbaikanReviewPageV2.tsx:201. Di Pendirian yang berlaku konfirmasi per-field |
| Submit nonaktif kalau section belum di-approve | PASS | M | Gate sebenarnya: semua field terkonfirmasi + nol FAIL belum di-override (review-data.ts:99-113) |
| Submit berhasil, data tersimpan | PASS | M | |
| Cek data karakter-per-karakter (0/O, 1/l, 5/S) | PASS | M | Ada salah baca OCR, tapi seluruhnya ter-highlight sebelum validasi dan bisa diedit. Nilai hasil edit ikut tersimpan dan tampil setelah submit — jalur koreksinya utuh dari ujung ke ujung |
| Tidak ada field tertukar posisinya | PASS | M | |
| Format tanggal & angka konsisten | FAIL | M+K | Tanggal akta YYYY-MM-DD, tanggal identitas DD-MM-YYYY → M13. Angka: helper formatRupiahDisplay / formatThousandsDisplay ada di projector-utils |
| Submit dua kali tidak membuat dua submission | PASS | K | Dua lapis: tombol dikunci saat pending (ReviewProgressHeader.tsx:228), dan server update status → COMPLETED, bukan create (review-data.ts:115) — panggilan kedua idempoten |
Bagian 5 — Lintas-flow & ketahanan (12 PASS · 5 FAIL · 3 MANUAL)
Jotai memang dipakai (package.json:22), tapi hanya untuk board Step 1/2. Step 3 & 4 sepenuhnya server-driven lewat TanStack Query, nol atom.
| Item | Status | Sumber | Catatan |
|---|---|---|---|
| Tombol back Step 3 → Step 2 benar | PASS | M+K | PendirianExtractionPage.tsx:234 → ?resume= (flow-nav.ts:99-101) |
| — state Jotai utuh setelah back | PASS | M | Mekanismenya bukan "state bertahan" tapi "board di-restage dari server" (KlasifikasiPage.tsx:184-256) — hasilnya sama saja bagi user |
| Tombol back Step 4 → Step 3 benar | PASS | M+K | PtPendirianReviewPageV2.tsx:310. Saat COMPLETED, backTo jadi null — tombol back hilang |
| — state utuh setelah back | PASS | M+K | Step 3 tidak menyimpan state klien yang bisa hilang |
| Refresh di Step 3 | PASS | K | use-submission.ts:64-70 |
| Refresh di Step 4 | PASS | K | PtPendirianReviewPageV2.tsx:111 → review-data.ts:24-40 |
| Refresh di Step 1/2 | FAIL | M+K | → M3. Catatan Anda: "reload step 1 ilang, harusnya engga" |
| Buka submission sama di dua tab | PASS | M | Kode tetap mencatat risikonya → M8: nol sinkronisasi antar-tab dan refetchOnWindowFocus: false. Di Step 3 polling 2,5 dtk menutupinya; Step 4 tidak polling sama sekali, jadi uji ulang khusus di Tinjauan |
| ID tidak valid → error handling jelas | FAIL | K | → B2. Anda menandai PASS — lihat K2 di bawah |
| — pembanding: Step 4 dengan ID tidak valid | PASS | K | PtPendirianReviewPageV2.tsx:146-154 |
| Akses langsung Step 4 tanpa lewat Step 3 | FAIL | M+K | Terkonfirmasi: Tinjauan terbuka walau ekstraksi belum selesai & validasi masih GAGAL → B6 |
| Putus koneksi di tengah polling → recovery | MANUAL | — | Polling terus jalan tiap tik; tapi tanpa cabang isError, UI menampilkan data lama tanpa indikator apa pun |
| Halaman Pendirian tidak dibungkus ErrorBoundary | FAIL | K | Bagian dari B2 |
| Negative test: scan jelek | MANUAL | — | |
| File belum pernah masuk cache | PASS — cara memaksanya diketahui | K | Kunci cache = ocr:{tipe}:{model}:{sha256 isi file} (task_cache.py:59-76), Redis db 2, TTL 7 hari (OCR_CACHE_TTL). Tiga cara menjamin jalur dingin: (1) pakai berkas yang isinya belum pernah diproses — ganti nama tidak cukup; (2) set OCR_CACHE_ENABLED=false di GPU server saat demo; (3) tunggu TTL habis. Model/prompt yang berubah otomatis meng-invalidasi cache |
| Simulasi flow verifikator — backend | PASS | K | bun test src/routes/__tests__/verifikator-*.test.ts → 28 pass / 0 fail, stabil di 3 kali jalan berturut-turut. Mencakup decide, list, get, assignment, dan unified inbox |
| Simulasi flow verifikator — UI | MANUAL | — | Butuh akun ber-peran verifikator (routes.tsx:243-256). Yang wajib diperhatikan: mengembalikan berkas menghapus semua override validasi (verifikator.ts:316-319) — pastikan notaris diberi tahu saat itu terjadi |
| "Dokumen lain bukan persyaratan" dipakai | PASS | K | classifier-labels.ts:37; backend klasifikasi-doc-types.ts:29, :41, :50 |
| "NPWP Pengurus Perseroan" dipakai | PASS | K | Konsisten di lima permukaan: classifier-labels.ts:24, document-checklist.tsx:48, extraction-progress.tsx:42, AnalyticsPage.tsx:42, review-pdf-viewer.tsx:38 |
| "Input yang benar" menggantikan "Nilai" | FAIL | K | → N1 |
Kesehatan test suite
Dijalankan penuh sebagai bagian audit ini.
| Suite | Hasil |
|---|---|
Backend (bun test) |
3031 pass · 1 skip · 2 fail — 355 file, 8.441 assertion, 113 dtk |
Frontend (vitest run) |
1092 pass · 0 fail — 149 file, 86 dtk |
Dua kegagalan backend keduanya environmental, bukan cacat produk — dan keduanya sudah ada sebelum audit ini (angka yang sama tercatat di commit dd783691):
| Test | Sebab |
|---|---|
config gateway defaults > gpuServerDirectUrl falls back to gpuServerUrl |
Test mengharapkan default http://192.168.83.20:8200, tapi membaca env nyata yang berisi https://x056.ahu-azure.val.id/api/gpu. Test-nya tidak mengisolasi environment |
processPendirianPpViaCustomModel — BO persistence |
Memanggil layanan PaddleOCR sungguhan dan gagal 400 Image decode failed. Unit test yang bergantung pada servis eksternal |
Keduanya layak diperbaiki sebagai kebersihan test (isolasi env + mock servis), tapi tidak memblokir apa pun.
⚠️ Jangan menjalankan dua suite backend bersamaan. Saat suite penuh berjalan, run verifikator paralel menghasilkan 24 pass / 4 fail; dijalankan sendirian, 28 pass / 0 fail tiga kali berturut-turut. Test berbagi satu database, jadi eksekusi paralel saling menimpa — kegagalan seperti itu palsu.
Cek silang — tes manual vs audit kode
Tiga item bertanda [x] di checklist Anda berbenturan dengan kode.
K1 · "Checklist tidak menampilkan Surat Domisili & Surat Pernyataan"
Yang Anda lihat benar, tapi alasannya bukan penghapusan — DocumentChecklist membuang semua docType non-identitas sebelum render (document-checklist.tsx:22-54). Baris checklistnya tetap ada di DB dengan required: true. Setelah revert, ini berbalik jadi cacat → B3.
K2 · "Akses /pendirian/:id/extraction dengan ID tidak valid — error handling jelas"
Audit kode menghasilkan FAIL (B2). Jalurnya sudah dirinci di atas — tidak ada cabang isError yang bisa dieksekusi.
Perlu konfirmasi: kalau yang terlihat spinner tak berhenti, itu justru B2. Kalau Anda melihat pesan error nyata, ada jalur yang belum saya temukan dan perlu saya telusuri lagi.
K3 · "Approve per section berfungsi" — ditutup
Dicatat PASS dari tes manual. Untuk arsip: SectionApprovalBar tidak dirender halaman PT Pendirian (hanya Perubahan/Akuisisi/Perbaikan), dan gate submit-nya adalah konfirmasi per-field + nol FAIL belum di-override. Beda penamaan, bukan beda fungsi.
Konfirmasi — tes manual cocok dengan kode
| Item checklist | Catatan Anda | Temuan audit |
|---|---|---|
| B5 refresh tiap halaman | "reload step 1 ilang, harusnya engga" | Persis M3 |
| B1 refresh di tengah upload | "hilang bersih" | Sumber sama dengan M3 |
| B3 KTP tidak cocok | "tidak ditandai" | Persis M1, dengan akar yang presisi |
| B5 akses langsung Step 4 | terkonfirmasi terbuka | B6 — celah nyata, bukan desain |
Sisa item yang hanya bisa ditutup dengan tes manual (4)
Tinggal empat, dan semuanya butuh bahan atau mata manusia — bukan sesuatu yang bisa dibuktikan dari kode:
| Item | Kenapa tidak bisa ditutup dari sini |
|---|---|
NPWP_LAMA vs NPWP baru |
Tidak ada dataset uji. Pemisahannya sendiri sudah terbukti ada di kode |
| Negative test: scan berkualitas jelek | Butuh dokumen scan buruk yang nyata |
| Putus koneksi di tengah polling | Butuh browser + gangguan jaringan sungguhan. Perilaku kodenya sudah dipetakan (polling terus jalan; tanpa cabang isError, data lama tampil tanpa indikator) |
| Simulasi flow verifikator — sisi UI | Butuh akun ber-peran verifikator. Sisi backend-nya sudah tertutup: 28 test lolos |
Yang paling disarankan didahulukan: simulasi flow verifikator di UI — satu-satunya tahap yang belum pernah disentuh, dan punya perilaku mengejutkan (mengembalikan berkas menghapus semua override validasi).
Catatan lingkungan
Audit ini dikerjakan terhadap dev_5 (dd783691). Kalau tes manual Anda dijalankan di worktree lain, sebagian hasil bisa berbeda — dev_6 misalnya tertinggal tiga commit: fix highlight preview (4b42996b), fix Surat Pernyataan Setor yang mengganjal finalize (e4e2de0d), dan fix debug toggle finalize (dd783691).
Pasangan dev_5 yang benar: frontend :47031 → backend :47015 (vite --strictPort, VITE_API_PROXY_TARGET=http://localhost:47015 — terverifikasi).