think
16px
820px

Pembubaran PT (Dissolution, PEMBUBARAN_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 PEMBUBARAN_PT OCR flow (upload → classify → extract → validate → review → submit to VERIFIKATOR → verifikasi state machine) as the THIRD engine:'v2-generic' tenant of the flow-engine FLOW_REGISTRY — one defineFlow config + a tuned rule pack + two focused passes (ONE genuinely new: the grounded 3B Surat Likuidator extractor; the BUKTI_PENGUMUMAN clipping extractor is SHARED, built by Peleburan, consumed here via one map entry) + the single-source company lookup the engine already ships. No new processor fork; no multi-source machinery touched.

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 with LAPORAN_RUPS_TAHUNAN + PELEBURAN_PT on feat/pt-flows-registry). The deed rides the perubahan extractor via extraction.overrideByClassifiedType: { AKTA_PEMBUBARAN: "perubahan" } (raw-subtype identity rows already exist in AKTA_SUBTYPE_MAP, ocr/classifier.ts:64-65). Genuinely new pieces: (1) surat-likuidator-extract.ts — deterministic-regex-first + grounded 3B, 12 ExtractedField rows, NO satellite table; (2) the deed-inline koran fallback via the SHARED parameterized extractKoranFields(rawText, { docContext }) (built in Peleburan — reused, never re-implemented); (3) the RUPS registry-roster attendance fallback (Submission.rupsKehadiranRegistry Json? — SABH's attendance checkboxes are against the REGISTRY list, the primary path for a dissolution); (4) the ≥-comparator option on the shared rupsQuorumRule wrapper (Ps.89(1) "paling sedikit 3/4" — the inherited deferred trigger); (5) the Berakhirnya doc-set predicate in inferSubmissionType (this flow is its first consumer and owner); (6) dual-deed (akta_kedua) transformer mappings. Validation = tuned matrix PEMBUBARAN_RULE_SET (13 wrapped rules + the ¾-quorum wrapper + 4 flow rules incl. the WARNING-NEVER-FAIL PEMBUBARAN_KORAN and the FAIL PT_ALREADY_DISSOLVED local double-dissolution guard). Submit is a new thin route family (routes/pembubaran.ts, routeFamily 'pembubaran'): explicit section approvals + checkFailGate (block-with-override) → COMPLETED + VerifikasiPerubahan row (MENUNGGU_VERIFIKATOR) — this flow IS verifikator-staged (peleburan/akuisisi twin; contrast terminal LAPORAN_RUPS_TAHUNAN). Voter roster for the verifikator's initial APPROVE = Submission.oldData.pemegang_saham via resolveVoterRoster's existing fallback (AKTA_PEMBUBARAN is deliberately NOT added to ROSTER_AKTA_TYPES; pinned by test). Frontend = thin mirrors (extraction page copied from LaporanRupsExtractionPage — single-company CompanySelectionModal, exactly 3 flow-specific tokens; a new flat review page with Kehadiran/Likuidator/Koran cards; a verifikasi-aware status page; a compact verifikator page per the VerifikatorPeleburanReviewPage precedent). Everything stays dark until BUILT_AKTA_TXN_TYPES += "pembubaran" flips (Task 7) — the launch switch.

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 PEMBUBARAN_PT (SABH id_aksi_transaksi = 12, UUPT Ps.142-147) — DISTINCT from PEMBUBARAN_PP (Perseroan Perorangan, UU CK regime). Exact-equality checks ONLY — never includes("PEMBUBARAN") / startsWith("PEMBUBARAN") anywhere (routes, queues, dashboards, rules); regression tests guard both directions. Carrier akta label AKTA_PEMBUBARAN already exists (AKTA_TXN_TYPE_TO_LABEL, akta-txn-classifier.ts:22; title rule ^AKTA\s+PEMBUBARAN at :56; KEYWORD_CONFIRM.pembubaran = /pembubaran|likuidasi/i at :101). Classifier taxonomy value "pembubaran". ValidationStatus is "PASS" | "WARNING" | "FAIL" | "SKIPPED" (NO -ED). DocumentTypes SURAT_LIKUIDATOR and BUKTI_PENGUMUMAN already exist in Prisma (registry-slice six-type batch, schema.prisma:52-57) — do NOT re-add them; LAPORAN_LIKUIDASI is Berakhirnya's DISTINCT type (penunjukan-vs-laporan distinction is load-bearing for routing — C2).
  • VERIFIKATOR-STAGED — not terminal. Submit → status COMPLETED + a VerifikasiPerubahan row (MENUNGGU_VERIFIKATOR) in ONE transaction, mirroring the PELEBURAN_PT submit arm (routes/peleburan.ts:340-394), incl. the PERBAIKAN re-submit arm (clears aiReport/aiRecommendation/aiGeneratedAt) and recordAuditEvent(NOTARIS_SUBMIT/NOTARIS_RESUBMIT). PEMBUBARAN_PT is NEVER added to TERMINAL_TYPES in frontend/src/lib/destination-for.ts; post-COMPLETED navigation is verifikasi-driven exactly like PELEBURAN_PT/AKUISISI_PT.
  • 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, pembubaranPtFlow) (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" } when status is outside its allowed set. (This deliberately TIGHTENS the peleburan submit arm, whose 409 used a submit-specific error text — recorded ledger minor.) Allowed sets: kehadiran / koran / likuidator edits = READY only; section approve/unapprove = READY only; submit = READY only. Company selection rides the EXISTING type-agnostic POST /api/perubahan/select-company/:submissionId (no family guard; its v2 branch resumes continueFlowroutes/perubahan.ts:186-195); pembubaran adds NO select route.
  • submissionDocument.create seeds MUST include confidence (required Float, no default). Every seed in this plan carries confidence: 1.
  • DO-NOT-COPY warnings (Peleburan whole-flow review — bake in, do not re-learn):
    1. Never make a status-gated dialog/panel the ONLY management surface for correctable state — every correctable row/field (likuidator fields, koran 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 must emit WARNING naming the unchecked items (LIKUIDATOR_SURAT_COMPLETE lists the missing field keys; PEMBUBARAN_KORAN names the unverifiable date).
    3. Staged flows MUST test verifikator decide-APPROVE end-to-end and declare their voter-roster source. Declared: Submission.oldData.pemegang_saham via resolveVoterRoster's existing fallbackAKTA_PEMBUBARAN is deliberately NOT added to ROSTER_AKTA_TYPES (shareholder-voting.ts:101): the registry roster IS the legal voter set for a dissolution, and a deed-restated roster must not hijack it. Pinned by tests in Task 10 (APPROVE → votes from oldData names; deed rows present → still oldData; roster-empty → 422). If you think you need to widen ROSTER_AKTA_TYPES — you don't; and if a future flow does, widen roster-LOCALLY (the const is roster-local already), never the shared PERUBAHAN_FAMILY_AKTA.
    4. companyLookup: true (single-source). Do NOT touch the engine's multi-source branch (processor.ts:58-77) or peleburan-sources.ts — that wiring is peleburan-coupled by documented design.
  • Inherited deferred triggers landing HERE:
  • (a) RUPS-quorum ≥-comparator. Pembubaran cites UUPT Ps.89(1) "paling sedikit 3/4" (≥, not >). The shared rupsQuorumRule wrapper (flow-engine/rules/pt-akta/rups-quorum.ts) currently encodes > semantics via ratio <= threshold → WARNING. Task 5 adds a comparator?: ">" | ">=" option AT THE WRAPPER (body validateRupsAttendanceQuorum untouched; default ">" preserved for every existing consumer): with ">=", WARNING fires on ratio < threshold, so exactly 75% attendance is compliant. Pembubaran consumes rupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" }).
  • (b) Shared bukti-pengumuman extractor (services/bukti-pengumuman-extract.ts, canonical nullable keys nama_koran/tanggal_pengumuman/nomor_pengumuman) is CONSUMED via ONE focusedPasses map entry — never re-implemented, never prompt-broadened. The clipping-extractor count-guard partial-write hardening trigger FIRES here (ledger: "trigger = Pembubaran's clipping adoption"): Task 2 converts runBuktiPengumumanPass's per-key create loop into ONE atomic createMany (all-or-nothing, so the any-key→skip-ALL idempotency guard becomes sound), and builds the NEW likuidator pass atomic from day one.
  • The tuned validation matrix (severities binding, spec §4.6):
  • Wrapped bit-for-bit via pickRules(PT_AKTA_RULE_SET, …) — 13 codes: NIK_KTP_AKTA, NAMA_NPWP_KTP, KTP_COMPLETENESS, NPWP_COMPLETENESS, NAMA_PT_CONSISTENCY, DOMISILI_NAMA, NPWP_PERSEROAN_FUZZY, CONTACT_INFO_COMPLETE, PASSPORT_AKTA, AKTA_DATE_WINDOW (30-day: shared context sets isPerubahan: true), OLD_DATA_CONSISTENCY, PERSEROAN_STATE (FAIL on status_transaksi 5/6/≠1/blocked — cross-validator.ts:1418; the registry "PT must be ACTIVE, not already dissolved" gate), NOTARIS_TERAKHIR. Plus the parameterized rupsQuorumRule({ quorumThreshold: 3/4, comparator: ">=" }) (WARNING tier — attendance is heuristic name-matching, never hard-block).
  • Net-new 4: PT_ALREADY_DISSOLVED (FAIL, async — a COMPLETED PEMBUBARAN_PT for the same oldData.perseroan.id_perseroan, pp-pembubaran.ts:475 pattern, exact-equality type match), LIKUIDATOR_SURAT_COMPLETE (WARNING naming missing fieldKeys — OCR gaps are completable in review; telepon/email often absent from the letter, SABH types them manually today), LIKUIDATOR_KTP_MATCH (FAIL on a plausible-but-conflicting best match (tokenScore 0.5–0.8 band) / WARNING on absence (<0.5 or no KTPs — "KTP likuidator belum diunggah"; the likuidator may be a pengurus whose KTP already matched — spec open-question 4 resolution: an existing identity-match KTP is accepted, PASS-on-doubling falls out of scoring the whole pool) / PASS ≥0.8), PEMBUBARAN_KORAN (WARNING — NEVER FAIL, severityFloor: "WARNING" + structural test: UUPT Ps.147(1) makes the announcement a LIKUIDATOR obligation within 30 days FROM the dissolution — it may legitimately not exist at filing time. Do NOT copy PELEBURAN_KORAN_WINDOW's FAIL — that is Ps.127(2) pre-RUPS semantics, a different statute).
  • EXCLUDED — deliberately, guard with a set-composition test: 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) — a dissolution touches no cap table; the jenis machinery (DATA_ACTUALLY_CHANGED, JENIS_SELECTION_NONEMPTY, JENIS_DESELECT_REASONS_COMPLETE, CONTRADICTORY_JENIS) — no jenis selection exists; REQUIRED_SUPPORTING_DOCS + UPLOADED_DOC_UNUSED — adjudicated DEVIATION from the spec matrix: their bodies are JENIS-scoped (cross-validator.ts:1235/:1742 key required/unused docs off effectiveJenis.nonpad), so for a jenis-less flow they emit noise-SKIPs at best and a FALSE WARNING at worst (BERITA_ACARA_RUPS flagged "unused" — but the BA is a recommended pembubaran doc). The required-doc surface is the ?type=pembubaran board set + the presence tiers inside LIKUIDATOR_SURAT_COMPLETE/PEMBUBARAN_KORAN (peleburan-precedent exclusion class). Omission-by-config, not input-shape-SKIPPED.
  • 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).
  • Ladder position (canonical): Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > PEMBUBARAN > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT. Pembubaran's rung goes BELOW Akuisisi (and below Peleburan) in inferSubmissionType (routes/submissions.ts) and in the frontend detection chains — note this is the OPPOSITE of Peleburan's above-Akuisisi rung; do not copy its position. The BERAKHIRNYA_PACKAGE doc-set predicate ships HERE (its first consumer, owned ONCE in submissions.ts, quoted identically by the Berakhirnya spec — C5): a Berakhirnya package (AKTA_BERAKHIRNYA, or AKTA_PEMBUBARAN + laporan/permohonan labels, or AKTA_PEMBUBARAN + ≥2 docs mapping to the BUKTI_PENGUMUMAN carrier via mapToDocumentType — count the CARRIER, never the raw label string) must gate to "belum didukung" (supported: false, detectedType: "berakhirnya"), never silently route into PEMBUBARAN_PT. A SINGLE clipping (the Ps.147 announcement) does NOT trip it. Pembubaran wins only bare AKTA_PEMBUBARAN.
  • Classifier gating = the launch switch: everything stays dark ("Terdeteksi: Pembubaran (belum didukung)") until BUILT_AKTA_TXN_TYPES += "pembubaran" (Task 7). Tasks 1-6 must not make the flow reachable. Task 7 also updates the tests that use pembubaran as the unbuilt case (klasifikasi-akta-txn.test.ts:35-36, laporan-rups-routing.test.ts:14) to berakhirnya.
  • Model routing: the likuidator pass and all koran passes = the 3B (CLEANUP_LLM_URL/CLEANUP_LLM_MODEL, cleanup-3b @8003), temperature: 0, guided-JSON, every value verbatim-substring-grounded (norm() as in akta-koran-extract.ts:56); dasar_penunjukan grounds TOKEN-WISE (≥80% of normalized tokens present) or it always nulls out. Deterministic regexes (RT/RW, labeled kodepos, telepon, email) WIN over LLM output. Hallucination → null, never an ERROR. This flow adds NO 35B pass (no list-of-objects extraction — the peleburan sources pass is not a template here).
  • 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 PUT /likuidator / the review cards, 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 already 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-30. base-ui Button link idiom is render={<Link to="..." />} (no asChild — this project uses @base-ui, not Radix).
  • No auto-confirm / no auto-accept: section approvals are EXPLICIT persisted PerubahanSectionApproval rows — never derived from child confirmed state (regression c0698d8). The likuidator/koran grounded values persist at the 0.75 "please review" tier — never auto-LOCK. The SURAT_LIKUIDATOR re-check pass (Task 8) is suggestion-only, applied through the existing re-map mechanism.
  • Do not touch v1 forks: perubahan/akuisisi/pendirian processors, the akuisisi inline koran call sites (document-processor.ts:416/472), extractKoranViaFocusedLlm, and every existing route behaviour stay byte-identical. The field-transformer.ts akta_kedua mappings (Task 3) are provably inert for v1 flows (null-parent skip logic emits ZERO rows when akta_kedua is null — regression-tested). The verifikator queue widening (Task 10) adds PEMBUBARAN_PT without changing what existing rows return.

File Structure

File Create/Modify Responsibility
backend/prisma/schema.prisma Modify SubmissionType += PEMBUBARAN_PT; Submission.rupsKehadiranRegistry Json? (NO new tables — likuidator/koran are ExtractedField rows, spec §6)
backend/src/flow-engine/registry.ts Modify (twice) Task 1: temporary v1-fork stub; Task 6: real pembubaranPtFlow entry
backend/src/services/submission-dispatch.ts Modify processorKindForType case PEMBUBARAN_PT → "v2-generic" (satisfies the never tripwire)
backend/src/services/surat-likuidator-extract.ts Create Grounded 3B 12-field likuidator extractor (penunjukan shape ONLY) + runSuratLikuidatorPass (atomic ExtractedField persistence, idempotent-once)
backend/src/services/bukti-pengumuman-extract.ts Modify Partial-write hardening: per-key create loop → ONE atomic createMany (ledger trigger)
backend/src/flow-engine/focused-passes.ts Modify FOCUSED_PASS_HANDLERS["surat-likuidator"] entry + stale-comment fix (ledger minor)
backend/src/services/document-processor.ts Modify OCR-only case for documentType === "SURAT_LIKUIDATOR" (case 6k, after the BUKTI_PENGUMUMAN case)
backend/src/services/akta-pembubaran-deed-passes.ts Create hooks.afterAktaExtraction body: deed-inline koran fallback (SHARED extractKoranFields, pembubaran docContext, zero-clipping guard, idempotent-once)
backend/src/services/field-transformer.ts Modify PERUBAHAN_FIELD_MAPPING += the 3 akta_kedua_* dual-deed keys (null-parent skip keeps v1 byte-identical)
backend/src/services/rups-attendance-matcher.ts Modify AKTA_PEMBUBARAN joins the akta filter + PEMBUBARAN_PT-scoped registry-roster fallback → Submission.rupsKehadiranRegistry
backend/src/flow-engine/context/pembubaran-context.ts Create PembubaranCtx (extends PtAktaCtx) + buildPembubaranContext (likuidator / koran / registry-quorum override / submissionId)
backend/src/flow-engine/rules/pt-akta/rups-quorum.ts Modify comparator?: ">" \| ">=" wrapper param (body untouched; default ">")
backend/src/flow-engine/rules/pembubaran/index.ts Create 4 flow rules + PEMBUBARAN_SHARED_RULE_CODES + assembled PEMBUBARAN_RULE_SET
backend/src/flow-engine/flows/pembubaran-pt.ts Create The real defineFlow<PembubaranCtx> config — THIRD v2-generic registry entry
backend/src/flow-engine/types.ts Modify RouteFamily union += "pembubaran"
backend/src/ocr/classifier.ts Modify AKTA_SUBTYPE_MAP["AKTA_PEMBUBARAN"] = "perubahan" (defensive label row; identity rows exist)
backend/src/services/akta-txn-classifier.ts Modify BUILT_AKTA_TXN_TYPES += "pembubaran" (LAUNCH SWITCH) + appoints-vs-accepts SYSTEM_PROMPT cue
backend/src/routes/submissions.ts Modify BERAKHIRNYA_PACKAGE predicate (owned here) + detectUnsupportedAktaType package check + inferSubmissionType rung BELOW Akuisisi + return union; mapToDocumentType/getProcessingOrder for SURAT_LIKUIDATOR
backend/src/routes/klasifikasi.ts Modify PEMBUBARAN_DOC_TYPES + ?type=pembubaran + LABEL_MAP spread; POST /recheck-likuidator scoped re-check endpoint
backend/src/services/supporting-doc-classifier.ts Modify suggestSuratLikuidator (deterministic tiers) + isSuratPenunjukanLikuidatorViaLlm (focused 3B tail)
backend/src/routes/pembubaran.ts Create Route family: review-data, likuidator/kehadiran/koran edits, section approve/unapprove, submit→verifikator (canonical envelopes)
backend/src/index.ts Modify Mount /api/pembubaran
backend/src/routes/verifikator.ts Modify Queue where admits PEMBUBARAN_PT; tagged item type
backend/src/services/shareholder-voting.ts Modify (comment only) Document the deliberate NON-widening of ROSTER_AKTA_TYPES for AKTA_PEMBUBARAN
backend/src/flow-engine/__tests__/{pembubaran-schema,pembubaran-context,rules-pembubaran,pembubaran-flow,pembubaran-integration}.test.ts Create Per-task TDD
backend/src/flow-engine/__tests__/registry.test.ts Modify V2_SHIPPED += "PEMBUBARAN_PT" (Task 6)
backend/src/services/__tests__/{surat-likuidator-extract,akta-pembubaran-deed-passes,akta-kedua-transformer,pembubaran-rups-fallback}.test.ts Create Extractor grounding + pass persistence + transformer inertness + matcher fallback
backend/src/routes/__tests__/{pembubaran-routing,pembubaran-routes,verifikator-pembubaran,klasifikasi-recheck-likuidator}.test.ts Create Routing/gating + route family + queue/decide-APPROVE + re-check
backend/src/routes/__tests__/{klasifikasi-akta-txn,laporan-rups-routing}.test.ts Modify Stale "pembubaran is unbuilt" fixtures → berakhirnya
frontend/src/lib/classifier-labels.ts Modify AKTA_PEMBUBARAN + SURAT_LIKUIDATOR display names
frontend/src/lib/pembubaran-detect.ts Create Pure FE mirrors: isBerakhirnyaPackage, pickLikuidatorRecheckCandidates
frontend/src/components/klasifikasi/detect-banner.tsx Modify DetectedFlow += "pembubaran" + labels/descriptions
frontend/src/pages/KlasifikasiPage.tsx Modify Detect chains (rung BELOW akuisisi) + label set + create-route + supported:false gate toast + re-check trigger
frontend/src/lib/flow-steps.ts Modify PEMBUBARAN_LABELS (5 steps incl. verifikator stage) + type union + staged arm
frontend/src/lib/destination-for.ts Modify Type union + base pembubaran (NOT terminal)
frontend/src/lib/pembubaran-review.ts Create Pure gate helpers (6 required section keys, missingSections)
frontend/src/pages/PembubaranExtractionPage.tsx Create Copy of LaporanRupsExtractionPage.tsx + exactly 3 token diffs (single-company modal flow)
frontend/src/pages/PembubaranReviewPage.tsx Create Flat sectioned review: Perseroan(SABH)/Akta(+dual-deed)/Kehadiran/Likuidator/Koran/Identitas/Kontak + submit-to-verifikator
frontend/src/pages/PembubaranStatusPage.tsx Create Verifikasi-aware status page (peleburan mirror)
frontend/src/pages/VerifikatorPembubaranReviewPage.tsx Create Compact verifikator surface (VerifikatorPeleburanReviewPage precedent)
frontend/src/hooks/use-verifikator.ts Modify Queue item type union += "PEMBUBARAN_PT"
frontend/src/pages/VerifikatorDashboardPage.tsx Modify Route PEMBUBARAN_PT items to the pembubaran verifikator page
frontend/src/routes.tsx Modify pembubaran/:submissionId/{extraction,review,status} + verifikator/pembubaran/:submissionId

Task 1: Prisma schema + compile-gate (enum, rupsKehadiranRegistry, registry stub, ProcessorKind case)

Adds PEMBUBARAN_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 ONE Submission column: rupsKehadiranRegistry Json? (the registry-roster attendance fallback, spec §4.3). NO new tables — the dispatch brief's "likuidator satellite" is resolved against the spec authority (§6: "NO new tables — likuidator + koran values are ExtractedField rows"), which buys confirm/bbox/override/rematch for free. SURAT_LIKUIDATOR/BUKTI_PENGUMUMAN DocumentTypes already exist — do not touch DocumentType.

Files
- backend/prisma/schema.prisma — Modify (enum SubmissionType ends at PELEBURAN_PT ~line 101; model Submission Json columns ~line 750-758)
- backend/src/flow-engine/registry.ts — Modify (stub key after the PELEBURAN_PT entry)
- backend/src/services/submission-dispatch.ts — Modify (processorKindForType, add case before default:)
- backend/src/flow-engine/__tests__/pembubaran-schema.test.ts — Create

Interfaces
- Produces: generated enum SubmissionType.PEMBUBARAN_PT; Submission.rupsKehadiranRegistry: Prisma.JsonValue | null; processorKindForType("PEMBUBARAN_PT") === "v2-generic"; FLOW_REGISTRY.PEMBUBARAN_PT defined.

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/pembubaran-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 { FLOW_REGISTRY } from "../registry";

/**
 * Task 1 — PEMBUBARAN_PT schema slice + the two compile-forced wiring points
 * (registry key + ProcessorKind case). The registry entry is a TEMPORARY
 * v1-fork stub until Task 6 registers the real defineFlow config. NO new
 * tables (spec §6): likuidator + koran live as ExtractedField rows; the one
 * new column is the registry-roster attendance JSON.
 */
describe("PEMBUBARAN_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 PEMBUBARAN_PP)", () => {
    expect(SubmissionType.PEMBUBARAN_PT).toBe("PEMBUBARAN_PT");
    expect(SubmissionType.PEMBUBARAN_PP).toBe("PEMBUBARAN_PP"); // both exist, never aliased
  });

  it("persists rupsKehadiranRegistry rows on Submission", async () => {
    const rows = [
      { nama: "BUDI SANTOSO", persentase: 60, kehadiran: true },
      { nama: "SITI AMINAH", persentase: 40, kehadiran: null },
    ];
    const sub = await db.submission.create({
      data: { type: "PEMBUBARAN_PT", status: "CREATED", rupsKehadiranRegistry: rows },
    });
    ids.push(sub.id);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.rupsKehadiranRegistry).toEqual(rows);
    // default null
    const bare = await db.submission.create({ data: { type: "PEMBUBARAN_PT", status: "CREATED" } });
    ids.push(bare.id);
    expect(bare.rupsKehadiranRegistry).toBeNull();
  });

  it("routes the new type to the v2-generic ProcessorKind and a total registry", () => {
    expect(processorKindForType("PEMBUBARAN_PT")).toBe("v2-generic");
    expect(FLOW_REGISTRY.PEMBUBARAN_PT).toBeDefined();
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/pembubaran-schema.test.ts. Expected: fails — PEMBUBARAN_PT not on the enum (typecheck of the test file itself errors).
  • [ ] Minimal implementation A — backend/prisma/schema.prisma:
    1. Inside enum SubmissionType { ... }, append after PELEBURAN_PT:
  /// Pembubaran PT (SABH id_aksi_transaksi = 12, UUPT Ps.142-147): dissolution of a
  /// REGISTERED PT — the deed dissolves the PT and APPOINTS a likuidator (liquidation
  /// STARTS; contrast Berakhirnya id_aksi 36, which ACCEPTS the likuidator's final
  /// accounting and ENDS it). DISTINCT from PEMBUBARAN_PP (Perseroan Perorangan,
  /// UU CK regime) — exact-equality checks only, never substring matching.
  /// THIRD engine:'v2-generic' FLOW_REGISTRY tenant. VERIFIKATOR-STAGED (submit
  /// creates a VerifikasiPerubahan row; voter roster = oldData.pemegang_saham).
  PEMBUBARAN_PT
  1. In model Submission, next to the existing perubahan-family Json columns (changeTypeResult/selectedJenisPerubahan, ~line 752-758), add:
  /// PEMBUBARAN_PT registry-roster RUPS-attendance fallback (spec §4.3):
  /// [{ nama, persentase, kehadiran }] written by matchRupsAttendance when the
  /// dissolution deed restates no shareholders — SABH's attendance checkboxes are
  /// against the REGISTRY list, so this is the flow's PRIMARY attendance path.
  /// Consumed by buildPembubaranContext (quorum) and the Kehadiran review card.
  rupsKehadiranRegistry Json?
  • [ ] Minimal implementation B — backend/src/flow-engine/registry.ts, append to FLOW_REGISTRY after the PELEBURAN_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).
  PEMBUBARAN_PT: { engine: "v1-fork", type: "PEMBUBARAN_PT" },
  • [ ] Minimal implementation C — backend/src/services/submission-dispatch.ts, in processorKindForType, add after the PELEBURAN_PT case:
    case "PEMBUBARAN_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__/pembubaran-schema.test.ts. Expected: 3 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 (LAPORAN_RUPS_TAHUNAN, PELEBURAN_PT) treats the stub as v1-fork NOW; Task 6 adds PEMBUBARAN_PT to that set.
  • [ ] Commit: git add backend/prisma/schema.prisma backend/src/generated/prisma backend/src/flow-engine/registry.ts backend/src/services/submission-dispatch.ts backend/src/flow-engine/__tests__/pembubaran-schema.test.ts && git commit -m "feat(pembubaran): PEMBUBARAN_PT enum + rupsKehadiranRegistry + compile-gate wiring"

Task 2: Surat Likuidator grounded extractor + focused-pass handler + OCR-only doc handler + clipping partial-write hardening

The flow's definitional net-new extractor (spec §4.3). Penunjukan shape ONLY — the laporan-akhir/pertanggungjawaban is the DISTINCT LAPORAN_LIKUIDASI DocumentType (built in Berakhirnya); the §4.2 doc-set predicate keys on that distinction (C2). Deterministic regexes first (RT/RW, labeled kodepos, telepon, email — they WIN over LLM output); ONE focused 3B guided-JSON call fills the rest; EVERY value grounded (dasar_penunjukan token-wise ≥80% — it spans lines and whole-substring grounding would always null it). Persists 12 ExtractedField rows in ONE atomic createMany (0.75 tier / FLAGGED-null), idempotent-once. Wired as FOCUSED_PASS_HANDLERS["surat-likuidator"]. Plus the document-processor.ts OCR-only case — without it a SURAT_LIKUIDATOR letter falls through to the generic akta handler and burns an akta-VLM extraction. Also the inherited ledger trigger: runBuktiPengumumanPass's per-key create loop becomes ONE atomic createMany (a crash mid-loop used to leave a permanent partial row set that the any-key idempotency guard then preserved forever).

Files
- backend/src/services/surat-likuidator-extract.ts — Create
- backend/src/services/bukti-pengumuman-extract.ts — Modify (atomic createMany)
- backend/src/flow-engine/focused-passes.ts — Modify (one map entry + stale-comment fix)
- backend/src/services/document-processor.ts — Modify (insert case 6k directly after the BUKTI_PENGUMUMAN case, ~line 2500)
- backend/src/services/__tests__/surat-likuidator-extract.test.ts — Create

Interfaces
- Consumes: CLEANUP_LLM_URL/CLEANUP_LLM_MODEL/CLEANUP_LLM_TIMEOUT_MS env (3B); db.
- Produces:
- const LIKUIDATOR_EXTRACTED_FIELD_KEYS: readonly string[] — the 12 canonical fieldKeys likuidator_nama, likuidator_nomor_surat, likuidator_tanggal_surat, likuidator_alamat, likuidator_rt, likuidator_rw, likuidator_kode_pos, likuidator_kelurahan, likuidator_kecamatan, likuidator_telepon, likuidator_email, likuidator_dasar_penunjukan
- interface SuratLikuidatorFields (12 nullable strings, unprefixed keys)
- async function extractSuratLikuidator(rawText: string): Promise<SuratLikuidatorFields>
- async function runSuratLikuidatorPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> — idempotent-once (any existing key → skip ALL; sound because the write is atomic)
- FOCUSED_PASS_HANDLERS["surat-likuidator"]

Steps

  • [ ] Write failing test. Create backend/src/services/__tests__/surat-likuidator-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import {
  extractSuratLikuidator,
  runSuratLikuidatorPass,
  LIKUIDATOR_EXTRACTED_FIELD_KEYS,
} from "../surat-likuidator-extract";

const RAW =
  "SURAT PENUNJUKAN LIKUIDATOR\nNomor: 017/SPL/V/2026\nJakarta, 2 Mei 2026\n" +
  "Menunjuk Saudara BUDI HARTONO, S.H. selaku likuidator Perseroan\n" +
  "beralamat di Jalan Melati Raya No. 12, RT. 004/RW. 007,\n" +
  "Kelurahan Cempaka Putih, Kecamatan Menteng, Kode Pos 10310\n" +
  "Telepon: 081234567890, Email: budi.hartono@example.co.id\n" +
  "Dasar penunjukan: Keputusan Rapat Umum Pemegang Saham tanggal 28 April 2026\n" +
  "sebagaimana dimuat dalam Akta Pembubaran Nomor 5.";

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: "BUDI HARTONO, S.H.", nomor_surat: "017/SPL/V/2026", tanggal_surat: "2 Mei 2026",
  alamat: "Jalan Melati Raya No. 12", rt: "999", rw: "999", kode_pos: "99999",
  kelurahan: "Cempaka Putih", kecamatan: "Menteng",
  telepon: "089999999999", email: "salah@salah.com",
  dasar_penunjukan: "Keputusan Rapat Umum Pemegang Saham tanggal 28 April 2026 sebagaimana dimuat dalam Akta Pembubaran Nomor 5",
};

describe("extractSuratLikuidator (deterministic-first grounded 3B)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("deterministic RT/RW/kodepos/telepon/email WIN over conflicting LLM output", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const r = await extractSuratLikuidator(RAW);
      expect(r.rt).toBe("004");
      expect(r.rw).toBe("007");
      expect(r.kode_pos).toBe("10310");
      expect(r.telepon).toBe("081234567890");
      expect(r.email).toBe("budi.hartono@example.co.id");
    } finally { restore(); }
  });

  it("grounds LLM values verbatim; token-wise grounding accepts the multi-line dasar_penunjukan", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const r = await extractSuratLikuidator(RAW);
      expect(r.nama).toBe("BUDI HARTONO, S.H.");
      expect(r.nomor_surat).toBe("017/SPL/V/2026");
      expect(r.kelurahan).toBe("Cempaka Putih");
      // spans two OCR lines — whole-substring grounding would null it; token-wise ≥80% accepts it
      expect(r.dasar_penunjukan).toContain("Keputusan Rapat Umum Pemegang Saham");
    } finally { restore(); }
  });

  it("rejects hallucinated values (not grounded in rawText) → null", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ ...LLM_FULL, nama: "SINTA HALUSINASI", kelurahan: "Tanah Abang" });
    try {
      const r = await extractSuratLikuidator(RAW);
      expect(r.nama).toBeNull();
      expect(r.kelurahan).toBeNull();
    } finally { restore(); }
  });

  it("returns all-null LLM fields on outage/empty text — deterministic fields still fire; never throws", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(null, 500);
    try {
      const r = await extractSuratLikuidator(RAW);
      expect(r.nama).toBeNull();           // LLM-only field
      expect(r.rt).toBe("004");            // deterministic survives outage
      const empty = await extractSuratLikuidator("");
      expect(Object.values(empty).every((v) => v === null)).toBe(true);
    } finally { restore(); }
  });
});

describe("runSuratLikuidatorPass persistence (12 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: "spl.pdf", originalName: "spl.pdf", fileSize: 0, filePath: "/tmp/spl.pdf",
        documentType: "SURAT_LIKUIDATOR", status: "VERIFICATION_READY", rawText: RAW,
      },
    });
    docIds.push(doc.id);
    return doc.id;
  }

  it("persists ALL 12 canonical rows in one shot (0.75 grounded tier; null → FLAGGED)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(LLM_FULL);
    try {
      const documentId = await seedDoc();
      await runSuratLikuidatorPass({ submissionId: "s", documentId, rawText: RAW });
      const rows = await db.extractedField.findMany({ where: { documentId } });
      expect(rows.length).toBe(LIKUIDATOR_EXTRACTED_FIELD_KEYS.length);
      const nama = rows.find((r) => r.fieldKey === "likuidator_nama");
      expect(nama?.value).toBe("BUDI HARTONO, S.H.");
      expect(nama?.confidence).toBe(0.75); // "please review" tier — never auto-LOCK
      expect(nama?.status).toBe("EDITABLE");
      const alamat = rows.find((r) => r.fieldKey === "likuidator_alamat");
      expect(alamat?.value).toBe("Jalan Melati Raya No. 12");
    } 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: "likuidator_nama", value: "EDIT OPERATOR", confidence: 1, status: "MANUAL" },
      });
      await runSuratLikuidatorPass({ 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 below 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 runSuratLikuidatorPass({ submissionId: "s", documentId, rawText: "" });
    expect(await db.extractedField.findMany({ where: { documentId } })).toEqual([]);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/services/__tests__/surat-likuidator-extract.test.ts. Expected: fails — module not found.
  • [ ] Minimal implementation A — create backend/src/services/surat-likuidator-extract.ts:
/**
 * Focused, grounded SURAT_LIKUIDATOR extractor — PEMBUBARAN_PT's definitional
 * net-new block (spec §4.3). PENUNJUKAN SHAPE ONLY: the laporan-akhir /
 * pertanggungjawaban is the DISTINCT LAPORAN_LIKUIDASI DocumentType (built in
 * the Berakhirnya flow) — the §4.2 doc-set predicate keys on that distinction,
 * so this extractor must never swallow it (C2).
 *
 * Strategy (memory reference_apostille_extraction_llm — the 3B is brittle on
 * broad prompts, so this is a dedicated single-purpose call, never a widening
 * of any shared prompt):
 *   1. Deterministic regexes FIRST (RT/RW, labeled kodepos, telepon, email) —
 *      they win over LLM output unconditionally.
 *   2. ONE 3B guided-JSON call (CLEANUP_LLM, temperature 0) fills the rest.
 *   3. EVERY LLM value is grounded against rawText: whole-normalized-substring
 *      for scalars; TOKEN-WISE (≥80% of normalized tokens present) for
 *      dasar_penunjukan, which spans lines and would otherwise always null out.
 * Persistence: 12 ExtractedField rows (spec §6 — NO satellite table; buys
 * confirm/bbox/override/rematch wholesale), grounded 0.75 "please review" tier
 * (never auto-LOCK), ONE atomic createMany, IDEMPOTENT-ONCE.
 */
import { db } from "../lib/db";

export interface SuratLikuidatorFields {
  nama: string | null;
  nomor_surat: string | null;
  tanggal_surat: string | null;
  alamat: string | null;
  rt: string | null;
  rw: string | null;
  kode_pos: string | null;
  kelurahan: string | null;
  kecamatan: string | null;
  telepon: string | null;
  email: string | null;
  dasar_penunjukan: string | null;
}

const FIELD_ORDER = [
  "nama", "nomor_surat", "tanggal_surat", "alamat", "rt", "rw", "kode_pos",
  "kelurahan", "kecamatan", "telepon", "email", "dasar_penunjukan",
] as const;

/** Canonical ExtractedField keys — `likuidator_` + the schema key. Exported for
 *  the context builder, rules, routes and tests (ONE source of truth). */
export const LIKUIDATOR_EXTRACTED_FIELD_KEYS = FIELD_ORDER.map((k) => `likuidator_${k}`) as readonly string[];

const NONE: SuratLikuidatorFields = Object.fromEntries(FIELD_ORDER.map((k) => [k, null])) as SuratLikuidatorFields;

/** Deterministic tier — regex-extract BEFORE the LLM; these win unconditionally. */
export function extractLikuidatorDeterministic(rawText: string): Partial<SuratLikuidatorFields> {
  const out: Partial<SuratLikuidatorFields> = {};
  const rtRw = rawText.match(/RT\s*\.?\s*(\d{1,3})\s*[\/.]?\s*RW\s*\.?\s*(\d{1,3})/i);
  if (rtRw) { out.rt = rtRw[1]!; out.rw = rtRw[2]!; }
  const kodePos = rawText.match(/kode\s*pos\s*:?\s*(\d{5})\b/i);
  if (kodePos) out.kode_pos = kodePos[1]!;
  const telepon = rawText.match(/(?:\+62|62|08)\d{7,12}/);
  if (telepon) out.telepon = telepon[0];
  const email = rawText.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/);
  if (email) out.email = email[0];
  return out;
}

const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");

/** Token-wise grounding for free text spanning OCR lines: ≥80% of the value's
 *  normalized tokens must appear in the haystack. */
function groundTokens(value: string, hay: string): boolean {
  const tokens = value.split(/\s+/).map(norm).filter((t) => t.length > 0);
  if (tokens.length === 0) return false;
  const present = tokens.filter((t) => hay.includes(t)).length;
  return present / tokens.length >= 0.8;
}

export async function extractSuratLikuidator(rawText: string): Promise<SuratLikuidatorFields> {
  if (!rawText.trim()) return { ...NONE };
  const deterministic = extractLikuidatorDeterministic(rawText);

  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  let llm: Partial<Record<keyof SuratLikuidatorFields, string | null>> = {};
  if (url) {
    const system =
      "From an Indonesian surat penunjukan likuidator (liquidator-appointment letter for a PT dissolution), extract: " +
      "nama (the appointed likuidator's full name incl. titles), nomor_surat (the letter number), tanggal_surat (the letter date as written), " +
      "alamat (the likuidator's street address line, WITHOUT RT/RW/kelurahan/kecamatan/kode pos), rt, rw, kode_pos, kelurahan, kecamatan, " +
      "telepon, email, dasar_penunjukan (the legal basis of the appointment, e.g. the RUPS resolution or court order recital). " +
      "Copy VERBATIM from the text; if absent, return null. Never invent. " +
      "Return ONLY JSON {nama, nomor_surat, tanggal_surat, alamat, rt, rw, kode_pos, kelurahan, kecamatan, telepon, email, dasar_penunjukan}.";
    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(FIELD_ORDER.map((k) => [k, { type: ["string", "null"] }])),
            required: [...FIELD_ORDER],
            additionalProperties: false,
          },
        }),
        signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
      });
      if (res.ok) {
        const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
        const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
        if (m) llm = JSON.parse(m[0]) as Partial<Record<keyof SuratLikuidatorFields, string | null>>;
      }
    } catch (err) {
      console.warn("[surat-likuidator] 3B extraction failed:", err);
    }
  }

  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;
  };

  const out: SuratLikuidatorFields = { ...NONE };
  for (const key of FIELD_ORDER) {
    const det = deterministic[key];
    if (det) { out[key] = det; continue; } // deterministic wins
    const raw = llm[key] ? String(llm[key]).trim() : "";
    if (!raw) continue;
    if (key === "dasar_penunjukan") {
      out[key] = groundTokens(raw, hay) ? raw : null; // token-wise — spans lines
    } else {
      out[key] = ground(raw);
    }
  }
  return out;
}

/** The 'surat-likuidator' focused-pass body (invoked by flow-engine focusedPasses). */
export async function runSuratLikuidatorPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> {
  const { documentId, rawText } = args;
  if (!rawText.trim()) {
    console.warn(`[surat-likuidator] no rawText for doc ${documentId} — focused pass skipped (manual entry via PUT /likuidator)`);
    return;
  }
  const existing = await db.extractedField.findMany({
    where: { documentId, fieldKey: { in: [...LIKUIDATOR_EXTRACTED_FIELD_KEYS] } },
    select: { id: true },
  });
  // IDEMPOTENT-ONCE, any-key→skip-ALL. Sound because the write below is ONE atomic
  // createMany: a partial row set can no longer exist (the Peleburan-ledger
  // crash-mid-write class is closed for this consumer by construction).
  if (existing.length > 0) return;
  const r = await extractSuratLikuidator(rawText);
  await db.extractedField.createMany({
    data: FIELD_ORDER.map((k) => {
      const value = r[k];
      return {
        documentId,
        fieldKey: `likuidator_${k}`,
        value,
        confidence: value ? 0.75 : 0, // grounded "please review" tier — never auto-LOCK
        status: value ? "EDITABLE" : "FLAGGED",
      };
    }),
  });
}
  • [ ] Minimal implementation B — backend/src/flow-engine/focused-passes.ts: add to FOCUSED_PASS_HANDLERS after the "bukti-pengumuman" entry:
  "surat-likuidator": async (args) => {
    const { runSuratLikuidatorPass } = await import("../services/surat-likuidator-extract");
    await runSuratLikuidatorPass(args);
  },

and fix the stale header comment (ledger minor): change Later flows only add entries (bukti-pengumuman is already live; e.g. surat-likuidator lands with Pembubaran). to Later flows only add entries (laporan-keuangan, bukti-pengumuman and surat-likuidator are live).
- [ ] Minimal implementation C — backend/src/services/document-processor.ts: insert directly AFTER the BUKTI_PENGUMUMAN OCR-only case (// 6j. block) and before // 7. Akta handling:

    // 6k. SURAT_LIKUIDATOR (surat penunjukan likuidator, Pembubaran PT) — OCR-ONLY.
    // The grounded 12-field extraction runs as the flow-engine 'surat-likuidator'
    // 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-2 page letter.
    if (doc.documentType === "SURAT_LIKUIDATOR") {
      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;
    }
  • [ ] Minimal implementation D — backend/src/services/bukti-pengumuman-extract.ts: in runBuktiPengumumanPass, replace the trailing per-key loop
  const r = await extractBuktiPengumuman(rawText);
  for (const fieldKey of KEYS) {
    const value = r[fieldKey];
    await db.extractedField.create({
      data: {
        documentId, fieldKey, value,
        confidence: value ? 0.75 : 0, // grounded-3B "please review" tier — never auto-LOCK territory
        status: value ? "EDITABLE" : "FLAGGED",
      },
    });
  }

with ONE atomic createMany:

  const r = await extractBuktiPengumuman(rawText);
  // ATOMIC single createMany (was a per-key create loop): a crash mid-loop left a
  // permanent partial row set that the any-key idempotency guard above then
  // preserved forever (Peleburan-ledger carry; hardening trigger = Pembubaran's
  // clipping adoption). All-or-nothing now, so the guard is sound.
  await db.extractedField.createMany({
    data: KEYS.map((fieldKey) => {
      const value = r[fieldKey];
      return {
        documentId, fieldKey, value,
        confidence: value ? 0.75 : 0, // grounded-3B "please review" tier — never auto-LOCK territory
        status: value ? "EDITABLE" : "FLAGGED",
      };
    }),
  });
  • [ ] Run GREEN: cd backend && bun test src/services/__tests__/surat-likuidator-extract.test.ts src/services/__tests__/bukti-pengumuman-extract.test.ts src/flow-engine/__tests__/focused-passes.test.ts. Expected: all pass (the existing bukti-pengumuman suite proves the hardening is behavior-preserving).
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/services/surat-likuidator-extract.ts backend/src/services/bukti-pengumuman-extract.ts backend/src/flow-engine/focused-passes.ts backend/src/services/document-processor.ts backend/src/services/__tests__/surat-likuidator-extract.test.ts && git commit -m "feat(pembubaran): grounded surat-likuidator extractor + focused pass + clipping partial-write hardening"

Task 3: Deed seams — inline-koran hook + dual-deed transformer mappings + RUPS registry-roster fallback

Three small extraction-side seams. (a) runPembubaranDeedPasses — the flow's hooks.afterAktaExtraction body: the deed-inline koran fallback via the SHARED parameterized extractKoranFields(rawText, { docContext }) (built in Peleburan — consumed, never re-implemented), fired ONLY when zero BUKTI_PENGUMUMAN clippings are uploaded (spec §4.3/§8 — the clipping is the better source; the guard also avoids stale deed values shadowing it) and idempotent-once. (b) PERUBAHAN_FIELD_MAPPING gains the 3 akta_kedua_* keys — spec open question 2's dual-deed data (akta pembubaran + BA/PKR RUPS as second deed) is extracted by the perubahan LLM schema (schema/akta-perubahan.ts:467) but currently DROPPED at persistence; the transformer's null-parent skip logic (field-transformer.ts:545-560) provably emits ZERO rows for single-deed v1 extractions. (c) matchRupsAttendanceAKTA_PEMBUBARAN joins the akta filter and, scoped to type === "PEMBUBARAN_PT", the registry-roster fallback writes Submission.rupsKehadiranRegistry when the deed restates no shareholders (the flow's PRIMARY attendance path — SABH's checkboxes are against the registry list). First run fires automatically: document-processor.ts:1972 calls the matcher after every BA-RUPS extraction (Phase 3, after Phase-2 lookup has set oldData); re-runs ride rematch.rupsAttendance: true (Task 6).

Files
- backend/src/services/akta-pembubaran-deed-passes.ts — Create
- backend/src/services/field-transformer.ts — Modify (3 mapping entries)
- backend/src/services/rups-attendance-matcher.ts — Modify
- backend/src/services/__tests__/akta-pembubaran-deed-passes.test.ts — Create
- backend/src/services/__tests__/akta-kedua-transformer.test.ts — Create
- backend/src/services/__tests__/pembubaran-rups-fallback.test.ts — Create

Interfaces
- Produces: runPembubaranDeedPasses(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void>; ExtractedField keys akta_kedua_nomor / akta_kedua_tanggal / akta_kedua_nama_notaris on dual-deed extractions; matchRupsAttendance unchanged signature, new side-effect Submission.rupsKehadiranRegistry = [{ nama, persentase, kehadiran }] (PEMBUBARAN_PT only).

Steps

  • [ ] Write failing tests. Create backend/src/services/__tests__/akta-pembubaran-deed-passes.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import { runPembubaranDeedPasses } from "../akta-pembubaran-deed-passes";

const RAW =
  "AKTA PEMBUBARAN PT MAJU MUNDUR\nNomor 5, tanggal 30 April 2026\n" +
  "RUPS memutuskan membubarkan Perseroan dan menunjuk likuidator.\n" +
  "Pengumuman telah dimuat dalam surat kabar harian Media Indonesia tanggal 20 April 2026.";

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

async function seed(withClipping: boolean) {
  const sub = await db.submission.create({ data: { type: "PEMBUBARAN_PT", status: "EXTRACTING" } });
  const akta = await db.document.create({
    data: { filename: "akta.pdf", originalName: "akta.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: RAW },
  });
  await db.submissionDocument.create({
    data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
  });
  let clipId: string | null = null;
  if (withClipping) {
    const clip = await db.document.create({
      data: { filename: "koran.pdf", originalName: "koran.pdf", fileSize: 0, filePath: "/tmp/k.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY", rawText: "PENGUMUMAN PEMBUBARAN" },
    });
    clipId = clip.id;
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: clip.id, classifiedType: "BUKTI_PENGUMUMAN", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
    });
  }
  return { sub, akta, clipId };
}

describe("runPembubaranDeedPasses (deed-inline koran fallback, SHARED extractKoranFields)", () => {
  const subIds: string[] = [];
  const docIds: string[] = [];
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: subIds } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  it("fires ONLY when zero clippings are uploaded; persists canonical keys on the deed", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ tanggal_koran: "20 April 2026", nama_koran: "Media Indonesia" });
    try {
      const { sub, akta } = await seed(false);
      subIds.push(sub.id); docIds.push(akta.id);
      await runPembubaranDeedPasses({ submissionId: sub.id, aktaDocumentId: akta.id, rawText: RAW });
      const nama = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: akta.id, fieldKey: "nama_koran" } } });
      const tgl = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: akta.id, fieldKey: "tanggal_pengumuman" } } });
      expect(nama?.value).toBe("Media Indonesia");
      expect(nama?.confidence).toBe(0.75);
      expect(tgl?.value).toBe("20 April 2026");
    } finally { restore(); }
  });

  it("SKIPS the inline pass when a BUKTI_PENGUMUMAN clipping exists (clipping is the better source)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ tanggal_koran: "20 April 2026", nama_koran: "Media Indonesia" });
    try {
      const { sub, akta, clipId } = await seed(true);
      subIds.push(sub.id); docIds.push(akta.id, clipId!);
      await runPembubaranDeedPasses({ submissionId: sub.id, aktaDocumentId: akta.id, rawText: RAW });
      expect(await db.extractedField.findMany({ where: { documentId: akta.id } })).toEqual([]);
    } finally { restore(); }
  });

  it("is idempotent-once (operator edit survives) and skips loudly on empty rawText", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ tanggal_koran: "20 April 2026", nama_koran: "Media Indonesia" });
    try {
      const { sub, akta } = await seed(false);
      subIds.push(sub.id); docIds.push(akta.id);
      await db.extractedField.create({ data: { documentId: akta.id, fieldKey: "nama_koran", value: "EDIT OPERATOR", confidence: 1, status: "MANUAL" } });
      await runPembubaranDeedPasses({ submissionId: sub.id, aktaDocumentId: akta.id, rawText: RAW });
      const rows = await db.extractedField.findMany({ where: { documentId: akta.id } });
      expect(rows.length).toBe(1);
      expect(rows[0]?.value).toBe("EDIT OPERATOR");
      // empty rawText path
      const { sub: s2, akta: a2 } = await seed(false);
      subIds.push(s2.id); docIds.push(a2.id);
      await runPembubaranDeedPasses({ submissionId: s2.id, aktaDocumentId: a2.id, rawText: "" });
      expect(await db.extractedField.findMany({ where: { documentId: a2.id } })).toEqual([]);
    } finally { restore(); }
  });
});
  • [ ] Create backend/src/services/__tests__/akta-kedua-transformer.test.ts:
import { describe, it, expect } from "bun:test";
import { transformAktaPerubahanResult } from "../field-transformer";
import type { AktaPerubahanData } from "../../schema/akta-perubahan";

/** Minimal AktaPerubahanData factory — mirror of field-transformer.test.ts's shape. */
function makeData(overrides: Partial<AktaPerubahanData> = {}): AktaPerubahanData {
  return {
    jenis_dokumen: "Akta Pembubaran", akta: { nomor_akta: "5", tanggal_akta: "2026-04-30", judul_akta: null },
    notaris: null, perseroan: null, rups: null, jenis_perubahan: [], modal_baru: null,
    direksi_baru: null, komisaris_baru: null, pemegang_saham_baru: null,
    perubahan_nama: null, perubahan_alamat: null, perubahan_kbli: null,
    perubahan_jangka_waktu: null, perubahan_status_perseroan: null, perubahan_jenis_perseroan: null,
    peralihan_saham: null, pengurus_change_scope: null, pengangkatan_kembali: null,
    penghadap: [], pasal_yang_diubah: null, referensi_akta_pendirian: null,
    referensi_akta_perubahan_sebelumnya: null, saksi_saksi: null, akta_kedua: null,
    ...overrides,
  } as unknown as AktaPerubahanData;
}

describe("akta_kedua dual-deed transformer mappings", () => {
  it("emits the 3 akta_kedua_* rows when the block is populated (dual-deed pembubaran)", () => {
    const r = transformAktaPerubahanResult(
      makeData({ akta_kedua: { nomor_akta: "6", tanggal_akta: "2026-04-30", nama_notaris: "Sari, S.H., M.Kn." } as never }),
      {},
    );
    const byKey = new Map(r.fields.map((f) => [f.fieldKey, f.value]));
    expect(byKey.get("akta_kedua_nomor")).toBe("6");
    expect(byKey.get("akta_kedua_tanggal")).toBe("2026-04-30");
    expect(byKey.get("akta_kedua_nama_notaris")).toBe("Sari, S.H., M.Kn.");
  });

  it("emits ZERO akta_kedua rows when the block is null — v1 perubahan/akuisisi stay byte-identical", () => {
    const r = transformAktaPerubahanResult(makeData(), {});
    expect(r.fields.some((f) => f.fieldKey.startsWith("akta_kedua_"))).toBe(false);
  });
});

NOTE: if transformAktaPerubahanResult's real signature differs (e.g. requires boundingBoxes), mirror the call shape used in the existing field-transformer.test.ts — adapt the factory, keep the two assertions.
- [ ] Create backend/src/services/__tests__/pembubaran-rups-fallback.test.ts:

import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { matchRupsAttendance } from "../rups-attendance-matcher";

describe("matchRupsAttendance — PEMBUBARAN_PT registry-roster fallback (spec §4.3)", () => {
  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(opts: { type: "PEMBUBARAN_PT" | "PERUBAHAN_PT"; deedPs: boolean }) {
    const sub = await db.submission.create({
      data: {
        type: opts.type, status: "EXTRACTING",
        oldData: { perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 4242 }, pemegang_saham: [
          { nama: "BUDI SANTOSO", persentase: 60 },
          { nama: "SITI AMINAH", persentase: 40 },
        ] },
      },
    });
    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_PEMBUBARAN", 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/b.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,
        attendees: [
          { nama: "Bpk. BUDI SANTOSO", kehadiran: "hadir" },
          { nama: "SITI AMINAH", kehadiran: "tidak_hadir" },
        ],
      },
    });
    return { sub, akta };
  }

  it("writes rupsKehadiranRegistry from oldData.pemegang_saham when the deed restates no roster", async () => {
    const { sub } = await seed({ type: "PEMBUBARAN_PT", deedPs: false });
    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 },   // honorific-stripped name match
      { nama: "SITI AMINAH", persentase: 40, kehadiran: false },
    ]);
  });

  it("deed-roster path still wins when the deed DOES restate shareholders (AKTA_PEMBUBARAN now accepted)", async () => {
    const { sub, akta } = await seed({ type: "PEMBUBARAN_PT", deedPs: true });
    const r = await matchRupsAttendance(sub.id, db);
    expect(r.matched).toBe(1);
    const ps = await db.aktaPemegangSaham.findFirst({ where: { documentId: akta.id } });
    expect(ps?.kehadiranRups).toBe(true);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.rupsKehadiranRegistry).toBeNull(); // fallback did not fire
  });

  it("fallback is PEMBUBARAN_PT-scoped: a roster-less PERUBAHAN_PT never writes the JSON", async () => {
    const { sub } = await seed({ type: "PERUBAHAN_PT", deedPs: false });
    await matchRupsAttendance(sub.id, db);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.rupsKehadiranRegistry).toBeNull();
  });
});

NOTE: classifiedType: "AKTA_PEMBUBARAN" seeds rely on the Task-3 matcher widening; the PERUBAHAN_PT case seeds the same classifiedType shape but its own type — the scope check is on submission.type. If aktaPemegangSaham/beritaAcaraRupsExtraction require extra non-null columns, add the minimal values the schema demands (check schema.prisma) without changing the assertions.
- [ ] Run to verify RED: cd backend && bun test src/services/__tests__/akta-pembubaran-deed-passes.test.ts src/services/__tests__/akta-kedua-transformer.test.ts src/services/__tests__/pembubaran-rups-fallback.test.ts. Expected: module-not-found / missing-mapping / fallback-not-written failures.
- [ ] Minimal implementation A — create backend/src/services/akta-pembubaran-deed-passes.ts:

/**
 * PEMBUBARAN_PT hooks.afterAktaExtraction body (spec §4.3). ONE pass: the
 * deed-INLINE koran fallback via the SHARED parameterized extractKoranFields
 * (built in Peleburan — consumed here, never re-implemented; never broaden the
 * prompt, pass docContext). Fires ONLY when zero BUKTI_PENGUMUMAN clippings are
 * uploaded: the clipping pass (focusedPasses) is the better source, and skipping
 * saves a 3B call + avoids stale deed values shadowing the clipping in review.
 * IDEMPOTENT-ONCE + atomic createMany (engine resume never clobbers operator
 * edits). Log-and-continue is enforced by the engine (processor.ts try/catch).
 * NOTE: no source-refs pass here — pembubaran is SINGLE-company (companyLookup:
 * true); the peleburan 35B sources pass is NOT a template for this flow.
 */
import { db } from "../lib/db";
import { extractKoranFields } from "./akta-koran-extract";

export async function runPembubaranDeedPasses(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void> {
  const { submissionId, aktaDocumentId, rawText } = a;
  if (!rawText.trim()) {
    console.warn(`[pembubaran-deed] no rawText for akta ${aktaDocumentId} — inline koran pass skipped (manual entry in the Koran card)`);
    return;
  }
  const clippingCount = await db.submissionDocument.count({
    where: { submissionId, document: { documentType: "BUKTI_PENGUMUMAN" } },
  });
  if (clippingCount > 0) return; // clipping wins — inline fallback only at zero clippings (spec §4.3)

  const existing = await db.extractedField.findMany({
    where: { documentId: aktaDocumentId, fieldKey: { in: ["nama_koran", "tanggal_pengumuman"] } },
    select: { id: true },
  });
  if (existing.length > 0) return; // idempotent-once — operator edits survive resume

  const koran = await extractKoranFields(rawText, { docContext: "akta pembubaran perseroan terbatas (likuidasi)" });
  await db.extractedField.createMany({
    data: [
      { documentId: aktaDocumentId, fieldKey: "nama_koran", value: koran.nama_koran, confidence: koran.nama_koran ? 0.75 : 0, status: koran.nama_koran ? "EDITABLE" : "FLAGGED" },
      { documentId: aktaDocumentId, fieldKey: "tanggal_pengumuman", value: koran.tanggal_koran, confidence: koran.tanggal_koran ? 0.75 : 0, status: koran.tanggal_koran ? "EDITABLE" : "FLAGGED" },
    ],
  });
}
  • [ ] Minimal implementation B — backend/src/services/field-transformer.ts: append to PERUBAHAN_FIELD_MAPPING (after the ref_pendirian_* block, ~line 520):
  // Dual-deed block (AktaKedua, schema/akta-perubahan.ts:467) — populated for
  // two-deed transactions (e.g. PEMBUBARAN_PT filed as akta pembubaran + BA/PKR
  // RUPS second deed). The null-parent skip logic below emits ZERO rows when
  // akta_kedua is null, so single-deed v1 perubahan/akuisisi extractions stay
  // byte-identical (regression-tested in akta-kedua-transformer.test.ts).
  akta_kedua_nomor: ["akta_kedua", "nomor_akta"],
  akta_kedua_tanggal: ["akta_kedua", "tanggal_akta"],
  akta_kedua_nama_notaris: ["akta_kedua", "nama_notaris"],
  • [ ] Minimal implementation C — backend/src/services/rups-attendance-matcher.ts:
    1. Widen the akta filter:
  const aktaDocIds = subDocs
    .filter((d) => d.classifiedType === "AKTA_PERUBAHAN" || d.classifiedType === "AKTA" || d.classifiedType === "AKTA_PEMBUBARAN")
    .map((d) => d.documentId);
  1. Insert the registry fallback AFTER the attendanceByName map is built and BEFORE the for (const ps of psRows) loop:
  // Registry-roster fallback (spec §4.3 — the PRIMARY attendance path for
  // PEMBUBARAN_PT): SABH's kehadiran checkboxes are against the REGISTRY
  // shareholder list and a dissolution deed often restates no roster. When the
  // deed carries no PS rows, match BA attendees against oldData.pemegang_saham
  // and persist Submission.rupsKehadiranRegistry ([{ nama, persentase, kehadiran }]),
  // consumed by buildPembubaranContext's quorum override and the Kehadiran card.
  // SCOPED to PEMBUBARAN_PT so roster-less v1 flows never grow a stray JSON blob.
  if (psRows.length === 0) {
    const submission = await tx.submission.findUnique({
      where: { id: submissionId },
      select: { type: true, oldData: true },
    });
    if (submission?.type !== "PEMBUBARAN_PT") return { matched: 0, unmatched: [] };
    const oldPs = ((submission.oldData as { pemegang_saham?: { nama?: string | null; persentase?: number | null }[] } | null)?.pemegang_saham ?? [])
      .filter((p): p is { nama: string; persentase: number | null } => !!p.nama && p.nama.trim().length > 0);
    if (oldPs.length === 0) return { matched: 0, unmatched: [] };
    let regMatched = 0;
    const regUnmatched: string[] = [];
    const rows = oldPs.map((p) => {
      const flag = attendanceByName.get(normalizeName(p.nama));
      if (flag === undefined) {
        regUnmatched.push(p.nama);
        return { nama: p.nama, persentase: p.persentase ?? null, kehadiran: null };
      }
      regMatched++;
      return { nama: p.nama, persentase: p.persentase ?? null, kehadiran: flag };
    });
    await tx.submission.update({
      where: { id: submissionId },
      data: { rupsKehadiranRegistry: rows as unknown as Prisma.InputJsonValue },
    });
    return { matched: regMatched, unmatched: regUnmatched };
  }

(Prisma is already imported as a type — import type { PrismaClient, Prisma } from "../generated/prisma/client"; — the cast is type-only.)
- [ ] Run GREEN: cd backend && bun test src/services/__tests__/akta-pembubaran-deed-passes.test.ts src/services/__tests__/akta-kedua-transformer.test.ts src/services/__tests__/pembubaran-rups-fallback.test.ts. Then regression: bun test src/services/__tests__/field-transformer.test.ts src/services/__tests__/rups-attendance-matcher.test.ts 2>/dev/null; bun test src/services/__tests__ --timeout 60000 | tail -5 (existing matcher/transformer suites must stay green).
- [ ] Typecheck: cd backend && bunx tsc --noEmit.
- [ ] Commit: git add backend/src/services/akta-pembubaran-deed-passes.ts backend/src/services/field-transformer.ts backend/src/services/rups-attendance-matcher.ts backend/src/services/__tests__/akta-pembubaran-deed-passes.test.ts backend/src/services/__tests__/akta-kedua-transformer.test.ts backend/src/services/__tests__/pembubaran-rups-fallback.test.ts && git commit -m "feat(pembubaran): deed-inline koran hook + akta_kedua mappings + registry-roster attendance fallback"


Task 4: Context builder — buildPembubaranContext

PembubaranCtx extends the shared PtAktaCtx (which pre-resolves ALL registry IO — blokir state, notaris ID — so wrapped rules run sync). Adds: submissionId (the async PT_ALREADY_DISSOLVED rule needs self-exclusion), the 12 likuidator values (ExtractedField rows on the SURAT_LIKUIDATOR doc), the koran resolution (clipping-doc-first, deed-inline second — canonical keys, peleburan-context parity), and the registry-quorum override: when the deed carries no PS rows, rupsQuorum.pemegangSaham is rebuilt from Submission.rupsKehadiranRegistry so the shared quorum body evaluates the registry roster unchanged.

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

Interfaces
- Consumes: buildPtAktaContext(submissionId, { aktaClassifiedType: "AKTA_PEMBUBARAN" }); LIKUIDATOR_EXTRACTED_FIELD_KEYS (Task 2); Submission.rupsKehadiranRegistry (Task 1/3).
- Produces:

export interface PembubaranCtx extends PtAktaCtx {
  submissionId: string;
  likuidator: { hasDoc: boolean; documentId: string | null; fields: Record<string, string | null> }; // keyed by the 12 canonical fieldKeys
  koran: { namaKoran: string | null; tanggalKoran: string | null; source: "clipping" | "akta" | null };
  rupsRosterSource: "deed" | "registry" | null;
}
export async function buildPembubaranContext(submissionId: string): Promise<PembubaranCtx>;

Steps

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

describe("buildPembubaranContext", () => {
  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(opts: { likuidatorDoc?: boolean; clipping?: boolean; deedKoran?: boolean; registryKehadiran?: boolean } = {}) {
    const sub = await db.submission.create({
      data: {
        type: "PEMBUBARAN_PT", status: "VALIDATING",
        oldData: { perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 4242, status_transaksi: 1 } },
        ...(opts.registryKehadiran
          ? { rupsKehadiranRegistry: [
              { nama: "BUDI SANTOSO", persentase: 60, kehadiran: true },
              { nama: "SITI AMINAH", persentase: 40, kehadiran: false },
            ] }
          : {}),
      },
    });
    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_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    await db.extractedField.createMany({
      data: [
        { documentId: akta.id, fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR", confidence: 0.9, status: "EDITABLE" },
        { documentId: akta.id, fieldKey: "tanggal_akta", value: "2026-04-30", confidence: 0.9, status: "EDITABLE" },
        ...(opts.deedKoran
          ? [
              { documentId: akta.id, fieldKey: "nama_koran", value: "Media Indonesia", confidence: 0.75, status: "EDITABLE" },
              { documentId: akta.id, fieldKey: "tanggal_pengumuman", value: "20 April 2026", confidence: 0.75, status: "EDITABLE" },
            ]
          : []),
      ],
    });
    if (opts.likuidatorDoc) {
      const lik = await db.document.create({
        data: { filename: "spl.pdf", originalName: "spl.pdf", fileSize: 0, filePath: "/tmp/s.pdf", documentType: "SURAT_LIKUIDATOR", status: "VERIFICATION_READY" },
      });
      docIds.push(lik.id);
      await db.submissionDocument.create({
        data: { submissionId: sub.id, documentId: lik.id, classifiedType: "SURAT_LIKUIDATOR", confidence: 1, processingOrder: 4, extractionStatus: "DONE" },
      });
      await db.extractedField.createMany({
        data: [
          { documentId: lik.id, fieldKey: "likuidator_nama", value: "BUDI HARTONO, S.H.", confidence: 0.75, status: "EDITABLE" },
          { documentId: lik.id, fieldKey: "likuidator_telepon", value: "081234567890", confidence: 0.75, status: "EDITABLE" },
        ],
      });
    }
    if (opts.clipping) {
      const clip = await db.document.create({
        data: { filename: "k.pdf", originalName: "k.pdf", fileSize: 0, filePath: "/tmp/k.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY" },
      });
      docIds.push(clip.id);
      await db.submissionDocument.create({
        data: { submissionId: sub.id, documentId: clip.id, classifiedType: "BUKTI_PENGUMUMAN", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
      });
      await db.extractedField.createMany({
        data: [
          { documentId: clip.id, fieldKey: "nama_koran", value: "Harian Kompas", confidence: 0.75, status: "EDITABLE" },
          { documentId: clip.id, fieldKey: "tanggal_pengumuman", value: "21 April 2026", confidence: 0.75, status: "EDITABLE" },
        ],
      });
    }
    return sub;
  }

  it("exposes submissionId, likuidator fields (all 12 keys, absent → null) and hasDoc", async () => {
    const sub = await seed({ likuidatorDoc: true });
    const ctx = await buildPembubaranContext(sub.id);
    expect(ctx.submissionId).toBe(sub.id);
    expect(ctx.likuidator.hasDoc).toBe(true);
    expect(ctx.likuidator.fields.likuidator_nama).toBe("BUDI HARTONO, S.H.");
    expect(ctx.likuidator.fields.likuidator_email).toBeNull();
    expect(Object.keys(ctx.likuidator.fields).length).toBe(12);
  });

  it("koran: clipping doc wins over deed-inline; deed-inline used when no clipping; null when neither", async () => {
    const both = await seed({ clipping: true, deedKoran: true });
    const c1 = await buildPembubaranContext(both.id);
    expect(c1.koran).toEqual({ namaKoran: "Harian Kompas", tanggalKoran: "21 April 2026", source: "clipping" });
    const deedOnly = await seed({ deedKoran: true });
    const c2 = await buildPembubaranContext(deedOnly.id);
    expect(c2.koran).toEqual({ namaKoran: "Media Indonesia", tanggalKoran: "20 April 2026", source: "akta" });
    const neither = await seed({});
    const c3 = await buildPembubaranContext(neither.id);
    expect(c3.koran.source).toBeNull();
  });

  it("quorum override: deed restates no roster → rupsQuorum rebuilt from rupsKehadiranRegistry", async () => {
    const sub = await seed({ registryKehadiran: true });
    const ctx = await buildPembubaranContext(sub.id);
    expect(ctx.rupsRosterSource).toBe("registry");
    expect(ctx.rupsQuorum.pemegangSaham).toEqual([
      { persentase: 60, kehadiranRups: true },
      { persentase: 40, kehadiranRups: false },
    ]);
    const bare = await seed({});
    const ctxBare = await buildPembubaranContext(bare.id);
    expect(ctxBare.rupsRosterSource).toBeNull();
    expect(ctxBare.rupsQuorum.pemegangSaham).toEqual([]);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/pembubaran-context.test.ts.
  • [ ] Minimal implementation — create backend/src/flow-engine/context/pembubaran-context.ts:
import { db } from "../../lib/db";
import { buildPtAktaContext, type PtAktaCtx } from "./pt-akta-context";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../../services/surat-likuidator-extract";

export interface PembubaranCtx extends PtAktaCtx {
  /** Needed by PT_ALREADY_DISSOLVED for self-exclusion (PtAktaCtx has no id). */
  submissionId: string;
  /** ExtractedField rows on the SURAT_LIKUIDATOR doc (spec §6, no satellite). */
  likuidator: { hasDoc: boolean; documentId: string | null; fields: Record<string, string | null> };
  /** Clipping (BUKTI_PENGUMUMAN doc) preferred; deed-inline akta fields as fallback
   *  (canonical keys — peleburan-context parity). */
  koran: { namaKoran: string | null; tanggalKoran: string | null; source: "clipping" | "akta" | null };
  /** Where the quorum roster came from. "registry" = the rupsKehadiranRegistry
   *  fallback (the PRIMARY pembubaran path — SABH checkboxes are registry-based). */
  rupsRosterSource: "deed" | "registry" | null;
}

export async function buildPembubaranContext(submissionId: string): Promise<PembubaranCtx> {
  const base = await buildPtAktaContext(submissionId, { aktaClassifiedType: "AKTA_PEMBUBARAN" });

  // Likuidator — the 12 canonical ExtractedField rows.
  const likSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, document: { documentType: "SURAT_LIKUIDATOR" } },
    select: {
      documentId: true,
      document: { select: { fields: { where: { fieldKey: { in: [...LIKUIDATOR_EXTRACTED_FIELD_KEYS] } } } } },
    },
  });
  const likFields = Object.fromEntries(
    LIKUIDATOR_EXTRACTED_FIELD_KEYS.map((k) => [k, likSubDoc?.document.fields.find((f) => f.fieldKey === k)?.value ?? null]),
  ) as Record<string, string | null>;
  const likuidator = { hasDoc: !!likSubDoc, documentId: likSubDoc?.documentId ?? null, fields: likFields };

  // Koran — clipping doc first, deed-inline second (canonical keys, spec §4.3).
  const subDocs = await db.submissionDocument.findMany({
    where: { submissionId },
    select: {
      documentId: true, classifiedType: true,
      document: { select: { documentType: true, fields: { where: { fieldKey: { in: ["nama_koran", "tanggal_pengumuman"] } } } } },
    },
  });
  const pick = (match: (d: (typeof subDocs)[number]) => boolean): { namaKoran: string | null; tanggalKoran: string | null } | null => {
    for (const d of subDocs) {
      if (!match(d)) continue;
      const nama = d.document.fields.find((f) => f.fieldKey === "nama_koran")?.value ?? null;
      const tanggal = d.document.fields.find((f) => f.fieldKey === "tanggal_pengumuman")?.value ?? null;
      if (nama || tanggal) return { namaKoran: nama, tanggalKoran: tanggal };
    }
    return null;
  };
  const clipping = pick((d) => d.document.documentType === "BUKTI_PENGUMUMAN");
  const inline = clipping ? null : pick((d) => d.classifiedType === "AKTA_PEMBUBARAN");
  const koran = clipping
    ? { ...clipping, source: "clipping" as const }
    : inline
      ? { ...inline, source: "akta" as const }
      : { namaKoran: null, tanggalKoran: null, source: null };

  // Quorum roster override — deed PS rows when present; else the registry
  // fallback JSON, mapped 1:1 into the SAME shape the shared quorum body reads
  // (validateRupsAttendanceQuorum is unchanged; only its input roster differs).
  let rupsRosterSource: PembubaranCtx["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";
    }
  }

  return { ...base, submissionId, likuidator, koran, rupsRosterSource };
}
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/pembubaran-context.test.ts.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/context/pembubaran-context.ts backend/src/flow-engine/__tests__/pembubaran-context.test.ts && git commit -m "feat(pembubaran): PembubaranCtx context builder (likuidator + koran + registry-quorum override)"

Task 5: Rules — the ≥-comparator quorum param + PEMBUBARAN_RULE_SET (the tuned matrix)

Two deliverables. (1) The inherited deferred trigger: rupsQuorumRule gains comparator?: ">" | ">=" — the wrapper currently encodes > semantics via ratio <= threshold → WARNING; UUPT Ps.89(1) "paling sedikit 3/4" means exactly 75% attendance is COMPLIANT, so the ">=" mode warns only on ratio < threshold. Wrapper param only; validateRupsAttendanceQuorum's body is untouched; default ">" preserves every existing consumer bit-for-bit. (2) The tuned matrix: 13 wrapped shared codes + the ¾-quorum wrapper + 4 flow rules. Severity discipline per Global Constraints (PEMBUBARAN_KORAN WARNING-NEVER-FAIL with a structural severityFloor pin; LIKUIDATOR_SURAT_COMPLETE names every missing key — warning-2 discipline).

Files
- backend/src/flow-engine/rules/pt-akta/rups-quorum.ts — Modify
- backend/src/flow-engine/rules/pembubaran/index.ts — Create
- backend/src/flow-engine/__tests__/rules-pembubaran.test.ts — Create

Interfaces
- Produces: rupsQuorumRule(opts?: { quorumThreshold?: number; comparator?: ">" | ">=" }); PEMBUBARAN_SHARED_RULE_CODES; PEMBUBARAN_RULE_SET: Rule<PembubaranCtx>[] (18 rules); exported rule objects ptAlreadyDissolvedRule, likuidatorSuratCompleteRule, likuidatorKtpMatchRule, pembubaranKoranRule.

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/rules-pembubaran.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { rupsQuorumRule } from "../rules/pt-akta/rups-quorum";
import {
  PEMBUBARAN_RULE_SET, PEMBUBARAN_SHARED_RULE_CODES,
  ptAlreadyDissolvedRule, likuidatorSuratCompleteRule, likuidatorKtpMatchRule, pembubaranKoranRule,
} from "../rules/pembubaran/index";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../../services/surat-likuidator-extract";
import type { PembubaranCtx } from "../context/pembubaran-context";

/** Minimal ctx factory — only the fields the rules under test read. */
function makeCtx(overrides: Partial<PembubaranCtx> = {}): PembubaranCtx {
  return {
    submissionId: "ctx-sub",
    input: {
      identityMatches: [], aktaPenghadap: [], ktpFields: new Map(), npwpFields: new Map(),
      aktaModalDisetor: null, buktiSetorAmounts: [], aktaKedudukan: null, aktaTempatKedudukan: null,
      checklistItems: [], aktaModalDasar: null, aktaJenisPerseroan: null, aktaStatusPerseroan: null,
      kbliCodes: [], aktaNamaPerseroan: "PT MAJU MUNDUR", domisiliNamaEntitas: null, buktiSetorEntityName: null,
      contactInfoEntityName: null, pemegangSahamPersentase: [], identityMatchContacts: [], knownPeopleNames: [],
      aktaNewPengurus: [], contactInfoEntries: [], uploadedNpwpNames: [], isPerubahan: true,
      oldData: { modal_dasar: null, modal_ditempatkan: null, nama: "PT MAJU MUNDUR", id_perseroan: 4242, status_transaksi: 1, id_notaris: null },
      perseroanBlokir: null, aktaNotarisId: null, perubahanModalBaru: null, perubahanModalLama: null,
      perubahanKbliCodes: [], changeTypeChecked: null, selectedJenisPerubahan: null, effectiveJenis: null,
      perubahanNewValues: { nama: null, tempatKedudukan: null, statusPerseroan: null, jenisPerseroan: null, jangkaWaktu: null },
      peralihanSaham: [], aktaJumlahSahamModalDitempatkan: null, pemegangSahamLembarTotal: null,
      uploadedDocTypes: [], aktaTanggal: "2026-04-30", tanggalKoran: null, aktaPemegangSaham: [],
      buktiSetorEntries: [], namaAkta: "PT MAJU MUNDUR", npwpPerseroanNama: null,
      aktaPeopleWna: [], passportExtractions: [], kitasExtractions: [],
    } as PembubaranCtx["input"],
    rupsQuorum: { pemegangSaham: [], hasBaRupsDoc: false },
    aktaFields: [],
    likuidator: {
      hasDoc: true, documentId: "doc-lik",
      fields: Object.fromEntries(LIKUIDATOR_EXTRACTED_FIELD_KEYS.map((k) => [k, null])) as Record<string, string | null>,
    },
    koran: { namaKoran: null, tanggalKoran: null, source: null },
    rupsRosterSource: null,
    ...overrides,
  } as PembubaranCtx;
}

const asOne = (r: unknown) => (Array.isArray(r) ? r[0] : r) as { status: string; message: string };

describe("rupsQuorumRule ≥-comparator (Ps.89(1) 'paling sedikit 3/4')", () => {
  const roster = (present: number) => ({
    pemegangSaham: [
      { persentase: present, kehadiranRups: true },
      { persentase: 100 - present, kehadiranRups: false },
    ],
    hasBaRupsDoc: true,
  });

  it('">=" 3/4: exactly 75% PASSes; 74% WARNs with the ≥75% message', async () => {
    const rule = rupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" });
    const pass = asOne(await rule.run(makeCtx({ rupsQuorum: roster(75) })));
    expect(pass.status).toBe("PASS");
    const warn = asOne(await rule.run(makeCtx({ rupsQuorum: roster(74) })));
    expect(warn.status).toBe("WARNING");
    expect(warn.message).toContain("≥75%");
  });

  it('default ">" semantics preserved: threshold 3/4 without comparator WARNs at exactly 75%', async () => {
    const rule = rupsQuorumRule({ quorumThreshold: 3 / 4 });
    const warn = asOne(await rule.run(makeCtx({ rupsQuorum: roster(75) })));
    expect(warn.status).toBe("WARNING");
    expect(warn.message).toContain(">75%");
  });

  it("no roster at all → SKIPPED (body semantics untouched)", async () => {
    const rule = rupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" });
    expect(asOne(await rule.run(makeCtx())).status).toBe("SKIPPED");
  });
});

describe("PT_ALREADY_DISSOLVED (FAIL — local double-dissolution guard)", () => {
  const ids: string[] = [];
  afterAll(async () => { await db.submission.deleteMany({ where: { id: { in: ids } } }); });

  it("FAILs on a seeded COMPLETED PEMBUBARAN_PT for the same id_perseroan; PASSes otherwise; exact-spelling vs PEMBUBARAN_PP", async () => {
    const prior = await db.submission.create({
      data: { type: "PEMBUBARAN_PT", status: "COMPLETED", oldData: { perseroan: { id_perseroan: 4242 } } },
    });
    ids.push(prior.id);
    const ppNoise = await db.submission.create({
      data: { type: "PEMBUBARAN_PP", status: "COMPLETED", oldData: { perseroan: { id_perseroan: 777 } } },
    });
    ids.push(ppNoise.id);

    expect(asOne(await ptAlreadyDissolvedRule.run(makeCtx())).status).toBe("FAIL"); // ctx id_perseroan = 4242
    const other = makeCtx();
    (other.input.oldData as { id_perseroan: number }).id_perseroan = 777; // only the PP row has 777 → must PASS
    expect(asOne(await ptAlreadyDissolvedRule.run(other)).status).toBe("PASS");
    // self-exclusion: the prior submission evaluating ITSELF must not self-FAIL
    const self = makeCtx({ submissionId: prior.id });
    expect(asOne(await ptAlreadyDissolvedRule.run(self)).status).toBe("PASS");
    // no oldData → SKIPPED
    const noOld = makeCtx();
    (noOld.input as { oldData: null }).oldData = null;
    expect(asOne(await ptAlreadyDissolvedRule.run(noOld)).status).toBe("SKIPPED");
  });
});

describe("LIKUIDATOR_SURAT_COMPLETE (WARNING naming the missing keys)", () => {
  it("WARNs listing every missing fieldKey; PASSes when all 12 filled; SKIPs without the doc", async () => {
    const partial = makeCtx();
    partial.likuidator.fields.likuidator_nama = "BUDI HARTONO";
    const warn = asOne(await likuidatorSuratCompleteRule.run(partial));
    expect(warn.status).toBe("WARNING");
    expect(warn.message).toContain("likuidator_telepon"); // names the unchecked (warning-2 discipline)
    const full = makeCtx();
    for (const k of LIKUIDATOR_EXTRACTED_FIELD_KEYS) full.likuidator.fields[k] = "x";
    expect(asOne(await likuidatorSuratCompleteRule.run(full)).status).toBe("PASS");
    const noDoc = makeCtx({ likuidator: { hasDoc: false, documentId: null, fields: makeCtx().likuidator.fields } });
    expect(asOne(await likuidatorSuratCompleteRule.run(noDoc)).status).toBe("SKIPPED");
    expect(likuidatorSuratCompleteRule.severityFloor).toBe("WARNING");
  });
});

describe("LIKUIDATOR_KTP_MATCH (FAIL contradiction / WARNING absence / PASS incl. pengurus-doubling)", () => {
  it("scores the WHOLE KTP pool", async () => {
    const base = makeCtx();
    base.likuidator.fields.likuidator_nama = "BUDI HARTONO";
    // absence: no KTPs at all → WARNING
    expect(asOne(await likuidatorKtpMatchRule.run(base)).status).toBe("WARNING");
    // pengurus-doubling: the pengurus KTP matches the likuidator → PASS
    const doubling = makeCtx();
    doubling.likuidator.fields.likuidator_nama = "BUDI HARTONO";
    doubling.input.ktpFields = new Map([["ktp-1", { nama: "BUDI HARTONO", nik: "3171000000000001" }]]);
    expect(asOne(await likuidatorKtpMatchRule.run(doubling)).status).toBe("PASS");
    // contradiction: plausible-but-conflicting best match (shares one token) → FAIL
    const conflict = makeCtx();
    conflict.likuidator.fields.likuidator_nama = "BUDI HARTONO";
    conflict.input.ktpFields = new Map([["ktp-1", { nama: "BUDI SANTOSO", nik: "3171000000000002" }]]);
    expect(asOne(await likuidatorKtpMatchRule.run(conflict)).status).toBe("FAIL");
    // unreadable likuidator name → SKIPPED
    expect(asOne(await likuidatorKtpMatchRule.run(makeCtx())).status).toBe("SKIPPED");
  });
});

describe("PEMBUBARAN_KORAN (WARNING — NEVER FAIL; Ps.147(1) post-deed obligation)", () => {
  it("absent → WARNING; in-window → PASS; out-of-window → WARNING; unparseable → WARNING; floor pinned", async () => {
    expect(pembubaranKoranRule.severityFloor).toBe("WARNING"); // structural never-FAIL pin
    expect(asOne(await pembubaranKoranRule.run(makeCtx())).status).toBe("WARNING"); // no koran entry
    const inWindow = makeCtx({ koran: { namaKoran: "Media Indonesia", tanggalKoran: "2026-05-15", source: "akta" } });
    expect(asOne(await pembubaranKoranRule.run(inWindow)).status).toBe("PASS"); // akta 2026-04-30 + 15d
    const late = makeCtx({ koran: { namaKoran: "Media Indonesia", tanggalKoran: "2026-06-15", source: "clipping" } });
    const lateR = asOne(await pembubaranKoranRule.run(late));
    expect(lateR.status).toBe("WARNING"); // > 30d after the deed — non-compliance signal, still not our gate
    const garbled = makeCtx({ koran: { namaKoran: "Media Indonesia", tanggalKoran: "tanggal tidak terbaca", source: "clipping" } });
    const g = asOne(await pembubaranKoranRule.run(garbled));
    expect(g.status).toBe("WARNING"); // present-but-unverifiable — WARN naming it, never a silent PASS
  });
});

describe("PEMBUBARAN_RULE_SET composition (tuned matrix — omission-by-config)", () => {
  it("contains exactly the tuned set; cap-table/jenis/doc-usage rules are ABSENT", () => {
    const codes = PEMBUBARAN_RULE_SET.map((r) => r.code);
    expect(new Set(codes).size).toBe(codes.length);
    expect(codes.length).toBe(18);
    for (const c of ["PT_ALREADY_DISSOLVED", "LIKUIDATOR_SURAT_COMPLETE", "LIKUIDATOR_KTP_MATCH", "PEMBUBARAN_KORAN", "RUPS_ATTENDANCE_QUORUM"]) {
      expect(codes).toContain(c);
    }
    for (const c of PEMBUBARAN_SHARED_RULE_CODES) expect(codes).toContain(c);
    for (const excluded of [
      "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", "REQUIRED_SUPPORTING_DOCS", "UPLOADED_DOC_UNUSED",
    ]) {
      expect(codes).not.toContain(excluded);
    }
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/rules-pembubaran.test.ts.
  • [ ] Minimal implementation A — backend/src/flow-engine/rules/pt-akta/rups-quorum.ts: parameterize the comparator (body untouched):
export function rupsQuorumRule(opts?: { quorumThreshold?: number; comparator?: ">" | ">=" }): Rule<PtAktaCtx> {
  const threshold = opts?.quorumThreshold ?? 0.5;
  const comparator = opts?.comparator ?? ">";

and inside the re-tier block replace if (ratio <= threshold) { with:

          // ">"  — quorum only ABOVE threshold (Ps.86(1) "lebih dari 1/2" class).
          // ">=" — quorum AT threshold is compliant (Ps.89(1) "paling sedikit 3/4":
          //        exactly 75% attendance satisfies it — Pembubaran's consumer).
          const belowQuorum = comparator === ">=" ? ratio < threshold : ratio <= threshold;
          if (belowQuorum) {

and the message line becomes:

            message = `Kehadiran RUPS ${present.toFixed(2)}% dari total ${total.toFixed(2)}% (kuorum legal ${comparator === ">=" ? "≥" : ">"}${pct}%)`;

Also update the doc comment's "Pembubaran passes 3/4 (Ps.89(1))" sentence to note it passes { quorumThreshold: 3/4, comparator: ">=" }.
- [ ] Minimal implementation B — create backend/src/flow-engine/rules/pembubaran/index.ts:

import { db } from "../../../lib/db";
import type { Rule, RuleResult } from "../../types";
import type { PembubaranCtx } from "../../context/pembubaran-context";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../../../services/surat-likuidator-extract";
import { PT_AKTA_RULE_SET, pickRules } from "../pt-akta/index";
import { rupsQuorumRule } from "../pt-akta/rups-quorum";
import { parseFlexibleDate } from "../laporan-rups/index";
import { normalizeName, tokenScore } from "../../../services/identity-matcher";

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 — PoC-local double-dissolution guard (async: ONE local-DB lookup, so the
 *  runner's Promise.all partition absorbs it). A COMPLETED PEMBUBARAN_PT for the
 *  same oldData.perseroan.id_perseroan means the dissolution is already FILED —
 *  COMPLETED persists through the verifikator stages, so a second filing while the
 *  first sits in the queue is equally a double-dissolution (PEMBUBARAN_PP precedent
 *  pp-pembubaran.ts:475; the submission IS the local dissolution record — no
 *  onFinalize hook needed, nothing invokes hooks.onFinalize today). Complements
 *  PERSEROAN_STATE, which only sees SABH's (laggier) view. Exact-equality type
 *  match — NEVER substring (PEMBUBARAN_PP collision). */
export const ptAlreadyDissolvedRule: Rule<PembubaranCtx> = {
  code: "PT_ALREADY_DISSOLVED",
  label: "PT Belum Pernah Dibubarkan (Registri Lokal)",
  async: true,
  run: async (ctx) => {
    const L = "PT Belum Pernah Dibubarkan (Registri Lokal)";
    const idPerseroan = ctx.input.oldData?.id_perseroan ?? null;
    if (idPerseroan == null)
      return rr("PT_ALREADY_DISSOLVED", 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: { id: ctx.submissionId },
      },
      select: { id: true },
    });
    if (prior)
      return rr("PT_ALREADY_DISSOLVED", L, "FAIL",
        `Pembubaran untuk PT ini sudah pernah diajukan (submission ${prior.id}) — pembubaran ganda tidak diizinkan`,
        { priorSubmissionId: prior.id });
    return rr("PT_ALREADY_DISSOLVED", L, "PASS", "Belum ada pembubaran tercatat untuk PT ini");
  },
};

/** WARNING — NEVER FAIL. Completeness of the 12 likuidator fields, NAMING every
 *  missing key (whole-flow-review warning 2: partially-verified sets must WARN
 *  naming the unchecked, never a clean PASS/silent SKIP). WARNING because OCR
 *  gaps are completable in review — telepon/email are often absent from the
 *  letter and SABH's wizard has them hand-typed today too. */
export const likuidatorSuratCompleteRule: Rule<PembubaranCtx> = {
  code: "LIKUIDATOR_SURAT_COMPLETE",
  label: "Kelengkapan Data Likuidator",
  severityFloor: "WARNING",
  run: (ctx) => {
    const L = "Kelengkapan Data Likuidator";
    if (!ctx.likuidator.hasDoc)
      return rr("LIKUIDATOR_SURAT_COMPLETE", L, "SKIPPED", "Surat penunjukan likuidator belum diunggah");
    const missing = LIKUIDATOR_EXTRACTED_FIELD_KEYS.filter((k) => !(ctx.likuidator.fields[k] ?? "").trim());
    if (missing.length > 0)
      return rr("LIKUIDATOR_SURAT_COMPLETE", L, "WARNING",
        `Data likuidator belum lengkap: ${missing.join(", ")} — lengkapi pada kartu Likuidator`, { missing });
    return rr("LIKUIDATOR_SURAT_COMPLETE", L, "PASS", "Seluruh 12 field likuidator terisi");
  },
};

/** FAIL on contradiction / WARNING on absence. likuidator_nama is scored against
 *  the WHOLE KTP pool with the identity-matcher scorer (normalizeName+tokenScore —
 *  the same tiers matchKtpToPeople uses: AUTO ≥0.8, plausible band ≥0.5):
 *    best ≥ 0.8            → PASS (incl. pengurus-doubling: an already-matched
 *                            pengurus KTP serves the likuidator too — spec open-
 *                            question 4 resolution: accepted).
 *    0.5 ≤ best < 0.8      → FAIL: a plausible-but-conflicting near-miss is more
 *                            likely the wrong person / a bad OCR read than a
 *                            coincidence — surface it hard; override exists.
 *    best < 0.5 / no KTPs  → WARNING "KTP likuidator belum diunggah" (absence is
 *                            reviewable, not a hard defect). */
export const likuidatorKtpMatchRule: Rule<PembubaranCtx> = {
  code: "LIKUIDATOR_KTP_MATCH",
  label: "KTP Likuidator Sesuai Surat Penunjukan",
  run: (ctx) => {
    const L = "KTP Likuidator Sesuai Surat Penunjukan";
    const nama = ctx.likuidator.fields.likuidator_nama;
    if (!nama?.trim())
      return rr("LIKUIDATOR_KTP_MATCH", L, "SKIPPED", "Nama likuidator belum terbaca dari surat penunjukan");
    const likTokens = normalizeName(nama);
    let best: { nama: string; score: number } | null = null;
    for (const [, ktp] of ctx.input.ktpFields) {
      if (!ktp.nama) continue;
      const score = tokenScore(likTokens, normalizeName(ktp.nama));
      if (!best || score > best.score) best = { nama: ktp.nama, score };
    }
    if (!best || best.score < 0.5)
      return rr("LIKUIDATOR_KTP_MATCH", L, "WARNING",
        `KTP likuidator "${nama}" belum diunggah — likuidator mungkin pengurus yang KTP-nya sudah cocok; mohon verifikasi manual`,
        { bestScore: best?.score ?? 0 });
    if (best.score < 0.8)
      return rr("LIKUIDATOR_KTP_MATCH", L, "FAIL",
        `KTP terdekat ("${best.nama}") tidak cocok penuh dengan nama likuidator "${nama}" (skor ${(best.score * 100).toFixed(0)}%) — kemungkinan salah orang atau salah baca OCR`,
        { bestNama: best.nama, bestScore: best.score });
    return rr("LIKUIDATOR_KTP_MATCH", L, "PASS",
      `KTP atas nama "${best.nama}" cocok dengan likuidator (skor ${(best.score * 100).toFixed(0)}%)`);
  },
};

/** WARNING — NEVER FAIL (severityFloor clamp + structural test). UUPT Ps.147(1):
 *  the LIKUIDATOR must announce the dissolution in a newspaper (and Berita Negara)
 *  within 30 days counted FROM the dissolution — a POST-DEED obligation, so at
 *  filing time the announcement may legitimately not exist yet. Do NOT copy
 *  PELEBURAN_KORAN_WINDOW's FAIL: that rule enforces Ps.127(2) PRE-RUPS notice, a
 *  different statute with opposite timing. Tiers: absent → WARNING; present but a
 *  date unreadable → WARNING naming it (warning-2 discipline — never a silent
 *  PASS); > deed+30d → WARNING (likuidator non-compliance signal, still not our
 *  gate); before the deed → WARNING (suspicious — likely a mis-filed RUPS-summons
 *  clipping); within [deed, deed+30d] → PASS. Advisory precedent:
 *  AKUISISI_KORAN_WINDOW / project_pp_nik_age_advisory. */
export const pembubaranKoranRule: Rule<PembubaranCtx> = {
  code: "PEMBUBARAN_KORAN",
  label: "Pengumuman Koran Pembubaran (Ps.147)",
  severityFloor: "WARNING",
  run: (ctx) => {
    const L = "Pengumuman Koran Pembubaran (Ps.147)";
    if (!ctx.koran.namaKoran && !ctx.koran.tanggalKoran)
      return rr("PEMBUBARAN_KORAN", L, "WARNING",
        "Belum ada bukti/keterangan pengumuman koran — Ps.147(1): kewajiban likuidator dalam 30 hari SEJAK pembubaran (boleh menyusul)");
    const koran = parseFlexibleDate(ctx.koran.tanggalKoran);
    const akta = parseFlexibleDate(ctx.input.aktaTanggal);
    if (!koran || !akta)
      return rr("PEMBUBARAN_KORAN", L, "WARNING",
        `Jendela 30 hari Ps.147 tidak dapat diverifikasi — ${!koran ? "tanggal pengumuman" : "tanggal akta"} tidak terbaca (perbaiki pada kartu Pengumuman Koran)`);
    const diffDays = Math.round((koran.getTime() - akta.getTime()) / 86_400_000);
    if (diffDays < 0)
      return rr("PEMBUBARAN_KORAN", L, "WARNING",
        `Pengumuman (${ctx.koran.tanggalKoran}) MENDAHULUI tanggal akta pembubaran — periksa apakah kliping yang diunggah adalah pengumuman Ps.147`, { diffDays });
    if (diffDays > 30)
      return rr("PEMBUBARAN_KORAN", L, "WARNING",
        `Pengumuman ${diffDays} hari setelah akta — melewati jendela 30 hari Ps.147(1) (sinyal keterlambatan likuidator, bukan penghalang pengajuan)`, { diffDays });
    return rr("PEMBUBARAN_KORAN", L, "PASS", `Pengumuman ${diffDays} hari setelah akta pembubaran (≤30, Ps.147(1))`);
  },
};

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

/** Wrapped shared rules (bodies bit-for-bit in cross-validator.ts). DELIBERATE
 *  EXCLUSIONS (do not "fix"): the whole cap-table/modal/KBLI group + the jenis
 *  machinery (a dissolution touches no cap table and selects no jenis) AND —
 *  adjudicated deviation from the spec §4.6 matrix — REQUIRED_SUPPORTING_DOCS +
 *  UPLOADED_DOC_UNUSED: their bodies are JENIS-scoped (cross-validator.ts:1235/
 *  :1742 key doc usage off effectiveJenis.nonpad), so for a jenis-less flow they
 *  emit noise-SKIPs at best and a FALSE WARNING at worst (a pembubaran's BA RUPS
 *  would be flagged "unused"). The required-doc surface is the ?type=pembubaran
 *  board set + LIKUIDATOR_SURAT_COMPLETE / PEMBUBARAN_KORAN presence tiers
 *  (peleburan-precedent exclusion class). Omission-by-config, guard-tested. */
export const PEMBUBARAN_SHARED_RULE_CODES = [
  "NIK_KTP_AKTA", "NAMA_NPWP_KTP", "KTP_COMPLETENESS", "NPWP_COMPLETENESS",
  "NAMA_PT_CONSISTENCY", "DOMISILI_NAMA", "NPWP_PERSEROAN_FUZZY",
  "CONTACT_INFO_COMPLETE", "PASSPORT_AKTA", "AKTA_DATE_WINDOW",
  "OLD_DATA_CONSISTENCY", "PERSEROAN_STATE", "NOTARIS_TERAKHIR",
] as const;

export const PEMBUBARAN_RULE_SET: Rule<PembubaranCtx>[] = [
  ptAlreadyDissolvedRule,
  likuidatorSuratCompleteRule,
  likuidatorKtpMatchRule,
  pembubaranKoranRule,
  // Ps.89(1) "paling sedikit 3/4" — the ≥ comparator (inherited deferred trigger);
  // WARNING tier by body design: attendance is heuristic name-matching, never a hard block.
  rupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" }),
  ...pickRules(PT_AKTA_RULE_SET, PEMBUBARAN_SHARED_RULE_CODES),
];
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/rules-pembubaran.test.ts. Then regression on the shared wrapper's existing consumers: bun test src/flow-engine/__tests__/rules-laporan-rups.test.ts src/flow-engine/__tests__/rules-pt-akta.test.ts src/flow-engine/__tests__/wrapper-fidelity.test.ts.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/rules/pt-akta/rups-quorum.ts backend/src/flow-engine/rules/pembubaran/index.ts backend/src/flow-engine/__tests__/rules-pembubaran.test.ts && git commit -m "feat(pembubaran): PEMBUBARAN_RULE_SET tuned matrix + rups-quorum >= comparator (Ps.89(1))"

Task 6: The real FlowConfig — registry entry + routeFamily union + subtype label row + dispatch/rematch proof

Registers pembubaranPtFlow (replacing the Task-1 stub), widens the RouteFamily union with "pembubaran" — an adjudicated DEVIATION from spec §4.4's routeFamily: 'perubahan': the spec predates the landed template, where every v2 tenant owns a thin route family (laporan-rups, peleburan) and the v1 routes/perubahan.ts fork stays untouched (Global Constraints "do not touch v1 forks"; the only perubahan route this flow uses — select-company — is type-agnostic and needs NO family membership. PERUBAHAN_FAMILY in perubahan.ts:33 is NOT widened). Adds the defensive AKTA_SUBTYPE_MAP label row (engine passes the raw subtype "perubahan", resolved by the identity rows; the label row guards direct-label calls — akuisisi precedent). Proves dispatch + the rematch wipe-class (reference_rematch_dispatch): a field edit must re-validate, not wipe.

Files
- backend/src/flow-engine/flows/pembubaran-pt.ts — Create
- backend/src/flow-engine/types.ts — Modify (RouteFamily += "pembubaran")
- backend/src/flow-engine/registry.ts — Modify (stub → real entry)
- backend/src/ocr/classifier.ts — Modify (label row)
- backend/src/flow-engine/__tests__/registry.test.ts — Modify (V2_SHIPPED += "PEMBUBARAN_PT")
- backend/src/flow-engine/__tests__/pembubaran-flow.test.ts — Create

Interfaces
- Produces: pembubaranPtFlow: FlowConfig<PembubaranCtx>; FLOW_REGISTRY.PEMBUBARAN_PT = pembubaranPtFlow; typesForRouteFamily("pembubaran") === ["PEMBUBARAN_PT"].

Steps

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

describe("pembubaranPtFlow — THIRD v2-generic registry tenant", () => {
  it("is registered (stub replaced) with the binding config shape", () => {
    expect(FLOW_REGISTRY.PEMBUBARAN_PT).toBe(pembubaranPtFlow);
    expect(pembubaranPtFlow.engine).toBe("v2-generic");
    expect(pembubaranPtFlow.primaryAkta?.classifiedType).toBe("AKTA_PEMBUBARAN");
    expect(pembubaranPtFlow.extraction.overrideByClassifiedType).toEqual({ AKTA_PEMBUBARAN: "perubahan" });
    expect(pembubaranPtFlow.extraction.focusedPasses).toEqual({
      SURAT_LIKUIDATOR: "surat-likuidator",
      BUKTI_PENGUMUMAN: "bukti-pengumuman", // SHARED — one map entry, never re-implemented
    });
    expect(pembubaranPtFlow.companyLookup).toBe(true); // SINGLE-source — warning 4
    expect(pembubaranPtFlow.requiredDocs).toEqual(["AKTA", "SURAT_LIKUIDATOR", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK"]);
    expect(pembubaranPtFlow.rematch).toEqual({
      checklistResetDocTypes: ["KTP", "NPWP", "SURAT_PERNYATAAN_DOMISILI"],
      standardPtMatchers: true,
      rupsAttendance: true,
    });
    expect(pembubaranPtFlow.routeFamily).toBe("pembubaran");
    expect(typesForRouteFamily("pembubaran")).toEqual(["PEMBUBARAN_PT"]);
    // block-with-override gate stays ON — explicit default pin (ledger minor: not.toBe('off') is weak)
    expect(pembubaranPtFlow.failGate).toBeUndefined();
    expect(pembubaranPtFlow.hooks?.mapChangeTypes).toBeUndefined(); // no jenis machinery by design
    expect(pembubaranPtFlow.hooks?.onFinalize).toBeUndefined();     // COMPLETED submissions ARE the local record
  });

  it("declares the 6 required approvable sections (submit-gate source of truth)", () => {
    const required = pembubaranPtFlow.sections.filter((s) => s.approvable && s.required).map((s) => s.key);
    expect(required).toEqual(["perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]);
    const koran = pembubaranPtFlow.sections.find((s) => s.key === "pengumuman_koran");
    expect(koran?.approvable).toBe(true);
    expect(koran?.required).toBe(false); // advisory-only content (Ps.147)
  });
});

describe("dispatch + rematch wipe-class proof (reference_rematch_dispatch)", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: ids } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  it("flowFor routes v2-generic; a queued re-match re-validates instead of wiping", async () => {
    expect(flowFor("PEMBUBARAN_PT").engine).toBe("v2-generic");
    const sub = await db.submission.create({
      data: { type: "PEMBUBARAN_PT", status: "READY", oldData: { perseroan: { nama: "PT X", id_perseroan: 991 } } },
    });
    ids.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_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    await queueReMatchAndValidate(sub.id); // serialized — resolves when the queued run finishes
    const results = await db.validationResult.findMany({ where: { submissionId: sub.id } });
    expect(results.length).toBeGreaterThan(0); // the tuned matrix persisted — NOT silently wiped
    expect(results.some((r) => r.ruleCode === "PEMBUBARAN_KORAN")).toBe(true);
    expect(results.some((r) => r.ruleCode === "MODAL_BUKTI_SETOR")).toBe(false); // tuned-out rows genuinely absent
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/pembubaran-flow.test.ts.
  • [ ] Minimal implementation A — backend/src/flow-engine/types.ts: widen the union:
export type RouteFamily =
  | "pendirian" | "perubahan" | "perbaikan" | "pp" | "peralihan-pp-pt" | "apostille"
  | "laporan-rups" | "peleburan" | "pembubaran" | "berakhirnya";
  • [ ] Minimal implementation B — create backend/src/flow-engine/flows/pembubaran-pt.ts:
import { defineFlow } from "../types";
import { buildPembubaranContext, type PembubaranCtx } from "../context/pembubaran-context";
import { PEMBUBARAN_RULE_SET } from "../rules/pembubaran/index";

/**
 * Pembubaran PT (SABH id_aksi_transaksi = 12; UUPT Ps.142-147) — the THIRD
 * engine:'v2-generic' FLOW_REGISTRY tenant (ship order: LAPORAN_RUPS_TAHUNAN →
 * PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT).
 *
 * NO cap-table diff: the review is a flat sectioned review against the registry
 * snapshot (deed + RUPS attendance + likuidator + koran + identities). The deed
 * rides the PERUBAHAN extractor (classification-override, akuisisi precedent);
 * SINGLE-source companyLookup (the engine's existing branch — never the
 * peleburan-coupled multi-source wiring). Likuidator = 12 ExtractedField rows on
 * the SURAT_LIKUIDATOR doc (focused pass built here); koran = the SHARED
 * BUKTI_PENGUMUMAN clipping pass (built in Peleburan) + deed-inline fallback.
 *
 * VERIFIKATOR-STAGED: routes/pembubaran.ts submit → COMPLETED +
 * VerifikasiPerubahan (MENUNGGU_VERIFIKATOR). Voter roster = oldData.pemegang_saham
 * via resolveVoterRoster's fallback (AKTA_PEMBUBARAN deliberately NOT in
 * ROSTER_AKTA_TYPES — the registry roster is the legal voter set for a
 * dissolution). FAIL gate = block-with-override (checkFailGate; default 'on').
 * PEMBUBARAN_PT ≠ PEMBUBARAN_PP — exact-equality checks only, everywhere.
 */
export const pembubaranPtFlow = defineFlow<PembubaranCtx>({
  type: "PEMBUBARAN_PT",
  engine: "v2-generic",
  primaryAkta: {
    classifiedType: "AKTA_PEMBUBARAN",
    missingError: "Akta Pembubaran tidak ditemukan pada submission",
    extractError: "Ekstraksi Akta Pembubaran gagal",
  },
  skipExtractionTypes: ["OTHER"],
  companyLookup: true, // single-source: nama_perseroan + ref_pendirian_nomor_sk (SK terakhir)
  applyContactInfo: true,
  extraction: {
    // Hybrid (akuisisi precedent): the dissolution deed's header/RUPS/penghadap
    // payload rides the perubahan schema — incl. tanggal_rups, notaris_pengganti
    // and the akta_kedua dual-deed block (Task 3 transformer mappings).
    overrideByClassifiedType: { AKTA_PEMBUBARAN: "perubahan" },
    focusedPasses: {
      SURAT_LIKUIDATOR: "surat-likuidator",   // built HERE (penunjukan shape ONLY — C2)
      BUKTI_PENGUMUMAN: "bukti-pengumuman",   // SHARED clipping extractor (built in Peleburan)
    },
  },
  buildContext: buildPembubaranContext,
  rules: PEMBUBARAN_RULE_SET,
  // DocumentType enum values (requiredDocs contract) — DOMISILI is the existing
  // enum the SURAT_PERNYATAAN_DOMISILI label maps to (types.ts erratum applies to
  // the CHECKLIST vocabulary below, not here).
  requiredDocs: ["AKTA", "SURAT_LIKUIDATOR", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK"],
  sections: [
    { key: "perseroan",        label: "Data Perseroan (SABH)",                 approvable: true,  required: true },
    { key: "akta_pembubaran",  label: "Akta Pembubaran",                       approvable: true,  required: true },
    { key: "rups_kehadiran",   label: "RUPS & Kehadiran Pemegang Saham",       approvable: true,  required: true },
    { key: "likuidator",       label: "Likuidator",                            approvable: true,  required: true },
    // approvable but NOT required — advisory-only content (Ps.147 post-deed obligation)
    { key: "pengumuman_koran", label: "Pengumuman Koran",                      approvable: true,  required: false },
    { key: "identitas",        label: "Dokumen Identitas (KTP/NPWP)",          approvable: true,  required: true },
    { key: "data_kontak",      label: "Data Kontak",                           approvable: true,  required: true },
    { key: "validasi",         label: "Hasil Validasi",                        approvable: false, required: false },
  ],
  rematch: {
    // CHECKLIST-LABEL vocabulary (types.ts:31-47 erratum) — never the Prisma enum.
    // No SURAT_LIKUIDATOR/BUKTI_PENGUMUMAN rows: the checklist registers no such
    // rows (spec-sketch deviation — there is nothing to reset for them).
    checklistResetDocTypes: ["KTP", "NPWP", "SURAT_PERNYATAAN_DOMISILI"],
    standardPtMatchers: true,
    rupsAttendance: true, // re-runs matchRupsAttendance incl. the registry-roster fallback (Task 3)
  },
  hooks: {
    afterAktaExtraction: async (a) => {
      const { runPembubaranDeedPasses } = await import("../../services/akta-pembubaran-deed-passes");
      await runPembubaranDeedPasses(a);
    },
    // onFinalize deliberately ABSENT: nothing invokes hooks.onFinalize yet, and
    // PT_ALREADY_DISSOLVED reads COMPLETED PEMBUBARAN_PT submissions directly —
    // the submission IS the local dissolution record (pp-pembubaran.ts:475 pattern).
  },
  routeFamily: "pembubaran",
  labels: { akta: "Akta Pembubaran", flow: "Pembubaran PT" },
});
  • [ ] Minimal implementation C — backend/src/flow-engine/registry.ts: import pembubaranPtFlow and replace the stub line with:
  PEMBUBARAN_PT: pembubaranPtFlow, // THIRD v2-generic tenant (single-source lookup; verifikator-staged)
  • [ ] Minimal implementation D — backend/src/ocr/classifier.ts: in AKTA_SUBTYPE_MAP, after the AKTA_PELEBURAN row:
  // Hybrid: pembubaran rides the PERUBAHAN extractor (deed header + RUPS +
  // penghadap + akta_kedua dual-deed block; spec §4.3). Defensive label row —
  // the engine passes the raw subtype, resolved by the identity rows below.
  AKTA_PEMBUBARAN: "perubahan",
  • [ ] Minimal implementation E — backend/src/flow-engine/__tests__/registry.test.ts: add "PEMBUBARAN_PT" to the V2_SHIPPED set.
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/pembubaran-flow.test.ts src/flow-engine/__tests__/registry.test.ts src/flow-engine/__tests__/dispatch-routing.test.ts src/flow-engine/__tests__/pembubaran-schema.test.ts.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/flows/pembubaran-pt.ts backend/src/flow-engine/types.ts backend/src/flow-engine/registry.ts backend/src/ocr/classifier.ts backend/src/flow-engine/__tests__/registry.test.ts backend/src/flow-engine/__tests__/pembubaran-flow.test.ts && git commit -m "feat(pembubaran): real FlowConfig registered — third v2-generic tenant + rematch proof"

Task 7: Routing + gating — BUILT flip, Berakhirnya doc-set predicate, ladder rung BELOW Akuisisi, doc-type maps, labels endpoint (THE LAUNCH SWITCH)

Flips BUILT_AKTA_TXN_TYPES += "pembubaran" — from this task on, an AKTA_PEMBUBARAN upload routes into the flow, so everything before must already work. Also: the appoints-vs-accepts classifier prompt cue; the BERAKHIRNYA_PACKAGE predicate (owned ONCE in submissions.ts; first consumer HERE) wired into BOTH detectUnsupportedAktaType (the create-route supported:false gate — the package must surface as "Terdeteksi: Berakhirnya Status Badan Hukum (belum didukung)", never silently route) AND inferSubmissionType (belt-and-braces rung guard); the pembubaran rung BELOW Akuisisi (canonical ladder — note this is the OPPOSITE of peleburan's rung placement); mapToDocumentType/getProcessingOrder for SURAT_LIKUIDATOR (without the map line it falls to the "AKTA" fallback and the focused pass never fires); the ?type=pembubaran board doc-set; and the stale-fixture updates (pembubaran was the canonical unbuilt case — becomes berakhirnya).

Files
- backend/src/services/akta-txn-classifier.ts — Modify (BUILT flip + SYSTEM_PROMPT cue)
- backend/src/routes/submissions.ts — Modify (predicate + gate + rung + maps + return union)
- backend/src/routes/klasifikasi.ts — Modify (PEMBUBARAN_DOC_TYPES + labels endpoint + LABEL_MAP spread)
- backend/src/routes/__tests__/pembubaran-routing.test.ts — Create
- backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts — Modify (stale fixture)
- backend/src/routes/__tests__/laporan-rups-routing.test.ts — Modify (stale fixture)

Interfaces
- Produces: BERAKHIRNYA_PACKAGE(files: InferableFile[]): boolean (exported from submissions.ts — the Berakhirnya spec quotes it identically, C5); inferSubmissionType return union += "PEMBUBARAN_PT"; mapToDocumentType("SURAT_LIKUIDATOR") === "SURAT_LIKUIDATOR"; getProcessingOrder("SURAT_LIKUIDATOR") === 4; GET /api/klasifikasi/labels?type=pembubaran.

Steps

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

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

describe("PEMBUBARAN_PT routing + gating (LAUNCH SWITCH)", () => {
  it("pembubaran is BUILT; berakhirnya stays gated", () => {
    expect(BUILT_AKTA_TXN_TYPES.has("pembubaran")).toBe(true);
    expect(BUILT_AKTA_TXN_TYPES.has("berakhirnya")).toBe(false);
    expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN")])).toBeNull();
    expect(detectUnsupportedAktaType([f("AKTA_BERAKHIRNYA")])).toBe("berakhirnya");
  });

  it("bare AKTA_PEMBUBARAN → PEMBUBARAN_PT (incl. via userOverride — confident-wrong guard)", () => {
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("KTP")])).toBe("PEMBUBARAN_PT");
    // GPU confidently-wrong AKTA_PERUBAHAN, operator re-mapped → override wins
    expect(inferSubmissionType([f("AKTA_PERUBAHAN", "AKTA_PEMBUBARAN")])).toBe("PEMBUBARAN_PT");
  });

  it("ladder: BELOW Peleburan/Akuisisi, ABOVE Perubahan/LaporanRups; Perbaikan wins all", () => {
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_PELEBURAN")])).toBe("PELEBURAN_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_AKUISISI")])).toBe("AKUISISI_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_PERUBAHAN")])).toBe("PEMBUBARAN_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_RUPS_TAHUNAN")])).toBe("PEMBUBARAN_PT");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("SURAT_PERNYATAAN_PERBAIKAN")])).toBe("PERBAIKAN_DATA_PT");
    // regressions: existing rungs untouched
    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("BERAKHIRNYA_PACKAGE: the 6-doc package gates AWAY; a single Ps.147 clipping does NOT", () => {
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_BERAKHIRNYA")])).toBe(true);
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("LAPORAN_LIKUIDASI")])).toBe(true);
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("SURAT_PERMOHONAN")])).toBe(true);
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN_152")])).toBe(true);
    // ≥2 docs mapping to the CARRIER DocumentType — counted via mapToDocumentType,
    // incl. one arriving as a userOverride re-map (never the raw label string)
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN"), f("OTHER", "BUKTI_PENGUMUMAN")])).toBe(true);
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN")])).toBe(false); // single clipping
    expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("KTP"), f("NPWP")])).toBe(false);
    // wired into the gate + the rung
    expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN"), f("BUKTI_PENGUMUMAN")])).toBe("berakhirnya");
    expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN")])).toBe("PEMBUBARAN_PT");
  });

  it("create route gates a Berakhirnya package with supported:false (never a mis-routed Submission)", async () => {
    const res = await app.request("/api/submissions", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        files: [
          { fileId: "x1", classification: "AKTA_PEMBUBARAN", confidence: 0.9 },
          { fileId: "x2", classification: "BUKTI_PENGUMUMAN", confidence: 0.9 },
          { fileId: "x3", classification: "BUKTI_PENGUMUMAN", confidence: 0.9 },
        ],
      }),
    });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { supported?: boolean; detectedType?: string };
    expect(body.supported).toBe(false);
    expect(body.detectedType).toBe("berakhirnya");
  });

  it("doc-type maps: SURAT_LIKUIDATOR carrier + processing order; AKTA_PEMBUBARAN → AKTA", () => {
    expect(mapToDocumentType("SURAT_LIKUIDATOR")).toBe("SURAT_LIKUIDATOR");
    expect(getProcessingOrder("SURAT_LIKUIDATOR")).toBe(4);
    expect(mapToDocumentType("AKTA_PEMBUBARAN")).toBe("AKTA"); // startsWith("AKTA_") rule — already true
    expect(getProcessingOrder("AKTA_PEMBUBARAN")).toBe(0);
  });

  it("labels endpoint serves the pembubaran doc set", async () => {
    const res = await app.request("/api/klasifikasi/labels?type=pembubaran");
    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_PEMBUBARAN")?.required).toBe(true);
    expect(byKey.get("AKTA_PEMBUBARAN")?.maxCount).toBe(1);
    expect(byKey.get("SURAT_LIKUIDATOR")?.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(true);
    expect(byKey.get("DATA_KONTAK")?.required).toBe(true);
    expect(byKey.get("BERITA_ACARA_RUPS")?.required).toBe(false); // recommended
    expect(byKey.get("BUKTI_PENGUMUMAN")?.required).toBe(false);  // optional (Ps.147 may post-date filing)
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/pembubaran-routing.test.ts.
  • [ ] Minimal implementation A — backend/src/services/akta-txn-classifier.ts:
    1. BUILT_AKTA_TXN_TYPES gains:
  "pembubaran",           // LAUNCH SWITCH — PEMBUBARAN_PT shipped (third v2-generic tenant)
  1. In SYSTEM_PROMPT, replace pembubaran (the PT is dissolved/liquidated), + berakhirnya (the PT's legal personhood ends after liquidation), with the content-level disambiguation cue (spec §4.2):
  "pembubaran (the deed DISSOLVES the PT and APPOINTS a likuidator — liquidation BEGINS), " +
  "berakhirnya (the deed ACCEPTS the likuidator's final accounting/pertanggungjawaban and declares the badan hukum ended — liquidation ENDS), " +
  • [ ] Minimal implementation B — backend/src/routes/submissions.ts:
    1. Directly above detectUnsupportedAktaType, add the predicate (uses the file's existing InferableFile + mapToDocumentType):
/**
 * Berakhirnya doc-set predicate (classifier design §6.2) — owned ONCE here;
 * the Berakhirnya spec quotes it identically (C5). Pembubaran (id_aksi 12)
 * STARTS liquidation; Berakhirnya (id_aksi 36) ENDS it — the deeds overlap, the
 * PACKAGE separates them: laporan akhir likuidasi / surat permohonan / the
 * Pasal 149+152 pengumuman PAIR. The count clause matches the CARRIER
 * DocumentType via mapToDocumentType (never the raw label string) so it keeps
 * firing after Berakhirnya introduces the _149/_152 fine labels. The
 * LAPORAN_LIKUIDASI / SURAT_PERMOHONAN / BUKTI_PENGUMUMAN_152 clauses activate
 * when that flow adds those labels — written now so it only appends names.
 * A SINGLE clipping (the Ps.147 dissolution announcement) does NOT trip it.
 */
export const BERAKHIRNYA_PACKAGE = (files: InferableFile[]): boolean => {
  const eff = (f: InferableFile) => f.userOverride ?? f.classification;
  return (
    files.some((fl) => eff(fl) === "AKTA_BERAKHIRNYA") ||
    (files.some((fl) => eff(fl) === "AKTA_PEMBUBARAN") &&
      (files.some((fl) => eff(fl) === "LAPORAN_LIKUIDASI" || eff(fl) === "SURAT_PERMOHONAN") ||
        files.some((fl) => eff(fl) === "BUKTI_PENGUMUMAN_152") ||
        files.filter((fl) => mapToDocumentType(eff(fl)) === "BUKTI_PENGUMUMAN").length >= 2))
  );
};

NOTE: mapToDocumentType("BUKTI_PENGUMUMAN_152") currently hits the "AKTA" fallback — the explicit BUKTI_PENGUMUMAN_152 clause covers it until Berakhirnya maps the fine labels; do NOT add the fine-label mapping here.
2. detectUnsupportedAktaType — package check FIRST:

export function detectUnsupportedAktaType(files: InferableFile[]): AktaTxnType | null {
  // Berakhirnya doc-set predicate runs FIRST: a Berakhirnya PACKAGE must gate to
  // "belum didukung" even though its AKTA_PEMBUBARAN deed label is built —
  // NEVER silently routed into PEMBUBARAN_PT (spec §4.2).
  if (BERAKHIRNYA_PACKAGE(files)) return "berakhirnya";
  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 | "PEMBUBARAN_PT", update the precedence doc-comment to Perbaikan > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > Pendirian PP > Pendirian PT (built rungs of the canonical ladder), and insert BETWEEN the AKTA_AKUISISI and AKTA_PERUBAHAN rungs:
  // BELOW Akuisisi/Peleburan, ABOVE Perubahan (canonical ladder — Pembubaran sits
  // BELOW Akuisisi, opposite of Peleburan's rung). Guarded by the Berakhirnya
  // doc-set predicate — the create-route unsupported gate fires first; this guard
  // is belt-and-braces so the rung can never steal a Berakhirnya package.
  if (files.some((f) => eff(f) === "AKTA_PEMBUBARAN") && !BERAKHIRNYA_PACKAGE(files)) return "PEMBUBARAN_PT";
  1. mapToDocumentType — add next to the BUKTI_PENGUMUMAN line:
  if (classification === "SURAT_LIKUIDATOR") return "SURAT_LIKUIDATOR";
  1. getProcessingOrder — add next to the domisili line (order 4 — after the deed + cards, spec §4.5):
  if (classification === "SURAT_LIKUIDATOR") return 4;
  • [ ] Minimal implementation C — backend/src/routes/klasifikasi.ts:
    1. After PELEBURAN_DOC_TYPES, add (spec §4.6 table + akuisisi aa4551d precedent — identity/address/kontak REQUIRED so cross-validation always has inputs):
const PEMBUBARAN_DOC_TYPES: DocTypeLabel[] = [
  { key: "AKTA_PEMBUBARAN", displayName: "Akta Pembubaran", required: true, maxCount: 1 },
  { key: "SURAT_LIKUIDATOR", displayName: "Surat Penunjukan Likuidator", required: true, maxCount: 1, description: "Wajib — blok likuidator wajib pada wizard SABH (nama, surat, alamat, kontak, dasar penunjukan)" },
  { key: "KTP", displayName: "KTP", required: true, description: "Wajib — identitas pengurus/penghadap + likuidator (cross-validation)" },
  { key: "NPWP", displayName: "NPWP Pengurus Perseroan", required: true, description: "Wajib — verifikasi NPWP pengurus (cross-validation)" },
  { key: "SURAT_PERNYATAAN_DOMISILI", displayName: "Surat Pernyataan Domisili", required: true, maxCount: 1, description: "Wajib — verifikasi alamat/kedudukan perseroan (cross-validation)" },
  { key: "DATA_KONTAK", displayName: "Data Kontak Pengurus dan Pemegang Saham", required: true, description: "Wajib — telepon, email, NPWP pengurus & pemegang saham (cross-validation)" },
  { key: "BERITA_ACARA_RUPS", displayName: "Berita Acara RUPS", required: false, description: "Direkomendasikan — akta dapat berupa PKR yang memuat RUPS, atau BA menjadi akta kedua" },
  { key: "BUKTI_PENGUMUMAN", displayName: "Bukti Pengumuman Koran", required: false, maxCount: 1, description: "Opsional — pengumuman Ps.147 adalah kewajiban likuidator 30 hari SEJAK pembubaran (boleh menyusul); ≥2 kliping menandakan paket Berakhirnya" },
  { key: "PASSPORT", displayName: "Paspor", required: false, description: "Untuk pengurus/likuidator WNA (alternatif KTP)" },
  { key: "KITAS", displayName: "KITAS / KITAP", required: false, description: "Untuk pengurus/likuidator WNA berdomisili di Indonesia" },
  { key: "OTHER", displayName: "Dokumen lain bukan persyaratan", required: false, description: "Misal penetapan pengadilan pada jalur pembubaran pengadilan (tidak diekstraksi fase 1)" },
];
  1. Add PEMBUBARAN_DOC_TYPES to the LABEL_MAP spread array.
  2. Labels endpoint: add if (type === "pembubaran") return c.json(PEMBUBARAN_DOC_TYPES); and extend the route comment's type list.
    - [ ] Stale fixtures (the canonical unbuilt example moves to berakhirnya):
    - backend/src/routes/__tests__/laporan-rups-routing.test.ts:14: expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN")])).toBe("pembubaran")expect(detectUnsupportedAktaType([f("AKTA_BERAKHIRNYA")])).toBe("berakhirnya"); // others stay gated. Also fix the stale describe-title ladder text in that file if it enumerates the ladder (ledger minor).
    - backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts:35-36: the "still unbuilt" assertion flips to AKTA_BERAKHIRNYA/berakhirnya (update the comment: "berakhirnya is still unbuilt (pembubaran became built once its flow shipped)"). Any test in that file asserting a pembubaran refine result keeps its label expectations (AKTA_PEMBUBARAN unchanged) — only supported-style assertions flip to true.
    - [ ] Run GREEN: cd backend && bun test src/routes/__tests__/pembubaran-routing.test.ts src/routes/__tests__/klasifikasi-akta-txn.test.ts src/routes/__tests__/laporan-rups-routing.test.ts src/routes/__tests__/peleburan-routing.test.ts src/routes/__tests__/akuisisi-routing.test.ts. Expected: all pass (peleburan/akuisisi rungs 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__/pembubaran-routing.test.ts backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts backend/src/routes/__tests__/laporan-rups-routing.test.ts && git commit -m "feat(pembubaran): LAUNCH SWITCH — BUILT flip + Berakhirnya doc-set predicate + ladder rung below akuisisi"

Task 8: Scoped SURAT_LIKUIDATOR re-check pass (klasifikasi endpoint + focused 3B tail)

The GPU classifier (22 labels, frozen w.r.t. new labels) will NOT abstain on a surat likuidator — it confidently mislabels (likely SURAT_PERNYATAAN_*) ABOVE the 0.6 gate, so the existing low-confidence refineSupportingLabel tier never sees it, and a missed SURAT_LIKUIDATOR silently kills the likuidator extraction + KTP cross-check (the flow's definitional net-new). Spec §4.5: a scoped re-check pass — deterministic tiers first (suggestByKeyword already owns the C2 penunjukan-vs-laporan distinction at 0.85; plus a /\bLIKUIDATOR\b/ title-window net), then a focused 3B yes/no tail for OCR-mangled headings. Suggestion-only, NEVER auto-assigned — surfaced through the existing amber suggestion-chip + re-map mechanism (ClassificationResult.suggestion, built by Peleburan). Scope: the FRONTEND gates the call to sets containing an AKTA_PEMBUBARAN (Task 11 wires the trigger), so other flows' classifications are never disturbed.

Files
- backend/src/services/supporting-doc-classifier.ts — Modify (suggestSuratLikuidator + isSuratPenunjukanLikuidatorViaLlm)
- backend/src/routes/klasifikasi.ts — Modify (POST /recheck-likuidator)
- backend/src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts — Create

Interfaces
- Produces: suggestSuratLikuidator(rawText: string): Promise<boolean>; POST /api/klasifikasi/recheck-likuidator body { fileIds: string[] }{ suggestions: { fileId: string; docType: "SURAT_LIKUIDATOR"; displayName: string }[] }.

Steps

  • [ ] Write failing test. Create backend/src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts:
import { describe, it, expect, afterEach } from "bun:test";
import { suggestSuratLikuidator } from "../../services/supporting-doc-classifier";
import { app } from "../../index";

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("suggestSuratLikuidator (scoped re-check tiers — spec §4.5)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("deterministic title rule: SURAT PENUNJUKAN LIKUIDATOR → true (no LLM call)", async () => {
    expect(await suggestSuratLikuidator("SURAT PENUNJUKAN LIKUIDATOR\nNomor 017")).toBe(true);
  });

  it("C2 distinction: a LAPORAN/PERTANGGUNGJAWABAN LIKUIDASI shape → false (that is LAPORAN_LIKUIDASI)", async () => {
    expect(await suggestSuratLikuidator("LAPORAN AKHIR LIKUIDASI PT X")).toBe(false);
    expect(await suggestSuratLikuidator("PERTANGGUNGJAWABAN LIKUIDATOR atas hasil likuidasi")).toBe(false);
  });

  it("title-window net: LIKUIDATOR in the first 800 chars without laporan-shape → true", async () => {
    expect(await suggestSuratLikuidator("PENUNJUKAN LIKUIDATOR PERSEROAN\ndengan ini menunjuk BUDI")).toBe(true);
  });

  it("3B tail: mangled heading, 'likuidator' only deep in the body → LLM decides; outage → false", async () => {
    const body = "S U R A T\n(judul tidak terbaca)\n" + "x".repeat(900) + "\nmenunjuk Saudara BUDI selaku likuidator Perseroan";
    process.env.CLEANUP_LLM_URL = "http://mock";
    let restore = mockLlm({ is_surat_penunjukan_likuidator: true });
    try { expect(await suggestSuratLikuidator(body)).toBe(true); } finally { restore(); }
    restore = mockLlm(null, 500);
    try { expect(await suggestSuratLikuidator(body)).toBe(false); } finally { restore(); }
    // grounding-by-construction: no "likuidator" anywhere → never even asks the LLM
    expect(await suggestSuratLikuidator("SURAT PERNYATAAN DOMISILI PERUSAHAAN")).toBe(false);
  });
});

describe("POST /api/klasifikasi/recheck-likuidator", () => {
  it("returns empty suggestions for unknown fileIds (best-effort, no error)", async () => {
    const res = await app.request("/api/klasifikasi/recheck-likuidator", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ fileIds: ["nonexistent-1", "nonexistent-2"] }),
    });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ suggestions: [] });
  });

  it("rejects a missing/invalid body shape gracefully", async () => {
    const res = await app.request("/api/klasifikasi/recheck-likuidator", { method: "POST" });
    expect(res.status).toBe(200);
    expect(await res.json()).toEqual({ suggestions: [] });
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts.
  • [ ] Minimal implementation A — backend/src/services/supporting-doc-classifier.ts, append:
/** Focused 3B yes/no for the SURAT_LIKUIDATOR re-check tail (spec §4.5): the
 *  heading is OCR-mangled but the body appoints a likuidator. Callers only invoke
 *  when /likuidator/i appears in the OCR text (grounding by construction). Outage/
 *  ambiguity → false. NEVER auto-accepted — proposal only. */
export async function isSuratPenunjukanLikuidatorViaLlm(rawText: string): Promise<boolean> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  if (!url || !rawText.trim()) return false;
  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. Answer whether it is a SURAT PENUNJUKAN " +
              "LIKUIDATOR — a letter APPOINTING a liquidator for a PT dissolution (header + appointee identity/address/" +
              "contact block). It is NOT one if it is a laporan akhir likuidasi / pertanggungjawaban likuidator " +
              "(the liquidator's final accounting) or any other document. Return ONLY JSON {is_surat_penunjukan_likuidator}.",
          },
          { role: "user", content: rawText.slice(0, 4000) },
        ],
        guided_json: {
          type: "object",
          properties: { is_surat_penunjukan_likuidator: { type: "boolean" } },
          required: ["is_surat_penunjukan_likuidator"],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return false;
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return false;
    return (JSON.parse(m[0]) as { is_surat_penunjukan_likuidator?: unknown }).is_surat_penunjukan_likuidator === true;
  } catch (err) {
    console.warn("[supporting-doc-classifier] likuidator 3B tail failed:", err);
    return false;
  }
}

/** SCOPED SURAT_LIKUIDATOR re-check (spec §4.5) — the frontend gates invocation
 *  to sets containing an AKTA_PEMBUBARAN. Tiers: (1) suggestByKeyword (owns the
 *  C2 penunjukan-vs-laporan distinction — a DIFFERENT confident keyword shape
 *  vetoes); (2) /\bLIKUIDATOR\b/ in the 800-char title window without a laporan
 *  shape; (3) the focused 3B tail when "likuidator" appears only deeper in the body. */
export async function suggestSuratLikuidator(rawText: string): Promise<boolean> {
  const kw = suggestByKeyword(rawText);
  if (kw?.docType === "SURAT_LIKUIDATOR") return true;
  if (kw) return false; // a different confident shape (LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN, …)
  const head = (rawText || "").slice(0, 800);
  if (/\bLIKUIDATOR\b/i.test(head) && !/(LAPORAN|PERTANGGUNGJAWABAN)/i.test(head)) return true;
  if (/likuidator/i.test(rawText)) return isSuratPenunjukanLikuidatorViaLlm(rawText);
  return false;
}
  • [ ] Minimal implementation B — backend/src/routes/klasifikasi.ts: import suggestSuratLikuidator and add before the labels endpoint:
// POST /recheck-likuidator — SCOPED re-check pass (spec §4.5). The GPU classifier
// has no surat-likuidator label and will NOT abstain (confident-wrong above the
// 0.6 gate), so the low-confidence refine tier never fires; a missed
// SURAT_LIKUIDATOR silently kills the likuidator extraction + KTP cross-check.
// The FRONTEND calls this only when the staged set contains an AKTA_PEMBUBARAN
// (scope — other flows' classifications are never disturbed). Suggestion-only:
// NEVER auto-assigns; applied through the existing re-map mechanism.
klasifikasi.post("/recheck-likuidator", async (c) => {
  const body = await c.req.json<{ fileIds?: string[] }>().catch(() => ({}) as { fileIds?: string[] });
  const fileIds = Array.isArray(body.fileIds) ? body.fileIds.slice(0, 20) : [];
  const suggestions: { fileId: string; docType: "SURAT_LIKUIDATOR"; displayName: string }[] = [];
  for (const fileId of fileIds) {
    try {
      const metaPath = path.join(TEMP_DIR, `${fileId}.json`);
      if (!fs.existsSync(metaPath)) continue;
      const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
      if (!fs.existsSync(meta.storedPath)) continue;
      const layout = await getLayoutProvider("paddleocr");
      const ocr = await layout.extractTextWithWords(fs.readFileSync(meta.storedPath), { unwarp: false, orient: false });
      if (await suggestSuratLikuidator(ocr.rawText ?? "")) {
        suggestions.push({ fileId, docType: "SURAT_LIKUIDATOR", displayName: getDisplayName("SURAT_LIKUIDATOR") });
      }
    } catch { /* best-effort per file — a failed OCR never fails the batch */ }
  }
  return c.json({ suggestions });
});
  • [ ] Run GREEN: cd backend && bun test src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/services/supporting-doc-classifier.ts backend/src/routes/klasifikasi.ts backend/src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts && git commit -m "feat(pembubaran): scoped SURAT_LIKUIDATOR re-check pass (suggestion-only, never auto-assigned)"

Task 9: Route family routes/pembubaran.ts — review-data, likuidator/kehadiran/koran edits, approvals, submit → VERIFIKATOR

The thin route family (peleburan.ts mirror, minus everything multi-source). Company selection deliberately rides the EXISTING type-agnostic POST /api/perubahan/select-company/:submissionId (its v2 branch resumes continueFlow — no new select route, proven by test). Every mutating endpoint carries the CANONICAL guardReady envelope — including submit (tightens the peleburan submit-arm text; Global Constraints). READY-state management surfaces (whole-flow-review warning 1): the likuidator bulk-fill PUT /likuidator (the safety net when the focused pass produced ZERO rows — empty rawText — so the generic per-field confirm would P2025), the kehadiran editor PUT /kehadiran (deed-roster AND registry-roster paths), and the koran editor PUT /koran — all live on the review page in READY, none gated behind a status-locked dialog. Per-field likuidator confirm/edit rides the generic /api/submissions/... field endpoints (ExtractedField rows — spec §6, confirm/bbox for free).

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

Interfaces
- Produces (all under /api/pembubaran):
- GET /submissions/:submissionId/review-data — composite payload (submission incl. kontak, perseroan snapshot, akta fields + PS kehadiran, likuidator 12 rows, kehadiran block, koran, documents, validationResults, sections, sectionApprovals)
- PUT /submissions/:submissionId/likuidator — bulk upsert of canonical likuidator fieldKeys (MANUAL, confidence 1) — READY only
- PUT /submissions/:submissionId/kehadiran — attendance ticks, deed-roster or registry-roster path — READY only
- PUT /submissions/:submissionId/koran — canonical koran keys, clipping-doc-first target — READY only
- POST /submissions/:submissionId/sections/:sectionKey/approve|unapprove — READY only
- POST /submissions/:submissionId/submit — staged submit (409 canonical / 400 SECTIONS_UNAPPROVED / 422 VALIDATION_FAILED / COMPLETED + VerifikasiPerubahan)

Steps

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

describe("pembubaran route family", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    // submit seeds VerifikasiPerubahan + a VerifikasiAuditEvent; the latter is
    // DB-trigger append-only (installAuditTrailGuards), so drop audit rows first
    // under session_replication_role=replica (peleburan-routes.test.ts pattern).
    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 seed(status: SubmissionStatus = "READY", opts: { likuidatorRows?: boolean; clipping?: boolean; registryKehadiran?: boolean; approveAll?: boolean; failRow?: boolean } = {}) {
    const sub = await db.submission.create({
      data: {
        type: "PEMBUBARAN_PT", status,
        teleponPerseroan: "0215550001", emailPerseroan: "corp@majumundur.co.id",
        oldData: { perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 555, status_transaksi: 1 }, pemegang_saham: [{ nama: "BUDI SANTOSO", persentase: 100 }] },
        ...(opts.registryKehadiran ? { rupsKehadiranRegistry: [{ nama: "BUDI SANTOSO", persentase: 100, kehadiran: null }] } : {}),
      },
    });
    ids.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_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    await db.extractedField.createMany({
      data: [
        { documentId: akta.id, fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR", confidence: 0.9, status: "EDITABLE" },
        { documentId: akta.id, fieldKey: "nomor_akta", value: "5", confidence: 0.9, status: "EDITABLE" },
        { documentId: akta.id, fieldKey: "tanggal_akta", value: "2026-04-30", confidence: 0.9, status: "EDITABLE" },
      ],
    });
    const lik = await db.document.create({
      data: { filename: "spl.pdf", originalName: "spl.pdf", fileSize: 0, filePath: "/tmp/s.pdf", documentType: "SURAT_LIKUIDATOR", status: "VERIFICATION_READY" },
    });
    docIds.push(lik.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: lik.id, classifiedType: "SURAT_LIKUIDATOR", confidence: 1, processingOrder: 4, extractionStatus: "DONE" },
    });
    if (opts.likuidatorRows) {
      await db.extractedField.create({
        data: { documentId: lik.id, fieldKey: "likuidator_nama", value: "BUDI HARTONO", confidence: 0.75, status: "EDITABLE" },
      });
    }
    if (opts.clipping) {
      const clip = await db.document.create({
        data: { filename: "k.pdf", originalName: "k.pdf", fileSize: 0, filePath: "/tmp/k.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY" },
      });
      docIds.push(clip.id);
      await db.submissionDocument.create({
        data: { submissionId: sub.id, documentId: clip.id, classifiedType: "BUKTI_PENGUMUMAN", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
      });
    }
    if (opts.approveAll) {
      for (const key of ["perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]) {
        await db.perubahanSectionApproval.create({ data: { submissionId: sub.id, sectionKey: key, approved: true, approvedAt: new Date() } });
      }
    }
    if (opts.failRow) {
      await db.validationResult.create({
        data: { submissionId: sub.id, ruleCode: "LIKUIDATOR_KTP_MATCH", ruleLabel: "KTP Likuidator Sesuai Surat Penunjukan", status: "FAIL", message: "seeded" },
      });
    }
    return { sub, akta, lik };
  }

  const json = (body: unknown) => ({ method: "PUT" as const, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });

  it("review-data returns the composite payload (perseroan snapshot, likuidator 12 keys, kehadiran, koran, sections)", async () => {
    const { sub } = await seed("READY", { likuidatorRows: true, registryKehadiran: true });
    const res = await app.request(`/api/pembubaran/submissions/${sub.id}/review-data`);
    expect(res.status).toBe(200);
    const body = (await res.json()) as Record<string, any>;
    expect(body.submission.type).toBe("PEMBUBARAN_PT");
    expect(body.perseroan).toEqual({ nama: "PT MAJU MUNDUR", statusTransaksi: 1, statusPerseroan: null });
    expect(Object.keys(body.likuidator.fields).length).toBe(12);
    expect(body.likuidator.fields.likuidator_nama.value).toBe("BUDI HARTONO");
    expect(body.likuidator.fields.likuidator_email.value).toBeNull(); // absent key still present, null row
    expect(body.kehadiran).toEqual({ source: "registry", rows: [{ nama: "BUDI SANTOSO", persentase: 100, kehadiran: null }] });
    expect(body.kontak).toEqual({ teleponPerseroan: "0215550001", emailPerseroan: "corp@majumundur.co.id" });
    expect(body.sections.some((s: { key: string }) => s.key === "likuidator")).toBe(true);
    // wrong type guard
    const pp = await db.submission.create({ data: { type: "PEMBUBARAN_PP", status: "READY" } });
    ids.push(pp.id);
    expect((await app.request(`/api/pembubaran/submissions/${pp.id}/review-data`)).status).toBe(400); // exact-spelling: PP never aliases
  });

  it("PUT /likuidator upserts MANUAL rows (safety net for a zero-row pass) and 409s outside READY", async () => {
    const { sub, lik } = await seed("READY");
    const ok = await app.request(`/api/pembubaran/submissions/${sub.id}/likuidator`, json({ fields: { likuidator_nama: "BUDI HARTONO", likuidator_telepon: "081234567890", not_a_key: "ignored" } }));
    expect(ok.status).toBe(200);
    const rows = await db.extractedField.findMany({ where: { documentId: lik.id } });
    expect(rows.length).toBe(2); // only canonical keys accepted
    expect(rows.find((r) => r.fieldKey === "likuidator_nama")?.status).toBe("MANUAL");
    const { sub: busy } = await seed("EXTRACTING");
    const blocked = await app.request(`/api/pembubaran/submissions/${busy.id}/likuidator`, json({ fields: { likuidator_nama: "X" } }));
    expect(blocked.status).toBe(409);
    expect(await blocked.json()).toEqual({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" });
  });

  it("PUT /kehadiran updates the registry rows (registry path) and the deed PS rows (deed path)", async () => {
    const { sub } = await seed("READY", { registryKehadiran: true });
    const res = await app.request(`/api/pembubaran/submissions/${sub.id}/kehadiran`, json({ entries: [{ nama: "BUDI SANTOSO", kehadiran: true }] }));
    expect(res.status).toBe(200);
    const fresh = await db.submission.findUnique({ where: { id: sub.id } });
    expect(fresh?.rupsKehadiranRegistry).toEqual([{ nama: "BUDI SANTOSO", persentase: 100, kehadiran: true }]);
    // deed path
    const { sub: s2, akta } = await seed("READY");
    await db.aktaPemegangSaham.create({ data: { documentId: akta.id, namaAtauBadanHukum: "SITI AMINAH", persentase: 100, orderIndex: 0 } });
    const res2 = await app.request(`/api/pembubaran/submissions/${s2.id}/kehadiran`, json({ entries: [{ nama: "Ibu SITI AMINAH", kehadiran: false }] }));
    expect(res2.status).toBe(200);
    const ps = await db.aktaPemegangSaham.findFirst({ where: { documentId: akta.id } });
    expect(ps?.kehadiranRups).toBe(false); // honorific-stripped name match
  });

  it("PUT /koran lands on the clipping doc when present, else the deed (canonical keys)", async () => {
    const { sub } = await seed("READY", { clipping: true });
    const res = await app.request(`/api/pembubaran/submissions/${sub.id}/koran`, json({ namaKoran: "Media Indonesia", tanggalPengumuman: "2026-05-10" }));
    expect(res.status).toBe(200);
    const body = (await res.json()) as { documentId: string; updated: string[] };
    const doc = await db.document.findUnique({ where: { id: body.documentId } });
    expect(doc?.documentType).toBe("BUKTI_PENGUMUMAN");
    expect(body.updated).toEqual(["nama_koran", "tanggal_pengumuman"]);
  });

  it("submit: canonical 409 outside READY; 400 SECTIONS_UNAPPROVED naming missing; koran NOT required", async () => {
    const { sub } = await seed("EXTRACTING");
    const blocked = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
    expect(blocked.status).toBe(409);
    expect(await blocked.json()).toEqual({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" });
    const { sub: s2 } = await seed("READY");
    await db.perubahanSectionApproval.create({ data: { submissionId: s2.id, sectionKey: "perseroan", approved: true, approvedAt: new Date() } });
    const missing = await app.request(`/api/pembubaran/submissions/${s2.id}/submit`, { method: "POST" });
    expect(missing.status).toBe(400);
    const mb = (await missing.json()) as { code: string; missingSections: string[] };
    expect(mb.code).toBe("SECTIONS_UNAPPROVED");
    expect(mb.missingSections).toEqual(["akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]);
    expect(mb.missingSections).not.toContain("pengumuman_koran"); // advisory-only section never gates
  });

  it("submit: 422 VALIDATION_FAILED on un-overridden FAIL; override unblocks; COMPLETED + MENUNGGU_VERIFIKATOR in one tx", async () => {
    const { sub } = await seed("READY", { approveAll: true, failRow: true });
    const fail = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
    expect(fail.status).toBe(422);
    const fb = (await fail.json()) as { code: string; failing: string[] };
    expect(fb.code).toBe("VALIDATION_FAILED");
    expect(fb.failing).toEqual(["LIKUIDATOR_KTP_MATCH"]);
    await db.validationResult.updateMany({
      where: { submissionId: sub.id, ruleCode: "LIKUIDATOR_KTP_MATCH" },
      data: { overridden: true, overrideReason: "verified manually" },
    });
    const ok = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
    expect(ok.status).toBe(200);
    expect(((await ok.json()) as { status: string }).status).toBe("COMPLETED");
    const fresh = await db.submission.findUnique({ where: { id: sub.id }, include: { verifikasi: true } });
    expect(fresh?.status).toBe("COMPLETED");
    expect(fresh?.verifikasi?.status).toBe("MENUNGGU_VERIFIKATOR"); // staged, NOT terminal
  });

  it("PERBAIKAN re-submit arm: re-arms the existing verifikasi row and clears the AI report", async () => {
    const { sub } = await seed("READY", { approveAll: true });
    await db.verifikasiPerubahan.create({
      data: { submissionId: sub.id, status: "PERBAIKAN", aiRecommendation: "WARN", aiGeneratedAt: new Date() },
    });
    const res = await app.request(`/api/pembubaran/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();
  });

  it("sections approve/unapprove persist EXPLICIT rows; unknown key 400; READY-only 409", async () => {
    const { sub } = await seed("READY");
    const ok = await app.request(`/api/pembubaran/submissions/${sub.id}/sections/likuidator/approve`, { method: "POST" });
    expect(ok.status).toBe(200);
    const row = await db.perubahanSectionApproval.findUnique({ where: { submissionId_sectionKey: { submissionId: sub.id, sectionKey: "likuidator" } } });
    expect(row?.approved).toBe(true);
    expect((await app.request(`/api/pembubaran/submissions/${sub.id}/sections/nonsense/approve`, { method: "POST" })).status).toBe(400);
    const { sub: busy } = await seed("VALIDATING");
    const blocked = await app.request(`/api/pembubaran/submissions/${busy.id}/sections/likuidator/approve`, { method: "POST" });
    expect(blocked.status).toBe(409);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/pembubaran-routes.test.ts. Expected: 404s (route not mounted).
  • [ ] Minimal implementation — create backend/src/routes/pembubaran.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 { pembubaranPtFlow } from "../flow-engine/flows/pembubaran-pt";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../services/surat-likuidator-extract";
import { normalizeName } from "../services/rups-attendance-matcher";
import { recordAuditEvent } from "../services/audit-event";
import { getRequestContext } from "../middleware/request-context";

/**
 * Route family 'pembubaran' — Pembubaran PT (dissolution). VERIFIKATOR-STAGED:
 * submit → COMPLETED + VerifikasiPerubahan (MENUNGGU_VERIFIKATOR) in ONE
 * transaction (peleburan-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 (Global Constraints; tightens the
 * peleburan submit-arm text). PEMBUBARAN_PT ≠ PEMBUBARAN_PP — exact equality.
 */
const pembubaran = new Hono();

const APPROVABLE_KEYS = new Set(pembubaranPtFlow.sections.filter((s) => s.approvable).map((s) => s.key));
const READY_ONLY = ["READY"] 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 !== "PEMBUBARAN_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 };

// GET /submissions/:submissionId/review-data — the composite review payload.
pembubaran.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 !== "PEMBUBARAN_PT") return c.json({ error: "Not a Pembubaran PT submission" }, 400);

  const aktaSubDoc = submission.documents.find((d) => d.classifiedType === "AKTA_PEMBUBARAN");
  const likSubDoc = submission.documents.find((d) => d.document.documentType === "SURAT_LIKUIDATOR");
  const clipping = submission.documents.find((d) => d.document.documentType === "BUKTI_PENGUMUMAN");

  // Perseroan snapshot (registry) — the "perseroan" section's content.
  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;

  // Likuidator — ALWAYS all 12 canonical keys (absent → null row) so the card
  // renders a stable form even when the pass produced zero rows.
  const likFields = Object.fromEntries(
    LIKUIDATOR_EXTRACTED_FIELD_KEYS.map((k) => {
      const f = likSubDoc?.document.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" }];
    }),
  );

  // Kehadiran — deed roster first; registry fallback second (matcher output).
  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[] };

  // Koran — clipping-first then deed-inline (same resolution as the context).
  const koranOf = (doc: typeof aktaSubDoc) => ({
    namaKoran: doc?.document.fields.find((f) => f.fieldKey === "nama_koran")?.value ?? null,
    tanggalKoran: doc?.document.fields.find((f) => f.fieldKey === "tanggal_pengumuman")?.value ?? null,
  });
  const clip = koranOf(clipping);
  const inline = koranOf(aktaSubDoc);
  const koran = clipping && (clip.namaKoran || clip.tanggalKoran)
    ? { ...clip, source: "clipping" as const }
    : inline.namaKoran || inline.tanggalKoran
      ? { ...inline, source: "akta" as const }
      : { namaKoran: null, tanggalKoran: null, source: null };

  return c.json({
    submission: { id: submission.id, status: submission.status, type: submission.type },
    perseroan,
    kontak: { teleponPerseroan: submission.teleponPerseroan, emailPerseroan: submission.emailPerseroan },
    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,
    likuidator: { hasDoc: !!likSubDoc, documentId: likSubDoc?.documentId ?? null, fields: likFields },
    kehadiran,
    koran,
    documents: submission.documents.map((d) => ({
      documentId: d.documentId, classifiedType: d.classifiedType,
      extractionStatus: d.extractionStatus, originalName: d.document.originalName,
    })),
    validationResults: submission.validationResults,
    sections: pembubaranPtFlow.sections,
    sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((a) => [a.sectionKey, a.approved])),
  });
});

// PUT /submissions/:submissionId/likuidator — bulk manual upsert of the canonical
// likuidator fieldKeys (READY-state management surface, warning 1). The safety net
// for a zero-row focused pass (empty rawText): generic per-field confirm needs an
// existing row (P2025), this endpoint creates them as MANUAL.
pembubaran.put("/submissions/:submissionId/likuidator", 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 Pembubaran PT submission" }, 400);
  if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
  const likSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, document: { documentType: "SURAT_LIKUIDATOR" } },
    select: { documentId: true },
  });
  if (!likSubDoc) return c.json({ error: "Surat likuidator belum diunggah" }, 404);
  type Body = { fields?: Record<string, string | null> };
  const body = await c.req.json<Body>().catch(() => ({}) as Body);
  const canonical = new Set(LIKUIDATOR_EXTRACTED_FIELD_KEYS);
  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: likSubDoc.documentId, fieldKey } },
      update: { value: value ?? null, confidence: 1, status: "MANUAL" },
      create: { documentId: likSubDoc.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 c.json({ documentId: likSubDoc.documentId, updated });
});

// PUT /submissions/:submissionId/kehadiran — attendance ticks. Handles BOTH
// roster paths: deed PS rows (AktaPemegangSaham.kehadiranRups) and the registry
// fallback (Submission.rupsKehadiranRegistry). Name matching is honorific-stripped
// (same normalizeName the matcher uses).
pembubaran.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 Pembubaran 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_PEMBUBARAN" },
    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" });
});

// PUT /submissions/:submissionId/koran — operator-editable koran fields (the
// PEMBUBARAN_KORAN advisory is correctable by design). Lands on the clipping doc
// when present, else the deed — the SAME canonical keys the context reads.
pembubaran.put("/submissions/:submissionId/koran", 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 Pembubaran PT submission" }, 400);
  if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
  const clipping = await db.submissionDocument.findFirst({
    where: { submissionId, document: { documentType: "BUKTI_PENGUMUMAN" } }, select: { documentId: true },
  });
  const akta = clipping
    ? null
    : await db.submissionDocument.findFirst({ where: { submissionId, classifiedType: "AKTA_PEMBUBARAN" }, select: { documentId: true } });
  const documentId = clipping?.documentId ?? akta?.documentId;
  if (!documentId) return c.json({ error: "Tidak ada dokumen pembawa data koran" }, 404);
  type KoranBody = { namaKoran?: string | null; tanggalPengumuman?: string | null };
  const body = await c.req.json<KoranBody>().catch(() => ({}) as KoranBody);
  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]);
  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" },
    });
  }
  await queueReMatchAndValidate(submissionId).catch(console.error);
  return c.json({ documentId, updated: pairs.map(([k]) => k) });
});

// 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) {
  pembubaran.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 Pembubaran 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 → 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 dissolution has no diff — aiReport stays null, the verifikator
 * reads the validation list). NO auto-confirm anywhere.
 */
pembubaran.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 !== "PEMBUBARAN_PT") return c.json({ error: "Not a Pembubaran PT submission" }, 400);
  if (submission.status !== "READY") return c.json(WRONG_STATUS, 409);

  // (1) required section approvals — explicit persisted rows only (6 keys;
  // pengumuman_koran is approvable-but-not-required — Ps.147 advisory content).
  const requiredKeys = pembubaranPtFlow.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, pembubaranPtFlow);
  if (blocked) {
    return c.json({ error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing }, 422);
  }

  // (3) verifikator staging — peleburan/akuisisi arm mirror.
  const likSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, document: { documentType: "SURAT_LIKUIDATOR" } },
    select: { document: { select: { fields: { where: { fieldKey: { in: ["likuidator_nama", "likuidator_nomor_surat"] } } } } } },
  });
  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: "PEMBUBARAN_PT",
        sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((s) => [s.sectionKey, s.approved])),
        likuidator: {
          nama: likSubDoc?.document.fields.find((f) => f.fieldKey === "likuidator_nama")?.value ?? null,
          nomorSurat: likSubDoc?.document.fields.find((f) => f.fieldKey === "likuidator_nomor_surat")?.value ?? null,
        },
      },
    }, getRequestContext(c));
  });

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

export default pembubaran;
  • [ ] Mount — backend/src/index.ts: import pembubaran from "./routes/pembubaran"; next to the peleburan import, and app.route("/api/pembubaran", pembubaran); next to the peleburan mount.
  • [ ] Run GREEN: cd backend && bun test src/routes/__tests__/pembubaran-routes.test.ts. Also prove the select-company reuse: bun test src/routes/__tests__/pembubaran-routing.test.ts still green (no perubahan.ts change was needed — if a test elsewhere asserts PERUBAHAN_FAMILY membership for v2 flows, it should NOT include PEMBUBARAN_PT).
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/routes/pembubaran.ts backend/src/index.ts backend/src/routes/__tests__/pembubaran-routes.test.ts && git commit -m "feat(pembubaran): route family — review-data + READY-state edit surfaces + staged verifikator submit"

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

Queue admission (the b92232e/677d7ea pattern — remember AKUISISI_PT was silently NEVER admitted for weeks because the where was type-listed; the invariant test prevents the same for PEMBUBARAN_PT), the tagged item type, the compact verifikator review page (VerifikatorPeleburanReviewPage precedent — no diff/AI-advisory; the validation list + likuidator/koran snapshots ARE the review), and the whole-flow-review warning-3 obligations: a decide-APPROVE end-to-end test plus the roster-source PIN — resolveVoterRoster must serve oldData.pemegang_saham for PEMBUBARAN_PT via its existing fallback (AKTA_PEMBUBARAN ∉ ROSTER_AKTA_TYPES, and a deed-restated roster must NOT hijack — the registry roster is the legal voter set for a dissolution). shareholder-voting.ts gets a documenting comment ONLY, no code change.

Files
- backend/src/routes/verifikator.ts — Modify (queue where + tagging)
- backend/src/services/shareholder-voting.ts — Modify (comment only, next to ROSTER_AKTA_TYPES)
- backend/src/routes/__tests__/verifikator-pembubaran.test.ts — Create
- frontend/src/hooks/use-verifikator.ts — Modify (item type union)
- frontend/src/pages/VerifikatorPembubaranReviewPage.tsx — Create
- frontend/src/pages/VerifikatorDashboardPage.tsx — Modify (routing ternary)
- frontend/src/routes.tsx — Modify (verifikator route)
- frontend/src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx — Create

Interfaces
- Consumes: GET /api/verifikator/submissions, POST /api/verifikator/submissions/:id/decide; GET /api/pembubaran/submissions/:id/review-data (Task 9); useVerifikasiData/useDecide; DecisionPanel/AuditTrailPanel.
- Produces: queue items { type: "PEMBUBARAN_PT" } routed to /verifikator/pembubaran/:submissionId.

Steps

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

/**
 * Warning-3 obligations (Peleburan whole-flow review): queue admission invariant
 * + decide-APPROVE end-to-end + the voter-roster-source PIN. Test DB has no SMTP
 * host → emailDispatch.isDemoMode === true → the email gate is skipped and votes
 * get rows with empty email (the roster-empty 422 still fires — that is the pin).
 */
describe("verifikator × PEMBUBARAN_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: "PEMBUBARAN_PT", status: "COMPLETED",
        oldData: {
          perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 555 },
          ...(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_PEMBUBARAN", 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 PEMBUBARAN_PT tagged with its real type; PEMBUBARAN_PP is NEVER admitted (exact spelling)", async () => {
    const sub = await seedStaged();
    const pp = await db.submission.create({ data: { type: "PEMBUBARAN_PP", status: "COMPLETED" } });
    ids.push(pp.id);
    await db.verifikasiPerubahan.create({ data: { submissionId: pp.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 }[] };
    const mine = body.items.find((i) => i.submissionId === sub.id);
    expect(mine?.type).toBe("PEMBUBARAN_PT");
    expect(body.items.some((i) => i.submissionId === pp.id)).toBe(false);
  });

  it("decide-APPROVE end-to-end: MENUNGGU_VOTING + votes built from oldData.pemegang_saham (ROSTER-SOURCE PIN)", async () => {
    const sub = await seedStaged({ roster: true, deedRoster: true }); // deed roster present AND must not hijack
    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_PEMBUBARAN
    // is deliberately NOT in ROSTER_AKTA_TYPES; the registry roster is the legal
    // voter set for a dissolution.
    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 (resolveVoterRoster returns null; 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-pembubaran.test.ts. Expected: queue-admission failures (type not in the where list).
  • [ ] Minimal implementation A — backend/src/routes/verifikator.ts:
    1. The queue where (~line 380) becomes:
  // Perubahan-family queue + the v2 verifikator-staged flows. PERUBAHAN_PT rows
  // are byte-identical; PELEBURAN_PT/PEMBUBARAN_PT rows carry no diff/advisory
  // (aiReport null) and surface their validation lists on their per-family pages.
  // AKUISISI_PT rows surface through the shared PERUBAHAN_PT verifikator review page.
  const where: any = {
    submission: { type: { in: ["PERUBAHAN_PT", "PELEBURAN_PT", "AKUISISI_PT", "PEMBUBARAN_PT"] }, status: "COMPLETED" },
  };
  1. The tagging block (~line 593) gains a pembubaran set and branch:
  const pembubaranIds = new Set(all.filter((v) => v.submission.type === "PEMBUBARAN_PT").map((v) => v.submissionId));
    type: peleburanIds.has(it.submissionId)
      ? ("PELEBURAN_PT" as const)
      : pembubaranIds.has(it.submissionId)
        ? ("PEMBUBARAN_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 comment block above ROSTER_AKTA_TYPES (NO code change):
  // Roster-LOCAL widening: PERUBAHAN_FAMILY_AKTA is consumed elsewhere (the
  // dispatch-context finder, isPerubahanFamilyAkta) with perubahan-only semantics,
  // so we widen here rather than the shared const.
  // PEMBUBARAN_PT (deliberate NON-widening): AKTA_PEMBUBARAN is NOT added — a
  // dissolution's voters are the CURRENT registered shareholders, i.e.
  // oldData.pemegang_saham via the fallback below; a deed-restated roster must
  // not hijack it (pinned by verifikator-pembubaran.test.ts).
  • [ ] Minimal implementation C — frontend:
    1. frontend/src/hooks/use-verifikator.ts: the queue item union becomes type: "PERUBAHAN_PT" | "PELEBURAN_PT" | "AKUISISI_PT" | "PERBAIKAN_DATA_PT" | "PEMBUBARAN_PT";
    2. frontend/src/pages/VerifikatorDashboardPage.tsx: the navigate ternary gains a branch BEFORE the fall-through:
                        item.type === "PERBAIKAN_DATA_PT"
                          ? `/verifikator/perbaikan/${item.submissionId}`
                          : item.type === "PELEBURAN_PT"
                            ? `/verifikator/peleburan/${item.submissionId}`
                            : item.type === "PEMBUBARAN_PT"
                              ? `/verifikator/pembubaran/${item.submissionId}`
                              : `/verifikator/review/${item.submissionId}`,
  1. Create frontend/src/pages/VerifikatorPembubaranReviewPage.tsx (VerifikatorPeleburanReviewPage mirror — same imports/STATUS_STYLE; body):
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, CheckCircle2, AlertTriangle, XCircle, MinusCircle, UserCheck } 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 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;
  likuidator: { hasDoc: boolean; fields: Record<string, { value: string | null }> };
  kehadiran: { source: string | null; rows: { nama: string; persentase: number | null; kehadiran: boolean | null }[] };
  koran: { namaKoran: string | null; tanggalKoran: string | null; source: string | 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 PEMBUBARAN_PT (per-family precedent:
 *  VerifikatorPeleburanReviewPage). No diff/AI-advisory — a dissolution has no
 *  old-vs-new diff; the validation list + likuidator/kehadiran/koran snapshots
 *  ARE the review. */
export function VerifikatorPembubaranReviewPage() {
  const { submissionId } = useParams<{ submissionId: string }>();
  const navigate = useNavigate();
  const { data: verif } = useVerifikasiData(submissionId);
  const decide = useDecide(submissionId);
  const { data, isLoading } = useQuery<ReviewData>({
    queryKey: ["pembubaran-review", submissionId],
    queryFn: () => apiFetch<ReviewData>(`/api/pembubaran/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 lik = (key: string) => data.likuidator.fields[key]?.value ?? "—";

  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">Pembubaran PT  Tinjauan Verifikator</h1>
          <p className="text-sm text-muted-foreground">
            {data.perseroan?.nama ?? f("nama_perseroan")} · Akta No. {f("nomor_akta")} · {f("nama_notaris")}
          </p>
        </div>
        <Button variant="outline" size="sm" onClick={() => navigate("/verifikator")}>Kembali</Button>
      </div>

      {/* Likuidator */}
      <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"><UserCheck className="h-4 w-4" /> Likuidator</h2>
        <div>Nama: {lik("likuidator_nama")} · Surat: {lik("likuidator_nomor_surat")} ({lik("likuidator_tanggal_surat")})</div>
        <div className="text-xs text-muted-foreground">
          {lik("likuidator_alamat")} RT {lik("likuidator_rt")}/RW {lik("likuidator_rw")}, {lik("likuidator_kelurahan")}, {lik("likuidator_kecamatan")} {lik("likuidator_kode_pos")} · {lik("likuidator_telepon")} · {lik("likuidator_email")}
        </div>
        <div className="text-xs text-muted-foreground">Dasar penunjukan: {lik("likuidator_dasar_penunjukan")}</div>
      </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 ?? "—"} · kuorum Ps.89(1) 3/4)</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>

      {/* Pengumuman */}
      <section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
        <h2 className="text-sm font-semibold">Pengumuman Koran (Ps.147)</h2>
        <div>Koran: {data.koran.namaKoran ?? "—"} · Tanggal: {data.koran.tanggalKoran ?? "—"} · Sumber: {data.koran.source ?? "—"}</div>
      </section>

      {/* Hasil Validasi — the review substance (no AI advisory for this flow) */}
      <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>
  );
}
  1. frontend/src/routes.tsx: import the page and add inside the verifikator RoleGuard children:
          {
            path: "verifikator/pembubaran/:submissionId",
            element: <ErrorBoundary><VerifikatorPembubaranReviewPage /></ErrorBoundary>,
          },
  • [ ] Write the FE page test. Create frontend/src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx (LaporanRupsReviewPage.test.tsx pattern: vi.mock("@/lib/api-client"), IOStub, MemoryRouter; also mock @/hooks/use-verifikator returning { verifikasi: { status: "MENUNGGU_VERIFIKATOR" }, auditEvents: [] } and a no-op useDecide): assert the header renders the perseroan name, the Likuidator section renders likuidator_nama, the kehadiran rows render with HADIR/TIDAK HADIR badges, and the validation list renders a WARNING row. Keep it to 3-4 focused assertions.
  • [ ] Run GREEN: cd backend && bun test src/routes/__tests__/verifikator-pembubaran.test.ts src/routes/__tests__/verifikator-peleburan.test.ts src/routes/__tests__/verifikator-list.test.ts && cd ../frontend && bunx vitest run src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx.
  • [ ] Typecheck both: cd backend && bunx tsc --noEmit && cd ../frontend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/routes/verifikator.ts backend/src/services/shareholder-voting.ts backend/src/routes/__tests__/verifikator-pembubaran.test.ts frontend/src/hooks/use-verifikator.ts frontend/src/pages/VerifikatorPembubaranReviewPage.tsx frontend/src/pages/VerifikatorDashboardPage.tsx frontend/src/routes.tsx frontend/src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx && git commit -m "feat(pembubaran): verifikator queue admission + decide-APPROVE E2E + oldData roster pin + compact page"

Task 11: Frontend klasifikasi surfacing — detect chain, banner, labels, create-route, unsupported gate, re-check trigger

The board learns pembubaran: display labels, the DetectedFlow banner entry, the detect chains with the pembubaran rung BELOW akuisisi and the FE mirror of the Berakhirnya-package suppression (a package must never show a confident "Pembubaran PT" detection), the create-route branch, the supported:false create-response gate (the backend now returns it for Berakhirnya packages — surfaced as a toast, no undefined navigation), and the Task-8 re-check trigger (fires once per classified set containing an AKTA_PEMBUBARAN with no effective SURAT_LIKUIDATOR; merges suggestions into the existing amber chip mechanism — never auto-applies).

Files
- frontend/src/lib/classifier-labels.ts — Modify
- frontend/src/lib/pembubaran-detect.ts — Create
- frontend/src/components/klasifikasi/detect-banner.tsx — Modify
- frontend/src/pages/KlasifikasiPage.tsx — Modify
- frontend/src/lib/__tests__/pembubaran-detect.test.ts — Create

Steps

  • [ ] Write failing test. Create frontend/src/lib/__tests__/pembubaran-detect.test.ts (vitest):
import { describe, it, expect } from "vitest";
import { isBerakhirnyaPackage, pickLikuidatorRecheckCandidates } from "../pembubaran-detect";

const r = (classification: string, userOverride?: string, backendFileId = classification.toLowerCase()) =>
  ({ classification, userOverride, backendFileId }) as never;

describe("isBerakhirnyaPackage (FE mirror of the backend predicate — suppression only)", () => {
  it("true on AKTA_BERAKHIRNYA / laporan-permohonan labels / ≥2 clippings; false on a single clipping", () => {
    expect(isBerakhirnyaPackage([r("AKTA_BERAKHIRNYA")])).toBe(true);
    expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("LAPORAN_LIKUIDASI")])).toBe(true);
    expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("SURAT_PERMOHONAN")])).toBe(true);
    expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("BUKTI_PENGUMUMAN"), r("OTHER", "BUKTI_PENGUMUMAN")])).toBe(true);
    expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("BUKTI_PENGUMUMAN")])).toBe(false);
    expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("KTP")])).toBe(false);
  });
});

describe("pickLikuidatorRecheckCandidates", () => {
  it("selects non-card, non-akta, non-BA files; skips when a SURAT_LIKUIDATOR already exists", () => {
    const set = [r("AKTA_PEMBUBARAN"), r("KTP"), r("NPWP"), r("BERITA_ACARA_RUPS"), r("SURAT_PERNYATAAN_DOMISILI"), r("OTHER")];
    expect(pickLikuidatorRecheckCandidates(set)).toEqual(["surat_pernyataan_domisili", "other"]);
    expect(pickLikuidatorRecheckCandidates([...set, r("SURAT_LIKUIDATOR")])).toEqual([]);
    expect(pickLikuidatorRecheckCandidates([r("KTP"), r("OTHER")])).toEqual([]); // no AKTA_PEMBUBARAN → out of scope
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/lib/__tests__/pembubaran-detect.test.ts.
  • [ ] Minimal implementation A — create frontend/src/lib/pembubaran-detect.ts:
/** Pure FE helpers for the pembubaran klasifikasi surfacing. */

export interface DetectLite { classification: string; userOverride?: string; backendFileId?: string }
const eff = (r: DetectLite) => r.userOverride ?? r.classification;

/**
 * FE mirror of the backend BERAKHIRNYA_PACKAGE predicate (routes/submissions.ts —
 * the authority; this mirror only SUPPRESSES the confident "Pembubaran PT"
 * detection so a Berakhirnya package never banners as pembubaran. The backend
 * create gate — supported:false — is the enforcement).
 */
export function isBerakhirnyaPackage(results: DetectLite[]): boolean {
  const isClipping = (r: DetectLite) => eff(r) === "BUKTI_PENGUMUMAN"; // FE approximation of the carrier map
  return (
    results.some((r) => eff(r) === "AKTA_BERAKHIRNYA") ||
    (results.some((r) => eff(r) === "AKTA_PEMBUBARAN") &&
      (results.some((r) => eff(r) === "LAPORAN_LIKUIDASI" || eff(r) === "SURAT_PERMOHONAN") ||
        results.some((r) => eff(r) === "BUKTI_PENGUMUMAN_152") ||
        results.filter(isClipping).length >= 2))
  );
}

const RECHECK_EXCLUDED = new Set([
  "KTP", "SIM", "NPWP", "NPWP_LAMA", "PASSPORT", "KITAS",
  "BERITA_ACARA_RUPS", "DATA_KONTAK", "BUKTI_PENGUMUMAN", "SURAT_LIKUIDATOR",
]);

/**
 * Candidate backendFileIds for the SCOPED SURAT_LIKUIDATOR re-check pass
 * (spec §4.5). Only when the set contains an AKTA_PEMBUBARAN and no effective
 * SURAT_LIKUIDATOR yet; cards/akta/BA/clippings are excluded (they cannot be
 * the letter). Returns [] when out of scope.
 */
export function pickLikuidatorRecheckCandidates(results: DetectLite[]): string[] {
  if (!results.some((r) => eff(r) === "AKTA_PEMBUBARAN")) return [];
  if (results.some((r) => eff(r) === "SURAT_LIKUIDATOR")) return [];
  return results
    .filter((r) => !eff(r).startsWith("AKTA_") && !eff(r).startsWith("BUKTI_TRANSFER") && !RECHECK_EXCLUDED.has(eff(r)))
    .map((r) => r.backendFileId)
    .filter((id): id is string => !!id);
}
  • [ ] Minimal implementation B — frontend/src/lib/classifier-labels.ts: add after AKTA_PELEBURAN:
  AKTA_PEMBUBARAN: "Akta Pembubaran",
  SURAT_LIKUIDATOR: "Surat Penunjukan Likuidator",
  • [ ] Minimal implementation C — frontend/src/components/klasifikasi/detect-banner.tsx: DetectedFlow union += "pembubaran"; FLOW_LABELS.pembubaran = "Pembubaran PT"; FLOW_DESCRIPTIONS.pembubaran = "Akta Pembubaran terdeteksi. Alur Pembubaran PT (likuidasi — penunjukan likuidator) akan dijalankan.".
  • [ ] Minimal implementation D — frontend/src/pages/KlasifikasiPage.tsx:
    1. Import { isBerakhirnyaPackage, pickLikuidatorRecheckCandidates } from @/lib/pembubaran-detect; extend KlasifikasiLabelType with "pembubaran".
    2. Add next to the other hasAkta* consts:
  const berakhirnyaPaket = isBerakhirnyaPackage(results);
  const hasAktaPembubaran = !berakhirnyaPaket && results.some(
    (r) => (r.userOverride ?? r.classification) === "AKTA_PEMBUBARAN"
  );
  1. computedLabelType chain — insert BELOW akuisisi, ABOVE perubahan: ... : hasAktaAkuisisi ? "akuisisi" : hasAktaPembubaran ? "pembubaran" : hasAktaPerubahan ? "perubahan" : ... — and the detectedFlow chain identically ("pembubaran" between "akuisisi" and "perubahan").
  2. Create-route: in createSubmission's onSuccess, FIRST handle the unsupported gate, then the pembubaran branch:
                        onSuccess: (data) => {
                          if ((data as { supported?: boolean }).supported === false) {
                            toast.error("Paket dokumen terdeteksi sebagai alur yang belum didukung — periksa klasifikasi dokumen.");
                            return;
                          }
                          if (data.type === "PERBAIKAN_DATA_PT") {

(add import { toast } from "sonner"; if absent) and after the PELEBURAN_PT branch:

                          } else if (data.type === "PEMBUBARAN_PT") {
                            navigate(`/pembubaran/${data.submissionId}/extraction`);
  1. Re-check trigger — add an effect after the results/flow.phase state (fires ONCE per classified set; merges into the existing suggestion chip, never auto-applies):
  // SCOPED SURAT_LIKUIDATOR re-check (spec §4.5): only for sets containing an
  // AKTA_PEMBUBARAN with no effective SURAT_LIKUIDATOR. Suggestion-only — the
  // amber chip applies through the existing re-map mechanism.
  const recheckedRef = useRef<string | null>(null);
  useEffect(() => {
    if (flow.phase !== "done") return;
    const candidates = pickLikuidatorRecheckCandidates(results);
    if (candidates.length === 0) return;
    const signature = candidates.slice().sort().join("|");
    if (recheckedRef.current === signature) return;
    recheckedRef.current = signature;
    apiFetch<{ suggestions: { fileId: string; docType: string; displayName: string }[] }>(
      "/api/klasifikasi/recheck-likuidator",
      { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileIds: candidates }) },
    )
      .then(({ suggestions }) => {
        if (suggestions.length === 0) return;
        setResults((prev) => prev.map((r) =>
          suggestions.some((s) => s.fileId === r.backendFileId) && !r.userOverride
            ? { ...r, suggestion: { docType: "SURAT_LIKUIDATOR", displayName: "Surat Penunjukan Likuidator" } }
            : r,
        ));
      })
      .catch(() => {});
  }, [flow.phase, results]);

(adapt setResults to the page's actual results setter — it is the jotai classificationResultsAtom writer already used by the classify handlers; useRef/useEffect imports as needed.)
- [ ] Run GREEN: cd frontend && bunx vitest run src/lib/__tests__/pembubaran-detect.test.ts && bunx vitest run. Expected: new tests pass, full FE suite stays green.
- [ ] Typecheck: cd frontend && bunx tsc --noEmit.
- [ ] Commit: git add frontend/src/lib/classifier-labels.ts frontend/src/lib/pembubaran-detect.ts frontend/src/components/klasifikasi/detect-banner.tsx frontend/src/pages/KlasifikasiPage.tsx frontend/src/lib/__tests__/pembubaran-detect.test.ts && git commit -m "feat(pembubaran): klasifikasi surfacing — detect rung below akuisisi + package suppression + re-check trigger"


Task 12: Frontend flow config + extraction page (steps, destination, gate helpers, single-company mirror)

The staged step labels (5 steps — verifikator stage; peleburan twin), the destinationFor base (NOT terminal — verifikasi-driven), the pure submit-gate helpers, and the extraction page. The extraction page is a copy of LaporanRupsExtractionPage.tsx — the RIGHT copy source: it is the v2 single-company page already wired to the type-agnostic CompanySelectionModal/select-company flow, and it carries exactly 3 flow-specific tokens (verified by grep: the function name at line 43, the computeFlowStep type literal at ~254, the review navigate at ~488). Do NOT copy PeleburanExtractionPage (its sources panel replaced the modal — wrong shape for a single-company flow).

Files
- frontend/src/lib/flow-steps.ts — Modify
- frontend/src/lib/destination-for.ts — Modify
- frontend/src/lib/pembubaran-review.ts — Create
- frontend/src/pages/PembubaranExtractionPage.tsx — Create (copy + 3 diffs)
- frontend/src/lib/__tests__/pembubaran-flow-config.test.ts — Create

Steps

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

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

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

describe("pembubaran-review gate helpers", () => {
  it("mirrors the backend's 6 required sections; koran never gates", () => {
    expect([...PEMBUBARAN_REQUIRED_SECTIONS]).toEqual([
      "perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak",
    ]);
    expect(missingSections({ perseroan: true, akta_pembubaran: true, rups_kehadiran: true, likuidator: true, identitas: true, data_kontak: true })).toEqual([]);
    expect(missingSections({ perseroan: true, pengumuman_koran: true })).toEqual([
      "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak",
    ]);
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/lib/__tests__/pembubaran-flow-config.test.ts.
  • [ ] Minimal implementation A — frontend/src/lib/flow-steps.ts:
    1. After PELEBURAN_LABELS, add:
// Verifikator-staged (peleburan/akuisisi twin): submit creates a VerifikasiPerubahan
// row; post-COMPLETED stages drive from its status. PEMBUBARAN_PT ≠ PEMBUBARAN_PP
// (the PP flow has its own PP_STEP_LABELS entry below — never alias the two).
const PEMBUBARAN_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: "Pembubaran tercatat" },
] as const;
  1. SubmissionLite.type union += "PEMBUBARAN_PT".
  2. In computeFlowStep: const isPembubaran = submission.type === "PEMBUBARAN_PT";, extend the labels chain (isPeleburan ? PELEBURAN_LABELS : isPembubaran ? PEMBUBARAN_LABELS : ...), and widen the staged arm condition to if (isAkuisisi || isPeleburan || isPembubaran) {.
    - [ ] Minimal implementation B — frontend/src/lib/destination-for.ts: SubmissionLite.type union += "PEMBUBARAN_PT"; the base chain gains : submission.type === "PEMBUBARAN_PT" ? "pembubaran" before the "pendirian" fallback. Do NOT touch TERMINAL_TYPES.
    - [ ] Minimal implementation C — create frontend/src/lib/pembubaran-review.ts:
/** Pure gate helpers for PembubaranReviewPage — mirror of the backend submit gate
 *  (routes/pembubaran.ts) so the UI pre-flights the exact conditions it will be
 *  blocked on. Approvals are EXPLICIT persisted rows (c0698d8 discipline).
 *  pengumuman_koran is approvable but NEVER required (Ps.147 advisory content). */

export const PEMBUBARAN_REQUIRED_SECTIONS = [
  "perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak",
] as const;

export function missingSections(approvals: Record<string, boolean>): string[] {
  return PEMBUBARAN_REQUIRED_SECTIONS.filter((k) => !approvals[k]);
}
  • [ ] Minimal implementation D — create frontend/src/pages/PembubaranExtractionPage.tsx: copy frontend/src/pages/LaporanRupsExtractionPage.tsx byte-for-byte, then apply EXACTLY these diffs:
    1. export function LaporanRupsExtractionPage()export function PembubaranExtractionPage().
    2. The computeFlowStep call's type literal: { type: "LAPORAN_RUPS_TAHUNAN", status: submission.status }{ type: "PEMBUBARAN_PT", status: submission.status }.
    3. The review navigate: navigate(`/laporan-rups/${submissionId}/review`)navigate(`/pembubaran/${submissionId}/review`).
    4. Add a file-head comment: /** Thin mirror of LaporanRupsExtractionPage — the v2 single-company extraction page (CompanySelectionModal → POST /api/perubahan/select-company, type-agnostic; resumes continueFlow). Exactly 3 token diffs. */
    Then diff <(sed 's/Pembubaran/LaporanRups/g; s/PEMBUBARAN_PT/LAPORAN_RUPS_TAHUNAN/g; s|/pembubaran/|/laporan-rups/|g' frontend/src/pages/PembubaranExtractionPage.tsx) frontend/src/pages/LaporanRupsExtractionPage.tsx — output must be the head-comment only (faithful-mirror proof; if the copy source has other flow-specific strings the grep missed, mirror them the same way and note it in the commit body).
  • [ ] Run GREEN: cd frontend && bunx vitest run src/lib/__tests__/pembubaran-flow-config.test.ts && bunx vitest run (full FE suite — flow-steps/destination changes are additive).
  • [ ] 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/pembubaran-review.ts frontend/src/pages/PembubaranExtractionPage.tsx frontend/src/lib/__tests__/pembubaran-flow-config.test.ts && git commit -m "feat(pembubaran): FE flow config (staged steps, non-terminal destination) + extraction page mirror"

Task 13: Frontend review + status pages + routes

The flat sectioned review page (PeleburanReviewPage skeleton, pembubaran content): Perseroan (SABH snapshot) / Akta Pembubaran (deed header + dual-deed + notaris pengganti + tanggal RUPS, per-field confirm via the generic endpoint) / RUPS & Kehadiran (roster rows with HADIR/TIDAK toggles → PUT /kehadiran; source badge) / Likuidator (the 12 fields — confirm-able rows + a manual editor saving through PUT /likuidator; READY-state surface, warning 1) / Pengumuman Koran (editable, approvable-but-not-required) / Identitas / Data Kontak / validation list with override / staged submit dialog with the 422 surfaced inline + the COMPLETED Navigate idiom (regression 46176de baked in). Plus the verifikasi-aware status page (peleburan mirror) and the route trio.

Files
- frontend/src/pages/PembubaranReviewPage.tsx — Create
- frontend/src/pages/PembubaranStatusPage.tsx — Create
- frontend/src/routes.tsx — Modify
- frontend/src/pages/__tests__/PembubaranReviewPage.test.tsx — Create

Interfaces
- Consumes: GET /api/pembubaran/submissions/:id/review-data, POST .../sections/:key/approve|unapprove, PUT .../likuidator, PUT .../kehadiran, PUT .../koran, POST .../submit (Task 9); generic POST /api/submissions/:id/documents/:docId/fields/confirm, POST /api/submissions/:id/override-validation; missingSections (Task 12); computeFlowStep; useVerifikasiData (status page).
- Produces: routes pembubaran/:submissionId/{extraction,review,status}.

Steps

  • [ ] Write failing test. Create frontend/src/pages/__tests__/PembubaranReviewPage.test.tsx (LaporanRupsReviewPage.test.tsx pattern — apiFetch mock, IOStub, MemoryRouter, qc-preseeded ["pembubaran-review", id] cache):
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 { PembubaranReviewPage } from "../PembubaranReviewPage";

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

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

const SUBMISSION_ID = "pembubaran-sub-1";
const likField = (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: "PEMBUBARAN_PT" },
    perseroan: { nama: "PT MAJU MUNDUR", statusTransaksi: 1, statusPerseroan: "AKTIF" },
    kontak: { teleponPerseroan: "0215550001", emailPerseroan: "corp@majumundur.co.id" },
    akta: {
      documentId: "doc-akta-1",
      fields: [
        { fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR", confidence: 0.9, status: "CONFIRMED" },
        { fieldKey: "nomor_akta", value: "5", confidence: 0.9, status: "EXTRACTED" },
        { fieldKey: "tanggal_akta", value: "2026-04-30", confidence: 0.9, status: "CONFIRMED" },
        { fieldKey: "nama_notaris", value: "Sari, S.H., M.Kn.", confidence: 0.9, status: "CONFIRMED" },
        { fieldKey: "tanggal_rups", value: "2026-04-28", confidence: 0.9, status: "CONFIRMED" },
        { fieldKey: "akta_kedua_nomor", value: "6", confidence: 0.9, status: "EXTRACTED" },
      ],
      penghadap: [],
    },
    likuidator: {
      hasDoc: true, documentId: "doc-lik-1",
      fields: {
        likuidator_nama: likField("BUDI HARTONO"), likuidator_nomor_surat: likField("017/SPL"),
        likuidator_tanggal_surat: likField("2026-05-02"), likuidator_alamat: likField("Jl. Melati 12"),
        likuidator_rt: likField("004"), likuidator_rw: likField("007"), likuidator_kode_pos: likField("10310"),
        likuidator_kelurahan: likField("Cempaka Putih"), likuidator_kecamatan: likField("Menteng"),
        likuidator_telepon: likField(null), likuidator_email: likField(null), likuidator_dasar_penunjukan: likField("Keputusan RUPS"),
      },
    },
    kehadiran: { source: "registry", rows: [{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: true }, { nama: "SITI AMINAH", persentase: 40, kehadiran: null }] },
    koran: { namaKoran: "Media Indonesia", tanggalKoran: "2026-05-10", source: "akta" },
    documents: [{ documentId: "doc-ktp-1", classifiedType: "KTP", extractionStatus: "DONE", originalName: "ktp.pdf" }],
    validationResults: [
      { id: "v1", ruleCode: "PEMBUBARAN_KORAN", ruleLabel: "Pengumuman Koran Pembubaran (Ps.147)", status: "PASS", message: "ok" },
      { id: "v2", ruleCode: "LIKUIDATOR_KTP_MATCH", ruleLabel: "KTP Likuidator Sesuai Surat Penunjukan", status: "FAIL", message: "tidak cocok" },
    ],
    sections: [],
    sectionApprovals: { perseroan: true, akta_pembubaran: true, rups_kehadiran: true, likuidator: true, identitas: true, data_kontak: true },
    ...overrides,
  };
}

function renderPage(data: Record<string, unknown>) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  qc.setQueryData(["pembubaran-review", SUBMISSION_ID], data);
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={[`/pembubaran/${SUBMISSION_ID}/review`]}>
        <Routes>
          <Route path="/pembubaran/:submissionId/review" element={<PembubaranReviewPage />} />
          <Route path="/pembubaran/:submissionId/status" element={<div>Status page</div>} />
        </Routes>
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

describe("PembubaranReviewPage", () => {
  beforeEach(() => {
    apiFetch.mockReset();
    apiFetch.mockImplementation((url: string) =>
      typeof url === "string" && url.includes("/review-data")
        ? Promise.resolve(makeReviewData())
        : Promise.resolve({ ok: true }),
    );
  });

  it("renders the 7 sections incl. Likuidator (12 fields) and Kehadiran with source badge", async () => {
    renderPage(makeReviewData());
    await waitFor(() => expect(screen.getByText("Data Perseroan (SABH)")).toBeTruthy());
    expect(screen.getByText("Likuidator")).toBeTruthy();
    expect(screen.getByText("BUDI HARTONO")).toBeTruthy();
    expect(screen.getByText(/RUPS & Kehadiran/)).toBeTruthy();
    expect(screen.getByText("BUDI SANTOSO")).toBeTruthy();
    expect(screen.getByText("Pengumuman Koran")).toBeTruthy();
  });

  it("blocks submit on un-overridden FAIL and on missing approvals (gate pre-flight)", async () => {
    renderPage(makeReviewData()); // has one FAIL row
    await waitFor(() => expect(screen.getByText(/1 validasi FAIL/)).toBeTruthy());
    const btn = screen.getByRole("button", { name: /Kirim ke Verifikator/ }) as HTMLButtonElement;
    expect(btn.disabled).toBe(true);
  });

  it("surfaces the submit 422 inline in the dialog (never a silent failure)", async () => {
    const clean = makeReviewData({ validationResults: [] });
    apiFetch.mockImplementation((url: string, opts?: RequestInit) => {
      if (typeof url === "string" && url.includes("/review-data")) return Promise.resolve(clean);
      if (typeof url === "string" && url.endsWith("/submit") && opts?.method === "POST") {
        return Promise.reject(Object.assign(new Error("Validasi FAIL"), { failing: ["LIKUIDATOR_KTP_MATCH"] }));
      }
      return Promise.resolve({ ok: true });
    });
    renderPage(clean);
    await waitFor(() => expect(screen.getByRole("button", { name: /Kirim ke Verifikator/ })).toBeTruthy());
    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 → Navigate to the status page (46176de idiom)", async () => {
    const done = makeReviewData({ submission: { id: SUBMISSION_ID, status: "COMPLETED", type: "PEMBUBARAN_PT" } });
    apiFetch.mockImplementation(() => Promise.resolve(done));
    renderPage(done);
    await waitFor(() => expect(screen.getByText("Status page")).toBeTruthy());
  });

  it("kehadiran toggle PUTs to /kehadiran", async () => {
    renderPage(makeReviewData());
    await waitFor(() => expect(screen.getByText("SITI AMINAH")).toBeTruthy());
    fireEvent.click(screen.getAllByRole("button", { name: "Hadir" })[1]!); // second row's Hadir toggle
    await waitFor(() =>
      expect(apiFetch.mock.calls.some(([u, o]) => String(u).endsWith("/kehadiran") && (o as RequestInit)?.method === "PUT")).toBe(true));
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/pages/__tests__/PembubaranReviewPage.test.tsx.
  • [ ] Minimal implementation A — create frontend/src/pages/PembubaranReviewPage.tsx (PeleburanReviewPage skeleton — same imports minus the sources panel, same STATUS_STYLE/ApproveButton/FieldList/submit-dialog idioms; the full component):
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, UserCheck } 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/pembubaran-review";
import { cn } from "@/lib/utils";

interface FieldRow { fieldKey: string; value: string | null; confidence: number; status: string }
interface LikuidatorField { value: string | null; confidence: number; status: string }
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;
  kontak: { teleponPerseroan: string | null; emailPerseroan: string | null };
  akta: { documentId: string; fields: FieldRow[]; penghadap: { nama: string | null; nomorIdentitas: string | null }[] } | null;
  likuidator: { hasDoc: boolean; documentId: string | null; fields: Record<string, LikuidatorField> };
  kehadiran: { source: "deed" | "registry" | null; rows: KehadiranRow[] };
  koran: { namaKoran: string | null; tanggalKoran: string | null; source: string | null };
  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", "akta_kedua_nomor", "akta_kedua_tanggal", "akta_kedua_nama_notaris"];
const LIKUIDATOR_KEYS = [
  "likuidator_nama", "likuidator_nomor_surat", "likuidator_tanggal_surat", "likuidator_alamat",
  "likuidator_rt", "likuidator_rw", "likuidator_kode_pos", "likuidator_kelurahan",
  "likuidator_kecamatan", "likuidator_telepon", "likuidator_email", "likuidator_dasar_penunjukan",
];
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 PembubaranReviewPage() {
  const { submissionId } = useParams<{ submissionId: string }>();
  const navigate = useNavigate();
  const qc = useQueryClient();
  const invalidate = () => qc.invalidateQueries({ queryKey: ["pembubaran-review", submissionId] });

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

  const sectionMut = useMutation({
    mutationFn: ({ key, approve }: { key: string; approve: boolean }) =>
      apiFetch(`/api/pembubaran/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 likuidatorMut = useMutation({
    mutationFn: (fields: Record<string, string | null>) =>
      apiFetch(`/api/pembubaran/submissions/${submissionId}/likuidator`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }),
      }),
    onSuccess: invalidate,
  });
  const kehadiranMut = useMutation({
    mutationFn: (entries: { nama: string; kehadiran: boolean | null }[]) =>
      apiFetch(`/api/pembubaran/submissions/${submissionId}/kehadiran`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries }),
      }),
    onSuccess: invalidate,
  });
  const koranMut = useMutation({
    mutationFn: (body: { namaKoran?: string | null; tanggalPengumuman?: string | null }) =>
      apiFetch(`/api/pembubaran/submissions/${submissionId}/koran`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
      }),
    onSuccess: invalidate,
  });
  const [submitError, setSubmitError] = useState<string | null>(null);
  const submitMut = useMutation({
    mutationFn: () => apiFetch(`/api/pembubaran/submissions/${submissionId}/submit`, { method: "POST" }),
    onMutate: () => setSubmitError(null),
    onSuccess: () => navigate(`/pembubaran/${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 [likDraft, setLikDraft] = useState<Record<string, string> | null>(null);
  const [koranDraft, setKoranDraft] = useState<{ namaKoran: string; tanggalPengumuman: string } | null>(null);
  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, kontak, akta, likuidator, kehadiran, koran, documents, validationResults, sectionApprovals } = data;
  if (submission.status === "COMPLETED") { return <Navigate to={`/pembubaran/${submissionId}/status`} replace />; }

  const flow = computeFlowStep({ type: "PEMBUBARAN_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 koranBase = { namaKoran: koran.namaKoran ?? "", tanggalPengumuman: koran.tanggalKoran ?? "" };

  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>
        );
      })}
    </>
  );

  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="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 ?? "—"}</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 Pembubaran — deed header + dual-deed + notaris pengganti + tanggal RUPS */}
      <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 Pembubaran</h2>
          <ApproveButton sectionKey="akta_pembubaran" />
        </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>

      {/* RUPS & Kehadiran — editable ticks (deed or registry roster; Ps.89(1) ≥3/4) */}
      <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">
            RUPS & Kehadiran Pemegang Saham{" "}
            <Badge variant="outline" className="ml-1 text-[10px]">{kehadiran.source === "registry" ? "roster registri SABH" : kehadiran.source === "deed" ? "roster akta" : "—"}</Badge>
          </h2>
          <ApproveButton sectionKey="rups_kehadiran" />
        </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>
        ))}
        <p className="text-xs text-muted-foreground">Kuorum pembubaran: paling sedikit 3/4 (UUPT Ps.89(1))  dinilai pada Hasil Validasi (advisory).</p>
      </section>

      {/* Likuidator — 12 confirm-able fields + manual editor (READY-state surface) */}
      <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"><UserCheck className="h-4 w-4" /> Likuidator</h2>
          <ApproveButton sectionKey="likuidator" />
        </div>
        {!likuidator.hasDoc && <p className="text-sm text-amber-600">Surat penunjukan likuidator belum diunggah (dokumen wajib).</p>}
        <FieldList keys={LIKUIDATOR_KEYS} documentId={likuidator.documentId} fields={(k) => likuidator.fields[k]} />
        {likuidator.hasDoc && (
          <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">
              {LIKUIDATOR_KEYS.map((k) => (
                <div key={k} className="grid grid-cols-[140px_1fr] items-center gap-1.5">
                  <Label htmlFor={`lik-${k}`} className="truncate text-[11px]">{k.replace("likuidator_", "")}</Label>
                  <Input id={`lik-${k}`} className="h-7 text-xs" defaultValue={likuidator.fields[k]?.value ?? ""}
                    onChange={(e) => setLikDraft((d) => ({ ...(d ?? {}), [k]: e.target.value }))} />
                </div>
              ))}
            </div>
            <div className="flex justify-end">
              <Button size="sm" variant="outline" disabled={!likDraft || likuidatorMut.isPending}
                onClick={() => likDraft && likuidatorMut.mutate(likDraft)}>Simpan Likuidator</Button>
            </div>
          </div>
        )}
      </section>

      {/* Pengumuman Koran — approvable but NOT required (Ps.147 advisory) */}
      <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">Pengumuman Koran <span className="text-xs font-normal text-muted-foreground">(opsional  Ps.147)</span></h2>
          <ApproveButton sectionKey="pengumuman_koran" />
        </div>
        <div className="text-xs text-muted-foreground">
          Terbaca: {koran.namaKoran ?? "—"} · {koran.tanggalKoran ?? "—"} · sumber: {koran.source === "clipping" ? "kliping koran" : koran.source === "akta" ? "tercantum di dalam akta" : "—"}
        </div>
        <div className="grid grid-cols-[160px_1fr] items-center gap-2 pt-1">
          <Label htmlFor="nama-koran" className="text-xs">Nama Koran</Label>
          <Input id="nama-koran" defaultValue={koran.namaKoran ?? ""}
            onChange={(e) => setKoranDraft((d) => ({ ...(d ?? koranBase), namaKoran: e.target.value }))} />
          <Label htmlFor="tanggal-pengumuman" className="text-xs">Tanggal Pengumuman</Label>
          <Input id="tanggal-pengumuman" defaultValue={koran.tanggalKoran ?? ""}
            onChange={(e) => setKoranDraft((d) => ({ ...(d ?? koranBase), tanggalPengumuman: e.target.value }))} />
        </div>
        <div className="flex items-center justify-between">
          <span className="text-xs text-muted-foreground">Ps.147(1): kewajiban likuidator 30 hari SEJAK pembubaran  boleh menyusul (tidak menghalangi submit)</span>
          <Button size="sm" variant="outline" disabled={!koranDraft || koranMut.isPending}
            onClick={() => koranDraft && koranMut.mutate(koranDraft)}>Simpan Koran</Button>
        </div>
      </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" />
        </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 KTP likuidator) divalidasi otomatis  lihat Hasil Validasi.</p>
      </section>

      {/* Data Kontak */}
      <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 Kontak</h2>
          <ApproveButton sectionKey="data_kontak" />
        </div>
        <div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
          <div><div className="text-xs text-muted-foreground">Telepon Perseroan</div>{kontak.teleponPerseroan ?? "—"}</div>
          <div><div className="text-xs text-muted-foreground">Email Perseroan</div>{kontak.emailPerseroan ?? "—"}</div>
        </div>
      </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 Pembubaran PT ke Verifikator</DialogTitle>
            <DialogDescription>
              Pengajuan akan masuk antrean verifikator (Menunggu Verifikasi). 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/PembubaranStatusPage.tsx: copy PeleburanStatusPage.tsx and apply the mechanical diffs — component name PembubaranStatusPage; queryKey/API ["pembubaran-review", …] / /api/pembubaran/...; computeFlowStep({ type: "PEMBUBARAN_PT", ... }); both Navigate targets /pembubaran/${submissionId}/review; VERIF_LABEL.SELESAI = "Disetujui — pembubaran tercatat"; heading Pembubaran PT — {nama} where nama falls back data.perseroan?.nama ?? akta nama_perseroan ?? "PT" (the review-data payload carries perseroan); keep the base-ui render={<Link to="/dashboard" />} idiom.
  • [ ] Minimal implementation C — frontend/src/routes.tsx: import the three pages; add in the notaris children next to the peleburan trio:
          { path: "pembubaran/:submissionId/extraction", element: <PembubaranExtractionPage /> },
          { path: "pembubaran/:submissionId/review", element: <PembubaranReviewPage /> },
          { path: "pembubaran/:submissionId/status", element: <PembubaranStatusPage /> },
  • [ ] Run GREEN: cd frontend && bunx vitest run src/pages/__tests__/PembubaranReviewPage.test.tsx && bunx vitest run.
  • [ ] Typecheck: cd frontend && bunx tsc --noEmit.
  • [ ] Commit: git add frontend/src/pages/PembubaranReviewPage.tsx frontend/src/pages/PembubaranStatusPage.tsx frontend/src/routes.tsx frontend/src/pages/__tests__/PembubaranReviewPage.test.tsx && git commit -m "feat(pembubaran): review + status pages (flat sections, staged submit, inline 422) + routes"

Task 14: End-to-end integration test (engine run → focused passes → tuned matrix → company select → approvals → FAIL-gate → verifikator staging)

One backend test that walks the WHOLE flow through the real engine + routes (peleburan-integration precedent): seeded DONE documents (so no OCR calls), a mocked 3B fetch, runFlow with the real registry entry, the env-robust company-selection pause (test-setup blanks SABH creds → the single-source lookup yields no high match → AWAITING_COMPANY_SELECTION → resume via the type-agnostic POST /api/perubahan/select-company — proving warning 4's "single-source only" wiring end-to-end), the focused likuidator pass + deed-inline koran, the tuned matrix persisted (advisory WARNING never FAIL; tuned-out rows absent), section approvals via routes, and the staged submit.

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

Steps

  • [ ] Write the test. Create backend/src/flow-engine/__tests__/pembubaran-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 { pembubaranPtFlow } from "../flows/pembubaran-pt";

/**
 * PEMBUBARAN_PT end-to-end (engine + routes; laporan-rups/peleburan-integration
 * harness style). Docs seeded extractionStatus DONE (no OCR); global fetch mocked
 * (3B likuidator + inline koran). SABH creds are blanked by test-setup.ts, so the
 * single-source lookup pauses in AWAITING_COMPANY_SELECTION (env-robust: a READY
 * short-circuit is also accepted) and resumes through the type-agnostic
 * select-company route — the engine's single-source branch, never multi-source.
 */
const AKTA_RAW =
  "AKTA PEMBUBARAN PT MAJU MUNDUR\nNomor 5, tanggal 30 April 2026\n" +
  "RUPS tanggal 28 April 2026 memutuskan membubarkan Perseroan dan menunjuk likuidator.\n" +
  "Pengumuman dimuat dalam surat kabar Media Indonesia tanggal 10 Mei 2026.";
const LIK_RAW =
  "SURAT PENUNJUKAN LIKUIDATOR\nNomor: 017/SPL/V/2026\n" +
  "Menunjuk BUDI HARTONO selaku likuidator, Jalan Melati Raya No. 12, RT. 004/RW. 007,\n" +
  "Kelurahan Cempaka Putih, Kecamatan Menteng, Kode Pos 10310\n" +
  "Dasar penunjukan: Keputusan RUPS tanggal 28 April 2026";

describe("PEMBUBARAN_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; anything else in-process (app.request) never
    // hits fetch. Likuidator call answered by field shape; koran by docContext.
    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 ?? "");
      if (body.includes("surat penunjukan likuidator")) {
        return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
          nama: "BUDI HARTONO", nomor_surat: "017/SPL/V/2026", tanggal_surat: null,
          alamat: "Jalan Melati Raya No. 12", rt: null, rw: null, kode_pos: null,
          kelurahan: "Cempaka Putih", kecamatan: "Menteng", telepon: null, email: null,
          dasar_penunjukan: "Keputusan RUPS tanggal 28 April 2026",
        }) } }] }), { status: 200 });
      }
      return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
        tanggal_koran: "10 Mei 2026", nama_koran: "Media Indonesia",
      }) } }] }), { status: 200 });
    }) 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}.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→submit→verifikator end-to-end", async () => {
    const sub = await db.submission.create({ data: { type: "PEMBUBARAN_PT", status: "CREATED" } });
    ids.push(sub.id);
    const akta = await seedDoc("AKTA", "AKTA_PEMBUBARAN", 0, sub.id, AKTA_RAW, [
      { fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR" },
      { fieldKey: "nomor_akta", value: "5" },
      { fieldKey: "tanggal_akta", value: "2026-04-30" },
      { fieldKey: "tanggal_rups", value: "2026-04-28" },
      { fieldKey: "ref_pendirian_nomor_sk", value: "AHU-0001.AH.01.01.TAHUN 2019" },
    ]);
    const lik = await seedDoc("SURAT_LIKUIDATOR", "SURAT_LIKUIDATOR", 4, sub.id, LIK_RAW);

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

    // Env-robust single-source pause: no SABH in tests → AWAITING; resume via the
    // TYPE-AGNOSTIC select-company route (warning 4: no multi-source machinery).
    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 MAJU MUNDUR", tahun: 2019 }),
      });
      expect(sel.status).toBe(200);
      // continueFlow is fire-and-forget from the route — poll to READY.
      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");

    // Focused likuidator pass persisted grounded rows (deed passes ran via hook).
    const likRows = await db.extractedField.findMany({ where: { documentId: lik.id } });
    expect(likRows.length).toBe(12);
    expect(likRows.find((r) => r.fieldKey === "likuidator_nama")?.value).toBe("BUDI HARTONO");
    expect(likRows.find((r) => r.fieldKey === "likuidator_rt")?.value).toBe("004"); // deterministic tier
    // Deed-inline koran (zero clippings uploaded).
    const koranRow = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: akta.id, fieldKey: "nama_koran" } } });
    expect(koranRow?.value).toBe("Media Indonesia");

    // Tuned matrix persisted; advisory stays WARNING/PASS (never FAIL); tuned-out rows absent.
    const results = await db.validationResult.findMany({ where: { submissionId: sub.id } });
    const byCode = new Map(results.map((r) => [r.ruleCode, r.status]));
    expect(byCode.has("PEMBUBARAN_KORAN")).toBe(true);
    expect(byCode.get("PEMBUBARAN_KORAN")).not.toBe("FAIL");
    expect(byCode.has("LIKUIDATOR_SURAT_COMPLETE")).toBe(true);
    expect(byCode.has("LIKUIDATOR_KTP_MATCH")).toBe(true);
    expect(byCode.has("RUPS_ATTENDANCE_QUORUM")).toBe(true);
    expect(byCode.has("MODAL_BUKTI_SETOR")).toBe(false);
    expect(byCode.has("REQUIRED_SUPPORTING_DOCS")).toBe(false);

    // Approve the 6 required sections through the routes; then clear/override any
    // FAILs the seeded data produced (manual selection → oldData null → PERSEROAN_STATE
    // SKIPs; likuidator has no KTP → WARNING; so typically no FAILs here).
    for (const key of ["perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]) {
      const res = await app.request(`/api/pembubaran/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/pembubaran/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__/pembubaran-integration.test.ts. Debug any cross-task seams it exposes (this test exists to catch them). If the engine's Phase-4 change-type detection needs aktaPerubahan includes that the seeded doc lacks, it tolerates empty rows by design — do not seed jenis data.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/__tests__/pembubaran-integration.test.ts && git commit -m "test(pembubaran): end-to-end integration — engine run, single-source pause/resume, tuned matrix, staged submit"

Task 15: Full-suite + typecheck gate

The whole-repo green gate before handoff.

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 pembubaran-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 (PEMBUBARAN_PP collision, spec §7): grep -rn 'includes("PEMBUBARAN")\|startsWith("PEMBUBARAN")\|PEMBUBARAN_P[TP]"' backend/src frontend/src --include='*.ts' --include='*.tsx' | grep -v __tests__ | grep -iv 'PEMBUBARAN_PT\|PEMBUBARAN_PP'. Expected: no output (no substring matching on the shared prefix anywhere).
  • [ ] Commit (only if fixes were needed): git add -A && git commit -m "test(pembubaran): full-suite + typecheck gate green for the Pembubaran PT flow". If nothing changed, record the gate evidence in the task handoff instead.

Self-Review

Spec coverage (2026-07-02-pembubaran-pt-design.md):
- §2/§4.4 FlowConfig: born v2-generic, no fork — Task 6. engine, primaryAkta, skipExtractionTypes, companyLookup: true, extraction.overrideByClassifiedType, focusedPasses (both), buildContext, rules, requiredDocs, sections, rematch, hooks.afterAktaExtraction, labels — all present. Deviations from the spec sketch, each adjudicated inline: routeFamily: "pembubaran" not 'perubahan' (landed per-family template; the union gains the value; the type-agnostic select-company route is reused, PERUBAHAN_FAMILY untouched); rematch.preValidationSteps: [matchRupsAttendance] replaced by the canonical rupsAttendance: true flag (the engine grew it after the spec); hooks.onFinalize: recordLocalDissolution dropped (nothing invokes onFinalize; COMPLETED submissions ARE the local record — pp-pembubaran precedent, PT_ALREADY_DISSOLVED queries them directly); checklistResetDocTypes uses the checklist-LABEL vocabulary and omits SURAT_LIKUIDATOR/BUKTI_PENGUMUMAN (no checklist rows exist for them).
- §4.2 routing: BUILT flip + prompt cue + BERAKHIRNYA_PACKAGE (canonical form, carrier-count clause, owned in submissions.ts) + rung BELOW Akuisisi — Task 7, with create-route supported:false test and FE suppression mirror (Task 11).
- §4.3 extraction: perubahan-override hybrid (Task 6), dual-deed akta_kedua persistence gap CLOSED (Task 3 — spec open question 2's fallback direction), tanggal_rups/notaris_pengganti already persisted (verified during planning), likuidator extractor penunjukan-only w/ deterministic-first + token-wise dasar grounding (Task 2), shared clipping extractor consumed via one map entry + deed-inline fallback w/ zero-clipping guard (Tasks 2/3/6), registry-roster attendance fallback + rupsKehadiranRegistry (Tasks 1/3/4).
- §4.5 classification: SURAT_LIKUIDATOR scoped re-check (deterministic tiers + 3B tail, suggestion-only) — Tasks 8/11; BUKTI_PENGUMUMAN keyword pre-suggester already shared (no change); mapToDocumentType/getProcessingOrder — Task 7.
- §4.6 matrix: 13 wrapped + ¾-quorum(≥) + 4 new — Task 5. REQUIRED_SUPPORTING_DOCS/UPLOADED_DOC_UNUSED exclusion is an explicit adjudicated deviation (jenis-scoped bodies; false-WARNING on the BA); PERSEROAN_STATE covers the active/not-dissolved/blocked registry gate; PEMBUBARAN_KORAN never-FAIL structurally pinned.
- §4.7 sections/review: 7 sections + validasi row; explicit PerubahanSectionApproval; submit gate = 6 static keys — Tasks 6/9/13. Thin mirrors, no ReviewEngine.
- §5 wiring checklist: items 1-12 mapped to Tasks 1,6,7,7,1,6(rematch-proof),7,2,9(own family instead of perubahan.ts widening — adjudicated),3,11-13,10 respectively.
- §7 gotchas: rematch dispatch (Task 6 test), explicit approvals, exhaustive switches (Task 1), PP collision (Tasks 5/9/10/15), koran advisory (Task 5), classifier scope (Tasks 8/11), extraction override never re-classifies (engine passes the subtype — Task 6), Berakhirnya gate (Task 7), registry outage degrades (async partition + SKIPs), newsprint best-effort (Task 2 semantics).
- §8 testing strategy: every listed case has a home (routing Task 7; registry/wipe Task 6; likuidator fixtures Task 2; koran Task 3/5/14; rules Task 5; RUPS fallback Task 3/4; submit gate Task 9; classification Task 8; regression Tasks 7/10/15). Whole-flow warnings: (1) READY-state surfaces = review-page cards + PUT endpoints (Tasks 9/13); (2) partial-verification WARNINGs name the unchecked (Task 5); (3) decide-APPROVE E2E + roster pin (Task 10); (4) single-source only, multi-source untouched (Tasks 6/14 assertions).
- Inherited triggers: ≥-comparator (Task 5, wrapper-only, default preserved + regression); shared clipping extractor consumed not rebuilt (Task 6) with the count-guard partial-write hardening APPLIED (Task 2 — atomic createMany, both consumers).

Placeholder scan: no TODO/TBD/...-elision inside code blocks; every test has RED and GREEN commands; every task ends in an exact commit. Two intentional adapt-notes flag schema-shape checks the implementer must verify against generated Prisma types (transformAktaPerubahanResult call shape in Task 3; satellite-row required columns in Task 3's seeds) — both bounded with "keep the assertions".

Type consistency: PembubaranCtx extends PtAktaCtx so pickRules(PT_AKTA_RULE_SET, …) assigns without casts (peleburan precedent); LIKUIDATOR_EXTRACTED_FIELD_KEYS is the single source of truth consumed by extractor/context/rules/routes/FE key lists; rupsKehadiranRegistry JSON shape {nama, persentase, kehadiran} is written by the matcher (Task 3), read by the context (Task 4), review-data + PUT /kehadiran (Task 9), and the FE (Tasks 10/13) — identical field names throughout; canonical envelopes are byte-identical strings in Tasks 9's code and tests; ValidationStatus spellings use no -ED anywhere; confidence: 1 present on every submissionDocument.create seed.

Risk notes for the executor: Task 7 is the point of no return (launch switch) — do not start it until Tasks 1-6 are green; Task 9's kehadiran deed-path test seeds aktaPemegangSaham (check required columns against schema); Task 14 is deliberately tolerant of the SABH-less environment (AWAITING→manual-select path) — if it short-circuits to READY on some env, the assertions still hold.