Change of Circumstance
Architecture of the Change of Circumstance request — the typed details value object, the form-context read model, the submit/PDF flow, the documentation-only MeridianLink writes, and the binary PDF routes.
The Change of Circumstance (COC) request is an extension of the loan-request
graph, not a new subsystem. It reuses the existing LoanRequest model
(request_type = coc) and its lifecycle, and adds a typed payload, a form-context
read model, a submit action, and server-rendered PDF.
Documentation-only writes
Submitting writes only three things to MeridianLink:
sU3LStatD (disclosure-request date), sU3LStatN
(comment "COC Requested"), and the uploaded PDF tagged with
custLoan593. The per-field "new values" are captured in the PDF +
the local loan_requests.details record only — never pushed as
field edits. This is intentional; applying the changes stays manual for now.
The data contract
loan_requests.details is cast to a typed value object — never a bare array —
so the structured payload is type-safe end to end (FormRequest validates on
write, the cast guarantees the shape, the Resource emits toArray(), Zod
re-validates at the BFF edge).
| Piece | Location |
|---|---|
| Root value object | App\Support\LoanRequests\LoanRequestDetails |
| Nested VOs | CocChangedItem, CocCreditScoreChange, CocExistingFee, CocAdditionalFee, CocCompensation (same namespace) |
| Cast | App\Casts\AsLoanRequestDetails |
| Reason enum | App\Enums\ComplianceCocCategory (8 codes + code() / label() / options()) |
| Zod mirror | apps/flex-hub/src/lib/schemas/coc-request.ts (loanRequestDetailsSchema, cocFormContextSchema) |
The payload holds reason_category, reason_detail, requested_date,
requested_by_name, an array of generic changed_items (key / label /
current_value / new_value), per-borrower credit_scores, a fees
(existing + additional) block, and a compensation (source + current +
new) block.
HTTP surface
Thin controllers wire FormRequest → Policy → Action → Resource. All four routes
sit before the {loanRequest} wildcard so the static coc segment is not
captured.
# apps/flex-hub-api/routes/api.php (loan group)
GET loans/{loan}/requests/coc/form-context # CocFormContextController@show
POST loans/{loan}/requests/coc # CocRequestController@submit
POST loans/{loan}/requests/coc/preview-pdf # CocRequestController@previewPdf (no persist)
GET loans/{loan}/requests/{loanRequest}/pdf # CocRequestController@pdfAuthorization is data-scoped
LoanRequestPolicy is permission-only, so every COC endpoint also
authorizes view on the loan (the data-scoped LoanPolicy,
which ANDs the permission with the per-user visibility scope). A caller can
never act on a loan outside their visibility — the cross-tenant 403 is tested.
Form context (the read model)
GET …/coc/form-context (App\Actions\Requests\BuildCocFormContext) assembles
what the form pre-fills with: current values (rate, amount, occupancy, property
type, purpose, appraised value, program, term, doc type), per-borrower credit
scores, fees over $0, current compensation, the reason categories, and the
enum-backed option lists for occupancy / property type / loan purpose.
Compensation detail values (percent / flat / total) aren't stored as
columns, so they are read on demand from MeridianLink with the caller's read
ticket — best-effort: a missing session or a still-provisional field id degrades
to null, never an error. The program / term / doc-type option lists are
supplied client-side from the loan-program catalog, so they are intentionally
absent from this payload.
The submit flow
App\Actions\Requests\SubmitCocRequest runs MLM-first, mirroring
SaveMlmLoanFields:
- Render the PDF first (no side effects) so an environment/render failure aborts before anything is persisted.
- If the loan is MeridianLink-linked, resolve the caller's own session ticket up front — a missing session fails fast with a 422.
- In one
DB::transaction: create theLoanRequest(status = submitted), write the two disclosure flat fields viaLoanFieldProvider::saveFields(a vendor throw rolls the whole transaction back), and store the PDF as aLoanDocument. - After commit, dispatch
PublishLoanDocumentToMlmJob, which uploads the PDF and — on a successful Change-of-Circumstance upload — stampscustLoan593with the upload date via a follow-upsaveFields(best-effort; a marker failure must never re-upload the document).
The COC-specific MeridianLink field ids live in App\Support\Mlm\MlmCocFieldMap
(kept out of the general loan-editor field map so they don't widen the writable
whitelist).
The MLM ids are PROVISIONAL
sU3LStatD, sU3LStatN, custLoan593, and
the compensation read ids come from the SP-219 ticket and are recorded in the
MLM knowledge base as provisional. Confirm them against a live
probe (and record the observation) before relying on them in production.
PDF rendering
App\Services\LoanRequests\CocPdfRenderer renders
resources/views/loans/coc-request-pdf.blade.php to PDF bytes via Spatie
Browsershot (headless Chromium), reusing the shared config('pricer.pdf.*')
binary settings. The same renderer serves both the draft preview and the
uploaded copy, so they can never drift.
Because the generic /api/v1/[...path] BFF proxy reads upstream bodies as text
(which corrupts binary), the PDF is served through dedicated binary route
handlers that stream arrayBuffer():
| Route | Purpose |
|---|---|
apps/flex-hub/src/app/api/v1/loans/[id]/requests/coc/preview-pdf/route.ts | POST the draft → preview PDF |
apps/flex-hub/src/app/api/v1/loans/[id]/requests/[requestId]/pdf/route.ts | GET a saved request's PDF |
Frontend
| Piece | Location |
|---|---|
| Form-context loader | apps/flex-hub/src/lib/server/coc-request.ts (getCocFormContext) |
| Workspace | apps/flex-hub/src/app/(app)/loans/[id]/coc-request-workspace.tsx |
| Value table (generic rows) | apps/flex-hub/src/components/requests/coc-value-table.tsx |
| Leaf components | ppt-picker, compensation-calculator, additional-fees-editor, credit-score-warning (under components/requests/) |
The workspace is mounted from the Requests tab (requests-section.tsx shows
a New COC request button when form-context is available). It assembles the
request-info section, the changed-items grid, the value table, and the structured
rows; Submit POSTs the details payload; Preview PDF POSTs the same
draft to preview-pdf and opens the returned blob.
Reuse for the Initial Closing Disclosure
The Initial Closing Disclosure (ICD) request reuses this contract, form, and
PDF pipeline — the same LoanRequestDetails value object and workspace,
parameterized by request type — so the two request forms stay consistent.
Authorization & Visibility
The two independent gates every request passes — permission (what you can do) and data-scoped visibility (which records you can see) — and why even admins hold no visibility bypass.
Initial Closing Disclosure
Architecture of the Initial Closing Disclosure request — the additive value-object blocks, the eligibility read model, the submit flow with its three field writes and two collection syncs, the type-dispatched PDF route, and the MeridianLink field provenance.