immudb + AI Agents: Build a Tamper-Proof Audit Log
Your agent has a database connection with write access. It also has an audit table, sitting in that same database, that records what it did. If the agent goes off the rails at 3am and you spend the next morning reading that table to work out what happened, you are trusting a log that the thing you are investigating could reach.
That bothered me more the longer I thought about it. So I went looking for a place to put agent actions where the record itself proves it has not been edited. That place is immudb.
What immudb actually does
immudb is an immutable database. You can append new versions of a record, but you cannot change or remove an old one. That part is easy to describe and easy to fake, because any database can be locked down with permissions.
The interesting part is underneath. Every commit goes into a cryptographic commit log with a parallel Merkle tree. Each transaction is hashed into a structure that depends on every transaction before it. Rewrite one old row and every proof after it stops adding up. You are not trusting that the database behaved. You are checking.
That distinction matters for agents specifically. Permissions are a policy, and policies get changed, usually by whoever is in a hurry. A hash chain is arithmetic.
Deploying it
immudb is on Elestio starting at $11/mo, which covers the VM, automated backups, SSL, monitoring and updates. Pick your provider (Netcup, Hetzner, DigitalOcean, Linode, Vultr, ScaleWay, or bring your own VM), take the 2 CPU / 4 GB configuration as a starting point, and deploy.
Everything below assumes immudb 1.11.2, released September 3, 2026.
Talking to it with psql
Here is the part that makes this practical. Since v1.11.0 in April 2026, immudb speaks the PostgreSQL wire protocol on port 5432, on by default. That means psycopg, SQLAlchemy, Django, GORM and plain psql connect to it without any immudb-specific client.
psql "host=YOUR-INSTANCE.vm.elestio.app port=5432 user=immudb dbname=defaultdb"
Create somewhere to put agent actions:
CREATE TABLE agent_actions (
id INTEGER AUTO_INCREMENT,
ts TIMESTAMP,
agent VARCHAR(64),
tool VARCHAR(64),
target VARCHAR(256),
payload_hash VARCHAR(64),
outcome VARCHAR(16),
PRIMARY KEY (id)
);
Note the VARCHAR(64) lengths. immudb wants a size on indexed string columns, and the error when you forget is not helpful.
Store a hash of the payload, not the payload. You want to prove a specific request was made, not keep a permanent uneditable copy of whatever the agent passed around. That is a distinction your legal team will care about more than you do.
Writing from the agent
import hashlib, json
from datetime import datetime, timezone
import psycopg
from psycopg_pool import ConnectionPool
DSN = "host=YOUR-INSTANCE.vm.elestio.app port=5432 user=immudb password=YOUR-PASSWORD dbname=defaultdb"
# One pool for the process. Opening a connection per action means a TCP
# handshake and an auth round trip on every tool call your agent makes.
pool = ConnectionPool(DSN, min_size=1, max_size=4, kwargs={"autocommit": True})
def record(agent: str, tool: str, target: str, payload: dict, outcome: str) -> None:
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
with pool.connection() as conn:
conn.execute(
"INSERT INTO agent_actions (ts, agent, tool, target, payload_hash, outcome) "
"VALUES (%s, %s, %s, %s, %s, %s)",
(datetime.now(timezone.utc), agent, tool, target, digest, outcome),
)
record(
agent="support-triage",
tool="refund.issue",
target="order/88213",
payload={"amount_cents": 4200, "reason": "damaged"},
outcome="ok",
)
Use %s placeholders, not immudb's native ?. You are going through the Postgres wire protocol here, so psycopg does the binding and psycopg's rules apply.
Sort the JSON keys before hashing. If you do not, the same payload produces different digests across runs and your proofs become decorative.
Checking the record
immudb exposes its proof system as SQL functions, so verification is a query:
SELECT immudb_state();
SELECT immudb_verify_row('agent_actions', 1);
SELECT immudb_verify_tx(12);
immudb_state() returns the current transaction ID and its hash. immudb_verify_row() returns verified, table_name, tx_id, revision and entry_key. When a check fails you get verified = false and an error column explaining why.
The part everyone gets wrong
Running verification over the SQL connection means the server computes the proof and tells you it checked out. For catching accidental corruption, that is fine. Against an attacker who owns the server, it is the server grading its own homework.
The real guarantee comes from the client holding state. immudb's SDKs keep the last known root hash locally and verify each proof against it, so a server that quietly rewrote history between two of your sessions fails the check on the next call. In the Python SDK, in-memory state is the default and it is not enough:
from immudb.client import ImmudbClient, PersistentRootService
client = ImmudbClient(rs=PersistentRootService("/var/lib/agent/immudb-root"))
client.login(username="immudb", password="YOUR-PASSWORD")
client.verifiedGet(b"agent:last-action")
PersistentRootService writes that state to disk. Use one state file per process, because the implementation is not thread safe.
Put that file somewhere the agent cannot write. An audit trail verified by state the agent controls is not an audit trail.
Catching what never reaches your table
Your own logging only records what your code chose to log. immudb 1.11 added server-side audit logging for that gap:
IMMUDB_AUDIT_LOG=true
IMMUDB_AUDIT_LOG_EVENTS=write
Every immudb flag has an IMMUDB_-prefixed environment variable, which is how you set this on a managed instance: add the two variables to your service configuration and restart. Running the binary yourself, the flags are --audit-log and --audit-log-events.
Every gRPC operation becomes a JSON event in immudb's own tamper-proof store, with timestamp, user, client IP, database, method, outcome and duration. Those events are queryable and verifiable like anything else, and exportable to your SIEM. --audit-log-events=write keeps reads out so the volume stays sane.
What this does not give you
Being straight about the limits, because they are real:
| Limitation | What it means for you |
|---|---|
| Tamper-evident, not tamper-proof | Everyone says tamper-proof, immudb's own docs included, and I used it in the title too. The precise word is tamper-evident. Root on the box can still destroy the volume. You detect tampering, you do not prevent deletion. Backups still matter. |
| Storage only grows | Nothing is ever removed. Size your disk for the retention you actually need. |
| Not a Postgres replacement | No stored procedures, no GIN or GiST indexes, no generated columns. Use it for the audit trail, not the whole app. |
| Verification is your job | Proofs prove nothing if nobody runs them. Schedule a check, alert on failure. |
Troubleshooting
Login rejected on a fresh instance. immudb requires the default account's password to be changed before it will do real work. Set it through immuadmin or the web console first.
VARCHAR column errors on CREATE TABLE. Add an explicit length. VARCHAR(256), not bare VARCHAR, on anything you index.
Port 5432 refuses connections. The Postgres wire server is enabled by default but can be turned off. Confirm --pgsql-server is true and that the port is open on your instance.
verified = false with an error string. Read the error column before assuming the worst. A missing primary key value and an actual proof failure both land here, and they mean very different things.
Worth doing?
If your agents only read things, this is overkill. The moment they issue refunds, change infrastructure, or email customers, the question stops being "what did it do" and becomes "can you prove what it did" to someone who was not in the room. A $11/mo instance answering that is a reasonable trade.
You can deploy immudb on Elestio and have it running in a few minutes. Custom domains and SSL are covered in the Elestio documentation.
Thanks for reading ❤️ See you in the next one 👋