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_TAHUNAN → SubmissionType 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_tahunan → BUILT_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 (classificationOverride → resolveAktaSubtype, 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
VerifikasiPerubahanrow is ever created forLAPORAN_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_VERIFICATIONis 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 thePerubahanSectionApprovaltable keyed by submission — same shape, no schema change; [[reference_perubahan_section_approval]]: never derive from child state).akuntan-publikbecomes required at gate time whenjenisAudit=WAJIB_AUDIT.
2.tahunLaporanandjenisAuditare 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 viaVerifikasiAuditEvent-style event).
5. Gate passes → single transaction: persist declarations, stampsubmittedAt,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
backend/prisma/schema.prisma—enum SubmissionType += LAPORAN_RUPS_TAHUNAN;enum RupsJenisAudit { WAJIB_AUDIT NON_AUDIT };Submission += tahunLaporan Int?, jenisAudit RupsJenisAudit?, rupsSubmittedAt DateTime?; newLaporanKeuanganExtractionmodel (§6).LAPORAN_KEUANGAN+LAMPIRAN_LAPORAN_TAHUNANneed 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).backend/src/services/akta-txn-classifier.ts—BUILT_AKTA_TXN_TYPES += "laporan_rups_tahunan"(flipssupported, removes it fromdetectUnsupportedAktaType).backend/src/routes/klasifikasi.ts—LAPORAN_RUPS_DOC_TYPESlabel set (AKTA_RUPS_TAHUNAN wajib; LAPORAN_KEUANGAN conditional-desc; six LAMPIRAN_ keys; NPWP optional; OTHER) + serve?type=laporan_rups; extend therefineAktaLabeltrigger* forBERITA_ACARA_RUPS(gotcha 2, §7); wire the LAPORAN_KEUANGAN keyword classifier into the classify pass.backend/src/routes/submissions.ts—inferSubmissionType: add theAKTA_RUPS_TAHUNAN → LAPORAN_RUPS_TAHUNANrung of the canonical ladderPerbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT(a BA-RUPS-tahunan riding along a perubahan must not steal the submission);mapToDocumentTypefor the new labels;getProcessingOrder(akta first, LAPORAN_KEUANGAN mid, lampiran last).backend/src/flow-engine/flows/laporan-rups-tahunan.ts— theFlowConfig(§4.2) + registry key (compile-forced onceFLOW_REGISTRYisRecord<SubmissionType,…>).backend/src/services/submission-dispatch.ts—processorKindForTypecase forLAPORAN_RUPS_TAHUNAN→ the'v2-generic'kind (registry slice §4.7) delegatingrunFlow(id, flowFor(type))(thenever-tripwire fails compile until added).backend/src/services/submission-processor.ts—reMatchAndValidatebranch →reMatchAndValidateFlow(id, flowFor('LAPORAN_RUPS_TAHUNAN')), reached only viaqueueReMatchAndValidate([[reference_rematch_dispatch]] — THE validation-wipe gotcha).backend/src/services/document-processor.ts— generalize the two hardcodedAKTA_AKUISISIsites (L416/L472) to honour the engine-passedextraction.overrideByClassifiedType(the subtype override itself is declared on the FlowConfig, §4.2); the LAPORAN_KEUANGAN pass is dispatched viaextraction.focusedPasses.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 inindex.ts.- 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? }— mirrorsBeritaAcaraRupsExtraction(schema.prisma:593) per-document satellite shape. - Section approvals: reuse
PerubahanSectionApprovalrows 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
- reMatch registry note:
LAPORAN_RUPS_TAHUNANMUST be dispatched inreMatchAndValidate(via the registry) or every field edit silently wipes its validations ([[reference_rematch_dispatch]]); alwaysqueueReMatchAndValidate(serialized), never fire-and-forget. With the registry both dispatch and reMatch resolve the SAMEFlowConfig— this flow is the first to prove that property. - The
BERITA_ACARA_RUPSrefine blind spot (flow-critical, found in code):refineAktaLabelonly triggers onCOARSE_AKTA_LABELS = {AKTA_PENDIRIAN, AKTA_PERUBAHAN}(klasifikasi.ts:13,26). A Risalah/BA RUPS Tahunan will often be GPU-labelledBERITA_ACARA_RUPS— which never reaches the refine pass, soAKTA_RUPS_TAHUNANis never emitted and the flow is unreachable for exactly its most common deed shape. Fix: addBERITA_ACARA_RUPSto the trigger set with a conservative rule — override only when the sub-classifier returnslaporan_rups_tahunan; any other result keepsBERITA_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). - Ladder position: the canonical ladder is
Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT(implemented once insubmissions.ts, documented in the registry spec) —AKTA_RUPS_TAHUNANsits below Akuisisi/Perubahan, so a perubahan submission that also carries the annual BA must stayPERUBAHAN_PT. - 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. - Section approvals: explicit persisted rows only; never derive from child confirmed state ([[reference_perubahan_section_approval]]).
- 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.
never-switches:processorKindForType(and any exhaustiveSubmissionTypeswitch) won't compile until the new case exists — add all cases before trusting green.- 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. - 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. - Classifier gating: everything stays dark until ship:
laporan_rups_tahunan ∉ BUILT_AKTA_TXN_TYPESkeeps 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
- Lampiran slot names: confirm the exact upload slots in SABH
views/pelaporanRupsTahunan/_step3_upload.phpbefore freezing the sixLAMPIRAN_*keys (spec derives them from Ps.66(2) letters b–g; SABH may bundle differently or have a different count). - 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.)
- One submission = one tahun_laporan is assumed (backlog = N filings). Verify SABH doesn't batch years.
- 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_RUPSextractor (BeritaAcaraRupsExtractionalready holds nomor/tanggal/attendees) + a small focused header pass for notaris/nama-perseroan/SK. - 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.
- C12 — routeFamily:'laporan-rups' accepted; the RouteFamily union extension is made ONCE in flow-engine/types.ts by the registry slice.
- C14 — RUPS_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.