Files
udyamlinc-api/API-SPEC.md
T
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

251 lines
16 KiB
Markdown

# 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.
A header matches on the whole normalised name **and on each part**, splitting separators and camelCase alike — a real core ships `CUST_NAME` and an in-house LMS ships `borrowerMobile`, neither of which an exact-match list catches. `loan_account_ref` splits to `loan`/`account`/`ref` and stays clean, which is why this is part-matching and not substring-matching.
### Core banking / LMS integration
The loan book is the one input no public feed supplies, and no two lenders expose it the same way. What exists is the seam, not an integration: an adapter per transport, a mapping per bank, and one validator — mapped rows are handed to the same `LoanBookEntryService.stage` an uploaded spreadsheet goes through, and land in the same `manual_entry_batch` with the same preview.
| Method | Path | Notes |
|---|---|---|
| `GET` | `/admin/connectors/catalog` | every adapter, its dialect, and `simulated` |
| `GET` | `/admin/connectors/canonical-columns` | the template a mapping targets, plus what may never be mapped in |
| `GET` | `/admin/connectors/profiles`, `/admin/connectors/profiles/{code}` | configured links |
| `POST` | `/admin/connectors/profiles` | onboard a lender |
| `PUT` | `/admin/connectors/profiles/{code}` | mapping and settings; **an invalid mapping is refused here**, not at the next nightly run |
| `POST` | `/admin/connectors/profiles/{code}/test` | reaches the source and shows the columns. **Stages nothing.** |
| `POST` | `/admin/connectors/profiles/{code}/sync` | pulls and stages; writes only if the profile has `autoCommit` |
| `POST` | `/admin/connectors/profiles/{code}/backfill?from=&to=` | one extract per month across a range, staged as **one** batch |
| `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.
**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.
**Auto-commit is off by default.** A sync stages and stops, preserving the manual path's rule that nothing is written until someone has seen what will be. A bank that wants the nightly feed unattended turns it on per profile, knowingly.
### DPD history — the training label
| Method | Path | Notes |
|---|---|---|
| `GET` | `/admin/loan-history/coverage` | three buckets and a verdict in words |
| `GET` | `/admin/loan-history/series/{accountRef}` | one account's snapshots, oldest first |
| `POST` | `/admin/loan-history/generate?months=24` | synthetic monthly snapshots, stamped `SEEDED_HISTORY` |
| `DELETE` | `/admin/loan-history/generated` | removes generated rows only |
**Every `loan_record` carries a `source`:** `SEEDED`, `SEEDED_HISTORY`, `UPLOAD`, or the connector profile code. Coverage reports **three** buckets — synthetic, real, and unknown provenance — and never folds unknown into real. A row whose origin is unlabelled is not evidence of a lender extract, and treating it as one is exactly the mistake the column exists to prevent.
**Generated history is shaped like a loan book, not like noise.** Real DPD steps: an account either pays and cures to zero, or misses and rolls forward one instalment period. So the miss/pay sequence is drawn first and DPD derived from it (`DpdWalk`), and the day-of-month slack is **fixed per delinquency episode** — a due date does not move between two missed payments, so an account 5 days down that misses again is 35 days down, not 12. Drawing the slack per month made consecutive months differ by 1 to 59 days, which is not a roll-forward and would have taught a model a transition matrix no book produces.
The series is walked **backwards from the state already on the book**, and the trailing episode's slack is derived from the anchor's own DPD, so the generated history joins today's number exactly. Generation never overwrites an existing row: a real extract survives a regeneration untouched.
The simulated adapters walk the same `DpdWalk`, so a connector backfill returns a real-shaped series rather than today's DPD repeated for every month.
A failed run is still a run. Nothing to read is `SUCCESS` with a message, not `FAILED` — a bank that has not delivered tonight's file is a normal state, and conflating it with a broken connector trains everyone to ignore the status.
---
## 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 | connector seam built; one real adapter (file drop), four simulated dialects. Manual upload remains the fallback |
See `ARCHITECTURE.md` §4 for provenance and the degradation rules.