Named, append-only, forever.
GAA log is a named append-only sequence of leaves. Entries are only ever added at the end — nothing is edited, nothing is deleted. Each entry has a permanent index, and each value appears in a log at most once.
What a log is made of
| Field | Meaning |
|---|---|
slug | The log’s name inside your org — short, stable, and part of its identity. |
origin | The globally unique name signed into every checkpoint, shaped host/slug (e.g. notary.tilldev.dev/acme-releases). Verifiers check it, so a proof from one log can never be replayed against another. |
suite | The signature suite — hash function plus hybrid signature algorithms. Chosen at creation, fixed for the log’s lifetime. See the suite table. |
witness_threshold | How many witness cosignatures a checkpoint needs before it counts as complete. List a log’s witnesses with GET /v1/logs/{slug}/witnesses or tilldev notary witness <log>. |
public_read | When true, anyone can read leaves, proofs, and checkpoints with no token. Submitting always requires a token. |
max_leaves_per_day | New entries the log accepts per rolling 24 hours: 1 to 50,000, default 50,000. Change it any time in the log’s settings, with tilldev notary logs update <slug> --daily-cap <n>, or with PATCH /v1/logs/{slug}. Past it, submissions get a 429. |
status | active or frozen. A frozen log rejects new leaves (409) but keeps serving reads and proofs — history stays verifiable forever. |
# Dashboard: /<org>/notary/logs → "New log". Or the API (admin scope):
curl -s https://notary.tilldev.dev/v1/logs \
-H "authorization: Bearer $TILLDEV_NOTARY_TOKEN" \
-H "content-type: application/json" \
-d '{
"slug": "acme-releases",
"description": "Release artifact provenance",
"suite": "sig-ed25519-mldsa65-sha256.v1",
"witness_threshold": 1,
"public_read": true,
"max_leaves_per_day": 5000
}'
# 201 → { "log": { "slug": "acme-releases", "max_leaves_per_day": 5000, … },
# "public_key": "…", "witnesses": ["tillnotary-witness/w1"] }
# Pin that key now: tilldev notary trust-init acme-releases
# CLI equivalent:
tilldev notary logs create acme-releases --description "Release artifact provenance" --daily-cap 5000Two submission modes
Hash-only is the default and the right choice almost always. You send the digest — exactly 64 hex characters on a SHA-256 log, 96 on a SHA-384 log — and nothing else. The log commits to the value without learning anything about your content:
# Default mode: hash-only. Send exactly 64 hex characters on a SHA-256
# log (96 on a SHA-384 log). The server never sees your content.
curl -s https://notary.tilldev.dev/v1/logs/acme-releases/leaves \
-H "authorization: Bearer $TILLDEV_NOTARY_TOKEN" \
-H "content-type: application/json" \
-d '{"leaf":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}'
# 201 → { "leaf_index": 1042, "leaf": "2cf2…", "duplicate": false,
# "inclusion_proof": { … }, "checkpoint": { … }, "complete": true }
# Sending the same value again → 200 with the original receipt and "duplicate": true.
# CLI equivalents:
tilldev notary submit acme-releases --hash 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
tilldev notary submit acme-releases --file ./release.tar.gz # hashes locallyPublic attestation is for content that is meant to be published — a signed release statement, a transparency notice. You send the raw bytes (base64, at most 4096 bytes) and must also say content_class: "public-attestation":
# Public-attestation mode: raw bytes (base64, ≤ 4096 bytes) AND the
# explicit content class. Both are required — a deliberate double opt-in,
# because these bytes become PUBLICLY READABLE.
curl -s https://notary.tilldev.dev/v1/logs/acme-transparency/leaves \
-H "authorization: Bearer $TILLDEV_NOTARY_TOKEN" \
-H "content-type: application/json" \
-d '{
"bytes": "eyJhcnRpZmFjdCI6InJlbGVhc2UtMi4zLjAiLCJzaGEyNTYiOiIyY2YyNGRiYS4uLiJ9",
"content_class": "public-attestation"
}'
# CLI equivalent — --attest is the opt-in flag:
tilldev notary submit acme-transparency --file ./attestation.json --attestEach value is logged once
An entry is identified by its position, (log, index), and each value appears in a log at most once. Submitting a value that is already there does not add a second entry: you get the original receipt back — same index, same submission time — with "duplicate": true. That makes submitting safe to retry after a timeout, and it works even after the log is frozen.
To record the same content as a separate event — a document re-signed, an artifact promoted to production — make the event part of what you hash: log the digest of a small statement that names the content’s digest together with what happened and when.
Keep the evidence bundle from tilldev notary submit --out when you can: it verifies with no network at all. When you only have the content, find its entry by value — see Reading entries back.
Caps, freezing, and errors
Because a log is append-only, junk in a log is junk forever — so two brakes exist:
- Daily cap → 429. Each log accepts at most
max_leaves_per_daynew entries in any rolling 24 hours; past it, submissions fail withRATE_LIMITEDand aretry-afterheader saying when the oldest entry in the window ages out. Set the cap near your real volume, so a leaked submit token cannot flood your permanent record. Resubmitting a value already in the log does not count. Reads are not capped this way; request rate limits are covered under Tokens & limits. - Frozen → 409. Freezing a log ends its growth: new values fail with
CONFLICT, while every read, proof, checkpoint and existing receipt keeps working. Freeze a log when the thing it tracked is finished — the history remains verifiable indefinitely.
# Every error, every endpoint — one envelope, plus an x-request-id header.
# HTTP/1.1 429, retry-after: 5123
{
"error": "Daily submission quota reached for this log (5,000 per 24 hours). Retry after 5123 seconds.",
"code": "RATE_LIMITED",
"request_id": "0f5c2d1a-8b7e-4f3a-9c61-2e4d7a9b3c10"
}
# Codes: UNAUTHORIZED · FORBIDDEN · NOT_FOUND · ENTRY_NOT_FOUND · INVALID_INPUT
# CONFLICT · RATE_LIMITED · PAYLOAD_TOO_LARGE · METHOD_NOT_ALLOWED
# UPSTREAM_UNAVAILABLE · INTERNALReading entries back
Reading requires the read scope — or no token at all on a public_read log, which is what makes independent, third-party verification of your log possible.
# The newest entries (limit up to 200; before=<index> pages back):
curl -s "https://notary.tilldev.dev/v1/logs/acme-releases/leaves?limit=20"
# → { "tree_size": 2210, "leaves": [ { "leaf_index": 2209, … }, … ] }
# One entry, with a fresh inclusion proof and the checkpoint it is proven against:
curl -s https://notary.tilldev.dev/v1/logs/acme-releases/leaves/1042
# → { "leaf": { "leaf_index": 1042, "leaf": "2cf2…", "bytes": null,
# "content_class": "hash-only", "submitted_at": "…" },
# "inclusion_proof": { … }, "checkpoint": { … } }
# On a public_read log, all of these work with no token at all.Find an entry by its value. Hash your content and ask for the entry that holds it. The answer carries the same inclusion proof as a read by index, so the SDK’s findLeaf and the CLI verify it before reporting it; the dashboard’s entry list has the same search, and hashes a file in the browser without uploading it.
# The entry holding a value, with the same proof and checkpoint as above:
curl -s https://notary.tilldev.dev/v1/logs/acme-releases/leaves/by-value/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
# 404, code ENTRY_NOT_FOUND → no entry in this log holds that value
# CLI: hashes the file with the log's suite, verifies the proof, exits 1 if absent.
tilldev notary lookup acme-releases --file ./release.tar.gz
tilldev notary lookup acme-releases --out release.bundle.json \
--hash 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824A 404 with ENTRY_NOT_FOUND means the log has no such entry. A plain NOT_FOUND means the log itself does not exist or your token cannot read it, so never read it as “not logged”.
Fetching an entry gives you its inclusion proof and the current checkpoint alongside it — what those actually establish, and how to check them, is the subject of Inclusion & consistency.