# 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.