Two proofs, one guarantee.
GAAn inclusion proof shows your entry is in the tree. A consistency proof shows the tree only ever grew. Together they are the whole guarantee: what was logged is present, and what was present was never rewritten — tamper-evident.
Leaf i is in the tree of size n
A Merkle tree hashes leaves in pairs, then hashes the pairs, up to a single root hash R that commits to every leaf and its order. An inclusion proof for leaf i in a tree of size n is the audit path: the sibling hash at each level between your leaf and the root. You rebuild the root from the bottom up — your leaf’s hash, folded together with each sibling in turn — and compare against the R in a signed checkpoint. If they match, your leaf is in that exact tree; even a single flipped bit anywhere on the path produces a different root.
root R tree_size n = 8
┌──────┴──────┐
h(0–3) h(4–7)
path[2] ┌───┴───┐
h(4–5) h(6–7)
┌──┴──┐ path[1]
h4 h5
path[0] │
└── your leaf (index 5)
verify, bottom-up: H = h5 // hash of your leaf
H = hash(path[0] ‖ H) // fold in h4
H = hash(H ‖ path[1]) // fold in h(6–7)
H = hash(path[2] ‖ H) // fold in h(0–3)
H == R ? proven : rejectedThe path is O(log n): 3 hashes prove membership among 8 leaves, and only about 30 hashes among a billion. That is what makes a receipt small enough to store next to every artifact you ever notarize. The proof arrives with your submission response as inclusion_proof: {leaf_index, tree_size, path[]}, and can be re-fetched any time from GET /v1/logs/{slug}/leaves/{index}.
The tree of size n extends the tree of size m
Inclusion alone has a loophole: a malicious log could show you a tree containing your leaf while showing everyone else a different tree. The consistency proof closes it. Given two sizes m ≤ n, the log must produce a short path of internal nodes that does two things at once: recombine to exactly the old root Rm, and extend to the new root Rn. That is only possible if the first m leaves of the new tree are byte-for-byte the first m leaves of the old one — same leaves, same order, nothing rewritten. The new tree is an append-only superset of the old.
This is also what the witnesses run before cosigning each checkpoint: a witness that cannot verify consistency with everything it saw before refuses to cosign and publishes a signed refusal. Your own consistency checks and the witnesses’ are the same proof, run from two vantage points.
Verifying consistency from the CLI
Feed the CLI the tree_size and root_hash from a checkpoint you stored — your receipt from submit time, or your last heartbeat — and it fetches the proof and verifies it locally against your pinned trust file:
# Prove the live log still extends the checkpoint you stored earlier.
# --from is the tree_size from your stored checkpoint; --from-root its root_hash.
tilldev notary consistency acme-releases \
--from 4 \
--from-root a1d55691a4764f1c797f8849de205858215b2113e6f0dca650bc5c85f428a743
OK APPEND-ONLY HOLDS: size 4 → 8 of notary.tilldev.dev/acme-releases
from root a1d55691a4764f1c797f8849de205858215b2113e6f0dca650bc5c85f428a743
origin notary.tilldev.dev/acme-releases
tree size 8
root 96b66e36e50a3a61fc37f08bf11e12d2b6ea922068183a8d24e1ae613b98402d
timestamp 2026-09-27T05:44:12.622Z
cosignatures tillnotary-witness/w1, acme-witness/primary
status COMPLETE (witness-cosigned)
# A root that the live log does not extend fails loudly (exit 1):
x CONSISTENCY FAILED for notary.tilldev.dev/acme-releases: the tree at size 8 does NOT extend your checkpoint at size 6 — treat as compromised
# The raw proof behind it, if you verify yourself:
curl -s "https://notary.tilldev.dev/v1/logs/acme-releases/consistency?from=4&to=8"
{ "from": 4, "to": 8, "path": ["…"] }Verifying with the Node SDK
In the SDK, verification is not optional and not separate — every read is proven against your pinned keys before it returns, and a response that fails throws NotaryVerificationError instead of returning data.
import { NotaryClient, parseTrustFile, trustFromFile } from '@tillstack/sdk-notary-node'
import { readFile } from 'node:fs/promises'
const trust = parseTrustFile(await readFile('./notary-trust.json', 'utf8'))
const notary = new NotaryClient({ baseUrl: 'https://notary.tilldev.dev', logs: trustFromFile(trust) })
// The checkpoint from your receipt, stored at submit time:
const stored = JSON.parse(await readFile('./bundle.json', 'utf8')).checkpoint
// Prove the live log is an append-only superset of what you saw.
// Resolves with the verified live checkpoint — or throws NotaryVerificationError.
const live = await notary.verifyConsistencyFrom('acme-releases', {
treeSize: stored.tree_size,
rootHash: stored.root_hash,
})
console.log('extends cleanly to tree size', live.treeSize)
// Related, and also verified-before-returned:
await notary.getLeaf('acme-releases', 1042) // leaf + inclusion proof, checked
await notary.getCheckpoint('acme-releases') // strict: throws below witness threshold
await notary.getCheckpoint('acme-releases', { allowIncomplete: true }) // explicit opt-outNote getCheckpoint is strict by default: it throws if the checkpoint hasn’t reached the log’s witness threshold. Passing allowIncomplete is an explicit, visible decision to accept the log’s word alone.
What's inside a checkpoint
Both proofs terminate at a checkpoint — the signed statement of the tree’s state:
| Field | Meaning |
|---|---|
origin | The log’s globally unique name (host/slug). Verifiers reject a checkpoint whose origin doesn’t match the pin. |
tree_size · root_hash | The state being attested: n leaves, root R (hex). |
timestamp | When the log signed this state (RFC 3339, UTC) — the timestamping half of the notary. |
suite · key_name · signature | The log’s hybrid signature (base64) under its pinned key — both component algorithms must verify. |
cosignatures[] | One {witness, observed_at, signature} per witness that verified consistency itself and cosigned. |
complete | Whether cosignatures have reached the log’s witness threshold. |
Past checkpoints stay retrievable via GET /v1/logs/{slug}/checkpoints?since_size=…, and the signed-note form (tilldev notary note <log>) is the interop format monitors gossip to catch split views.
The heartbeat that catches tampering
The practical discipline is two lines long: store the checkpoint from your receipt, and periodically prove the live log still extends it. Each successful check re-anchors everything before it; each new checkpoint becomes the anchor for the next check. Any attempt to rewrite the interval between two heartbeats fails the very next consistency proof.
# A nightly heartbeat (CI cron, or any scheduler):
# 1. prove the live log still extends the last checkpoint you trusted,
# 2. then — and only then — roll your stored checkpoint forward.
tilldev notary consistency acme-releases \
--from "$(jq -r .tree_size checkpoint.json)" \
--from-root "$(jq -r .root_hash checkpoint.json)" \
|| exit 1 # a failure here is a five-alarm event, not a flake
tilldev notary checkpoint acme-releases --json > checkpoint.jsontilldev notary refusals <log>), and contact support with the x-request-id. This alarm firing truthfully is precisely the property you’re paying for.The heartbeat checks the tree; a mirror (tilldev notary mirror <log>) also keeps every entry and checks each one against the signed roots, so you hold the whole log, not just its head.
New here? Walk the loop end to end in the Quickstart, or see how entries and logs behave in Logs & entries.