think
16px
820px

ReviewData Engine + PP-Pendirian Vertical Slice — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the normalized ReviewData read-model end-to-end — compiler-forced review registry + one GET /api/submissions/:id/review endpoint + the frontend ReviewEngine — proven by migrating PENDIRIAN_PP as the first tenant behind a parity oracle.

Architecture: Backend: a REVIEW_REGISTRY: Record<SubmissionType, ReviewProjection | null> (separate from flow-engine's FLOW_REGISTRY — review migration is orthogonal to processing migration; a flow can migrate its review while staying a v1-fork processor) feeds one generic route that validates responses against contract/review.ts zod schemas outside production. Frontend: createReviewHook parses with the same schemas; <ReviewEngine> walks sections[] and dispatches by kind to renderers. Legacy endpoints/pages stay until parity + Efran's smoke test (strangler).

Tech Stack: Bun + Hono + Prisma (backend), zod v4 (contract/review.ts — already built + tested), React 19 + TanStack Query + vitest (frontend).

Global Constraints

  • Spec: docs/specs/2026-07-03-reviewdata-read-model-design.md (APPROVED). VM shapes there are law; the contract schemas in contract/review.ts already encode them — do not modify contract/review.ts in this plan.
  • Do NOT touch the un-smoke-tested new PT flows: no edits under backend/src/flow-engine/, services/akuisisi-processor.ts, routes/peleburan.ts, routes/laporan-rups.ts, or their frontend pages.
  • Do NOT modify legacy behavior: GET /api/pp/pendirian/:id/review-data and PpPendirianReviewPage keep working unchanged; the V2 page mounts at a NEW route (/pp/pendirian/:submissionId/review-v2) until Efran smoke-tests it.
  • TDD every task (RED → verify RED → GREEN → verify). Gate every commit with cd backend && bun run test (uses --timeout 20000; kill stray bun processes first: for pid in $(pgrep -x bun); do ps -o comm= -p $pid | grep -qx bun && kill $pid; done).
  • Architecture guards must stay green (bun test src/__tests__/architecture/): new backend files ≤ 800 lines, no console.* (use createLogger), no hand-declared contract-enum unions, request bodies via validateBody.
  • Backend runs from backend/, frontend from frontend/; worktree root is /home/efran/remote-development/poc-ahu-ai/ahu-ocr-tidyup. Frontend tests: npx vitest run <file>.
  • All optional payload values are T | null (never undefined) — the contract schemas enforce this; .optional() exists only where the spec says so (confirmMode, columns, docTypes, format).
  • Commits end with the session trailer line used across this branch (Claude-Session: https://claude.ai/code/session_01VJPKQCWek7jes4Lx4AoGiF).

Task 1: Compiler-forced REVIEW_REGISTRY skeleton

Files:
- Create: backend/src/review/review-registry.ts
- Test: backend/src/review/__tests__/review-registry.test.ts

Interfaces:
- Consumes: SubmissionType (generated enum), ReviewData/SectionSchema types from @contract/review.
- Produces (later tasks rely on these exact names):
- interface ReviewProjection { sections: SectionSchema[]; projectReview(submissionId: string): Promise<ReviewData | null> } (null = submission not found)
- const REVIEW_REGISTRY: Record<SubmissionType, ReviewProjection | null> (null entry = review not migrated; legacy endpoint still owns it)
- function reviewProjectionFor(type: SubmissionType): ReviewProjection | null

  • [ ] Step 1: Write the failing test
// backend/src/review/__tests__/review-registry.test.ts
import { describe, test, expect } from "bun:test";
import { SubmissionType } from "../../generated/prisma/enums";
import { REVIEW_REGISTRY, reviewProjectionFor } from "../review-registry";

describe("REVIEW_REGISTRY", () => {
  test("is total over SubmissionType (compiler-forced, runtime-checked)", () => {
    for (const t of Object.values(SubmissionType)) {
      expect(Object.hasOwn(REVIEW_REGISTRY, t)).toBe(true);
    }
  });

  test("a registered projection exposes sections + projectReview", () => {
    for (const t of Object.values(SubmissionType)) {
      const p = reviewProjectionFor(t);
      if (p !== null) {
        expect(Array.isArray(p.sections)).toBe(true);
        expect(typeof p.projectReview).toBe("function");
      }
    }
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: cd backend && bun test src/review/__tests__/review-registry.test.ts
Expected: FAIL — Cannot find module '../review-registry'

  • [ ] Step 3: Write the registry
// backend/src/review/review-registry.ts
/**
 * Compiler-forced review-projection registry (approved ReviewData spec §9).
 * SEPARATE from flow-engine's FLOW_REGISTRY on purpose: review migration is
 * orthogonal to processing migration — PENDIRIAN_PP migrates its review here
 * while staying a v1-fork processor. When a flow is fully v2 end-to-end the
 * two registries can merge; until then this map answers exactly one question:
 * "does GET /api/submissions/:id/review know how to project this type?"
 *
 * Adding a SubmissionType to the Prisma enum makes this a missing-key COMPILE
 * error. `null` = not migrated yet (the legacy per-flow endpoint still owns
 * the review page); the route returns REVIEW_NOT_MIGRATED for it.
 */
import type { SubmissionType } from "../generated/prisma/enums";
import type { ReviewData, SectionSchema } from "@contract/review";

export interface ReviewProjection {
  /** The serializable per-flow structure declaration (spec §5.2). */
  sections: SectionSchema[];
  /** Project the submission into the normalized ReviewData. null = not found. */
  projectReview(submissionId: string): Promise<ReviewData | null>;
}

export const REVIEW_REGISTRY: Record<SubmissionType, ReviewProjection | null> = {
  PENDIRIAN_PT: null,
  PENDIRIAN_PP: null, // becomes the first tenant in Task 5
  PERUBAHAN_PT: null,
  PERBAIKAN_DATA_PT: null,
  PERUBAHAN_PP: null,
  PEMBUBARAN_PP: null,
  PERBAIKAN_DATA_PP: null,
  PERALIHAN_PP_KE_PT: null,
  APOSTILLE: null,
  AKUISISI_PT: null,
  LAPORAN_RUPS_TAHUNAN: null,
  PELEBURAN_PT: null,
  PEMBUBARAN_PT: null,
};

export function reviewProjectionFor(type: SubmissionType): ReviewProjection | null {
  return REVIEW_REGISTRY[type];
}
  • [ ] Step 4: Run test to verify it passes

Run: cd backend && bun test src/review/__tests__/review-registry.test.ts
Expected: 2 pass

  • [ ] Step 5: Typecheck + commit

Run: cd backend && bunx tsc --noEmit

git add backend/src/review
git commit -m "feat(review): compiler-forced REVIEW_REGISTRY skeleton (all null)"

Task 2: Generic GET /api/submissions/:id/review route

Files:
- Create: backend/src/routes/review-data.ts
- Modify: backend/src/index.ts (one mount line, next to the existing app.route("/api/submissions", submissions))
- Test: backend/src/routes/__tests__/review-data-route.test.ts

Interfaces:
- Consumes: reviewProjectionFor (Task 1), ReviewDataSchema from @contract/review, db from ../lib/db.
- Produces: GET /api/submissions/:id/review200 ReviewData | 404 {error, code: "NOT_FOUND"} | 404 {error, code: "REVIEW_NOT_MIGRATED", type}. Contract-validated outside production (CONTRACT_VALIDATION !== "off" && NODE_ENV !== "production").
- Note: /api/submissions is already AUTHED in ROUTE_POLICIES — longest-prefix matching covers this router; no policy change needed (the mount-exhaustiveness test will confirm).

  • [ ] Step 1: Write the failing test
// backend/src/routes/__tests__/review-data-route.test.ts
import { describe, test, expect, afterAll } from "bun:test";
import { Hono } from "hono";
import { db } from "../../lib/db";
import reviewData from "../review-data";

const app = new Hono().route("/api/submissions", reviewData);
const ids: string[] = [];
afterAll(async () => {
  await db.submission.deleteMany({ where: { id: { in: ids } } });
});

describe("GET /api/submissions/:id/review", () => {
  test("404 NOT_FOUND for an unknown submission", async () => {
    const res = await app.request("/api/submissions/nope-xyz/review");
    expect(res.status).toBe(404);
    expect((await res.json()).code).toBe("NOT_FOUND");
  });

  test("404 REVIEW_NOT_MIGRATED for a type with a null registry entry", async () => {
    const id = `review-route-test-${Date.now()}`;
    ids.push(id);
    // PERUBAHAN_PT stays null in this plan — a stable not-migrated probe.
    await db.submission.create({ data: { id, type: "PERUBAHAN_PT", status: "READY" } });
    const res = await app.request(`/api/submissions/${id}/review`);
    expect(res.status).toBe(404);
    const body = await res.json();
    expect(body.code).toBe("REVIEW_NOT_MIGRATED");
    expect(body.type).toBe("PERUBAHAN_PT");
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: cd backend && bun test src/routes/__tests__/review-data-route.test.ts
Expected: FAIL — Cannot find module '../review-data'

  • [ ] Step 3: Write the route
// backend/src/routes/review-data.ts
/**
 * THE generic review read endpoint (approved ReviewData spec §9): one URL for
 * every flow, dispatched through REVIEW_REGISTRY. Legacy per-flow review-data
 * endpoints keep serving their pages until each flow migrates (strangler);
 * a null registry entry answers REVIEW_NOT_MIGRATED so nothing guesses.
 * Responses are contract-validated outside production — a projector emitting
 * off-spec data fails loudly in dev/test/CI, never silently drifts.
 */
import { Hono } from "hono";
import { db } from "../lib/db";
import { reviewProjectionFor } from "../review/review-registry";
import { ReviewDataSchema } from "@contract/review";
import { createLogger } from "../lib/logger";

const log = createLogger({ scope: "review-data" });

const validateResponses =
  process.env.CONTRACT_VALIDATION !== "off" && process.env.NODE_ENV !== "production";

const reviewData = new Hono();

reviewData.get("/:id/review", async (c) => {
  const id = c.req.param("id");
  const sub = await db.submission.findUnique({ where: { id }, select: { type: true } });
  if (!sub) return c.json({ error: "Submission not found", code: "NOT_FOUND" }, 404);

  const projection = reviewProjectionFor(sub.type);
  if (!projection) {
    return c.json(
      { error: `Review projection not yet migrated for ${sub.type}`, code: "REVIEW_NOT_MIGRATED", type: sub.type },
      404,
    );
  }

  const data = await projection.projectReview(id);
  if (!data) return c.json({ error: "Submission not found", code: "NOT_FOUND" }, 404);

  if (validateResponses) {
    const parsed = ReviewDataSchema.safeParse(data);
    if (!parsed.success) {
      log.error("projector emitted off-contract ReviewData", {
        submissionId: id,
        type: sub.type,
        issues: parsed.error.issues.slice(0, 5),
      });
      return c.json({ error: "Review projection violated the contract", code: "CONTRACT_VIOLATION" }, 500);
    }
  }
  return c.json(data);
});

export default reviewData;
  • [ ] Step 4: Mount it in index.ts

In backend/src/index.ts, add the import next to the other route imports and the mount line directly ABOVE app.route("/api/submissions", submissions);:

import reviewData from "./routes/review-data";
// …
app.route("/api/submissions", reviewData); // generic /:id/review (ReviewData spec §9)
app.route("/api/submissions", submissions);
  • [ ] Step 5: Run tests to verify green

Run: cd backend && bun test src/routes/__tests__/review-data-route.test.ts src/security/
Expected: route tests 2 pass; security suite (incl. mount-exhaustiveness) all pass

  • [ ] Step 6: Commit
git add backend/src/routes/review-data.ts backend/src/routes/__tests__/review-data-route.test.ts backend/src/index.ts
git commit -m "feat(review): generic GET /api/submissions/:id/review with contract validation"

Task 3: Extract the shared PP shape module (no behavior change)

Files:
- Create: backend/src/review/pp-shape.ts
- Modify: backend/src/routes/pp-pendirian.ts (delete the local PpSection/PP_SECTIONS/PP_PENDIRIAN_DECLARATIONS definitions; import from the new module; KEEP re-exporting PP_PENDIRIAN_DECLARATIONS because routes/pp-perubahan.ts:25 imports it from ./pp-pendirian)
- Test: existing suites are the safety net (src/__tests__ PP tests must stay green)

Interfaces:
- Produces: export type PpSection = { key: string; title: string; docType: string; keys: string[]; extra?: { docType: string; keys: string[] } }, export const PP_SECTIONS: PpSection[], export const PP_PENDIRIAN_DECLARATIONS: string[] — all byte-identical to today's values in routes/pp-pendirian.ts:67-111.

  • [ ] Step 1: Create backend/src/review/pp-shape.ts — move (verbatim) the PpSection type, PP_SECTIONS array, and PP_PENDIRIAN_DECLARATIONS array from routes/pp-pendirian.ts:67-111, including their comments. Header comment:
/**
 * PENDIRIAN_PP review structure — the single source both the legacy
 * /review-data route and the ReviewData projector read. Moved verbatim from
 * routes/pp-pendirian.ts (2026-07-03) so review/ never imports from routes/.
 */
  • [ ] Step 2: Update routes/pp-pendirian.ts — replace the moved definitions with:
import { PP_SECTIONS, PP_PENDIRIAN_DECLARATIONS, type PpSection } from "../review/pp-shape";
export { PP_PENDIRIAN_DECLARATIONS }; // pp-perubahan.ts imports it from here
  • [ ] Step 3: Verify no behavior change

Run: cd backend && bunx tsc --noEmit && bun test src/__tests__/pp src/routes/__tests__ 2>/dev/null | tail -3
(If PP tests live elsewhere, run the full suite: bun run test.) Expected: green, same counts as before the change.

  • [ ] Step 4: Commit
git add backend/src/review/pp-shape.ts backend/src/routes/pp-pendirian.ts
git commit -m "refactor(review): extract PP_SECTIONS/declarations into review/pp-shape (verbatim move)"

Task 4: PENDIRIAN_PP projector

Files:
- Create: backend/src/review/projectors/pp-pendirian-review.ts
- Test: backend/src/review/__tests__/pp-pendirian-review.test.ts

Interfaces:
- Consumes: PP_SECTIONS, PP_PENDIRIAN_DECLARATIONS (Task 3), db, fieldProvenance from ../../lib/field-provenance, contract types.
- Produces: export const ppPendirianReviewProjection: ReviewProjection — sections declared as SectionSchema[]; projectReview(id) returns spec-shaped ReviewData.
- Field ref convention (spec §6.1): `${documentId}:${fieldKey}`. Roster keys: "kbli", "pemilik_manfaat". Section keys: the four PP_SECTIONS keys + "kbli" + "pemilik_manfaat" (+ approvable "pernyataan" carried by declarations, matching the legacy page).
- Provenance mapping: fieldProvenance(f) returns the legacy strings; map AUTO_OCR→AUTO_OCR, NOTARIS_EDITED→USER_EDITED, NOTARIS_CONFIRMED→USER_CONFIRMED (check lib/field-provenance.ts for the exact return values first and adjust the map to cover them all — the contract only accepts the three PROVENANCE_VALUES).

  • [ ] Step 1: Write the failing test (seeds a real submission; asserts contract-parse + content)
// backend/src/review/__tests__/pp-pendirian-review.test.ts
import { describe, test, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { ReviewDataSchema } from "@contract/review";
import { ppPendirianReviewProjection } from "../projectors/pp-pendirian-review";

const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
  await db.submission.deleteMany({ where: { id: { in: ids } } });
  await db.document.deleteMany({ where: { id: { in: docIds } } });
});

async function seed(): Promise<string> {
  const id = `pp-review-proj-${Date.now()}`;
  ids.push(id);
  const doc = await db.document.create({
    data: {
      filename: "sp.pdf", originalName: "sp.pdf", fileSize: 9, filePath: "/tmp/x.pdf",
      documentType: "SURAT_PERNYATAAN_PP", pageCount: 2,
      fields: { create: [
        { fieldKey: "namaPerseroan", value: "PP MAJU", confidence: 96, status: "LOCKED" },
        { fieldKey: "modal", value: "50000000", confidence: 80, status: "EDITABLE" },
      ]},
      boundingBoxes: { create: [{ fieldKey: "namaPerseroan", page: 0, x1: 1, y1: 2, x2: 3, y2: 4 }] },
      kbliEntries: { create: [
        { orderIndex: 0, kbliCode: "47911", description: "Perdagangan eceran", confidence: 90, status: "EDITABLE", notarisConfirmed: true },
      ]},
    },
  });
  docIds.push(doc.id);
  await db.submission.create({
    data: {
      id, type: "PENDIRIAN_PP", status: "READY",
      documents: { create: [{ documentId: doc.id, classifiedType: "SURAT_PERNYATAAN_PP", confidence: 0.99, processingOrder: 0, extractionStatus: "DONE" }] },
      sectionApprovals: { create: [{ sectionKey: "data_perseroan", approved: true }] },
      validationResults: { create: [{ ruleCode: "PP_MODAL_MAX", ruleLabel: "Modal maksimal", status: "PASS", message: "ok" }] },
      pemilikManfaatEntries: { create: [{
        orderIndex: 0, isFounder: true, nama: "Budi", kewarganegaraan: "WNI",
        jenisIdentitas: "KTP", nomorIdentitas: "3171000000000001",
        kriteriaIds: [1, 2], confidence: 75, status: "EDITABLE", notarisConfirmed: false,
      }]},
    },
  });
  return id;
}

describe("ppPendirianReviewProjection", () => {
  test("projects a seeded submission into contract-valid ReviewData", async () => {
    const id = await seed();
    const data = await ppPendirianReviewProjection.projectReview(id);
    expect(data).not.toBeNull();
    const parsed = ReviewDataSchema.parse(data!); // throws on any spec violation

    // fields store keyed by ref, bbox attached
    const doc = (await db.submission.findUnique({ where: { id }, include: { documents: true } }))!.documents[0];
    const ref = `${doc.documentId}:namaPerseroan`;
    expect(parsed.fields[ref].value).toBe("PP MAJU");
    expect(parsed.fields[ref].bbox).toEqual({ page: 0, x1: 1, y1: 2, x2: 3, y2: 4 });
    expect(parsed.fields[ref].confirmed).toBe(false);

    // sections reference the store; only keys with data are required
    const dataPerseroan = parsed.sections.find((s) => s.key === "data_perseroan");
    expect(dataPerseroan?.kind).toBe("field-list");
    expect(parsed.requiredSections).toContain("data_perseroan");
    expect(parsed.sectionApprovals["data_perseroan"]).toBe(true);

    // rosters
    expect(parsed.rosters["kbli"].rows[0].cells["kbliCode"]).toBe("47911");
    expect(parsed.rosters["kbli"].rows[0].confirmed).toBe(true);
    expect(parsed.rosters["pemilik_manfaat"].rows[0].cells["nama"]).toBe("Budi");
    expect(parsed.rosters["pemilik_manfaat"].rows[0].detail).toMatchObject({ kriteriaIds: [1, 2] });

    // declarations + docs
    expect(parsed.declarations.length).toBeGreaterThan(3);
    expect(parsed.documents[0].classifiedType).toBe("SURAT_PERNYATAAN_PP");
  });

  test("returns null for an unknown submission", async () => {
    expect(await ppPendirianReviewProjection.projectReview("missing-id")).toBeNull();
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: cd backend && bun test src/review/__tests__/pp-pendirian-review.test.ts
Expected: FAIL — module not found

  • [ ] Step 3: Implement the projector — mirror the legacy route's reads (routes/pp-pendirian.ts:172-280) exactly; only the OUTPUT shape changes:
// backend/src/review/projectors/pp-pendirian-review.ts
import { db } from "../../lib/db";
import { fieldProvenance } from "../../lib/field-provenance";
import { PP_BO_KRITERIA } from "../../services/pp-bo-kriteria";
import { PP_SECTIONS, PP_PENDIRIAN_DECLARATIONS } from "../pp-shape";
import type { ReviewProjection } from "../review-registry";
import type {
  ReviewData, ReviewFieldVM, RosterVM, SectionVM, SectionSchema,
} from "@contract/review";

const PROVENANCE_MAP: Record<string, ReviewFieldVM["provenance"]> = {
  AUTO_OCR: "AUTO_OCR",
  NOTARIS_EDITED: "USER_EDITED",
  NOTARIS_CONFIRMED: "USER_CONFIRMED",
};

const KBLI_COLUMNS = [
  { key: "kbliCode", label: "Kode KBLI" },
  { key: "description", label: "Uraian" },
];
const BO_COLUMNS = [
  { key: "nama", label: "Nama" },
  { key: "jenisIdentitas", label: "Jenis ID" },
  { key: "nomorIdentitas", label: "Nomor Identitas" },
  { key: "kewarganegaraan", label: "Kewarganegaraan" },
];

const SECTION_SCHEMAS: SectionSchema[] = [
  ...PP_SECTIONS.map((s, i) => ({
    key: s.key, label: s.title, kind: "field-list" as const,
    approvable: true, required: true, order: i,
    docTypes: [s.docType, ...(s.extra ? [s.extra.docType] : [])],
  })),
  { key: "kbli", label: "KBLI", kind: "roster", approvable: true, required: true, order: 10, columns: KBLI_COLUMNS },
  { key: "pemilik_manfaat", label: "Pemilik Manfaat", kind: "roster", approvable: true, required: true, order: 11, columns: BO_COLUMNS },
];

export const ppPendirianReviewProjection: ReviewProjection = {
  sections: SECTION_SCHEMAS,

  async projectReview(submissionId: string): Promise<ReviewData | null> {
    const sub = await db.submission.findUnique({
      where: { id: submissionId },
      include: {
        documents: { include: { document: { include: {
          fields: true,
          kbliEntries: { orderBy: { orderIndex: "asc" } },
          boundingBoxes: true,
        } } } },
        validationResults: { orderBy: { createdAt: "asc" } },
        sectionApprovals: true,
        pemilikManfaatEntries: { orderBy: { orderIndex: "asc" } },
      },
    });
    if (!sub || sub.type !== "PENDIRIAN_PP") return null;

    const docByType = (t: string) => sub.documents.find((d) => d.document.documentType === t)?.document;

    // ── fields store + field-list sections ──────────────────────────────
    const fields: Record<string, ReviewFieldVM> = {};
    const addField = (doc: NonNullable<ReturnType<typeof docByType>>, fieldKey: string): string | null => {
      const f = doc.fields.find((x) => x.fieldKey === fieldKey);
      if (!f) return null;
      const ref = `${doc.id}:${fieldKey}`;
      const bb = doc.boundingBoxes.find((b) => b.fieldKey === fieldKey) ?? null;
      fields[ref] = {
        ref, fieldKey, documentId: doc.id, label: fieldKey,
        value: f.value, displayValue: null,
        confidence: f.confidence, status: f.status as ReviewFieldVM["status"],
        confirmed: f.notarisConfirmed,
        confirmedAt: f.notarisConfirmedAt ? f.notarisConfirmedAt.toISOString() : null,
        provenance: PROVENANCE_MAP[fieldProvenance(f)] ?? "AUTO_OCR",
        editable: f.status !== "LOCKED",
        editReason: f.editReason ?? null,
        bbox: bb ? { page: bb.page, x1: bb.x1, y1: bb.y1, x2: bb.x2, y2: bb.y2 } : null,
      };
      return ref;
    };

    const sections: SectionVM[] = [];
    for (const s of PP_SECTIONS) {
      const doc = docByType(s.docType);
      const refs: string[] = [];
      if (doc) for (const k of s.keys) { const r = addField(doc, k); if (r) refs.push(r); }
      if (s.extra) {
        const xdoc = docByType(s.extra.docType);
        if (xdoc) for (const k of s.extra.keys) { const r = addField(xdoc, k); if (r) refs.push(r); }
      }
      sections.push({
        kind: "field-list", key: s.key, label: s.title,
        approvable: true, required: true, documentId: doc?.id ?? null, fieldRefs: refs,
      });
    }

    // ── rosters ─────────────────────────────────────────────────────────
    const suratDoc = docByType("SURAT_PERNYATAAN_PP");
    const rosters: Record<string, RosterVM> = {
      kbli: {
        key: "kbli", columns: KBLI_COLUMNS,
        rows: (suratDoc?.kbliEntries ?? []).map((k) => ({
          id: k.id, cells: { kbliCode: k.kbliCode, description: k.description },
          cellFieldRefs: {}, confirmed: k.notarisConfirmed,
          status: k.status as ReviewFieldVM["status"],
          provenance: "AUTO_OCR", personRef: null, detail: null,
        })),
      },
      pemilik_manfaat: {
        key: "pemilik_manfaat", columns: BO_COLUMNS,
        rows: sub.pemilikManfaatEntries.map((b) => ({
          id: b.id,
          cells: { nama: b.nama, jenisIdentitas: b.jenisIdentitas, nomorIdentitas: b.nomorIdentitas, kewarganegaraan: b.kewarganegaraan },
          cellFieldRefs: {}, confirmed: b.notarisConfirmed,
          status: b.status as ReviewFieldVM["status"],
          provenance: "AUTO_OCR", personRef: null,
          detail: {
            isFounder: b.isFounder, npwp: b.npwp, tempatLahir: b.tempatLahir, tanggalLahir: b.tanggalLahir,
            alamat: b.alamat, negaraAsal: b.negaraAsal, hubungan: b.hubungan,
            kriteriaIds: b.kriteriaIds, npwpVerified: b.matchedNpwpDocumentId != null,
            kriteriaCatalog: PP_BO_KRITERIA.map((k) => ({ id: k.id, group: k.group, label: k.label })),
          },
        })),
      },
    };
    sections.push({ kind: "roster", key: "kbli", label: "KBLI", approvable: true, required: rosters.kbli.rows.length > 0, documentId: suratDoc?.id ?? null, rosterKey: "kbli" });
    sections.push({ kind: "roster", key: "pemilik_manfaat", label: "Pemilik Manfaat", approvable: true, required: rosters.pemilik_manfaat.rows.length > 0, documentId: null, rosterKey: "pemilik_manfaat" });

    // ── approvals / required (mirrors legacy ppRequiredSections logic) ──
    const sectionApprovals: Record<string, boolean> = {};
    for (const sa of sub.sectionApprovals) sectionApprovals[sa.sectionKey] = sa.approved;
    const requiredSections = sections
      .filter((s) => s.required && (s.kind !== "field-list" || s.fieldRefs.length > 0))
      .map((s) => s.key);

    return {
      submissionId: sub.id, type: sub.type, status: sub.status,
      sections, fields, rosters, diff: null, registryEntities: {},
      documents: sub.documents.map((d) => ({
        documentId: d.documentId, classifiedType: d.classifiedType, userOverride: d.userOverride,
        filename: d.document.filename, pageCount: Math.max(1, d.document.pageCount ?? 1),
        extractionStatus: d.extractionStatus, meta: null,
      })),
      pdfSources: sub.documents.map((d) => ({
        documentId: d.documentId, label: d.classifiedType,
        originalName: d.document.originalName, pageCount: Math.max(1, d.document.pageCount ?? 1),
      })),
      validationResults: sub.validationResults.map((v) => ({
        id: v.id, ruleCode: v.ruleCode, ruleLabel: v.ruleLabel,
        status: v.status, message: v.message, details: (v.details ?? null) as unknown,
        sectionKey: null, overridden: v.overridden ?? false,
        overrideReason: v.overrideReason ?? null, overridable: true,
      })),
      sectionApprovals, requiredSections,
      declarations: PP_PENDIRIAN_DECLARATIONS.map((label, i) => ({
        key: `D${i + 1}`, label, checked: sectionApprovals["pernyataan"] ?? false,
      })),
      meta: { expiresAt: null, aiSummary: null, evidenceCheckAvailable: false, revisionFeedback: null },
    };
  },
};

NB: the exact ValidationResult column names (overridden, overrideReason) and fieldProvenance return values must be checked against prisma/schema.prisma / lib/field-provenance.ts while implementing — adjust the two mappings, keep the VM shape fixed.

  • [ ] Step 4: Run test to verify it passes

Run: cd backend && bun test src/review/__tests__/pp-pendirian-review.test.ts
Expected: 2 pass (the contract parse inside the test is the spec compliance check)

  • [ ] Step 5: Typecheck + commit
cd backend && bunx tsc --noEmit
git add backend/src/review
git commit -m "feat(review): PENDIRIAN_PP projector — first ReviewData tenant"

Task 5: Register the tenant + parity oracle

Files:
- Modify: backend/src/review/review-registry.ts (PENDIRIAN_PP: null → the projection)
- Test: backend/src/review/__tests__/pp-pendirian-parity.test.ts

Interfaces:
- Consumes: the seeded-submission pattern from Task 4's test, the legacy route GET /api/pp/pendirian/:id/review-data (mounted via routes/pp-pendirian.ts default export), the new route (Task 2).
- Produces: the parity guarantee later flows copy — every value the legacy payload renders exists in the new payload.

  • [ ] Step 1: Register
// review-registry.ts
import { ppPendirianReviewProjection } from "./projectors/pp-pendirian-review";
// …
  PENDIRIAN_PP: ppPendirianReviewProjection,
  • [ ] Step 2: Write the parity oracle test (this is the RED→GREEN of the registration — before registering it would 404)
// backend/src/review/__tests__/pp-pendirian-parity.test.ts
import { describe, test, expect, afterAll } from "bun:test";
import { Hono } from "hono";
import { db } from "../../lib/db";
import ppPendirian from "../../routes/pp-pendirian";
import reviewData from "../../routes/review-data";

// Seed EXACTLY as in pp-pendirian-review.test.ts (copy the seed() helper verbatim,
// including the afterAll cleanup — do not import across test files).

const app = new Hono()
  .route("/api/pp/pendirian", ppPendirian)
  .route("/api/submissions", reviewData);

describe("parity: legacy review-data ⊆ new ReviewData", () => {
  test("every field value + roster row + approval the legacy payload renders exists in the new payload", async () => {
    const id = await seed();
    const legacy = await (await app.request(`/api/pp/pendirian/${id}/review-data`)).json();
    const next = await (await app.request(`/api/submissions/${id}/review`)).json();

    // 1) every legacy section field appears in the new fields store with same value
    for (const s of legacy.sections) {
      for (const f of s.fields) {
        const ref = `${f.documentId}:${f.fieldKey}`;
        expect(next.fields[ref], `missing field ${ref}`).toBeDefined();
        expect(next.fields[ref].value).toBe(f.value);
        expect(next.fields[ref].confirmed).toBe(f.notarisConfirmed);
      }
      // section exists under the same key
      expect(next.sections.some((x: { key: string }) => x.key === s.key)).toBe(true);
    }
    // 2) kbli + BO rosters row-for-row
    expect(next.rosters.kbli.rows.map((r: { cells: Record<string, unknown> }) => r.cells.kbliCode))
      .toEqual(legacy.kbli.map((k: { kbliCode: string }) => k.kbliCode));
    expect(next.rosters.pemilik_manfaat.rows.length).toBe(legacy.pemilikManfaat.length);
    // 3) approvals, required, declarations, documents
    expect(next.sectionApprovals).toEqual(legacy.sectionApprovals);
    for (const k of legacy.requiredSections) expect(next.requiredSections).toContain(k);
    expect(next.declarations.map((d: { label: string }) => d.label)).toEqual(legacy.declarations);
    expect(next.documents.length).toBe(legacy.documents.length);
  });
});
  • [ ] Step 3: Run — RED first (with registration reverted), then GREEN

Revert the registry line to null, run: cd backend && bun test src/review/__tests__/pp-pendirian-parity.test.ts → expect FAIL (404 REVIEW_NOT_MIGRATED). Re-apply the registration, run again → expect PASS.

  • [ ] Step 4: Full backend gate + commit
cd backend && bun run test   # full suite, kill stray bun first
git add backend/src/review
git commit -m "feat(review): register PENDIRIAN_PP + parity oracle vs legacy endpoint"

Task 6: Frontend useReviewData hook (contract-parsed)

Files:
- Create: frontend/src/hooks/use-review-engine.ts
- Test: frontend/src/hooks/use-review-engine.test.ts

Interfaces:
- Consumes: apiFetch from @/lib/api-client, ReviewDataSchema/ReviewData from @contract/review (zod resolves through the root node_modules — see vite.config fs.allow already covering ../contract).
- Produces: export function useReviewEngineData(submissionId: string): UseQueryResult<ReviewData> — queryKey ["review-engine", submissionId], parses the response with ReviewDataSchema.parse (client-side contract check, always on per spec §9).

  • [ ] Step 1: Write the failing test
// frontend/src/hooks/use-review-engine.test.ts
import { describe, it, expect, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useReviewEngineData } from "./use-review-engine";
import * as api from "@/lib/api-client";

const MINIMAL = {
  submissionId: "s1", type: "PENDIRIAN_PP", status: "READY",
  sections: [], fields: {}, rosters: {}, diff: null, registryEntities: {},
  documents: [], pdfSources: [], validationResults: [], sectionApprovals: {},
  requiredSections: [], declarations: [],
  meta: { expiresAt: null, aiSummary: null, evidenceCheckAvailable: false, revisionFeedback: null },
};

function wrapper({ children }: { children: React.ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

describe("useReviewEngineData", () => {
  it("fetches and contract-parses ReviewData", async () => {
    vi.spyOn(api, "apiFetch").mockResolvedValue(MINIMAL);
    const { result } = renderHook(() => useReviewEngineData("s1"), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data?.submissionId).toBe("s1");
    expect(api.apiFetch).toHaveBeenCalledWith("/api/submissions/s1/review");
  });

  it("surfaces a contract violation as a query error", async () => {
    vi.spyOn(api, "apiFetch").mockResolvedValue({ ...MINIMAL, type: "NOT_A_TYPE" });
    const { result } = renderHook(() => useReviewEngineData("s1"), { wrapper });
    await waitFor(() => expect(result.current.isError).toBe(true));
  });
});

(File must be .tsx if JSX in wrapper complains — name it use-review-engine.test.tsx in that case and adjust imports accordingly.)

  • [ ] Step 2: Run to verify REDcd frontend && npx vitest run src/hooks/use-review-engine.test.tsx → FAIL (module not found)

  • [ ] Step 3: Implement

// frontend/src/hooks/use-review-engine.ts
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api-client";
import { ReviewDataSchema, type ReviewData } from "@contract/review";

/** The one review hook (ReviewData spec §9): fetches the generic endpoint and
 *  parses with the SAME zod schema the backend validates against — drift
 *  between the two sides fails here, loudly, not in a renderer. */
export function useReviewEngineData(submissionId: string): UseQueryResult<ReviewData> {
  return useQuery({
    queryKey: ["review-engine", submissionId],
    queryFn: async () => ReviewDataSchema.parse(await apiFetch(`/api/submissions/${submissionId}/review`)),
    enabled: !!submissionId,
  });
}
  • [ ] Step 4: GREEN + typecheck + commit
cd frontend && npx vitest run src/hooks/use-review-engine.test.tsx && bunx tsc --noEmit
git add frontend/src/hooks/use-review-engine*
git commit -m "feat(review-engine): contract-parsed useReviewEngineData hook"

Task 7: ReviewEngine shell + renderer registry

Files:
- Create: frontend/src/components/review-engine/ReviewEngine.tsx, frontend/src/components/review-engine/types.ts
- Test: frontend/src/components/review-engine/__tests__/ReviewEngine.test.tsx

Interfaces:
- Produces (all later renderers + the page rely on these):

// types.ts
import type { ReviewData, SectionVM } from "@contract/review";
export interface ReviewActions {
  editField(ref: string, value: string, reason?: string): void;
  confirmField(ref: string, confirmed: boolean): void;
  confirmRosterRow(rosterKey: string, rowId: string, confirmed: boolean): void;
  approveSection(sectionKey: string, approved: boolean): void;
  focusField(ref: string): void; // PDF viewer focus
}
export interface SectionRendererProps { data: ReviewData; section: SectionVM; actions: ReviewActions; }
export type SectionRenderer = (props: SectionRendererProps) => React.ReactNode;
  • <ReviewEngine data={ReviewData} actions={ReviewActions} renderers?={Partial<Record<SectionKind, SectionRenderer>>} /> — walks data.sections in order, dispatches on section.kind through a default renderer registry merged with the renderers override prop; unknown/unimplemented kind renders a visible fallback card (never crashes, never silently skips).

  • [ ] Step 1: RED — test renders a two-section fixture (one field-list, one kind with no renderer) and asserts: both section labels visible; the unimplemented one shows text Renderer belum tersedia; renderer override prop wins.

// __tests__/ReviewEngine.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ReviewEngine } from "../ReviewEngine";
import type { ReviewData } from "@contract/review";

const noop = () => {};
const actions = { editField: noop, confirmField: noop, confirmRosterRow: noop, approveSection: noop, focusField: noop };

const data = {
  submissionId: "s1", type: "PENDIRIAN_PP", status: "READY",
  sections: [
    { kind: "field-list", key: "a", label: "Bagian A", approvable: true, required: true, documentId: null, fieldRefs: [] },
    { kind: "registry-entity", key: "b", label: "Bagian B", approvable: false, required: false, documentId: null, entityKey: "x" },
  ],
  fields: {}, rosters: {}, diff: null,
  registryEntities: { x: { source: "PTP", matchedBy: null, label: "PP", groups: [], candidates: null } },
  documents: [], pdfSources: [], validationResults: [], sectionApprovals: {},
  requiredSections: ["a"], declarations: [],
  meta: { expiresAt: null, aiSummary: null, evidenceCheckAvailable: false, revisionFeedback: null },
} as unknown as ReviewData;

describe("ReviewEngine", () => {
  it("renders every section by kind and falls back visibly for missing renderers", () => {
    render(<ReviewEngine data={data} actions={actions} />);
    expect(screen.getByText("Bagian A")).toBeInTheDocument();
    expect(screen.getByText("Bagian B")).toBeInTheDocument();
    expect(screen.getByText(/renderer belum tersedia/i)).toBeInTheDocument();
  });
});
  • [ ] Step 2: verify REDnpx vitest run src/components/review-engine/__tests__/ReviewEngine.test.tsx

  • [ ] Step 3: implement ReviewEngine.tsx — default registry starts with field-list (Task 8 fills it; until then it may render an empty card with the label), fallback card for anything unregistered:

// ReviewEngine.tsx
import type { ReviewData, SectionVM } from "@contract/review";
import type { ReviewActions, SectionRenderer } from "./types";
import { FieldListSection } from "./FieldListSection"; // Task 8 (stub first: label-only card)

const DEFAULT_RENDERERS: Partial<Record<SectionVM["kind"], SectionRenderer>> = {
  "field-list": FieldListSection,
};

export function ReviewEngine({ data, actions, renderers }: {
  data: ReviewData; actions: ReviewActions;
  renderers?: Partial<Record<SectionVM["kind"], SectionRenderer>>;
}) {
  const registry = { ...DEFAULT_RENDERERS, ...renderers };
  return (
    <div className="space-y-4">
      {data.sections.map((section) => {
        const Renderer = registry[section.kind];
        return (
          <section key={section.key} className="rounded-xl border bg-card p-4">
            <h2 className="mb-3 text-base font-semibold">{section.label}</h2>
            {Renderer ? (
              <Renderer data={data} section={section} actions={actions} />
            ) : (
              <p className="text-sm text-muted-foreground">
                Renderer belum tersedia untuk bagian ini ({section.kind}).
              </p>
            )}
          </section>
        );
      })}
    </div>
  );
}

For this task, FieldListSection is the minimal stub: export function FieldListSection() { return null; } in its own file (Task 8 replaces it TDD).

  • [ ] Step 4: GREEN + commitgit add frontend/src/components/review-engine && git commit -m "feat(review-engine): engine shell + renderer registry with visible fallback"

Task 8: FieldListSection renderer

Files:
- Modify: frontend/src/components/review-engine/FieldListSection.tsx
- Test: frontend/src/components/review-engine/__tests__/FieldListSection.test.tsx

Interfaces: consumes SectionRendererProps; resolves each fieldRefs entry via data.fields[ref]; renders per field: label, value (displayValue ?? value ?? "—"), confidence badge, LOCKED lock icon, confirm toggle button (actions.confirmField(ref, !confirmed), disabled when status === "LOCKED" or !editable), edit button (actions.editField(ref, …) opens caller-provided dialog later — for now call with current value), row click → actions.focusField(ref).

  • [ ] Step 1: RED — fixture with 2 fields (one LOCKED conf 96 confirmed, one EDITABLE conf 75 unconfirmed); assert values render; confirm button present only for the editable one; clicking it calls confirmField("d1:modal", true); clicking the row calls focusField.
  • [ ] Step 2: verify RED
  • [ ] Step 3: implement — plain list rows; use existing UI atoms (Button, cn); confidence badge = colored span (>=95 green / >=70 amber / red) with the number; no new deps.
  • [ ] Step 4: GREEN + bunx tsc --noEmit + commit feat(review-engine): field-list renderer

Task 9: RosterSection renderer

Files:
- Create: frontend/src/components/review-engine/RosterSection.tsx (+ register in DEFAULT_RENDERERS)
- Test: frontend/src/components/review-engine/__tests__/RosterSection.test.tsx

Interfaces: consumes data.rosters[section.rosterKey]; renders columns as a table header, one row per RosterRowVM (cells[col.key] ?? "—"), per-row confirm checkbox → actions.confirmRosterRow(rosterKey, row.id, checked); a row with detail !== null gets a "Detail" button opening an inline expandable block listing detail entries (skip kriteriaCatalog; render kriteriaIds as a count chip).

  • [ ] Step 1: RED — kbli-shaped fixture (2 rows, one confirmed); assert header labels, cell text, checkbox states; toggling calls confirmRosterRow("kbli", "r2", true); BO-shaped fixture with detail expands on click.
  • [ ] Step 2: verify REDStep 3: implementStep 4: GREEN + commit feat(review-engine): roster renderer

Task 10: Validation banner + approval bar + submit gate

Files:
- Create: frontend/src/components/review-engine/ValidationBanner.tsx, frontend/src/components/review-engine/SectionApprovalBar.tsx
- Modify: ReviewEngine.tsx (render ValidationBanner above sections; render SectionApprovalBar under each approvable section)
- Test: frontend/src/components/review-engine/__tests__/approval-and-validation.test.tsx

Interfaces:
- ValidationBanner({ results }) — groups by status; FAIL red list, WARNING amber list, hides PASS/SKIPPED; shows overridden as struck-through with reason.
- SectionApprovalBar({ section, approved, onToggle }) — "Setujui Bagian Ini" / "Batalkan Persetujuan" → actions.approveSection(key, !approved).
- export function submitGate(data: ReviewData): { ready: boolean; missingSections: string[]; failingRules: string[] } (pure, in frontend/src/components/review-engine/submit-gate.ts) — ready when every requiredSections key is approved AND no un-overridden FAIL validation. The page (Task 11) uses it to enable finalize.

  • [ ] Step 1: RED — pure submitGate tests first (4 cases: ready; missing section; un-overridden FAIL blocks; overridden FAIL doesn't block) + render tests for banner/bar.
  • [ ] Step 2–4: verify RED → implement → GREEN + commit feat(review-engine): validation banner, approval bar, pure submit gate

Task 11: PP Pendirian V2 page (new route, legacy untouched)

Files:
- Create: frontend/src/pages/pp/PpPendirianReviewPageV2.tsx
- Modify: frontend/src/routes.tsx (add { path: "pp/pendirian/:submissionId/review-v2", element: <PpPendirianReviewPageV2 /> } in the SAME RoleGuard group as the legacy PP pendirian review route)
- Test: frontend/src/pages/pp/__tests__/PpPendirianReviewPageV2.test.tsx

Interfaces:
- Consumes: useReviewEngineData (Task 6), ReviewEngine + submitGate (Tasks 7–10), ReviewPdfViewer from @/components/pendirian/review-pdf-viewer (existing), and the EXISTING PP mutation hooks from @/hooks/use-pp-pendirian (verify exact names in that file: usePpEditField, usePpConfirmField, usePpSectionApproval, useSetKbliConfirmed, usePpSetBoConfirmed, useFinalizePpPendirian — wire each into a ReviewActions impl; roster confirm dispatches by rosterKey: "kbli" → KBLI hook, "pemilik_manfaat" → BO hook).
- Produces: /pp/pendirian/:submissionId/review-v2 — the smoke-test surface. On success every mutation invalidates ["review-engine", submissionId].
- Layout: engine left (65%), ReviewPdfViewer right (35%) fed by data.pdfSources; focusField(ref) sets active document + field key from data.fields[ref] (documentId, fieldKey).

  • [ ] Step 1: RED — page test mocks useReviewEngineData (fixture with one field-list + kbli roster) and the PP mutation hooks (vi.mock, jest-fn mutate); asserts sections render, finalize button disabled when gate not ready, confirm click reaches the right mocked hook.
  • [ ] Step 2: verify REDStep 3: implement pageStep 4: GREEN + bunx tsc --noEmit
  • [ ] Step 5: Commit feat(review-engine): PP Pendirian V2 page at /review-v2 (legacy untouched)

Task 12: Full gates + trackers + handoff note

  • [ ] Step 1: cd backend && bun run test → expect ≥1950 pass / 0 fail (all guards green; new files under FF7 cap)
  • [ ] Step 2: cd frontend && npx vitest run && bunx tsc --noEmit → all pass
  • [ ] Step 3: Update tidyup/progress.md (new entry: engine + first tenant live at /review-v2; parity oracle green; Efran smoke-test = flip the main route + delete legacy endpoint next) and tidyup/tasks.md (Phase B checklist: pembubaran-PP → PT pendirian → peralihan → PP perubahan/perbaikan + FF4 fork collapse — each = "copy Tasks 4–5+11 pattern").
  • [ ] Step 4: Commit docs(tidyup): ReviewData engine + PP-pendirian slice complete — awaiting smoke test

Self-Review Notes

  • Spec coverage: §4 envelope (Task 4 return), §5 sections/schemas (Tasks 1, 4), §6 VMs (Task 4 mappings; identity/diff/document-set kinds deliberately NOT implemented — not needed by PENDIRIAN_PP; engine fallback card covers them until their flows migrate), §8 mutation refs (Task 11 wiring), §9 endpoint/validation/oracle (Tasks 2, 5), §10 order (this plan = first tenant; follow-ups per flow).
  • Deviation from spec recorded: registryEntity?registryEntities: Record<string, …> was already amended in contract/review.ts; the separate REVIEW_REGISTRY (vs. extending flow-engine's FlowConfig) is documented in Task 1's header — revisit when the new PT flows are smoke-tested and the registries can merge.
  • Type consistency: ReviewProjection/reviewProjectionFor (T1) used in T2/T5; ReviewActions (T7) used in T8–T11; ref format ${documentId}:${fieldKey} consistent T4/T5/T8/T11; queryKey ["review-engine", id] T6/T11.