Financial rules for AI agents
This document describes the invariants the Monetia MCP server enforces when an AI agent writes financial data. These rules exist because an AI making reasonable-sounding decisions can still produce ledger errors that are hard to detect and costly to repair.
Every rule listed here is enforced server-side. Violating a rule returns a structured error code; the AI cannot override it.
1. The loan trap
Rule: Creating an expense on a Loan entity increases the loan balance (it adds interest, fees, or charges — not a payment). This is the opposite of what "recording an expense" means for a bank account.
Enforcement: create_expense with entity_type="loan" requires the caller to pass
loan_charge=True as an explicit acknowledgment. Omitting this flag returns
loan_expense_requires_acknowledgment. The flag exists so an AI that has reasoned through the
direction must confirm it — it cannot silently flip the ledger.
Correct patterns:
- Recording a loan payment (money leaving an account to reduce a loan) → use
create_transferwithintent="loan_payment"from an Account to the Loan. - Recording interest or a fee charged by the lender → use
create_expensewithentity_type="loan"andloan_charge=True. - Drawing down a loan (lender deposits money into your account) → use
create_transferwithintent="loan_draw"from the Loan to an Account.
Direction enforcement: TransferService.create_transfer performs a post-write
balance-direction assertion. intent="loan_payment" must decrease the loan balance;
intent="loan_draw" must increase it. A mismatch triggers transfer_direction_violation
and rolls back the entire transaction.
2. Cross-currency transfers
Rule: When the source and destination entities use different currencies, the caller must supply both the source amount and the destination amount explicitly. The server never derives one from the other — doing so would introduce rounding drift that silently corrupts balances over time.
Schema: Transaction.destination_amount (Decimal 15,2) and
Transaction.destination_currency store the received amount in the destination currency.
BalanceService uses COALESCE(destination_amount, amount) when computing the destination
entity's balance: same-currency transfers have destination_amount = NULL so the source
amount is used as-is.
FX drift warning: If the implicit rate (derived from the two supplied amounts) differs from
the system's stored ExchangeRate for that date by more than 5% (15% for crypto pairs), the
server emits a non-blocking fx_drift_warning in the response. The transfer is not rejected
— the user's supplied amounts are authoritative — but the warning lets the AI surface the
discrepancy to the user.
Stale rate warning: When a computation uses an exchange rate older than 7 days (no fresher
rate in the database), the response includes an fx_rate_stale warning. Rates are managed by
the system: they refresh automatically every day, and they can't be set from MCP.
3. Transaction splits
Rule: Splitting a transaction divides it into two or more child transactions. The sum of all
child amounts must exactly equal the parent's amount — any mismatch returns
split_amount_mismatch. A transfer cannot be split (cannot_split_transfer).
Ledger semantics: When a transaction is split, the parent's status is set to "void".
The BalanceService filter status__in=["cleared","reconciled"] naturally excludes voided
parents, so only the children contribute to the balance — no double-counting.
Reversibility: unsplit_transaction reverses a split: it deletes the children and
restores the parent's status to its pre-split value (stored in Transaction.pre_split_status).
This requires that no child has been subsequently reconciled; if a child is reconciled, the
unsplit is blocked because deleting a reconciled row would break the ledger (see Rule 4).
4. Reconciled transactions are sacred
Rule: A transaction with status="reconciled" has been verified against a bank statement.
It must not be deleted and must not be silently modified.
Enforcement:
delete_transactionwith a reconciled transaction returnsreconciled_immutable. There is noforceoverride — this protection is absolute.- To delete a reconciled transaction, the user must first call
void_transaction, which sets the status to"void"(preserving the row for the audit trail), and then calldelete_transactionon the voided row. update_transactionon a reconciled row returnsreconciled_immutablefor any change to amount, date, entity, or type. Non-financial metadata (description, notes, tags) can still be updated.
Rationale: Many tax regulations require seven years of financial records. A deleted or silently modified reconciled transaction creates a gap that cannot be reconstructed from the database — and which could trigger audit liability.
5. Assets do not move via transactions
Rule: Asset transfers are not supported. create_transfer with entity_type="asset" on
either the source or destination side returns asset_transfer_unsupported.
Rationale: Assets (real estate, vehicles, stocks, crypto held outside an exchange) represent
ownership of an external thing, not a liquid balance. "Transferring" an asset would mean the
system creating a Transaction whose BalanceService logic doesn't have a meaningful balance
direction. Cash flows from assets (dividends, rent, sale proceeds) are recorded as income on
the receiving bank account, not on the asset itself.
Correct patterns for asset cash flows:
| Event | How to record it |
|---|---|
| Dividend received | create_income on the receiving Account with category dividend (system category) |
| Rental income | create_income on the receiving Account with category rental_income |
| Interest credited | create_income on the receiving Account with category interest |
| Asset sold (proceeds) | Use record_asset_sale (composite tool): atomically records the income on the receiving Account and marks the asset inactive |
| Asset purchased | Use record_asset_purchase (composite tool): atomically records the expense on the source Account and creates the Asset record |
The composite tools (record_asset_purchase, record_asset_sale) require both assets:write
and transactions:write scopes because they touch two resources in one atomic operation.
6. Refunds as income with expense categories
Rule: A refund for a past expense is recorded as income using the same expense category as the original purchase. This is intentional.
Why: get_spending_by_category nets income and expense rows with the same category. A
refund on "Groceries" (an expense category) reduces the gross "Groceries" spend for that
period, producing an accurate net figure. Creating the refund in a separate "Refunds" income
category would show the correct cash balance but inflate gross spending in reports.
Enforcement: create_income accepts an expense-typed category without error. The response
includes a category_type_mismatch_allowed_for_refund warning so the AI can confirm to the
user that the intentional pattern was used. The warning code is stable and can be branched on.
Example:
create_income(
entity_type="account", entity_id="...",
amount="12.50", currency_code="USD", date="2026-05-01",
category_id="<id of 'Groceries' expense category>",
description="Supermarket refund for returned item",
)
→ warnings: [{"code": "category_type_mismatch_allowed_for_refund", ...}]