Eformogi
The Record: protocol draft 0.1 (CC BY 4.0; verifier Apache-2.0)

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:

fieldtypemeaning
leaf_indexbigint, gapless from 0position
receipt_id, prefix, sha256, issued_atas in chapter 02, section 2.5the four leaf fields, denormalized
leaf_payloadtextthe exact canonical leaf string (source of truth)
leaf_hash64 hexSHA256(0x00 || leaf_payload)
prev_chain_hash64 hexthe previous row's chain_hash, or 64 zeros for leaf 0
chain_hash64 hexSHA256(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 byteroleformula
0x00leaf hashSHA256(0x00 || utf8(leaf_payload))
0x01interior nodeSHA256(0x01 || left32 || right32)
0x02append-order chainSHA256(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:

fieldtypevalue
versionint1
log_idstringeformogi-receipt-log-v1
tree_sizeintnumber of leaves covered
root_hash64 hexMTH over leaves [0, tree_size)
chain_hash64 hexthe chain hash of leaf tree_size - 1, or 64 zeros for an empty tree
timestampISO mssigning time
key_idstringdid:web:eformogi.com#key-1
signaturemultibase'z' + base58btc(Ed25519('eformogi-sth-v1:' + canonical(body)))

Rules (MUST):

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:

fieldmeaning
receipt_id, leaf_index, tree_sizeposition in the tree the head covers
leaf_payload, leaf_hashthe logged bytes and their leaf hash
audit_path[]sibling hashes from the leaf to the root, RFC 6962 section 2.1.1
sththe newest signed head whose tree_size exceeds leaf_index
did_documenthttps://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:

  1. 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.
  2. 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).
  3. 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 stored leaf_hash, and continue the chain; a direct database insert of arbitrary or PII-bearing bytes fails one of these and blocks signing.
  4. Sign. If the tree grew, mint one STH for the current size (idempotent: a head for that size already existing skips minting).
  5. 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):

pathcontent
sth/<tree_size>.jsonthe exact signed body plus signature, pretty-printed
latest.jsonthe same, for the newest head
leaves/<first>-<last>.jsonlone 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

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.