think
16px
820px

Flow-engine seams for non-akta tenants (PP family + Apostille) — C1B.1 spec

Date: 2026-07-09 · Status: S1+S2 IMPLEMENTED dormant-by-default (2026-07-09; no tenant consumes them until C1C/C1D, which stay gated on the C0.1 smoke). S3 = verify-only, pending C1C. S4 = design-only, implemented with Apostille.
Context: docs/superpowers/plans/2026-07-09-migration-implementation-phases.md Phase 1B. Ruling in force: "all PP flows" = Full A+B — every PP fork processor must eventually delete in favor of v2-generic tenancy.


0. Problem

The generic engine (flow-engine/processor.ts + FlowConfig) is PT-akta-shaped. Reading all five PP processors end-to-end (2026-07-09 exploration) found four structural mismatches that today only customProcess could absorb — which "largely defeats the engine's purpose" (a total escape hatch: registry membership without shared pipeline). These four seams make PP flows first-class tenants instead. S1/S2 also serve Apostille (Wave 2); S4 is designed here and implemented with Apostille, its second consumer, after Peleburan's pause becomes its first internal consumer.

Non-goals (deliberately NOT seams):
- Konfirmasi/expiry lifecycle (MENUNGGU_KONFIRMASI → 7-day lazy expiry → KEDALUWARSA): a finalize handshake, not processing. The engine's job ends at READY; konfirmasi stays route-owned (precedent: verifikator staging vs terminal flows both live outside runFlow). The ReviewData projectors already surface it (meta.expiresAt, tenant #2/#6).
- BO document-driven CRUD (/pemilik-manfaat/from-document upload-parse-append): interactive route-side behavior between pipeline runs, not a pipeline stage. Stays in routes.
- Jenis/section selection (the multi-select step): pre-pipeline user input. Stays route-owned; the engine only reads selectedJenisPerubahan.


1. S1 — hooks.seedDocuments (synthetic carrier support)

Consumer evidence: pp-perubahan-processor.ts:88-112 and its perbaikan clone synthesize a PP_*_CARRIER document from the oldData registry snapshot and write LOCKED scalar fields before anything else runs. runFlow has no pre-extraction seam.

Design:

interface ProcessorHooks<Ctx> {
  /** Runs FIRST in runFlow, before the extraction loop — synthesize/refresh
   *  carrier documents + LOCKED snapshot scalars. Idempotent by contract
   *  (re-dispatch must not duplicate carriers). */
  seedDocuments?(submissionId: string): Promise<void>;
  // ... existing hooks unchanged
}
  • Call site: top of runFlow, inside the try, before the EXTRACTING status write (a seed failure = config/data bug → LOUD error path, not log-and-continue: without the carrier the whole flow is meaningless).
  • Dormancy: unset ⇒ byte-identical behavior (parity re-run on all current tenants proves it).
  • The carrier doc must be excluded from extraction: its SubmissionDocument.extractionStatus is seeded DONE (the PP processors already do this) — no engine special-case needed.

2. S2 — context-gated rules (rules as function)

Consumer evidence: ruleCodesForJenis (pp-jenis.ts:203) — the active rule subset depends on the user's jenis selection; perbaikan mirrors with string section keys. FlowConfig.rules is a static array.

Design (AS IMPLEMENTED 2026-07-09 — deviation from the first draft recorded):

rules: Rule<Ctx>[] | ((ctx: Ctx) => Rule<Ctx>[]);
  • validation-runner.ts resolves typeof rules === "function" ? rules(ctx) : rules once per run.
  • Deviation — NO allRules universe is needed (engine-facts-over-spec): the draft assumed delete-stale scoped to the emitted set would strand deselected rules' rows. Reading persistValidationResults shows deletion is notIn(emitted) — a deselected rule emits nothing, so its old row is removed automatically on the next run. Test-pinned (seams-s1-s2.test.ts: deselect-B → B's row gone). The defineFlow-time guard is therefore also unnecessary.
  • Static-array tenants: nothing changes (dormancy test-pinned; two config-assertion fixtures narrowed for the union type).

3. S3 — post-edit re-derive (verify, likely zero new surface)

Consumer evidence: PP flows re-run rematchBoEntries + syncBoChecklist on identity edits. RematchConfig.preValidationSteps already exists and reMatchAndValidateFlow runs it (step 3).

Design: no new type surface expected. Work item = verification: (a) confirm the generic field-edit path routes PP tenants through reMatchAndValidate → registry → reMatchAndValidateFlow (it dispatches v2-generic first — submission-processor.ts:720-724); (b) confirm preValidationSteps: [rematchBoEntries, syncBoChecklist] reproduces the fork's edit-path behavior; (c) extend the C0.2 parity oracle with a PP leg once the PP context (C1C) exists. If the edit path proves to fire only a subset of rematch, add rematch.onFieldEdit?: (submissionId, docId, fieldKey) => Promise<void> — but only with a red oracle as evidence.

4. S4 — pause/resume gates (design now, implement with Apostille)

Consumer evidence: Apostille parks at AWAITING_CLASSIFICATION between classify and extract (apostille-processor.ts:284, resume = POST /:id/startstartApostilleExtraction). Peleburan's AWAITING_COMPANY_SELECTION branch in runFlow is the same shape, currently inlined (and since C0.3, already hook-dispatched for its resolver).

Design sketch:

gates?: Array<{
  /** Where in the pipeline the gate sits. */
  after: "extraction" | "lookup";
  /** Submission.status to park in when not cleared. */
  status: SubmissionStatus;
  isCleared(submissionId: string): Promise<boolean>;
}>;
  • runFlow checks each gate at its after point: not cleared → write status, stop. Resume = the flow's route calls continueFlow (exactly peleburan's contract today).
  • Migration: peleburan's multi-source pause becomes gates: [{ after: "extraction", status: "AWAITING_COMPANY_SELECTION", isCleared: !hasUnresolved }] — first internal consumer, parity-proven; apostille adds AWAITING_CLASSIFICATION as the second.
  • NOT implemented in C1B (no PP consumer). Recorded here so S1/S2 don't paint the engine into a corner.

5. Sequencing, gates, oracle discipline

  1. All seams land dormant-by-default; every existing tenant must be byte-identical (full parity suite re-run per seam — C0.2's six-surface oracle is the bar).
  2. Implementation order: S1+S2 together (perubahan/perbaikan PP consume both), S3 verification alongside, S4 deferred to Wave 2.
  3. Gate: per the phases plan, no PP tenant flips until C0.1 (Efran smokes Peleburan + Laporan RUPS) is green. Seam implementation is additive and could proceed ahead of the smoke — flagged as an open call for Efran (deviating from the plan's letter, not its intent, since dormant seams don't ride the unproven engine paths).
  4. Every seam gets: a unit test (engine behavior), a dormancy test (unset ⇒ unchanged), and — once C1C's buildPpContext exists — a PP leg in the parity oracle.

6. What C1C consumes from this spec

buildPpContext (per the exploration): submission + docs + identityMatches + ordered pemilikManfaatEntries + founder-BO matched-doc ids + field maps (SP/carrier, owner KTP/NPWP) + KBLI rows/master set + parsed modal + name-availability + oldData as PpRegistrySnapshot + resolveOwnerDocId. The 39 PP rule bodies already return the engine's RuleResult — wrappers project scattered args from PpCtx; jenis-gating rides S2; carrier seeding rides S1; Pembubaran PP additionally needs its standalone reprocess entrypoint (dispatch gap, currently "none").