think
16px
820px

Berakhirnya Status Badan Hukum PT (BERAKHIRNYA_STATUS_PT) Flow — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the FULL BERAKHIRNYA_STATUS_PT OCR flow (upload → classify → extract → validate → review → submit to VERIFIKATOR → verifikasi state machine) as the FOURTH AND FINAL engine:'v2-generic' tenant of the flow-engine FLOW_REGISTRY — the terminal PT transaction (SABH id_aksi_transaksi = 36, transaction code 70: ends a PT's legal personhood after pembubaran + liquidation complete). One defineFlow config + a tuned rule pack + TWO genuinely new grounded extractors (surat-permohonan-extract.ts, laporan-likuidasi-extract.ts — the laporan-akhir shape, DISTINCT from Pembubaran's penunjukan-shaped surat-likuidator-extract.ts) + one deed hook (akta-pembubaran-ref-extract.ts) + the _149/_152 fine-label axis going LIVE (board labels + mapToDocumentType carrier rows + the already-built fine-label suggester surfacing) + the single-source company lookup (companyLookup: true; the pembubaran-prerequisite is enforced at validation by BSBHP_REGISTRY_STATE reading a pre-fetched registry snapshot — the allowedStatusTransaksi seam stays UNUSED, see the erratum). There is no diff: the review is per-document field sections + the registry snapshot. The BERAKHIRNYA_PACKAGE doc-set predicate (owned by submissions.ts, forward-written + tested by Pembubaran) flips from DEFLECT-to-unsupported to ROUTE-to-BERAKHIRNYA_STATUS_PT.

Erratum (2026-07-03 — SABH registry reconciliation). The discovery-report claim "status_transaksi == 3 = pembubaran recorded" is WRONG. Controller-run read-only counts against live AHU_BADAN_HUKUM.tbl_transaksi_perseroan_<YYYY> (2026-07-03): pembubaran rows (id_aksi_transaksi=12) sit at status_transaksi=1 when APPROVED (2025: 1→4866, 3→1, 0→1; 2024: 1→3893, 0→8) — an approved transaction row is 1 regardless of aksi type; berakhirnya rows (id_aksi_transaksi=36) reach status_transaksi=6 when COMPLETED (2025: 6→121, 3→61, 7→43; 3 there is an in-progress wizard state, NOT a dissolved-PT marker). Consequences applied throughout this plan: (a) the company lookup keeps the DEFAULT [1] filter (companyLookup: true) — a PT in liquidation is discoverable at status 1, so the allowedStatusTransaksi seam stays UNUSED; (b) BSBHP_REGISTRY_STATE no longer reads oldData.status_transaksi — it reads a pre-fetched registry snapshot (loadPembubaranRegistryState, Task 1) and FAILs unless an APPROVED pembubaran row exists (id_aksi 12 + status 1), ALSO FAILing "sudah berakhir" when a COMPLETED berakhirnya row exists (id_aksi 36 + status 6), degrading to SKIPPED on SABH outage; (c) Task 1's single constant DISSOLVED_STATUS_TRANSAKSI = 3 is replaced by the empirical set (APPROVED_TX_STATUS/BERAKHIRNYA_DONE_STATUS/PEMBUBARAN_AKSI/BERAKHIRNYA_AKSI) and the deno-recipe live-check step is dropped (superseded by this controller verification).

Erratum follow-on (2026-07-03 — final review C1: registry-scan anchor). buildBerakhirnyaContext anchored the loadPembubaranRegistryState scan at selectedTahun ?? currentYear — but on the auto-select path selectedTahun is the PENDIRIAN transaction's year (Phase-2 SK-exact match) and the scan walks shards DOWNWARD, so the approved pembubaran row (id_aksi 12, filed years after pendirian) sat ABOVE the window: BSBHP_REGISTRY_STATE false-FAILed every legitimate live filing (and the already-ended guard never fired). Corrected to Math.max(selectedTahun ?? 0, currentYear) (registryScanStartYear) — pembubaran/berakhirnya rows are recent by construction (the filing is happening NOW); the manual-entry path is correct under the same recency argument (a future-dated operator year still wins the max). Liquidations longer than MAX_YEAR_SCAN (5 → the window reaches currentYear-5) degrade to a FAIL the operator overrides — acceptable, documented in berakhirnya-context.ts.

Architecture: The flow is a CONFIG + RULES + HOOKS consumer of backend/src/flow-engine/ (registry/processor/rematch/validation-runner/fail-gate/focused-passes all landed on feat/pt-flows-registry; the engine's Phase-2 SABH lookups are already timeout-wrapped via FlowSabhDeps — fix wave a9ca0e8 — nothing to add). The deed rides the perubahan extractor via extraction.overrideByClassifiedType: { AKTA_BERAKHIRNYA: "perubahan" } (AKTA_AKUISISI/AKTA_PEMBUBARAN precedent). The 6-PDF statutory package: (1) the deed (final PKR), (2) risalah/BA RUPS (existing BeritaAcaraRupsExtraction + attendance matcher — the matcher's deed loop already merge-preserves operator ticks, a9ca0e8 I1; this flow only widens its akta filter + registry-fallback scope), (3) SURAT_PERMOHONAN (NEW focused pass), (4) LAPORAN_LIKUIDASI (NEW focused pass incl. sisa_kekayaan), (5+6) BUKTI_PENGUMUMAN ×2 with the Pasal 149/152 fine-label axis riding the SHARED clipping extractor (built in Peleburan, atomic-createMany hardened in Pembubaran — consumed AS-IS via one focusedPasses map entry). Validation = tuned BERAKHIRNYA_RULE_SET: 9 wrapped rules + default rupsQuorumRule() + 9 flow rules incl. the FAIL prerequisite gate BSBHP_REGISTRY_STATE (a NEW rule — deliberately an INVERSION of validatePerseroanState, which stays untouched and EXCLUDED) and the WARNING-NEVER-FAIL BSBHP_PENGUMUMAN_ORDER. Submit is a new thin route family (routes/berakhirnya.ts, routeFamily 'berakhirnya' — the union value already exists in flow-engine/types.ts): explicit section approvals (7 required keys) + checkFailGateCOMPLETED + VerifikasiPerubahan (MENUNGGU_VERIFIKATOR) — VERIFIKATOR-STAGED (pembubaran/peleburan/akuisisi twin). Voter roster = Submission.oldData.pemegang_saham via resolveVoterRoster's existing fallback (AKTA_BERAKHIRNYA deliberately NOT added to ROSTER_AKTA_TYPES; pinned by test). Frontend = thin mirrors (extraction page copied from PembubaranExtractionPage — it carries the FIXED FAIL-only proceed gate, 5c68969; a flat 8-section review page; a verifikasi-aware status page; a compact verifikator page). Everything stays dark until BUILT_AKTA_TXN_TYPES += "berakhirnya" flips (Task 7) — the launch switch; after it, ALL SEVEN taxonomy types are built.

Tech Stack: Bun + Hono backend; Prisma 7 (client generated to src/generated/prisma/); PostgreSQL 16; backend tests via bun test against the isolated ahu_ocr_test DB (bunfig preload; bun run db:push:test after schema changes); frontend React 19 + Vite 6 + TanStack Query v5 + jotai, tests via cd frontend && bunx vitest run; typecheck via bunx tsc --noEmit in each package.

Global Constraints

Binding values — copy verbatim; do not paraphrase:

  • Enum spellings (exact). SubmissionType BERAKHIRNYA_STATUS_PT (SABH id_aksi_transaksi = 36, code 70; SABH's code path is misleadingly named PerubahanStatus* — every view titles it "Berakhirnya Status Badan Hukum"). Carrier akta label AKTA_BERAKHIRNYA already exists (AKTA_TXN_TYPE_TO_LABEL, akta-txn-classifier.ts:23; KEYWORD_CONFIRM.berakhirnya = /berakhirnya\s+status|hapusnya\s+badan\s+hukum/i at :105). Classifier taxonomy value "berakhirnya". ValidationStatus is "PASS" | "WARNING" | "FAIL" | "SKIPPED" (NO -ED). Exact-equality checks ONLY — never includes("BERAKHIRNYA")/startsWith("BERAKHIRNYA") (the label AKTA_BERAKHIRNYA and the enum BERAKHIRNYA_STATUS_PT share the prefix; Task 15 greps for violations). Rule-code prefix BSBHP_ (Berakhirnya Status Badan Hukum PT).
  • ZERO DocumentType schema changes. SURAT_PERMOHONAN, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN already exist in Prisma (schema.prisma:52-59, registry-slice six-type additive batch, incl. the doc-comments distinguishing SURAT_PERMOHONAN from SURAT_PERMOHONAN_KORPORASI and LAPORAN_LIKUIDASI from SURAT_LIKUIDATOR) — do NOT re-add or edit them. The ONLY schema change in this plan is SubmissionType += BERAKHIRNYA_STATUS_PT (Task 1). Submission.rupsKehadiranRegistry Json? already exists (Pembubaran Task 1) and is REUSED.
  • C2 is load-bearing: LAPORAN_LIKUIDASI (laporan-akhir/pertanggungjawaban shape, incl. sisa_kekayaan) is DISTINCT from SURAT_LIKUIDATOR (penunjukan shape, Pembubaran's) — the BERAKHIRNYA_PACKAGE doc-set predicate keys on the distinction. Never merge the extractors; never let one prompt swallow the other's shape.
  • VERIFIKATOR-STAGED — not terminal. Submit → status COMPLETED + a VerifikasiPerubahan row (MENUNGGU_VERIFIKATOR) in ONE transaction, mirroring routes/pembubaran.ts:266-323 incl. the PERBAIKAN re-submit arm (clears aiReport/aiRecommendation/aiGeneratedAt) and recordAuditEvent(NOTARIS_SUBMIT/NOTARIS_RESUBMIT). BERAKHIRNYA_STATUS_PT is NEVER added to TERMINAL_TYPES in frontend/src/lib/destination-for.ts. Warning-3 obligations (Task 10): verifikator decide-APPROVE tested end-to-end + voter-roster source DECLARED: Submission.oldData.pemegang_saham via resolveVoterRoster's existing fallbackAKTA_BERAKHIRNYA is deliberately NOT added to ROSTER_AKTA_TYPES (shareholder-voting.ts:105): the registry roster is the legal voter set for a terminal transaction; a deed-restated roster must not hijack it (pinned by the deed-roster-present test).
  • Canonical route envelopes (template rules — binding). FAIL gate = HTTP 422 + { error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing } via the NAMED step checkFailGate(submissionId, berakhirnyaStatusPtFlow) (backend/src/flow-engine/fail-gate.ts). guardReady = EVERY mutating endpoint INCLUDING submit returns 409 + { error: "submission cannot be edited in its current status", code: "WRONG_STATUS" } outside its allowed set. Allowed sets: permohonan / laporan-likuidasi / pengumuman / kehadiran edits = READY only; section approve/unapprove = READY only; submit = READY only. Company selection rides the EXISTING type-agnostic POST /api/perubahan/select-company/:submissionId (its v2 branch resumes continueFlowroutes/perubahan.ts:186-195); berakhirnya adds NO select route.
  • submissionDocument.create seeds MUST include confidence (required Float, no default). Every seed in this plan carries confidence: 1.
  • COPY THE FIXED VERSIONS (Pembubaran whole-flow fix wave a9ca0e8 + 5c68969 — landed; consume, never re-introduce the pre-fix idioms):
    1. Deed-path kehadiran merge-preserve LANDED (a9ca0e8 I1): rups-attendance-matcher.ts's deed loop and registry path both preserve existing non-null kehadiran. Task 3 only WIDENS the matcher's akta filter (AKTA_BERAKHIRNYA) and registry-fallback scope (BERAKHIRNYA_STATUS_PT) — it must NOT touch the merge-preserve logic, and its tests re-assert it on the berakhirnya path.
    2. Engine Phase-2 SABH lookups are timeout-wrapped (a9ca0e8 I3: FlowSabhDeps + FLOW_SABH_TIMEOUT_MS, timeout → AWAITING_COMPANY_SELECTION, never ERROR) — NOTHING to add; Task 6 uses the injectable deps to prove the lookup runs on the DEFAULT status filter (no dissolved-state opts — approved rows sit at status 1; erratum).
    3. Extraction-page proceed gate = FAIL-only (5c68969 I2): copy PembubaranExtractionPage.tsx (which HAS the gate — non-overridden FAIL blocks, WARNING never does), NOT the akuisisi/peleburan pages. WARNING is this flow's norm too (BSBHP_PENGUMUMAN_ORDER, BSBHP_SISA_KEKAYAAN_PRESENT, …).
    4. Local-record rules DITOLAK-aware FROM BIRTH (a9ca0e8 I4 class): BSBHP_PEMBUBARAN_PREREQ (Task 5) must exclude DITOLAK (verifikator-rejected) PEMBUBARAN_PT priors from satisfying the prerequisite — a rejected dissolution filing is NOT a completed pembubaran.
    5. CreateSubmissionResponse discriminated union exists (5c68969: frontend/src/hooks/use-submission.ts:85-87) — narrow via "supported" in data; NO casts anywhere.
  • DO-NOT-COPY warnings (both progress ledgers — bake in, do not re-learn):
    1. Never make a status-gated dialog/panel the ONLY management surface for correctable state — every correctable field (permohonan/laporan/pengumuman fields, kehadiran ticks) gets a READY-state entry point on the review page (Tasks 9/13).
    2. No all-or-nothing SKIP/PASS tiers over partially-verified sets — mixed verified/unknown emits WARNING naming the unchecked items (BSBHP_PENGUMUMAN_ORDER names the unparseable dates; BSBHP_NAMA_PT_DOCS names the un-extracted letters; BSBHP_REQUIRED_DOCS names the missing docs).
    3. Staged flows MUST test verifikator decide-APPROVE end-to-end + declare the roster source (Task 10; declaration above).
    4. companyLookup: true is SINGLE-source (this flow's value — the { allowedStatusTransaksi } seam stays UNUSED per the erratum). Do NOT touch the engine's multi-source branch (processor.ts:78-97) or peleburan-sources.ts — peleburan-coupled by documented design.
  • PT lookup + registry-state prerequisite (spec §4.6 — reconciled 2026-07-03, see erratum): lookupCompany keeps its DEFAULT status_transaksi IN (1) filter (companyLookup: true), because an APPROVED pembubaran row sits at status_transaksi = 1 — a PT in liquidation IS discoverable via the default lookup. The opts.allowedStatusTransaksi seam stays UNUSED (the 2026-07-03 controller verification killed the "st=3 = dissolved" assumption). The prerequisite is enforced NOT by the lookup filter but at validation by BSBHP_REGISTRY_STATE, which reads a pre-fetched registry snapshot (loadPembubaranRegistryState, Task 1 — a read-only shard-aware scan of tbl_transaksi_perseroan_<YYYY> for the selected PT's id_perseroan): FAIL unless an APPROVED pembubaran row exists (id_aksi_transaksi=12 + status_transaksi=1); FAIL "sudah berakhir" when a COMPLETED berakhirnya row exists (id_aksi_transaksi=36 + status_transaksi=6); blokir → FAIL; SABH outage / manual entry → SKIPPED (never guess). The snapshot is pre-fetched in buildBerakhirnyaContext (peleburan loadSnapshot precedent) and wrapped in withTimeout — a wedged pool degrades to SKIPPED, never FAIL.
  • Predicate ownership handoff (C5): BERAKHIRNYA_PACKAGE is owned ONCE in routes/submissions.ts:89-98, forward-written + tested by Pembubaran — do NOT rewrite its body. Task 7 (a) REMOVES the detectUnsupportedAktaType package deflection (submissions.ts:105), (b) adds the positive rung at the canonical ladder position Perbaikan > BERAKHIRNYA > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT (ABOVE Peleburan — the deed package may recite/contain other deed labels; the package must win), (c) adds the mapToDocumentType carrier rows (SURAT_PERMOHONAN, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN_* → the fine labels currently fall to the silent "AKTA" fallback, which both mis-routes the docs into the akta extractor AND is exactly why the predicate's ≥2 carrier clause must start counting them). The Pembubaran rung's && !BERAKHIRNYA_PACKAGE(files) guard STAYS (belt-and-braces).
  • The tuned validation matrix (severities binding, spec §4.5):
  • Wrapped bit-for-bit via pickRules(PT_AKTA_RULE_SET, …) — 9 codes: NIK_KTP_AKTA (likuidator is the deed comparant), NAMA_NPWP_KTP, KTP_COMPLETENESS, NPWP_COMPLETENESS, NAMA_PT_CONSISTENCY, NPWP_PERSEROAN_FUZZY (the §4.5 "NPWP anchors PT identity" rationale), AKTA_DATE_WINDOW (30-day; shared context sets isPerubahan: true), OLD_DATA_CONSISTENCY, NOTARIS_TERAKHIR. Plus rupsQuorumRule() DEFAULT >1/2 Ps.86(1) — the discharge RUPS (accepting the likuidator's accounting) is NOT the Ps.89(1) dissolution decision; do NOT copy Pembubaran's { quorumThreshold: 3/4, comparator: ">=" }.
  • Net-new 9 (Task 5): BSBHP_REQUIRED_DOCS (FAIL, the statutory 6-doc package + cards incl. per-pasal fine-label coverage — a flow rule, NOT the wrapped REQUIRED_SUPPORTING_DOCS, whose body is jenis-scoped: the Pembubaran-adjudicated exclusion class; C10's intent — "REQUIRED_SUPPORTING_DOCS over the _149/_152 fine labels" — is delivered by this rule), BSBHP_REGISTRY_STATE (FAIL unless the pre-fetched registry snapshot shows an APPROVED pembubaran row (id_aksi 12 + status 1) → "PT belum memiliki pembubaran yang disetujui di SABH"; a COMPLETED berakhirnya row (id_aksi 36 + status 6) → "Status badan hukum sudah berakhir"; blokir → FAIL; no registry data / SABH outage / manual entry → SKIPPED, never guess — an INVERSION: do NOT wrap validatePerseroanState, which FAILs everything ≠1 and stays untouched for the perubahan family; the snapshot is pre-fetched in buildBerakhirnyaContext, NOT queried in the rule body — erratum), BSBHP_PEMBUBARAN_PREREQ (WARNING, async — the local complement: a COMPLETED non-DITOLAK PEMBUBARAN_PT for the same id_perseroan satisfies it; DITOLAK-aware from birth), BSBHP_LIKUIDATOR_CONSISTENCY (laporan nama_likuidator vs deed penghadap + KTP pool, matchBakumDJP best pairwise: ≥85 PASS / 60–84 WARNING / <60 FAIL), BSBHP_PENGUMUMAN_ORDER (WARNING — NEVER FAIL, severityFloor: "WARNING" + structural test: expected t₁₄₉ < t_RUPS ≤ t₁₅₂ — UU 40/2007 Ps.147(1) creditor notice ≤30d from dissolution, Ps.152(3) hasil-akhir notice AFTER the discharging RUPS; never-FAIL because koran dates are the weakest OCR surface AND the 149/152 axis is operator-assigned — a FAIL would punish a mis-drag twice), BSBHP_DOC_DATE_SANITY (WARNING: tanggal_laporan_likuidasi ≤ t_RUPS, tanggal_surat_permohonan ≥ t_RUPS), BSBHP_PEMBUBARAN_REF (WARNING when the hook fields are absent; SKIPPED when present — the per-PT SABH pembubaran-transaction cross-check (year-sharded transaksi_perseroan, id_aksi 12) has no integration helper yet, spec §10 Q2: ship SKIPPED-degrading, never block on our own gap), BSBHP_SISA_KEKAYAAN_PRESENT (WARNING when absent — deeds state it in prose, possibly "nihil"), BSBHP_NAMA_PT_DOCS (FAIL on mismatch <70 matchBakumDJP — parity with wrapped NAMA_PT_CONSISTENCY's FAIL tiering; carries the spec's "inputs extended" intent for the two new letters, adjudicated deviation: validateNamaPerseroan's source list is hard-coded to buktiSetor/domisili/kontak and wrap-don't-rewrite forbids a body change, so the letters' *_nama_perseroan keys feed a flow rule instead).
  • EXCLUDED — deliberately, guard with a set-composition test: PERSEROAN_STATE (would FAIL every legitimate berakhirnya — replaced by BSBHP_REGISTRY_STATE), REQUIRED_SUPPORTING_DOCS + UPLOADED_DOC_UNUSED (jenis-scoped bodies), the whole cap-table/modal/KBLI group (MODAL_BUKTI_SETOR, PEMEGANG_SAHAM_BUKTI_SETOR, PP29_MODAL, KBLI_VALID, PERUBAHAN_KBLI, PERUBAHAN_MODAL, SHARES_TRANSFER_BALANCE, SHARES_TOTAL_LEMBAR, SHARES_SUM_CONSISTENCY, MODAL_HIERARCHY, NOMINAL_RECONCILE, PS_TOTAL_100, PEMEGANG_SAHAM_CONTACT), the jenis machinery (DATA_ACTUALLY_CHANGED, JENIS_SELECTION_NONEMPTY, JENIS_DESELECT_REASONS_COMPLETE, CONTRADICTORY_JENIS), and — tuned per spec §4.5 (2026-07-02 user decision: DOMISILI + DATA_KONTAK are OPTIONAL for this flow; no address/kontak surface exists in the transaction) — DOMISILI_NAMA, CONTACT_INFO_COMPLETE, PASSPORT_AKTA.
  • FAIL rules block submission per the engine's block-with-override gate (program decision 2026-07-02); ValidationResult.overridden is the override bit; failGate stays default 'on' (config key absent).
  • Model routing: ALL new passes (surat-permohonan, laporan-likuidasi, pembubaran-ref) = the 3B (CLEANUP_LLM_URL/CLEANUP_LLM_MODEL, cleanup-3b @8003), temperature: 0, guided-JSON, every value verbatim-substring-grounded (norm() as in bukti-pengumuman-extract.ts:61); hallucination → null, never an ERROR; one narrow prompt per document class — NEVER broaden a shared prompt (3B brittleness). Dates: store the RAW grounded value (bbox integrity; apostille date convention), parse at rule time via parseFlexibleDate (flow-engine/rules/laporan-rups/index.ts:16 — handles "10 April 2024" word form). No 35B pass anywhere in this flow.
  • Shared clipping extractor consumed AS-IS (C4): services/bukti-pengumuman-extract.ts (canonical nullable keys nama_koran/tanggal_pengumuman/nomor_pengumuman, atomic createMany already hardened) is wired via ONE focusedPasses map entry — never re-implemented, never prompt-broadened. Berakhirnya is the first consumer that READS nomor_pengumuman. Adjudicated vs the spec-§4.4 "JSON array" note: the landed shared extractor stores nomor_pengumuman as a per-clipping SCALAR; SABH's nomor[] emerges naturally as one nomor per clipping document (≥1 clippings per pasal) — do NOT change the shared extractor's shape (C4 canonical keys are the authority; a shape change would break its two live consumers).
  • rawText gotcha (inherited): the GPU-server akta path stores rawText: "". Focused passes receive whatever Document.rawText holds; empty → console.warn + skip → fields absent → manual entry via the review-page PUT surfaces, never a processing failure.
  • Test-DB isolation: all backend DB tests target ahu_ocr_test via the bunfig preload. NEVER run bun test against the dev DB. Run cd backend && bun run db:push:test after Task 1's schema change. test-setup.ts blanks SABH creds — company lookups in tests degrade to no-match (AWAITING_COMPANY_SELECTION), which the integration test handles env-robustly.
  • Frontend = thin mirrors. NO ReviewEngine / FrontendReviewDescriptor / ReviewData reshape. Frontend tests via cd frontend && bunx vitest run (NEVER bun test). Page tests need the IOStub IntersectionObserver stub + jsdom patterns from frontend/src/pages/__tests__/LaporanRupsReviewPage.test.tsx:26-31. base-ui Button link idiom is render={<Link to="..." />} (no asChild — @base-ui, not Radix). Submit mutations: onMutate clears the inline error, onError sets it (rendered with role="alert"), COMPLETED status renders return <Navigate .../> (46176de idiom — both already baked into the pembubaran pages being mirrored).
  • No auto-confirm / no auto-accept: section approvals are EXPLICIT persisted PerubahanSectionApproval rows — never derived from child confirmed state (regression c0698d8). Grounded values persist at the 0.75 "please review" tier — never auto-LOCK. Classification suggestions (incl. the _149/_152 axis and the 3B letter-confirm) are amber proposals — the board assignment is authoritative; BUKTI_PENGUMUMAN NEVER reaches an LLM (C3 — classifySupportingDoc returns the keyword hit before the LLM branch; pinned by test).
  • Classifier gating = the launch switch: everything stays dark ("Terdeteksi: Berakhirnya Status Badan Hukum (belum didukung)") until BUILT_AKTA_TXN_TYPES += "berakhirnya" (Task 7). Tasks 1–6 must not make the flow reachable. FOURTH AND FINAL: after the flip ALL 7 taxonomy types are built — the stale "berakhirnya is the canonical unbuilt example" fixtures (pembubaran-routing.test.ts, peleburan-routing.test.ts:12, laporan-rups-routing.test.ts:14, klasifikasi-akta-txn.test.ts:35-36,46) flip to all-built semantics in the same task; detectUnsupportedAktaType stays (future types + the merger unsupported-title path).
  • Do not touch v1 forks or sibling v2 flows: perubahan/akuisisi/pendirian processors, pembubaran-pt.ts/peleburan-pt.ts/laporan-rups-tahunan.ts flow configs, surat-likuidator-extract.ts, validatePerseroanState, and every existing route behaviour stay byte-identical. The matcher widening (Task 3) is proven inert for non-berakhirnya types by regression tests.

File Structure

File Create/Modify Responsibility
backend/prisma/schema.prisma Modify SubmissionType += BERAKHIRNYA_STATUS_PT (the ONLY schema change)
backend/src/services/company-lookup.ts Modify empirical registry constants (APPROVED_TX_STATUS/BERAKHIRNYA_DONE_STATUS/PEMBUBARAN_AKSI/BERAKHIRNYA_AKSI, 2026-07-03 erratum) + loadPembubaranRegistryState shard-aware helper (read-only)
backend/src/flow-engine/registry.ts Modify (twice) Task 1: temporary v1-fork stub; Task 6: real berakhirnyaStatusPtFlow entry
backend/src/services/submission-dispatch.ts Modify processorKindForType case BERAKHIRNYA_STATUS_PT → "v2-generic" (the never tripwire)
backend/src/services/surat-permohonan-extract.ts Create Grounded 3B 3-field surat-permohonan extractor + runSuratPermohonanPass (atomic, idempotent-once)
backend/src/services/laporan-likuidasi-extract.ts Create Grounded 3B 5-field laporan-akhir extractor (incl. sisa_kekayaan) + runLaporanLikuidasiPass — laporan shape ONLY (C2)
backend/src/flow-engine/focused-passes.ts Modify FOCUSED_PASS_HANDLERS["surat-permohonan"] + ["laporan-likuidasi"] entries
backend/src/services/document-processor.ts Modify OCR-only cases 6l (SURAT_PERMOHONAN) + 6m (LAPORAN_LIKUIDASI), after the SURAT_LIKUIDATOR case
backend/src/services/akta-pembubaran-ref-extract.ts Create hooks.afterAktaExtraction body: grounded pembubaran-ref pass (nomor/tanggal_akta_pembubaran ExtractedFields on the deed)
backend/src/services/rups-attendance-matcher.ts Modify AKTA_BERAKHIRNYA joins the akta filter; registry-fallback scope += BERAKHIRNYA_STATUS_PT (merge-preserve untouched)
backend/src/flow-engine/context/berakhirnya-context.ts Create BerakhirnyaCtx (extends PtAktaCtx) + buildBerakhirnyaContext
backend/src/flow-engine/rules/berakhirnya/index.ts Create 9 flow rules + BERAKHIRNYA_SHARED_RULE_CODES + assembled BERAKHIRNYA_RULE_SET
backend/src/flow-engine/flows/berakhirnya-status-pt.ts Create The real defineFlow<BerakhirnyaCtx> config — FOURTH (final) v2-generic registry entry
backend/src/ocr/classifier.ts Modify AKTA_SUBTYPE_MAP["AKTA_BERAKHIRNYA"] = "perubahan" (defensive label row)
backend/src/services/akta-txn-classifier.ts Modify BUILT_AKTA_TXN_TYPES += "berakhirnya" (LAUNCH SWITCH) + TITLE_RULES berakhirnya fast-path (§10-Q6)
backend/src/routes/submissions.ts Modify Remove the package deflection; inferSubmissionType BERAKHIRNYA rung ABOVE Peleburan + return union; mapToDocumentType/getProcessingOrder carrier rows
backend/src/routes/klasifikasi.ts Modify BERAKHIRNYA_DOC_TYPES + ?type=berakhirnya + LABEL_MAP spread; fine-label suggestion surfacing + 3B letter-confirm wiring
backend/src/services/berakhirnya-doc-classifier.ts Create Focused 3B letter-confirm (SURAT_PERMOHONAN/LAPORAN_LIKUIDASI ONLY — C3) for classifySupportingDoc's opts.llm seam
backend/src/routes/berakhirnya.ts Create Route family: review-data, permohonan/laporan/pengumuman/kehadiran edits, section approve/unapprove, submit→verifikator (canonical envelopes)
backend/src/index.ts Modify Mount /api/berakhirnya
backend/src/routes/verifikator.ts Modify Queue where admits BERAKHIRNYA_STATUS_PT; tagged item type
backend/src/services/shareholder-voting.ts Modify (comment only) Document the deliberate NON-widening of ROSTER_AKTA_TYPES for AKTA_BERAKHIRNYA
backend/src/flow-engine/__tests__/{berakhirnya-schema,berakhirnya-context,rules-berakhirnya,berakhirnya-flow,berakhirnya-integration}.test.ts Create Per-task TDD
backend/src/flow-engine/__tests__/registry.test.ts Modify V2_SHIPPED += "BERAKHIRNYA_STATUS_PT" (Task 6)
backend/src/services/__tests__/{surat-permohonan-extract,laporan-likuidasi-extract,akta-pembubaran-ref-extract,berakhirnya-rups-fallback,berakhirnya-doc-classifier}.test.ts Create Extractor grounding + pass persistence + matcher widening + classifier tiers
backend/src/routes/__tests__/{berakhirnya-routing,berakhirnya-routes,verifikator-berakhirnya,klasifikasi-berakhirnya-suggest}.test.ts Create Routing flip + route family + queue/decide-APPROVE + suggestion surfacing
backend/src/routes/__tests__/{pembubaran-routing,peleburan-routing,laporan-rups-routing,klasifikasi-akta-txn}.test.ts Modify Stale "berakhirnya is unbuilt" fixtures → all-built semantics
frontend/src/lib/pembubaran-detect.ts Modify isClipping counts the carrier via startsWith("BUKTI_PENGUMUMAN") (fine-label mirror)
frontend/src/components/klasifikasi/detect-banner.tsx Modify DetectedFlow += "berakhirnya" + labels/descriptions
frontend/src/pages/KlasifikasiPage.tsx Modify Positive berakhirnya rung ABOVE peleburan (labelType + detectedFlow chains) + create-route navigate
frontend/src/lib/flow-steps.ts Modify BERAKHIRNYA_LABELS (5 steps, verifikator stage) + type union + staged arm
frontend/src/lib/destination-for.ts Modify Type union + base berakhirnya (NOT terminal)
frontend/src/lib/berakhirnya-review.ts Create Pure gate helpers (7 required section keys, missingSections)
frontend/src/components/perubahan/CompanySelectionModal.tsx Modify Additive optional hint?: string prop (registry-prerequisite hint, §4.6)
frontend/src/pages/BerakhirnyaExtractionPage.tsx Create Copy of PembubaranExtractionPage.tsx (FAIL-only gate) + enumerated token diffs + modal hint
frontend/src/pages/BerakhirnyaReviewPage.tsx Create Flat 8-section review (per-document sections + registry snapshot; no diff) + staged submit
frontend/src/pages/BerakhirnyaStatusPage.tsx Create Verifikasi-aware status page (pembubaran mirror)
frontend/src/pages/VerifikatorBerakhirnyaReviewPage.tsx Create Compact verifikator surface (VerifikatorPembubaranReviewPage precedent)
frontend/src/hooks/use-verifikator.ts Modify Queue item type union += "BERAKHIRNYA_STATUS_PT"
frontend/src/pages/VerifikatorDashboardPage.tsx Modify Route BERAKHIRNYA_STATUS_PT items to the berakhirnya verifikator page
frontend/src/routes.tsx Modify berakhirnya/:submissionId/{extraction,review,status} + verifikator/berakhirnya/:submissionId

Task 1: Prisma enum + compile-gate + the empirical registry constants + the registry-state helper

Adds BERAKHIRNYA_STATUS_PT to the Prisma SubmissionType enum — which FORCES two compile-time additions: a FLOW_REGISTRY key (temporary v1-fork stub, replaced by Task 6) and a processorKindForType case (returns "v2-generic"). Adds the empirical SABH-registry constants (APPROVED_TX_STATUS/BERAKHIRNYA_DONE_STATUS/PEMBUBARAN_AKSI/BERAKHIRNYA_AKSI, per the 2026-07-03 erratum — these SUPERSEDE the discovery-report DISSOLVED_STATUS_TRANSAKSI = 3) plus the read-only shard-aware helper loadPembubaranRegistryState to company-lookup.ts (the registry facts BSBHP_REGISTRY_STATE gates on but the companyLookup payload does not carry). NO other schema changes: the three DocumentTypes already exist (registry-slice batch), rupsKehadiranRegistry already exists (Pembubaran Task 1), and all new-doc fields persist as ExtractedField rows (spec §6 — zero new tables).

Files
- backend/prisma/schema.prisma — Modify (enum SubmissionType ends at PEMBUBARAN_PT)
- backend/src/services/company-lookup.ts — Modify (constant next to DEFAULT_STATUS_TRANSAKSI, ~line 134)
- backend/src/flow-engine/registry.ts — Modify (stub key after the PEMBUBARAN_PT entry)
- backend/src/services/submission-dispatch.ts — Modify (processorKindForType, add case before default:)
- backend/src/flow-engine/__tests__/berakhirnya-schema.test.ts — Create

Interfaces
- Produces: generated enum SubmissionType.BERAKHIRNYA_STATUS_PT; the empirical registry constants (APPROVED_TX_STATUS/BERAKHIRNYA_DONE_STATUS/PEMBUBARAN_AKSI/BERAKHIRNYA_AKSI) + loadPembubaranRegistryState(idPerseroan, startYear): Promise<PembubaranRegistryState | null> + interface PembubaranRegistryState exported from company-lookup.ts; processorKindForType("BERAKHIRNYA_STATUS_PT") === "v2-generic"; FLOW_REGISTRY.BERAKHIRNYA_STATUS_PT defined.

Steps

  • [ ] SABH status_transaksi semantics — ALREADY VERIFIED (2026-07-03; no live check needed). The discovery-report claim "status_transaksi == 3 = pembubaran recorded" was DISPROVEN by a controller-run read-only scan of live AHU_BADAN_HUKUM.tbl_transaksi_perseroan_<YYYY> (see the header erratum): pembubaran rows (id_aksi_transaksi=12) sit at status_transaksi=1 when APPROVED (2025: 1→4866, 3→1, 0→1; 2024: 1→3893, 0→8) — an approved transaction row is 1 regardless of aksi type; berakhirnya rows (id_aksi_transaksi=36) reach status_transaksi=6 when COMPLETED (2025: 6→121, 3→61, 7→43). The old deno-recipe live-check step is DROPPED (superseded). Constants below encode these facts; there is nothing left to verify at implementation time.
  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/berakhirnya-schema.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { SubmissionType } from "../../generated/prisma/enums";
import { processorKindForType } from "../../services/submission-dispatch";
import { APPROVED_TX_STATUS, BERAKHIRNYA_DONE_STATUS, PEMBUBARAN_AKSI, BERAKHIRNYA_AKSI, loadPembubaranRegistryState } from "../../services/company-lookup";
import { FLOW_REGISTRY } from "../registry";

/**
 * Task 1 — BERAKHIRNYA_STATUS_PT schema slice + the two compile-forced wiring
 * points (registry key + ProcessorKind case) + the empirical registry constants.
 * The registry entry is a TEMPORARY v1-fork stub until Task 6 registers the real
 * defineFlow config. ZERO other schema changes: the three DocumentTypes landed
 * with the registry slice; rupsKehadiranRegistry landed with Pembubaran; all
 * new-doc fields are ExtractedField rows (spec §6).
 */
describe("BERAKHIRNYA_STATUS_PT schema + compile-gate", () => {
  const ids: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: ids } } });
  });

  it("exposes the new enum value (distinct from the AKTA_BERAKHIRNYA label — exact equality everywhere)", () => {
    expect(SubmissionType.BERAKHIRNYA_STATUS_PT).toBe("BERAKHIRNYA_STATUS_PT");
    expect(SubmissionType.PEMBUBARAN_PT).toBe("PEMBUBARAN_PT"); // the PREREQUISITE flow — never aliased
  });

  it("persists a submission of the new type (reusing rupsKehadiranRegistry from the pembubaran slice)", async () => {
    const sub = await db.submission.create({
      data: {
        type: "BERAKHIRNYA_STATUS_PT", status: "CREATED",
        rupsKehadiranRegistry: [{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: null }],
      },
    });
    ids.push(sub.id);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.type).toBe("BERAKHIRNYA_STATUS_PT");
    expect(fresh?.rupsKehadiranRegistry).toEqual([{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: null }]);
  });

  it("routes the new type to the v2-generic ProcessorKind and a total registry", () => {
    expect(processorKindForType("BERAKHIRNYA_STATUS_PT")).toBe("v2-generic");
    expect(FLOW_REGISTRY.BERAKHIRNYA_STATUS_PT).toBeDefined();
  });

  it("exports the empirical registry constants (2026-07-03 erratum — supersede DISSOLVED_STATUS_TRANSAKSI=3)", () => {
    expect(APPROVED_TX_STATUS).toBe(1);
    expect(BERAKHIRNYA_DONE_STATUS).toBe(6);
    expect(PEMBUBARAN_AKSI).toBe(12);
    expect(BERAKHIRNYA_AKSI).toBe(36);
  });

  it("registry-state helper degrades to null when SABH is unavailable (test-setup blanks creds — never FAIL on outage)", async () => {
    expect(await loadPembubaranRegistryState(777, 2025)).toBeNull();
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/berakhirnya-schema.test.ts. Expected: fails — BERAKHIRNYA_STATUS_PT not on the enum (typecheck of the test file itself errors).
  • [ ] Minimal implementation A — backend/prisma/schema.prisma, inside enum SubmissionType { ... }, append after PEMBUBARAN_PT:
  /// Berakhirnya Status Badan Hukum PT (SABH id_aksi_transaksi = 36, code 70 —
  /// SABH's code path is misleadingly named PerubahanStatus*): the TERMINAL PT
  /// transaction, ends legal personhood AFTER pembubaran (id_aksi 12) + liquidation
  /// complete — the deed ACCEPTS the likuidator's final accounting (contrast
  /// PEMBUBARAN_PT, which STARTS liquidation). 6-PDF statutory package (deed,
  /// risalah RUPS, surat permohonan, laporan akhir likuidasi, pengumuman Ps.149 +
  /// Ps.152). NO diff — per-document review vs the registry snapshot. FOURTH
  /// (final) engine:'v2-generic' FLOW_REGISTRY tenant. VERIFIKATOR-STAGED (submit
  /// creates a VerifikasiPerubahan row; voter roster = oldData.pemegang_saham).
  /// Prerequisite (approved pembubaran in SABH) is enforced at validation by
  /// BSBHP_REGISTRY_STATE reading a registry snapshot, NOT by the company lookup
  /// (which uses the default status filter — 2026-07-03 erratum).
  BERAKHIRNYA_STATUS_PT
  • [ ] Minimal implementation B — backend/src/services/company-lookup.ts, directly below the DEFAULT_STATUS_TRANSAKSI declaration (~line 134), the empirical SABH-registry facts (the discovery-report DISSOLVED_STATUS_TRANSAKSI = 3 is REPLACED — see the 2026-07-03 erratum):
/**
 * SABH tbl_transaksi_perseroan_<YYYY> registry facts for the Berakhirnya
 * prerequisite gate (spec §4.5/§4.6). Empirically verified read-only against live
 * AHU_BADAN_HUKUM on 2026-07-03 (controller-run, per-year shards):
 *   pembubaran rows (id_aksi_transaksi=12), 2025: status_transaksi 1→4866, 3→1, 0→1;
 *     2024: 1→3893, 0→8 → an APPROVED transaction row sits at status_transaksi=1
 *     regardless of aksi type (an approved pembubaran is 1, NOT 3).
 *   berakhirnya rows (id_aksi_transaksi=36), 2025: 6→121, 3→61, 7→43 → status 6 =
 *     COMPLETED berakhirnya (matches cross-validator.ts 6=berakhirnya); 3 there is an
 *     in-progress wizard state, NOT a dissolved-PT marker.
 * These SUPERSEDE the discovery-report claim "status_transaksi==3 = pembubaran
 * recorded" (docs/research/sabh-pt-untouched-flows-2026-07-01.md).
 */
export const APPROVED_TX_STATUS = 1;      // an approved transaction row (any aksi)
export const BERAKHIRNYA_DONE_STATUS = 6; // a COMPLETED berakhirnya row (id_aksi 36)
export const PEMBUBARAN_AKSI = 12;        // id_aksi_transaksi for a pembubaran filing
export const BERAKHIRNYA_AKSI = 36;       // id_aksi_transaksi for a berakhirnya filing
  • [ ] Minimal implementation B2 — backend/src/services/company-lookup.ts, the read-only shard-aware registry-state helper (place next to loadPerseroanBlokirState; reuses the module's queryYearPartitioned + MAX_YEAR_SCAN + parseIntOrNull). The companyLookup row carries status_transaksi = 1 for EVERY approved row, so it cannot distinguish "approved pembubaran" from "plain active" — this helper scans the PT's transaction rows for the aksi/status pair the gate needs:
export interface PembubaranRegistryState {
  /** An APPROVED pembubaran row (id_aksi_transaksi=12, status_transaksi=1) exists. */
  approvedPembubaran: boolean;
  /** A COMPLETED berakhirnya row (id_aksi_transaksi=36, status_transaksi=6) exists. */
  completedBerakhirnya: boolean;
}

/**
 * Read-only, shard-aware scan of the PT's pembubaran/berakhirnya transaction rows —
 * the registry data BSBHP_REGISTRY_STATE gates on but the companyLookup payload does
 * NOT carry (the lookup row's status_transaksi is 1 for every approved row). Scans
 * per-year tbl_transaksi_perseroan_<YYYY>, newest shard first (company-lookup.ts
 * mechanics), keyed on id_perseroan. Returns null on SABH-unavailable / all-shard
 * error so the caller degrades to SKIPPED (partial-verification discipline — never
 * FAIL on an outage). The caller (buildBerakhirnyaContext) wraps this in withTimeout.
 */
export async function loadPembubaranRegistryState(
  idPerseroan: number,
  startYear: number,
): Promise<PembubaranRegistryState | null> {
  if (!isSabhAvailable()) return null;
  if (!Number.isFinite(idPerseroan)) return null;
  let approvedPembubaran = false;
  let completedBerakhirnya = false;
  let anyShardOk = false;
  for (let y = startYear; y >= startYear - MAX_YEAR_SCAN; y--) {
    try {
      const rows = await queryYearPartitioned(
        "tbl_transaksi_perseroan", y,
        `WHERE id_perseroan = ? AND ((id_aksi_transaksi = ? AND status_transaksi = ?) OR (id_aksi_transaksi = ? AND status_transaksi = ?))`,
        [idPerseroan, PEMBUBARAN_AKSI, APPROVED_TX_STATUS, BERAKHIRNYA_AKSI, BERAKHIRNYA_DONE_STATUS],
      );
      anyShardOk = true;
      for (const r of rows) {
        const aksi = parseIntOrNull(r.id_aksi_transaksi);
        const st = parseIntOrNull(r.status_transaksi);
        if (aksi === PEMBUBARAN_AKSI && st === APPROVED_TX_STATUS) approvedPembubaran = true;
        if (aksi === BERAKHIRNYA_AKSI && st === BERAKHIRNYA_DONE_STATUS) completedBerakhirnya = true;
      }
    } catch {
      continue; // per-shard: a missing/blipped shard must not fail the whole scan
    }
  }
  if (!anyShardOk) return null; // every shard errored → treat as outage → SKIPPED
  return { approvedPembubaran, completedBerakhirnya };
}
  • [ ] Minimal implementation C — backend/src/flow-engine/registry.ts, append to FLOW_REGISTRY after the PEMBUBARAN_PT line:
  // TEMPORARY v1-fork stub — satisfies Record totality until Task 6 registers the
  // real defineFlow(...) entry. processorKindForType already returns "v2-generic",
  // so a premature legacy dispatch throws LOUD (unreachable while the classifier
  // gate keeps the flow dark — BUILT_AKTA_TXN_TYPES flips in Task 7).
  BERAKHIRNYA_STATUS_PT: { engine: "v1-fork", type: "BERAKHIRNYA_STATUS_PT" },
  • [ ] Minimal implementation D — backend/src/services/submission-dispatch.ts, in processorKindForType, add after the PEMBUBARAN_PT case:
    case "BERAKHIRNYA_STATUS_PT":
      // v2-generic: dispatch resolves FLOW_REGISTRY BEFORE this switch; the
      // switch's "v2-generic" case below is a deliberate unreachable-throw.
      return "v2-generic";
  • [ ] Regenerate + sync test DB: cd backend && bunx prisma generate && bun run db:push:test.
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/berakhirnya-schema.test.ts. Expected: 6 pass.
  • [ ] Typecheck + stub-safety: cd backend && bunx tsc --noEmit && bun test src/flow-engine/__tests__/registry.test.ts src/flow-engine/__tests__/dispatch-routing.test.ts. Expected: clean — registry.test.ts's V2_SHIPPED set treats the stub as v1-fork NOW; Task 6 adds BERAKHIRNYA_STATUS_PT to that set.
  • [ ] Commit: git add backend/prisma/schema.prisma backend/src/generated/prisma backend/src/services/company-lookup.ts backend/src/flow-engine/registry.ts backend/src/services/submission-dispatch.ts backend/src/flow-engine/__tests__/berakhirnya-schema.test.ts && git commit -m "feat(berakhirnya): BERAKHIRNYA_STATUS_PT enum + empirical SABH registry constants + registry-state helper + compile-gate wiring" (cite the 2026-07-03 controller verification in the commit body — it supersedes the discovery-report st=3 claim).

Task 2: The two NEW grounded extractors (surat permohonan + laporan akhir likuidasi) + focused-pass handlers + OCR-only doc handlers

The flow's definitional net-new extraction (spec §4.4). Both mirror the SHARED clipping extractor's shape (bukti-pengumuman-extract.ts): ONE focused 3B guided-JSON call, temperature: 0, EVERY value grounded verbatim against rawText (hallucination → null), ExtractedField persistence in ONE atomic createMany (0.75 tier / FLAGGED-null), idempotent-once (any-key → skip-ALL — sound because the write is atomic). Laporan shape ONLY for laporan-likuidasi-extract.ts — the penunjukan letter is Pembubaran's DISTINCT SURAT_LIKUIDATOR (C2). Each extractor also grounds the letter's PT name (permohonan_nama_perseroan / laporan_nama_perseroan) — the carrier keys for BSBHP_NAMA_PT_DOCS (the spec-§4.5 NAMA_PT_CONSISTENCY extension, adjudicated as a flow rule). Plus the two document-processor.ts OCR-only cases — without them the letters fall through to the generic akta handler and burn an akta-VLM extraction.

Files
- backend/src/services/surat-permohonan-extract.ts — Create
- backend/src/services/laporan-likuidasi-extract.ts — Create
- backend/src/flow-engine/focused-passes.ts — Modify (two map entries)
- backend/src/services/document-processor.ts — Modify (cases 6l + 6m directly after the SURAT_LIKUIDATOR case, ~line 2515)
- backend/src/services/__tests__/surat-permohonan-extract.test.ts — Create
- backend/src/services/__tests__/laporan-likuidasi-extract.test.ts — Create

Interfaces
- Consumes: CLEANUP_LLM_URL/CLEANUP_LLM_MODEL/CLEANUP_LLM_TIMEOUT_MS env (3B); db.
- Produces:
- const PERMOHONAN_EXTRACTED_FIELD_KEYS = ["nomor_surat_permohonan", "tanggal_surat_permohonan", "permohonan_nama_perseroan"] as const
- interface SuratPermohonanFields (3 nullable strings) + extractSuratPermohonan(rawText) + runSuratPermohonanPass(args)
- const LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS = ["nama_likuidator", "nomor_laporan_likuidasi", "tanggal_laporan_likuidasi", "sisa_kekayaan", "laporan_nama_perseroan"] as const
- interface LaporanLikuidasiFields (5 nullable strings) + extractLaporanLikuidasi(rawText) + runLaporanLikuidasiPass(args)
- FOCUSED_PASS_HANDLERS["surat-permohonan"], FOCUSED_PASS_HANDLERS["laporan-likuidasi"]

Steps

  • [ ] Write failing tests. Create backend/src/services/__tests__/surat-permohonan-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import {
  extractSuratPermohonan, runSuratPermohonanPass, PERMOHONAN_EXTRACTED_FIELD_KEYS,
} from "../surat-permohonan-extract";

const RAW =
  "SURAT PERMOHONAN\nNomor: 012/BSBH/VI/2026\nJakarta, 15 Juni 2026\n" +
  "Perihal: Permohonan pemberitahuan berakhirnya status badan hukum\n" +
  "PT KARYA USAI SUDAH berkedudukan di Jakarta Selatan,\n" +
  "sehubungan dengan telah selesainya proses likuidasi.";

function mockLlm(content: object | null, status = 200) {
  const orig = globalThis.fetch;
  globalThis.fetch = (async () =>
    new Response(
      JSON.stringify({ choices: [{ message: { content: content ? JSON.stringify(content) : "" } }] }),
      { status },
    )) as typeof fetch;
  return () => { globalThis.fetch = orig; };
}

const LLM_FULL = {
  nomor_surat_permohonan: "012/BSBH/VI/2026",
  tanggal_surat_permohonan: "15 Juni 2026",
  permohonan_nama_perseroan: "PT KARYA USAI SUDAH",
};

describe("extractSuratPermohonan (grounded 3B — filing-letter shape)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("grounds all three values verbatim (word-form date stored RAW — apostille convention)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const r = await extractSuratPermohonan(RAW);
      expect(r.nomor_surat_permohonan).toBe("012/BSBH/VI/2026");
      expect(r.tanggal_surat_permohonan).toBe("15 Juni 2026"); // raw word form, parsed at rule time
      expect(r.permohonan_nama_perseroan).toBe("PT KARYA USAI SUDAH");
    } finally { restore(); }
  });

  it("rejects hallucinated values → null", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ ...LLM_FULL, nomor_surat_permohonan: "999/PALSU/2026", permohonan_nama_perseroan: "PT HALUSINASI" });
    try {
      const r = await extractSuratPermohonan(RAW);
      expect(r.nomor_surat_permohonan).toBeNull();
      expect(r.permohonan_nama_perseroan).toBeNull();
      expect(r.tanggal_surat_permohonan).toBe("15 Juni 2026"); // grounded value survives
    } finally { restore(); }
  });

  it("outage / empty text → all-null; never throws", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(null, 500);
    try {
      const r = await extractSuratPermohonan(RAW);
      expect(Object.values(r).every((v) => v === null)).toBe(true);
      const empty = await extractSuratPermohonan("");
      expect(Object.values(empty).every((v) => v === null)).toBe(true);
    } finally { restore(); }
  });
});

describe("runSuratPermohonanPass persistence (3 ExtractedField rows, atomic, idempotent-once)", () => {
  const docIds: string[] = [];
  afterAll(async () => { await db.document.deleteMany({ where: { id: { in: docIds } } }); });

  async function seedDoc() {
    const doc = await db.document.create({
      data: {
        filename: "sp.pdf", originalName: "sp.pdf", fileSize: 0, filePath: "/tmp/sp.pdf",
        documentType: "SURAT_PERMOHONAN", status: "VERIFICATION_READY", rawText: RAW,
      },
    });
    docIds.push(doc.id);
    return doc.id;
  }

  it("persists ALL 3 canonical rows in one shot (0.75 grounded tier; null → FLAGGED)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ ...LLM_FULL, permohonan_nama_perseroan: null });
    try {
      const documentId = await seedDoc();
      await runSuratPermohonanPass({ submissionId: "s", documentId, rawText: RAW });
      const rows = await db.extractedField.findMany({ where: { documentId } });
      expect(rows.length).toBe(PERMOHONAN_EXTRACTED_FIELD_KEYS.length);
      const nomor = rows.find((r) => r.fieldKey === "nomor_surat_permohonan");
      expect(nomor?.value).toBe("012/BSBH/VI/2026");
      expect(nomor?.confidence).toBe(0.75); // "please review" tier — never auto-LOCK
      expect(nomor?.status).toBe("EDITABLE");
      const nama = rows.find((r) => r.fieldKey === "permohonan_nama_perseroan");
      expect(nama?.value).toBeNull();
      expect(nama?.status).toBe("FLAGGED");
    } finally { restore(); }
  });

  it("is idempotent-once: NEVER clobbers existing rows (operator edit survives resume)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const documentId = await seedDoc();
      await db.extractedField.create({
        data: { documentId, fieldKey: "nomor_surat_permohonan", value: "EDIT OPERATOR", confidence: 1, status: "MANUAL" },
      });
      await runSuratPermohonanPass({ submissionId: "s", documentId, rawText: RAW });
      const rows = await db.extractedField.findMany({ where: { documentId } });
      expect(rows.length).toBe(1); // any-key → skip-ALL (sound: the write is atomic)
      expect(rows[0]?.value).toBe("EDIT OPERATOR");
    } finally { restore(); }
  });

  it("skips loudly (no rows) on empty rawText — manual entry path, never an ERROR", async () => {
    const documentId = await seedDoc();
    await runSuratPermohonanPass({ submissionId: "s", documentId, rawText: "" });
    expect(await db.extractedField.findMany({ where: { documentId } })).toEqual([]);
  });
});
  • [ ] Create backend/src/services/__tests__/laporan-likuidasi-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import {
  extractLaporanLikuidasi, runLaporanLikuidasiPass, LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS,
} from "../laporan-likuidasi-extract";

const RAW =
  "LAPORAN AKHIR LIKUIDASI\nNomor: 03/LAL/VI/2026\nJakarta, 10 Juni 2026\n" +
  "Likuidator PT KARYA USAI SUDAH (dalam likuidasi), BUDI HARTONO, S.H.,\n" +
  "dengan ini menyampaikan pertanggungjawaban likuidator.\n" +
  "Sisa kekayaan hasil likuidasi: Rp 12.500.000\n" +
  "telah dibagikan kepada para pemegang saham.";

function mockLlm(content: object | null, status = 200) {
  const orig = globalThis.fetch;
  globalThis.fetch = (async () =>
    new Response(
      JSON.stringify({ choices: [{ message: { content: content ? JSON.stringify(content) : "" } }] }),
      { status },
    )) as typeof fetch;
  return () => { globalThis.fetch = orig; };
}

const LLM_FULL = {
  nama_likuidator: "BUDI HARTONO, S.H.",
  nomor_laporan_likuidasi: "03/LAL/VI/2026",
  tanggal_laporan_likuidasi: "10 Juni 2026",
  sisa_kekayaan: "Rp 12.500.000",
  laporan_nama_perseroan: "PT KARYA USAI SUDAH",
};

describe("extractLaporanLikuidasi (grounded 3B — LAPORAN shape ONLY, C2)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("grounds all five values verbatim (incl. sisa_kekayaan amount + word-form date)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const r = await extractLaporanLikuidasi(RAW);
      expect(r.nama_likuidator).toBe("BUDI HARTONO, S.H.");
      expect(r.nomor_laporan_likuidasi).toBe("03/LAL/VI/2026");
      expect(r.tanggal_laporan_likuidasi).toBe("10 Juni 2026");
      expect(r.sisa_kekayaan).toBe("Rp 12.500.000");
      expect(r.laporan_nama_perseroan).toBe("PT KARYA USAI SUDAH");
    } finally { restore(); }
  });

  it("grounds 'nihil' sisa kekayaan verbatim", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const nihilRaw = RAW.replace("Rp 12.500.000", "nihil");
    const restore = mockLlm({ ...LLM_FULL, sisa_kekayaan: "nihil" });
    try {
      const r = await extractLaporanLikuidasi(nihilRaw);
      expect(r.sisa_kekayaan).toBe("nihil");
    } finally { restore(); }
  });

  it("rejects hallucinated values → null", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ ...LLM_FULL, nama_likuidator: "SINTA HALUSINASI", sisa_kekayaan: "Rp 999.999.999" });
    try {
      const r = await extractLaporanLikuidasi(RAW);
      expect(r.nama_likuidator).toBeNull();
      expect(r.sisa_kekayaan).toBeNull();
    } finally { restore(); }
  });

  it("outage / empty text → all-null; never throws", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(null, 500);
    try {
      const r = await extractLaporanLikuidasi(RAW);
      expect(Object.values(r).every((v) => v === null)).toBe(true);
      const empty = await extractLaporanLikuidasi("");
      expect(Object.values(empty).every((v) => v === null)).toBe(true);
    } finally { restore(); }
  });
});

describe("runLaporanLikuidasiPass persistence (5 ExtractedField rows, atomic, idempotent-once)", () => {
  const docIds: string[] = [];
  afterAll(async () => { await db.document.deleteMany({ where: { id: { in: docIds } } }); });

  async function seedDoc() {
    const doc = await db.document.create({
      data: {
        filename: "lal.pdf", originalName: "lal.pdf", fileSize: 0, filePath: "/tmp/lal.pdf",
        documentType: "LAPORAN_LIKUIDASI", status: "VERIFICATION_READY", rawText: RAW,
      },
    });
    docIds.push(doc.id);
    return doc.id;
  }

  it("persists ALL 5 canonical rows in one shot", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const documentId = await seedDoc();
      await runLaporanLikuidasiPass({ submissionId: "s", documentId, rawText: RAW });
      const rows = await db.extractedField.findMany({ where: { documentId } });
      expect(rows.length).toBe(LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS.length);
      expect(rows.find((r) => r.fieldKey === "sisa_kekayaan")?.value).toBe("Rp 12.500.000");
      expect(rows.find((r) => r.fieldKey === "nama_likuidator")?.confidence).toBe(0.75);
    } finally { restore(); }
  });

  it("is idempotent-once (operator edit survives resume)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const documentId = await seedDoc();
      await db.extractedField.create({
        data: { documentId, fieldKey: "nama_likuidator", value: "EDIT OPERATOR", confidence: 1, status: "MANUAL" },
      });
      await runLaporanLikuidasiPass({ submissionId: "s", documentId, rawText: RAW });
      const rows = await db.extractedField.findMany({ where: { documentId } });
      expect(rows.length).toBe(1);
      expect(rows[0]?.value).toBe("EDIT OPERATOR");
    } finally { restore(); }
  });

  it("skips loudly on empty rawText", async () => {
    const documentId = await seedDoc();
    await runLaporanLikuidasiPass({ submissionId: "s", documentId, rawText: "" });
    expect(await db.extractedField.findMany({ where: { documentId } })).toEqual([]);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/services/__tests__/surat-permohonan-extract.test.ts src/services/__tests__/laporan-likuidasi-extract.test.ts. Expected: fails — modules not found.
  • [ ] Minimal implementation A — create backend/src/services/surat-permohonan-extract.ts:
/**
 * Focused, grounded SURAT_PERMOHONAN extractor — BERAKHIRNYA_STATUS_PT's filing
 * letter (SABH PDF #3; spec §4.4). DISTINCT from SURAT_PERMOHONAN_KORPORASI (the
 * Perbaikan-korporasi letter) — schema doc-comment + exact-equality mapping keep
 * them apart. Shape mirrors the SHARED clipping extractor (bukti-pengumuman-
 * extract.ts): ONE 3B guided-JSON call (CLEANUP_LLM, temperature 0), every value
 * grounded VERBATIM against rawText (hallucination → null, never an ERROR).
 * Dates stored RAW (word form allowed — parsed at rule time via parseFlexibleDate;
 * apostille date convention). permohonan_nama_perseroan carries the letter's PT
 * name for BSBHP_NAMA_PT_DOCS (spec §4.5 NAMA_PT_CONSISTENCY extension).
 * Persistence: 3 ExtractedField rows (spec §6 — no satellite table), grounded
 * 0.75 "please review" tier, ONE atomic createMany, IDEMPOTENT-ONCE.
 */
import { db } from "../lib/db";

export interface SuratPermohonanFields {
  nomor_surat_permohonan: string | null;
  tanggal_surat_permohonan: string | null;
  permohonan_nama_perseroan: string | null;
}

export const PERMOHONAN_EXTRACTED_FIELD_KEYS = [
  "nomor_surat_permohonan", "tanggal_surat_permohonan", "permohonan_nama_perseroan",
] as const;

const NONE: SuratPermohonanFields = {
  nomor_surat_permohonan: null, tanggal_surat_permohonan: null, permohonan_nama_perseroan: null,
};

export async function extractSuratPermohonan(rawText: string): Promise<SuratPermohonanFields> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  if (!url) return { ...NONE };
  if (!rawText.trim()) {
    console.warn("[surat-permohonan] rawText empty — extraction skipped (GPU-OCR path?)");
    return { ...NONE };
  }
  const system =
    "From an Indonesian surat permohonan pemberitahuan berakhirnya status badan hukum perseroan " +
    "(the filing letter to the Minister after a PT's liquidation completes), extract: " +
    "nomor_surat_permohonan (the letter number as printed, e.g. '012/BSBH/VI/2026'), " +
    "tanggal_surat_permohonan (the letter date exactly as written — word form allowed), " +
    "permohonan_nama_perseroan (the PT name the letter concerns, including the 'PT' prefix if printed). " +
    "Copy VERBATIM from the text; if absent, return null. Never invent. " +
    "Return ONLY JSON {nomor_surat_permohonan, tanggal_surat_permohonan, permohonan_nama_perseroan}.";
  try {
    const res = await fetch(`${url}/chat/completions`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model, temperature: 0,
        messages: [
          { role: "system", content: system },
          { role: "user", content: rawText.slice(0, 12000) },
        ],
        guided_json: {
          type: "object",
          properties: Object.fromEntries(PERMOHONAN_EXTRACTED_FIELD_KEYS.map((k) => [k, { type: ["string", "null"] }])),
          required: [...PERMOHONAN_EXTRACTED_FIELD_KEYS],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return { ...NONE };
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return { ...NONE };
    const parsed = JSON.parse(m[0]) as Partial<Record<keyof SuratPermohonanFields, string | null>>;
    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
    const hay = norm(rawText);
    const ground = (v: string | null | undefined): string | null => {
      const s = v ? String(v).trim() : "";
      return s && hay.includes(norm(s)) ? s : null;
    };
    return {
      nomor_surat_permohonan: ground(parsed.nomor_surat_permohonan),
      tanggal_surat_permohonan: ground(parsed.tanggal_surat_permohonan),
      permohonan_nama_perseroan: ground(parsed.permohonan_nama_perseroan),
    };
  } catch (err) {
    console.warn("[surat-permohonan] 3B extraction failed:", err);
    return { ...NONE };
  }
}

/** The 'surat-permohonan' focused-pass body (invoked by flow-engine focusedPasses). */
export async function runSuratPermohonanPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> {
  const { documentId, rawText } = args;
  if (!rawText.trim()) {
    console.warn(`[surat-permohonan] no rawText for doc ${documentId} — focused pass skipped (manual entry via PUT /permohonan)`);
    return;
  }
  const existing = await db.extractedField.findMany({
    where: { documentId, fieldKey: { in: [...PERMOHONAN_EXTRACTED_FIELD_KEYS] } },
    select: { id: true },
  });
  if (existing.length > 0) return; // idempotent-once — operator edits survive engine resume
  const r = await extractSuratPermohonan(rawText);
  await db.extractedField.createMany({
    data: PERMOHONAN_EXTRACTED_FIELD_KEYS.map((fieldKey) => {
      const value = r[fieldKey];
      return {
        documentId, fieldKey, value,
        confidence: value ? 0.75 : 0, // grounded-3B "please review" tier — never auto-LOCK
        status: value ? "EDITABLE" : "FLAGGED",
      };
    }),
  });
}
  • [ ] Minimal implementation B — create backend/src/services/laporan-likuidasi-extract.ts:
/**
 * Focused, grounded LAPORAN_LIKUIDASI extractor — the likuidator's final
 * accounting (laporan akhir / pertanggungjawaban; SABH PDF #4; spec §4.4).
 * LAPORAN SHAPE ONLY: the penunjukan letter is Pembubaran's DISTINCT
 * SURAT_LIKUIDATOR DocumentType (surat-likuidator-extract.ts) — the doc-set
 * predicate keys on that distinction (C2), so this extractor must never swallow
 * it. sisa_kekayaan is the SABH-typed remaining-assets field — deeds/letters
 * state it in prose (an Rp amount or the word "nihil"); grounded verbatim.
 * laporan_nama_perseroan carries the letter's PT name for BSBHP_NAMA_PT_DOCS.
 * Persistence: 5 ExtractedField rows, 0.75 tier, ONE atomic createMany,
 * IDEMPOTENT-ONCE. Dates stored RAW (apostille date convention).
 */
import { db } from "../lib/db";

export interface LaporanLikuidasiFields {
  nama_likuidator: string | null;
  nomor_laporan_likuidasi: string | null;
  tanggal_laporan_likuidasi: string | null;
  sisa_kekayaan: string | null;
  laporan_nama_perseroan: string | null;
}

export const LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS = [
  "nama_likuidator", "nomor_laporan_likuidasi", "tanggal_laporan_likuidasi",
  "sisa_kekayaan", "laporan_nama_perseroan",
] as const;

const NONE: LaporanLikuidasiFields = {
  nama_likuidator: null, nomor_laporan_likuidasi: null, tanggal_laporan_likuidasi: null,
  sisa_kekayaan: null, laporan_nama_perseroan: null,
};

export async function extractLaporanLikuidasi(rawText: string): Promise<LaporanLikuidasiFields> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  if (!url) return { ...NONE };
  if (!rawText.trim()) {
    console.warn("[laporan-likuidasi] rawText empty — extraction skipped (GPU-OCR path?)");
    return { ...NONE };
  }
  const system =
    "From an Indonesian laporan akhir likuidasi / pertanggungjawaban likuidator " +
    "(the liquidator's FINAL accounting for a dissolved PT — NOT the appointment letter), extract: " +
    "nama_likuidator (the liquidator's full name incl. titles), " +
    "nomor_laporan_likuidasi (the report/letter number as printed), " +
    "tanggal_laporan_likuidasi (the report date exactly as written — word form allowed), " +
    "sisa_kekayaan (the remaining-assets statement exactly as printed — an amount like 'Rp 12.500.000' or the word 'nihil'), " +
    "laporan_nama_perseroan (the PT name the report concerns). " +
    "Copy VERBATIM from the text; if absent, return null. Never invent. " +
    "Return ONLY JSON {nama_likuidator, nomor_laporan_likuidasi, tanggal_laporan_likuidasi, sisa_kekayaan, laporan_nama_perseroan}.";
  try {
    const res = await fetch(`${url}/chat/completions`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model, temperature: 0,
        messages: [
          { role: "system", content: system },
          { role: "user", content: rawText.slice(0, 12000) },
        ],
        guided_json: {
          type: "object",
          properties: Object.fromEntries(LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS.map((k) => [k, { type: ["string", "null"] }])),
          required: [...LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return { ...NONE };
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return { ...NONE };
    const parsed = JSON.parse(m[0]) as Partial<Record<keyof LaporanLikuidasiFields, string | null>>;
    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
    const hay = norm(rawText);
    const ground = (v: string | null | undefined): string | null => {
      const s = v ? String(v).trim() : "";
      return s && hay.includes(norm(s)) ? s : null;
    };
    return {
      nama_likuidator: ground(parsed.nama_likuidator),
      nomor_laporan_likuidasi: ground(parsed.nomor_laporan_likuidasi),
      tanggal_laporan_likuidasi: ground(parsed.tanggal_laporan_likuidasi),
      sisa_kekayaan: ground(parsed.sisa_kekayaan),
      laporan_nama_perseroan: ground(parsed.laporan_nama_perseroan),
    };
  } catch (err) {
    console.warn("[laporan-likuidasi] 3B extraction failed:", err);
    return { ...NONE };
  }
}

/** The 'laporan-likuidasi' focused-pass body (invoked by flow-engine focusedPasses). */
export async function runLaporanLikuidasiPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> {
  const { documentId, rawText } = args;
  if (!rawText.trim()) {
    console.warn(`[laporan-likuidasi] no rawText for doc ${documentId} — focused pass skipped (manual entry via PUT /laporan-likuidasi)`);
    return;
  }
  const existing = await db.extractedField.findMany({
    where: { documentId, fieldKey: { in: [...LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS] } },
    select: { id: true },
  });
  if (existing.length > 0) return; // idempotent-once — operator edits survive engine resume
  const r = await extractLaporanLikuidasi(rawText);
  await db.extractedField.createMany({
    data: LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS.map((fieldKey) => {
      const value = r[fieldKey];
      return {
        documentId, fieldKey, value,
        confidence: value ? 0.75 : 0,
        status: value ? "EDITABLE" : "FLAGGED",
      };
    }),
  });
}
  • [ ] Minimal implementation C — backend/src/flow-engine/focused-passes.ts, append to FOCUSED_PASS_HANDLERS after the "surat-likuidator" entry:
  "surat-permohonan": async (args) => {
    const { runSuratPermohonanPass } = await import("../services/surat-permohonan-extract");
    await runSuratPermohonanPass(args);
  },
  "laporan-likuidasi": async (args) => {
    const { runLaporanLikuidasiPass } = await import("../services/laporan-likuidasi-extract");
    await runLaporanLikuidasiPass(args);
  },
  • [ ] Minimal implementation D — backend/src/services/document-processor.ts, directly after the SURAT_LIKUIDATOR case (6k, ~line 2515), insert:
    // 6l. SURAT_PERMOHONAN (berakhirnya filing letter) — OCR-ONLY. The grounded
    // extraction runs as the flow-engine 'surat-permohonan' focusedPasses pass,
    // NOT here. Without this case the letter falls through to the generic Akta
    // handler and burns an akta-VLM extraction on a 1-page letter.
    if (doc.documentType === "SURAT_PERMOHONAN") {
      const layoutProvider = await getLayoutProvider("paddleocr");
      const ocr = await layoutProvider.extractTextWithWords(fileBuffer, { unwarp: false, orient: false });
      await db.document.update({
        where: { id: documentId },
        data: { rawText: ocr.rawText ?? "", pageCount: ocr.pageCount ?? 1, status: "VERIFICATION_READY" },
      });
      return;
    }

    // 6m. LAPORAN_LIKUIDASI (laporan akhir likuidasi, Berakhirnya) — OCR-ONLY.
    // The grounded 5-field extraction runs as the flow-engine 'laporan-likuidasi'
    // focusedPasses pass, NOT here.
    if (doc.documentType === "LAPORAN_LIKUIDASI") {
      const layoutProvider = await getLayoutProvider("paddleocr");
      const ocr = await layoutProvider.extractTextWithWords(fileBuffer, { unwarp: false, orient: false });
      await db.document.update({
        where: { id: documentId },
        data: { rawText: ocr.rawText ?? "", pageCount: ocr.pageCount ?? 1, status: "VERIFICATION_READY" },
      });
      return;
    }
  • [ ] Run GREEN: cd backend && bun test src/services/__tests__/surat-permohonan-extract.test.ts src/services/__tests__/laporan-likuidasi-extract.test.ts src/flow-engine/__tests__/focused-passes.test.ts. Expected: all pass (the focused-passes suite proves the registry additions don't break the runner).
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/services/surat-permohonan-extract.ts backend/src/services/laporan-likuidasi-extract.ts backend/src/flow-engine/focused-passes.ts backend/src/services/document-processor.ts backend/src/services/__tests__/surat-permohonan-extract.test.ts backend/src/services/__tests__/laporan-likuidasi-extract.test.ts && git commit -m "feat(berakhirnya): grounded surat-permohonan + laporan-likuidasi extractors + focused passes + OCR-only handlers"

Task 3: Deed hook (pembubaran-ref grounded pass) + RUPS attendance-matcher widening

Two deed-adjacent seams. (1) akta-pembubaran-ref-extract.ts — the hooks.afterAktaExtraction body: the final PKR's premisse recites the EARLIER dissolution deed; a grounded 3B pass writes nomor_akta_pembubaran + tanggal_akta_pembubaran as ExtractedField rows ON THE DEED document (spec §4.4 — consumed by BSBHP_PEMBUBARAN_REF and the review page's Akta card; re-runs via the hook on re-extract, idempotent-once). (2) Matcher wideningrups-attendance-matcher.ts's akta filter gains AKTA_BERAKHIRNYA (a deed that restates PS rows gets ticks; the merge-preserve landed in a9ca0e8 is inherited UNTOUCHED) and the registry-roster fallback scope gains BERAKHIRNYA_STATUS_PT (SABH's berakhirnya kehadiran checkboxes are against the REGISTRY roster — same reality as pembubaran; a dissolved PT's discharge RUPS is voted by the registry shareholder list).

Files
- backend/src/services/akta-pembubaran-ref-extract.ts — Create
- backend/src/services/rups-attendance-matcher.ts — Modify (two scope lines ONLY — lines 52 and 94)
- backend/src/services/__tests__/akta-pembubaran-ref-extract.test.ts — Create
- backend/src/services/__tests__/berakhirnya-rups-fallback.test.ts — Create

Interfaces
- Produces: const PEMBUBARAN_REF_FIELD_KEYS = ["nomor_akta_pembubaran", "tanggal_akta_pembubaran"] as const; extractPembubaranRef(rawText); runBerakhirnyaDeedPasses(a: { submissionId; aktaDocumentId; rawText }) (the Task-6 hook body); the widened matchRupsAttendance scopes.

Steps

  • [ ] Write failing test. Create backend/src/services/__tests__/akta-pembubaran-ref-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import {
  extractPembubaranRef, runBerakhirnyaDeedPasses, PEMBUBARAN_REF_FIELD_KEYS,
} from "../akta-pembubaran-ref-extract";

// The deed's OWN nomor is 7; the recited pembubaran deed is Nomor 5 — the pass
// must return the RECITED one (prompt is explicit; both are grounded, so the
// grounding tier cannot disambiguate — the prompt + WARNING-tier rule keep it safe).
const RAW =
  "AKTA BERAKHIRNYA STATUS BADAN HUKUM\nPT KARYA USAI SUDAH (dalam likuidasi)\nNomor 7, tanggal 20 Juni 2026\n" +
  "bahwa berdasarkan Akta Pembubaran Nomor 5 tanggal 30 April 2026 yang dibuat di hadapan\n" +
  "notaris RATNA SARI, S.H., Perseroan telah dibubarkan dan likuidator telah ditunjuk;\n" +
  "bahwa RUPS tanggal 18 Juni 2026 telah menerima pertanggungjawaban likuidator.";

function mockLlm(content: object | null, status = 200) {
  const orig = globalThis.fetch;
  globalThis.fetch = (async () =>
    new Response(
      JSON.stringify({ choices: [{ message: { content: content ? JSON.stringify(content) : "" } }] }),
      { status },
    )) as typeof fetch;
  return () => { globalThis.fetch = orig; };
}

describe("extractPembubaranRef (grounded 3B — the premisse's dissolution-deed recital)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("grounds the recited nomor + tanggal verbatim", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ nomor_akta_pembubaran: "5", tanggal_akta_pembubaran: "30 April 2026" });
    try {
      const r = await extractPembubaranRef(RAW);
      expect(r.nomor_akta_pembubaran).toBe("5");
      expect(r.tanggal_akta_pembubaran).toBe("30 April 2026");
    } finally { restore(); }
  });

  it("rejects hallucinated values → null; outage → all-null", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    let restore = mockLlm({ nomor_akta_pembubaran: "99", tanggal_akta_pembubaran: "1 Januari 2020" });
    try {
      const r = await extractPembubaranRef(RAW);
      expect(r.nomor_akta_pembubaran).toBeNull();
      expect(r.tanggal_akta_pembubaran).toBeNull();
    } finally { restore(); }
    restore = mockLlm(null, 500);
    try {
      const out = await extractPembubaranRef(RAW);
      expect(out.nomor_akta_pembubaran).toBeNull();
    } finally { restore(); }
  });
});

describe("runBerakhirnyaDeedPasses persistence (2 ExtractedField rows on the DEED, atomic, idempotent-once)", () => {
  const docIds: string[] = [];
  afterAll(async () => { await db.document.deleteMany({ where: { id: { in: docIds } } }); });

  async function seedAkta() {
    const doc = await db.document.create({
      data: {
        filename: "akta.pdf", originalName: "akta.pdf", fileSize: 0, filePath: "/tmp/akta.pdf",
        documentType: "AKTA", status: "VERIFICATION_READY", rawText: RAW,
      },
    });
    docIds.push(doc.id);
    return doc.id;
  }

  it("writes both canonical rows (0.75 / FLAGGED-null)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ nomor_akta_pembubaran: "5", tanggal_akta_pembubaran: null });
    try {
      const aktaDocumentId = await seedAkta();
      await runBerakhirnyaDeedPasses({ submissionId: "s", aktaDocumentId, rawText: RAW });
      const rows = await db.extractedField.findMany({
        where: { documentId: aktaDocumentId, fieldKey: { in: [...PEMBUBARAN_REF_FIELD_KEYS] } },
      });
      expect(rows.length).toBe(2);
      expect(rows.find((r) => r.fieldKey === "nomor_akta_pembubaran")?.value).toBe("5");
      expect(rows.find((r) => r.fieldKey === "nomor_akta_pembubaran")?.confidence).toBe(0.75);
      expect(rows.find((r) => r.fieldKey === "tanggal_akta_pembubaran")?.status).toBe("FLAGGED");
    } finally { restore(); }
  });

  it("is idempotent-once: a resume/re-extract never clobbers operator edits", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ nomor_akta_pembubaran: "5", tanggal_akta_pembubaran: "30 April 2026" });
    try {
      const aktaDocumentId = await seedAkta();
      await db.extractedField.create({
        data: { documentId: aktaDocumentId, fieldKey: "nomor_akta_pembubaran", value: "EDIT", confidence: 1, status: "MANUAL" },
      });
      await runBerakhirnyaDeedPasses({ submissionId: "s", aktaDocumentId, rawText: RAW });
      const rows = await db.extractedField.findMany({
        where: { documentId: aktaDocumentId, fieldKey: { in: [...PEMBUBARAN_REF_FIELD_KEYS] } },
      });
      expect(rows.length).toBe(1);
      expect(rows[0]?.value).toBe("EDIT");
    } finally { restore(); }
  });

  it("skips loudly on empty rawText (GPU-akta path) — never a processing failure", async () => {
    const aktaDocumentId = await seedAkta();
    await runBerakhirnyaDeedPasses({ submissionId: "s", aktaDocumentId, rawText: "" });
    expect(await db.extractedField.findMany({
      where: { documentId: aktaDocumentId, fieldKey: { in: [...PEMBUBARAN_REF_FIELD_KEYS] } },
    })).toEqual([]);
  });
});
  • [ ] Create backend/src/services/__tests__/berakhirnya-rups-fallback.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { matchRupsAttendance } from "../rups-attendance-matcher";

/**
 * Task 3 — matcher widening ONLY. The merge-preserve semantics (a9ca0e8 I1 +
 * 3c2906c) are inherited and RE-ASSERTED here on the berakhirnya path; the two
 * scope lines are the only diff. Inertness: non-berakhirnya/non-pembubaran types
 * still never grow the registry JSON.
 */
describe("matchRupsAttendance × BERAKHIRNYA_STATUS_PT", () => {
  const subIds: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: subIds } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seed(type: string, opts: { deedPs?: boolean; existingRegistry?: unknown } = {}) {
    const sub = await db.submission.create({
      data: {
        type: type as never, status: "VALIDATING",
        oldData: { perseroan: { nama: "PT KARYA USAI SUDAH", id_perseroan: 777 },
          pemegang_saham: [{ nama: "BUDI SANTOSO", persentase: 60 }, { nama: "SITI AMINAH", persentase: 40 }] },
        ...(opts.existingRegistry !== undefined ? { rupsKehadiranRegistry: opts.existingRegistry as never } : {}),
      },
    });
    subIds.push(sub.id);
    const akta = await db.document.create({
      data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
    });
    docIds.push(akta.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_BERAKHIRNYA", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    if (opts.deedPs) {
      await db.aktaPemegangSaham.create({
        data: { documentId: akta.id, namaAtauBadanHukum: "BUDI SANTOSO", persentase: 60, orderIndex: 0 },
      });
    }
    const ba = await db.document.create({
      data: { filename: "ba.pdf", originalName: "ba.pdf", fileSize: 0, filePath: "/tmp/ba.pdf", documentType: "BERITA_ACARA_RUPS", status: "VERIFICATION_READY" },
    });
    docIds.push(ba.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: ba.id, classifiedType: "BERITA_ACARA_RUPS", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
    });
    await db.beritaAcaraRupsExtraction.create({
      data: {
        documentId: ba.id, tanggalRups: "18 Juni 2026",
        attendees: [{ nama: "BUDI SANTOSO", kehadiran: "hadir" }, { nama: "SITI AMINAH", kehadiran: "tidak_hadir" }],
      },
    });
    return { sub, aktaId: akta.id };
  }

  it("deed roster path: AKTA_BERAKHIRNYA now joins the akta filter (ticks land on deed PS rows)", async () => {
    const { sub, aktaId } = await seed("BERAKHIRNYA_STATUS_PT", { deedPs: true });
    const r = await matchRupsAttendance(sub.id, db);
    expect(r.matched).toBe(1);
    const ps = await db.aktaPemegangSaham.findFirst({ where: { documentId: aktaId } });
    expect(ps?.kehadiranRups).toBe(true);
  });

  it("registry fallback: zero deed PS rows → rupsKehadiranRegistry built from oldData + BA match", async () => {
    const { sub } = await seed("BERAKHIRNYA_STATUS_PT");
    const r = await matchRupsAttendance(sub.id, db);
    expect(r.matched).toBe(2);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.rupsKehadiranRegistry).toEqual([
      { nama: "BUDI SANTOSO", persentase: 60, kehadiran: true },
      { nama: "SITI AMINAH", persentase: 40, kehadiran: false },
    ]);
  });

  it("merge-preserve INHERITED: an existing non-null operator tick WINS over the fresh BA match", async () => {
    const { sub } = await seed("BERAKHIRNYA_STATUS_PT", {
      existingRegistry: [{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: false }], // operator says TIDAK HADIR
    });
    await matchRupsAttendance(sub.id, db);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    const rows = fresh?.rupsKehadiranRegistry as { nama: string; kehadiran: boolean | null }[];
    expect(rows.find((x) => x.nama === "BUDI SANTOSO")?.kehadiran).toBe(false); // preserved
    expect(rows.find((x) => x.nama === "SITI AMINAH")?.kehadiran).toBe(false);  // null slot took fresh match
  });

  it("inertness: a PERUBAHAN_PT submission never grows the registry JSON (scope is exact-equality)", async () => {
    const { sub } = await seed("PERUBAHAN_PT");
    const r = await matchRupsAttendance(sub.id, db);
    expect(r.matched).toBe(0);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.rupsKehadiranRegistry).toBeNull();
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/services/__tests__/akta-pembubaran-ref-extract.test.ts src/services/__tests__/berakhirnya-rups-fallback.test.ts. Expected: first fails module-not-found; second fails on the deed-filter and scope assertions.
  • [ ] Minimal implementation A — create backend/src/services/akta-pembubaran-ref-extract.ts:
/**
 * BERAKHIRNYA_STATUS_PT hooks.afterAktaExtraction body (spec §4.4): the final
 * PKR's premisse recites the EARLIER dissolution deed (akta pembubaran) — one
 * grounded 3B pass writes nomor/tanggal_akta_pembubaran as ExtractedField rows
 * ON THE DEED document. Consumed by BSBHP_PEMBUBARAN_REF (WARNING when absent)
 * and the review page's Akta card. The engine re-invokes the hook on re-extract;
 * IDEMPOTENT-ONCE + atomic createMany keep operator edits safe. Log-and-continue
 * is enforced by the engine (processor.ts hook try/catch). NOTE: both the deed's
 * own nomor and the recited one are grounded substrings — the prompt carries the
 * disambiguation ("the PRIOR akta pembubaran, NOT this deed's own number"); the
 * WARNING-tier rule + review keep a confusion harmless.
 */
import { db } from "../lib/db";

export interface PembubaranRefFields {
  nomor_akta_pembubaran: string | null;
  tanggal_akta_pembubaran: string | null;
}

export const PEMBUBARAN_REF_FIELD_KEYS = ["nomor_akta_pembubaran", "tanggal_akta_pembubaran"] as const;

const NONE: PembubaranRefFields = { nomor_akta_pembubaran: null, tanggal_akta_pembubaran: null };

export async function extractPembubaranRef(rawText: string): Promise<PembubaranRefFields> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  if (!url || !rawText.trim()) return { ...NONE };
  const system =
    "You are shown OCR text of an Indonesian akta berakhirnya status badan hukum (the FINAL deed that " +
    "accepts a liquidator's final accounting for a dissolved PT). Its premisse recites the EARLIER " +
    "dissolution deed (akta pembubaran). Extract: nomor_akta_pembubaran (the RECITED dissolution deed's " +
    "number — NOT this deed's own number) and tanggal_akta_pembubaran (the recited deed's date exactly " +
    "as written — word form allowed). Copy VERBATIM from the text; if the recital is absent, return null. " +
    "Never invent. Return ONLY JSON {nomor_akta_pembubaran, tanggal_akta_pembubaran}.";
  try {
    const res = await fetch(`${url}/chat/completions`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model, temperature: 0,
        messages: [
          { role: "system", content: system },
          { role: "user", content: rawText.slice(0, 12000) },
        ],
        guided_json: {
          type: "object",
          properties: {
            nomor_akta_pembubaran: { type: ["string", "null"] },
            tanggal_akta_pembubaran: { type: ["string", "null"] },
          },
          required: [...PEMBUBARAN_REF_FIELD_KEYS],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return { ...NONE };
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return { ...NONE };
    const parsed = JSON.parse(m[0]) as Partial<PembubaranRefFields>;
    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
    const hay = norm(rawText);
    const ground = (v: string | null | undefined): string | null => {
      const s = v ? String(v).trim() : "";
      return s && hay.includes(norm(s)) ? s : null;
    };
    return {
      nomor_akta_pembubaran: ground(parsed.nomor_akta_pembubaran),
      tanggal_akta_pembubaran: ground(parsed.tanggal_akta_pembubaran),
    };
  } catch (err) {
    console.warn("[akta-pembubaran-ref] 3B extraction failed:", err);
    return { ...NONE };
  }
}

/** The hooks.afterAktaExtraction body (registered by flows/berakhirnya-status-pt.ts). */
export async function runBerakhirnyaDeedPasses(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void> {
  const { aktaDocumentId, rawText } = a;
  if (!rawText.trim()) {
    console.warn(`[berakhirnya-deed] no rawText for akta ${aktaDocumentId} — pembubaran-ref pass skipped (manual entry on the Akta card)`);
    return;
  }
  const existing = await db.extractedField.findMany({
    where: { documentId: aktaDocumentId, fieldKey: { in: [...PEMBUBARAN_REF_FIELD_KEYS] } },
    select: { id: true },
  });
  if (existing.length > 0) return; // idempotent-once — operator edits survive resume/re-extract
  const r = await extractPembubaranRef(rawText);
  await db.extractedField.createMany({
    data: PEMBUBARAN_REF_FIELD_KEYS.map((fieldKey) => {
      const value = r[fieldKey];
      return {
        documentId: aktaDocumentId, fieldKey, value,
        confidence: value ? 0.75 : 0,
        status: value ? "EDITABLE" : "FLAGGED",
      };
    }),
  });
}
  • [ ] Minimal implementation B — backend/src/services/rups-attendance-matcher.ts, EXACTLY two scope changes (merge-preserve logic untouched):
    1. Line 52 — the akta filter gains the berakhirnya deed label:
    .filter((d) => d.classifiedType === "AKTA_PERUBAHAN" || d.classifiedType === "AKTA" || d.classifiedType === "AKTA_PEMBUBARAN" || d.classifiedType === "AKTA_BERAKHIRNYA")
  1. Line 94 — the registry-fallback scope admits the fourth flow (exact equality both — never substring; update the block comment above it to say "SCOPED to PEMBUBARAN_PT + BERAKHIRNYA_STATUS_PT — SABH's kehadiran checkboxes are registry-based for both terminal-family flows"):
    if (submission?.type !== "PEMBUBARAN_PT" && submission?.type !== "BERAKHIRNYA_STATUS_PT") return { matched: 0, unmatched: [] };
  • [ ] Run GREEN: cd backend && bun test src/services/__tests__/akta-pembubaran-ref-extract.test.ts src/services/__tests__/berakhirnya-rups-fallback.test.ts src/services/__tests__/rups-attendance-matcher.test.ts src/services/__tests__/pembubaran-rups-fallback.test.ts. Expected: all pass — the two existing matcher suites prove pembubaran/perubahan behavior is byte-preserved.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/services/akta-pembubaran-ref-extract.ts backend/src/services/rups-attendance-matcher.ts backend/src/services/__tests__/akta-pembubaran-ref-extract.test.ts backend/src/services/__tests__/berakhirnya-rups-fallback.test.ts && git commit -m "feat(berakhirnya): pembubaran-ref deed hook + attendance matcher widened to the berakhirnya deed + registry fallback"

Task 4: Context builder — buildBerakhirnyaContext

BerakhirnyaCtx extends PtAktaCtx (so pickRules(PT_AKTA_RULE_SET, …) assigns without casts — peleburan/pembubaran precedent) and adds this flow's inputs (spec §4.2): the two letters' canonical fields, the pengumuman clippings PARTITIONED BY FINE LABEL (SubmissionDocument.classifiedType — the operator-assigned pasal axis), the deed's pembubaran-ref, tanggalRups (BA-RUPS extraction first, deed field fallback), and the registry-roster quorum override (pembubaran-context mirror — same rupsKehadiranRegistry column). Per target-arch: principal data never enters the context; buildContext builds it once per run.

Files
- backend/src/flow-engine/context/berakhirnya-context.ts — Create
- backend/src/flow-engine/__tests__/berakhirnya-context.test.ts — Create

Interfaces
- Consumes: buildPtAktaContext (aktaClassifiedType: "AKTA_BERAKHIRNYA"); the Task-2/3 field-key consts; db.
- Produces: interface BerakhirnyaCtx extends PtAktaCtx; buildBerakhirnyaContext(submissionId): Promise<BerakhirnyaCtx>.

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/berakhirnya-context.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { buildBerakhirnyaContext } from "../context/berakhirnya-context";

describe("buildBerakhirnyaContext", () => {
  const subIds: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: subIds } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seedDoc(submissionId: string, documentType: string, classifiedType: string, order: number, fields: { fieldKey: string; value: string | null }[] = []) {
    const doc = await db.document.create({
      data: { filename: `${classifiedType}-${order}.pdf`, originalName: `${classifiedType}.pdf`, fileSize: 0, filePath: `/tmp/${classifiedType}.pdf`, documentType: documentType as never, status: "VERIFICATION_READY" },
    });
    docIds.push(doc.id);
    await db.submissionDocument.create({
      data: { submissionId, documentId: doc.id, classifiedType, confidence: 1, processingOrder: order, extractionStatus: "DONE" },
    });
    if (fields.length) {
      await db.extractedField.createMany({
        data: fields.map((f) => ({ documentId: doc.id, ...f, confidence: 0.75, status: "EDITABLE" })),
      });
    }
    return doc;
  }

  async function seedFull() {
    const sub = await db.submission.create({
      data: {
        type: "BERAKHIRNYA_STATUS_PT", status: "VALIDATING",
        oldData: { perseroan: { nama: "PT KARYA USAI SUDAH", id_perseroan: 777, status_transaksi: 1 },
          pemegang_saham: [{ nama: "BUDI SANTOSO", persentase: 60 }, { nama: "SITI AMINAH", persentase: 40 }] },
        rupsKehadiranRegistry: [
          { nama: "BUDI SANTOSO", persentase: 60, kehadiran: true },
          { nama: "SITI AMINAH", persentase: 40, kehadiran: null },
        ],
      },
    });
    subIds.push(sub.id);
    await seedDoc(sub.id, "AKTA", "AKTA_BERAKHIRNYA", 0, [
      { fieldKey: "nama_perseroan", value: "PT KARYA USAI SUDAH" },
      { fieldKey: "nomor_akta", value: "7" },
      { fieldKey: "tanggal_akta", value: "20 Juni 2026" },
      { fieldKey: "nomor_akta_pembubaran", value: "5" },
      { fieldKey: "tanggal_akta_pembubaran", value: "30 April 2026" },
    ]);
    const ba = await seedDoc(sub.id, "BERITA_ACARA_RUPS", "BERITA_ACARA_RUPS", 6);
    await db.beritaAcaraRupsExtraction.create({
      data: { documentId: ba.id, tanggalRups: "18 Juni 2026", attendees: [] },
    });
    await seedDoc(sub.id, "SURAT_PERMOHONAN", "SURAT_PERMOHONAN", 6, [
      { fieldKey: "nomor_surat_permohonan", value: "012/BSBH/VI/2026" },
      { fieldKey: "tanggal_surat_permohonan", value: "15 Juni 2026" },
      { fieldKey: "permohonan_nama_perseroan", value: "PT KARYA USAI SUDAH" },
    ]);
    await seedDoc(sub.id, "LAPORAN_LIKUIDASI", "LAPORAN_LIKUIDASI", 6, [
      { fieldKey: "nama_likuidator", value: "BUDI HARTONO, S.H." },
      { fieldKey: "nomor_laporan_likuidasi", value: "03/LAL/VI/2026" },
      { fieldKey: "tanggal_laporan_likuidasi", value: "10 Juni 2026" },
      { fieldKey: "sisa_kekayaan", value: "nihil" },
      { fieldKey: "laporan_nama_perseroan", value: "PT KARYA USAI SUDAH" },
    ]);
    await seedDoc(sub.id, "BUKTI_PENGUMUMAN", "BUKTI_PENGUMUMAN_149", 6, [
      { fieldKey: "nama_koran", value: "Media Indonesia" },
      { fieldKey: "tanggal_pengumuman", value: "5 Mei 2026" },
      { fieldKey: "nomor_pengumuman", value: "IKL-4411" },
    ]);
    await seedDoc(sub.id, "BUKTI_PENGUMUMAN", "BUKTI_PENGUMUMAN_152", 6, [
      { fieldKey: "nama_koran", value: "Kompas" },
      { fieldKey: "tanggal_pengumuman", value: "19 Juni 2026" },
      { fieldKey: "nomor_pengumuman", value: null },
    ]);
    await seedDoc(sub.id, "BUKTI_PENGUMUMAN", "BUKTI_PENGUMUMAN", 6, [
      { fieldKey: "nama_koran", value: "Tempo" },
      { fieldKey: "tanggal_pengumuman", value: "1 Juni 2026" },
    ]);
    return sub;
  }

  it("assembles the letters, the fine-label-partitioned clippings, the deed ref and tanggalRups", async () => {
    const sub = await seedFull();
    const ctx = await buildBerakhirnyaContext(sub.id);
    expect(ctx.submissionId).toBe(sub.id);
    expect(ctx.suratPermohonan.hasDoc).toBe(true);
    expect(ctx.suratPermohonan.nomor).toBe("012/BSBH/VI/2026");
    expect(ctx.suratPermohonan.namaPerseroan).toBe("PT KARYA USAI SUDAH");
    expect(ctx.laporanLikuidasi.namaLikuidator).toBe("BUDI HARTONO, S.H.");
    expect(ctx.laporanLikuidasi.sisaKekayaan).toBe("nihil");
    expect(ctx.pengumuman149.length).toBe(1);
    expect(ctx.pengumuman149[0]?.namaKoran).toBe("Media Indonesia");
    expect(ctx.pengumuman149[0]?.nomorPengumuman).toBe("IKL-4411"); // the ONE consumer of nomor_pengumuman (C4)
    expect(ctx.pengumuman152.length).toBe(1);
    expect(ctx.pengumuman152[0]?.tanggalPengumuman).toBe("19 Juni 2026");
    expect(ctx.pengumumanUnassigned.length).toBe(1); // plain BUKTI_PENGUMUMAN — axis not yet assigned
    expect(ctx.pembubaranRef).toEqual({ nomorAkta: "5", tanggalAkta: "30 April 2026" });
    expect(ctx.tanggalRups).toBe("18 Juni 2026"); // BA extraction wins over any deed field
  });

  it("quorum roster: deed PS rows absent → registry override (pembubaran mirror)", async () => {
    const sub = await seedFull();
    const ctx = await buildBerakhirnyaContext(sub.id);
    expect(ctx.rupsRosterSource).toBe("registry");
    expect(ctx.rupsQuorum.pemegangSaham).toEqual([
      { persentase: 60, kehadiranRups: true },
      { persentase: 40, kehadiranRups: null },
    ]);
  });

  it("degrades: bare submission (no letters, no clippings) → hasDoc false / empty arrays / null ref", async () => {
    const sub = await db.submission.create({ data: { type: "BERAKHIRNYA_STATUS_PT", status: "VALIDATING" } });
    subIds.push(sub.id);
    await seedDoc(sub.id, "AKTA", "AKTA_BERAKHIRNYA", 0, [{ fieldKey: "nama_perseroan", value: "PT X" }]);
    const ctx = await buildBerakhirnyaContext(sub.id);
    expect(ctx.suratPermohonan.hasDoc).toBe(false);
    expect(ctx.laporanLikuidasi.hasDoc).toBe(false);
    expect(ctx.pengumuman149).toEqual([]);
    expect(ctx.pengumuman152).toEqual([]);
    expect(ctx.pembubaranRef).toEqual({ nomorAkta: null, tanggalAkta: null });
    expect(ctx.tanggalRups).toBeNull();
    expect(ctx.rupsRosterSource).toBeNull();
    expect(ctx.registryState).toBeNull(); // no id_perseroan → no scan (also SABH-blank in tests)
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/berakhirnya-context.test.ts. Expected: module not found.
  • [ ] Minimal implementation — create backend/src/flow-engine/context/berakhirnya-context.ts:
import { db } from "../../lib/db";
import { buildPtAktaContext, type PtAktaCtx } from "./pt-akta-context";
import { PERMOHONAN_EXTRACTED_FIELD_KEYS } from "../../services/surat-permohonan-extract";
import { LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS } from "../../services/laporan-likuidasi-extract";
import { PEMBUBARAN_REF_FIELD_KEYS } from "../../services/akta-pembubaran-ref-extract";
import { loadPembubaranRegistryState, type PembubaranRegistryState } from "../../services/company-lookup";
import { withTimeout } from "../../lib/with-timeout";

/** One koran clipping's canonical fields + its fine-label axis (operator-assigned). */
export interface PengumumanClip {
  documentId: string;
  classifiedType: string; // BUKTI_PENGUMUMAN_149 | BUKTI_PENGUMUMAN_152 | BUKTI_PENGUMUMAN
  namaKoran: string | null;
  tanggalPengumuman: string | null;
  /** Per-clipping SCALAR (C4 adjudication) — SABH's nomor[] is one nomor per clipping doc. */
  nomorPengumuman: string | null;
}

export interface BerakhirnyaCtx extends PtAktaCtx {
  /** Needed by BSBHP_PEMBUBARAN_PREREQ for the local-DB lookup (PtAktaCtx has no id). */
  submissionId: string;
  suratPermohonan: { hasDoc: boolean; documentId: string | null; nomor: string | null; tanggal: string | null; namaPerseroan: string | null };
  laporanLikuidasi: { hasDoc: boolean; documentId: string | null; namaLikuidator: string | null; nomorSurat: string | null; tanggalSurat: string | null; sisaKekayaan: string | null; namaPerseroan: string | null };
  /** Clippings partitioned by the operator-assigned pasal axis (SubmissionDocument.classifiedType). */
  pengumuman149: PengumumanClip[];
  pengumuman152: PengumumanClip[];
  /** Plain BUKTI_PENGUMUMAN — uploaded but the pasal axis not yet assigned (BSBHP_REQUIRED_DOCS nudges). */
  pengumumanUnassigned: PengumumanClip[];
  /** The deed hook's recital of the earlier dissolution deed (Task 3). */
  pembubaranRef: { nomorAkta: string | null; tanggalAkta: string | null };
  /** BA-RUPS extraction first, deed field fallback — the pivot of the ordering rules. */
  tanggalRups: string | null;
  /** "registry" = the rupsKehadiranRegistry fallback (the PRIMARY berakhirnya path). */
  rupsRosterSource: "deed" | "registry" | null;
  /** Pre-fetched SABH registry snapshot for BSBHP_REGISTRY_STATE (approved-pembubaran /
   *  completed-berakhirnya facts the companyLookup payload does NOT carry). null on
   *  manual entry / SABH outage → the rule SKIPs; approvedPembubaran gates PASS. */
  registryState: PembubaranRegistryState | null;
}

export async function buildBerakhirnyaContext(submissionId: string): Promise<BerakhirnyaCtx> {
  const base = await buildPtAktaContext(submissionId, { aktaClassifiedType: "AKTA_BERAKHIRNYA" });

  const subDocs = await db.submissionDocument.findMany({
    where: { submissionId },
    select: {
      documentId: true, classifiedType: true,
      document: { select: { documentType: true, fields: { select: { fieldKey: true, value: true } } } },
    },
  });
  const fieldOf = (doc: (typeof subDocs)[number] | undefined, key: string): string | null =>
    doc?.document.fields.find((f) => f.fieldKey === key)?.value ?? null;

  // The two letters — carrier DocumentType lookups (the board may have re-mapped labels).
  const spDoc = subDocs.find((d) => d.document.documentType === "SURAT_PERMOHONAN");
  const suratPermohonan = {
    hasDoc: !!spDoc, documentId: spDoc?.documentId ?? null,
    nomor: fieldOf(spDoc, PERMOHONAN_EXTRACTED_FIELD_KEYS[0]),
    tanggal: fieldOf(spDoc, PERMOHONAN_EXTRACTED_FIELD_KEYS[1]),
    namaPerseroan: fieldOf(spDoc, PERMOHONAN_EXTRACTED_FIELD_KEYS[2]),
  };
  const lalDoc = subDocs.find((d) => d.document.documentType === "LAPORAN_LIKUIDASI");
  const laporanLikuidasi = {
    hasDoc: !!lalDoc, documentId: lalDoc?.documentId ?? null,
    namaLikuidator: fieldOf(lalDoc, "nama_likuidator"),
    nomorSurat: fieldOf(lalDoc, "nomor_laporan_likuidasi"),
    tanggalSurat: fieldOf(lalDoc, "tanggal_laporan_likuidasi"),
    sisaKekayaan: fieldOf(lalDoc, "sisa_kekayaan"),
    namaPerseroan: fieldOf(lalDoc, LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS[4]),
  };

  // Clippings — partition by the operator-assigned fine label (classifiedType string; zero schema cost).
  const clips: PengumumanClip[] = subDocs
    .filter((d) => d.document.documentType === "BUKTI_PENGUMUMAN")
    .map((d) => ({
      documentId: d.documentId, classifiedType: d.classifiedType,
      namaKoran: fieldOf(d, "nama_koran"),
      tanggalPengumuman: fieldOf(d, "tanggal_pengumuman"),
      nomorPengumuman: fieldOf(d, "nomor_pengumuman"),
    }));
  const pengumuman149 = clips.filter((c) => c.classifiedType === "BUKTI_PENGUMUMAN_149");
  const pengumuman152 = clips.filter((c) => c.classifiedType === "BUKTI_PENGUMUMAN_152");
  const pengumumanUnassigned = clips.filter((c) => c.classifiedType !== "BUKTI_PENGUMUMAN_149" && c.classifiedType !== "BUKTI_PENGUMUMAN_152");

  // Deed recital of the dissolution deed (Task 3 hook writes these on the akta doc).
  const aktaDoc = subDocs.find((d) => d.classifiedType === "AKTA_BERAKHIRNYA");
  const pembubaranRef = {
    nomorAkta: fieldOf(aktaDoc, PEMBUBARAN_REF_FIELD_KEYS[0]),
    tanggalAkta: fieldOf(aktaDoc, PEMBUBARAN_REF_FIELD_KEYS[1]),
  };

  // tanggalRups — the BA-RUPS satellite is authoritative; the deed's tanggal_rups
  // field (perubahan-extractor payload) is the fallback.
  const baDocIds = subDocs.filter((d) => d.classifiedType === "BERITA_ACARA_RUPS").map((d) => d.documentId);
  const baRups = baDocIds.length > 0
    ? await db.beritaAcaraRupsExtraction.findFirst({ where: { documentId: { in: baDocIds } } })
    : null;
  const tanggalRups = baRups?.tanggalRups ?? base.aktaFields.find((f) => f.fieldKey === "tanggal_rups")?.value ?? null;

  // Quorum roster override — deed PS rows when present; else the registry fallback
  // JSON mapped 1:1 into the shape the shared quorum body reads (pembubaran mirror).
  let rupsRosterSource: BerakhirnyaCtx["rupsRosterSource"] = base.rupsQuorum.pemegangSaham.length > 0 ? "deed" : null;
  if (base.rupsQuorum.pemegangSaham.length === 0) {
    const sub = await db.submission.findUnique({ where: { id: submissionId }, select: { rupsKehadiranRegistry: true } });
    const rows = (sub?.rupsKehadiranRegistry as { nama?: string; persentase?: number | null; kehadiran?: boolean | null }[] | null) ?? [];
    if (rows.length > 0) {
      base.rupsQuorum = {
        pemegangSaham: rows.map((r) => ({ persentase: r.persentase ?? null, kehadiranRups: r.kehadiran ?? null })),
        hasBaRupsDoc: base.rupsQuorum.hasBaRupsDoc,
      };
      rupsRosterSource = "registry";
    }
  }

  // Registry-state pre-fetch — BSBHP_REGISTRY_STATE reads THIS snapshot (the
  // approved-pembubaran / completed-berakhirnya facts the companyLookup payload does
  // NOT carry: an approved row is status_transaksi=1 regardless of aksi — 2026-07-03
  // erratum). Peleburan loadSnapshot precedent: SABH round-trip in the pre-fetch, the
  // rule reads the snapshot. Timeout-wrapped; null on manual entry / SABH outage →
  // the rule degrades to SKIPPED (never FAIL on our own gap).
  const idPerseroan = base.input.oldData?.id_perseroan ?? null;
  let registryState: PembubaranRegistryState | null = null;
  if (typeof idPerseroan === "number" && Number.isFinite(idPerseroan)) {
    const sel = await db.submission.findUnique({ where: { id: submissionId }, select: { selectedTahun: true } });
    const startYear = sel?.selectedTahun ?? new Date().getFullYear();
    try {
      registryState = await withTimeout(
        loadPembubaranRegistryState(idPerseroan, startYear),
        parseInt(process.env.FLOW_SABH_TIMEOUT_MS || "10000", 10),
        "loadPembubaranRegistryState",
      );
    } catch (err) {
      console.error(`[berakhirnya-context] registry-state prefetch failed for ${submissionId}:`, err);
      registryState = null; // SABH outage → SKIPPED, never FAIL
    }
  }

  return {
    ...base, submissionId, suratPermohonan, laporanLikuidasi,
    pengumuman149, pengumuman152, pengumumanUnassigned, pembubaranRef, tanggalRups, rupsRosterSource,
    registryState,
  };
}
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/berakhirnya-context.test.ts. Expected: 3 pass.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/context/berakhirnya-context.ts backend/src/flow-engine/__tests__/berakhirnya-context.test.ts && git commit -m "feat(berakhirnya): BerakhirnyaCtx context builder (letters + fine-label clippings + deed ref + registry roster)"

Task 5: Rules — BERAKHIRNYA_RULE_SET (the tuned matrix)

Nine flow rules + nine wrapped codes + the DEFAULT quorum wrapper. The load-bearing ones: BSBHP_REGISTRY_STATE (the prerequisite gate — FAIL unless the pre-fetched registry snapshot shows an APPROVED pembubaran row (id_aksi 12 + status 1); FAIL "sudah berakhir" on a COMPLETED berakhirnya row (id_aksi 36 + status 6); a deliberate INVERSION of validatePerseroanState, which is EXCLUDED and untouched; 2026-07-03 erratum), BSBHP_PEMBUBARAN_PREREQ (the local complement — DITOLAK-aware from birth, a9ca0e8-I4 class), and BSBHP_PENGUMUMAN_ORDER (WARNING-never-FAIL, structurally tested). Severities per the Global Constraints matrix — bit-binding.

Files
- backend/src/flow-engine/rules/berakhirnya/index.ts — Create
- backend/src/flow-engine/__tests__/rules-berakhirnya.test.ts — Create

Interfaces
- Consumes: BerakhirnyaCtx (incl. the pre-fetched registryState snapshot); PT_AKTA_RULE_SET/pickRules; rupsQuorumRule; parseFlexibleDate (laporan-rups rules); matchBakumDJP (string-similarity); db. (BSBHP_REGISTRY_STATE reads ctx.registryState, NOT SABH — the round-trip is in buildBerakhirnyaContext.)
- Produces: 9 named Rule<BerakhirnyaCtx> exports + BERAKHIRNYA_SHARED_RULE_CODES + BERAKHIRNYA_RULE_SET.

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/rules-berakhirnya.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import {
  requiredDocsRule, registryStateRule, pembubaranPrereqRule, likuidatorConsistencyRule,
  pengumumanOrderRule, docDateSanityRule, pembubaranRefRule, sisaKekayaanRule, namaPtDocsRule,
  BERAKHIRNYA_RULE_SET, BERAKHIRNYA_SHARED_RULE_CODES,
} from "../rules/berakhirnya/index";
import type { BerakhirnyaCtx } from "../context/berakhirnya-context";
import type { PembubaranRegistryState } from "../../services/company-lookup";
import type { RuleResult } from "../types";

/** Minimal ctx factory — only the fields the rules read. */
function ctx(over: Partial<BerakhirnyaCtx> = {}): BerakhirnyaCtx {
  return {
    submissionId: "test-sub",
    input: {
      oldData: { nama: "PT KARYA USAI SUDAH", id_perseroan: 777, status_transaksi: 1, modal_dasar: null, modal_ditempatkan: null, id_notaris: null },
      perseroanBlokir: null,
      aktaNamaPerseroan: "PT KARYA USAI SUDAH",
      aktaPenghadap: [{ nama: "BUDI HARTONO", nomor_identitas: "317" }],
      ktpFields: new Map([["k1", { nama: "BUDI HARTONO", nik: "317" }]]),
      uploadedDocTypes: [
        "AKTA_BERAKHIRNYA", "BERITA_ACARA_RUPS", "SURAT_PERMOHONAN", "LAPORAN_LIKUIDASI",
        "BUKTI_PENGUMUMAN_149", "BUKTI_PENGUMUMAN_152", "KTP", "NPWP",
      ],
    } as unknown as BerakhirnyaCtx["input"],
    rupsQuorum: { pemegangSaham: [], hasBaRupsDoc: true },
    aktaFields: [],
    suratPermohonan: { hasDoc: true, documentId: "d-sp", nomor: "012/BSBH/VI/2026", tanggal: "19 Juni 2026", namaPerseroan: "PT KARYA USAI SUDAH" },
    laporanLikuidasi: { hasDoc: true, documentId: "d-lal", namaLikuidator: "BUDI HARTONO, S.H.", nomorSurat: "03/LAL/VI/2026", tanggalSurat: "10 Juni 2026", sisaKekayaan: "nihil", namaPerseroan: "PT KARYA USAI SUDAH" },
    pengumuman149: [{ documentId: "d-149", classifiedType: "BUKTI_PENGUMUMAN_149", namaKoran: "Media Indonesia", tanggalPengumuman: "5 Mei 2026", nomorPengumuman: "IKL-4411" }],
    pengumuman152: [{ documentId: "d-152", classifiedType: "BUKTI_PENGUMUMAN_152", namaKoran: "Kompas", tanggalPengumuman: "19 Juni 2026", nomorPengumuman: null }],
    pengumumanUnassigned: [],
    pembubaranRef: { nomorAkta: "5", tanggalAkta: "30 April 2026" },
    tanggalRups: "18 Juni 2026",
    rupsRosterSource: "registry",
    registryState: { approvedPembubaran: true, completedBerakhirnya: false }, // default: prereq satisfied
    ...over,
  } as BerakhirnyaCtx;
}
const one = (r: RuleResult | RuleResult[]): RuleResult => (Array.isArray(r) ? r[0]! : r);

describe("BSBHP_REQUIRED_DOCS (FAIL — the statutory 6-doc package incl. per-pasal fine labels)", () => {
  it("PASS on the full package", async () => {
    expect(one(await requiredDocsRule.run(ctx())).status).toBe("PASS");
  });
  it("FAIL naming every missing doc; plain BUKTI_PENGUMUMAN does NOT satisfy a pasal slot", async () => {
    const r = one(await requiredDocsRule.run(ctx({
      input: { ...ctx().input, uploadedDocTypes: ["AKTA_BERAKHIRNYA", "BUKTI_PENGUMUMAN", "KTP"] } as never,
    })));
    expect(r.status).toBe("FAIL");
    for (const missing of ["BERITA_ACARA_RUPS", "SURAT_PERMOHONAN", "LAPORAN_LIKUIDASI", "BUKTI_PENGUMUMAN_149", "BUKTI_PENGUMUMAN_152", "NPWP"]) {
      expect(r.message).toContain(missing);
    }
    expect(r.message).not.toContain("KTP,"); // present docs are not named
  });
});

describe("BSBHP_REGISTRY_STATE (FAIL unless an APPROVED pembubaran row exists — the prerequisite gate; INVERSION, never a validatePerseroanState wrap; reads the pre-fetched snapshot, 2026-07-03 erratum)", () => {
  const withReg = (registryState: PembubaranRegistryState | null) => ctx({ registryState });
  it("approved pembubaran row (id_aksi 12, status 1) → PASS", async () => {
    expect(one(await registryStateRule.run(withReg({ approvedPembubaran: true, completedBerakhirnya: false }))).status).toBe("PASS");
  });
  it("no approved pembubaran → FAIL 'pembubaran ... disetujui'", async () => {
    const r = one(await registryStateRule.run(withReg({ approvedPembubaran: false, completedBerakhirnya: false })));
    expect(r.status).toBe("FAIL");
    expect(r.message).toContain("pembubaran");
  });
  it("completed berakhirnya row (id_aksi 36, status 6) → FAIL 'sudah berakhir' (even if pembubaran also approved)", async () => {
    const r = one(await registryStateRule.run(withReg({ approvedPembubaran: true, completedBerakhirnya: true })));
    expect(r.status).toBe("FAIL");
    expect(r.message).toContain("sudah berakhir");
  });
  it("blokir → FAIL even with an approved pembubaran", async () => {
    const c = withReg({ approvedPembubaran: true, completedBerakhirnya: false });
    (c.input as { perseroanBlokir: unknown }).perseroanBlokir = { blocked: true, reason: "sengketa" };
    const r = one(await registryStateRule.run(c));
    expect(r.status).toBe("FAIL");
    expect(r.message).toContain("terblokir");
  });
  it("no SABH oldData → SKIPPED; registry unavailable (null snapshot) → SKIPPED (never guess)", async () => {
    expect(one(await registryStateRule.run(ctx({ input: { ...ctx().input, oldData: null } as never }))).status).toBe("SKIPPED");
    expect(one(await registryStateRule.run(withReg(null))).status).toBe("SKIPPED");
  });
});

describe("BSBHP_PEMBUBARAN_PREREQ (local complement — DITOLAK-aware FROM BIRTH)", () => {
  const subIds: string[] = [];
  afterAll(async () => { await db.submission.deleteMany({ where: { id: { in: subIds } } }); });
  async function seedPrior(status: "SELESAI" | "DITOLAK" | null) {
    const prior = await db.submission.create({
      data: { type: "PEMBUBARAN_PT", status: "COMPLETED", oldData: { perseroan: { id_perseroan: 90777 } } },
    });
    subIds.push(prior.id);
    if (status) await db.verifikasiPerubahan.create({ data: { submissionId: prior.id, status } });
    return prior;
  }
  const forId = () => ctx({ input: { ...ctx().input, oldData: { ...(ctx().input.oldData as object), id_perseroan: 90777 } } as never });

  it("a COMPLETED (verifikasi SELESAI) PEMBUBARAN_PT prior → PASS", async () => {
    await seedPrior("SELESAI");
    expect(one(await pembubaranPrereqRule.run(forId())).status).toBe("PASS");
  });
  it("a DITOLAK prior does NOT satisfy the prerequisite (rejected filing ≠ completed pembubaran) → WARNING", async () => {
    await db.submission.deleteMany({ where: { id: { in: subIds } } });
    subIds.length = 0;
    await seedPrior("DITOLAK");
    const r = one(await pembubaranPrereqRule.run(forId()));
    expect(r.status).toBe("WARNING");
  });
  it("no prior at all → WARNING (pembubaran may have been filed directly in SABH — the FAIL authority is BSBHP_REGISTRY_STATE)", async () => {
    await db.submission.deleteMany({ where: { id: { in: subIds } } });
    subIds.length = 0;
    expect(one(await pembubaranPrereqRule.run(forId())).status).toBe("WARNING");
  });
  it("no id_perseroan → SKIPPED", async () => {
    expect(one(await pembubaranPrereqRule.run(ctx({ input: { ...ctx().input, oldData: null } as never }))).status).toBe("SKIPPED");
  });
});

describe("BSBHP_LIKUIDATOR_CONSISTENCY (matchBakumDJP tiers: ≥85 PASS / 60–84 WARNING / <60 FAIL)", () => {
  it("exact-ish match incl. gelar noise → PASS (S.H. suffix is fuzzy-tolerated)", async () => {
    expect(one(await likuidatorConsistencyRule.run(ctx())).status).toBe("PASS");
  });
  it("partially-overlapping name → WARNING", async () => {
    const c = ctx({ laporanLikuidasi: { ...ctx().laporanLikuidasi, namaLikuidator: "BUDI HARTONO WIJAYA KUSUMA" } });
    const r = one(await likuidatorConsistencyRule.run(c));
    expect(["WARNING", "PASS"]).toContain(r.status); // tier depends on token overlap — must NOT be FAIL
    expect(r.status).not.toBe("FAIL");
  });
  it("fully disjoint name set → FAIL (wrong-package signal)", async () => {
    const c = ctx({ laporanLikuidasi: { ...ctx().laporanLikuidasi, namaLikuidator: "RINA MELATI PUTRI" } });
    expect(one(await likuidatorConsistencyRule.run(c)).status).toBe("FAIL");
  });
  it("no laporan name → SKIPPED; no candidates → WARNING naming the absence", async () => {
    expect(one(await likuidatorConsistencyRule.run(ctx({ laporanLikuidasi: { ...ctx().laporanLikuidasi, namaLikuidator: null } }))).status).toBe("SKIPPED");
    const empty = ctx({ input: { ...ctx().input, aktaPenghadap: [], ktpFields: new Map() } as never });
    expect(one(await likuidatorConsistencyRule.run(empty)).status).toBe("WARNING");
  });
});

describe("BSBHP_PENGUMUMAN_ORDER (WARNING — NEVER FAIL; expected t149 < tRUPS ≤ t152)", () => {
  it("correct order → PASS (cites Ps.147(1) + Ps.152(3))", async () => {
    const r = one(await pengumumanOrderRule.run(ctx()));
    expect(r.status).toBe("PASS");
  });
  it("149 after RUPS → WARNING; 152 before RUPS → WARNING", async () => {
    const late149 = ctx({ pengumuman149: [{ ...ctx().pengumuman149[0]!, tanggalPengumuman: "19 Juni 2026" }] });
    expect(one(await pengumumanOrderRule.run(late149)).status).toBe("WARNING");
    const early152 = ctx({ pengumuman152: [{ ...ctx().pengumuman152[0]!, tanggalPengumuman: "1 Mei 2026" }] });
    expect(one(await pengumumanOrderRule.run(early152)).status).toBe("WARNING");
  });
  it("t152 == tRUPS is compliant (≤, Ps.152(3) same-day filing)", async () => {
    const sameDay = ctx({ pengumuman152: [{ ...ctx().pengumuman152[0]!, tanggalPengumuman: "18 Juni 2026" }] });
    expect(one(await pengumumanOrderRule.run(sameDay)).status).toBe("PASS");
  });
  it("unreadable date → WARNING NAMING it (warning-2 discipline)", async () => {
    const bad = ctx({ pengumuman149: [{ ...ctx().pengumuman149[0]!, tanggalPengumuman: "tgl kliping robek" }] });
    const r = one(await pengumumanOrderRule.run(bad));
    expect(r.status).toBe("WARNING");
    expect(r.message).toContain("tidak terbaca");
  });
  it("STRUCTURAL: never emits FAIL over a full date-triple matrix (severityFloor + body)", async () => {
    expect(pengumumanOrderRule.severityFloor).toBe("WARNING");
    const dates = ["1 Mei 2026", "18 Juni 2026", "20 Juni 2026", "kliping robek", null];
    for (const d149 of dates) for (const dR of dates) for (const d152 of dates) {
      const c = ctx({
        pengumuman149: [{ documentId: "a", classifiedType: "BUKTI_PENGUMUMAN_149", namaKoran: null, tanggalPengumuman: d149, nomorPengumuman: null }],
        pengumuman152: [{ documentId: "b", classifiedType: "BUKTI_PENGUMUMAN_152", namaKoran: null, tanggalPengumuman: d152, nomorPengumuman: null }],
        tanggalRups: dR,
      });
      expect(one(await pengumumanOrderRule.run(c)).status).not.toBe("FAIL");
    }
  });
});

describe("BSBHP_DOC_DATE_SANITY (WARNING: laporan ≤ RUPS; permohonan ≥ RUPS)", () => {
  it("sane dates → PASS; laporan after RUPS → WARNING; permohonan before RUPS → WARNING", async () => {
    expect(one(await docDateSanityRule.run(ctx())).status).toBe("PASS");
    const lateLaporan = ctx({ laporanLikuidasi: { ...ctx().laporanLikuidasi, tanggalSurat: "19 Juni 2026" } });
    expect(one(await docDateSanityRule.run(lateLaporan)).status).toBe("WARNING");
    const earlyPermohonan = ctx({ suratPermohonan: { ...ctx().suratPermohonan, tanggal: "1 Juni 2026" } });
    expect(one(await docDateSanityRule.run(earlyPermohonan)).status).toBe("WARNING");
  });
  it("missing/unparseable dates → WARNING naming them, never FAIL", async () => {
    const r = one(await docDateSanityRule.run(ctx({ tanggalRups: null })));
    expect(r.status).toBe("WARNING");
  });
});

describe("BSBHP_PEMBUBARAN_REF (WARNING when absent; SKIPPED-degrading when present — spec §10 Q2)", () => {
  it("ref present → SKIPPED with the manual-review message (no registry cross-check helper exists)", async () => {
    const r = one(await pembubaranRefRule.run(ctx()));
    expect(r.status).toBe("SKIPPED");
    expect(r.message).toContain("No. 5");
  });
  it("ref absent → WARNING (review nudge)", async () => {
    const r = one(await pembubaranRefRule.run(ctx({ pembubaranRef: { nomorAkta: null, tanggalAkta: null } })));
    expect(r.status).toBe("WARNING");
  });
});

describe("BSBHP_SISA_KEKAYAAN_PRESENT (WARNING when absent)", () => {
  it("present (incl. 'nihil') → PASS; absent → WARNING; no laporan doc → SKIPPED", async () => {
    expect(one(await sisaKekayaanRule.run(ctx())).status).toBe("PASS");
    expect(one(await sisaKekayaanRule.run(ctx({ laporanLikuidasi: { ...ctx().laporanLikuidasi, sisaKekayaan: null } }))).status).toBe("WARNING");
    expect(one(await sisaKekayaanRule.run(ctx({ laporanLikuidasi: { ...ctx().laporanLikuidasi, hasDoc: false, sisaKekayaan: null } }))).status).toBe("SKIPPED");
  });
});

describe("BSBHP_NAMA_PT_DOCS (FAIL <70 — parity with the wrapped NAMA_PT_CONSISTENCY tiering)", () => {
  it("both letters match the deed/registry name → PASS", async () => {
    expect(one(await namaPtDocsRule.run(ctx())).status).toBe("PASS");
  });
  it("a mismatching letter name → FAIL naming the doc", async () => {
    const c = ctx({ laporanLikuidasi: { ...ctx().laporanLikuidasi, namaPerseroan: "PT SAMA SEKALI LAIN" } });
    const r = one(await namaPtDocsRule.run(c));
    expect(r.status).toBe("FAIL");
    expect(r.message).toContain("Laporan Likuidasi");
  });
  it("un-extracted letter names → WARNING naming the unchecked docs (warning-2); both absent → SKIPPED", async () => {
    const half = ctx({ suratPermohonan: { ...ctx().suratPermohonan, namaPerseroan: null } });
    const r = one(await namaPtDocsRule.run(half));
    expect(r.status).toBe("WARNING");
    expect(r.message).toContain("Surat Permohonan");
    const none = ctx({
      suratPermohonan: { ...ctx().suratPermohonan, namaPerseroan: null },
      laporanLikuidasi: { ...ctx().laporanLikuidasi, namaPerseroan: null },
    });
    expect(one(await namaPtDocsRule.run(none)).status).toBe("SKIPPED");
  });
});

describe("set composition (the tuned matrix — omission-by-config, guard-tested)", () => {
  it("contains the 9 flow rules + quorum + exactly the 9 wrapped codes", () => {
    const codes = BERAKHIRNYA_RULE_SET.map((r) => r.code);
    for (const c of [
      "BSBHP_REQUIRED_DOCS", "BSBHP_REGISTRY_STATE", "BSBHP_PEMBUBARAN_PREREQ",
      "BSBHP_LIKUIDATOR_CONSISTENCY", "BSBHP_PENGUMUMAN_ORDER", "BSBHP_DOC_DATE_SANITY",
      "BSBHP_PEMBUBARAN_REF", "BSBHP_SISA_KEKAYAAN_PRESENT", "BSBHP_NAMA_PT_DOCS",
      "RUPS_ATTENDANCE_QUORUM", ...BERAKHIRNYA_SHARED_RULE_CODES,
    ]) expect(codes).toContain(c);
    expect(codes.length).toBe(10 + BERAKHIRNYA_SHARED_RULE_CODES.length);
  });
  it("EXCLUDES the deliberate omissions (esp. PERSEROAN_STATE — it would FAIL every legitimate berakhirnya)", () => {
    const codes = new Set(BERAKHIRNYA_RULE_SET.map((r) => r.code));
    for (const excluded of [
      "PERSEROAN_STATE", "REQUIRED_SUPPORTING_DOCS", "UPLOADED_DOC_UNUSED",
      "MODAL_BUKTI_SETOR", "PEMEGANG_SAHAM_BUKTI_SETOR", "PP29_MODAL", "KBLI_VALID", "PERUBAHAN_KBLI",
      "PERUBAHAN_MODAL", "SHARES_TRANSFER_BALANCE", "SHARES_TOTAL_LEMBAR", "SHARES_SUM_CONSISTENCY",
      "MODAL_HIERARCHY", "NOMINAL_RECONCILE", "PS_TOTAL_100", "PEMEGANG_SAHAM_CONTACT",
      "DATA_ACTUALLY_CHANGED", "JENIS_SELECTION_NONEMPTY", "JENIS_DESELECT_REASONS_COMPLETE", "CONTRADICTORY_JENIS",
      "DOMISILI_NAMA", "CONTACT_INFO_COMPLETE", "PASSPORT_AKTA",
    ]) expect(codes.has(excluded)).toBe(false);
  });
  it("quorum is the DEFAULT >1/2 wrapper (the discharge RUPS is not the Ps.89(1) dissolution decision)", async () => {
    const quorum = BERAKHIRNYA_RULE_SET.find((r) => r.code === "RUPS_ATTENDANCE_QUORUM")!;
    // exactly 60% attendance: PASS under >1/2 — would also pass ≥3/4? no: 60 < 75.
    const c = ctx({ rupsQuorum: { pemegangSaham: [{ persentase: 60, kehadiranRups: true }, { persentase: 40, kehadiranRups: false }], hasBaRupsDoc: true } });
    expect(one(await quorum.run(c)).status).toBe("PASS"); // 60% > 50% → the default comparator is in force
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/rules-berakhirnya.test.ts. Expected: module not found.
  • [ ] Minimal implementation — create backend/src/flow-engine/rules/berakhirnya/index.ts:
import { db } from "../../../lib/db";
import type { Rule, RuleResult } from "../../types";
import type { BerakhirnyaCtx } from "../../context/berakhirnya-context";
import { PT_AKTA_RULE_SET, pickRules } from "../pt-akta/index";
import { rupsQuorumRule } from "../pt-akta/rups-quorum";
import { parseFlexibleDate } from "../laporan-rups/index";
import { matchBakumDJP } from "../../../services/string-similarity";

const rr = (ruleCode: string, ruleLabel: string, status: RuleResult["status"], message: string, details?: unknown): RuleResult =>
  ({ ruleCode, ruleLabel, status, message, ...(details !== undefined ? { details: details as never } : {}) });

/** FAIL — the statutory 6-doc package (+ cards). A FLOW rule over
 *  ctx.input.uploadedDocTypes (effective classifiedTypes incl. the operator-assigned
 *  _149/_152 fine labels) — NOT the wrapped REQUIRED_SUPPORTING_DOCS, whose body is
 *  jenis-scoped (cross-validator.ts:1235 keys off effectiveJenis.nonpad; the
 *  Pembubaran-adjudicated exclusion class). Delivers C10's intent: per-pasal
 *  coverage — a plain BUKTI_PENGUMUMAN (axis unassigned) satisfies NEITHER slot,
 *  the message says to assign the pasal on the board / re-upload. */
export const requiredDocsRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_REQUIRED_DOCS",
  label: "Kelengkapan Paket Dokumen Berakhirnya (6 PDF + identitas)",
  run: (ctx) => {
    const L = "Kelengkapan Paket Dokumen Berakhirnya (6 PDF + identitas)";
    const have = new Set(ctx.input.uploadedDocTypes ?? []);
    const missing: string[] = [];
    for (const req of ["AKTA_BERAKHIRNYA", "BERITA_ACARA_RUPS", "SURAT_PERMOHONAN", "LAPORAN_LIKUIDASI", "BUKTI_PENGUMUMAN_149", "BUKTI_PENGUMUMAN_152", "KTP", "NPWP"]) {
      if (!have.has(req)) missing.push(req);
    }
    if (missing.length > 0) {
      const hint = (missing.includes("BUKTI_PENGUMUMAN_149") || missing.includes("BUKTI_PENGUMUMAN_152")) && have.has("BUKTI_PENGUMUMAN")
        ? " (ada kliping BUKTI_PENGUMUMAN tanpa pasal — tetapkan Pasal 149/152 pada papan klasifikasi)"
        : "";
      return rr("BSBHP_REQUIRED_DOCS", L, "FAIL", `Dokumen wajib belum lengkap: ${missing.join(", ")}${hint}`, { missing });
    }
    return rr("BSBHP_REQUIRED_DOCS", L, "PASS", "Paket 6 dokumen + identitas lengkap");
  },
};

/** FAIL unless the pre-fetched registry snapshot shows an APPROVED pembubaran row
 *  (id_aksi_transaksi=12 + status_transaksi=1) — THE prerequisite gate (completed
 *  pembubaran must precede; spec §4.5). Deliberate INVERSION of validatePerseroanState
 *  (which FAILs everything ≠1 and stays untouched for the perubahan family — do NOT
 *  wrap it). 2026-07-03 erratum: an approved transaction row sits at status 1
 *  regardless of aksi type, so oldData.status_transaksi CANNOT distinguish "approved
 *  pembubaran" from "plain active" — the rule reads ctx.registryState instead (the
 *  SABH round-trip is pre-fetched in buildBerakhirnyaContext, peleburan precedent).
 *  SABH unreachable / manual entry → oldData null OR snapshot null → SKIPPED (never
 *  guess — never FAIL on our own outage). A COMPLETED berakhirnya row
 *  (id_aksi_transaksi=36 + status_transaksi=6) → FAIL "sudah berakhir". */
export const registryStateRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_REGISTRY_STATE",
  label: "Status PT = Pembubaran Disetujui di SABH (Prasyarat)",
  run: (ctx) => {
    const L = "Status PT = Pembubaran Disetujui di SABH (Prasyarat)";
    const old = ctx.input.oldData;
    if (!old) return rr("BSBHP_REGISTRY_STATE", L, "SKIPPED", "Tidak ada data SABH (input manual / SABH tidak terjangkau) — status pembubaran tidak dapat diverifikasi");
    if (ctx.input.perseroanBlokir?.blocked === true) {
      const reason = ctx.input.perseroanBlokir.reason;
      return rr("BSBHP_REGISTRY_STATE", L, "FAIL", `PT terblokir di SABH${reason ? ": " + reason : ""}`, { reason });
    }
    const snap = ctx.registryState;
    if (!snap) return rr("BSBHP_REGISTRY_STATE", L, "SKIPPED", "Registri transaksi SABH tidak tersedia — status pembubaran tidak dapat diverifikasi");
    if (snap.completedBerakhirnya)
      return rr("BSBHP_REGISTRY_STATE", L, "FAIL", "Status badan hukum sudah berakhir — transaksi berakhirnya sudah tercatat di SABH", { snap });
    if (!snap.approvedPembubaran)
      return rr("BSBHP_REGISTRY_STATE", L, "FAIL", "PT belum memiliki pembubaran yang disetujui di SABH — ajukan/selesaikan Pembubaran PT terlebih dahulu", { snap });
    return rr("BSBHP_REGISTRY_STATE", L, "PASS", "Pembubaran PT telah disetujui di SABH (id_aksi 12, status 1) — prasyarat berakhirnya terpenuhi");
  },
};

/** WARNING — the LOCAL complement of the prerequisite: a COMPLETED PEMBUBARAN_PT
 *  submission for the same oldData.perseroan.id_perseroan is the PoC-local
 *  dissolution record (the mirror-image of pembubaran's PT_ALREADY_DISSOLVED,
 *  pp-pembubaran.ts:475 pattern). DITOLAK-AWARE FROM BIRTH (a9ca0e8 I4 class):
 *  a prior whose verifikator REJECTED it (VerifikasiPerubahan DITOLAK) is NOT a
 *  completed pembubaran and must NOT satisfy the prerequisite. WARNING not FAIL:
 *  the pembubaran may have been filed directly in SABH outside the PoC — the FAIL
 *  authority is BSBHP_REGISTRY_STATE. Async: one local-DB lookup (Promise.all
 *  partition absorbs it). Exact-equality type match — NEVER substring. */
export const pembubaranPrereqRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_PEMBUBARAN_PREREQ",
  label: "Pembubaran PT Tercatat (Registri Lokal)",
  async: true,
  run: async (ctx) => {
    const L = "Pembubaran PT Tercatat (Registri Lokal)";
    const idPerseroan = ctx.input.oldData?.id_perseroan ?? null;
    if (idPerseroan == null)
      return rr("BSBHP_PEMBUBARAN_PREREQ", L, "SKIPPED", "Tidak ada data SABH (id_perseroan) untuk diperiksa");
    const prior = await db.submission.findFirst({
      where: {
        type: "PEMBUBARAN_PT",
        status: "COMPLETED",
        oldData: { path: ["perseroan", "id_perseroan"], equals: idPerseroan },
        NOT: [{ verifikasi: { status: "DITOLAK" } }], // a rejected prior is NOT a filed dissolution
      },
      select: { id: true },
    });
    if (prior)
      return rr("BSBHP_PEMBUBARAN_PREREQ", L, "PASS",
        `Pembubaran PT tercatat di registri lokal (submission ${prior.id})`, { priorSubmissionId: prior.id });
    return rr("BSBHP_PEMBUBARAN_PREREQ", L, "WARNING",
      "Tidak ada Pembubaran PT tercatat di PoC untuk PT ini — jika pembubaran diajukan langsung di SABH, pastikan BSBHP_REGISTRY_STATE lolos");
  },
};

/** ≥85 PASS / 60–84 WARNING / <60 FAIL — laporan nama_likuidator vs the deed
 *  penghadap + KTP pool (matchBakumDJP, 0–100). Identity is load-bearing: the
 *  likuidator is the legally accountable party across the package; a fully
 *  disjoint name set is a wrong-package signal (FAIL at the bottom tier only —
 *  gelar/honorific noise lands in WARNING/PASS). The spec's "surat-permohonan
 *  signer" input is NOT extracted in phase 1 (§4.4 field list is the authority)
 *  — candidates are deed penghadap + KTPs. */
export const likuidatorConsistencyRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_LIKUIDATOR_CONSISTENCY",
  label: "Konsistensi Nama Likuidator (Laporan ↔ Akta ↔ KTP)",
  run: (ctx) => {
    const L = "Konsistensi Nama Likuidator (Laporan ↔ Akta ↔ KTP)";
    const nama = ctx.laporanLikuidasi.namaLikuidator;
    if (!nama?.trim())
      return rr("BSBHP_LIKUIDATOR_CONSISTENCY", L, "SKIPPED", "Nama likuidator belum terbaca dari laporan akhir likuidasi");
    const candidates: string[] = [
      ...ctx.input.aktaPenghadap.map((p) => p.nama).filter((n): n is string => !!n),
      ...Array.from(ctx.input.ktpFields.values()).map((k) => k.nama).filter((n): n is string => !!n),
    ];
    if (candidates.length === 0)
      return rr("BSBHP_LIKUIDATOR_CONSISTENCY", L, "WARNING",
        `Tidak ada penghadap akta / KTP untuk dicocokkan dengan likuidator "${nama}" — verifikasi manual`);
    let best = { nama: candidates[0]!, score: 0 };
    for (const c of candidates) {
      const score = matchBakumDJP(nama, c);
      if (score > best.score) best = { nama: c, score };
    }
    if (best.score >= 85)
      return rr("BSBHP_LIKUIDATOR_CONSISTENCY", L, "PASS",
        `Nama likuidator cocok dengan "${best.nama}" (skor ${best.score.toFixed(0)})`);
    if (best.score >= 60)
      return rr("BSBHP_LIKUIDATOR_CONSISTENCY", L, "WARNING",
        `Nama likuidator "${nama}" mirip namun tidak persis dengan "${best.nama}" (skor ${best.score.toFixed(0)}) — periksa gelar/ejaan`, { best });
    return rr("BSBHP_LIKUIDATOR_CONSISTENCY", L, "FAIL",
      `Nama likuidator "${nama}" tidak cocok dengan penghadap akta maupun KTP manapun (skor terbaik ${best.score.toFixed(0)}) — indikasi paket dokumen salah`, { best });
  },
};

/** WARNING — NEVER FAIL (severityFloor clamp + structural test). Expected order
 *  t149 < tRUPS ≤ t152: UU 40/2007 Ps.147(1) puts the creditor newspaper notice
 *  ≤30 days after DISSOLUTION (early in liquidation, so before the discharge
 *  RUPS); Ps.152(3) puts the hasil-akhir announcement AFTER the RUPS discharges
 *  the likuidator ("both before RUPS" is legally WRONG for 152). Never-FAIL:
 *  (a) koran dates are our weakest OCR surface (PaddleOCR line-splits newsprint);
 *  (b) the 149/152 axis itself is operator-assigned — a FAIL would punish a
 *  mis-drag twice. Multiple clippings: EVERY 149 must predate the RUPS; EVERY
 *  152 must be ≥ the RUPS. Mixed verified/unknown WARNs naming the unreadable. */
export const pengumumanOrderRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_PENGUMUMAN_ORDER",
  label: "Urutan Pengumuman Koran (Ps.147(1) / Ps.152(3))",
  severityFloor: "WARNING",
  run: (ctx) => {
    const L = "Urutan Pengumuman Koran (Ps.147(1) / Ps.152(3))";
    if (ctx.pengumuman149.length === 0 || ctx.pengumuman152.length === 0)
      return rr("BSBHP_PENGUMUMAN_ORDER", L, "WARNING",
        `Kliping pengumuman belum lengkap per pasal (149: ${ctx.pengumuman149.length}, 152: ${ctx.pengumuman152.length}) — urutan tidak dapat dinilai`);
    const tRups = parseFlexibleDate(ctx.tanggalRups);
    if (!tRups)
      return rr("BSBHP_PENGUMUMAN_ORDER", L, "WARNING",
        "Tanggal RUPS tidak terbaca — urutan Ps.147/Ps.152 tidak dapat diverifikasi (perbaiki pada kartu Risalah RUPS)");
    const unreadable: string[] = [];
    const issues: string[] = [];
    for (const c of ctx.pengumuman149) {
      const t = parseFlexibleDate(c.tanggalPengumuman);
      if (!t) { unreadable.push(`Ps.149 (${c.namaKoran ?? "koran"}): tanggal tidak terbaca`); continue; }
      if (t.getTime() >= tRups.getTime())
        issues.push(`Pengumuman Ps.149 (${c.tanggalPengumuman}) tidak MENDAHULUI RUPS — Ps.147(1): pemberitahuan kreditor ≤30 hari sejak pembubaran, jauh sebelum RUPS pertanggungjawaban`);
    }
    for (const c of ctx.pengumuman152) {
      const t = parseFlexibleDate(c.tanggalPengumuman);
      if (!t) { unreadable.push(`Ps.152 (${c.namaKoran ?? "koran"}): tanggal tidak terbaca`); continue; }
      if (t.getTime() < tRups.getTime())
        issues.push(`Pengumuman Ps.152 (${c.tanggalPengumuman}) MENDAHULUI RUPS — Ps.152(3): pengumuman hasil akhir likuidasi dilakukan SETELAH RUPS menerima pertanggungjawaban`);
    }
    if (issues.length > 0 || unreadable.length > 0)
      return rr("BSBHP_PENGUMUMAN_ORDER", L, "WARNING",
        [...issues, ...unreadable.map((u) => `${u} (verifikasi manual)`)].join("; "), { issues, unreadable });
    return rr("BSBHP_PENGUMUMAN_ORDER", L, "PASS",
      "Urutan pengumuman sesuai: Ps.149 sebelum RUPS, Ps.152 pada/setelah RUPS (t149 < tRUPS ≤ t152)");
  },
};

/** WARNING — same-day paperwork is common; advisory only. laporan ≤ RUPS (the
 *  report is what the RUPS accepts); permohonan ≥ RUPS (the filing follows the
 *  discharge). Unparseable inputs WARN naming them. */
export const docDateSanityRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_DOC_DATE_SANITY",
  label: "Kewajaran Tanggal Dokumen (Laporan ≤ RUPS ≤ Permohonan)",
  run: (ctx) => {
    const L = "Kewajaran Tanggal Dokumen (Laporan ≤ RUPS ≤ Permohonan)";
    const tRups = parseFlexibleDate(ctx.tanggalRups);
    const tLaporan = parseFlexibleDate(ctx.laporanLikuidasi.tanggalSurat);
    const tPermohonan = parseFlexibleDate(ctx.suratPermohonan.tanggal);
    const missing: string[] = [];
    if (!tRups) missing.push("tanggal RUPS");
    if (!tLaporan) missing.push("tanggal laporan likuidasi");
    if (!tPermohonan) missing.push("tanggal surat permohonan");
    if (missing.length > 0)
      return rr("BSBHP_DOC_DATE_SANITY", L, "WARNING", `Tidak dapat dinilai — ${missing.join(", ")} tidak terbaca/kosong`, { missing });
    const issues: string[] = [];
    if (tLaporan!.getTime() > tRups!.getTime())
      issues.push(`laporan likuidasi (${ctx.laporanLikuidasi.tanggalSurat}) bertanggal SETELAH RUPS yang menerimanya`);
    if (tPermohonan!.getTime() < tRups!.getTime())
      issues.push(`surat permohonan (${ctx.suratPermohonan.tanggal}) bertanggal SEBELUM RUPS pertanggungjawaban`);
    if (issues.length > 0)
      return rr("BSBHP_DOC_DATE_SANITY", L, "WARNING", `Tanggal tidak wajar: ${issues.join("; ")}`, { issues });
    return rr("BSBHP_DOC_DATE_SANITY", L, "PASS", "Urutan tanggal dokumen wajar");
  },
};

/** WARNING when the deed recital is absent; SKIPPED when present — the per-PT
 *  SABH pembubaran-transaction cross-check (year-sharded transaksi_perseroan,
 *  id_aksi 12) has NO integration helper yet (spec §10 Q2): ship SKIPPED-degrading,
 *  never block on our own integration gap. The ref stays a reviewed field. */
export const pembubaranRefRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_PEMBUBARAN_REF",
  label: "Rujukan Akta Pembubaran pada Premisse",
  run: (ctx) => {
    const L = "Rujukan Akta Pembubaran pada Premisse";
    const { nomorAkta, tanggalAkta } = ctx.pembubaranRef;
    if (!nomorAkta && !tanggalAkta)
      return rr("BSBHP_PEMBUBARAN_REF", L, "WARNING",
        "Premisse akta tidak memuat rujukan akta pembubaran (nomor/tanggal) — lengkapi manual pada kartu Akta");
    return rr("BSBHP_PEMBUBARAN_REF", L, "SKIPPED",
      `Rujukan akta pembubaran terbaca (No. ${nomorAkta ?? "—"}, tanggal ${tanggalAkta ?? "—"}) — verifikasi silang ke registri SABH belum tersedia, tinjau manual`);
  },
};

/** WARNING when absent — SABH types the field; deeds/letters state it in prose
 *  (possibly "nihil"). Absence is a review nudge, not a defect. */
export const sisaKekayaanRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_SISA_KEKAYAAN_PRESENT",
  label: "Sisa Kekayaan Hasil Likuidasi Terisi",
  run: (ctx) => {
    const L = "Sisa Kekayaan Hasil Likuidasi Terisi";
    if (!ctx.laporanLikuidasi.hasDoc)
      return rr("BSBHP_SISA_KEKAYAAN_PRESENT", L, "SKIPPED", "Laporan akhir likuidasi belum diunggah");
    if (!ctx.laporanLikuidasi.sisaKekayaan?.trim())
      return rr("BSBHP_SISA_KEKAYAAN_PRESENT", L, "WARNING",
        "Sisa kekayaan tidak terbaca dari laporan likuidasi — SABH mewajibkan field ini; isi manual (boleh 'nihil')");
    return rr("BSBHP_SISA_KEKAYAAN_PRESENT", L, "PASS", `Sisa kekayaan terisi: ${ctx.laporanLikuidasi.sisaKekayaan}`);
  },
};

/** FAIL <70 — the spec-§4.5 NAMA_PT_CONSISTENCY "inputs extended" intent for the
 *  two new letters, delivered as a FLOW rule (adjudicated deviation: the wrapped
 *  body's source list is hard-coded to buktiSetor/domisili/kontak and wrap-don't-
 *  rewrite forbids body changes). Tiering mirrors the wrapped rule: <70 → FAIL;
 *  un-extracted letter names → WARNING naming the unchecked docs (warning-2). */
export const namaPtDocsRule: Rule<BerakhirnyaCtx> = {
  code: "BSBHP_NAMA_PT_DOCS",
  label: "Nama PT Konsisten pada Surat Permohonan & Laporan Likuidasi",
  run: (ctx) => {
    const L = "Nama PT Konsisten pada Surat Permohonan & Laporan Likuidasi";
    const anchor = ctx.input.aktaNamaPerseroan ?? ctx.input.oldData?.nama ?? null;
    if (!anchor)
      return rr("BSBHP_NAMA_PT_DOCS", L, "SKIPPED", "Nama perseroan tidak tersedia dari akta/registri");
    const docs: { label: string; name: string | null; uploaded: boolean }[] = [
      { label: "Surat Permohonan", name: ctx.suratPermohonan.namaPerseroan, uploaded: ctx.suratPermohonan.hasDoc },
      { label: "Laporan Likuidasi", name: ctx.laporanLikuidasi.namaPerseroan, uploaded: ctx.laporanLikuidasi.hasDoc },
    ];
    const mismatches: string[] = [];
    const unchecked: string[] = [];
    let checked = 0;
    for (const d of docs) {
      if (!d.uploaded) continue;
      if (!d.name?.trim()) { unchecked.push(d.label); continue; }
      checked++;
      const score = matchBakumDJP(anchor, d.name);
      if (score < 70) mismatches.push(`${d.label}: "${d.name}" (skor ${score.toFixed(0)})`);
    }
    if (mismatches.length > 0)
      return rr("BSBHP_NAMA_PT_DOCS", L, "FAIL", `Nama PT tidak konsisten — ${mismatches.join("; ")}`, { anchor, mismatches });
    if (checked === 0)
      return rr("BSBHP_NAMA_PT_DOCS", L, unchecked.length > 0 ? "WARNING" : "SKIPPED",
        unchecked.length > 0
          ? `Nama PT belum terbaca dari: ${unchecked.join(", ")} — verifikasi manual`
          : "Kedua surat belum diunggah");
    if (unchecked.length > 0)
      return rr("BSBHP_NAMA_PT_DOCS", L, "WARNING",
        `Nama PT cocok pada dokumen terbaca, namun belum terbaca dari: ${unchecked.join(", ")} — verifikasi manual`, { unchecked });
    return rr("BSBHP_NAMA_PT_DOCS", L, "PASS", "Nama PT konsisten pada kedua surat");
  },
};

// ── the assembled tuned matrix ────────────────────────────────────────────────

/** Wrapped shared rules (bodies bit-for-bit in cross-validator.ts). DELIBERATE
 *  EXCLUSIONS (guard-tested; do not "fix"): PERSEROAN_STATE (FAILs everything ≠1 —
 *  it would FAIL every legitimate berakhirnya; replaced by BSBHP_REGISTRY_STATE),
 *  REQUIRED_SUPPORTING_DOCS + UPLOADED_DOC_UNUSED (jenis-scoped bodies — the
 *  Pembubaran-adjudicated exclusion class; BSBHP_REQUIRED_DOCS is the surface),
 *  the whole cap-table/modal/KBLI group + jenis machinery (nothing changes — the
 *  transaction erases the entity), and DOMISILI_NAMA / CONTACT_INFO_COMPLETE /
 *  PASSPORT_AKTA (spec §4.5 tuned: DOMISILI + DATA_KONTAK are OPTIONAL here —
 *  2026-07-02 user decision — and no WNA surface is asserted). */
export const BERAKHIRNYA_SHARED_RULE_CODES = [
  "NIK_KTP_AKTA", "NAMA_NPWP_KTP", "KTP_COMPLETENESS", "NPWP_COMPLETENESS",
  "NAMA_PT_CONSISTENCY", "NPWP_PERSEROAN_FUZZY", "AKTA_DATE_WINDOW",
  "OLD_DATA_CONSISTENCY", "NOTARIS_TERAKHIR",
] as const;

export const BERAKHIRNYA_RULE_SET: Rule<BerakhirnyaCtx>[] = [
  requiredDocsRule,
  registryStateRule,
  pembubaranPrereqRule,
  likuidatorConsistencyRule,
  pengumumanOrderRule,
  docDateSanityRule,
  pembubaranRefRule,
  sisaKekayaanRule,
  namaPtDocsRule,
  // DEFAULT >1/2 (Ps.86(1)): the discharge RUPS accepts the likuidator's accounting —
  // it is NOT the Ps.89(1) dissolution decision (that quorum applied at PEMBUBARAN_PT).
  rupsQuorumRule(),
  ...pickRules(PT_AKTA_RULE_SET, BERAKHIRNYA_SHARED_RULE_CODES),
];
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/rules-berakhirnya.test.ts. Expected: all pass.
  • [ ] Regression: cd backend && bun test src/flow-engine/__tests__/rules-pembubaran.test.ts src/flow-engine/__tests__/rules-pt-akta.test.ts src/flow-engine/__tests__/wrapper-fidelity.test.ts. Expected: untouched suites stay green (validatePerseroanState unmodified).
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/rules/berakhirnya backend/src/flow-engine/__tests__/rules-berakhirnya.test.ts && git commit -m "feat(berakhirnya): BERAKHIRNYA_RULE_SET tuned matrix — registry-state prerequisite gate + DITOLAK-aware local prereq + never-FAIL pengumuman order"

Task 6: The real FlowConfig — registry entry + subtype label row + single-source-lookup proof + rematch/dispatch proof

Registers berakhirnyaStatusPtFlow as the FOURTH (final) v2-generic tenant, replacing the Task-1 stub. The config is the spec-§4.2 sketch conformed to the LANDED canonical shapes (C11): companyLookup: true (single-source, DEFAULT status filter — the allowedStatusTransaksi seam stays UNUSED per the 2026-07-03 erratum; proven here with an injected FlowSabhDeps spy that the lookup is called with NO opts), rematch.rupsAttendance: true (the canonical mechanism that superseded the spec's preValidationSteps sketch — pembubaran-adjudicated), hooks.afterAktaExtraction → runBerakhirnyaDeedPasses. Plus the defensive AKTA_SUBTYPE_MAP label row (AKTA_BERAKHIRNYA → 'perubahan') and the rematch-dispatch proof (reference_rematch_dispatch — field edits must reach this flow's config or validations silently wipe).

Files
- backend/src/flow-engine/flows/berakhirnya-status-pt.ts — Create
- backend/src/flow-engine/registry.ts — Modify (stub → real entry)
- backend/src/ocr/classifier.ts — Modify (AKTA_SUBTYPE_MAP one row)
- backend/src/flow-engine/__tests__/berakhirnya-flow.test.ts — Create
- backend/src/flow-engine/__tests__/registry.test.ts — Modify (V2_SHIPPED)

Interfaces
- Produces: berakhirnyaStatusPtFlow: FlowConfig<BerakhirnyaCtx>; FLOW_REGISTRY.BERAKHIRNYA_STATUS_PT.engine === "v2-generic"; typesForRouteFamily("berakhirnya") === ["BERAKHIRNYA_STATUS_PT"]; getAktaSubtype({classification:"AKTA_BERAKHIRNYA"}) === "perubahan".

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/berakhirnya-flow.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { FLOW_REGISTRY, flowFor, typesForRouteFamily } from "../registry";
import { berakhirnyaStatusPtFlow } from "../flows/berakhirnya-status-pt";
import { getAktaSubtype } from "../../ocr/classifier";
import { runFlow } from "../processor";
import { queueReMatchAndValidate } from "../../services/submission-processor";

describe("berakhirnyaStatusPtFlow — the FOURTH (final) v2-generic registry entry", () => {
  const subIds: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: subIds } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  it("is registered (stub replaced) and shaped per the canonical FlowConfig", () => {
    const entry = flowFor("BERAKHIRNYA_STATUS_PT");
    expect(entry).toBe(berakhirnyaStatusPtFlow as never);
    expect(entry.engine).toBe("v2-generic");
    expect(berakhirnyaStatusPtFlow.primaryAkta?.classifiedType).toBe("AKTA_BERAKHIRNYA");
    // Single-source, DEFAULT status filter — the allowedStatusTransaksi seam stays UNUSED (2026-07-03 erratum)
    expect(berakhirnyaStatusPtFlow.companyLookup).toBe(true);
    expect(berakhirnyaStatusPtFlow.extraction.overrideByClassifiedType).toEqual({ AKTA_BERAKHIRNYA: "perubahan" });
    expect(berakhirnyaStatusPtFlow.extraction.focusedPasses).toEqual({
      SURAT_PERMOHONAN: "surat-permohonan",
      LAPORAN_LIKUIDASI: "laporan-likuidasi",
      BUKTI_PENGUMUMAN: "bukti-pengumuman", // SHARED clipping extractor — one map entry, never re-implemented (C4)
    });
    expect(berakhirnyaStatusPtFlow.requiredDocs).toEqual([
      "AKTA", "BERITA_ACARA_RUPS", "SURAT_PERMOHONAN", "LAPORAN_LIKUIDASI", "BUKTI_PENGUMUMAN", "KTP", "NPWP",
    ]);
    expect(berakhirnyaStatusPtFlow.rematch).toEqual({
      checklistResetDocTypes: ["KTP", "NPWP"],
      standardPtMatchers: true,
      rupsAttendance: true,
    });
    expect(berakhirnyaStatusPtFlow.routeFamily).toBe("berakhirnya");
    expect(typesForRouteFamily("berakhirnya")).toEqual(["BERAKHIRNYA_STATUS_PT"]);
    expect(berakhirnyaStatusPtFlow.failGate).toBeUndefined(); // default 'on'
    expect(typeof berakhirnyaStatusPtFlow.hooks?.afterAktaExtraction).toBe("function");
  });

  it("publishes the 8 spec-§4.7 sections + validasi; required = the 6 doc sections + data_perseroan", () => {
    const keys = berakhirnyaStatusPtFlow.sections.map((s) => s.key);
    expect(keys).toEqual([
      "data_perseroan", "akta_berakhirnya", "risalah_rups", "surat_permohonan",
      "laporan_likuidasi", "pengumuman_149", "pengumuman_152", "identitas_likuidator", "validasi",
    ]);
    const required = berakhirnyaStatusPtFlow.sections.filter((s) => s.required).map((s) => s.key);
    expect(required).toEqual([
      "data_perseroan", "akta_berakhirnya", "risalah_rups", "surat_permohonan",
      "laporan_likuidasi", "pengumuman_149", "pengumuman_152",
    ]);
    expect(berakhirnyaStatusPtFlow.sections.find((s) => s.key === "identitas_likuidator")?.approvable).toBe(true);
    expect(berakhirnyaStatusPtFlow.sections.find((s) => s.key === "validasi")?.approvable).toBe(false);
  });

  it("engine subtype row: AKTA_BERAKHIRNYA rides the perubahan extractor", () => {
    expect(getAktaSubtype({ classification: "AKTA_BERAKHIRNYA", confidence: 1 })).toBe("perubahan");
  });

  it("Phase-2 lookup runs on the DEFAULT status filter — NO opts (FlowSabhDeps spy; the allowedStatusTransaksi seam stays unused, 2026-07-03 erratum)", async () => {
    const sub = await db.submission.create({ data: { type: "BERAKHIRNYA_STATUS_PT", status: "CREATED" } });
    subIds.push(sub.id);
    const akta = await db.document.create({
      data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: "" },
    });
    docIds.push(akta.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_BERAKHIRNYA", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    await db.extractedField.create({ data: { documentId: akta.id, fieldKey: "nama_perseroan", value: "PT KARYA USAI SUDAH", confidence: 0.9, status: "EDITABLE" } });
    let seenOpts: unknown = "never-called";
    await runFlow(sub.id, berakhirnyaStatusPtFlow, {
      lookupCompany: (async (_req: unknown, opts: unknown) => { seenOpts = opts; return { matches: [] }; }) as never,
      timeoutMs: 5000,
    });
    expect(seenOpts).toBeUndefined(); // companyLookup: true → processor passes no opts → default status [1] (erratum)
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.status).toBe("AWAITING_COMPANY_SELECTION"); // zero matches → the manual-resume pause
  });

  it("rematch dispatch resolves THIS flow's config (validation-wipe guard, reference_rematch_dispatch)", async () => {
    const sub = await db.submission.create({
      data: { type: "BERAKHIRNYA_STATUS_PT", status: "READY" },
    });
    subIds.push(sub.id);
    const akta = await db.document.create({
      data: { filename: "a2.pdf", originalName: "a2.pdf", fileSize: 0, filePath: "/tmp/a2.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
    });
    docIds.push(akta.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_BERAKHIRNYA", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    await queueReMatchAndValidate(sub.id);
    const results = await db.validationResult.findMany({ where: { submissionId: sub.id } });
    // The tuned matrix ran: the flow-owned FAIL gate rule exists, the excluded
    // perubahan-family rule does NOT (the flow config, not a fork, was resolved).
    expect(results.some((r) => r.ruleCode === "BSBHP_REQUIRED_DOCS")).toBe(true);
    expect(results.some((r) => r.ruleCode === "PERSEROAN_STATE")).toBe(false);
  }, 20000);
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/berakhirnya-flow.test.ts. Expected: module not found.
  • [ ] Minimal implementation A — create backend/src/flow-engine/flows/berakhirnya-status-pt.ts:
import { defineFlow } from "../types";
import { buildBerakhirnyaContext, type BerakhirnyaCtx } from "../context/berakhirnya-context";
import { BERAKHIRNYA_RULE_SET } from "../rules/berakhirnya/index";

/**
 * Berakhirnya Status Badan Hukum PT (SABH id_aksi_transaksi = 36, code 70) — the
 * FOURTH AND FINAL engine:'v2-generic' FLOW_REGISTRY tenant (ship order:
 * LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT).
 *
 * The TERMINAL PT transaction: ends legal personhood AFTER pembubaran +
 * liquidation complete. There is NO diff — the whole payload is document headers
 * of the 6-PDF statutory package; the review is per-document sections + the
 * registry snapshot. The deed rides the PERUBAHAN extractor
 * (classification-override; AKTA_AKUISISI/AKTA_PEMBUBARAN precedent).
 *
 * SINGLE-SOURCE LOOKUP (spec §4.6, reconciled 2026-07-03 — erratum): lookupCompany
 * keeps its DEFAULT status_transaksi = 1 filter (companyLookup: true), because an
 * APPROVED pembubaran row sits at status 1 — a PT in liquidation IS discoverable via
 * the default lookup, so the allowedStatusTransaksi seam stays UNUSED. The
 * prerequisite is enforced NOT by the lookup filter but at validation by
 * BSBHP_REGISTRY_STATE, which reads a pre-fetched registry snapshot
 * (loadPembubaranRegistryState) on the SELECTED PT.
 *
 * VERIFIKATOR-STAGED: routes/berakhirnya.ts submit → COMPLETED +
 * VerifikasiPerubahan (MENUNGGU_VERIFIKATOR). Voter roster =
 * oldData.pemegang_saham via resolveVoterRoster's fallback (AKTA_BERAKHIRNYA
 * deliberately NOT in ROSTER_AKTA_TYPES). FAIL gate = block-with-override
 * (checkFailGate; default 'on'). Exact-equality type checks only, everywhere.
 */
export const berakhirnyaStatusPtFlow = defineFlow<BerakhirnyaCtx>({
  type: "BERAKHIRNYA_STATUS_PT",
  engine: "v2-generic",
  primaryAkta: {
    classifiedType: "AKTA_BERAKHIRNYA",
    missingError: "Akta Berakhirnya Status Badan Hukum tidak ditemukan pada submission",
    extractError: "Ekstraksi Akta Berakhirnya Status Badan Hukum gagal",
  },
  skipExtractionTypes: ["OTHER"],
  // Single-source, DEFAULT status filter (companyLookup: true) — an approved
  // pembubaran row sits at status 1, so the default lookup finds a liquidating PT;
  // the prerequisite is gated by BSBHP_REGISTRY_STATE, not this filter (2026-07-03
  // erratum). The allowedStatusTransaksi seam stays UNUSED.
  companyLookup: true,
  applyContactInfo: true, // DATA_KONTAK is OPTIONAL here (spec §4.5) — applied when present
  extraction: {
    overrideByClassifiedType: { AKTA_BERAKHIRNYA: "perubahan" },
    focusedPasses: {
      SURAT_PERMOHONAN: "surat-permohonan",   // built HERE (Task 2)
      LAPORAN_LIKUIDASI: "laporan-likuidasi", // built HERE (Task 2) — laporan shape ONLY (C2)
      BUKTI_PENGUMUMAN: "bukti-pengumuman",   // SHARED clipping extractor (built in Peleburan,
                                              // hardened in Pembubaran); BOTH _149/_152 fine labels
                                              // map to this carrier DocumentType
    },
  },
  buildContext: buildBerakhirnyaContext,
  rules: BERAKHIRNYA_RULE_SET,
  // DocumentType enum values (requiredDocs contract). The _149/_152 per-pasal
  // coverage is enforced by BSBHP_REQUIRED_DOCS over the fine LABELS — the enum
  // carrier here is the single BUKTI_PENGUMUMAN.
  requiredDocs: ["AKTA", "BERITA_ACARA_RUPS", "SURAT_PERMOHONAN", "LAPORAN_LIKUIDASI", "BUKTI_PENGUMUMAN", "KTP", "NPWP"],
  sections: [
    { key: "data_perseroan",       label: "Data Perseroan (SABH)",                    approvable: true,  required: true },
    { key: "akta_berakhirnya",     label: "Akta Berakhirnya Status Badan Hukum",      approvable: true,  required: true },
    { key: "risalah_rups",         label: "Risalah RUPS & Kehadiran",                 approvable: true,  required: true },
    { key: "surat_permohonan",     label: "Surat Permohonan",                         approvable: true,  required: true },
    { key: "laporan_likuidasi",    label: "Laporan Akhir Likuidasi",                  approvable: true,  required: true },
    { key: "pengumuman_149",       label: "Bukti Pengumuman Koran — Pasal 149",       approvable: true,  required: true },
    { key: "pengumuman_152",       label: "Bukti Pengumuman Koran — Pasal 152",       approvable: true,  required: true },
    // approvable but NOT required — identity checks run automatically (spec §4.7:
    // required = the 6 doc sections + data_perseroan).
    { key: "identitas_likuidator", label: "Dokumen Identitas (KTP/NPWP)",             approvable: true,  required: false },
    { key: "validasi",             label: "Hasil Validasi",                           approvable: false, required: false },
  ],
  rematch: {
    // CHECKLIST-LABEL vocabulary (types.ts:31-47 erratum). No DOMISILI row: the
    // doc is OPTIONAL here (spec §4.5) and no domisili cross-check is in the
    // matrix; no SURAT_PERMOHONAN/LAPORAN_LIKUIDASI/BUKTI_PENGUMUMAN rows — the
    // checklist registers none for them (pembubaran precedent).
    checklistResetDocTypes: ["KTP", "NPWP"],
    standardPtMatchers: true,
    rupsAttendance: true, // re-runs matchRupsAttendance incl. the registry-roster fallback (Task 3)
  },
  hooks: {
    afterAktaExtraction: async (a) => {
      const { runBerakhirnyaDeedPasses } = await import("../../services/akta-pembubaran-ref-extract");
      await runBerakhirnyaDeedPasses(a);
    },
  },
  routeFamily: "berakhirnya", // union value already present in flow-engine/types.ts
  labels: { akta: "Akta Berakhirnya Status Badan Hukum", flow: "Berakhirnya Status Badan Hukum" },
});
  • [ ] Minimal implementation B — backend/src/flow-engine/registry.ts: replace the Task-1 stub line with:
  BERAKHIRNYA_STATUS_PT: berakhirnyaStatusPtFlow, // FOURTH (final) v2-generic tenant (single-source lookup + registry-state prerequisite gate; verifikator-staged)

and add the import import { berakhirnyaStatusPtFlow } from "./flows/berakhirnya-status-pt"; next to the other flow imports.
- [ ] Minimal implementation C — backend/src/ocr/classifier.ts, in AKTA_SUBTYPE_MAP after the AKTA_PEMBUBARAN row:

  // Hybrid: berakhirnya rides the PERUBAHAN extractor (deed header + RUPS +
  // penghadap; spec §4.4). Defensive label row — the engine passes the raw
  // subtype, resolved by the identity rows below.
  AKTA_BERAKHIRNYA: "perubahan",
  • [ ] Minimal implementation D — backend/src/flow-engine/__tests__/registry.test.ts: V2_SHIPPED becomes new Set(["LAPORAN_RUPS_TAHUNAN", "PELEBURAN_PT", "PEMBUBARAN_PT", "BERAKHIRNYA_STATUS_PT"]).
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/berakhirnya-flow.test.ts src/flow-engine/__tests__/registry.test.ts src/flow-engine/__tests__/dispatch-routing.test.ts src/flow-engine/__tests__/rematch-parity.test.ts. Expected: all pass.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/flows/berakhirnya-status-pt.ts backend/src/flow-engine/registry.ts backend/src/ocr/classifier.ts backend/src/flow-engine/__tests__/berakhirnya-flow.test.ts backend/src/flow-engine/__tests__/registry.test.ts && git commit -m "feat(berakhirnya): real FlowConfig registered — fourth and final v2-generic tenant + single-source-lookup proof"

Task 7: Routing + gating — BUILT flip, predicate ownership handoff, ladder rung ABOVE Peleburan, carrier doc-type maps, labels endpoint, stale-fixture flip (THE LAUNCH SWITCH)

Flips BUILT_AKTA_TXN_TYPES += "berakhirnya" — from this task on the package routes into the flow, so everything before must already work. The predicate ownership handoff (C5): BERAKHIRNYA_PACKAGE (owned in submissions.ts, body UNCHANGED) stops deflecting — the detectUnsupportedAktaType early-return is REMOVED and the positive inferSubmissionType rung lands at the canonical position ABOVE Peleburan. The mapToDocumentType carrier rows make the fine labels routable (they currently fall to the silent "AKTA" fallback) and make the predicate's ≥2 carrier clause fire for _149/_152. Plus the TITLE_RULES fast-path (spec §10 Q6 — cheap, deterministic, harmless when deeds carry generic PKR titles), the ?type=berakhirnya board doc-set, and the stale-fixture flip (after this task ALL 7 taxonomy types are built — no unbuilt example remains).

Files
- backend/src/services/akta-txn-classifier.ts — Modify (BUILT flip + TITLE_RULES row)
- backend/src/routes/submissions.ts — Modify (deflection removal + rung + return union + maps)
- backend/src/routes/klasifikasi.ts — Modify (BERAKHIRNYA_DOC_TYPES + labels endpoint + LABEL_MAP spread)
- backend/src/routes/__tests__/berakhirnya-routing.test.ts — Create
- backend/src/routes/__tests__/pembubaran-routing.test.ts — Modify (stale fixtures)
- backend/src/routes/__tests__/peleburan-routing.test.ts — Modify (stale fixture, line 12)
- backend/src/routes/__tests__/laporan-rups-routing.test.ts — Modify (stale fixture, line 14)
- backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts — Modify (stale fixtures, lines 35-36 + 46)

Interfaces
- Produces: inferSubmissionType return union += "BERAKHIRNYA_STATUS_PT"; mapToDocumentType("SURAT_PERMOHONAN") === "SURAT_PERMOHONAN", ("LAPORAN_LIKUIDASI") === "LAPORAN_LIKUIDASI", ("BUKTI_PENGUMUMAN_149"|"BUKTI_PENGUMUMAN_152") === "BUKTI_PENGUMUMAN"; GET /api/klasifikasi/labels?type=berakhirnya.

Steps

  • [ ] Write failing test. Create backend/src/routes/__tests__/berakhirnya-routing.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import {
  inferSubmissionType, detectUnsupportedAktaType, BERAKHIRNYA_PACKAGE,
  mapToDocumentType, getProcessingOrder,
} from "../submissions";
import { BUILT_AKTA_TXN_TYPES, detectAktaTitleType } from "../../services/akta-txn-classifier";
import { app } from "../../index";

const f = (classification: string, userOverride?: string) => ({ classification, userOverride });

describe("BERAKHIRNYA_STATUS_PT routing + gating (THE FINAL LAUNCH SWITCH)", () => {
  it("berakhirnya is BUILT — ALL 7 taxonomy types now built; nothing gates", () => {
    expect(BUILT_AKTA_TXN_TYPES.has("berakhirnya")).toBe(true);
    expect(BUILT_AKTA_TXN_TYPES.size).toBe(7);
    expect(detectUnsupportedAktaType([f("AKTA_BERAKHIRNYA")])).toBeNull();
    // the package DEFLECTION is gone — a berakhirnya package no longer gates
    expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN"), f("LAPORAN_LIKUIDASI")])).toBeNull();
  });

  it("TITLE_RULES fast-path: an explicit deed title short-circuits deterministically (§10 Q6)", () => {
    expect(detectAktaTitleType("AKTA BERAKHIRNYA STATUS BADAN HUKUM\nPT X").txnType).toBe("berakhirnya");
    expect(detectAktaTitleType("AKTA PEMBUBARAN PT X").txnType).toBe("pembubaran"); // untouched
  });

  it("the predicate ROUTES now: every package shape → BERAKHIRNYA_STATUS_PT (rung uses BERAKHIRNYA_PACKAGE positively)", () => {
    expect(inferSubmissionType([f("AKTA_BERAKHIRNYA")])).toBe("BERAKHIRNYA_STATUS_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("LAPORAN_LIKUIDASI")])).toBe("BERAKHIRNYA_STATUS_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("SURAT_PERMOHONAN")])).toBe("BERAKHIRNYA_STATUS_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN_152")])).toBe("BERAKHIRNYA_STATUS_PT");
    // ≥2 docs mapping to the carrier — NOW including the fine labels via mapToDocumentType
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN_149"), f("BUKTI_PENGUMUMAN_152")])).toBe("BERAKHIRNYA_STATUS_PT");
    // confident-wrong GPU label re-mapped by the operator → override wins
    expect(inferSubmissionType([f("AKTA_PERUBAHAN", "AKTA_BERAKHIRNYA")])).toBe("BERAKHIRNYA_STATUS_PT");
  });

  it("ladder: ABOVE Peleburan/Akuisisi/Pembubaran; Perbaikan still wins; bare pembubaran deed UNAFFECTED", () => {
    expect(inferSubmissionType([f("AKTA_BERAKHIRNYA"), f("AKTA_PELEBURAN")])).toBe("BERAKHIRNYA_STATUS_PT");
    expect(inferSubmissionType([f("AKTA_BERAKHIRNYA"), f("AKTA_AKUISISI")])).toBe("BERAKHIRNYA_STATUS_PT");
    expect(inferSubmissionType([f("AKTA_BERAKHIRNYA"), f("SURAT_PERNYATAAN_PERBAIKAN")])).toBe("PERBAIKAN_DATA_PT");
    // Pembubaran keeps winning bare deeds + a SINGLE Ps.147 clipping (predicate untouched)
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("KTP")])).toBe("PEMBUBARAN_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN")])).toBe("PEMBUBARAN_PT");
    // regressions: existing rungs untouched
    expect(inferSubmissionType([f("AKTA_PELEBURAN")])).toBe("PELEBURAN_PT");
    expect(inferSubmissionType([f("AKTA_AKUISISI")])).toBe("AKUISISI_PT");
    expect(inferSubmissionType([f("AKTA_PERUBAHAN")])).toBe("PERUBAHAN_PT");
    expect(inferSubmissionType([f("AKTA_RUPS_TAHUNAN")])).toBe("LAPORAN_RUPS_TAHUNAN");
    expect(inferSubmissionType([f("SURAT_PERNYATAAN_PENDIRIAN_PP")])).toBe("PENDIRIAN_PP");
  });

  it("carrier doc-type maps: fine labels stop falling to the silent AKTA fallback", () => {
    expect(mapToDocumentType("SURAT_PERMOHONAN")).toBe("SURAT_PERMOHONAN");
    expect(mapToDocumentType("LAPORAN_LIKUIDASI")).toBe("LAPORAN_LIKUIDASI");
    expect(mapToDocumentType("BUKTI_PENGUMUMAN_149")).toBe("BUKTI_PENGUMUMAN");
    expect(mapToDocumentType("BUKTI_PENGUMUMAN_152")).toBe("BUKTI_PENGUMUMAN");
    expect(mapToDocumentType("BUKTI_PENGUMUMAN")).toBe("BUKTI_PENGUMUMAN"); // unchanged
    // regression pins: the KORPORASI letter keeps its PRE-EXISTING fallback behavior
    expect(mapToDocumentType("SURAT_PERMOHONAN_KORPORASI")).toBe("AKTA");
    expect(mapToDocumentType("AKTA_BERAKHIRNYA")).toBe("AKTA");
    // processing order: supporting docs after the deed + cards
    expect(getProcessingOrder("SURAT_PERMOHONAN")).toBe(6);
    expect(getProcessingOrder("LAPORAN_LIKUIDASI")).toBe(6);
    expect(getProcessingOrder("BUKTI_PENGUMUMAN_149")).toBe(6);
    expect(getProcessingOrder("BUKTI_PENGUMUMAN_152")).toBe(6);
    expect(getProcessingOrder("AKTA_BERAKHIRNYA")).toBe(0);
  });

  it("BERAKHIRNYA_PACKAGE body is UNCHANGED (still owned once; single clipping still does not trip)", () => {
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN")])).toBe(false);
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("KTP"), f("NPWP")])).toBe(false);
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN_149"), f("BUKTI_PENGUMUMAN_152")])).toBe(true);
  });

  it("create route now CREATES a BERAKHIRNYA_STATUS_PT submission for the package (deflection removed)", async () => {
    const res = await app.request("/api/submissions", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        files: [
          { fileId: "nx1", classification: "AKTA_BERAKHIRNYA", confidence: 0.9 },
          { fileId: "nx2", classification: "LAPORAN_LIKUIDASI", confidence: 0.9 },
        ],
      }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { supported?: boolean; submissionId?: string; type?: string };
    expect(body.supported).toBeUndefined(); // no gate
    expect(body.type).toBe("BERAKHIRNYA_STATUS_PT");
    // cleanup (the nonexistent fileIds are skipped — the submission itself is created)
    if (body.submissionId) await db.submission.delete({ where: { id: body.submissionId } });
  });

  it("labels endpoint serves the berakhirnya doc set (6-doc package required; DOMISILI/KONTAK optional — tuned §4.5)", async () => {
    const res = await app.request("/api/klasifikasi/labels?type=berakhirnya");
    expect(res.status).toBe(200);
    const labels = (await res.json()) as { key: string; required: boolean; maxCount?: number }[];
    const byKey = new Map(labels.map((l) => [l.key, l]));
    expect(byKey.get("AKTA_BERAKHIRNYA")?.required).toBe(true);
    expect(byKey.get("AKTA_BERAKHIRNYA")?.maxCount).toBe(1);
    expect(byKey.get("BERITA_ACARA_RUPS")?.required).toBe(true);
    expect(byKey.get("SURAT_PERMOHONAN")?.required).toBe(true);
    expect(byKey.get("LAPORAN_LIKUIDASI")?.required).toBe(true);
    expect(byKey.get("BUKTI_PENGUMUMAN_149")?.required).toBe(true);
    expect(byKey.get("BUKTI_PENGUMUMAN_149")?.maxCount).toBeUndefined(); // ≥1 allowed
    expect(byKey.get("BUKTI_PENGUMUMAN_152")?.required).toBe(true);
    expect(byKey.get("KTP")?.required).toBe(true);
    expect(byKey.get("NPWP")?.required).toBe(true);
    expect(byKey.get("SURAT_PERNYATAAN_DOMISILI")?.required).toBe(false); // tuned optional
    expect(byKey.get("DATA_KONTAK")?.required).toBe(false);               // tuned optional
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/berakhirnya-routing.test.ts.
  • [ ] Minimal implementation A — backend/src/services/akta-txn-classifier.ts:
    1. BUILT_AKTA_TXN_TYPES gains:
  "berakhirnya",          // LAUNCH SWITCH — BERAKHIRNYA_STATUS_PT shipped (FOURTH AND FINAL v2-generic tenant; all 7 taxonomy types now built)
  1. TITLE_RULES gains (after the AKTA_PEMBUBARAN row — distinct prefixes, order-independent; the §10-Q6 cheap fast-path; deeds with generic PKR titles simply fall through to the LLM as today):
  { re: /^\s*AKTA\s+BERAKHIRNYA/im, txnType: "berakhirnya" },
  • [ ] Minimal implementation B — backend/src/routes/submissions.ts:
    1. detectUnsupportedAktaType — REMOVE the if (BERAKHIRNYA_PACKAGE(files)) return "berakhirnya"; early-return and its comment block; replace with:
export function detectUnsupportedAktaType(files: InferableFile[]): AktaTxnType | null {
  // NOTE (flow #4 shipped): the BERAKHIRNYA_PACKAGE deflection that used to live
  // here is gone — the package now routes positively via the inferSubmissionType
  // rung. This function remains for FUTURE unbuilt types (all 7 current taxonomy
  // types are built; the merger unsupported-title path is handled upstream).
  for (const f of files) {
    const eff = f.userOverride ?? f.classification;
    const t = AKTA_LABEL_TO_TXN_TYPE[eff];
    if (t && !BUILT_AKTA_TXN_TYPES.has(t)) return t;
  }
  return null;
}
  1. inferSubmissionType — widen the return union with | "BERAKHIRNYA_STATUS_PT", update the precedence doc-comment to Perbaikan > Berakhirnya > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > Pendirian PP > Pendirian PT (all rungs of the canonical ladder now built), and insert BETWEEN the Perbaikan rung and the AKTA_PELEBURAN rung:
  // BERAKHIRNYA rung — canonical position: Perbaikan > BERAKHIRNYA > Peleburan >
  // Akuisisi > Pembubaran. The doc-set PREDICATE (not a bare label) is the rung:
  // the final PKR often recites the pembubaran (so its deed label may read
  // AKTA_PEMBUBARAN), and the liquidation-final documents are the tell. ABOVE
  // Peleburan: a berakhirnya package must never be stolen by another deed label
  // riding along in the bundle.
  if (BERAKHIRNYA_PACKAGE(files)) return "BERAKHIRNYA_STATUS_PT";

The Pembubaran rung's && !BERAKHIRNYA_PACKAGE(files) guard STAYS (now redundant — the package returns earlier — but belt-and-braces per its own comment).
3. mapToDocumentType — replace the exact-equality BUKTI_PENGUMUMAN line and add the two letters (EXACT equality for SURAT_PERMOHONANSURAT_PERMOHONAN_KORPORASI must keep its pre-existing fallback behavior):

  // Fine labels BUKTI_PENGUMUMAN_149/_152 (the operator-assigned pasal axis) map
  // onto the ONE carrier — same many-labels→one-DocumentType pattern as
  // BUKTI_TRANSFER-* → BUKTI_SETOR. Without this row the fine labels fell to the
  // silent "AKTA" fallback (mis-routed into the akta extractor AND invisible to
  // the BERAKHIRNYA_PACKAGE carrier-count clause).
  if (classification.startsWith("BUKTI_PENGUMUMAN")) return "BUKTI_PENGUMUMAN";
  if (classification === "SURAT_LIKUIDATOR") return "SURAT_LIKUIDATOR";
  // EXACT equality — SURAT_PERMOHONAN_KORPORASI (Perbaikan's letter) is a
  // DIFFERENT DocumentType-less label and keeps its pre-existing behavior.
  if (classification === "SURAT_PERMOHONAN") return "SURAT_PERMOHONAN";
  if (classification === "LAPORAN_LIKUIDASI") return "LAPORAN_LIKUIDASI";

(The SURAT_LIKUIDATOR line already exists — keep exactly one copy; the BUKTI_PENGUMUMAN exact-equality line is REPLACED by the startsWith line.)
4. getProcessingOrder — replace the exact-equality BUKTI_PENGUMUMAN line with if (classification.startsWith("BUKTI_PENGUMUMAN")) return 6; and add:

  if (classification === "SURAT_PERMOHONAN" || classification === "LAPORAN_LIKUIDASI") return 6; // supporting docs, after the deed + cards
  • [ ] Minimal implementation C — backend/src/routes/klasifikasi.ts:
    1. After PEMBUBARAN_DOC_TYPES, add (spec §4.5 required-docs table; DOMISILI/KONTAK tuned OPTIONAL — 2026-07-02 user decision):
const BERAKHIRNYA_DOC_TYPES: DocTypeLabel[] = [
  { key: "AKTA_BERAKHIRNYA", displayName: "Akta Berakhirnya Status Badan Hukum", required: true, maxCount: 1, description: "PKR final — RUPS menerima pertanggungjawaban likuidator" },
  { key: "BERITA_ACARA_RUPS", displayName: "Berita Acara / Risalah RUPS", required: true, maxCount: 1, description: "Wajib — risalah RUPS pertanggungjawaban + kehadiran (SABH PDF #2)" },
  { key: "SURAT_PERMOHONAN", displayName: "Surat Permohonan", required: true, maxCount: 1, description: "Wajib — permohonan pemberitahuan berakhirnya status badan hukum (SABH PDF #3); BUKAN Surat Permohonan Korporasi (Perbaikan)" },
  { key: "LAPORAN_LIKUIDASI", displayName: "Laporan Akhir Likuidasi", required: true, maxCount: 1, description: "Wajib — pertanggungjawaban likuidator termasuk sisa kekayaan (SABH PDF #4)" },
  { key: "BUKTI_PENGUMUMAN_149", displayName: "Bukti Pengumuman Koran — Pasal 149", required: true, description: "Wajib ≥1 — pengumuman kepada kreditor, ≤30 hari sejak pembubaran (SABH PDF #5)" },
  { key: "BUKTI_PENGUMUMAN_152", displayName: "Bukti Pengumuman Koran — Pasal 152", required: true, description: "Wajib ≥1 — pengumuman hasil akhir likuidasi, SETELAH RUPS pertanggungjawaban (SABH PDF #6)" },
  { key: "KTP", displayName: "KTP", required: true, description: "Wajib — KTP likuidator/penghadap (cross-validation)" },
  { key: "NPWP", displayName: "NPWP Perseroan", required: true, description: "Wajib — jangkar identitas PT (NPWP registri tampil pada wizard SABH)" },
  { key: "SURAT_PERNYATAAN_DOMISILI", displayName: "Surat Pernyataan Domisili", required: false, maxCount: 1, description: "Opsional — tidak ada alamat yang diubah pada transaksi ini (tuned §4.5)" },
  { key: "DATA_KONTAK", displayName: "Data Kontak", required: false, description: "Opsional — payload berakhirnya SABH tidak memuat field kontak (tuned §4.5)" },
  { key: "PASSPORT", displayName: "Paspor", required: false, description: "Untuk likuidator/penghadap WNA (alternatif KTP)" },
  { key: "KITAS", displayName: "KITAS / KITAP", required: false, description: "Untuk likuidator/penghadap WNA berdomisili di Indonesia" },
  { key: "OTHER", displayName: "Dokumen lain bukan persyaratan", required: false, description: "Dokumen pendukung tambahan" },
];
  1. Add ...BERAKHIRNYA_DOC_TYPES to the LABEL_MAP spread array.
  2. Labels endpoint: add if (type === "berakhirnya") return c.json(BERAKHIRNYA_DOC_TYPES); and extend the route comment's type list with | berakhirnya.
    - [ ] Stale fixtures — ALL 7 types are now built; the "berakhirnya stays gated" pins flip (update semantics, never delete coverage):
    - backend/src/routes/__tests__/pembubaran-routing.test.ts: (a) expect(BUILT_AKTA_TXN_TYPES.has("berakhirnya")).toBe(false).toBe(true) and expect(detectUnsupportedAktaType([f("AKTA_BERAKHIRNYA")])).toBe("berakhirnya").toBeNull() (retitle the it-block "pembubaran AND berakhirnya are BUILT"); (b) the detectUnsupportedAktaType([...package...])).toBe("berakhirnya") assertion → .toBeNull(); (c) the create-route supported:false test flips to expect body.type === "BERAKHIRNYA_STATUS_PT" + body.supported undefined (mirror the new berakhirnya-routing create test, incl. cleanup); (d) the inferSubmissionType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN")])).toBe("PEMBUBARAN_PT") pin STAYS (single clipping — unchanged behavior).
    - backend/src/routes/__tests__/peleburan-routing.test.ts:12: expect(detectUnsupportedAktaType([f("AKTA_BERAKHIRNYA")])).toBe("berakhirnya").toBeNull(); // ALL txn types are built as of flow #4.
    - backend/src/routes/__tests__/laporan-rups-routing.test.ts:14: same flip; update the trailing comment (// others stay gated// all 7 built as of flow #4).
    - backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts:33-47: the "flags an unbuilt akta type" it-block becomes "returns null for ALL built types (all 7 as of flow #4)" asserting detectUnsupportedAktaType([{ classification: "AKTA_BERAKHIRNYA" }]) is null; the :46 override test keeps its shape but its comment flips ("any label overridden to a built one — all are built now"); the label-map loop at :55 already pins AKTA_BERAKHIRNYA → AKTA / order 0 — keep it.
    - [ ] Run GREEN: cd backend && bun test src/routes/__tests__/berakhirnya-routing.test.ts src/routes/__tests__/pembubaran-routing.test.ts src/routes/__tests__/peleburan-routing.test.ts src/routes/__tests__/laporan-rups-routing.test.ts src/routes/__tests__/klasifikasi-akta-txn.test.ts src/routes/__tests__/akuisisi-routing.test.ts. Expected: all pass (every sibling rung regression-proven).
    - [ ] Typecheck: cd backend && bunx tsc --noEmit.
    - [ ] Commit: git add backend/src/services/akta-txn-classifier.ts backend/src/routes/submissions.ts backend/src/routes/klasifikasi.ts backend/src/routes/__tests__/berakhirnya-routing.test.ts backend/src/routes/__tests__/pembubaran-routing.test.ts backend/src/routes/__tests__/peleburan-routing.test.ts backend/src/routes/__tests__/laporan-rups-routing.test.ts backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts && git commit -m "feat(berakhirnya): FINAL LAUNCH SWITCH — BUILT flip + predicate routes positively + carrier doc-type maps + berakhirnya board set"

Task 8: Classification refinement — the 3B letter-confirm + the fine-label suggestion surfacing (the _149/_152 axis goes LIVE)

The board-side classification quality pass (spec §4.3). Two pieces: (1) berakhirnya-doc-classifier.ts — the focused 3B letter-confirm for SURAT_PERMOHONAN/LAPORAN_LIKUIDASI ONLY (apostille-classifier pattern; plugs into classifySupportingDoc's existing opts.llm seam; BUKTI_PENGUMUMAN NEVER reaches it — C3, classifySupportingDoc returns the keyword hit before the LLM branch, pinned by a fetch-spy test). (2) Fine-label suggestion surfacing in routes/klasifikasi.ts's classify pass: the suggestion object now proposes s.fineLabel ?? s.docType — the already-built _149/_152 axis suggester (supporting-doc-classifier.ts:29-35) becomes REACHABLE on the board (display names exist via Task 7's BERAKHIRNYA_DOC_TYPES LABEL_MAP spread). Everything stays amber/advisory — the operator re-map is authoritative; nothing auto-accepts.

Files
- backend/src/services/berakhirnya-doc-classifier.ts — Create
- backend/src/routes/klasifikasi.ts — Modify (the suggestion block in POST /classify/:fileId)
- backend/src/services/__tests__/berakhirnya-doc-classifier.test.ts — Create
- backend/src/routes/__tests__/klasifikasi-berakhirnya-suggest.test.ts — Create

Interfaces
- Produces: classifyBerakhirnyaSupportingViaLlm(rawText): Promise<{ docType: string; confidence: number } | null> (the opts.llm implementation); the classify endpoint's suggestion.docType may now be a fine label (BUKTI_PENGUMUMAN_149/_152).

Steps

  • [ ] Write failing test. Create backend/src/services/__tests__/berakhirnya-doc-classifier.test.ts:
import { describe, it, expect, afterEach } from "bun:test";
import { classifyBerakhirnyaSupportingViaLlm } from "../berakhirnya-doc-classifier";
import { classifySupportingDoc } from "../supporting-doc-classifier";

function mockLlm(content: object | null, status = 200) {
  const orig = globalThis.fetch;
  let calls = 0;
  globalThis.fetch = (async () => {
    calls++;
    return new Response(
      JSON.stringify({ choices: [{ message: { content: content ? JSON.stringify(content) : "" } }] }),
      { status },
    );
  }) as typeof fetch;
  return { restore: () => { globalThis.fetch = orig; }, calls: () => calls };
}

const PERMOHONAN_WEAK =
  "S U R A T\nkepada Yth. Menteri Hukum\nperihal permohonan pemberitahuan atas berakhirnya st4tus badan hukum PT X"; // mangled heading — keyword pre-screen for the title misses, body signal remains

describe("classifyBerakhirnyaSupportingViaLlm (3B letter-confirm — SURAT_PERMOHONAN/LAPORAN_LIKUIDASI ONLY)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("proposes SURAT_PERMOHONAN with the keyword-confirmed tier", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const m = mockLlm({ docType: "SURAT_PERMOHONAN" });
    try {
      const r = await classifyBerakhirnyaSupportingViaLlm(PERMOHONAN_WEAK);
      expect(r?.docType).toBe("SURAT_PERMOHONAN");
      expect(r?.confidence).toBeGreaterThanOrEqual(0.6);
    } finally { m.restore(); }
  });

  it("OTHER / outage / empty → null (keeps the keyword result or manual assignment)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    let m = mockLlm({ docType: "OTHER" });
    try { expect(await classifyBerakhirnyaSupportingViaLlm(PERMOHONAN_WEAK)).toBeNull(); } finally { m.restore(); }
    m = mockLlm(null, 500);
    try { expect(await classifyBerakhirnyaSupportingViaLlm(PERMOHONAN_WEAK)).toBeNull(); } finally { m.restore(); }
    expect(await classifyBerakhirnyaSupportingViaLlm("")).toBeNull();
  });

  it("C3 pin: a BUKTI_PENGUMUMAN keyword hit NEVER reaches the LLM (fetch spy, zero calls)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const m = mockLlm({ docType: "SURAT_PERMOHONAN" });
    try {
      const s = await classifySupportingDoc(
        "PENGUMUMAN\nPEMBUBARAN PT KARYA USAI SUDAH\nkreditor dipersilakan mengajukan tagihan",
        { llm: classifyBerakhirnyaSupportingViaLlm },
      );
      expect(s.docType).toBe("BUKTI_PENGUMUMAN");
      expect(s.autoAcceptable).toBe(false);
      expect(s.fineLabel).toBe("BUKTI_PENGUMUMAN_149"); // kreditor/pembubaran axis
      expect(m.calls()).toBe(0); // NO LLM over line-split newsprint (C3)
    } finally { m.restore(); }
  });

  it("hasil-likuidasi clipping → _152 axis proposal, still amber", async () => {
    const s = await classifySupportingDoc("PENGUMUMAN HASIL LIKUIDASI\nPT KARYA USAI SUDAH (dalam likuidasi)");
    expect(s.fineLabel).toBe("BUKTI_PENGUMUMAN_152");
    expect(s.autoAcceptable).toBe(false);
  });
});
  • [ ] Create backend/src/routes/__tests__/klasifikasi-berakhirnya-suggest.test.ts — the surfacing seam is the pure suggestion-shaping logic; test it through the exported helper (implementation step B extracts it as shapeSuggestion so it is testable without OCR/GPU plumbing):
import { describe, it, expect } from "bun:test";
import { shapeSuggestion } from "../klasifikasi";
import type { SupportingDocSuggestion } from "../../services/supporting-doc-classifier";

const s = (over: Partial<SupportingDocSuggestion>): SupportingDocSuggestion => ({
  docType: "BUKTI_PENGUMUMAN", confidence: 0.6, source: "keyword", autoAcceptable: false, evidence: null, ...over,
});

describe("shapeSuggestion — the fine-label axis goes LIVE on the board", () => {
  it("proposes the FINE label when present (149/152 axis), display name from the board set", () => {
    expect(shapeSuggestion(s({ fineLabel: "BUKTI_PENGUMUMAN_149" }), "OTHER")).toEqual({
      docType: "BUKTI_PENGUMUMAN_149",
      displayName: "Bukti Pengumuman Koran — Pasal 149",
    });
    expect(shapeSuggestion(s({ fineLabel: "BUKTI_PENGUMUMAN_152" }), "OTHER")?.docType).toBe("BUKTI_PENGUMUMAN_152");
  });
  it("falls back to the coarse docType without a fine label", () => {
    expect(shapeSuggestion(s({}), "OTHER")?.docType).toBe("BUKTI_PENGUMUMAN");
  });
  it("suppresses auto-acceptable hits and no-ops (adopted upstream / nothing to say)", () => {
    expect(shapeSuggestion(s({ autoAcceptable: true, docType: "LAPORAN_LIKUIDASI" }), "OTHER")).toBeNull();
    expect(shapeSuggestion(s({ docType: "" }), "OTHER")).toBeNull();
    expect(shapeSuggestion(s({}), "BUKTI_PENGUMUMAN")).toBeNull(); // already the refined label
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/services/__tests__/berakhirnya-doc-classifier.test.ts src/routes/__tests__/klasifikasi-berakhirnya-suggest.test.ts.
  • [ ] Minimal implementation A — create backend/src/services/berakhirnya-doc-classifier.ts:
/**
 * berakhirnya-doc-classifier.ts — the focused 3B letter-confirm for the two
 * berakhirnya letters (spec §4.3, C3): SURAT_PERMOHONAN and LAPORAN_LIKUIDASI
 * ONLY. Plugs into classifySupportingDoc's opts.llm seam — it is invoked ONLY
 * when the deterministic keyword tier produced no auto-acceptable hit, and
 * classifySupportingDoc short-circuits BUKTI_PENGUMUMAN before the LLM branch
 * (no LLM over line-split newsprint — C3, pinned by test). Advisory only: the
 * seam marks LLM results autoAcceptable:false; the board re-map is authoritative.
 * Confidence tier: keyword-corroborated → 0.8, else 0.6 (apostille pattern).
 */
const CONFIRM_RE: Record<string, RegExp> = {
  SURAT_PERMOHONAN: /permohonan[\s\S]{0,80}(berakhir|hapus)/i,
  LAPORAN_LIKUIDASI: /laporan\s+(akhir\s+)?likuidasi|pertanggungjawaban\s+likuidator/i,
};

export async function classifyBerakhirnyaSupportingViaLlm(
  rawText: string,
): Promise<{ docType: string; confidence: number } | null> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  if (!url || !rawText.trim()) return null;
  try {
    const res = await fetch(`${url}/chat/completions`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model, temperature: 0,
        messages: [
          {
            role: "system",
            content:
              "You are shown OCR text of an Indonesian corporate document from a PT liquidation package. " +
              "Classify it as exactly one of: SURAT_PERMOHONAN (a letter to the Minister REQUESTING the " +
              "notification of the end of a PT's legal-entity status after liquidation), LAPORAN_LIKUIDASI " +
              "(the liquidator's FINAL accounting / pertanggungjawaban report, often stating the remaining " +
              "assets), or OTHER (anything else — including newspaper clippings, appointment letters, deeds, " +
              "ID cards). Return ONLY JSON {docType}.",
          },
          { role: "user", content: rawText.slice(0, 4000) },
        ],
        guided_json: {
          type: "object",
          properties: { docType: { type: "string", enum: ["SURAT_PERMOHONAN", "LAPORAN_LIKUIDASI", "OTHER"] } },
          required: ["docType"],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return null;
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return null;
    const docType = (JSON.parse(m[0]) as { docType?: unknown }).docType;
    if (docType !== "SURAT_PERMOHONAN" && docType !== "LAPORAN_LIKUIDASI") return null;
    const confirmed = CONFIRM_RE[docType]!.test(rawText);
    return { docType, confidence: confirmed ? 0.8 : 0.6 };
  } catch (err) {
    console.warn("[berakhirnya-doc-classifier] 3B letter-confirm failed:", err);
    return null;
  }
}
  • [ ] Minimal implementation B — backend/src/routes/klasifikasi.ts:
    1. Import: import { classifyBerakhirnyaSupportingViaLlm } from "../services/berakhirnya-doc-classifier"; and add SupportingDocSuggestion to the existing supporting-doc-classifier import's type list.
    2. Export the pure suggestion shaper (place it above the klasifikasi Hono instance; display names via the existing getDisplayName):
/** Shape a classifySupportingDoc result into the board's suggestion chip.
 *  Proposes the FINE label when present (the _149/_152 pasal axis — LIVE as of
 *  the Berakhirnya flow; display names come from BERAKHIRNYA_DOC_TYPES via
 *  LABEL_MAP). Auto-acceptable hits were already adopted by refineSupportingLabel;
 *  suggestions are ALWAYS amber — the operator re-map is authoritative. */
export function shapeSuggestion(
  s: SupportingDocSuggestion,
  refinedClassification: string,
): { docType: string; displayName: string } | null {
  const proposed = s.fineLabel ?? s.docType;
  if (!proposed || s.autoAcceptable || proposed === refinedClassification) return null;
  return { docType: proposed, displayName: getDisplayName(proposed) };
}
  1. In POST /classify/:fileId, replace the suggestion block's body with the seam-wired version (same guard conditions):
    let suggestion: { docType: string; displayName: string } | null = null;
    if (confidence < config.classifierMinConfidence && !refinedClassification.startsWith("AKTA_")) {
      try {
        // Letter-confirm LLM tail (SURAT_PERMOHONAN/LAPORAN_LIKUIDASI only — C3:
        // classifySupportingDoc short-circuits BUKTI_PENGUMUMAN before opts.llm).
        const s = await classifySupportingDoc(await ocrText(), { llm: classifyBerakhirnyaSupportingViaLlm });
        suggestion = shapeSuggestion(s, refinedClassification);
      } catch { /* suggestion is best-effort */ }
    }
  • [ ] Run GREEN: cd backend && bun test src/services/__tests__/berakhirnya-doc-classifier.test.ts src/routes/__tests__/klasifikasi-berakhirnya-suggest.test.ts src/services/__tests__/supporting-doc-classifier.test.ts. Expected: all pass (the shared suggester's own suite proves the keyword tiers are untouched). If supporting-doc-classifier.test.ts does not exist under that exact name, run the whole src/services/__tests__ directory instead and record the actual filename in the commit body.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/services/berakhirnya-doc-classifier.ts backend/src/routes/klasifikasi.ts backend/src/services/__tests__/berakhirnya-doc-classifier.test.ts backend/src/routes/__tests__/klasifikasi-berakhirnya-suggest.test.ts && git commit -m "feat(berakhirnya): 3B letter-confirm + fine-label 149/152 suggestion surfacing (amber-only, C3 pinned)"

Task 9: Route family routes/berakhirnya.ts — review-data, READY-state edit surfaces, approvals, submit → VERIFIKATOR

The thin route family (pembubaran mirror, minus likuidator-PUT plus the three letter/clipping surfaces). Every correctable field gets a READY-state entry point (do-not-copy warning 1): PUT /permohonan, PUT /laporan-likuidasi (bulk upserts of the canonical keys — the safety net for a zero-row focused pass on empty rawText), PUT /pengumuman/:documentId (per-clipping — there are ≥2 clippings; incl. nomor_pengumuman, this flow's unique read), PUT /kehadiran (deed/registry both — pembubaran mirror). All edits are MANUAL-status upserts + queueReMatchAndValidate (serialized — reference_rematch_dispatch). Submit: canonical 409 → 7 required section approvals (SECTIONS_UNAPPROVED 400) → checkFailGate 422 → ONE transaction: COMPLETED + VerifikasiPerubahan (MENUNGGU_VERIFIKATOR) / PERBAIKAN re-submit arm + audit event. NO advisory run (no diff → aiReport stays null).

Files
- backend/src/routes/berakhirnya.ts — Create
- backend/src/index.ts — Modify (mount)
- backend/src/routes/__tests__/berakhirnya-routes.test.ts — Create

Interfaces
- Produces: GET /api/berakhirnya/submissions/:submissionId/review-data; PUT .../permohonan, PUT .../laporan-likuidasi, PUT .../pengumuman/:documentId, PUT .../kehadiran; POST .../sections/:sectionKey/{approve,unapprove}; POST .../submit.

Steps

  • [ ] Write failing test. Create backend/src/routes/__tests__/berakhirnya-routes.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";

describe("berakhirnya route family", () => {
  const subIds: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    if (subIds.length) {
      await db.$transaction(async (tx) => {
        await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica;`);
        await tx.$executeRawUnsafe(
          `DELETE FROM "VerifikasiAuditEvent" WHERE "verifikasiId" IN (SELECT id FROM "VerifikasiPerubahan" WHERE "submissionId" = ANY($1::text[]))`,
          subIds,
        );
      });
      await db.submission.deleteMany({ where: { id: { in: subIds } } });
    }
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seedDoc(submissionId: string, documentType: string, classifiedType: string, order: number, fields: { fieldKey: string; value: string | null }[] = []) {
    const doc = await db.document.create({
      data: { filename: `${classifiedType}-${order}.pdf`, originalName: `${classifiedType}.pdf`, fileSize: 0, filePath: `/tmp/x.pdf`, documentType: documentType as never, status: "VERIFICATION_READY", rawText: "seeded" },
    });
    docIds.push(doc.id);
    await db.submissionDocument.create({
      data: { submissionId, documentId: doc.id, classifiedType, confidence: 1, processingOrder: order, extractionStatus: "DONE" },
    });
    if (fields.length) {
      await db.extractedField.createMany({ data: fields.map((f) => ({ documentId: doc.id, ...f, confidence: 0.75, status: "EDITABLE" })) });
    }
    return doc;
  }

  async function seedReady() {
    const sub = await db.submission.create({
      data: {
        type: "BERAKHIRNYA_STATUS_PT", status: "READY",
        oldData: { perseroan: { nama: "PT KARYA USAI SUDAH", id_perseroan: 777, status_transaksi: 1 },
          pemegang_saham: [{ nama: "BUDI SANTOSO", persentase: 60 }, { nama: "SITI AMINAH", persentase: 40 }] },
        rupsKehadiranRegistry: [
          { nama: "BUDI SANTOSO", persentase: 60, kehadiran: null },
          { nama: "SITI AMINAH", persentase: 40, kehadiran: null },
        ],
      },
    });
    subIds.push(sub.id);
    await seedDoc(sub.id, "AKTA", "AKTA_BERAKHIRNYA", 0, [
      { fieldKey: "nama_perseroan", value: "PT KARYA USAI SUDAH" },
      { fieldKey: "nomor_akta", value: "7" },
      { fieldKey: "nomor_akta_pembubaran", value: "5" },
    ]);
    await seedDoc(sub.id, "SURAT_PERMOHONAN", "SURAT_PERMOHONAN", 6, [
      { fieldKey: "nomor_surat_permohonan", value: "012/BSBH/VI/2026" },
    ]);
    await seedDoc(sub.id, "LAPORAN_LIKUIDASI", "LAPORAN_LIKUIDASI", 6, [
      { fieldKey: "nama_likuidator", value: "BUDI HARTONO" },
    ]);
    const c149 = await seedDoc(sub.id, "BUKTI_PENGUMUMAN", "BUKTI_PENGUMUMAN_149", 6, [
      { fieldKey: "nama_koran", value: "Media Indonesia" },
    ]);
    const c152 = await seedDoc(sub.id, "BUKTI_PENGUMUMAN", "BUKTI_PENGUMUMAN_152", 6);
    return { sub, c149, c152 };
  }

  it("review-data returns the composite payload (letters, per-pasal clippings, kehadiran, sections)", async () => {
    const { sub } = await seedReady();
    const res = await app.request(`/api/berakhirnya/submissions/${sub.id}/review-data`);
    expect(res.status).toBe(200);
    const body = (await res.json()) as Record<string, any>;
    expect(body.submission.type).toBe("BERAKHIRNYA_STATUS_PT");
    expect(body.perseroan.nama).toBe("PT KARYA USAI SUDAH");
    expect(body.perseroan.statusTransaksi).toBe(1); // an approved (liquidating) PT row sits at status 1 (2026-07-03 erratum) — passthrough echo
    expect(body.akta.fields.some((f: any) => f.fieldKey === "nomor_akta_pembubaran")).toBe(true);
    expect(body.suratPermohonan.fields.nomor_surat_permohonan.value).toBe("012/BSBH/VI/2026");
    expect(body.suratPermohonan.fields.tanggal_surat_permohonan.status).toBe("NOT_FOUND"); // stable form — absent keys are null rows
    expect(body.laporanLikuidasi.fields.nama_likuidator.value).toBe("BUDI HARTONO");
    expect(body.pengumuman["149"].length).toBe(1);
    expect(body.pengumuman["152"].length).toBe(1);
    expect(body.pengumuman["149"][0].fields.nama_koran.value).toBe("Media Indonesia");
    expect(body.kehadiran.source).toBe("registry");
    expect(body.sections.map((s: any) => s.key)).toContain("pengumuman_152");
    expect(body.sectionApprovals).toEqual({});
  });

  it("PUT /permohonan + /laporan-likuidasi upsert canonical keys as MANUAL (zero-row safety net)", async () => {
    const { sub } = await seedReady();
    const r1 = await app.request(`/api/berakhirnya/submissions/${sub.id}/permohonan`, {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ fields: { tanggal_surat_permohonan: "19 Juni 2026", bogus_key: "x" } }),
    });
    expect(r1.status).toBe(200);
    expect(((await r1.json()) as any).updated).toEqual(["tanggal_surat_permohonan"]); // off-contract keys ignored
    const r2 = await app.request(`/api/berakhirnya/submissions/${sub.id}/laporan-likuidasi`, {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ fields: { sisa_kekayaan: "nihil" } }),
    });
    expect(r2.status).toBe(200);
    const review = (await (await app.request(`/api/berakhirnya/submissions/${sub.id}/review-data`)).json()) as any;
    expect(review.suratPermohonan.fields.tanggal_surat_permohonan.value).toBe("19 Juni 2026");
    expect(review.suratPermohonan.fields.tanggal_surat_permohonan.status).toBe("MANUAL");
    expect(review.laporanLikuidasi.fields.sisa_kekayaan.value).toBe("nihil");
  });

  it("PUT /pengumuman/:documentId edits ONE clipping (incl. nomor_pengumuman); rejects a foreign/non-clipping doc", async () => {
    const { sub, c149 } = await seedReady();
    const ok = await app.request(`/api/berakhirnya/submissions/${sub.id}/pengumuman/${c149.id}`, {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ tanggalPengumuman: "5 Mei 2026", nomorPengumuman: "IKL-4411" }),
    });
    expect(ok.status).toBe(200);
    const review = (await (await app.request(`/api/berakhirnya/submissions/${sub.id}/review-data`)).json()) as any;
    expect(review.pengumuman["149"][0].fields.nomor_pengumuman.value).toBe("IKL-4411");
    const foreign = await app.request(`/api/berakhirnya/submissions/${sub.id}/pengumuman/nonexistent-doc`, {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ tanggalPengumuman: "x" }),
    });
    expect(foreign.status).toBe(404);
  });

  it("PUT /kehadiran writes registry ticks (deed roster absent)", async () => {
    const { sub } = await seedReady();
    const res = await app.request(`/api/berakhirnya/submissions/${sub.id}/kehadiran`, {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ entries: [{ nama: "BUDI SANTOSO", kehadiran: true }] }),
    });
    expect(res.status).toBe(200);
    expect(((await res.json()) as any).source).toBe("registry");
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    const rows = fresh?.rupsKehadiranRegistry as { nama: string; kehadiran: boolean | null }[];
    expect(rows.find((r) => r.nama === "BUDI SANTOSO")?.kehadiran).toBe(true);
  });

  it("canonical 409 WRONG_STATUS on EVERY mutating endpoint outside READY (incl. submit)", async () => {
    const { sub } = await seedReady();
    await db.submission.update({ where: { id: sub.id }, data: { status: "EXTRACTING" } });
    for (const [method, path, body] of [
      ["PUT", "permohonan", { fields: {} }],
      ["PUT", "laporan-likuidasi", { fields: {} }],
      ["PUT", "kehadiran", { entries: [{ nama: "X", kehadiran: true }] }],
      ["POST", "sections/data_perseroan/approve", undefined],
      ["POST", "submit", undefined],
    ] as const) {
      const res = await app.request(`/api/berakhirnya/submissions/${sub.id}/${path}`, {
        method, headers: { "Content-Type": "application/json" },
        ...(body ? { body: JSON.stringify(body) } : {}),
      });
      expect(res.status).toBe(409);
      expect(await res.json()).toEqual({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" });
    }
  });

  it("submit gate ORDER: sections 400 → failgate 422 → staged COMPLETED + MENUNGGU_VERIFIKATOR", async () => {
    const { sub } = await seedReady();
    // (a) no approvals → 400 SECTIONS_UNAPPROVED naming all 7 required keys
    let res = await app.request(`/api/berakhirnya/submissions/${sub.id}/submit`, { method: "POST" });
    expect(res.status).toBe(400);
    const a = (await res.json()) as any;
    expect(a.code).toBe("SECTIONS_UNAPPROVED");
    expect(a.missingSections).toEqual([
      "data_perseroan", "akta_berakhirnya", "risalah_rups", "surat_permohonan",
      "laporan_likuidasi", "pengumuman_149", "pengumuman_152",
    ]);
    // approve all 7 through the routes
    for (const key of a.missingSections) {
      const ap = await app.request(`/api/berakhirnya/submissions/${sub.id}/sections/${key}/approve`, { method: "POST" });
      expect(ap.status).toBe(200);
    }
    // (b) a non-overridden FAIL blocks with the canonical 422 envelope
    await db.validationResult.create({
      data: { submissionId: sub.id, ruleCode: "BSBHP_REGISTRY_STATE", ruleLabel: "x", status: "FAIL", message: "seeded" },
    });
    res = await app.request(`/api/berakhirnya/submissions/${sub.id}/submit`, { method: "POST" });
    expect(res.status).toBe(422);
    const b = (await res.json()) as any;
    expect(b.code).toBe("VALIDATION_FAILED");
    expect(b.error).toBe("Validasi FAIL harus diselesaikan atau di-override sebelum submit");
    expect(b.failing).toEqual(["BSBHP_REGISTRY_STATE"]);
    // (c) override → staged submit succeeds in ONE transaction
    await db.validationResult.updateMany({
      where: { submissionId: sub.id, ruleCode: "BSBHP_REGISTRY_STATE" },
      data: { overridden: true, overrideReason: "test override" },
    });
    res = await app.request(`/api/berakhirnya/submissions/${sub.id}/submit`, { method: "POST" });
    expect(res.status).toBe(200);
    const done = await db.submission.findUnique({ where: { id: sub.id }, include: { verifikasi: true } });
    expect(done?.status).toBe("COMPLETED");
    expect(done?.verifikasi?.status).toBe("MENUNGGU_VERIFIKATOR");
    expect(done?.verifikasi?.aiReport).toBeNull(); // NO advisory — a terminal filing has no diff
  });

  it("PERBAIKAN re-submit arm: clears the AI report fields and flips back to MENUNGGU_VERIFIKATOR", async () => {
    const { sub } = await seedReady();
    for (const key of ["data_perseroan", "akta_berakhirnya", "risalah_rups", "surat_permohonan", "laporan_likuidasi", "pengumuman_149", "pengumuman_152"]) {
      await db.perubahanSectionApproval.create({ data: { submissionId: sub.id, sectionKey: key, approved: true, approvedAt: new Date() } });
    }
    await db.verifikasiPerubahan.create({
      data: { submissionId: sub.id, status: "PERBAIKAN", aiRecommendation: "WARN", aiGeneratedAt: new Date() },
    });
    const res = await app.request(`/api/berakhirnya/submissions/${sub.id}/submit`, { method: "POST" });
    expect(res.status).toBe(200);
    const v = await db.verifikasiPerubahan.findUnique({ where: { submissionId: sub.id } });
    expect(v?.status).toBe("MENUNGGU_VERIFIKATOR");
    expect(v?.aiRecommendation).toBeNull();
    expect(v?.aiGeneratedAt).toBeNull();
  });

  it("type guard: a PEMBUBARAN_PT submission is rejected with 400 (exact equality — never substring)", async () => {
    const other = await db.submission.create({ data: { type: "PEMBUBARAN_PT", status: "READY" } });
    subIds.push(other.id);
    const res = await app.request(`/api/berakhirnya/submissions/${other.id}/review-data`);
    expect(res.status).toBe(400);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/berakhirnya-routes.test.ts. Expected: 404s everywhere (routes not mounted).
  • [ ] Minimal implementation A — create backend/src/routes/berakhirnya.ts:
import { Hono } from "hono";
import { db } from "../lib/db";
import { Prisma } from "../generated/prisma/client";
import { queueReMatchAndValidate } from "../services/submission-processor";
import { checkFailGate } from "../flow-engine/fail-gate";
import { berakhirnyaStatusPtFlow } from "../flow-engine/flows/berakhirnya-status-pt";
import { PERMOHONAN_EXTRACTED_FIELD_KEYS } from "../services/surat-permohonan-extract";
import { LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS } from "../services/laporan-likuidasi-extract";
import { normalizeName } from "../services/rups-attendance-matcher";
import { recordAuditEvent } from "../services/audit-event";
import { getRequestContext } from "../middleware/request-context";

/**
 * Route family 'berakhirnya' — Berakhirnya Status Badan Hukum PT (terminal
 * transaction, id_aksi 36). VERIFIKATOR-STAGED: submit → COMPLETED +
 * VerifikasiPerubahan (MENUNGGU_VERIFIKATOR) in ONE transaction (pembubaran-arm
 * mirror). Field edit/confirm/override ride the generic /api/submissions
 * endpoints (queueReMatchAndValidate reaches this flow via the FLOW_REGISTRY).
 * Company selection rides the type-agnostic POST /api/perubahan/select-company
 * (v2 branch resumes continueFlow) — this family adds NO select route. Every
 * mutating endpoint INCLUDING submit uses the canonical WRONG_STATUS envelope.
 * BERAKHIRNYA_STATUS_PT type guard = exact equality, never substring.
 */
const berakhirnya = new Hono();

const APPROVABLE_KEYS = new Set(berakhirnyaStatusPtFlow.sections.filter((s) => s.approvable).map((s) => s.key));
const READY_ONLY = ["READY"] as const;
const CLIP_KEYS = ["nama_koran", "tanggal_pengumuman", "nomor_pengumuman"] as const;

async function loadTyped(submissionId: string) {
  const submission = await db.submission.findUnique({ where: { id: submissionId } });
  if (!submission) return { error: "not_found" as const, submission: null };
  if (submission.type !== "BERAKHIRNYA_STATUS_PT") return { error: "wrong_type" as const, submission: null };
  return { error: null, submission };
}

function guardStatus(submission: { status: string }, allowed: readonly string[]) {
  return allowed.includes(submission.status) ? { blocked: false as const } : { blocked: true as const };
}

const WRONG_STATUS = { error: "submission cannot be edited in its current status", code: "WRONG_STATUS" } as const;

type KehadiranRow = { nama: string; persentase: number | null; kehadiran: boolean | null };
type FieldCell = { value: string | null; confidence: number; status: string };

/** ALWAYS all canonical keys (absent → NOT_FOUND null row) so cards render a stable form. */
function stableFields(keys: readonly string[], fields: { fieldKey: string; value: string | null; confidence: number; status: string }[] | undefined): Record<string, FieldCell> {
  return Object.fromEntries(
    keys.map((k) => {
      const f = fields?.find((x) => x.fieldKey === k);
      return [k, f ? { value: f.value, confidence: f.confidence, status: f.status } : { value: null, confidence: 0, status: "NOT_FOUND" }];
    }),
  );
}

/** Bulk MANUAL upsert of a canonical key set on ONE carrier doc + serialized rematch. */
async function bulkUpsert(documentId: string, canonical: ReadonlySet<string>, body: { fields?: Record<string, string | null> }, submissionId: string) {
  const updated: string[] = [];
  for (const [fieldKey, value] of Object.entries(body.fields ?? {})) {
    if (!canonical.has(fieldKey)) continue; // ignore anything off-contract
    await db.extractedField.upsert({
      where: { documentId_fieldKey: { documentId, fieldKey } },
      update: { value: value ?? null, confidence: 1, status: "MANUAL" },
      create: { documentId, fieldKey, value: value ?? null, confidence: 1, status: "MANUAL" },
    });
    updated.push(fieldKey);
  }
  if (updated.length > 0) await queueReMatchAndValidate(submissionId).catch(console.error); // serialized — reference_rematch_dispatch
  return updated;
}

// GET /submissions/:submissionId/review-data — the composite review payload (NO diff).
berakhirnya.get("/submissions/:submissionId/review-data", async (c) => {
  const submissionId = c.req.param("submissionId");
  const submission = await db.submission.findUnique({
    where: { id: submissionId },
    include: {
      documents: {
        include: { document: { include: { fields: true, aktaPemegangSaham: true, aktaPenghadap: true } } },
        orderBy: { processingOrder: "asc" },
      },
      validationResults: { orderBy: { createdAt: "asc" } },
      perubahanSectionApprovals: true,
    },
  });
  if (!submission) return c.json({ error: "Submission not found" }, 404);
  if (submission.type !== "BERAKHIRNYA_STATUS_PT") return c.json({ error: "Not a Berakhirnya Status PT submission" }, 400);

  const aktaSubDoc = submission.documents.find((d) => d.classifiedType === "AKTA_BERAKHIRNYA");
  const spSubDoc = submission.documents.find((d) => d.document.documentType === "SURAT_PERMOHONAN");
  const lalSubDoc = submission.documents.find((d) => d.document.documentType === "LAPORAN_LIKUIDASI");
  const clippings = submission.documents.filter((d) => d.document.documentType === "BUKTI_PENGUMUMAN");
  const baSubDoc = submission.documents.find((d) => d.classifiedType === "BERITA_ACARA_RUPS");
  const baExtraction = baSubDoc
    ? await db.beritaAcaraRupsExtraction.findUnique({ where: { documentId: baSubDoc.documentId } })
    : null;

  const old = submission.oldData as { perseroan?: { nama?: string | null; status_transaksi?: number | null; status_perseroan?: string | null } } | null;
  const perseroan = old?.perseroan
    ? { nama: old.perseroan.nama ?? null, statusTransaksi: old.perseroan.status_transaksi ?? null, statusPerseroan: old.perseroan.status_perseroan ?? null }
    : null;

  // Kehadiran — deed roster first; registry fallback second (matcher output; pembubaran mirror).
  const deedPs = aktaSubDoc?.document.aktaPemegangSaham ?? [];
  const registryRows = (submission.rupsKehadiranRegistry as KehadiranRow[] | null) ?? [];
  const kehadiran = deedPs.length > 0
    ? { source: "deed" as const, rows: deedPs.map((p) => ({ nama: p.namaAtauBadanHukum, persentase: p.persentase, kehadiran: p.kehadiranRups })) }
    : registryRows.length > 0
      ? { source: "registry" as const, rows: registryRows }
      : { source: null, rows: [] as KehadiranRow[] };

  const clipOf = (d: (typeof clippings)[number]) => ({
    documentId: d.documentId,
    classifiedType: d.classifiedType,
    originalName: d.document.originalName,
    fields: stableFields(CLIP_KEYS, d.document.fields),
  });

  return c.json({
    submission: { id: submission.id, status: submission.status, type: submission.type },
    perseroan,
    akta: aktaSubDoc
      ? {
          documentId: aktaSubDoc.documentId,
          fields: aktaSubDoc.document.fields.map((f) => ({ fieldKey: f.fieldKey, value: f.value, confidence: f.confidence, status: f.status })),
          penghadap: aktaSubDoc.document.aktaPenghadap.map((p) => ({ nama: p.namaLengkap, nomorIdentitas: p.nomorIdentitas })),
        }
      : null,
    risalah: baSubDoc
      ? { documentId: baSubDoc.documentId, nomorBerita: baExtraction?.nomorBerita ?? null, tanggalRups: baExtraction?.tanggalRups ?? null }
      : null,
    suratPermohonan: {
      hasDoc: !!spSubDoc, documentId: spSubDoc?.documentId ?? null,
      fields: stableFields(PERMOHONAN_EXTRACTED_FIELD_KEYS, spSubDoc?.document.fields),
    },
    laporanLikuidasi: {
      hasDoc: !!lalSubDoc, documentId: lalSubDoc?.documentId ?? null,
      fields: stableFields(LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS, lalSubDoc?.document.fields),
    },
    pengumuman: {
      "149": clippings.filter((d) => d.classifiedType === "BUKTI_PENGUMUMAN_149").map(clipOf),
      "152": clippings.filter((d) => d.classifiedType === "BUKTI_PENGUMUMAN_152").map(clipOf),
      unassigned: clippings.filter((d) => d.classifiedType !== "BUKTI_PENGUMUMAN_149" && d.classifiedType !== "BUKTI_PENGUMUMAN_152").map(clipOf),
    },
    kehadiran,
    documents: submission.documents.map((d) => ({
      documentId: d.documentId, classifiedType: d.classifiedType,
      extractionStatus: d.extractionStatus, originalName: d.document.originalName,
    })),
    validationResults: submission.validationResults,
    sections: berakhirnyaStatusPtFlow.sections,
    sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((a) => [a.sectionKey, a.approved])),
  });
});

// PUT /submissions/:submissionId/permohonan — bulk manual upsert (READY-state
// management surface, warning 1; safety net for a zero-row focused pass).
berakhirnya.put("/submissions/:submissionId/permohonan", async (c) => {
  const submissionId = c.req.param("submissionId");
  const { error, submission } = await loadTyped(submissionId);
  if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
  if (error === "wrong_type") return c.json({ error: "Not a Berakhirnya Status PT submission" }, 400);
  if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
  const spSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, document: { documentType: "SURAT_PERMOHONAN" } },
    select: { documentId: true },
  });
  if (!spSubDoc) return c.json({ error: "Surat permohonan belum diunggah" }, 404);
  type Body = { fields?: Record<string, string | null> };
  const body = await c.req.json<Body>().catch(() => ({}) as Body);
  const updated = await bulkUpsert(spSubDoc.documentId, new Set(PERMOHONAN_EXTRACTED_FIELD_KEYS), body, submissionId);
  return c.json({ documentId: spSubDoc.documentId, updated });
});

// PUT /submissions/:submissionId/laporan-likuidasi — bulk manual upsert.
berakhirnya.put("/submissions/:submissionId/laporan-likuidasi", async (c) => {
  const submissionId = c.req.param("submissionId");
  const { error, submission } = await loadTyped(submissionId);
  if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
  if (error === "wrong_type") return c.json({ error: "Not a Berakhirnya Status PT submission" }, 400);
  if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
  const lalSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, document: { documentType: "LAPORAN_LIKUIDASI" } },
    select: { documentId: true },
  });
  if (!lalSubDoc) return c.json({ error: "Laporan akhir likuidasi belum diunggah" }, 404);
  type Body = { fields?: Record<string, string | null> };
  const body = await c.req.json<Body>().catch(() => ({}) as Body);
  const updated = await bulkUpsert(lalSubDoc.documentId, new Set(LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS), body, submissionId);
  return c.json({ documentId: lalSubDoc.documentId, updated });
});

// PUT /submissions/:submissionId/pengumuman/:documentId — per-CLIPPING edits
// (≥2 clippings exist; each carries its own nama_koran/tanggal/nomor — the
// nomor_pengumuman read is unique to this flow, C4).
berakhirnya.put("/submissions/:submissionId/pengumuman/:documentId", async (c) => {
  const submissionId = c.req.param("submissionId");
  const documentId = c.req.param("documentId");
  const { error, submission } = await loadTyped(submissionId);
  if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
  if (error === "wrong_type") return c.json({ error: "Not a Berakhirnya Status PT submission" }, 400);
  if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
  const clip = await db.submissionDocument.findFirst({
    where: { submissionId, documentId, document: { documentType: "BUKTI_PENGUMUMAN" } },
    select: { documentId: true },
  });
  if (!clip) return c.json({ error: "Kliping pengumuman tidak ditemukan pada submission ini" }, 404);
  type Body = { namaKoran?: string | null; tanggalPengumuman?: string | null; nomorPengumuman?: string | null };
  const body = await c.req.json<Body>().catch(() => ({}) as Body);
  const pairs: Array<[string, string | null]> = [];
  if ("namaKoran" in body) pairs.push(["nama_koran", body.namaKoran ?? null]);
  if ("tanggalPengumuman" in body) pairs.push(["tanggal_pengumuman", body.tanggalPengumuman ?? null]);
  if ("nomorPengumuman" in body) pairs.push(["nomor_pengumuman", body.nomorPengumuman ?? null]);
  for (const [fieldKey, value] of pairs) {
    await db.extractedField.upsert({
      where: { documentId_fieldKey: { documentId, fieldKey } },
      update: { value, confidence: 1, status: "MANUAL" },
      create: { documentId, fieldKey, value, confidence: 1, status: "MANUAL" },
    });
  }
  if (pairs.length > 0) await queueReMatchAndValidate(submissionId).catch(console.error);
  return c.json({ documentId, updated: pairs.map(([k]) => k) });
});

// PUT /submissions/:submissionId/kehadiran — attendance ticks; deed roster
// (AktaPemegangSaham.kehadiranRups) or the registry fallback — pembubaran mirror.
berakhirnya.put("/submissions/:submissionId/kehadiran", async (c) => {
  const submissionId = c.req.param("submissionId");
  const { error, submission } = await loadTyped(submissionId);
  if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
  if (error === "wrong_type") return c.json({ error: "Not a Berakhirnya Status PT submission" }, 400);
  if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
  type Body = { entries?: { nama?: string; kehadiran?: boolean | null }[] };
  const body = await c.req.json<Body>().catch(() => ({}) as Body);
  const entries = (body.entries ?? []).filter((e): e is { nama: string; kehadiran: boolean | null } => !!e.nama?.trim());
  if (entries.length === 0) return c.json({ error: "entries wajib diisi" }, 422);
  const byName = new Map(entries.map((e) => [normalizeName(e.nama), e.kehadiran ?? null]));

  const aktaSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, classifiedType: "AKTA_BERAKHIRNYA" },
    select: { documentId: true },
  });
  const deedPs = aktaSubDoc
    ? await db.aktaPemegangSaham.findMany({ where: { documentId: aktaSubDoc.documentId } })
    : [];
  let updated = 0;
  if (deedPs.length > 0) {
    for (const ps of deedPs) {
      const key = normalizeName(ps.namaAtauBadanHukum);
      if (!byName.has(key)) continue;
      await db.aktaPemegangSaham.update({ where: { id: ps.id }, data: { kehadiranRups: byName.get(key) ?? null } });
      updated++;
    }
  } else {
    const rows = ((submission!.rupsKehadiranRegistry as KehadiranRow[] | null) ?? []).map((r) => {
      const key = normalizeName(r.nama);
      if (!byName.has(key)) return r;
      updated++;
      return { ...r, kehadiran: byName.get(key) ?? null };
    });
    await db.submission.update({
      where: { id: submissionId },
      data: { rupsKehadiranRegistry: rows as unknown as Prisma.InputJsonValue },
    });
  }
  if (updated > 0) await queueReMatchAndValidate(submissionId).catch(console.error);
  return c.json({ updated, source: deedPs.length > 0 ? "deed" : "registry" });
});

// POST /submissions/:submissionId/sections/:sectionKey/approve | /unapprove —
// EXPLICIT persisted rows, never derived from child confirmed state (c0698d8).
for (const [suffix, approved] of [["approve", true], ["unapprove", false]] as const) {
  berakhirnya.post(`/submissions/:submissionId/sections/:sectionKey/${suffix}`, async (c) => {
    const submissionId = c.req.param("submissionId");
    const sectionKey = c.req.param("sectionKey");
    const { error, submission } = await loadTyped(submissionId);
    if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
    if (error === "wrong_type") return c.json({ error: "Not a Berakhirnya Status PT submission" }, 400);
    if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
    if (!APPROVABLE_KEYS.has(sectionKey)) return c.json({ error: `Unknown section: ${sectionKey}` }, 400);
    const row = await db.perubahanSectionApproval.upsert({
      where: { submissionId_sectionKey: { submissionId, sectionKey } },
      update: { approved, approvedAt: approved ? new Date() : null },
      create: { submissionId, sectionKey, approved, approvedAt: approved ? new Date() : null },
    });
    return c.json({ sectionKey: row.sectionKey, approved: row.approved });
  });
}

/**
 * POST /submissions/:submissionId/submit — VERIFIKATOR STAGING.
 * Order: canonical status guard → 7 required section approvals → checkFailGate
 * (block-with-override, NAMED engine step, canonical 422 envelope) → ONE
 * transaction: COMPLETED + VerifikasiPerubahan create / PERBAIKAN re-submit +
 * audit event. NO runAdvisory (the advisory engine builds a perubahan-shaped
 * diff report; a terminal filing has no diff — aiReport stays null; the
 * verifikator reads the validation list). NO auto-confirm anywhere.
 */
berakhirnya.post("/submissions/:submissionId/submit", async (c) => {
  const submissionId = c.req.param("submissionId");
  const submission = await db.submission.findUnique({
    where: { id: submissionId },
    include: { perubahanSectionApprovals: true, verifikasi: true },
  });
  if (!submission) return c.json({ error: "Submission not found" }, 404);
  if (submission.type !== "BERAKHIRNYA_STATUS_PT") return c.json({ error: "Not a Berakhirnya Status PT submission" }, 400);
  if (submission.status !== "READY") return c.json(WRONG_STATUS, 409);

  // (1) required section approvals — the 7 spec-§4.7 keys (identitas_likuidator is
  // approvable-but-not-required; validasi is not approvable).
  const requiredKeys = berakhirnyaStatusPtFlow.sections.filter((s) => s.approvable && s.required).map((s) => s.key);
  const approvedSet = new Set(submission.perubahanSectionApprovals.filter((a) => a.approved).map((a) => a.sectionKey));
  const missingSections = requiredKeys.filter((k) => !approvedSet.has(k));
  if (missingSections.length > 0) {
    return c.json({ error: "Semua bagian wajib harus disetujui", code: "SECTIONS_UNAPPROVED", missingSections }, 400);
  }

  // (2) FAIL gate — block-with-override, canonical envelope.
  const { blocked, failing } = await checkFailGate(submissionId, berakhirnyaStatusPtFlow);
  if (blocked) {
    return c.json({ error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing }, 422);
  }

  // (3) verifikator staging — pembubaran/peleburan/akuisisi arm mirror.
  const lalSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, document: { documentType: "LAPORAN_LIKUIDASI" } },
    select: { document: { select: { fields: { where: { fieldKey: { in: ["nama_likuidator", "nomor_laporan_likuidasi"] } } } } } },
  });
  const aktaSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, classifiedType: "AKTA_BERAKHIRNYA" },
    select: { document: { select: { fields: { where: { fieldKey: { in: ["nomor_akta_pembubaran", "tanggal_akta_pembubaran"] } } } } } },
  });
  await db.$transaction(async (tx) => {
    await tx.submission.update({ where: { id: submissionId }, data: { status: "COMPLETED" } });
    const existing = submission.verifikasi;
    if (!existing) {
      await tx.verifikasiPerubahan.create({ data: { submissionId, status: "MENUNGGU_VERIFIKATOR" } });
    } else if (existing.status === "PERBAIKAN") {
      await tx.verifikasiPerubahan.update({
        where: { submissionId },
        data: { status: "MENUNGGU_VERIFIKATOR", aiReport: Prisma.JsonNull, aiRecommendation: null, aiGeneratedAt: null },
      });
    }
    const isResubmit = !!submission.verifikasi && submission.verifikasi.status === "PERBAIKAN";
    await recordAuditEvent(tx, { submissionId }, {
      eventType: isResubmit ? "NOTARIS_RESUBMIT" : "NOTARIS_SUBMIT",
      actor: "NOTARIS",
      payload: {
        submissionType: "BERAKHIRNYA_STATUS_PT",
        sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((s) => [s.sectionKey, s.approved])),
        likuidator: {
          nama: lalSubDoc?.document.fields.find((f) => f.fieldKey === "nama_likuidator")?.value ?? null,
          nomorLaporan: lalSubDoc?.document.fields.find((f) => f.fieldKey === "nomor_laporan_likuidasi")?.value ?? null,
        },
        pembubaranRef: {
          nomorAkta: aktaSubDoc?.document.fields.find((f) => f.fieldKey === "nomor_akta_pembubaran")?.value ?? null,
          tanggalAkta: aktaSubDoc?.document.fields.find((f) => f.fieldKey === "tanggal_akta_pembubaran")?.value ?? null,
        },
      },
    }, getRequestContext(c));
  });

  return c.json({ status: "COMPLETED" });
});

export default berakhirnya;
  • [ ] Minimal implementation B — backend/src/index.ts: add import berakhirnya from "./routes/berakhirnya"; next to the pembubaran import and app.route("/api/berakhirnya", berakhirnya); next to the pembubaran mount.
  • [ ] Run GREEN: cd backend && bun test src/routes/__tests__/berakhirnya-routes.test.ts. Expected: all pass. NOTE: the PUT/rematch tests trigger queueReMatchAndValidate — serialized and awaited via .catch(console.error) fire-and-forget; if a review-data read races a rematch in CI, re-read once (the assertions are on persisted MANUAL rows, which are written before the queue call — no flake expected).
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/routes/berakhirnya.ts backend/src/index.ts backend/src/routes/__tests__/berakhirnya-routes.test.ts && git commit -m "feat(berakhirnya): route family — review-data + READY-state edit surfaces + staged verifikator submit"

Task 10: Verifikator surface — queue admits BERAKHIRNYA_STATUS_PT + decide-APPROVE E2E + roster-source pin + compact page

Warning-3 obligations. Backend: the queue where in-array + the tagged item type + the ROSTER_AKTA_TYPES non-widening comment. The E2E proves decide-APPROVE → MENUNGGU_VOTING with votes built from oldData.pemegang_saham (the DECLARED roster source), that a deed-restated roster does NOT hijack (AKTA_BERAKHIRNYA not in ROSTER_AKTA_TYPES), and that an empty roster 422s (no silent zero-voter voting). Frontend: the compact verifikator page (pembubaran precedent — validation list + laporan/permohonan/pengumuman/kehadiran snapshots ARE the review; no diff/AI-advisory), dashboard routing, type-union widening.

Files
- backend/src/routes/verifikator.ts — Modify (in-array + tagging)
- backend/src/services/shareholder-voting.ts — Modify (comment only)
- backend/src/routes/__tests__/verifikator-berakhirnya.test.ts — Create
- frontend/src/pages/VerifikatorBerakhirnyaReviewPage.tsx — Create
- frontend/src/hooks/use-verifikator.ts — Modify (type union)
- frontend/src/pages/VerifikatorDashboardPage.tsx — Modify (route ternary)
- frontend/src/routes.tsx — Modify (verifikator route)

Steps

  • [ ] Write failing test. Create backend/src/routes/__tests__/verifikator-berakhirnya.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";

/**
 * Warning-3 obligations: queue admission invariant + decide-APPROVE end-to-end +
 * the voter-roster-source PIN (oldData.pemegang_saham via resolveVoterRoster's
 * fallback — AKTA_BERAKHIRNYA deliberately NOT in ROSTER_AKTA_TYPES; the registry
 * roster is the legal voter set for a terminal transaction). Test DB has no SMTP
 * host → email gate skipped; the roster-empty 422 still fires — that is the pin.
 */
describe("verifikator × BERAKHIRNYA_STATUS_PT", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    if (ids.length) {
      await db.$transaction(async (tx) => {
        await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica;`);
        await tx.$executeRawUnsafe(
          `DELETE FROM "VerifikasiAuditEvent" WHERE "verifikasiId" IN (SELECT id FROM "VerifikasiPerubahan" WHERE "submissionId" = ANY($1::text[]))`,
          ids,
        );
      });
      await db.submission.deleteMany({ where: { id: { in: ids } } });
    }
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seedStaged(opts: { roster?: boolean; deedRoster?: boolean } = { roster: true }) {
    const sub = await db.submission.create({
      data: {
        type: "BERAKHIRNYA_STATUS_PT", status: "COMPLETED",
        oldData: {
          perseroan: { nama: "PT KARYA USAI SUDAH", id_perseroan: 777 },
          ...(opts.roster !== false
            ? { pemegang_saham: [
                { nama: "BUDI SANTOSO", persentase: 60, jumlah_lembar: 600 },
                { nama: "SITI AMINAH", persentase: 40, jumlah_lembar: 400 },
              ] }
            : {}),
        },
      },
    });
    ids.push(sub.id);
    await db.verifikasiPerubahan.create({ data: { submissionId: sub.id, status: "MENUNGGU_VERIFIKATOR" } });
    if (opts.deedRoster) {
      const akta = await db.document.create({
        data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
      });
      docIds.push(akta.id);
      await db.submissionDocument.create({
        data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_BERAKHIRNYA", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
      });
      await db.aktaPemegangSaham.create({
        data: { documentId: akta.id, namaAtauBadanHukum: "PENYUSUP DARI AKTA", persentase: 100, orderIndex: 0 },
      });
    }
    return sub;
  }

  it("queue admits BERAKHIRNYA_STATUS_PT tagged with its real type; PEMBUBARAN_PT rows still admitted (regression)", async () => {
    const sub = await seedStaged();
    const pmb = await db.submission.create({
      data: { type: "PEMBUBARAN_PT", status: "COMPLETED", oldData: { perseroan: { nama: "PT LAIN", id_perseroan: 888 } } },
    });
    ids.push(pmb.id);
    await db.verifikasiPerubahan.create({ data: { submissionId: pmb.id, status: "MENUNGGU_VERIFIKATOR" } });
    const res = await app.request("/api/verifikator/submissions?status=MENUNGGU_VERIFIKATOR&limit=100");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { items: { submissionId: string; type: string }[] };
    expect(body.items.find((i) => i.submissionId === sub.id)?.type).toBe("BERAKHIRNYA_STATUS_PT");
    expect(body.items.find((i) => i.submissionId === pmb.id)?.type).toBe("PEMBUBARAN_PT");
  });

  it("decide-APPROVE end-to-end: MENUNGGU_VOTING + votes from oldData.pemegang_saham (ROSTER-SOURCE PIN — deed roster present and must NOT hijack)", async () => {
    const sub = await seedStaged({ roster: true, deedRoster: true });
    const res = await app.request(`/api/verifikator/submissions/${sub.id}/decide`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ decision: "APPROVE", notes: "roster pin", verifikatorId: "verifikator-dev" }),
    });
    expect(res.status).toBe(200);
    const v = await db.verifikasiPerubahan.findUnique({ where: { submissionId: sub.id }, include: { votes: true } });
    expect(v?.status).toBe("MENUNGGU_VOTING");
    const names = v!.votes.map((x) => x.shareholderName).sort();
    // oldData names, NOT the deed-restated "PENYUSUP DARI AKTA" — AKTA_BERAKHIRNYA is
    // deliberately NOT in ROSTER_AKTA_TYPES.
    expect(names).toEqual(["BUDI SANTOSO", "SITI AMINAH"]);
    expect(v!.votes.find((x) => x.shareholderName === "BUDI SANTOSO")?.sharePercentage).toBe(60);
  });

  it("decide-APPROVE with an EMPTY roster → 422 (no silent zero-voter voting)", async () => {
    const sub = await seedStaged({ roster: false });
    const res = await app.request(`/api/verifikator/submissions/${sub.id}/decide`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ decision: "APPROVE", notes: "no roster", verifikatorId: "verifikator-dev" }),
    });
    expect(res.status).toBe(422);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/verifikator-berakhirnya.test.ts. Expected: the queue test fails (type missing from the in-array → the row is invisible).
  • [ ] Minimal implementation A — backend/src/routes/verifikator.ts:
    1. The queue where (~line 380): type: { in: ["PERUBAHAN_PT", "PELEBURAN_PT", "AKUISISI_PT", "PEMBUBARAN_PT", "BERAKHIRNYA_STATUS_PT"] }; extend the comment above it: "…PELEBURAN_PT/PEMBUBARAN_PT/BERAKHIRNYA_STATUS_PT rows carry no diff/advisory (aiReport null) and surface their validation lists on their per-family pages."
    2. The tagging block (~line 593): add const berakhirnyaIds = new Set(all.filter((v) => v.submission.type === "BERAKHIRNYA_STATUS_PT").map((v) => v.submissionId)); and extend the ternary:
  const tagged = items.map((it) => ({
    ...it,
    type: peleburanIds.has(it.submissionId)
      ? ("PELEBURAN_PT" as const)
      : pembubaranIds.has(it.submissionId)
        ? ("PEMBUBARAN_PT" as const)
        : berakhirnyaIds.has(it.submissionId)
          ? ("BERAKHIRNYA_STATUS_PT" as const)
          : akuisisiIds.has(it.submissionId)
            ? ("AKUISISI_PT" as const)
            : ("PERUBAHAN_PT" as const),
  }));
  • [ ] Minimal implementation B — backend/src/services/shareholder-voting.ts, extend the existing PEMBUBARAN_PT non-widening comment (~line 101) with:
  // BERAKHIRNYA_STATUS_PT (same deliberate NON-widening): AKTA_BERAKHIRNYA is NOT
  // added either — the discharge vote belongs to the REGISTRY roster
  // (oldData.pemegang_saham via the fallback below); a deed-restated roster on the
  // final PKR must not hijack it. Pinned by verifikator-berakhirnya.test.ts.
  • [ ] Minimal implementation C — frontend. Create frontend/src/pages/VerifikatorBerakhirnyaReviewPage.tsx:
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, CheckCircle2, AlertTriangle, XCircle, MinusCircle, FileCheck2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DecisionPanel } from "@/components/verifikator/review/DecisionPanel";
import { AuditTrailPanel } from "@/components/verifikator/review/AuditTrailPanel";
import { useVerifikasiData, useDecide } from "@/hooks/use-verifikator";
import { apiFetch } from "@/lib/api-client";
import { cn } from "@/lib/utils";

interface FieldCell { value: string | null }
interface Clip { documentId: string; classifiedType: string; originalName: string; fields: Record<string, FieldCell> }
interface ValidationRow { id: string; ruleCode: string; ruleLabel: string; status: string; message: string; overridden?: boolean; overrideReason?: string | null }
interface ReviewData {
  submission: { id: string; status: string };
  perseroan: { nama: string | null; statusTransaksi: number | null; statusPerseroan: string | null } | null;
  akta: { fields: { fieldKey: string; value: string | null }[] } | null;
  suratPermohonan: { hasDoc: boolean; fields: Record<string, FieldCell> };
  laporanLikuidasi: { hasDoc: boolean; fields: Record<string, FieldCell> };
  pengumuman: { "149": Clip[]; "152": Clip[]; unassigned: Clip[] };
  kehadiran: { source: string | null; rows: { nama: string; persentase: number | null; kehadiran: boolean | null }[] };
  validationResults: ValidationRow[];
}

const STATUS_STYLE: Record<string, { icon: typeof CheckCircle2; cls: string }> = {
  PASS: { icon: CheckCircle2, cls: "text-emerald-600 border-l-emerald-500" },
  WARNING: { icon: AlertTriangle, cls: "text-amber-600 border-l-amber-500" },
  FAIL: { icon: XCircle, cls: "text-red-600 border-l-red-500" },
  SKIPPED: { icon: MinusCircle, cls: "text-muted-foreground border-l-gray-300" },
};

/** Compact verifikator surface for BERAKHIRNYA_STATUS_PT (per-family precedent:
 *  VerifikatorPembubaranReviewPage). No diff/AI-advisory — the terminal filing has
 *  no old-vs-new diff; the validation list + the 6-doc header snapshots ARE the review. */
export function VerifikatorBerakhirnyaReviewPage() {
  const { submissionId } = useParams<{ submissionId: string }>();
  const navigate = useNavigate();
  const { data: verif } = useVerifikasiData(submissionId);
  const decide = useDecide(submissionId);
  const { data, isLoading } = useQuery<ReviewData>({
    queryKey: ["berakhirnya-review", submissionId],
    queryFn: () => apiFetch<ReviewData>(`/api/berakhirnya/submissions/${submissionId}/review-data`),
    enabled: !!submissionId,
  });

  if (isLoading || !data || !verif) {
    return <div className="flex items-center justify-center h-96"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>;
  }
  const f = (key: string) => data.akta?.fields.find((x) => x.fieldKey === key)?.value ?? "—";
  const sp = (key: string) => data.suratPermohonan.fields[key]?.value ?? "—";
  const lal = (key: string) => data.laporanLikuidasi.fields[key]?.value ?? "—";
  const ClipLine = ({ c }: { c: Clip }) => (
    <div className="text-xs text-muted-foreground">
      {c.fields.nama_koran?.value ?? "—"} · {c.fields.tanggal_pengumuman?.value ?? "—"} · No. {c.fields.nomor_pengumuman?.value ?? "—"}
    </div>
  );

  return (
    <div className="mx-auto max-w-4xl space-y-4 p-4 pb-16">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-lg font-semibold">Berakhirnya Status Badan Hukum  Tinjauan Verifikator</h1>
          <p className="text-sm text-muted-foreground">
            {data.perseroan?.nama ?? f("nama_perseroan")} · Akta No. {f("nomor_akta")} · {f("nama_notaris")} · ref. Akta Pembubaran No. {f("nomor_akta_pembubaran")}
          </p>
        </div>
        <Button variant="outline" size="sm" onClick={() => navigate("/verifikator")}>Kembali</Button>
      </div>

      {/* Likuidator (laporan akhir) + permohonan */}
      <section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
        <h2 className="flex items-center gap-1.5 text-sm font-semibold"><FileCheck2 className="h-4 w-4" /> Laporan Akhir Likuidasi & Surat Permohonan</h2>
        <div>Likuidator: {lal("nama_likuidator")} · Laporan: {lal("nomor_laporan_likuidasi")} ({lal("tanggal_laporan_likuidasi")}) · Sisa kekayaan: {lal("sisa_kekayaan")}</div>
        <div className="text-xs text-muted-foreground">Surat Permohonan: {sp("nomor_surat_permohonan")} ({sp("tanggal_surat_permohonan")})</div>
      </section>

      {/* Pengumuman 149 / 152 */}
      <section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
        <h2 className="text-sm font-semibold">Pengumuman Koran <span className="text-xs font-normal text-muted-foreground">(urutan Ps.147(1)/Ps.152(3) dinilai pada Hasil Validasi)</span></h2>
        <div className="text-xs font-medium">Pasal 149 ({data.pengumuman["149"].length})</div>
        {data.pengumuman["149"].map((c) => <ClipLine key={c.documentId} c={c} />)}
        <div className="text-xs font-medium pt-1">Pasal 152 ({data.pengumuman["152"].length})</div>
        {data.pengumuman["152"].map((c) => <ClipLine key={c.documentId} c={c} />)}
      </section>

      {/* RUPS & Kehadiran */}
      <section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
        <h2 className="text-sm font-semibold">Kehadiran RUPS <span className="text-xs font-normal text-muted-foreground">(sumber: {data.kehadiran.source ?? "—"})</span></h2>
        {data.kehadiran.rows.map((r, i) => (
          <div key={i} className="flex items-center justify-between border-b py-1 last:border-0">
            <span>{r.nama}</span>
            <span className="text-xs text-muted-foreground">
              {r.persentase ?? "—"}% · <Badge variant="outline" className="text-[10px]">{r.kehadiran === true ? "HADIR" : r.kehadiran === false ? "TIDAK HADIR" : "?"}</Badge>
            </span>
          </div>
        ))}
      </section>

      {/* Hasil Validasi — the review substance */}
      <section className="rounded-lg border bg-card p-4 space-y-1.5">
        <h2 className="text-sm font-semibold">Hasil Validasi</h2>
        {data.validationResults.map((v) => {
          const S = STATUS_STYLE[v.status] ?? STATUS_STYLE.SKIPPED!;
          const Icon = S.icon;
          return (
            <div key={v.id} className={cn("flex items-start gap-2 rounded border-l-4 bg-muted/30 p-2", S.cls)}>
              <Icon className="mt-0.5 h-4 w-4 shrink-0" />
              <div className="min-w-0 flex-1">
                <div className="text-xs font-medium">
                  {v.ruleLabel} {v.overridden && <span className="text-muted-foreground">(di-override: {v.overrideReason ?? "tanpa alasan"})</span>}
                </div>
                <div className="text-xs text-muted-foreground">{v.message}</div>
              </div>
            </div>
          );
        })}
      </section>

      <DecisionPanel
        status={verif.verifikasi.status}
        isPending={decide.isPending}
        onDecide={(decision, notes) =>
          decide.mutate(
            { decision, notes, verifikatorId: "verifikator-dev" },
            { onError: (e) => toast.error(e instanceof Error ? e.message : "Gagal menyimpan keputusan") },
          )
        }
      />

      <AuditTrailPanel events={verif.auditEvents} />
    </div>
  );
}
  • [ ] Minimal implementation D — frontend wiring:
    1. frontend/src/hooks/use-verifikator.ts (~line 173): type: "PERUBAHAN_PT" | "PELEBURAN_PT" | "AKUISISI_PT" | "PERBAIKAN_DATA_PT" | "PEMBUBARAN_PT" | "BERAKHIRNYA_STATUS_PT"; (update the doc comment to name all six).
    2. frontend/src/pages/VerifikatorDashboardPage.tsx (~line 266): extend the destination ternary with : item.type === "BERAKHIRNYA_STATUS_PT" ? \/verifikator/berakhirnya/${item.submissionId}`before the fall-through. 3.frontend/src/routes.tsx`: import the page and add next to the pembubaran verifikator route:
          {
            path: "verifikator/berakhirnya/:submissionId",
            element: <ErrorBoundary><VerifikatorBerakhirnyaReviewPage /></ErrorBoundary>,
          },
  • [ ] Run GREEN: cd backend && bun test src/routes/__tests__/verifikator-berakhirnya.test.ts src/routes/__tests__/verifikator-pembubaran.test.ts (regression) and cd frontend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/routes/verifikator.ts backend/src/services/shareholder-voting.ts backend/src/routes/__tests__/verifikator-berakhirnya.test.ts frontend/src/pages/VerifikatorBerakhirnyaReviewPage.tsx frontend/src/hooks/use-verifikator.ts frontend/src/pages/VerifikatorDashboardPage.tsx frontend/src/routes.tsx && git commit -m "feat(berakhirnya): verifikator queue admission + decide-APPROVE E2E + oldData roster pin + compact page"

Task 11: Frontend klasifikasi surfacing — positive detect rung, banner, labels, create-route, fine-label carrier mirror

The FE mirror of Task 7. The berakhirnya package now detects POSITIVELY (the pembubaran build's suppression rung stays — it just yields to the new rung above it, closing the recorded "berakhirnya-package shows no-akta banner" ledger minor). The FE predicate mirror (isBerakhirnyaPackage in pembubaran-detect.ts — shared, NOT duplicated) gets ONE fix: its isClipping approximation must count the carrier via startsWith("BUKTI_PENGUMUMAN") now that the _149/_152 fine labels are live board keys. CreateSubmissionResponse union already exists — the new navigate branch narrows with zero casts.

Files
- frontend/src/lib/pembubaran-detect.ts — Modify (isClipping carrier mirror)
- frontend/src/lib/__tests__/pembubaran-detect.test.ts — Modify (+ fine-label cases)
- frontend/src/components/klasifikasi/detect-banner.tsx — Modify
- frontend/src/pages/KlasifikasiPage.tsx — Modify

Steps

  • [ ] Write failing test. In frontend/src/lib/__tests__/pembubaran-detect.test.ts, ADD (do not modify existing cases — the suppression semantics are unchanged):
describe("isBerakhirnyaPackage — fine-label carrier mirror (flow #4)", () => {
  it("counts _149/_152 fine labels toward the ≥2-clipping clause (carrier mirror of mapToDocumentType)", () => {
    expect(isBerakhirnyaPackage([
      { classification: "AKTA_PEMBUBARAN" },
      { classification: "BUKTI_PENGUMUMAN_149" },
      { classification: "BUKTI_PENGUMUMAN_152" },
    ])).toBe(true);
    expect(isBerakhirnyaPackage([
      { classification: "AKTA_PEMBUBARAN" },
      { classification: "BUKTI_PENGUMUMAN_149" },
    ])).toBe(false); // still a single clipping
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/lib/__tests__/pembubaran-detect.test.ts.
  • [ ] Minimal implementation A — frontend/src/lib/pembubaran-detect.ts: replace the isClipping line with:
  // FE mirror of the mapToDocumentType carrier: the _149/_152 fine labels (live
  // board keys as of the Berakhirnya flow) count toward the clipping clause.
  const isClipping = (r: DetectLite) => eff(r).startsWith("BUKTI_PENGUMUMAN");

Also update the file-head comment: the mirror no longer only SUPPRESSES — the KlasifikasiPage now also detects berakhirnya POSITIVELY with it (backend inferSubmissionType remains the enforcement).
- [ ] Minimal implementation B — frontend/src/components/klasifikasi/detect-banner.tsx:
1. export type DetectedFlow = "pendirian" | "perubahan" | "perbaikan" | "akuisisi" | "laporan-rups" | "peleburan" | "pembubaran" | "berakhirnya";
2. FLOW_LABELS += berakhirnya: "Berakhirnya Status Badan Hukum PT",
3. FLOW_DESCRIPTIONS += berakhirnya: "Paket dokumen berakhirnya status badan hukum terdeteksi (laporan akhir likuidasi / surat permohonan / pengumuman Ps.149+152). Alur Berakhirnya Status Badan Hukum akan dijalankan.",
- [ ] Minimal implementation C — frontend/src/pages/KlasifikasiPage.tsx:
1. KlasifikasiLabelType union += "berakhirnya".
2. The existing const berakhirnyaPaket = isBerakhirnyaPackage(results); stays; ABOVE the hasAktaPeleburan rung insert berakhirnyaPaket into BOTH chains (labelType and detectedFlow), keeping Perbaikan on top — canonical ladder mirror:

  const computedLabelType: KlasifikasiLabelType = hasSuratPerbaikan
    ? "perbaikan"
    : berakhirnyaPaket
      ? "berakhirnya"
      : hasAktaPeleburan
        ? "peleburan"
        : hasAktaAkuisisi
          ? "akuisisi"
          : hasAktaPembubaran
            ? "pembubaran"
            : hasAktaPerubahan
              ? "perubahan"
              : hasAktaRupsTahunan
                ? "laporan_rups"
                : "pendirian";

and in the detectedFlow chain (~line 285) insert : berakhirnyaPaket ? "berakhirnya" in the same position (after hasSuratPerbaikan, before hasAktaPeleburan). The hasAktaPembubaran = !berakhirnyaPaket && ... suppression stays byte-identical.
3. createSubmission.mutate onSuccess: add before the PEMBUBARAN_PT branch:

                          } else if (data.type === "BERAKHIRNYA_STATUS_PT") {
                            navigate(`/berakhirnya/${data.submissionId}/extraction`);

(the union narrowing via "supported" in data is already in place — NO casts.)
- [ ] Run GREEN: cd frontend && bunx vitest run src/lib/__tests__/pembubaran-detect.test.ts && bunx tsc --noEmit (the navigate target page lands in Task 12 — tsc is clean because routes.tsx is untouched until Task 13; the navigate string is just a string).
- [ ] Commit: git add frontend/src/lib/pembubaran-detect.ts frontend/src/lib/__tests__/pembubaran-detect.test.ts frontend/src/components/klasifikasi/detect-banner.tsx frontend/src/pages/KlasifikasiPage.tsx && git commit -m "feat(berakhirnya): klasifikasi surfacing — positive detect rung above peleburan + fine-label carrier mirror + create-route"


Task 12: Frontend flow config + extraction page (staged steps, non-terminal destination, gate helpers, FAIL-only-gate mirror, dissolved-PT modal hint)

The staged step labels (5 steps — verifikator stage), the destinationFor base (NOT terminal), the pure submit-gate helpers (7 required section keys), the additive hint prop on CompanySelectionModal (spec §4.6 — reconciled: the list is NOT filtered to dissolved PTs; the hint explains the registry prerequisite is verified at validation), and the extraction page. Copy source = PembubaranExtractionPage.tsx — it carries the FIXED FAIL-only proceed gate (5c68969) and the v2 single-company CompanySelectionModal flow; do NOT copy the akuisisi/peleburan pages.

Files
- frontend/src/lib/flow-steps.ts — Modify
- frontend/src/lib/destination-for.ts — Modify
- frontend/src/lib/berakhirnya-review.ts — Create
- frontend/src/components/perubahan/CompanySelectionModal.tsx — Modify (additive hint prop)
- frontend/src/pages/BerakhirnyaExtractionPage.tsx — Create (copy + enumerated diffs)
- frontend/src/lib/__tests__/berakhirnya-flow-config.test.ts — Create

Steps

  • [ ] Write failing test. Create frontend/src/lib/__tests__/berakhirnya-flow-config.test.ts (vitest):
import { describe, it, expect } from "vitest";
import { computeFlowStep } from "../flow-steps";
import { destinationFor } from "../destination-for";
import { BERAKHIRNYA_REQUIRED_SECTIONS, missingSections } from "../berakhirnya-review";

describe("BERAKHIRNYA_STATUS_PT flow steps (verifikator-staged — 5 steps)", () => {
  it("pre-submit drives from submission.status; post-COMPLETED from verifikasi", () => {
    const ready = computeFlowStep({ type: "BERAKHIRNYA_STATUS_PT", status: "READY" }, null);
    expect(ready.steps.length).toBe(5);
    expect(ready.currentStep).toBe(2);
    const waiting = computeFlowStep({ type: "BERAKHIRNYA_STATUS_PT", status: "COMPLETED" }, { status: "MENUNGGU_VERIFIKATOR" });
    expect(waiting.currentStep).toBe(3);
    const done = computeFlowStep({ type: "BERAKHIRNYA_STATUS_PT", status: "COMPLETED" }, { status: "SELESAI" });
    expect(done.currentStep).toBe(5); // past the last index — final dot green
    const rejected = computeFlowStep({ type: "BERAKHIRNYA_STATUS_PT", status: "COMPLETED" }, { status: "DITOLAK" });
    expect(rejected.variant).toBe("error");
  });
});

describe("destinationFor BERAKHIRNYA_STATUS_PT (NOT terminal — verifikasi-driven)", () => {
  const sub = (status: string) => ({ id: "s1", type: "BERAKHIRNYA_STATUS_PT" as const, status });
  it("pre-COMPLETED → review; COMPLETED → status; PERBAIKAN → review; SELESAI → status", () => {
    expect(destinationFor(sub("READY"), null)).toBe("/berakhirnya/s1/review");
    expect(destinationFor(sub("COMPLETED"), null)).toBe("/berakhirnya/s1/status");
    expect(destinationFor(sub("COMPLETED"), { status: "PERBAIKAN" })).toBe("/berakhirnya/s1/review");
    expect(destinationFor(sub("COMPLETED"), { status: "SELESAI" })).toBe("/berakhirnya/s1/status");
  });
});

describe("berakhirnya-review gate helpers", () => {
  it("mirrors the backend's 7 required sections; identitas_likuidator never gates", () => {
    expect([...BERAKHIRNYA_REQUIRED_SECTIONS]).toEqual([
      "data_perseroan", "akta_berakhirnya", "risalah_rups", "surat_permohonan",
      "laporan_likuidasi", "pengumuman_149", "pengumuman_152",
    ]);
    expect(missingSections({
      data_perseroan: true, akta_berakhirnya: true, risalah_rups: true, surat_permohonan: true,
      laporan_likuidasi: true, pengumuman_149: true, pengumuman_152: true,
    })).toEqual([]);
    expect(missingSections({ data_perseroan: true, identitas_likuidator: true })).toEqual([
      "akta_berakhirnya", "risalah_rups", "surat_permohonan", "laporan_likuidasi", "pengumuman_149", "pengumuman_152",
    ]);
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/lib/__tests__/berakhirnya-flow-config.test.ts.
  • [ ] Minimal implementation A — frontend/src/lib/flow-steps.ts:
    1. After PEMBUBARAN_LABELS, add:
// Verifikator-staged (pembubaran/peleburan/akuisisi twin): submit creates a
// VerifikasiPerubahan row; post-COMPLETED stages drive from its status. This is
// the TERMINAL PT transaction (Berakhirnya ends the badan hukum) but NOT a
// terminal FLOW (destination-for TERMINAL_TYPES never includes it).
const BERAKHIRNYA_LABELS = [
  { label: "Unggah", sub: "AI mengklasifikasi otomatis" },
  { label: "Ekstraksi", sub: "AI membaca dokumen" },
  { label: "Tinjauan", sub: "Verifikasi & kirim" },
  { label: "Menunggu Verifikasi", sub: "Sedang ditinjau verifikator" },
  { label: "Selesai", sub: "Status badan hukum berakhir" },
] as const;
  1. SubmissionLite.type union += "BERAKHIRNYA_STATUS_PT".
  2. In computeFlowStep: const isBerakhirnya = submission.type === "BERAKHIRNYA_STATUS_PT";, extend the labels chain (isPembubaran ? PEMBUBARAN_LABELS : isBerakhirnya ? BERAKHIRNYA_LABELS : ...), and widen the staged arm to if (isAkuisisi || isPeleburan || isPembubaran || isBerakhirnya) { (update its comment to name all four).
    - [ ] Minimal implementation B — frontend/src/lib/destination-for.ts: SubmissionLite.type union += "BERAKHIRNYA_STATUS_PT"; the base chain gains : submission.type === "BERAKHIRNYA_STATUS_PT" ? "berakhirnya" before the "pendirian" fallback. Do NOT touch TERMINAL_TYPES.
    - [ ] Minimal implementation C — create frontend/src/lib/berakhirnya-review.ts:
/** Pure gate helpers for BerakhirnyaReviewPage — mirror of the backend submit gate
 *  (routes/berakhirnya.ts) so the UI pre-flights the exact conditions it will be
 *  blocked on. Approvals are EXPLICIT persisted rows (c0698d8 discipline).
 *  identitas_likuidator is approvable but NEVER required (spec §4.7: required =
 *  the 6 doc sections + data_perseroan). */

export const BERAKHIRNYA_REQUIRED_SECTIONS = [
  "data_perseroan", "akta_berakhirnya", "risalah_rups", "surat_permohonan",
  "laporan_likuidasi", "pengumuman_149", "pengumuman_152",
] as const;

export function missingSections(approvals: Record<string, boolean>): string[] {
  return BERAKHIRNYA_REQUIRED_SECTIONS.filter((k) => !approvals[k]);
}
  • [ ] Minimal implementation D — frontend/src/components/perubahan/CompanySelectionModal.tsx (ADDITIVE — every existing consumer unchanged):
    1. CompanySelectionModalProps gains hint?: string;
    2. Destructure hint in the component signature.
    3. Directly under </DialogDescription> (inside DialogHeader), render:
          {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
  • [ ] Minimal implementation E — create frontend/src/pages/BerakhirnyaExtractionPage.tsx: copy frontend/src/pages/PembubaranExtractionPage.tsx byte-for-byte, then apply EXACTLY these diffs:
    1. export function PembubaranExtractionPage()export function BerakhirnyaExtractionPage() (line ~44).
    2. The computeFlowStep call's type literal { type: "PEMBUBARAN_PT", status: submission.status }{ type: "BERAKHIRNYA_STATUS_PT", status: submission.status } (line ~260).
    3. The review navigate navigate(`/pembubaran/${submissionId}/review`)navigate(`/berakhirnya/${submissionId}/review`) (line ~494).
    4. In the FAIL-only-gate comment (line ~213), replace the flow-specific text: WARNING is the pembubaran NORM (PEMBUBARAN_KORAN "boleh … → WARNING is the berakhirnya NORM (BSBHP_PENGUMUMAN_ORDER / BSBHP_SISA_KEKAYAAN_PRESENT are advisory-by-design) keeping the rest of the comment's structure (the gate LOGIC — !v.overridden && v.status === "FAIL" — is byte-identical).
    5. The CompanySelectionModal JSX gains the registry-prerequisite hint prop (spec §4.6 — reconciled 2026-07-03: the lookup uses the DEFAULT status filter, so active AND liquidating PTs appear; the prerequisite is verified at validation, not by the list):
              hint="Pilih PT yang pembubarannya telah disetujui di SABH. Prasyarat status pembubaran diverifikasi otomatis saat validasi."
  1. Add a file-head comment: /** Thin mirror of PembubaranExtractionPage — the v2 single-company extraction page with the FAIL-only proceed gate (5c68969) and the type-agnostic CompanySelectionModal → POST /api/perubahan/select-company flow. Enumerated diffs: function name, flow-step literal, review navigate, gate comment, modal hint. */
    Then prove the mirror: diff <(sed 's/Berakhirnya/Pembubaran/g; s/BERAKHIRNYA_STATUS_PT/PEMBUBARAN_PT/g; s|/berakhirnya/|/pembubaran/|g' frontend/src/pages/BerakhirnyaExtractionPage.tsx) frontend/src/pages/PembubaranExtractionPage.tsx — output must be ONLY the head comment, the gate-comment wording, and the hint prop line (if the copy source has other flow-specific strings the enumeration missed, mirror them the same way and note it in the commit body).
    - [ ] Run GREEN: cd frontend && bunx vitest run src/lib/__tests__/berakhirnya-flow-config.test.ts && bunx vitest run (full FE suite — flow-steps/destination/modal changes are additive; the pembubaran extraction-gate test must stay green).
    - [ ] Typecheck: cd frontend && bunx tsc --noEmit (the new page is not yet routed — routes land in Task 13; tsc still checks it).
    - [ ] Commit: git add frontend/src/lib/flow-steps.ts frontend/src/lib/destination-for.ts frontend/src/lib/berakhirnya-review.ts frontend/src/components/perubahan/CompanySelectionModal.tsx frontend/src/pages/BerakhirnyaExtractionPage.tsx frontend/src/lib/__tests__/berakhirnya-flow-config.test.ts && git commit -m "feat(berakhirnya): FE flow config (staged steps, non-terminal destination) + extraction page mirror + dissolved-PT modal hint"

Task 13: Frontend review + status pages + routes

The flat 8-section review page (PembubaranReviewPage skeleton, berakhirnya content — per-document sections, NO diff): Data Perseroan (SABH snapshot) / Akta (header + pembubaran-ref + notaris pengganti, per-field confirm via the generic endpoint) / Risalah RUPS & Kehadiran (nomor/tanggal risalah + HADIR/TIDAK toggles → PUT /kehadiran, source badge) / Surat Permohonan (3 fields + manual editor → PUT /permohonan) / Laporan Likuidasi (5 fields incl. sisa_kekayaan + manual editor → PUT /laporan-likuidasi) / Pengumuman 149 and Pengumuman 152 (per-clipping cards with nama/tanggal/nomor editors → PUT /pengumuman/:documentId; amber note when a pasal has zero clippings) / Identitas / validation list with override / staged submit dialog with the 422 surfaced inline (role="alert", onMutate clear) + the COMPLETED Navigate idiom. Plus the verifikasi-aware status page (pembubaran mirror + token diffs) and the route trio.

Files
- frontend/src/pages/BerakhirnyaReviewPage.tsx — Create
- frontend/src/pages/BerakhirnyaStatusPage.tsx — Create
- frontend/src/routes.tsx — Modify (route trio)
- frontend/src/pages/__tests__/BerakhirnyaReviewPage.test.tsx — Create

Steps

  • [ ] Write failing test. Create frontend/src/pages/__tests__/BerakhirnyaReviewPage.test.tsx (vitest + jsdom; the IOStub idiom from LaporanRupsReviewPage.test.tsx:26-31):
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { BerakhirnyaReviewPage } from "../BerakhirnyaReviewPage";

const apiFetch = vi.fn();
vi.mock("@/lib/api-client", () => ({
  apiFetch: (url: string, opts?: RequestInit) => apiFetch(url, opts),
}));

// jsdom doesn't provide IntersectionObserver
class IOStub {
  observe() {} disconnect() {} unobserve() {} takeRecords() { return []; }
  root = null; rootMargin = ""; thresholds: number[] = [];
}
(globalThis as unknown as { IntersectionObserver: typeof IOStub }).IntersectionObserver = IOStub;

const SUBMISSION_ID = "bsbhp-1";
const cell = (value: string | null) => ({ value, confidence: value ? 0.75 : 0, status: value ? "EDITABLE" : "NOT_FOUND" });

function makeReviewData(overrides: Record<string, unknown> = {}) {
  return {
    submission: { id: SUBMISSION_ID, status: "READY", type: "BERAKHIRNYA_STATUS_PT" },
    perseroan: { nama: "PT KARYA USAI SUDAH", statusTransaksi: 3, statusPerseroan: "BUBAR" },
    akta: {
      documentId: "doc-akta",
      fields: [
        { fieldKey: "nomor_akta", value: "7", confidence: 0.9, status: "EDITABLE" },
        { fieldKey: "tanggal_akta", value: "20 Juni 2026", confidence: 0.9, status: "EDITABLE" },
        { fieldKey: "nomor_akta_pembubaran", value: "5", confidence: 0.75, status: "EDITABLE" },
      ],
      penghadap: [{ nama: "BUDI HARTONO", nomorIdentitas: "317" }],
    },
    risalah: { documentId: "doc-ba", nomorBerita: "BA-12", tanggalRups: "18 Juni 2026" },
    suratPermohonan: { hasDoc: true, documentId: "doc-sp", fields: {
      nomor_surat_permohonan: cell("012/BSBH/VI/2026"), tanggal_surat_permohonan: cell(null), permohonan_nama_perseroan: cell("PT KARYA USAI SUDAH"),
    } },
    laporanLikuidasi: { hasDoc: true, documentId: "doc-lal", fields: {
      nama_likuidator: cell("BUDI HARTONO"), nomor_laporan_likuidasi: cell("03/LAL/VI/2026"),
      tanggal_laporan_likuidasi: cell("10 Juni 2026"), sisa_kekayaan: cell("nihil"), laporan_nama_perseroan: cell("PT KARYA USAI SUDAH"),
    } },
    pengumuman: {
      "149": [{ documentId: "doc-149", classifiedType: "BUKTI_PENGUMUMAN_149", originalName: "koran149.pdf",
        fields: { nama_koran: cell("Media Indonesia"), tanggal_pengumuman: cell("5 Mei 2026"), nomor_pengumuman: cell(null) } }],
      "152": [],
      unassigned: [],
    },
    kehadiran: { source: "registry", rows: [{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: true }] },
    documents: [{ documentId: "k1", classifiedType: "KTP", extractionStatus: "DONE", originalName: "ktp.pdf" }],
    validationResults: [
      { id: "v1", ruleCode: "BSBHP_PENGUMUMAN_ORDER", ruleLabel: "Urutan Pengumuman", status: "WARNING", message: "belum lengkap" },
    ],
    sections: [],
    sectionApprovals: {},
    ...overrides,
  };
}

function renderPage(data: Record<string, unknown>) {
  apiFetch.mockImplementation((url: string) => {
    if (url.endsWith("/review-data")) return Promise.resolve(data);
    return Promise.resolve({});
  });
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={[`/berakhirnya/${SUBMISSION_ID}/review`]}>
        <Routes>
          <Route path="/berakhirnya/:submissionId/review" element={<BerakhirnyaReviewPage />} />
          <Route path="/berakhirnya/:submissionId/status" element={<div data-testid="status-page" />} />
        </Routes>
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

beforeEach(() => apiFetch.mockReset());

describe("BerakhirnyaReviewPage", () => {
  it("renders the 8 flat sections incl. the per-pasal clipping cards + the zero-152 amber note", async () => {
    renderPage(makeReviewData());
    await waitFor(() => expect(screen.getByText("Data Perseroan (SABH)")).toBeTruthy());
    expect(screen.getByText("Akta Berakhirnya Status Badan Hukum")).toBeTruthy();
    expect(screen.getByText(/Risalah RUPS & Kehadiran/)).toBeTruthy();
    expect(screen.getByText("Surat Permohonan")).toBeTruthy();
    expect(screen.getByText("Laporan Akhir Likuidasi")).toBeTruthy();
    // getAllByText: "Pasal 152" appears in BOTH the section h2 and the zero-clipping
    // amber note — a bare getByText would throw on multiple matches.
    expect(screen.getAllByText(/Pasal 149/).length).toBeGreaterThan(0);
    expect(screen.getAllByText(/Pasal 152/).length).toBeGreaterThan(0);
    expect(screen.getByText(/Belum ada kliping Pasal 152/)).toBeTruthy(); // zero-clipping amber note
    expect(screen.getByText("012/BSBH/VI/2026")).toBeTruthy();
    expect(screen.getByText(/nihil/)).toBeTruthy();
  });

  it("submit CTA is gated until all 7 required sections are approved and no FAIL", async () => {
    renderPage(makeReviewData());
    await waitFor(() => expect(screen.getByText("Data Perseroan (SABH)")).toBeTruthy());
    const cta = screen.getByRole("button", { name: /Kirim ke Verifikator/ }) as HTMLButtonElement;
    expect(cta.disabled).toBe(true);
    expect(screen.getByText(/Bagian belum disetujui/).textContent).toContain("pengumuman_152");
  });

  it("a non-overridden FAIL blocks submit and shows the Override action", async () => {
    renderPage(makeReviewData({
      sectionApprovals: {
        data_perseroan: true, akta_berakhirnya: true, risalah_rups: true, surat_permohonan: true,
        laporan_likuidasi: true, pengumuman_149: true, pengumuman_152: true,
      },
      validationResults: [
        { id: "v2", ruleCode: "BSBHP_REGISTRY_STATE", ruleLabel: "Status PT", status: "FAIL", message: "PT belum memiliki pembubaran yang disetujui di SABH" },
      ],
    }));
    await waitFor(() => expect(screen.getByText(/1 validasi FAIL/)).toBeTruthy());
    const cta = screen.getByRole("button", { name: /Kirim ke Verifikator/ }) as HTMLButtonElement;
    expect(cta.disabled).toBe(true);
    expect(screen.getByRole("button", { name: "Override" })).toBeTruthy();
  });

  it("submit 422 surfaces INLINE with role=alert (never a dead-end)", async () => {
    renderPage(makeReviewData({
      sectionApprovals: {
        data_perseroan: true, akta_berakhirnya: true, risalah_rups: true, surat_permohonan: true,
        laporan_likuidasi: true, pengumuman_149: true, pengumuman_152: true,
      },
    }));
    await waitFor(() => expect(screen.getByText("Data Perseroan (SABH)")).toBeTruthy());
    apiFetch.mockImplementation((url: string) => {
      if (url.endsWith("/review-data")) return Promise.resolve(makeReviewData());
      if (url.endsWith("/submit")) return Promise.reject({ code: "VALIDATION_FAILED", failing: ["BSBHP_REGISTRY_STATE"] });
      return Promise.resolve({});
    });
    fireEvent.click(screen.getByRole("button", { name: /Kirim ke Verifikator/ }));
    await waitFor(() => expect(screen.getByRole("button", { name: /Kirim Sekarang/ })).toBeTruthy());
    fireEvent.click(screen.getByRole("button", { name: /Kirim Sekarang/ }));
    await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Validasi gagal"));
  });

  it("COMPLETED status renders the Navigate idiom to /status", async () => {
    renderPage(makeReviewData({ submission: { id: SUBMISSION_ID, status: "COMPLETED", type: "BERAKHIRNYA_STATUS_PT" } }));
    await waitFor(() => expect(screen.getByTestId("status-page")).toBeTruthy());
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/pages/__tests__/BerakhirnyaReviewPage.test.tsx. Expected: module not found.
  • [ ] Minimal implementation A — create frontend/src/pages/BerakhirnyaReviewPage.tsx:
import { useState } from "react";
import { useParams, useNavigate, Navigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Loader2, AlertTriangle, XCircle, MinusCircle, ShieldCheck, FileCheck2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
  Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { StepIndicator } from "@/components/process/step-indicator";
import { computeFlowStep } from "@/lib/flow-steps";
import { apiFetch } from "@/lib/api-client";
import { missingSections } from "@/lib/berakhirnya-review";
import { cn } from "@/lib/utils";

interface FieldRow { fieldKey: string; value: string | null; confidence: number; status: string }
interface FieldCell { value: string | null; confidence: number; status: string }
interface Clip { documentId: string; classifiedType: string; originalName: string; fields: Record<string, FieldCell> }
interface KehadiranRow { nama: string; persentase: number | null; kehadiran: boolean | null }
interface ValidationRow { id: string; ruleCode: string; ruleLabel: string; status: string; message: string; overridden?: boolean }
interface ReviewData {
  submission: { id: string; status: string; type: string };
  perseroan: { nama: string | null; statusTransaksi: number | null; statusPerseroan: string | null } | null;
  akta: { documentId: string; fields: FieldRow[]; penghadap: { nama: string | null; nomorIdentitas: string | null }[] } | null;
  risalah: { documentId: string; nomorBerita: string | null; tanggalRups: string | null } | null;
  suratPermohonan: { hasDoc: boolean; documentId: string | null; fields: Record<string, FieldCell> };
  laporanLikuidasi: { hasDoc: boolean; documentId: string | null; fields: Record<string, FieldCell> };
  pengumuman: { "149": Clip[]; "152": Clip[]; unassigned: Clip[] };
  kehadiran: { source: "deed" | "registry" | null; rows: KehadiranRow[] };
  documents: { documentId: string; classifiedType: string; extractionStatus: string; originalName: string }[];
  validationResults: ValidationRow[];
  sections: { key: string; label: string; approvable: boolean; required: boolean }[];
  sectionApprovals: Record<string, boolean>;
}

const AKTA_KEYS = ["nomor_akta", "tanggal_akta", "nama_notaris", "notaris_pengganti", "tanggal_rups", "nomor_akta_pembubaran", "tanggal_akta_pembubaran"];
const PERMOHONAN_KEYS = ["nomor_surat_permohonan", "tanggal_surat_permohonan", "permohonan_nama_perseroan"];
const LAPORAN_KEYS = ["nama_likuidator", "nomor_laporan_likuidasi", "tanggal_laporan_likuidasi", "sisa_kekayaan", "laporan_nama_perseroan"];
const STATUS_STYLE: Record<string, { icon: typeof CheckCircle2; cls: string }> = {
  PASS: { icon: CheckCircle2, cls: "text-emerald-600 border-l-emerald-500" },
  WARNING: { icon: AlertTriangle, cls: "text-amber-600 border-l-amber-500" },
  FAIL: { icon: XCircle, cls: "text-red-600 border-l-red-500" },
  SKIPPED: { icon: MinusCircle, cls: "text-muted-foreground border-l-gray-300" },
};

export function BerakhirnyaReviewPage() {
  const { submissionId } = useParams<{ submissionId: string }>();
  const navigate = useNavigate();
  const qc = useQueryClient();
  const invalidate = () => qc.invalidateQueries({ queryKey: ["berakhirnya-review", submissionId] });

  const { data, isLoading } = useQuery<ReviewData>({
    queryKey: ["berakhirnya-review", submissionId],
    queryFn: () => apiFetch<ReviewData>(`/api/berakhirnya/submissions/${submissionId}/review-data`),
    enabled: !!submissionId,
  });

  const sectionMut = useMutation({
    mutationFn: ({ key, approve }: { key: string; approve: boolean }) =>
      apiFetch(`/api/berakhirnya/submissions/${submissionId}/sections/${key}/${approve ? "approve" : "unapprove"}`, { method: "POST" }),
    onSuccess: invalidate,
  });
  const confirmMut = useMutation({
    mutationFn: ({ documentId, fieldKey }: { documentId: string; fieldKey: string }) =>
      apiFetch(`/api/submissions/${submissionId}/documents/${documentId}/fields/confirm`, {
        method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fieldKey }),
      }),
    onSuccess: invalidate,
  });
  const overrideMut = useMutation({
    mutationFn: ({ ruleCode, reason }: { ruleCode: string; reason: string }) =>
      apiFetch(`/api/submissions/${submissionId}/override-validation`, {
        method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ruleCode, reason }),
      }),
    onSuccess: invalidate,
  });
  const permohonanMut = useMutation({
    mutationFn: (fields: Record<string, string | null>) =>
      apiFetch(`/api/berakhirnya/submissions/${submissionId}/permohonan`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }),
      }),
    onSuccess: invalidate,
  });
  const laporanMut = useMutation({
    mutationFn: (fields: Record<string, string | null>) =>
      apiFetch(`/api/berakhirnya/submissions/${submissionId}/laporan-likuidasi`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }),
      }),
    onSuccess: invalidate,
  });
  const pengumumanMut = useMutation({
    mutationFn: ({ documentId, body }: { documentId: string; body: { namaKoran?: string | null; tanggalPengumuman?: string | null; nomorPengumuman?: string | null } }) =>
      apiFetch(`/api/berakhirnya/submissions/${submissionId}/pengumuman/${documentId}`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
      }),
    onSuccess: invalidate,
  });
  const kehadiranMut = useMutation({
    mutationFn: (entries: { nama: string; kehadiran: boolean | null }[]) =>
      apiFetch(`/api/berakhirnya/submissions/${submissionId}/kehadiran`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries }),
      }),
    onSuccess: invalidate,
  });
  const [submitError, setSubmitError] = useState<string | null>(null);
  const submitMut = useMutation({
    mutationFn: () => apiFetch(`/api/berakhirnya/submissions/${submissionId}/submit`, { method: "POST" }),
    onMutate: () => setSubmitError(null),
    onSuccess: () => navigate(`/berakhirnya/${submissionId}/status`),
    onError: (err: unknown) => {
      const hasFailing = !!(err as { failing?: unknown })?.failing;
      setSubmitError(hasFailing ? "Validasi gagal — periksa Hasil Validasi" : "Terjadi kesalahan saat mengirim. Coba lagi.");
    },
  });

  const [spDraft, setSpDraft] = useState<Record<string, string> | null>(null);
  const [lalDraft, setLalDraft] = useState<Record<string, string> | null>(null);
  const [clipDrafts, setClipDrafts] = useState<Record<string, { namaKoran: string; tanggalPengumuman: string; nomorPengumuman: string }>>({});
  const [submitOpen, setSubmitOpen] = useState(false);

  if (isLoading) return <div className="flex items-center justify-center h-96"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>;
  if (!data) return <div className="p-8 text-center text-muted-foreground">Submission tidak ditemukan.</div>;

  const { submission, perseroan, akta, risalah, suratPermohonan, laporanLikuidasi, pengumuman, kehadiran, documents, validationResults, sectionApprovals } = data;
  if (submission.status === "COMPLETED") { return <Navigate to={`/berakhirnya/${submissionId}/status`} replace />; }

  const flow = computeFlowStep({ type: "BERAKHIRNYA_STATUS_PT", status: submission.status }, null);
  const fails = validationResults.filter((v) => v.status === "FAIL" && !v.overridden);
  const missing = missingSections(sectionApprovals);
  const canSubmit = submission.status === "READY" && fails.length === 0 && missing.length === 0;
  const f = (key: string) => akta?.fields.find((x) => x.fieldKey === key);

  const ApproveButton = ({ sectionKey }: { sectionKey: string }) => (
    <Button
      size="sm"
      variant={sectionApprovals[sectionKey] ? "outline" : "default"}
      onClick={() => sectionMut.mutate({ key: sectionKey, approve: !sectionApprovals[sectionKey] })}
    >
      {sectionApprovals[sectionKey] ? "Batalkan Persetujuan" : "Setujui Bagian"}
    </Button>
  );
  const FieldList = ({ keys, documentId, fields }: { keys: string[]; documentId: string | null; fields: (key: string) => { value: string | null; status: string } | undefined }) => (
    <>
      {keys.map((key) => {
        const field = fields(key);
        return (
          <div key={key} className="flex items-center justify-between gap-2 border-b py-1.5 last:border-0">
            <div className="min-w-0">
              <div className="text-xs text-muted-foreground">{key}</div>
              <div className="text-sm truncate">{field?.value ?? <span className="text-muted-foreground">kosong</span>}</div>
            </div>
            {field && field.value != null && field.status !== "CONFIRMED" && field.status !== "LOCKED" && field.status !== "MANUAL" && documentId ? (
              <Button size="sm" variant="outline" disabled={confirmMut.isPending}
                onClick={() => confirmMut.mutate({ documentId, fieldKey: key })}>
                Konfirmasi
              </Button>
            ) : field && field.value != null ? (
              <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
            ) : null}
          </div>
        );
      })}
    </>
  );
  const ManualEditor = ({ keys, prefixStrip, draft, setDraft, onSave, pending, fields }: {
    keys: string[]; prefixStrip: RegExp; draft: Record<string, string> | null;
    setDraft: (fn: (d: Record<string, string> | null) => Record<string, string>) => void;
    onSave: () => void; pending: boolean; fields: (key: string) => { value: string | null } | undefined;
  }) => (
    <div className="space-y-1 border-t pt-2">
      <div className="text-xs font-medium text-muted-foreground">Lengkapi manual (field yang tidak terbaca OCR)</div>
      <div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
        {keys.map((k) => (
          <div key={k} className="grid grid-cols-[170px_1fr] items-center gap-1.5">
            <Label htmlFor={`ed-${k}`} className="truncate text-[11px]">{k.replace(prefixStrip, "")}</Label>
            <Input id={`ed-${k}`} className="h-7 text-xs" defaultValue={fields(k)?.value ?? ""}
              onChange={(e) => setDraft((d) => ({ ...(d ?? {}), [k]: e.target.value }))} />
          </div>
        ))}
      </div>
      <div className="flex justify-end">
        <Button size="sm" variant="outline" disabled={!draft || pending} onClick={onSave}>Simpan</Button>
      </div>
    </div>
  );
  const ClipCard = ({ clip }: { clip: Clip }) => {
    const d = clipDrafts[clip.documentId];
    return (
      <div className="rounded border p-2 space-y-1.5">
        <div className="text-xs font-medium">{clip.originalName}</div>
        <div className="text-xs text-muted-foreground">
          Terbaca: {clip.fields.nama_koran?.value ?? "—"} · {clip.fields.tanggal_pengumuman?.value ?? "—"} · No. {clip.fields.nomor_pengumuman?.value ?? "—"}
        </div>
        <div className="grid grid-cols-[140px_1fr] items-center gap-1.5">
          <Label className="text-[11px]" htmlFor={`nk-${clip.documentId}`}>Nama Koran</Label>
          <Input id={`nk-${clip.documentId}`} className="h-7 text-xs" defaultValue={clip.fields.nama_koran?.value ?? ""}
            onChange={(e) => setClipDrafts((p) => ({ ...p, [clip.documentId]: { namaKoran: e.target.value, tanggalPengumuman: p[clip.documentId]?.tanggalPengumuman ?? (clip.fields.tanggal_pengumuman?.value ?? ""), nomorPengumuman: p[clip.documentId]?.nomorPengumuman ?? (clip.fields.nomor_pengumuman?.value ?? "") } }))} />
          <Label className="text-[11px]" htmlFor={`tp-${clip.documentId}`}>Tanggal Pengumuman</Label>
          <Input id={`tp-${clip.documentId}`} className="h-7 text-xs" defaultValue={clip.fields.tanggal_pengumuman?.value ?? ""}
            onChange={(e) => setClipDrafts((p) => ({ ...p, [clip.documentId]: { namaKoran: p[clip.documentId]?.namaKoran ?? (clip.fields.nama_koran?.value ?? ""), tanggalPengumuman: e.target.value, nomorPengumuman: p[clip.documentId]?.nomorPengumuman ?? (clip.fields.nomor_pengumuman?.value ?? "") } }))} />
          <Label className="text-[11px]" htmlFor={`np-${clip.documentId}`}>Nomor Pengumuman</Label>
          <Input id={`np-${clip.documentId}`} className="h-7 text-xs" defaultValue={clip.fields.nomor_pengumuman?.value ?? ""}
            onChange={(e) => setClipDrafts((p) => ({ ...p, [clip.documentId]: { namaKoran: p[clip.documentId]?.namaKoran ?? (clip.fields.nama_koran?.value ?? ""), tanggalPengumuman: p[clip.documentId]?.tanggalPengumuman ?? (clip.fields.tanggal_pengumuman?.value ?? ""), nomorPengumuman: e.target.value } }))} />
        </div>
        <div className="flex justify-end">
          <Button size="sm" variant="outline" disabled={!d || pengumumanMut.isPending}
            onClick={() => d && pengumumanMut.mutate({ documentId: clip.documentId, body: d })}>Simpan Kliping</Button>
        </div>
      </div>
    );
  };

  return (
    <div className="space-y-6 pb-16">
      <div className="bg-card border-b p-6">
        <StepIndicator steps={flow.steps} currentStep={flow.currentStep} variant={flow.variant} />
      </div>

      {/* Data Perseroan (SABH) — the registry snapshot; there is NO diff */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">Data Perseroan (SABH)</h2>
          <ApproveButton sectionKey="data_perseroan" />
        </div>
        {perseroan ? (
          <div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-3">
            <div><div className="text-xs text-muted-foreground">Nama</div>{perseroan.nama ?? "—"}</div>
            <div><div className="text-xs text-muted-foreground">Status Transaksi</div>{perseroan.statusTransaksi ?? "—"} <span className="text-xs text-muted-foreground">(3 = pembubaran tercatat)</span></div>
            <div><div className="text-xs text-muted-foreground">Status Perseroan</div>{perseroan.statusPerseroan ?? "—"}</div>
          </div>
        ) : (
          <p className="text-sm text-muted-foreground">Data registri belum tersedia (pilih perusahaan pada langkah Ekstraksi).</p>
        )}
      </section>

      {/* Akta — header + the pembubaran-ref recital */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">Akta Berakhirnya Status Badan Hukum</h2>
          <ApproveButton sectionKey="akta_berakhirnya" />
        </div>
        {akta
          ? <FieldList keys={AKTA_KEYS} documentId={akta.documentId} fields={(k) => f(k)} />
          : <div className="text-sm text-muted-foreground">Akta belum tersedia.</div>}
      </section>

      {/* Risalah RUPS & Kehadiran */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">
            Risalah RUPS & Kehadiran{" "}
            <Badge variant="outline" className="ml-1 text-[10px]">{kehadiran.source === "registry" ? "roster registri SABH" : kehadiran.source === "deed" ? "roster akta" : "—"}</Badge>
          </h2>
          <ApproveButton sectionKey="risalah_rups" />
        </div>
        <div className="text-xs text-muted-foreground">
          Risalah: {risalah ? `${risalah.nomorBerita ?? "tanpa nomor"} · RUPS ${risalah.tanggalRups ?? "—"}` : "belum diunggah (dokumen wajib)"}
        </div>
        {kehadiran.rows.length === 0 && <p className="text-sm text-muted-foreground">Belum ada roster pemegang saham (unggah BA RUPS / pilih perusahaan).</p>}
        {kehadiran.rows.map((r) => (
          <div key={r.nama} className="flex items-center justify-between border-b py-1 text-sm last:border-0">
            <span>{r.nama} <span className="text-xs text-muted-foreground">· {r.persentase ?? "—"}%</span></span>
            <span className="flex gap-1">
              <Button size="sm" variant={r.kehadiran === true ? "default" : "outline"} disabled={kehadiranMut.isPending}
                onClick={() => kehadiranMut.mutate([{ nama: r.nama, kehadiran: true }])}>Hadir</Button>
              <Button size="sm" variant={r.kehadiran === false ? "default" : "outline"} disabled={kehadiranMut.isPending}
                onClick={() => kehadiranMut.mutate([{ nama: r.nama, kehadiran: false }])}>Tidak Hadir</Button>
            </span>
          </div>
        ))}
      </section>

      {/* Surat Permohonan */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">Surat Permohonan</h2>
          <ApproveButton sectionKey="surat_permohonan" />
        </div>
        {!suratPermohonan.hasDoc && <p className="text-sm text-amber-600">Surat permohonan belum diunggah (dokumen wajib).</p>}
        <FieldList keys={PERMOHONAN_KEYS} documentId={suratPermohonan.documentId} fields={(k) => suratPermohonan.fields[k]} />
        {suratPermohonan.hasDoc && (
          <ManualEditor keys={PERMOHONAN_KEYS} prefixStrip={/^permohonan_/} draft={spDraft} setDraft={setSpDraft as never}
            onSave={() => spDraft && permohonanMut.mutate(spDraft)} pending={permohonanMut.isPending}
            fields={(k) => suratPermohonan.fields[k]} />
        )}
      </section>

      {/* Laporan Akhir Likuidasi */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="flex items-center gap-1.5 text-sm font-semibold"><FileCheck2 className="h-4 w-4" /> Laporan Akhir Likuidasi</h2>
          <ApproveButton sectionKey="laporan_likuidasi" />
        </div>
        {!laporanLikuidasi.hasDoc && <p className="text-sm text-amber-600">Laporan akhir likuidasi belum diunggah (dokumen wajib).</p>}
        <FieldList keys={LAPORAN_KEYS} documentId={laporanLikuidasi.documentId} fields={(k) => laporanLikuidasi.fields[k]} />
        {laporanLikuidasi.hasDoc && (
          <ManualEditor keys={LAPORAN_KEYS} prefixStrip={/^laporan_/} draft={lalDraft} setDraft={setLalDraft as never}
            onSave={() => lalDraft && laporanMut.mutate(lalDraft)} pending={laporanMut.isPending}
            fields={(k) => laporanLikuidasi.fields[k]} />
        )}
      </section>

      {/* Pengumuman 149 */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">Bukti Pengumuman Koran  Pasal 149 <span className="text-xs font-normal text-muted-foreground">(kreditor, 30 hari sejak pembubaran)</span></h2>
          <ApproveButton sectionKey="pengumuman_149" />
        </div>
        {pengumuman["149"].length === 0 && <p className="text-sm text-amber-600">Belum ada kliping Pasal 149  unggah atau tetapkan pasal pada papan klasifikasi (dokumen wajib).</p>}
        {pengumuman["149"].map((clip) => <ClipCard key={clip.documentId} clip={clip} />)}
      </section>

      {/* Pengumuman 152 */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">Bukti Pengumuman Koran  Pasal 152 <span className="text-xs font-normal text-muted-foreground">(hasil akhir likuidasi, setelah RUPS)</span></h2>
          <ApproveButton sectionKey="pengumuman_152" />
        </div>
        {pengumuman["152"].length === 0 && <p className="text-sm text-amber-600">Belum ada kliping Pasal 152  unggah atau tetapkan pasal pada papan klasifikasi (dokumen wajib).</p>}
        {pengumuman["152"].map((clip) => <ClipCard key={clip.documentId} clip={clip} />)}
        {pengumuman.unassigned.length > 0 && (
          <p className="text-xs text-amber-600">{pengumuman.unassigned.length} kliping belum ditetapkan pasalnya  kembali ke papan klasifikasi untuk menetapkan 149/152.</p>
        )}
      </section>

      {/* Dokumen Identitas */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <div className="flex items-center justify-between">
          <h2 className="text-sm font-semibold">Dokumen Identitas (KTP/NPWP)</h2>
          <ApproveButton sectionKey="identitas_likuidator" />
        </div>
        {documents.filter((d) => ["KTP", "NPWP", "NPWP_LAMA", "PASSPORT", "KITAS"].includes(d.classifiedType)).map((d) => (
          <div key={d.documentId} className="flex items-center justify-between border-b py-1 text-sm last:border-0">
            <span>{d.originalName}</span>
            <span className="text-xs text-muted-foreground">{d.classifiedType} · {d.extractionStatus}</span>
          </div>
        ))}
        <p className="text-xs text-muted-foreground">Kecocokan NIK/nama (termasuk konsistensi likuidator) divalidasi otomatis  lihat Hasil Validasi.</p>
      </section>

      {/* Hasil Validasi */}
      <section className="rounded-lg border bg-card p-4 space-y-1.5">
        <h2 className="text-sm font-semibold">Hasil Validasi</h2>
        {validationResults.map((v) => {
          const S = STATUS_STYLE[v.status] ?? STATUS_STYLE.SKIPPED!;
          const Icon = S.icon;
          return (
            <div key={v.id} className={cn("flex items-start gap-2 rounded border-l-4 bg-muted/30 p-2", S.cls)}>
              <Icon className="mt-0.5 h-4 w-4 shrink-0" />
              <div className="min-w-0 flex-1">
                <div className="text-xs font-medium">{v.ruleLabel} {v.overridden && <span className="text-muted-foreground">(di-override)</span>}</div>
                <div className="text-xs text-muted-foreground">{v.message}</div>
              </div>
              {v.status === "FAIL" && !v.overridden && (
                <Button size="sm" variant="outline" disabled={overrideMut.isPending}
                  onClick={() => {
                    const reason = window.prompt(`Alasan override untuk ${v.ruleCode}:`);
                    if (reason) overrideMut.mutate({ ruleCode: v.ruleCode, reason });
                  }}>
                  Override
                </Button>
              )}
            </div>
          );
        })}
      </section>

      {/* Submit → verifikator */}
      <div className="flex items-center justify-end gap-3">
        {missing.length > 0 && <span className="text-xs text-amber-600">Bagian belum disetujui: {missing.join(", ")}</span>}
        {fails.length > 0 && <span className="text-xs text-red-600">{fails.length} validasi FAIL menghalangi submit</span>}
        <Button disabled={!canSubmit} onClick={() => setSubmitOpen(true)}>
          <ShieldCheck className="mr-1.5 h-4 w-4" /> Kirim ke Verifikator
        </Button>
      </div>

      <Dialog open={submitOpen} onOpenChange={setSubmitOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Kirim Berakhirnya Status Badan Hukum ke Verifikator</DialogTitle>
            <DialogDescription>
              Pengajuan akan masuk antrean verifikator (Menunggu Verifikasi). Setelah disetujui,
              status badan hukum perseroan berakhir. Anda dapat merevisi jika verifikator meminta perbaikan.
            </DialogDescription>
          </DialogHeader>
          {submitError && <p role="alert" className="text-sm text-destructive">{submitError}</p>}
          <DialogFooter>
            <Button variant="outline" onClick={() => setSubmitOpen(false)}>Batal</Button>
            <Button disabled={submitMut.isPending} onClick={() => submitMut.mutate()}>
              {submitMut.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />} Kirim Sekarang
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}
  • [ ] Minimal implementation B — create frontend/src/pages/BerakhirnyaStatusPage.tsx: copy frontend/src/pages/PembubaranStatusPage.tsx byte-for-byte, then apply EXACTLY these diffs:
    1. PembubaranStatusPageBerakhirnyaStatusPage (function name + head comment).
    2. Every /pembubaran/ path → /berakhirnya/; /api/pembubaran//api/berakhirnya/; queryKey "pembubaran-review""berakhirnya-review".
    3. { type: "PEMBUBARAN_PT", ... }{ type: "BERAKHIRNYA_STATUS_PT", ... }.
    4. VERIF_LABEL.SELESAI: "Disetujui — pembubaran tercatat""Disetujui — status badan hukum berakhir".
    5. The <h1>: Pembubaran PT — {nama}Berakhirnya Status Badan Hukum — {nama}.
    (The render={<Link to="/dashboard" />} base-ui idiom is inherited — do not change it.)
  • [ ] Minimal implementation C — frontend/src/routes.tsx: import the three pages and add next to the pembubaran trio:
          { path: "berakhirnya/:submissionId/extraction", element: <BerakhirnyaExtractionPage /> },
          { path: "berakhirnya/:submissionId/review", element: <BerakhirnyaReviewPage /> },
          { path: "berakhirnya/:submissionId/status", element: <BerakhirnyaStatusPage /> },
  • [ ] Run GREEN: cd frontend && bunx vitest run src/pages/__tests__/BerakhirnyaReviewPage.test.tsx && bunx vitest run && bunx tsc --noEmit. Expected: new suite green; full FE suite green.
  • [ ] Commit: git add frontend/src/pages/BerakhirnyaReviewPage.tsx frontend/src/pages/BerakhirnyaStatusPage.tsx frontend/src/routes.tsx frontend/src/pages/__tests__/BerakhirnyaReviewPage.test.tsx && git commit -m "feat(berakhirnya): review + status pages (8 flat sections, per-clipping editors, staged submit, inline 422) + routes"

Task 14: End-to-end integration test (engine run → 5 focused passes + deed hook → tuned matrix → single-source-lookup pause → company select → approvals → FAIL-gate → verifikator staging)

One backend test that walks the WHOLE flow through the real engine + routes (pembubaran-integration precedent): seeded DONE documents for the full 6-PDF package (no OCR calls), a mocked 3B fetch routed by prompt content, runFlow with the real registry entry, the env-robust company-selection pause (test-setup blanks SABH creds → the single-source lookup yields no match → AWAITING_COMPANY_SELECTION → resume via the type-agnostic POST /api/perubahan/select-company), all three new focused passes + the shared clipping pass on BOTH fine-labeled clippings + the deed hook, the tuned matrix persisted (prerequisite FAIL present pre-override; BSBHP_PENGUMUMAN_ORDER never FAIL; excluded rows absent), section approvals via routes, and the staged submit.

Files
- backend/src/flow-engine/__tests__/berakhirnya-integration.test.ts — Create

Steps

  • [ ] Write the test. Create backend/src/flow-engine/__tests__/berakhirnya-integration.test.ts:
import { describe, it, expect, afterAll, beforeAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";
import { runFlow } from "../processor";
import { berakhirnyaStatusPtFlow } from "../flows/berakhirnya-status-pt";

/**
 * BERAKHIRNYA_STATUS_PT end-to-end (engine + routes; pembubaran-integration
 * harness style). Docs seeded extractionStatus DONE (no OCR); global fetch mocked
 * (3B: permohonan / laporan / clippings / deed-ref — routed by prompt content).
 * SABH creds are blanked by test-setup.ts, so the single-source lookup (DEFAULT
 * status filter — 2026-07-03 erratum) pauses in AWAITING_COMPANY_SELECTION
 * (env-robust: a READY short-circuit is also accepted) and resumes through the
 * type-agnostic select-company route.
 */
const AKTA_RAW =
  "AKTA BERAKHIRNYA STATUS BADAN HUKUM\nPT KARYA USAI SUDAH (dalam likuidasi)\nNomor 7, tanggal 20 Juni 2026\n" +
  "bahwa berdasarkan Akta Pembubaran Nomor 5 tanggal 30 April 2026 Perseroan telah dibubarkan;\n" +
  "bahwa RUPS tanggal 18 Juni 2026 telah menerima pertanggungjawaban likuidator.";
const SP_RAW =
  "SURAT PERMOHONAN\nNomor: 012/BSBH/VI/2026\nJakarta, 19 Juni 2026\n" +
  "Perihal: Permohonan pemberitahuan berakhirnya status badan hukum PT KARYA USAI SUDAH";
const LAL_RAW =
  "LAPORAN AKHIR LIKUIDASI\nNomor: 03/LAL/VI/2026\nJakarta, 10 Juni 2026\n" +
  "Likuidator PT KARYA USAI SUDAH, BUDI HARTONO, menyampaikan pertanggungjawaban.\nSisa kekayaan: nihil";
const K149_RAW = "PENGUMUMAN PEMBUBARAN\nPT KARYA USAI SUDAH\nkreditor dipersilakan mengajukan tagihan\nMedia Indonesia, 5 Mei 2026\nNo. IKL-4411";
const K152_RAW = "PENGUMUMAN HASIL LIKUIDASI\nPT KARYA USAI SUDAH (dalam likuidasi)\nKompas, 19 Juni 2026";

describe("BERAKHIRNYA_STATUS_PT integration", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  let origFetch: typeof fetch;
  const origLlmUrl = process.env.CLEANUP_LLM_URL;

  beforeAll(() => {
    origFetch = globalThis.fetch;
    process.env.CLEANUP_LLM_URL = "http://mock-3b";
    // Route ONLY the mocked 3B host; in-process app.request never hits fetch.
    // Answered by SYSTEM-PROMPT content (each pass has a distinct narrow prompt).
    globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
      const u = String(url);
      if (!u.startsWith("http://mock-3b")) return new Response("{}", { status: 500 });
      const body = String(init?.body ?? "");
      const reply = (obj: object) =>
        new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(obj) } }] }), { status: 200 });
      if (body.includes("surat permohonan pemberitahuan berakhirnya"))
        return reply({ nomor_surat_permohonan: "012/BSBH/VI/2026", tanggal_surat_permohonan: "19 Juni 2026", permohonan_nama_perseroan: "PT KARYA USAI SUDAH" });
      if (body.includes("laporan akhir likuidasi"))
        return reply({ nama_likuidator: "BUDI HARTONO", nomor_laporan_likuidasi: "03/LAL/VI/2026", tanggal_laporan_likuidasi: "10 Juni 2026", sisa_kekayaan: "nihil", laporan_nama_perseroan: "PT KARYA USAI SUDAH" });
      if (body.includes("premisse recites the EARLIER"))
        return reply({ nomor_akta_pembubaran: "5", tanggal_akta_pembubaran: "30 April 2026" });
      // shared clipping extractor — answer by which clipping text rode along
      if (body.includes("Media Indonesia"))
        return reply({ nama_koran: "Media Indonesia", tanggal_pengumuman: "5 Mei 2026", nomor_pengumuman: "IKL-4411" });
      return reply({ nama_koran: "Kompas", tanggal_pengumuman: "19 Juni 2026", nomor_pengumuman: null });
    }) as typeof fetch;
  });
  afterAll(async () => {
    globalThis.fetch = origFetch;
    process.env.CLEANUP_LLM_URL = origLlmUrl;
    if (ids.length) {
      await db.$transaction(async (tx) => {
        await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica;`);
        await tx.$executeRawUnsafe(
          `DELETE FROM "VerifikasiAuditEvent" WHERE "verifikasiId" IN (SELECT id FROM "VerifikasiPerubahan" WHERE "submissionId" = ANY($1::text[]))`,
          ids,
        );
      });
      await db.submission.deleteMany({ where: { id: { in: ids } } });
    }
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seedDoc(documentType: string, classifiedType: string, order: number, submissionId: string, rawText: string, fields: { fieldKey: string; value: string }[] = []) {
    const doc = await db.document.create({
      data: { filename: `${classifiedType}-${order}.pdf`, originalName: `${classifiedType}.pdf`, fileSize: 0, filePath: `/tmp/${classifiedType}.pdf`, documentType: documentType as never, status: "VERIFICATION_READY", rawText },
    });
    docIds.push(doc.id);
    await db.submissionDocument.create({
      data: { submissionId, documentId: doc.id, classifiedType, confidence: 1, processingOrder: order, extractionStatus: "DONE" },
    });
    if (fields.length) {
      await db.extractedField.createMany({ data: fields.map((f) => ({ documentId: doc.id, ...f, confidence: 0.9, status: "EDITABLE" })) });
    }
    return doc;
  }

  it("runs upload→extract→pause→select→validate→approve→override→submit→verifikator end-to-end", async () => {
    const sub = await db.submission.create({ data: { type: "BERAKHIRNYA_STATUS_PT", status: "CREATED" } });
    ids.push(sub.id);
    const akta = await seedDoc("AKTA", "AKTA_BERAKHIRNYA", 0, sub.id, AKTA_RAW, [
      { fieldKey: "nama_perseroan", value: "PT KARYA USAI SUDAH" },
      { fieldKey: "nomor_akta", value: "7" },
      { fieldKey: "tanggal_akta", value: "20 Juni 2026" },
      { fieldKey: "tanggal_rups", value: "18 Juni 2026" },
      { fieldKey: "ref_pendirian_nomor_sk", value: "AHU-0007.AH.01.01.TAHUN 2018" },
    ]);
    const ba = await seedDoc("BERITA_ACARA_RUPS", "BERITA_ACARA_RUPS", 6, sub.id, "RISALAH RUPS", []);
    await db.beritaAcaraRupsExtraction.create({
      data: { documentId: ba.id, nomorBerita: "BA-12", tanggalRups: "18 Juni 2026", attendees: [{ nama: "BUDI SANTOSO", kehadiran: "hadir" }] },
    });
    const sp = await seedDoc("SURAT_PERMOHONAN", "SURAT_PERMOHONAN", 6, sub.id, SP_RAW);
    const lal = await seedDoc("LAPORAN_LIKUIDASI", "LAPORAN_LIKUIDASI", 6, sub.id, LAL_RAW);
    const k149 = await seedDoc("BUKTI_PENGUMUMAN", "BUKTI_PENGUMUMAN_149", 6, sub.id, K149_RAW);
    const k152 = await seedDoc("BUKTI_PENGUMUMAN", "BUKTI_PENGUMUMAN_152", 6, sub.id, K152_RAW);
    await seedDoc("KTP", "KTP", 1, sub.id, "");
    await seedDoc("NPWP", "NPWP", 2, sub.id, "");

    await runFlow(sub.id, berakhirnyaStatusPtFlow);
    let fresh = await db.submission.findUnique({ where: { id: sub.id } });

    // Env-robust single-source-lookup pause: no SABH in tests → AWAITING; resume via
    // the TYPE-AGNOSTIC select-company route (manual entry → oldData null).
    if (fresh?.status === "AWAITING_COMPANY_SELECTION") {
      const sel = await app.request(`/api/perubahan/select-company/${sub.id}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ manual: true, nama: "PT KARYA USAI SUDAH", tahun: 2018 }),
      });
      expect(sel.status).toBe(200);
      for (let i = 0; i < 50 && fresh?.status !== "READY"; i++) {
        await new Promise((r) => setTimeout(r, 100));
        fresh = await db.submission.findUnique({ where: { id: sub.id } });
      }
    }
    expect(fresh?.status).toBe("READY");

    // Deed hook wrote the pembubaran-ref rows on the AKTA doc.
    const refRows = await db.extractedField.findMany({
      where: { documentId: akta.id, fieldKey: { in: ["nomor_akta_pembubaran", "tanggal_akta_pembubaran"] } },
    });
    expect(refRows.find((r) => r.fieldKey === "nomor_akta_pembubaran")?.value).toBe("5");
    // All three new focused passes persisted grounded rows.
    expect((await db.extractedField.findMany({ where: { documentId: sp.id } })).find((r) => r.fieldKey === "nomor_surat_permohonan")?.value).toBe("012/BSBH/VI/2026");
    expect((await db.extractedField.findMany({ where: { documentId: lal.id } })).find((r) => r.fieldKey === "sisa_kekayaan")?.value).toBe("nihil");
    // The SHARED clipping pass ran on BOTH fine-labeled clippings (carrier DocumentType).
    expect((await db.extractedField.findMany({ where: { documentId: k149.id } })).find((r) => r.fieldKey === "nomor_pengumuman")?.value).toBe("IKL-4411");
    expect((await db.extractedField.findMany({ where: { documentId: k152.id } })).find((r) => r.fieldKey === "nama_koran")?.value).toBe("Kompas");

    // Tuned matrix persisted: flow rules present; PENGUMUMAN_ORDER never FAIL;
    // excluded rows absent; the prerequisite gate FAILs (manual entry → oldData
    // null → SKIPPED actually — accept SKIPPED or FAIL, but the row EXISTS).
    const results = await db.validationResult.findMany({ where: { submissionId: sub.id } });
    const byCode = new Map(results.map((r) => [r.ruleCode, r.status]));
    expect(byCode.has("BSBHP_REQUIRED_DOCS")).toBe(true);
    expect(byCode.get("BSBHP_REQUIRED_DOCS")).toBe("PASS"); // the full package incl. per-pasal fine labels was uploaded
    expect(byCode.has("BSBHP_REGISTRY_STATE")).toBe(true);
    expect(byCode.get("BSBHP_REGISTRY_STATE")).toBe("SKIPPED"); // manual entry → no SABH data → never guess
    expect(byCode.has("BSBHP_PEMBUBARAN_PREREQ")).toBe(true);
    expect(byCode.has("BSBHP_LIKUIDATOR_CONSISTENCY")).toBe(true);
    expect(byCode.has("BSBHP_PENGUMUMAN_ORDER")).toBe(true);
    expect(byCode.get("BSBHP_PENGUMUMAN_ORDER")).not.toBe("FAIL");
    expect(byCode.get("BSBHP_PENGUMUMAN_ORDER")).toBe("PASS"); // 5 Mei < 18 Juni ≤ 19 Juni
    expect(byCode.has("BSBHP_SISA_KEKAYAAN_PRESENT")).toBe(true);
    expect(byCode.has("RUPS_ATTENDANCE_QUORUM")).toBe(true);
    expect(byCode.has("PERSEROAN_STATE")).toBe(false);      // the INVERSION replaced it
    expect(byCode.has("MODAL_BUKTI_SETOR")).toBe(false);    // cap-table group excluded
    expect(byCode.has("REQUIRED_SUPPORTING_DOCS")).toBe(false);

    // Approve the 7 required sections through the routes; then override any FAILs
    // the seeded data produced (e.g. BSBHP_NAMA_PT_DOCS if a name grounded oddly).
    for (const key of ["data_perseroan", "akta_berakhirnya", "risalah_rups", "surat_permohonan", "laporan_likuidasi", "pengumuman_149", "pengumuman_152"]) {
      const res = await app.request(`/api/berakhirnya/submissions/${sub.id}/sections/${key}/approve`, { method: "POST" });
      expect(res.status).toBe(200);
    }
    const fails = await db.validationResult.findMany({ where: { submissionId: sub.id, status: "FAIL", overridden: false } });
    for (const row of fails) {
      await db.validationResult.update({ where: { id: row.id }, data: { overridden: true, overrideReason: "integration override" } });
    }

    // Staged submit → COMPLETED + MENUNGGU_VERIFIKATOR (never terminal).
    const submit = await app.request(`/api/berakhirnya/submissions/${sub.id}/submit`, { method: "POST" });
    expect(submit.status).toBe(200);
    const done = await db.submission.findUnique({ where: { id: sub.id }, include: { verifikasi: true } });
    expect(done?.status).toBe("COMPLETED");
    expect(done?.verifikasi?.status).toBe("MENUNGGU_VERIFIKATOR");
  }, 30000);
});
  • [ ] Run: cd backend && bun test src/flow-engine/__tests__/berakhirnya-integration.test.ts. Debug any cross-task seams it exposes (this test exists to catch them). Known tolerances: (a) if the environment short-circuits the lookup to READY, the select-company block is skipped by design; (b) BSBHP_REGISTRY_STATE is SKIPPED on the manual-entry path — the FAIL behavior is unit-covered in Task 5; (c) the change-type Phase-4 tolerates empty aktaPerubahan rows — do not seed jenis data.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/__tests__/berakhirnya-integration.test.ts && git commit -m "test(berakhirnya): end-to-end integration — engine run, single-source-lookup pause/resume, 5 focused passes, tuned matrix, staged submit"

Task 15: Full-suite + typecheck gate

The whole-repo green gate before handoff — the program's final flow ships only on a fully green tree.

Steps

  • [ ] Backend full suite: cd backend && bun test 2>&1 | tail -20. Expected: green. KNOWN pre-existing flake: pp-perubahan-bo-crud has an FK isolation flake in full-suite runs (passes isolated — Peleburan ledger note); if it is the ONLY failure, re-run it isolated to confirm and record in the commit body. Any berakhirnya-owned failure must be fixed, not recorded.
  • [ ] Backend typecheck: cd backend && bunx tsc --noEmit. Expected: zero errors.
  • [ ] Frontend full suite: cd frontend && bunx vitest run 2>&1 | tail -10. Expected: green.
  • [ ] Frontend typecheck: cd frontend && bunx tsc --noEmit. Expected: zero errors.
  • [ ] Exact-spelling audit (label/enum prefix collision, Global Constraints): grep -rn 'includes("BERAKHIRNYA")\|startsWith("BERAKHIRNYA")' backend/src frontend/src --include='*.ts' --include='*.tsx' | grep -v __tests__. Expected: no output (no substring matching on the shared BERAKHIRNYA prefix anywhere; startsWith("BUKTI_PENGUMUMAN") is the sanctioned carrier map and does not match this grep).
  • [ ] Dark-flow sanity (nothing else lit up): grep -rn 'BUILT_AKTA_TXN_TYPES' backend/src/services/akta-txn-classifier.ts | head -3 — the set contains exactly the 7 taxonomy types; and grep -c 'engine: "v1-fork"' backend/src/flow-engine/registry.ts returns 10 (the ten v1 forks unchanged).
  • [ ] Commit (only if fixes were needed): git add -A && git commit -m "test(berakhirnya): full-suite + typecheck gate green for the Berakhirnya Status PT flow". If nothing changed, record the gate evidence in the task handoff instead.

Self-Review

Spec coverage (2026-07-02-berakhirnya-status-pt-design.md):
- §4.1 spelling: BERAKHIRNYA_STATUS_PT exact everywhere (Task 1 enum; Task 15 substring audit).
- §4.2 FlowConfig: born v2-generic, FOURTH tenant — Task 6. primaryAkta (canonical 3-key shape), skipExtractionTypes: ["OTHER"], companyLookup: true (single-source, DEFAULT status filter — the allowedStatusTransaksi seam stays UNUSED per the 2026-07-03 erratum; the prerequisite moved to BSBHP_REGISTRY_STATE over a pre-fetched registry snapshot), extraction.overrideByClassifiedType + focusedPasses (C11 shapes), buildContext, rules, sections (§4.7 keys exact), requiredDocs, rematch (canonical RematchConfig), hooks.afterAktaExtraction, routeFamily: "berakhirnya" (C12 — union value pre-existing), labels, policy omitted (typed-but-inert). Deviations from the spec sketch, each adjudicated inline: rematch.preValidationSteps: [matchRupsAttendance] → the canonical rupsAttendance: true flag (the engine grew it with Pembubaran; same adjudication as the pembubaran plan); BerakhirnyaCtx.pengumuman149[].nomorPengumuman is a per-clipping SCALAR not an array (C4: the landed shared extractor's canonical keys are the authority; SABH's nomor[] = one nomor per clipping doc); ctx adds pengumumanUnassigned/rupsRosterSource (operational needs: the axis nudge + the quorum-source badge).
- §4.3 classification: berakhirnya-doc-classifier.ts = the 3B letter-confirm for SURAT_PERMOHONAN/LAPORAN_LIKUIDASI ONLY, wired via classifySupportingDoc's opts.llm seam (Task 8) — C3 honored: BUKTI_PENGUMUMAN never reaches an LLM (fetch-spy pinned); the shared keyword pre-suggester + the _149/_152 axis were ALREADY BUILT (supporting-doc-classifier.ts:29-35) — this plan makes them REACHABLE (fine-label suggestion surfacing + board display names); everything amber, board authoritative; the flywheel persistence rides the existing userOverride/reason columns (spec §6 defers the store decision). Doc-set predicate: owned once, body untouched; the DEFLECT→ROUTE handoff is Task 7 (deflection removed + positive rung + carrier rows) — exactly the spec's "flips it to ROUTE" instruction.
- §4.4 extraction: deed rides perubahan (Task 6 + AKTA_SUBTYPE_MAP row); pembubaran-ref hook (Task 3 — nomor/tanggal_akta_pembubaran as ExtractedFields on the deed, re-runs via the hook, idempotent-once); BA RUPS reused; surat-permohonan (Task 2 — spec keys + the adjudicated permohonan_nama_perseroan carrier); laporan-likuidasi (Task 2 — spec's 4 keys + laporan_nama_perseroan; laporan shape only, C2); shared clipping extractor consumed via one map entry, nomor_pengumuman read for the first time (Task 4/9/13); cards ride the standard pipeline (KTP two-pass merge untouched). Dates stored RAW grounded + parseFlexibleDate at rule time (apostille convention). ExtractedField-only persistence — zero new tables (§6).
- §4.5 matrix: every spec row present with its binding severity — BSBHP_REGISTRY_STATE FAIL-unless-3 as a NEW INVERSION rule (C10; validatePerseroanState untouched + guard-tested absent); REQUIRED_SUPPORTING_DOCS-intent delivered by the NEW BSBHP_REQUIRED_DOCS FAIL rule over the fine labels (adjudicated: the wrapped body is jenis-scoped — pembubaran-precedent exclusion class; C10's "over the fine labels" is exactly what the flow rule does); NIK_KTP_AKTA/NAMA_NPWP_KTP/completeness/NAMA_PT_CONSISTENCY/NOTARIS_TERAKHIR/AKTA_DATE_WINDOW/RUPS_ATTENDANCE_QUORUM(default >1/2 — the discharge RUPS is not the Ps.89 decision)/OLD_DATA_CONSISTENCY wrapped bit-for-bit + NPWP_PERSEROAN_FUZZY (the spec's own NPWP-anchor rationale); BSBHP_LIKUIDATOR_CONSISTENCY ≥85/60–84/<60 (surat-permohonan signer input dropped — not extracted in phase 1, adjudicated vs §4.4's field list); BSBHP_PENGUMUMAN_ORDER WARNING-never-FAIL + t₁₄₉<t_RUPS≤t₁₅₂ + both pasal cited + structural triple-matrix test; BSBHP_DOC_DATE_SANITY; BSBHP_PEMBUBARAN_REF WARNING/SKIPPED-degrading (§10 Q2); BSBHP_SISA_KEKAYAAN_PRESENT; BSBHP_NAMA_PT_DOCS carries the NAMA_PT_CONSISTENCY input-extension intent (adjudicated: wrap-don't-rewrite forbids the body change). PLUS the brief-mandated local prereq BSBHP_PEMBUBARAN_PREREQ, DITOLAK-aware from birth (a9ca0e8-I4 class). Async partition: the only DB-round-trip rule (BSBHP_PEMBUBARAN_PREREQ) is async: true; BSBHP_REGISTRY_STATE/NOTARIS_TERAKHIR read context-preloaded data (sync — the spec's async note is satisfied at the context layer, PERSEROAN_STATE precedent). DOMISILI/DATA_KONTAK optional (§10 Q3 resolution) → DOMISILI_NAMA/CONTACT_INFO_COMPLETE/PASSPORT_AKTA excluded + board rows optional.
- §4.6 lookup (reconciled 2026-07-03 — erratum): the allowedStatusTransaksi param + engine seam ARE landed but this plan does NOT use them — an APPROVED pembubaran row sits at status 1, so companyLookup: true (DEFAULT filter) finds a liquidating PT. Task 6's FlowSabhDeps spy proves the lookup is called with NO opts. The prerequisite is enforced by BSBHP_REGISTRY_STATE over a pre-fetched registry snapshot (loadPembubaranRegistryState, Task 1 — read-only shard scan for an id_aksi 12/status-1 pembubaran + an id_aksi 36/status-6 completed berakhirnya), pre-fetched in buildBerakhirnyaContext (Task 4) and timeout-wrapped → SKIPPED on outage. Task 12's modal hint is reworded (the list is not filtered to dissolved PTs).
- §4.7 frontend: thin mirrors, NO ReviewEngine — extraction page = PembubaranExtractionPage copy (FAIL-only gate inherited), 8-section flat review + per-clipping editors + explicit PerubahanSectionApproval rows + required = 6 doc sections + data_perseroan, status page, routes (Tasks 12/13); verifikator reuse + allow-list widening (Task 10). Card confidence tiers untouched; no apostille manual-confirm regime; no auto-confirm.
- §5 wiring checklist: items 1→Task 1 (enum only; DocumentTypes pre-landed per C17), 2→Task 6, 3→Task 7, 4→Task 7 (predicate handoff + maps + orders), 5→Task 1, 6→registry-generic rematch dispatch (proven Task 6), 7→Tasks 7/8, 8→Tasks 2/6 (subtype + focused dispatch via OCR-only cases + focusedPasses), 9→Tasks 2/3/8 (+company-lookup opts pre-landed), 10→Task 9, 11→Tasks 11/12/13, 12→Task 10, 13→n/a (RoutePolicy inert — security slice not landed).
- §7 gotchas: rematch dispatch (Task 6 proof, queueReMatchAndValidate everywhere in Task 9); explicit approvals (Tasks 9/13); mapToDocumentType silent-AKTA fallback (Task 7 — same commit as the routing flip); registry-state prerequisite gate over a pre-fetched snapshot, NOT a lookup filter (Task 1 helper + Task 4 pre-fetch + Task 5 rule; 2026-07-03 erratum); PERSEROAN_STATE inversion (Task 5 + guard test); koran line-splits (grounded 3B + WARNING tiers + per-clipping manual editors); 3B brittleness (one prompt per class, never widened); confident-wrong GPU labels (Task 8 suggestions + operator board; Task 7 override-wins test); compile tripwires (Task 1); word-form dates (RAW + parseFlexibleDate); name discipline (PEMBUBARAN_PP ≠ PEMBUBARAN_PT ≠ BERAKHIRNYA_STATUS_PT — exact-equality everywhere + Task 15 audit).
- §8 testing strategy: classifier keyword/LLM/axis-amber (Task 8); ladder incl. package-vs-single-clipping + regression pins (Task 7); extractor grounding + JSON/scalar round-trip + word dates + hook re-run (Tasks 2/3); rules table-driven incl. st ∈ {1,3,5,6,null}+blokir+SKIPPED, likuidator tiers, never-FAIL property test, date sanity, sisa presence (Task 5); wrapped-rule parity rides the existing wrapper-fidelity.test.ts (pickRules reuses the SAME wrapped objects — no new bodies to oracle); integration incl. pause/resume + rematch-wipe guard + submit gates + finalize allow-list (Tasks 6/9/10/14); regression: PERUBAHAN/AKUISISI/PEMBUBARAN untouched (validatePerseroanState still theirs — Tasks 3/5/7/10 regression runs).
- §9 deferred honored: no SABH submit-back, no verifier-pool modeling, no TBN checks, no ReviewEngine, BSBHP_PENGUMUMAN_ORDER stays WARNING.
- §10 open questions: Q1 → RESOLVED by the 2026-07-03 controller verification (erratum): status_transaksi==3 is NOT "pembubaran recorded" — an approved row is status 1; Task 1 ships the empirical constants (APPROVED_TX_STATUS/BERAKHIRNYA_DONE_STATUS/PEMBUBARAN_AKSI/BERAKHIRNYA_AKSI) + the loadPembubaranRegistryState helper (the deno-recipe live-check is dropped); Q2 → BSBHP_PEMBUBARAN_REF SKIPPED-degrading; Q3 → resolved (optional, baked in); Q4 → WARNING-only + both pasal cited; Q5 → moot (the slice + three tenants landed; born v2-generic); Q6 → TITLE_RULES fast-path added (Task 7).
- Whole-flow-review warnings (both ledgers): (1) READY-state surfaces for every correctable field — Tasks 9/13 (permohonan/laporan/per-clipping/kehadiran PUTs + review cards); (2) partial-verification WARNINGs name the unchecked — Task 5 (order/nama/required rules); (3) decide-APPROVE E2E + declared roster — Task 10; (4) single-source only — Tasks 6/14 (multi-source branch untouched). Fix-wave inheritance: merge-preserve untouched + re-asserted (Task 3); engine timeout consumed via injectable deps (Task 6); FAIL-only extraction gate inherited by copy (Task 12); DITOLAK-aware from birth (Task 5); CreateSubmissionResponse union used, zero casts (Task 11).

Placeholder scan: no TODO/TBD/ellipsis-elision inside code blocks; every test task has RED and GREEN commands; every task ends in an exact commit; the two copy-with-diffs pages (Tasks 12/13-B) enumerate EVERY diff and carry a sed-diff mirror proof (Task 12) / a bounded diff list (Task 13-B, 61-line file). Bounded adapt-notes for the implementer: Task 8's GREEN step names a possibly-differently-named shared-suggester test file (fallback: run the directory); Task 14 documents its three env tolerances explicitly.

Type consistency: BerakhirnyaCtx extends PtAktaCtx so pickRules(PT_AKTA_RULE_SET, …) assigns without casts (sibling precedent); the three field-key consts (PERMOHONAN_EXTRACTED_FIELD_KEYS, LAPORAN_LIKUIDASI_EXTRACTED_FIELD_KEYS, PEMBUBARAN_REF_FIELD_KEYS) are single sources of truth consumed by extractor/context/routes (FE key lists mirror them as string arrays); the empirical registry constants (APPROVED_TX_STATUS/BERAKHIRNYA_DONE_STATUS/PEMBUBARAN_AKSI/BERAKHIRNYA_AKSI) live in ONE place (company-lookup.ts) imported by the loadPembubaranRegistryState helper; the registry-state rule reads the pre-fetched ctx.registryState snapshot (booleans) rather than re-deriving from the constants, so they cannot diverge; review-data cell shape {value, confidence, status} is identical across backend stableFields, the review page, and the verifikator page; canonical envelopes are byte-identical strings in Task 9's code and tests; ValidationStatus spellings carry no -ED; confidence: 1 present on every submissionDocument.create seed; rupsKehadiranRegistry row shape {nama, persentase, kehadiran} identical across matcher/context/routes/FE.

Risk notes for the executor: Task 7 is the point of no return (the FINAL launch switch) — do not start it until Tasks 1–6 are green; Task 7 and Task 11 both touch the detection ladder (backend/frontend mirrors) — keep the rung ABOVE Peleburan in BOTH; Task 9's per-clipping PUT test seeds two clippings — the documentId_fieldKey upsert requires the ExtractedField compound unique (it exists — pembubaran routes use it); Task 14 is deliberately tolerant of the SABH-less environment (AWAITING→manual-select path; REGISTRY_STATE SKIPPED there — its FAIL tiers are Task-5 unit-covered).