docs: README and API-SPEC, both promised by the original scaffold

A judge who cannot run the code reads the README, and until now the
reasoning behind this system lived only in Java comments and commit
messages — including the one thing a NABARD panel will check first, which
is why the apex tier cannot reach a borrower.

README covers the institutional model and why NABARD's restriction is the
point rather than a limitation, how to run both projects, the seven console
logins with what each sees, the demo path, and the design decisions worth
defending.

It also states plainly what is real and what is not: live Agmarknet and
Open-Meteo data and a working rule engine, a forecast that is deterministic
rather than machine-learned, and a seeded loan book. It says that
trust-seeded defaults to true and that the honest setting turns 34
trip-wires into 5. A demo that implies more than it has is worth less than
one that is straight about the gap.

API-SPEC documents every endpoint across the four surfaces, the response
envelope, the tenancy rule, small-cell suppression, effective dating and
the rejection reasons — plus the one defect found while writing it:
SecurityConfig whitelists /health but nothing is mapped there, so it
returns 500. The working probe is /auth/health.

Every credential and figure quoted was verified against the running
service: seven logins, the enterprise PIN, and the 44 / 39 / 59 / 12
portfolio counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-06 20:02:38 +05:30
co-authored by Claude Opus 5
parent 3531e6e79f
commit 22870eb480
2 changed files with 360 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
# UdyamLinc — API Specification
Base URL: `http://localhost:8082/api/v1/udyam`
Every response uses one envelope. The UI's `api.ts` unwraps `.data`, so the shape is stable across every endpoint.
```json
{ "success": true, "message": "OK", "data": { } }
```
A failure keeps the envelope and nulls the payload:
```json
{ "success": false, "message": "No enterprise MSME-1409", "data": null }
```
---
## 1. Authentication and tiers
Three surfaces, three institutional tiers. **They are not ranks of one permission** — an apex user cannot reach a bank endpoint, and a bank user cannot reach an apex one. Both get `403`.
| Scope | Who | May read |
|---|---|---|
| `NABARD` | the apex institution | aggregates across every partner bank; **no borrower row at all** |
| `BANK` | a lending institution | its own book in full, optionally narrowed to a branch |
| `ENTERPRISE` | a borrower | itself |
### The tenancy rule
The institution is a **signed claim in the JWT**. It is never read from a path variable, query parameter or request body. A tenancy key the caller can type is not a tenancy key.
`TenantContext` is resolved once per request from the token and injected into handlers as an ordinary argument, so an endpoint that needs scoping cannot forget to ask for it. The filter **fails closed**: a caller with no institution sees nothing, not everything.
Narrowing parameters that select a *view* — `level`, `zone`, `state`, `block` — do come from the request, because they grant nothing. An apex user may read every zone, so naming one changes only what is displayed.
### Cross-tenant reads return 404, not 403
Requesting a borrower on another bank's book returns exactly what a fabricated id returns:
```
GET /officer/enterprises/MSME-1409 as dccb.admin (owner) → 200
GET /officer/enterprises/MSME-1409 as mpgb.admin (other) → 404
{"success":false,"message":"No enterprise MSME-1409","data":null}
```
A distinguishable `403` would confirm the id is real and on somebody's book — enough to enumerate a competitor's portfolio without reading a single field.
### Endpoints
| Method | Path | Notes |
|---|---|---|
| `POST` | `/auth/officer-login` | Both staff tiers. Issues an apex or bank token depending on the user's institution. |
| `POST` | `/auth/app-login` | Enterprise PWA. `{ enterpriseId, pin }`. |
| `GET` | `/auth/health` | `{ status: "UP", service: "udyamlinc-backend" }` |
> **Known defect.** `SecurityConfig` whitelists `/health`, but no handler is mapped there — it returns `500`. The working probe is `/auth/health`. Worth fixing before any container health check points at it.
---
## 2. `/nabard/**` — the apex surface
Requires `SCOPE_NABARD`. **Not one endpoint here returns an enterprise id, a borrower name, or any row joinable back to a person.** That is verified rather than asserted: a check across all six read endpoints returns zero `MSME-` identifiers.
**Small-cell suppression.** Any cell with fewer than `udyam.apex.min-cell-size` (default 5) borrowers is returned with `suppressed: true` and no figures. A district with two borrowers and one alert discloses that borrower as surely as naming them. Suppressed rows are **returned, not omitted** — a missing district reads as "no activity", a different and wrong claim.
| Method | Path | Returns |
|---|---|---|
| `GET` | `/nabard/dashboard` | national totals, stress rate, portfolio value, open alerts, `minCellSize`, `dataAgeDays` |
| `GET` | `/nabard/banks` | partner-bank scorecard |
| `GET` | `/nabard/banks/{code}/branches` | one bank by branch — **the deepest this tier goes** |
| `GET` | `/nabard/geography?level=&zone=&state=` | `level` = `ZONE` \| `STATE` \| `DISTRICT` |
| `GET` | `/nabard/districts` | stress by district |
| `GET` | `/nabard/sectors` | stress by sector |
| `GET` | `/nabard/digest` | districts, sectors and slowest-responding banks |
| `GET` | `/nabard/institutions` | the institution and branch register |
**Response rate** on the scorecard is the share of raised alerts a bank has acted on. Stress rate is largely a fact about a bank's districts and sectors; response rate is a fact about the bank, and it is the number no bank self-reports.
---
## 3. `/officer/**` — the bank console
Requires `SCOPE_BANK`. Every response is filtered to the caller's institution, and to their branch when they are branch-scoped.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/officer/portfolio?block=&month=` | counts plus rows |
| `GET` | `/officer/enterprises?block=` | ranked by stress score |
| `GET` | `/officer/enterprises/{enterpriseId}` | score, reasons, forecast, alerts, visits, recent ledger |
| `GET` | `/officer/alerts?block=&state=` | scoped through visible borrowers |
| `PUT` | `/officer/alerts/{alertId}` | `OPEN` \| `ACKNOWLEDGED` \| `ESCALATED` \| `SNOOZED` \| `CLOSED`; re-checks tenancy |
| `GET` | `/officer/visits?block=` | |
| `POST` | `/officer/visits` | schedule from the playbook |
| `PUT` | `/officer/visits/{visitId}/outcome` | |
| `GET` | `/officer/playbook` | intervention catalogue |
| `GET` | `/officer/sectors?block=` | sector rollup |
| `GET` | `/forecast/{enterpriseId}` | stored quantile path, first breach, shortfall |
| `GET` | `/forecast/runs` | |
---
## 4. `/admin/**` — configuration and operations
`GET` is open to any console tier. Writes require `ROLE_SUPERVISOR`, `ROLE_BANK_ADMIN` or `ROLE_DATA_STEWARD`.
### Ingestion
| Method | Path | Notes |
|---|---|---|
| `POST` | `/admin/ingestion/run` | all sources |
| `POST` | `/admin/ingestion/run/{source}` | `AGMARKNET` \| `OPEN_METEO` |
| `GET` | `/admin/ingestion/runs?source=&limit=` | **every outcome, including a skip** |
| `GET` | `/admin/ingestion/calls` | outbound call log; the API key is redacted |
| `GET` | `/admin/ingestion/signals/market?weeks=` | carries `source`: `AGMARKNET` or `MANUAL` |
| `GET` | `/admin/ingestion/signals/climate?weeks=` | |
| `GET` | `/admin/ingestion/freshness` | per-district staleness — what a trip-wire checks first |
### Features
| Method | Path | Notes |
|---|---|---|
| `POST` | `/admin/features/compute` | whole book |
| `POST` | `/admin/features/compute/{enterpriseId}` | |
| `GET` | `/admin/features/definitions` | catalogue with staleness tolerances |
| `GET` | `/admin/features/snapshot/{enterpriseId}` | wide row |
| `GET` | `/admin/features/values/{enterpriseId}` | tall rows **including nulls and their reasons** |
| `GET` | `/admin/features/coverage` | populated share per feature |
A feature that cannot be computed is stored as null with a reason — `NO_SIGNAL`, `NO_LEDGER`, `NO_CBS`, `NO_CONSENT`, `NO_FORECAST`, `INSUFFICIENT_HISTORY`. "We did not know" and "we never looked" are different facts.
### Scoring and rules
| Method | Path | Notes |
|---|---|---|
| `POST` | `/admin/scoring/run` | whole book |
| `POST` | `/admin/scoring/run/{enterpriseId}` | |
| `GET` | `/admin/scoring/runs` | |
| `GET` | `/admin/scoring/explain/{enterpriseId}` | every rule's outcome, no writes |
| `GET` `POST` `PUT` `DELETE` | `/admin/rules`, `/admin/rules/{code}` | trip-wire CRUD; version bumps on edit |
| `PUT` | `/admin/sectors/{code}/weights` | sub-score weights in basis points |
| `GET` | `/admin/sectors`, `/admin/districts`, `/admin/commodities` | masters |
`explain` returns one row per rule with a status that distinguishes the cases that matter:
```
TW-01 FIRED DSCR(P10) < 1.00
TW-02 SKIPPED_MISSING upi_inflow_change (NO_CONSENT)
TW-03 NOT_FIRED no entry ≥ 14 days
TW-05 SKIPPED_MISSING price_z_score (INSUFFICIENT_HISTORY)
```
`NOT_FIRED` means the enterprise is fine. `SKIPPED_STALE` and `SKIPPED_MISSING` mean the rule could not run. These must never look the same.
### Manual and bulk entry
For the feeds no API serves — egg, milk, fodder — and the loan book.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/admin/manual/template/market` | CSV template |
| `GET` | `/admin/manual/template/loans` | CSV template |
| `POST` | `/admin/manual/upload?type=` | multipart; `MARKET_SIGNAL` \| `LOAN_RECORD`. **Stages a preview and writes nothing.** |
| `POST` | `/admin/manual/{batchId}/commit` | only a `PREVIEW` batch may commit |
| `DELETE` | `/admin/manual/{batchId}` | discard |
| `POST` | `/admin/manual/entry` | one row, validated and committed |
| `GET` | `/admin/manual/batches`, `/admin/manual/batches/{id}` | audit trail |
An upload returns `accepted`, `rejected`, `overwriting` and `warnings` as **separate lists**. Merging them hides the only two questions the person clicking Commit has: what will not land, and what will land on top of something already there. An overwriting row carries `priorModalPrice`.
**Effective dating.** `effective_date` is the date the fact describes and comes from the file. `observedAt` is when we learned it and is stamped at commit — the uploader cannot reach it. A March rate uploaded in August must stay invisible to a March scoring run, or the model trains on data that did not exist yet.
**Rejection reasons.** `UNKNOWN_DISTRICT`, `UNKNOWN_COMMODITY`, `UNKNOWN_ENTERPRISE`, `FUTURE_DATE`, `BAD_DATE`, `BAD_NUMBER`, `UNIT_MISMATCH`, `PRICE_ORDER`, `DUPLICATE_IN_FILE`, `MISSING_REQUIRED`, `IDENTIFYING_COLUMN`.
A price far from last week's **warns and is accepted** — a genuine price shock is the event this system exists to catch, and rejecting it would be backwards.
**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.
---
## 5. `/app/**` — the enterprise PWA
Requires `SCOPE_ENTERPRISE`, pinned to one enterprise id.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/app/home` | |
| `GET` | `/app/ledger` | |
| `POST` | `/app/ledger/sync` | offline queue; `clientId` is generated on the phone so an offline entry keeps identity through sync |
| `GET` | `/app/alerts` | |
| `PUT` | `/app/consent` | |
---
## 6. External feeds
Never called in a request path. A nightly job writes signals; the API serves only what is already in Postgres.
| Feed | Source | Notes |
|---|---|---|
| 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 |
See `ARCHITECTURE.md` §4 for provenance and the degradation rules.
+154
View File
@@ -0,0 +1,154 @@
# UdyamLinc
**Predictive cash-flow intelligence and early warning for rural microenterprises.**
Team LincFusion · NABARD Hackathon
A dairy owner in Sehore does not default suddenly. Feed prices rise for six weeks, the milk rate holds flat, the margin thins, and the instalment that was comfortable in June is not comfortable in October. By the time a missed EMI appears on a bank's system, the cause is three months old and the cheap interventions have expired.
UdyamLinc watches the causes rather than the symptom: mandi prices, rainfall and heat against the thirty-year normal, the enterprise's own ledger, and the loan book — turned into features, scored, and surfaced as an alert an officer can act on **while it is still early**.
---
## The institutional model
**NABARD does not lend to enterprises.** It refinances and supervises the banks that do — regional rural banks, cooperative banks and small finance banks. A platform that collapses those into one console implicitly casts the apex bank as the lender, which is wrong about the sector and wrong about who may see a borrower.
So there are three tiers, and they are **not ranks of one permission**:
| Tier | Who | Sees | Cannot |
|---|---|---|---|
| **NABARD** | apex | aggregates across every partner bank | read any borrower row |
| **Bank** | RRB / cooperative / SFB | its own book in full | see another bank's book |
| **Enterprise** | borrower | itself | see anything else |
The restriction on NABARD is the point, not a limitation. **Because the apex tier can never reach a borrower, portfolio-wide early warning can be granted to it at all** — a bank can tell its regulator, truthfully, that this platform does not expose its customers.
Cross-tenant requests return `404`, identical to a fabricated id. A distinguishable `403` would confirm the id is real and on somebody's book.
---
## Running it
**Prerequisites** — Java 17, Maven, Node 20+, PostgreSQL with a database named `UdyamLinc_DEMO`.
```bash
# Backend — port 8082, context path /api/v1/udyam
cd UdyamLinc-Backend
export DATAGOV_API_KEY=your_data_gov_in_key # optional; without it Agmarknet records SKIPPED
mvn spring-boot:run
# UI — port 5182
cd UdyamLinc-UI
npm install
npm run dev
```
Open **http://localhost:5182**. The schema and demo portfolio are created on first boot.
### Demo credentials
Password for every console login: `Udyam@123`
| Username | Role | Lands on |
|---|---|---|
| `nabard.admin` | NABARD administrator | `/nabard` |
| `nabard.officer` | NABARD officer | `/nabard` |
| `mpgb.admin` | MP Gramin Bank — head office (44 borrowers) | `/officer` |
| `mpgb.officer` | MP Gramin — Sehore branch (12 borrowers) | `/officer` |
| `dccb.admin` | Sehore DCCB — head office (39 borrowers) | `/officer` |
| `dccb.officer` | Sehore DCCB — branch | `/officer` |
| `supervisor` | supervisor (MP Gramin) | `/officer` |
Enterprise PWA: any seeded enterprise id — e.g. `MSME-4417` — with PIN `1234`.
**To see tenancy working**, sign in as `mpgb.admin`, note a borrower id, sign out, sign in as `dccb.admin` and request that id directly. It returns "no such enterprise".
### The demo path
```bash
POST /admin/ingestion/run # calls data.gov.in and Open-Meteo
POST /admin/features/compute # 14 derivations per enterprise
POST /admin/scoring/run # score, evaluate trip-wires, raise alerts
GET /admin/scoring/explain/MSME-4417
```
Then open `/officer/feeds` (provenance), `/officer/rules` (why each trip-wire did or did not fire) and `/nabard` (the supervisory view).
---
## What is real, and what is not
Being straight about this is more useful than a demo that implies more than it has.
**Real, on live data**
- Agmarknet mandi prices via data.gov.in — 16,000 rows, four districts, verified against the live API
- Rainfall and heat from Open-Meteo, against a thirty-year normal
- 14 feature derivations, point-in-time correct on both `valid_for_date` and `observed_at`
- The rule engine, the staleness gate, and the score
- Manual entry, the loan-book upload and the tenancy model
**Real, but computed without a model**
- The P10/P50/P90 forecast is **deterministic**, not machine-learned — median monthly ledger position, margin drift, and a band that widens with the square root of the horizon. It sits behind a `ForecastStrategy` seam, so the LightGBM quantile heads replace it as a configuration change.
**Demo data**
- The loan book and ledger are seeded. A real CBS extract is the deepest gap in the system: **DPD history is the training label**, and without it no model can be trained.
- `udyam.forecast.trust-seeded` defaults to `true`, which lets `dscr_p10` fall back to the seeder's forecast rows so the demo portfolio looks populated. **Set `UDYAM_TRUST_SEEDED_FORECASTS=false` for any run whose alerts are meant to be believed** — trip-wires firing drops from 34 to 5, and those 5 are real.
**Not built**
- Account Aggregator consent, so `upi_inflow_change` is null and TW-02 can never fire
- A three-year price backfill, so `price_z_score` is null and TW-05 cannot fire
- NECC egg, dairy-federation milk and fodder feeds — no usable API exists, which is why manual entry does
---
## Design decisions worth knowing
**A feature that cannot be computed is stored as null with a reason**, never skipped. `NO_SIGNAL`, `NO_LEDGER`, `NO_CBS`, `NO_CONSENT`, `NO_FORECAST`, `INSUFFICIENT_HISTORY`. "We did not know" and "we never looked" are different facts.
**Trip-wires refuse to fire on stale data.** A rule firing on a three-week-old price is worse than no rule. Each feature carries the age of the data that actually built it — not the district's newest row, which would let a month-old milk rate inherit yesterday's maize freshness.
**A fired trip-wire escalates the band but never rewrites the score.** They answer different questions — how stressed is this enterprise, and has a specific agreed line been crossed — and an audit needs both.
**Missing inputs widen the forecast band; they never lower its centre.** A pessimistic P50 built on ignorance would manufacture breaches.
**Nothing is written until someone has seen what will be.** Uploads stage a preview reporting what will land, what was rejected and why, and what will overwrite an existing value.
**No PII by schema, not by policy.** `enterprise` holds no name, phone, Aadhaar or account number, and a loan-book upload whose headers carry one is refused whole.
---
## Layout
```
UdyamLinc-Backend/ Spring Boot 4 · Java 17 · Postgres
apex/ NABARD aggregates — no endpoint returns a borrower
institution/ Institution, Branch — the tenancy key
security/ JWT, TenantContext
ingestion/ Agmarknet, Open-Meteo, scheduler
features/ Feature store and the 14 derivations
rules/ risk/ SpEL trip-wires, scoring and forecast strategies
manualentry/ Upload, preview, commit
ARCHITECTURE.md The build contract — read this first
API-SPEC.md Endpoint reference
UdyamLinc-UI/ Vite · React 19 · TypeScript
features/nabard/ Apex portal
features/officer/ Bank console
features/enterprise/ Enterprise PWA, offline, Hindi-first
```
`ARCHITECTURE.md` is the build contract and supersedes any other plan.
---
## Attribution
- **Mandi prices** — [data.gov.in](https://data.gov.in) Open Government Data platform, Ministry of Agriculture and Farmers Welfare
- **Rainfall and temperature** — [Open-Meteo](https://open-meteo.com) reanalysis archive
- **State boundaries** — [DataMeet India community](http://datameet.org/) ([datameet/maps](https://github.com/datameet/maps)), [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Simplified for display and **not an authoritative depiction of any boundary**; the source predates the 2019 reorganisation, so Ladakh is drawn within Jammu and Kashmir.
The data.gov.in API key is read from the environment and is never committed. It is redacted before any URL reaches the audit log.