Berakhirnya Status Badan Hukum PT Flow — Design Spec
Date: 2026-07-02
Status: Draft design, pending spec review
Author: Efran Nathanael (with Claude)
Intended path: docs/superpowers/specs/2026-07-02-berakhirnya-status-pt-flow-design.md
Related: docs/research/sabh-pt-untouched-flows-2026-07-01.md (roadmap rows 5+8 — same flow, build once), docs/specs/2026-07-01-target-architecture-design.md (FlowConfig/Rule/FLOW_REGISTRY are canonical), docs/superpowers/specs/2026-07-01-akta-transaction-type-classifier-design.md (taxonomy already carries berakhirnya), docs/superpowers/specs/2026-07-01-akuisisi-pt-flow-design.md (the flow-spec pattern + the shipped v1-fork precedent).
1. Overview & Goal
Berakhirnya Status Badan Hukum (SABH id_aksi_transaksi = 36, transaction code 70) is the terminal PT transaction: it ends a PT's legal personhood after dissolution (pembubaran) + liquidation have completed. SABH's code path is misleadingly named PerubahanStatus* but every view/label titles it "Berakhirnya Status Badan Hukum" — the discovery report confirms rows 5 and 8 are the same flow; build once.
In SABH it is a 3-step wizard where the notaris hand-keys ~19 metadata fields, every one of which is a header of one of 6 uploaded PDFs: (1) the akta notaris (final PKR), (2) the risalah/BA RUPS, (3) a surat permohonan, (4) the laporan akhir likuidasi, (5) bukti pengumuman koran Pasal 149, (6) bukti pengumuman koran Pasal 152. The SABH model's own comment says essentially every Step-3 field is a document header → near-ideal auto-fill: OCR the 6 PDFs, fill the 19 fields, cross-validate them against each other and against the registry, review, verify, finalize.
Goal: ship a FULL BERAKHIRNYA_STATUS_PT flow (upload → classify → extract → validate → review → verifikator → finalize) at parity with PENDIRIAN_PT/PERUBAHAN_PT/AKUISISI_PT — as the fourth (last) 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), with a tuned per-document cross-validation matrix and a hard registry-state gate (the PT must already be in the dissolved state; pembubaran must precede). By the time it lands the engine, the shared koran components (bukti-pengumuman-extract.ts, the keyword pre-suggester), and the doc-set predicate are all already built by the earlier tenants — this flow only adds its own data (the LAPORAN_LIKUIDASI/SURAT_PERMOHONAN DocumentTypes, the _149/_152 fine-label axis, and the dissolved-state lookup).
Two facts that shape everything:
1. There is no diff. Unlike perubahan/akuisisi, nothing about the PT's anggaran dasar changes — the transaction erases the entity. The review surface is per-document field sections + registry snapshot, not a delta view. The whole payload is document headers, which is exactly what the generic flow engine + focused grounded extraction handle best.
2. Three of the six documents are new to the system. The x056 GPU classifier's 22 labels have no surat-permohonan / laporan-likuidasi / bukti-pengumuman classes. §4.3 picks a classification strategy per new type.
2. Scope
In scope (phase 1)
- New BERAKHIRNYA_STATUS_PT SubmissionType (spelling justified in §4.1), shipped as a FlowConfig entry on FLOW_REGISTRY — no processor fork (binding program decision; existing flows stay engine: 'v1-fork').
- Classifier wiring: berakhirnya → BUILT_AKTA_TXN_TYPES; doc-set predicate disambiguation vs the (unbuilt) Pembubaran flow in inferSubmissionType.
- Three new supporting document classes (SURAT_PERMOHONAN, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN with a 149/152 label axis) + an in-app classification strategy per class (§4.3) + focused grounded extractors per class (§4.4).
- The dissolved-state SABH company-lookup variant (today's lookup filters status_transaksi = 1 and would never find the PT — §4.6).
- Tuned validation matrix (§4.5): required-doc FAILs, likuidator-name consistency, per-document date/number cross-checks, pengumuman ordering (advisory), registry-state FAIL.
- Frontend: thin mirrors of the akuisisi pages (extraction + review + status), no ReviewEngine / FrontendReviewDescriptor — that greenfield is sequenced later; do not front-run the ReviewData reshape.
- Verifikator/finalize reusing the shared verifikasi machinery (MENUNGGU_VERIFIKATOR → VOTING → SELESAI), finalize allow-list admits the new type.
Out of scope (deferred — §9)
- SABH submit-back (out of scope for the whole PoC).
- Modeling SABH's 2-level berakhirnya verifier pool as a distinct role tree (reuse the existing verifikator flow as-is).
- Berita Negara (TBN) publication checks beyond the two koran pengumuman.
- The pembubaran-stage likuidator registration fields (alamat/RT-RW/telepon/email/dasar_penunjukan) — those belong to the separate PEMBUBARAN_PT flow (task #17), not this terminal transaction.
- GPU-classifier retraining for the three new doc classes (flywheel-graduation only, per classifier spec §5.5).
- ReviewEngine descriptor for this flow (arrives with the frontend greenfield migration, this flow migrates onto it then).
3. Current Context (what we reuse) — real paths, branch feat/pt-flows-registry
The akuisisi build (merged on this branch) proved the perubahan-family machinery transfers to a sibling transaction; this flow reuses the same substrate minus the diff, plus the flow-engine registry.
Reuse-as-is (no change):
| Component | Path | Role here |
|---|---|---|
| Akta txn classifier | backend/src/services/akta-txn-classifier.ts | berakhirnya is already in AKTA_TXN_TYPES, AKTA_TXN_TYPE_TO_LABEL (berakhirnya → AKTA_BERAKHIRNYA), and KEYWORD_CONFIRM (/berakhirnya\s+status|hapusnya\s+badan\s+hukum/i). Only BUILT_AKTA_TXN_TYPES changes. |
| Akta header extraction | backend/src/services/document-processor.ts (resolveAktaSubtype ~L372, override path) + the 35B extractor | AKTA_BERAKHIRNYA → akta_subtype 'perubahan', the exact mechanism AKTA_AKUISISI uses (L392–416) — yields nomor_akta, tanggal_akta, nama_notaris, nama_perseroan, ref_pendirian_nomor_sk, penghadap |
| Notaris pengganti | backend/src/services/notaris-pengganti-enrichment.ts | notaris + pengganti fields |
| BA RUPS extraction | BeritaAcaraRupsExtraction (schema.prisma:593) + backend/src/services/rups-attendance-matcher.ts | nomor/tanggal RUPS + kehadiran (pre-tick attendance) |
| Focused grounded pass pattern | backend/src/services/akta-koran-extract.ts (guided-JSON, temp 0, verbatim-grounding against rawText) + apostille-generic-extract.ts (normalizeWordDateViaLlm, recoverDocNumberViaLlm) | the template for ALL three new extractors (§4.4) |
| In-app classifier pattern | backend/src/services/apostille-classifier.ts (keyword pre-screen + 3B guided-JSON + confidence tier + deterministic overrides) | template for berakhirnya-doc-classifier.ts (§4.3) |
| SABH lookup + snapshot | backend/src/services/company-lookup.ts, sabh-pt-snapshot.ts (lookupCompany, loadOldData, loadPerseroanBlokirState, lookupNotarisIdByName) | locate the dissolved PT + registry snapshot (needs the §4.6 variant) |
| Cross-validator bodies | backend/src/services/cross-validator.ts (RuleResult shape at :148; validateNikKtpAkta, validateNamaPtConsistency, validateAktaDateWindow, validateRupsAttendanceQuorum, validatePerseroanState as the severity template, NOTARIS_TERAKHIR :1543) | wrapped bit-for-bit into Rule objects (target-arch decision #5) |
| Identity matching | identity-matcher.ts, submission-processor.ts::matchKtpDocument/matchNpwpDocument/fulfillChecklist | likuidator KTP/NPWP matching |
| reMatch serialization | submission-processor.ts::queueReMatchAndValidate (:694) | field edits revalidate serialized |
| Verifikasi machinery | PerubahanSectionApproval (schema.prisma:960) + verifikator/voting routes + frontend/src/pages/{VerifikatorReviewPage,PublicVotingPage} | shared, keyed by submission — akuisisi already proved a second type rides it |
| Frontend mirrors | frontend/src/pages/{AkuisisiExtractionPage,AkuisisiDeltaReviewPage}.tsx, CompanySelectionModal, routes.tsx akuisisi block (:79–90), lib/{classifier-labels,destination-for,flow-steps}.ts, components/klasifikasi/detect-banner.tsx | the thin-mirror pattern |
Depends on (sequencing): the FLOW_REGISTRY slice (task #14) and the three earlier v2-generic tenants (LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT), which build the shared koran components and the doc-set predicate this flow consumes. This spec consumes the slice's canonical interfaces exactly as accepted in docs/specs/2026-07-01-target-architecture-design.md §2 (FlowConfig, Rule, RuleResult verbatim from cross-validator.ts:148, ProcessorHooks, SectionSchema) and does not redesign them. Berakhirnya is a clean v2-generic tenant: flat sections, no diff, no legacy fork to keep as oracle.
Genuinely NEW (honest list): the 3 document classes + their in-app classifier + 3 focused extractors + 1 akta hook (pembubaran-ref) + 5 flow rules + the dissolved-state lookup variant + 2 thin frontend pages. Everything else is registry config + wrapped rules.
4. Design
4.1 SubmissionType spelling: BERAKHIRNYA_STATUS_PT
Considered: BERAKHIRNYA_PT (terse but semantically wrong — the PT doesn't "berakhir", its status badan hukum does; also collides in reading with the deed label AKTA_BERAKHIRNYA), BERAKHIRNYA_STATUS_BADAN_HUKUM (SABH's full title, but drops the _PT family suffix every PT flow carries and is needlessly long), PERUBAHAN_STATUS_PT (SABH's internal code-path name — actively misleading; the discovery report calls it "mislabeled").
Chosen: BERAKHIRNYA_STATUS_PT — keeps SABH's user-facing noun ("Berakhirnya Status") so operators recognize it, keeps the <ACTION>_<ENTITY> convention of the enum (PEMBUBARAN_PP, AKUISISI_PT, PERBAIKAN_DATA_PT), and stays distinct from the future PEMBUBARAN_PT (a different SubmissionType for the earlier dissolution transaction, id_aksi 12). Exact Prisma spelling everywhere: BERAKHIRNYA_STATUS_PT.
4.2 Data flow + FlowConfig (registry-first — the actual entry)
upload 6-PDF package (+ KTP likuidator, NPWP perseroan)
→ GPU classify (coarse) → akta-txn-classifier refines deed → AKTA_BERAKHIRNYA
→ berakhirnya-doc-classifier refines supporting docs → SURAT_PERMOHONAN /
LAPORAN_LIKUIDASI / BUKTI_PENGUMUMAN_149 / BUKTI_PENGUMUMAN_152 (§4.3)
→ inferSubmissionType doc-set predicate → BERAKHIRNYA_STATUS_PT
→ dispatchProcessor → FLOW_REGISTRY → runFlow(id, berakhirnyaStatusPtFlow)
Phase 1: extract deed (akta_subtype 'perubahan' + pembubaran-ref hook)
Phase 2: dissolved-state company lookup (+ AWAITING_COMPANY_SELECTION pause)
Phase 3: extract remaining docs (BA RUPS extractor + 3 focused passes + cards)
Phase 4: identity match (likuidator KTP/NPWP) + RUPS attendance match
Phase 5: runValidations(cfg) → persistValidationResults → READY
→ per-section review (explicit persisted section approvals)
→ verifikator (MENUNGGU_VERIFIKATOR → VOTING → SELESAI) → finalize
// backend/src/flow-engine/flows/berakhirnya-status-pt.ts
export const berakhirnyaStatusPtFlow = defineFlow<BerakhirnyaCtx>({
type: "BERAKHIRNYA_STATUS_PT",
engine: "v2-generic", // FOURTH (last) v2 tenant per the program ship order — no v1 fork exists or will
primaryAkta: { // canonical shape: {classifiedType, missingError, extractError} ONLY
classifiedType: "AKTA_BERAKHIRNYA",
missingError: "Akta Berakhirnya Status Badan Hukum tidak ditemukan",
extractError: "Gagal memproses Akta Berakhirnya Status Badan Hukum",
},
skipExtractionTypes: ["OTHER"],
companyLookup: { allowedStatusTransaksi: [3] }, // widened canonical shape (registry slice) — dissolved-state lookup, §4.6
extraction: { // canonical ExtractionPlan (registry slice §4.2)
overrideByClassifiedType: { AKTA_BERAKHIRNYA: "perubahan" }, // header extraction rides the perubahan extractor (AKTA_AKUISISI precedent)
focusedPasses: { // the ONE per-docType focused-pass seam
SURAT_PERMOHONAN: "surat-permohonan", // NEW — §4.4
LAPORAN_LIKUIDASI: "laporan-likuidasi", // NEW — §4.4 (DISTINCT DocumentType, incl. sisa_kekayaan)
BUKTI_PENGUMUMAN: "bukti-pengumuman", // SHARED clipping extractor (built in Peleburan); _149/_152 fine labels both map to this carrier DocumentType
},
// BERITA_ACARA_RUPS + KTP + NPWP ride the standard extraction pipeline — no per-type inventory needed
},
buildContext: buildBerakhirnyaContext, // buildPtAktaContext + the 5 supporting extractions + SABH state (§4.5 inputs)
rules: [...PT_IDENTITY_RULE_SET, ...BERAKHIRNYA_RULE_SET], // §4.5 matrix
sections: BERAKHIRNYA_SECTIONS, // §4.7 — 8 SectionSchema entries
requiredDocs: ["AKTA", "BERITA_ACARA_RUPS", "SURAT_PERMOHONAN", "LAPORAN_LIKUIDASI", "BUKTI_PENGUMUMAN", "KTP", "NPWP"], // DocumentType[]; _149/_152 coverage enforced by REQUIRED_SUPPORTING_DOCS over the fine labels (§4.5)
rematch: { // canonical RematchConfig; queueing is engine-level (queueReMatchAndValidate)
checklistResetDocTypes: ["KTP", "NPWP"],
standardPtMatchers: true,
preValidationSteps: [(id) => matchRupsAttendance(id, db)], // re-derive the pembubaran ref runs via hooks.afterAktaExtraction on re-extract
},
hooks: { afterAktaExtraction: extractPembubaranRefHook }, // §4.4 — grounded pass, sanctioned hook, not a fork
routeFamily: "berakhirnya", // RouteFamily union extended ONCE in flow-engine/types.ts (registry slice); company-select / review-data / section-approval / finalize CRUD
// policy: typed-but-INERT in the target arch — omitted here; RoutePolicy enforcement lands with the security pillar
labels: { akta: "Akta Berakhirnya Status Badan Hukum", flow: "Berakhirnya Status Badan Hukum" },
});
BerakhirnyaCtx extends the shared PT-akta context with: suratPermohonan {nomor, tanggal}, laporanLikuidasi {namaLikuidator, nomorSurat, tanggalSurat, sisaKekayaan}, pengumuman149[] / pengumuman152[] ({namaKoran, tanggalPengumuman, nomorPengumuman[]}), pembubaranRef {nomorAkta, tanggalAkta}, tanggalRups, and the registry state (status_transaksi, blokir, pembubaran transaction row if queryable). Per target-arch, principal data never enters the context; buildContext builds it once per run.
4.3 Classification — new document classes + strategy per class (binding decision #5)
The x056 ahu-classifier-v2 (LayoutLMv3, 22 labels) has no class for surat permohonan, laporan likuidasi, or bukti pengumuman — and it will not abstain: it confidently maps unknown docs to the nearest known class above the 0.6 gate (the "confident-wrong" trap the classifier spec documented). We do not retrain it (cold-start scarcity on rare terminal docs; layout model, content distinction; flywheel-graduation later per classifier spec §5.5). Instead:
New module backend/src/services/berakhirnya-doc-classifier.ts — mirrors apostille-classifier.ts: deterministic keyword pre-screen over the cached OCR text (Document.ocrCache, no double-OCR) → cleanup-3B guided-JSON confirm → keyword-confirmation confidence tier. Invoked from the routes/klasifikasi.ts classify pass as a refinement step on non-card, non-akta GPU labels — the same slot refineAktaLabel already occupies (klasifikasi.ts:26). Result is advisory: the operator board override remains authoritative, exactly like the PT klasifikasi step. The LLM confirm applies to SURAT_PERMOHONAN and LAPORAN_LIKUIDASI only; BUKTI_PENGUMUMAN is deliberately excluded from the LLM path — it rides the ONE SHARED deterministic keyword pre-suggester (C3, no LLM classification over line-split newsprint) plus the berakhirnya-only _149/_152 keyword axis bolt-on.
Per-class strategy and why:
| New class | Strategy | Why |
|---|---|---|
SURAT_PERMOHONAN |
In-app grounded LLM (3B, apostille pattern). Pre-screen: /\bpermohonan\b/i + /(berakhir|hapus).{0,40}badan\s+hukum/i in the first page. Keyword-confirmed → auto-accept tier. |
Strong masthead/title + perihal-line signal; single-page letter; exactly the shape the apostille classifier already reads reliably. Manual-only would waste a clean signal. |
LAPORAN_LIKUIDASI |
In-app grounded LLM. Pre-screen: /laporan\s+(akhir\s+)?likuidasi|pertanggungjawaban\s+likuidator/i. |
Explicit title document; same rationale. |
BUKTI_PENGUMUMAN + 149/152 axis |
Manual board assignment + the ONE SHARED deterministic keyword pre-suggester — NO LLM (C3). The base BUKTI_PENGUMUMAN suggestion is the shared union-keyword pre-suggester built in Peleburan (PENGUMUMAN + PELEBURAN\|KONSOLIDASI\|PEMBUBARAN\|LIKUIDASI\|RUPS\|RINGKASAN RANCANGAN + masthead shape). On top of it, a berakhirnya-only keyword bolt-on axis suggester proposes the pasal (pembubaran/kreditor → _149; hasil likuidasi/pertanggungjawaban → _152). Both the base label and the axis proposal always land in the amber needs-review tier — never auto-accepted; the board assignment is authoritative. |
Koran clippings are the weakest OCR surface we have (PaddleOCR line-splits dense newsprint — the "No.AL" loss in the 2026-06-29 benchmark), so grounded-LLM classification over shredded text is unreliable — a keyword rule on the same text is strictly cheaper (C3: no LLM pass for this DocumentType). The 149/152 axis flips a legal-ordering validation (§4.5); a wrong auto-label silently corrupts BSBHP_PENGUMUMAN_ORDER, so the axis is a suggester, never auto-accepted. |
| Risalah RUPS | Reuse GPU label BERITA_ACARA_RUPS — trained, keep. |
Already one of the 22 labels at 0.98 val-acc. |
| The deed | akta-txn-classifier → AKTA_BERAKHIRNYA — already in the taxonomy; the LLM reads the keputusan (final RUPS accepting the likuidator's pertanggungjawaban), KEYWORD_CONFIRM.berakhirnya already exists. |
Built. Only the gate flips. |
Every operator confirm/correct persists as a labeled sample (classifier-spec flywheel) so these classes can graduate to a trained text head later. DocumentType mapping follows the BUKTI_TRANSFER-* → BUKTI_SETOR precedent: classification labels BUKTI_PENGUMUMAN_149/BUKTI_PENGUMUMAN_152 both map to one Prisma DocumentType BUKTI_PENGUMUMAN; the pasal axis lives on SubmissionDocument.classifiedType (a string — zero schema cost).
Doc-set predicate (disambiguation vs Pembubaran, classifier spec §6.2). Defined ONCE in inferSubmissionType (backend/src/routes/submissions.ts:84), at the canonical ladder's Berakhirnya rung (most-specific-wins). It lands with PEMBUBARAN_PT (its first consumer) and is quoted IDENTICALLY in the Pembubaran spec §4.2 (C5 reconciliation) — this flow only appends its own label names to the already-written clauses:
// eff(f) = f.userOverride ?? f.classification; carrier = mapToDocumentType(eff(f))
const BERAKHIRNYA_PACKAGE = (files: InferableFile[]) =>
files.some((f) => eff(f) === "AKTA_BERAKHIRNYA") ||
(files.some((f) => eff(f) === "AKTA_PEMBUBARAN") && (
files.some((f) => eff(f) === "LAPORAN_LIKUIDASI" || eff(f) === "SURAT_PERMOHONAN") || // labels arrive with the Berakhirnya flow
files.some((f) => eff(f) === "BUKTI_PENGUMUMAN_152") || // Pasal 152 fine label (Berakhirnya axis)
files.filter((f) => mapToDocumentType(eff(f)) === "BUKTI_PENGUMUMAN").length >= 2 // ≥2 docs mapping to the carrier DocumentType — count the carrier, NEVER the raw label string
));
if (BERAKHIRNYA_PACKAGE(files)) return "BERAKHIRNYA_STATUS_PT";
Rationale: the final PKR often recites the pembubaran, so the txn classifier may plausibly read it as pembubaran; the presence of the liquidation-final documents (laporan akhir, Ps.152 pengumuman — which exist only after liquidation completes) is the tell that this is the terminal filing. The ≥2 count matches the carrier DocumentType via mapToDocumentType, never the raw label string, so it keeps firing after this flow introduces the _149/_152 fine labels (C5). A bare AKTA_PEMBUBARAN without that set routes to PEMBUBARAN_PT once that flow ships, or stays gated as "Terdeteksi: Pembubaran (belum didukung)" until then — this flow flips only its OWN BUILT_AKTA_TXN_TYPES entry and makes no claim about Pembubaran's gating.
4.4 Extraction plan (~19 fields ← 6 PDFs)
All new extractors follow the focused grounded single-purpose pass pattern of akta-koran-extract.ts — cleanup-3B (CLEANUP_LLM_URL@8003), temperature: 0, guided_json, values grounded verbatim against rawText (hallucinations rejected), one narrow prompt per document class. Never broaden a shared generic prompt (the 3B brittleness lesson: fixing one field regresses others). Dates: store the raw grounded value (bbox matches raw), normalize via the deterministic pass + normalizeWordDateViaLlm fallback, render via formatDateDisplay — the apostille date-handling convention. Numbers lost to line-splits: recoverDocNumberViaLlm fallback. Persistence: ExtractedField rows (fieldKey/value/confidence/bbox) — no new tables; the array field is JSON-encoded in the value, the exact peralihan_saham precedent (akuisisi-processor.ts:573).
| Doc | Extractor | Field keys (exact) | New? |
|---|---|---|---|
| Akta (final PKR) | perubahan header extractor via resolveAktaSubtype("AKTA_BERAKHIRNYA") → 'perubahan' + notaris-pengganti enrichment |
nomor_akta, tanggal_akta, nama_notaris, notaris_pengganti, nama_perseroan, ref_pendirian_nomor_sk |
reuse |
| Akta — pembubaran ref | hooks.afterAktaExtraction → new backend/src/services/akta-pembubaran-ref-extract.ts (grounded pass over the deed rawText; the premisse recites the dissolution deed) |
nomor_akta_pembubaran, tanggal_akta_pembubaran (written as ExtractedField on the akta doc) |
NEW (≈ akta-koran-extract clone) |
| Risalah RUPS | BeritaAcaraRupsExtraction + rups-attendance-matcher |
existing columns: nomor/tanggal RUPS, kehadiran per PS | reuse |
| Surat Permohonan | new surat-permohonan-extract.ts |
nomor_surat_permohonan, tanggal_surat_permohonan |
NEW |
| Laporan Akhir Likuidasi | new laporan-likuidasi-extract.ts |
nama_likuidator, nomor_laporan_likuidasi, tanggal_laporan_likuidasi, sisa_kekayaan |
NEW |
| Bukti Pengumuman 149 / 152 | SHARED bukti-pengumuman-extract.ts (built in Peleburan; one extractor, both fine labels) — Berakhirnya is the only consumer that reads nomor_pengumuman |
nama_koran, tanggal_pengumuman, nomor_pengumuman (JSON array string — SABH takes nomor[]) |
reuse (shared, C4) |
| KTP likuidator / NPWP perseroan | existing card pipeline (two-pass merge KTP; REGEX_CONF_CEILING intact) | existing card keys | reuse |
Not OCR-derived (matches SABH): nomor voucher, registry/blocking checks, the DB-pulled company/shareholder block (served from oldData, displayed read-only).
4.5 Validation matrix (TUNED — binding decision #4)
Required documents (checklist → REQUIRED_SUPPORTING_DOCS FAIL when unfulfilled):
| Doc | Required | maxCount | Rationale |
|---|---|---|---|
AKTA_BERAKHIRNYA |
yes | 1 | primary deed |
BERITA_ACARA_RUPS |
yes | 1 | SABH requires the risalah + kehadiran |
SURAT_PERMOHONAN |
yes | 1 | SABH-required PDF #3 |
LAPORAN_LIKUIDASI |
yes | 1 | SABH-required PDF #4 |
BUKTI_PENGUMUMAN_149 |
yes | ≥1 | SABH-required PDF #5 |
BUKTI_PENGUMUMAN_152 |
yes | ≥1 | SABH-required PDF #6 |
KTP (likuidator) |
yes | — | identity cross-checks apply (NIK_KTP_AKTA, likuidator consistency) — akuisisi precedent (commit aa4551d: require the docs so cross-validation always has inputs) |
NPWP (perseroan) |
yes | — | NPWP-perseroan fuzzy check anchors the PT identity; SABH shows registry NPWP on this wizard |
SURAT_PERNYATAAN_DOMISILI |
no (optional) | 1 | tuned away from akuisisi parity: no address is asserted or changed by this transaction — there is no target field for DOMISILI_NAMA to protect; requiring it adds burden without a check (decision #4 says required "wherever the cross-checks apply" — here they don't) |
DATA_KONTAK |
no (optional) | — | same reasoning — the SABH berakhirnya payload has no kontak fields |
OTHER |
no | — | pass-through |
Rules (cfg.rules = [...PT_IDENTITY_RULE_SET, ...BERAKHIRNYA_RULE_SET]; wrapped = body imported verbatim from cross-validator.ts, per target-arch decision #5 — existing severities preserved bit-for-bit):
| Rule code | Wrapped/NEW | Inputs | Severity | Notes / legal citation |
|---|---|---|---|---|
REQUIRED_SUPPORTING_DOCS |
wrapped | checklist (table above) | FAIL on missing required doc | the 6-doc package is statutory |
BSBHP_REGISTRY_STATE |
NEW (async — SABH round-trip; template: validatePerseroanState) |
oldData.status_transaksi, perseroanBlokir |
FAIL unless status_transaksi == 3 (pembubaran recorded). st==1 → FAIL "PT masih aktif — pembubaran belum tercatat di SABH"; st==6 → FAIL "Status badan hukum sudah berakhir"; st==5 → FAIL (peleburan); blokir → FAIL. SABH unreachable → SKIPPED (never guess) |
the prerequisite gate: completed pembubaran must precede. This deliberately inverts validatePerseroanState (which FAILs everything ≠1) — do NOT reuse it as-is. Existing code already treats 5=peleburan, 6=berakhirnya (cross-validator.ts:1431-1456); ==3 semantic to be live-verified (§10 Q1) |
NIK_KTP_AKTA |
wrapped | likuidator KTP ↔ deed penghadap | as-is (FAIL on mismatch) | likuidator appears as the deed's comparant |
NAMA_NPWP_KTP |
wrapped | NPWP ↔ KTP names | as-is | |
KTP_COMPLETENESS / NPWP_COMPLETENESS |
wrapped | cards | as-is | |
NAMA_PT_CONSISTENCY |
wrapped, inputs extended | nama_perseroan across deed / BA RUPS / surat permohonan / laporan likuidasi vs oldData.perseroan.nama |
as-is | feed the two new header docs into the existing supporting-docs name list — pure input threading, not a body change |
NOTARIS_TERAKHIR |
wrapped (async) | lookupNotarisIdByName(nama_notaris) vs oldData.id_notaris |
as-is | SABH-matched notaris + pengganti |
AKTA_DATE_WINDOW |
wrapped | tanggal_akta |
as-is | deed freshness window |
RUPS_ATTENDANCE_QUORUM |
wrapped | BA RUPS kehadiran + PS roster (from oldData — the deed carries no cap table) |
as-is (WARNING) | matcher pre-ticks kehadiran |
OLD_DATA_CONSISTENCY |
wrapped | deed vs registry snapshot | as-is | |
BSBHP_LIKUIDATOR_CONSISTENCY |
NEW | nama_likuidator (laporan) ↔ surat-permohonan signer ↔ deed penghadap ↔ KTP nama (fuzzy, matchBakumDJP-style) |
best pairwise score ≥85 PASS / 60–84 WARNING / <60 FAIL | identity is load-bearing: the likuidator is the legally accountable party across all 6 docs; a fully disjoint name set is a wrong-package signal, hence FAIL at the bottom tier only (fuzzy honorific/gelar noise lands in WARNING) |
BSBHP_PENGUMUMAN_ORDER |
NEW | tanggal_pengumuman(149), tanggal_pengumuman(152), tanggal RUPS |
WARNING, never FAIL (severityFloor: 'WARNING') |
expected order t₁₄₉ < t_RUPS ≤ t₁₅₂: UU 40/2007 Ps.147(1) puts the creditor newspaper notice ≤30 days after dissolution (early in liquidation); Ps.152(3) puts the hasil-akhir-likuidasi announcement after the RUPS discharges the likuidator — so "both before RUPS" is legally wrong for 152 and the rule must not assume it. Never-FAIL justification: (a) the dates come off koran clippings, our weakest OCR surface; (b) the 149/152 axis itself is operator-assigned (§4.3) — a FAIL would punish a mis-drag twice. Cite both pasal in the message. |
BSBHP_DOC_DATE_SANITY |
NEW | laporan / RUPS / surat-permohonan / akta dates | WARNING | tanggal_laporan_likuidasi ≤ tanggal_rups (the report is what the RUPS accepts) and tanggal_surat_permohonan ≥ tanggal_rups (the filing follows the discharge). Advisory: real-world same-day paperwork is common |
BSBHP_PEMBUBARAN_REF |
NEW (async) | nomor/tanggal_akta_pembubaran (hook) vs SABH pembubaran transaction (id_aksi 12, year-sharded transaksi_perseroan) |
WARNING; SKIPPED when the registry ref isn't queryable | per-document number/date cross-check against the registry; availability uncertain (§10 Q2) — never block on our own integration gap |
BSBHP_SISA_KEKAYAAN_PRESENT |
NEW | sisa_kekayaan (laporan) |
WARNING when absent | SABH types the field; deeds state it in prose (possibly "nihil") — absence is a review nudge, not a defect |
Async partition note (target-arch HIGH finding): BSBHP_REGISTRY_STATE, BSBHP_PEMBUBARAN_REF, NOTARIS_TERAKHIR are async: true rules — they ride the Promise.all partition, never serialized.
4.6 The dissolved-state lookup variant (small but load-bearing)
lookupCompany filters status_transaksi = 1 in both its SQL paths (company-lookup.ts:156,176). A dissolved PT (st=3) is therefore invisible to today's lookup — without this change the flow always dead-ends at AWAITING_COMPANY_SELECTION with zero matches. Add an optional param, additive and default-preserving:
lookupCompany(input, opts?: { allowedStatusTransaksi?: number[] }) // berakhirnya passes [3]
The FlowConfig.companyLookup seam carries the option. CompanySelectionModal gets a one-line hint ("menampilkan PT berstatus bubar"). BSBHP_REGISTRY_STATE then re-asserts the state on the selected PT (lookup admits, the rule gates — a manually-entered PT still gets the FAIL).
4.7 Frontend (thin mirrors — binding decision #3, NO ReviewEngine)
frontend/src/pages/BerakhirnyaExtractionPage.tsx— mirror ofAkuisisiExtractionPage.tsx(per-doc progress, company-selection pause).frontend/src/pages/BerakhirnyaReviewPage.tsx— mirrors the akuisisi review page skeleton (stepper, validation panel, section-approval gating, PDF viewer 35% right) but with per-document field sections instead of diff sections — there is no delta. Sections (= theSectionSchema[]published by the flow; keys exact):
akta_berakhirnya,risalah_rups,surat_permohonan,laporan_likuidasi,pengumuman_149,pengumuman_152,identitas_likuidator(KTP/NPWP cards),data_perseroan(read-only registry snapshot card). All approvable; required = the 6 doc sections +data_perseroan.- Routes (
frontend/src/routes.tsx, mirroring the akuisisi block at :79–90):berakhirnya/:submissionId/extraction,berakhirnya/:submissionId/review,berakhirnya/:submissionId/status(SubmissionStatusPage type="BERAKHIRNYA_STATUS_PT"). - Section approvals: explicit persisted rows in
PerubahanSectionApproval(shared table, keyed by submission + sectionKey), read by the submit gate — never derived from child confirmed state (reference_perubahan_section_approval, regression c0698d8). - Field confirm: standard PT confirm/LOCK pipeline. Card confidences keep their by-design tiers (KTP 94 regex ceiling). This is NOT apostille — no per-field manual-confirm-everything regime, and no re-adding auto-confirm anywhere.
- Verifikator/voting: reuse
VerifikatorReviewPage/PublicVotingPageunchanged (akuisisi precedent — commit 84e00e7 made the shared paths type-aware; extend the same allow-lists).
5. Integration / wiring checklist (each ~1 mechanical change; several compile-forced)
backend/prisma/schema.prisma—enum SubmissionType+=BERAKHIRNYA_STATUS_PT. The DocumentType additions (SURAT_PERMOHONAN,LAPORAN_LIKUIDASI,BUKTI_PENGUMUMAN) arrive via the registry slice's single six-DocumentType additive batch (C17), not a per-flow migration; add a schema doc-comment onSURAT_PERMOHONANdistinguishing it from the existingSURAT_PERMOHONAN_KORPORASI(berakhirnya filing letter vs Perbaikan-korporasi letter) to preventmapToDocumentType/operator confusion.prisma generate(+ regeneratecontract/enums.gen.tsonce the contract slice exists — the missing-key compile error then fans out to every registry).backend/src/flow-engine/flows/berakhirnya-status-pt.ts— the FlowConfig (§4.2) +FLOW_REGISTRYkey (compile-forced once the registry isRecord<SubmissionType, FlowConfig>).backend/src/services/akta-txn-classifier.ts— add"berakhirnya"toBUILT_AKTA_TXN_TYPES(:32). Do not addpembubaran.backend/src/routes/submissions.ts—inferSubmissionType: doc-set predicate (§4.3) above the akuisisi line + widen the return union;mapToDocumentType(:27):SURAT_PERMOHONAN,LAPORAN_LIKUIDASI,BUKTI_PENGUMUMAN_149|152 → BUKTI_PENGUMUMAN— mandatory: the current fallback silently maps unknown labels toAKTA;getProcessingOrder: deed 0, BA RUPS 1, new docs after cards.backend/src/services/submission-dispatch.ts—processorKindForType+dispatchProcessorcase"BERAKHIRNYA_STATUS_PT"→ registryrunFlow(theconst _exhaustive: nevertripwire fails the build until added).- reMatch — with the registry slice,
reMatchAndValidateresolves the same FlowConfig (reMatchAndValidateFlow); until the slice's dispatch rewiring lands, add the explicit branch insubmission-processor.ts::reMatchAndValidate(:710) delegating to the flow engine. Either wayqueueReMatchAndValidate(serialized) stays the only entry —reference_rematch_dispatch. backend/src/routes/klasifikasi.ts—BERAKHIRNYA_DOC_TYPESlabel set (the §4.5 table) + serve?type=berakhirnya+LABEL_MAPentries + wireberakhirnya-doc-classifierinto the classify pass (therefineAktaLabelslot).backend/src/services/document-processor.ts—resolveAktaSubtype:AKTA_BERAKHIRNYA → 'perubahan'; dispatch the three new DocumentTypes to their focused extractors.- New services —
berakhirnya-doc-classifier.ts,surat-permohonan-extract.ts,laporan-likuidasi-extract.ts,bukti-pengumuman-extract.ts,akta-pembubaran-ref-extract.ts;company-lookup.tsallowedStatusTransaksiparam (§4.6). - Route module —
backend/src/routes/berakhirnya.tsvia the registry'srouteFamilyCRUD (company-select / review-data / section-approval / finalize); if the slice's routeFamily generator isn't ready, a thin mirror ofroutes/perubahan.tsminus the diff endpoints, flagged for deletion when routeFamily lands. - Frontend —
lib/classifier-labels.ts,detect-banner.tsx(DetectedFlow+FLOW_LABELS/FLOW_DESCRIPTIONS),KlasifikasiPage.tsx(detectedFlowmirror of the ladder +createSubmission.onSuccessroute),routes.tsx,lib/destination-for.ts+flow-steps.ts, the two new pages (§4.7). - Finalize allow-list + verifikator/voting/retry/advisory type allow-lists (the exact set commit 84e00e7 touched for akuisisi).
RoutePolicyfor the family (CI policy-exhaustiveness test, if the security slice has landed).
6. Data model / persistence (additive only)
- Enums:
SubmissionType += BERAKHIRNYA_STATUS_PT;DocumentType += SURAT_PERMOHONAN, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN. One additive migration. No removals, no column changes. - Zero new tables. All new-doc fields persist as
ExtractedFieldrows (fieldKey per §4.4;nomor_pengumumanJSON-encoded invalue— theperalihan_sahamprecedent). The 149/152 axis lives onSubmissionDocument.classifiedType(string, no schema change). BA RUPS uses the existingBeritaAcaraRupsExtraction. Section approvals reusePerubahanSectionApproval. Registry snapshot reusesSubmission.oldData+companyLookupResult+selectedNomorTransaksi/selectedTahun. - Flywheel: operator-confirmed doc labels persist via whatever the classifier spec's open-question-3 resolution chose (flag on classification records vs small table) — this flow adds samples, it does not decide the store.
7. Error handling & gotchas
- reMatch dispatch —
BERAKHIRNYA_STATUS_PTMUST be resolved byreMatchAndValidate(registry or explicit branch) or field edits silently wipe validations; always throughqueueReMatchAndValidate, never fire-and-forget (reference_rematch_dispatch). - Section approvals — explicit persisted rows only; the submit gate reads them (
reference_perubahan_section_approval). mapToDocumentTypefallback — unknown labels silently becomeAKTA(:45). Add the three mappings in the same commit as the enum, or the new docs route into the akta extractor and fail confusingly.- Dissolved PT invisible to lookup — the
status_transaksi = 1SQL filter (§4.6). Forgetting this makes every berakhirnya submission stall at company selection with zero matches; there is no error, just an empty list. BSBHP_REGISTRY_STATEis an inversion — do not wrapvalidatePerseroanStateas-is (it FAILs st≠1, i.e. it would FAIL every legitimate berakhirnya). New rule body,validatePerseroanStatekept untouched for the perubahan-family flows.- Koran clippings — PaddleOCR line-splits dense newsprint (2026-06-29 benchmark: the "No.AL" class of loss).
nomor_pengumumanrecovery goes through the grounded fallback; checkrawTextbefore blaming OCR; all grounded passes reject values not verbatim-present. - 3B brittleness — one focused prompt per document class; never widen a shared prompt to fix a field (apostille lesson: signerName Mulyoto→school).
- Confident-wrong GPU labels — the GPU will label a laporan likuidasi as some nearest-of-22 above 0.6; the refinement pass + operator board are the corrective; tests assert the refined label wins routing.
- Compile tripwires —
dispatchProcessor'sneverswitch and (post-slice) threeRecord<SubmissionType,…>registries won't compile until all keys exist; add every case before trusting green. - Dates — deed/laporan dates arrive in Indonesian word form; store raw grounded (bbox integrity), normalize deterministically +
normalizeWordDateViaLlm, display viaformatDateDisplay(apostille date convention). - Name discipline —
PEMBUBARAN_PP(existing, PP regime) ≠ futurePEMBUBARAN_PT≠BERAKHIRNYA_STATUS_PT. Never route a bare pembubaran deed here (§4.3 predicate).
8. Testing strategy (TDD; DB tests → ahu_ocr_test via bunfig preload — never the dev DB)
- Fixtures: redacted OCR text for the 6 docs (incl. a generically-titled final PKR, both koran clippings, a word-form-date laporan).
- Classifier: keyword pre-screens hit/miss per class; 3B guided-JSON parse (mocked); pengumuman axis proposal always lands amber; confident-wrong guard (GPU says
AKTA_PERUBAHAN/nearest-card for a laporan → refined label wins; a berakhirnya package never routesPERUBAHAN_PT). - Ladder: doc-set predicate —
AKTA_BERAKHIRNYAalone routes;AKTA_PEMBUBARAN+LAPORAN_LIKUIDASIroutes; bareAKTA_PEMBUBARANstays belum didukung; Perbaikan still beats it; existing flows regression-pinned. - Extractors: grounding rejects hallucinated values;
nomor_pengumuman[]JSON round-trip; word-date normalization; pembubaran-ref hook writes ExtractedFields and re-runs on re-extract. - Rules (pure, table-driven):
BSBHP_REGISTRY_STATEover st ∈ {1,3,5,6,null} + blokir + SABH-down→SKIPPED; likuidator fuzzy tiers (≥85/60–84/<60) incl. gelar noise; pengumuman order never emits FAIL (property test over date triples); date-sanity; sisa-kekayaan presence. - Wrapped-rule parity: each wrapped rule's
RuleResultdeep-equals the rawvalidateXoutput on shared fixtures (the wrap-don't-rewrite oracle). - Integration (ahu_ocr_test): full
runFlow— pause at company selection when no high match; dissolved-lookup variant returns st=3 rows;reMatchAndValidateroutes to this flow's config (the validation-wipe guard); section-approval submit gate; finalize allow-list. - Regression: PERUBAHAN_PT/AKUISISI_PT untouched (
validatePerseroanStatestill FAILs st=3 for them).
9. Deferred
SABH submit-back API; 2-level verifier-pool modeling; Berita Negara/TBN checks; likuidator address/kontak fields (→ PEMBUBARAN_PT flow); GPU/text-model graduation for the new doc classes (flywheel only); ReviewEngine descriptor + normalized ReviewData for this flow (frontend greenfield phase); tightening BSBHP_PENGUMUMAN_ORDER beyond WARNING (only after legal confirmation + real-deed telemetry).
10. Open questions / risks
status_transaksi == 3semantics — the discovery report supplies it; cross-validator only hard-codes 1/5/6. Verify on live SABH (read-only, via the ahu-api-profil-badan-usaha container path) that a completed-pembubaran PT actually sits at 3 before freezing the rule messages; if pembubaran maps to a different code, only the constant changes.- Is the pembubaran transaction queryable per-PT (
transaksi_perseroanyear-sharded,id_aksi 12) forBSBHP_PEMBUBARAN_REF? If not, the rule ships SKIPPED-degrading and the ref remains a reviewed field. - Required-doc tuning sign-off — DOMISILI/DATA_KONTAK tuned to optional (§4.5 rationale) vs strict akuisisi parity. My recommendation stands as specced; flag for the user.
- Pengumuman ordering legal reading — Ps.152(3) places the 152 announcement after the discharging RUPS (so "both before RUPS" is wrong for 152). Confirm with AHU/legal; the WARNING-only severity makes the current uncertainty safe.
- Registry-slice sequencing — this spec assumes task #14 lands first (
v2-generic, no fork). If Berakhirnya must ship earlier, the fallback is a thin v1 fork, which contradicts binding decision #2 — that needs an explicit user call, not a silent downgrade. - Deed title reality — if fixture deeds carry an explicit "…BERAKHIRNYA STATUS BADAN HUKUM" title, add a deterministic
TITLE_RULESentry (cheap fast-path); otherwise LLM-only is fine (KEYWORD_CONFIRM already tiers it).
Cross-spec reconciliation (2026-07-02)
Applied from .superpowers/sdd/spec-critique-full.md:
- C2 — LAPORAN_LIKUIDASI kept as a DISTINCT DocumentType (built here, incl. sisa_kekayaan), separate from Pembubaran's SURAT_LIKUIDATOR (penunjukan shape) — the two are kept distinct because the doc-set predicate keys on the distinction (§4.4).
- C3 — BUKTI_PENGUMUMAN classification switched off the in-app LLM: base label rides the ONE SHARED deterministic keyword pre-suggester (built in Peleburan), with a berakhirnya-only _149/_152 keyword axis bolt-on; both never auto-accepted, no LLM over line-split newsprint. berakhirnya-doc-classifier.ts's LLM confirm now applies to SURAT_PERMOHONAN/LAPORAN_LIKUIDASI only (§4.3).
- C4 — bukti-pengumuman-extract.ts marked SHARED (built in Peleburan, this flow is the only consumer of nomor_pengumuman); canonical field keys nama_koran / tanggal_pengumuman / nomor_pengumuman unchanged (§4.4).
- C5 — the Pembubaran↔Berakhirnya doc-set predicate rewritten to the canonical form (adds the SURAT_PERMOHONAN clause and the ≥2-docs-mapping-to-carrier-DocumentType clause via mapToDocumentType), defined ONCE in submissions.ts and quoted IDENTICALLY in the Pembubaran spec §4.2 (§4.3).
- C10 — BSBHP_REGISTRY_STATE is a NEW rule (FAIL unless status_transaksi == 3), NOT an inverted wrap of validatePerseroanState (which stays untouched for the perubahan-family flows); REQUIRED_SUPPORTING_DOCS over the _149/_152 fine labels replaces any new presence rule — no PENGUMUMAN_PS149_152_PRESENT (§4.5). Already matched the canonical resolution; confirmed.
- C11 — §4.2 FlowConfig conformed to the registry slice's canonical shapes: primaryAkta loses subtype (→ extraction.overrideByClassifiedType), extraction.perDocType → focusedPasses, non-canonical rematch (aktaClassifiedType/checklistDocTypes/rerunRupsMatcher/rerunHooks) → canonical RematchConfig (checklistResetDocTypes/standardPtMatchers/preValidationSteps), policy omitted (typed-but-inert), requiredDocs added.
- C12 — routeFamily: 'berakhirnya' accepted; the RouteFamily union extension is made ONCE in flow-engine/types.ts by the registry slice.
- C13 — companyLookup conformed to the widened shape { allowedStatusTransaksi: [3] } for the dissolved-state lookup (was true + prose); the additive default-preserving lookupCompany opts param carries it (§4.6).
- C16 — "first v2-generic tenant" reworded: Berakhirnya is the fourth (last) v2-generic tenant per the program ship order; it flips only its OWN BUILT_AKTA_TXN_TYPES entry and makes no claim that Pembubaran stays gated after this flow lands. Shared components are already built by the three earlier tenants.
- C17 — DocumentTypes (SURAT_PERMOHONAN, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN) arrive via the registry slice's single six-type additive batch; SURAT_PERMOHONAN needs the schema doc-comment distinguishing it from the existing SURAT_PERMOHONAN_KORPORASI (berakhirnya filing letter vs Perbaikan-korporasi letter). DOMISILI/DATA_KONTAK are tuned optional here, so they do not appear in the typed requiredDocs array.