Read-Only LLM Queries for Personal Finance Data: Practice and Privacy Design

    Personal finance data is highly sensitive. This article explains why LLM access should stay read-only, how an end-to-end query pipeline works, and how least-privilege tools, categorization, anomaly flags, numeric trust, and data governance keep answers useful without write access or training on financial records.

    Why personal finance needs a read-only boundary first

    Personal finance data is both highly sensitive and highly valuable. Balances, transaction details, and spending categories can reveal housing, food, and travel habits. A leak is far more damaging than a typical account compromise. When a large language model is connected to this data, the first architectural rule is a hard capability boundary: the system may only read, interpret, and summarize. It must never initiate a funds movement.

    That boundary yields three practical benefits.

    • Error cost stays bounded. If the model miscalculates, the user can check the original statement and correct the answer. No money is lost.
    • The attack surface shrinks. Even if a prompt is injected, the model has no write API to call.
    • Compliance review is simpler. Auditors inspect what was read and who received the output, not a broad set of possible actions.

    End-to-end data path

    A typical personal-finance Q&A system has five stages.

    1. Account authorization: The user authenticates with the bank and grants the app read-only access. The app never handles the bank login password.
    2. Data aggregation: An aggregator normalizes accounts and transactions from multiple institutions into a standard schema.
    3. Standardization: Currencies, date formats, and merchant names are unified, and duplicates are removed.
    4. Q&A orchestration: The model interprets the question, calls restricted query functions, and composes a natural-language answer.
    5. Presentation: Text, charts, or dashboards are shown with the data time range and source labeled.

    Only necessary fields move between stages. Full account numbers should not appear in model context; the last four digits are enough for identification.

    Engineering read-only access

    Least privilege is applied at three layers.

    Authorization scope: The consent screen requests only metadata, balances, and transaction reads. Write, transfer, and asset-management scopes are never requested.

    Token management: Access tokens have a short lifetime and are bound to the user and account set. Refresh tokens are encrypted in a server-side key-management component and never appear in the frontend or logs.

    Tool allowlist: The model may call only a predefined set of query functions. Every parameter is type- and range-checked. The following is illustrative; names and structure are generic:

    1. ALLOWED_TOOLS = {
    2. "get_balances": {"mode": "read", "params": ["account_id"]},
    3. "list_transactions": {"mode": "read", "params": ["account_id", "start", "end"]},
    4. "summarize_spending": {"mode": "read", "params": ["start", "end", "group_by"]},
    5. }
    6. def dispatch(tool_name, params, user_scope):
    7. spec = ALLOWED_TOOLS.get(tool_name)
    8. if spec is None or spec["mode"] != "read":
    9. raise PermissionError("tool not allowed")
    10. unexpected = set(params) - set(spec["params"])
    11. if unexpected:
    12. raise ValueError(f"unexpected parameters: {unexpected}")
    13. if params.get("account_id") not in user_scope.account_ids:
    14. raise PermissionError("account outside authorized scope")
    15. if (params.get("end") - params.get("start")).days > 730:
    16. raise ValueError("time range exceeds retention limit")
    17. return query_backend(tool_name, params)

    Tool calls from the model are proposals only. They execute after dispatch succeeds. The system must not let the model concatenate query strings. Time ranges need a cap as well—for example, transaction lookback limited to two years.

    Breaking down Q&A

    For a question such as “Where did my money go this month?”, the system typically does four steps.

    1. Intent: Classify spend summary, balance lookup, period comparison, or concept explanation.
    2. Time and scope: Map phrases like “this month” or “before payday” to date ranges. Payday may be inferred from income records; if inference fails, confirm with the user.
    3. Cross-account rollup and categorization: Merge transactions and aggregate by category.
    4. Reconciliation: Check that line items sum to the total and that amounts in different currencies were not added directly.

    Categorization is the most error-prone step. Merchant names are messy; the same merchant may appear in several forms. A common pattern is rules first, model fallback: match keywords and merchant codes, then send unmatched rows to the model, and keep a source tag for audit and user correction.

    1. def categorize(txn, rules, model_hint):
    2. for pattern, category in rules:
    3. if pattern in txn.merchant_normalized:
    4. return category, "rule"
    5. category = model_hint(txn.merchant_normalized, txn.memo)
    6. return category, "model"

    Anomaly detection: rules and statistics

    Personal-statement anomalies fall into three types, each with a different detector.

    • Duplicate charges: Same merchant, same amount, short time gap. A rule can flag two matches within 24 hours.
    • Unexpected fees: Amount far from that merchant’s history. Median plus IQR can set an upper bound; records above it become candidates.
    • Rising recurring bills: The same subscription merchant increases over consecutive periods. Compare adjacent-period deltas and growth rates.

    These are candidates, not conclusions. Label them “pending user confirmation” and keep the evidence: time gap, historical median, and percentage increase.

    1. def flag_duplicates(txns, window_hours=24):
    2. flagged = []
    3. for a, b in pairs_by_merchant(txns):
    4. same_amount = a.amount == b.amount
    5. close_in_time = abs(a.time - b.time) <= window_hours * 3600
    6. if same_amount and close_in_time:
    7. flagged.append((a, b))
    8. return flagged

    Numeric trust and terminology

    If the user asks how much extra interest they paid this year because of the annual rate, structure the answer in a fixed order: define the term, state the calculation method, then give the number. Daily accrual versus monthly settlement changes the result, so the method must be explicit.

    Critical figures should be computed in code, not by the model. The model writes language and explains concepts; deterministic logic produces the numbers. Answers should include data cutoff time and fields used, and note that the bank statement is authoritative.

    Privacy and data governance

    • Minimization: Store only fields needed to answer questions. Do not store full account numbers.
    • Training isolation: Financial data must not enter model training corpora. Enforce this with labels and storage isolation in the pipeline, not with policy text alone.
    • Retention: Transaction caches expire and are deleted automatically.
    • Revocation: When the user unlinks, tokens are invalidated immediately, local caches are cleared within an agreed window, and an audit log is written.
    • Log redaction: Logs record tool name, time range, and result counts—not amounts or full merchant names.
    • Domain isolation: Finance sessions and general sessions use different data policies and storage partitions so policies are not mixed.

    Launch checklist

    • Authorization is read-only only, with automated tests
    • The model can call only allowlisted functions, with parameter checks
    • Query time span is capped; over-limit queries prompt the user
    • Category results keep a source tag and can be corrected by the user
    • Anomalies are labeled as candidates, not conclusions
    • Critical numbers are computed in code, with a data cutoff time
    • After unlink, tokens fail, caches clear, and audits exist
    • Logs contain no amount details or full account numbers