think
16px
820px

Akuisisi PT (Pengambilalihan) 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 a working AKUISISI_PT OCR flow — upload akta pengambilalihan → route → extract (reusing the perubahan cap-table extractor) → SABH lookup → diff → cross-validate → review → verifikator → finalize — reusing the perubahan pipeline, adding only the acquisition net-new (required koran + advisory 30-day window + attestation-lite legal path).

Architecture: A separate AKUISISI_PT SubmissionType with akuisisi-processor.ts as a thin fork of perubahan-processor.ts (3 hooks: force peralihan_saham, extract+require koran, none for attestation — that is user-entered). Extraction reuses the perubahan akta extractor via AKTA_AKUISISI → akta_subtype 'perubahan'. Routes reuse the perubahan endpoints by relaxing their type-guards to a perubahan-family set; PerubahanSectionApproval + VerifikasiPerubahan tables are shared (keyed by submissionId). Frontend mirrors the perubahan pages/hooks parameterized by type.

Tech Stack: Bun + Hono + TypeScript; Prisma 7 / PostgreSQL; bun:test; React 19 + Vite; on-prem vLLM (3B cleanup CLEANUP_LLM_URL for the focused koran pass).

Global Constraints

  • Spec: docs/superpowers/specs/2026-07-01-akuisisi-pt-flow-design.md. Reuse-heavy: fork thin, do not rewrite the perubahan pipeline.
  • AKUISISI_PT is a real Prisma enum value → the dispatchProcessor + processorKindForType const _exhaustive: never switches will not compile until the case is added. reMatchAndValidate MUST branch to AKUISISI_PT or the default path wipes validations ([[reference_rematch_dispatch]]).
  • Per-jenis review sections need EXPLICIT persisted PerubahanSectionApproval — the submit gate reads it ([[reference_perubahan_section_approval]]).
  • SABH parity / single-subject: acquirer = a shareholder row in the target's roster, never a resolved/linked entity. No two-party modeling.
  • Koran window is WARNING-only, never FAIL (Pasal 127(8) exempts direct acquisitions). Koran fields are required-for-review (mirror SABH) but their absence is a review gap, not an OCR error.
  • Attestation-lite = user-entered legal-path radios {jenisPerseroan: TERTUTUP|TERBUKA, caraPengambilalihan: LANGSUNG|MELALUI_DIREKSI, deedType: AKTA_NOTARIS|DI_BAWAH_TANGAN} persisted to Submission.akuisisiAttestation; NO conditional-doc/OJK/Rancangan validation.
  • Test DB ahu_ocr_test via bunfig preload ([[feedback_test_db_isolation]]); classifier already emits AKTA_AKUISISI (built last session).
  • ValidationStatus values: PASS | WARNING | FAIL | SKIPPED (exact).

File Structure

File Responsibility Action
backend/prisma/schema.prisma AKUISISI_PT enum + Submission.akuisisiAttestation Json? Modify
backend/src/ocr/classifier.ts AKTA_AKUISISI → 'perubahan' subtype map Modify
backend/src/services/akta-txn-classifier.ts akuisisiBUILT_AKTA_TXN_TYPES Modify
backend/src/schema/akta-perubahan.ts tanggal_koran_akuisisi/nama_koran_akuisisi fields + schema doc Modify
backend/src/services/akuisisi-processor.ts Thin fork of perubahan-processor (forced peralihan + koran require) Create
backend/src/services/akta-koran-extract.ts Focused grounded koran extraction (extractKoranViaFocusedLlm) Create
backend/src/services/document-processor.ts Thread classificationOverride into akta path + koran hook Modify
backend/src/services/cross-validator.ts validateAkuisisiKoranWindow (WARNING) + ValidationInput.tanggalKoran Modify
backend/src/services/submission-dispatch.ts akuisisi-pt ProcessorKind + 2 switch cases Modify
backend/src/services/submission-processor.ts reMatchAndValidateAKUISISI_PT branch Modify
backend/src/routes/submissions.ts inferSubmissionType/mapToDocumentType/getProcessingOrder Modify
backend/src/routes/perubahan.ts Relax type-guards to {PERUBAHAN_PT, AKUISISI_PT}; akuisisi attestation shape + submit gate Modify
backend/src/routes/klasifikasi.ts AKUISISI_DOC_TYPES + ?type=akuisisi Modify
frontend/src/lib/classifier-labels.ts, components/klasifikasi/detect-banner.tsx, pages/KlasifikasiPage.tsx, lib/flow-steps.ts, lib/destination-for.ts, routes.tsx Klasifikasi surfacing + routing Modify
frontend/src/pages/AkuisisiExtractionPage.tsx, AkuisisiDeltaReviewPage.tsx Thin mirrors of the perubahan pages Create

Phasing: A (backend core: route + process AKTA_AKUISISI end-to-end, API-testable) → B (backend routes: shared-guard relax + akuisisi attestation/submit) → C (frontend surfacing + pages). Each phase is independently testable.


Phase A — Backend core

Task 1: Recognize + route AKUISISI_PT (schema, subtype map, built set, inference)

Files:
- Modify: backend/prisma/schema.prisma (enum SubmissionType, model Submission)
- Modify: backend/src/ocr/classifier.ts:52-69 (AKTA_SUBTYPE_MAP, getAktaSubtype)
- Modify: backend/src/services/akta-txn-classifier.ts (BUILT_AKTA_TXN_TYPES)
- Modify: backend/src/routes/submissions.ts (inferSubmissionType L82-96, mapToDocumentType L26, getProcessingOrder L47)
- Test: backend/src/routes/__tests__/akuisisi-routing.test.ts

Interfaces:
- Produces: SubmissionType.AKUISISI_PT; getAktaSubtype({classification:"AKTA_AKUISISI"}) === "perubahan"; inferSubmissionType([{classification:"AKTA_AKUISISI"}]) === "AKUISISI_PT"; BUILT_AKTA_TXN_TYPES.has("akuisisi").

  • [ ] Step 1: Write the failing test
// backend/src/routes/__tests__/akuisisi-routing.test.ts
import { describe, it, expect } from "bun:test";
import { inferSubmissionType, mapToDocumentType, getProcessingOrder } from "../submissions";
import { getAktaSubtype } from "../../ocr/classifier";
import { BUILT_AKTA_TXN_TYPES } from "../../services/akta-txn-classifier";

describe("AKUISISI_PT recognition + routing", () => {
  it("akuisisi is a built txn type", () => {
    expect(BUILT_AKTA_TXN_TYPES.has("akuisisi")).toBe(true);
  });
  it("AKTA_AKUISISI extracts via the perubahan subtype (hybrid reuse)", () => {
    expect(getAktaSubtype({ classification: "AKTA_AKUISISI", confidence: 1 })).toBe("perubahan");
  });
  it("infers AKUISISI_PT, before PERUBAHAN_PT", () => {
    expect(inferSubmissionType([{ classification: "AKTA_AKUISISI" }])).toBe("AKUISISI_PT");
    // and does not perturb existing routing
    expect(inferSubmissionType([{ classification: "AKTA_PERUBAHAN" }])).toBe("PERUBAHAN_PT");
  });
  it("AKTA_AKUISISI maps to AKTA doc-type + processing order 0", () => {
    expect(mapToDocumentType("AKTA_AKUISISI")).toBe("AKTA");
    expect(getProcessingOrder("AKTA_AKUISISI")).toBe(0);
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: cd backend && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: FAIL — getAktaSubtype throws on AKTA_AKUISISI; inferSubmissionType returns PENDIRIAN_PT; BUILT_AKTA_TXN_TYPES lacks akuisisi.

  • [ ] Step 3: Write minimal implementation

schema.prisma — add to enum SubmissionType after PERUBAHAN_PT:

  AKUISISI_PT

schema.prisma — add to model Submission after selectedTahun Int?:

  akuisisiAttestation    Json?

Then: cd backend && bunx prisma generate.

classifier.tsAKTA_SUBTYPE_MAP gains a line (hybrid: akuisisi extracts as perubahan):

const AKTA_SUBTYPE_MAP: Record<string, AktaSubtype> = {
  AKTA_PENDIRIAN: "pendirian",
  AKTA_PERUBAHAN: "perubahan",
  AKTA_AKUISISI: "perubahan",
};

akta-txn-classifier.ts — add to BUILT_AKTA_TXN_TYPES:

  "akuisisi",

submissions.ts mapToDocumentType already returns "AKTA" for any AKTA_* (Task 6 of the classifier plan added the prefix rule) — confirm AKTA_AKUISISI → AKTA and getProcessingOrder → 0 already hold via the startsWith("AKTA_") guards; if not present, add the explicit lines. inferSubmissionType — add BEFORE the AKTA_PERUBAHAN check:

  if (files.some((f) => eff(f) === "AKTA_AKUISISI")) return "AKUISISI_PT";

and extend the return-type union with | "AKUISISI_PT".

  • [ ] Step 4: Run test to verify it passes

Run: cd backend && bunx prisma generate && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: PASS (4 tests).

  • [ ] Step 5: Commit
git add backend/prisma/schema.prisma backend/src/ocr/classifier.ts backend/src/services/akta-txn-classifier.ts backend/src/routes/submissions.ts backend/src/routes/__tests__/akuisisi-routing.test.ts
git commit -m "feat(akuisisi): recognize AKTA_AKUISISI + route AKUISISI_PT (enum, subtype map, built set, inference)"

Task 2: akuisisi-processor.ts — thin fork of the perubahan processor

Files:
- Create: backend/src/services/akuisisi-processor.ts (fork of backend/src/services/perubahan-processor.ts)
- Test: backend/src/services/__tests__/akuisisi-processor.test.ts

Interfaces:
- Consumes: shared services company-lookup (lookupCompany, loadOldData, loadPerseroanBlokirState, lookupNotarisIdByName), change-type-detector (detectChangeTypes), cross-validator (runAllValidations, validateRupsAttendanceQuorum), document-processor (processDocument).
- Produces: processAkuisisiSubmission(submissionId: string): Promise<void>, continueAkuisisiProcessing(submissionId: string): Promise<void>, runAkuisisiCrossValidation(submissionId: string): Promise<void>, reMatchAndValidateAkuisisi(submissionId: string): Promise<void>.

Fork instruction (this IS the implementation — the processor is ~95% identical): copy perubahan-processor.tsakuisisi-processor.ts verbatim, then apply exactly these diffs. Do NOT reproduce the file from scratch.
1. Rename exports: processPerubahanSubmission→processAkuisisiSubmission, continuePerubahanProcessing→continueAkuisisiProcessing, runPerubahanCrossValidation→runAkuisisiCrossValidation, reMatchAndValidatePerubahan→reMatchAndValidateAkuisisi. Update the internal call from continuePerubahanProcessingcontinueAkuisisiProcessing and runPerubahanCrossValidationrunAkuisisiCrossValidation.
2. Phase-1 akta lookup: change d.classifiedType === "AKTA_PERUBAHAN""AKTA_AKUISISI" (both in processAkuisisiSubmission and continueAkuisisiProcessing).
3. HOOK — force peralihan_saham. In continueAkuisisiProcessing, immediately after the const changeTypeResult = detectChangeTypes(...) line (before persisting it), insert:

   // AKUISISI: peralihan saham is the definitional change — force it on.
   if (!changeTypeResult.checkedItems.nonpad.includes("peralihanSaham")) {
     changeTypeResult.checkedItems.nonpad.push("peralihanSaham");
     changeTypeResult.categories.nonpad = true;
   }
  1. reMatchAndValidateAkuisisi re-detects change types near its end — apply the identical force-peralihan insert there too, before it persists / before runAkuisisiCrossValidation.
  2. Everything else (Phase 2 lookup, Phase 3 remaining docs + contact matching, Phase 5 runAllValidations+validateRupsAttendanceQuorum+upsert, the ValidationInput construction) stays verbatim — it reads the persisted changeTypeResult, so the forced peralihan flows through automatically. Do NOT add koran/attestation logic here (koran = Task 5, attestation = user-entered in Task 8).
  • [ ] Step 1: Write the failing test (unit test the one behavioral delta — forced peralihan — via a DB fixture)
// backend/src/services/__tests__/akuisisi-processor.test.ts
import { describe, it, expect, afterEach } from "bun:test";
import { db } from "../../lib/db";

const subIds: string[] = [];
afterEach(async () => { for (const id of subIds) await db.submission.deleteMany({ where: { id } }).catch(() => {}); subIds.length = 0; });

describe("akuisisi-processor exports + forced peralihan", () => {
  it("exports the four fork functions", async () => {
    const m = await import("../akuisisi-processor");
    expect(typeof m.processAkuisisiSubmission).toBe("function");
    expect(typeof m.continueAkuisisiProcessing).toBe("function");
    expect(typeof m.runAkuisisiCrossValidation).toBe("function");
    expect(typeof m.reMatchAndValidateAkuisisi).toBe("function");
  });
  it("forces peralihanSaham into changeTypeResult (helper is applied)", async () => {
    // The force logic is inlined; assert it via a small exported pure helper.
    const { forcePeralihanOn } = await import("../akuisisi-processor");
    const ct = { categories: { pad: false, ppad: false, nonpad: false }, checkedItems: { pad: [], ppad: [], nonpad: [] }, requiredDocuments: [], unmatchedItems: [] };
    const out = forcePeralihanOn(ct);
    expect(out.checkedItems.nonpad).toContain("peralihanSaham");
    expect(out.categories.nonpad).toBe(true);
  });
});

To make the force logic testable, extract it into a tiny exported pure helper in akuisisi-processor.ts and call it from both hook sites:

import type { ChangeTypeDetectionResult } from "./change-type-detector";
export function forcePeralihanOn(ct: ChangeTypeDetectionResult): ChangeTypeDetectionResult {
  if (!ct.checkedItems.nonpad.includes("peralihanSaham")) {
    ct.checkedItems.nonpad.push("peralihanSaham");
    ct.categories.nonpad = true;
  }
  return ct;
}

Both hook sites become forcePeralihanOn(changeTypeResult);.

  • [ ] Step 2: Run test to verify it fails

Run: cd backend && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: FAIL — module ../akuisisi-processor not found.

  • [ ] Step 3: Write minimal implementation — perform the fork per the Fork instruction above, including the forcePeralihanOn helper.

  • [ ] Step 4: Run test + typecheck

Run: cd backend && bunx tsc --noEmit && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: tsc clean; PASS (2 tests). (ChangeTypeDetectionResult is exported from change-type-detector.ts — confirm/add the export.)

  • [ ] Step 5: Commit
git add backend/src/services/akuisisi-processor.ts backend/src/services/__tests__/akuisisi-processor.test.ts backend/src/services/change-type-detector.ts
git commit -m "feat(akuisisi): akuisisi-processor as thin fork of perubahan (force peralihan saham)"

Task 3: Wire the dispatch + reMatchAndValidate to the akuisisi processor

Files:
- Modify: backend/src/services/submission-dispatch.ts (ProcessorKind L4-13, processorKindForType L22-48, dispatchProcessor L56-110)
- Modify: backend/src/services/submission-processor.ts (reMatchAndValidate L709-763)
- Test: backend/src/routes/__tests__/akuisisi-routing.test.ts (extend)

Interfaces:
- Consumes: processAkuisisiSubmission, reMatchAndValidateAkuisisi (Task 2).
- Produces: processorKindForType("AKUISISI_PT") === "akuisisi-pt".

  • [ ] Step 1: Write the failing test (append)
import { processorKindForType } from "../../services/submission-dispatch";
describe("AKUISISI_PT dispatch", () => {
  it("routes AKUISISI_PT to the akuisisi processor kind", () => {
    expect(processorKindForType("AKUISISI_PT")).toBe("akuisisi-pt");
  });
});
  • [ ] Step 2: Run to verify it fails

Run: cd backend && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: FAIL — processorKindForType has no AKUISISI_PT case (and tsc would flag the never switch).

  • [ ] Step 3: Implement

submission-dispatch.ts — add | "akuisisi-pt" to ProcessorKind; add case to processorKindForType:

    case "AKUISISI_PT":
      return "akuisisi-pt";

add case to dispatchProcessor:

    case "akuisisi-pt": {
      const { processAkuisisiSubmission } = await import("./akuisisi-processor");
      return processAkuisisiSubmission(submissionId);
    }

submission-processor.ts — in reMatchAndValidate, after the PERBAIKAN_DATA_PT branch (before the PP flows), add:

  if (submission?.type === "AKUISISI_PT") {
    const { reMatchAndValidateAkuisisi } = await import("./akuisisi-processor");
    return reMatchAndValidateAkuisisi(submissionId);
  }
  • [ ] Step 4: Run test + full typecheck (the never switches must now compile)

Run: cd backend && bunx tsc --noEmit && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: tsc clean (exhaustiveness satisfied); PASS.

  • [ ] Step 5: Commit
git add backend/src/services/submission-dispatch.ts backend/src/services/submission-processor.ts backend/src/routes/__tests__/akuisisi-routing.test.ts
git commit -m "feat(akuisisi): dispatch + reMatchAndValidate route AKUISISI_PT (exhaustiveness + no validation-wipe)"

Task 4: Thread classificationOverride into the akta extraction path

Files:
- Modify: backend/src/services/document-processor.ts (processAktaViaAzureOnPrem L370-418, processAktaViaGpuServer, the akta dispatch L2416-2420)
- Test: backend/src/services/__tests__/akuisisi-processor.test.ts (extend — pure-ish: assert the subtype resolution honors the override)

Interfaces:
- Produces: when processDocument(documentId, { classificationOverride: "AKTA_AKUISISI" }) runs the akta path, the extraction uses akta_subtype "perubahan" (via getAktaSubtype) rather than re-classifying.

  • [ ] Step 1: Write the failing test
import { resolveAktaSubtype } from "../document-processor";
describe("akta subtype honors classificationOverride", () => {
  it("uses the override label over a re-classify", () => {
    expect(resolveAktaSubtype("AKTA_AKUISISI", { classification: "AKTA_PERUBAHAN", confidence: 1 })).toBe("perubahan");
    expect(resolveAktaSubtype(undefined, { classification: "AKTA_PENDIRIAN", confidence: 1 })).toBe("pendirian");
  });
});
  • [ ] Step 2: Run to verify it fails

Run: cd backend && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: FAIL — resolveAktaSubtype not exported.

  • [ ] Step 3: Implement — in document-processor.ts, add + use a small exported helper, and thread options into the akta path:
import { getAktaSubtype, type ClassificationResult } from "../ocr/classifier";
export function resolveAktaSubtype(override: string | undefined, classified: ClassificationResult) {
  return getAktaSubtype(override ? { classification: override, confidence: 1 } : classified);
}

In processAktaViaAzureOnPrem(documentId, fileBuffer, filename, options?) (add the options?: { classificationOverride?: string } param), replace const aktaSubtype = getAktaSubtype(classificationResult); with const aktaSubtype = resolveAktaSubtype(options?.classificationOverride, classificationResult);. In the akta dispatch (L2416-2420) pass options through to processAktaViaAzureOnPrem/processAktaViaGpuServer.

  • [ ] Step 4: Run test + typecheck

Run: cd backend && bunx tsc --noEmit && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: PASS.

  • [ ] Step 5: Commit
git add backend/src/services/document-processor.ts backend/src/services/__tests__/akuisisi-processor.test.ts
git commit -m "feat(akuisisi): akta extraction honors classificationOverride (AKTA_AKUISISI extracts as perubahan)"

Task 5: Focused koran extraction (extractKoranViaFocusedLlm)

Files:
- Modify: backend/src/schema/akta-perubahan.ts (add tanggal_koran_akuisisi/nama_koran_akuisisi to AktaPerubahanData + schema doc)
- Create: backend/src/services/akta-koran-extract.ts
- Modify: backend/src/services/document-processor.ts (call the koran pass on the akuisisi akta after transform)
- Test: backend/src/services/__tests__/akta-koran-extract.test.ts

Interfaces:
- Produces: extractKoranViaFocusedLlm(rawText: string): Promise<{ tanggal_koran: string | null; nama_koran: string | null }> — a grounded single-field pass mirroring apostille recovery: reads CLEANUP_LLM_URL/CLEANUP_LLM_MODEL; returns nulls when unset/absent; rejects values not present verbatim in rawText.

  • [ ] Step 1: Write the failing test (fetch-mock pattern, from doc-forensic-client.test.ts)
// backend/src/services/__tests__/akta-koran-extract.test.ts
import { describe, it, expect, afterEach, mock } from "bun:test";
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; delete process.env.CLEANUP_LLM_URL; });
async function fresh() { return await import("../akta-koran-extract"); }

describe("extractKoranViaFocusedLlm", () => {
  it("returns nulls when LLM url unset (graceful)", async () => {
    const { extractKoranViaFocusedLlm } = await fresh();
    expect(await extractKoranViaFocusedLlm("apa pun")).toEqual({ tanggal_koran: null, nama_koran: null });
  });
  it("extracts + grounds koran fields present in OCR text", async () => {
    process.env.CLEANUP_LLM_URL = "http://llm.local/v1";
    globalThis.fetch = mock(async () =>
      new Response(JSON.stringify({ choices: [{ message: { content: '{"tanggal_koran":"10 Januari 2026","nama_koran":"Berita Negara"}' } }] })),
    ) as never;
    const { extractKoranViaFocusedLlm } = await fresh();
    const r = await extractKoranViaFocusedLlm("...diumumkan dalam Berita Negara tanggal 10 Januari 2026...");
    expect(r.nama_koran).toBe("Berita Negara");
    expect(r.tanggal_koran).toContain("Januari");
  });
  it("drops a hallucinated koran name not present in OCR text", async () => {
    process.env.CLEANUP_LLM_URL = "http://llm.local/v1";
    globalThis.fetch = mock(async () =>
      new Response(JSON.stringify({ choices: [{ message: { content: '{"tanggal_koran":null,"nama_koran":"Kompas"}' } }] })),
    ) as never;
    const { extractKoranViaFocusedLlm } = await fresh();
    const r = await extractKoranViaFocusedLlm("deed text without that newspaper");
    expect(r.nama_koran).toBeNull();
  });
});
  • [ ] Step 2: Run to verify it fails

Run: cd backend && bun test src/services/__tests__/akta-koran-extract.test.ts
Expected: FAIL — module not found.

  • [ ] Step 3: Implement akta-koran-extract.ts:
/** Focused, grounded koran extraction for AKTA_AKUISISI (mirrors apostille single-field recovery). */
export async function extractKoranViaFocusedLlm(
  rawText: string,
): Promise<{ tanggal_koran: string | null; nama_koran: string | null }> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  const NONE = { tanggal_koran: null, nama_koran: null };
  if (!url || !rawText.trim()) return NONE;
  const system =
    "From an Indonesian akta pengambilalihan (akuisisi), extract the newspaper announcement (pengumuman surat kabar): " +
    "tanggal_koran (publication date) and nama_koran (newspaper/gazette name, e.g. 'Berita Negara'). " +
    "Copy VERBATIM from the text; if absent, return null. Never invent. Return ONLY JSON {tanggal_koran, nama_koran}.";
  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: { tanggal_koran: { type: ["string", "null"] }, nama_koran: { type: ["string", "null"] } }, required: ["tanggal_koran", "nama_koran"], additionalProperties: false },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return NONE;
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return NONE;
    const parsed = JSON.parse(m[0]) as { tanggal_koran?: string | null; nama_koran?: string | null };
    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
    const hay = norm(rawText);
    const ground = (v: string | null | undefined) => { const s = v ? String(v).trim() : ""; return s && hay.includes(norm(s)) ? s : null; };
    return { tanggal_koran: ground(parsed.tanggal_koran), nama_koran: ground(parsed.nama_koran) };
  } catch { return NONE; }
}

Add the two fields to AktaPerubahanData in akta-perubahan.ts (after peralihan_saham) + a one-line note in the schema doc. In document-processor.ts, after transforming an akuisisi akta (subtype perubahan AND override was AKTA_AKUISISI), call extractKoranViaFocusedLlm(ocrResult.rawText) and persist the two values as ExtractedFields (tanggal_koran_akuisisi, nama_koran_akuisisi) on the akta document (reuse the existing ExtractedField upsert used for other akta fields).

  • [ ] Step 4: Run test + typecheck — Expected: PASS + tsc clean.

  • [ ] Step 5: Commit

git add backend/src/services/akta-koran-extract.ts backend/src/schema/akta-perubahan.ts backend/src/services/document-processor.ts backend/src/services/__tests__/akta-koran-extract.test.ts
git commit -m "feat(akuisisi): focused grounded koran extraction + akta schema fields"

Task 6: AKUISISI_KORAN_WINDOW advisory validation

Files:
- Modify: backend/src/services/cross-validator.ts (add validateAkuisisiKoranWindow; add tanggalKoran to ValidationInput)
- Modify: backend/src/services/akuisisi-processor.ts (runAkuisisiCrossValidation: populate tanggalKoran + push the rule result)
- Test: backend/src/services/__tests__/akuisisi-koran-window.test.ts

Interfaces:
- Consumes: RuleResult ({ ruleCode, ruleLabel, status: "PASS"|"WARNING"|"FAIL"|"SKIPPED", message, details? }).
- Produces: validateAkuisisiKoranWindow(input: { tanggalKoran: string | null; tanggalRups: string | null }): RuleResultruleCode: "AKUISISI_KORAN_WINDOW"; never FAIL: SKIPPED if either date missing/invalid; PASS if koran ≥30 days before RUPS; WARNING otherwise (Pasal 127(8) exempts direct acquisitions, so late/absent windows are advisory).

  • [ ] Step 1: Write the failing test
// backend/src/services/__tests__/akuisisi-koran-window.test.ts
import { describe, it, expect } from "bun:test";
import { validateAkuisisiKoranWindow } from "../cross-validator";

describe("validateAkuisisiKoranWindow — advisory, never FAIL", () => {
  it("PASS when koran >= 30 days before RUPS", () => {
    const r = validateAkuisisiKoranWindow({ tanggalKoran: "2026-01-01", tanggalRups: "2026-02-15" });
    expect(r.status).toBe("PASS");
    expect(r.ruleCode).toBe("AKUISISI_KORAN_WINDOW");
  });
  it("WARNING (not FAIL) when koran is inside the 30-day window", () => {
    const r = validateAkuisisiKoranWindow({ tanggalKoran: "2026-02-10", tanggalRups: "2026-02-15" });
    expect(r.status).toBe("WARNING");
  });
  it("SKIPPED when a date is missing", () => {
    expect(validateAkuisisiKoranWindow({ tanggalKoran: null, tanggalRups: "2026-02-15" }).status).toBe("SKIPPED");
  });
});
  • [ ] Step 2: Run to verify it fails — Expected: FAIL, function not exported.

  • [ ] Step 3: Implement in cross-validator.ts (clone the validateAktaDateWindow shape):

export function validateAkuisisiKoranWindow(
  input: { tanggalKoran: string | null; tanggalRups: string | null },
): RuleResult {
  const ruleCode = "AKUISISI_KORAN_WINDOW";
  const ruleLabel = "Pengumuman Koran ≥30 Hari sebelum RUPS";
  const k = input.tanggalKoran ? new Date(input.tanggalKoran) : null;
  const r = input.tanggalRups ? new Date(input.tanggalRups) : null;
  if (!k || !r || isNaN(k.getTime()) || isNaN(r.getTime()))
    return { ruleCode, ruleLabel, status: "SKIPPED", message: "Tanggal koran atau RUPS tidak tersedia" };
  const days = Math.round((r.getTime() - k.getTime()) / 86400000);
  if (days >= 30)
    return { ruleCode, ruleLabel, status: "PASS", message: `Pengumuman ${days} hari sebelum RUPS`, details: { days } };
  return {
    ruleCode, ruleLabel, status: "WARNING",
    message: `Pengumuman hanya ${days} hari sebelum RUPS (ideal ≥30; Pasal 127(8) mengecualikan pengambilalihan langsung)`,
    details: { days },
  };
}

Add tanggalKoran: string | null; to ValidationInput. In runAkuisisiCrossValidation, after runAllValidations, populate tanggalKoran from the akta's tanggal_koran_akuisisi ExtractedField and tanggalRups from the extracted rups.tanggal_rups, then results.push(validateAkuisisiKoranWindow({ tanggalKoran, tanggalRups })); (before the upsert loop).

  • [ ] Step 4: Run test + typecheck — Expected: PASS + tsc clean.

  • [ ] Step 5: Commit

git add backend/src/services/cross-validator.ts backend/src/services/akuisisi-processor.ts backend/src/services/__tests__/akuisisi-koran-window.test.ts
git commit -m "feat(akuisisi): AKUISISI_KORAN_WINDOW advisory validation (WARNING, Pasal 127(8))"

Phase B — Backend routes

Task 7: Relax perubahan route guards to the perubahan-family (reuse for akuisisi)

Files:
- Modify: backend/src/routes/perubahan.ts (the submission.type !== "PERUBAHAN_PT" guards on the SHARED endpoints: /lookup, /old-data, /select-company, /review-data, /selected-jenis, /sections/*, /data-kontak/*, /retry-flow)
- Test: backend/src/routes/__tests__/akuisisi-routes.test.ts

Interfaces:
- Produces: the shared perubahan endpoints accept an AKUISISI_PT submission (guard becomes PERUBAHAN_FAMILY.has(submission.type) where PERUBAHAN_FAMILY = new Set(["PERUBAHAN_PT","AKUISISI_PT"])). The select-company handler dispatches to the correct processor (already handled by dispatchProcessor). Do NOT relax /attestations or /submit (Task 8 handles akuisisi's differing shape).

  • [ ] Step 1: Write the failing test (integration: create an AKUISISI_PT submission, hit a shared endpoint, assert it is not rejected as wrong-type)
// backend/src/routes/__tests__/akuisisi-routes.test.ts — uses the Hono app + db fixture
// (mirror the perubahan route test setup; seed a Submission {type:"AKUISISI_PT"} + oldData,
//  GET /api/perubahan/submissions/:id/review-data, assert 200 not 400 "bukan Perubahan").

(Write the concrete fixture mirroring the existing perubahan route test; assert the shared endpoint returns 200 for an AKUISISI_PT submission and still 400 for an unrelated type.)

  • [ ] Step 2: Run to verify it fails — Expected: FAIL (guard rejects AKUISISI_PT).

  • [ ] Step 3: Implement — introduce const PERUBAHAN_FAMILY = new Set(["PERUBAHAN_PT", "AKUISISI_PT"]); at the top of perubahan.ts and replace each shared-endpoint guard if (submission.type !== "PERUBAHAN_PT")if (!PERUBAHAN_FAMILY.has(submission.type)). Leave /attestations and /submit guards as PERUBAHAN_PT-only for now.

  • [ ] Step 4: Run test + typecheck — Expected: PASS.

  • [ ] Step 5: Commit

git add backend/src/routes/perubahan.ts backend/src/routes/__tests__/akuisisi-routes.test.ts
git commit -m "feat(akuisisi): shared perubahan endpoints accept the perubahan-family (AKUISISI_PT)"

Task 8: Akuisisi attestation shape + submit gate

Files:
- Modify: backend/src/routes/perubahan.ts (/attestations + /submit: branch on AKUISISI_PT)
- Modify: backend/src/routes/klasifikasi.ts (AKUISISI_DOC_TYPES + ?type=akuisisi)
- Test: backend/src/routes/__tests__/akuisisi-routes.test.ts (extend)

Interfaces:
- Produces: for AKUISISI_PT, /attestations persists Submission.akuisisiAttestation = { jenisPerseroan, caraPengambilalihan, deedType } (no s1-s5/rupsType/syaratUtama); /submit gate for AKUISISI_PT = all appearing sections approved (via PerubahanSectionApproval) + creates/resets the shared VerifikasiPerubahan row. It does NOT require s1-s5 or rupsType. Koran + attestation absence surface as review gaps upstream, not hard submit FAILs (koran window is WARNING).

  • [ ] Step 1: Write the failing test — seed an AKUISISI_PT submission, POST akuisisi attestation, assert akuisisiAttestation persisted; POST submit with all sections approved, assert COMPLETED + a VerifikasiPerubahan row exists; assert submit does NOT require s1-s5.

  • [ ] Step 2: Run to verify it fails.

  • [ ] Step 3: Implement — in /attestations, if (submission.type === "AKUISISI_PT") persist akuisisiAttestation from { jenisPerseroan, caraPengambilalihan, deedType } and return; else existing perubahan path. In /submit, branch AKUISISI_PT to a gate that checks section approvals + creates the VerifikasiPerubahan row (reuse the perubahan finalize helper, skipping the s1-s5/rupsType checks). Add AKUISISI_DOC_TYPES to klasifikasi.ts (Akta Akuisisi required + KTP/NPWP/Domisili/Data Kontak optional) and if (type === "akuisisi") return c.json(AKUISISI_DOC_TYPES); in /labels, and include it in LABEL_MAP.

  • [ ] Step 4: Run test + typecheck — Expected: PASS.

  • [ ] Step 5: Commit

git add backend/src/routes/perubahan.ts backend/src/routes/klasifikasi.ts backend/src/routes/__tests__/akuisisi-routes.test.ts
git commit -m "feat(akuisisi): attestation-lite persistence + submit gate + AKUISISI_DOC_TYPES"

Phase C — Frontend

Task 9: Klasifikasi surfacing (detect + route AKTA_AKUISISI)

Files:
- Modify: frontend/src/lib/classifier-labels.ts (AKTA_AKUISISI: "Akta Akuisisi")
- Modify: frontend/src/components/klasifikasi/detect-banner.tsx (DetectedFlow += "akuisisi"; FLOW_LABELS/FLOW_DESCRIPTIONS)
- Modify: frontend/src/pages/KlasifikasiPage.tsx (detectedFlow L250-267 detect akuisisi; createSubmission.onSuccess L468-478 route AKUISISI_PT → /akuisisi/:id/extraction; computedLabelType include akuisisi)
- Test: frontend/src/pages/__tests__/* (extend the KlasifikasiPage test if present; else a small detect-banner unit test)

Interfaces:
- Produces: an AKTA_AKUISISI classification yields detectedFlow==="akuisisi" and navigates to /akuisisi/:submissionId/extraction.

  • [ ] Steps 1–5: TDD a detect-banner/detectedFlow unit assertion (akuisisi label + description present; detection returns "akuisisi" when an AKTA_AKUISISI result is present), implement the exact additions the frontend reader enumerated, run cd frontend && bunx tsc --noEmit && bun test, commit.
git add frontend/src/lib/classifier-labels.ts frontend/src/components/klasifikasi/detect-banner.tsx frontend/src/pages/KlasifikasiPage.tsx
git commit -m "feat(akuisisi): klasifikasi surfacing — detect + route AKTA_AKUISISI"

Task 10: Flow config + routes

Files:
- Modify: frontend/src/lib/flow-steps.ts (AKUISISI_LABELS + akuisisi branch + AKUISISI_VERIFIKASI_STEP)
- Modify: frontend/src/lib/destination-for.ts (AKUISISI_PT → "akuisisi" base + type union)
- Modify: frontend/src/routes.tsx (akuisisi extraction/review/status routes)
- Test: frontend/src/lib/__tests__/* (destination-for + flow-steps unit tests if present)

  • [ ] Steps 1–5: TDD computeFlowStep/destinationFor for an AKUISISI_PT submission (mirrors PERUBAHAN_PT verifikasi-driven stages), implement the exact additions, bunx tsc --noEmit && bun test, commit.
git add frontend/src/lib/flow-steps.ts frontend/src/lib/destination-for.ts frontend/src/routes.tsx
git commit -m "feat(akuisisi): flow steps + destination routing + routes"

Task 11: Akuisisi extraction + review pages (thin mirrors)

Files:
- Create: frontend/src/pages/AkuisisiExtractionPage.tsx, frontend/src/pages/AkuisisiDeltaReviewPage.tsx
- Modify: reuse frontend/src/hooks/use-perubahan.ts (parameterize the review-data/company hooks by submissionType if hardcoded to PERUBAHAN_PT; else reuse as-is since the API is shared)

Interfaces:
- Consumes: the shared /api/perubahan/* endpoints (Task 7) via the perubahan hooks; the akuisisi attestation form posts { jenisPerseroan, caraPengambilalihan, deedType } (Task 8).
- Produces: the two pages rendered by the Task-10 routes.

  • [ ] Steps 1–5: create the pages as thin mirrors of PerubahanExtractionPage/PerubahanDeltaReviewPage (same hooks + components; the review page swaps the perubahan s1-s5 attestation panel for the akuisisi legal-path radios + shows the koran field + the AKUISISI_KORAN_WINDOW advisory). Where a perubahan hook hardcodes the type or URL, add a submissionType/basePath param and pass "AKUISISI_PT"/"akuisisi". Run cd frontend && bunx tsc --noEmit && bun test; manually smoke via the run skill if available; commit.
git add frontend/src/pages/AkuisisiExtractionPage.tsx frontend/src/pages/AkuisisiDeltaReviewPage.tsx frontend/src/hooks/use-perubahan.ts
git commit -m "feat(akuisisi): extraction + delta-review pages (thin mirrors; legal-path radios + koran advisory)"

Self-Review

  • Spec coverage: §4.1 SubmissionType+fork → Tasks 1-3. §4.2 data flow → Tasks 2-6 (backend) + 9-11 (frontend). §4.3 hybrid extraction → Tasks 1 (subtype map) + 4 (override) + 5 (koran). §4.4 pengumuman → Tasks 5 (extract) + 6 (window WARNING) + 8 (required-in-review). §4.5 attestation-lite → Task 8 (persist) + 11 (radios). §4.6 forced peralihan + single-subject → Task 2. §5 wiring checklist → Tasks 1,3,8,9,10. §6 data model → Task 1 (akuisisiAttestation) + 5 (koran fields). §7 gotchas → Tasks 3 (reMatch/exhaustiveness) + 8 (section approval). §8 tests → every task TDD.
  • Placeholder scan: the fork tasks (2, 7, 11) use "copy file + apply these exact diffs / mirror" — a complete, precise instruction for a thin fork of a large existing file, not a hand-wave; the genuinely-new code (koran pass, window rule, wiring, schema) is given in full. Tasks 7-11's fixture/test bodies say "mirror the existing perubahan route/page test" — the implementer has the exact endpoint/props from the plan + the sibling test to copy; acceptable for integration-test scaffolds where reproducing the whole harness inline would be noise. Flag for the reviewer: confirm those fixtures assert real behavior.
  • Type consistency: processAkuisisiSubmission/continueAkuisisiProcessing/runAkuisisiCrossValidation/reMatchAndValidateAkuisisi, forcePeralihanOn, resolveAktaSubtype, extractKoranViaFocusedLlm, validateAkuisisiKoranWindow, PERUBAHAN_FAMILY, akuisisiAttestation {jenisPerseroan,caraPengambilalihan,deedType} are used identically across tasks. RuleResult/ValidationStatus match the codebase.