Witnesses & refusals.
GAA Merkle log makes history tamper-evident, but only for someone who keeps comparing notes over time. Witnesses are how TillNotary makes sure someone always does: co-signers with their own keys that check every new head against the history they already vouched for, and refuse, with a signed statement, when it doesn’t extend.
A Merkle log alone cannot stop a split view
Merkle proofs show that a tree is internally consistent: that a leaf is in a given tree, and that one tree extends another. On their own they cannot show that everyone is being shown the same tree. A dishonest operator could keep two consistent trees and present one to you and another to your auditor:
the split-view attack a bare Merkle log permits
you your auditor
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ tree A (size 4096) │ │ tree B (size 4096) │
│ root 9f2a… │ │ root c81d… │
│ every proof checks ✓ │ │ every proof checks ✓ │
└───────────────────────┘ └───────────────────────┘
└────────── same log operator ──────────┘
Each view is internally consistent. Neither audience can tell,
from proofs alone, that the other view exists.Each audience sees valid checkpoints and valid proofs. This is the split-view (equivocation) attack, and it is the one hole in a bare verifiable log. Witnessing closes it.
What a witness checks before it cosigns
A witness holds the log to its own history. For each new checkpoint it:
- checks the log’s signature on the head, against the log key it pinned the first time it saw the log;
- demands a consistency proof from the last head it cosigned to the new one, and verifies it itself;
- checks
tree_sizeandtimestampagainst what it has already seen: sizes never shrink, time never runs backwards or more than five minutes ahead; - then either cosigns, adding its signature to the checkpoint’s
cosignatures, or refuses.
The witness never sees your content and never takes the log’s word for anything. Network errors and rate limits are retried on the next pass, never turned into refusals: a refusal always means the log misbehaved.
$ tilldev notary witness acme-releases
OK Witness policy for notary.tilldev.dev/acme-releases — threshold 2
WITNESS ACTIVE COSIGNED @1 KEY FP
--------------------- ------ ----------- -------------------
acme-witness/primary yes yes d3ee 0e5f a5f0 f399
tillnotary-witness/w1 yes yes 29ac d643 062e 5a23
origin notary.tilldev.dev/acme-releases
tree size 1
root 84e6c134dd57ca79605d502bf263367b69094c88ed2d683d559cc462c3d87348
timestamp 2026-09-27T04:48:57.894Z
cosignatures tillnotary-witness/w1, acme-witness/primary
status COMPLETE (witness-cosigned)Platform witnesses and your own
A log’s policy can name two kinds of witness, and they protect you against different things.
| Witness | Run by | Protects you against |
|---|---|---|
| Platform witness | TillDev, under its own key, in a deployment separate from the log service. New logs get one by default. | A log service or database that rewrites, forks or truncates history. It is not independent of TillDev itself. |
| Your witness | You, on a machine you control, under a key that never leaves it. | Everything above, and TillDev too: no checkpoint can meet your policy unless your witness has checked it first. |
Clients accept k of n, nothing less
Every log has a witness set and a required threshold: k of n cosignatures. Your trust file (the CLI’s required_cosignatures, the SDK’s requiredCosignatures) pins that threshold on your side: the client rejects any checkpoint with fewer valid cosignatures from the witnesses you pinned, whatever the server says. A checkpoint below threshold is what complete: false means: signed, not yet trustable.
Re-running tilldev notary trust-init <log> only ever strengthens your pin. It adds newly attached witnesses, takes a raised threshold, keeps keys that are no longer attached so evidence they cosigned still verifies, and refuses outright if the log or a witness presents a different key than the one you pinned. To accept a lower threshold or drop keys, pass --prune.
Run a witness in four commands
Your witness is the CLI’s witness-node. It needs a machine that can reach the log service, a key file and a small state file. Registering and attaching need an admin token; running the witness needs none, because it signs every request with its own key.
1. Make the key. The key file is written once with mode 0600 and never overwritten. Back it up somewhere only your security team can reach.
$ tilldev notary witness-node keygen --name acme-witness/primary
OK Wrote witness.key (mode 0600). Back it up; it never leaves this machine.
name acme-witness/primary
suite sig-ed25519-mldsa65-sha256.v1
key fp d3ee 0e5f a5f0 f399 a298 2f58 3ad7 134b
* Next: tilldev notary witnesses register --key-file witness.key
* Registering in the dashboard instead? Paste the output of: tilldev notary witness-node identity --key-file witness.key2. Register it and attach it to a log. You can also do this on the Witnesses page of the dashboard by pasting the output of tilldev notary witness-node identity.
$ tilldev notary witnesses register --key-file witness.key --custody-note "Acme security team"
OK Registered acme-witness/primary (fp d3ee 0e5f a5f0 f399)
* Next: tilldev notary attach <log> acme-witness/primary
$ tilldev notary attach acme-releases acme-witness/primary
OK Attached acme-witness/primary to acme-releases
* Re-run trust-init acme-releases to pin the new witness, then raise --threshold once it cosigns.3. Let it cosign, then require it. The first pass cosigns every log the witness is attached to. Raise the threshold once it has, and re-pin so your trust file includes it.
$ tilldev notary witness-node run --once
2026-09-27T04:48:58.549Z initial trust origin=notary.tilldev.dev/acme-releases size=1 root=84e6c134dd57ca79605d502bf263367b69094c88ed2d683d559cc462c3d87348 — first cosign of this log
2026-09-27T04:48:58.583Z bootstrap-cosigned notary.tilldev.dev/acme-releases @1
$ tilldev notary logs update acme-releases --threshold 2
$ tilldev notary trust-init acme-releases4. Keep it running. --interval <seconds> (at least 30, default 300) runs a pass on a schedule; --once runs one pass and exits with status 3 if the witness refused or raised an alarm, which suits cron or CI. A systemd unit:
# /etc/systemd/system/tillnotary-witness.service
[Unit]
Description=TillNotary witness
After=network-online.target
Wants=network-online.target
[Service]
User=tillnotary-witness
WorkingDirectory=/var/lib/tillnotary-witness
ExecStart=/usr/bin/env tilldev notary witness-node run --interval 300
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target- State.
witness-state.jsonrecords the last head the witness cosigned for each log. Back it up with the key. If it is lost, the witness rebuilds it from its own published cosignatures, which it can verify; only a log it has never cosigned is trusted on first sight. - Private logs. A witness can read a private log only if the log’s policy names it. Requests are signed, so the machine’s clock must be within five minutes of real time.
- Which logs. By default the witness cosigns every log that names it.
--only-pinnedlimits it to the logs in your trust file. - Output.
--jsonprints one line per pass with the outcome for each log and any notices, for your log pipeline.
Rotate or retire a witness key
An organization can run up to 8 active witnesses and keep up to 32 in total, retired ones included. Retiring takes effect immediately: the witness can no longer read logs or add cosignatures. Its existing cosignatures stay valid wherever its key is still pinned.
# 1. a new key under a new name, registered and attached next to the old one
tilldev notary witness-node keygen --name acme-witness/2026-10 --key-file witness-2026-10.key
tilldev notary witnesses register --key-file witness-2026-10.key
tilldev notary attach acme-releases acme-witness/2026-10
# 2. let it cosign, then take the old one out of the policy and retire it
tilldev notary witness-node run --once --key-file witness-2026-10.key --state witness-2026-10-state.json
tilldev notary detach acme-releases acme-witness/primary
tilldev notary witnesses retire acme-witness/primary
# 3. re-pin: the new key is added, the retired one is kept so old evidence still verifies
tilldev notary trust-init acme-releasesA log that still requires a retired witness cannot reach its threshold, and the dashboard flags it. Attach a replacement or lower the threshold. If a key was compromised rather than rotated, retire it and run trust-init <log> --prune so your trust file stops accepting it. Evidence bundles that relied on it can be re-exported against the latest checkpoint with tilldev notary proof <log> <index> --out <file>.
Key pinning and alarms
The first time your witness sees a log, it pins the log’s public key. After that, two things stop it from witnessing that log and raise an alarm instead. Alarms are not refusals: nothing is signed or published, but the alarm is printed on every pass and --once exits with status 3.
| Alarm | What it means |
|---|---|
log-key-changed | The log now presents a different key than the one pinned. Nothing is cosigned until you resolve it. |
bad-log-signature | A head did not verify against the pinned key: a wrong key or a forged checkpoint. Nothing is cosigned. |
Log keys do not rotate in normal operation, so treat either alarm as an attack until it is explained. The alarm prints the pinned and offered key fingerprints; compare them with the fingerprint in your trust file (printed by trust-init) and on the log’s page in the dashboard, and write to security@tilldev.dev. Only once TillDev has confirmed a new key through a channel you trust: remove the log from your trust file, run trust-init again, and start the witness with --trust <file>. A key in the trust file takes precedence over the one the witness pinned itself.
A refusal is the loudest event in the system
A refusal means a witness was shown a checkpoint that does not extend a history it already witnessed. It is not a degraded state or a retry candidate. It is the exact signal this design exists to produce, and the witness keeps refusing that log on every pass until the log presents a history that does extend.
On your own witness, a refusal is an ALARM line on stderr and a REFUSED outcome, and --once exits with status 3:
$ tilldev notary witness-node run --once
2026-09-27T05:20:57.469Z ALARM SEV-1 REFUSAL origin=notary.tilldev.dev/acme-releases reason=same-size-different-root from=2 to=2 — the log presented a history that does not extend the one we cosigned
2026-09-27T05:20:57.489Z REFUSED notary.tilldev.dev/acme-releases @2: same-size-different-root
$ echo $?
3Whichever witness refused, it reaches you in several places at once:
- Email to every owner and admin of your organization, with the log, the witness, the reason and what to do next. TillDev security gets a copy of every refusal, and replying to the email reaches them. Delivery is retried until it lands, and its status shows next to the refusal.
- The dashboard: a sev-1 panel on the log’s page and an entry in the notification bell.
- The CLI:
tilldev notary refusals <log>lists them, each checked against the witness key you pinned. - The SDK:
client.getRefusals(slug)returns only statements that verify, and puts the rest inrejected. See the Node SDK.
$ tilldev notary refusals acme-releases
WITNESS REASON FROM TO OBSERVED
-------------------- ------------------------ -------------- -------------- ------------------------
acme-witness/primary same-size-different-root 2 d89183b0fd9c 2 5fd661a32efb 2026-09-27T05:20:57.000Z
* Keep these statements (--json): each verifies against the witness key, with or without TillNotary.
! 1 verified refusal(s) on acme-releases: a witness saw history that does not extend what it cosigned.A refusal is a signed statement, not a log line. The public endpoint serves it with everything needed to check the signature against the witness key, so the accusation stands on its own without trusting TillNotary or the witness operator:
GET /v1/logs/acme-releases/refusals
{
"refusals": [
{
"id": 1,
"witness": "acme-witness/primary",
"suite": "sig-ed25519-mldsa65-sha256.v1",
"signature": "QX+3qETyc+6kTXMMLpmuH1xX…",
"statement": {
"origin": "notary.tilldev.dev/acme-releases",
"from_size": 2,
"from_root": "d89183b0fd9c2fc87fe8df650485d1d9fd381e5cd52dd220dffd74bacbd81dd3",
"to_size": 2,
"to_root": "5fd661a32efbac3704b6b69b673da4f6b5dbace45764566508159be89e4ebaee",
"reason": "same-size-different-root",
"observed_at": 1790486457
},
"recorded_at": "2026-09-27T05:20:57.487Z"
}
],
"next_before": null
}observed_at is the witness’s clock, in Unix seconds, and part of what it signed; recorded_at is when TillNotary received it. Page with ?before=<id> and limit (up to 100). The reasons a witness gives:
| Reason | What it means |
|---|---|
consistency-proof-failed | The proof from the last head the witness cosigned to the new head did not verify: the new tree does not extend the one it witnessed. |
same-size-different-root | Two heads claim the same size with different roots. This is direct evidence of a split view. |
tree-size-shrunk | The log presented a head smaller than one already witnessed. An append-only log never gets shorter. |
timestamp-regressed | The head is dated earlier than one already witnessed. Checkpoint time never runs backwards. |
timestamp-in-future | The head is dated more than five minutes ahead of the witness clock. |
timestamp-unparseable | The head carries a timestamp the witness cannot read, so it cannot be dated. |
consistency-proof-unavailable | The log would not produce a consistency proof. No proof, no cosignature. |
consistency-proof-malformed | The log answered with a consistency proof that is not well formed. |
Equivocation is non-repudiable
Checkpoints are public. Anyone can fetch a log’s head, and any two parties can compare what they were shown. If two validly signed checkpoints of the same tree_size carry different root_hash values, together they are non-repudiable proof of equivocation: both bear the log’s own signature, and anyone can check the pair with nothing but the log’s public key.
Witnesses make a split view infeasible to sustain in real time; gossip makes any attempt provable after the fact. Keep the checkpoints you rely on (your evidence bundles already embed them): each one is a comparison point for good. A mirror keeps every head it advanced to, and every entry, so it catches a shrink, a fork or a rewrite on its own and saves both signed heads when it does.
See Checkpoints & signatures for the head witnesses cosign, and the CLI reference for every witness command.