think
16px
820px

Pelaporan RUPS Tahunan (LAPORAN_RUPS_TAHUNAN) Flow — Design Spec

Date: 2026-07-02
Status: Draft for review
Author: Efran Nathanael (with Claude)
Related: docs/research/sabh-pt-untouched-flows-2026-07-01.md (roadmap, Pelaporan RUPS Tahunan = #2 "QoL sleeper"), docs/superpowers/specs/2026-07-01-akta-transaction-type-classifier-design.md (classifier substrate; taxonomy already contains laporan_rups_tahunan), docs/superpowers/specs/2026-07-01-akuisisi-pt-flow-design.md (structural template), docs/specs/2026-07-01-target-architecture-design.md (canonical FlowConfig/Rule/FLOW_REGISTRY — consumed, not redesigned).


1. Overview & Goal

Pelaporan RUPS Tahunan (SABH PelaporanRupsTahunanController.php) is the statutory annual filing every active PT must submit (UUPT Ps.66–68): notaris-driven, 4-step, and — uniquely among the flows we've built — a terminal self-service submit with no verifier queue. Backlog filings are accepted up to 10 years back (tahun_laporan). The user uploads the akta/risalah RUPS Tahunan (the deed whose agenda is the pengesahan laporan tahunan), the laporan keuangan when the PT is wajib-audit, and ~6 opaque annual-report PDFs. SABH's free-text surface is small but every character of it lives in the uploaded PDFs: no_akta_notaris + tanggal_akta_notaris (Step 3) and, audit-conditional, nama_akuntan_publik / izin_akuntan_publik / tanggal_izin_akuntan.

Goal: ship a FULL LAPORAN_RUPS_TAHUNAN OCR flow (upload → classify → extract → validate → review → submit) built registry-first: one FlowConfig entry on the new FLOW_REGISTRY slice with wrapped rules and sanctioned hooks — no new processor fork. This is the FIRST engine:'v2-generic' flow (program ship order: LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT; existing flows stay v1-fork as the oracle). Shared components this flow needs ship with it as their first consumer; the registry slice carries the shared seams (focusedPasses, quorumThreshold, widened companyLookup, RouteFamily extension) so later flows only add data.

Two facts shape everything:
1. No verifier downstream. Submit is terminal — validation-at-entry IS the safety net. FAIL rules must block submit; there is nobody to catch a typo'd akta number after the fact.
2. The identity surface is thin. This filing registers no person: no new pengurus, no shareholders, no address change, no kontak fields. The akuisisi required-docs precedent (KTP/NPWP/Domisili/DataKontak wajib, commit aa4551d) does not transfer — forcing those uploads here would create work with zero cross-check value. The cross-validation that matters is akta-header integrity, tahun-buku sanity, registry consistency, and the wajib-audit conditionality.

Enum spelling: LAPORAN_RUPS_TAHUNAN (not PELAPORAN_RUPS_TAHUNAN)

Chosen: LAPORAN_RUPS_TAHUNAN. Justification:
- It is the mechanical uppercase of the already-committed classifier taxonomy: AktaTxnType "laporan_rups_tahunan" → carrier label AKTA_RUPS_TAHUNANSubmissionType LAPORAN_RUPS_TAHUNAN (backend/src/services/akta-txn-classifier.ts:12,24, on this branch). One naming chain, no translation table entry that can drift.
- No _PT suffix needed: RUPS is an organ that exists only in a PT (a PP has a single owner, no RUPS) — unlike PEMBUBARAN_PP/future PEMBUBARAN_PT where the suffix disambiguates. No collision is possible.
- Rejected PELAPORAN_RUPS_TAHUNAN (SABH's controller noun): breaks the txnType↔SubmissionType mechanical mapping and buys nothing — the SABH linkage is documented here and in the flow label, not the enum.

2. Scope

In scope (phase 1)
- New LAPORAN_RUPS_TAHUNAN SubmissionType as a FlowConfig entry on the FLOW_REGISTRY slice (engine:'v2-generic'), wrapped rules, sanctioned hooks only.
- Classifier lighting: laporan_rups_tahunanBUILT_AKTA_TXN_TYPES; AKTA_RUPS_TAHUNAN routing through inferSubmissionType; a conservative extension of the refineAktaLabel trigger set for BERITA_ACARA_RUPS-labelled deeds (§7, gotcha 2).
- Hybrid extraction: perubahan akta extractor (subtype override) for the deed header + a NEW focused grounded extractor for the laporan keuangan's akuntan-publik block (§4.4).
- New DocumentTypes with a per-type classification strategy (§4.3): LAPORAN_KEUANGAN (in-app LLM/keyword classification) + LAMPIRAN_LAPORAN_TAHUNAN (manual board assignment, store-only).
- tahun_laporan picker (10-year backlog window) + jenis_audit attestation with conditional akuntan-publik requirements.
- The tuned validation matrix (§4.5) and the terminal submit gate replacing the verifikator stage (§4.6).
- Company lookup + SABH oldData snapshot + notaris resolution (reuse).
- Thin frontend mirrors of the akuisisi/perubahan pages (NO ReviewEngine/descriptor — sequenced later; this spec does not front-run the ReviewData reshape).

Out of scope (deferred — §9)
- Extracting the 6 lampiran PDFs (susunan direksi/komisaris + gaji docs are latent upside — store-only now).
- Auto-deriving wajib-audit status from SABH registry criteria (Ps.68(1) asset/terbuka/BUMN tests) — attestation radio in phase 1.
- Verifying the akuntan-publik license against an external registry (P2PK Kemenkeu is a cloud call — violates full on-premise).
- SABH submit-back (out of PoC scope program-wide).
- FrontendReviewDescriptor / ReviewEngine migration.

3. Current Context (what we reuse)

Reuse-as-is (no change):
| Component | Path | Role here |
|---|---|---|
| Akta extractor (perubahan subtype) | backend/src/services/document-processor.ts (classificationOverrideresolveAktaSubtype, lines ~379–425 — the exact mechanism AKTA_AKUISISI uses) | Deed header: nomor_akta, tanggal_akta, nama_notaris, nama_perseroan, ref_pendirian_nomor_sk, tanggal_rups |
| Company lookup + snapshot | backend/src/services/company-lookup.ts (lookupCompany/loadOldData/loadPerseroanBlokirState/lookupNotarisIdByName), sabh-pt-snapshot.ts | Step-1 PT identification + oldData + blokir state |
| RUPS attendance matcher | backend/src/services/rups-attendance-matcher.ts (matchRupsAttendance, normalizeName, mapKehadiran) | Quorum advisory input when a separate BA RUPS is uploaded |
| Cross-validator bodies | backend/src/services/cross-validator.ts (validateNamaPerseroan, validateOldDataConsistency, validateNotarisTerakhir, validateNpwpPerseroanFuzzyMatch, validatePerseroanState, validateRupsAttendanceQuorum) | Wrapped bit-for-bit as Rules (§4.5) |
| Focused grounded LLM pattern | backend/src/services/akta-koran-extract.ts (guided-JSON + verbatim-substring grounding on rawText, CLEANUP_LLM 3B) | Template for the laporan-keuangan extractor |
| In-app classifier pattern | backend/src/services/apostille-classifier.ts (keyword-first + grounded-LLM, swappable seam) | Template for LAPORAN_KEUANGAN classification |
| Akta txn classifier | backend/src/services/akta-txn-classifier.ts (laporan_rups_tahunan in AKTA_TXN_TYPES, keyword /laporan\s+tahunan|rups\s+tahunan/i, carrier AKTA_RUPS_TAHUNAN) | Already emits the label; gated by BUILT_AKTA_TXN_TYPES |
| Dispatch + reMatch | backend/src/services/submission-dispatch.ts (processorKindForType never-switch), submission-processor.ts (queueReMatchAndValidate ~L694) | Registry delegation point (§5) |
| Confirm/LOCK + field edit machinery | field-confirmation.ts, field-transformer.ts, ExtractedField/bbox pipeline | Unchanged |
| Frontend shells | AkuisisiExtractionPage.tsx, CompanySelectionModal, SubmissionStatusPage, validation-row components | Mirrored (§4.7) |

Consumed from the registry slice (task #14, must land first): FLOW_REGISTRY: Record<SubmissionType, FlowConfig>, defineFlow<Ctx>(), runFlow, reMatchAndValidateFlow, runValidations (async-partitioned), persistValidationResults, wrapped Rule/RuleResult (verbatim cross-validator.ts:148 shape). This spec does not redesign those interfaces — it fills in one entry.

Genuinely NEW in this flow (honest list):
1. laporan-keuangan-extract.ts — the akuntan-publik grounded extractor (no PoC extractor exists for financial statements; flagged by the discovery report).
2. laporan-keuangan-classify — a tiny keyword-first classifier for the LAPORAN_KEUANGAN label.
3. Four new rules: TAHUN_LAPORAN_VALID, TAHUN_BUKU_ORDERING, RUPS_ANNUAL_WINDOW, AKUNTAN_PUBLIK_REQUIRED (+ two micro-advisories AKUNTAN_IZIN_FORMAT, RUPS_LAMPIRAN_COMPLETENESS, LAPORAN_KEUANGAN_TAHUN_MATCH).
4. The terminal submit gate (no VerifikasiPerubahan row is ever created).
5. tahunLaporan/jenisAudit typed columns + LaporanKeuanganExtraction satellite.
6. The routeFamily:'laporan-rups' thin route file + two frontend pages.

Everything else is a wrap or a mirror.

4. Design

4.1 Data flow

upload akta/risalah RUPS tahunan (+ laporan keuangan + lampiran PDFs)
   GPU coarse classify  refineAktaLabel  AKTA_RUPS_TAHUNAN
    (conservative BERITA_ACARA_RUPS extension  §7 gotcha 2)
   LAPORAN_KEUANGAN in-app classify (keyword-first, 3B fallback); lampiran assigned manually on the board
   inferSubmissionType  LAPORAN_RUPS_TAHUNAN (canonical ladder: Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT)
   dispatch  runFlow(id, FLOW_REGISTRY.LAPORAN_RUPS_TAHUNAN)       v2-generic, NOT a fork
      Phase 1: extract deed (perubahan subtype override)
      Phase 2: company-lookup + oldData + blokir (identical to akuisisi Phase 2)
      Phase 3: focused laporan-keuangan pass (hook), skip-extraction for lampiran
      Phase 4: runValidations(cfg)  persistValidationResults
      Phase 5: READY
   review: confirm fields, pick tahun_laporan, attest jenis_audit, approve sections
   SUBMIT (terminal): gate passes  status COMPLETED. No verifikator, no voting.

4.2 FlowConfig entry (actual values)

backend/src/flow-engine/flows/laporan-rups-tahunan.ts:

export const laporanRupsTahunanFlow = defineFlow<RupsTahunanContext>({
  type: 'LAPORAN_RUPS_TAHUNAN',
  engine: 'v2-generic',                       // FIRST v2 tenant (ship order: RUPS-Tahunan → Peleburan → Pembubaran → Berakhirnya)
  primaryAkta: {                              // canonical shape: {classifiedType, missingError, extractError} ONLY
    classifiedType: 'AKTA_RUPS_TAHUNAN',
    missingError: 'Akta/Risalah RUPS Tahunan tidak ditemukan',
    extractError: 'Gagal memproses Akta RUPS Tahunan',
  },
  skipExtractionTypes: ['LAMPIRAN_LAPORAN_TAHUNAN', 'OTHER'],   // store-only, mark DONE
  companyLookup: true,                        // Step-1 parity: lookup + oldData + blokir (canonical boolean form)
  applyContactInfo: false,                    // no kontak surface in this filing
  extraction: {
    overrideByClassifiedType: { AKTA_RUPS_TAHUNAN: 'perubahan' },  // same mechanism as AKTA_AKUISISI
    focusedPasses: { LAPORAN_KEUANGAN: 'laporan-keuangan' },       // §4.4 — the registry slice's canonical per-docType seam
  },
  buildContext: buildRupsTahunanContext,      // wraps buildPtAktaContext + { tahunLaporan, jenisAudit, akuntan, lampiranSlots, baRupsAttendees }
  rules: RUPS_TAHUNAN_RULE_SET,               // §4.5 — wrapped shared + flow-specific
  requiredDocs: ['AKTA'],                     // DocumentType[]; LAPORAN_KEUANGAN conditionality is enforced by AKUNTAN_PUBLIK_REQUIRED, not the checklist
  sections: [
    { key: 'identitas-perseroan', label: 'Identitas Perseroan',            approvable: true,  required: true },
    { key: 'akta-rups',           label: 'Akta / Risalah RUPS Tahunan',    approvable: true,  required: true },
    { key: 'laporan-tahunan',     label: 'Tahun Laporan & Jenis Audit',    approvable: true,  required: true },
    { key: 'akuntan-publik',      label: 'Akuntan Publik',                 approvable: true,  required: false }, // required flips at runtime on jenisAudit=WAJIB_AUDIT
    { key: 'lampiran',            label: 'Lampiran Laporan Tahunan',       approvable: true,  required: false },
    { key: 'validasi',            label: 'Hasil Validasi',                 approvable: false, required: false },
  ],
  rematch: {                                  // canonical RematchConfig; queueing is engine-level (queueReMatchAndValidate)
    checklistResetDocTypes: [],               // no card checklist — this filing registers no person
    standardPtMatchers: false,
    preValidationSteps: [(id) => matchRupsAttendance(id, db)],   // quorum advisory input when a BA RUPS exists
  },
  // no hooks: the laporan-keuangan pass rides extraction.focusedPasses; NO onFinalize SABH outbox in PoC scope
  routeFamily: 'laporan-rups',                // RouteFamily union extended ONCE in flow-engine/types.ts (registry slice); NOT 'perubahan' (no diff, no verifikator)
  labels: { akta: 'Akta/Risalah RUPS Tahunan', flow: 'Pelaporan RUPS Tahunan' },
});

Notes:
- extraction.overrideByClassifiedType: { AKTA_RUPS_TAHUNAN: 'perubahan' } (the registry slice's canonical mechanism) because the perubahan extraction already yields the header fields SABH types (nomor_akta/tanggal_akta/nama_notaris at akta-perubahan.ts:136–138, tanggal_rups at :27, nama_perseroan, ref_pendirian_nomor_sk for lookup) — same hybrid call the akuisisi flow proved (document-processor.ts:392,416). A dedicated AKTA_RUPS_TAHUNAN schema is deferred; open question §10.4 covers the risk.
- No diff. companyLookup:true but there is no perubahan-diff/post-perubahan-roster step — the filing changes nothing on the record. oldData is used for consistency rules and the registry-entity display only.

4.3 New DocumentTypes + classification strategy (binding decision 5)

The x056 ahu-classifier-v2 (22 labels) has no laporan-keuangan or lampiran labels, and an untrained LayoutLMv3 will confidently mislabel (the confident-wrong trap from the classifier spec §4). Strategy per type:

LAPORAN_KEUANGAN — in-app grounded LLM classification (apostille-classifier pattern).
Why LLM and not manual: this document has downstream extraction and a conditional FAIL rule hanging off it (AKUNTAN_PUBLIK_REQUIRED) — a mis-bucketed laporan keuangan silently breaks the wajib-audit conditionality, so we want it auto-recognized, not dependent on the operator remembering to drag it. Why it's cheap and reliable: its masthead text is highly distinctive (LAPORAN KEUANGAN, LAPORAN AUDITOR INDEPENDEN, OPINI, LAPORAN POSISI KEUANGAN). Implementation: keyword-first title rule on the cached PaddleOCR text (first ~800 chars, mirroring akta-txn-classifier.ts:49), falling back to a one-shot grounded 3B call only when keywords are ambiguous — the exact keyword-then-LLM tiering apostille-classifier.ts ships. Result is ADVISORY on the klasifikasi board (operator can override), like everything else.

LAMPIRAN_LAPORAN_TAHUNAN — manual board assignment (no LLM).
The ~6 annual-report PDFs (UUPT Ps.66(2) letters b–g: laporan kegiatan, TJSL, rincian masalah, laporan pengawasan Dewan Komisaris, susunan Direksi/Komisaris, gaji & tunjangan) are store-only: no extraction, no validation branches on which letter a PDF is. An LLM sub-classifier would add a per-upload GPU call and a new failure mode for zero downstream value — the honest answer is the operator drags them into slots on the existing klasifikasi board. Mechanism: six fine DocTypeLabel keys (LAMPIRAN_KEGIATAN_PERSEROAN, LAMPIRAN_TJSL, LAMPIRAN_RINCIAN_MASALAH, LAMPIRAN_PENGAWASAN_KOMISARIS, LAMPIRAN_SUSUNAN_PENGURUS, LAMPIRAN_GAJI_PENGURUS) all mapping to one Prisma DocumentType LAMPIRAN_LAPORAN_TAHUNAN carrier (canonical spelling — conflict-17 reconciliation) — the same many-fine-labels→one-DocumentType pattern mapToDocumentType already uses for AKTA_*AKTA. The fine label survives on SubmissionDocument.classifiedType (= the SABH slot for future submit-back). Unrecognized uploads default into the board's unassigned tier; RUPS_LAMPIRAN_COMPLETENESS (WARNING) nudges on empty slots.

AKTA_RUPS_TAHUNAN is NOT new — the classifier already emits it (akta-txn-classifier.ts:24); this flow only adds it to BUILT_AKTA_TXN_TYPES and the ladders.

4.4 Extraction plan

Deed (reuse, override): AKTA_RUPS_TAHUNAN → akta_subtype 'perubahan' via the existing classificationOverride path — extend the document-processor.ts override branch (the two === "AKTA_AKUISISI" sites at lines ~416/472 generalize to a small override map). Fields consumed: nomor_akta, tanggal_akta, nama_notaris (+ pengganti), nama_perseroan, ref_pendirian_nomor_sk, tanggal_rups. Roster fields the perubahan extractor may emit (recited pemegang saham in the premisse) are kept — they feed the quorum advisory — but drive no diff.

Laporan keuangan (NEW — backend/src/services/laporan-keuangan-extract.ts): a focused grounded pass mirroring akta-koran-extract.ts line-for-line in shape:
- Input: the document's PaddleOCR rawText (audit-opinion + cover pages; slice ~15k chars).
- Output (guided-JSON, temperature:0, CLEANUP_LLM 3B @8003): { nama_akuntan_publik, nomor_izin_akuntan_publik, tanggal_izin_akuntan_publik, tahun_buku } — all string|null.
- Grounding: every value must substring-match the normalized rawText (verbatim-copy contract, ground() helper as in akta-koran) — the 3B is brittle on broad prompts (memory reference_apostille_extraction_llm), so this stays a single-purpose call; date word-forms normalize via the existing normalizeWordDateViaLlm pattern.
- Absent values are left for manual entry in the akuntan-publik section (required-but-empty → review), never an extraction ERROR — the izin AP often appears only on the opinion signature block and may be missing from scans.
- Trigger: the flow's extraction.focusedPasses.LAPORAN_KEUANGAN entry (the registry slice's canonical per-docType seam) — the engine invokes it after the LAPORAN_KEUANGAN doc's OCR completes; results persist to LaporanKeuanganExtraction (§6) + ExtractedField rows with bboxes when the layout provider yields them.

Lampiran: skipExtractionTypes — marked DONE without extraction (existing mechanism, same as akuisisi's non-extracted supporting docs).

4.5 Validation matrix (THE core deliverable — tuned, honest)

Required documents:

Doc Required? Why / why not (honesty note)
AKTA_RUPS_TAHUNAN Wajib (maxCount 1) The primary deed; carries both mandatory SABH typed fields
LAPORAN_KEUANGAN Conditional — wajib when jenisAudit=WAJIB_AUDIT UUPT Ps.68; enforced by AKUNTAN_PUBLIK_REQUIRED, board shows required:false + description "Wajib jika perseroan wajib diaudit (UUPT Ps.68)"
LAMPIRAN_* (6 slots) Optional (advisory) Store-only; Ps.66(2) letters c/d/g are conditional ("jika ada"; TJSL only for Ps.74 resource-sector PTs) — cannot hard-require
NPWP (perseroan) Optional Weak but free cross-check vs oldData.npwp_perseroan via existing self-skipping NPWP_PERSEROAN_FUZZY
KTP / personal NPWP / SURAT_PERNYATAAN_DOMISILI / DATA_KONTAK Excluded — deliberately The akuisisi precedent (commit aa4551d) required these because akuisisi keys new people/addresses into SABH. This filing keys nobody and no address — there is no field for a KTP/kontak/domisili value to cross-validate against. Forcing them = operator burden, zero validation value.

Rules (RUPS_TAHUNAN_RULE_SET — wrapped shared + flow-specific):

# Rule code New? Severity Legal basis / rationale
1 RUPS_AKTA_FIELDS_PRESENT NEW FAIL no_akta_notaris + tanggal_akta_notaris are SABH's two mandatory typed fields; terminal submit with them missing/unconfirmed is an unfixable filing
2 TAHUN_LAPORAN_VALID NEW FAIL tahunLaporan must be within SABH's accepted backlog window (currentYear−10 … currentYear−1 per the dropdown); outside it SABH hard-rejects
3 TAHUN_BUKU_ORDERING NEW FAIL tanggal_rups must be after 31 Dec of tahunLaporan — a RUPS cannot ratify a fiscal year that hasn't ended; violation ⇒ OCR misread or wrong tahun picked
4 AKUNTAN_PUBLIK_REQUIRED NEW FAIL (conditional) UUPT Ps.68(1)+(3): audited PTs must file the audited laporan keuangan; when jenisAudit=WAJIB_AUDIT, nama/izin/tanggal-izin AP + a LAPORAN_KEUANGAN doc are mandatory. SKIPPED when NON_AUDIT
5 PERSEROAN_STATE wrap (cross-validator.ts:1418) FAIL-capable (existing semantics, bit-for-bit) Blocked/dissolved PT cannot file; reads loadPerseroanBlokirState
6 RUPS_ANNUAL_WINDOW NEW WARNING — never FAIL. Citation: UUPT Ps.78(2) requires the annual RUPS ≤6 months after tahun buku ends, but lateness does not invalidate the RUPS or the filing, and SABH itself accepts 10-year backlog — a hard FAIL would block the exact backlog filings this flow exists to accept Emit when tanggal_rups > 30 Jun of tahunLaporan+1
7 RUPS_KUORUM wrap (the shared rules/pt-akta/rups-quorum.ts wrapper at its default quorumThreshold >½ + matchRupsAttendance; pembubaran passes ¾ — registry-slice seam) WARNING — never FAIL. Citation: UUPT Ps.86(1) sets >½ quorum "kecuali anggaran dasar menentukan jumlah kuorum yang lebih besar" (we don't hold the AD), and Ps.86(4) makes a second-call RUPS valid at ⅓ — a below-½ deed can be perfectly legal Runs when attendance data exists (deed roster or separate BERITA_ACARA_RUPS); SKIPPED otherwise
8 AKUNTAN_IZIN_FORMAT NEW WARNING — never FAIL. No authoritative on-prem AP-license registry (P2PK Kemenkeu is a cloud service; full on-premise constraint forbids the call) — we can flag an odd-looking izin, never assert invalidity Regex sanity on nomor_izin (e.g. AP.\d+ shapes)
9 LAPORAN_KEUANGAN_TAHUN_MATCH NEW WARNING Extracted tahun_buku (cover: "untuk tahun yang berakhir 31 Desember YYYY") vs tahunLaporan; grounded extraction may legitimately miss → advisory
10 RUPS_LAMPIRAN_COMPLETENESS NEW WARNING — never FAIL. Citation: Ps.66(2) letters c (TJSL — Ps.74 sectoral), d (rincian masalah — may be nil), g are conditional; hard-requiring all six is legally wrong Lists empty lampiran slots
11 NAMA_PT_CONSISTENCY wrap (validateNamaPerseroan) existing (WARNING on mismatch) Deed PT name vs laporan-keuangan cover entity vs NPWP — the one real multi-doc name check here
12 OLD_DATA_CONSISTENCY wrap (validateOldDataConsistency) existing Deed-recited PT data vs SABH oldData snapshot
13 NOTARIS_TERAKHIR wrap (validateNotarisTerakhir, aktaNotarisId via lookupNotarisIdByName) existing (SKIPs when unresolvable) Deed notaris resolves in SABH ms_notaris_profile
14 NPWP_PERSEROAN_FUZZY wrap (validateNpwpPerseroanFuzzyMatch) existing (self-SKIPs when no NPWP doc) Optional NPWP upload vs oldData

Explicitly EXCLUDED rules (as important as the included ones):
- AKTA_DATE_WINDOW (cross-validator.ts:1282, SABH's 30/60-day validateTglAkta mirror) — would false-FAIL every legitimate backlog filing; the 10-year tahun_laporan regime replaces it (rules 2/3/6). This is the strongest single argument for a per-flow tuned matrix over runAllValidations wholesale.
- All pendirian/perubahan payload rules (NIK_KTP_AKTA, NAMA_NPWP_KTP, MODAL_*, SHARES_*, KBLI_*, CONTACT_INFO_COMPLETE, PEMEGANG_SAHAM_*, DOMISILI_NAMA, jenis-selection rules, PASSPORT_AKTA) — no such payload exists in this filing; they'd all SKIP, and carrying 20 permanent SKIPPED rows is noise, not safety.

Rules 5/13/14 hit SABH → marked async:true so the runner's Promise.all partition keeps them parallel (target-arch §7 HIGH).

4.6 Terminal submit — what replaces the verifikator stage

SABH has no verifier queue for this flow; parity means we must not invent one. Design:

  • No VerifikasiPerubahan row is ever created for LAPORAN_RUPS_TAHUNAN → the verifikator dashboard/voting surfaces (which key off verifikasi rows) naturally never see it. Status path: CREATED → EXTRACTING → (AWAITING_COMPANY_SELECTION?) → VALIDATING → READY → COMPLETED. IN_VERIFICATION is never entered.
  • The submit gate IS the safety net (POST /api/laporan-rups/:id/submit):
    1. All required sections have an explicit persisted approval row (reuse the PerubahanSectionApproval table keyed by submission — same shape, no schema change; [[reference_perubahan_section_approval]]: never derive from child state). akuntan-publik becomes required at gate time when jenisAudit=WAJIB_AUDIT.
    2. tahunLaporan and jenisAudit are set (attested).
    3. Zero FAIL validations (rules 1–5). Blockers are returned in the error envelope so the UI can deep-link each.
    4. WARNINGs require explicit acknowledgment — a declaration checklist ("Saya menyatakan data laporan tahunan ini benar…", + one per outstanding WARNING), mirroring SABH's notaris self-declaration and the existing attestation/declaration pattern. Persisted with the submission (audit trail via VerifikasiAuditEvent-style event).
    5. Gate passes → single transaction: persist declarations, stamp submittedAt, status=COMPLETED.
  • Re-open semantics: none (terminal, parity with SABH). Corrections post-submit are a Perbaikan-family concern, out of scope.

4.7 Frontend (thin mirrors — NO ReviewEngine)

Per binding decision 3, mirror the akuisisi/perubahan pages; do not build descriptors.
- frontend/src/pages/LaporanRupsExtractionPage.tsx — mirror of AkuisisiExtractionPage.tsx (progress, company-selection via the shared CompanySelectionModal).
- frontend/src/pages/LaporanRupsReviewPage.tsx — a flat review page (no delta/diff — nothing changes on the record): registry-entity header (from oldData), akta-rups field card (confirm/LOCK + PDF bbox panel), tahun-laporan picker (dropdown, currentYear−10…currentYear−1) + jenis-audit radio, akuntan-publik card (extracted values, editable, conditional-required styling), lampiran slot checklist, validation results, section-approval buttons, terminal submit with the declaration checklist. Structurally closer to the pendirian review than to PerubahanDeltaReviewPage — expect ~500–700 LOC, honest cost of the pre-ReviewEngine world.
- Routes (frontend/src/routes.tsx, mirroring the akuisisi trio at lines 79–90): laporan-rups/:submissionId/extraction | review | status (+ SubmissionStatusPage type="LAPORAN_RUPS_TAHUNAN").
- lib/classifier-labels.ts, components/klasifikasi/detect-banner.tsx (DetectedFlow, FLOW_LABELS/FLOW_DESCRIPTIONS), KlasifikasiPage.detectedFlow + createSubmission.onSuccess route, lib/destination-for.ts/flow-steps.ts — the same 6 mechanical touchpoints the akuisisi spec §5.8 lists.

5. Integration / wiring checklist

  1. backend/prisma/schema.prismaenum SubmissionType += LAPORAN_RUPS_TAHUNAN; enum RupsJenisAudit { WAJIB_AUDIT NON_AUDIT }; Submission += tahunLaporan Int?, jenisAudit RupsJenisAudit?, rupsSubmittedAt DateTime?; new LaporanKeuanganExtraction model (§6). LAPORAN_KEUANGAN + LAMPIRAN_LAPORAN_TAHUNAN need no migration here — they arrive via the registry slice's ONE additive six-DocumentType batch (LAPORAN_KEUANGAN, LAMPIRAN_LAPORAN_TAHUNAN, SURAT_LIKUIDATOR, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN, SURAT_PERMOHONAN). prisma generate (+ additive migration).
  2. backend/src/services/akta-txn-classifier.tsBUILT_AKTA_TXN_TYPES += "laporan_rups_tahunan" (flips supported, removes it from detectUnsupportedAktaType).
  3. backend/src/routes/klasifikasi.tsLAPORAN_RUPS_DOC_TYPES label set (AKTA_RUPS_TAHUNAN wajib; LAPORAN_KEUANGAN conditional-desc; six LAMPIRAN_ keys; NPWP optional; OTHER) + serve ?type=laporan_rups; extend the refineAktaLabel trigger* for BERITA_ACARA_RUPS (gotcha 2, §7); wire the LAPORAN_KEUANGAN keyword classifier into the classify pass.
  4. backend/src/routes/submissions.tsinferSubmissionType: add the AKTA_RUPS_TAHUNAN → LAPORAN_RUPS_TAHUNAN rung of the canonical ladder Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT (a BA-RUPS-tahunan riding along a perubahan must not steal the submission); mapToDocumentType for the new labels; getProcessingOrder (akta first, LAPORAN_KEUANGAN mid, lampiran last).
  5. backend/src/flow-engine/flows/laporan-rups-tahunan.ts — the FlowConfig (§4.2) + registry key (compile-forced once FLOW_REGISTRY is Record<SubmissionType,…>).
  6. backend/src/services/submission-dispatch.tsprocessorKindForType case for LAPORAN_RUPS_TAHUNAN → the 'v2-generic' kind (registry slice §4.7) delegating runFlow(id, flowFor(type)) (the never-tripwire fails compile until added).
  7. backend/src/services/submission-processor.tsreMatchAndValidate branch → reMatchAndValidateFlow(id, flowFor('LAPORAN_RUPS_TAHUNAN')), reached only via queueReMatchAndValidate ([[reference_rematch_dispatch]] — THE validation-wipe gotcha).
  8. backend/src/services/document-processor.ts — generalize the two hardcoded AKTA_AKUISISI sites (L416/L472) to honour the engine-passed extraction.overrideByClassifiedType (the subtype override itself is declared on the FlowConfig, §4.2); the LAPORAN_KEUANGAN pass is dispatched via extraction.focusedPasses.
  9. backend/src/routes/laporan-rups.ts — thin route family: GET /:id/review-data, POST /:id/select-company (reuse the shared perubahan-family handler as akuisisi does), PUT /:id/tahun-laporan, PUT /:id/jenis-audit, PUT /:id/akuntan-publik, POST /:id/section-approval, POST /:id/submit (§4.6). Mount in index.ts.
  10. Frontend — §4.7 touchpoints.

6. Data model (additive only)

  • Submission.tahunLaporan Int? + Submission.jenisAudit RupsJenisAudit?typed columns, not JSON, because rules 2/3/4 branch on them (target-arch Pillar-3 guidance: branch-on-it data → typed). Submission.rupsSubmittedAt DateTime? + declarations persisted via the existing checklist/attestation tables.
  • New LaporanKeuanganExtraction { id, documentId @unique, namaAkuntanPublik String?, nomorIzinAkuntanPublik String?, tanggalIzinAkuntanPublik String?, tahunBuku String?, confidence Float @default(0), rawOcrText String? @db.Text, rawExtractionJson Json? } — mirrors BeritaAcaraRupsExtraction (schema.prisma:593) per-document satellite shape.
  • Section approvals: reuse PerubahanSectionApproval rows keyed by submission (no schema change).
  • Lampiran slot = SubmissionDocument.classifiedType (fine label) — no new column.
  • No enum/table removals; no god-table JSON additions beyond what exists.

7. Error handling & gotchas

  1. reMatch registry note: LAPORAN_RUPS_TAHUNAN MUST be dispatched in reMatchAndValidate (via the registry) or every field edit silently wipes its validations ([[reference_rematch_dispatch]]); always queueReMatchAndValidate (serialized), never fire-and-forget. With the registry both dispatch and reMatch resolve the SAME FlowConfig — this flow is the first to prove that property.
  2. The BERITA_ACARA_RUPS refine blind spot (flow-critical, found in code): refineAktaLabel only triggers on COARSE_AKTA_LABELS = {AKTA_PENDIRIAN, AKTA_PERUBAHAN} (klasifikasi.ts:13,26). A Risalah/BA RUPS Tahunan will often be GPU-labelled BERITA_ACARA_RUPS — which never reaches the refine pass, so AKTA_RUPS_TAHUNAN is never emitted and the flow is unreachable for exactly its most common deed shape. Fix: add BERITA_ACARA_RUPS to the trigger set with a conservative rule — override only when the sub-classifier returns laporan_rups_tahunan; any other result keeps BERITA_ACARA_RUPS (a BA RUPS is normally a supporting doc of perubahan/akuisisi and must never be promoted to their primary deed). Test explicitly (§8).
  3. Ladder position: the canonical ladder is Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT (implemented once in submissions.ts, documented in the registry spec) — AKTA_RUPS_TAHUNAN sits below Akuisisi/Perubahan, so a perubahan submission that also carries the annual BA must stay PERUBAHAN_PT.
  4. Do not run AKTA_DATE_WINDOW. Its 30-day window FAILs every backlog filing; exclusion is a deliberate matrix decision (§4.5), not an oversight — guard with a test so a future "just use runAllValidations" refactor can't regress it.
  5. Section approvals: explicit persisted rows only; never derive from child confirmed state ([[reference_perubahan_section_approval]]).
  6. Terminal-submit discipline: FAIL rules block submit (no verifier downstream); WARNINGs require acknowledged declarations. Do NOT auto-confirm extracted values to grease the gate — PT auto-LOCK stands, but the gate reads confirmations, it never writes them.
  7. never-switches: processorKindForType (and any exhaustive SubmissionType switch) won't compile until the new case exists — add all cases before trusting green.
  8. 3B brittleness: the laporan-keuangan pass stays a single-purpose grounded call; never broaden a shared prompt to squeeze in these fields (memory reference_apostille_extraction_llm). Empty results → manual entry, not ERROR.
  9. GPU-OCR rawText gap: like akta-koran-extract.ts, the focused pass needs PaddleOCR rawText; log-and-skip (visible warn) when absent rather than silently nulling.
  10. Classifier gating: everything stays dark until ship: laporan_rups_tahunan ∉ BUILT_AKTA_TXN_TYPES keeps it "belum didukung" on the board; flipping the allowlist entry is the launch switch (binding decision 6).

8. Testing strategy (TDD, ahu_ocr_test)

Write tests first (superpowers:test-driven-development); DB tests target ahu_ocr_test via the bunfig preload — never the dev DB ([[feedback_test_db_isolation]]).
- Routing/wiring: AKTA_RUPS_TAHUNAN → LAPORAN_RUPS_TAHUNAN; no longer "unsupported"; ladder — perubahan+BA-tahunan stays PERUBAHAN_PT; dispatch + queueReMatchAndValidate resolve the registry entry (a field edit must NOT wipe validations — regression for gotcha 1).
- Refine extension (gotcha 2): GPU BERITA_ACARA_RUPS + laporan-tahunan agenda text → AKTA_RUPS_TAHUNAN; GPU BERITA_ACARA_RUPS + perubahan agenda → stays BERITA_ACARA_RUPS.
- Rules (pure, fixture-driven): each of rules 1–10 across PASS/WARNING/FAIL/SKIPPED; TAHUN_BUKU_ORDERING boundary (RUPS on 31 Dec vs 1 Jan); RUPS_ANNUAL_WINDOW never emits FAIL; backlog year at −10/−11 boundary; AKUNTAN_PUBLIK_REQUIRED flips with jenisAudit; AKTA_DATE_WINDOW is absent from the rule set (explicit assertion).
- Extractor: extractLaporanKeuanganAkuntan grounding (hallucinated AP name not in rawText → null); word-date normalization; empty rawText → warn+skip.
- Classification: LAPORAN_KEUANGAN keyword rule on real masthead fixtures; lampiran labels map to the carrier DocumentType with fine label preserved.
- Submit gate: blocked on FAIL / missing section approval / missing tahun-jenis; WARNING requires declaration; success is transactional and terminal (COMPLETED, no VerifikasiPerubahan row, verifikator list does not include it).
- Regression: PERUBAHAN_PT + AKUISISI_PT routing/validation untouched; if the registry slice ships its parity harness, add flow-parity coverage for the wrapped shared rules (same {ruleCode,status,message,details} as their cross-validator bodies).

9. Deferred

Lampiran extraction (susunan pengurus + gaji docs — latent upside per the discovery report); wajib-audit auto-derivation from SABH registry criteria (Ps.68(1) tests); external AP-license verification (P2PK — cloud, forbidden on-prem); dedicated AKTA_RUPS_TAHUNAN extraction schema; SABH submit-back (program-wide); ReviewEngine descriptor migration (sequenced later — this flow will be an easy flat-descriptor candidate when it comes).

10. Open questions / risks

  1. Lampiran slot names: confirm the exact upload slots in SABH views/pelaporanRupsTahunan/_step3_upload.php before freezing the six LAMPIRAN_* keys (spec derives them from Ps.66(2) letters b–g; SABH may bundle differently or have a different count).
  2. jenis_audit pre-fill: SABH system-forces it from registry criteria — can we read those criteria from the SABH DB to pre-fill the attestation radio? (Nice-to-have; the attestation remains authoritative in phase 1.)
  3. One submission = one tahun_laporan is assumed (backlog = N filings). Verify SABH doesn't batch years.
  4. Perubahan extractor on a bare Risalah: does the perubahan subtype reliably yield the header fields on a deed with no AD-change clauses? Verify on a real sample early; fallback = route the deed through the BERITA_ACARA_RUPS extractor (BeritaAcaraRupsExtraction already holds nomor/tanggal/attendees) + a small focused header pass for notaris/nama-perseroan/SK.
  5. Dependency: this spec assumes the FLOW_REGISTRY slice (task #14) lands first — binding decision 2 forbids a new fork, so the flow blocks on it. If #14 slips materially, that is a program-sequencing call, not a spec change. This flow is the FIRST v2 tenant in the program ship order, so it also proves the engine for Peleburan/Pembubaran/Berakhirnya.

Cross-spec reconciliation (2026-07-02)

Applied from .superpowers/sdd/spec-critique-full.md:
- C1 — this spec's LAPORAN_RUPS_TAHUNAN spelling WON; the registry slice's matrix/gotcha were renamed (no change here beyond keeping the rejection rationale).
- C7 — this spec's exclusions WON (no AKTA_DATE_WINDOW, no KTP/DATA_KONTAK requirement); the registry slice's first-cut matrix row was corrected to match.
- C11 — §4.2 FlowConfig conformed to the registry slice's canonical shapes: primaryAkta loses subtype (now extraction.overrideByClassifiedType), extraction.{primary,focused}overrideByClassifiedType + focusedPasses, rematch:{queue:true} → canonical RematchConfig (queueing is engine-level), requiredDocs added.
- C12routeFamily:'laporan-rups' accepted; the RouteFamily union extension is made ONCE in flow-engine/types.ts by the registry slice.
- C14RUPS_KUORUM now explicitly rides the shared rups-quorum.ts wrapper at the default quorumThreshold (>½, Ps.86(1)).
- C15 — all ladder mentions now quote the canonical ladder (Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT).
- C16 — "first v2-generic flow" claim CONFIRMED by the program ship order (LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT); shared components ship with their first consumer.
- C17 — §4.3 lampiran carrier wobble fixed to the canonical LAMPIRAN_LAPORAN_TAHUNAN; §5 migration notes the DocumentTypes arrive via the registry slice's single six-type additive batch.