{"slug": "how-i-built-a-signed-audit-trail-for-an-llm-system-and-broke-it-with-one-sql", "title": "How I Built a Signed Audit Trail for an LLM System (And Broke It With One SQL Update)", "summary": "Petros Savvakis, in Part 2 of his 'From Prompt to Proof' series, describes building a signed audit trail for QuoteBot, an LLM-based loan pricing assistant, using Ed25519 signatures and SHA-256 hash chaining to ensure log integrity. He notes that the EU AI Act (Articles 12, 19, 26) requires automatic event recording and log retention for high-risk AI systems, and he references Brown University's 'Audit Trails for Accountability in LLMs' model. Savvakis emphasizes that audit records must be designed before other layers to avoid retrofitting history.", "body_md": "- Published on\n\n# How I Built a Signed Audit Trail for an LLM System (And Broke It With One SQL Update)\n\n- Authors\n- Name\n- Petros Savvakis\n[@PetrosSavvakis](https://twitter.com/PetrosSavvakis)\n\nHow I Built a Signed Audit Trail for an LLM System (And Broke It With One SQL Update)\n\nLogs, 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.\n\nMost systems already have logs. The real question is different:\n\nCan you prove that the logs you are showing today are the same records the system wrote six months ago?\n\nThis is **Part 2 of 7** of my **From Prompt to Proof** series before PyCon Greece 2026 (repo coming soon!).\n\nIn [Part 1](/blog/How_I_Measured_The_PII_Layer), I measured the PII layer in front of the model. This time I am moving to Layer 7: the signed audit trail.\n\nThe 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.\n\nThe goal is not to collect more logs.\n\nThe goal is to create evidence.\n\nThe failure that started this\n\nImagine somebody asks:\n\nWhy did the system approve this loan quote on March 3?\n\nThe 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.\n\nThat is useful for debugging. It is not strong evidence.\n\nFor one decision I want one record that answers:\n\n- Who asked?\n- What PII did the detector find?\n- Which policy decided?\n- Which model and prompt produced the proposal?\n- Did a human approve it?\n- What action was finally executed?\n- Which trace contains the technical timeline?\n\nA simplified record looks like this:\n\n```\nAuditRecord(\n    sequence=42,\n    trace_id=\"0af76519...\",\n    action=\"quote.issue instalment_eur=940.00\",\n    outcome=\"approved\",\n    principal=principal,\n    pii=pii_result,\n    policy=policy_result,\n    model=model_result,\n    approval=human_decision,\n    prev_hash=\"8f2c...\",\n)\n```\n\nI designed this record before the other layers. That decision helped more than I expected.\n\nIf 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.\n\nLogged and auditable are not the same thing\n\nThe 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.\n\nThat 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.\n\nBrown University researchers describe a useful model in *Audit Trails for Accountability in LLMs*:\n\n**Capture** the technical and human governance events.**Store** them in a chronological integrity protected trail.**Use** them through a verifier and an auditor interface.\n\nThat model is close to what I needed. Capture without verification is just logging. Storage without a useful read path is just an archive.\n\nAs 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.\n\nTwo properties, not one\n\nThe implementation uses an Ed25519 signature and a SHA 256 link between records. They solve different problems.\n\n1. The signature protects one record\n\nThe system first creates one exact byte sequence and signs it:\n\n```\npayload = canonical_json(record)\nsignature = signing_key.sign(payload).signature\n```\n\nIf somebody edits the payload, verification with the public key fails.\n\nMore 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.\n\n2. The link protects the sequence\n\nEvery record carries the SHA 256 digest of the record before it:\n\n```\nentry_hash = hashlib.sha256(payload).hexdigest()\nnext_record = {\"prev_hash\": entry_hash}\n```\n\nThe verifier walks the records in order and checks both properties:\n\n```\nif body[\"prev_hash\"] != expected_prev:\n    return Verification(position, \"broken link\")\n\nverify_key.verify(entry.payload, entry.signature)\nexpected_prev = entry.entry_hash\n```\n\nThe signature detects an edit to one record. The link detects a missing, reordered, or inserted record in the middle of the chain.\n\nThere 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.\n\nThis is why I call the design **tamper evident**, not tamper proof. The database can still be changed. The verifier makes that change visible.\n\nCanonical JSON is not optional\n\nSignatures and hashes work on bytes, not Python dictionaries.\n\nThe 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.\n\nSo the system has one canonical encoding:\n\n```\njson.dumps(\n    plain,\n    sort_keys=True,\n    separators=(\",\", \":\"),\n    ensure_ascii=False,\n).encode(\"utf8\")\n```\n\n`sort_keys=True`\n\nremoves dictionary order as a variable.\n\n`separators=(\",\", \":\")`\n\nremoves optional whitespace.\n\n`ensure_ascii=False`\n\nkeeps Greek text readable and stable as UTF 8.\n\nThe serialiser also rejects floats:\n\n```\nif isinstance(value, float):\n    raise TypeError(\"format the value before writing the record\")\n```\n\nMoney stays as integer cents until the display boundary. Detection scores become fixed precision strings before they enter the record.\n\nThis 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.\n\nWhy the database stores TEXT instead of JSONB\n\nThe Postgres table is intentionally boring:\n\n```\nCREATE TABLE audit_log (\n    sequence  INTEGER PRIMARY KEY,\n    payload   TEXT    NOT NULL,\n    signature TEXT    NOT NULL\n);\n```\n\nWhy `TEXT`\n\n?\n\nBecause the signature covers the exact canonical bytes. `JSONB`\n\nparses and normalises the document when it enters Postgres. That is great for queries, but wrong when the exact representation is the thing we signed.\n\nThere 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.\n\nSometimes boring storage is the correct design.\n\nThen I broke it with one SQL update\n\nThe demo starts with four records:\n\n```\nOK  1  allowed\nOK  2  denied\nOK  3  escalated\nOK  4  approved\n\nCHAIN INTACT\n```\n\nThen I edit record 2 directly in Postgres. The application is not involved:\n\n```\nUPDATE audit_log\nSET payload = replace(\n    payload,\n    'instalment_eur=940.00',\n    'instalment_eur=9.40'\n)\nWHERE sequence = 2;\n```\n\nThe verifier reports:\n\n```\nCHAIN BROKEN AT RECORD 2\nsignature invalid\n```\n\nGood. But this is the easy attack.\n\nThe question everybody asks next is:\n\nWhat if the attacker also steals the signing key?\n\nSo I re sign the edited record with the real key.\n\nRecord 2 verifies again. Record 3 now fails because it still contains the hash of the old record 2:\n\n```\nCHAIN BROKEN AT RECORD 3\nbroken link to the previous record\n```\n\nTo hide one edit, the attacker must rewrite and re sign every record after it.\n\nThat is what the chain buys you. It raises the cost and exposes partial rewriting.\n\nIt 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.\"\n\nThe production answer is:\n\n- Keep signing behind a KMS or HSM.\n- Give signing operations their own audit record.\n- Publish the chain head periodically to an external system.\n- Alert when an expected anchor is missing.\n\nOnce a head is outside the application boundary, rewriting history also requires rewriting the external evidence.\n\nThe concurrency bug that I almost missed (thanks to AI it caught it)\n\nCryptography was not the hardest problem. Concurrent writes were.\n\nThe 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.\n\nBoth records were individually signed. The chain was still broken.\n\nNo retry can repair that after the records are written.\n\nThe store now owns sequence assignment and creates the next link inside one transaction:\n\n```\nwith conn.transaction():\n    conn.execute(\"LOCK TABLE audit_log IN EXCLUSIVE MODE\")\n    entry = build(next_sequence, current_head)\n    insert(entry)\n```\n\nThe test starts 24 concurrent writers, then runs the full verifier. It fails reliably against the old design.\n\nThere is a cost. The exclusive lock serialises every audit write. Ed25519 is fast. The lock is the bottleneck.\n\nFor 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.\n\nThe record shape also tells a story\n\nLayers that did not run are omitted, not stored as null.\n\nIf policy refuses a request before the model call, the audit record has no `model`\n\nfield. That small decision means the shape of the record tells you how far the request travelled.\n\nHuman approval is also first class audit data:\n\n```\nApprovalRef(\n    reviewer=\"eleni.p\",\n    decision=\"edit\",\n    original_action=\"instalment_eur=9.40\",\n    final_action=\"instalment_eur=940.00\",\n)\n```\n\nThe approval is not metadata about the audit trail. It is part of the audit trail.\n\nOtherwise 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.\n\nFinal Thoughts\n\nIf you are building an audit trail around an LLM feature or an AI agent, here is my advice:\n\n**Design the record first.** Let every control layer contribute one typed slice.**Canonicalise before signing.** Hashes protect bytes, not logical JSON.**Use signatures and links.** They protect different properties.**Call it tamper evident.** A local chain is not tamper proof.**Anchor the head externally.** Otherwise complete tail rewriting and tail deletion remain possible.**Test concurrent writers.** A perfectly signed fork is still a broken chain.**Record human decisions as data.** Reviewer, timestamps, original action, final action.**Build the verifier.** An audit store without a use path is an archive, not proof.\n\nThe model is not the evidence.\n\nThe record is not automatically the evidence either.\n\nThe ability to verify it is.\n\nNext up: **Part 3, policy as code.** A prompt is a suggestion. A policy is a law.\n\nCode, tests, and the full tamper demo: (Soon will attach the repo, some days before PyCon)\n\nSources\n\n[Audit Trails for Accountability in Large Language Models](https://arxiv.org/abs/2601.20727), Brown University, January 2026.[ISO IEC FDIS 24970](https://www.iso.org/standard/88723.html), AI system logging, stage 50.20 as checked in September 2026.- EU AI Act Article 12, record keeping.\n- EU AI Act Article 19, provider log retention.\n- EU AI Act Article 26, deployer log retention.\n\nDisclaimer\n\nThis 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.", "url": "https://wpnews.pro/news/how-i-built-a-signed-audit-trail-for-an-llm-system-and-broke-it-with-one-sql", "canonical_source": "https://www.petrostechchronicles.com//blog/How_I_Built_A_Signed_Audit_Trail", "published_at": "2026-09-04 00:00:00+00:00", "updated_at": "2026-09-04 08:53:30.103448+00:00", "lang": "en", "topics": ["ai-policy", "ai-safety", "ai-infrastructure"], "entities": ["Petros Savvakis", "QuoteBot", "EU AI Act", "Brown University", "ISO IEC 24970", "PyCon Greece 2026"], "alternates": {"html": "https://wpnews.pro/news/how-i-built-a-signed-audit-trail-for-an-llm-system-and-broke-it-with-one-sql", "markdown": "https://wpnews.pro/news/how-i-built-a-signed-audit-trail-for-an-llm-system-and-broke-it-with-one-sql.md", "text": "https://wpnews.pro/news/how-i-built-a-signed-audit-trail-for-an-llm-system-and-broke-it-with-one-sql.txt", "jsonld": "https://wpnews.pro/news/how-i-built-a-signed-audit-trail-for-an-llm-system-and-broke-it-with-one-sql.jsonld"}}