think
16px
820px

Peleburan PT (Konsolidasi) Flow — Design Spec

Date: 2026-07-02
Status: Draft design, pending spec review
Author: Efran Nathanael (with Claude)
SubmissionType: PELEBURAN_PT — SABH id_aksi_transaksi = 35
Related: docs/research/sabh-pt-untouched-flows-2026-07-01.md (roadmap, Peleburan = #4, transformability 5), docs/specs/2026-07-01-target-architecture-design.md (ACCEPTED target architecture — FlowConfig/Rule/FLOW_REGISTRY are canonical), docs/superpowers/specs/2026-07-01-akta-transaction-type-classifier-design.md (classifier substrate), docs/superpowers/specs/2026-07-01-akuisisi-pt-flow-design.md (sibling flow, thin-fork era).


1. Overview & Goal

Peleburan PT (Konsolidasi, SABH id_aksi = 35) is a new-entity transaction: two or more registered PTs consolidate into a brand-new PT; the source PTs' legal personhood ends by operation of law without liquidation (UUPT 40/2007 Pasal 1 angka 10 jo. Pasal 122). In SABH, Step 4 is structurally a Pendirian-grade "pengisian" form for the new PT (the akta-upload partial is commented out at _formLeburPengisian.php:649, so today every field is hand-typed under a self-declaration), plus references to the 2+ source PTs, per-source RUPS approvals, and the Pasal 127(2) newspaper announcement.

Goal: ship a working PELEBURAN_PT OCR flow — upload → classify → extract → validate → review → verifikator → finalize — as the second engine: 'v2-generic' flow on the FLOW_REGISTRY slice (program ship order: LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT; no new processor fork), reusing the pendirian akta extractor for the new-PT payload and adding exactly three genuinely-new pieces: the multi-source PT lookup, the focused source-reference/koran passes, and the peleburan rule pack.

Two most important facts:
1. Unlike Akuisisi (single-subject), Peleburan is genuinely multi-entity: N ≥ 2 source PTs must each be looked up in SABH, snapshot, and blokir-checked. company-lookup.ts is single-company today — the multi-source design is §4.4, and it is the flow's only structurally new backend machinery.
2. The extracted payload is pendirian-shaped, not perubahan-shaped. There is no diff-against-registry for the new PT (it has no registry record); the perubahan diff/roster machinery is NOT used. The registry work is entirely on the source-PT side.

2. Scope

In scope (phase 1)
- New PELEBURAN_PT SubmissionType, implemented as a FlowConfig entry (engine: 'v2-generic') on the backend FLOW_REGISTRY slice + wrapped rules + sanctioned hooks. Existing flows stay engine: 'v1-fork' untouched.
- Classifier Phase-B wiring: peleburanBUILT_AKTA_TXN_TYPES (title rule ^AKTA\s+PELEBURAN already emits AKTA_PELEBURAN end-to-end; only the gate opens).
- Hybrid extraction: AKTA_PELEBURAN → akta_subtype 'pendirian' for the new-PT payload + two focused grounded passes (source-PT references; koran).
- Multi-source lookup: extract N source-PT references, loop the existing lookupCompany/loadOldData/loadPerseroanBlokirState per source, per-source selection UI, persisted per-source rows.
- Tuned validation matrix (§4.6): pendirian-grade identity/modal/KBLI rules wrapped bit-for-bit + 6 net-new peleburan rules, with honest severities and citations.
- First consumer of the SHARED BUKTI_PENGUMUMAN DocumentType (koran clipping; the enum value lands with the registry slice's six-type batch): the ONE shared board keyword pre-suggester (union keyword set) + the ONE shared clipping extractor bukti-pengumuman-extract.ts (§4.3.1) — reused verbatim by Pembubaran and Berakhirnya.
- Frontend: thin mirrors of the pendirian pages + an N-row source-selection panel mirroring CompanySelectionModal. NO ReviewEngine / FrontendReviewDescriptor (sequenced later; do not front-run the ReviewData reshape).

Out of scope (deferred — §9)
- The SABH submit-back (createLeburTransaksi, TransaksiPerseroan.php ~L6674) — outbox seam only, PoC-wide deferral.
- Creditor-objection (KeberatanKreditor) lifecycle; Rancangan Peleburan document validation; pesan-nama (name reservation) integration.
- Draft-SK / persetujuan-Menteri stage; vouchers; syarat-utama quorum checklists (attestation-lite only).
- The frontend ReviewEngine descriptor for this flow (greenfield slice, migrated later per target-arch §8 step 7).

3. Current context & reuse map (real paths)

The branch (feat/pt-flows-registry) ships PENDIRIAN_PT, PERUBAHAN_PT, PERBAIKAN_DATA_PT, the PP family, APOSTILLE, and AKUISISI_PT (the last thin-fork, 823 LOC akuisisi-processor.ts). The target architecture is accepted; the FLOW_REGISTRY slice (backend/src/flow-engine/) does not exist in the working tree yet — building it is a sibling task; this flow is its second v2-generic tenant, after LAPORAN_RUPS_TAHUNAN (dependency, §5 item 0).

Reuse-as-is (no change):
| Component | Path | Role for Peleburan |
|---|---|---|
| Pendirian akta extractor + schema | backend/src/schema/akta-notaris.ts (modal_dasar/ditempatkan/disetor, pemegang_saham incl. is_asing/passport/KITAS, direksi/komisaris, KBLI, penghadap/founder) + GPU akta_subtype 'pendirian' | The entire Step-4 new-PT payload |
| Subtype override mechanism | backend/src/ocr/classifier.ts::AKTA_SUBTYPE_MAP + document-processor.ts::resolveAktaSubtype (akuisisi precedent: AKTA_AKUISISI → 'perubahan') | AKTA_PELEBURAN → 'pendirian' |
| Single-company lookup primitives | backend/src/services/company-lookup.ts (lookupCompany, loadOldData, loadPerseroanBlokirState — mirrors SabhHelpers::checkBlokirPerseroan) | Called N times, once per source PT |
| Cross-validator bodies | backend/src/services/cross-validator.ts (validateNikKtpAkta, validateNamaNpwpKtp, validateKtpCompleteness, validateNpwpCompleteness, validateDomisiliNama, validateNamaPerseroan, validatePemegangSahamTotal, validateSharesTotalLembar, modal trio, validatePp29Modal, validateKbli, validateContactInfoComplete, validatePemegangSahamContact, validatePassportAkta) | Wrapped bit-for-bit as Rules (target-arch decision #5) |
| Focused grounded LLM pattern | backend/src/services/akta-koran-extract.ts (extractKoranViaFocusedLlm — verbatim-grounding against rawText) | Koran pass reused; source-refs pass modeled on it |
| Txn classifier | backend/src/services/akta-txn-classifier.ts (title rule AKTA PELEBURAN → peleburan already live; gated by BUILT_AKTA_TXN_TYPES) | Route trigger |
| Card extractors + identity matching | KTP/NPWP two-pass, identity-matcher.ts, parseKtpHeader, PASSPORT/KITAS WNA path | New-PT pengurus/PS/BO identity |
| Region + KBLI matching | pp-wilayah-resolve-family / kbli-sync.ts | Domisili + maksud-tujuan |
| Verifikasi state machine | VerifikasiPerubahan-family tables + verifikator.ts/vote.ts routes (already perubahan-family-aware since commit 84e00e7) | Same MENUNGGU_VERIFIKATOR → VOTING → SELESAI shape |
| Section approval persistence | PerubahanSectionApproval (generic submissionId + sectionKey shape) | Explicit approvals for the two peleburan-specific sections |
| Frontend kit | PendirianExtractionPage.tsx, PendirianReviewPage.tsx, CompanySelectionModal, flow-steps/destination-for | Thin mirrors |

Genuinely NEW (honest list):
1. The FLOW_REGISTRY residency itself (second v2-generic tenant — the slice and LAPORAN_RUPS_TAHUNAN precede it).
2. Multi-source lookup: PeleburanSourcePt persistence + resolve loop + N-row selection UI (§4.4).
3. Focused pass extractPeleburanSourcesViaLlm (source-PT references + per-source RUPS dates) (§4.3).
4. First build of the SHARED koran components: bukti-pengumuman-extract.ts clipping extractor + board keyword pre-suggester + the parameterized extractKoranFields refactor (§4.3/§4.3.1) — consumed later by Pembubaran and Berakhirnya.
5. Rule pack flow-engine/rules/peleburan/ — 6 rules (§4.6).
6. PeleburanReviewPage sections sumber_peleburan + rups_pengumuman (§4.7).

4. Design

4.1 Data flow

upload akta peleburan (+ BA RUPS per source PT, KTP/NPWP/Passport-KITAS of new-PT
       pengurus/PS/BO, Surat Pernyataan Domisili, Data Kontak, bukti pengumuman koran)
   klasifikasi: GPU coarse AKTA_*  akta-txn-classifier title rule  AKTA_PELEBURAN
   inferSubmissionType (canonical ladder: Perbaikan > Berakhirnya(doc-set predicate) > Peleburan >
       Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT)  PELEBURAN_PT
   dispatch: FLOW_REGISTRY[PELEBURAN_PT] (engine 'v2-generic')  runFlow(id, cfg)
   EXTRACT (hybrid §4.3): pendirian extractor for the new-PT payload
        + hooks.afterAktaExtraction: focused passes (source refs, koran)
   MULTI-SOURCE LOOKUP (§4.4): per source  lookupCompany + loadOldData + blokir
         per-source auto-select or AWAITING_COMPANY_SELECTION
   runValidations(cfg): wrapped pendirian rules + peleburan rule pack (async partitioned)
   review: pendirian-grade confirm/LOCK + explicit section approvals for
        sumber_peleburan + rups_pengumuman
   verifikator (MENUNGGU_VERIFIKATOR  VOTING  SELESAI)  finalize (allow-list admits PELEBURAN_PT)

4.2 FlowConfig (the registry entry — actual values)

// backend/src/flow-engine/flows/peleburan-pt.ts
export const peleburanPtFlow = defineFlow<PeleburanCtx>({
  type: "PELEBURAN_PT",
  engine: "v2-generic",                    // second v2 tenant (ship order: RUPS-Tahunan → Peleburan → Pembubaran → Berakhirnya)
  primaryAkta: {                           // canonical shape: {classifiedType, missingError, extractError} ONLY
    classifiedType: "AKTA_PELEBURAN",
    missingError: "Akta Peleburan tidak ditemukan pada submission",
    extractError: "Ekstraksi Akta Peleburan gagal",
  },
  skipExtractionTypes: ["OTHER"],
  companyLookup: { mode: "multi-source" }, // widened canonical shape (registry slice) — the N-source loop, §4.4
  applyContactInfo: true,
  extraction: {                            // canonical ExtractionPlan (registry slice §4.2)
    overrideByClassifiedType: { AKTA_PELEBURAN: "pendirian" },  // hybrid: pendirian-grade payload (§4.3)
    focusedPasses: { BUKTI_PENGUMUMAN: "bukti-pengumuman" },    // SHARED clipping extractor (§4.3 pass B)
    // cards + supporting docs ride the standard extraction pipeline — no per-type inventory needed
  },
  buildContext: buildPeleburanContext,     // pendirian ValidationInput + sources[] + koran + rupsDates (§4.5)
  rules: [...PT_PENDIRIAN_RULE_SET, ...PELEBURAN_RULES],     // §4.6 matrix
  requiredDocs: ["AKTA", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK", "BERITA_ACARA_RUPS"],  // DocumentType[] — DOMISILI is the existing enum the SURAT_PERNYATAAN_DOMISILI label maps to
  sections: PELEBURAN_SECTIONS,            // §4.7
  rematch: {                               // canonical RematchConfig; queueing is engine-level (queueReMatchAndValidate)
    checklistResetDocTypes: ["KTP", "NPWP", "DOMISILI"],
    standardPtMatchers: true,
    preValidationSteps: [(id) => resolvePeleburanSources(id), (id) => matchRupsAttendance(id, db)],
  },
  hooks: {
    afterAktaExtraction: runPeleburanFocusedPasses,          // deed passes: source refs + inline koran (§4.3)
    // onFinalize: enqueueOutbox(..., { targetSystem: 'SABH', operation: 'createLeburTransaksi' })  — deferred
  },
  routeFamily: "peleburan",                // RouteFamily union extended ONCE in flow-engine/types.ts (registry slice)
  labels: { akta: "Akta Peleburan", flow: "Peleburan PT (Konsolidasi)" },
});

No new processor fork, no forked cross-validator runner, no new route CRUD beyond the routeFamily endpoints the slice provides. dispatchProcessor and reMatchAndValidate both resolve this one config (during the transition, submission-dispatch.ts::processorKindForType's never-exhaustiveness switch needs the one-line PELEBURAN_PT → 'v2-generic' case — see §5).

4.3 Extraction (hybrid, mirroring the akuisisi precedent)

The deed extracts as akta_subtype 'pendirian'. Declared as extraction.overrideByClassifiedType: { AKTA_PELEBURAN: "pendirian" } on the FlowConfig — the registry slice's canonical mechanism; the engine passes it to processDocument (the generalized AKTA_LABEL_TO_SUBTYPE map in document-processor.ts replaces the two hardcoded AKTA_AKUISISI checks at L416/L472). Rationale: an akta peleburan's operative content IS the new PT's anggaran dasar — nama/jenis/jangka waktu, kedudukan+domisili, modal dasar/ditempatkan/disetor, full pemegang_saham (incl. badan-hukum shareholders — see the honesty note below), direksi/komisaris, maksud-dan-tujuan/KBLI, penghadap — exactly the akta-notaris.ts pendirian schema. The classificationOverride path in document-processor.ts (lines ~390–394, built for AKTA_AKUISISI → 'perubahan') carries it with zero new mechanism.

Honesty note — badan-hukum shareholders: the new PT's pemegang saham will typically include the source PTs' shareholders and possibly badan-hukum entries. PemegangSaham.jenis in akta-notaris.ts already carries the badan-hukum/perorangan distinction, and is_asing covers Badan Hukum Asing, but the schema does not carry per-badan-hukum nomor-akta/SK sub-fields the way akta-perubahan.ts does. Phase 1 accepts the pendirian schema as-is (name + jenis + shares); badan-hukum akta/SK enrichment is deferred (§9). Verify on a real akta peleburan during implementation (open question #2).

Focused pass A — source-PT references (NEW: backend/src/services/akta-peleburan-sources-extract.ts). A grounded guided-JSON call returning { sources: [{ nama_perseroan, nomor_sk, tanggal_rups }] } from the deed's premisse/komparisi (source PTs are recited as the meleburkan-diri parties with their SK numbers and RUPS approvals). Pattern: akta-koran-extract.ts verbatim-grounding (each returned string must substring-match normalized rawText, else null). Model: the 35B extraction endpoint (AKTA_TXN_CLASSIFIER_URL-style config, Qwen3.6-35B-A3B-FP8 @8001), NOT the 3B — a list-of-objects extraction is exactly where the 3B is brittle (memory reference_apostille_extraction_llm), and this is one call per submission.

Focused pass B — koran (SHARED — this flow builds both pieces, first consumer in the ship order).
1. Deed-INLINE fallback: the ONE parameterized extractKoranFields(rawText, { context }) refactor of extractKoranViaFocusedLlm (the current prompt hardcodes "akta pengambilalihan") — one refactor serving akuisisi (existing inline call site untouched on the v1 fork), peleburan, and pembubaran. Runs in hooks.afterAktaExtraction.
2. Clipping pass: any uploaded BUKTI_PENGUMUMAN doc runs the ONE shared clipping extractor bukti-pengumuman-extract.ts (via extraction.focusedPasses) with the canonical field keys nama_koran / tanggal_pengumuman / nomor_pengumuman — all nullable; nomor_pengumuman stays null here (only Berakhirnya consumes it). The clipping is the better source when present.
Persist as ExtractedField rows (akuisisi's saveKoranAkuisisiFields pattern) — canonical keys nama_koran, tanggal_pengumuman on the source document (clipping or akta).

rawText gotcha (known, inherited): the GPU-server akta path stores rawText: "" (OCR internal — see document-processor.ts:414-418, where the akuisisi koran pass degrades to nulls). The focused passes MUST read the same OCR text source the akta-txn-classifier uses (Document.ocrCache / the PaddleOCR layout text), not Document.rawText. If neither exists, degrade gracefully: fields NOT_FOUND → manual entry in review, never a processing failure.

4.3.1 New DocumentType classification strategy (binding decision 5)

Peleburan introduces one classifier-unknown document class: BUKTI_PENGUMUMAN (the Pasal 127(2) newspaper clipping; the enum value itself lands with the registry slice's six-DocumentType batch — this flow is its first consumer). The x056 GPU classifier's 22 labels have nothing like it, and an untrained LayoutLMv3 will not abstain — it will confidently mis-map the clipping to a nearest class above the 0.6 gate (the confident-wrong trap from the classifier spec §4).

Decision: manual board assignment, assisted by the ONE SHARED deterministic keyword pre-suggester — NOT in-app LLM classification, NOT a GPU retrain. (Shared component: built once here, reused verbatim by Pembubaran and Berakhirnya; Berakhirnya bolts its Ps.149/152 fine-label axis suggester on top.)
- Not GPU retrain: zero training samples (rarest SABH action), and per the classifier spec the GPU model is frozen w.r.t. new labels.
- Not the apostille-LLM pattern: PaddleOCR line-splits dense newsprint (2026-06-29 benchmark) — grounded-LLM classification over shredded text is unreliable, and a keyword rule on the same text is strictly cheaper. An LLM call buys nothing here.
- What ships: BUKTI_PENGUMUMAN is in PELEBURAN_DOC_TYPES so the operator can assign it on the Klasifikasi board (the existing re-map mechanism). The shared deterministic pre-suggestion runs on OCR text with the UNION keyword set: /(PENGUMUMAN)/i + (PELEBURAN|KONSOLIDASI|PEMBUBARAN|LIKUIDASI|RUPS|RINGKASAN RANCANGAN) + a masthead/date shape → pre-select "Bukti Pengumuman Koran" in the amber needs-review tier. Never auto-accepted — operator confirms (consistent with the manual-confirm posture, memory reference_apostille_manual_confirm).

Docs this flow does NOT introduce (they belong to sibling flows): laporan-keuangan/akuntan-publik + lampiran (RUPS Tahunan), surat-likuidator (Pembubaran), laporan-likuidasi + surat-permohonan (Berakhirnya). AKTA_PELEBURAN itself is a classification carrier label onlymapToDocumentType keeps mapping all AKTA_* → Prisma DocumentType.AKTA (classifier spec §6.1); no new DocumentType for the deed.

4.4 Multi-source PT lookup (the structurally new piece)

company-lookup.ts is single-company (lookupCompany(LookupRequest) → LookupResponse, plus loadOldData and loadPerseroanBlokirState(idPerseroan)). Peleburan needs it N ≥ 2 times with per-source state. Design:

  1. Persist per-source rows — new Prisma model PeleburanSourcePt (§6), one row per extracted source reference, @@unique([submissionId, ordinal]). Each row carries the extracted reference (namaPerseroan, nomorSk, tanggalRups), the lookup result (companyLookupResult Json), the selection (selectedNomorTransaksi, selectedTahun), the snapshot (oldData Json), and blokirState Json.
  2. Resolve loop (inside buildPeleburanContext / the flow's lookup phase): for each source row → lookupCompany({ nama_perseroan, nomor_sk_menkumham }). Exactly one high-confidence match → auto-select, then loadOldData + loadPerseroanBlokirState. Ambiguous/empty → row status AWAITING_SELECTION.
  3. Submission gating: while ANY source row is unresolved, the submission sits in the existing AWAITING_COMPANY_SELECTION status (reused enum value, no new status). The frontend panel (§4.7) resolves rows one by one; each selection triggers queueReMatchAndValidate (serialized).
  4. Manual add/remove: the operator can add a source row the extractor missed (deed OCR is imperfect) or delete a hallucination-guarded-but-wrong row; both re-run the resolve loop for that row only. Grounding (§4.3 pass A) makes fabricated sources unlikely; manual add covers misses.
  5. SABH-unavailable degradation: isSabhAvailable() === false → rows resolve as MANUAL with typed-in identity (matching single-company behavior); source-side rules emit SKIPPED, never FAIL (matrix note).

No new SABH query primitives — the loop composes the three existing functions. The only new code is the loop, the row persistence, and the per-row route handlers on the routeFamily.

4.5 Context (buildPeleburanContext)

PeleburanCtx = the existing pendirian-grade ValidationInput (akta fields + KTP/NPWP/Passport-KITAS maps + domisili + data-kontak, built the same way submission-processor.ts builds it for PENDIRIAN_PT) plus:

{ sources: PeleburanSourceState[];         // per-source: ref + lookup + oldData + blokir + resolution
  koran: { tanggalKoran: string | null; namaKoran: string | null };
  rupsBaDocs: { documentId: string; namaPerseroan: string | null; tanggalRups: string | null }[]; }

Built ONCE per run (target-arch: buildContext(id)), consumed by every rule. No principal data needed (flow is notaris-side; security policy is a standard RoutePolicy when the security pillar lands).

4.6 Tuned validation matrix (binding decision 4 — the priority)

Required documents (PELEBURAN_DOC_TYPES in backend/src/routes/klasifikasi.ts, served at ?type=peleburan — follows the akuisisi precedent of requiring the identity/address/kontak cross-check inputs so cross-validation always has inputs, commit aa4551d):

key required maxCount purpose
AKTA_PELEBURAN yes 1 primary deed (pendirian-grade payload)
KTP yes new-PT pengurus / pemegang saham / BO identity (cross-validation)
NPWP yes new-PT pengurus/PS NPWP (cross-validation)
SURAT_PERNYATAAN_DOMISILI yes 1 new-PT kedudukan/alamat (cross-validation)
DATA_KONTAK yes telepon/email/NPWP of pengurus & PS (cross-validation)
BERITA_ACARA_RUPS yes RUPS approval per source PT (≥1 at upload; per-source coverage enforced by rule, not the upload gate)
BUKTI_PENGUMUMAN no koran clipping (koran may instead be recited in-deed)
PASSPORT / KITAS no WNA pengurus/PS
OTHER no non-persyaratan

No bukti-setor documents: SABH's peleburan wizard has no bukti-setor upload (modal comes from the deed under self-declaration), so MODAL_BUKTI_SETOR/PEMEGANG_SAHAM_BUKTI_SETOR are excluded from the config rather than left to rot as permanent SKIPPEDs (open question #1 confirms with the user).

Rulesrules: [...PT_PENDIRIAN_RULE_SET, ...PELEBURAN_RULES]. Wrapped rules call the existing cross-validator.ts bodies bit-for-bit (target-arch decision #5); their PASS/WARNING/FAIL behavior is whatever the body already emits (listed below as today's severity). New rules live in backend/src/flow-engine/rules/peleburan/.

# Rule code New? Inputs Severity Rationale / citation
1 NIK_KTP_AKTA wrapped KTP × akta pengurus/PS/BO as-is (FAIL on mismatch) identity integrity of the new PT's roster
2 NAMA_NPWP_KTP wrapped NPWP × KTP as-is
3 KTP_COMPLETENESS wrapped KTP as-is (WARNING)
4 NPWP_COMPLETENESS wrapped NPWP as-is (WARNING)
5 DOMISILI_NAMA wrapped Surat Pernyataan Domisili × akta as-is new-PT address cross-check
6 NAMA_PT_CONSISTENCY wrapped all docs as-is new-PT name consistent everywhere
7 PS_TOTAL_100 wrapped akta PS roster as-is
8 SHARES_TOTAL_LEMBAR wrapped akta modal/PS as-is shares/modal balance
9 modal trio (validateModalHierarchy / validateSharesSumConsistency / validateNominalReconcile) wrapped akta modal tables as-is dasar ≥ ditempatkan ≥ disetor; lembar×nominal reconcile
10 PP29_MODAL wrapped, async akta modal as-is PP 29/2016 minimum-modal regime
11 KBLI_VALID wrapped, async akta maksud-tujuan as-is KBLI registry match
12 CONTACT_INFO_COMPLETE + PEMEGANG_SAHAM_CONTACT wrapped DATA_KONTAK as-is
13 PASSPORT_AKTA wrapped Passport/KITAS × akta as-is WNA roster
14 PELEBURAN_MIN_SOURCES NEW ctx.sources FAIL when < 2 resolvable sources UUPT Pasal 1 angka 10 jo. Pasal 122: peleburan IS "dua Perseroan atau lebih meleburkan diri dengan cara mendirikan satu Perseroan baru". Fewer than 2 sources is not a peleburan — hard defect. SKIPPED only when extraction produced zero refs AND operator hasn't confirmed the set yet
15 PELEBURAN_SOURCE_RESOLVED NEW per source row FAIL per unresolved source every source must be matched to a SABH record (or explicitly MANUAL). Mirrors SABH's hard identify-the-PT gate; resolved by the selection panel
16 PELEBURAN_SOURCE_BLOKIR NEW, async loadPerseroanBlokirState per source FAIL when any source is blocked; SKIPPED when SABH unavailable mirrors SabhHelpers::checkBlokirPerseroan — SABH refuses transactions on a blocked PT; filing anyway is wasted work
17 PELEBURAN_SOURCE_STATUS NEW, async oldData.status_perseroan/status_transaksi per source FAIL when a source is already berakhir/bubar; WARNING on other anomalies (stale/odd legacy status codes) a dissolved PT cannot meleburkan diri; but SABH legacy status data is noisy — only the unambiguous terminal states FAIL, the rest defer to the verifier
18 PELEBURAN_RUPS_PER_SOURCE NEW ctx.rupsBaDocs × ctx.sources (fuzzy name attribution) WARNING, never FAIL UUPT Pasal 89 requires RUPS approval per participating PT — but our BA-RUPS↔source attribution is heuristic name-matching over OCR text, and quorum validity is verifier judgment. A never-FAIL advisory per the severityFloor clamp (severityFloor: 'WARNING'), same posture as project_pp_nik_age_advisory
19 PELEBURAN_KORAN_WINDOW NEW ctx.koran.tanggalKoran × earliest tanggal_rups FAIL when both dates parse and tanggalRups − tanggalKoran < 30 hari; WARNING when koran data absent/unparseable; SKIPPED when no RUPS date either UUPT Pasal 127 ayat (2): announcement ≥30 days before the pemanggilan RUPS. The Pasal 127 ayat (8) exemption does NOT apply to peleburan — it is expressly limited to "Pengambilalihan saham yang dilakukan langsung dari pemegang saham" (which is why AKUISISI_KORAN_WINDOW is WARNING-only but this one may FAIL). Checking against the RUPS date is a conservative proxy in the safe direction: since pemanggilan must itself precede the RUPS by ≥14 days (Pasal 82(1)), koran <30d before the RUPS implies koran <16d before pemanggilan — an unambiguous 127(2) violation. Dates are operator-editable fields, so a wrong OCR date is correctable before it FAILs anyone permanently (contrast the locked-OCR rationale that kept PP rules advisory)
20 PELEBURAN_NAMA_BARU_UNIK NEW akta nama_perseroan × sources[].namaPerseroan WARNING, never FAIL the new PT needs a fresh reserved name; equality with a source name is suspicious but name reservation (Pesan Nama, Soundex/banned-word engine) is explicitly out of OCR scope — we cannot adjudicate name validity, only flag the obvious collision

Excluded on purpose (tuning, not omission): MODAL_BUKTI_SETOR, PEMEGANG_SAHAM_BUKTI_SETOR (no bukti setor in this SABH flow); the whole perubahan-family set — OLD_DATA_CONSISTENCY, DATA_ACTUALLY_CHANGED, SHARES_TRANSFER_BALANCE, PERUBAHAN_MODAL, PERUBAHAN_KBLI, jenis-selection rules (there is no old-vs-new diff for a brand-new entity); AKTA_DATE_WINDOW is a candidate pending semantics check — if its body is the 60-day filing window (UUPT Pasal 10(1) applies to the pendirian application), include it wrapped; verify during build.

Async partition matters: rules 10, 11, 16, 17 are async: true (SABH/KBLI round-trips) and must stay on the Promise.all side of the runner (target-arch HIGH-severity reconciliation — do not serialize them on every field edit).

4.7 Frontend (thin mirrors — binding decision 3)

No ReviewEngine, no descriptors. Three page-level artifacts, all mirroring existing pages:

  1. PeleburanExtractionPage.tsx — mirror of PendirianExtractionPage.tsx (same stepper family via flow-steps.ts).
  2. PeleburanSourcesPanel — the one genuinely new UI piece: an N-row generalization of CompanySelectionModal (frontend/src/components/...). Each row = one PeleburanSourcePt: extracted ref → match candidates → select / manual-enter; shows blokir + status badges from rules 16/17. Add-row / remove-row per §4.4.4. Blocks progression while any row unresolved (submission status AWAITING_COMPANY_SELECTION).
  3. PeleburanReviewPage.tsx — mirror of PendirianReviewPage.tsx (fields 65% left + PDF 35% right, confirm/LOCK, bbox grounding) with two added sections and the pendirian bukti_setor section removed. Sections (extends the SectionKey vocabulary in backend/src/services/field-section-mapping.ts — align exact keys with that module during build):
section key renderer today approvable
data_perseroan pendirian field list confirm/LOCK
akta_notaris (+ notaris_pengganti when present) pendirian field list confirm/LOCK
modal_saham pendirian modal + PS roster confirm/LOCK
pengurus / identitas pendirian rosters + cards confirm/LOCK
kbli pendirian KBLI list confirm/LOCK
sumber_peleburan NEW — sources panel summary (per-source snapshot + blokir/status) explicit PerubahanSectionApproval row
rups_pengumuman NEW — per-source RUPS dates + koran fields + window rule surface explicit PerubahanSectionApproval row

The two new sections get explicit persisted approvals (reusing the generic PerubahanSectionApproval table keyed submissionId + sectionKey) — never derived from child confirmed state (regression c0698d8, memory reference_perubahan_section_approval). The submit gate reads confirmations for pendirian-grade sections + the two explicit approvals.

Verifikator/voting pages: reuse VerifikatorReviewPage / PublicVotingPage (already family-aware); add PELEBURAN_PT to their type guards.

5. Integration / wiring checklist

  1. Dependency: the FLOW_REGISTRY slice (backend/src/flow-engine/: defineFlow, runFlow, reMatchAndValidateFlow, runValidations, persistValidationResults, routeFamily mounting) must exist — it is its own build task; PELEBURAN_PT is its first tenant. If the slice slips, do NOT fall back to a fork (binding decision 2); resequence instead.
  2. backend/prisma/schema.prismaenum SubmissionType += PELEBURAN_PT; enum DocumentType += BUKTI_PENGUMUMAN; new model PeleburanSourcePt (§6). Additive migrate only.
  3. backend/src/services/akta-txn-classifier.ts — add "peleburan" to BUILT_AKTA_TXN_TYPES (title rule already emits it; this opens the gate + drops it from detectUnsupportedAktaType).
  4. backend/src/routes/submissions.ts::inferSubmissionType — insert AKTA_PELEBURAN → PELEBURAN_PT at its rung of the canonical ladder Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT (implemented once in submissions.ts, documented in the registry spec); widen the return-type union (compile-checked). Peleburan sits above Akuisisi/Perubahan and the PENDIRIAN_PT default — a peleburan bundle can legitimately contain pendirian-looking content (classifier spec §6.2), so peleburan must win — while the Perbaikan and Berakhirnya(doc-set predicate) rungs stay above it.
  5. backend/src/ocr/classifier.tsAKTA_SUBTYPE_MAP["AKTA_PELEBURAN"] = "pendirian".
  6. backend/src/services/submission-dispatch.ts — the never-exhaustive processorKindForType gains PELEBURAN_PT → 'flow-engine'; dispatchProcessor routes v2 types to runFlow(id, FLOW_REGISTRY[type]) (compile fails until added).
  7. backend/src/services/submission-processor.ts::reMatchAndValidatePELEBURAN_PTreMatchAndValidateFlow(id, FLOW_REGISTRY.PELEBURAN_PT) (the validation-wipe gotcha, memory reference_rematch_dispatch; always via queueReMatchAndValidate).
  8. backend/src/routes/klasifikasi.tsPELEBURAN_DOC_TYPES (§4.6 table) + ?type=peleburan + LABEL_MAP entries (AKTA_PELEBURAN, BUKTI_PENGUMUMAN) + the keyword pre-suggestion (§4.3.1).
  9. backend/src/services/document-processor.ts — honour classificationOverride: "AKTA_PELEBURAN"; trigger hooks.afterAktaExtraction focused passes (via the flow engine, not an inline akuisisi-style if — the akuisisi inline koran call at line ~416 stays as-is on the v1 fork).
  10. Routes — routeFamily: 'peleburan' endpoints from the slice (review-data, source-row select/add/remove, section-approval, finalize). No hand-forked route file.
  11. Frontend — lib/classifier-labels.ts, components/klasifikasi/detect-banner.tsx (DetectedFlow + labels/descriptions), KlasifikasiPage.tsx (detectedFlow + create-route), routes.tsx, lib/destination-for.ts + flow-steps.ts, the three §4.7 artifacts, verifikator/voting type guards.
  12. Finalize allow-list — admit PELEBURAN_PT.
  13. Keep everything classifier-gated until ship: BUILT_AKTA_TXN_TYPES is the single gate (binding decision 6); pre-ship, AKTA_PELEBURAN surfaces as "Terdeteksi: Peleburan (belum didukung)".

6. Data model (additive Prisma changes only)

enum SubmissionType { ... PELEBURAN_PT }        // + doc comment: konsolidasi, id_aksi 35, new-entity
enum DocumentType   { ... BUKTI_PENGUMUMAN }    // Pasal 127(2) koran clipping

model PeleburanSourcePt {
  id                     String   @id @default(cuid())
  submissionId           String
  submission             Submission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
  ordinal                Int
  namaPerseroan          String                 // extracted (grounded) or manual
  nomorSk                String?
  tanggalRups            String?                // per-source RUPS approval date (word-form normalized)
  companyLookupResult    Json?                  // LookupResponse
  selectedNomorTransaksi String?
  selectedTahun          Int?
  oldData                Json?                  // OldData snapshot
  blokirState            Json?                  // PerseroanBlokirState
  resolutionStatus       String   @default("PENDING")  // PENDING | AUTO_SELECTED | AWAITING_SELECTION | MANUAL
  createdAt              DateTime @default(now())
  updatedAt              DateTime @updatedAt
  @@unique([submissionId, ordinal])
  @@index([submissionId])
}
  • Koran fields (tanggal_koran, nama_koran) live as ExtractedField rows (akuisisi precedent) on the akta / BUKTI_PENGUMUMAN document — no new Submission column.
  • Section approvals reuse PerubahanSectionApproval (generic shape) — no schema change.
  • Reused Submission columns: none of the perubahan quintet (companyLookupResult/oldData/… stay null — source data lives on PeleburanSourcePt); status enum value AWAITING_COMPANY_SELECTION reused.
  • No table removals, no enum removals, no reshapes (the satellite-table split is the persistence pillar's job, not this flow's).

7. Error handling & gotchas

  • reMatch registry note: PELEBURAN_PT MUST be dispatched in reMatchAndValidate (§5.6) or every field edit silently wipes its validations; always queueReMatchAndValidate (serialized), never fire-and-forget. Once the registry slice fully replaces the if-chain this failure class dies structurally; until then it is a checklist item.
  • Section approvals: sumber_peleburan + rups_pengumuman need EXPLICIT persisted approval rows; the submit gate reads them; never derive from child confirmed state.
  • Compile tripwires: processorKindForType's const _exhaustive: never and inferSubmissionType's return union will not compile until PELEBURAN_PT is handled — add all cases before trusting green.
  • rawText for focused passes: GPU-akta path stores empty rawText; ground against Document.ocrCache/Paddle layout text (same source as the txn classifier). Missing text → NOT_FOUND fields + manual entry, never a hard failure.
  • Ladder precedence: a peleburan bundle may contain a doc the GPU labels AKTA_PENDIRIAN; if AKTA_PELEBURAN doesn't outrank it (and AKTA_PERUBAHAN), the submission mis-routes into PENDIRIAN_PT. Test explicitly.
  • FAIL ≠ step-2 gate: rules 14–17/19 surface at review/verifier; do not wire them into any early upload gate (deferred-gate precedent, memory project_pp_nik_age_advisory). PELEBURAN_SOURCE_RESOLVED is enforced by the AWAITING_COMPANY_SELECTION status, not by blocking uploads.
  • Multi-source edit races: every per-row select/add/remove funnels through queueReMatchAndValidate; per-row SABH calls go through the existing sabh-db resilience (the mysql2 pool timeout/circuit-breaker lesson from reference_apostille_registry_hang applies — N sequential lookups amplify a wedged pool).
  • Do not touch v1 forks: perubahan/akuisisi processors stay byte-identical (engine: 'v1-fork'); the akuisisi inline koran call is not refactored in this build.

8. Testing strategy (TDD, ahu_ocr_test)

Write tests first (superpowers:test-driven-development); DB tests target ahu_ocr_test via the bunfig preload — never the dev DB (memory feedback_test_db_isolation).

  • Routing/gating: AKTA_PELEBURAN → PELEBURAN_PT; ladder Peleburan-beats-Pendirian and beats-Perubahan; peleburan ∈ BUILT_AKTA_TXN_TYPES flips "belum didukung" off; update the classifier gating tests that currently use peleburan as the unbuilt case (commit 2b1c2fa) to a still-unbuilt type (e.g. pembubaran).
  • Registry dispatch: dispatchProcessor(PELEBURAN_PT)runFlow with the registry config; reMatchAndValidate(PELEBURAN_PT)reMatchAndValidateFlow (not the default akta path that wipes validations).
  • Extraction: resolveAktaSubtype("AKTA_PELEBURAN") === "pendirian"; focused source-pass grounding (fabricated source name → null; verbatim name → kept); koran pass on clipping text; empty-rawText degradation.
  • Multi-source loop: 2 sources auto-selected; 1-of-3 ambiguous → AWAITING_SELECTION + submission AWAITING_COMPANY_SELECTION; manual add/remove re-resolves one row; SABH-down → MANUAL + SKIPPED source rules.
  • Rule pack: PELEBURAN_MIN_SOURCES FAIL at 1 source / PASS at 2; PELEBURAN_SOURCE_BLOKIR FAIL on blocked fixture; PELEBURAN_SOURCE_STATUS FAIL on berakhir, WARNING on odd code; PELEBURAN_KORAN_WINDOW FAIL at 29d, PASS at 30d, WARNING with missing koran, SKIPPED with no dates; PELEBURAN_RUPS_PER_SOURCE never exceeds WARNING (severityFloor clamp test); PELEBURAN_NAMA_BARU_UNIK WARNING on collision.
  • Wrapped-rule parity: for the shared bodies, assert the wrapped Rule.run(ctx) output deep-equals the direct validateX(input) call on identical fixtures ({ruleCode,status,message,details}) — the flow's contribution to the equivalence-oracle discipline.
  • Async partition: wall-clock assertion that rules 10/11/16/17 run concurrently (non-regression vs the fork runner's Promise.all).
  • Section approvals: submit gate blocked until both explicit approvals persist; approval survives re-validation.
  • Regression: PENDIRIAN_PT / PERUBAHAN_PT / AKUISISI_PT routing, extraction, and validations byte-identical (they stay v1-fork).

9. Deferred

Badan-hukum shareholder akta/SK sub-fields on the pendirian schema; Rancangan Peleburan + creditor-objection (KeberatanKreditor) lifecycle; pesan-nama integration; draft-SK/persetujuan-Menteri + voucher stages; SABH submit-back (createLeburTransaksi via the SubmissionOutbox seam + hooks.onFinalize); ReviewEngine descriptor for this flow (migrates with the frontend pillar); graduation of the source-refs pass into a dedicated GPU ExtractorSpec once the generic /extract/{name} endpoint exists.

10. Open questions

  1. Bukti setor: SABH's peleburan wizard uploads no proof-of-capital, so this spec excludes the bukti-setor docs and rules. Confirm we shouldn't add them anyway as an OCR-value-add (the new PT does have disetor capital).
  2. Real-deed verification: does an akta peleburan reliably recite (a) each source PT's SK number (needed for lookup — fallback is manual per-source entry) and (b) badan-hukum shareholder details beyond name+jenis? Verify on a sample before freezing the focused-pass schema.
  3. BA-RUPS ↔ source attribution: is fuzzy name-matching acceptable for PELEBURAN_RUPS_PER_SOURCE, or should the review UI let the operator assign each BA to a source row explicitly (cheap, removes the heuristic)?
  4. FAIL gate semantics: confirm what FAIL currently does at finalize for PT flows (surface-only vs hard block) so rules 14–17/19 land with the intended teeth.
  5. AKTA_DATE_WINDOW semantics: if the wrapped body is the 60-day filing window, include it; verify during build.
  6. Registry-slice sequencing: this spec assumes the FLOW_REGISTRY slice lands first (its own task). If the slice's ExtractionPlan/RematchConfig shapes settle differently, this config adapts — the field values here are the contract, not the field names.
  7. PELEBURAN_KORAN_WINDOW = FAIL severity (user-confirmable, C6). This rule FAILs when both dates parse and the RUPS−koran window is <30 days, on the legal reading that the UUPT Pasal 127 ayat (8) exemption is expressly limited to "Pengambilalihan saham yang dilakukan langsung dari pemegang saham" and therefore does not cover peleburan (which is exactly why AKUISISI_KORAN_WINDOW is WARNING-only but this one may FAIL). The severity was raised from the registry slice's first-cut WARNING to FAIL per this legal analysis; keep the Ps.127(8)-inapplicability citation in the rule message. Flag for user confirmation — if the program prefers a uniformly-advisory koran posture across the terminal flows, downgrade this one rule to WARNING (dates are operator-editable, so a wrong OCR date is correctable before it FAILs anyone permanently).

Cross-spec reconciliation (2026-07-02)

Applied from .superpowers/sdd/spec-critique-full.md (Peleburan is the FIRST BUILDER of the shared koran components):
- C3BUKTI_PENGUMUMAN board classification is the ONE SHARED deterministic keyword pre-suggester built here (UNION keyword set PENGUMUMAN + PELEBURAN|KONSOLIDASI|PEMBUBARAN|LIKUIDASI|RUPS|RINGKASAN RANCANGAN + masthead shape), never auto-accepted, no LLM (§4.3.1) — reused verbatim by Pembubaran and Berakhirnya.
- C4 — the ONE SHARED clipping extractor bukti-pengumuman-extract.ts (canonical keys nama_koran / tanggal_pengumuman / nomor_pengumuman, nullable) + the ONE parameterized extractKoranFields(rawText, {context}) deed-inline refactor are both built here (§4.3), consumed later by Pembubaran and Berakhirnya.
- C6PELEBURAN_KORAN_WINDOW FAIL (Ps.127(8) inapplicability) confirmed as the flow author's legally-cited position; the registry slice's first-cut WARNING row was corrected to match. Flagged user-confirmable in Open Questions (§10.7).
- C8 — no BUKTI_SETOR docs/rules (SABH's peleburan wizard has no bukti-setor upload); MODAL_BUKTI_SETOR/PEMEGANG_SAHAM_BUKTI_SETOR are excluded-from-config, not permanent SKIPPEDs (§4.6) — the registry slice's first-cut row was corrected to drop BUKTI_SETOR.
- C11 — §4.2 FlowConfig already conforms to the registry slice's canonical shapes: primaryAkta = {classifiedType, missingError, extractError} only, extraction.overrideByClassifiedType + focusedPasses, canonical RematchConfig (checklistResetDocTypes/standardPtMatchers/preValidationSteps). No change needed.
- C12routeFamily: 'peleburan' accepted; the RouteFamily union extension is made ONCE in flow-engine/types.ts by the registry slice.
- C13companyLookup: { mode: 'multi-source' } rides the widened companyLookup shape; the multi-source loop composes the additive default-preserving lookupCompany opts (§4.4).
- C15 — all ladder mentions now quote the canonical ladder (Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT); §5 item 3's stale ladder corrected.
- C16 — "second v2-generic flow" claim CONFIRMED by the program ship order (LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT); shared koran components ship with this flow as their first consumer.
- C17BUKTI_PENGUMUMAN arrives via the registry slice's single six-DocumentType additive batch; DOMISILI (existing) is the domisili carrier (the SURAT_PERNYATAAN_DOMISILI label maps to it) in the typed requiredDocs array.