The first version of our agent audit log was a list of entries on a public ledger. Append-only, ordered, replicated, impossible to rewrite without forging a transaction. On paper, that is a complete audit trail.
It is not. And the gap between "on paper complete" and actually complete is the whole subject of this article.
What the ledger gives you
The XRP Ledger (like every public chain) gives you three properties for the data you put in a transaction:
- Order. Ledger sequence numbers are total and final.
- Immutability. Changing history means forging a transaction, which means spending.
- Publicness. Anyone can read it.
If your audit log entries are ledger transactions, then your log's ordering and its integrity are guaranteed by the consensus of an entire network. That is a genuinely strong property and it is not cheap.
What the ledger does not give you
It does not give you the data.
A transaction carries a hash and a small payload. The actual log entry — what the agent did, the prompt, the response, the model version, the tool calls — is kilobytes to megabytes. It cannot all live in a transaction, and it should not: that is expensive, slow, and it would bloat the ledger for every node forever.
So the entry lives somewhere else. A file, an S3 bucket, a database, someone's laptop. And now:
The ledger guarantees the order of your hashes. It guarantees nothing about the data those hashes point to.
Someone with write access to that store can change an entry and leave the hash untouched. The chain still verifies. The ledger still agrees. The data is now a lie wearing a valid checksum.
This is the failure the hash chain exists to catch.
The hash chain
The fix is to make each entry depend on the previous one:
entry_n.hash = H(entry_n.content || entry_{n-1}.hash)
Now changing entry n changes its hash, which changes entry n+1's hash, which changes n+2's, and so on to the end. The log is not merely tamper-evident — it is tamper-evident all the way to the head.
The head is the important part. An attacker who rewrites entry 12 of a 5,000-entry log has to recompute 4,988 hashes. An attacker who rewrites the last entry has to recompute one.
Which is exactly why you cannot just trust the head you were handed. A log whose anchored entry was deleted reports a clean pass, because there is nothing left to check.
The anchor
So the head has to be published somewhere the party verifying the log cannot reach or alter. That is the anchor — the head hash, committed to the ledger, on a schedule.
Now verification has two parts:
- Chain integrity. Recompute the chain from the first entry, confirm every hash links, confirm the final hash matches the anchor you hold.
- Anchor presence. Confirm the anchor is actually there, on-chain, at the height you expect.
Both are necessary. The first without the second proves nothing if the attacker can delete the tail. The second without the first proves nothing if the attacker can rewrite the middle.
Our verification API takes both, and the expect_anchors parameter exists because of exactly this:
result = verify_on_chain_anchor(
log, client, expect_anchors=1, expected_anchor_hash=anchor_you_hold
)
Without it, a log whose anchored entry was deleted reports a clean pass. That is not a theoretical concern — it is the first thing we wrote a test for.
The part that took the longest to get right
Getting the expected head from somewhere the attacker cannot reach.
If the verifier asks the log server for the head, the server can return whatever it likes, and every check passes. The head has to come from elsewhere — a value published independently, or a copy the other party keeps. That sounds obvious. It is not, in practice, because the natural place to keep a copy is usually the same system you're trying to verify.
Our own answer was: the anchor on the ledger is the independent copy. Which is why the anchor has to be public, periodic, and verifiable by a third party with nothing but a public node.
What this does not solve
Worth being explicit, because "tamper-evident" gets oversold:
- It does not prove the entry was ever true. A chain can faithfully record something false that was written once.
- It does not prove who wrote it. That is what the identity layer is for. The two are separate systems that happen to compose.
- It does not stop an attacker with the signing key. Mutual attestation is what covers that: two parties sign each other's entries, so neither can rewrite history alone.
- It does not survive the anchor being unpublished. If the chain stops anchoring, the log's verifiability degrades to "the head you were handed."
The test we actually use
The question that settled the design:
Can a third party, with no relationship to either party, reconstruct the current truth from public data alone?
If the answer is yes, you have an audit log. If the answer is no — if verification requires trusting the party being verified — you have a private channel with extra steps and a nicer name.
*This is the design behind xrpl_agent_log. The implementation is MIT licensed, and the verification API is public.*