think
16px
820px

Backend Flow-Engine Slice — FLOW_REGISTRY Substrate for the 4 New PT Flows — Design Spec

Date: 2026-07-02
Status: Draft for review
Author: Efran Nathanael (with Claude)
Branch: feat/pt-flows-registry
Related:
- docs/specs/2026-07-01-target-architecture-design.md — the ACCEPTED target architecture. §2 FlowConfig/Rule/FLOW_REGISTRY interfaces are canonical; this spec implements the minimal Pillar-1 subset, it does not redesign them.
- docs/research/sabh-pt-untouched-flows-2026-07-01.md — SABH deep-read of the 4 flows this substrate serves.
- docs/superpowers/specs/2026-07-01-akta-transaction-type-classifier-design.md — the classifier taxonomy (already includes peleburan/pembubaran/berakhirnya/laporan_rups_tahunan) + BUILT_AKTA_TXN_TYPES gating.
- docs/superpowers/specs/2026-07-01-akuisisi-pt-flow-design.md — the flow-spec pattern; akuisisi's 823-line fork is the last fork we ever write.


1. Overview & Goal

The PT roadmap adds four full flows — Pelaporan RUPS Tahunan, Peleburan PT, Pembubaran PT, Berakhirnya Status Badan Hukum PT — each a complete upload→classify→extract→validate→review→verifikator/finalize experience like PENDIRIAN_PT/PERUBAHAN_PT/AKUISISI_PT. Building them the current way means four more ~800-line forks of perubahan-processor.ts (the akuisisi fork is 823 lines that differ from perubahan's 796 by ~30 meaningful lines: a different classifiedType, forcePeralihanOn, one koran field key, one appended rule).

Goal: build the minimal backend flow-engine slice from the accepted target architecture (Pillar 1 only) so that each new flow is a FlowConfig entry + a handful of wrapped rules + sanctioned hooks — no new processor forks, ever again. The slice must:

  1. Ship FLOW_REGISTRY: Record<SubmissionType, FlowConfig-or-stub>total, so the compiler forces an entry per enum value.
  2. Ship a generic runFlow/continueFlow that reproduces the PT-akta Phase 1–5 skeleton, config-gated (generalized from perubahan-processor.ts + akuisisi-processor.ts, which are near-byte-identical).
  3. Ship reMatchAndValidateFlow that resolves the same config — structurally killing the silent-validation-wipe class (reference_rematch_dispatch): there is no longer a second, un-exhaustive dispatch to forget.
  4. Wrap the existing cross-validator.ts rule bodies bit-for-bit ({code, label, run: ctx => existingValidator(ctx.input)}) — never rewrite legal logic during the strangle.
  5. Ship a transactional persistValidationResults (notIn-delete + override-preserving upsert, blanketDelete opt-out).
  6. Carry the engine: 'v1-fork' | 'v2-generic' migration flag. All ten existing flows stay v1-fork, untouched. The four new flows are the FIRST v2-generic consumers, adopting in the program ship order LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT (LAPORAN_RUPS_TAHUNAN is the first tenant). Shared components ship with their FIRST consumer in that order; this slice carries only the shared seams (types, focusedPasses, quorumThreshold param, widened companyLookup, RouteFamily extension) so later flows only add data.

Plus two substrate concerns the new flows share: the tuned validation-matrix mechanism (which docs are required, which rules run, at what severities — binding program decision) and the classification strategy for the four new supporting DocumentTypes the x056 GPU classifier does not know.

2. Scope

In scope
- backend/src/flow-engine/ — registry, defineFlow<Ctx>(), generic processor (runFlow + continueFlow), reMatchAndValidateFlow, runValidations (async-partitioned), transactional persistValidationResults, ProcessorHooks, RematchConfig.
- context/pt-akta-context.ts — the ValidationInput assembly lifted from runPerubahanCrossValidation (perubahan-processor.ts:267–628), parameterized by the flow's primary-akta classifiedType (it is forked 3× today, byte-identical).
- rules/pt-akta/ — the shared PT-akta rule set (33 validators from cross-validator.ts + RUPS_ATTENDANCE_QUORUM) wrapped, bodies untouched.
- v1/v2 routing in services/submission-dispatch.ts::dispatchProcessor and services/submission-processor.ts::reMatchAndValidate without breaking the never-exhaustiveness tripwires.
- Equivalence-oracle test harness: shadow configs for PERUBAHAN_PT + AKUISISI_PT used by tests only (their registry entries stay v1-fork), proving the generic engine reproduces the fork outputs on the same rule bodies.
- The per-flow validation-matrix declaration mechanism + first-cut matrix for the 4 flows (finalized in each flow spec).
- New supporting DocumentType enum values + per-type classification strategy (in-app grounded LLM vs manual board assignment) + the shared supporting-doc-classifier.ts seam.

Out of scope (explicitly EXCLUDED — other pillars / later slices)
- The contract/ dir, @contract alias, enum codegen, RouteContract (Pillar 2).
- The security pillar (installSecurity, RoutePolicy enforcement, JWT). FlowConfig.policy stays a typed-but-inert optional field.
- Persistence re-baseline, satellite tables, JsonCodec, outbox tables (Pillar 3). This slice is zero-migration except additive DocumentType enum values.
- Frontend ReviewEngine / FrontendReviewDescriptor / normalized ReviewData reshape (Pillar 4). The 4 flows' frontends are thin mirrors of the perubahan/akuisisi pages — do not front-run the ReviewData co-design sync point (target-arch §7 critical #1).
- Migrating any existing flow to v2. PENDIRIAN_PT, PERUBAHAN_PT, AKUISISI_PT, PERBAIKAN_DATA_PT, all PP flows, APOSTILLE keep their forks (engine:'v1-fork'). Flipping perubahan/akuisisi is a later decision, taken only after the 4 new flows prove the engine and the shadow parity suite is green.
- The four flow designs themselves (each has its own spec). The classifier stays gated: this slice changes neither BUILT_AKTA_TXN_TYPES (backend/src/services/akta-txn-classifier.ts, currently {pendirian, perubahan, akuisisi}) nor inferSubmissionType (backend/src/routes/submissions.ts:~88); each flow flips its own allowlist entry when it ships.
- New GPU extractors / gpu-server changes (Pillar 5) — per-flow specs.

3. Current Context & Reuse Map (what the engine generalizes)

The two donor processors are structural twins; the table names every seam the generic engine must reproduce:

Concern Perubahan (backend/src/services/perubahan-processor.ts) Akuisisi (backend/src/services/akuisisi-processor.ts) Becomes in v2
Phase 1: primary akta gate + extract classifiedType === "AKTA_PERUBAHAN", error "Akta Perubahan tidak ditemukan" "AKTA_AKUISISI", "Akta Akuisisi tidak ditemukan" cfg.primaryAkta.{classifiedType, missingError, extractError}
Phase 2: SABH lookup + auto-select / pause lookupCompany on nama_perseroan + ref_pendirian_nomor_sk; 1 high match → loadOldData + continue, else AWAITING_COMPANY_SELECTION identical cfg.companyLookup: true (gates the whole phase)
Phase 3: remaining docs SKIP_EXTRACTION_TYPES = ["AKTA_PEMINDAHAN_HAK","PENETAPAN_GANTI_NAMA"] identical cfg.skipExtractionTypes
Phase 3b: contact-info apply identical ~55-line block identical engine built-in, gated cfg.applyContactInfo
Phase 4: change-type detection detectChangeTypes(jenis, pasalYangDiubah) + forcePeralihanOn(changeTypeResult) engine calls detectChangeTypes then cfg.hooks?.mapChangeTypes?.(ct)at both call sites (Phase 4 AND rematch re-detect, akuisisi-processor.ts:806)
Phase 5: validation runAllValidations(input) + appended RUPS_ATTENDANCE_QUORUM + appended AKUISISI_KORAN_WINDOW; tanggalKoran from field key tanggal_koran_akuisisi cfg.rules = [...PT_AKTA_RULE_SET, ...flowRules] over one built context
Persist notIn-delete + upsert loop (NOT transactional) — upsert update omits overridden/overrideReason so it preserves overrides; the rematch wipe blanket-deletes and destroys them identical persistValidationResults — one $transaction, blanketDelete opt-out
Rematch reMatchAndValidatePerubahan (wipe → re-derive people from primary akta → standard KTP/NPWP/bukti/domisili/kontak matcher loop → RUPS matcher → change-type re-detect → cross-validate) identical fork reMatchAndValidateFlow(id, cfg) resolving the SAME config
Focused extraction pass koran pass extractKoranViaFocusedLlm wired inline in document-processor.ts:417,473 cfg.hooks?.afterAktaExtraction
Route family routes/perubahan.ts PERUBAHAN_FAMILY = new Set(["PERUBAHAN_PT","AKUISISI_PT"]) (line 32) + per-type branches at 178/235/998/1237 shared file cfg.routeFamily — family sets derive from the registry

Other load-bearing files (reused as-is):
- backend/src/services/cross-validator.tsValidationInput (line 32), RuleResult (line 148, spelling PASS/WARNING/FAIL/SKIPPED), 33 exported validateX functions, runAllValidations (line 1898: 29 sync + Promise.all of 3 async SABH-hitting rules validatePp29Modal/validateKbli/validatePerubahanKbli).
- backend/src/services/submission-dispatch.tsprocessorKindForType with the const _exhaustive: never tripwire (line 47).
- backend/src/services/submission-processor.tsqueueReMatchAndValidate (line 694, per-submission serialization — kept verbatim) and the reMatchAndValidate if-chain (line 710).
- backend/src/services/{company-lookup, change-type-detector, jenis-selection, rups-attendance-matcher, string-similarity}.ts, backend/src/services/document-processor.ts::processDocument — called by the engine, unchanged.
- backend/src/services/apostille-classifier.ts + akta-txn-classifier.ts — the template for the supporting-doc classifier (§4.8).
- backend/prisma/schema.prismaSubmissionType (line 58, ten values), DocumentType (line 20), ValidationResult (line 932: @@unique([submissionId, ruleCode]), overridden Boolean @default(false), overrideReason String?).

4. Design

4.1 File structure — backend/src/flow-engine/

backend/src/flow-engine/
├── types.ts                      # FlowConfig<Ctx>, FlowEntry, Rule<Ctx>, ProcessorHooks<Ctx>,
                                 #   RematchConfig, ExtractionPlan, RouteFamily, defineFlow<Ctx>()
├── registry.ts                   # FLOW_REGISTRY: Record<SubmissionType, FlowEntry> (TOTAL) + flowFor(type)
├── processor.ts                  # runFlow(id, cfg)  +  continueFlow(id, cfg)   (Phase 15 skeleton)
├── rematch.ts                    # reMatchAndValidateFlow(id, cfg)
├── validation-runner.ts          # runValidations(cfg, ctx)  +  persistValidationResults(id, results, opts)
├── context/
   └── pt-akta-context.ts        # buildPtAktaContext(id, opts)  the forked-3× ValidationInput assembly, once
├── rules/
   ├── pt-akta/index.ts          # PT_AKTA_RULE_SET: Rule<PtAktaCtx>[]  wrappers ONLY, bodies stay in cross-validator.ts
   ├── pt-akta/rups-quorum.ts    # RUPS_ATTENDANCE_QUORUM wrapper (adapts the {code,status,message} return; optional quorumThreshold param)
   └── akuisisi/koran-window.ts  # AKUISISI_KORAN_WINDOW wrapper (shadow/parity use until akuisisi flips)
├── flows/
   ├── v1-forks.ts               # the ten engine:'v1-fork' stub entries
   ├── __shadow__/perubahan-pt.shadow.ts    # parity-test-only v2 configs (NOT registered)
   ├── __shadow__/akuisisi-pt.shadow.ts
   └── (per flow spec, in ship order: laporan-rups-tahunan.ts · peleburan-pt.ts · pembubaran-pt.ts · berakhirnya-status-pt.ts)
└── __tests__/                    # registry totality · wrapper fidelity · parity oracle · persist semantics · dispatch routing

Import discipline: flow-engine/* may import services/* (cross-validator, company-lookup, document-processor, …) but never routes/*; services/submission-dispatch.ts and submission-processor.ts import flow-engine dynamically (same circular-dep hygiene the dispatch already uses).

4.2 Types — the canonical interfaces, minimal-slice subset

types.ts implements the target-arch §2 interfaces. RuleResult is re-exported from cross-validator.ts:148 (not redeclared) so every existing validator satisfies it unchanged.

// flow-engine/types.ts
import type { SubmissionType, DocumentType } from "../generated/prisma/enums";
import type { RuleResult } from "../services/cross-validator";          // VERBATIM re-export
import type { ChangeTypeDetectionResult } from "../services/change-type-detector";

export type RouteFamily =
  | "pendirian" | "perubahan" | "perbaikan" | "pp" | "peralihan-pp-pt" | "apostille"
  | "laporan-rups" | "peleburan" | "berakhirnya";
  // ^ extended ONCE here (sanctioned amendment #3): the three new-flow families genuinely need new route files
  //   (terminal submit / multi-source panel / dissolved-lookup CRUD). Pembubaran rides "perubahan".
export type ValidationStatus = "PASS" | "WARNING" | "FAIL" | "SKIPPED"; // exact Prisma spelling — no -ED

export interface Rule<Ctx> {
  code: string;                       // must equal the ruleCode the body emits (asserted by test)
  label: string;
  severityFloor?: ValidationStatus;   // clamp DOWN only (e.g. koran windows ≤ WARNING); never raises
  async?: boolean;                    // partitioned into Promise.all (SABH round-trips) — §7 HIGH latency fix
  run(ctx: Ctx): RuleResult | RuleResult[] | Promise<RuleResult | RuleResult[]>;
}

export interface ProcessorHooks<Ctx> {
  /** applied at EVERY detectChangeTypes call site (Phase 4 + rematch re-detect). Akuisisi precedent: forcePeralihanOn. */
  mapChangeTypes?(ct: ChangeTypeDetectionResult): ChangeTypeDetectionResult;
  /** focused post-akta pass. Precedent: extractKoranViaFocusedLlm (services/akta-koran-extract.ts). rawText from the stored OCR cache. */
  afterAktaExtraction?(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void>;
  /** runs inside the finalize transaction of the flow's route family. Future SABH-outbox seam; inert until a flow uses it. */
  onFinalize?(tx: PrismaTxClient, submissionId: string): Promise<void>;
}

export interface RematchConfig {
  /** SubmissionChecklist docTypes reset in the wipe (PT-akta standard: KTP, NPWP, BUKTI_SETOR, DOMISILI).
      NB: DOMISILI is the Prisma DocumentType (schema.prisma:27); the SURAT_PERNYATAAN_DOMISILI classification
      label maps onto it — never type the label here. */
  checklistResetDocTypes: DocumentType[];
  /** re-derive people from the primary akta + run the standard KTP/NPWP/bukti-setor/domisili/DATA_KONTAK matcher loop */
  standardPtMatchers: boolean;
  /** serialized extra steps before validation (e.g. matchRupsAttendance; change-type re-detect is engine-built-in for akta flows) */
  preValidationSteps?: Array<(submissionId: string) => Promise<void>>;
}

export interface ExtractionPlan {
  /** classifiedType → akta-subtype override passed to processDocument (default: identity, today's behaviour).
      Lets the new deeds ride existing schemas exactly like AKTA_AKUISISI does:
      AKTA_PELEBURAN → 'pendirian'; AKTA_PEMBUBARAN / AKTA_BERAKHIRNYA / AKTA_RUPS_TAHUNAN → 'perubahan'. */
  overrideByClassifiedType?: Record<string, string>;
  /** Sanctioned amendment #1 — THE per-docType focused-pass seam. After a doc of this DocumentType finishes OCR,
      the engine invokes the named focused grounded extractor (LAPORAN_KEUANGAN, SURAT_LIKUIDATOR,
      LAPORAN_LIKUIDASI, SURAT_PERMOHONAN, BUKTI_PENGUMUMAN). All four flow specs independently proved the need;
      this ONE shape replaces their divergent per-spec extraction inventions. Keyed by the CARRIER DocumentType
      (fine labels like BUKTI_PENGUMUMAN_149/_152 map onto it). */
  focusedPasses?: Partial<Record<DocumentType, string>>;
}

export interface FlowConfig<Ctx = unknown> {
  type: SubmissionType;
  engine: "v2-generic";               // v1 lives on FlowEntry (below); flag deleted when all flipped
  primaryAkta: { classifiedType: string; missingError: string; extractError: string } | null;
  skipExtractionTypes: string[];
  /** Sanctioned amendment #2 — widened once. Berakhirnya passes { allowedStatusTransaksi: [3] } (a dissolved PT
      is invisible to the default status_transaksi=1 lookup; lookupCompany gains the additive default-preserving
      opts param); Peleburan passes { mode: 'multi-source' } (its N-source loop composes the same
      lookupCompany/loadOldData/blokir primitives per source). */
  companyLookup: boolean | { allowedStatusTransaksi?: number[]; mode?: "single" | "multi-source" };
  applyContactInfo: boolean;
  extraction: ExtractionPlan;
  buildContext(submissionId: string): Promise<Ctx>;   // built ONCE per run; rules stay pure
  rules: Rule<Ctx>[];                                 // [...PT_AKTA_RULE_SET-subset, ...flowRules] = the tuned matrix
  requiredDocs: DocumentType[];                       // feeds the checklist + REQUIRED_SUPPORTING_DOCS tuning (§4.7)
  sections: { key: string; label: string; approvable: boolean; required: boolean }[]; // serialisable; FE publication deferred
  rematch: RematchConfig;
  hooks?: ProcessorHooks<Ctx>;                        // the ONLY sanctioned per-flow behaviour
  routeFamily: RouteFamily;
  policy?: unknown;                                   // typed placeholder — INERT until the security slice
  customProcess?(id: string, cfg: FlowConfig<Ctx>): Promise<void>;  // escape hatch; unused by the 4 flows
  labels: { akta: string; flow: string };
}

export function defineFlow<Ctx>(cfg: FlowConfig<Ctx>): FlowConfig<Ctx> { return cfg; }

Deliberate minimal-slice call — the registry value is a union, not a fake full config. Authoring ten complete FlowConfigs for un-migrated forks would be fabricated data nobody executes. Instead:

// flow-engine/registry.ts
export type FlowEntry = { engine: "v1-fork"; type: SubmissionType } | FlowConfig<any>;

export const FLOW_REGISTRY: Record<SubmissionType, FlowEntry> = {
  PENDIRIAN_PT:       { engine: "v1-fork", type: "PENDIRIAN_PT" },
  PERUBAHAN_PT:       { engine: "v1-fork", type: "PERUBAHAN_PT" },
  AKUISISI_PT:        { engine: "v1-fork", type: "AKUISISI_PT" },
  PERBAIKAN_DATA_PT:  { engine: "v1-fork", type: "PERBAIKAN_DATA_PT" },
  PENDIRIAN_PP:       { engine: "v1-fork", type: "PENDIRIAN_PP" },
  PERUBAHAN_PP:       { engine: "v1-fork", type: "PERUBAHAN_PP" },
  PERBAIKAN_DATA_PP:  { engine: "v1-fork", type: "PERBAIKAN_DATA_PP" },
  PEMBUBARAN_PP:      { engine: "v1-fork", type: "PEMBUBARAN_PP" },
  PERALIHAN_PP_KE_PT: { engine: "v1-fork", type: "PERALIHAN_PP_KE_PT" },
  APOSTILLE:          { engine: "v1-fork", type: "APOSTILLE" },
  // Each new flow spec replaces its future stub with a real defineFlow(...) entry.
};
export const flowFor = (t: SubmissionType): FlowEntry => FLOW_REGISTRY[t];

The Record is total: adding e.g. PEMBUBARAN_PT to the Prisma enum makes this file a missing-key compile error — the adoption forcing mechanism the codebase has proven it needs. Ctx typing is existential (FlowConfig<any>) at the registry boundary; defineFlow<Ctx>() gives full typing inside a flow file (target-arch Pillar-1 judgment call, accepted).

4.3 Illustrative v2 entry (what a flow spec will write)

Concrete sketch using Pembubaran (values indicative; its own spec finalizes them):

// flow-engine/flows/pembubaran-pt.ts  (lands with the Pembubaran flow spec, NOT this slice)
export const pembubaranPtFlow = defineFlow<PtAktaCtx>({
  type: "PEMBUBARAN_PT",
  engine: "v2-generic",
  primaryAkta: {
    classifiedType: "AKTA_PEMBUBARAN",                    // carrier label from akta-txn-classifier
    missingError: "Akta Pembubaran tidak ditemukan",
    extractError: "Gagal memproses Akta Pembubaran",
  },
  skipExtractionTypes: ["AKTA_PEMINDAHAN_HAK", "PENETAPAN_GANTI_NAMA"],
  companyLookup: true,
  applyContactInfo: true,
  extraction: {
    overrideByClassifiedType: { AKTA_PEMBUBARAN: "perubahan" },   // rides the perubahan schema, akuisisi precedent
    focusedPasses: { SURAT_LIKUIDATOR: "surat-likuidator", BUKTI_PENGUMUMAN: "bukti-pengumuman" },
  },
  buildContext: (id) => buildPtAktaContext(id, { aktaClassifiedType: "AKTA_PEMBUBARAN" }),
  rules: [...pickRules(PT_AKTA_RULE_SET, PEMBUBARAN_RULE_CODES),
          rupsQuorumRule({ quorumThreshold: 3 / 4 }),             // UUPT Ps.89(1); default is >1/2 (Ps.86)
          pembubaranKoranRule /* WARNING-clamped, Ps.147 */],
  requiredDocs: ["AKTA", "SURAT_LIKUIDATOR", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK"],
    // BERITA_ACARA_RUPS recommended-only + BUKTI_PENGUMUMAN optional (Ps.147 post-dissolution timing) — flow spec §4.6
  sections: [ /* per flow spec */ ],
  rematch: { checklistResetDocTypes: ["KTP", "NPWP", "DOMISILI", "SURAT_LIKUIDATOR", "BUKTI_PENGUMUMAN"],
             standardPtMatchers: true,
             preValidationSteps: [ (id) => matchRupsAttendance(id, db) ] },
  hooks: { afterAktaExtraction: pembubaranKoranInlineFallback },  // deed-INLINE koran via the shared extractKoranFields refactor
  routeFamily: "perubahan",
  labels: { akta: "Akta Pembubaran", flow: "Pembubaran PT" },
});

~40 lines vs an 823-line fork. pickRules(set, codes) is a typed filter helper in rules/pt-akta/index.ts — the tuned matrix is literally the rules array + requiredDocs.

4.4 Generic processor — runFlow / continueFlow

Two entry points, mirroring the fork's two-function split (the pause at AWAITING_COMPANY_SELECTION requires a resumable second half — the perubahan company-select route calls continuePerubahanProcessing; v2 routes call continueFlow(id, cfg)):

runFlow(submissionId, cfg) — Phase 1–2:
1. Status → EXTRACTING.
2. If cfg.customProcess → delegate and return (escape hatch, unused here).
3. cfg.primaryAkta: find subDoc by classifiedType; missing → ERROR with cfg.primaryAkta.missingError. Extract via the shared processOneDocument (status bookkeeping identical to perubahan-processor.ts:242–263), passing cfg.extraction.overrideByClassifiedType[classifiedType] ?? classifiedType as classificationOverride. Extraction ERROR → submission ERROR with extractError.
4. cfg.hooks?.afterAktaExtraction — engine loads the akta's stored raw OCR text (the same cache akta-txn-classifier reads; no processDocument signature change in this slice) and invokes the hook. Hook failures log-and-continue (a focused pass must never brick the run — required-but-empty fields surface at review instead, akuisisi-spec precedent).
5. cfg.companyLookup: lookupCompany on nama_perseroan + ref_pendirian_nomor_sk; exactly-1 high match → loadOldData, persist selectedNomorTransaksi/selectedTahun/oldData, fall through to continueFlow; else status AWAITING_COMPANY_SELECTION and stop. companyLookup:false skips straight to continueFlow (primaryAkta:null + companyLookup:false is the non-akta short-circuit shape for later migrations).
6. try/catch → status ERROR (same envelope as the forks).

continueFlow(submissionId, cfg) — Phase 3–5:
1. Phase 3: extract remaining docs in processingOrder; cfg.skipExtractionTypes marked DONE without extraction.
2. Phase 3b: contact-info apply (the identical ~55-line block, now written once) if cfg.applyContactInfo.
3. Phase 4 (only when cfg.primaryAkta): detectChangeTypes(jenis_perubahan, pasalYangDiubah)cfg.hooks?.mapChangeTypes?.(ct) → persist changeTypeResult.
4. Phase 5: status VALIDATINGconst ctx = await cfg.buildContext(id)runValidations(cfg, ctx)persistValidationResults(id, results).
5. Status READY, currentDocumentId: null.

4.5 Context, rules, runner, persist

buildPtAktaContext(submissionId, opts: { aktaClassifiedType: string; tanggalKoranFieldKey?: string }) — the 360-line ValidationInput assembly lifted verbatim from runPerubahanCrossValidation (NPWP perseroan fuzzy-bucketing, WNA threading, lookupNotarisIdByName, loadPerseroanBlokirState, peralihan-saham JSON parse, bukti-setor entries, effectiveJenis…), parameterized only by the two values that actually differ between the perubahan and akuisisi copies. Returns:

export interface PtAktaCtx {
  input: ValidationInput;                                  // cross-validator.ts:32, unchanged
  rupsQuorum: { pemegangSaham: {persentase: number|null; kehadiranRups: boolean|null}[]; hasBaRupsDoc: boolean };
  aktaFields: { fieldKey: string; value: string | null }[]; // for flow rules needing extra field keys (tanggal_rups etc.)
}

rules/pt-akta/index.ts — wrap, don't rewrite (target-arch decision #5):

export const PT_AKTA_RULE_SET: Rule<PtAktaCtx>[] = [
  { code: "NIK_KTP_AKTA", label: "NIK KTP sesuai dengan Akta", run: (ctx) => validateNikKtpAkta(ctx.input) },
  { code: "NAMA_NPWP_KTP", label: "Nama NPWP vs KTP",          run: (ctx) => validateNamaNpwpKtp(ctx.input) },
  // … all 29 sync validators, one line each …
  { code: "PP29_MODAL",   label: "PP 29/2016 Modal",  async: true, run: (ctx) => validatePp29Modal(ctx.input) },
  { code: "KBLI_VALID",   label: "KBLI Valid",        async: true, run: (ctx) => validateKbli(ctx.input) },
  { code: "PERUBAHAN_KBLI", label: "KBLI Perubahan",  async: true, run: (ctx) => validatePerubahanKbli(ctx.input) },
];

RUPS_ATTENDANCE_QUORUM wraps validateRupsAttendanceQuorum(ctx.rupsQuorum) adapting its {code,status,message} return to a RuleResult with ruleLabel: "Kuorum Kehadiran RUPS" — bit-for-bit what both forks append today (perubahan-processor.ts:632–646). The wrapper factory accepts an optional quorumThreshold (default >1/2, UUPT Ps.86(1); Pembubaran passes 3/4 per Ps.89(1); RUPS-Tahunan and Berakhirnya consume the default). Companion shared piece: rups-attendance-matcher.ts gains ONE registry-roster (oldData) attendance fallback (the deed may restate no shareholders — SABH's checkboxes are against the registry list); built once with its first consumer (Pembubaran), consumed by Berakhirnya too. AKUISISI_KORAN_WINDOW wraps validateAkuisisiKoranWindow({ tanggalKoran: ctx.input.tanggalKoran, tanggalRups }) with severityFloor: "WARNING" (its body already never FAILs — UUPT Pasal 127(8) exemption; the clamp is belt-and-braces documentation-in-types).

runValidations(cfg, ctx) — partition cfg.rules by rule.async; sync rules run sequentially (they are pure/instant), async rules via Promise.all — preserving today's latency profile (target-arch §7 HIGH: a naïve sequential runner triples field-edit latency on the SABH round-trips). Apply severityFloor clamp (FAIL→floor, order PASS<WARNING<FAIL; SKIPPED passes through). Flatten RuleResult[] returns. Dev-mode assertion: emitted ruleCode === rule.code (catches wrapper/typo drift).

persistValidationResults(submissionId, results, opts?: { blanketDelete?: boolean }) — ONE db.$transaction:
1. blanketDelete ? deleteMany({submissionId}) : deleteMany({submissionId, ruleCode: {notIn: results.map(r=>r.ruleCode)}})
2. Upserts on @@unique([submissionId, ruleCode]) where update sets only {status, message, details}overridden/overrideReason survive re-validation (today's fork upsert already behaves this way; what the forks get wrong is the rematch blanket-delete, which destroys operator overrides — v2 defaults to the preserving path).
Default blanketDelete: false for all v2 flows (they are greenfield — no behaviour-parity constraint). The shadow parity configs pass blanketDelete: true to reproduce fork semantics bit-for-bit (target-arch §7 MEDIUM, gated exactly as prescribed).

4.6 reMatchAndValidateFlow(submissionId, cfg) — same config, wipe class dead

Generalizes reMatchAndValidatePerubahan/...Akuisisi (identical forks):
1. $transaction wipe: identityMatch.deleteMany + checklist reset for cfg.rematch.checklistResetDocTypes. No validationResult blanket-delete (see §4.5) — stale rules are removed by the notIn-delete after re-validation, so overrides survive field edits.
2. If cfg.rematch.standardPtMatchers and the primary akta exists: collectUniquePeople from the akta roles → the standard loop over DONE subDocs (matchKtpDocument, matchNpwpDocument, fulfillChecklist(BUKTI_SETOR/SURAT_PERNYATAAN_DOMISILI), applyContactInfoToSubmission) — reusing the existing submission-processor.ts exports, per-doc try/catch log-and-continue as today.
3. cfg.rematch.preValidationSteps serially (each try/catch), e.g. matchRupsAttendance.
4. Engine-built-in change-type re-detect (when cfg.primaryAkta) with cfg.hooks?.mapChangeTypes applied — the akuisisi fork proves the hook must run here too (akuisisi-processor.ts:806, forcePeralihanOn on re-detect) or a re-extracted akta silently drops the forced jenis.
5. buildContextrunValidationspersistValidationResults.

Because reMatchAndValidateFlow and runFlow resolve the same FLOW_REGISTRY entry, a v2 flow cannot exist without its rematch path — the reference_rematch_dispatch bug class (field edits silently wiping validations because someone forgot the if-chain branch) is structurally impossible for v2 flows.

4.7 v1/v2 routing without breaking the never tripwires

dispatchProcessor (services/submission-dispatch.ts) — registry check first, legacy switch second:

export async function dispatchProcessor(submissionId: string, type: SubmissionType): Promise<void> {
  const { flowFor } = await import("../flow-engine/registry");          // dynamic: no static cycle
  const entry = flowFor(type);
  if (entry.engine === "v2-generic") {
    const { runFlow } = await import("../flow-engine/processor");
    return runFlow(submissionId, entry);
  }
  switch (processorKindForType(type)) { /* existing v1 cases UNCHANGED */ }
}

ProcessorKind gains one member "v2-generic"; each new v2 SubmissionType adds case "PEMBUBARAN_PT": return "v2-generic"; to processorKindForType — satisfying its const _exhaustive: never tripwire — and the dispatch switch's case "v2-generic": throws loud ("unreachable: v2 types dispatch via FLOW_REGISTRY"). Net: adding an enum value now fails compile in two independent places (registry totality + processorKindForType), and the fix for a v2 flow is one registry entry + one one-line case.

reMatchAndValidate (services/submission-processor.ts:710) — same pattern at the top of the function, before the existing if-chain:

const entry = (await import("../flow-engine/registry")).flowFor(submission.type);
if (entry.engine === "v2-generic") {
  const { reMatchAndValidateFlow } = await import("../flow-engine/rematch");
  return reMatchAndValidateFlow(submissionId, entry);
}
// …existing PERUBAHAN_PT / PERBAIKAN / AKUISISI / PP / APOSTILLE if-chain UNTOUCHED…

queueReMatchAndValidate (the serialized wrapper) is untouched — v2 flows inherit the per-submission serialization for free.

Route family: routes/perubahan.ts:32 PERUBAHAN_FAMILY becomes registry-derived: new Set(["PERUBAHAN_PT","AKUISISI_PT", ...v2 types whose routeFamily === "perubahan"]) via a typesForRouteFamily("perubahan") helper in registry.ts. The per-type fire-and-forget branches at lines 178/235 gain a v2 arm (dispatchProcessor/continueFlow via registry). The company-select endpoint calls continueFlow(id, entry) for v2 types. Finalize invokes entry.hooks?.onFinalize?.(tx, id) when present. No new route files for the 4 flows — that is the payoff of routeFamily.

4.8 Tuned validation matrix — mechanism + first cut

Mechanism (the substrate): a flow's matrix is exactly three declarations on its FlowConfigrules (which wrapped rules run, via pickRules), requiredDocs (feeds the checklist consumed by REQUIRED_SUPPORTING_DOCS/KTP_COMPLETENESS/NPWP_COMPLETENESS), and per-rule severityFloor clamps. Severities are never edited inside rule bodies — bodies are shared law; tuning = selection + clamping. Any never-FAIL rule must carry a legal citation in its wrapper's doc comment (akuisisi precedent: AKUISISI_KORAN_WINDOW ≤ WARNING because UUPT Ps.127(8) exempts direct-from-shareholder acquisitions; PP precedent: project_pp_nik_age_advisory).

Shared rule catalog (wrapped from cross-validator.ts, grouped):

Group Rule codes Notes
Identity NIK_KTP_AKTA · NAMA_NPWP_KTP · KTP_COMPLETENESS · NPWP_COMPLETENESS · PASSPORT_AKTA FAIL-capable (bodies unchanged)
Modal / saham MODAL_BUKTI_SETOR · PEMEGANG_SAHAM_BUKTI_SETOR · SHARES_SUM_CONSISTENCY · MODAL_HIERARCHY · NOMINAL_RECONCILE · PS_TOTAL_100 · PERUBAHAN_MODAL · SHARES_TRANSFER_BALANCE · SHARES_TOTAL_LEMBAR · PP29_MODAL(async)
Entity / docs NAMA_PT_CONSISTENCY · DOMISILI_NAMA · NPWP_PERSEROAN_FUZZY · CONTACT_INFO_COMPLETE · PEMEGANG_SAHAM_CONTACT · REQUIRED_SUPPORTING_DOCS · UPLOADED_DOC_UNUSED · AKTA_DATE_WINDOW
Registry (SABH) OLD_DATA_CONSISTENCY · PERSEROAN_STATE · NOTARIS_TERAKHIR
Jenis DATA_ACTUALLY_CHANGED · JENIS_SELECTION_NONEMPTY · JENIS_DESELECT_REASONS_COMPLETE · CONTRADICTORY_JENIS
KBLI KBLI_VALID(async) · PERUBAHAN_KBLI(async)
RUPS RUPS_ATTENDANCE_QUORUM separate wrapper
Flow-specific AKUISISI_KORAN_WINDOW (exists) + net-new per flow below

First-cut per-flow matrix (each flow spec finalizes and its legally-cited positions supersede this first cut; the rows below already reflect the reconciled flow-spec decisions — 2026-07-02):

LAPORAN_RUPS_TAHUNAN PELEBURAN_PT PEMBUBARAN_PT BERAKHIRNYA_STATUS_PT
Primary akta label AKTA_RUPS_TAHUNAN AKTA_PELEBURAN AKTA_PEMBUBARAN AKTA_BERAKHIRNYA
Required docs AKTA_RUPS_TAHUNAN wajib, LAPORAN_KEUANGAN (conditional: wajib-audit), LAMPIRAN_LAPORAN_TAHUNAN optional, NPWP perseroan optional — no KTP/DOMISILI/DATA_KONTAK (the filing registers no person/address/kontak; forcing them = burden with zero cross-check value) KTP+NPWP (all pengurus/PS baru), DOMISILI (via the SURAT_PERNYATAAN_DOMISILI label), DATA_KONTAK, BERITA_ACARA_RUPSno BUKTI_SETOR (SABH's peleburan wizard has no bukti-setor upload); BUKTI_PENGUMUMAN optional AKTA_PEMBUBARAN, SURAT_LIKUIDATOR, KTP (penghadap + likuidator), NPWP, DOMISILI, DATA_KONTAK; optional: BERITA_ACARA_RUPS (deed may be a PKR incorporating the RUPS or the BA is the akta_kedua), BUKTI_PENGUMUMAN (Ps.147 post-dissolution timing) BERITA_ACARA_RUPS, SURAT_PERMOHONAN, LAPORAN_LIKUIDASI (laporan akhir — a DISTINCT DocumentType, not a SURAT_LIKUIDATOR sub-shape), BUKTI_PENGUMUMAN ×2 (fine labels _149/_152), KTP (likuidator), NPWP (perseroan)
Rule groups on Registry + RUPS + flow-specific tahun/akuntan rules ALL (pendirian-grade: identity, modal/saham minus the bukti-setor pair, entity, KBLI, RUPS) + Registry Identity + Registry + RUPS(¾) + REQUIRED_SUPPORTING_DOCS Registry (new rule) + REQUIRED_SUPPORTING_DOCS + Identity(light) + RUPS
Rule groups OFF modal/saham, KBLI, jenis, identity/kontak, AKTA_DATE_WINDOW (would false-FAIL every legitimate 10-year backlog filing — replaced by TAHUN_LAPORAN_VALID/TAHUN_BUKU_ORDERING/RUPS_ANNUAL_WINDOW) jenis-deselect set (new-entity), MODAL_BUKTI_SETOR/PEMEGANG_SAHAM_BUKTI_SETOR modal/saham, KBLI, jenis modal/saham, KBLI, jenis, kontak
Net-new rules RUPS_AKTA_FIELDS_PRESENT/TAHUN_LAPORAN_VALID/TAHUN_BUKU_ORDERING/AKUNTAN_PUBLIK_REQUIRED (FAIL) + RUPS_ANNUAL_WINDOW/AKUNTAN_IZIN_FORMAT/LAPORAN_KEUANGAN_TAHUN_MATCH/RUPS_LAMPIRAN_COMPLETENESS (WARNING) — flow spec §4.5 PELEBURAN_KORAN_WINDOWFAIL when both dates parse and koran <30d pre-RUPS (UUPT Ps.127(2)); the Ps.127(8) exemption does NOT cover peleburan — it is limited to pengambilalihan langsung dari pemegang saham, which is exactly why AKUISISI_KORAN_WINDOW is WARNING-only; WARNING when koran absent/unparseable, SKIPPED when no RUPS date PEMBUBARAN_KORAN (pengumuman ≤30d post-dissolution) — WARNING never FAIL, cite UUPT Ps.147 (publication is a post-dissolution obligation that may legitimately not exist at filing) BSBHP_REGISTRY_STATEFAIL unless status_transaksi==3 (a NEW rule; never wrap/invert validatePerseroanState); pengumuman presence handled by REQUIRED_SUPPORTING_DOCS over the _149/_152 fine labels (no separate presence rule); pengumuman dates WARNING
FAIL-capable core RUPS_AKTA_FIELDS_PRESENT, TAHUN_LAPORAN_VALID, TAHUN_BUKU_ORDERING, AKUNTAN_PUBLIK_REQUIRED, PERSEROAN_STATE full pendirian-grade set + PELEBURAN_MIN_SOURCES/PELEBURAN_SOURCE_RESOLVED/PELEBURAN_SOURCE_BLOKIR/PELEBURAN_SOURCE_STATUS/PELEBURAN_KORAN_WINDOW NIK_KTP_AKTA, PERSEROAN_STATE (must be active), PT_ALREADY_DISSOLVED, LIKUIDATOR_KTP_MATCH, REQUIRED_SUPPORTING_DOCS BSBHP_REGISTRY_STATE, BSBHP_LIKUIDATOR_CONSISTENCY (<60 tier), REQUIRED_SUPPORTING_DOCS

4.9 New supporting DocumentTypes + classification strategy

The x056 GPU classifier (ahu-classifier-v2, LayoutLMv3, 22 flat labels) has no label for any of the six supporting docs the flows need, and an untrained layout model will not abstain — it confidently maps to the nearest known class above the 0.6 gate (the "confident-wrong" trap, classifier-spec §4). Retraining is rejected for the same cold-start reasons as the txn classifier. This slice therefore adds six additive DocumentType enum values (ONE batch — conflict-17 reconciliation) and decides the classification strategy per type:

New DocumentType First consumer (ship order) Strategy Why
LAPORAN_KEUANGAN LAPORAN_RUPS_TAHUNAN Keyword-first + grounded-LLM fallback (apostille-classifier pattern) Strong lexical signal ("LAPORAN KEUANGAN", "OPINI", "AKUNTAN PUBLIK", izin number) + it feeds auto-fill (nama/izin/tanggal akuntan) in the highest-volume untouched flow — auto-routing pays for itself; manual assignment at RUPS-Tahunan volume would be a per-filing tax
LAMPIRAN_LAPORAN_TAHUNAN LAPORAN_RUPS_TAHUNAN Manual board assignment (store-only) Six LAMPIRAN_* fine labels (Ps.66(2) letters b–g) map onto this ONE carrier; no extraction, no validation branches on the letter — an LLM sub-classifier would add cost for zero downstream value
BUKTI_PENGUMUMAN PELEBURAN_PT (shared with PEMBUBARAN_PT + BERAKHIRNYA_STATUS_PT) ONE shared deterministic keyword pre-suggester on the board — NO LLM, never auto-accepted Union keyword set: /(PENGUMUMAN)/i + (PELEBURAN\|KONSOLIDASI\|PEMBUBARAN\|LIKUIDASI\|RUPS\|RINGKASAN RANCANGAN) + masthead/date shape → amber needs-review suggestion only. PaddleOCR line-splits dense newsprint (2026-06-29 benchmark) — grounded-LLM classification over shredded text is unreliable, which defeats the LLM option. The Ps.149/152 axis = berakhirnya-only fine labels BUKTI_PENGUMUMAN_149/_152 (both map to this carrier) with a bolt-on keyword suggester (kreditor/pembubaran → _149; hasil likuidasi/pertanggungjawaban → _152), also never auto-accepted. ONE shared clipping extractor bukti-pengumuman-extract.ts (canonical keys nama_koran/tanggal_pengumuman/nomor_pengumuman, all nullable)
SURAT_LIKUIDATOR PEMBUBARAN_PT In-app grounded LLM "SURAT PENUNJUKAN LIKUIDATOR" title — penunjukan shape ONLY. The laporan akhir/pertanggungjawaban likuidasi is the DISTINCT LAPORAN_LIKUIDASI type below (the Pembubaran-vs-Berakhirnya predicate keys on the distinction — collapsing them would swallow the routing tell)
LAPORAN_LIKUIDASI BERAKHIRNYA_STATUS_PT In-app grounded LLM Explicit title (/laporan\s+(akhir\s+)?likuidasi\|pertanggungjawaban\s+likuidator/i); laporan akhir/pertanggungjawaban shape incl. sisa_kekayaan — own extractor laporan-likuidasi-extract.ts
SURAT_PERMOHONAN BERAKHIRNYA_STATUS_PT In-app grounded LLM (keyword pre-screen /\bpermohonan\b/i + /(berakhir\|hapus).{0,40}badan\s+hukum/i), advisory Adopts the Berakhirnya flow spec's position (this slice's earlier first cut said manual-only): the perihal-line signal is strong on a single-page letter; the board override remains authoritative. Schema doc-comment required distinguishing it from the existing SURAT_PERMOHONAN_KORPORASI (schema.prisma:38): berakhirnya filing letter vs Perbaikan-korporasi letter

Domisili is NOT a new type: the SURAT_PERNYATAAN_DOMISILI classification label maps to the existing DocumentType.DOMISILI (schema.prisma:27); every spec typing requiredDocs/checklistResetDocTypes as DocumentType[] must use DOMISILI.

Mechanism: one new module backend/src/services/supporting-doc-classifier.ts mirroring apostille-classifier.ts/akta-txn-classifier.ts — grounded 3B call with deterministic keyword pre-rules and a keyword-confirmation confidence tier, invoked from routes/klasifikasi.ts exactly like refineAktaLabel (line 26 precedent) only for files the GPU classifies below confidence or outside the flow's expected label set. BUKTI_PENGUMUMAN (incl. the _149/_152 axis) bypasses the LLM path entirely — keyword-suggest-only. All six labels are added to the relevant flow ?type= label sets (PEMBUBARAN_DOC_TYPES etc., per flow spec) so manual board assignment is always available and always overrides — suggestions propose, the operator re-maps, never silently trusted. The classifier module ships with this slice (shared seam); label-set wiring + extractors ship with each type's first consumer flow.

4.10 Frontend

None in this slice. The 4 flows' frontends will be thin mirrors of the perubahan/akuisisi pages (PerubahanDeltaReviewPage, CompanySelectionModal, use-perubahan hooks, verifikator pages) per the akuisisi precedent — specified per flow. No ReviewEngine, no FrontendReviewDescriptor, no ReviewData reshape (hard sync point owned by Pillars 2+4+5, sequenced later). FlowConfig.sections is populated by flow configs but has no FE consumer yet — it is forward-compatible data, not a contract.

5. Integration / wiring checklist (this slice)

  1. backend/src/flow-engine/* — new modules per §4.1 (registry with ten v1-fork stubs; no v2 entries yet).
  2. backend/src/services/submission-dispatch.ts — registry check in dispatchProcessor; ProcessorKind gains "v2-generic"; unreachable-throw case in the switch. Existing cases byte-identical.
  3. backend/src/services/submission-processor.ts — registry check at top of reMatchAndValidate (before the if-chain). queueReMatchAndValidate untouched.
  4. backend/src/routes/perubahan.tsPERUBAHAN_FAMILY derived via typesForRouteFamily("perubahan"); company-select/retry/finalize branches gain the v2 arm (behaviour identical for PERUBAHAN_PT/AKUISISI_PT).
  5. backend/prisma/schema.prisma — ONE additive batch adding all six new DocumentType values: LAPORAN_KEUANGAN, LAMPIRAN_LAPORAN_TAHUNAN, SURAT_LIKUIDATOR, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN, SURAT_PERMOHONAN (domisili keeps using the existing DOMISILI value — no SURAT_PERNYATAAN_DOMISILI enum). Doc-comment on SURAT_PERMOHONAN distinguishing it from the existing SURAT_PERMOHONAN_KORPORASI (berakhirnya filing letter vs Perbaikan-korporasi letter). bunx prisma generate + additive migration.
  6. backend/src/services/supporting-doc-classifier.ts — new module (seam only; no klasifikasi wiring until a flow ships).
  7. NOT in this slice: no SubmissionType enum additions, no BUILT_AKTA_TXN_TYPES change, no inferSubmissionType change, no frontend change. Each flow spec owns its own gating flips (classifier-gated until ship, binding decision #6). The canonical inferSubmissionType ladder (implemented in submissions.ts, documented here so no flow inserts blind) is: Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT — each flow adds only its OWN rung at ship time. The Pembubaran↔Berakhirnya doc-set predicate is implemented ONCE in submissions.ts (lands with PEMBUBARAN_PT, its first consumer) and counts the carrier DocumentType via mapToDocumentType, never the raw label string.

Per-flow checklist (what each of the 4 flow specs inherits — for contrast with the akuisisi 9-step list): add enum value → compiler forces the FLOW_REGISTRY entry + the processorKindForType case → write flows/<flow>.ts + flow rules + hooks → flip BUILT_AKTA_TXN_TYPES + inferSubmissionType ladder + klasifikasi label set → frontend thin mirrors. Dispatch and rematch need zero edits beyond the one-line ProcessorKind case.

6. Data model / persistence

Additive only. No new tables, no column changes, no data migration.
- enum DocumentType + 6 values in ONE additive batch (§4.9). Nothing references them until the flows ship — inert.
- ValidationResult, SubmissionChecklist, IdentityMatch, PerubahanSectionApproval, verifikasi tables: unchanged. The engine writes through the exact same rows/uniques the forks do.
- The Pillar-3 re-baseline/satellites/codecs are explicitly out of scope; buildPtAktaContext keeps today's as any JSON reach-ins for oldData/changeTypeResult verbatim (lifting them into codecs is the persistence slice's job — doing it here would violate bit-for-bit and break the oracle).

7. Error handling & gotchas

  • The rematch registry note (reference_rematch_dispatch): for v2 flows the wipe class is dead by construction (§4.6). The v1 if-chain is UNTOUCHED — do not "clean it up" in this slice; it is the oracle.
  • Section approvals (reference_perubahan_section_approval): the engine never derives section-approved state; flows on routeFamily:"perubahan" inherit the submit gate that reads persisted PerubahanSectionApproval rows. Regression c0698d8 must not be re-imported via a "helpful" derived default.
  • Persist-semantics change is scoped: v2 default (blanketDelete:false in rematch) preserves operator overrides across field edits — a deliberate improvement for the greenfield flows, but shadow parity for perubahan/akuisisi must run with blanketDelete:true or the oracle diffs on rows the fork legitimately (if regrettably) destroys.
  • Async partition (target-arch §7 HIGH): PP29_MODAL/KBLI_VALID/PERUBAHAN_KBLI hit SABH; they MUST stay Promise.all-parallel or every field edit slows ~3×. Wall-clock non-regression assertion in the oracle suite.
  • Hook discipline: mapChangeTypes must be applied at both detectChangeTypes call sites (Phase 4 + rematch re-detect) — the akuisisi fork's ghost-jenis comment (akuisisi-processor.ts:781–786) documents the failure mode.
  • afterAktaExtraction failures never brick the run — log + continue; required-but-empty surfaces at review (akuisisi koran precedent, incl. the GPU-OCR empty-rawText warning path in akta-koran-extract.ts).
  • Circular imports: dispatch/submission-processor import flow-engine dynamically; flow-engine never imports routes/*. The ten v1 stubs carry no processor references at all (the legacy dynamic-import switch still owns those).
  • PEMBUBARAN_PP "none" kind (no reprocess entrypoint) is preserved exactly — its stub is v1-fork and the legacy switch still marks it ERROR on resume.
  • Enum spellings: ValidationStatus is PASS/WARNING/FAIL/SKIPPED (no -ED); the new-flow SubmissionTypes are LAPORAN_RUPS_TAHUNAN/PELEBURAN_PT/PEMBUBARAN_PT/BERAKHIRNYA_STATUS_PT (fixed here so all four specs agree; LAPORAN_ — the mechanical uppercase of the classifier taxonomy laporan_rups_tahunan, NOT SABH's controller noun PELAPORAN_; carrier labels AKTA_RUPS_TAHUNAN/AKTA_PELEBURAN/AKTA_PEMBUBARAN/AKTA_BERAKHIRNYA already exist in AKTA_TXN_TYPE_TO_LABEL).
  • NON_OVERRIDABLE_RULE_CODES (imported in routes/submissions.ts from pp-bo-kriteria) is an override-API concern, orthogonal to persist — unaffected, but flow specs adding never-overridable rules must register codes there.

8. Testing strategy (TDD)

Tests first (superpowers:test-driven-development); DB tests target ahu_ocr_test via the bunfig preload — never the dev DB (feedback_test_db_isolation).

  1. Registry totality — compile-level (a missing key fails bunx tsc --noEmit) + a runtime test iterating the Prisma SubmissionType enum asserting an entry exists and engine is valid.
  2. Wrapper fidelity — for every rule in PT_AKTA_RULE_SET: on a fixture PtAktaCtx, rule.run(ctx) is reference-equal in shape to calling the underlying validateX(ctx.input) directly, and the emitted ruleCode/ruleLabel match rule.code/rule.label. Guards against wrap-time drift without duplicating legal cases (which stay in the existing cross-validator suites).
  3. Equivalence oracle (the core deliverable)flow-engine/__tests__/flow-parity.test.ts: seed identical submissions (akta + KTP/NPWP/bukti-setor/domisili/DATA_KONTAK fixtures) twice in ahu_ocr_test; run the fork (runPerubahanCrossValidation / runAkuisisiCrossValidation) on one and the shadow v2 config (buildPtAktaContextrunValidationspersistValidationResults({blanketDelete:true})) on the other; deep-equal the ValidationResult row sets on {ruleCode, ruleLabel, status, message, details}. Because the rule bodies are the same functions on both paths, any diff isolates engine plumbing (context assembly, partition, persist) — exactly the honest oracle the target arch prescribes. Repeat for the rematch path (fork reMatchAndValidatePerubahan vs reMatchAndValidateFlow), including the RUPS-matcher and change-type-redetect steps, and assert forcePeralihanOn behaviour via the akuisisi shadow's mapChangeTypes hook.
  4. Persist semantics — override preservation (set overridden:true+reason, re-run, assert both survive with updated status/message); stale-rule notIn-deletion; blanketDelete:true reproduces fork wipe; transaction atomicity (inject a failing upsert, assert zero partial writes).
  5. Dispatch routing — a v2 entry routes dispatchProcessor to runFlow and reMatchAndValidate to reMatchAndValidateFlow; every v1 type still reaches its fork (spy on dynamic imports); the "v2-generic" legacy-switch case throws; regression on queueReMatchAndValidate serialization.
  6. Latency non-regression — mock the 3 async rules with delays; assert total runValidations wall clock ≈ max, not sum.
  7. Supporting-doc classifier — keyword pre-rules per label; LLM outage → null → file stays operator-assignable (never silently labeled); BUKTI_PENGUMUMAN (incl. the _149/_152 axis) is keyword-suggest-only — assert no LLM call and never auto-accepted.
  8. Full regression — existing perubahan/akuisisi/pendirian/PP/apostille suites must pass untouched (this slice changes their behaviour by exactly zero).

9. Deferred

  • Flipping PERUBAHAN_PT/AKUISISI_PT (or any existing flow) to v2-generic — after the 4 new flows prove the engine + shadow parity is green over real traffic fixtures.
  • contract/ publication of FlowConfig.sections; RoutePolicy enforcement; onFinalize→SABH outbox; persistence codecs replacing the oldData reach-ins; saveExtraction/gpuJob (Pillar 5); frontend ReviewEngine.
  • Deleting the legacy dispatch switch + reMatch if-chain + the engine flag itself (end-state cleanup, target-arch step 10).
  • LLM-assist for SURAT_PERMOHONAN; graduation of the supporting-doc classifier to a trained text model (flywheel per classifier-spec §5.5).

10. Open questions

  1. Shadow-config placement: committed under flows/__shadow__/ (importable by tests, excluded from the registry) vs living inside __tests__/. Recommendation: __shadow__/ — they become the real configs at flip time, so keeping them as first-class files avoids a rewrite.
  2. FlowEntry union vs full-config stubs: this spec chooses the union (v1-fork stubs carry only {engine, type}) to avoid fabricating config data for forks. Confirm this reading of the canonical interface is acceptable (the target arch's engine flag lives on FlowConfig; the union is the migration-period representation and disappears when the flag does).
  3. Finalize transaction boundary: routes/perubahan.ts finalize is not currently a single transaction; onFinalize(tx, id) presumes one. Verify during implementation whether wrapping the existing finalize writes in $transaction is behaviour-safe, or whether onFinalize should accept db for now and gain tx when the outbox lands.
  4. sections for the 4 flows: populate now (inert, forward-compatible) or defer to the ReviewData co-design to avoid inventing a section vocabulary twice? Recommendation: populate with the existing PerubahanSectionApproval section keys the thin-mirror frontends already use — zero invention.
  5. supporting-doc-classifier model choice: 3B cleanup endpoint (fast, apostille precedent — but brittle on broad prompts) vs 35B (akta-txn precedent). These are short, lexically-anchored letters; recommendation 3B with keyword grounding, single-label-per-call, upgrade per-type if mislabels show up. (Does not apply to BUKTI_PENGUMUMAN — keyword-suggest-only, no LLM.)

Cross-spec reconciliation (2026-07-02)

Applied from .superpowers/sdd/spec-critique-full.md (this spec owns the canonical interfaces):
- C1 — matrix column + enum-spelling gotcha renamed PELAPORAN_RUPS_TAHUNANLAPORAN_RUPS_TAHUNAN; flow file renamed laporan-rups-tahunan.ts.
- C2LAPORAN_LIKUIDASI added as a DISTINCT DocumentType (§4.9); SURAT_LIKUIDATOR narrowed to the penunjukan shape; berakhirnya's 6-doc row no longer misuses SURAT_LIKUIDATOR for the laporan akhir.
- C3BUKTI_PENGUMUMAN strategy corrected to the ONE shared deterministic keyword pre-suggester (union keyword set, no LLM, never auto-accepted) + berakhirnya's _149/_152 bolt-on axis.
- C6–C10 — first-cut matrix rows superseded by the flow specs' legally-cited positions (peleburan koran FAIL via Ps.127(8) inapplicability; RUPS-Tahunan drops AKTA_DATE_WINDOW + KTP/DATA_KONTAK; peleburan drops BUKTI_SETOR; pembubaran BA-RUPS recommended-only + BUKTI_PENGUMUMAN optional; berakhirnya BSBHP_REGISTRY_STATE replaces the inverted-PERSEROAN_STATE row and PENGUMUMAN_PS149_152_PRESENT is dropped in favour of REQUIRED_SUPPORTING_DOCS over the fine labels).
- C11 — sanctioned amendment #1: ExtractionPlan.focusedPasses (the ONE per-docType focused-pass seam); overrideByClassifiedType doc updated with the four flows' subtype targets.
- C12 — sanctioned amendment #3: RouteFamily union extended with 'laporan-rups' | 'peleburan' | 'berakhirnya'.
- C13 — sanctioned amendment #2: companyLookup widened to boolean | { allowedStatusTransaksi?; mode? }.
- C14RUPS_ATTENDANCE_QUORUM wrapper gains optional quorumThreshold (default >1/2 Ps.86; pembubaran 3/4 Ps.89) + the shared registry-roster (oldData) attendance fallback.
- C15 — canonical inferSubmissionType ladder documented here (§5 item 7), implemented once in submissions.ts.
- C16 — program ship order fixed: LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT; shared components ship with their first consumer; this slice carries the seams only.
- C17 — migration batch expanded to all SIX DocumentTypes; DOMISILI (existing) documented as the domisili carrier; SURAT_PERMOHONAN vs SURAT_PERMOHONAN_KORPORASI doc-comment mandated.
- Judgment call: SURAT_PERMOHONAN classification switched from this slice's manual-only first cut to the Berakhirnya flow author's keyword+grounded-LLM advisory strategy (flow-author-position preference; not one of the 17 numbered conflicts).