Sooner or later someone asks what the agent did. A customer was refunded twice, an order shipped that shouldn’t have, a table has fewer rows than yesterday. The answer people reach for is a log: the agent framework’s trace, the application log, maybe the database’s statement log.
Logs are good for debugging. They are a weak answer to “what happened”, because a log is a story some process chose to tell, stored where that process (and anyone with access to the machine) can change it. When the question is asked by your own team at 2 a.m., that’s usually fine. When it’s asked by a customer, an auditor or an insurer, it isn’t.
The question after an incident
There are really three questions hiding in “what did the agent do?”:
- What did it ask to do? The tool call and its arguments.
- What was allowed, and by whom? The rule that decided, the person who approved, the key that was used.
- What actually changed? In the system of record, not in the agent’s summary.
Most agent logs answer the first question well, the second partly, and the third not at all. The agent’s trace records that it called execute_sql and got UPDATE 1 back. As the 1 row, 301 rows run shows, UPDATE 1 can be true and still leave 300 changed rows out.
Three things a log can’t tell you
Who wrote this line. Application logs are written by the application. Agent traces are often written by the agent’s own runtime, sometimes summarised by the model itself. Nothing in a log line says which process produced it, and anything that can write to the log can write a line that looks like any other.
Whether it changed since. A log file or table is mutable by whoever holds write access to it. Deleting the three lines that show a bad refund leaves no trace in the file. Shipping logs to a central store helps, but the store’s admins can still edit, and the question “were these lines edited?” has no answer in the lines themselves.
Whether it matches what happened. A statement log records statements. pgAudit, for example, logs sessions and object access through PostgreSQL’s standard logging, with a WRITE class for INSERT, UPDATE, DELETE, TRUNCATE and COPY into a relation.4 That is a useful record of what was asked of the database. It is not a record of the rows that changed as a result, and it isn’t tied to the agent’s request or the rule that allowed it.
What a record needs
An agent action record should carry, for one action: the tool and arguments, the rule that decided it, who approved it if anyone did, what ran, and what the system of record says happened. Then two properties make it evidence rather than a log line:
- It’s sealed. Each record is signed, so a changed byte breaks the signature, and anyone holding the public key can check it.
- It’s linked. Each record carries the hash of the one before it, so removing or reordering a record breaks the chain after it.
Deedglass seals a signed record of each action that the client can check offline, and each action is sealed into a hash-linked chain that can be checked offline.11 The check result goes inside the record: the record for the 301-row write says Doesn’t match, with the diff, at the moment it was sealed.
Seal it and link it
Here is the action record from the demo, as the CLI shows it:
postgres.execute · support-bot · 2026-10-14 13:01:44 UTC
decision allow · rule allow-order-status-updates · pv_01m4x828…
execution executed · 3 ms
check Proven · Doesn't match · postgres.pgoutput
✓ Claimed The tool reported success.
✓ Checked Postgres shows row id = 42 in public.orders was last written by this action's transaction.
✕ Proven Postgres changed 301 rows in public.orders. The agent reported 1.
Proven by Postgres · commit 0/1D706F8 · 2026-10-14 13:01:44 UTC
record seq 14 · deed_01m4x828… · sealed with sk_01m4x827…
The decision, the rule’s version, the result and the Postgres commit position all go into the sealed record. Export the org’s records and check them on any machine, with nothing but the public keys:
$ deedglass deeds export --org acme-demo --format jsonl --out records.jsonl
✓ 18 records · 21.8 kB · seq 1 → 18
$ deedglass verify records.jsonl --keys keys.json
✓ 18 deeds · chain intact · 1 seal key · 2026-10-14 → 2026-10-14
verify doesn’t call our server. It checks every seal against the published keys and every link against the record before it.2
A real tamper run
Now change one digit in the fifth record of the export, the kind of edit someone might make to soften what a record says, and check again:
$ deedglass verify records-changed.jsonl --keys keys.json
✕ Deed 5 was changed after it was sealed.
deed deed_01m4x828craxys0xz8qq4wsq0c (line 5)
sealed body_hash sha256:b3cc…90f2
computed sha256:d4f3…0fec
before it 4 deeds intact · stopped at the first break
next Get a fresh export from the issuer and compare deed 5. Add --all to list every break.
It names the record, the line, the hash it was sealed with and the hash of what’s there now. Checking is fast enough to do routinely: deedglass verify checks 100,000 records in 2.4 seconds on an Apple M1 laptop.3
What a chain shows, and what it doesn’t
A signed, linked chain is strong evidence of some things and silent about others. Say both.
It shows that a record hasn’t changed since it was sealed, and that no record before the newest one was removed or reordered. It shows which key sealed it.
It doesn’t show that the content was true when it was written. A chain of signed lies is a valid chain. This is why the record should carry an independent observation (here, what Postgres’s change log shows) rather than only what the agent or tool reported.
It doesn’t show that the newest records are all there. Any hash chain has this property: cut off the last few records and what remains is still a valid, shorter chain. The fix is cheap. Copy the newest record’s hash somewhere the operator can’t edit, such as a ticket, a commit, an email to yourself, and compare it later.
And it relies on the seal key. Whoever holds the key can seal new records, so the key belongs somewhere the agent can’t reach, and rotating it should itself be recorded.
Doing this without Deedglass
You can get much of the way with your own code:
- Record decisions, not just calls. Log the rule or person that allowed each write next to the call.
- Record the outcome from the system of record. After a write, read the affected rows back, or better, read the database’s change stream, and store what you saw.
- Link the entries. Hash each entry together with the previous entry’s hash. A few lines of code:
import hashlib, json
def append(path, entry, prev_hash):
entry = dict(entry, prev=prev_hash)
line = json.dumps(entry, sort_keys=True, separators=(",", ":"))
with open(path, "a") as f:
f.write(line + "\n")
return hashlib.sha256(line.encode()).hexdigest()
def check(path):
prev = None
for n, line in enumerate(open(path), 1):
if json.loads(line).get("prev") != prev:
return f"line {n}: link broken"
prev = hashlib.sha256(line.rstrip("\n").encode()).hexdigest()
return f"ok, head {prev}"
- Sign it. Sign each line, or at least the head, with a key the writing process doesn’t hold.
- Keep the head elsewhere. Publish the latest hash somewhere you don’t control alone.
What’s hard to build yourself is step 2 done properly (an independent observation of what committed, tied to the one action that caused it) and keeping steps 1 to 5 correct under concurrency, retries and crashes. That’s the part Deedglass exists for.
Try it
Paste records and a public key into the checker on this site and your browser checks every seal and link. Watch an agent’s write get caught and put back in the playground. And to see what your own agent’s database role can change today, run the free scan, npx deedglass scan; the grade is explained here, and nothing leaves your machine.