Commit Graph
8 Commits
Author SHA1 Message Date
LincPayandClaude Opus 5 4a1fe69ff6 feat(institution): regional office tier, and one choke point for its scope
Adds a fourth supervisory level between apex and bank — HO -> RO -> Bank ->
Branch -> Enterprise — as 25 offices with a state mapping, plus /nabard/
regional-offices and its bank drill.

An RO is Tier.APEX carrying a regionalOfficeId, not a tier of its own, so it
inherits mayReadBorrowers() == false rather than restating it. The office is
resolved branch -> district -> state -> office and never stored on the bank:
six of eight partner banks are state-bound RRBs, so a bank-level column would
look right today and break the moment an SFB opens a second-state branch.

The two new endpoints scoped correctly from the start while the eight
pre-existing apex reads ignored the office claim entirely — the supervisory
boundary was decorative, though no borrower leaked. Every read now passes
through ApexService.scopeDistricts(tenant), one choke point rather than eight
guards. All six offices sum exactly to the national 263 units / 8 banks /
342 alerts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:26:36 +05:30
LincPayandClaude Opus 5 8efd9fd6ed feat(loan-history): DPD history the model can be trained against, with provenance
The book held one snapshot per loan, so there was no history at all and
nothing a model could learn from. Two routes now fill it, and the
difference between them is stated rather than implied: generate
synthetic monthly snapshots for the prototype, or backfill month-end
extracts from the lender's core, which is the real path — every core can
answer "what did the book look like at each month end last year",
because it is the query behind their regulatory returns.

LoanRecord.source is stamped at every write site: SEEDED,
SEEDED_HISTORY, UPLOAD, or the connector profile code. Coverage reports
three buckets — synthetic, real, and unknown provenance — and never
folds unknown into real, because an unlabelled row is not evidence of a
lender extract. With no real extract on the book it reports
trainable:false and says why in words. Generation never overwrites an
existing row, so a real extract survives a regeneration untouched, and
purge removes only what was generated.

The generator's arithmetic had to be got right and was not at first.
Real DPD steps: pay and cure to zero, or miss and roll forward one
instalment period. The first version drew the day-of-month slack per
month, so consecutive months differed by anything from 1 to 59 days — a
shape no loan book produces, and one that would have taught a model a
transition matrix nothing real generates. The slack is now fixed per
delinquency episode, since a due date does not move between two missed
payments: an account 5 days down that misses again is 35 days down, not
12. For the run reaching the anchor it is derived from the anchor's own
DPD, so the series joins today's number exactly rather than near it.

DpdWalk is shared with the simulated adapters, which previously returned
today's DPD for every requested date — a backfill through them would
have been a flat line, which is exactly the shape that makes a training
label useless and which a chart would have hidden.

Verified against a running instance: 3,256 snapshots across 142 loans,
24 monthly extract dates spanning 2024-08 to 2026-07, 100% of
enterprises now carrying history, every generated series rolling by
exactly 30 — the only non-30 steps being joins to pre-existing rows the
generator correctly refused to overwrite. A 6-month Finacle backfill
pulled 264 rows and staged them for review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:57:56 +05:30
LincPayandClaude Opus 5 b79b92fe8d feat(connector): core banking / LMS seam, one real adapter and four dialects
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.
What can be built before that room is the seam, and this is it.

CoreBankingConnector fetches and returns rows; it never writes. Mapped
rows go to LoanBookEntryService.stage — the same validator an uploaded
spreadsheet goes through, landing in the same manual_entry_batch with
the same preview. A parallel validator for the automated path would be
the obvious shape and would eventually enforce the no-PII rule
differently.

FileDropConnector is real: it reads the newest matching file from a
directory a bank delivers to, and leaves it there so a failed run is
repeatable. Finacle, FLEXCUBE, BaNCS and an in-house REST LMS are
simulated, each emitting the same portfolio in its own column dialect —
DPD_DAYS, OVERDUE_DAYS, DAYS_OVERDUE, daysPastDue — which is what makes
the case for a mapping layer rather than asserting it.

The mapping is {canonical: vendor}, keyed by canonical name, so an
unmapped vendor column has no route through and is dropped at the
boundary. CUST_NAME and MOBILE_NO stay out structurally rather than by
anyone remembering. Auto-commit is off on every profile: a sync stages
and stops, keeping the rule that nothing is written until someone has
seen what will be. simulated is stamped per run, not read from the
profile, so switching a profile live cannot make old runs look real.

Fixes a real defect this surfaced: FORBIDDEN_COLUMNS matched on the
exact normalised name, so CUST_NAME — what Finacle actually calls it —
was missed, and a directly uploaded bank file would have kept the
borrower's name in manual_entry_line.raw_row. Matching now splits
separators and camelCase, catching borrowerMobile too, while
loan_account_ref still splits clean.

Verified against a running instance: Finacle 44/44 accepted with 4
columns dropped, the file drop 2 accepted and 1 UNKNOWN_ENTERPRISE
rejected from a real CSV, and both CUST_NAME and borrowerMobile refused
at mapping save.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:20:00 +05:30
LincPayandClaude Opus 5 3531e6e79f feat(apex): India → zone → state → district, and bank → branch
NABARD reads a national portfolio geographically before it reads it by
institution, so the apex tier needs the hierarchy above the district that
the pilot never had.

District carries a zone. Held as data rather than derived in a service, so
a state moving zone is a data change, not a deploy — and Zones maps state
to zone from a table rather than guessing, because a portfolio rolled into
the wrong zone is worse than one not rolled up at all: confidently wrong,
and nothing downstream can detect it. An unmapped state becomes UNMAPPED
and is reported as such.

/nabard/geography takes level plus optional zone and state. Those come from
the request because they select a view, not an entitlement — an apex user
may read every zone, so naming one grants nothing. Contrast the institution
id, which is never accepted from a request.

/nabard/banks/{code}/branches is the deepest this tier goes. Below a branch
is a borrower, and that belongs to the bank.

Suppression applies at every level and bites hardest at the bottom, which
is the intended behaviour of a drill-down: the further in you go, the
closer you are to a person.

Verified: India→Central→Madhya Pradesh→4 districts resolves, and MPGB
splits into Sehore (12) and Vidisha (32) branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:10:51 +05:30
LincPayandClaude Opus 5 1e5f446672 feat(institution): three-tier model — NABARD, partner banks, enterprises
NABARD does not lend to enterprises. It refinances and supervises the banks
that do. A single field-officer console collapsed those two institutions
into one and implicitly cast the apex bank as the lender, which is wrong
about the sector and wrong about who may see a borrower.

Institution and Branch are new; enterprise and udyam_user carry
institution_id and branch_id. That pair is the tenancy key.

TenantContext is resolved once per request from the signed token and
injected into handlers as an ordinary argument. The institution is never
read from a path variable, query parameter or body — a tenancy key the
caller can type is not a tenancy key. Every borrower-level read now takes
its filter from there, and fails closed: a caller with no institution sees
nothing rather than everything.

Three entitlements, not three ranks of one. APEX reads aggregates across
every partner and no borrower row at all; that restriction is what makes
portfolio-wide early warning grantable to a supervisor in the first place.
BANK reads its own book, narrowed to a branch for branch-scoped users.
ENTERPRISE reads itself.

Alerts and visits are scoped through the borrowers a caller may see rather
than by a column of their own, and dispositioning an alert re-checks
tenancy — closing another bank's alert would be invisible to them.

A borrower on another bank's book returns the same 'no such enterprise' as
one that does not exist. A distinguishable 403 would confirm the id is real
and on somebody's book, which is itself the leak.

ApexService suppresses any cell below five borrowers and reports the
suppression rather than omitting the row, because a missing district reads
as 'no activity' — a different and wrong claim.

Verified live: MPGB sees 44, DCCB 39, a branch officer 12; NABARD is 403 on
/officer/**, banks are 403 on /nabard/**; a real cross-tenant id is
byte-identical to a fabricated one; and none of the six apex endpoints
emits a single MSME id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:50:10 +05:30
LincPayandClaude Opus 5 d710176f36 feat(forecast): deterministic quantile path so dscr_p10 stops reading seeded rows
dscr_p10 is the feature TW-01 fires on, and so the origin of nearly every
alert an officer sees — but nothing had written forecast_point since the
seeder. A headline number computed from demo data is worse than no
headline number, because it looks identical to a real one.

ForecastStrategy is a seam shaped like ScoringStrategy: the LightGBM
quantile heads implement it later and the swap is one property. The
forecast and the score answer different questions and fail differently,
so they get separate seams.

The method is fully explainable. Baseline is the median whole-month net
position from the ledger, median so one festival month does not become
the expectation, and the partial current month is discarded. Drift comes
from margin_squeeze, else feed_cost_change, capped so one sharp month
does not extrapolate into a cliff. Spread widens with the square root of
the horizon and again for every input we did not have.

Missing inputs widen the band, never lower the centre. A pessimistic P50
built on ignorance would manufacture breaches.

An enterprise with too little ledger gets no path rather than a fabricated
one, so dscr_p10 resolves NO_FORECAST visibly.

forecast_point gains engine/model_version. Seeded rows carry neither, and
udyam.forecast.trust-seeded decides whether dscr_p10 may fall back to
them. Left true so the demo portfolio stays populated; with it false the
picture is honest — 6 enterprises forecast, 136 NO_FORECAST, and
trip-wires firing drops from 34 to 5.

The forecast reads margin features and dscr_p10 reads the forecast, so
features are computed twice per enterprise rather than letting either half
read a previous run's answer.

Seeder now writes 150 days of ledger, not 31: the baseline needs whole
months and one month leaves nothing to take a median of. Ledger joins
loans in the backfill pass, keyed on clientId.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:53:32 +05:30
LincPayandClaude Opus 5 20879b5904 fix(ingestion): make the pipeline actually run against live feeds
First end-to-end run against Postgres and the real government APIs, which
surfaced four defects that static checks could not.

- Agmarknet pointed at 9ef84268-…, "Current Daily Price": a today-only
  snapshot holding one arrival_date, in which none of the four pilot
  districts appear. Switched to 35985678-…, the historical resource.
- Filter fields were lowercase. This resource matches exactly on the
  capitalised name; the lowercase form is tokenised, so "Madhya Pradesh"
  also matched Andhra, Himachal and Uttar Pradesh — wrong-state prices
  written under a pilot district, with freshness looking healthy.
- Rows arrive unordered and the lookback filter is client side, so a page
  was mostly years-old data and every commodity wrote zero weeks. Added
  sort[Arrival_Date]=desc.
- ExternalFeedConfig injected a RestClient.Builder bean Boot does not
  expose, so the context never built and the app could not start.

Also: Open-Meteo meters by request cost, so the 30-year baseline call
exhausted the minutely allowance and 429'd two districts out of their
climate data. Rate-limited calls now retry with backoff, while genuine
failures still fail once and are reported.

FODDER moves to MANUAL — Agmarknet carries Green Fodder, but every recent
row is a single market in Ambala, Haryana.

Result: 16,000 rows fetched, 202 market weeks and 52 climate weeks written,
0 failed calls; 142 scored, 33 trip-wires fired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:01:25 +05:30
LincPayandClaude Opus 5 1b9b02f2c7 chore: initial commit — UdyamLinc backend, phases A-E
Spring Boot 4 / Java 17 / Postgres. Masters and serving (A), Agmarknet and
climate ingestion (B), feature store with 14 derivations (C), SpEL rule
engine and risk scoring (D).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:30:14 +05:30