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.
The Initial Closing Disclosure (ICD) request is an extension of the Change of
Circumstance feature, not a new subsystem. It reuses the LoanRequest model
(request_type = icd), the LoanRequestDetails value object, the COC form
workspace, and the PDF pipeline — and adds an eligibility pre-flight, a
business-purpose block, and two MeridianLink collection syncs (Contacts and
Non-Obligor Borrowers). Read the
Change of Circumstance engineering page
first; this page documents only the deltas.
The data contract
The COC details value object is reused unchanged for the ICD change-item body.
Two optional blocks are added, populated only for ICD:
| Piece | Location |
|---|---|
| Additive blocks on the root VO | App\Support\LoanRequests\LoanRequestDetails (contacts, nonObligorBorrowers) |
| Nested VOs | App\Support\LoanRequests\IcdContact, IcdNonObligorBorrower |
| Eligibility result VO | App\Support\Requests\IcdEligibility (+ IcdEligibilityCheck) |
| Contact-role enum | App\Enums\IcdAgentRole (label() + agentRoleCode()) |
| Document category | App\Enums\DocumentCategory::InitialClosingDisclosure |
| Zod mirrors | apps/flex-hub/src/lib/schemas/icd-eligibility.ts, icd-request.ts, and icdAgentRole in enums.ts |
The COC-only payload round-trips unchanged — the contacts /
non_obligor_borrowers keys are omitted from toArray() when empty, so the
SP-219 shape is byte-for-byte preserved (asserted in LoanRequestDetailsTest).
HTTP surface
Thin controllers wire FormRequest → Policy → Action → Resource. The two static
ICD routes sit before the {loanRequest} wildcard so the icd segment is
not captured.
# apps/flex-hub-api/routes/api.php (loan group)
GET loans/{loan}/requests/icd/eligibility # IcdEligibilityController@show
POST loans/{loan}/requests/icd # IcdRequestController@submit
GET loans/{loan}/requests/{loanRequest}/pdf # LoanRequestController@pdf (shared, type-dispatched)The saved-request PDF route is type-dispatched
The shared {loanRequest}/pdf route resolves to
LoanRequestController@pdf, which renders with the request type's
own renderer: an ICD request renders via RenderIcdRequestPdf (so
it carries the Contacts + Non-Obligor sections), everything else via
CocPdfRenderer. The downloaded copy therefore always matches the
copy filed to MeridianLink on submit.
Authorization is data-scoped
Every ICD endpoint authorizes view on the loan (the data-scoped
LoanPolicy), in addition to the request permission, so a caller
can never act on a loan outside their visibility. The cross-tenant 403 and the
cross-loan 404 are both tested.
The eligibility read model
GET …/icd/eligibility (App\Actions\Requests\BuildIcdEligibility, emitted by
IcdEligibilityResource) returns { eligible, is_business_purpose, message, checks[] }. It is hybrid: it reuses locally-hydrated loan columns where they
exist and reads only the genuinely-missing fields live from MeridianLink with the
caller's read ticket.
| Check | Source |
|---|---|
loan_status | loan.vendor_loan_status (hydrated) |
locked | loan.rate_locked_at (hydrated) |
initial_approval_date | loan.uw_approved_date (hydrated) |
final_hazard_insurance | sFinalHazInsPolicyReceivedD (live) |
appraisal | loan.appraisal_received_date / sSpValuationEffectiveD / sHasPropertyInspectionWaiver (PIW) |
| business-purpose | sIsExemptFromAtr (live) → is_business_purpose |
A loan with no MeridianLink number, or a degraded live read, returns
eligible: false with the live-dependent checks marked not-ok and a "Live loan
data is unavailable" detail — it never errors. eligible is true only when every
check passes and is_business_purpose is false.
The submit flow
App\Actions\Requests\SubmitIcdRequest mirrors SubmitCocRequest, MLM-first:
- Resolve the caller's own session ticket; a missing session fails fast with a
422 (
MlmTicketExpiredException). - Re-check eligibility via
BuildIcdEligibility; an ineligible or business-purpose loan is rejected with a 422 (IcdNotEligibleException) and nothing is written. - Render the PDF first (no side effects) so a render failure aborts before any persistence.
- In one
DB::transaction: create theLoanRequest(status = submitted), write the three disclosure fields viaLoanFieldProvider::saveFields, sync the two collections, and store the PDF as aLoanDocument. - After commit, dispatch
PublishLoanDocumentToMlmJobto upload the PDF.
The ICD-specific field ids live in App\Support\Mlm\MlmIcdFieldMap
(sU3LStatD = today, sU3LStatN = "ICD Requested", sEstHUDOd = estimated CD
date), kept out of the general loan-editor field map so they don't widen the
writable whitelist.
Collection writes
App\Support\Mlm\MlmAgentCollectionMap builds the two collection payloads, both
written through the existing LoanFieldProvider::saveCollection /
saveCollections port (no new vendor seam):
- Contacts →
sAgentDataSet— eachIcdContactbecomes a record with theAgentRoleTinteger fromIcdAgentRole::agentRoleCode(). A contact carrying an existing record id is anedit; one without is anadd(LqbTempId_*). - Non-Obligor Borrowers →
sTitleBorrowersJsonContent— a flat JSON-stringified field (whole-field replace). It is written only when at least one borrower is present, so an existing title set is never clobbered with an empty value.
PDF rendering
App\Actions\Requests\RenderIcdRequestPdf renders
resources/views/loans/icd-request-pdf.blade.php to PDF bytes via Spatie
Browsershot, reusing the shared config('pricer.pdf.*') binary settings. The
Blade template extends the shared COC table markup with Contacts and
Non-Obligor Borrowers sections. The same renderer serves the submit path and
the saved-request PDF route, so the filed and downloaded copies never drift.
PublishLoanDocumentToMlmJob uploads the document with the doc type resolved
from config('vendors.edocs.doc_type_map') — initial_closing_disclosure maps
to CD - REQUEST FORM. Unlike COC/Doc Order, the ICD upload stamps no extra
marker field (its disclosure fields are written inline at submit, not on upload).
Frontend
| Piece | Location |
|---|---|
| Eligibility loader | apps/flex-hub/src/lib/server/icd-eligibility.ts (getIcdEligibility) |
| Gate | apps/flex-hub/src/components/requests/icd-eligibility-gate.tsx |
| Workspace | apps/flex-hub/src/app/(app)/loans/[id]/icd-request-workspace.tsx |
| Step components | icd-contacts-step.tsx, icd-non-obligor-step.tsx |
| Leaf components | icd-eligibility-checklist, business-purpose-block, contacts-editor, non-obligor-borrowers-editor (under components/requests/) |
The loan Requests tab loads eligibility alongside the COC form context and
passes it to RequestsSection, which shows a New ICD request button. Opening it
mounts IcdEligibilityGate, which renders the business-purpose block, the
ineligibility checklist, or the workspace. A saved request exposes a View PDF
link that opens the shared binary PDF route in a new tab.
Some MeridianLink ids are observed, not confirmed
In the MLM knowledge base, the agent-role codes lender 21,
loan_officer 19, broker 26, and title 4
are confirmed (live-probe). The remaining roles
(closing_agent 33, buyer_agent 32,
listing_agent 6, selling_agent 7), the
sU3LStatD / sU3LStatN / sEstHUDOd
writes, and the sTitleBorrowersJsonContent shape are
observed. Confirm them against a live probe — and record the
observation — before relying on them in production.
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.
Disclosures
Architecture of the disclosure feature — the DisclosureProvider port, the MeridianLink Document Framework adapter and its operation map, the caller-ticket model, and driver config.