05. Transparency log
Draft 0.1. Reference implementation: shared/merkleLog.js, shared/receiptLogAppend.js, supabase/V34_RECEIPT_LOG.sql, api/cron-tree-head.js, api/tree-heads.js, api/receipt-proof.js, docs/ANCHORED_LEDGER_SETUP.md. Vectors v02, v03, v13 to v19.
5.1 Purpose
The log makes "public ledger" a cryptographic fact rather than a marketing word. Every public receipt is committed as a leaf in an append-only, hash-chained Merkle log; a daily signed tree head (STH) commits to the whole log; each head is anchored in a third-party git history; and any receipt's inclusion is provable offline against a head and the published organization key, with no trust in the operator's servers.
5.2 Structure
The log is a sequence of rows (receipt_log), one per (receipt id, sha256) pair:
| field | type | meaning |
|---|---|---|
leaf_index | bigint, gapless from 0 | position |
receipt_id, prefix, sha256, issued_at | as in chapter 02, section 2.5 | the four leaf fields, denormalized |
leaf_payload | text | the exact canonical leaf string (source of truth) |
leaf_hash | 64 hex | SHA256(0x00 || leaf_payload) |
prev_chain_hash | 64 hex | the previous row's chain_hash, or 64 zeros for leaf 0 |
chain_hash | 64 hex | SHA256(0x02 || prev_chain_hash || leaf_hash) |
Domain separation (RFC 6962 section 2.1) is the load-bearing idea. Every hash is prefixed with one domain byte so no value computed in one role can be replayed in another:
| domain byte | role | formula |
|---|---|---|
0x00 | leaf hash | SHA256(0x00 || utf8(leaf_payload)) |
0x01 | interior node | SHA256(0x01 || left32 || right32) |
0x02 | append-order chain | SHA256(0x02 || prev32 || leaf32) |
Without the 0x00/0x01 split an attacker controlling leaf content could forge inclusion proofs (the second-preimage attack on naive Merkle trees). The chain domain keeps the linear "nothing reordered or dropped" hash from colliding with tree-internal values.
Tree shape (MUST): RFC 6962 Merkle Tree Hash. MTH({}) = SHA256(""); MTH({d0}) is the leaf hash itself; for n > 1, split at k = the largest power of two strictly less than n, and MTH(D[n]) = SHA256(0x01 || MTH(D[0:k]) || MTH(D[k:n])). Vector v13 pins roots for 1, 2, 3, 7, 8, and 16 leaves and the empty root e3b0c442...b855.
Append (MUST): through one RPC (append_receipt_log) under an advisory transaction lock, so leaf indexes are gapless and the chain never forks; idempotent on (receipt_id, sha256); leaf hash and chain hash computed in the database from the same domain bytes. Rows are append-only: UPDATE, DELETE, and TRUNCATE are refused by trigger. Every publish endpoint appends best-effort at publish time (a log failure never fails a family's publish); the daily job reconciles anything missed from the seven receipt tables.
A wl_ receipt appears once per published snapshot (same id, new sha256); the proof endpoint proves the latest leaf.
5.3 Signed tree heads
An STH is exactly seven signed fields plus a signature:
| field | type | value |
|---|---|---|
version | int | 1 |
log_id | string | eformogi-receipt-log-v1 |
tree_size | int | number of leaves covered |
root_hash | 64 hex | MTH over leaves [0, tree_size) |
chain_hash | 64 hex | the chain hash of leaf tree_size - 1, or 64 zeros for an empty tree |
timestamp | ISO ms | signing time |
key_id | string | did:web:eformogi.com#key-1 |
signature | multibase | 'z' + base58btc(Ed25519('eformogi-sth-v1:' + canonical(body))) |
Rules (MUST):
- The signature covers the canonical JSON (chapter 02, section 2.3) of exactly the seven fields, whitelisted before re-canonicalization, so a stray field on the wire can never break or widen verification.
- The exact signed string is persisted verbatim (
sth_body) and served by parsing it, never rebuilt from typed columns (a database that normalizes timestamps would otherwise change the bytes and every proof would fail;tests/test-sth-roundtrip.js). - The served head is
{sth: {seven fields, signature}, anchor_github}; the anchor commit lives outside the signed object. - Tree-head rows are immutable except for a single null-to-value transition on each anchor column.
Vector v16 is a real STH over the 8 fixture leaves (tree_size: 8, root_hash: 7820c302...9b567) signed with an ephemeral key; v17 shows that changing tree_size breaks the signature.
5.4 Inclusion proofs
GET /api/receipt-proof?id=<receipt id> (public, 10 per minute per IP) returns:
| field | meaning |
|---|---|
receipt_id, leaf_index, tree_size | position in the tree the head covers |
leaf_payload, leaf_hash | the logged bytes and their leaf hash |
audit_path[] | sibling hashes from the leaf to the root, RFC 6962 section 2.1.1 |
sth | the newest signed head whose tree_size exceeds leaf_index |
did_document | https://eformogi.com/.well-known/did.json |
A leaf newer than the latest head returns 200 {status: "pending", leaf_index, next_sth, leaf_payload, leaf_hash} (the leaf bytes are served so a family signature can be checked before the next head). An id with no leaf returns 404.
Verification (MUST, RFC 9162 section 2.1.3.2): with fn = leaf_index, sn = tree_size - 1, r = leaf_hash, for each path element p: if sn == 0 fail; if fn is odd or fn == sn, set r = node(p, r) and, while fn is even and non-zero, right-shift both fn and sn; otherwise r = node(r, p); then right-shift both. Accept iff sn == 0 and r == root_hash. Vector v14 (leaf 3 of 7) verifies; v15 (same path, claimed for index 4) MUST fail. tests/test-merkle-log.js checks every (tree size up to 16, index) pair exhaustively.
Scale note: the audit path is computed by loading every leaf hash in [0, tree_size), which is fine at hundreds or thousands of leaves and needs cached subtree hashes past roughly 100k.
5.5 Cadence and the daily job
Once a day at 04:30 UTC (api/cron-tree-head.js, bearer CRON_SECRET), in this order, each step refusing loudly rather than papering over:
- Reconcile. Page every row of the seven public receipt tables (
transcript_receipts,course_receipts,worksample_receipts,weekly_notes,walls,attestations,key_binding_receipts) and append any (id, hash) pair the log lacks. The first run doubles as the initial backfill. - Self-audit. Recompute the root at the latest head and at the latest externally anchored head; on any mismatch return 500 and sign nothing (a rolled-back or edited log can never be laundered under a fresh signature).
- Validate new leaves. Every leaf in
(previous tree_size, current tree_size]MUST parse as exactly the four-field grammar, re-canonicalize to its stored bytes, hash to its storedleaf_hash, and continue the chain; a direct database insert of arbitrary or PII-bearing bytes fails one of these and blocks signing. - Sign. If the tree grew, mint one STH for the current size (idempotent: a head for that size already existing skips minting).
- Anchor. Best-effort, non-fatal (section 5.6). Heads that failed to anchor earlier are retried, including on the skip path.
A missed day loses nothing; leaves keep appending and the next run signs a larger tree. Only proof latency grows (receipts read pending until the next head).
5.6 Anchoring
Each new head and the leaf payloads it newly covers are written to a public git repository through the GitHub contents API (GITHUB_ANCHOR_REPO, token scoped to that repository only):
| path | content |
|---|---|
sth/<tree_size>.json | the exact signed body plus signature, pretty-printed |
latest.json | the same, for the newest head |
leaves/<first>-<last>.jsonl | one canonical leaf payload per line for the newly covered range |
The commit sha is recorded once on the head (anchor_github). Leaf payloads are non-PII by construction, so receipt metadata rows stay deletable (chapter 09, legal) while anchors survive. The anchor repository's commit timestamps are the external clock: they are what a later party cannot rewrite. An anchor_ots column is reserved for an OpenTimestamps anchor (planned, not implemented). Consistency proofs between two heads (RFC 6962 section 2.1.2) are planned; today a monitor detects a rewrite by recomputing the root from the anchored leaf files, which is complete but heavier.
5.7 Discovery
GET /api/tree-headsand/.well-known/tree-heads.json: the latest head;?all=1: the last 100. Public, CORS open, cached 5 minutes.- The DID document lists the log as service
#receipt-log(typeTransparencyLog). - Live at the time of this draft: head
tree_size 68, timestamp2026-09-05T04:30:46.946Z, root7d7772555fa389d2bd4c0192e5a0809b7ae4833e5c5aabeaff8b37bb9dd98354, anchored at commitc286e3cb.... The full history is in TRUST_REPORT.md.
5.8 What this does not prove
Inclusion proves that a leaf with these four fields was in the log by the time of a signed head, and anchoring proves the head existed by the anchor commit's time. It does not prove the receipt's claims, the family's identity, or that the artifact you hold is the one the family meant to send; the artifact check is the receiver's own hash comparison (chapter 06, step 1). A pending proof proves nothing yet about the log; it only lets a family signature be checked early.