Twelve skills that had been sitting uncommitted in the working tree: the architecture contract, accounting reference, change-control rulebook, failure archaeology, debugging playbook, diagnostics scripts, ledger-analysis toolkit, research frontier, run/operate runbook, build-and-env, test-coverage campaign, and validation/QA guide. Updates three frontier statuses to match what actually shipped today rather than checking in a roadmap that describes closed work as open: - A1 double-entry hardening -> partly-done. The create half is enforced; what remains is running the existing audit_linked_legs.py against a prod backup. - B3 safety rails -> partly-done. Idempotency shipped, including the note that the index is not unique-when-not-null and why. dry_run is still open. - D3 CSV import -> unblocked but still gated. Its stated blocker was B3's idempotency keys, which now exist. Also gitignores .claude/settings.local.json, which is per-machine and was showing up as untracked on every status. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
33 KiB
name, description
| name | description |
|---|---|
| fin-ledger-analysis-toolkit | Proof methods for fin's ledger. Load whenever you must PROVE an accounting property rather than eyeball it — verifying that balances/spending/net-worth are correct, deriving what a number SHOULD be before measuring it, computing the effect of a proposed change on aggregates before implementing it, adjudicating whether observed data is wrong vs expected, auditing a code path for currency correctness, checking a claimed fix actually fixed an aggregate, or stating/classifying ledger invariants (conservation, derivation, definition) as enforced vs unenforced. Also load before accepting any hypothesis about a money bug. |
fin — Ledger Analysis Toolkit (proof methods)
House standard: prove it, don't just eyeball it. A number on a dashboard is a claim; this skill is the set of methods for deriving what the number must be from the transaction rows and the code, and for refusing conclusions that haven't survived an attempted refutation.
All facts below derived from code as of 2026-07-05 (HEAD = 082b608), with file:function citations. Worked examples use real commits via git show; where production amounts are not in the repo, numbers are faithful synthetic reconstructions and labeled as such.
Division of labor: fin-accounting-reference owns the definitions and formulas (balance formula, type-semantics table, two-leg mechanics). This skill owns the proof methods built on them. Don't re-derive definitions — cite that skill; do re-verify enforcement status, because that is this skill's subject.
When NOT to use this skill
- You need a formula or type-semantics definition →
fin-accounting-reference. - Something is broken and you don't yet know where →
fin-debugging-playbookfirst; come back here when you have a hypothesis to prove. - You want the history of a bug or whether a design question is settled →
fin-failure-archaeology. - You need runnable audit scripts (the mechanized form of these methods) →
fin-diagnostics-and-tooling. - You're deciding what evidence a change needs before "done" →
fin-validation-and-qa(its evidence hierarchy is the acceptance policy; this skill supplies the proof techniques it ranks). - You're turning an invariant into a regression/property test →
fin-test-coverage-campaign. - Design/refactor risk questions ("what breaks if I change this?") →
fin-architecture-contract; change gating →fin-change-control.
Method 1 — Invariant derivation & classification
Purpose. State a ledger invariant precisely enough to test it, then classify it honestly: ENFORCED (code prevents violation — cite where), INTENDED-UNENFORCED (design assumes it, nothing stops a violation — these are exactly where corruption enters), or STATISTICAL (expected to hold most of the time, gaps are normal).
Inputs. api/app/crud.py, api/app/routers/transactions.py, api/app/schemas.py, api/app/models.py, api/app/services/net_worth.py.
Procedure.
- Pick the quantity (a balance, a linked group, a holding) and write the invariant as an equation or a closed predicate over rows — no prose like "should be consistent". Three shapes exist in this codebase:
- Conservation: a linked group of rows sums to a known constant (usually 0 or the recognized expense).
- Derivation: a stored row is a pure function (fold) of other rows.
- Definition: an aggregate is defined as a query; it cannot be "wrong", only mis-consumed.
- For each write path that can touch the rows (router → crud → raw POST), ask: what code would reject a violating write? If none, the invariant is unenforced no matter how much the happy-path code respects it.
- Check at-rest enforcement separately from at-creation enforcement:
create_transferwriting both legs atomically does not stop a later single-leg write from another path. - Record the classification with the citation. An invariant without a citation is a wish.
Current invariant catalog (as of 2026-07-05)
Conservation (linked groups; sums are over amount unless noted):
| # | Invariant | Status |
|---|---|---|
| C1 | Same-currency transfer pair: Σ amount = 0; net-of-fee Σ(amount−fee) = −fee (fee sits on source leg only) |
ENFORCED at creation (crud.py:create_transfer — add_all + single commit); UNENFORCED at rest (see U1) |
| C2 | Cross-currency transfer: −amount in from-ccy, +to_amount in to-ccy; conservation holds only at the implied rate amount/to_amount |
INTENDED-UNENFORCED — to_amount is a free field, no rate sanity check (schemas.py:TransferRequest, router only checks > 0) |
| C3 | Settlement pair (create_settlement, /reconcile): Σ amount = 0, both fees 0 |
ENFORCED at creation (crud.py:create_settlement, routers/transactions.py:reconcile_accounts); UNENFORCED at rest |
| C4 | splitwise_pay pair: bank −total, splitwise +partner_share ⇒ Σ amount = −(total − partner_share) = the user's recognized expense |
ENFORCED at creation (crud.py:create_splitwise_i_paid; router rejects partner_share ≥ total) |
| C5 | Split-expense triple: −user_share − partner_share + partner_share = −user_share |
ENFORCED at creation (crud.py:create_split_expense, atomic since bc256f6/e36d719) |
| C6 | Funded buy/sell (cross-table): bank transfer leg + stock transaction satisfy bank_Δ ≈ −cash_native/rate; the "counter-leg" is the StockTransaction, not a Transaction row |
INTENDED-UNENFORCED at rest — the bank leg is a deliberately one-row transfer (crud.py:create_funded_buy); deleting either side alone is allowed and leaves the other orphaned |
Derivation:
| # | Invariant | Status |
|---|---|---|
| D1 | Holding(account,ticker) = fold over all its StockTransactions ordered by (date, id): buys/vests add shares and shares×price+fee to cost; sells remove at average cost; oversell at any historical point is rejected |
ENFORCED — every insert/delete recomputes (crud.py:recompute_holding_from_stock_transactions, called from create_stock_transaction, delete_stock_transaction, create_funded_buy, vest_rsu_grant); oversell → ValueError → 422 |
| D2 | Holding currency = currency of the first stock transaction; mixed-currency txns per ticker are not blocked | INTENDED-UNENFORCED (recompute…: currency = txns[0].currency) |
| D3 | StockTransaction.total_cost is informational — the fold uses shares×price_per_share+fee, never total_cost |
Definition/UNENFORCED — stored total_cost can diverge from the computed cost with no error |
| D4 | Vested grant ↔ vest transaction: FK rsu_grants.vested_stock_transaction_id; deleting the vest txn reverts the grant to pending |
ENFORCED (FK in models.py:RSUGrant; revert in crud.py:delete_stock_transaction, added in bc256f6) |
Definition (aggregates — see fin-accounting-reference for full semantics):
| # | Invariant | Where defined |
|---|---|---|
| B1 | balance ≡ SUM(amount − fee) per account, all types |
net_worth.py:calculate_net_worth balance query |
| S1 | monthly spending ≡ raw expense/splitwise_receive (amount<0, non-receivable accounts) + splitwise_pay at amount + splitwise_partner_share |
crud.py:get_monthly_spending |
| I1 | income ≡ income rows on non-payable/receivable accounts, excluding description = 'Opening balance' |
crud.py:get_monthly_spending income query — string-match exclusion is fragile (a user-entered "Opening balance" income is silently excluded) |
| N1 | net worth ≡ Σ per-account EUR balances (cash: B1; investment/rsu: shares × cached_price → _to_eur); missing FX rate ⇒ contribute 0.0 + warning |
net_worth.py:calculate_net_worth, _to_eur |
Integrity at rest (all INTENDED-UNENFORCED — the gap list):
- U1 — single-leg transfer/settlement via raw POST.
POST /api/transactions(routers/transactions.py:create_transaction) does zero validation beyond the Pydantic schema, andschemas.py:TransactionBaseacceptstype= any of the six includingtransfer/settlement, withlinked_account_idfreely settable and no partner row required. Verified truthfully: yes, a raw POST can create an unlinkedtransferrow. The CLI refuses (cli/fin_cli/tx.py:TX_TYPES = ["expense","income","splitwise_pay","splitwise_receive"]) — but that is client-side courtesy, not enforcement. (Small mercy:linked_transaction_idis NOT in the schema, so raw POST can't forge group links.) - U2 — transaction currency vs account currency: never checked anywhere; a USD row on a EUR account silently corrupts the native balance (balances are raw sums, no per-row FX).
- U3 — sign conventions: raw POST accepts a positive-amount
expense(drops out of spending viaamount < 0filter but still credits the balance) and a negativefee(inflates balance viaamount − fee). - U4 —
linked_transaction_idhas no FK (models.py:Transactionline 70 — plainInteger, notForeignKey): dangling group links possible;delete_transaction's graph walk silently skips missing partners. - U5 — edit block covers only
linked_account_id(routers/transactions.py:update_transaction): the split-expense expense row carries onlylinked_transaction_id, so PUT can edit its amount, desyncing it fromtotal − partner_shareof its transfer legs. - U6 —
PUT /api/accounts/{id}can changetypewith history attached (crud.py:update_accountsets any field) — e.g. bank→investment makes its transactions invisible to net worth (investment balance comes from holdings). - U7 —
splitwise_partner_shareunvalidated on raw POST: can exceed|amount|, making the "user share" positive;get_monthly_spendingthen silently drops the category (if total_amount >= 0: continue). - U8 — funded-buy FX fallback:
routers/stock_transactions.py:create_funded_buygets its rate fromnet_worth._get_eur_usd_rate, which still falls back to 1.0 when EUR/USD is missing — the6b7d19bfix removed the fallback only in_to_eur. A cross-currency funded buy with a cold price cache converts at 1.0.
Statistical:
- ST1 — snapshot continuity: one
net_worth_snapshotsrow per calendar day, written by the midnight cron (services/scheduler.py:fetch_prices_and_snapshot, upsert onsnapshot_date = date.today()). Gaps mean the container was down at midnight — expected, not corruption. Never treat a missing day as a data bug; treat a discontinuous jump between adjacent days as a lead (diffbreakdown_jsonper account).
Most dangerous unenforced invariant: U1. A single-leg transfer shifts one account's balance and net worth with no counterparty, and — because spending/income exclude transfer by type — it is invisible to the two aggregates a human actually reviews. It is the one corruption mode that evades every dashboard.
Worked example — e36d719 as a precise classification violation
Real commit (git show e36d719): before the fix, crud.py:create_funded_buy wrote the bank cash leg as type="expense", description=f"Buy {shares} {ticker}", no linked_account_id. State the violated invariant precisely: internal asset conversions must contribute 0 to spending and 0 to income (they change asset composition, not net worth or consumption).
Faithful reconstruction (repo has no prod amounts): buy 10 VUAA at $100, fee $2 ⇒ total_cost = 1002 USD; funding account EUR, eur_usd_rate = 1.08, no override ⇒ bank leg amount = −1002/1.08 = −927.78 EUR, type expense.
- Spending (S1): row matches
type in ('expense', …) AND amount < 0⇒ month total inflated by 927.78 under categoryNone. Violation: Δspending = +927.78, required 0. - Balance (B1): −927.78 either way — unchanged by the fix, because B1 sums all types.
- Net worth (N1): unchanged (cash −927.78, holdings +~927.78 at cost).
The fix retyped the leg to transfer (excluded from S1 by type) and migration h8c9d0e1f2a3 retyped historical rows (regex ^Buy \d+(\.\d+)? [A-Za-z0-9.\-]{1,12}$ so "Buy groceries" is untouched). The signature of a classification bug: exactly one aggregate moves, balances and net worth stay fixed. If your fix for a classification bug changes a balance, you fixed the wrong thing.
Failure modes of this method. (1) Classifying by intent instead of code — "create_transfer is atomic" says nothing about at-rest enforcement; always check the raw POST path. (2) Missing a write path (migrations and the scheduler also write rows). (3) Catalog rot: enforcement status changes with every router/schema edit — re-verify citations before relying on a status, don't trust this table's date-stamped snapshot blindly.
Method 2 — Delta analysis of a proposed change
Purpose. Compute the exact effect of a change on the three top-level aggregates — per-account balance (B1), monthly spending (S1/I1), net worth (N1) — before writing the code. The prediction is the acceptance test: after implementing, measured deltas must equal predicted deltas (fin-validation-and-qa's hypothesis-predicts-numbers discipline).
Inputs. The proposed diff (or migration), the aggregate queries in crud.py:get_monthly_spending and net_worth.py:calculate_net_worth, a small synthetic ledger.
Procedure.
- Identify every query whose row-set or per-row contribution the change touches. Grep for the column/type you're changing across
crud.pyandnet_worth.py— a type retype touches S1/I1 (type filters) but not B1 (no type filter): read the filters, don't assume. - Write each touched aggregate as an explicit formula over rows (copy the actual filter predicates).
- Build a 5-ish-row synthetic ledger that exercises every predicate branch the change touches (in-filter, out-of-filter, boundary).
- Hand-compute each aggregate before and after. Record the deltas, including the aggregates predicted not to move — those negative predictions are the cheapest bug detectors you own.
- After implementation, reconcile measured vs predicted (Method 3).
Worked example — migration h8c9d0e1f2a3 (real file, synthetic ledger)
api/alembic/versions/h8c9d0e1f2a3_retype_internal_cash_flows.py does two UPDATEs: (a) income/expense rows with linked_account_id IS NOT NULL → settlement (legacy reconcile legs); (b) expense rows whose description matches the funded-buy regex → transfer.
Synthetic ledger, month 2026-06 (labeled reconstruction; account 1 = bank EUR, account 3 = receivable):
| id | acct | type (before) | amount | description | linked_acct |
|---|---|---|---|---|---|
| t1 | 1 | expense | −50.00 | groceries | — |
| t2 | 1 | expense | −927.78 | Buy 10 VUAA | — |
| t3 | 1 | income | +3000.00 | salary | — |
| t4 | 1 | income | +200.00 | Received payment | 3 |
| t5 | 3 | expense | −200.00 | Received payment | 1 |
Before: spending = 50 + 927.78 = 977.78 (t5 excluded — receivable account, S1's Account.type.notin_(["receivable"])); income = 3000 + 200 = 3200; bank balance = −50 − 927.78 + 3000 + 200 = 2222.22.
Migration hits: t2 (regex match) → transfer; t4, t5 (linked + income/expense) → settlement. t1, t3 untouched (no link, no regex match — "Buy groceries" would also survive, that's what the regex is for).
After: spending = 50.00 (Δ = −927.78); income = 3000.00 (Δ = −200.00); bank balance = 2222.22 (Δ = 0); net worth Δ = 0. The spending/income drop is by design — the migration exists to remove exactly those contributions; the zero deltas on balance and net worth are the safety proof that it is a pure re-labeling. If you had predicted these five numbers before running the migration on a DB copy, one SELECT per aggregate confirms it.
Failure modes of this method. (1) Synthetic ledger too clean — always include a row designed to be incorrectly caught (the "Buy groceries" decoy) and one on an excluded account type. (2) Forgetting month boundaries — S1 is per-month; a date-shifting change moves spending between months with zero total-across-months delta. (3) Predicting from the schema instead of the query — e.g. assuming income excludes opening balances "somehow" instead of reading the literal description != 'Opening balance' filter. (4) Not writing predictions down before measuring; post-hoc "yes that looks right" is eyeballing with extra steps.
Method 3 — Reconciliation proof
Purpose. Prove a quantity by computing it three independent ways and showing agreement. Rule of the house: any claim that a fix corrected an aggregate needs a reconciliation triple — (1) the API's answer, (2) a raw-SQL recompute from first principles, (3) a hand calculation on the specific rows. Two agreeing numbers that share a code path prove nothing (the API and the frontend agree by construction).
Inputs. A read-only copy of the DB (prod DB is sacred — copy it out per fin-run-and-operate; never point tests or ad-hoc writes at it, per fin-validation-and-qa), the running API or fin --json, sqlite3.
Procedure.
- API answer:
curl -s http://<host>/api/net-worth/current | jq .orfin networth --json/fin spending --json(CLI is a thin HTTP wrapper — same code path as the frontend, counts as ONE source). - Raw-SQL recompute — re-derive from the definition, not from the API's implementation. Balances:
Spending for a month (mirror S1's two parts independently):
SELECT account_id, ROUND(SUM(amount - fee), 2) AS bal FROM transactions GROUP BY account_id;Holdings: foldSELECT ROUND(-SUM(t.amount), 2) FROM transactions t JOIN accounts a ON a.id = t.account_id WHERE t.date >= '2026-06-01' AND t.date < '2026-07-01' AND t.type IN ('expense','splitwise_receive') AND t.amount < 0 AND a.type != 'receivable'; SELECT ROUND(-SUM(t.amount + COALESCE(t.splitwise_partner_share,0)), 2) FROM transactions t JOIN accounts a ON a.id = t.account_id WHERE t.date >= '2026-06-01' AND t.date < '2026-07-01' AND t.type = 'splitwise_pay' AND t.amount < 0 AND a.type != 'receivable';stock_transactionsper (account, ticker) ordered by(date, id)and compare to theholdingsrow (D1). - Hand calculation: for the rows your change/bug touches (usually < 10), compute the contribution manually. This is the only leg that catches "the query and the API are consistently wrong together".
- Accept iff all three agree within |Δ| < 0.01 per account/category (money is
Floateverywhere — see Method 5; exact equality is not achievable and demanding it produces false alarms). - Mechanized form:
.claude/skills/fin-diagnostics-and-tooling/hosts executable audit scripts that are instances of these recomputes — use them for the SQL leg when they exist; the hand-calculation leg can never be mechanized away.
Worked micro-example (synthetic, from Method 2's ledger): claim "after migration, June spending is 50.00". Triple: fin spending --month 2026-06 --json → total: 50.0; SQL part 1 → 50.00, part 2 → NULL (no splitwise_pay rows); hand: only t1 qualifies, |−50| = 50. Agreement across three derivations ⇒ proven, not eyeballed.
Failure modes of this method. (1) Fake independence — recomputing with the ORM query copy-pasted from crud.py is source (1) again; write the SQL from the definition. (2) Reconciling against a moving target — the 5-minute price-refresh job (scheduler.py) mutates price_cache, so net-worth triples must pin prices (work on the DB copy, or capture price_cache first). (3) Currency blindness: the SQL balance is native; the API answer is EUR — convert with the same cached rate (SELECT price FROM price_cache WHERE symbol='EUR/USD') before comparing, or you'll "find" an 8% bug. (4) Proving the aggregate but not the rows — totals can agree while two row-level errors cancel; when adjudicating a specific bug, reconcile the affected rows, not just the total.
Method 4 — Currency-boundary analysis
Purpose. Audit any money-touching code path for currency correctness: where is a currency attached, converted, assumed, or silently defaulted?
The conventions (as of 2026-07-05, from code):
- Every cached FX symbol is
EUR/Xmeaning X units per 1 EUR (price_fetcher.py:fetch_eur_usdcaches frankfurter'sfrom=EUR&to=USDrate underEUR/USD). Therefore converting X→EUR is always a division:_to_eurdoesamount_native / rate(net_worth.py:_to_eur), andcreate_funded_buydoescash_native / used_rate(crud.py). Any multiplication by anEUR/Xrate when going to EUR is a bug; multiplication is only correct going EUR→X. - Only EUR/USD and EUR/GBP are fetched (
price_fetcher.py:fetch_all_prices). Any holding priced in CHF/JPY/SEK/… contributes 0.0 to net worth with a warning (N1). That is by design — see the worked example. - GBp/pence handling: yfinance reports LSE tickers in pence as
GBp/GBX;price_fetcher.py:_try_fetch_candidatedivides the price by 100 and stores currencyGBP. If you ever see a UK holding ~100× too large, this normalization got bypassed. - Ticker prices are cached in native currency with the currency stored on the row (
price_cache.currency);calculate_net_worthconverts using the price row's currency (ccy_map), while cash balances convert using the account's declared currency. Transactions carry their owncurrencybut balances never per-row convert (U2).
Checklist — run this against any diff that touches money:
- For every number, name its currency at every step (native → EUR is the only conversion direction in aggregates).
- Every conversion: is it a division by an
EUR/Xrate? If it multiplies, prove the direction. - What happens when the rate is missing? Acceptable answers: exclude-with-warning (
_to_eur→ 0.0) or refuse (vest requires a cached price,crud.py:vest_rsu_grant→ 422). Unacceptable: any silent numeric fallback. Known residual violation: U8 —_get_eur_usd_ratestill falls back to 1.0 androuters/stock_transactions.pyfeeds it tocreate_funded_buy. - Could a wrong currency's rate be used? (The pre-6b7d19b bug class.)
- Pence/GBX: does the path consume
price_cache(already normalized) or raw yfinance data (must normalize)? - Does a currency default lurk? (
ccy = (price_ccy or "USD")in_to_eur;Transaction.currencydefaults"EUR",StockTransaction.currencydefaults"USD"— a caller omitting currency gets these silently.)
Worked example — commit 6b7d19b (real diff; amounts reconstructed)
git show 6b7d19b: before, _to_eur with a missing EUR/{ccy} rate fell back to amount_native / EUR_USD_rate — converting with a different currency's rate. Reconstruction: 100 shares NESN.SW at 90 CHF = 9,000 CHF; cache has EUR/USD = 1.08 but no EUR/CHF (true today — CHF is never fetched). Fallback books 9000 / 1.08 = 8333.33 "EUR"; the correct value at EUR/CHF ≈ 0.93 is 9000 / 0.93 = 9677.42. Silent error: −1344.09 EUR (−13.9%), buried inside a plausible-looking total.
Why returning 0 is better than returning the unconverted-or-misconverted number: (a) 8333.33 is plausible — no one investigates plausible; a 9,677 hole in net worth is investigated the same day; (b) the midnight job persists net worth into net_worth_snapshots — a plausible wrong value corrupts the historical series permanently, while an obvious dip is a visible, dateable, explainable artifact; (c) the warning log names the missing rate, turning a silent data bug into an ops task ("fetch EUR/CHF"). This is the codebase's settled loud-zero-over-plausible-lie policy (fin-failure-archaeology: no-1.0-fallback, removed twice) — U8 is the one place it hasn't reached yet.
Failure modes of this method. (1) Auditing only the read path — currency bugs are often planted at write time (RSU vest hardcoded "USD" until e36d719 fixed it to read price_cache.currency). (2) Trusting variable names — eur_usd_rate in create_funded_buy is used for any account-vs-stock currency pair, not just EUR/USD; the name lies when the account is GBP. (3) Checking direction with round-trip algebra on symmetric test values (rate 1.0, amount 100) — always test with an asymmetric rate like 1.08 so a flipped division actually shows.
Method 5 — Boundary/edge catalog for money math
Purpose. The edges this codebase has historically gotten wrong, each with the standard probe to run against new money code. Every entry is a real commit.
| Edge | Incident (real commit) | Standard probe |
|---|---|---|
| Zero share / 0% split | 0a8a43f — SplitwiseShePayed with 0% computed user share submitted a 0-amount row; blocked client-side. Backend: split endpoints require partner_share > 0 and < total (schemas.py gt=0, router checks); raw POST still accepts amount = 0 (U3-adjacent) |
Feed 0 and the value that computes to 0 (e.g. total = 2×partner_share rounding to user share 0.00) |
| Negative inputs | 256c27f — Portfolio add-trade accepted negative shares/price (a "negative buy" is a disguised sell that corrupts avg cost). Backend now gt=0 on StockTransactionBase; Transaction.amount/fee remain unvalidated on raw POST |
Probe every numeric field with −1; decide reject-vs-meaning explicitly |
| Comma decimals | a3d9da7 — parseFloat("12,50") silently returns 12; German-locale entry lost cents/magnitude. QuickAdd inputs now normalize commas |
Enter "12,50" and "1.234,56" in any new amount input; assert 12.50, not 12 |
| UTC vs local midnight | bc256f6 — frontend default dates used toISOString().split('T')[0] (UTC); entries between 00:00 and ~01–02 CET booked to yesterday, silently moving spending across month boundaries. Fixed with todayLocalISO() (web/src/lib/utils.ts) |
Test any date-defaulting path at simulated 00:30 local; grep new frontend code for toISOString |
| Float money | No single incident — a standing condition: every money column is Float (models.py: amount, fee, shares, price_per_share, total_cost, total_eur, …). There is no Decimal and no backend rounding policy; the only rounding in the stack is frontend cents-rounding of partner_share (bc256f6) and display rounding (round(pct,1) in get_monthly_spending). Implications: (a) reconciliation must use tolerance |
Δ |
| RSU whole-share flooring | 225e69e — vest preview must floor, not round: net = floor(gross × (1 − withholding)) (crud.py:vest_rsu_grant); schedule generator floors per tranche and pushes the remainder into the last (crud.py:_generate_rsu_schedule); net_shares ≤ 0 → 422 |
gross=10, pct=0.51 ⇒ 4 (floor of 4.9 — round would say 5); gross=1, pct=0.5 ⇒ 0 ⇒ must 422 |
Failure modes of this catalog. It is a list of known edges — passing all probes proves you didn't repeat history, not that no new edge exists. And probes at the frontend prove nothing about the API (comma handling, negative rejection are client-side in several places): probe the layer you changed.
Method 6 — Adversarial self-refutation
Purpose. Before accepting any conclusion about ledger data ("the bug is X", "the data is actually correct", "the fix worked"), attack it yourself. Sonnet-and-owner failure mode this method exists to stop: the first mechanism that explains the headline observation gets accepted while a cheaper observation that contradicts it is never checked.
Procedure.
- Write down every observation the hypothesis must explain — explicitly including the negatives: which aggregates did NOT change? which rows were NOT affected? who is NOT seeing the problem? The negative space discriminates between mechanisms far better than the headline symptom (Method 2's classification signature — "spending moved, balance didn't" — is exactly such a negative).
- Construct at least one alternative mechanism that explains the headline observation. If you can't think of one, you haven't understood the system; check the layer above and below (frontend default vs backend default vs DB default all produce "wrong value in DB").
- Find a discriminating check: an observation the two mechanisms predict differently. Run it.
- Accept the hypothesis only when the alternative is falsified by the check — not when it merely feels less likely.
- For fixes: the prediction from Method 2 plus the triple from Method 3 is the acceptance; "the number looks right now" is not.
Worked example — the bc256f6 off-by-one-day bug (real incident; investigation reconstructed faithfully from the commit message and diff)
Headline observation: trades, vests, and settlements entered via the UI sometimes carry yesterday's date.
Full observation list, including negatives:
- O1: affected rows are dated one day early;
- O2 (negative): amounts, accounts, balances all correct — only the
datecolumn; - O3: only entries made shortly after local midnight (before ~01–02 CET) are affected;
- O4 (negative): rows created with an explicitly typed date, and CLI/API-created rows, are correct;
- O5 (negative): the scheduler's snapshot dates are correct.
Hypothesis A (first instinct): the backend/server clock or timezone shifts dates. Hypothesis B: the frontend's default date is computed in UTC.
Discriminating checks: (i) A predicts O4 fails — the backend would shift stored dates regardless of who sent them; but explicitly-dated rows are stored verbatim (the Date column stores what it's given, models.py:Transaction.date), so A is falsified by O4. (ii) A predicts O5 fails too — the scheduler runs server-side (scheduler.py uses date.today()), and its snapshots were fine. (iii) B predicts the bug lives where defaults are computed client-side: grep -rn "toISOString" web/src found toISOString().split('T')[0] in Dashboard, Portfolio, Spending and the QuickAdd flows — JS toISOString() is UTC, so between local midnight and UTC midnight it yields yesterday. B explains O1–O5 with nothing left over; A is dead on two independent checks. The fix (todayLocalISO() helper, bc256f6) touched only the frontend — which is itself a post-hoc confirmation that B was the mechanism.
The refutation discipline is what made this cheap: checking O4 (one CLI insert with an explicit date) cost seconds and killed the expensive hypothesis (timezone archaeology on the container) before anyone spent an hour on it. Also note O3's precision — "before ~01–02 CET" is CET/CEST's UTC offset speaking; a hypothesis that can't derive the cutoff hour from its mechanism doesn't actually explain O3.
Failure modes of this method. (1) Strawman alternatives — the alternative must be one you'd genuinely believe if the check went the other way. (2) Confirmation-shaped checks — pick the observation the hypotheses disagree on, not another one they both predict. (3) Stopping at one bug: bc256f6 was a bug-hunt commit; after falsifying A for one symptom, re-run the loop per symptom — several independent mechanisms (currency hardcoding, non-atomic split expense, vest-unlink deadlock) were live simultaneously, and a single unified explanation for all of them would have been wrong.
Provenance and maintenance
All claims verified against HEAD = 082b608, 2026-07-05. Re-verification one-liners (from repo root):
- Invariant catalog / U1:
sed -n '24,29p' api/app/routers/transactions.py(create_transaction has no checks) andgrep -n "TX_TYPES" cli/fin_cli/tx.py(CLI refusal). - U4:
grep -n "linked_transaction_id" api/app/models.py— still plainInteger? - U5:
grep -n "linked_account_id is not None" api/app/routers/transactions.py— edit block still ignoreslinked_transaction_id-only rows? - U8:
grep -n "_get_eur_usd_rate" api/app/routers/stock_transactions.py api/app/services/net_worth.py— 1.0 fallback still feeding funded buys? - B1/S1/I1/N1 formulas:
grep -n "amount - models.Transaction.fee" api/app/services/net_worth.py;grep -n "splitwise_partner_share\|Opening balance" api/app/crud.py. - FX direction & GBp:
grep -n "amount_native / rate" api/app/services/net_worth.py;grep -n "GBp\|GBX" api/app/services/price_fetcher.py; fetched rates:grep -n "fetch_eur_" api/app/services/price_fetcher.py. - Float money:
grep -c "Float" api/app/models.py(any Decimal yet?). - Worked-example commits still resolve:
git show --stat e36d719 6b7d19b bc256f6 225e69e a3d9da7 0a8a43f 256c27f | grep ^commit. - Migration example:
sed -n '30,52p' api/alembic/versions/h8c9d0e1f2a3_retype_internal_cash_flows.py.
If any check disagrees with this file, the code wins — update the catalog entry and its date stamp in the same commit as the change (per fin-change-control's docs-of-record rule).