Master Ball Logo
Published on

How I Built a Signed Audit Trail for an LLM System (And Broke It With One SQL Update)

Authors

How I Built a Signed Audit Trail for an LLM System (And Broke It With One SQL Update)

Logs, traces, governance, compliance, audit trails... those are some of the words that come up every time we discuss putting LLM features and AI agents into production.

Most systems already have logs. The real question is different:

Can you prove that the logs you are showing today are the same records the system wrote six months ago?

This is Part 2 of 7 of my From Prompt to Proof series before PyCon Greece 2026 (repo coming soon!).

In Part 1, I measured the PII layer in front of the model. This time I am moving to Layer 7: the signed audit trail.

The working example is QuoteBot, a small loan pricing assistant. One request passes through identity, PII detection, policy, the model, human approval when required, and an output guard. Every layer contributes one part of a single audit record.

The goal is not to collect more logs.

The goal is to create evidence.

The failure that started this

Imagine somebody asks:

Why did the system approve this loan quote on March 3?

The usual answer is a search across application logs, model gateway logs, policy logs, and maybe a database table. You find six lines from four services and hope they describe the same request.

That is useful for debugging. It is not strong evidence.

For one decision I want one record that answers:

  1. Who asked?
  2. What PII did the detector find?
  3. Which policy decided?
  4. Which model and prompt produced the proposal?
  5. Did a human approve it?
  6. What action was finally executed?
  7. Which trace contains the technical timeline?

A simplified record looks like this:

AuditRecord(
    sequence=42,
    trace_id="0af76519...",
    action="quote.issue instalment_eur=940.00",
    outcome="approved",
    principal=principal,
    pii=pii_result,
    policy=policy_result,
    model=model_result,
    approval=human_decision,
    prev_hash="8f2c...",
)

I designed this record before the other layers. That decision helped more than I expected.

If you design the audit record last, every service has already decided what it feels like logging. Then you spend weeks trying to reconstruct one decision from incompatible events. You cannot retrofit history.

Logged and auditable are not the same thing

The EU AI Act gives useful context here (legal articles reference coming up). Article 12 requires high risk AI systems to technically support automatic event recording over the lifetime of the system. Article 19 requires providers to retain the logs under their control for at least six months. Article 26 contains the parallel duty for deployers.

That does not mean every LLM feature is automatically high risk though. Classification depends on the actual system and its use. It does mean that record keeping and traceability are now architecture topics, not tasks for the compliance team after release.

Brown University researchers describe a useful model in Audit Trails for Accountability in LLMs:

  1. Capture the technical and human governance events.
  2. Store them in a chronological integrity protected trail.
  3. Use them through a verifier and an auditor interface.

That model is close to what I needed. Capture without verification is just logging. Storage without a useful read path is just an archive.

As of September 2026, ISO IEC 24970 is still a final draft under approval. Its scope covers common capabilities, requirements, and an information model for AI system logging. I treat it as useful direction, not as proof that my implementation is compliant, at least until it is finalised.

Two properties, not one

The implementation uses an Ed25519 signature and a SHA 256 link between records. They solve different problems.

1. The signature protects one record

The system first creates one exact byte sequence and signs it:

payload = canonical_json(record)
signature = signing_key.sign(payload).signature

If somebody edits the payload, verification with the public key fails.

More precisely, a valid signature proves that these bytes match something signed by the holder of the private key. It does not prove that the key was never stolen. Keep that in mind as that detail becomes important very soon.

Every record carries the SHA 256 digest of the record before it:

entry_hash = hashlib.sha256(payload).hexdigest()
next_record = {"prev_hash": entry_hash}

The verifier walks the records in order and checks both properties:

if body["prev_hash"] != expected_prev:
    return Verification(position, "broken link")

verify_key.verify(entry.payload, entry.signature)
expected_prev = entry.entry_hash

The signature detects an edit to one record. The link detects a missing, reordered, or inserted record in the middle of the chain.

There is an important limitation. A local chain alone cannot prove that somebody did not delete records from the end. For that, the latest head must be anchored somewhere the application cannot rewrite. More on that below.

This is why I call the design tamper evident, not tamper proof. The database can still be changed. The verifier makes that change visible.

Canonical JSON is not optional

Signatures and hashes work on bytes, not Python dictionaries.

The same logical JSON can have different bytes because of key order, whitespace, Unicode escaping, or number representation. If the writer and the verifier do not produce exactly the same bytes, verification becomes unreliable.

So the system has one canonical encoding:

json.dumps(
    plain,
    sort_keys=True,
    separators=(",", ":"),
    ensure_ascii=False,
).encode("utf8")

sort_keys=True removes dictionary order as a variable.

separators=(",", ":") removes optional whitespace.

ensure_ascii=False keeps Greek text readable and stable as UTF 8.

The serialiser also rejects floats:

if isinstance(value, float):
    raise TypeError("format the value before writing the record")

Money stays as integer cents until the display boundary. Detection scores become fixed precision strings before they enter the record.

This may sound strict, but the alternative is signing a representation that can change across implementations (and as I had tried in the past this can become really messy). An audit trail should have no free variables.

Why the database stores TEXT instead of JSONB

The Postgres table is intentionally boring:

CREATE TABLE audit_log (
    sequence  INTEGER PRIMARY KEY,
    payload   TEXT    NOT NULL,
    signature TEXT    NOT NULL
);

Why TEXT?

Because the signature covers the exact canonical bytes. JSONB parses and normalises the document when it enters Postgres. That is great for queries, but wrong when the exact representation is the thing we signed.

There is also no stored entry hash. The verifier recomputes it from the payload. A stored hash would be one more value that could disagree with the bytes.

Sometimes boring storage is the correct design.

Then I broke it with one SQL update

The demo starts with four records:

OK  1  allowed
OK  2  denied
OK  3  escalated
OK  4  approved

CHAIN INTACT

Then I edit record 2 directly in Postgres. The application is not involved:

UPDATE audit_log
SET payload = replace(
    payload,
    'instalment_eur=940.00',
    'instalment_eur=9.40'
)
WHERE sequence = 2;

The verifier reports:

CHAIN BROKEN AT RECORD 2
signature invalid

Good. But this is the easy attack.

The question everybody asks next is:

What if the attacker also steals the signing key?

So I re sign the edited record with the real key.

Record 2 verifies again. Record 3 now fails because it still contains the hash of the old record 2:

CHAIN BROKEN AT RECORD 3
broken link to the previous record

To hide one edit, the attacker must rewrite and re sign every record after it.

That is what the chain buys you. It raises the cost and exposes partial rewriting.

It does not create magic. An attacker with the database, the private key, enough time, and no external anchor can rewrite the complete tail. The production answer is not "the key cannot be stolen."

The production answer is:

  1. Keep signing behind a KMS or HSM.
  2. Give signing operations their own audit record.
  3. Publish the chain head periodically to an external system.
  4. Alert when an expected anchor is missing.

Once a head is outside the application boundary, rewriting history also requires rewriting the external evidence.

The concurrency bug that I almost missed (thanks to AI it caught it)

Cryptography was not the hardest problem. Concurrent writes were.

The first design read the chain head in the writer, created the next record, then appended it. Two requests arriving at the same time could both read the same head and both claim the same predecessor.

Both records were individually signed. The chain was still broken.

No retry can repair that after the records are written.

The store now owns sequence assignment and creates the next link inside one transaction:

with conn.transaction():
    conn.execute("LOCK TABLE audit_log IN EXCLUSIVE MODE")
    entry = build(next_sequence, current_head)
    insert(entry)

The test starts 24 concurrent writers, then runs the full verifier. It fails reliably against the old design.

There is a cost. The exclusive lock serialises every audit write. Ed25519 is fast. The lock is the bottleneck.

For this demo and its traffic, that is acceptable. At larger scale I would shard the chain by tenant or another stable boundary, then verify each shard independently. The important part is to state the tradeoff instead of pretending cryptography is free architecture.

The record shape also tells a story

Layers that did not run are omitted, not stored as null.

If policy refuses a request before the model call, the audit record has no model field. That small decision means the shape of the record tells you how far the request travelled.

Human approval is also first class audit data:

ApprovalRef(
    reviewer="eleni.p",
    decision="edit",
    original_action="instalment_eur=9.40",
    final_action="instalment_eur=940.00",
)

The approval is not metadata about the audit trail. It is part of the audit trail.

Otherwise the record says a human approved 940 euros and hides the fact that the model proposed 9.40 euros. That correction is exactly what an auditor needs to see.

Final Thoughts

If you are building an audit trail around an LLM feature or an AI agent, here is my advice:

  1. Design the record first. Let every control layer contribute one typed slice.
  2. Canonicalise before signing. Hashes protect bytes, not logical JSON.
  3. Use signatures and links. They protect different properties.
  4. Call it tamper evident. A local chain is not tamper proof.
  5. Anchor the head externally. Otherwise complete tail rewriting and tail deletion remain possible.
  6. Test concurrent writers. A perfectly signed fork is still a broken chain.
  7. Record human decisions as data. Reviewer, timestamps, original action, final action.
  8. Build the verifier. An audit store without a use path is an archive, not proof.

The model is not the evidence.

The record is not automatically the evidence either.

The ability to verify it is.

Next up: Part 3, policy as code. A prompt is a suggestion. A policy is a law.

Code, tests, and the full tamper demo: (Soon will attach the repo, some days before PyCon)


Sources

  1. Audit Trails for Accountability in Large Language Models, Brown University, January 2026.
  2. ISO IEC FDIS 24970, AI system logging, stage 50.20 as checked in September 2026.
  3. EU AI Act Article 12, record keeping.
  4. EU AI Act Article 19, provider log retention.
  5. EU AI Act Article 26, deployer log retention.

Disclaimer

This article is based on my personal work on the open source control plane I am presenting at PyCon Greece 2026. The code, results, and mistakes come from that public repository. They reflect a small technical demonstration, not a complete governance platform or a claim that every LLM feature has the same legal classification. This is not legal advice, and it is not the internal process of any employer.