think
16px
820px

FLOW_REGISTRY — design spec

Status: design for approval (build deferred until the akuisisi flow lands on master).
Goal: make it structurally impossible to add an OCR flow by copy‑forking a
processor — turn "add a flow" into "fill one typed data entry," enforced by the
compiler and by tests, so no author (human, Opus, or Fable) can recreate the
current mess.

Grounded in a full as‑is mapping of all 9 flows (flow-matrix-map workflow,
2026‑07‑02). Every claim below cites the real code.


1. The problem, quantified

The codebase has 9 flows, and several are near‑verbatim copies of each other:

Flow (SubmissionType) Forked from Similarity Evidence
AKUISISI_PT PERUBAHAN_PT 95.5% akuisisi-processor.ts (823 LOC) vs perubahan-processor.ts (796 LOC): only 101 of 1,619 lines differ. A 202‑line ValidationInput block is character‑identical; the 52‑line contact‑info loop diffs 0 lines; processOneDocument is byte‑identical. Real differences: forcePeralihanOn, AKTA_AKUISISI vs AKTA_PERUBAHAN, tanggalKoran, validateAkuisisiKoranWindow — ~40 lines total.
PERBAIKAN_DATA_PP pp‑perubahan ~65‑70% route file has three explicit "cloned from" comments
PERUBAHAN_PP PENDIRIAN_PP ~60‑65% BO‑CRUD "cloned from pp‑pendirian" comment
PERALIHAN_PP_KE_PT pp‑pembubaran + pp‑pendirian ~50% hybrid fork of two route skeletons
PEMBUBARAN_PP PERBAIKAN_DATA_PP ~40% shared pp-konfirmasi.ts machinery

Each flow also owns a separate cross‑validator module
({flow}-cross-validator.ts × 9) and is wired by hand into several dispatch
seams. Adding a flow today means: copy a ~800‑line processor, copy a validator
module, and remember to touch 7 manual seams (below) — miss one and it fails
silently.

The dispatch seams (what the registry replaces)

# Seam File Guarded?
1 processorKindForType services/submission-dispatch.ts never exhaustiveness switch — adding a SubmissionType without a case is a tsc error
2 reMatchAndValidate services/submission-processor.ts:710 unguarded if‑chain — a new type silently falls through to the PENDIRIAN_PT path, which wipes ValidationResults and re‑creates nothing (silent data corruption, no error). Called from 6 sites via queueReMatchAndValidate.
3 inferSubmissionType routes/submissions.ts:87 ❌ hardcoded if‑chain, knows only 4 of 9 types
4 detectUnsupportedAktaType / BUILT_AKTA_TXN_TYPES akta-txn-classifier.ts ❌ manual set
5 validator selection buried in each processor body ❌ no type → validatorFn map anywhere
6 review route + navigate() target frontend/src/routes.tsx + upload pages ❌ static per flow
7 finalize allow‑list routes/submissions.ts:1555 ❌ manual per‑type if (type === …) return 400

Key insight: seam #1 proves the pattern already works — the never switch
in processorKindForType already compiler‑forces one dimension. The registry
generalizes that single good idea to every dimension.


2. What a flow is, as data — the FlowConfig contract

A flow varies along exactly these axes (derived from the mapping). Everything
else is boilerplate that gets copied today.

// contract/flow-config.ts (shape; lives beside the enum SSOT)
interface FlowConfig {
  // ── identity ──
  readonly submissionType: SubmissionType;       // the Prisma enum key (SSOT)
  readonly family: "PT" | "PP" | "APOSTILLE";
  readonly label: string;                        // human name

  // ── ingest ──
  readonly primaryAktaType: DocumentType | null; // "AKTA_AKUISISI" | "AKTA_PERUBAHAN" | null (SP-driven)
  readonly skipExtractionTypes: DocumentType[];  // e.g. AKTA_PEMINDAHAN_HAK
  readonly requiresSabhLookup: boolean;          // adds AWAITING_COMPANY_SELECTION state

  // ── the four behaviors that differ per flow ──
  readonly processor: (submissionId: string) => Promise<void>;      // seam #1
  readonly revalidate: (submissionId: string) => Promise<void>;     // seam #2  ← the dangerous one
  readonly reviewSections: (sub: SubmissionView) => SectionKey[];   // seam #5/6 (dynamic per flow)
  readonly submitGate: (sub: SubmissionView) => Blocker[];          // seam #7

  // ── outward mapping (Phase-1 outbox already exists) ──
  readonly sabhMapping?: (sub: SubmissionView) => OutboxPayload;

  // ── frontend descriptor (mirrors the accepted FrontendReviewDescriptor) ──
  readonly review: {
    readonly routePath: string;                  // "/perubahan/:id/review"
    readonly page: ReviewPageKind;               // which review component
    readonly finalizeRoute: string;              // the flow's own finalize endpoint
  };
}

Notes grounded in the mapping:
- reviewSections must be a function, not a static list: PERUBAHAN computes
appearingSectionKeys(effectiveJenis) at submit time (folds 3 modal PAD items
pad:modal), and this dynamic computation is exactly what prevents the UI/gate
drift that caused regression C1. The registry preserves that.
- submitGate returns a Blocker[] (the perbaikan flow already models blockers
as SECTION_NOT_APPROVED / ROW_NOT_CONFIRMED / ATTESTATION_INCOMPLETE /
UNRESOLVED_FAIL_VALIDATION) — generalize that shape across flows.
- revalidate is the single most important field: today it's the unguarded seam
#2. In the registry it becomes a required field, so a flow without a
revalidator cannot compile.

Cross‑validators stay in their own modules (they're genuinely per‑flow logic);
the registry only needs the entry point (revalidate), not the rules.


3. The mechanism — compiler‑forced FLOW_REGISTRY

// backend/src/flows/registry.ts
export const FLOW_REGISTRY: Record<SubmissionType, FlowConfig> = {
  PENDIRIAN_PT:      pendirianPtFlow,
  PERUBAHAN_PT:      perubahanPtFlow,
  AKUISISI_PT:       akuisisiPtFlow,
  PERBAIKAN_DATA_PT: perbaikanPtFlow,
  PENDIRIAN_PP:      pendirianPpFlow,
  PERUBAHAN_PP:      perubahanPpFlow,
  PEMBUBARAN_PP:     pembubaranPpFlow,
  PERBAIKAN_DATA_PP: perbaikanPpFlow,
  PERALIHAN_PP_KE_PT: peralihanPpKePtFlow,
  APOSTILLE:         apostilleFlow,
};

Because SubmissionType is the Prisma‑generated enum (already the SSOT via
@contract/enums.gen), a Record<SubmissionType, FlowConfig> is a total
map
: adding MERGER_PT to schema.prisma regenerates the enum, and the
Record fails to typecheck until MERGER_PT: mergerFlow is added. You
physically cannot add a flow without declaring all of its behavior as data.

The seams collapse to registry lookups:

// seam #2, the dangerous one — now exhaustive by construction:
export function reMatchAndValidate(id: string) {
  const type = await typeOf(id);
  return FLOW_REGISTRY[type].revalidate(id);   // no fall-through, no silent PENDIRIAN treatment
}
// seam #1, #7, review dispatch, finalize allow-list: same one-line pattern.

This deletes the 7 hand‑maintained seams and, transitively, the ~800‑line
processor forks (they become the processor/revalidate fields, sharing the
202‑line ValidationInput builder instead of copying it).


4. Guardrail layers (defense in depth)

Layer What it enforces Collision with akuisisi When
L1 — recipe + completeness/drift tests ADDING_A_FLOW.md; a test that fails if any SubmissionType lacks a processor/revalidator/review/route; a copy‑fork drift detector (flags two processor files >85% similar) none (tests+docs only) can land anytime
L2 — compiler‑forced FLOW_REGISTRY build fails until a new flow is registered with a full FlowConfig high (imports the forked processors) after akuisisi merges
L3 — generic dispatcher seams #2–#7 route through the registry; the manual if‑chains are deleted high after L2, per‑seam
L4 — shared processor core the 202‑line ValidationInput builder + contact loop + doc loop extracted to ONE helper; perubahan/akuisisi processors shrink to their ~40 real differences highest (rewrites the forks) last, flow‑by‑flow

L1 is the cheap, immediate win. L2–L4 are the real fix but are exactly the
akuisisi‑touching work — hence deferred and coordinated (below).


5. Strangler migration order — akuisisi is case #1

Never big‑bang. One flow at a time, behind an equivalence oracle: the
existing forked processor is the reference. For each flow:

  1. Wrap the flow's current processor/revalidator/gate as its FlowConfig entry
    (no behavior change — just move the function reference into the registry).
  2. Route that type's seam through FLOW_REGISTRY[type].
  3. Run the full suite (the flow's existing tests are the oracle) — byte‑for‑byte
    same behavior.
  4. Only then (L4) extract shared internals and delete the fork.

Order (easiest/highest‑value first):
1. AKUISISI_PT — 95.5% fork of PERUBAHAN, so proving equivalence is trivial
and the payoff (deleting an 800‑line copy) is maximal. It's also your active
work, so it becomes the worked example of "the right way."
2. PERBAIKAN_DATA_PP ← pp‑perubahan (65‑70%).
3. PERUBAHAN_PP ← PENDIRIAN_PP (60‑65%).
4. PEMBUBARAN_PP, PERALIHAN_PP_KE_PT (hybrids).
5. The standalone flows (PENDIRIAN_PT/PP, PERBAIKAN_DATA_PT, APOSTILLE) — register
them (L2) for exhaustiveness; they have little to de‑dupe (L4 minimal).


6. Coordination with the parallel akuisisi work

Your akuisisi flow (branch feat/akuisisi-pt) currently is the 95.5% fork.
Two clean sequencings — recommend A:

  • A (recommended): you finish + merge akuisisi as‑is (a working fork). Then
    the tidy‑up foundation merges. Then akuisisi is migration case #1 — we turn
    it into the first FlowConfig entry and, in doing so, extract the shared core
    that both perubahan and akuisisi use. Akuisisi becomes the proof that the
    registry way is less code, not more.
  • B: build L1 (tests+docs, zero collision) now against master's flows so the
    drift detector is watching before akuisisi merges — it would flag the
    akuisisi↔perubahan 95.5% duplication as a tracked "to be registry‑migrated"
    item rather than a silent fork.

L1 can happen either way; L2–L4 wait for akuisisi to land.


7. ADDING_A_FLOW.md — recipe outline

The doc L1 ships. Outline:

  1. Add the enum value to schema.prismadb:gen-enums (regenerates the
    SubmissionType SSOT). The build now fails until step 2.
  2. Add the FLOW_REGISTRY entry — fill the FlowConfig: processor,
    revalidate, reviewSections, submitGate, review descriptor. The compiler
    lists exactly what's missing.
  3. Reuse, don't copy: import the shared ValidationInput builder, doc loop,
    contact loop. If you're pasting >50 lines from another flow, stop — extract a
    helper (the drift test will fail the PR otherwise).
  4. Cross‑validator: add {flow}-cross-validator.ts only for genuinely new
    rules; reuse shared rules by code.
  5. Frontend: the review descriptor drives the route; no hand‑wired
    navigate().
  6. Tests: a flow isn't done until the completeness test is green (it asserts
    every dimension is wired).

8. What this buys

  • Impossible to copy‑fork silently: the Record<SubmissionType,…> +
    drift test make it a build/CI failure.
  • No more silent seam #2 corruption: exhaustive dispatch replaces the
    fall‑through that wipes validations for unknown types.
  • ~800 LOC deleted per fork at L4 (akuisisi alone).
  • One place to read a flow: its FlowConfig, not 5 scattered files.

Ask: approve this design (and pick sequencing A or B for L1). Build starts
only on your go, after akuisisi lands — no flow code is touched until then.