6. Compliance Infrastructure: Audit Trails, Policy-as-Code, and the Append-Only Principle

TL;DR

We had an AuditLogger that wrote to Loki only — the audit_events Postgres table sat empty for weeks. We had OPA running on a static policy file that needed a container restart to change: policy-in-code, not policy-as-code. And we had configuration tables that ran UPDATE freely, erasing every historical value the moment it changed. Three controls, three failure modes, one root cause — infrastructure that looks correct in review and fails a compliance audit. This piece covers the fix for all three: the transactional outbox pattern, an OPA bundle server under dual-control, and effective_from / effective_to temporal design.

Previous: Red-Teaming AI: OWASP LLM Top 10 and the Probes You Actually Need  |  Next: Human-in-the-Loop Is an Architecture Decision, Not a Feature

The Infrastructure That Looked Right

There’s a category of gap that’s almost impossible to catch by reading code. The class exists, the method gets called, the schema is well designed — and the actual compliance behaviour just never happens.

We found three of these in a single audit session, weeks after each control had been signed off as “implemented.” Our AuditLogger was wired into all five agent services and wrote to Loki only — the audit_events Postgres table had a clean schema and zero rows. Our OPA policy was a static .rego file mounted read-only, so changing a threshold meant rebuilding the image, with no record of what changed or when. And our configuration tables used plain SQL UPDATE — when the Sharia agent’s autonomy tier moved from SUPERVISED to FULL, the previous value simply disappeared.

A class that exists, a table that was created, a policy file that’s mounted — none of it guarantees the compliance behaviour actually happens. Only checking the actual rows does.

None of this showed up in review — dashboards showed activity, the system looked governed. It took an auditor’s questions to show it wasn’t. What follows is how we fixed all three: the transactional outbox, a policy bundle server, and temporal table design.

Back to top . Next: Building a Real Audit Trail →


Building a Real Audit Trail

Confusing a log line with an audit event is exactly what created this gap, so it’s worth being precise about the difference.

A log line is operational — useful for debugging, ephemeral by design, and Loki keeps ours for 30 days. An audit event is a compliance record that has to answer a regulatory question years later: which agent made this credit decision, what fatwa supported that Sharia ruling. That needs records that are immutable, timestamped, attributed, and queryable long after the fact.

Retention requirements vary by regulator but not by much: CBUAE, SAMA, and AAOIFI want 7 years for credit decisions and indefinite retention for Sharia records. A 30-day Loki log satisfies none of it.

A well-formed audit event carries a controlled event type (SHARIA_CHECK_PASSED, not free text), an agent ID, an actor that’s never null for human actions, a task ID tying it to the decision trace, and an immutable timestamp.

Two Gaps, Stacked

We found two problems here, stacked on top of each other. AuditLogger.record() called structlog.info() and returned — the INSERT INTO audit_events that should have followed simply never got written, so for weeks the dashboards showed activity while the table sat at zero rows. Adding a fire-and-forget _persist_to_db() call fixed that, but it exposed the second problem: downstream systems — a SIEM, OpenMetadata, a reporting dashboard — also needed these events on a Redpanda topic, and writing to Postgres then publishing separately means anything between the two calls can fail silently.

The Transactional Outbox

The fix is a standard distributed-systems pattern: a second table, the outbox, in the same database as audit_events — so the write is one local Postgres transaction, no external coordinator needed.

  • Atomic write: one transaction writes to audit_events and audit_outbox together — both succeed or both fail.
  • Background polling: a poller runs every two seconds, publishes unpublished rows to Redpanda’s audit.events topic, marks them published.
  • Guaranteed delivery: if the poller crashes mid-publish, the row stays unpublished and goes out next cycle. If it’s in Postgres, it eventually reaches the stream.

We eventually turned this into a rule: every significant compliance event produces four outputs — a log line, a Prometheus counter, a Postgres row, and a Redpanda publication. Fewer than that is underspecified for a regulated environment.

Back to top . Next: Policy-as-Code, Done Right →


Policy-as-Code, Done Right

When we first built the authorization layer, we did what most engineers do: wrote an OPA policy file, mounted it into the container, and enforced it at the one point we assumed mattered — the credit agent. Four problems in that setup only surfaced at audit.

  • No hot reload. Changing a threshold meant editing the file, rebuilding the image, and restarting OPA — so thresholds mostly didn’t get updated.
  • No audit trail of policy changes. Git shows when the file changed, not when the container restarted with it.
  • Enforcement was partial. Only the credit agent called OPA — Sharia, Risk, Simulation, and the Orchestrator didn’t, so a request could reach the Sharia agent with no authorization check at all.
  • Policy and code were coupled. Updating policy required a deployment, so changes shipped bundled with agent code even when the logic hadn’t changed.

Worth naming here: Rego policies default to allow := false, untouchable in our codebase, since default-allow can quietly turn a missing rule into a silent grant. Each agent gets back more than allow or deny — whether HITL is required, whether a Sharia evaluation must run first, whether the tier should be elevated — and Python never makes that call; OPA does.

The Bundle Server

The fix: package policies as a compressed bundle, upload to an object store, and have OPA pull it on a poll instead of mounting a file. We use MinIO, self-hosted and S3-compatible; OPA polls every 30 seconds and activates any bundle with a newer ETag — no restart, no redeploy.

Each bundle holds two files — authz.rego for the rules, data.json for the tier map — packaged for atomic activation by a shell script, bundle.sh, which also checks that OpenBao is reachable and the deploy token valid. An early version had a fail-open bug: an unreachable OpenBao made the token check return an empty string that was accidentally truthy, so the script deployed anyway. We now treat any validation failure as fatal.

Back to top . Next: Dual-Control for Policy Changes →


Dual-Control for Policy Changes

Hot reload solves the operational problem, but it opens a new one: if anyone with access to bundle.sh can deploy a policy instantly, the control can be bypassed faster than any human can intervene. Speed without accountability isn’t an improvement here.

The first control is GitOps: authz.rego lives in version control, changes need a pull request, and CODEOWNERS requires both the compliance officer and the security lead to review — neither can approve their own change. That doesn’t cover deployment, though. Merging a change and running bundle.sh are two different actions, and the same developer could do both unsupervised.

So the second control is a governed deploy endpoint, gated behind an admin key separate from the normal agent API key — stored in OpenBao, rotated quarterly, logged on every use. Merging a policy change doesn’t let you activate it. Every deployment also writes a row to opa_policy_versions — who deployed it, why, and an effective_from / effective_to pair — so “what policy was active at time T” is always answerable. For an Islamic finance platform, this gives the Sharia Board a real, blocking role in technical governance: rules about which products need Sharia pre-approval go through the same CODEOWNERS review as any other policy change.

Back to top . Next: Why UPDATE Fails Compliance →


Why UPDATE Fails Compliance

Every engineer who has built databases professionally has the same reflex: when data changes, you update the row. That reflex is correct for most systems and a liability in regulated AI.

Here’s the scenario. The Sharia agent starts at SUPERVISED. Three weeks later you promote it to FULL, and the instinct is UPDATE agent_registry SET autonomy_tier = 'FULL' WHERE agent_name = 'agent-sharia'. That’s correct, right up until a CBUAE examiner asks what tier the agent was operating under for a transaction approved on 14 March at 09:47. You can’t answer — the row says FULL because that’s what it is now, and what it was on 14 March no longer exists. The UPDATE erased it.

The update instinct destroys the one thing compliance needs most: the ability to reconstruct system state at the moment a regulated decision was made.

SR 11-7, the CBUAE Model Risk Guidelines, and AAOIFI Governance Standard 6 all require exactly that, each in its own language. If you ran UPDATE, you simply can’t.

The Fix: Soft-Close and Insert

Every row carries effective_from and effective_to (NULL means current). One transaction does two things: soft-close the current row (SET effective_to = now() WHERE effective_to IS NULL — the only UPDATE ever permitted, and it only stamps an end date), then insert a new row with the new tier, a fresh effective_from, and governance metadata — who changed it, who approved it, and why.

agent_nameautonomy_tiereffective_fromeffective_totier_changed_by
agent-shariaSUPERVISED2026-02-01 08:00:00+002026-03-14 11:22:00+00NULL (self-registered)
agent-shariaFULL2026-03-14 11:22:00+00NULL (active)alice@bank.ae

Querying at 09:47 with effective_from <= T AND (effective_to IS NULL OR effective_to > T) returns SUPERVISED — the promotion happened at 11:22, after the decision. Soft-close and insert, never delete or overwrite: the table only grows, and it’s queryable as its own audit trail.

The Tables That Live By This Rule

Not everything needs this — caches and session state can be overwritten freely. Four tables govern decisions, policies, models, and audit events, and all four are append-only:

  • agent_model_registry — which model version ran for which agent, when. Survives a rollback.
  • agent_registry — tier history with the four-eyes metadata above; an env-var restart can’t silently override a human-set tier.
  • opa_policy_versions — the exact Rego rules active at any past timestamp, not reconstructed from Git blame.
  • audit_outbox — a delivery queue, but the same rule applies: the only permitted change after insert is marking a row published.

Back to top . Next: Point-in-Time Reconstruction →


⏱️ Point-in-Time Reconstruction

A customer complains about a Murabaha offer from 14 March 2026, 09:47 UTC, claiming the profit rate was unfair. The regulator wants the full picture — which model ran, under which policy, at which agent tier, with what audit trail. With temporal tables and the outbox in place, that’s four queries against the same timestamp.

  • Model version: agent_model_registry at 09:47 → v1.2, approved by Alice on 1 February.
  • Autonomy tier: agent_registry, same timestamp → SUPERVISED, the tier before that morning’s promotion.
  • Active policy: opa_policy_versions → bundle authz/bundles/2026-02-15.tar.gz, downloadable and inspectable.
  • Audit trail: audit_events for that task ID → the full sequence, timestamped and attributed.

Without temporal design, some of those queries would return today’s state instead of March’s, and the reconstruction would just be wrong. The same tables pay off outside audits too — compliance officers can query them directly instead of opening a ticket, and root-cause work gets faster.

On GDPR right-to-erasure: it’s compatible with immutability. We pseudonymise instead of deleting — replace customer_id with a hash derived from a deleted-customer salt. The record survives; the link to a real person doesn’t.

Put together, a single credit decision now produces a connected chain, linked by one task ID: a decision trace, the audit events in the outbox stream, the Sharia decision detail with fatwas cited, the full Langfuse trace of every LLM call, and the exact policy and model context.

A regulator with a task ID can reconstruct the whole decision — every agent, every prompt, every fatwa, whether a human reviewed it. That’s not aspirational. It’s what the schema enforces.

Compliance Infrastructure Stack

AuditLogger

Dual-writes to audit_events + audit_outbox in one transaction. Fire-and-forget.

Transactional Outbox

Polled every 2 seconds, exactly-once delivery to Redpanda. Survives a poller crash.

OPA + Rego

Deny-by-default across all five agents. Python never makes the call.

MinIO Bundle Server

OPA polls every 30 seconds. Policy updates without a restart.

CODEOWNERS + Deploy API

Two gates: mandatory PR review, then an admin-key-gated deploy endpoint.

opa_policy_versions

Append-only. Returns the exact policy active at any past timestamp.

agent_model_registry / agent_registry

Temporal model-version and tier history with four-eyes metadata.

Debezium CDC

Streams raw Postgres changes to Redpanda for lineage tools.

Compliance Infrastructure: Audit Trails, Policy-as-Code, and the Append-Only Principle
Compliance Infrastructure: Audit Trails, Policy-as-Code, and the Append-Only Principle

Back to top . Next: Key Takeaways →


Key Takeaways

  • Declaration is not implementationa wired-up class or a mounted policy file doesn’t prove compliance behaviour actually happens. Only checking the rows does.
  • Static policy is policy-in-code, not policy-as-codea bundle server plus dual-control (GitOps review and a separately-credentialed deploy API) is the minimum for a regulated system.
  • UPDATE is the enemy of compliancesoft-close and insert is the only pattern that answers “what was true at time T,” which is what a regulator eventually asks.

Back to top


Thank You, Reader

These are the hardest gaps to catch — infrastructure that looks right in review and fails in production. If you’re building a regulated AI system, go check three things today: the actual rows in your audit table, the actual hot-reload behaviour under a live policy change, and the actual temporal queries against your configuration tables. Not the schema. The data.

Connect With Me

Enjoyed this article?

Get notified when the next one is published.

🔒 We send one email per new article — no spam, unsubscribe any time.

⚠️ Disclaimer: The information provided on LearnWithNeeraj.com regarding Astrology, Numerology, and other topics is for educational and guidance purposes only.

Not Professional Advice: This content should not be used as a substitute for professional medical, legal, or financial advice. Always consult a certified professional for specific concerns.

Guest Authors: This site features articles by various contributors. The views and interpretations expressed are those of the individual authors and do not necessarily reflect the views of the website administrator.

Your destiny is in your hands. Use this information as a map, not a mandate.

Related Posts

Red-Teaming AI — OWASP LLM Top 10 and the Probes You Actually Need

5. Red-Teaming AI: OWASP LLM Top 10 and the Probes You Actually Need

TL;DR We had heard of the OWASP LLM Top 10, and we had implemented fewer than half of it. We had PyRIT installed, and we had almost…

Securing Agentic AI Authentication, Authorization, and PII

4. Securing Agentic AI: Authentication, Authorization, and PII

TL;DR Our five-agent banking AI platform had OPA wired into exactly one agent. The MCP server had no auth middleware. All five agents shared one API key….

The 57-Gap Audit — Gap Categories and Discovery Method

3. The 57-Gap Audit — What “Done” Actually Means in Production AI

TL;DR After weeks of building a multi-agent AI platform — five agents, full pipeline, red-team harness, control UI — the system looked done. It wasn’t. A config…

The Engineering Platform: Orchestration, Monorepo, and the Stack Decisions

2. The Engineering Platform: Orchestration, Monorepo, and the Stack Decisions

TL;DR Once you decide to build a multi-agent AI system, you face three engineering choices that will determine whether the platform is governable or just functional: what…

Lessons From Building an Agentic Data Platform

17. Seventeen Parts Later: Lessons From Building an Agentic Data Platform

📌 TL;DR Seventeen articles. Seventeen components. One platform that generates, governs, and serves financial data through a network of coordinated AI agents. This final article reflects on…

Proactive Intelligence: Building Alert Systems That Think Ahead

16. Proactive Intelligence: Building Alert Systems That Think Ahead

Series: Building an Agentic Data Platform  |  Part 16 of 17Reading time: ⏳ ~12 minutesTags: 🏷️ alerting alert engine notification system escalation policy Prometheus AlertManager data quality…