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.
Disclosure generation follows the platform's ports-and-adapters rule: the domain depends on a vendor-neutral port, and every MeridianLink/DocMagic specific is confined to one adapter. Swapping vendors is a config change, not a code change.
The port
App\Contracts\Vendor\Disclosure\DisclosureProvider is the seam. The whole
lifecycle is expressed as value-object requests and responses:
interface DisclosureProvider
{
public function listPackages(DisclosurePackageRequest $r): DisclosurePackageListing; // packages[] + planCodes[]
public function audit(DisclosureAuditRequest $r): DisclosureAuditResult; // returns a generation ticket
public function generate(DisclosureGenerationRequest $r): GeneratedDisclosure; // eSign
public function preview(DisclosureGenerationRequest $r): GeneratedDisclosure; // no eSign
public function formsList(DisclosureFormsRequest $r): array; // list<DisclosureForm>
public function history(DisclosureHistoryRequest $r): DisclosureHistory; // LE + CD status dates
public function auditLog(DisclosureAuditLogRequest $r): array; // list<DisclosureAuditLogEntry>
public function name(): string;
}The DTOs (all final readonly value objects with a toArray()) live under
App\Contracts\Vendor\Disclosure\*. Controllers map them to the API Resource
envelope; the web BFF mirrors that envelope with Zod
(apps/flex-hub/src/lib/schemas/disclosure.ts).
Adapters and driver config
| Driver | Adapter | Use |
|---|---|---|
mlm | App\Adapters\Disclosure\MlmDocumentFrameworkAdapter | MeridianLink Document Framework (DocMagic). |
docmagic | App\Adapters\Disclosure\DocMagicLqbAdapter | Legacy DocMagic-via-LendingQB SOAP bridge. |
null | App\Adapters\Disclosure\NullDisclosureAdapter | Offline/test — synthetic stub, no external calls. |
The active driver is bound from config/vendors.php (disclosure block) via
DISCLOSURE_DRIVER. The mlm driver's DocMagic credentials —
vendor_id / username / password / account_identifier — come from env
(DOCMAGIC_*) and are passed as operation parameters, never hard-coded.
Test-data safety
vendors.disclosure.require_test_surname (on by default outside
production) blocks generation unless the primary borrower's last name contains
"TEST", so a real borrower can never be disclosed against during testing.
Document Framework operation map
MlmDocumentFrameworkAdapter speaks SOAP 1.2 to Loan.asmx (raw
application/soap+xml POSTs via the MlmSoapTransport trait, so the transport
is Http::fake()-able). Each port method maps to a Document Framework
operation:
| Port method | SOAP operation |
|---|---|
listPackages | DocumentFrameworkGetAvailableDocPackagesAndPlanCodes |
audit | DocumentFrameworkPerformAudit |
generate / preview | DocumentFrameworkGenerateDocs → DocumentFrameworkDownloadGeneratedDocument (chained) |
formsList | DocumentFrameworkGetFormsList |
history | Load (reads the loan file's LE/CD status-date collections) |
auditLog | GetAuditLog |
Generation is a two-step chain: GenerateDocs returns a handle that
DownloadGeneratedDocument resolves to the PDF bytes, returned as base64 on
GeneratedDisclosure and never persisted.
The caller-ticket model
Disclosures are a compliance write, so every Document Framework call carries
the acting user's own MeridianLink session ticket (sTicket), not a
service-account ticket. The controller resolves it with
App\Services\Identity\MlmSessionTicketResolver::currentPersonalTicket() (backed
by MlmTicketStore) and passes it on the request DTO as mlmTicket. MeridianLink
then enforces that user's permissions and attributes them in the audit log — which
is exactly what the Audit History view (AC4) surfaces. A missing ticket fails
loudly ("reconnect to MeridianLink") rather than silently falling back.
The DocMagic vendor credentials are a separate credential set from the caller ticket; both travel on the same operation.
HTTP surface
Thin controllers (App\Http\Controllers\DisclosureController for loans,
LeadDisclosureController for leads) wire FormRequest → Policy → adapter →
Resource. Supporting actions: SaveDocMagicPlanCode, SetDisclosureArchiveStatus,
and BuildDisclosureAuditHistory; Support\Mlm\MlmDisclosureDatesFieldMap
normalizes the status-date collections.
# loans (apps/flex-hub-api/routes/api.php ~L491)
GET loans/{loan}/disclosures/packages
GET loans/{loan}/disclosures/events # loans only (webhook-fed timeline)
POST loans/{loan}/disclosures/audit
POST loans/{loan}/disclosures/generate
POST loans/{loan}/disclosures/preview
GET loans/{loan}/disclosures/forms
GET loans/{loan}/disclosures/history
GET loans/{loan}/disclosures/audit-log
# leads (~L382) — same set, MINUS events
GET leads/{lead}/disclosures/packages
POST leads/{lead}/disclosures/audit
POST leads/{lead}/disclosures/generate
POST leads/{lead}/disclosures/preview
GET leads/{lead}/disclosures/forms
GET leads/{lead}/disclosures/history
GET leads/{lead}/disclosures/audit-logThe web tab composes four cards in
apps/flex-hub/src/app/(app)/loans/[id]/disclosures-section.tsx (workflow, past
disclosures, audit history, and — loans only — the eSign timeline). The lead twin
renders the same section under the entity context with events = null, so the
timeline is omitted. The eSign timeline itself is fed by the vendor webhook; see
Engineering → Event-Driven Architecture.
Anti-steering safe harbor (lender-paid loans)
For a lender-paid (LPC) loan the originator records a three-option safe-harbor comparison to the LOS before disclosures go out (Reg Z loan-originator comp). FlexPoint surfaces it as a non-blocking advisory, not a hard gate.
Triggers. Two MLM fields decide the behavior:
| Concept | MLM field | How it's read |
|---|---|---|
| Lender-paid? | sOriginatorCompensationPaymentSourceT (2 = LenderPaid) | promoted to Loan.originator_comp_source (OriginatorCompType enum) via MlmLoanFieldMap, hydrated on every LoadByRefNumber sync |
| ARM vs Fixed | sFinMethT (0 = Fixed) | read live |
originator_comp_source is promoted because the product reasons about it (the
advisory, an LPC filter); the 14 …SafeHarbor… table fields are a sparse write
payload read/written live via LoanFieldProvider, never persisted.
Field map + actions. App\Support\Mlm\MlmAntiSteeringFieldMap owns the 14 ids
(7 ARM + 7 Fixed) and the isLpc / isArm decode; AntiSteeringSafeHarbor is
the read value object (is_lpc / is_arm / is_complete / fields).
App\Actions\Disclosures\ReadAntiSteeringSafeHarbor loads the active set in one
round trip; SaveAntiSteeringSafeHarbor resolves the ARM|Fixed variant
server-side from sFinMethT and writes only that variant's ids.
The advisory. DisclosureController@generate (and the lead twin) attaches
warnings: ["anti_steering_incomplete"] to the response when the loan is LPC and
the table is incomplete, then proceeds — it never returns 422 or blocks.
Config-gated by vendors.disclosure.warn_safe_harbor (DISCLOSURE_WARN_SAFE_HARBOR,
on by default).
HTTP + web. GET/PUT loans|leads/{id}/disclosures/anti-steering read the
state and save the cells. The web tab renders
apps/flex-hub/src/app/(app)/loans/[id]/disclosures/anti-steering-card.tsx — a
self-gating banner (only for LPC) with a 3×(rate, comp) form — above the workflow
card; Generate stays enabled.
ARM variant is not yet live-verified
Only the Fixed field set (sFinMethT = 0) is
live-confirmed. The selector currently treats any non-Fixed
sFinMethT value as ARM (a heuristic), and the 14 SafeHarbor wire
shapes plus the end-to-end mlm-driver flow are unverified for an
ARM lender-paid loan. The step-by-step verification checklist lives in the
source repo at
docs/doc-generation-resources/plan-phases/60-anti-steering-safe-harbor.md.
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.
Event-Driven Architecture
How domain events, listeners, and the workflow engine compose automation in the FlexPoint API.