think
16px
820px

ReviewData Read-Model — Design (Step 4, the Pillar 2+4+5 sync point)

Date: 2026-07-03 · Status: APPROVED by Efran 2026-07-03 · Approach: A ("everything-is-a-section", chosen by Efran 2026-07-03; verifikator data kept as a separate companion payload, chosen 2026-07-03)

This is the design the accepted architecture calls the "single most load-bearing coupling in the whole plan": Pillars 2 (contract), 4 (frontend review engine), and 5 (OCR boundary) must agree on this shape before any of them ships read-model code. Design only — implementation is gated on the akuisisi merge per the roadmap.

Grounding: three exploration passes on 2026-07-03 mapped (a) all 10 backend review surfaces, (b) all 9 frontend review pages and their shared widget vocabulary, (c) every constraint the accepted architecture already locks. Claims below cite those maps.


1. Problem

Every review screen loads a flow-specific payload with a flow-specific shape:

Flow Endpoint Assembly LOC Shape family
PENDIRIAN_PT submissions.ts:481 GET /:id/review-data ~712 document-centric (akta, identityDocuments, buktiSetor, …)
PERUBAHAN_PT perubahan.ts:237 ~145 diff-centric (diff, changeTypeResult, votes)
PERBAIKAN_DATA_PT (reuses generic + GET /api/perbaikan/:id) correction-centric (corrections[], lama/baru)
PENDIRIAN_PP pp-pendirian.ts:172 ~109 section-centric (sections[])
PERUBAHAN_PP pp-perubahan.ts:435 ~116 section + diff hybrid
PERBAIKAN_DATA_PP pp-perbaikan-pp.ts:1023 ~134 section + diff hybrid
PEMBUBARAN_PP pp-pembubaran.ts:278 ~60 minimal identity + attestation
PERALIHAN_PP_KE_PT pp-peralihan-pt.ts:227 ~160 akta block + ppAsal registry block
APOSTILLE apostille.ts:609 ~141 per-document blocks + signer matching

Nine shapes feed nine copy-forked review pages (PendirianReviewPage 2,106 lines; PerubahanDeltaReviewPage 3,177 lines; …) built from the same recurring widgets — the frontend map found an 11-widget shared vocabulary (field card, identity card, roster table, diff row, validation banner, section-approval gate, PDF+bbox viewer, progress header, AI panel, attestation block, atomic modal block). The duplication is in the shapes, not the widgets. The three PP sections[] builders are already near-identical (FF4-grandfathered fork pair among them).

One normalized shape lets ONE <ReviewEngine> render every flow and turns "add a flow's review screen" into configuration.

2. Goals / non-goals

Goals
- Lock the canonical ReviewData payload: envelope, every ViewModel (VM), every section kind — including the five items the architecture explicitly deferred to Step 4 (RosterRowVM, DiffModel, attestation/custom VMs, requiredSections semantics, DocumentVM confidence/bbox surface).
- Stay inside every locked constraint (§3).
- Define endpoint, versioning, validation, and the strangler migration path with the existing endpoints as equivalence oracles.

Non-goals
- No implementation now (gated on akuisisi merge; akuisisi is FLOW_REGISTRY migration case #1 and will be the second consumer of this shape).
- The WRITE side (edit/confirm/approve endpoints) is not redesigned — ReviewData is the READ model; §8 defines only the round-trip references mutations need.
- Verifikator decision surface (decisions, votes admin, audit trail, AI advisory) stays a separate companion payload (GET …/verifikasi), per Efran 2026-07-03. ReviewData is persona-neutral: notaris and verifikator read the same payload.

3. Locked constraints honored (from the accepted architecture)

  • Normalised {sections, fields, rosters, diff?} — NOT a freeze of today's document-centric shape (D3; arch §7 CRITICAL #1).
  • SectionSchema is shared FE/BE data in contract/; backend publishes SectionSchema[] via FlowConfig; the FE descriptor registry lives entirely on the frontend keyed by SubmissionType (arch §7 CRITICAL #2).
  • zod SSOT in contract/review.ts; response-validated in dev/test/CI only; FE parses with the same schema (Pillar 2).
  • Rosters load from the Phase-1/2 satellite tables when the read-switch lands; v1 projectors may read today's models (Pillar 3).
  • Diff is the hardest VM, designed now, migrated last (arch §4/§6).
  • Extraction metadata (engine, model, processingTimeMs) surfaces per document (Pillar 5).

4. The envelope

// contract/review.ts (zod schemas; TS shapes shown for readability)
interface ReviewData {
  submissionId: string;
  type: SubmissionType;                       // @contract enum
  status: SubmissionStatus;                   // @contract enum
  sections: SectionVM[];                      // ORDERED page spine (§5) — only VISIBLE sections
  fields: Record<string, ReviewFieldVM>;      // normalized field store, keyed by FieldRef (§6.1)
  rosters: Record<string, RosterVM>;          // normalized roster store, keyed by roster key (§6.2)
  diff: DiffModel | null;                     // §6.3 — perubahan-family only
  registryEntities: Record<string, RegistryEntityVM>; // §6.6 — ppAsal, PT lookup, PP oldData
  documents: DocumentVM[];                    // §6.5 — every uploaded doc
  pdfSources: PdfSourceVM[];                  // what the viewer lists (ordered tabs)
  validationResults: ValidationResultVM[];    // §6.7
  sectionApprovals: Record<string, boolean>;  // section key → approved
  requiredSections: string[];                 // §5.3 — computed per submission
  declarations: DeclarationVM[];              // flow-level checkboxes (PP declarations)
  meta: ReviewMetaVM;                         // §6.8 — expiresAt, aiSummary, flow badges
}

Normalized store + section skeleton. Sections do not embed field/roster objects; they reference the stores by key. One field exists exactly once (single source of truth for optimistic cache patches), and the same roster can appear in a flat section and a diff section without duplication. This is the exact top-level shape the architecture locked (sections + fields + rosters + diff?), with registryEntity? generalized to a keyed record because two flows need more than one (PERALIHAN: ppAsal; PERBAIKAN: PT lookup) — flagged as the only deviation from the locked interface, and it is additive.

5. Sections

5.1 SectionVM — discriminated union by renderer kind

type SectionVM = SectionBase & (
  | { kind: "field-list";  fieldRefs: string[]; confirmMode?: "per-field" | "atomic" }
  | { kind: "roster";      rosterKey: string }
  | { kind: "identity";    people: IdentityPersonVM[] }               // §6.4
  | { kind: "diff";        diffPartKeys: string[] }                   // parts live in diff.parts
  | { kind: "attestation"; attestation: AttestationVM }               // §6.9
  | { kind: "registry-entity"; entityKey: string }
  | { kind: "document-set"; items: DocSetItemVM[] }                   // §6.10 — apostille
  | { kind: "custom";      payloadKind: string; payload: unknown }    // escape hatch, zod-typed per payloadKind
);

interface SectionBase {
  key: string;            // stable, flow-scoped: "modal_saham", "nonpad:direksi", …
  label: string;
  approvable: boolean;    // renders the "Setujui Bagian Ini" gate
  required: boolean;      // must be approved before submit (when visible)
  documentId: string | null; // primary doc for PDF focus when the section header is clicked
}

Eight kinds. The architecture named six (field-list | roster | attestation | registry-entity | diff | custom); this design adds identity (the per-person KTP/NPWP match card — used by 3+ pages, structurally unlike a roster) and document-set (apostille's per-document lifecycle blocks). Step 4 is where the kind list is finalized; these two earn kind status because they are recurring widgets with rich, distinct payloads that would otherwise land in custom and defeat the engine.

custom is the pressure valve for true one-offs (e.g. perubahan's voting-status panel on the notaris page). Rules: a custom payload MUST have a registered zod schema keyed by payloadKind, and a new custom requires a comment justifying why no existing kind fits. The FF-style review gate: if two flows register the same payloadKind, it must be promoted to a real kind.

5.2 SectionSchema (contract, published by backend)

// contract/review.ts — serializable, no functions
interface SectionSchema {
  key: string; label: string; kind: SectionKind;
  approvable: boolean; required: boolean;
  order: number;
  columns?: RosterColumnSchema[];   // kind=roster/diff-roster: column defs
  docTypes?: string[];              // which classifiedTypes feed it (projection hint + DocSwitcher)
}
interface RosterColumnSchema { key: string; label: string; format?: "text" | "currency" | "percent" | "kbli"; }

FlowConfig.sections: SectionSchema[] (backend registry) is the single place a flow declares its review structure. The FE FrontendReviewDescriptor registry maps kind → renderer once, globally; per-flow descriptors only override labels/renderers for custom payloads. Both sides stay compiler-forced Record<SubmissionType, …>.

5.3 Visibility and requiredSections (deferred Q4 — resolved)

SectionSchema.required is the static rule; visibility is dynamic. Jenis-gated flows (PERUBAHAN_PT's pad/ppad/nonpad checklist, PP perubahan/perbaikan's selected sections) include only the visible sections in sections[], and requiredSections is computed at projection time as the keys of visible sections whose schema says required. The submit gate reads requiredSections ⊆ approved — same semantics the perubahan gate enforces today (appearingSectionKeys must all be approved, the c0698d8 regression class), now uniform.

6. The ViewModels

6.1 ReviewFieldVM (deferred Q — resolved) — feeds FieldReviewCard, DiffFieldRow, PDF focus

interface ReviewFieldVM {
  ref: string;                 // store key: "<documentId>:<fieldKey>", or "manual:<fieldKey>" for user-entered fields with no source document (globally unique)
  fieldKey: string;            // raw key as the extraction/mutation layer knows it
  documentId: string | null;
  label: string;
  value: string | null;
  displayValue: string | null; // formatted (dates via formatDateDisplay, currency); null = use value
  confidence: number;          // 0–100 (project convention; REGEX_CONF_CEILING etc. preserved)
  status: FieldStatus;         // @contract enum — LOCKED blocks all ops
  confirmed: boolean;          // notarisConfirmed
  confirmedAt: string | null;
  provenance: "AUTO_OCR" | "USER_EDITED" | "USER_CONFIRMED";
  editable: boolean;           // server-derived: status/flow/phase allow editing
  editReason: string | null;
  bbox: { page: number; x1: number; y1: number; x2: number; y2: number } | null; // 200-DPI px (existing convention)
}

ref = documentId:fieldKey keeps the mutation round-trip trivial (§8) and makes bbox lookup document-local, exactly how ReviewPdfViewer resolves highlights today. Roster-cell fields use the existing composed-key convention (direksi_baru.0.nama) as fieldKey, unchanged.

6.2 RosterVM / RosterRowVM (deferred Q1 — resolved) — feeds roster tables, per-row confirm

interface RosterVM {
  key: string;                        // "direksi" | "komisaris" | "pemegangSaham" | "penghadap" | "kbli" | "buktiSetor" | "pemilikManfaat" | …
  columns: RosterColumnSchema[];      // echoed from SectionSchema for self-containment
  rows: RosterRowVM[];
}
interface RosterRowVM {
  id: string;                         // stable row identity (entity row id)
  cells: Record<string, string | number | null>; // column key → display value
  cellFieldRefs: Record<string, string>;         // column key → ReviewFieldVM ref (when cell is bbox-linked/editable)
  confirmed: boolean;
  status: FieldStatus;
  provenance: ReviewFieldVM["provenance"];
  personRef: string | null;           // links a named row to its IdentityPersonVM (matchId)
  detail: Record<string, unknown> | null; // row drill-down (BO criteria, shareholder jenis/lembar/persentase) — zod-typed per roster key
}

Covers every roster the pages render: akta entity rosters, KBLI, bukti setor, PP beneficial owners (criteria in detail), RUPS attendance. The "atomic modal block" is NOT a roster — it is a field-list section with confirmMode:"atomic" (confirm cascades across its fields), matching today's aktaModal behavior.

6.3 DiffModel (deferred Q2 — resolved; the hardest VM, migrated last)

interface DiffModel { parts: Record<string, DiffPartVM>; }

type DiffPartVM =
  | { kind: "scalar-list"; rows: DiffScalarRowVM[] }
  | { kind: "roster"; columns: RosterColumnSchema[]; rows: DiffRosterRowVM[] }
  | { kind: "pasal"; blocks: DiffPasalVM[] };

interface DiffScalarRowVM {          // DiffFieldRow widget
  key: string; label: string;
  old: string | number | null; new: string | number | null;
  changed: boolean;
  fieldRef: string | null;           // the "new" side's ReviewFieldVM (PDF focus + confirm)
}
interface DiffRosterRowVM {          // DiffTable unified row (+/−/↻/· icons)
  id: string;
  change: "added" | "removed" | "modified" | "unchanged";
  before: Record<string, string | number | null> | null;  // null when added
  after:  Record<string, string | number | null> | null;  // null when removed
  confirmed: boolean;
  fieldRefs: Record<string, string>; // after-side cells → field store
}
interface DiffPasalVM { pasal: string; oldText: string | null; newText: string | null; }

Decisions baked in, matching today's proven UX:
- Granularity: scalar parts are field-level; roster parts are row-level with cell values inline (the unified-row model buildUnifiedRowsFromDiff already produces).
- Row matching before↔after happens server-side in the projector (today's computePerubahanDiff / computePerubahanPpDiff logic is the reference implementation); the FE never re-derives matching.
- Approval: diff sections are approvable sections like any other; row confirms feed sectionChildrenConfirmed exactly as today. Per-jenis sections keep explicit persisted approval (never derived from child state — the c0698d8 lesson).
- Perbaikan corrections are diffs. A correction's lama→baru is a scalar-list or roster part; each correction becomes one diff section. This removes the third payload family entirely.
- Evidence check (Cek Bukti) stays an action endpoint; the read model only carries meta.evidenceCheckAvailable so the engine knows to render the button.

6.4 IdentityPersonVM — feeds PersonIdentityReview

interface IdentityPersonVM {
  matchId: string;
  personName: string;
  roles: string[];                    // ["Direktur Utama", "Pemegang Saham"]
  matchStatus: MatchStatus;           // @contract enum
  docs: { ktp: IdentityDocRef | null; npwp: IdentityDocRef | null };
  contact: { phoneFieldRef: string | null; entryIndex: number | null };
}
interface IdentityDocRef { documentId: string; fieldRefs: string[]; } // fields live in the store

6.5 DocumentVM / PdfSourceVM (deferred Q5 — resolved)

interface DocumentVM {
  documentId: string;
  classifiedType: string;
  userOverride: string | null;
  filename: string;
  pageCount: number;
  extractionStatus: ExtractionStatus;  // @contract enum
  meta: { engine: string | null; model: string | null; processingTimeMs: number | null } | null;
}
interface PdfSourceVM { documentId: string; label: string; originalName: string; pageCount: number; }

Confidence is per-FIELD (§6.1), not per-document — that is how every page renders it. Bboxes ride on fields; the viewer's contract is unchanged (activeDocumentId + activeFieldKey → bbox highlight at 200-DPI coords).

6.6 RegistryEntityVM — ppAsal, PT lookup, PP oldData snapshots

interface RegistryEntityVM {
  source: "SABH" | "PTP";
  matchedBy: "auto" | "manual" | null;
  label: string;                      // "PP Asal", "Data PT Terdaftar"
  groups: { label: string; fields: { label: string; value: string | null }[] }[]; // read-only labeled groups
  candidates: { id: string; label: string }[] | null; // when a picker applies (ambiguous match)
}

Read-only by design — registry snapshots are never edited in review; pickers act via existing flow endpoints.

6.7 ValidationResultVM

interface ValidationResultVM {
  id: string; ruleCode: string; ruleLabel: string;
  status: ValidationStatus;           // @contract enum PASS/WARNING/FAIL/SKIPPED
  message: string; details: unknown | null;
  sectionKey: string | null;          // NEW: lets the engine render results inline in their section
  overridden: boolean; overrideReason: string | null; overridable: boolean;
}

sectionKey is the one addition over today's shape — currently only perbaikan can place validations next to their section; the engine needs it uniformly (nullable: unplaced results render in the global banner).

6.8 ReviewMetaVM

interface ReviewMetaVM {
  expiresAt: string | null;           // konfirmasi deadlines (PP perbaikan/pembubaran); lazy-expiry stays server-side
  aiSummary: string | null;           // markdown (AiSummaryCard)
  evidenceCheckAvailable: boolean;
  revisionFeedback: { decision: "REVISE" | "REJECT"; notes: string | null; at: string } | null; // latestRevise/latestReject surfaced to the notaris
}

6.9 AttestationVM (deferred Q3 — resolved)

interface AttestationVM {
  items: { key: string; label: string; checked: boolean }[];       // S1–S5, PP declarations
  choices: { key: string; label: string; options: { value: string; label: string }[]; value: string | null }[]; // e.g. rupsType rups|bar
  publications: { key: string; label: string; fieldRefs: string[] }[] | null; // capital-reduction newspaper refs
}

Flow-level declarations that are simple checkboxes without section context stay in top-level declarations (PP pattern); section-scoped attestation UIs (perubahan's AttestationBlock) are attestation sections.

6.10 DocSetItemVM — apostille

interface DocSetItemVM {
  documentId: string;
  category: ApostilleCategory;        // @contract enum
  layout: ApostilleDocLayout;         // @contract enum
  classifierConfidence: number | null;
  userOverride: string | null;
  translation: { isTranslation: boolean; underlyingLayout: string | null; translatorRegisterNo: string | null } | null;
  extractionStatus: ExtractionStatus;
  recordFieldRefs: string[];          // documentName/Number/Date/PersonName + per-category fields
  signer: {
    fieldRef: string | null;          // the OCR'd signer-name field
    matchStatus: MatchStatus;
    resolved: { name: string; position: string | null } | null;
    candidates: { id: string; name: string; position: string | null }[] | null; // AMBIGUOUS → picker
  } | null;
}

7. Per-flow section mapping (grounding — every screen expressed in the 8 kinds)

Flow Sections (kind:key)
PENDIRIAN_PT field-list:data_perseroan, field-list:akta_notaris, field-list:notaris_pengganti, field-list(atomic):modal_saham, roster:kbli, roster:direksi, roster:komisaris, roster:pemegang_saham, roster:penghadap, identity:identitas, roster:bukti_setor, field-list:alamat_kontak
PERUBAHAN_PT diff:pad:* (scalar-list; modal = 3 scalar rows), diff:ppad (pasal), diff:nonpad:direksi (roster), diff:nonpad:pemegang_saham (roster), diff:nonpad:peralihan_saham (roster), diff:nonpad:alamat, attestation:attestation, identity:identitas, custom:voting-status (notaris view)
PERBAIKAN_DATA_PT registry-entity:pt_terdaftar, diff: (scalar-list or roster), field-list:email_telp
PENDIRIAN_PP field-list:badan_usaha, field-list:data_pemilik, roster:kbli, roster:pemilik_manfaat, identity:identitas
PERUBAHAN_PP (visible-jenis subset of pendirian sections) + diff:perubahan (scalar-list/roster from oldData), registry-entity:old_data
PERBAIKAN_DATA_PP as PERUBAHAN_PP with perbaikan section keys
PEMBUBARAN_PP registry-entity:pp_terdaftar, field-list:identitas_pemilik, field-list:alasan_pembubaran, attestation:pernyataan
PERALIHAN_PP_KE_PT registry-entity:pp_asal, + the PENDIRIAN_PT section set (akta rosters reused)
APOSTILLE field-list:identitas_pemohon, document-set:dokumen

Every current top-level oddity has a home; nothing needs custom except perubahan's voting-status panel.

8. Mutations (round-trip contract only)

The write side is unchanged in this step. The engine calls today's per-flow endpoints through the createReviewHook(endpoints) factory (arch Pillar 4); ReviewData guarantees the identifiers those endpoints need:

  • Field edit/confirm: ReviewFieldVM.documentId + .fieldKey (exact params of every existing edit route).
  • Row confirm: RosterRowVM.id (+ roster key).
  • Section approve: SectionVM.key — persisted approval rows, never derived (c0698d8).
  • After any mutation, the client re-fetches (today's model) — optimistic patching of the normalized stores is a later optimization the keyed stores make possible, not a requirement.

9. Endpoint, versioning, validation

  • One new endpoint: GET /api/submissions/:id/review (all flows), assembled by loadReviewData(submissionId) dispatching through FLOW_REGISTRY — each FlowConfig provides sections: SectionSchema[] + a projectReview(ctx) projector. The 9 legacy endpoints keep serving their pages until each flow's page migrates (strangler; old endpoint + old page retired together per flow).
  • Oracle: per migrated flow, an equivalence test asserts the new payload contains everything the legacy payload rendered (field-by-field projection oracle, same forks-as-oracle discipline as the flow-engine migration).
  • Contract: zod schemas in contract/review.ts; @contract alias on the FE; response .parse() in dev/test/CI only (config-gated, off in prod hot path); FE parses with the same schema at the query boundary.
  • Auth: the endpoint joins ROUTE_POLICIES like every mount (exhaustiveness-tested); object-level ownership checks ride Submission.ownerId when enforcement lands.
  • Companion (unchanged, separate): GET /api/perubahan/submissions/:id/verifikasi for the verifikator bundle.

10. Migration order & coordination

  1. Design locked now (this spec) — steps 5–7 build against it; no code until akuisisi merges.
  2. First implementations, flat → hard: PENDIRIAN_PP (already section-shaped, simplest) → PEMBUBARAN_PP → PENDIRIAN_PT (the 712-line monolith is the biggest single win) → PERALIHAN → PP perubahan/perbaikan → AKUISISI + PERUBAHAN_PT (diff — last) → PERBAIKAN_PT → APOSTILLE.
  3. The FF4 fork detector's grandfathered PP pair is expected to become deletable at step "PP perubahan/perbaikan" — shrink the allowlist then.
  4. Akuisisi lands on the legacy perubahan shape first (its in-flight branch); it migrates with perubahan in the diff wave. No akuisisi-branch coordination needed for this spec (design-only, no shared files).

11. Risks

  • Diff VM is load-bearing and migrated last — mitigated by designing it against the two working diff implementations (perubahan PT + PP) whose projectors become the reference.
  • custom erosion — mitigated by the zod-registration rule + promote-on-second-use rule (§5.1); a fitness function can enforce "every payloadKind has a registered schema" when implementation starts.
  • Payload size (normalized stores ship all fields once): comparable to today's payloads, which already ship everything; measure at implementation, paginate nothing.

12. Resolved / open questions

Resolved here: the five explicitly deferred by the architecture (rosters §6.2, diff §6.3, attestation §6.9 + custom §5.1, requiredSections §5.3, DocumentVM §6.5) + verifikator separation + perbaikan-as-diff + identity/document-set as first-class kinds.

Open (deliberately, for implementation time): exact zod file layout inside contract/review.ts vs a contract/review/ dir; whether custom:voting-status should instead join the verifikator companion (leaning: keep on notaris payload, it renders on the notaris page today).