think
16px
820px

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). ValidationStatus union is "PASS" | "WARNING" | "FAIL" | "SKIPPED" (NO -ED suffixes). The four new SubmissionTypes are LAPORAN_RUPS_TAHUNAN, PELEBURAN_PT, PEMBUBARAN_PT, BERAKHIRNYA_STATUS_PT (note LAPORAN_, NOT PELAPORAN_). This slice adds NONE of them to the Prisma enum — SubmissionType is unchanged (see below). Carrier akta labels AKTA_RUPS_TAHUNAN/AKTA_PELEBURAN/AKTA_PEMBUBARAN/AKTA_BERAKHIRNYA already exist in AKTA_TXN_TYPE_TO_LABEL.
  • The six additive DocumentType values (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 EXISTING DOMISILI value — there is NO SURAT_PERNYATAAN_DOMISILI enum value (that is a classification label mapping onto DocumentType.DOMISILI). SURAT_PERMOHONAN carries a schema doc-comment distinguishing it from the existing SURAT_PERMOHONAN_KORPORASI.
  • NO SubmissionType enum changes in this slice. No BUILT_AKTA_TXN_TYPES change. No inferSubmissionType change. No frontend change. The FLOW_REGISTRY ships with all ten existing types as v1-fork stubs and ZERO v2-generic entries. Each future flow spec adds its own SubmissionType + registry entry + ProcessorKind case.
  • Wrap rules bit-for-bit — NEVER edit validator bodies. Every rule in PT_AKTA_RULE_SET is { code, label, run: (ctx) => validateX(ctx.input) } calling the UNCHANGED exported function from cross-validator.ts. Legal logic and severities live in the bodies; tuning is selection (pickRules) + severityFloor clamps only.
  • ALL existing flows stay engine:'v1-fork' untouched. PENDIRIAN_PT, PERUBAHAN_PT, AKUISISI_PT, PERBAIKAN_DATA_PT, all PP flows, APOSTILLE keep their forks. The legacy dispatch switch and the reMatchAndValidate if-chain are the ORACLE — do not "clean them up".
  • FAIL gate = block-with-override, reusing ValidationResult.overridden. For v2-generic flows the engine blocks finalize when any row has status === "FAIL" && overridden !== true. SKIPPED and WARNING NEVER block regardless of overridden. No new table. The pp-perubahan.ts:1538 gate is the reference pattern. Opt-out only via FlowConfig.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 ValidationResult rows + @@unique([submissionId, ruleCode]) the forks use. Keep the as any JSON reach-ins for oldData/changeTypeResult verbatim (codecs are the persistence slice's job).
  • Import discipline / circular-dep hygiene. flow-engine/* may import services/* but NEVER routes/*. submission-dispatch.ts and submission-processor.ts import flow-engine DYNAMICALLY (await import(...)), same as the existing dynamic switch.
  • Async partition is load-bearing. PP29_MODAL / KBLI_VALID / PERUBAHAN_KBLI hit SABH and MUST run via Promise.all (rule.async: true); a naïve sequential runner triples field-edit latency.
  • Test-DB isolation. All DB tests target ahu_ocr_test via the bunfig preload. NEVER run bun test against the dev DB. Run bun run db:push:test after 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.prismaenum 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 are undefined on the generated enum.
  • [ ] Minimal implementation. Edit backend/prisma/schema.prisma, appending inside enum DocumentType { ... } right after APOSTILLE_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 of runPerubahanCrossValidation from backend/src/services/perubahan-processor.ts lines 268-628 (the submission fetch through the input object literal), then apply exactly these three edits so it becomes a parameterized builder:
    1. Wrap it as export async function buildPtAktaContext(submissionId: string, opts: { aktaClassifiedType: string; tanggalKoranFieldKey?: string }): Promise<PtAktaCtx> and change the header to fetch by submissionId (the perubahan body already uses submissionId).
    2. Replace the akta selector const aktaSubDoc = submission.documents.find((d) => d.classifiedType === "AKTA_PERUBAHAN") with const aktaSubDoc = submission.documents.find((d) => d.classifiedType === opts.aktaClassifiedType).
    3. Replace the tanggalKoran: null, line in the input literal with tanggalKoran: opts.tanggalKoranFieldKey ? (aktaFields.find((f) => f.fieldKey === opts.tanggalKoranFieldKey)?.value ?? null) : null,.
    4. If !submission, throw new Error("submission not found") instead of return (a builder must not silently return void).
    Then REMOVE the trailing runAllValidations + RUPS-quorum-push + persist block (lines 630-670 in the donor) and instead return:
  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 a code/label mismatch 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, extend EMPTY_INPUT with 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 with cfg.* and inlining processOneDocument (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.tsProcessorKind (L4-14), processorKindForType (L23-51), dispatchProcessor (L59-117).
- backend/src/services/submission-processor.ts — top of reMatchAndValidate (L710).
- backend/src/routes/perubahan.tsPERUBAHAN_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. Edit ProcessorKind (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 on git stash too — 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.