diff --git a/.gitignore b/.gitignore
index c4b4782..54b7b1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,4 @@ Thumbs.db
# Logs
*.log
logs/
+data/
diff --git a/API-SPEC.md b/API-SPEC.md
index 51fda6b..7cf77b6 100644
--- a/API-SPEC.md
+++ b/API-SPEC.md
@@ -176,6 +176,32 @@ A price far from last week's **warns and is accepted** — a genuine price shock
**The loan book carries no borrower identity.** A file whose headers include a name, phone, Aadhaar, PAN, account number or address is **refused whole**, with the offending columns named. Dropping the column quietly would let a bank believe it had sent something we promised to keep and had not.
+A header matches on the whole normalised name **and on each part**, splitting separators and camelCase alike — a real core ships `CUST_NAME` and an in-house LMS ships `borrowerMobile`, neither of which an exact-match list catches. `loan_account_ref` splits to `loan`/`account`/`ref` and stays clean, which is why this is part-matching and not substring-matching.
+
+### Core banking / LMS integration
+
+The loan book is the one input no public feed supplies, and no two lenders expose it the same way. What exists is the seam, not an integration: an adapter per transport, a mapping per bank, and one validator — mapped rows are handed to the same `LoanBookEntryService.stage` an uploaded spreadsheet goes through, and land in the same `manual_entry_batch` with the same preview.
+
+| Method | Path | Notes |
+|---|---|---|
+| `GET` | `/admin/connectors/catalog` | every adapter, its dialect, and `simulated` |
+| `GET` | `/admin/connectors/canonical-columns` | the template a mapping targets, plus what may never be mapped in |
+| `GET` | `/admin/connectors/profiles`, `/admin/connectors/profiles/{code}` | configured links |
+| `POST` | `/admin/connectors/profiles` | onboard a lender |
+| `PUT` | `/admin/connectors/profiles/{code}` | mapping and settings; **an invalid mapping is refused here**, not at the next nightly run |
+| `POST` | `/admin/connectors/profiles/{code}/test` | reaches the source and shows the columns. **Stages nothing.** |
+| `POST` | `/admin/connectors/profiles/{code}/sync` | pulls and stages; writes only if the profile has `autoCommit` |
+| `GET` | `/admin/connectors/runs` | every attempt, successful or not |
+| `GET` | `/admin/connectors/runs/{runId}/preview` | the staged batch behind a run |
+
+**One adapter is real.** `FILE_DROP` reads an actual extract from a directory a bank delivers to (`udyam.connector.file-drop.dir`, default `./data/cbs-drop`), newest file matching the profile's glob, left where it was found so a failed run is repeatable. `FINACLE`, `FLEXCUBE`, `BANCS` and `CUSTOM_API` are **simulated**: they generate an extract in each core's own column dialect so the mapping layer is exercisable end to end. `simulated` is stamped per run, not read from the profile — switching a profile to a live adapter must not retroactively make its old runs look real.
+
+**The mapping is `{canonical_column: vendor_column}`, and the direction matters.** Keyed by the canonical name, so a vendor column nobody mapped has no route through: unmapped columns are **dropped at the boundary** and counted on the run. That is how `CUST_NAME` and `MOBILE_NO` stay out of a system that promised not to hold them — structurally, rather than by anyone remembering.
+
+**Auto-commit is off by default.** A sync stages and stops, preserving the manual path's rule that nothing is written until someone has seen what will be. A bank that wants the nightly feed unattended turns it on per profile, knowingly.
+
+A failed run is still a run. Nothing to read is `SUCCESS` with a message, not `FAILED` — a bank that has not delivered tonight's file is a normal state, and conflating it with a broken connector trains everyone to ignore the status.
+
---
## 5. `/app/**` — the enterprise PWA
@@ -201,6 +227,6 @@ Never called in a request path. A nightly job writes signals; the API serves onl
| Mandi prices | data.gov.in resource `35985678-0d79-46b4-9ed6-6f13308a1d24` | filters are `State`/`District`/`Commodity`, capitalised and exact; `sort[Arrival_Date]=desc` is required |
| Rainfall, temperature | Open-Meteo archive | no key; meters by request cost, so a rate-limited call retries with backoff |
| Egg, milk, fodder | **no usable API** | manual entry |
-| Loan book | CBS/LMS extract | manual upload until a nightly feed exists |
+| Loan book | CBS/LMS extract | connector seam built; one real adapter (file drop), four simulated dialects. Manual upload remains the fallback |
See `ARCHITECTURE.md` §4 for provenance and the degradation rules.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 5b06c27..0bea7a3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -217,6 +217,7 @@ believes.
| Feature store + derivations | ✅ Phase C — see §6.2 |
| Risk engine + rule engine + admin API | ✅ Phase D — see §6.3 |
| Console wire-up: rules screen, market panel, freshness | ✅ Phase E — see §6.4 |
+| Core banking / LMS connector seam | ✅ Phase F — see §6.5 |
| LightGBM quantile heads + SHAP | ○ R1 milestone |
Phases B, C and D each terminate in a table you can inspect, so each is testable
@@ -339,6 +340,47 @@ a worse idea than the round trip through an API client.
---
+### 6.5 What Phase F delivered
+
+Package `com.lincfusion.udyamlinc.connector`, plus `/officer/integration`.
+
+**The problem it answers.** The loan book is the training label and the one input
+no public feed supplies. Integrating with a lender's core is not a technical
+unknown — it is an onboarding process per bank, with a schema agreed in a room.
+There is no single integration to build ahead of that room. What can be built
+ahead of it is the shape of the seam, and that is what this is.
+
+| Piece | Notes |
+|---|---|
+| `CoreBankingConnector` | Fetches and returns rows. Never writes — an adapter that could write would be an adapter that could bypass the no-PII rule. |
+| `FileDropConnector` | **The one that is real.** Reads the newest matching file from a drop directory a bank delivers to. Leaves the file where it found it, so a failed run is repeatable. |
+| `SimulatedCoreConnector` × 4 | Finacle, FLEXCUBE, BaNCS and an in-house REST LMS, each emitting the same portfolio in its own column dialect. Rows derived from real enterprises and loan records, drift seeded from the account ref so an extract is reproducible. |
+| `FieldMapper` | `{canonical_column: vendor_column}`. Validated before it is stored, not at the next nightly run. |
+| `ConnectorSyncService` | Fetch → map → **`LoanBookEntryService.stage`** → run record. |
+| `ConnectorProfile`, `ConnectorSyncRun` | Per-bank configuration and per-attempt audit. |
+| `features/officer/IntegrationPage.tsx` | Connections, field mapping, sync history. Simulated adapters badged everywhere they appear. |
+
+**Three decisions worth keeping.** The mapped rows go through the validator the
+manual upload already had rather than a second one written for the automated
+path — the obvious shape would be a parallel validator, and it would eventually
+enforce the no-PII rule differently. Unmapped vendor columns are dropped because
+the map is keyed by canonical name, which makes the promise structural rather
+than remembered. And `simulated` is stamped per run rather than read from the
+profile, so switching a profile to a live adapter cannot retroactively make its
+old runs look real.
+
+**One real defect this surfaced.** `FORBIDDEN_COLUMNS` was matched by exact
+normalised name, so `CUST_NAME` — what Finacle actually calls it — was not
+caught, and a directly uploaded bank file would have kept the borrower's name in
+`manual_entry_line.raw_row`. Matching now also splits separators and camelCase.
+That fix belongs to the manual upload path as much as to the connector.
+
+**Still not built:** SFTP polling with credentials (the drop is assumed mounted or
+synced by something the bank already runs), scheduled execution (`scheduleCron` is
+recorded and read by nothing), and incremental watermarks.
+
+---
+
## 7. Explicitly not being built yet
Kafka, Neo4j, Elasticsearch, Keycloak, MinIO, and separate deployables. All appear
diff --git a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorCatalog.java b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorCatalog.java
new file mode 100644
index 0000000..2485fda
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorCatalog.java
@@ -0,0 +1,150 @@
+package com.lincfusion.udyamlinc.connector;
+
+import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
+import com.lincfusion.udyamlinc.enterprise.LoanRecordRepository;
+import com.lincfusion.udyamlinc.institution.InstitutionRepository;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+
+/**
+ * Every adapter the platform can run, and the dialect each one speaks.
+ *
+ *
The four simulated dialects are not decoration. Read the {@code columns}
+ * maps side by side and the case for a mapping layer makes itself: the days-past
+ * due field is {@code DPD_DAYS}, {@code OVERDUE_DAYS}, {@code DAYS_OVERDUE} and
+ * {@code daysPastDue} depending on whose core it is, and a bank will not be
+ * renaming it for us. Column names are drawn from the public documentation and
+ * common extract conventions for each core; a given bank's actual export will
+ * differ again, which is exactly why the mapping is per-profile data rather than
+ * per-vendor code.
+ *
+ *
{@code extraColumns} on each dialect carries the fields a real export ships
+ * that this system has promised never to store — the borrower's name, a mobile
+ * number. They are emitted so that the mapping screen can show them arriving and
+ * being dropped unmapped at the boundary. An extract that were already clean
+ * would let a reviewer assume the promise is kept by luck.
+ */
+@Component
+@RequiredArgsConstructor
+public class ConnectorCatalog {
+
+ private final FileDropConnector fileDropConnector;
+ private final EnterpriseRepository enterpriseRepository;
+ private final LoanRecordRepository loanRepository;
+ private final InstitutionRepository institutionRepository;
+
+ private final Map byVendor = new LinkedHashMap<>();
+
+ @PostConstruct
+ void register() {
+ add(fileDropConnector);
+ add(simulated(finacle()));
+ add(simulated(flexcube()));
+ add(simulated(bancs()));
+ add(simulated(customApi()));
+ }
+
+ private void add(CoreBankingConnector connector) {
+ byVendor.put(connector.vendor(), connector);
+ }
+
+ private SimulatedCoreConnector simulated(SimulatedCoreConnector.Dialect dialect) {
+ return new SimulatedCoreConnector(dialect, enterpriseRepository, loanRepository, institutionRepository);
+ }
+
+ public List all() {
+ return List.copyOf(byVendor.values());
+ }
+
+ public Optional find(String vendor) {
+ return Optional.ofNullable(byVendor.get(vendor == null ? "" : vendor.trim().toUpperCase(Locale.ROOT)));
+ }
+
+ // ── dialects ────────────────────────────────────────────
+
+ private SimulatedCoreConnector.Dialect finacle() {
+ LinkedHashMap columns = new LinkedHashMap<>();
+ columns.put("enterprise_ref", "ENTITY_REF");
+ columns.put("loan_account_ref", "CUST_ACCT_ID");
+ columns.put("product", "SCHM_CODE");
+ columns.put("sanctioned_amount", "SANCT_AMT");
+ columns.put("outstanding", "PRIN_OS");
+ columns.put("emi_amount", "INSTL_AMT");
+ columns.put("due_date", "NEXT_DUE_DT");
+ columns.put("last_paid_date", "LAST_PAID_DT");
+ columns.put("dpd", "DPD_DAYS");
+ columns.put("bucket", "ASSET_CLASS");
+ columns.put("closed", "ACCT_CLS_FLG");
+ columns.put("as_of_date", "EXTRACT_DT");
+ return new SimulatedCoreConnector.Dialect("FINACLE", "Infosys Finacle (simulated)", "FILE",
+ "Finacle exports through a scheduled CRM/Reports extract. A bank enables it, "
+ + "agrees the column list, and the file lands on the drop path — at which point "
+ + "this profile switches to the file-drop adapter with the mapping already made.",
+ columns, List.of("CUST_NAME", "MOBILE_NO", "BRANCH_CODE", "CCY"));
+ }
+
+ private SimulatedCoreConnector.Dialect flexcube() {
+ LinkedHashMap columns = new LinkedHashMap<>();
+ columns.put("enterprise_ref", "CUSTOMER_REF");
+ columns.put("loan_account_ref", "ACCOUNT_NUMBER");
+ columns.put("product", "PRODUCT_CODE");
+ columns.put("sanctioned_amount", "AMOUNT_FINANCED");
+ columns.put("outstanding", "PRINCIPAL_OUTSTANDING");
+ columns.put("emi_amount", "INSTALMENT_AMOUNT");
+ columns.put("due_date", "NEXT_DUE_DATE");
+ columns.put("last_paid_date", "LAST_PAYMENT_DATE");
+ columns.put("dpd", "OVERDUE_DAYS");
+ columns.put("bucket", "NPA_STAGE");
+ columns.put("closed", "ACCOUNT_STATUS");
+ columns.put("as_of_date", "VALUE_DATE");
+ return new SimulatedCoreConnector.Dialect("FLEXCUBE", "Oracle FLEXCUBE (simulated)", "JDBC",
+ "FLEXCUBE is usually read through a reporting schema or an extract view rather than "
+ + "the transaction tables. Needs a read-only account scoped to the loan module, "
+ + "and the bank's DBA in the room.",
+ columns, List.of("CUSTOMER_NAME", "CONTACT_MOBILE", "BRANCH_CODE"));
+ }
+
+ private SimulatedCoreConnector.Dialect bancs() {
+ LinkedHashMap columns = new LinkedHashMap<>();
+ columns.put("enterprise_ref", "PARTY_REF");
+ columns.put("loan_account_ref", "ARRANGEMENT_ID");
+ columns.put("product", "PRODUCT_GROUP");
+ columns.put("sanctioned_amount", "LIMIT_AMT");
+ columns.put("outstanding", "OUTSTANDING_BAL");
+ columns.put("emi_amount", "EMI");
+ columns.put("due_date", "DUE_DATE");
+ columns.put("last_paid_date", "PAID_DATE");
+ columns.put("dpd", "DAYS_OVERDUE");
+ columns.put("bucket", "CLASSIFICATION");
+ columns.put("closed", "STATUS");
+ columns.put("as_of_date", "POSITION_DATE");
+ return new SimulatedCoreConnector.Dialect("BANCS", "TCS BaNCS (simulated)", "FILE",
+ "BaNCS deployments here are typically cooperative-bank hosted. A nightly position "
+ + "file is the realistic ask; a live API rarely is in year one.",
+ columns, List.of("PARTY_NAME", "MOBILE", "BRANCH"));
+ }
+
+ private SimulatedCoreConnector.Dialect customApi() {
+ LinkedHashMap columns = new LinkedHashMap<>();
+ columns.put("enterprise_ref", "borrowerRef");
+ columns.put("loan_account_ref", "loanId");
+ columns.put("product", "productCode");
+ columns.put("sanctioned_amount", "sanctionedAmount");
+ columns.put("outstanding", "outstandingPrincipal");
+ columns.put("emi_amount", "instalment");
+ columns.put("due_date", "nextDueDate");
+ columns.put("last_paid_date", "lastRepaymentDate");
+ columns.put("dpd", "daysPastDue");
+ columns.put("bucket", "assetClassification");
+ columns.put("closed", "isClosed");
+ columns.put("as_of_date", "asOfDate");
+ return new SimulatedCoreConnector.Dialect("CUSTOM_API", "In-house LMS over REST (simulated)", "REST",
+ "For a lender whose loan management system is their own. They expose one paged "
+ + "read-only endpoint returning these fields; the mapping absorbs whatever they "
+ + "happen to call them.",
+ columns, List.of("borrowerName", "borrowerMobile", "branchCode"));
+ }
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorController.java b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorController.java
new file mode 100644
index 0000000..7b9b510
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/connector/ConnectorController.java
@@ -0,0 +1,254 @@
+package com.lincfusion.udyamlinc.connector;
+
+import com.lincfusion.udyamlinc.common.ApiResponse;
+import com.lincfusion.udyamlinc.manualentry.LoanBookEntryService;
+import com.lincfusion.udyamlinc.manualentry.ManualEntryPreview;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDate;
+import java.util.*;
+
+/**
+ * The core-banking integration surface.
+ *
+ * Mounted under {@code /admin}, so the existing split holds without a new
+ * rule: any bank user may read the catalogue, the profiles and the run history;
+ * changing a mapping or triggering a sync needs a role carrying CONFIG:MANAGE.
+ * Reading who is connected is exactly the sort of thing a field officer should be
+ * able to check before trusting a DPD number.
+ */
+@RestController
+@RequestMapping("/admin/connectors")
+@RequiredArgsConstructor
+public class ConnectorController {
+
+ private final ConnectorCatalog catalog;
+ private final ConnectorProfileRepository profileRepository;
+ private final ConnectorSyncService syncService;
+ private final FieldMapper fieldMapper;
+
+ // ── catalogue ───────────────────────────────────────────
+
+ /**
+ * Every adapter the platform can run, with its dialect and what it would take
+ * to make it live. The console renders {@code simulated} as a badge.
+ */
+ @GetMapping("/catalog")
+ public ApiResponse>> catalog() {
+ List