Files
udyamlinc-api/API-SPEC.md
T
LincPayandClaude Opus 5 7d192dfe9a feat(features): backfill the feature store so a model can be trained on it
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>
2026-08-25 13:44:54 +05:30

16 KiB
Raw Blame History

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.

{ "success": true, "message": "OK", "data": { } }

A failure keeps the envelope and nulls the payload:

{ "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.