Reference

The .arca file format

Your data is yours. This document describes the .arca file in enough detail that anyone can read it without Arca, even if the project disappeared. No proprietary tricks, nothing hidden.

Summary

A .arca file is a complete SQLite database wrapped in an 8-byte header. If the file is password-protected, the database is encrypted with AES-256-GCM using a key derived with Argon2id.

  • No password: strip the first 8 bytes and you have a normal SQLite file, openable with any off-the-shelf tool (sqlite3, DB Browser for SQLite, Python, Excel via ODBC…).
  • With a password: derive the key (Argon2id), decrypt (AES-256-GCM), and you get the same SQLite file.

The container

All integers are little-endian.

Header (first 8 bytes, always present)

OffsetSizeField
04Magic bytes: ASCII ARCA
42Container version (u16) — currently 1
62Flags (u16). Bit 0 = encrypted

Unencrypted (bit 0 = 0)

From offset 8 to the end of the file is the SQLite database as-is — it starts with SQLite format 3\0.

Encrypted (bit 0 = 1)

OffsetSizeField
81KDF: 1 = Argon2id v1.3
94m_cost in KiB (u32) — default 65,536 (64 MiB)
134t_cost (u32) — default 3
174p_cost (u32) — default 1
2116Salt (random, renewed whenever the password changes)
3712AES-GCM nonce (random, new on every save)
49…Ciphertext: encrypted SQLite database + 16-byte GCM tag at the end
  • Key = Argon2id(password in UTF-8, salt, m_cost, t_cost, p_cost), 32-byte output.
  • The full 49-byte header is used as AES-GCM's associated data (AAD): if anyone tampers with the parameters, decryption fails outright instead of silently weakening security.

Python example

This is literally all it takes to get a plain SQLite file out of a .arca file, with or without a password:

from argon2.low_level import hash_secret_raw, Type   # pip install argon2-cffi
from cryptography.hazmat.primitives.ciphers.aead import AESGCM  # pip install cryptography
import struct, sqlite3

data = open("my budget.arca", "rb").read()
assert data[:4] == b"ARCA"
flags = struct.unpack_from("<H", data, 6)[0]
if flags & 1:
    m, t, p = struct.unpack_from("<III", data, 9)
    salt, nonce = data[21:37], data[37:49]
    key = hash_secret_raw(b"my password", salt, t, m, p, 32, Type.ID)
    db = AESGCM(key).decrypt(nonce, data[49:], data[:49])
else:
    db = data[8:]
open("budget.sqlite", "wb").write(db)
print(sqlite3.connect("budget.sqlite").execute("select count(*) from transactions").fetchone())

How it's written to disk

Arca never modifies the file "live." It keeps the database in memory and, on every save:

  1. writes the full container to file.arca.tmp and flushes it to disk;
  2. renames the current .arca to file.arca.bak;
  3. renames the .tmp to file.arca.

That means there's always at least one complete copy on disk, and synced folders (Dropbox, OneDrive, iCloud Drive, Syncthing) never see a half-written file. While a budget is open, a file.arca.lock file also exists — a lock so two windows can't write at once — and it's removed on close.

If Arca detects, on save, that the file on disk changed since it was opened (another computer, the Dropbox client catching up), it does not overwrite it: it asks whether to keep your version, load the one on disk, or save as a new file.

SQLite schema (version 5)

The meta table holds schema_version, budget_name, base_currency, mode (simple/full), locale and created_at. It may contain other internal keys besides those — safe to ignore.

TableWhat it stores
currencies(code, exponent, symbol, name)Currencies used in the budget.
fx_rates(code, as_of, rate, source)Rate history (source: manual, online, or ecb).
accounts(id, name, kind, currency, closed, sort, note, created_at)Accounts. kind: checking, savings, cash, credit, other.
category_groups(id, name, sort, hidden)Envelope groups.
categories(id, group_id, name, icon, sort, hidden, goal_kind, goal_amount, goal_date, debt_account_id)Envelopes and their optional goal (target, by_date, monthly; amount in base currency). (v4) debt_account_id: when not NULL, this is the debt envelope of that credit card.
assignments(month, category_id, amount)Money assigned to an envelope in a given month (base currency).
transactions(id, account_id, date, amount, payee, memo, category_id, kind, transfer_id, fx_rate, amount_base, cleared, fitid, import_id, created_at)Movements. See below.
imports(id, account_id, file_name, imported_at, count)Record of each import.
payee_memory(payee_norm, category_id, income, uses)Last envelope used for a given payee (for suggestions).
rules(id, match_kind, pattern, category_id, rename_to, sort)Categorization rules.
csv_profiles(account_id, mapping)CSV column mapping remembered per account (JSON).
scheduled(id, account_id, payee, memo, amount, kind, category_id, transfer_account_id, frequency, anchor_day, next_date, end_date, created_at)Scheduled transactions. frequency: weekly, biweekly, monthly, yearly; anchor_day is the original day of the month, so "every 31st" lands on the last day of short months. Never posted on their own — the app shows them as upcoming and you confirm or skip each one.

Conventions

  • Amounts: integers in the minor unit of their currency (1234 = 12.34 USD; 1500 = 1,500 CLP). The number of decimals for each currency is in currencies.exponent.
  • Dates: ISO text YYYY-MM-DD. Months: YYYY-MM.
  • Ids: UUID v7 as text.
  • Rates: exact decimal text, "units of the base currency per 1 unit of the currency."

Transactions

  • amount: in the account's own currency, signed (negative = money out).
  • amount_base: the same movement in the base currency, frozen at the fx_rate in effect when it was recorded. Changing rates later doesn't rewrite history.
  • kind:
    • normal — an expense or refund; affects the category_id envelope (or is "uncategorized" if NULL).
    • income — income; goes to "Ready to assign."
    • starting — an account's opening balance; goes to "Ready to assign."
    • transfer — one leg of a transfer between accounts; both legs share transfer_id and have exactly opposite amount_base values, so no money is created or destroyed.

How the budget math works

For each envelope and month: available = carryover + assigned + activity, where activity is the sum of amount_base for its normal transactions that month. A positive balance carries over to the next month; a negative one (overspending) resets to zero and is deducted from next month's "Ready to assign."

Debt envelopes (v4): a credit card's starting balance (starting) may have a category_id pointing to its debt envelope. It then counts as that envelope's activity instead of "Ready to assign" income, and the envelope's negative balance does carry over (it's earlier debt being paid down, not overspending).

Ready to assign(month) = income through the month − assigned through the month − assigned in future months − overspending from previous months + uncategorized transactions through the month.

This guarantees: Ready to assign + Σ available = total account balances (at frozen rates) − amount assigned to future months.

Compatibility

  • A newer Arca always opens files from older versions (it migrates the schema on save).
  • An older Arca refuses to open a file with a schema_version higher than the one it knows, rather than risk corrupting it.

Since version 0.1.3 you don't even need this: Settings → Security & backup → Everything, as SQLite writes the plain SQLite file directly — no container, no header to strip.