think
16px
820px

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 RuleResult type, 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 localStorage value; 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.ts refuses 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/137 useMutation, consistent keys), minimal jotai, a readOnly context reused across ~13 files. Foundation‑ready.
  • paddle-ocr-service (273 LOC, one file) — benchmark‑driven, operationally hardened (fail‑loud GPU guard, to_thread offload, 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 exhaustive SubmissionType → processor registry (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.tsakuisisi-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 of perubahan-processor.ts (795 LOC) — the only substantive delta is a 10‑line forcePeralihanOn() 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-pembubaran added a mutableStatuses guard the others lack; pp-peralihan-pt:473 omits the stale‑cleanup entirely.
  • 8 cross‑validators = 6,957 LOC re‑implementing the same runner scaffold; there are even two incompatible validation frameworks (ValidationInput/RuleResult vs RuleContext/RuleInstanceResult) encoding semantically identical rules (saham‑balance, NIK format, PP29 modal) under different names.
  • 8 hand‑rolled review-data DTO 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.ts and use-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.

  1. NO server‑side auth/authz — the #1 blocker. Role is a localStorage jotai atom (lib/role.ts), enforced only by RoleGuard in the browser. CORS is wildcard cors(). Any network client can call any endpoint, self‑assign verifikator, 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.
  2. 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.
  3. reMatchAndValidate silent‑validation‑WIPE — an active legal‑data‑integrity bug (upgrade to top priority). submission-processor.ts:709 is a hand if‑chain with no never check; an unrouted SubmissionType falls through to the generic akta path, which deleteManys ValidationResult then 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.
  4. prisma db push deploy is a data‑loss path, not just missing history — against a diverged schema it can silently drop columns on the legal‑filing store.
  5. SABH MySQL pool is both un‑timed and un‑read‑only on the edit hot path (sabh-db.ts, connectionLimit: 2, connectTimeout only). 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.
  6. 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).
  7. Observability — 0 hits for Sentry/OTel/Prometheus; /api/health returns static {status:'ok'} without touching DB/GPU/external DBs — a probe that always lies.
  8. 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.
  9. 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.
  10. 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.
  11. 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 backend test npm script.
  • Hard test‑DB guard: test-setup.ts throws unless DATABASE_URL ends in /_test$/. Closes the unscoped deleteMany({}) in 12 files — a live data‑loss hazard the moment old & new share an environment.
  • Migration baseline: prisma migrate diff the live schema into a squashed baseline; switch deploy db push → migrate deploy; forbid db push against shared DBs. (Snapshot the true schema with prisma db pull first — 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 reMatchAndValidate onto the exhaustive dispatch registry (never check). 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 (mutableStatuses guard, 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 so apiFetch<T> casts become inferred (coexists with old apiFetch).
  • @hono/zod-validator on request bodies (grow 7 → 47 sites) + validate responses.
  • AppError + codes enum + global app.onError/notFound + jsonError() helper; teach apiFetch to 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 direct process.env readers through it.

Phase 3 — Collapse the backend flow forks (behind the existing dispatch seam)

  • Promote submission-dispatch into a full FlowConfig registry (aktaClassifiedType, docTypes, ruleSet, processorHooks, sections, labels, routeFamily, frontendBase).
  • One generic processor parameterized by FlowConfig + hooks (akuisisi's forcePeralihanOn → a postDetectChangeTypes hook; the 815‑line fork → a ~15‑line config entry).
  • makeFlowRoutes(config) factory for the shared PP endpoint set (pemilik‑manfaat/kbli/sections/finalize/start); adopt field-confirmation.ts across 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 (PerubahanDeltaReviewPage 3,176 LOC, PendirianReviewPage 2,105 LOC) — bbox scroll‑sync, confidence tinting, KBLI/roster confirm, section‑approval gating.
  • Extract the 5 primitives the committed UI audit already names: buildPdfSources, a ValidationRow, an AttestationChecklist, a RegistryEntityCard, and route all steppers through computeFlowStep.
  • Parameterized mutation factory (kills the byte‑identical 85‑line hook duplication); promote the de‑facto shared kit out of components/pendirian/components/perubahancomponents/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 ReviewPage shell fed by the same FlowConfig.

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 107 as unknown as reach‑ins.
  • Split the Submission god‑table into a thin core + per‑flow satellites (PtSubmission, ApostilleSubmission, …) so required‑per‑flow fields become NOT 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-service as‑is (keep its invariants as tests).
  • Rebuild gpu-server's duplication: one run_llm_json() helper + Celery task factory (~700 LOC collapse); move inline main.py/vlm.py prompts into versioned prompts/*.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 /info health 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)

  1. CI + hard test‑DB name guard (closes a live data‑loss hole).
  2. Prisma migration baseline + switch deploy off db push.
  3. Fix reMatchAndValidate (active legal‑data‑integrity bug) by folding it onto the exhaustive registry.
  4. sabh-db.ts timeout + circuit breaker + read‑only (unfixed production wedge risk on the edit hot path).
  5. 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.