Status: Proposal for review (not yet implemented). Goal: Prepare the database and public API for the v1 open-source launch so that (a) self-hosters can grow their database confidently, and (b) a third-party developer would want to build a standalone product on top of the API. Stance: Bold. This is the last cheap moment to make breaking changes. After v1 we owe users a stability pledge, so we spend the breakage budget now.
The schema is fundamentally healthy. Past sprints already retired the worst cruft
(admin_accounts, audit_log, category_mappings, review_queue,
transaction_comments, member_accounts are all dropped). What remains is drift, not
rot: half-finished renames, reserved-word footguns, provider-vocabulary leakage,
TEXT-as-enum sprawl, and two experimental subsystems (OAuth, device-codes) sitting in the
schema with zero production rows.
The API is RESTful and well-documented, but it blurs two audiences into one surface: the operator (who manages providers, users, workflows) and the integrator (who reads financial data). v1 should split those cleanly and make the integrator surface a first-class, stable, fully-paginated contract.
Seven bold strokes define this proposal:
- Finish the agent→workflow rename down to the FK columns and query files. Stop shipping a subsystem that is half "agent", half "workflow".
short_idbecomes the public ID. The API stops leaking internal UUIDs. One ID, everywhere, base62, stable.- Promote canonical enums to real Postgres
ENUMtypes. We already document 14 canonical enums inCLAUDE.md; the schema should enforce them, not approximate them withTEXT + CHECK. - Rename
annotations→transaction_eventsand treat it as the first-class, partition-ready event log it already is. - Split the surface: public
/api/v1(token-auth, OpenAPI, stable) vs. operator/admin(session-auth). Workflows, provider credentials, users/logins, and settings move to operator-only. - Uniform cursor pagination + field selection on every list. Kill offset pagination and bare-array responses.
- Defer OAuth out of the advertised v1; ship API-keys as the single, coherent front door (and finish device-code CLI login so headless self-hosters can mint a key). Add the missing retention/cleanup jobs for everything we ship.
⚠️ Scope note (verified):csv_import_*,dev_reports, andtransactions.content_hashappear in the shared dev database but are NOT inmain— they leaked from sibling worktree branches (csv-import-v2, devmode-reporter). This proposal targets the realmainschema (34 live tables). Their design feedback is captured in §6 for when those branches land, but they are not part of the v1 cleanup.
34 tables today → 30 in the v1 target (after renames, drops, and deferrals). Grouped
by domain. Renamed columns are shown as old → new. New columns marked +.
| Table | Notable target shape | Changes |
|---|---|---|
users |
household members (the people) | + deleted_at (soft-delete preserves connection history) |
auth_accounts |
login credentials, FK→users | role TEXT → role_type ENUM(admin, editor, viewer) |
api_keys |
the v1 front door | agent_definition_id → workflow_id; keep scope ENUM(full_access, read_only) |
sessions |
dashboard cookies (scs-managed) | unchanged — first-party admin only, never an API token |
| Table | Notable target shape | Changes |
|---|---|---|
bank_connections |
one row per provider linkage | external_id → provider_connection_id; + index (user_id, status); promote (provider, provider_connection_id) partial unique index → real UNIQUE constraint |
accounts |
financial accounts under a connection | iso_currency_code → NOT NULL DEFAULT 'USD'; document is_dependent_linked/excluded as API-visible flags |
account_links |
multi-account reconciliation links | match_strategy TEXT → match_strategy ENUM(date_amount_name) |
transaction_matches |
matched txn pairs across linked accounts | keep (feature-complete, 0 rows is expected for fresh installs) |
| Table | Notable target shape | Changes |
|---|---|---|
transactions |
the core ledger | drop unofficial_currency_code (dead Plaid legacy); move provider_raw → side table transaction_provider_payloads (decision §4.1); provider_pending_transaction_id → replaced_pending_provider_id; category_override → category_source (ENUM none/rule/agent/user); + index (attributed_user_id) |
transaction_provider_payloads (NEW) |
1:1 raw provider payload archive, keyed by transaction_id |
holds provider_raw JSONB off the hot ledger so scans/queries stay lean; full payload available on join for debugging/re-enrichment |
categories |
hierarchical category tree | no change — clean |
tags |
reusable labels | lifecycle TEXT → tag_retention ENUM(persistent, ephemeral) + document "ephemeral = removal requires a reason" |
transaction_tags |
txn↔tag join w/ provenance | no change — clean |
transaction_rules |
the rule DSL store | trigger TEXT → rule_trigger ENUM(on_create, on_change, always) |
transaction_events ⬅ annotations |
renamed. the activity/audit event log | kind TEXT+CHECK → event_kind ENUM; partition-by-month plan documented for scale |
recurring_series |
detected subscriptions/bills | cadence, status, type, detection_source, confidence → ENUM types; document detection_signals JSONB shape |
series_tags |
series↔tag join (materialized onto txns) | no change — clean |
| Table | Notable target shape | Changes |
|---|---|---|
sync_logs |
one row per sync run | trigger → sync_trigger (kill reserved-word footgun); keep duration_ms (perf denorm) |
sync_log_accounts |
per-account breakdown of a run | no change — intentional, not redundant |
sync_schedules |
cron-anchored schedules | no change — recently landed, clean |
sync_schedule_connections |
schedule↔connection join | no change |
webhook_events |
provider webhook audit | + retention job (7-day default, config-keyed); document "payload not persisted, processed transactionally" |
hosted_link_sessions |
shareable bank-link surface | no change — clean |
| Table | Notable target shape | Changes |
|---|---|---|
workflows ⬅ (agent_definitions) |
scheduled AI run definitions | table already renamed; finish query-file rename |
workflow_runs ⬅ (agent_runs) |
run history + token/cost metrics | agent_definition_id → workflow_id; query-file rename |
reports ⬅ agent_reports |
AI-authored reports | agent_run_id → workflow_run_id; collapse author + created_by_name → single author display contract |
connector_library |
global custom-MCP connectors | no change — newly landed, clean |
| Table | Notable target shape | Changes |
|---|---|---|
mcp_sessions |
one row per MCP transport session | no change |
mcp_tool_calls |
per-tool-call audit | gate request_json/response_json persistence behind a config flag (PII); + retention job (30-day default) |
| Table | Notable target shape | Changes |
|---|---|---|
app_config |
env→DB→default key/value | remove dead seeded keys sync_interval_hours, setup_complete |
| Table(s) | Verdict | Rationale |
|---|---|---|
oauth_clients, oauth_access_tokens, oauth_refresh_tokens, oauth_authorization_codes |
DEFER — not advertised; remove from v1 docs/UI | 0 production rows; expiry-cleanup job never wired; unproven rotation/revocation under load. Ship API-keys as the single front door. Re-introduce as a post-1.0 minor with the cleanup hook. |
auth_device_codes |
FINISH for v1 (decision §4.2) | RFC-8628 CLI login is a real self-hoster story. Wire the service layer + breadbox auth login, add the expiry-cleanup job, and ship it. |
Net table count: 34 today + 1 (
transaction_provider_payloads) − 4 OAuth (deferred) − 0 (reportsis a rename, device-codes now ships) = ~31 advertised tables for v1.
PUBLIC INTEGRATOR API OPERATOR / ADMIN SURFACE
/api/v1/* /admin/* (or keep /-/*)
───────────────────── ─────────────────────────
auth: X-API-Key (bb_…) auth: session cookie + CSRF
documented in openapi.yaml not in the public spec
stability pledge applies free to change
cursor pagination everywhere —
transactions (+ events, providers / credentials
tags, metadata) users / login-accounts
accounts workflows / runs / reports
connections (read) connectors
categories settings / app_config
rules sync-schedules
series hosted-links (create)
tags
sync-logs (read)
webhooks (NEW: out-bound)
Why: today /api/v1/workflows, /api/v1/settings/providers/plaid, and
/api/v1/login-accounts live under the public umbrella but are operator-only in practice.
A third-party dev shouldn't discover an endpoint, build against it, and then learn it
needs admin rights. The split makes the public contract honest.
- One ID. Every resource exposes a single
id= the 8-char base62short_id. Internal UUIDs are never returned. Path params take the public id. (Today REST returns bothid(uuid) andshort_id— confusing and leaky.) - Cursor pagination, uniformly.
?limit=50&cursor=<opaque>on every list.limitcaps at 500. Response:{ "<resource>": [...], "next_cursor": "…"|null, "has_more": bool }. No offset, no bare arrays. - Field selection, uniformly.
?fields=core|full|all(+ named projections) on every read. Documented default per resource. - Error envelope (unchanged).
{ "error": { "code": "UPPER_SNAKE", "message": "…" } }. Codes are stable contracts. - Money (unchanged, documented loudly).
NUMERIC(12,2), always paired withiso_currency_code, positive = money out. Never summed across currencies. - Idempotency. Optional
Idempotency-Keyheader on all writes; replays within 24h return the cached response. - Traceability. Echo
X-Request-IDon every response (middleware already generates it). - CORS.
CORS_ALLOWED_ORIGINSenv var so self-hosters can call the API from a SPA.
Within
/api/v1, fields are additive: never removed, never renamed, never re-typed. New capabilities arrive as new fields or new/api/v1/{resource}paths. A breaking change means a new/api/v2prefix;/api/v1keeps running. Deprecated endpoints carry aSunsetheader and live for at least 12 months.
| Capability | Shape |
|---|---|
| Outbound webhooks | POST /api/v1/webhooks registers a callback URL; Breadbox fires HMAC-signed POSTs on transaction/series/report changes. The single biggest unlock for external products (no more polling). |
| Bulk transaction update | consolidate batch-categorize + bulk-recategorize into one POST /api/v1/transactions/update (filter- or id-based). |
| Request body examples in OpenAPI | spec defines shapes but lacks example payloads for writes. |
- Standardize
fields=defaults across all row-returning tools (today transactions/rules have it, series doesn't). - Consolidate the 11 series tools: fold
add_series_tag/remove_series_tagintoupdate_series; clarifyreview_series(adjudicate) vsassign_series(create/backfill). - Add
get_transaction_rule(single-fetch mirror; REST has it, MCP doesn't). - Add reconciliation tools (
list_transaction_matches,confirm_match,reject_match) if account-linking is advertised for v1.
These were the four open calls; resolved 2026-06-14.
transactions.provider_raw→ side table. ✅ Move to a new 1:1transaction_provider_payloads(keyed bytransaction_id). Keeps the hot ledger lean for scans/queries while preserving every payload for debugging and re-enrichment. The sync upsert writes the lean row + the payload row in the same transaction.auth_device_codes→ finish for v1. ✅ Wire the service layer +breadbox auth loginso headless self-hosters can bootstrap a key without a browser. Add the missing expiry-cleanup job alongside it. Table stays.transaction_eventspartitioning → defer. ✅ Ship the rename + ENUM in v1; addPARTITION BY RANGE (created_at)as a documented v1.x step once an install crosses ~1M events. No over-engineering for typical household scale.- Enum conversions → do them now (v1). ✅ Convert all 14 canonical enums from
TEXT+CHECKto real PostgresENUMtypes as part of v1. These areALTER TYPE-class (destructive on the shared dev DB), so they run as a coordinated cutover — sequenced, announced to other worktrees, applied againstbreadbox_testfirst — not as additive trickle. This is the riskiest migration in the set and gets its own wave.
transaction_provider_payloadstable (1:1 raw-payload archive;provider_rawmoves here off the hot ledger).- Finish
breadbox auth login— wireauth_device_codesservice layer + CLI + expiry-cleanup job. users.deleted_at(soft-delete).- Indexes:
bank_connections(user_id, status),transactions(attributed_user_id). UNIQUE(bank_connections.provider, provider_connection_id)constraint.- Retention/cleanup jobs:
webhook_events(7d),mcp_tool_calls(30d),auth_device_codes(expiry). - Config flag gating
mcp_tool_callsrequest/response JSON persistence. - Public API: outbound webhooks,
Idempotency-Key,X-Request-IDecho, CORS config, OpenAPI request examples.
- Finish agent→workflow:
workflow_runs.agent_definition_id → workflow_id;api_keys.agent_definition_id → workflow_id;agent_reports → reportswithagent_run_id → workflow_run_id; query filesagent_*.sql → workflow_*.sql. annotations → transaction_events(+kind → event_kind).sync_logs.trigger → sync_trigger(reserved word).bank_connections.external_id → provider_connection_id.transactions.category_override → category_source;provider_pending_transaction_id → replaced_pending_provider_id.tags.lifecycle → tag_retention.- Public API IDs: responses expose
short_idas the single canonicalid; drop UUID exposure.
auth_accounts.role,account_links.match_strategy,transaction_rules.trigger,transactions.category_source,transaction_events.event_kind,recurring_series.{cadence,status,type,detection_source,confidence}.
transactions.unofficial_currency_code(dead).app_configseeded keyssync_interval_hours,setup_complete(dead).- OAuth (4 tables) from advertised v1 — keep dormant or remove; ship API-keys only.
- API: flat
/login-accountslist, redundantbatch-categorize/bulk-recategorize(→/transactions/update).
transactions.provider_rawcolumn →transaction_provider_payloadsside table (DB).- Surface (public → operator):
/workflows,/workflow-runs,/reports,/connectors, provider credentials, users/login management, settings, sync-schedules →/admin/*(session-auth, out of the public spec).
- OAuth 2.1 (4 tables), multi-tenant client-credentials, fine-grained scopes beyond read/write.
transaction_eventspartitioning (PARTITION BY RANGE (created_at)) — add at ~1M events.- Account-link MCP tools (unless linking ships).
- csv-import-v2 (
csv_import_sessions/rows/profiles,transactions.content_hash):raw_blob BYTEAstoring whole CSVs in Postgres is an anti-pattern — externalize or stream.csv_import_rowsdouble-storesrawJSON + parsed columns — pick one. - devmode-reporter (
dev_reports): leaked into dev DB with nomainmigration and no DB-write path in the handler ("no token, no persistence"). Gate behind a dev flag or drop before it reachesmain.