diff --git a/API-SPEC.md b/API-SPEC.md
index efb501f..a25c2a1 100644
--- a/API-SPEC.md
+++ b/API-SPEC.md
@@ -195,7 +195,7 @@ The loan book is the one input no public feed supplies, and no two lenders expos
| `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.
+**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. `CBSNEXORA` is **simulated**: no CBSNexora instance feeds this deployment, so it generates an extract in that platform’s own field names to exercise the mapping layer end to end. No third-party core banking product is named anywhere — a lender on a different core is absorbed by the per-profile field mapping. `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.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 529bd87..9d13daf 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -355,7 +355,7 @@ ahead of it is the shape of the seam, and that is what this is.
|---|---|
| `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. |
+| `SimulatedCoreConnector` | CBSNexora — LincPay's own core banking platform — emitting the real portfolio in its own field names. Rows derived from real enterprises and loan records, drift seeded from the account ref so an extract is reproducible. **No third-party core banking product is named anywhere in this system;** where a lender runs someone else's core, the per-profile field mapping absorbs their column names. |
| `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. |
@@ -371,7 +371,7 @@ 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
+normalised name, so `CUST_NAME` — a column name real extracts do carry — 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.
diff --git a/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java b/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java
index 2f5bcaf..3d04952 100644
--- a/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java
+++ b/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java
@@ -1,6 +1,7 @@
package com.lincfusion.udyamlinc.admin;
import com.lincfusion.udyamlinc.common.ApiResponse;
+import com.lincfusion.udyamlinc.seed.LedgerBackfillService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.RestController;
public class DemoBootstrapController {
private final DemoBootstrapService bootstrapService;
+ private final LedgerBackfillService ledgerBackfillService;
/**
* Generate loan history, UPI inflows and price series, then compute features
@@ -41,4 +43,23 @@ public class DemoBootstrapController {
DemoBootstrapService.Result result = bootstrapService.bootstrap(months, force);
return ApiResponse.ok(result.message(), result);
}
+
+ /**
+ * Extend the demo ledger backwards so features can be computed at a past
+ * as-of date.
+ *
+ *
Separate from {@code /demo} because it is the one bootstrap step that
+ * invents rather than derives: a ledger has no archive to recover, so
+ * reaching further back means generating. Every row is stamped
+ * {@code SEEDED_HISTORY} and can only be written into the gap before the
+ * earliest entry an enterprise already has.
+ *
+ * @param days how far back the ledger should reach, default 760 (~25 months)
+ */
+ @PostMapping("/ledger-history")
+ public ApiResponse ledgerHistory(
+ @RequestParam(defaultValue = "760") int days) {
+ LedgerBackfillService.Result result = ledgerBackfillService.backfill(days);
+ return ApiResponse.ok(result.message(), result);
+ }
}
diff --git a/src/main/java/com/lincfusion/udyamlinc/features/FeatureController.java b/src/main/java/com/lincfusion/udyamlinc/features/FeatureController.java
index 52d7dd8..2e588dc 100644
--- a/src/main/java/com/lincfusion/udyamlinc/features/FeatureController.java
+++ b/src/main/java/com/lincfusion/udyamlinc/features/FeatureController.java
@@ -107,6 +107,59 @@ public class FeatureController {
engineeringService.computeAll(date == null ? LocalDate.now() : date, null));
}
+ /**
+ * Recompute the book at every month end in a range — the feature store as a
+ * history rather than as a snapshot.
+ *
+ * Why this exists. {@code /compute} writes one as-of date, and the
+ * job runs nightly, so the store only ever held the present. A model cannot
+ * be trained on the present: it needs the features as they stood at each
+ * past date, joined to what happened next. Every derivation already takes
+ * {@code asOfDate} and filters on it, so this is a loop, not new logic.
+ *
+ *
What it is honest about. This reconstructs the features by
+ * fact date, not by knowledge date. A mandi price for March
+ * was ingested in August, so a March row here uses a number a live March run
+ * would not yet have had. That is not label leakage — the label lives in
+ * {@code loan_record} ninety days later — but it does mean these rows are
+ * reconstructed rather than recorded, and anything trained on them should
+ * say so.
+ *
+ *
Idempotent: each row upserts on
+ * {@code (enterprise, feature, valid_for_date)}, so a re-run corrects rather
+ * than duplicates.
+ */
+ @PostMapping("/backfill")
+ public ApiResponse