AHU OCR PoC — Jankiness Assessment & Tidy‑Up Plan
Date: 2026‑07‑01
Scope: Full stack — backend (Bun/Hono), frontend (React/Vite), Prisma/Postgres, Python OCR/GPU services.
Method: 13‑agent parallel assessment (9 dimension audits + completeness critic + 3‑way strategy panel), reading real code with file:line evidence.
Constraints that shaped the recommendation: goal = build the foundation for the officially‑launching project; greenfield migration acceptable; ownership stays AI‑driven (solo + Claude); optimize for AI‑navigability + guardrails.
This document changes no code. It is an assessment and a plan.
0. TL;DR
- How janky, honestly: moderately‑to‑notably janky, but disciplined. This is not spaghetti — it's one architectural decision (copy‑fork‑per‑flow) repeated ~10 times, plus the hygiene/reproducibility gaps you'd expect from a fast PoC. The author clearly knows the right patterns (an exhaustive dispatch registry, a shared
RuleResulttype, DB‑level append‑only audit triggers, an injection‑safe external‑DB layer all exist) — they just weren't applied uniformly under many‑iterations‑per‑day pressure. - The single biggest thing the audit scope itself missed: there is no server‑side authentication or authorization anywhere. Roles are a
localStoragevalue; CORS is wildcard; any network client can approve a legal filing or download any citizen's KTP/akta PDF by id. For a system feeding SABH this is the #1 foundation blocker, above all the code‑structure debt. - Recommendation: Hybrid strangler — refactor‑in‑place for ~70% of the debt (the forked orchestration/rules), with two targeted greenfield‑in‑place rebuilds (the frontend review engine, and the JSON‑blob/god‑table persistence), all sequenced strictly after a safety scaffold. Not a clean‑room rewrite in a new project; not pure in‑place either. Stay in the current repo behind the seams that already exist.
- Why not a full recode into a new directory: the per‑flow test suites are an equivalence oracle that a rewrite throws away exactly where the code encodes legally‑consequential thresholds; and the config‑driven collapse is cheaper and lower‑risk done in‑tree. A separate greenfield tree only pays off if you both (a) exploit from‑scratch freedoms and (b) port legal rules verbatim behind golden fixtures — and even the greenfield advocate conceded an in‑place refactor "could bank ~70% of the correctness win at lower risk."
1. How janky is it? — Scorecard
Jankiness: 1 = clean/foundation‑ready … 5 = severe/blocking.
| # | Dimension | Score | One‑line verdict |
|---|---|---|---|
| 1 | Architecture & per‑flow duplication | 4/5 | Real & large, but disciplined copy‑paste; seams to collapse it already exist |
| 2 | God‑files & module boundaries | 4/5 | Genuine 2.5k–3.2k‑line files mixing routing/DB/DTO/LLM concerns |
| 3 | Type system & contracts | 3/5 | strict + zero @ts-ignore (good) but the contract half is absent: 405 unchecked network casts, 3 live enum drifts |
| 4 | Test suite (as migration safety net) | 2/5 | The healthiest layer — behaviour‑focused, 0 snapshots; but the extraction core is untested and isolation is fragile |
| 5 | Data model & external integrations | 3/5 | Coherent schema, but flow‑accreted god‑table, migrations abandoned since May, fragmented DB pools |
| 6 | Frontend architecture | 4/5 | Clean router/query/UI‑kit; but 8 forked review pages (~9.4k LOC), two live UI generations |
| 7 | Python OCR/GPU services | 3/5 | paddle-ocr-service is excellent; gpu-server is heavy copy‑paste; split‑brain TS↔Python contract |
| 8 | Repo hygiene, build, deploy, config | 3/5 | Secrets narrowly safe (gitignored) but db push deploy, no CI, 98 unvalidated env vars |
| 9 | Cross‑cutting consistency & fragility | 4/5 | "Update N places or it silently breaks"; error contract is aspirational; one active data‑integrity footgun |
Calibration caveat (from the critic): dimensions 1, 2, and 9 all score 4/5 largely because of the same phenomenon — copy‑fork‑per‑flow. Don't read this as three independent structural problems. Collapsing the flow registry fixes all three at once. The "true" independent‑problem count is smaller than nine 3‑and‑4s suggest.
Genuine strengths worth preserving (calibrates the recode temptation)
- DB‑level append‑only audit trigger on
VerifikasiAuditEvent;index.tsrefuses to boot if it can't install the guards. Real integrity engineering. - Injection‑safe external‑DB layer — regex‑validated identifiers, range‑checked shards, fully parameterized values across
sabh-db.ts,pp-registry-db.ts,pp-wilayah-resolve.ts. Port verbatim. - Clean frontend state layer — one centralized typed router, TanStack Query as the de‑facto store (182
useQuery/137useMutation, consistent keys), minimal jotai, areadOnlycontext reused across ~13 files. Foundation‑ready. paddle-ocr-service(273 LOC, one file) — benchmark‑driven, operationally hardened (fail‑loud GPU guard,to_threadoffload, magic‑byte detection, adaptive reading order). Lift as‑is.- Behaviour‑focused tests — ~1,596 backend cases, 0
toHaveBeenCalled, 0 snapshots; verdict/HTTP‑contract assertions. This is the migration safety net. submission-dispatch.ts— already a compile‑time exhaustiveSubmissionType → processorregistry (const _exhaustive: never). The pattern to finish exists.
2. The core finding — copy‑fork‑per‑flow
Each new legal flow (PT/PP/Apostille × pendirian/perubahan/perbaikan/pembubaran/peralihan/akuisisi) was shipped by cloning the nearest sibling's whole route + processor + cross‑validator + diff + frontend page + hook set and applying small diffs. The akuisisi implementation plan even formalizes it: "copy perubahan-processor.ts → akuisisi-processor.ts verbatim, then apply exactly these diffs."
The evidence (all verified with diffs/greps):
akuisisi-processor.ts(815 LOC) is a ~98.5% verbatim fork ofperubahan-processor.ts(795 LOC) — the only substantive delta is a 10‑lineforcePeralihanOn()helper + a literal swap. ~780 shared lines now live in two files.- The validation persist‑loop (
deleteMany‑notIn+upsert+status→READY, ~23 lines) is copy‑pasted verbatim into 10 files — one even comments "verbatim from pp‑perubahan‑cross‑validator.ts". The copies have already silently diverged:pp-pembubaranadded amutableStatusesguard the others lack;pp-peralihan-pt:473omits the stale‑cleanup entirely. - 8 cross‑validators = 6,957 LOC re‑implementing the same runner scaffold; there are even two incompatible validation frameworks (
ValidationInput/RuleResultvsRuleContext/RuleInstanceResult) encoding semantically identical rules (saham‑balance, NIK format, PP29 modal) under different names. - 8 hand‑rolled
review-dataDTO builders (50–714 LOC each) re‑solve the same read‑model per flow. - 8 forked frontend review pages (~9,400 LOC) with no shared engine; a byte‑identical 85‑line mutation block appears in two PP hooks.
- A byte‑identical 85‑line hook block verified across
use-pp-pendirian-review.tsanduse-pp-perubahan-review.ts.
Consequence: the maintenance tax is O(N flows). A single fix — a transaction around the persist loop, a new date format, a DJP NPWP‑scheme change — is a 3‑to‑10‑file edit, and missing one copy is a silent semantic (sometimes legal) divergence, not a compile error.
Why this is tractable, not fatal: the shared substrate already exists (leaf services imported 6–15×, one RuleResult type, the exhaustive dispatch registry). The job is "finish the seam that exists," not "invent one." That's the crux of the refactor‑vs‑recode call.
3. The dangerous stuff the 9‑dimension lens under‑weighted (from the completeness critic)
These are not style debt — several are launch‑blocking or active bugs, and the first was outside the original 9 dimensions entirely.
- NO server‑side auth/authz — the #1 blocker. Role is a
localStoragejotai atom (lib/role.ts), enforced only byRoleGuardin the browser. CORS is wildcardcors(). Any network client can call any endpoint, self‑assignverifikator, approve/reject filings, and download any citizen's KTP/akta PDF by id (documents.ts:519, no authz). The verifikator approval gate — the human control that legitimizes a filing before SABH — is cosmetic. - Secrets blast radius. The on‑disk
.env(correctly gitignored) holds live prod credentials for SABH MySQL, the Rebuild‑PP Postgres (192.168.80.127:5434), and Apostille MariaDB. An unauthenticated backend holding network‑reachable prod master‑system creds is a pivot into those prod networks. No vault, no rotation. reMatchAndValidatesilent‑validation‑WIPE — an active legal‑data‑integrity bug (upgrade to top priority).submission-processor.ts:709is a hand if‑chain with nonevercheck; an unroutedSubmissionTypefalls through to the generic akta path, whichdeleteManysValidationResultthen early‑returns without recreating them — on every field edit, fired fire‑and‑forget with.catch(console.error). Its exhaustive twin (dispatchProcessor) proves the fix is trivial.prisma db pushdeploy is a data‑loss path, not just missing history — against a diverged schema it can silently drop columns on the legal‑filing store.- SABH MySQL pool is both un‑timed and un‑read‑only on the edit hot path (
sabh-db.ts,connectionLimit: 2,connectTimeoutonly). This is the exact connection‑exhaustion wedge that already froze apostille edits — fixed there, unfixed here, and it sits on the per‑field‑edit revalidation path. - LLM/extraction non‑determinism unassessed — workers run temp 0.1–0.3 with no seed, so the same document extracts differently on cache‑miss/retry (and a golden‑fixture net would be flaky unless this is fixed).
- Observability — 0 hits for Sentry/OTel/Prometheus;
/api/healthreturns static{status:'ok'}without touching DB/GPU/external DBs — a probe that always lies. - DoS / resource exhaustion — no rate limiting; backend upload endpoints have no size cap or MIME allowlist (only the GPU service caps size). Combined with no‑auth + wildcard CORS, the GPU queue and dev‑box disk are trivially floodable.
- Backup / DR of the audit store — the product's stated purpose is "store for audit," yet data lives in a single dev‑box Postgres with zero backup/PITR. Losing the box loses the audit record.
- PII / UU PDP No.27/2022 lifecycle — 5,482 real citizen KTP/akta PDFs at rest, unencrypted, no retention, served by id without authz. A compliance exposure, not disk hygiene.
- SABH REST write‑back seam — correctly out‑of‑scope to build, but the data model has no foundation for it: no outbox, no submission→external‑transaction mapping, no idempotency key. It will be the highest‑risk future work.
Overstated / de‑prioritize (also from the critic): SQL‑injection in the DB layer (it's disciplined — don't flag it); the 97k‑LOC committed Prisma client (real noise but a one‑line .gitignore/exclude fix, shouldn't weigh on the recode decision).
4. Refactor vs Recode — the decision
Three strategies were evaluated by independent panelists fed the full findings digest. All three converged on the same shape.
| Strategy | Verdict from its own advocate |
|---|---|
| Refactor‑in‑place (hybrid) | "Right call as the primary strategy, but only as an explicit hybrid — absorbing two targeted greenfield rebuilds (frontend review engine, JSON‑blob persistence)." |
| Greenfield recode | "Right call — but only scoped as strangler‑fig‑with‑verbatim‑rule‑lift, not a clean‑room rewrite… an aggressive in‑place refactor could bank most of the correctness win at lower risk." |
| Hybrid strangler | "Right call, but conditionally — green‑light only if the safety scaffold + reMatch fix + test‑DB guard are non‑negotiable prerequisites and there's a hard rule against forking in the new tree." |
Recommendation: Hybrid strangler, in‑tree
Refactor‑in‑place the forked orchestration and rules (they collapse onto seams that already exist, and the per‑flow tests prove parity flow‑by‑flow). Greenfield‑in‑place the two genuinely structural slices — the config‑driven frontend review engine, and the typed persistence replacing the Submission god‑table + 42 JSON‑blob columns. Everything sequenced after a safety scaffold.
Why this over a full recode into a new project:
- Audit‑only data makes parallel run free — new filings go to the new path, old submissions stay in the legacy DB; no back‑fill, no dual‑write reconciliation. This is the decisive de‑risker, and it works in‑tree too.
- The per‑flow test suites are an equivalence oracle. You can introduce a generic processor/validator behind the existing dispatch seam, keep the old fork live, and prove bit‑for‑bit parity before deleting. A clean‑room rewrite throws that net away exactly where the code encodes legally‑consequential thresholds (PP29 modal minimums, bukti‑setor totals, NIK matching, BO gates).
- AI‑solo ownership favors many small, context‑window‑sized units over one monolithic rewrite that can't be held in context — which is also why the god‑files must be broken regardless of path.
- The clean layers port verbatim either way (router, query layer, ui kit,
paddle-ocr-service, prompt files, bbox matcher, leaf services), so a recode would mostly discard low‑value boilerplate — meaning the recode premium is small while its risk (re‑deriving legal rules cold, reverse‑engineering 42 untyped blobs) is large.
The decision hinges on discipline, not diagnosis. Every panelist named the same failure mode: "two architectures forever." The strangler only works if (a) the safety scaffold lands first, and (b) adoption is forced by the compiler/tests — otherwise new forks keep appearing (proof: field-confirmation.ts is adopted by only 1 of 6 confirm endpoints today). If you can't commit to adoption‑forcing guardrails, the honest fallback is a greenfield‑in‑parallel tree for the structural slices only.
5. The tidy‑up plan (phased)
Sequencing principles: safety scaffold first · active footguns early · adoption‑forcing tests to prevent stall · delete superseded generations last. Each phase is independently valuable and shippable.
Phase 0 — Safety scaffold (non‑negotiable prerequisites; ship nothing structural until green)
- CI: Postgres service +
db:push:test+bun test+vitest run+tsc --noEmit(both packages) on every push. Add the missing backendtestnpm script. - Hard test‑DB guard:
test-setup.tsthrows unlessDATABASE_URLends in/_test$/. Closes the unscopeddeleteMany({})in 12 files — a live data‑loss hazard the moment old & new share an environment. - Migration baseline:
prisma migrate diffthe live schema into a squashed baseline; switch deploydb push → migrate deploy; forbiddb pushagainst shared DBs. (Snapshot the true schema withprisma db pullfirst — migrations are frozen since 2026‑05‑04.) - Gitignore
backend/src/generated(regenerated in the Dockerfile anyway) — halves the ~28s typecheck, de‑noises AI navigation.
Phase 0b — Security workstream (parallel, high priority — the real launch blockers)
- Server‑side authentication + session/token + server‑enforced roles; delete the client‑only role model.
- CORS allowlist; authz on document/PDF serving; upload size cap + MIME allowlist; basic rate limiting.
- Secrets: move to injected‑only (
env_file/secrets manager), plan rotation. - PII lifecycle for
uploads/(retention + access control + encryption) and backup/PITR for the audit Postgres.
Phase 1 — Kill active footguns + bank zero‑behaviour‑change wins (highest ROI, week one)
- Unify
reMatchAndValidateonto the exhaustive dispatch registry (nevercheck). New flow = compile error, not a silent validation wipe. Stop swallowing revalidation errors. - Extract
persistValidationResults(...)and delete the ~23‑line loop from all 10 sites — reconciling the divergences (mutableStatusesguard, missing stale‑cleanup) into one guarded, transactional impl. - Extract
runValidations(submissionId, ruleSet)so each cross‑validator shrinks to its rule array. - Move forked domain rules (
parseBirthDate/INDO_MONTHS, NPWP normalization, name‑match threshold) into shared modules. - Add hard timeout + circuit breaker to
sabh-db.ts(reuse the apostille‑proven pattern) + read‑only enforcement. - Make prompt‑hash mandatory in gpu‑server
make_cache_key— fixes stale extractions on 9 of 12 task types. - Prove each change with the existing per‑fork suites.
Phase 2 — The contract spine (cross‑cutting, mostly additive)
- Re‑export Prisma enums as the single source → instantly kills the 3 live enum drifts (
DocumentType/SubmissionStatus/FieldStatus). - Typed client: adopt Hono RPC (
hc<AppType>) per‑route soapiFetch<T>casts become inferred (coexists with oldapiFetch). @hono/zod-validatoron request bodies (grow 7 → 47 sites) + validate responses.AppError+ codes enum + globalapp.onError/notFound+jsonError()helper; teachapiFetchto preserve{code, blockers, unapprovedSections, ...}so the FE can finally branch on failure class.- Structured logger threading the already‑minted
requestId; zod env schema that fails fast at boot; route the 21 directprocess.envreaders through it.
Phase 3 — Collapse the backend flow forks (behind the existing dispatch seam)
- Promote
submission-dispatchinto a fullFlowConfigregistry (aktaClassifiedType,docTypes,ruleSet,processorHooks,sections,labels,routeFamily,frontendBase). - One generic processor parameterized by
FlowConfig+ hooks (akuisisi'sforcePeralihanOn→ apostDetectChangeTypeshook; the 815‑line fork → a ~15‑line config entry). makeFlowRoutes(config)factory for the shared PP endpoint set (pemilik‑manfaat/kbli/sections/finalize/start); adoptfield-confirmation.tsacross all 6 confirm endpoints.- Extract
buildReviewData(submission, flowConfig)— one backend read‑model, deleting the 8 hand‑rolled DTO builders (stabilizes the FE contract). - Prove the engine on a new flow first (e.g. Pelaporan RUPS from the roadmap) before touching any legally‑consequential existing one. Then migrate existing flows one at a time; at each collapse, explicitly adjudicate the silently‑diverged fork behaviors and record which is canonical.
Phase 4 — Frontend review engine (tests before touch)
- Write characterization/page‑level tests for the two untested mega‑pages (
PerubahanDeltaReviewPage3,176 LOC,PendirianReviewPage2,105 LOC) — bbox scroll‑sync, confidence tinting, KBLI/roster confirm, section‑approval gating. - Extract the 5 primitives the committed UI audit already names:
buildPdfSources, aValidationRow, anAttestationChecklist, aRegistryEntityCard, and route all steppers throughcomputeFlowStep. - Parameterized mutation factory (kills the byte‑identical 85‑line hook duplication); promote the de‑facto shared kit out of
components/pendirian/components/perubahan→components/review/*with an explicit boundary. - Add the ERROR‑status early‑return to the 6 pages that render blank‑on‑OCR‑error (audit H1); unify onto one confidence‑tier table (audit H2).
- Collapse the 8 pages onto one config‑driven
ReviewPageshell fed by the sameFlowConfig.
Phase 5 — Structural greenfield‑in‑place slices (in‑tree, behind migrations + dual‑write)
- Typed persistence: replace JSON‑blob columns (
oldData,companyLookupResult,rawExtractionJson, …) with zod‑validated typed snapshot/dedicated tables → erases the 107as unknown asreach‑ins. - Split the
Submissiongod‑table into a thin core + per‑flow satellites (PtSubmission,ApostilleSubmission, …) so required‑per‑flow fields becomeNOT NULL. - Centralize the HITL quintet + raw‑OCR trio (currently copy‑pasted across ~10 / 6 models).
- Add a typed audit/outbox seam (submission→external‑transaction mapping + idempotency) so the future SABH REST push has a real foundation.
Phase 6 — Python / infra hardening (independent, low‑risk, parallelizable)
- Lift
paddle-ocr-serviceas‑is (keep its invariants as tests). - Rebuild
gpu-server's duplication: onerun_llm_json()helper + Celery task factory (~700 LOC collapse); move inlinemain.py/vlm.pyprompts into versionedprompts/*.txt; delete ~1,100 LOC dead training code + the always‑failing KTP job path. - Formalize the TS↔Python contract — generate TS types from the pydantic response models; one shared
gpuJob<Req,Res>submit/poll helper (replaces ~8 hand‑rolled clients). - Reproducibility: lockfiles for every Python service; pin vLLM/PaddleOCR image + model tags (no moving
latest); unify the 4 divergent model‑name configs to one env source; make the code default equal the deployed model. - Version the external LayoutLMv3 classifier + forensic services behind an
/infohealth contract the backend asserts at boot, so a relabel fails loudly. - Fix LLM determinism (seed / temp 0 where extraction should be reproducible) and build the extraction golden‑fixture harness (real OCR JSON → expected fields) — the one thing the current test suite lacks and the riskiest path to migrate.
Delete last
The ~5,400 LOC legacy per‑document verification generation (components/verification + VerifyPage/VerificationPage + use-document + use-bounding-boxes) is still routed and reachable. Quarantine → delete only after flows are confirmed live on the new engine.
6. Effort & risk
- Effort: ~8–14 agent‑weeks, heavily front‑loaded. Phases 0–2 (~2–3 weeks) bank most of the correctness/safety value; the generic engine + review engine (Phases 3–4) is the real design work; Phase 5 is the largest/riskiest chunk. Of ~177k typecheck LOC, ~97k is generated Prisma (excluded); of the human‑authored ~80k, ~30–45% of the route/validator/processor volume is accidental duplication that collapses rather than being rewritten.
- Top risks & mitigations:
- Strangler stall → "two architectures forever." Mitigation: adoption‑forcing guardrails (exhaustive dispatch, FE/BE section‑key equality tests), a hard rule against forking in the new path, finish flow‑by‑flow.
- Canonicalizing a silently‑diverged fork's wrong behavior (parity‑to‑old ≠ correctness). Mitigation: explicit, recorded adjudication at each collapse.
- Extraction core has ~zero automated coverage. Mitigation: build golden fixtures before touching prompts/persistence.
- Test‑DB data loss during parallel run. Mitigation: the Phase‑0 name‑guard is a prerequisite, not a nice‑to‑have.
7. Do this week — the no‑regret moves (valuable under any path)
- CI + hard test‑DB name guard (closes a live data‑loss hole).
- Prisma migration baseline + switch deploy off
db push. - Fix
reMatchAndValidate(active legal‑data‑integrity bug) by folding it onto the exhaustive registry. sabh-db.tstimeout + circuit breaker + read‑only (unfixed production wedge risk on the edit hot path).- Auth spike — even a minimal server‑side token + role check + CORS allowlist + PDF authz, because the human approval gate is currently cosmetic.
None of these presuppose refactor vs recode; all five are pure wins that also unblock the strangler.
Generated from a 13‑agent assessment (9 dimension audits + completeness critic + 3‑strategy panel). Full per‑dimension findings with file:line evidence are available on request.