Mirror YNAB budget into Postgres with delta sync and change log #55

Merged
ryan merged 5 commits from feat/ynab-mirror into main 2026-10-12 18:07:32 -04:00
Owner

Why

The YNAB tools called the live API on every question and filtered results in Python, so many questions they couldn't answer at all. YNAB has no event log, but it supports delta requests (server_knowledge), so this keeps a local copy in Postgres and answers from it.

What

  • ynab_* tables (accounts, payees, categories, groups, months, month categories, transactions, subtransactions, sync state). The migration was generated by aerich and only adds tables, so it's safe for blue-green deploys.
  • Sync (blueprints/ynab/sync.py): full download on the first run (~5 s, ~8 MB), then a delta every YNAB_SYNC_INTERVAL (default 900 s) and on demand when the data is >2 min old, plus a full resync once a day. Concurrent syncs, including across blue and green, are serialized by an asyncio lock and a Postgres advisory lock.
  • ynab_changes: a log of real edits (transactions, assignments, renames) as old → new values, built by diffing each sync against the stored copy. Derived fields like balances and activity aren't logged.
  • Agent tools: ynab_budget_summary, ynab_account_balances, ynab_search_transactions (splits expanded), ynab_spending_report (by category, group, payee, account or month), ynab_category_budget, ynab_recent_changes.
  • Admin-only ynab_sql: read-only SELECT over the ynab_* tables. sqlglot validation (one SELECT, table allowlist resolved through scopes, dangerous functions denied), then a READ ONLY transaction, 10 s timeout and 200-row cap.
  • make ynab-sync / make ynab-resync, python -m blueprints.ynab [--full].
  • Removed utils/ynab_service.py and the unused ynab SDK; added sqlglot.

Notes

  • "Spent" is computed from transactions (on-budget, non-transfer, excluding Ready to Assign). YNAB's own month activity nets in the Credit Card Payments categories: it shows -$434 for October 2026 against $3,805 actually spent.
  • YNAB marks every category group internal, so only the category-level flag is used for filtering.
  • The regular YNAB tools are still available to all users, as before. Only ynab_sql is admin-only.

Testing

  • .venv/bin/pytest tests/ -q: 122 passed (new tests/unit/test_ynab_mirror.py covers delta application, change-log diffs, split/delete handling, SQL guard bypass cases and month validation).
  • Ran against the real budget on local Postgres: full sync, a delta sync with no changes, a full resync that logged no changes, and every tool checked by hand.
  • A code review found a CTE-shadowing bypass in the SQL guard, now fixed with regression tests.

🤖 Generated with Claude Code

## Why The YNAB tools called the live API on every question and filtered results in Python, so many questions they couldn't answer at all. YNAB has no event log, but it supports delta requests (`server_knowledge`), so this keeps a local copy in Postgres and answers from it. ## What - **`ynab_*` tables** (accounts, payees, categories, groups, months, month categories, transactions, subtransactions, sync state). The migration was generated by aerich and only adds tables, so it's safe for blue-green deploys. - **Sync** (`blueprints/ynab/sync.py`): full download on the first run (~5 s, ~8 MB), then a delta every `YNAB_SYNC_INTERVAL` (default 900 s) and on demand when the data is >2 min old, plus a full resync once a day. Concurrent syncs, including across blue and green, are serialized by an asyncio lock and a Postgres advisory lock. - **`ynab_changes`**: a log of real edits (transactions, assignments, renames) as old → new values, built by diffing each sync against the stored copy. Derived fields like balances and activity aren't logged. - **Agent tools**: `ynab_budget_summary`, `ynab_account_balances`, `ynab_search_transactions` (splits expanded), `ynab_spending_report` (by category, group, payee, account or month), `ynab_category_budget`, `ynab_recent_changes`. - **Admin-only `ynab_sql`**: read-only SELECT over the `ynab_*` tables. sqlglot validation (one SELECT, table allowlist resolved through scopes, dangerous functions denied), then a READ ONLY transaction, 10 s timeout and 200-row cap. - `make ynab-sync` / `make ynab-resync`, `python -m blueprints.ynab [--full]`. - Removed `utils/ynab_service.py` and the unused `ynab` SDK; added `sqlglot`. ## Notes - "Spent" is computed from transactions (on-budget, non-transfer, excluding Ready to Assign). YNAB's own month `activity` nets in the Credit Card Payments categories: it shows -$434 for October 2026 against $3,805 actually spent. - YNAB marks every category group `internal`, so only the category-level flag is used for filtering. - The regular YNAB tools are still available to all users, as before. Only `ynab_sql` is admin-only. ## Testing - `.venv/bin/pytest tests/ -q`: 122 passed (new `tests/unit/test_ynab_mirror.py` covers delta application, change-log diffs, split/delete handling, SQL guard bypass cases and month validation). - Ran against the real budget on local Postgres: full sync, a delta sync with no changes, a full resync that logged no changes, and every tool checked by hand. - A code review found a CTE-shadowing bypass in the SQL guard, now fixed with regression tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
ryan added 1 commit 2026-10-12 17:33:01 -04:00
YNAB has no event feed, so keep a local copy in ynab_* tables synced via
server_knowledge delta requests: full download on first run, deltas every
YNAB_SYNC_INTERVAL seconds and on demand before each tool call, full
resync daily. Each sync diffs against the mirror and appends real edits
(transactions, assignments, renames) to ynab_changes, the edit history
YNAB doesn't keep.

Agent YNAB tools now answer from the mirror: budget summary (spending
computed from transactions, since YNAB's month activity nets in credit
card payment categories), account balances, transaction search with
splits expanded, spending reports by category/payee/account/month,
category budget, and recent changes. Adds an admin-only read-only
ynab_sql tool guarded by a sqlglot allowlist (scope-resolved tables, no
dangerous functions), a READ ONLY transaction, a timeout, and a row cap.

Replaces utils/ynab_service.py and drops the unused ynab SDK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ryan added 1 commit 2026-10-12 17:58:16 -04:00
- Full resyncs mark mirror rows missing from the download as deleted;
  YNAB omits deleted entities from non-delta responses, so deletions
  made just before a full sync never reached the mirror.
- YNAB tools return errors as text instead of raising, since ToolNode
  re-raises and would fail the whole chat turn. ynab_sql catches all
  sqlglot errors (e.g. an unbalanced quote) and runs the SQL regenerated
  from the validated tree, which also drops comments that broke the
  LIMIT wrapper. Results keep duplicate column names.
- Sync fetches from YNAB before opening the DB transaction, discards the
  payload if the other container synced meanwhile, re-checks freshness
  under the lock so parallel tool calls share one sync, and backs off
  for 60s after a failure.
- The mirror is pinned to one budget; a changed YNAB_BUDGET_ID errors
  instead of mixing budgets, and an unset id stays on the budget the
  first sync resolved from "last-used".
- Change log: no entries for empty month rollover rows, and id fields
  are logged as names (category, payee, account). ynab_recent_changes
  accepts the names it prints ("assignment") and plurals.
- Outputs sized to the 4000-char tool result cap; ynab_sql reuses the
  shared admin message.
- YNAB API client moves back to utils/ynab_service.py, and the frontend
  tool status labels list the new tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ryan added 1 commit 2026-10-12 18:03:50 -04:00
- Failure backoff is re-checked under the sync lock, so parallel tool
  calls share one failed YNAB request instead of retrying in turn.
- Budget id: YNAB_BUDGET_ID aliases ("last-used", "default") count as
  unset; state is always keyed by the real budget id, a mismatched
  response errors instead of silently skipping every sync, and more
  than one stored budget is an error rather than an arbitrary pick.
- Change log renames only id fields that resolve to names (account,
  payee, category, group); subtransaction transaction_id stays as is.
- ynab_recent_changes: correct singular for "categories", multi-word
  types, and the "month" type.
- The SQL wrap test now parses the wrapped query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ryan added 1 commit 2026-10-12 18:06:51 -04:00
Resolve the Makefile .PHONY conflict (desktop + ynab targets) and
regenerate the YNAB migration with aerich as 17 on top of main's
16_add_oidc_states, replacing the colliding 16_add_ynab_mirror.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ryan added 1 commit 2026-10-12 18:07:28 -04:00
Resolve config/db.py (artifacts + ynab models) and regenerate the YNAB
migration with aerich as 18 on top of main's 17_add_artifacts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ryan merged commit 417d4061d7 into main 2026-10-12 18:07:32 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ryan/simbarag#55