FlexPoint Docs
Engineering

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

DriverAdapterUse
mlmApp\Adapters\Disclosure\MlmDocumentFrameworkAdapterMeridianLink Document Framework (DocMagic).
docmagicApp\Adapters\Disclosure\DocMagicLqbAdapterLegacy DocMagic-via-LendingQB SOAP bridge.
nullApp\Adapters\Disclosure\NullDisclosureAdapterOffline/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 methodSOAP operation
listPackagesDocumentFrameworkGetAvailableDocPackagesAndPlanCodes
auditDocumentFrameworkPerformAudit
generate / previewDocumentFrameworkGenerateDocsDocumentFrameworkDownloadGeneratedDocument (chained)
formsListDocumentFrameworkGetFormsList
historyLoad (reads the loan file's LE/CD status-date collections)
auditLogGetAuditLog

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-log

The 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:

ConceptMLM fieldHow 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 FixedsFinMethT (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.

On this page