feat(app): complete the borrower PWA contract, and drop vendor core names

The offline promise was only half kept. Ledger entries were queued on the
phone and marked PENDING, and the client flipped them to SYNCED without
sending anything — so an owner could record a sale with no signal, which was
the point, but it never reached the lender and the officer's screen never saw
it. POST /app/ledger/sync now has a caller; it dedupes on the clientId the
phone minted, so a retry after a half-failed sync writes nothing twice.

Adds GET /app/reasons: the borrower's own scored reason codes, newest run
only. A borrower told their limit is under review is entitled to know which
measurement drove it, and the app had been generating sentences from a demo
sector table rather than reading the ones the model produced. Both surfaces
now render the identical string.

PUT /app/consent was already here but nothing called it; the profile toggle
was local state. Consent is the artefact that makes reading a borrower's
inflows lawful, so it has to live on the server and be revocable from the
owner's own device.

Separately: the connector layer no longer names third-party core banking
products. Finacle, FLEXCUBE and BaNCS are replaced by CBSNexora, LincPay's
own core, with an in-place migration in ConnectorProfileSeeder.renameLegacy()
that also rewrites existing sync runs and manual entry batch labels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-25 13:44:41 +05:30
co-authored by Claude Opus 5
parent e7c93e29ea
commit 1c42376a92
7 changed files with 179 additions and 107 deletions
@@ -47,6 +47,13 @@ public class EnterpriseAppController {
return ApiResponse.ok("Synced", appService.sync(enterpriseId, batch)); return ApiResponse.ok("Synced", appService.sync(enterpriseId, batch));
} }
/** Why the owner's score is what it is — the newest run's sentences. */
@GetMapping("/reasons")
public ApiResponse<List<com.lincfusion.udyamlinc.risk.ReasonCode>> reasons(
@AuthenticationPrincipal String enterpriseId) {
return ApiResponse.ok(appService.reasons(enterpriseId));
}
@GetMapping("/alerts") @GetMapping("/alerts")
public ApiResponse<List<RiskAlert>> alerts(@AuthenticationPrincipal String enterpriseId) { public ApiResponse<List<RiskAlert>> alerts(@AuthenticationPrincipal String enterpriseId) {
return ApiResponse.ok(appService.alerts(enterpriseId)); return ApiResponse.ok(appService.alerts(enterpriseId));
@@ -10,8 +10,11 @@ import com.lincfusion.udyamlinc.enterprise.LedgerEntry;
import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository; import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
import com.lincfusion.udyamlinc.master.Sector; import com.lincfusion.udyamlinc.master.Sector;
import com.lincfusion.udyamlinc.master.SectorRepository; import com.lincfusion.udyamlinc.master.SectorRepository;
import com.lincfusion.udyamlinc.risk.ForecastPoint;
import com.lincfusion.udyamlinc.risk.ForecastPointRepository; import com.lincfusion.udyamlinc.risk.ForecastPointRepository;
import com.lincfusion.udyamlinc.risk.RiskAlert; import com.lincfusion.udyamlinc.risk.RiskAlert;
import com.lincfusion.udyamlinc.risk.ReasonCode;
import com.lincfusion.udyamlinc.risk.ReasonCodeRepository;
import com.lincfusion.udyamlinc.risk.RiskAlertRepository; import com.lincfusion.udyamlinc.risk.RiskAlertRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -38,6 +41,7 @@ public class EnterpriseAppService {
private final LedgerEntryRepository ledgerRepository; private final LedgerEntryRepository ledgerRepository;
private final ForecastPointRepository pointRepository; private final ForecastPointRepository pointRepository;
private final RiskAlertRepository alertRepository; private final RiskAlertRepository alertRepository;
private final ReasonCodeRepository reasonRepository;
private final SectorRepository sectorRepository; private final SectorRepository sectorRepository;
public AppHomeResponse home(String enterpriseId) { public AppHomeResponse home(String enterpriseId) {
@@ -47,13 +51,22 @@ public class EnterpriseAppService {
.findFirst() .findFirst()
.orElse(null); .orElse(null);
// Newest scoring run only. Unscoped, this returned every point ever forecast
// for the enterprise — seventeen runs deep on the demo book — and the owner's
// six-month strip drew a hundred overlapping months.
Long latestRun = pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId).stream()
.map(ForecastPoint::getRunId).filter(java.util.Objects::nonNull)
.max(Long::compareTo).orElse(null);
List<ForecastPoint> forecast = latestRun == null ? List.of()
: pointRepository.findByEnterpriseIdAndRunIdOrderByHorizonAsc(enterpriseId, latestRun);
List<LedgerEntry> today = ledgerRepository.findByEnterpriseIdAndEntryDate(enterpriseId, LocalDate.now()); List<LedgerEntry> today = ledgerRepository.findByEnterpriseIdAndEntryDate(enterpriseId, LocalDate.now());
Map<String, String> advice = adviceFor(sectorCode(enterprise)); Map<String, String> advice = adviceFor(sectorCode(enterprise));
return new AppHomeResponse( return new AppHomeResponse(
enterprise, enterprise,
alert, alert,
pointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId), forecast,
sum(today, "IN"), sum(today, "IN"),
sum(today, "OUT"), sum(today, "OUT"),
advice.get("en"), advice.get("en"),
@@ -65,6 +78,22 @@ public class EnterpriseAppService {
return ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId); return ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId);
} }
/**
* The owner's own reasons, newest run only.
*
* <p>The same sentences the officer sees. A borrower who is told their limit
* is under review is entitled to know which measurement drove it, and reading
* across runs would pair today's score with last month's explanation.
*/
public List<ReasonCode> reasons(String enterpriseId) {
requireEnterprise(enterpriseId);
List<ReasonCode> all = reasonRepository.findByEnterpriseIdOrderByRankAsc(enterpriseId);
Long latestRun = all.stream().map(ReasonCode::getRunId)
.filter(java.util.Objects::nonNull).max(Long::compareTo).orElse(null);
if (latestRun == null) return List.of();
return all.stream().filter(r -> latestRun.equals(r.getRunId())).toList();
}
public List<RiskAlert> alerts(String enterpriseId) { public List<RiskAlert> alerts(String enterpriseId) {
return alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId); return alertRepository.findByEnterpriseIdOrderByRaisedAtDesc(enterpriseId);
} }
@@ -12,25 +12,25 @@ import java.util.*;
/** /**
* Every adapter the platform can run, and the dialect each one speaks. * Every adapter the platform can run, and the dialect each one speaks.
* *
* <p>The four simulated dialects are not decoration. Read the {@code columns} * <p>Two adapters, and the difference between them is the whole design. The file
* maps side by side and the case for a mapping layer makes itself: the days-past * drop is real and reads whatever a bank chooses to deliver. {@link #cbsNexora()}
* due field is {@code DPD_DAYS}, {@code OVERDUE_DAYS}, {@code DAYS_OVERDUE} and * is CBSNexora, LincPay's own core banking platform, simulated here because no
* {@code daysPastDue} depending on whose core it is, and a bank will not be * instance feeds this deployment.
* 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.
* *
* <p>{@code extraColumns} on each dialect carries the fields a real export ships * <p><b>No third-party core banking product is named anywhere in this system.</b>
* that this system has promised never to store — the borrower's name, a mobile * Where a lender runs someone else's core, the integration is described by its
* number. They are emitted so that the mapping screen can show them arriving and * shape — a scheduled extract, a reporting view, a nightly position file — and
* being dropped unmapped at the boundary. An extract that were already clean * the per-profile field mapping absorbs whatever that deployment calls its
* would let a reviewer assume the promise is kept by luck. * columns. Naming another vendor would imply a relationship with it that does not
* exist, and the mapping layer means the platform never needed the name.
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class ConnectorCatalog { public class ConnectorCatalog {
/** Vendor code for the CBSNexora adapter. Matches a profile's {@code vendor}. */
public static final String VENDOR = "CBSNEXORA";
private final FileDropConnector fileDropConnector; private final FileDropConnector fileDropConnector;
private final EnterpriseRepository enterpriseRepository; private final EnterpriseRepository enterpriseRepository;
private final LoanRecordRepository loanRepository; private final LoanRecordRepository loanRepository;
@@ -41,10 +41,7 @@ public class ConnectorCatalog {
@PostConstruct @PostConstruct
void register() { void register() {
add(fileDropConnector); add(fileDropConnector);
add(simulated(finacle())); add(simulated(cbsNexora()));
add(simulated(flexcube()));
add(simulated(bancs()));
add(simulated(customApi()));
} }
private void add(CoreBankingConnector connector) { private void add(CoreBankingConnector connector) {
@@ -64,70 +61,20 @@ public class ConnectorCatalog {
} }
// ── dialects ──────────────────────────────────────────── // ── dialects ────────────────────────────────────────────
/**
private SimulatedCoreConnector.Dialect finacle() { * CBSNexora — LincPay's own core banking platform, as the platform would read it.
LinkedHashMap<String, String> columns = new LinkedHashMap<>(); *
columns.put("enterprise_ref", "ENTITY_REF"); * <p>Simulated here: no CBSNexora instance feeds this deployment, and every row
columns.put("loan_account_ref", "CUST_ACCT_ID"); * an adapter of this class produces is stamped {@code simulated} per sync run so
columns.put("product", "SCHM_CODE"); * a profile later pointed at a real instance cannot make its old runs look real.
columns.put("sanctioned_amount", "SANCT_AMT"); *
columns.put("outstanding", "PRIN_OS"); * <p>{@code extraColumns} carries the fields a real export ships that this system
columns.put("emi_amount", "INSTL_AMT"); * has promised never to store — the borrower's name, a mobile number. They are
columns.put("due_date", "NEXT_DUE_DT"); * emitted so the mapping screen can show them arriving and being dropped unmapped
columns.put("last_paid_date", "LAST_PAID_DT"); * at the boundary. An extract that were already clean would let a reviewer assume
columns.put("dpd", "DPD_DAYS"); * the promise is kept by luck.
columns.put("bucket", "ASSET_CLASS"); */
columns.put("closed", "ACCT_CLS_FLG"); private SimulatedCoreConnector.Dialect cbsNexora() {
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<String, String> 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<String, String> 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<String, String> columns = new LinkedHashMap<>(); LinkedHashMap<String, String> columns = new LinkedHashMap<>();
columns.put("enterprise_ref", "borrowerRef"); columns.put("enterprise_ref", "borrowerRef");
columns.put("loan_account_ref", "loanId"); columns.put("loan_account_ref", "loanId");
@@ -141,10 +88,10 @@ public class ConnectorCatalog {
columns.put("bucket", "assetClassification"); columns.put("bucket", "assetClassification");
columns.put("closed", "isClosed"); columns.put("closed", "isClosed");
columns.put("as_of_date", "asOfDate"); columns.put("as_of_date", "asOfDate");
return new SimulatedCoreConnector.Dialect("CUSTOM_API", "In-house LMS over REST (simulated)", "REST", return new SimulatedCoreConnector.Dialect(VENDOR, "CBSNexora (simulated)", "REST",
"For a lender whose loan management system is their own. They expose one paged " "CBSNexora exposes one paged, read-only loan endpoint. A lender enables it for "
+ "read-only endpoint returning these fields; the mapping absorbs whatever they " + "the platform's service account and agrees the field list; the mapping absorbs "
+ "happen to call them.", + "whatever their deployment happens to call them.",
columns, List.of("borrowerName", "borrowerMobile", "branchCode")); columns, List.of("borrowerName", "borrowerMobile", "branchCode"));
} }
} }
@@ -43,7 +43,7 @@ public class ConnectorProfile {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
/** Unique per deployment — "MPGB-FINACLE". */ /** Unique per deployment — "MPGB-CBSNEXORA". */
@Column(nullable = false, unique = true, length = 40) @Column(nullable = false, unique = true, length = 40)
private String code; private String code;
@@ -1,7 +1,10 @@
package com.lincfusion.udyamlinc.connector; package com.lincfusion.udyamlinc.connector;
import com.lincfusion.udyamlinc.manualentry.ManualEntryBatch;
import com.lincfusion.udyamlinc.manualentry.ManualEntryBatchRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
@@ -15,10 +18,17 @@ import java.util.List;
* first populated never reaches it, and the symptom is a screen that is empty for * first populated never reaches it, and the symptom is a screen that is empty for
* everyone except whoever last dropped their schema. * everyone except whoever last dropped their schema.
* *
* <p>The three lenders are on three different cores on purpose. It is also true * <p>The three lenders do not arrive the same way, on purpose. Two are on
* of the sector — a regional rural bank, a district cooperative bank and a small * CBSNexora and read over its loan API; the cooperative bank delivers a file to a
* finance bank will not be on one core — and a demo where every bank is on * drop path. A demo where every bank arrived identically would quietly suggest
* Finacle would quietly suggest that the mapping layer is optional. * that the mapping layer is optional, and it is the opposite of optional — it is
* what lets the platform onboard a lender whose core it has never seen.
*
* <p><b>CBSNexora is the only core banking product named anywhere in this
* system,</b> and it is named because LincPay builds it. A lender on someone
* else's core is absorbed by the per-profile field mapping instead: the platform
* never needs that product's name, and printing one would imply a relationship
* with its vendor that does not exist.
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -26,6 +36,8 @@ import java.util.List;
public class ConnectorProfileSeeder { public class ConnectorProfileSeeder {
private final ConnectorProfileRepository profileRepository; private final ConnectorProfileRepository profileRepository;
private final ConnectorSyncRunRepository runRepository;
private final ManualEntryBatchRepository batchRepository;
private final ConnectorCatalog catalog; private final ConnectorCatalog catalog;
private final FieldMapper fieldMapper; private final FieldMapper fieldMapper;
@@ -33,14 +45,90 @@ public class ConnectorProfileSeeder {
String endpoint, String cron) { String endpoint, String cron) {
} }
/**
* Codes and vendors this seeder used before the vendor names were removed.
*
* <p>Seeding is guarded per code, so without this a database populated under
* the old names would keep them for ever and quietly seed a second profile
* beside each one. Renaming in place also preserves every sync run already
* recorded against the profile, which deleting and reseeding would orphan.
*/
private record Legacy(String oldCode, String newCode, String oldVendor, String displayName) {
}
private static final List<Legacy> LEGACY = List.of(
new Legacy("MPGB-FINACLE", "MPGB-CBSNEXORA", "FINACLE",
"MP Gramin Bank — CBSNexora loan API"),
new Legacy("VSFB-LMS", "VSFB-CBSNEXORA", "CUSTOM_API",
"Vindhya SFB — CBSNexora loan API"));
/**
* Relabel, in three places, because the old name was denormalised into all
* three. A profile carries it; every sync run copies the code and vendor it
* ran under; and each staged upload batch carries the profile code plus a
* human label built from it at the time.
*
* <p>The one field deliberately left alone is {@code simulated} on a run. It
* records what the run actually was, and renaming an identity must never
* rewrite the provenance of work already done under it.
*/
private void renameLegacy() {
for (Legacy row : LEGACY) {
profileRepository.findByCode(row.oldCode()).ifPresent(profile -> {
profile.setCode(row.newCode());
profile.setVendor(ConnectorCatalog.VENDOR);
profile.setDisplayName(row.displayName());
profileRepository.save(profile);
log.info("Renamed legacy connector profile {} → {}", row.oldCode(), row.newCode());
});
// Runs are looked up by profile code, so leaving these would both show
// the old name in sync history and strand the runs from their profile.
List<ConnectorSyncRun> runs =
runRepository.findByProfileCodeOrderByStartedAtDesc(row.oldCode(), Pageable.unpaged());
if (!runs.isEmpty()) {
runs.forEach(run -> {
run.setProfileCode(row.newCode());
run.setVendor(ConnectorCatalog.VENDOR);
});
runRepository.saveAll(runs);
log.info("Repointed {} sync run(s) from {} to {}", runs.size(), row.oldCode(), row.newCode());
}
// The batch label is free text composed at sync time — "MPGB-FINACLE ·
// FINACLE simulated extract · 44 accounts". Both halves have to go.
List<ManualEntryBatch> batches = batchRepository.findAllByOrderByUploadedAtDesc(Pageable.unpaged())
.stream()
.filter(b -> row.oldCode().equals(b.getSource())
|| (b.getFilename() != null && b.getFilename().contains(row.oldCode())))
.toList();
if (batches.isEmpty()) continue;
batches.forEach(b -> {
if (row.oldCode().equals(b.getSource())) b.setSource(row.newCode());
if (b.getFilename() != null) {
b.setFilename(b.getFilename()
.replace(row.oldCode(), row.newCode())
.replace(row.oldVendor(), ConnectorCatalog.VENDOR));
}
});
batchRepository.saveAll(batches);
log.info("Relabelled {} upload batch(es) from {} to {}",
batches.size(), row.oldCode(), row.newCode());
}
}
public void seed() { public void seed() {
renameLegacy();
List<Seed> seeds = List.of( List<Seed> seeds = List.of(
new Seed("MPGB-FINACLE", "MP Gramin Bank — Finacle nightly extract", new Seed("MPGB-CBSNEXORA", "MP Gramin Bank — CBSNexora loan API",
"FINACLE", "MPGB", "loanbook-*.csv", "0 30 1 * * *"), ConnectorCatalog.VENDOR, "MPGB", "https://cbsnexora.mpgb.example/api/loans",
"0 30 1 * * *"),
new Seed("SEHDCCB-DROP", "Sehore DCCB — SFTP file drop", new Seed("SEHDCCB-DROP", "Sehore DCCB — SFTP file drop",
FileDropConnector.VENDOR, "SEHDCCB", "sehdccb-loanbook-*.{csv,xlsx}", "0 0 2 * * *"), FileDropConnector.VENDOR, "SEHDCCB", "sehdccb-loanbook-*.{csv,xlsx}", "0 0 2 * * *"),
new Seed("VSFB-LMS", "Vindhya SFB — in-house LMS over REST", new Seed("VSFB-CBSNEXORA", "Vindhya SFB — CBSNexora loan API",
"CUSTOM_API", "VSFB", "https://lms.vindhyasfb.example/api/loans", "0 15 1 * * *")); ConnectorCatalog.VENDOR, "VSFB", "https://cbsnexora.vindhyasfb.example/api/loans",
"0 15 1 * * *"));
int written = 0; int written = 0;
for (Seed seed : seeds) { for (Seed seed : seeds) {
@@ -9,7 +9,8 @@ import java.util.Map;
* management system. * management system.
* *
* <p><b>Why an interface and not an integration.</b> Every bank's core is * <p><b>Why an interface and not an integration.</b> Every bank's core is
* different — Finacle, Flexcube, BaNCS, a cooperative bank's in-house system — * different — a scheduled report extract, a reporting view, a nightly position
* file, a REST loan API such as CBSNexora —
* and each names the same eleven fields differently, exposes them over a * and each names the same eleven fields differently, exposes them over a
* different transport, and will only agree a schema during its own onboarding. * different transport, and will only agree a schema during its own onboarding.
* There is no single integration to build ahead of that conversation. What can * There is no single integration to build ahead of that conversation. What can
@@ -31,7 +32,7 @@ import java.util.Map;
*/ */
public interface CoreBankingConnector { public interface CoreBankingConnector {
/** Stable code — {@code FINACLE}, {@code FILE_DROP}. Matches a profile's vendor. */ /** Stable code — {@code CBSNEXORA}, {@code FILE_DROP}. Matches a profile's vendor. */
String vendor(); String vendor();
/** What a bank operations user would call it. */ /** What a bank operations user would call it. */
@@ -15,17 +15,17 @@ import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
/** /**
* A core banking system that does not exist, exporting in the dialect of one * A core banking instance that is not there, exporting in the dialect of one
* that does. * that would be.
* *
* <p><b>What this is for, and what it is not.</b> Integrating with Finacle, * <p><b>What this is for, and what it is not.</b> Connecting a lender's core is
* Flexcube or BaNCS is not a technical unknown — it is an onboarding process per * not a technical unknown — it is an onboarding process per bank, with a schema
* bank, with a schema agreed in a room. Until that room, the thing worth * agreed in a room. Until that room, the thing worth demonstrating is not a fake
* demonstrating is not a fake feed but the <i>shape of the seam</i>: that each * feed but the <i>shape of the seam</i>: that a core names the same eleven fields
* core names the same eleven fields differently, that a mapping profile is what * in its own way, that a mapping profile is what reconciles them, and that
* reconciles them, and that nothing after the mapping cares which core it was. * nothing after the mapping cares which core it was. Running the real portfolio
* Running the same portfolio out through four dialects makes that visible in a * out through a declared dialect makes that visible in a way a paragraph of
* way a paragraph of intent does not. * intent does not.
* *
* <p>Every row it produces is stamped simulated, all the way to the console badge * <p>Every row it produces is stamped simulated, all the way to the console badge
* and the sync-run record. That matters more than it sounds: the failure mode * and the sync-run record. That matters more than it sounds: the failure mode