ADR-0060: Finance source-of-truth — authority, identity-keyed dedup, bounded agent reasoning¶
Status: Accepted (2026-06-17) Context: Finance metrics (net worth, balances, monthly transactions) are computed from overlapping document sources. A depository CAS subsumes a registrar MF CAS; contract notes are trades not positions; margin reports aren't balance-bearing. Today overlap is resolved by hand (deactivating the superseded account, ADR — see #120/#121). This ADR makes source selection deterministic, auditable, and automatic, with a bounded agent layer that validates and explains. All examples below use synthetic values (no real account data — ADR privacy rule).
Context¶
A user's money appears in several documents at once, and each metric wants a different authoritative source:
- Depository CAS (e.g. NSDL/CDSL consolidated): every demat equity + MF folio across all accounts — the superset for investments / net worth.
- Registrar MF CAS (e.g. CAMS/KFintech): a mutual-fund-only subset already inside the depository CAS — counting both double-counts.
- Bank statement: one account's ledger + closing balance — authoritative for that account's cash balance and its transactions. A CAS carries no bank balances.
- Card statement: card ledger + dues — transactions + liabilities.
- Contract note: a day's individual trades (buys/sells) — trade history, not positions, not net worth.
- Margin report / advice / promo: not a statement — must be ignored (it polluted balances when mis-ingested).
Naively summing everything double-counts (the live bug: a registrar MF folio counted both standalone and inside the depository CAS). The decision of "which source is authoritative for which metric, and how to drop overlap" has five dimensions: role (what a doc contributes), authority (who wins on overlap), coverage/identity (a CAS spans many accounts; a bank statement one), recency (latest snapshot; transactions accumulate), and quality (deterministic+reconciled > LLM+unreconciled; junk rejected).
Decision¶
Adopt a hybrid model: a deterministic resolver (declarative authority + identity-keyed dedup) computes the numbers, and a bounded agent layer validates and surfaces the decision. The agent reasons within the rules — it never invents sources or overrides figures (consistent with ADR-0035: numbers come from code, not the model; here, source selection comes from rules, not the model).
1. Document role map (doc-type → contribution)¶
Each statement document_type maps to exactly one role:
| Role | Doc types | Feeds |
|---|---|---|
holdings |
depository CAS, registrar CAS, broker holding statement | investments |
cash |
bank statement, wallet statement | cash balance |
liability |
card statement, loan statement | liabilities |
transactions |
bank, card (ledgers) | monthly transactions |
trade_ledger |
contract note | trade history only (informational) |
ignore |
margin report, advice, promo, unknown | nothing |
This alone fixes margin-reports/contract-notes being mistaken for balance-bearing statements: they never feed balances or net worth.
2. Declarative source authority (config)¶
A per-source authority_rank (higher wins) lives in config (alongside the
institution registry). Example ranks for the holdings role:
depository_cas : 100 # NSDL/CDSL consolidated — canonical for investments
registrar_cas : 60 # CAMS/KFintech MF-only — subset of depository
broker_holding_stmt : 40 # single-broker positions
For cash/liability, a bank/card account's own statement is
authoritative for that account (rank by recency, not cross-source).
3. Identity-keyed dedup (the robust core)¶
Normalise every contribution to a canonical identity, then keep the value from the highest-authority, most-recent source per identity:
- Holdings → key on ISIN (equities/bonds) or folio (MFs); fall back
to
symbol/normalised-name when absent. - Cash/liability → key on account identity.
Net worth = sum over deduped keys. This handles partial overlap automatically: a depository CAS covering folios A+B and a separate US broker covering holding C → union by identity (A,B from the CAS, C from the broker), no double-count, no manual deactivation. Two Indian sources both listing folio A → keep the depository CAS's value (rank 100 > 60).
Phased: per-security identity requires per-security extraction of the CAS (today we extract the asset-class summary). Until then, dedup is at the source level — "a depository CAS, when present, is the sole
holdingssource for its jurisdiction; lower-rank holding sources are suppressed" — which reproduces today's manual reconciliation automatically.
4. Net-worth formula (user decision)¶
Per currency, never FX-summed (ADR-0007):
investments = Σ deduped holdings # GROSS — never reduced by liabilities
liquid = bank_cash − liabilities # liabilities net ONLY against cash
net_worth = investments + liquid
Investments are reported gross (the canonical CAS value). Liabilities (card dues, loans) subtract from the bank/cash side only — they never reduce the investments figure.
Presentation (user decision): liabilities are shown as their OWN line, not pre-netted into the cash figure. The breakdown lists investments / cash / liabilities as distinct lines; the total nets them:
Net worth (INR): <total = investments + cash − liabilities>
Investments: <gross>
Cash: <bank balance>
Liabilities: −<card dues + loans>
cash − liabilities may go negative (debt exceeds cash); investments stand on
their own line, untouched.
5. Per-metric resolution¶
- Net worth — canonical depository CAS for all investments (gross) + per-account bank cash − liabilities. Latest snapshot per source.
- Balances — latest statement per account, from that account's own statement (a CAS does not override a bank's balance).
- Monthly transactions — union of
transactions-role ledgers (bank + card), deduped by transaction identity, accumulated across periods. CAS (a snapshot) and contract notes (a different ledger) are excluded.
6. Bounded agent layer (validate + reason + surface)¶
The deterministic resolver emits the figures and a SourceDecision
record (which sources were used / suppressed / ignored, and why). The agent
then, within the rules:
- Validates — reconciliation/sanity checks (e.g. asset-class subtotal vs. the CAS's stated consolidated total; flags a mismatch rather than trusting blindly).
- Reasons — interprets in-rule ambiguity (e.g. notes a gap like no bank statement for a known account).
- Surfaces — explains the decision in the answer (transparency): "Net worth from the Apr-2026 depository CAS; bank cash from 2 statements; a registrar CAS and a margin report were excluded."
Conflict resolution is deterministic (user decision Q4): IRIS decides
conflicts by the rules automatically — it does NOT gate on user confirmation.
The agent notifies (surfaces what it decided and why) but does not ask
permission; overlap/precedence is resolved by authority_rank + recency, gaps
are flagged for visibility, and the manual override (§7) remains for the rare
case the user wants to change the outcome. The agent cannot change a figure
or choose a source outside the declared authority — it explains; the
deterministic engine decides.
7. Manual override (escape hatch)¶
deactivate_account (and the active-account filtering from #120/#121) remains
as a manual override for cases the rules don't yet cover.
Consequences¶
- Correct, auditable net worth without per-statement hand-tuning; overlap resolves from rules + identity, not vibes.
- Junk is structurally excluded (margin reports, promos) via the role map.
- Extensible — new institutions/doc-types get a role + rank in config.
- Cost: needs identity extraction (ISIN/folio) for full dedup; until then, source-level supersession covers the common case.
- Trust preserved — money figures stay deterministic; the LLM only explains/validates, never selects or computes.
Build plan¶
SOT-0 — role map + ignore junk (small, immediate)
document_role(doc_type) map; mark margin-reports/advices/promos ignore at
ingest; exclude ignore/trade_ledger from balance/holdings/net-worth queries
(complements the active-account filter). Backfill existing rows. Tests:
margin-report contributes nothing; contract note feeds trade history only.
SOT-1 — declarative authority + source-level supersession (the automation
of today's manual deactivation)
config/finance/source_authority.yaml (doc-type/issuer → role + rank). A
net_worth resolver that, for the holdings role, keeps the highest-rank
source per jurisdiction and suppresses subordinates — so a depository CAS
auto-supersedes a registrar CAS without deactivating accounts. Emit the
SourceDecision record. Tests with synthetic multi-source fixtures.
SOT-2 — identity-keyed dedup (depends on per-security CAS extraction) Extend CAS extraction to per-security rows (ISIN/folio, qty, value). Dedup holdings by identity across sources; enable partial-overlap + cross-jurisdiction union. Tests: overlapping folios dedup to the authoritative value; disjoint holdings union.
SOT-3 — liabilities + full net-worth formula
Card/loan liability role wired in; net worth = investments(gross) +
(bank_cash − liabilities), per currency. CLI/chat digest shows liabilities as a
separate line (investments / cash / liabilities / total) + the
SourceDecision summary.
SOT-4 — bounded agent validation + surfacing (notify, don't gate) Wire the agent layer: validate (reconcile subtotals), reason (flag in-rule ambiguity/gaps), and surface the source decision in the chat answer. Conflicts are resolved deterministically by the engine; the agent only notifies what it decided (no confirmation gate). Governance hooks fire on the (read-only) reasoning call.
SOT-5 — monthly transactions resolver
Union bank+card ledgers, dedup by transaction identity, accumulate across
periods; exclude snapshots/contract-notes. Power iris finance spend /
transaction views from the resolver.