Technical Documentation

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 attachments

Workspace Metadata (info.json)

FieldDescription
apiVersionFormat version, currently 3
workspaceTypeAlways receipts2 for Receipts Space
workspaceIdUnique workspace ID
createDateCreation 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:

FieldDescription
_idUnique ID of the record
_typeData type (e.g., category, receipt)
_vVersion 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:

  1. Header (first line): JSON object with metadata
  2. Content (rest): JSONL (newline-delimited JSON) formatted changes (UTF-8)

Header fields:

FieldDescription
sContent size in bytes
cSHA256 checksum of the content (URL-safe Base64, no padding)
tCreation time (Unix timestamp in seconds)
vFormat 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>

ParameterDescription
clientIdClient ID
indexSequential index
filenameOriginal filename
sSize in bytes
tMIME type
dSHA256 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

TypeFormatExample
Reference_id of the target object"category": "abc123"
DateInteger YYYYMMDD20241201 (Dec 1, 2024)
AmountFloating point number1.23
TimestampUnix seconds1732311704

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 ID d0c5d0c5d0c5d0c5d0c5d0c5d0c5d0c5. If this is not set, the credit value determines whether the record is an income (true) or an expense (false).
  • Contact (contact) and category (category) are referenced by their ID.
  • Tags in tags are 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 in taxDetails. 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:

SuitealgorithmkdfUsed by
Currentxaes-256-gcmargon2idevery workspace encrypted by Receipts Space 4.0 or later
Legacyaes-256-gcmpbkdf2workspaces 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"
}
FieldDescription
algorithmxaes-256-gcm or aes-256-gcm; missing means aes-256-gcm
kdfKey derivation, fixed by the algorithm: argon2id or pbkdf2
kdfHashLegacy only: sha256
kdfIterationsArgon2id: passes t (2 to 10). PBKDF2: iterations (100,000 to 10,000,000)
kdfMemoryArgon2id only: memory m in KiB (19,456 to 1,048,576)
kdfParallelismArgon2id only: lanes p (1 to 16)
saltRandom salt (Base64, at least 16 bytes)
verifyTest 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:

  1. Create the normal transaction file (Header + Content)
  2. Generate a random nonce (24 bytes)
  3. Encrypt: encrypted = XAES-256-GCM(key, nonce, plaintext), no associated data
  4. Store: [0x58 0x01][nonce][encrypted] (encrypted contains Ciphertext + AuthTag)

Decrypting:

  1. Check the first two bytes: 0x58 0x01
  2. Extract the nonce (next 24 bytes) and encrypted (rest)
  3. Decrypt: plaintext = XAES-256-GCM-Decrypt(key, nonce, encrypted)
  4. Parse Header (first line) and Content (rest)
  5. Verify checksum c against 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

AspectRecommendation
AlgorithmXAES-256-GCM (current) or AES-256-GCM (legacy), both authenticated encryption
NonceNever reuse, always generate randomly: 24 bytes (current), 12 bytes (legacy)
Key DerivationArgon2id with 64 MiB, 3 passes, 4 lanes (current); PBKDF2-SHA256 with at least 100,000 iterations (legacy)
SaltGenerate once per workspace, store in info.json
Password VerificationThe AuthTag fails on a wrong password
DowngradeReceipts 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: p is 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.

Example

GitHub holtwick/receipts-space