Skip to Content
DomainDecision policy — eligibility & the approve/refer/decline layer

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 like ScoringPolicy. The engine just executes them deterministically and records why.

The four layers

LayerWhatWhere
1. Data qualityclassification, reconciliation, consolidationverify.py, consolidate.py, taxonomy.py
2. Riskincome, obligations, cash flow, score, band, flagsanalytics.py, scoring.py
3. Eligibilitysupportable EMI, max & recommended loandecision.py: compute_eligibility
4. Decisionapprove / approve-with-conditions / counter-offer / refer / declinedecision.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 credit

This 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):

  1. 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_dishonours recent dishonours; concurrent loans ≥ max_active_loans; failed reconciliation; suspected tampering. → Decline with structured adverse-action reasons.
  2. 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).
  3. 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.
  4. Policy matrix on (risk band, post-loan FOIR):
Risk bandFOIRAction
Low< approve_max_foir (40%)Approve
Low40–50%Approve with Conditions
Medium< 40%Approve with Conditions
Medium40–refer_max_foir (60%)Refer
High / FOIR above refer bandanyRefer (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}/decision API (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.