Decision policy — eligibility & the approve/refer/decline layer
How an indicative analysis becomes an actionable, auditable decision. SME-reviewed (2026-06-18). Code:
server/src/domain/decision.py; API:POST /v1/statements/{id}/decision.Positioning, unchanged: Obsrv stays the engine. The decision rules are the lender’s — a configurable, versioned
DecisionPolicy(per tenant, per product), exactly likeScoringPolicy. The engine just executes them deterministically and records why.
The four layers
| Layer | What | Where |
|---|---|---|
| 1. Data quality | classification, reconciliation, consolidation | verify.py, consolidate.py, taxonomy.py |
| 2. Risk | income, obligations, cash flow, score, band, flags | analytics.py, scoring.py |
| 3. Eligibility | supportable EMI, max & recommended loan | decision.py: compute_eligibility |
| 4. Decision | approve / approve-with-conditions / counter-offer / refer / decline | decision.py: decide |
Separating these keeps the engine explainable, configurable, lender-agnostic, and regulator-friendly: each institution encodes its own credit philosophy at Layer 4 without touching Layers 1–3.
Layer 3 — Eligibility (the commercial output)
From the borrower’s core income (A1) and existing obligations:
supportable EMI = max(0, target_FOIR × core_income − existing_obligations)
max loan = annuity_principal(supportable EMI, product_rate, tenure) # reducing-balance
recommended = min(requested, max loan)
total repayable = supportable EMI × tenure # what's paid back on the max loan
total interest = total repayable − max loan # the cost of creditThis turns “requested ₹10L and failed” into “supports ₹6.8L under current policy” — materially
better for conversion. Surfaced for any analysis (no loan request needed). The max loan is the
present value of the EMI stream (reducing-balance), not EMI × tenure — so the engine also
exposes total_repayable and total_interest (and the tenure_months / annual_interest_rate
used), making the cost of credit explicit rather than implied.
Layer 4 — Decision
Evaluated in order; the first match wins, and every decision records the triggered rule(s):
- Mandatory declines (hard policy — the lender wouldn’t lend at any score): an external
hard-stop (
external_hard_stop— the caller’s KYC/fraud/sanctions/blacklist/PEP verdict, which isn’t in a statement); no verifiable income; inactive income; existing FOIR >decline_foir; ≥max_recent_dishonoursrecent dishonours; concurrent loans ≥max_active_loans; failed reconciliation; suspected tampering. → Decline with structured adverse-action reasons. - Affordability for the requested amount: if the requested EMI exceeds the supportable EMI →
Counter-Offer at the max supportable loan (or Decline if that’s below
min_loan_amount). - Refer triggers (data/complexity → human underwriter): joint account, large unexplained
credit, circular transfers, high cash, data-quality caveat, speculative activity, liquidity
stress, revolving credit, or
warn-level reconciliation. → Refer. - Policy matrix on (risk band, post-loan FOIR):
| Risk band | FOIR | Action |
|---|---|---|
| Low | < approve_max_foir (40%) | Approve |
| Low | 40–50% | Approve with Conditions |
| Medium | < 40% | Approve with Conditions |
| Medium | 40–refer_max_foir (60%) | Refer |
| High / FOIR above refer band | any | Refer (or Counter-Offer if a smaller amount fits) |
Approve-with-Conditions attaches conditions (salary-account mandate; co-applicant / income proof for higher leverage; verify cash income; obtain ≥6 months coverage). Counter-Offer carries the supportable amount. Decline carries adverse-action reasons (insufficient verified income, excessive obligations, high FOIR, recent dishonours, insufficient coverage, unverifiable history).
Eligibility on a decline. Capacity (max_supportable_emi, max_loan_amount) describes what the
borrower can bear; recommended_loan_amount is what we’d extend. On an ordinary decline
(behaviour/risk — e.g. dishonours, loan stacking) the engine keeps the capacity figures as
underwriting context — a human reviewer may counter or reconsider — but zeroes recommended. On a
hard-stop (external_hard_stop, failed reconciliation, or suspected tamper — the same set the
approvals queue treats as a non-overridable hard block) all three are zeroed: “supports ₹X” next
to a fraud/KYC decline is the wrong signal. (Affordability declines naturally show ~0 capacity, since
existing obligations already exceed the target FOIR.)
Product presets (SME §7)
Same engine, different credit philosophy — PRODUCT_PRESETS in decision.py:
personal_loan (tight FOIR, income-led), lap (collateral-tolerant, longer tenure, lower rate),
business_loan (turnover-led, refer for seasonality), microfinance (small, short, tight —
mfi-v2: target FOIR 45%, approve < 30%, decline > 50%), consumer_durable. Any field is
tenant-overridable. (Phase 3, post-backtest: business lending should weight banking turnover /
DSCR / seasonality over FOIR — SME — not yet built.)
Audit & reproducibility
Every decision carries its inputs (band, FOIR, income used, coverage), the action, the
triggered rules / conditions / adverse-action reasons, and the policy_version — so a
decision is reproducible months later from the stored policy version and inputs. The decision is
also written to the append-only audit log (decision:<action>).
Built vs. deferred
- ✅ Built: the deterministic engine (Layers 3 & 4), product presets, the
POST /v1/statements/{id}/decisionAPI (audited), golden tests, the in-app decision panel (/analyze→ run a decision on a completed report), and the per-tenant decision policy (tenant.decision_policy,GET/PATCH /v1/org/decision-policy, Settings → Decision policy editor; overrides merge over the product preset, owner-only). - ✅ Override / delegated-authority workflow — built: per-user authority levels (l1/l2/l3),
an approvals queue with claim / resolve / override / escalate, authority-gated and reason-coded,
with immutable event records. See
../product/override-workflow.md.