feature_value held three as-of dates. A point-in-time training set needs
history, so three paths now generate it:
POST /admin/features/backfill?from=&to= loops computeAll(monthEnd) across
a date range — 23 months, ~6 min
POST /admin/bootstrap/ledger-history fills the gap before the earliest
entry only, stamped SEEDED_HISTORY
so generated rows stay
distinguishable from recorded ones
ClimateIngestionService honours the lookbackWeeks the
controller was already passing and
had been ignoring, so the
Open-Meteo archive can be pulled
Together these took the store to 21 monthly as-of dates over 263 enterprises,
which is the 5,523-row dataset the published metrics were measured on.
Documentation catches up with the code: API-SPEC lists the new endpoints,
ARCHITECTURE §7 records what was deliberately not built and why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
16 KiB
Markdown
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. `CBSNEXORA` is **simulated**: no CBSNexora instance feeds this deployment, so it generates an extract in that platform’s own field names to exercise the mapping layer end to end. No third-party core banking product is named anywhere — a lender on a different core is absorbed by the per-profile field mapping. `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.
|