Commit Graph
5 Commits
Author SHA1 Message Date
LincPayandClaude Opus 5 2cb7d3b14a feat(seed): a national portfolio, band-coherent ledger, and a forecast worth trusting
PortfolioPlan makes the demo book explicit — 263 enterprises across six states,
one per NABARD zone, over eight partner banks and sixteen branches — and
PortfolioPurge is the destructive escape hatch for the one boot where that shape
changes. The seeder never rewrites an existing enterprise, so a plan change
without a purge leaves orphans rather than corrupting rows.

udyam.forecast.trust-seeded now defaults to false. It was true only because
ledger coverage reached six enterprises of 142, so without the fallback the
console had almost no forecasts and showed 34 trip-wires of which 5 were real.
The seeder now writes five months of ledger for every enterprise, band-coherent
— a RED enterprise's monthly net genuinely sits below its instalment — so the
computed forecast is the one worth showing and the fallback is no longer
load-bearing.

jwt.secret becomes UDYAM_JWT_SECRET-overridable. The default stays committed so
a fresh clone boots without setup, which is precisely why it is not a secret:
any deployment holding real borrower data must set it.

Two traps this run surfaced, both silent:

  - dealBands() now deals round-robin across sectors. Appending HORTICULTURE to
    the sector list put it last and the fixed-seed band shuffle was drained by
    the five packs ahead of it, so all 19 seeded STANDARD.
  - Anything added to the seeder after a database was first populated never
    reaches it, because of the count() > 0 guards. backfillPortfolio() now
    covers loans and ledger and FeatureCatalog.seed() reconciles tolerances, but
    trip-wire rules and sector weights still carry the hazard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:29:00 +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