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)
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic bytes: ASCII ARCA |
| 4 | 2 | Container version (u16) — currently 1 |
| 6 | 2 | Flags (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)
| Offset | Size | Field |
|---|---|---|
| 8 | 1 | KDF: 1 = Argon2id v1.3 |
| 9 | 4 | m_cost in KiB (u32) — default 65,536 (64 MiB) |
| 13 | 4 | t_cost (u32) — default 3 |
| 17 | 4 | p_cost (u32) — default 1 |
| 21 | 16 | Salt (random, renewed whenever the password changes) |
| 37 | 12 | AES-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:
- writes the full container to
file.arca.tmpand flushes it to disk; - renames the current
.arcatofile.arca.bak; - renames the
.tmptofile.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.
| Table | What 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 incurrencies.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 thefx_ratein effect when it was recorded. Changing rates later doesn't rewrite history.kind:normal— an expense or refund; affects thecategory_idenvelope (or is "uncategorized" ifNULL).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 sharetransfer_idand have exactly oppositeamount_basevalues, 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_versionhigher 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.