A provenance ledger for a home library, anchored in Bitcoin
Blog · knowledge-management

A provenance ledger for a home library, anchored in Bitcoin

IPFS content IDs, an RFC 9162 Merkle log, Ed25519 signatures and OpenTimestamps, plus the lock queue that took it down for 13 minutes

This morning, the provenance log of my research library held 331,035 distinct contents in 14 daily epochs, every one of them anchored in a Bitcoin block. Every file in the library has a fingerprint, and every fingerprint sits in a signed, append-only log that I cannot quietly rewrite. Anyone with a copy of a file and the public key can check, offline, that the file is byte-for-byte what the library held on a given date.

This post explains why I built it, how the pieces fit (content IDs, a Merkle log, signatures, OpenTimestamps), and the 13-minute outage that taught me where long-running maintenance jobs and database migrations collide.

Contents · 11 sections

TL;DR #

  • The goal: content that any person or AI system can verify, without trusting me or my server.
  • Each file gets an IPFS content ID (CIDv1), a plain SHA-256 and its size. The fingerprint is tied to the file record, not the path, so moving a file never changes it.
  • Fingerprints become leaves of an RFC 9162 Merkle log. Once a day the tree root is signed with Ed25519 and its hash is timestamped with OpenTimestamps, which anchors it in a Bitcoin block.
  • A proof for one file is: the leaf, its inclusion in the first epoch that contained it, a consistency proof to the latest epoch, the signature and the Bitcoin attestation. A browser page and a small Python script both verify it.
  • A slow maintenance job held a database transaction open, a startup migration waited behind it, and every query queued behind the migration: 13 minutes of outage. Commit before slow calls; put a lock_timeout on migrations.

Why bother #

A personal library is a pile of files that I say are real. That is fine for me. It is not fine if the library is to be a source for others, and especially not for AI systems that quote it. The question a careful reader asks is: “Is this the document you had, and did you have it then, or did you change it later?”

A database row that says “added on 5 October” proves nothing; I can edit the row. What I wanted was a record that I cannot quietly rewrite, even though I run every server involved. That rules out anything that relies only on my own clock or my own database.

Step 1: fingerprint every file #

Every file gets three values:

  • a CIDv1, the content address used by IPFS, built with the reproducible unixfs-v1-2025 profile so that any tool using the same profile computes the same ID;
  • a plain SHA-256, because everyone can compute it without IPFS tooling;
  • the size.

I wrote the CID computation in plain Python and tested it against the reference IPFS implementation (Kubo 0.43.1), including a two-level tree for a 1 GiB file. The same algorithm runs in the browser, checked against the same test vectors, so the verify page can fingerprint a file locally without uploading it.

Two design decisions matter more than the hashing:

  1. Fingerprints belong to the file record, not the path. My library reshelves books often. If the fingerprint were keyed by path, every move would look like a new file. A content change, on the other hand, creates a history row, so the old fingerprint is never lost.
  2. Metadata is excluded. The leaf commits to bytes only. Titles, authors and shelves can be corrected forever without touching the log.

The first backfill read 121,855 live files, 928 GB, with three parallel readers at about 105 MB/s, limited by the NAS. A cron job picks up new files every five minutes and a nightly job re-reads a rotating slice to catch silent corruption.

Step 2: an append-only Merkle log #

Fingerprints alone do not stop me from swapping a file and its fingerprint together. For that the fingerprints go into a Merkle tree log, the same construction Certificate Transparency uses (RFC 9162).

Each distinct content is one leaf, with a domain-separated, versioned encoding:

lsnlib-leaf-v1
<cid>
<sha256>
<size>

The tree only grows. Once a day an epoch job computes the root and signs a canonical JSON statement:

{
  "epoch": 13,
  "tree_size": 331035,
  "root": "<merkle root hex>",
  "prev": "<sha256 of the previous statement>",
  "time": "<UTC timestamp>",
  "key": "<signing key fingerprint>"
}

The prev field chains every statement to the one before it. Two kinds of proof fall out of RFC 9162 for free:

  • an inclusion proof shows that a leaf is in the tree of a given size;
  • a consistency proof shows that a later tree contains the earlier one unchanged, so nothing was removed or rewritten.

The signing key is Ed25519. The first key is pinned on the first epoch and the epoch job refuses to sign with any other key. Rotation is possible, but only as a deliberate act: retire the old key with a note, add the new one, never delete epochs. When the AI assistant that built this tried to generate the key, the platform blocked it from writing to the secret store, which was the right outcome. I ran the key-generation script myself, and an offline copy is backed up.

Step 3: anchor in Bitcoin with OpenTimestamps #

A signature proves who signed, not when. I could sign a backdated statement today. To fix the time, the epoch job submits the SHA-256 of the signed statement to the public OpenTimestamps calendars. They aggregate many hashes into one Bitcoin transaction. A few hours later the proof can be “upgraded” to a full path from my hash to a block header.

An upgrade job runs every three hours. It completes pending proofs and checks the claimed block header, its Merkle root and its hash, against two independent block explorers before marking the epoch confirmed. As I write this, the latest epoch is anchored in Bitcoin block 974,471. The first revision of my own published book, which also lives in the library, is anchored in block 972,852.

Only a hash leaves the house. Calendars learn nothing about the content.

What a proof looks like #

To verify one file, a reader needs:

  1. the file, from which they compute the CID and SHA-256;
  2. the leaf and its inclusion proof in the first epoch that contained it;
  3. a consistency proof from that epoch to the latest;
  4. the signed statements and the pinned public key;
  5. the OpenTimestamps proof and a Bitcoin block header.

The library’s verify page does all of it in the browser. For people who prefer not to trust my JavaScript, the same check is a single Python script that runs offline with the proof JSON. Statements, signatures and timestamp proofs are also mirrored every three hours to a Git repository, and the mirror script refuses to rewrite a statement that was already published: it exits with an error and raises an alarm instead.

The IPFS node stays private #

Every file is also added to a private Kubo node, so it can be fetched by CID. The node has no internet access: routing is off, providing is off, and a NetworkPolicy allows only the catalogue and the API gateway to talk to it.

That choice is about privacy. On public IPFS, announcing a CID to the network ties that content to your IP address. Publishing to public IPFS will only ever happen through a separate node, for an allow-list of works whose rights are clear.

A related lesson came when the ledger grew to cover my OSINT shelf: the fingerprint is not sensitive, but the path is. A CID and a SHA-256 say nothing about a file. A path like collection/filename can reveal what a restricted collection holds. Provenance endpoints now apply the same per-collection access rules as the documents themselves and drop the real path for readers.

The outage: a slow job, a migration and a lock queue #

On 6 October 2026, the catalogue service went down for about 13 minutes. Both pods were unready; every request timed out.

The cause was a three-way interaction:

  1. The IPFS follower job read its work list inside a transaction and then, without committing, made slow calls to the IPFS node. Removing blocks took 30 seconds to 2 minutes each, with about 121,000 pins.
  2. A new version of the catalogue started and ran its schema migration: an ALTER TABLE, which needs an exclusive lock. It waited for the follower’s open transaction.
  3. In PostgreSQL, once an exclusive lock request is waiting, every new query on that table queues behind it, even plain reads. The whole catalogue stopped.

I recovered by terminating the idle-in-transaction session. The permanent fixes:

# the follower: read, commit, then do slow work
rows = cur.execute("SELECT ... FROM work_list LIMIT %s", (n,)).fetchall()
conn.commit()                 # release the snapshot before IPFS calls
for row in rows:
    slow_ipfs_call(row)
-- every startup migration
SET lock_timeout = '10s';     -- fail the deploy, not the service
ALTER TABLE ...;

The IPFS design also changed: instead of one pin per content, files are linked into the node’s mutable file system under a short prefix, which made removals cheap. Old pins migrate gradually, about 2,000 per run.

Checklist: content provenance you can defend #

  • Fingerprint bytes only, with a standard hash everyone can compute, plus a content address if you use one.
  • Tie fingerprints to a stable record id, not a path.
  • Put fingerprints in an append-only log with inclusion and consistency proofs; never edit or delete log rows.
  • Sign each epoch, chain statements, pin the key and make rotation explicit.
  • Anchor the statement hash in a public timestamp you do not control.
  • Verify the anchor against more than one independent source.
  • Publish a standalone verifier that works offline.
  • Treat paths and filenames as metadata that needs access control.
  • In any job that does slow external work, commit before the slow part; give migrations a lock_timeout.

Frequently asked questions #

How can I prove a file existed at a certain date without a trusted third party? #

Hash it, put the hash in a log, and timestamp the log’s root with OpenTimestamps. The proof leads to a Bitcoin block whose time and contents nobody can change later. It proves the hash existed no later than that block.

Why use both an IPFS CID and SHA-256? #

The CID lets the file be fetched and checked by content on IPFS. The SHA-256 is universal: anyone can compute it with standard tools, without knowing how IPFS builds its trees.

What does a Merkle log add over a list of hashes? #

Compact proofs. An inclusion proof shows a file is in the log without downloading the log, and a consistency proof shows that later versions of the log only added entries and never changed old ones.

Does anchoring in Bitcoin cost money or leak data? #

With OpenTimestamps, public calendars aggregate many hashes into one transaction, so there is no per-file fee. Only the hash of the signed statement leaves your server.

Why did a schema migration take down the whole service? #

PostgreSQL grants locks in order. A waiting ALTER TABLE blocks every later query on that table, so one idle transaction held by a slow job can stall everything. A lock_timeout makes the migration fail fast instead.

The lesson #

Trust is cheaper to build than to argue for. With a hash per file, an append-only log, a pinned key and a public timestamp, I no longer need anyone to believe me about what the library held and when: they can check. The outage was the usual reminder that the boring parts, transactions and locks, decide whether the clever parts stay up.