AI Assessment Flow — Koperasi (AHU Koperasi / Yii2)
Status: proposal for review · Date: 2026-07-31
Subject codebase: AHU Koperasi (Yii2), master
Note on links: file paths are relative to that repo root.
Method: read-only code survey. Every claim is tagged [CERTAIN] (read directly in a code path that executes) or [VERIFY] (inferred, needs a probe).
Precedents copied: tasks/prd-ptp-ai-transformation.md, docs/specs/apostille-build-plan.md, docs/specs/apostille-extraction-catalog.md
0. Why this flow looks different from PTP and Apostille
Both earlier assessments burned a stage on a question this codebase answers for free.
- PTP: we designed an akta-extraction flow, then discovered PTP has no akta upload anywhere. The document layer was assumed.
- Apostille: the extraction catalog over-claimed code reuse and had to be corrected adversarially before build. Reuse was assumed.
For koperasi, the document layer is not an unknown. It is declared in code, in one class, with types, conditional requirements, size limits and storage paths. That collapses the most expensive stage of the standard assessment from "buy a DB grant and wait for a corpus" into "confirm on samples what the code already tells us."
The headline: koperasi requires a notarial deed (akta) as a mandatory PDF upload on both Pendirian and Perubahan [CERTAIN] — models/TPendirianDokumen.php:46-56. That single fact makes this domain cheaper for us than Apostille was: our akta extractor stack lands directly on the critical path instead of needing a new document family.
The counter-headline, and the reason B0 took longer than expected: the ActiveRecord model for the main table is not a usable schema reference. Its @property docblock and rules() describe a superseded schema generation and disagree with the columns the application actually writes. §2.3 and D5 deal with this. Any read client built from the model class would query columns that are at best unpopulated.
1. The flow in one picture
(this document)"]) --> B0 B0["B0 — Baseline from code — done
doc taxonomy · upload entry points
field census · roster columns · defects"] B0 --> A0 A0["A0 — Access & corpus
read-only DB grant · information_schema
volume counts · sample PDFs · file survival"] A0 --> G0{"G0
Real documents in hand,
schema confirmed,
volume known?"} G0 -->|no| Park["PARK — escalate.
No design spend."] G0 -->|yes| A1 A1["A1 — Traceability validation ⭐
run the B0 field↔document map
against real bundles · silver + gold sets"] A1 --> G1{"G1
Scalars recoverable?
Rosters segmentable?"} G1 -->|"either fails"| Rescope["RESCOPE to validation-assist
+ roster QA"] G1 -->|both pass| A2 A2["A2 — Flow ranking
volume × fields eliminated
× extractability ÷ build cost"] A2 --> A3["A3 — Integration decision
read path · write path
who owns the front door"] A3 --> A4["A4 — Reuse ledger
adversarially verified"] A4 --> A5["A5 — Risk register
D1–D9 → design constraints"] A5 --> D2G{"D2 — product decision
Defensible against the
PP → Apostille → PT queue?"} D2G -->|no| Defer["DEFER with a dated
re-open trigger"] D2G -->|yes| A6["A6 — Scope lock
PRD + phased build plan"] A6 --> Build(["Build — Phase 0 scaffold"]) style B0 fill:#d1e7dd,stroke:#0f5132,stroke-width:2px style A1 fill:#fff3cd,stroke:#d39e00,stroke-width:2px style G0 fill:#f8d7da,stroke:#b02a37 style G1 fill:#f8d7da,stroke:#b02a37 style D2G fill:#cfe2ff,stroke:#084298
Effort (one engineer; S ≤ 1 day · M 2–4 days · L 1–2 weeks): B0 done · A0 S, long lead time · A1 M–L · A2 S · A3 M · A4 M · A5 S · A6 M. Total ≈ 2.5–3 weeks of engineering, versus 3–4 for a blind assessment. A0 is mostly waiting on AHU — file it on day one and run A4 (pure in-repo work) while waiting.
Note on the final gate. It is drawn as a decision point, not a gate, deliberately. "Beats the marginal next item in the queue" is only a gate if the existing queue is scored on the same axes. Unless we commit to doing that, this is a product call with a scorecard attached — and calling it a gate would be theatre.
2. B0 — What the code already tells us ✅
2.1 Document taxonomy — complete, from one constant
TPendirianDokumen::TYPE_MAPPING [CERTAIN] — models/TPendirianDokumen.php:21-30:
type |
Constant | Flow | Required? |
|---|---|---|---|
| 1 | PENDIRIAN_AKTA |
Pendirian | always |
| 2 | PENDIRIAN_BERITA_ACARA |
Pendirian | always |
| 3 | PENDIRIAN_SURAT_REKOMENDASI |
Pendirian | conditional |
| 4 | PERUBAHAN_AKTA |
Perubahan | always |
| 5 | PERUBAHAN_BERITA_ACARA |
Perubahan | always |
| 6 | PERUBAHAN_SURAT_REKOMENDASI |
Perubahan | conditional |
| 7 | PENDIRIAN_HASIL_MUSYAWARAH_DESA |
Pendirian | conditional |
| 8 | PERUBAHAN_HASIL_MUSYAWARAH_DESA |
Perubahan | conditional |
Conditions are in code, not policy documents [CERTAIN]:
- surat_rekomendasi required when jenis_koperasi == 'jasa tkbm' or pola_pengelolaan == 'syariah' — models/TPendirianDokumen.php:67-69
- hasil_musyawarah_desa required when jenis_koperasi is 'desa merah putih' or 'kelurahan merah putih' — models/TPendirianDokumen.php:81-83
Constraints [CERTAIN]: PDF only, MIME-checked; 5 MB for akta / berita acara / rekomendasi, 10 MB for musyawarah desa; duplicate filenames within one submission rejected (fileUnique, :126-147). Storage: web/uploads/<transaction_id>/<basename>.<ext>; row in kop_t_pendirian_dokumen (transaction_id, type, document_name) — filename only, no path (:149-176).
This is our DocumentType enum, pre-written. No corpus needed to derive it. But see D1 — the stored type value cannot be trusted as a training label.
2.2 Every upload entry point in the app [CERTAIN]
| Flow | Fields | Where |
|---|---|---|
| Pendirian | akta, berita_acara, surat_rekomendasi, hasil_musyawarah_desa |
controllers/PendirianController.php:449-452 |
| Pendirian (sekunder) | file_sk_bakum_kop — SK of each founding cooperative |
controllers/PendirianController.php:416, :803 |
| Perubahan | same four | controllers/PerubahanController.php:781-784 |
| Pesan Nama | ksop only — no validation at all, see §2.5 |
controllers/PesanNamaController.php:74; form models/forms/PesanNamaForm.php:23 |
| Pembubaran | document — multiple files, free-form |
controllers/PembubaranController.php:64 |
| Perbaikan Data | file_surat_permohonan, file_surat_pernyataan, file_lain_lain (multi) |
models/forms/PerbaikanDataForm.php:37,43,44, :62 |
| Admin | upload_akta_ulang — akta re-upload, pendirian and perubahan |
models/forms/AdminEditForm.php:110; components/TransaksiHelper.php:1459; views edit_pendirian.php:690, edit_perubahan.php:780 |
Six flows, and every one has a document layer. Contrast PTP, where the answer was "none."
Two corrections worth carrying, both [CERTAIN]:
- There is no surat_kuasa upload. file_surat_kuasa is a column on kop_pesan_nama read by an admin download action (controllers/AdminController.php:1806-1818); nothing in the app uploads to it. Provenance [VERIFY] — possibly ODS or a retired path.
- kop_t_perbaikan.file_surat_keterangan_domisili is NOT NULL in the DDL (migrations/20250816_insert_kop_t_perbaikan.sql:8) but is hardcoded to an empty string on write (components/helper/PerbaikanDataHelper.php:35). It is a dead column. See D9.
2.3 Pendirian field census — the extraction target set
Source of truth: the assignment block inside savePendirian() — models/forms/PendirianForm.php:794-863 — cross-checked against the raw SQL at models/TransaksiKoperasi.php:591 and the API serialiser at components/ApiHelper.php:105-210.
Why not the docblock. savePendirian() ends in $Trans->save(false) (:864) — validation is skipped, so the model's rules() have never executed in this path and were free to drift. Yii2 derives ActiveRecord attributes from the live table schema and raises UnknownPropertyException on an unknown attribute, so every assignment below is proof the column exists. rules(), attributeLabels() and @property are proof of nothing.
Confidence discipline: the column is [CERTAIN]. The document source is [INFER] — that is the hypothesis A1 measures. A verified column name must not lend borrowed confidence to an unverified source claim.
| Form field | Live column | Written at | Source doc [INFER] |
Class |
|---|---|---|---|---|
no_akta |
akta_koperasi |
:832 | akta header | from-akta |
tgl_akta |
tanggal_akta |
:833 | akta header | from-akta |
| — (session) | nama_notaris |
:828 | akta header | from-akta + registry match |
| — (session) | ahu_notaris_id |
:827 | — | registry match |
id_notaris_pengganti |
id_notaris_pengganti |
:834 | akta header | from-akta |
no_sk_notaris_pengganti |
no_sk_notaris_pengganti |
:835 | akta header | from-akta |
nama_koperasi |
nama_koperasi |
:795 | akta + Pesan Nama | from-akta (cross-check) |
full_nama |
full_nama |
:796 | derived | computed |
tgl_rapat |
tanggal_rapat |
:842 | berita acara | from-berita-acara |
alamat_rapat |
alamat_rapat |
:843 | berita acara | from-berita-acara |
jumlah_peserta_rapat |
jumlah_peserta_rapat |
:844 | berita acara | from-berita-acara |
modal_koperasi |
modal_koperasi |
:847 | akta (modal) | from-akta |
simpanan_pokok |
simpanan_pokok |
:848 | akta (modal) | from-akta |
simpanan_wajib |
simpanan_wajib |
:849 | akta (modal) | from-akta |
dana_hibah |
dana_hibah |
:850 | akta (modal) | from-akta |
dana_cadangan |
dana_cadangan |
:851 | akta (modal) | from-akta |
periode_pengurus_awal |
periode_pengurus_awal |
:838 | akta (AD) | from-akta |
periode_pengurus_akhir |
periode_pengurus_akhir |
:839 | akta (AD) | from-akta |
jangka_waktu |
jangka_waktu |
:803 | akta (AD) | from-akta |
jumlah_jangka_waktu |
jumlah_jangka_waktu |
:806/:808 | akta (AD) | from-akta |
provinsi_id … kel_desa_id |
same | :811–817 | akta (domisili) | from-akta + master pick |
alamat_koperasi |
alamat_koperasi |
:819 | akta (domisili) | from-akta |
rt / rw |
rt / rw |
:820–821 | akta (domisili) | from-akta |
kode_pos |
kode_pos |
:822 | akta (domisili) | from-akta |
email |
email |
:823 | — | genuinely typed |
telepon |
telepon |
:824 | — | genuinely typed |
m_tingkat_wilayah_id |
m_tingkat_wilayah_id |
:801 | — | master pick |
b_koperasi_id |
b_koperasi_id |
:802 | — | master pick |
| — (derived) | jenis_koperasi |
:800 | — | derived string |
| — (derived) | jenis_transaksi_id / jenis_transaksi |
:797–798 | — | constant |
| — | t_pesan_nama_id |
:794 | — | FK |
Columns written on every pendirian that the docblock does not mention [CERTAIN]:
| Column | Written at | Why it matters to us |
|---|---|---|
nama_provinsi |
:812 | Denormalised wilayah names. Turns address cross-validation from a cross-schema join against WILAYAH_SABH into a same-row string comparison |
nama_kabupaten |
:814 | same |
nama_kecamatan |
:816 | same |
nama_kelurahan |
:818 | same |
email_notaris |
:829 | notary contact from session |
no_hp_notaris |
:830 | same |
kedudukan_notaris |
:831 | notary jurisdiction — free cross-check against the akta header |
tanggal_akhir |
:856 | preview expiry, +7 days |
nik |
:859 | see D8 |
is_finish |
:863 | -1 on create — confirms the draft state |
First-pass ratio: ~24 of ~30 substantive scalar fields, plus all three rosters, are document-derived. That is a strong prior for passing G1 — but it is a prior, measured against form structure, not against real akta text. A1 exists to measure it; G1 stops us if it does not hold.
2.4 Roster and child tables — highest-confidence column lists [CERTAIN]
These come from batchInsert() calls, where the column list is written verbatim into the SQL. Strongest evidence available anywhere in this codebase.
kop_data_anggota — officers and supervisors — models/forms/PendirianForm.php:940
t_koperasi_id, m_status_pengurus_id, nama_status, nik, nama,
jenis_kelamin_id, jabatan, npwp, hp
Column is hp, not no_hp (:953). nama_status is stored denormalised beside the FK.
kop_pemilik_manfaat — beneficial owners — models/forms/PendirianForm.php:966
t_koperasi_id, nama, m_jenis_identitas_id, no_identitas, tempat_lahir,
tanggal_lahir, alamat, kewarganegaraan, npwp, hubungan, json_checkbox,
provinsi_id, kabupaten_id, kecamatan_id, kelurahan_id, rt, rw,
negara_asal, is_from_api
Three traps: kewarganegaraan stores the text 'WNI'/'WNA' (:978, confirmed by components/ApiHelper.php:452); the column is negara_asal while the form field is negara_bo (:988); npwp is capped at 16 chars (models/PemilikManfaat.php:55) while a formatted NPWP is 20 — normalise to digits before comparing.
kop_t_aktifitas_usaha — KBLI — models/forms/PendirianForm.php:923
t_koperasi_id, kbli_id, jenis_kbli
jenis_kbli holds 'Utama'/'Pendukung'/'Tambahan', capitalised by ucwords() (:928) and read back with that casing (models/TransaksiKoperasi.php:577). Match case-insensitively.
kop_t_koperasi_sekunder — founding cooperatives — models/forms/PendirianForm.php:901
t_koperasi_id, id_master_sekunder, nik, nama_koperasi, no_sk_koperasi, npwp, file_sk
file_sk is written as an empty string — the copy is commented out (:906-907). The uploaded SK is not persisted by this path.
Where the document attestations actually live. The notary ticks a checkbox group declaring which supporting documents exist. It is not stored as is_minuta / is_surat_bukti / is_rencana_kerja columns — those have no writer anywhere in the application. The list is built by ActionHelper::getListCheckbox('dokumen_form_pendiran') (controllers/PendirianController.php:221), persisted through SaveChkBoxPendirian() → DisclaimerHelper::generateValues(..., 'dokumen_form_pendiran', ...) (models/forms/PendirianForm.php:999-1002), and lands in kop_t_statement_disclaimer (models/StatementDisclaimer.php:23).
Expansion path worth naming in the PRD, not in Phase 1 scope: those attested-but-unfiled documents — minuta, surat bukti setor, rencana kerja — are documents we already extract elsewhere (bukti setor in PP/PT). If AHU ever requires them to be uploaded, our stack is ready the day the policy changes. Inspect kop_t_statement_disclaimer at A0 to see which are actually attested today.
2.5 Validation gaps — the value story [CERTAIN]
| Model | rules() |
Consequence |
|---|---|---|
models/DataAnggota.php:9-33 |
none at all | Officer/member roster has zero server-side validation |
models/AktifitasUsaha.php:10-25 |
none at all | KBLI business activities unvalidated |
models/PemilikManfaat.php:47-57 |
present, no required |
Every BO field optional; nama capped at 50, alamat at 100, npwp at 16 |
models/forms/PesanNamaForm.php:79-82 |
file rule commented out | The ksop upload accepts any file type, any size — declared safe only |
models/TransaksiKoperasi.php:417-429 |
present but never executed | save(false) at PendirianForm.php:864 bypasses it entirely |
Three of the highest-risk tables in the system — the rosters — have no meaningful validation, and the one table that does have rules never runs them. We would be the first validation layer these rosters have ever had. That mirrors work already shipped for PP beneficial owners: cheap for us, highly visible to them.
2.6 Defects found in passing — carry into A5
| # | Finding | Evidence | Why it matters to us |
|---|---|---|---|
| D1 | type = 7 is double-booked. Pembubaran hardcodes type 7 for its dissolution document, colliding with PENDIRIAN_HASIL_MUSYAWARAH_DESA = 7 |
controllers/PembubaranController.php:123 vs models/TPendirianDokumen.php:28; read back as pembubaran at models/TransaksiKoperasi.php:667-669 |
Any classifier or fetch keyed on type mis-labels. Never trust type alone — classify the file. Disambiguate historical rows via the parent transaction's jenis_transaksi_id |
| D2 | Destructive re-upload, in two places. Pembubaran DELETEs rows and unlink()s files for type = 7; pendirian does the same for types 1, 2, 3 and 7 before any re-upload |
controllers/PembubaranController.php:69-85; controllers/PendirianController.php:497-513 |
Any document re-upload permanently destroys the previous files. Historical file survival is worse than the row count suggests. Our corpus must be copied, never referenced in place — and A0 must measure how much is already gone |
| D3 | Raw SQL string concatenation for document inserts and deletes; filename interpolated with only addslashes() |
models/TPendirianDokumen.php:162-176; controllers/PembubaranController.php:73-76, :123; controllers/PendirianController.php:501-504 |
Filenames are attacker-influenced. Report it; do not replicate the pattern |
| D4 | Caller sniffing via debug_backtrace() decides whether an upload is pendirian or perubahan |
models/TPendirianDokumen.php:183-200; same pattern at models/TransaksiKoperasi.php:648-669 |
Any refactor silently mis-types documents. Historical rows may already be mis-typed — reinforces D1 |
| D5 | The model class describes a superseded schema. taggal_rapat, jumlah_rapat, modal_usaha, simpanan_pokok_anggota, simpanan_wajib_anggota, modal_lain, usaha_utama/_pendukung/_tambahan, file_akta, file_berita_acara, jumlah_anggota, is_minuta, is_beria_acara, is_surat_bukti, is_rencana_kerja appear only in @property, rules() and attributeLabels() — no write path anywhere |
models/TransaksiKoperasi.php:43-59, :417-429; contradicted by models/forms/PendirianForm.php:794-863 |
The pattern is coherent: documents as filename columns, KBLI as free-text columns, attestations as integer flags — all since moved into child tables, model class left behind. Derive every column name from write paths and raw SQL, never from the model class. [VERIFY] whether the legacy columns still physically exist |
| D6 | nomor_transaksi duplication is an active bug class. Duplicates were renamed <nomor>_DUP_<id> and are still in the data; the unique constraint arrived only in Feb 2026 |
migrations/20260205_fix_duplicate_nomor_transaksi.sql:8-23; eight repair operation logs + rollback SQL in the repo root |
Never key on nomor_transaksi. Same conclusion the PT work reached |
| D7 | Free-text full_nama / nama_koperasi declared safe, then used as lookup keys |
models/TransaksiKoperasi.php:419; models/MasterTransaction.php:225-263 |
Matcher must normalise; surface candidates, never auto-fill on a name-only multi-hit |
| D8 | nik is // DUMMY at creation. A literal // DUMMY comment sits in the live write path; the value is later overwritten from ODS responses, and a bulk correction ran in production Feb 2026 |
models/forms/PendirianForm.php:858-859; backfill job at commands/TaskController.php:322-335 |
nik is unreliable at creation and in history. Key on id / id_master |
| D9 | Dead NOT NULL column. kop_t_perbaikan.file_surat_keterangan_domisili is NOT NULL in DDL but hardcoded to '' on write |
migrations/20250816_insert_kop_t_perbaikan.sql:8 vs components/helper/PerbaikanDataHelper.php:35 |
Do not model it as a real document slot; a domicile letter is not being collected despite the schema implying it |
Additional schema/model mismatch worth flagging in A5: kop_t_perbaikan.status_transaksi_perbaikan has seven enum values in DDL but the model validates only five (migrations/20250816_insert_kop_t_perbaikan.sql:11 vs models/TransaksiPerbaikan.php:63-69). Historical rows may hold values the current workflow never produces.
2.7 Integration surface — [CERTAIN] for existence, [VERIFY] for access
- Read:
ServiceController::actionGetKoperasiandactionGetKoperasiV2—controllers/ServiceController.php:69,:201. JWT viaauth-jwt: Bearer. Returns onlyis_active = 1 AND is_finish = 1(modules/Api/components/TransaksiHelper.php:415-416) — dissolved, merged, blocked and in-progress records, and the entire amendment history, are invisible through the API. Errors fire a Telegram message to an ops group (:97,:193) — do not build bulk polling on this. - Write:
modules/Api/controllers/TransaksiController.phpexposesactionInsert(:540),actionUpdate(:353),actionEdit(:747), plusactionDummyand an unauthenticatedactionSandbox(:945-947)[VERIFY]whether that route is reachable. - Access gate: every route, including
/api/*and/service/*, passes an application-wide filter resolved against a DB allow-list (config/web.php:197-199;vendor/docotel/yii2-dcms/components/Helper.php:412).[VERIFY]— this decides whether any API integration is possible at all. - Identity: Dukcapil NIK validation is already wired —
components/HelperDukcapil.php:36-76, with a feature flag (CheckIsActiveValidateDukcapil) and anAllowValidation()bypass. Do not duplicate the Dukcapil call — cross-check against it. - Notary:
NotarisNpak::checkNotarisNPAK()—models/NotarisNpak.php:24— koperasi keeps its own NPAK list keyed onid_notaris. Our notary matching targets SABH PT transactions.[VERIFY]whetherid_notarisshares an identifier space — if yes, matching is nearly free; if no, it is net-new. - Schema:
docs/dbml/schema.20250808.dbmlexists but covers only 3 tables. The rest needs aninformation_schemadump (A0).
3. Remaining stages
A0 — Access & corpus (S, long lead time — file day one)
| # | Ask | Why |
|---|---|---|
| A0.1 | Read-only MySQL grant on AHU_KOPERASI and AHU_BADAN_HUKUM |
The app joins across both in PHP, not SQL. One grant alone yields KBLI codes without titles and wilayah without ground truth |
| A0.2 | information_schema dump for all kop_* tables |
Gates §2.3. Settles D5 — whether the legacy columns still exist, and if so which holds pre-migration data |
| A0.3 | Document corpus: ≥50 PDFs per type, across years and provinces, copied out (see D2), with the matching kop_t_pendirian_dokumen rows |
A1 cannot run without it. The matching rows are what make the silver set in A1 possible |
| A0.4 | File-survival probe: count kop_t_pendirian_dokumen rows against files actually present on disk, bucketed by year |
D2 destroys files on re-upload. If pre-2023 survival is poor, A0.3's cross-year spread is unachievable — and we want to know before G0, not after |
| A0.5 | Row counts per jenis_transaksi_id per month |
Feeds A2 ranking |
| A0.6 | SELECT on allowed / group / auth_assignment, and on kop_setting |
Decides whether API integration is possible at all; kop_setting holds every integration URL and feature flag |
| A0.7 | SELECT on kop_t_statement_disclaimer |
Which supporting documents are actually attested today — sizes the expansion path in §2.4 |
| A0.8 | Staging instance + service account | A3 contract tests. Probing production is antisocial given the Telegram alerting |
Artifact: koperasi-a0-access-report.md.
Gate G0: DB read live and information_schema returned and corpus delivered with a date. Anything less → park, escalate, no design spend.
A1 — Traceability validation ⭐ (M–L — the stage that decides the product)
Not a discovery stage; a measurement stage. §2.3/§2.4 is the hypothesis, A1 is the experiment.
- Gold set. Manually key ground truth for 20–30 real bundles. This is the only honest accuracy measure and it is not optional.
- Silver set — cheap and large. Every corpus file joins through
kop_t_pendirian_dokumen.transaction_idtokop_t_koperasi+kop_data_anggota+kop_pemilik_manfaat— i.e. what the notary claimed the akta says. That is automated field-level recall at corpus scale for near-zero extra effort. It is silver, not gold: where extraction and database disagree, either may be wrong — and that ambiguity is itself a finding worth surfacing. - Run the existing pipeline unchanged (classifier + akta extractor + KTP/NPWP) over both sets. Output is one number per field, not a narrative.
- Characterise the corpus: native PDF vs scan vs photo-of-screen; multi-document-per-file; how an akta koperasi differs structurally from the akta PT our extractor was tuned on.
- Roster shapes: cardinality distribution of pengurus/pengawas and BOs per cooperative, and which document each row is transcribed from.
Artifact: koperasi-extraction-catalog.md, same shape as the Apostille catalog, [CERTAIN]/[INFER] tagged.
Gate G1 — two thresholds, because the two problems are not comparable:
| Threshold | Rationale | |
|---|---|---|
| G1a — scalar fields | ≥60% of §2.3's document-derived fields recovered on real samples | Single values anchored by predictable deed phrasing. Tractable |
| G1b — roster rows | Row segmentation reliable enough that a reviewer corrects rather than re-types; identity numbers human-confirmed regardless of confidence | One wrong row boundary shifts every subsequent field. A single misread digit yields a plausible but wrong NIK |
Both thresholds are starting points and should be calibrated in A1 against what the review UI can actually absorb. Failing either → rescope to validation-assist + roster QA, which remains genuinely valuable given §2.5, rather than sell an extraction product we cannot deliver.
A2 — Flow ranking (S)
Score = annual volume (A0.5) × manual fields eliminated (§2.3) × extractability (A1) ÷ build cost (A4), with reviewer-time-saved weighted double — it is the scarce resource.
| Flow | Why it might rank high | Why it might rank low |
|---|---|---|
| Pendirian | Most fields, mandatory akta + berita acara, all three rosters; the archetype we have built four times | Highest build cost |
| Perubahan | Same document pair, same extractors; checklist-driven → maps onto our change-type machinery; we already ship a delta-review UI | Needs reliable "current state" reads, complicated by clone-per-amendment |
| Perbaikan Data | Two-stage verifikator queue (controllers/BakumVerifikatorController.php:491, :1115) — the only flow with a captive back-office audience, and we already ship a verifikator persona |
Newest module (Aug 2025) → low volume [VERIFY]; enum mismatch in §2.6 |
| Pesan Nama | Elasticsearch fuzzy index already populated; name similarity is a solved shape for us | Small field surface — assist, not transformation |
| Pembubaran | Cheap; we have dissolution rule sets | Thin, free-form document layer; D1/D2 land here |
Note on the rest of the system. Pendirian and Perubahan are notary self-service — the notary calls approvetransaction and the SK is auto-generated with no ministry verifier in the loop (controllers/PendirianController.php:624-690). The only human verification workflow in koperasi is Perbaikan Data. That caps how much "verifier time saved" exists to win, and it should be said out loud in A2 rather than discovered in build.
A3 — Integration decision (M)
richest, full history, inherits D1-D9"] R2["R2: /service/get-koperasi-v2
clean, deduped, but live records only"] R3["R3: Elasticsearch kop_pesan_nama
name/dedup work only"] end subgraph write["WRITE"] W1["W1: STANDALONE — no write-back
Apostille precedent"] W2["W2: new REST endpoint
built by the koperasi team"] W3["W3: masquerade via Api/TransaksiController"] end read --> D{"Front door?"} write --> D D --> D1["AI app primary,
koperasi wizard fallback"] D --> D2["Koperasi primary,
AI app assist panel"] style W3 fill:#f8d7da,stroke:#b02a37
Going-in recommendation, to be confirmed not assumed: R1 + R2 for read (R1 for analysis and backfill, R2 as the supported runtime interface), and W1 standalone for Phase 1 — the Apostille decision that de-risked that project by removing an integration dependency from the critical path.
W3 stays red [CERTAIN]. Records written through Api/TransaksiController are flagged is_migrasi / is_ods, and for those records: transaction-number generation is skipped entirely (models/TransaksiKoperasi.php:381), the ODS clone path writes nomor_transaksi = '' (modules/Api/components/TransaksiHelper.php:171), notary identity is nulled (:173-174), is_finish is forced to 1 (:186), and notary validation is skipped downstream (models/MasterTransaction.php:296, :332). Writing through that door means our submissions inherit the exact defect class behind D6 — and leave the legally responsible notary unrecorded.
Probes: confirm A0.6's allow-list result end-to-end with a real service account; obtain a non-paging path for our traffic; get a written answer on who owns the front door. That last one is a product decision, not an engineering one, and it silently determines half the frontend scope — so ask it on day one (§6.7) even though it is decided here.
Artifact: koperasi-integration-decision.md — one page, options, chosen path, signed off by product.
A4 — Reuse ledger (M — run in parallel with A0)
| Need | Reuse? | Where |
|---|---|---|
| Flow definition, rules, validation runner, rematch | ♻️ high | defineFlow + FLOW_REGISTRY — a koperasi flow slots in as another tenant |
| KTP / NPWP / domisili / bukti-setor extraction | ♻️ high | backend/src/ocr/ — same Indonesian documents |
| Akta extraction | ♻️ medium–high, pending A1 | backend/src/schema/akta-notaris.ts; akta koperasi has different parties (pendiri / pengurus / pengawas, rapat pendirian, simpanan pokok & wajib) → new schema, same machinery |
| Review UI, PDF viewer, 3-state confidence, validation panel | ♻️ high | existing component library |
| Verifikator persona + queue | ♻️ high | maps onto the two-stage bakum verification |
| BO roster + shared-document handling | ♻️ high | PP BO stack, incl. the reference-set guard learned the hard way |
| KBLI catalogue | ♻️ high | koperasi reads the same AHU_BADAN_HUKUM.m_kbli |
| Dukcapil NIK | ♻️ medium | koperasi already calls it — cross-check, do not duplicate |
Koperasi read client (koperasi-db.ts) |
🆕 | build from §2.3/§2.4 column lists, never from the model class (D5) |
| NPAK notary matcher | 🆕 [VERIFY] |
separate registry from SABH — the exact trap the Apostille catalog fell into |
| Classifier classes for the 8 doc types | 🆕 | retrain on the A0.3 corpus; cannot key on type (D1) |
| Akta koperasi + rapat-pendirian schemas | 🆕 | modal koperasi, simpanan pokok/wajib, dana hibah, dana cadangan, periode pengurus, koperasi pembentuk |
SubmissionType.PENDIRIAN_KOPERASI (+ siblings) |
🆕 | schema + registry entries |
Exit: an adversarial pass whose only job is to refute each ♻️. Every row downgraded here is effort we would otherwise discover mid-build. The Apostille precedent says the first draft of this table over-claims — and this is a first draft.
A5 — Risk register (S)
D1–D9 from §2.6, each tagged design constraint / validator severity / ops ask, carried into the PRD. The five that change architecture:
- D1 + D4 → classify every file by content; treat stored
typeas a hint, never a label. - D2 → copy the corpus; never reference files in place; size the loss before promising cross-year coverage.
- D5 → column names come from write paths and raw SQL only;
information_schemais the arbiter. - D6 + D7 + D8 → key on
id/id_master. Never onnomor_transaksi,nik, or names. - Name sanitisation →
Helpers::sanitizeHtmlInputstrips& / < > " 'from stored values (components/Helpers.php:328-349, applied atcontrollers/PerubahanController.php:93,:114). Stored names differ from akta text; normalise both sides before comparing, or every name with an ampersand reads as a mismatch that is not one.
Two further items for the register: historical-discrepancy policy — decide, before running cross-validation at scale, what happens when it flags a past registration as not matching its akta; and upload ceilings — if our front door accepts scans larger than the existing 5 MB / 10 MB limits (models/TPendirianDokumen.php:53, :78), handover under W1 can fail at the far end.
A6 — Scope lock (M)
PRD (PTP shape) + phased build plan (Apostille shape). Non-negotiables:
- Phase 0 is scaffold-only — enum + registry entry + route + page shell + rematch wiring; exits when a submission can be created and the shell renders, with no extraction.
- First value phase is identity (KTP → identity block + cross-validation) — cheapest shippable win, the #1 typo field, and it lands on rosters that have no validation today (§2.5).
- All model/OCR egress through the gateway with standard headers and queue semantics (
ahu-gpu-manager/docs/CONVENTIONS.md); engine changes dormant until the env flip. - Phase 1 writes nothing back to
AHU_KOPERASI.
4. Gate summary
| Gate | Question | Pass | Fail action |
|---|---|---|---|
| G0 | Documents in hand, schema confirmed, volume known? | Grant live + information_schema returned + corpus delivered with a date |
Park; escalate. No design spend |
| G1a | Do the akta and berita acara carry the typed scalar fields? | ≥60% recall on real samples | Rescope to validation-assist + roster QA |
| G1b | Are rosters segmentable well enough to correct rather than re-type? | Reviewer corrects, not re-types; identity numbers always human-confirmed | Rescope as above |
| D2 (decision) | Defensible against the PP → Apostille → PT queue? | Ranked backlog beats the marginal next item — on the same scoring axes | Defer with a dated re-open trigger |
5. Where this sits in the queue — say it out loud
Standing priority is PP → Apostille/Legalisasi → PT. Koperasi is a fourth domain. What changed with this survey is the price of finding out: the document layer is known, the field census is verified against write paths, the reuse story is legible, and the remaining unknowns are volume, corpus quality, file survival and schema confirmation — all AHU asks with long lead times and near-zero engineering cost while waiting.
Recommended: file the A0 asks now, assess when they land, build when the queue clears.
Be honest about what "assess now" costs: the waiting is free, but A2, A4, A5 and A6 are roughly two to three engineer-weeks of real work. That is small, and it is not nothing.
Starting the build in parallel is not on the table — koperasi needs a net-new read client, a net-new classifier taxonomy and new akta schemas. It is not a configuration of an existing flow.
6. Asks for AHU (file these on day 1)
- Read-only MySQL grant on
AHU_KOPERASIandAHU_BADAN_HUKUM. information_schemadump for allkop_*tables — the committed DBML covers 3, and the model class is unreliable (D5).- Document corpus — ≥50 PDFs per
type, across years and provinces, copied out, with matchingkop_t_pendirian_dokumenrows. - File-survival counts: rows versus files present on disk, by year (see D2).
- Row counts per
jenis_transaksi_idper month. SELECTonallowed/group/auth_assignment,kop_setting, andkop_t_statement_disclaimer.- A named product owner who can answer the front-door question in A3.
- Staging instance + service account, if API integration survives A3.
- Confirmation of who owns the NPAK registry and whether
id_notarisshares an identifier space with SABH.
Bugs worth reporting back regardless of whether this project proceeds: D1 (type 7 collision), D2 (document deletion on re-upload, in two flows), D3 (raw SQL with interpolated filenames), D9 (dead NOT NULL column). Worth mentioning separately: the application ships with YII_DEBUG enabled (web/index.php:4-5), and the working tree carries live-looking credentials — treat any koperasi checkout or dump as credential-bearing.