TILLNOTARY · PROOFS

Two proofs, one guarantee.

GA

An 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.

01Inclusion

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.

text
                      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 : rejected

The 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}.

02Consistency

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.

The load-bearing sentence
A silent rewrite makes some consistency proof fail, and there is no rewrite that passes it. If any historical leaf changed, the old root can no longer be reconstructed from the live tree — the math leaves no path around it. This is the precise sense in which the log is tamper-evident: whoever holds an earlier head can always tell.

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.

03CLI

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:

bash
# 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": ["…"] }
04SDK

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.

ts
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-out

Note 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.

05Checkpoints

What's inside a checkpoint

Both proofs terminate at a checkpoint — the signed statement of the tree’s state:

FieldMeaning
originThe log’s globally unique name (host/slug). Verifiers reject a checkpoint whose origin doesn’t match the pin.
tree_size · root_hashThe state being attested: n leaves, root R (hex).
timestampWhen the log signed this state (RFC 3339, UTC) — the timestamping half of the notary.
suite · key_name · signatureThe 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.
completeWhether 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.

06Habit

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.

bash
# 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.json
Treat a failure as an incident
A consistency failure is not a transient error to retry — it means the log’s history no longer extends what you verifiably saw. Keep the stored checkpoint and the failing output (both are signed evidence), check whether the witnesses have signed refusals (tilldev 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.