Git-backed Beancount journal: commit-per-write #25

Open
opened 2026-06-06 11:01:47 +00:00 by padreug · 0 comments
Owner

Goal

Move the Beancount file from a plain file to one that lives inside a git repo, with a structured commit recorded after every write. This is the load-bearing step toward Tackler-style auditability (issue #26) — the commit-id leg of the audit triplet comes for free from git, and historical reports become reproducible by re-running against a specific commit.

Prerequisite

Depends on #24 (reversing entries). The point of git-backed commit-per-write is to record an immutable per-write history; while the void path still mutates the source file in place, the commits would interleave append-style and edit-style writes and lose the strict append-only property that makes the audit story defensible.

After #24, there is exactly one mutation surface (FavaClient.add_entry), and commit-per-write becomes a one-line decorator around it.

Scope

Repository setup

  • Initialize a git repo at the directory containing the Beancount ledger (configurable; defaults to <fava_ledger_dir>/.git).
  • Settings additions: fava_ledger_repo_path (optional override), git_commit_enabled (bool, default true), git_author_name / git_author_email (commit metadata).

Write path

  • Inside FavaClient.add_entry, after a successful PUT /api/add_entries response, commit the modified ledger file.
  • Commit happens inside _write_lock so commit + write are serialized as a single critical section. (Lock already exists; no new locking needed.)
  • Structured commit message:
    libra: <operation> <entry_link>
    
    user: <lnbits_user_id>
    operation: add_entry | void_entry | …
    idempotency_key: <link>
    entry_hash: <fava entry hash, if known>
    reverses: <original_entry_hash>  # only on voids; carries the supersession chain into git metadata
    
  • For void operations specifically, capture the prior (voided) entry's hash in the commit message — see the reverses field above. This makes git history carry the supersession chain explicitly, which is useful for forensic reconstruction even though the reverses: meta on the entry itself is the canonical record.

Git library choice

  • Use pygit2 or dulwich for in-process commits — avoid shelling out to the git CLI (process spawn cost on every write, harder to test). dulwich is pure-Python and probably easier to package; pygit2 is faster but binds to libgit2. Pick one based on what's already in the LNbits dependency tree.

Failure handling

  • If Fava write succeeds but git commit fails: the Beancount file has the new entry but git history doesn't record it. This is a soft inconsistency — the canonical record (the file Fava reads) is correct, but the audit trail is missing one event. Log loudly, surface in health endpoint, but don't roll back the Fava write (rolling back via delete_entry would itself need to be committed; turtles all the way down).
  • Reconciliation: a startup check that compares current ledger state against last commit can detect any drift.

Tests

  • Round-trip: add an entry, verify it appears in both the ledger file and as the latest commit.
  • Lock serialization: parallel add_entry calls produce sequential commits in the expected order.
  • Concurrent commit + add: the lock prevents interleaving.

Out of scope

  • Pushing commits to a remote (audit copy, off-site backup) — separate concern.
  • Migrating an existing non-git ledger into a git-backed one — a one-time git init && git add -A && git commit -m "initial import" before this feature goes live.

Dependencies

  • Requires #24 (reversing entries) — strict append-only invariant.
  • Prerequisite for #26 (audit triplet) — the commit-id leg of the triplet.
## Goal Move the Beancount file from a plain file to one that lives inside a git repo, with a structured commit recorded after every write. This is the load-bearing step toward Tackler-style auditability (issue #26) — the commit-id leg of the audit triplet comes for free from git, and historical reports become reproducible by re-running against a specific commit. ## Prerequisite Depends on #24 (reversing entries). The point of git-backed commit-per-write is to record an immutable per-write history; while the void path still mutates the source file in place, the commits would interleave append-style and edit-style writes and lose the strict append-only property that makes the audit story defensible. After #24, there is exactly one mutation surface (`FavaClient.add_entry`), and commit-per-write becomes a one-line decorator around it. ## Scope ### Repository setup - Initialize a git repo at the directory containing the Beancount ledger (configurable; defaults to `<fava_ledger_dir>/.git`). - Settings additions: `fava_ledger_repo_path` (optional override), `git_commit_enabled` (bool, default true), `git_author_name` / `git_author_email` (commit metadata). ### Write path - Inside `FavaClient.add_entry`, after a successful `PUT /api/add_entries` response, commit the modified ledger file. - Commit happens **inside `_write_lock`** so commit + write are serialized as a single critical section. (Lock already exists; no new locking needed.) - Structured commit message: ``` libra: <operation> <entry_link> user: <lnbits_user_id> operation: add_entry | void_entry | … idempotency_key: <link> entry_hash: <fava entry hash, if known> reverses: <original_entry_hash> # only on voids; carries the supersession chain into git metadata ``` - For void operations specifically, capture the *prior* (voided) entry's hash in the commit message — see the `reverses` field above. This makes git history carry the supersession chain explicitly, which is useful for forensic reconstruction even though the `reverses:` meta on the entry itself is the canonical record. ### Git library choice - Use `pygit2` or `dulwich` for in-process commits — avoid shelling out to the `git` CLI (process spawn cost on every write, harder to test). `dulwich` is pure-Python and probably easier to package; `pygit2` is faster but binds to libgit2. Pick one based on what's already in the LNbits dependency tree. ### Failure handling - If Fava write succeeds but git commit fails: the Beancount file has the new entry but git history doesn't record it. This is a soft inconsistency — the canonical record (the file Fava reads) is correct, but the audit trail is missing one event. Log loudly, surface in health endpoint, but don't roll back the Fava write (rolling back via `delete_entry` would itself need to be committed; turtles all the way down). - Reconciliation: a startup check that compares current ledger state against last commit can detect any drift. ### Tests - Round-trip: add an entry, verify it appears in both the ledger file and as the latest commit. - Lock serialization: parallel `add_entry` calls produce sequential commits in the expected order. - Concurrent commit + add: the lock prevents interleaving. ## Out of scope - Pushing commits to a remote (audit copy, off-site backup) — separate concern. - Migrating an existing non-git ledger into a git-backed one — a one-time `git init && git add -A && git commit -m "initial import"` before this feature goes live. ## Dependencies - Requires #24 (reversing entries) — strict append-only invariant. - Prerequisite for #26 (audit triplet) — the commit-id leg of the triplet.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/libra#25
No description provided.