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). SectionSchemais shared FE/BE data incontract/; backend publishesSectionSchema[]via FlowConfig; the FE descriptor registry lives entirely on the frontend keyed bySubmissionType(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: |
| 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 byloadReviewData(submissionId)dispatching through FLOW_REGISTRY — eachFlowConfigprovidessections: SectionSchema[]+ aprojectReview(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;@contractalias 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_POLICIESlike every mount (exhaustiveness-tested); object-level ownership checks rideSubmission.ownerIdwhen enforcement lands. - Companion (unchanged, separate):
GET /api/perubahan/submissions/:id/verifikasifor the verifikator bundle.
10. Migration order & coordination
- Design locked now (this spec) — steps 5–7 build against it; no code until akuisisi merges.
- 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.
- The FF4 fork detector's grandfathered PP pair is expected to become deletable at step "PP perubahan/perbaikan" — shrink the allowlist then.
- 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.
customerosion — 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).