This documentation describes the technical architecture of the data format for use outside the Receipts Space app. The design follows the principles of “Local First” and “File Over App”.
Highlights
- Offline-First: Fully usable without a server
- Conflict-Free: CRDT-based synchronization across arbitrary storage media
- Tamper-Resistant: Chained transactions with optional signing
- Durable: Simple, documented format (JSON/JSONL)
- Portable: Data belongs to the user, not the app
Example code on GitHub holtwick/receipts-space
App-level interfaces
This document describes the data format at the file level. For import, export, and automation through the running app (JSON import, AppleScript, URL scheme, watched folders) see Interfaces.
Directory Structure
A workspace is organized as follows:
workspace/
├── info.json → Metadata
├── transactions/<clientId>/<...>/<idx>.dat → Change log
└── assets/<clientId>/<...>/<idx>.dat → File attachmentsWorkspace Metadata (info.json)
| Field | Description |
|---|---|
apiVersion | Format version, currently 3 |
workspaceType | Always receipts2 for Receipts Space |
workspaceId | Unique workspace ID |
createDate | Creation time (Unix timestamp in seconds) |
encryption | (optional) Encryption configuration → Encryption |
Transactions
Changes are stored in an append-only log. Each client maintains its own log identified by a unique clientId. Entries are numbered sequentially (0.dat, 1.dat, …) and, to avoid filesystem conflicts, are split into subdirectories using a distribution algorithm (max. 1000 files per folder).
Conflict-Free Synchronization (CRDT)
Each database record contains:
| Field | Description |
|---|---|
_id | Unique ID of the record |
_type | Data type (e.g., category, receipt) |
_v | Version counter (Lamport clock) |
Synchronization uses Last-Write-Wins (LWW) per field: a field value is adopted only if its _v is greater than the version last stored for that field — a change may carry just a subset of fields, the others keep their previous values. If _v is equal, the writers’ client IDs decide deterministically: the value from the lexicographically smaller clientId wins. The comparison is against the client ID stored with the field, not the one reading — and when both are the same client, the incoming value is applied again, so a transaction delivered twice changes nothing. This makes application order irrelevant (CRDT).
File Format
Each transaction file consists of two parts separated by a newline:
- Header (first line): JSON object with metadata
- Content (rest): JSONL (newline-delimited JSON) formatted changes (UTF-8)
Header fields:
| Field | Description |
|---|---|
s | Content size in bytes |
c | SHA256 checksum of the content (URL-safe Base64, no padding) |
t | Creation time (Unix timestamp in seconds) |
v | Format version, currently 1 |
p | (optional) Hash of the previous transaction → Chaining |
did | (optional) Unique Device ID of the installation |
app | (optional) Identifier of the writing app, only in a client’s first transaction |
b | (optional) Build number of the writing app |
Readers must ignore header fields they do not know: the list may grow.
Example:
{"c":"Sg96RWfbFLN6_d3gsG65IJjJVgM9Jw9yFbxSzaYrxv8","did":"8rc37rrax970579qkvra65cke4","p":"za_9iYEZLZtTmdv0MMaV_IwD_tF17Sx-imb31KhkgUY","s":4134,"t":1768137121,"v":1}
{"_id":"47855a70de36449f821d40b45f8c170a","_type":"category","_v":1,"title":"Telekommunikation"}Device-ID
The Device ID did is a unique identifier per installation (device or user). It is typically set in the first transaction of a client and remains the same even when different Client IDs are used. The same did can be shared by multiple clients, but each client always has only one did.
Transaction Chaining
Using the optional p (previous) field, each transaction can be linked to its predecessor. The value is the SHA256 hash of the complete previous file (header + content).
┌────────────────────┐ ┌──────────────┐ ┌──────────────┐
│ 0.dat │◄────│ 1.dat │◄────│ 2.dat │
│ p: hash(info.json) │ p │ p: hash(0) │ p │ p: hash(1) │
└────────────────────┘ └──────────────┘ └──────────────┘Guarantees:
- Integrity: Tampering changes the hash → broken chain detectable
- Completeness: Missing transactions break the chain
- Order: Chronology is cryptographically secured
Verification: Verify from the last transaction backwards that each p value matches the computed hash of the previous file.
File Attachments (Assets)
Binary files are stored separately as assets, following the same per-client sequential indexing as transactions.
Asset URL format: asset:///<clientId>/<index>/<filename>?s=<size>&t=<mimeType>&d=<checksum>
| Parameter | Description |
|---|---|
clientId | Client ID |
index | Sequential index |
filename | Original filename |
s | Size in bytes |
t | MIME type |
d | SHA256 checksum (Base64) |
Example: asset:///1EH7BEtuL9xOz5aTpEyI4K/466/invoice.pdf?s=6284&t=application%2Fpdf&d=JAd0HmXcSIVVdYMmDBjfVZeTvAyXQ94GmjA6CwSwOYU
Metadata is included in the URL so integrity can be checked when loading the asset.
Data Types
| Type | Format | Example |
|---|---|---|
| Reference | _id of the target object | "category": "abc123" |
| Date | Integer YYYYMMDD | 20241201 (Dec 1, 2024) |
| Amount | Floating point number | 1.23 |
| Timestamp | Unix seconds | 1732311704 |
Numeric fields are read generously. A value that is meant to be a number and can be understood as one is taken rather than discarded: true and false count as 1 and 0, a number written as a string ("12.34", "20260127") is parsed, and a day number that arrives as 20260127.0 is that day. A string is always read the way JSON writes a number, never the way a locale does — "1,5" is refused rather than guessed. Anything else, including NaN and infinity, is ignored and the field keeps its previous value.
Some fields have special behavior to be aware of:
- The document type is stored in
doctype. Currently there is only one document type with the IDd0c5d0c5d0c5d0c5d0c5d0c5d0c5d0c5. If this is not set, thecreditvalue determines whether the record is an income (true) or an expense (false). - Contact (
contact) and category (category) are referenced by their ID. - Tags in
tagsare stored as a dictionary where the keys are the reference/ID and the values indicate whether the tag is set (truthy) or removed (falsy). - The tax total is stored in
tax. The tax details are stored intaxDetails. This is also a dictionary where the keys represent the tax rate as a string (e.g."19.0") and the values represent the amount. - The confirmation (which in revision-safety mode also makes the record read-only) is stored in
confirmed.
Encryption (optional)
For sensitive data, transactions and assets can be fully encrypted (header + content). Encryption is configured workspace-wide in info.json - a workspace is either fully encrypted or not encrypted at all.
There are two suites. A workspace keeps its suite for life, because its key comes from its key derivation:
| Suite | algorithm | kdf | Used by |
|---|---|---|---|
| Current | xaes-256-gcm | argon2id | every workspace encrypted by Receipts Space 4.0 or later |
| Legacy | aes-256-gcm | pbkdf2 | workspaces encrypted before 4.0, still read and written unchanged |
Configuration in info.json
{
"apiVersion": 5,
"workspaceType": "receipts2",
"workspaceId": "...",
"createDate": 1732311704,
"encryption": {
"algorithm": "xaes-256-gcm",
"kdf": "argon2id",
"kdfIterations": 3,
"kdfMemory": 65536,
"kdfParallelism": 4,
"salt": "base64-random-salt-16bytes",
"verify": "base64-encrypted-test-string"
}
}The legacy suite instead:
"encryption": {
"algorithm": "aes-256-gcm",
"kdf": "pbkdf2",
"kdfHash": "sha256",
"kdfIterations": 100000,
"salt": "base64-random-salt-16bytes",
"verify": "base64-encrypted-test-string"
}| Field | Description |
|---|---|
algorithm | xaes-256-gcm or aes-256-gcm; missing means aes-256-gcm |
kdf | Key derivation, fixed by the algorithm: argon2id or pbkdf2 |
kdfHash | Legacy only: sha256 |
kdfIterations | Argon2id: passes t (2 to 10). PBKDF2: iterations (100,000 to 10,000,000) |
kdfMemory | Argon2id only: memory m in KiB (19,456 to 1,048,576) |
kdfParallelism | Argon2id only: lanes p (1 to 16) |
salt | Random salt (Base64, at least 16 bytes) |
verify | Test string encrypted with the workspace key, for password verification |
Values outside these limits are refused before any key is derived: too weak, or so large that deriving would take minutes or gigabytes. An unknown algorithm means a newer version wrote the workspace. A legacy block without algorithm, kdf, kdfHash or kdfIterations is read with aes-256-gcm, pbkdf2, sha256 and 100,000, as versions before 4.0 did.
Key Derivation
Current: Password + Salt → Argon2id (t = kdfIterations, m = kdfMemory, p = kdfParallelism) → Workspace Key (256 bit)
Legacy: Password + Salt → PBKDF2-SHA256 (kdfIterations) → Workspace Key (256 bit)The password enters as UTF-8. Argon2id is version 1.3 (RFC 9106) with a raw 32-byte output; new workspaces use 64 MiB, 3 passes and 4 lanes.
Password Verification
The verify field contains the string receipts2, encrypted with the workspace key as one block in the workspace’s layout (see below).
Check: Decrypting verify → Result = receipts2? → Password correct ✓
File Layout (encrypted)
Every transaction and asset file is one block:
Current: [0x58 0x01][Nonce (24B)][Ciphertext][AuthTag (16B)]
Legacy: [Nonce (12B)][Ciphertext][AuthTag (16B)]
Ciphertext = Encrypt(Header + "\n" + Content)0x58 0x01 is “X” and format version 1.
Encryption Flow (XAES-256-GCM)
XAES-256-GCM is AES-256-GCM with a 192-bit nonce: the first 12 bytes of the nonce derive a fresh AES-256-GCM key from the workspace key, the last 12 bytes are the GCM nonce. Random nonces then do not collide in practice, however many blocks a workspace collects - with plain AES-GCM and random 12-byte nonces a key is good for about 2^32 blocks.
Encrypting:
- Create the normal transaction file (Header + Content)
- Generate a random nonce (24 bytes)
- Encrypt:
encrypted = XAES-256-GCM(key, nonce, plaintext), no associated data - Store:
[0x58 0x01][nonce][encrypted](encryptedcontains Ciphertext + AuthTag)
Decrypting:
- Check the first two bytes:
0x58 0x01 - Extract the nonce (next 24 bytes) and
encrypted(rest) - Decrypt:
plaintext = XAES-256-GCM-Decrypt(key, nonce, encrypted) - Parse Header (first line) and Content (rest)
- Verify checksum
cagainst Content
The legacy suite works the same without the two leading bytes and with plain AES-256-GCM and a random 12-byte nonce: [nonce (12B)][encrypted].
Security Notes
| Aspect | Recommendation |
|---|---|
| Algorithm | XAES-256-GCM (current) or AES-256-GCM (legacy), both authenticated encryption |
| Nonce | Never reuse, always generate randomly: 24 bytes (current), 12 bytes (legacy) |
| Key Derivation | Argon2id with 64 MiB, 3 passes, 4 lanes (current); PBKDF2-SHA256 with at least 100,000 iterations (legacy) |
| Salt | Generate once per workspace, store in info.json |
| Password Verification | The AuthTag fails on a wrong password |
| Downgrade | Receipts Space remembers per library and location which suite it unlocked; a weaker suite or a missing encryption block later is refused |
Limitations
When encryption is enabled:
- Sync requires password: Without the password no metadata is readable
- No chain verification without decryption:
pis encrypted - Filename remains visible: Only content is protected
- Versions before 4.0 cannot open the current suite; they report a wrong password
Note
With encryption enabled, the entire transaction is unreadable without the correct password. Integrity verification via c is performed after decryption.