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 theEXTRACTINGstatus 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.extractionStatusis seededDONE(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.tsresolvestypeof rules === "function" ? rules(ctx) : rulesonce per run.- Deviation — NO
allRulesuniverse is needed (engine-facts-over-spec): the draft assumed delete-stale scoped to the emitted set would strand deselected rules' rows. ReadingpersistValidationResultsshows deletion isnotIn(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). ThedefineFlow-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/start → startApostilleExtraction). 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>;
}>;
runFlowchecks each gate at itsafterpoint: not cleared → writestatus, stop. Resume = the flow's route callscontinueFlow(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 addsAWAITING_CLASSIFICATIONas 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
- 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).
- Implementation order: S1+S2 together (perubahan/perbaikan PP consume both), S3 verification alongside, S4 deferred to Wave 2.
- 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).
- Every seam gets: a unit test (engine behavior), a dormancy test (unset ⇒ unchanged), and — once C1C's
buildPpContextexists — 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").