think
16px
820px

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

flowchart TD Start(["Code survey complete
(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 documentmultiple 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_idkel_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::actionGetKoperasi and actionGetKoperasiV2controllers/ServiceController.php:69, :201. JWT via auth-jwt: Bearer. Returns only is_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.php exposes actionInsert (:540), actionUpdate (:353), actionEdit (:747), plus actionDummy and an unauthenticated actionSandbox (: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 an AllowValidation() 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 on id_notaris. Our notary matching targets SABH PT transactions. [VERIFY] whether id_notaris shares an identifier space — if yes, matching is nearly free; if no, it is net-new.
  • Schema: docs/dbml/schema.20250808.dbml exists but covers only 3 tables. The rest needs an information_schema dump (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.

  1. Gold set. Manually key ground truth for 20–30 real bundles. This is the only honest accuracy measure and it is not optional.
  2. Silver set — cheap and large. Every corpus file joins through kop_t_pendirian_dokumen.transaction_id to kop_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.
  3. Run the existing pipeline unchanged (classifier + akta extractor + KTP/NPWP) over both sets. Output is one number per field, not a narrative.
  4. 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.
  5. 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)

flowchart TD subgraph read["READ"] R1["R1: read-only MySQL
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 type as 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_schema is the arbiter.
  • D6 + D7 + D8 → key on id / id_master. Never on nomor_transaksi, nik, or names.
  • Name sanitisationHelpers::sanitizeHtmlInput strips & / < > " ' from stored values (components/Helpers.php:328-349, applied at controllers/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)

  1. Read-only MySQL grant on AHU_KOPERASI and AHU_BADAN_HUKUM.
  2. information_schema dump for all kop_* tables — the committed DBML covers 3, and the model class is unreliable (D5).
  3. Document corpus — ≥50 PDFs per type, across years and provinces, copied out, with matching kop_t_pendirian_dokumen rows.
  4. File-survival counts: rows versus files present on disk, by year (see D2).
  5. Row counts per jenis_transaksi_id per month.
  6. SELECT on allowed / group / auth_assignment, kop_setting, and kop_t_statement_disclaimer.
  7. A named product owner who can answer the front-door question in A3.
  8. Staging instance + service account, if API integration survives A3.
  9. Confirmation of who owns the NPAK registry and whether id_notaris shares 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.