Backend Flow-Engine Slice — FLOW_REGISTRY Substrate Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the minimal backend flow-engine slice (Pillar-1 subset) — a total FLOW_REGISTRY, a generic config-gated runFlow/continueFlow/reMatchAndValidateFlow, wrapped (bit-for-bit) PT-akta rules, transactional persistValidationResults, block-with-override FAIL gate, and the supporting-doc-classifier seam — so the four new PT flows become FlowConfig entries instead of ~800-line processor forks.
Architecture: A new backend/src/flow-engine/ package generalizes the two near-byte-identical donor processors (perubahan-processor.ts + akuisisi-processor.ts) behind a FlowConfig<Ctx> record keyed by SubmissionType. All ten existing flows stay engine:'v1-fork' (routed through the untouched legacy dispatch/if-chain); v1/v2 selection happens via a registry check at the top of dispatchProcessor and reMatchAndValidate without breaking their never-exhaustiveness tripwires. Rule bodies stay in cross-validator.ts and are wrapped, never rewritten; equivalence-oracle tests prove the generic engine reproduces the forks' ValidationResult rows on the same rule bodies.
Tech Stack: Bun + Hono backend; Prisma 7 (client generated to src/generated/prisma/); PostgreSQL 16; tests via bun:test against the isolated ahu_ocr_test DB (bunfig [test].preload = ./src/test-setup.ts); typecheck via bunx tsc --noEmit.
Global Constraints
Binding values — copy verbatim; do not paraphrase:
- Enum spellings (exact).
ValidationStatusunion is"PASS" | "WARNING" | "FAIL" | "SKIPPED"(NO-EDsuffixes). The four newSubmissionTypes areLAPORAN_RUPS_TAHUNAN,PELEBURAN_PT,PEMBUBARAN_PT,BERAKHIRNYA_STATUS_PT(noteLAPORAN_, NOTPELAPORAN_). This slice adds NONE of them to the Prisma enum — SubmissionType is unchanged (see below). Carrier akta labelsAKTA_RUPS_TAHUNAN/AKTA_PELEBURAN/AKTA_PEMBUBARAN/AKTA_BERAKHIRNYAalready exist inAKTA_TXN_TYPE_TO_LABEL. - The six additive
DocumentTypevalues (ONE batch, this slice's ONLY schema change):LAPORAN_KEUANGAN,LAMPIRAN_LAPORAN_TAHUNAN,SURAT_LIKUIDATOR,LAPORAN_LIKUIDASI,BUKTI_PENGUMUMAN,SURAT_PERMOHONAN. Domisili keeps using the EXISTINGDOMISILIvalue — there is NOSURAT_PERNYATAAN_DOMISILIenum value (that is a classification label mapping ontoDocumentType.DOMISILI).SURAT_PERMOHONANcarries a schema doc-comment distinguishing it from the existingSURAT_PERMOHONAN_KORPORASI. - NO SubmissionType enum changes in this slice. No
BUILT_AKTA_TXN_TYPESchange. NoinferSubmissionTypechange. No frontend change. TheFLOW_REGISTRYships with all ten existing types asv1-forkstubs and ZEROv2-genericentries. Each future flow spec adds its own SubmissionType + registry entry +ProcessorKindcase. - Wrap rules bit-for-bit — NEVER edit validator bodies. Every rule in
PT_AKTA_RULE_SETis{ code, label, run: (ctx) => validateX(ctx.input) }calling the UNCHANGED exported function fromcross-validator.ts. Legal logic and severities live in the bodies; tuning is selection (pickRules) +severityFloorclamps only. - ALL existing flows stay
engine:'v1-fork'untouched.PENDIRIAN_PT,PERUBAHAN_PT,AKUISISI_PT,PERBAIKAN_DATA_PT, all PP flows,APOSTILLEkeep their forks. The legacy dispatch switch and thereMatchAndValidateif-chain are the ORACLE — do not "clean them up". - FAIL gate = block-with-override, reusing
ValidationResult.overridden. Forv2-genericflows the engine blocks finalize when any row hasstatus === "FAIL" && overridden !== true. SKIPPED and WARNING NEVER block regardless ofoverridden. No new table. Thepp-perubahan.ts:1538gate is the reference pattern. Opt-out only viaFlowConfig.failGate: 'off'(default'on'). - Zero-migration except the six additive DocumentTypes. No new tables, no column changes, no data migration. The engine writes through the exact same
ValidationResultrows +@@unique([submissionId, ruleCode])the forks use. Keep theas anyJSON reach-ins foroldData/changeTypeResultverbatim (codecs are the persistence slice's job). - Import discipline / circular-dep hygiene.
flow-engine/*may importservices/*but NEVERroutes/*.submission-dispatch.tsandsubmission-processor.tsimportflow-engineDYNAMICALLY (await import(...)), same as the existing dynamic switch. - Async partition is load-bearing.
PP29_MODAL/KBLI_VALID/PERUBAHAN_KBLIhit SABH and MUST run viaPromise.all(rule.async: true); a naïve sequential runner triples field-edit latency. - Test-DB isolation. All DB tests target
ahu_ocr_testvia the bunfig preload. NEVER runbun testagainst the dev DB. Runbun run db:push:testafter the schema change (Task 1) to sync the test DB before any DB-touching test.
File Structure
| File | Create/Modify | Responsibility |
|---|---|---|
backend/prisma/schema.prisma |
Modify (enum DocumentType L20-48) |
Add the six additive DocumentType values in one batch |
backend/src/flow-engine/types.ts |
Create | FlowConfig<Ctx>, Rule<Ctx>, ProcessorHooks<Ctx>, RematchConfig, ExtractionPlan, RouteFamily, ValidationStatus, defineFlow<Ctx>(); re-export RuleResult |
backend/src/flow-engine/registry.ts |
Create | FlowEntry union, total FLOW_REGISTRY, flowFor(), typesForRouteFamily() |
backend/src/flow-engine/context/pt-akta-context.ts |
Create | PtAktaCtx + buildPtAktaContext() — the forked-3× ValidationInput assembly, once |
backend/src/flow-engine/rules/pt-akta/index.ts |
Create | PT_AKTA_RULE_SET (wrappers only) + pickRules() |
backend/src/flow-engine/rules/pt-akta/rups-quorum.ts |
Create | rupsQuorumRule({quorumThreshold?}) wrapper |
backend/src/flow-engine/rules/akuisisi/koran-window.ts |
Create | akuisisiKoranRule wrapper (shadow/parity use) |
backend/src/flow-engine/validation-runner.ts |
Create | runValidations(cfg, ctx) (async-partitioned + severityFloor) + persistValidationResults(id, results, opts) |
backend/src/flow-engine/processor.ts |
Create | runFlow(id, cfg) + continueFlow(id, cfg) (Phase 1-5) |
backend/src/flow-engine/rematch.ts |
Create | reMatchAndValidateFlow(id, cfg) |
backend/src/flow-engine/fail-gate.ts |
Create | checkFailGate(id, cfg) block-with-override |
backend/src/flow-engine/flows/__shadow__/perubahan-pt.shadow.ts |
Create | Parity-test-only v2 config for PERUBAHAN_PT (NOT registered) |
backend/src/flow-engine/flows/__shadow__/akuisisi-pt.shadow.ts |
Create | Parity-test-only v2 config for AKUISISI_PT (NOT registered) |
backend/src/services/supporting-doc-classifier.ts |
Create | Supporting-doc classifier seam (keyword pre-rules + grounded-LLM fallback; BUKTI_PENGUMUMAN keyword-only) |
backend/src/services/submission-dispatch.ts |
Modify (ProcessorKind L4-14, processorKindForType L23-51, dispatchProcessor L59-117) |
Registry check first; ProcessorKind gains "v2-generic"; unreachable-throw switch case |
backend/src/services/submission-processor.ts |
Modify (top of reMatchAndValidate L710) |
Registry check before the existing if-chain |
backend/src/routes/perubahan.ts |
Modify (PERUBAHAN_FAMILY L32) |
Derive family via typesForRouteFamily("perubahan") |
backend/src/flow-engine/__tests__/*.test.ts |
Create | totality · wrapper fidelity · parity oracle · persist · dispatch routing · latency · fail-gate |
backend/src/services/__tests__/supporting-doc-classifier.test.ts |
Create | classifier keyword pre-rules + LLM-outage + BUKTI_PENGUMUMAN no-LLM |
Task 1: Prisma additive migration — six DocumentType values
Adds the six supporting DocumentType enum values in one batch and regenerates the Prisma client. Sequencing note: this must land FIRST so DocumentType[] typed fields in Task 2 compile; it does NOT touch SubmissionType, so it introduces NO exhaustiveness fallout (the processorKindForType / FLOW_REGISTRY totality gates fire only on SubmissionType additions, which arrive with each future flow spec — not here).
Files
- backend/prisma/schema.prisma — enum DocumentType (lines 20-48), append six values before the closing }.
Interfaces
- Produces: DocumentType enum (generated at src/generated/prisma/enums.ts) gains members LAPORAN_KEUANGAN, LAMPIRAN_LAPORAN_TAHUNAN, SURAT_LIKUIDATOR, LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN, SURAT_PERMOHONAN.
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/document-type-additive.test.ts:
import { describe, it, expect } from "bun:test";
import { DocumentType } from "../../generated/prisma/enums";
/**
* Task 1 — the six additive supporting DocumentType values must exist on the
* generated Prisma enum (they feed requiredDocs/checklistResetDocTypes typed
* as DocumentType[]). Domisili is NOT new — it keeps using DOMISILI.
*/
describe("additive supporting DocumentType values", () => {
const added = [
"LAPORAN_KEUANGAN",
"LAMPIRAN_LAPORAN_TAHUNAN",
"SURAT_LIKUIDATOR",
"LAPORAN_LIKUIDASI",
"BUKTI_PENGUMUMAN",
"SURAT_PERMOHONAN",
] as const;
it("exposes every new value", () => {
for (const v of added) {
expect((DocumentType as Record<string, string>)[v]).toBe(v);
}
});
it("keeps DOMISILI (domisili carrier — NOT replaced)", () => {
expect(DocumentType.DOMISILI).toBe("DOMISILI");
});
it("does NOT introduce a SURAT_PERNYATAAN_DOMISILI enum value", () => {
expect((DocumentType as Record<string, string>)["SURAT_PERNYATAAN_DOMISILI"]).toBeUndefined();
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/document-type-additive.test.ts. Expected: fails — the new values areundefinedon the generated enum. - [ ] Minimal implementation. Edit
backend/prisma/schema.prisma, appending insideenum DocumentType { ... }right afterAPOSTILLE_DOCUMENT(line 47) and before the closing}:
/// Laporan keuangan tahunan (RUPS Tahunan) — keyword-first + grounded-LLM classification.
LAPORAN_KEUANGAN
/// Carrier for the six Ps.66(2) letters b–g lampiran (store-only, manual board assignment).
LAMPIRAN_LAPORAN_TAHUNAN
/// Surat penunjukan likuidator (Pembubaran) — penunjukan shape ONLY.
SURAT_LIKUIDATOR
/// Laporan akhir / pertanggungjawaban likuidasi (Berakhirnya) — DISTINCT from SURAT_LIKUIDATOR.
LAPORAN_LIKUIDASI
/// Bukti pengumuman koran (Peleburan/Pembubaran/Berakhirnya) — keyword-suggest only, never auto-accepted.
BUKTI_PENGUMUMAN
/// Berakhirnya-status filing letter. DISTINCT from SURAT_PERMOHONAN_KORPORASI (the Perbaikan-korporasi letter).
SURAT_PERMOHONAN
- [ ] Regenerate + sync test DB:
cd backend && bunx prisma generate && bun run db:push:test. - [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/document-type-additive.test.ts. Expected: 3 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add prisma/schema.prisma src/generated/prisma src/flow-engine/__tests__/document-type-additive.test.ts && git commit -m "feat(flow-engine): add six additive supporting DocumentType values"
Task 2: Flow-engine types + defineFlow
Defines the canonical minimal-slice interfaces. No behaviour yet — pure types + the identity defineFlow helper. RuleResult is re-exported from cross-validator.ts, never redeclared.
Files
- backend/src/flow-engine/types.ts (Create)
Interfaces
- Consumes: SubmissionType, DocumentType from ../generated/prisma/enums; RuleResult from ../services/cross-validator; ChangeTypeDetectionResult from ../services/change-type-detector; Prisma from ../generated/prisma/client (for PrismaTxClient).
- Produces:
- type ValidationStatus = "PASS" | "WARNING" | "FAIL" | "SKIPPED"
- type RouteFamily = "pendirian" | "perubahan" | "perbaikan" | "pp" | "peralihan-pp-pt" | "apostille" | "laporan-rups" | "peleburan" | "berakhirnya"
- interface Rule<Ctx> { code: string; label: string; severityFloor?: ValidationStatus; async?: boolean; run(ctx: Ctx): RuleResult | RuleResult[] | Promise<RuleResult | RuleResult[]> }
- interface ProcessorHooks<Ctx> { mapChangeTypes?(ct): ChangeTypeDetectionResult; afterAktaExtraction?(a: { submissionId; aktaDocumentId; rawText }): Promise<void>; onFinalize?(tx, submissionId): Promise<void> }
- interface RematchConfig { checklistResetDocTypes: DocumentType[]; standardPtMatchers: boolean; preValidationSteps?: Array<(submissionId: string) => Promise<void>> }
- interface ExtractionPlan { overrideByClassifiedType?: Record<string, string>; focusedPasses?: Partial<Record<DocumentType, string>> }
- interface FlowConfig<Ctx = unknown> { type; engine: "v2-generic"; primaryAkta: {classifiedType; missingError; extractError} | null; skipExtractionTypes: string[]; companyLookup: boolean | {allowedStatusTransaksi?: number[]; mode?: "single" | "multi-source"}; applyContactInfo: boolean; extraction: ExtractionPlan; buildContext(id): Promise<Ctx>; rules: Rule<Ctx>[]; requiredDocs: DocumentType[]; sections: {key; label; approvable; required}[]; rematch: RematchConfig; hooks?: ProcessorHooks<Ctx>; routeFamily: RouteFamily; policy?: unknown; failGate?: "on" | "off"; customProcess?(id, cfg): Promise<void>; labels: {akta; flow} }
- function defineFlow<Ctx>(cfg: FlowConfig<Ctx>): FlowConfig<Ctx>
- re-export type { RuleResult }
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/types.test.ts:
import { describe, it, expect } from "bun:test";
import { defineFlow, type FlowConfig, type Rule } from "../types";
describe("flow-engine types + defineFlow", () => {
it("defineFlow is identity and preserves the config", () => {
const rule: Rule<{ n: number }> = { code: "X", label: "x", run: () => [] };
const cfg: FlowConfig<{ n: number }> = {
type: "PERUBAHAN_PT",
engine: "v2-generic",
primaryAkta: { classifiedType: "AKTA_PERUBAHAN", missingError: "m", extractError: "e" },
skipExtractionTypes: [],
companyLookup: true,
applyContactInfo: true,
extraction: {},
buildContext: async () => ({ n: 1 }),
rules: [rule],
requiredDocs: ["KTP"],
sections: [],
rematch: { checklistResetDocTypes: ["KTP"], standardPtMatchers: true },
routeFamily: "perubahan",
labels: { akta: "Akta", flow: "Flow" },
};
expect(defineFlow(cfg)).toBe(cfg);
expect(cfg.failGate).toBeUndefined(); // defaults handled at the gate, not here
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/types.test.ts. Expected: fails — cannot resolve../types. - [ ] Minimal implementation. Create
backend/src/flow-engine/types.ts:
import type { SubmissionType, DocumentType } from "../generated/prisma/enums";
import type { RuleResult } from "../services/cross-validator";
import type { ChangeTypeDetectionResult } from "../services/change-type-detector";
import type { Prisma } from "../generated/prisma/client";
export type { RuleResult };
/** Prisma interactive-transaction client (the arg $transaction(fn) passes). */
export type PrismaTxClient = Prisma.TransactionClient;
export type ValidationStatus = "PASS" | "WARNING" | "FAIL" | "SKIPPED";
export type RouteFamily =
| "pendirian" | "perubahan" | "perbaikan" | "pp" | "peralihan-pp-pt" | "apostille"
| "laporan-rups" | "peleburan" | "berakhirnya";
export interface Rule<Ctx> {
code: string;
label: string;
severityFloor?: ValidationStatus; // clamp DOWN only
async?: boolean;
run(ctx: Ctx): RuleResult | RuleResult[] | Promise<RuleResult | RuleResult[]>;
}
export interface ProcessorHooks<Ctx> {
mapChangeTypes?(ct: ChangeTypeDetectionResult): ChangeTypeDetectionResult;
afterAktaExtraction?(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void>;
onFinalize?(tx: PrismaTxClient, submissionId: string): Promise<void>;
}
export interface RematchConfig {
checklistResetDocTypes: DocumentType[];
standardPtMatchers: boolean;
preValidationSteps?: Array<(submissionId: string) => Promise<void>>;
}
export interface ExtractionPlan {
overrideByClassifiedType?: Record<string, string>;
focusedPasses?: Partial<Record<DocumentType, string>>;
}
export interface FlowConfig<Ctx = unknown> {
type: SubmissionType;
engine: "v2-generic";
primaryAkta: { classifiedType: string; missingError: string; extractError: string } | null;
skipExtractionTypes: string[];
companyLookup: boolean | { allowedStatusTransaksi?: number[]; mode?: "single" | "multi-source" };
applyContactInfo: boolean;
extraction: ExtractionPlan;
buildContext(submissionId: string): Promise<Ctx>;
rules: Rule<Ctx>[];
requiredDocs: DocumentType[];
sections: { key: string; label: string; approvable: boolean; required: boolean }[];
rematch: RematchConfig;
hooks?: ProcessorHooks<Ctx>;
routeFamily: RouteFamily;
policy?: unknown; // INERT until the security slice
failGate?: "on" | "off"; // default 'on' (enforced at the gate)
customProcess?(id: string, cfg: FlowConfig<Ctx>): Promise<void>;
labels: { akta: string; flow: string };
}
export function defineFlow<Ctx>(cfg: FlowConfig<Ctx>): FlowConfig<Ctx> {
return cfg;
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/types.test.ts. Expected: 1 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/flow-engine/types.ts src/flow-engine/__tests__/types.test.ts && git commit -m "feat(flow-engine): canonical FlowConfig/Rule types + defineFlow"
Task 3: Total FLOW_REGISTRY + compile-forced totality
Ships the FlowEntry union and the total FLOW_REGISTRY with v1-fork stubs for all ten existing types (zero v2 entries), plus flowFor() and typesForRouteFamily(). The Record<SubmissionType, FlowEntry> type makes a new SubmissionType a compile error here — the runtime test pins the same property at test time.
Files
- backend/src/flow-engine/registry.ts (Create)
Interfaces
- Consumes: SubmissionType from ../generated/prisma/enums; FlowConfig, RouteFamily from ./types.
- Produces:
- type FlowEntry = { engine: "v1-fork"; type: SubmissionType } | FlowConfig<any>
- const FLOW_REGISTRY: Record<SubmissionType, FlowEntry>
- const flowFor = (t: SubmissionType): FlowEntry
- function typesForRouteFamily(family: RouteFamily): SubmissionType[]
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/registry.test.ts:
import { describe, it, expect } from "bun:test";
import { SubmissionType } from "../../generated/prisma/enums";
import { FLOW_REGISTRY, flowFor, typesForRouteFamily } from "../registry";
describe("FLOW_REGISTRY totality", () => {
it("has an entry for every SubmissionType with a valid engine", () => {
for (const t of Object.values(SubmissionType)) {
const entry = FLOW_REGISTRY[t];
expect(entry, `missing registry entry for ${t}`).toBeDefined();
expect(["v1-fork", "v2-generic"]).toContain(entry.engine);
expect(entry.type).toBe(t);
}
});
it("ships every existing type as a v1-fork stub (no v2 entries yet)", () => {
for (const t of Object.values(SubmissionType)) {
expect(FLOW_REGISTRY[t].engine).toBe("v1-fork");
}
});
it("flowFor resolves the same entry", () => {
expect(flowFor("PERUBAHAN_PT")).toBe(FLOW_REGISTRY.PERUBAHAN_PT);
});
it("typesForRouteFamily returns [] while no v2 flows are registered", () => {
expect(typesForRouteFamily("perubahan")).toEqual([]);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/registry.test.ts. Expected: fails — cannot resolve../registry. - [ ] Minimal implementation. Create
backend/src/flow-engine/registry.ts:
import type { SubmissionType } from "../generated/prisma/enums";
import type { FlowConfig, RouteFamily } from "./types";
/** Migration-period union: v1 forks carry only {engine,type}; v2 carries a full FlowConfig. */
export type FlowEntry = { engine: "v1-fork"; type: SubmissionType } | FlowConfig<any>;
/**
* TOTAL registry — Record<SubmissionType, FlowEntry>. Adding a SubmissionType to
* the Prisma enum makes this a missing-key compile error: the adoption forcing
* mechanism. Each new flow spec replaces its future stub with a real defineFlow(...).
*/
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" },
};
export const flowFor = (t: SubmissionType): FlowEntry => FLOW_REGISTRY[t];
/** Route-family sets derive from the registry so route files stop hardcoding type lists. */
export function typesForRouteFamily(family: RouteFamily): SubmissionType[] {
const out: SubmissionType[] = [];
for (const entry of Object.values(FLOW_REGISTRY)) {
if (entry.engine === "v2-generic" && entry.routeFamily === family) out.push(entry.type);
}
return out;
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/registry.test.ts. Expected: 4 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors (all ten keys present ⇒ Record is satisfied). - [ ] Commit:
cd backend && git add src/flow-engine/registry.ts src/flow-engine/__tests__/registry.test.ts && git commit -m "feat(flow-engine): total FLOW_REGISTRY with v1-fork stubs + totality test"
Task 4: Context builder — buildPtAktaContext (lift from the fork)
Lifts the ValidationInput assembly from runPerubahanCrossValidation (perubahan-processor.ts:267-628) verbatim into one shared builder parameterized by the two values that differ between the perubahan and akuisisi copies: the akta classifiedType and the tanggalKoran field key.
Files
- backend/src/flow-engine/context/pt-akta-context.ts (Create)
Interfaces
- Consumes: db from ../../lib/db; loadPerseroanBlokirState, lookupNotarisIdByName from ../../services/company-lookup; getEffectiveJenis from ../../services/jenis-selection; type ValidationInput from ../../services/cross-validator; matchBakumDJP from ../../services/string-similarity.
- Produces:
- interface PtAktaCtx { input: ValidationInput; rupsQuorum: { pemegangSaham: {persentase: number|null; kehadiranRups: boolean|null}[]; hasBaRupsDoc: boolean }; aktaFields: { fieldKey: string; value: string | null }[] }
- async function buildPtAktaContext(submissionId: string, opts: { aktaClassifiedType: string; tanggalKoranFieldKey?: string }): Promise<PtAktaCtx>
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/pt-akta-context.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { buildPtAktaContext } from "../context/pt-akta-context";
/**
* Task 4 — the lifted context builder assembles a ValidationInput parameterized
* by the akta classifiedType + optional tanggalKoran field key. We assert the
* two parameterized seams (akta selection + tanggalKoran) resolve correctly on a
* minimal seeded submission; the full field-by-field fidelity is proven by the
* parity oracle (Task 11) against the fork.
*/
describe("buildPtAktaContext", () => {
const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
for (const id of ids) await db.submission.deleteMany({ where: { id } });
for (const d of docIds) await db.document.deleteMany({ where: { id: d } });
});
it("selects the akta by classifiedType and reads tanggalKoran from the given key", async () => {
const doc = await db.document.create({
data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "AI_EXTRACTION", rawText: "" },
});
docIds.push(doc.id);
await db.extractedField.createMany({
data: [
{ documentId: doc.id, fieldKey: "nama_perseroan", value: "PT UJI" },
{ documentId: doc.id, fieldKey: "tanggal_koran_akuisisi", value: "2020-01-01" },
],
});
const sub = await db.submission.create({ data: { type: "AKUISISI_PT", status: "VALIDATING" } });
ids.push(sub.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: doc.id, classifiedType: "AKTA_AKUISISI", processingOrder: 0, extractionStatus: "DONE" },
});
const ctx = await buildPtAktaContext(sub.id, {
aktaClassifiedType: "AKTA_AKUISISI",
tanggalKoranFieldKey: "tanggal_koran_akuisisi",
});
expect(ctx.input.aktaNamaPerseroan).toBe("PT UJI");
expect(ctx.input.tanggalKoran).toBe("2020-01-01");
expect(Array.isArray(ctx.rupsQuorum.pemegangSaham)).toBe(true);
});
it("returns null tanggalKoran when no field key is given (perubahan shape)", async () => {
const doc = await db.document.create({
data: { filename: "b.pdf", originalName: "b.pdf", fileSize: 0, filePath: "/tmp/b.pdf", documentType: "AKTA", status: "AI_EXTRACTION", rawText: "" },
});
docIds.push(doc.id);
await db.extractedField.create({ data: { documentId: doc.id, fieldKey: "nama_perseroan", value: "PT DUA" } });
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "VALIDATING" } });
ids.push(sub.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: doc.id, classifiedType: "AKTA_PERUBAHAN", processingOrder: 0, extractionStatus: "DONE" },
});
const ctx = await buildPtAktaContext(sub.id, { aktaClassifiedType: "AKTA_PERUBAHAN" });
expect(ctx.input.aktaNamaPerseroan).toBe("PT DUA");
expect(ctx.input.tanggalKoran).toBeNull();
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/pt-akta-context.test.ts. Expected: fails — cannot resolve../context/pt-akta-context. - [ ] Minimal implementation. Create
backend/src/flow-engine/context/pt-akta-context.ts. Copy the body ofrunPerubahanCrossValidationfrombackend/src/services/perubahan-processor.tslines 268-628 (thesubmissionfetch through theinputobject literal), then apply exactly these three edits so it becomes a parameterized builder:
1. Wrap it asexport async function buildPtAktaContext(submissionId: string, opts: { aktaClassifiedType: string; tanggalKoranFieldKey?: string }): Promise<PtAktaCtx>and change the header to fetch bysubmissionId(the perubahan body already usessubmissionId).
2. Replace the akta selectorconst aktaSubDoc = submission.documents.find((d) => d.classifiedType === "AKTA_PERUBAHAN")withconst aktaSubDoc = submission.documents.find((d) => d.classifiedType === opts.aktaClassifiedType).
3. Replace thetanggalKoran: null,line in theinputliteral withtanggalKoran: opts.tanggalKoranFieldKey ? (aktaFields.find((f) => f.fieldKey === opts.tanggalKoranFieldKey)?.value ?? null) : null,.
4. If!submission,throw new Error("submission not found")instead ofreturn(a builder must not silently returnvoid).
Then REMOVE the trailingrunAllValidations+ RUPS-quorum-push + persist block (lines 630-670 in the donor) and insteadreturn:
const hasBaRupsDoc = submission.documents.some((d) => d.classifiedType === "BERITA_ACARA_RUPS");
return {
input,
rupsQuorum: {
pemegangSaham: (aktaDoc?.aktaPemegangSaham ?? []).map((ps) => ({
persentase: ps.persentase,
kehadiranRups: ps.kehadiranRups,
})),
hasBaRupsDoc,
},
aktaFields: aktaFields.map((f) => ({ fieldKey: f.fieldKey, value: f.value })),
};
Prepend the imports (db, loadPerseroanBlokirState, lookupNotarisIdByName, getEffectiveJenis, matchBakumDJP, type ValidationInput, Prisma if referenced by the copied body) and the PtAktaCtx interface. Keep every as any reach-in verbatim.
- [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/pt-akta-context.test.ts. Expected: 2 pass. (If the seed shape differs from the donor's include, adjust the test seed — not the builder body.)
- [ ] Typecheck: cd backend && bunx tsc --noEmit. Expected: no errors.
- [ ] Commit: cd backend && git add src/flow-engine/context/pt-akta-context.ts src/flow-engine/__tests__/pt-akta-context.test.ts && git commit -m "feat(flow-engine): lift buildPtAktaContext from the perubahan fork (parameterized)"
Task 5: Rules — PT_AKTA_RULE_SET, pickRules, RUPS-quorum + koran wrappers
Wraps the 29 sync + 3 async validators from cross-validator.ts one line each, plus the RUPS-quorum wrapper (adapting its {code,status,message} return, with an optional quorumThreshold param) and the akuisisi koran wrapper (WARNING-clamped). Bodies are UNCHANGED.
Files
- backend/src/flow-engine/rules/pt-akta/index.ts (Create)
- backend/src/flow-engine/rules/pt-akta/rups-quorum.ts (Create)
- backend/src/flow-engine/rules/akuisisi/koran-window.ts (Create)
Interfaces
- Consumes: all validateX functions + validateRupsAttendanceQuorum, validateAkuisisiKoranWindow from ../../../services/cross-validator; PtAktaCtx from ../../context/pt-akta-context; Rule from ../../types.
- Produces:
- const PT_AKTA_RULE_SET: Rule<PtAktaCtx>[] (32 wrappers: 29 sync + 3 async:true)
- function pickRules(set: Rule<PtAktaCtx>[], codes: readonly string[]): Rule<PtAktaCtx>[]
- function rupsQuorumRule(opts?: { quorumThreshold?: number }): Rule<PtAktaCtx>
- const akuisisiKoranRule: Rule<PtAktaCtx>
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/rules-pt-akta.test.ts:
import { describe, it, expect } from "bun:test";
import { PT_AKTA_RULE_SET, pickRules } from "../rules/pt-akta/index";
import { rupsQuorumRule } from "../rules/pt-akta/rups-quorum";
import { akuisisiKoranRule } from "../rules/akuisisi/koran-window";
import type { PtAktaCtx } from "../context/pt-akta-context";
const EXPECTED_CODES = [
"NIK_KTP_AKTA","NAMA_NPWP_KTP","MODAL_BUKTI_SETOR","PEMEGANG_SAHAM_BUKTI_SETOR","SHARES_SUM_CONSISTENCY",
"MODAL_HIERARCHY","NOMINAL_RECONCILE","KTP_COMPLETENESS","NPWP_COMPLETENESS","DOMISILI_NAMA",
"NAMA_PT_CONSISTENCY","PS_TOTAL_100","CONTACT_INFO_COMPLETE","OLD_DATA_CONSISTENCY","PERUBAHAN_MODAL",
"DATA_ACTUALLY_CHANGED","SHARES_TRANSFER_BALANCE","SHARES_TOTAL_LEMBAR","REQUIRED_SUPPORTING_DOCS",
"AKTA_DATE_WINDOW","PERSEROAN_STATE","NPWP_PERSEROAN_FUZZY","NOTARIS_TERAKHIR","JENIS_SELECTION_NONEMPTY",
"JENIS_DESELECT_REASONS_COMPLETE","UPLOADED_DOC_UNUSED","CONTRADICTORY_JENIS","PEMEGANG_SAHAM_CONTACT",
"PASSPORT_AKTA","PP29_MODAL","KBLI_VALID","PERUBAHAN_KBLI",
];
describe("PT_AKTA_RULE_SET", () => {
it("has exactly the 32 shared rules, each with code+label+run", () => {
expect(PT_AKTA_RULE_SET.map((r) => r.code).sort()).toEqual([...EXPECTED_CODES].sort());
for (const r of PT_AKTA_RULE_SET) {
expect(r.code.length).toBeGreaterThan(0);
expect(r.label.length).toBeGreaterThan(0);
expect(typeof r.run).toBe("function");
}
});
it("marks exactly the 3 SABH-hitting rules as async", () => {
const asyncCodes = PT_AKTA_RULE_SET.filter((r) => r.async).map((r) => r.code).sort();
expect(asyncCodes).toEqual(["KBLI_VALID", "PERUBAHAN_KBLI", "PP29_MODAL"].sort());
});
it("pickRules filters by code preserving order and dropping unknowns", () => {
const picked = pickRules(PT_AKTA_RULE_SET, ["NIK_KTP_AKTA", "NOPE", "PASSPORT_AKTA"]);
expect(picked.map((r) => r.code)).toEqual(["NIK_KTP_AKTA", "PASSPORT_AKTA"]);
});
});
describe("rupsQuorumRule wrapper", () => {
it("adapts {code,status,message} to a RuleResult with the RUPS label", () => {
const ctx = { rupsQuorum: { pemegangSaham: [], hasBaRupsDoc: false } } as unknown as PtAktaCtx;
const rule = rupsQuorumRule();
expect(rule.code).toBe("RUPS_ATTENDANCE_QUORUM");
const out = rule.run(ctx) as { ruleCode: string; ruleLabel: string; status: string };
expect(out.ruleCode).toBe("RUPS_ATTENDANCE_QUORUM");
expect(out.ruleLabel).toBe("Kuorum Kehadiran RUPS");
expect(out.status).toBe("SKIPPED"); // no BA RUPS doc
});
it("threshold param clamps a >1/2-but-<3/4 attendance to WARNING", () => {
const ctx = {
rupsQuorum: {
pemegangSaham: [{ persentase: 60, kehadiranRups: true }, { persentase: 40, kehadiranRups: false }],
hasBaRupsDoc: true,
},
} as unknown as PtAktaCtx;
const base = rupsQuorumRule().run(ctx) as { status: string };
const strict = rupsQuorumRule({ quorumThreshold: 3 / 4 }).run(ctx) as { status: string };
expect(base.status).toBe("PASS"); // 60% > 50%
expect(strict.status).toBe("WARNING"); // 60% < 75%
});
});
describe("akuisisiKoranRule wrapper", () => {
it("wraps validateAkuisisiKoranWindow with severityFloor WARNING", () => {
expect(akuisisiKoranRule.code).toBe("AKUISISI_KORAN_WINDOW");
expect(akuisisiKoranRule.severityFloor).toBe("WARNING");
const ctx = {
input: { tanggalKoran: null },
aktaFields: [{ fieldKey: "tanggal_rups", value: null }],
} as unknown as PtAktaCtx;
const out = akuisisiKoranRule.run(ctx) as { ruleCode: string };
expect(out.ruleCode).toBe("AKUISISI_KORAN_WINDOW");
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/rules-pt-akta.test.ts. Expected: fails — cannot resolve the rule modules. - [ ] Minimal implementation A. Create
backend/src/flow-engine/rules/pt-akta/index.ts:
import {
validateNikKtpAkta, validateNamaNpwpKtp, validateModalBuktiSetor, validatePemegangSahamBuktiSetor,
validateSharesSumConsistency, validateModalHierarchy, validateNominalReconcile, validateKtpCompleteness,
validateNpwpCompleteness, validateDomisiliNama, validateNamaPerseroan, validatePemegangSahamTotal,
validateContactInfoComplete, validateOldDataConsistency, validatePerubahanModal, validateDataActuallyChanged,
validateSharesTransferBalance, validateSharesTotalLembar, validateRequiredSupportingDocs, validateAktaDateWindow,
validatePerseroanState, validateNpwpPerseroanFuzzyMatch, validateNotarisTerakhir, validateJenisSelectionNonempty,
validateJenisDeselectReasonsComplete, validateUploadedDocUnused, validateContradictoryJenis,
validatePemegangSahamContact, validatePassportAkta, validatePp29Modal, validateKbli, validatePerubahanKbli,
} from "../../../services/cross-validator";
import type { Rule } from "../../types";
import type { PtAktaCtx } from "../../context/pt-akta-context";
/** Wrappers ONLY — legal logic + severities stay in cross-validator.ts. Order mirrors runAllValidations. */
export const PT_AKTA_RULE_SET: Rule<PtAktaCtx>[] = [
{ code: "NIK_KTP_AKTA", label: "NIK KTP sesuai dengan Akta", run: (c) => validateNikKtpAkta(c.input) },
{ code: "NAMA_NPWP_KTP", label: "Nama NPWP vs KTP", run: (c) => validateNamaNpwpKtp(c.input) },
{ code: "MODAL_BUKTI_SETOR", label: "Modal vs Bukti Setor", run: (c) => validateModalBuktiSetor(c.input) },
{ code: "PEMEGANG_SAHAM_BUKTI_SETOR", label: "Pemegang Saham vs Bukti Setor", run: (c) => validatePemegangSahamBuktiSetor(c.input) },
{ code: "SHARES_SUM_CONSISTENCY", label: "Konsistensi Jumlah Saham", run: (c) => validateSharesSumConsistency(c.input) },
{ code: "MODAL_HIERARCHY", label: "Hierarki Modal", run: (c) => validateModalHierarchy(c.input) },
{ code: "NOMINAL_RECONCILE", label: "Rekonsiliasi Nominal", run: (c) => validateNominalReconcile(c.input) },
{ code: "KTP_COMPLETENESS", label: "Kelengkapan KTP", run: (c) => validateKtpCompleteness(c.input) },
{ code: "NPWP_COMPLETENESS", label: "Kelengkapan NPWP", run: (c) => validateNpwpCompleteness(c.input) },
{ code: "DOMISILI_NAMA", label: "Nama Domisili", run: (c) => validateDomisiliNama(c.input) },
{ code: "NAMA_PT_CONSISTENCY", label: "Konsistensi Nama PT", run: (c) => validateNamaPerseroan(c.input) },
{ code: "PS_TOTAL_100", label: "Total Persentase Pemegang Saham", run: (c) => validatePemegangSahamTotal(c.input) },
{ code: "CONTACT_INFO_COMPLETE", label: "Kelengkapan Data Kontak", run: (c) => validateContactInfoComplete(c.input) },
{ code: "OLD_DATA_CONSISTENCY", label: "Konsistensi Data Lama", run: (c) => validateOldDataConsistency(c.input) },
{ code: "PERUBAHAN_MODAL", label: "Perubahan Modal", run: (c) => validatePerubahanModal(c.input) },
{ code: "DATA_ACTUALLY_CHANGED", label: "Data Berubah", run: (c) => validateDataActuallyChanged(c.input) },
{ code: "SHARES_TRANSFER_BALANCE", label: "Keseimbangan Peralihan Saham", run: (c) => validateSharesTransferBalance(c.input) },
{ code: "SHARES_TOTAL_LEMBAR", label: "Total Lembar Saham", run: (c) => validateSharesTotalLembar(c.input) },
{ code: "REQUIRED_SUPPORTING_DOCS", label: "Dokumen Pendukung Wajib", run: (c) => validateRequiredSupportingDocs(c.input) },
{ code: "AKTA_DATE_WINDOW", label: "Tanggal Akta dalam Jangka", run: (c) => validateAktaDateWindow(c.input) },
{ code: "PERSEROAN_STATE", label: "Status PT Aktif di SABH", run: (c) => validatePerseroanState(c.input) },
{ code: "NPWP_PERSEROAN_FUZZY", label: "NPWP Perseroan (Fuzzy)", run: (c) => validateNpwpPerseroanFuzzyMatch(c.input) },
{ code: "NOTARIS_TERAKHIR", label: "Notaris Terakhir", run: (c) => validateNotarisTerakhir(c.input) },
{ code: "JENIS_SELECTION_NONEMPTY", label: "Jenis Perubahan Tidak Kosong", run: (c) => validateJenisSelectionNonempty(c.input) },
{ code: "JENIS_DESELECT_REASONS_COMPLETE", label: "Alasan Deseleksi Lengkap", run: (c) => validateJenisDeselectReasonsComplete(c.input) },
{ code: "UPLOADED_DOC_UNUSED", label: "Dokumen Terunggah Tidak Terpakai", run: (c) => validateUploadedDocUnused(c.input) },
{ code: "CONTRADICTORY_JENIS", label: "Jenis Bertentangan", run: (c) => validateContradictoryJenis(c.input) },
{ code: "PEMEGANG_SAHAM_CONTACT", label: "Kontak Pemegang Saham", run: (c) => validatePemegangSahamContact(c.input) },
{ code: "PASSPORT_AKTA", label: "Paspor sesuai Akta", run: (c) => validatePassportAkta(c.input) },
{ code: "PP29_MODAL", label: "PP 29/2016 Modal", async: true, run: (c) => validatePp29Modal(c.input) },
{ code: "KBLI_VALID", label: "KBLI Valid", async: true, run: (c) => validateKbli(c.input) },
{ code: "PERUBAHAN_KBLI", label: "KBLI Perubahan", async: true, run: (c) => validatePerubahanKbli(c.input) },
];
/** Typed filter — the tuned matrix is `pickRules(PT_AKTA_RULE_SET, FLOW_CODES)` + flow rules. */
export function pickRules(set: Rule<PtAktaCtx>[], codes: readonly string[]): Rule<PtAktaCtx>[] {
const wanted = new Set(codes);
return set.filter((r) => wanted.has(r.code));
}
Verification note for the implementer: confirm each code/label above matches the actual ruleCode/ruleLabel the body emits — run grep -n 'ruleCode = ' src/services/cross-validator.ts and reconcile. If a label differs, use the body's label (the wrapper-fidelity test in Task 6 pins this).
- [ ] Minimal implementation B. Create backend/src/flow-engine/rules/pt-akta/rups-quorum.ts:
import { validateRupsAttendanceQuorum } from "../../../services/cross-validator";
import type { Rule, RuleResult } from "../../types";
import type { PtAktaCtx } from "../../context/pt-akta-context";
/**
* Wraps validateRupsAttendanceQuorum (body returns {code,status,message}) into a
* RuleResult with ruleLabel "Kuorum Kehadiran RUPS" — bit-for-bit what both forks
* append today. Default legal quorum is >1/2 (UUPT Ps.86(1)); Pembubaran passes 3/4
* (Ps.89(1)). When a threshold is given we recompute the tier from the same present/
* total percentages the body exposes in its message — the body itself is unchanged.
*/
export function rupsQuorumRule(opts?: { quorumThreshold?: number }): Rule<PtAktaCtx> {
const threshold = opts?.quorumThreshold ?? 0.5;
return {
code: "RUPS_ATTENDANCE_QUORUM",
label: "Kuorum Kehadiran RUPS",
run: (c): RuleResult => {
const raw = validateRupsAttendanceQuorum(c.rupsQuorum);
let status = raw.status as RuleResult["status"];
let message = raw.message;
// Only re-tier when a stricter threshold is requested AND the body evaluated a real quorum.
if (raw.status !== "SKIPPED" && threshold > 0.5) {
const total = c.rupsQuorum.pemegangSaham.reduce((s, p) => s + (p.persentase ?? 0), 0);
const present = c.rupsQuorum.pemegangSaham
.filter((p) => p.kehadiranRups === true)
.reduce((s, p) => s + (p.persentase ?? 0), 0);
if (total > 0) {
const ratio = present / total;
if (ratio <= threshold) {
status = "WARNING";
const pct = (threshold * 100).toFixed(0);
message = `Kehadiran RUPS ${present.toFixed(2)}% dari total ${total.toFixed(2)}% (kuorum legal >${pct}%)`;
}
}
}
return { ruleCode: raw.code, ruleLabel: "Kuorum Kehadiran RUPS", status, message };
},
};
}
- [ ] Minimal implementation C. Create
backend/src/flow-engine/rules/akuisisi/koran-window.ts:
import { validateAkuisisiKoranWindow } from "../../../services/cross-validator";
import type { Rule } from "../../types";
import type { PtAktaCtx } from "../../context/pt-akta-context";
/**
* AKUISISI_KORAN_WINDOW ≤ WARNING — the body already never FAILs (UUPT Ps.127(8)
* exempts direct-from-shareholder acquisitions). The severityFloor is
* belt-and-braces documentation-in-types. tanggalRups is read from aktaFields.
*/
export const akuisisiKoranRule: Rule<PtAktaCtx> = {
code: "AKUISISI_KORAN_WINDOW",
label: "Pengumuman Koran ≥30 Hari sebelum RUPS",
severityFloor: "WARNING",
run: (c) => {
const tanggalRups = c.aktaFields.find((f) => f.fieldKey === "tanggal_rups")?.value ?? null;
return validateAkuisisiKoranWindow({ tanggalKoran: c.input.tanggalKoran, tanggalRups });
},
};
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/rules-pt-akta.test.ts. Expected: all pass. (If acode/labelmismatch surfaces, fix the wrapper to match the body, then re-run.) - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/flow-engine/rules src/flow-engine/__tests__/rules-pt-akta.test.ts && git commit -m "feat(flow-engine): wrap PT_AKTA_RULE_SET + RUPS-quorum + koran (bodies untouched)"
Task 6: Validation runner + transactional persist
Ships runValidations (async-partitioned, severityFloor clamp, RuleResult[] flatten, dev-mode code-drift assertion) and persistValidationResults (one $transaction: notIn-delete or blanketDelete + override-preserving upsert). Also the wrapper-fidelity test.
Files
- backend/src/flow-engine/validation-runner.ts (Create)
Interfaces
- Consumes: db, Prisma from ../lib/db / ../generated/prisma/client; Rule, RuleResult, ValidationStatus from ./types.
- Produces:
- async function runValidations<Ctx>(cfg: { rules: Rule<Ctx>[] }, ctx: Ctx): Promise<RuleResult[]>
- async function persistValidationResults(submissionId: string, results: RuleResult[], opts?: { blanketDelete?: boolean }): Promise<void>
Steps
- [ ] Write failing test A (runner). Create
backend/src/flow-engine/__tests__/validation-runner.test.ts:
import { describe, it, expect } from "bun:test";
import { runValidations } from "../validation-runner";
import type { Rule, RuleResult } from "../types";
type Ctx = { v: number };
const mk = (code: string, status: RuleResult["status"]): RuleResult => ({ ruleCode: code, ruleLabel: code, status, message: "" });
describe("runValidations", () => {
it("runs async rules in parallel (wall clock ≈ max, not sum)", async () => {
const slow = (code: string): Rule<Ctx> => ({
code, label: code, async: true,
run: async () => { await new Promise((r) => setTimeout(r, 80)); return mk(code, "PASS"); },
});
const start = Date.now();
const out = await runValidations({ rules: [slow("A"), slow("B"), slow("C")] }, { v: 1 });
const elapsed = Date.now() - start;
expect(out.map((r) => r.ruleCode).sort()).toEqual(["A", "B", "C"]);
expect(elapsed).toBeLessThan(200); // ~80ms parallel, not ~240ms serial
});
it("flattens RuleResult[] returns", async () => {
const multi: Rule<Ctx> = { code: "M", label: "M", run: () => [mk("M1", "PASS"), mk("M2", "FAIL")] };
const out = await runValidations({ rules: [multi] }, { v: 1 });
expect(out.map((r) => r.ruleCode)).toEqual(["M1", "M2"]);
});
it("severityFloor clamps FAIL down to the floor; never raises", async () => {
const failing: Rule<Ctx> = { code: "F", label: "F", severityFloor: "WARNING", run: () => mk("F", "FAIL") };
const passing: Rule<Ctx> = { code: "P", label: "P", severityFloor: "WARNING", run: () => mk("P", "PASS") };
const out = await runValidations({ rules: [failing, passing] }, { v: 1 });
expect(out.find((r) => r.ruleCode === "F")?.status).toBe("WARNING"); // clamped down
expect(out.find((r) => r.ruleCode === "P")?.status).toBe("PASS"); // never raised
});
});
- [ ] Write failing test B (persist). Create
backend/src/flow-engine/__tests__/persist-validation-results.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { persistValidationResults } from "../validation-runner";
import type { RuleResult } from "../types";
const r = (code: string, status: RuleResult["status"], msg: string): RuleResult => ({ ruleCode: code, ruleLabel: code, status, message: msg });
describe("persistValidationResults", () => {
const ids: string[] = [];
afterAll(async () => {
for (const id of ids) { await db.validationResult.deleteMany({ where: { submissionId: id } }); await db.submission.deleteMany({ where: { id } }); }
});
it("preserves overridden+reason on re-persist; updates status/message; notIn-deletes stale", async () => {
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "READY" } });
ids.push(sub.id);
await db.validationResult.create({ data: { submissionId: sub.id, ruleCode: "KEEP", ruleLabel: "KEEP", status: "FAIL", message: "old", overridden: true, overrideReason: "notaris" } });
await db.validationResult.create({ data: { submissionId: sub.id, ruleCode: "STALE", ruleLabel: "STALE", status: "FAIL", message: "x" } });
await persistValidationResults(sub.id, [r("KEEP", "FAIL", "new"), r("FRESH", "PASS", "ok")]);
const keep = await db.validationResult.findUnique({ where: { submissionId_ruleCode: { submissionId: sub.id, ruleCode: "KEEP" } } });
const stale = await db.validationResult.findUnique({ where: { submissionId_ruleCode: { submissionId: sub.id, ruleCode: "STALE" } } });
const fresh = await db.validationResult.findUnique({ where: { submissionId_ruleCode: { submissionId: sub.id, ruleCode: "FRESH" } } });
expect(keep?.overridden).toBe(true);
expect(keep?.overrideReason).toBe("notaris");
expect(keep?.message).toBe("new");
expect(stale).toBeNull();
expect(fresh).not.toBeNull();
});
it("blanketDelete:true removes ALL rows first (fork parity path)", async () => {
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "READY" } });
ids.push(sub.id);
await db.validationResult.create({ data: { submissionId: sub.id, ruleCode: "OVR", ruleLabel: "OVR", status: "FAIL", message: "x", overridden: true, overrideReason: "gone" } });
await persistValidationResults(sub.id, [r("OVR", "FAIL", "y")], { blanketDelete: true });
const row = await db.validationResult.findUnique({ where: { submissionId_ruleCode: { submissionId: sub.id, ruleCode: "OVR" } } });
expect(row?.overridden).toBe(false); // blanket-deleted then re-created without the override
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/validation-runner.test.ts src/flow-engine/__tests__/persist-validation-results.test.ts. Expected: both fail — cannot resolve../validation-runner. - [ ] Minimal implementation. Create
backend/src/flow-engine/validation-runner.ts:
import { db } from "../lib/db";
import { Prisma } from "../generated/prisma/client";
import type { Rule, RuleResult, ValidationStatus } from "./types";
const SEVERITY_ORDER: Record<ValidationStatus, number> = { PASS: 0, WARNING: 1, FAIL: 2, SKIPPED: -1 };
/** Clamp DOWN only (never raise); SKIPPED passes through unchanged. */
function applyFloor(status: ValidationStatus, floor?: ValidationStatus): ValidationStatus {
if (!floor || status === "SKIPPED" || floor === "SKIPPED") return status;
return SEVERITY_ORDER[status] > SEVERITY_ORDER[floor] ? floor : status;
}
/**
* Partition rules by async: sync rules run sequentially (pure/instant), async
* rules via Promise.all (SABH round-trips) — preserving today's latency profile.
* Flattens RuleResult[] returns; applies severityFloor; dev-mode drift assertion.
*/
export async function runValidations<Ctx>(cfg: { rules: Rule<Ctx>[] }, ctx: Ctx): Promise<RuleResult[]> {
const out: RuleResult[] = [];
const collect = (rule: Rule<Ctx>, res: RuleResult | RuleResult[]) => {
const arr = Array.isArray(res) ? res : [res];
for (const rr of arr) {
if (process.env.NODE_ENV !== "production" && !Array.isArray(res) && rr.ruleCode !== rule.code) {
console.warn(`[flow-engine] rule "${rule.code}" emitted ruleCode "${rr.ruleCode}" — wrapper drift`);
}
out.push({ ...rr, status: applyFloor(rr.status as ValidationStatus, rule.severityFloor) });
}
};
const asyncRules = cfg.rules.filter((r) => r.async);
const syncRules = cfg.rules.filter((r) => !r.async);
for (const rule of syncRules) collect(rule, await rule.run(ctx));
const asyncResults = await Promise.all(asyncRules.map((r) => Promise.resolve(r.run(ctx))));
asyncRules.forEach((rule, i) => collect(rule, asyncResults[i]!));
return out;
}
/**
* ONE $transaction: delete stale (notIn) — or blanket-delete when opted in — then
* upsert. The update branch sets ONLY {status,message,details} so overridden/
* overrideReason survive re-validation. Default blanketDelete:false (v2 greenfield).
*/
export async function persistValidationResults(
submissionId: string,
results: RuleResult[],
opts?: { blanketDelete?: boolean },
): Promise<void> {
const validCodes = results.map((r) => r.ruleCode);
await db.$transaction([
opts?.blanketDelete
? db.validationResult.deleteMany({ where: { submissionId } })
: db.validationResult.deleteMany({ where: { submissionId, ruleCode: { notIn: validCodes } } }),
...results.map((res) =>
db.validationResult.upsert({
where: { submissionId_ruleCode: { submissionId, ruleCode: res.ruleCode } },
create: {
submissionId, ruleCode: res.ruleCode, ruleLabel: res.ruleLabel,
status: res.status, message: res.message,
details: (res.details as unknown as Prisma.InputJsonValue) ?? undefined,
},
update: {
status: res.status, message: res.message,
details: (res.details as unknown as Prisma.InputJsonValue) ?? undefined,
},
}),
),
]);
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/validation-runner.test.ts src/flow-engine/__tests__/persist-validation-results.test.ts. Expected: all pass. - [ ] Write wrapper-fidelity test. Create
backend/src/flow-engine/__tests__/wrapper-fidelity.test.ts:
import { describe, it, expect } from "bun:test";
import * as CV from "../../services/cross-validator";
import { PT_AKTA_RULE_SET } from "../rules/pt-akta/index";
import type { PtAktaCtx } from "../context/pt-akta-context";
/**
* Task 6 — each wrapper must call its underlying validateX(ctx.input) and emit a
* ruleCode equal to rule.code. Bodies stay in cross-validator.ts; this guards
* against wrap-time typos without duplicating legal cases (those live in the
* existing cross-validator suites).
*/
const VALIDATOR_BY_CODE: Record<string, (i: CV.ValidationInput) => CV.RuleResult | Promise<CV.RuleResult>> = {
NIK_KTP_AKTA: CV.validateNikKtpAkta, NAMA_NPWP_KTP: CV.validateNamaNpwpKtp,
MODAL_BUKTI_SETOR: CV.validateModalBuktiSetor, PEMEGANG_SAHAM_BUKTI_SETOR: CV.validatePemegangSahamBuktiSetor,
SHARES_SUM_CONSISTENCY: CV.validateSharesSumConsistency, MODAL_HIERARCHY: CV.validateModalHierarchy,
NOMINAL_RECONCILE: CV.validateNominalReconcile, KTP_COMPLETENESS: CV.validateKtpCompleteness,
NPWP_COMPLETENESS: CV.validateNpwpCompleteness, DOMISILI_NAMA: CV.validateDomisiliNama,
NAMA_PT_CONSISTENCY: CV.validateNamaPerseroan, PS_TOTAL_100: CV.validatePemegangSahamTotal,
CONTACT_INFO_COMPLETE: CV.validateContactInfoComplete, OLD_DATA_CONSISTENCY: CV.validateOldDataConsistency,
PERUBAHAN_MODAL: CV.validatePerubahanModal, DATA_ACTUALLY_CHANGED: CV.validateDataActuallyChanged,
SHARES_TRANSFER_BALANCE: CV.validateSharesTransferBalance, SHARES_TOTAL_LEMBAR: CV.validateSharesTotalLembar,
REQUIRED_SUPPORTING_DOCS: CV.validateRequiredSupportingDocs, AKTA_DATE_WINDOW: CV.validateAktaDateWindow,
PERSEROAN_STATE: CV.validatePerseroanState, NPWP_PERSEROAN_FUZZY: CV.validateNpwpPerseroanFuzzyMatch,
NOTARIS_TERAKHIR: CV.validateNotarisTerakhir, JENIS_SELECTION_NONEMPTY: CV.validateJenisSelectionNonempty,
JENIS_DESELECT_REASONS_COMPLETE: CV.validateJenisDeselectReasonsComplete, UPLOADED_DOC_UNUSED: CV.validateUploadedDocUnused,
CONTRADICTORY_JENIS: CV.validateContradictoryJenis, PEMEGANG_SAHAM_CONTACT: CV.validatePemegangSahamContact,
PASSPORT_AKTA: CV.validatePassportAkta, PP29_MODAL: CV.validatePp29Modal, KBLI_VALID: CV.validateKbli,
PERUBAHAN_KBLI: CV.validatePerubahanKbli,
};
// A near-empty but structurally valid ValidationInput; the rules must not throw on it.
const EMPTY_INPUT = {
identityMatches: [], aktaPenghadap: [], ktpFields: new Map(), npwpFields: new Map(),
aktaModalDisetor: null, buktiSetorAmounts: [], aktaKedudukan: null, aktaTempatKedudukan: null,
checklistItems: [], aktaModalDasar: null, aktaJenisPerseroan: null, aktaStatusPerseroan: null,
kbliCodes: [], aktaNamaPerseroan: null, domisiliNamaEntitas: null, buktiSetorEntityName: null,
contactInfoEntityName: null, pemegangSahamPersentase: [], identityMatchContacts: [], knownPeopleNames: [],
aktaNewPengurus: [], contactInfoEntries: [], uploadedNpwpNames: [], isPerubahan: true, oldData: null,
perseroanBlokir: null, aktaNotarisId: null, perubahanModalBaru: null, perubahanModalLama: null,
perubahanKbliCodes: [], changeTypeChecked: null, selectedJenisPerubahan: null, effectiveJenis: null,
perubahanNewValues: { nama: null, tempatKedudukan: null, statusPerseroan: null, jenisPerseroan: null, jangkaWaktu: null },
peralihanSaham: [], aktaJumlahSahamModalDitempatkan: null, pemegangSahamLembarTotal: null,
uploadedDocTypes: [], aktaTanggal: null, tanggalKoran: null, aktaPemegangSaham: [], buktiSetorEntries: [],
namaAkta: null, npwpPerseroanNama: null, aktaPeopleWna: [], passportExtractions: [], kitasExtractions: [],
} as unknown as CV.ValidationInput;
describe("PT_AKTA_RULE_SET wrapper fidelity", () => {
it("every wrapper emits ruleCode === rule.code on an empty input", async () => {
const ctx = { input: EMPTY_INPUT } as unknown as PtAktaCtx;
for (const rule of PT_AKTA_RULE_SET) {
const direct = await VALIDATOR_BY_CODE[rule.code]!(EMPTY_INPUT);
const wrapped = await rule.run(ctx);
const w = Array.isArray(wrapped) ? wrapped[0]! : wrapped;
expect(w.ruleCode, `wrapper ${rule.code} code drift`).toBe(rule.code);
expect((Array.isArray(direct) ? direct[0]! : direct).ruleCode).toBe(w.ruleCode);
}
});
});
- [ ] Run GREEN (fidelity):
cd backend && bun test src/flow-engine/__tests__/wrapper-fidelity.test.ts. Expected: pass. (If a validator throws on the empty input, extendEMPTY_INPUTwith the missing field it dereferences — do NOT change the validator.) - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/flow-engine/validation-runner.ts src/flow-engine/__tests__/validation-runner.test.ts src/flow-engine/__tests__/persist-validation-results.test.ts src/flow-engine/__tests__/wrapper-fidelity.test.ts && git commit -m "feat(flow-engine): runValidations (async-partitioned) + transactional persist + wrapper fidelity"
Task 7: Generic processor — runFlow / continueFlow
Reproduces the fork's Phase 1-5 skeleton, config-gated. Two entry points (the AWAITING_COMPANY_SELECTION pause needs a resumable second half).
Files
- backend/src/flow-engine/processor.ts (Create)
Interfaces
- Consumes: db, Prisma; processDocument from ../services/document-processor; lookupCompany, loadOldData from ../services/company-lookup; detectChangeTypes from ../services/change-type-detector; runValidations, persistValidationResults from ./validation-runner; FlowConfig from ./types; PasalYangDiubah from ../schema/akta-perubahan.
- Produces:
- async function runFlow(submissionId: string, cfg: FlowConfig<any>): Promise<void>
- async function continueFlow(submissionId: string, cfg: FlowConfig<any>): Promise<void>
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/processor.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { runFlow } from "../processor";
import { defineFlow } from "../types";
/**
* Task 7 — runFlow Phase 1 gate. With a config whose primaryAkta.classifiedType
* is absent from the submission, the engine sets the submission to ERROR with the
* config's missingError (identical envelope to the fork). companyLookup:false +
* primaryAkta present + akta already DONE should fall through continueFlow to READY.
*/
describe("runFlow", () => {
const ids: string[] = [];
afterAll(async () => { for (const id of ids) { await db.validationResult.deleteMany({ where: { submissionId: id } }); await db.submission.deleteMany({ where: { id } }); } });
const baseCfg = (over: Partial<Parameters<typeof defineFlow>[0]> = {}) => defineFlow({
type: "PERUBAHAN_PT", engine: "v2-generic",
primaryAkta: { classifiedType: "AKTA_TEST", missingError: "Akta uji tidak ditemukan", extractError: "Gagal" },
skipExtractionTypes: [], companyLookup: false, applyContactInfo: false, extraction: {},
buildContext: async () => ({}), rules: [], requiredDocs: [], sections: [],
rematch: { checklistResetDocTypes: [], standardPtMatchers: false },
routeFamily: "perubahan", labels: { akta: "Akta Uji", flow: "Uji" }, ...over,
});
it("ERRORs with missingError when the primary akta is absent", async () => {
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "CREATED" } });
ids.push(sub.id);
await runFlow(sub.id, baseCfg());
const after = await db.submission.findUnique({ where: { id: sub.id } });
expect(after?.status).toBe("ERROR");
expect(after?.error).toBe("Akta uji tidak ditemukan");
});
it("companyLookup:false with an already-DONE akta reaches READY via continueFlow", async () => {
const doc = await db.document.create({ data: { filename: "x.pdf", originalName: "x.pdf", fileSize: 0, filePath: "/tmp/x.pdf", documentType: "AKTA", status: "COMPLETED", rawText: "" } });
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "CREATED" } });
ids.push(sub.id);
await db.submissionDocument.create({ data: { submissionId: sub.id, documentId: doc.id, classifiedType: "AKTA_TEST", processingOrder: 0, extractionStatus: "DONE" } });
await runFlow(sub.id, baseCfg({ buildContext: async () => ({}), rules: [] }));
const after = await db.submission.findUnique({ where: { id: sub.id } });
expect(after?.status).toBe("READY");
await db.document.delete({ where: { id: doc.id } }).catch(() => {});
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/processor.test.ts. Expected: fails — cannot resolve../processor. - [ ] Minimal implementation. Create
backend/src/flow-engine/processor.ts. Model Phase 1-2 on perubahan-processor.ts:21-105 and Phase 3-5 on 115-240, replacing hardcoded values withcfg.*and inliningprocessOneDocument(donor lines 242-263):
import { db } from "../lib/db";
import { Prisma } from "../generated/prisma/client";
import { processDocument } from "../services/document-processor";
import { lookupCompany, loadOldData } from "../services/company-lookup";
import { detectChangeTypes } from "../services/change-type-detector";
import { runValidations, persistValidationResults } from "./validation-runner";
import type { FlowConfig } from "./types";
import type { PasalYangDiubah } from "../schema/akta-perubahan";
async function processOneDocument(submissionId: string, subDocId: string, documentId: string, classificationOverride?: string): Promise<void> {
await db.submissionDocument.update({ where: { id: subDocId }, data: { extractionStatus: "PROCESSING" } });
await db.submission.update({ where: { id: submissionId }, data: { currentDocumentId: documentId } });
try {
await processDocument(documentId, classificationOverride ? { classificationOverride } : undefined);
const doc = await db.document.findUnique({ where: { id: documentId }, select: { status: true } });
await db.submissionDocument.update({ where: { id: subDocId }, data: { extractionStatus: doc?.status === "ERROR" ? "ERROR" : "DONE" } });
} catch (err) {
console.error(`Document ${documentId} extraction error:`, err);
await db.submissionDocument.update({ where: { id: subDocId }, data: { extractionStatus: "ERROR" } });
}
}
/** Phase 1-2: primary-akta extract + company lookup / auto-select / pause. */
export async function runFlow(submissionId: string, cfg: FlowConfig<any>): Promise<void> {
try {
await db.submission.update({ where: { id: submissionId }, data: { status: "EXTRACTING" } });
if (cfg.customProcess) return cfg.customProcess(submissionId, cfg);
const subDocs = await db.submissionDocument.findMany({ where: { submissionId }, orderBy: { processingOrder: "asc" }, include: { document: true } });
let aktaDoc: (typeof subDocs)[number] | undefined;
if (cfg.primaryAkta) {
aktaDoc = subDocs.find((d) => d.classifiedType === cfg.primaryAkta!.classifiedType);
if (!aktaDoc) {
await db.submission.update({ where: { id: submissionId }, data: { status: "ERROR", error: cfg.primaryAkta.missingError } });
return;
}
if (aktaDoc.extractionStatus !== "DONE") {
const override = cfg.extraction.overrideByClassifiedType?.[aktaDoc.classifiedType] ?? aktaDoc.classifiedType;
await processOneDocument(submissionId, aktaDoc.id, aktaDoc.documentId, override);
const updated = await db.submissionDocument.findUnique({ where: { id: aktaDoc.id }, select: { extractionStatus: true } });
if (updated?.extractionStatus === "ERROR") {
await db.submission.update({ where: { id: submissionId }, data: { status: "ERROR", error: cfg.primaryAkta.extractError, currentDocumentId: null } });
return;
}
}
// afterAktaExtraction focused pass — log-and-continue; must never brick the run.
if (cfg.hooks?.afterAktaExtraction) {
try {
const doc = await db.document.findUnique({ where: { id: aktaDoc.documentId }, select: { rawText: true } });
await cfg.hooks.afterAktaExtraction({ submissionId, aktaDocumentId: aktaDoc.documentId, rawText: doc?.rawText ?? "" });
} catch (err) { console.error(`[flow-engine] afterAktaExtraction failed for ${submissionId}:`, err); }
}
}
const lookup = cfg.companyLookup;
if (lookup && aktaDoc) {
const fields = await db.extractedField.findMany({ where: { documentId: aktaDoc.documentId } });
const namaPerseroan = fields.find((f) => f.fieldKey === "nama_perseroan")?.value ?? "";
const nomorSk = fields.find((f) => f.fieldKey === "ref_pendirian_nomor_sk")?.value ?? "";
const lookupResult = await lookupCompany({ nama_perseroan: namaPerseroan, nomor_sk_menkumham: nomorSk });
await db.submission.update({ where: { id: submissionId }, data: { companyLookupResult: lookupResult as unknown as Prisma.InputJsonValue } });
const highMatches = lookupResult.matches.filter((m) => m.confidence === "high");
if (highMatches.length === 1) {
const match = highMatches[0]!;
const oldData = await loadOldData(match.nomor_transaksi, match.tahun_transaksi);
await db.submission.update({ where: { id: submissionId }, data: { selectedNomorTransaksi: match.nomor_transaksi, selectedTahun: match.tahun_transaksi, oldData: oldData as unknown as Prisma.InputJsonValue } });
await continueFlow(submissionId, cfg);
} else {
await db.submission.update({ where: { id: submissionId }, data: { status: "AWAITING_COMPANY_SELECTION", currentDocumentId: null } });
}
} else {
await continueFlow(submissionId, cfg);
}
} catch (err) {
console.error(`runFlow ${submissionId} error:`, err);
await db.submission.update({ where: { id: submissionId }, data: { status: "ERROR", error: err instanceof Error ? err.message : "Unknown error" } }).catch(() => {});
}
}
/** Phase 3-5: remaining docs, contact-info, change-type detect, validation, READY. */
export async function continueFlow(submissionId: string, cfg: FlowConfig<any>): Promise<void> {
try {
await db.submission.update({ where: { id: submissionId }, data: { status: "EXTRACTING" } });
const subDocs = await db.submissionDocument.findMany({ where: { submissionId }, orderBy: { processingOrder: "asc" }, include: { document: true } });
const aktaDoc = cfg.primaryAkta ? subDocs.find((d) => d.classifiedType === cfg.primaryAkta!.classifiedType) : undefined;
// Phase 3: remaining docs
for (const subDoc of subDocs.filter((d) => d.id !== aktaDoc?.id)) {
if (subDoc.extractionStatus === "DONE") continue;
if (cfg.skipExtractionTypes.includes(subDoc.classifiedType)) {
await db.submissionDocument.update({ where: { id: subDoc.id }, data: { extractionStatus: "DONE" } });
} else {
const override = cfg.extraction.overrideByClassifiedType?.[subDoc.classifiedType];
await processOneDocument(submissionId, subDoc.id, subDoc.documentId, override);
}
}
// Phase 3b: contact-info apply (verbatim from the fork, gated)
if (cfg.applyContactInfo) {
const contactInfoDoc = subDocs.find((d) => d.classifiedType === "DATA_KONTAK");
if (contactInfoDoc) {
const extraction = await db.contactInfoExtraction.findUnique({ where: { documentId: contactInfoDoc.documentId } });
if (extraction) {
const submission = await db.submission.findUnique({ where: { id: submissionId }, select: { teleponPerseroan: true, emailPerseroan: true } });
if (submission) {
const updates: Record<string, string> = {};
if (!submission.teleponPerseroan && extraction.entityPhone) updates.teleponPerseroan = extraction.entityPhone;
if (!submission.emailPerseroan && extraction.entityEmail) updates.emailPerseroan = extraction.entityEmail;
if (Object.keys(updates).length > 0) await db.submission.update({ where: { id: submissionId }, data: updates });
}
const entries = (extraction.entries as any[]) ?? [];
if (entries.length > 0) {
const identityMatches = await db.identityMatch.findMany({ where: { submissionId }, select: { id: true, matchedPersonName: true, teleponPengurus: true, emailPengurus: true } });
if (identityMatches.length > 0) {
const { matchContactInfoToPeople } = await import("../services/contact-info-matcher");
const people = identityMatches.map((im) => ({ name: im.matchedPersonName, roles: [] as string[] }));
for (const entry of entries) {
if (!entry.nama) continue;
const matchResult = matchContactInfoToPeople(entry.nama, people);
if (matchResult.matchStatus === "UNMATCHED") continue;
const im = identityMatches.find((m) => m.matchedPersonName.toLowerCase() === matchResult.matchedPersonName.toLowerCase());
if (!im) continue;
const upd: Record<string, string> = {};
if (!im.teleponPengurus && entry.no_telepon) upd.teleponPengurus = entry.no_telepon;
if (!im.emailPengurus && entry.email) upd.emailPengurus = entry.email;
if (Object.keys(upd).length > 0) await db.identityMatch.update({ where: { id: im.id }, data: upd });
}
}
}
}
}
}
// Phase 4: change-type detection (akta flows only) + mapChangeTypes hook
if (aktaDoc) {
const fields = await db.extractedField.findMany({ where: { documentId: aktaDoc.documentId } });
const jenisStr = fields.find((f) => f.fieldKey === "jenis_perubahan")?.value ?? "";
const jenisPerubahan = jenisStr ? jenisStr.split(", ").map((s) => s.trim()).filter(Boolean) : [];
const recs = await db.aktaPerubahan.findMany({ where: { documentId: aktaDoc.documentId }, orderBy: { orderIndex: "asc" } });
const pasal: PasalYangDiubah[] = recs.map((r) => ({ nomor_pasal: r.pasalYangDiubah, perihal: r.jenisPerubahan, isi_lama: r.keteranganLama, isi_baru: r.keteranganBaru }));
let changeTypeResult = detectChangeTypes(jenisPerubahan, pasal);
if (cfg.hooks?.mapChangeTypes) changeTypeResult = cfg.hooks.mapChangeTypes(changeTypeResult);
await db.submission.update({ where: { id: submissionId }, data: { changeTypeResult: changeTypeResult as unknown as Prisma.InputJsonValue } });
}
// Phase 5: validation
await db.submission.update({ where: { id: submissionId }, data: { status: "VALIDATING" } });
const ctx = await cfg.buildContext(submissionId);
const results = await runValidations(cfg, ctx);
await persistValidationResults(submissionId, results);
await db.submission.update({ where: { id: submissionId }, data: { status: "READY", currentDocumentId: null } });
} catch (err) {
console.error(`continueFlow ${submissionId} error:`, err);
await db.submission.update({ where: { id: submissionId }, data: { status: "ERROR", error: err instanceof Error ? err.message : "Unknown error" } }).catch(() => {});
}
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/processor.test.ts. Expected: 2 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/flow-engine/processor.ts src/flow-engine/__tests__/processor.test.ts && git commit -m "feat(flow-engine): generic runFlow/continueFlow Phase 1-5 (config-gated)"
Task 8: reMatchAndValidateFlow — same config, wipe class dead
Generalizes reMatchAndValidatePerubahan/...Akuisisi. Resolves the SAME FlowConfig as runFlow, so a v2 flow cannot exist without its rematch path.
Files
- backend/src/flow-engine/rematch.ts (Create)
Interfaces
- Consumes: db, Prisma; detectChangeTypes; runValidations, persistValidationResults from ./validation-runner; FlowConfig from ./types; dynamic imports of ../services/submission-checklist (collectUniquePeople) and ../services/submission-processor (matchKtpDocument, matchNpwpDocument, fulfillChecklist, applyContactInfoToSubmission); PasalYangDiubah.
- Produces: async function reMatchAndValidateFlow(submissionId: string, cfg: FlowConfig<any>): Promise<void>
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/rematch.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { reMatchAndValidateFlow } from "../rematch";
import { defineFlow } from "../types";
import type { RuleResult } from "../types";
/**
* Task 8 — reMatchAndValidateFlow must (a) NOT blanket-delete ValidationResult
* (overrides survive field edits — the notIn-delete after re-validation removes
* stale rules), and (b) run buildContext→runValidations→persist from the SAME cfg.
* We use a stub cfg whose buildContext returns a marker context and one rule that
* emits it, so no akta/matcher machinery is needed.
*/
describe("reMatchAndValidateFlow", () => {
const ids: string[] = [];
afterAll(async () => { for (const id of ids) { await db.validationResult.deleteMany({ where: { submissionId: id } }); await db.submission.deleteMany({ where: { id } }); } });
it("preserves an override across rematch and re-runs the config's rules", async () => {
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "READY" } });
ids.push(sub.id);
await db.validationResult.create({ data: { submissionId: sub.id, ruleCode: "KEEP_ME", ruleLabel: "K", status: "FAIL", message: "old", overridden: true, overrideReason: "notaris" } });
const cfg = defineFlow({
type: "PERUBAHAN_PT", engine: "v2-generic", primaryAkta: null, skipExtractionTypes: [],
companyLookup: false, applyContactInfo: false, extraction: {},
buildContext: async () => ({}),
rules: [{ code: "KEEP_ME", label: "K", run: (): RuleResult => ({ ruleCode: "KEEP_ME", ruleLabel: "K", status: "FAIL", message: "new" }) }],
requiredDocs: [], sections: [],
rematch: { checklistResetDocTypes: ["KTP"], standardPtMatchers: false },
routeFamily: "perubahan", labels: { akta: "a", flow: "f" },
});
await reMatchAndValidateFlow(sub.id, cfg);
const row = await db.validationResult.findUnique({ where: { submissionId_ruleCode: { submissionId: sub.id, ruleCode: "KEEP_ME" } } });
expect(row?.overridden).toBe(true); // NOT blanket-deleted
expect(row?.overrideReason).toBe("notaris");
expect(row?.message).toBe("new"); // re-validated from the config's rule
});
it("runs preValidationSteps before validation", async () => {
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "READY" } });
ids.push(sub.id);
let ran = false;
const cfg = defineFlow({
type: "PERUBAHAN_PT", engine: "v2-generic", primaryAkta: null, skipExtractionTypes: [],
companyLookup: false, applyContactInfo: false, extraction: {},
buildContext: async () => ({}), rules: [], requiredDocs: [], sections: [],
rematch: { checklistResetDocTypes: [], standardPtMatchers: false, preValidationSteps: [async () => { ran = true; }] },
routeFamily: "perubahan", labels: { akta: "a", flow: "f" },
});
await reMatchAndValidateFlow(sub.id, cfg);
expect(ran).toBe(true);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/rematch.test.ts. Expected: fails — cannot resolve../rematch. - [ ] Minimal implementation. Create
backend/src/flow-engine/rematch.ts, generalizing donor lines 680-796:
import { db } from "../lib/db";
import { Prisma } from "../generated/prisma/client";
import { detectChangeTypes } from "../services/change-type-detector";
import { runValidations, persistValidationResults } from "./validation-runner";
import type { FlowConfig } from "./types";
import type { PasalYangDiubah } from "../schema/akta-perubahan";
export async function reMatchAndValidateFlow(submissionId: string, cfg: FlowConfig<any>): Promise<void> {
// (1) wipe: identity matches + checklist reset. NO validationResult blanket-delete
// (overrides survive; stale rules removed by the notIn-delete after re-validation).
await db.$transaction([
db.identityMatch.deleteMany({ where: { submissionId } }),
db.submissionChecklist.updateMany({
where: { submissionId, docType: { in: cfg.rematch.checklistResetDocTypes } },
data: { fulfilled: false, linkedDocumentId: null },
}),
]);
let aktaSubDoc: Awaited<ReturnType<typeof findAkta>> = null;
async function findAkta() {
if (!cfg.primaryAkta) return null;
return db.submissionDocument.findFirst({
where: { submissionId, classifiedType: cfg.primaryAkta.classifiedType },
include: { document: { include: { aktaDireksi: true, aktaKomisaris: true, aktaPemegangSaham: true, aktaPenghadap: true } } },
});
}
aktaSubDoc = await findAkta();
// (2) standard PT matcher loop
if (cfg.rematch.standardPtMatchers && aktaSubDoc) {
const { collectUniquePeople } = await import("../services/submission-checklist");
const { matchKtpDocument, matchNpwpDocument, fulfillChecklist, applyContactInfoToSubmission } = await import("../services/submission-processor");
const people = collectUniquePeople({
direksi: aktaSubDoc.document.aktaDireksi, komisaris: aktaSubDoc.document.aktaKomisaris,
pemegangSaham: aktaSubDoc.document.aktaPemegangSaham, penghadap: aktaSubDoc.document.aktaPenghadap,
});
const subDocs = await db.submissionDocument.findMany({ where: { submissionId, extractionStatus: "DONE" }, orderBy: { processingOrder: "asc" } });
for (const subDoc of subDocs) {
try {
if (subDoc.classifiedType === "KTP") await matchKtpDocument(submissionId, subDoc.documentId, people);
if (subDoc.classifiedType === "NPWP" || subDoc.classifiedType === "NPWP_LAMA") await matchNpwpDocument(submissionId, subDoc.documentId);
if (subDoc.classifiedType.startsWith("BUKTI_TRANSFER") || subDoc.classifiedType === "BUKTI_SETOR") await fulfillChecklist(submissionId, "BUKTI_SETOR", subDoc.documentId);
if (subDoc.classifiedType === "DOMISILI" || subDoc.classifiedType === "SURAT_PERNYATAAN_DOMISILI" || subDoc.classifiedType === "SURAT_PERNYATAAN_DOMISILI_LEGACY") await fulfillChecklist(submissionId, "SURAT_PERNYATAAN_DOMISILI", subDoc.documentId);
if (subDoc.classifiedType === "DATA_KONTAK") await applyContactInfoToSubmission(submissionId, subDoc.documentId, people);
} catch (err) { console.error(`reMatchAndValidateFlow: failed ${subDoc.classifiedType} ${subDoc.documentId}:`, err); }
}
}
// (3) preValidationSteps serially (each try/catch)
for (const step of cfg.rematch.preValidationSteps ?? []) {
try { await step(submissionId); } catch (err) { console.error(`reMatchAndValidateFlow: preValidationStep failed for ${submissionId}:`, err); }
}
// (4) change-type re-detect + mapChangeTypes at THIS call site too (akuisisi ghost-jenis fix)
if (aktaSubDoc) {
try {
const fields = await db.extractedField.findMany({ where: { documentId: aktaSubDoc.documentId } });
const jenisStr = fields.find((f) => f.fieldKey === "jenis_perubahan")?.value ?? "";
const jenisPerubahan = jenisStr ? jenisStr.split(", ").map((s) => s.trim()).filter(Boolean) : [];
const recs = await db.aktaPerubahan.findMany({ where: { documentId: aktaSubDoc.documentId }, orderBy: { orderIndex: "asc" } });
const pasal: PasalYangDiubah[] = recs.map((r) => ({ nomor_pasal: r.pasalYangDiubah, perihal: r.jenisPerubahan, isi_lama: r.keteranganLama, isi_baru: r.keteranganBaru }));
let changeTypeResult = detectChangeTypes(jenisPerubahan, pasal);
if (cfg.hooks?.mapChangeTypes) changeTypeResult = cfg.hooks.mapChangeTypes(changeTypeResult);
await db.submission.update({ where: { id: submissionId }, data: { changeTypeResult: changeTypeResult as unknown as Prisma.InputJsonValue } });
} catch (err) { console.error(`reMatchAndValidateFlow: change-type re-detect failed for ${submissionId}:`, err); }
}
// (5) buildContext → runValidations → persist (preserving path)
try {
const ctx = await cfg.buildContext(submissionId);
const results = await runValidations(cfg, ctx);
await persistValidationResults(submissionId, results);
} catch (err) { console.error(`reMatchAndValidateFlow: validation failed for ${submissionId}:`, err); }
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/rematch.test.ts. Expected: 2 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/flow-engine/rematch.ts src/flow-engine/__tests__/rematch.test.ts && git commit -m "feat(flow-engine): reMatchAndValidateFlow resolving the same config"
Task 9: v1/v2 routing — dispatch + reMatchAndValidate + route family
Adds the registry check to dispatchProcessor and reMatchAndValidate, extends ProcessorKind with "v2-generic" + the unreachable-throw switch case, and derives PERUBAHAN_FAMILY from the registry. All existing v1 behaviour byte-identical.
Files
- backend/src/services/submission-dispatch.ts — ProcessorKind (L4-14), processorKindForType (L23-51), dispatchProcessor (L59-117).
- backend/src/services/submission-processor.ts — top of reMatchAndValidate (L710).
- backend/src/routes/perubahan.ts — PERUBAHAN_FAMILY (L32).
Interfaces
- Consumes: flowFor, typesForRouteFamily from ../flow-engine/registry; runFlow from ../flow-engine/processor; reMatchAndValidateFlow from ../flow-engine/rematch (all via dynamic import).
- Produces: no new exports; ProcessorKind gains member "v2-generic".
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/dispatch-routing.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { processorKindForType, dispatchProcessor } from "../../services/submission-dispatch";
import { FLOW_REGISTRY } from "../registry";
/**
* Task 9 — a v2-generic registry entry routes dispatchProcessor to runFlow; every
* existing v1 type still reaches its fork (processorKindForType unchanged for them).
* We temporarily register a v2 entry for a spare-but-present SubmissionType by
* mutating the registry in-test (restored in afterAll), asserting the dispatch arm.
*/
describe("v1/v2 dispatch routing", () => {
const original = FLOW_REGISTRY.PEMBUBARAN_PP;
afterAll(() => { (FLOW_REGISTRY as any).PEMBUBARAN_PP = original; });
it("keeps every existing v1 type mapping intact", () => {
expect(processorKindForType("PERUBAHAN_PT")).toBe("perubahan-pt");
expect(processorKindForType("AKUISISI_PT")).toBe("akuisisi-pt");
expect(processorKindForType("APOSTILLE")).toBe("apostille");
});
it("dispatchProcessor sends a v2-generic entry to runFlow (reaches EXTRACTING then ERROR on missing akta)", async () => {
(FLOW_REGISTRY as any).PEMBUBARAN_PP = {
type: "PEMBUBARAN_PP", engine: "v2-generic",
primaryAkta: { classifiedType: "AKTA_NONE", missingError: "v2 akta missing", extractError: "e" },
skipExtractionTypes: [], companyLookup: false, applyContactInfo: false, extraction: {},
buildContext: async () => ({}), rules: [], requiredDocs: [], sections: [],
rematch: { checklistResetDocTypes: [], standardPtMatchers: false },
routeFamily: "perubahan", labels: { akta: "a", flow: "f" },
};
const sub = await db.submission.create({ data: { type: "PEMBUBARAN_PP", status: "CREATED" } });
await dispatchProcessor(sub.id, "PEMBUBARAN_PP");
const after = await db.submission.findUnique({ where: { id: sub.id } });
expect(after?.error).toBe("v2 akta missing"); // proves runFlow ran, not the legacy 'none' brick
await db.validationResult.deleteMany({ where: { submissionId: sub.id } });
await db.submission.delete({ where: { id: sub.id } });
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/dispatch-routing.test.ts. Expected: the second test fails — dispatch does not yet consult the registry (PEMBUBARAN_PP hits the legacy"none"brick). - [ ] Minimal implementation A —
submission-dispatch.ts. EditProcessorKind(add| "v2-generic"before| "none"):
export type ProcessorKind =
| "pendirian-pt"
| "perubahan-pt"
| "pendirian-pp"
| "perubahan-pp"
| "perbaikan-pp"
| "perbaikan-pt"
| "peralihan-pp-pt"
| "apostille"
| "akuisisi-pt"
| "v2-generic"
| "none";
In dispatchProcessor, add the registry check as the FIRST statement (before switch):
export async function dispatchProcessor(submissionId: string, type: SubmissionType): Promise<void> {
const { flowFor } = await import("../flow-engine/registry");
const entry = flowFor(type);
if (entry.engine === "v2-generic") {
const { runFlow } = await import("../flow-engine/processor");
return runFlow(submissionId, entry);
}
switch (processorKindForType(type)) {
Add the unreachable-throw case to the dispatchProcessor switch (before case "none"):
case "v2-generic": {
throw new Error("unreachable: v2-generic types dispatch via FLOW_REGISTRY before the switch");
}
Leave processorKindForType UNCHANGED for now (its never tripwire only fires when a new SubmissionType is added; no v2 SubmissionTypes exist in this slice, so the "v2-generic" ProcessorKind member is reachable only when a future flow spec adds case "PEMBUBARAN_PT": return "v2-generic";). The switch case "v2-generic" compiles because the member now exists on the union.
- [ ] Minimal implementation B — submission-processor.ts. Insert at the very top of reMatchAndValidate (before the db.submission.findUnique that selects type, line ~713), the registry check. Since the function already fetches type a few lines down, restructure to fetch it first then branch:
export async function reMatchAndValidate(submissionId: string): Promise<void> {
const submission = await db.submission.findUnique({
where: { id: submissionId },
select: { type: true },
});
if (submission) {
const { flowFor } = await import("../flow-engine/registry");
const entry = flowFor(submission.type);
if (entry.engine === "v2-generic") {
const { reMatchAndValidateFlow } = await import("../flow-engine/rematch");
return reMatchAndValidateFlow(submissionId, entry);
}
}
if (submission?.type === "PERUBAHAN_PT") {
// …existing if-chain UNTOUCHED from here…
Delete the now-duplicate const submission = await db.submission.findUnique(...) that previously sat at the top of the if-chain (the one at donor line 713-716) — the block above replaces it. Everything else stays byte-identical.
- [ ] Minimal implementation C — routes/perubahan.ts. Replace line 32:
const PERUBAHAN_FAMILY = new Set(["PERUBAHAN_PT", "AKUISISI_PT"]);
with a registry-derived set (import at top of file):
import { typesForRouteFamily } from "../flow-engine/registry";
const PERUBAHAN_FAMILY = new Set<string>(["PERUBAHAN_PT", "AKUISISI_PT", ...typesForRouteFamily("perubahan")]);
(No v2 flows register routeFamily:"perubahan" yet, so the spread is [] — behaviour for PERUBAHAN_PT/AKUISISI_PT is identical. The per-type company-select/retry v2 arms land with each flow spec; do not add them here.)
- [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/dispatch-routing.test.ts src/services/__tests__/submission-dispatch.test.ts. Expected: all pass (the existing dispatch test still green — v1 mappings unchanged).
- [ ] Typecheck: cd backend && bunx tsc --noEmit. Expected: no errors.
- [ ] Commit: cd backend && git add src/services/submission-dispatch.ts src/services/submission-processor.ts src/routes/perubahan.ts src/flow-engine/__tests__/dispatch-routing.test.ts && git commit -m "feat(flow-engine): v1/v2 routing in dispatch + reMatchAndValidate + registry-derived route family"
Task 10: FAIL gate — block-with-override (engine-owned)
Ships checkFailGate(id, cfg) reusing the pp-perubahan.ts:1538 pattern: block when any row is FAIL && !overridden; SKIPPED/WARNING never block; failGate:'off' opts out.
Files
- backend/src/flow-engine/fail-gate.ts (Create)
Interfaces
- Consumes: db; FlowConfig from ./types.
- Produces: async function checkFailGate(submissionId: string, cfg: FlowConfig<any>): Promise<{ blocked: boolean; failing: string[] }>
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/fail-gate.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { checkFailGate } from "../fail-gate";
import { defineFlow, type FlowConfig } from "../types";
const cfg = (over: Partial<FlowConfig<any>> = {}): FlowConfig<any> => defineFlow({
type: "PERUBAHAN_PT", engine: "v2-generic", primaryAkta: null, skipExtractionTypes: [],
companyLookup: false, applyContactInfo: false, extraction: {}, buildContext: async () => ({}),
rules: [], requiredDocs: [], sections: [], rematch: { checklistResetDocTypes: [], standardPtMatchers: false },
routeFamily: "perubahan", labels: { akta: "a", flow: "f" }, ...over,
});
describe("checkFailGate", () => {
const ids: string[] = [];
afterAll(async () => { for (const id of ids) { await db.validationResult.deleteMany({ where: { submissionId: id } }); await db.submission.deleteMany({ where: { id } }); } });
async function seed(rows: Array<{ code: string; status: "PASS" | "WARNING" | "FAIL" | "SKIPPED"; overridden?: boolean }>) {
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "READY" } });
ids.push(sub.id);
for (const r of rows) await db.validationResult.create({ data: { submissionId: sub.id, ruleCode: r.code, ruleLabel: r.code, status: r.status, message: "", overridden: r.overridden ?? false } });
return sub.id;
}
it("blocks on an un-overridden FAIL", async () => {
const id = await seed([{ code: "X", status: "FAIL" }]);
expect(await checkFailGate(id, cfg())).toEqual({ blocked: true, failing: ["X"] });
});
it("passes when the FAIL is overridden", async () => {
const id = await seed([{ code: "X", status: "FAIL", overridden: true }]);
expect((await checkFailGate(id, cfg())).blocked).toBe(false);
});
it("SKIPPED and WARNING never block, even un-overridden", async () => {
const id = await seed([{ code: "S", status: "SKIPPED" }, { code: "W", status: "WARNING" }]);
expect((await checkFailGate(id, cfg())).blocked).toBe(false);
});
it("failGate:'off' never blocks", async () => {
const id = await seed([{ code: "X", status: "FAIL" }]);
expect((await checkFailGate(id, cfg({ failGate: "off" }))).blocked).toBe(false);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/fail-gate.test.ts. Expected: fails — cannot resolve../fail-gate. - [ ] Minimal implementation. Create
backend/src/flow-engine/fail-gate.ts:
import { db } from "../lib/db";
import type { FlowConfig } from "./types";
/**
* Block-with-override FAIL gate for v2-generic flows (program decision 2026-07-02).
* Blocks when any ValidationResult row is FAIL and NOT overridden. SKIPPED and
* WARNING never block regardless of overridden. Reuses ValidationResult.overridden
* (schema.prisma:941) — no new table. Reference pattern: pp-perubahan.ts:1538.
* Opt-out only via cfg.failGate === 'off' (default 'on').
*/
export async function checkFailGate(
submissionId: string,
cfg: FlowConfig<any>,
): Promise<{ blocked: boolean; failing: string[] }> {
if (cfg.failGate === "off") return { blocked: false, failing: [] };
const rows = await db.validationResult.findMany({
where: { submissionId, status: "FAIL", overridden: false },
select: { ruleCode: true },
});
return { blocked: rows.length > 0, failing: rows.map((r) => r.ruleCode) };
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/fail-gate.test.ts. Expected: 4 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/flow-engine/fail-gate.ts src/flow-engine/__tests__/fail-gate.test.ts && git commit -m "feat(flow-engine): block-with-override FAIL gate (engine-owned, failGate opt-out)"
Task 11: Equivalence oracle — shadow configs + parity harness
Ships tests-only shadow FlowConfigs for PERUBAHAN_PT and AKUISISI_PT (NOT registered — the registry entries stay v1-fork) and the parity oracle: seed identical submissions, run the fork on one and the shadow v2 path on the other, deep-equal the ValidationResult row sets. Because the rule bodies are the same functions on both paths, any diff isolates engine plumbing.
Files
- backend/src/flow-engine/flows/__shadow__/perubahan-pt.shadow.ts (Create)
- backend/src/flow-engine/flows/__shadow__/akuisisi-pt.shadow.ts (Create)
Interfaces
- Consumes: defineFlow, FlowConfig from ../../types; buildPtAktaContext, PtAktaCtx from ../../context/pt-akta-context; PT_AKTA_RULE_SET from ../../rules/pt-akta/index; rupsQuorumRule from ../../rules/pt-akta/rups-quorum; akuisisiKoranRule from ../../rules/akuisisi/koran-window.
- Produces: const perubahanPtShadow: FlowConfig<PtAktaCtx>, const akuisisiPtShadow: FlowConfig<PtAktaCtx>.
Steps
- [ ] Minimal implementation A — shadow configs. Create
backend/src/flow-engine/flows/__shadow__/perubahan-pt.shadow.ts:
import { defineFlow, type FlowConfig } from "../../types";
import { buildPtAktaContext, type PtAktaCtx } from "../../context/pt-akta-context";
import { PT_AKTA_RULE_SET } from "../../rules/pt-akta/index";
import { rupsQuorumRule } from "../../rules/pt-akta/rups-quorum";
/**
* TESTS-ONLY shadow config for PERUBAHAN_PT (NOT registered; PERUBAHAN_PT stays
* v1-fork). Rules = the full PT_AKTA_RULE_SET + RUPS quorum — exactly what
* runPerubahanCrossValidation runs. Proves the generic engine reproduces the fork.
*/
export const perubahanPtShadow: FlowConfig<PtAktaCtx> = defineFlow<PtAktaCtx>({
type: "PERUBAHAN_PT", engine: "v2-generic",
primaryAkta: { classifiedType: "AKTA_PERUBAHAN", missingError: "Akta Perubahan tidak ditemukan", extractError: "Gagal memproses Akta Perubahan" },
skipExtractionTypes: ["AKTA_PEMINDAHAN_HAK", "PENETAPAN_GANTI_NAMA"],
companyLookup: true, applyContactInfo: true, extraction: {},
buildContext: (id) => buildPtAktaContext(id, { aktaClassifiedType: "AKTA_PERUBAHAN" }),
rules: [...PT_AKTA_RULE_SET, rupsQuorumRule()],
requiredDocs: [], sections: [],
rematch: { checklistResetDocTypes: ["KTP", "NPWP", "BUKTI_SETOR", "DOMISILI"], standardPtMatchers: true },
routeFamily: "perubahan", labels: { akta: "Akta Perubahan", flow: "Perubahan PT" },
});
Create backend/src/flow-engine/flows/__shadow__/akuisisi-pt.shadow.ts:
import { defineFlow, type FlowConfig } from "../../types";
import { buildPtAktaContext, type PtAktaCtx } from "../../context/pt-akta-context";
import { PT_AKTA_RULE_SET } from "../../rules/pt-akta/index";
import { rupsQuorumRule } from "../../rules/pt-akta/rups-quorum";
import { akuisisiKoranRule } from "../../rules/akuisisi/koran-window";
import { forcePeralihanOn } from "../../../services/akuisisi-processor";
/**
* TESTS-ONLY shadow config for AKUISISI_PT. Adds akuisisiKoranRule + the
* forcePeralihanOn mapChangeTypes hook, and reads tanggalKoran from the akuisisi
* field key — exactly what runAkuisisiCrossValidation does.
*/
export const akuisisiPtShadow: FlowConfig<PtAktaCtx> = defineFlow<PtAktaCtx>({
type: "AKUISISI_PT", engine: "v2-generic",
primaryAkta: { classifiedType: "AKTA_AKUISISI", missingError: "Akta Akuisisi tidak ditemukan", extractError: "Gagal memproses Akta Akuisisi" },
skipExtractionTypes: ["AKTA_PEMINDAHAN_HAK", "PENETAPAN_GANTI_NAMA"],
companyLookup: true, applyContactInfo: true, extraction: {},
buildContext: (id) => buildPtAktaContext(id, { aktaClassifiedType: "AKTA_AKUISISI", tanggalKoranFieldKey: "tanggal_koran_akuisisi" }),
rules: [...PT_AKTA_RULE_SET, rupsQuorumRule(), akuisisiKoranRule],
requiredDocs: [], sections: [],
rematch: { checklistResetDocTypes: ["KTP", "NPWP", "BUKTI_SETOR", "DOMISILI"], standardPtMatchers: true },
hooks: { mapChangeTypes: forcePeralihanOn },
routeFamily: "perubahan", labels: { akta: "Akta Akuisisi", flow: "Akuisisi PT" },
});
- [ ] Write failing parity test. Create
backend/src/flow-engine/__tests__/flow-parity.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { runPerubahanCrossValidation } from "../../services/perubahan-processor";
import { perubahanPtShadow } from "../flows/__shadow__/perubahan-pt.shadow";
import { runValidations, persistValidationResults } from "../validation-runner";
/**
* Task 11 — equivalence oracle. Seed a PERUBAHAN_PT submission, run the FORK
* (runPerubahanCrossValidation) and the SHADOW v2 path (buildContext →
* runValidations → persist blanketDelete:true) on TWO clones of the same seed,
* and deep-equal the ValidationResult row sets on {ruleCode,ruleLabel,status,message}.
* Same rule bodies on both paths ⇒ any diff isolates engine plumbing.
*/
async function seedPerubahan(): Promise<string> {
const doc = await db.document.create({ data: { filename: "akta.pdf", originalName: "akta.pdf", fileSize: 0, filePath: "/tmp/akta.pdf", documentType: "AKTA", status: "COMPLETED", rawText: "" } });
await db.extractedField.createMany({ data: [
{ documentId: doc.id, fieldKey: "nama_perseroan", value: "PT PARITAS UJI" },
{ documentId: doc.id, fieldKey: "tanggal_akta_notaris", value: "2023-05-01" },
] });
const sub = await db.submission.create({ data: { type: "PERUBAHAN_PT", status: "VALIDATING" } });
await db.submissionDocument.create({ data: { submissionId: sub.id, documentId: doc.id, classifiedType: "AKTA_PERUBAHAN", processingOrder: 0, extractionStatus: "DONE" } });
return sub.id;
}
const rows = (id: string) => db.validationResult.findMany({ where: { submissionId: id }, orderBy: { ruleCode: "asc" }, select: { ruleCode: true, ruleLabel: true, status: true, message: true } });
describe("flow parity — PERUBAHAN_PT fork vs shadow v2", () => {
const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
for (const id of ids) { await db.validationResult.deleteMany({ where: { submissionId: id } }); await db.submissionDocument.deleteMany({ where: { submissionId: id } }); await db.submission.deleteMany({ where: { id } }); }
for (const d of docIds) { await db.extractedField.deleteMany({ where: { documentId: d } }); await db.document.deleteMany({ where: { id: d } }); }
});
it("produces byte-identical ValidationResult row sets", async () => {
const forkId = await seedPerubahan(); ids.push(forkId);
const shadowId = await seedPerubahan(); ids.push(shadowId);
// capture doc ids for cleanup
for (const id of [forkId, shadowId]) {
const sd = await db.submissionDocument.findFirst({ where: { submissionId: id } });
if (sd) docIds.push(sd.documentId);
}
await runPerubahanCrossValidation(forkId);
const ctx = await perubahanPtShadow.buildContext(shadowId);
const results = await runValidations(perubahanPtShadow, ctx);
await persistValidationResults(shadowId, results, { blanketDelete: true });
expect(await rows(shadowId)).toEqual(await rows(forkId));
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/flow-parity.test.ts. Expected: fails initially if shadow files don't compile OR the row sets diverge (a divergence means a plumbing bug in Task 4/5/6 — fix THERE, not by editing bodies). - [ ] If GREEN already (row sets equal), proceed. If RED on a diff, use
superpowers:systematic-debugging: compare the differing rows, trace to the context builder or runner, fix the plumbing, re-run. Do NOT touch validator bodies. - [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/flow-parity.test.ts. Expected: pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/flow-engine/flows/__shadow__ src/flow-engine/__tests__/flow-parity.test.ts && git commit -m "test(flow-engine): equivalence oracle — PERUBAHAN_PT fork vs shadow v2 parity"
Task 12: Supporting-doc-classifier seam + keyword pre-suggester
Ships supporting-doc-classifier.ts mirroring apostille-classifier.ts/akta-txn-classifier.ts: deterministic keyword pre-rules per new DocumentType + grounded-LLM fallback, with BUKTI_PENGUMUMAN (incl. the _149/_152 axis) as keyword-suggest-only (NO LLM, never auto-accepted). Seam only — no klasifikasi wiring (that lands with each flow).
Files
- backend/src/services/supporting-doc-classifier.ts (Create)
Interfaces
- Consumes: DocumentType from ../generated/prisma/enums (type-only; the module returns carrier strings).
- Produces:
- interface SupportingDocSuggestion { docType: string; confidence: number; source: "keyword" | "llm" | "none"; autoAcceptable: boolean; evidence: string | null; fineLabel?: string }
- function suggestByKeyword(rawText: string): SupportingDocSuggestion | null (deterministic; returns BUKTI_PENGUMUMAN with autoAcceptable:false + optional fineLabel: "BUKTI_PENGUMUMAN_149" | "BUKTI_PENGUMUMAN_152", or one of the other five carriers)
- async function classifySupportingDoc(rawText: string, opts?: { llm?: (text: string) => Promise<{ docType: string; confidence: number } | null> }): Promise<SupportingDocSuggestion> (keyword-first; BUKTI_PENGUMUMAN bypasses the LLM entirely; LLM outage → keyword result or {docType:"", source:"none", autoAcceptable:false})
Steps
- [ ] Write failing test. Create
backend/src/services/__tests__/supporting-doc-classifier.test.ts:
import { describe, it, expect } from "bun:test";
import { suggestByKeyword, classifySupportingDoc } from "../supporting-doc-classifier";
describe("suggestByKeyword — deterministic pre-rules", () => {
it("laporan keuangan → LAPORAN_KEUANGAN", () => {
expect(suggestByKeyword("LAPORAN KEUANGAN\nOPINI WAJAR TANPA PENGECUALIAN\nAKUNTAN PUBLIK")?.docType).toBe("LAPORAN_KEUANGAN");
});
it("surat penunjukan likuidator → SURAT_LIKUIDATOR", () => {
expect(suggestByKeyword("SURAT PENUNJUKAN LIKUIDATOR")?.docType).toBe("SURAT_LIKUIDATOR");
});
it("laporan akhir likuidasi → LAPORAN_LIKUIDASI", () => {
expect(suggestByKeyword("LAPORAN AKHIR LIKUIDASI\nsisa kekayaan")?.docType).toBe("LAPORAN_LIKUIDASI");
});
it("pengumuman koran → BUKTI_PENGUMUMAN, never auto-acceptable", () => {
const s = suggestByKeyword("PENGUMUMAN PEMBUBARAN PERSEROAN\nkepada para kreditor");
expect(s?.docType).toBe("BUKTI_PENGUMUMAN");
expect(s?.autoAcceptable).toBe(false);
expect(s?.fineLabel).toBe("BUKTI_PENGUMUMAN_149"); // kreditor/pembubaran axis
});
it("pengumuman hasil likuidasi → BUKTI_PENGUMUMAN_152 fine label", () => {
const s = suggestByKeyword("PENGUMUMAN HASIL LIKUIDASI\npertanggungjawaban likuidator");
expect(s?.fineLabel).toBe("BUKTI_PENGUMUMAN_152");
});
it("returns null on unrelated text", () => {
expect(suggestByKeyword("SERTIFIKAT HAK MILIK")).toBeNull();
});
});
describe("classifySupportingDoc", () => {
it("BUKTI_PENGUMUMAN bypasses the LLM entirely (keyword-only)", async () => {
let called = false;
const out = await classifySupportingDoc("PENGUMUMAN PEMBUBARAN\nkreditor", { llm: async () => { called = true; return { docType: "WRONG", confidence: 1 }; } });
expect(called).toBe(false);
expect(out.docType).toBe("BUKTI_PENGUMUMAN");
expect(out.autoAcceptable).toBe(false);
});
it("LLM outage leaves the file operator-assignable (never silently labeled)", async () => {
const out = await classifySupportingDoc("some ambiguous letter", { llm: async () => null });
expect(out.autoAcceptable).toBe(false);
expect(out.source).toBe("none");
});
it("keyword hit wins without calling the LLM for the auto-routable types", async () => {
let called = false;
const out = await classifySupportingDoc("LAPORAN KEUANGAN\nAKUNTAN PUBLIK", { llm: async () => { called = true; return null; } });
expect(out.docType).toBe("LAPORAN_KEUANGAN");
expect(called).toBe(false);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/services/__tests__/supporting-doc-classifier.test.ts. Expected: fails — cannot resolve../supporting-doc-classifier. - [ ] Minimal implementation. Create
backend/src/services/supporting-doc-classifier.ts:
/**
* supporting-doc-classifier.ts — the seam that suggests a DocumentType for the six
* new supporting docs the x056 GPU classifier does not know. Mirrors
* apostille-classifier.ts / akta-txn-classifier.ts: deterministic keyword pre-rules
* + a grounded-LLM fallback. BUKTI_PENGUMUMAN (incl. the _149/_152 axis) is
* keyword-suggest ONLY — PaddleOCR line-splits dense newsprint so LLM classification
* over shredded text is unreliable — and is NEVER auto-accepted. Suggestions propose;
* the board operator re-maps and always overrides. Seam only: no klasifikasi wiring
* until each flow ships.
*/
export interface SupportingDocSuggestion {
docType: string; // carrier DocumentType value, or "" when unknown
confidence: number; // 0–1 heuristic tier, not a trained probability
source: "keyword" | "llm" | "none";
autoAcceptable: boolean; // BUKTI_PENGUMUMAN is ALWAYS false
evidence: string | null;
fineLabel?: string; // e.g. BUKTI_PENGUMUMAN_149 / _152
}
const norm = (t: string) => (t || "").toUpperCase();
/** Deterministic pre-rules. First match wins; ordered so the more specific titles win. */
export function suggestByKeyword(rawText: string): SupportingDocSuggestion | null {
const t = norm(rawText);
const hit = (docType: string, confidence: number, evidence: string, autoAcceptable = true, fineLabel?: string): SupportingDocSuggestion =>
({ docType, confidence, source: "keyword", autoAcceptable, evidence, fineLabel });
// BUKTI_PENGUMUMAN — union keyword set; keyword-suggest only, NEVER auto-accepted.
if (/PENGUMUMAN/.test(t) && /(PELEBURAN|KONSOLIDASI|PEMBUBARAN|LIKUIDASI|RUPS|RINGKASAN RANCANGAN|KREDITOR|HASIL LIKUIDASI)/.test(t)) {
// _149 = kreditor/pembubaran axis; _152 = hasil likuidasi/pertanggungjawaban axis.
const fineLabel = /(HASIL LIKUIDASI|PERTANGGUNGJAWABAN)/.test(t)
? "BUKTI_PENGUMUMAN_152"
: /(KREDITOR|PEMBUBARAN)/.test(t) ? "BUKTI_PENGUMUMAN_149" : undefined;
return hit("BUKTI_PENGUMUMAN", 0.6, "PENGUMUMAN + axis keyword", false, fineLabel);
}
// LAPORAN_LIKUIDASI before SURAT_LIKUIDATOR (both contain "LIKUID*").
if (/(LAPORAN\s+(AKHIR\s+)?LIKUIDASI|PERTANGGUNGJAWABAN\s+LIKUIDATOR)/.test(t)) return hit("LAPORAN_LIKUIDASI", 0.85, "laporan akhir likuidasi");
if (/SURAT\s+PENUNJUKAN\s+LIKUIDATOR/.test(t)) return hit("SURAT_LIKUIDATOR", 0.85, "surat penunjukan likuidator");
if (/LAPORAN\s+KEUANGAN/.test(t) && /(OPINI|AKUNTAN\s+PUBLIK|NERACA|LABA\s+RUGI)/.test(t)) return hit("LAPORAN_KEUANGAN", 0.8, "laporan keuangan + akuntan/opini");
if (/LAMPIRAN\s+LAPORAN\s+TAHUNAN/.test(t)) return hit("LAMPIRAN_LAPORAN_TAHUNAN", 0.7, "lampiran laporan tahunan", false);
if (/\bPERMOHONAN\b/.test(t) && /(BERAKHIR|HAPUS).{0,40}BADAN\s+HUKUM/.test(t)) return hit("SURAT_PERMOHONAN", 0.6, "permohonan berakhir/hapus badan hukum", false);
return null;
}
/**
* Keyword-first, grounded-LLM fallback. BUKTI_PENGUMUMAN bypasses the LLM entirely.
* LLM outage / null → keep the keyword result (if any) or an operator-assignable
* {docType:"", source:"none", autoAcceptable:false}. The LLM is never trusted to
* auto-accept — it only proposes.
*/
export async function classifySupportingDoc(
rawText: string,
opts?: { llm?: (text: string) => Promise<{ docType: string; confidence: number } | null> },
): Promise<SupportingDocSuggestion> {
const kw = suggestByKeyword(rawText);
if (kw && kw.docType === "BUKTI_PENGUMUMAN") return kw; // keyword-only, no LLM
if (kw && kw.autoAcceptable) return kw; // strong lexical hit — no LLM needed
if (opts?.llm) {
try {
const r = await opts.llm(rawText);
if (r && r.docType) return { docType: r.docType, confidence: r.confidence, source: "llm", autoAcceptable: false, evidence: null };
} catch (err) { console.warn("[supporting-doc-classifier] LLM failed:", err); }
}
if (kw) return kw; // weak keyword suggestion (autoAcceptable:false)
return { docType: "", confidence: 0, source: "none", autoAcceptable: false, evidence: null };
}
- [ ] Run GREEN:
cd backend && bun test src/services/__tests__/supporting-doc-classifier.test.ts. Expected: all pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors. - [ ] Commit:
cd backend && git add src/services/supporting-doc-classifier.ts src/services/__tests__/supporting-doc-classifier.test.ts && git commit -m "feat(flow-engine): supporting-doc-classifier seam (keyword pre-rules + BUKTI_PENGUMUMAN no-LLM)"
Task 13: Full-suite + typecheck gate
Final regression gate — the entire existing test suite plus the new flow-engine suite must pass, and tsc --noEmit must be clean. This slice changes existing flow behaviour by exactly zero.
Files
- None (verification-only).
Interfaces
- Consumes: everything shipped in Tasks 1-12.
- Produces: green build.
Steps
- [ ] Run the full flow-engine suite:
cd backend && bun test src/flow-engine. Expected: all flow-engine tests pass. - [ ] Run the regression-critical existing suites (behaviour must be unchanged):
cd backend && bun test src/services/__tests__/submission-dispatch.test.ts src/services/__tests__/akuisisi-processor.test.ts src/services/__tests__/perbaikan-override-preserve.test.ts src/services/__tests__/cross-validator.test.ts src/services/__tests__/akta-txn-classifier.test.ts. Expected: all pass. - [ ] Run the whole backend suite:
cd backend && bun test. Expected: no NEW failures vs. the pre-slice baseline. (If a pre-existing flaky/env-gated test fails, confirm it fails ongit stashtoo — do not attribute pre-existing failures to this slice; note them in the commit body if any.) - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: zero errors. - [ ] Commit (allow-empty marker if nothing changed):
cd backend && git commit --allow-empty -m "test(flow-engine): full-suite + typecheck gate green for the FLOW_REGISTRY slice"
Self-Review
Spec coverage per section:
- §2 Scope / §4.1 File structure → all modules present: types.ts (T2), registry.ts (T3), context/pt-akta-context.ts (T4), rules/pt-akta/* + rules/akuisisi/koran-window.ts (T5), validation-runner.ts (T6), processor.ts (T7), rematch.ts (T8), fail-gate.ts (T10), flows/__shadow__/* (T11), supporting-doc-classifier.ts (T12). flows/v1-forks.ts from the spec is folded into registry.ts (the stubs ARE the registry values — no separate file needed; noted here so a reviewer isn't surprised by its absence).
- §4.2 Types → T2 (RouteFamily 9-arm union, Rule.severityFloor/async, ProcessorHooks 3 hooks, RematchConfig, ExtractionPlan w/ focusedPasses, FlowConfig w/ companyLookup widening + failGate + policy).
- §4.2 FlowEntry union + totality → T3 (compile-forced Record + runtime totality test).
- §4.3 illustrative entry → shadow configs in T11 exercise the same shape.
- §4.4 runFlow/continueFlow (Phase 1-5, customProcess, afterAktaExtraction log-and-continue, overrideByClassifiedType) → T7.
- §4.5 context/rules/runner/persist (async partition, severityFloor clamp, flatten, drift assert; override-preserving upsert + blanketDelete opt-out) → T4/T5/T6.
- §4.6 reMatchAndValidateFlow (no validation blanket-delete, standardPtMatchers, preValidationSteps, change-type re-detect + mapChangeTypes at THIS site) → T8.
- §4.7 v1/v2 routing (registry check first, ProcessorKind "v2-generic", unreachable throw, registry-derived PERUBAHAN_FAMILY, never-tripwire preserved) → T9.
- §4.8 tuned matrix mechanism (pickRules + requiredDocs + severityFloor) → T5 (pickRules) + T2 (requiredDocs/severityFloor). Per-flow first-cut matrices are explicitly deferred to flow specs (spec §4.8 says "finalized in each flow spec").
- §4.9 six DocumentTypes + classifier strategy → T1 (enum) + T12 (classifier, incl. BUKTI_PENGUMUMAN no-LLM + _149/_152 axis + SURAT_PERMOHONAN keyword advisory).
- §4.10 FAIL gate (block-with-override, SKIPPED/WARNING never block, failGate:'off') → T10.
- §4.11 Frontend none → honored (no FE tasks).
- §8 Testing (1 totality, 2 wrapper fidelity, 3 oracle, 4 persist, 5 dispatch routing, 6 latency, 7 classifier, 8 full regression) → T3, T6, T11, T6, T9, T6, T12, T13.
Placeholder scan: no TBD/TODO/"similar to Task N"; every task carries full test code + full implementation code (or, for the two large lifts T4/context and the fork-modeled T7/T8, exact copy-source line refs + the precise diffs to apply). No tests without code. flows/v1-forks.ts absence justified above.
Type consistency across tasks: RuleResult/ValidationStatus sourced once (T2, re-exported from cross-validator) and consumed by T5/T6/T8/T10/T11. PtAktaCtx defined in T4, consumed by T5/T6/T11. FlowConfig/FlowEntry/flowFor/typesForRouteFamily defined in T2/T3, consumed by T7/T8/T9/T10/T11. runValidations/persistValidationResults defined T6, consumed T7/T8/T11. buildPtAktaContext defined T4, consumed T11 shadows. SupportingDocSuggestion self-contained in T12. Names match between each producer's Interfaces block and every downstream Consumes block.
Resolved spec ambiguities (documented for the executor):
1. flows/v1-forks.ts (spec §4.1) vs. inline stubs in registry.ts — chose inline (the stubs are trivially the registry values; a separate file would be an empty re-export). Noted above.
2. reMatchAndValidate already fetches type mid-function — T9 restructures to fetch-type-first then branch, deleting the later duplicate fetch, so the registry check sits above the untouched if-chain (spec §4.7 shows the check "at the top ... before the existing if-chain").
3. rupsQuorumRule threshold: the underlying body only knows >1/2; the wrapper re-tiers from present/total percentages WITHOUT editing the body (spec §4.5 allows a wrapper factory param; the re-tier reads the same numbers the body reads). Default (no threshold) delegates verbatim.