think
16px
820px

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_REGISTRYno processor fork (binding program decision; existing flows stay engine: 'v1-fork').
- Classifier wiring: berakhirnyaBUILT_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-classifierAKTA_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.afterAktaExtractionnew 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 of AkuisisiExtractionPage.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 (= the SectionSchema[] 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/PublicVotingPage unchanged (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)

  1. backend/prisma/schema.prismaenum 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 on SURAT_PERMOHONAN distinguishing it from the existing SURAT_PERMOHONAN_KORPORASI (berakhirnya filing letter vs Perbaikan-korporasi letter) to prevent mapToDocumentType/operator confusion. prisma generate (+ regenerate contract/enums.gen.ts once the contract slice exists — the missing-key compile error then fans out to every registry).
  2. backend/src/flow-engine/flows/berakhirnya-status-pt.ts — the FlowConfig (§4.2) + FLOW_REGISTRY key (compile-forced once the registry is Record<SubmissionType, FlowConfig>).
  3. backend/src/services/akta-txn-classifier.ts — add "berakhirnya" to BUILT_AKTA_TXN_TYPES (:32). Do not add pembubaran.
  4. backend/src/routes/submissions.tsinferSubmissionType: doc-set predicate (§4.3) above the akuisisi line + widen the return union; mapToDocumentType (:27): SURAT_PERMOHONAN, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN_149|152 → BUKTI_PENGUMUMANmandatory: the current fallback silently maps unknown labels to AKTA; getProcessingOrder: deed 0, BA RUPS 1, new docs after cards.
  5. backend/src/services/submission-dispatch.tsprocessorKindForType + dispatchProcessor case "BERAKHIRNYA_STATUS_PT" → registry runFlow (the const _exhaustive: never tripwire fails the build until added).
  6. reMatch — with the registry slice, reMatchAndValidate resolves the same FlowConfig (reMatchAndValidateFlow); until the slice's dispatch rewiring lands, add the explicit branch in submission-processor.ts::reMatchAndValidate (:710) delegating to the flow engine. Either way queueReMatchAndValidate (serialized) stays the only entry — reference_rematch_dispatch.
  7. backend/src/routes/klasifikasi.tsBERAKHIRNYA_DOC_TYPES label set (the §4.5 table) + serve ?type=berakhirnya + LABEL_MAP entries + wire berakhirnya-doc-classifier into the classify pass (the refineAktaLabel slot).
  8. backend/src/services/document-processor.tsresolveAktaSubtype: AKTA_BERAKHIRNYA → 'perubahan'; dispatch the three new DocumentTypes to their focused extractors.
  9. 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.ts allowedStatusTransaksi param (§4.6).
  10. Route module — backend/src/routes/berakhirnya.ts via the registry's routeFamily CRUD (company-select / review-data / section-approval / finalize); if the slice's routeFamily generator isn't ready, a thin mirror of routes/perubahan.ts minus the diff endpoints, flagged for deletion when routeFamily lands.
  11. Frontend — lib/classifier-labels.ts, detect-banner.tsx (DetectedFlow + FLOW_LABELS/FLOW_DESCRIPTIONS), KlasifikasiPage.tsx (detectedFlow mirror of the ladder + createSubmission.onSuccess route), routes.tsx, lib/destination-for.ts + flow-steps.ts, the two new pages (§4.7).
  12. Finalize allow-list + verifikator/voting/retry/advisory type allow-lists (the exact set commit 84e00e7 touched for akuisisi).
  13. RoutePolicy for 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 ExtractedField rows (fieldKey per §4.4; nomor_pengumuman JSON-encoded in value — the peralihan_saham precedent). The 149/152 axis lives on SubmissionDocument.classifiedType (string, no schema change). BA RUPS uses the existing BeritaAcaraRupsExtraction. Section approvals reuse PerubahanSectionApproval. Registry snapshot reuses Submission.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 dispatchBERAKHIRNYA_STATUS_PT MUST be resolved by reMatchAndValidate (registry or explicit branch) or field edits silently wipe validations; always through queueReMatchAndValidate, never fire-and-forget (reference_rematch_dispatch).
  • Section approvals — explicit persisted rows only; the submit gate reads them (reference_perubahan_section_approval).
  • mapToDocumentType fallback — unknown labels silently become AKTA (: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 = 1 SQL 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_STATE is an inversion — do not wrap validatePerseroanState as-is (it FAILs st≠1, i.e. it would FAIL every legitimate berakhirnya). New rule body, validatePerseroanState kept untouched for the perubahan-family flows.
  • Koran clippings — PaddleOCR line-splits dense newsprint (2026-06-29 benchmark: the "No.AL" class of loss). nomor_pengumuman recovery goes through the grounded fallback; check rawText before 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 tripwiresdispatchProcessor's never switch and (post-slice) three Record<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 via formatDateDisplay (apostille date convention).
  • Name disciplinePEMBUBARAN_PP (existing, PP regime) ≠ future PEMBUBARAN_PTBERAKHIRNYA_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 routes PERUBAHAN_PT).
  • Ladder: doc-set predicate — AKTA_BERAKHIRNYA alone routes; AKTA_PEMBUBARAN+LAPORAN_LIKUIDASI routes; bare AKTA_PEMBUBARAN stays 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_STATE over 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 RuleResult deep-equals the raw validateX output 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; reMatchAndValidate routes to this flow's config (the validation-wipe guard); section-approval submit gate; finalize allow-list.
  • Regression: PERUBAHAN_PT/AKUISISI_PT untouched (validatePerseroanState still 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

  1. status_transaksi == 3 semantics — 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.
  2. Is the pembubaran transaction queryable per-PT (transaksi_perseroan year-sharded, id_aksi 12) for BSBHP_PEMBUBARAN_REF? If not, the rule ships SKIPPED-degrading and the ref remains a reviewed field.
  3. 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.
  4. 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.
  5. 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.
  6. Deed title reality — if fixture deeds carry an explicit "…BERAKHIRNYA STATUS BADAN HUKUM" title, add a deterministic TITLE_RULES entry (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:
- C2LAPORAN_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).
- C3BUKTI_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).
- C4bukti-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).
- C10BSBHP_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.perDocTypefocusedPasses, non-canonical rematch (aktaClassifiedType/checklistDocTypes/rerunRupsMatcher/rerunHooks) → canonical RematchConfig (checklistResetDocTypes/standardPtMatchers/preValidationSteps), policy omitted (typed-but-inert), requiredDocs added.
- C12routeFamily: 'berakhirnya' accepted; the RouteFamily union extension is made ONCE in flow-engine/types.ts by the registry slice.
- C13companyLookup 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.