The uncomfortable question is not “did the AI agent change the price?”
The uncomfortable question is: can you prove what it changed, why it changed it, which model or tool version was involved, who approved it, what the inputs were, and whether anyone altered the record afterward?
Most AI-integrated applications cannot answer that. They have logs, sure. Maybe a logger.info("Agent executed tool", { tool: "refund_order" }) line somewhere. Maybe a row in an activity_logs table that an admin can edit. Maybe a trace in an observability vendor with a 30-day retention policy.
That is not an audit trail. That is a suggestion.
Once an AI agent can write to your database, call external APIs, send messages, trigger payments, or modify infrastructure, every action needs a durable, tamper-evident record. The pattern I have found most useful for this is an immutable execution ledger: an append-only sequence of cryptographically linked events that records the lifecycle of each AI action from request to approval, execution, failure, and compensation.
This is not about distrusting the model. It is about making the system legible after the fact.
TL;DR
If you only take away a few ideas:
- Treat every AI action as a structured business event, not a free-form log line.
- Store those events in an append-only ledger where updates and deletes are blocked by design.
- Link entries with a hash chain so missing or altered entries are detectable.
- Write the ledger event in the same transaction as the business mutation when possible.
- Never update an action’s status; emit a new lifecycle event.
- Keep secrets and personal data out of the ledger, or use crypto-shredding.
- Verify the ledger continuously and anchor checkpoints outside the primary database.
📋 Table of Contents
- The real problem: mutable logs fail exactly when you need them
- 1. Model the AI action as an execution envelope, not a log line
- 2. Make writes append-only before you worry about cryptography
- 3. Hash-chain the ledger so tampering breaks the story
- 4. Use the transactional outbox so the trace never lags behind the action
- 5. Never update an action record; emit the next lifecycle event
- 6. Keep secrets and personal data out of the ledger
- 7. Verify the ledger continuously, not during the incident
- 8. Anchor trust outside the primary database
- 9. Choose the storage backend based on your threat model
- A practical rollout checklist
The real problem: mutable logs fail exactly when you need them
Imagine this sequence:
- An AI agent receives a request to “clean up failed orders.”
- It calls a tool named
cancel_orders. - A support ticket arrives two days later: three paying customers were cancelled incorrectly.
- Your team checks the logs.
- The logs say:
Executed cancel_orders. - Someone asks: “Which orders? What parameters? Who approved it? What did the model see? Was this the old prompt or the new one?”
- Nobody knows.
Then someone “fixes” the log record to add more context. That may be harmless, but it destroys the evidentiary value of the log. If the record can be edited, you cannot prove it was not edited.
An immutable execution ledger solves a different problem than ordinary logging.
A normal log answers: What did the application print?
An execution ledger answers: What can we prove happened?
It needs to be:
- Attributable: who or what initiated the action.
- Reconstructable: you can replay events and understand the final state.
- Tamper-evident: alterations, deletions, or missing entries are detectable.
- Durable: it survives restarts, retries, deployments, and partial outages.
- Queryable: engineers and compliance teams can actually ask questions.
The ledger does not prevent bad actions by itself. It makes bad actions detectable, explainable, and attributable.
1. Model the AI action as an execution envelope, not a log line
Scenario: Your agent calls a tool called update_customer_plan. The log says, “Updated customer plan.” That tells you almost nothing. Was the customer on a trial? What plan did they move to? Did the agent act on behalf of a user? Was the model instructed by a support transcript? Was the action approved? Did it succeed?
A log line is too weak. You need an execution envelope: a structured event that captures the context of the action.
The envelope should answer these questions:
- Who initiated the action? A user, an agent, a scheduled job, another service?
- Which agent session produced it?
- Which tool was invoked?
- Which model or policy version was involved?
- What inputs were used? Usually as a hash, not raw data.
- What output was produced? Again, usually as a hash or reference.
- What lifecycle stage is this? Requested, approved, executed, failed, compensated?
- Which previous event caused it?
- What metadata is safe to keep?
A practical schema might look like this:
CREATE TABLE ai_execution_ledger (
ledger_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
idempotency_key TEXT UNIQUE,
schema_version INTEGER NOT NULL DEFAULT 1,
occurred_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
action_id UUID NOT NULL,
correlation_id UUID,
causation_event_id UUID,
actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'agent', 'service', 'scheduler')),
actor_id TEXT NOT NULL,
agent_session_id TEXT,
tool_name TEXT NOT NULL,
model_id TEXT,
prompt_template_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'requested',
'preview_generated',
'approval_requested',
'approved',
'rejected',
'execution_started',
'succeeded',
'failed',
'compensated'
)),
risk_level TEXT NOT NULL CHECK (risk_level IN ('low', 'medium', 'high', 'critical')),
input_hash TEXT,
output_hash TEXT,
metadata_hash TEXT NOT NULL,
previous_hash TEXT NOT NULL,
entry_hash TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_ledger_action_id
ON ai_execution_ledger (action_id, ledger_id);
CREATE INDEX idx_ledger_session
ON ai_execution_ledger (agent_session_id, ledger_id);
CREATE INDEX idx_ledger_tool_status
ON ai_execution_ledger (tool_name, status);
The important fields are not just the obvious ones.
action_id groups all events for a single logical action. The agent may request, wait for approval, execute, and later compensate. These are separate rows, not one mutable row.
correlation_id connects the action to a broader workflow. If a customer support conversation triggers a refund, the correlation ID can connect the ledger to that conversation.
causation_event_id identifies the direct parent event. The succeeded event was caused by the execution_started event. The execution_started event was caused by the approved event.
input_hash and output_hash let you prove what was used and returned without necessarily storing the raw payload.
previous_hash and entry_hash make the ledger tamper-evident. More on that shortly.
🔍 Why this matters: If your ledger cannot express causality, you will end up with a pile of rows that are technically complete but practically useless. The hardest question during an incident is rarely “what happened?” It is “why did this happen after that?”
2. Make writes append-only before you worry about cryptography
A lot of teams jump straight to hashing, Merkle trees, and external anchors. That is fun, but it is also easy to overengineer before you fix the basic problem: the application can update the ledger.
If the same database user that inserts ledger rows can also update or delete them, your ledger is only immutable until someone writes a migration, runs a one-off script, or ships a bug.
Start by making mutation difficult by default.
In PostgreSQL, for example, create a role that can insert and select but cannot update, delete, or truncate:
CREATE ROLE ledger_owner;
CREATE ROLE ledger_app LOGIN;
GRANT INSERT, SELECT ON TABLE ai_execution_ledger TO ledger_app;
GRANT USAGE ON SEQUENCE ai_execution_ledger_ledger_id_seq TO ledger_app;
REVOKE UPDATE, DELETE, TRUNCATE ON TABLE ai_execution_ledger FROM ledger_app;
Then add a trigger as a second line of defense:
CREATE OR REPLACE FUNCTION prevent_ledger_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'ai_execution_ledger is append-only';
END;
$$;
CREATE TRIGGER ledger_no_update_delete
BEFORE UPDATE OR DELETE ON ai_execution_ledger
FOR EACH ROW
EXECUTE FUNCTION prevent_ledger_mutation();
This does not make the ledger mathematically immutable. A sufficiently privileged database administrator can still bypass application-level controls. But it removes the casual paths to mutation.
It prevents:
- A developer accidentally writing an admin screen that edits ledger metadata.
- A background job “cleaning up” old rows.
- A migration that silently rewrites history.
- A support tool that updates records in place.
⚠️ Gotcha: Database-level immutability is not enough if your threat model includes database administrators, compromised credentials, or cloud provider access. For stronger guarantees, you need external anchoring, separate audit storage, or managed ledger infrastructure. But for many teams, the first win is simply stopping accidental mutation.
3. Hash-chain the ledger so tampering breaks the story
Once the ledger is append-only at the database level, the next step is to make missing or altered entries detectable.
The simplest useful pattern is a linear hash chain.
Each entry contains:
previous_hash: the hash of the previous ledger entry.entry_hash: a hash of the current entry’s stable fields plusprevious_hash.
The first entry uses a genesis hash, usually a string of zeros.
entry_1.previous_hash = 0000...
entry_1.entry_hash = sha256(entry_1_fields + entry_1.previous_hash)
entry_2.previous_hash = entry_1.entry_hash
entry_2.entry_hash = sha256(entry_2_fields + entry_2.previous_hash)
entry_3.previous_hash = entry_2.entry_hash
entry_3.entry_hash = sha256(entry_3_fields + entry_3.previous_hash)
If someone changes entry 2, its entry_hash no longer matches. Then entry 3’s previous_hash no longer matches. The chain breaks.
The hard part is not the hashing. It is canonical serialization.
If you hash a JSON object, the hash changes when key order changes, whitespace changes, floating-point formatting changes, or optional fields appear differently. You need a stable representation.
Here is a simple canonical JSON function in TypeScript:
export function canonicalJson(value: unknown): string {
if (value === undefined || value === null) {
return "null";
}
if (Array.isArray(value)) {
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
}
const type = typeof value;
if (type === "string" || type === "number" || type === "boolean") {
return JSON.stringify(value);
}
if (type === "object") {
const record = value as Record<string, unknown>;
const keys = Object.keys(record)
.filter((key) => record[key] !== undefined)
.sort();
const body = keys
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",");
return `{${body}}`;
}
throw new Error(`Unsupported value type: ${type}`);
}
Then compute the entry hash from the fields that matter:
import { createHash } from "node:crypto";
export function sha256Hex(input: string): string {
return createHash("sha256").update(input, "utf8").digest("hex");
}
export interface LedgerHashInput {
schemaVersion: number;
eventId: string;
idempotencyKey?: string | null;
occurredAt: string;
receivedAt: string;
actionId: string;
correlationId?: string | null;
causationEventId?: string | null;
actorType: string;
actorId: string;
agentSessionId?: string | null;
toolName: string;
modelId?: string | null;
promptTemplateId?: string | null;
status: string;
riskLevel: string;
inputHash?: string | null;
outputHash?: string | null;
metadataHash: string;
previousHash: string;
}
export function computeLedgerEntryHash(event: LedgerHashInput): string {
const canonical = canonicalJson({
v: event.schemaVersion,
eventId: event.eventId,
idempotencyKey: event.idempotencyKey ?? null,
occurredAt: event.occurredAt,
receivedAt: event.receivedAt,
actionId: event.actionId,
correlationId: event.correlationId ?? null,
causationEventId: event.causationEventId ?? null,
actor: {
type: event.actorType,
id: event.actorId,
},
agentSessionId: event.agentSessionId ?? null,
toolName: event.toolName,
modelId: event.modelId ?? null,
promptTemplateId: event.promptTemplateId ?? null,
status: event.status,
riskLevel: event.riskLevel,
inputHash: event.inputHash ?? null,
outputHash: event.outputHash ?? null,
metadataHash: event.metadataHash,
previousHash: event.previousHash,
});
return sha256Hex(canonical);
}
Do not include the entry_hash field in the hash calculation. That would create a circular dependency.
Also, be careful with fields that can be changed by the database after insertion. The hash should be computed over the values you actually store, including received_at, not over a new value generated later.
🧠 The important part: A global hash chain serializes writes. That is fine for moderate volume, but it can become a bottleneck. If you need high throughput, use per-stream chains: one chain per tenant, per agent session, per action type, or per business domain. You can then batch stream roots into a Merkle tree or checkpoint them externally.
4. Use the transactional outbox so the trace never lags behind the action
A ledger is only useful if it records actions reliably. The most common failure mode is not malicious tampering. It is inconsistency.
The agent executes an action, but the logging service fails. The database transaction commits, but the event publisher crashes. The external API succeeds, but the local ledger insert times out. Now your system state and your ledger disagree.
The cleanest fix is to write the ledger entry in the same transaction as the business mutation.
await db.transaction(async (tx) => {
await tx.query(
`INSERT INTO refunds (id, order_id, amount, status, created_by)
VALUES ($1, $2, $3, 'completed', $4)`,
[refundId, orderId, amount, actorId]
);
await tx.query(
`INSERT INTO ai_execution_ledger (
occurred_at,
action_id,
actor_type,
actor_id,
agent_session_id,
tool_name,
status,
risk_level,
input_hash,
output_hash,
metadata_hash,
previous_hash,
entry_hash,
metadata
)
VALUES (
$1, $2, 'agent', $3, $4, 'payments.refund', 'succeeded',
'high', $5, $6, $7, $8, $9, $10
)`,
[
occurredAt,
actionId,
actorId,
agentSessionId,
inputHash,
outputHash,
metadataHash,
previousHash,
entryHash,
metadata,
]
);
});
If the transaction commits, both the business mutation and the ledger event exist. If it rolls back, neither exists.
For actions that involve external systems, the pattern changes slightly. You cannot put Stripe, SendGrid, Slack, or a third-party REST API inside your database transaction. In that case, record the lifecycle as separate events:
const startedEvent = await ledger.append({
actionId,
status: "execution_started",
toolName: "payments.refund",
actorType: "agent",
actorId,
inputHash,
});
try {
const result = await payments.refund({
orderId,
amount,
idempotencyKey: actionId,
});
await ledger.append({
actionId,
status: "succeeded",
toolName: "payments.refund",
actorType: "service",
actorId: "payment-gateway-client",
causationEventId: startedEvent.eventId,
outputHash: hashOutput(result),
});
} catch (error) {
await ledger.append({
actionId,
status: "failed",
toolName: "payments.refund",
actorType: "service",
actorId: "payment-gateway-client",
causationEventId: startedEvent.eventId,
metadataHash: hashMetadata({
errorClass: error?.constructor?.name,
errorMessage: redact(error?.message),
}),
});
}
This does not give you perfect exactly-once execution across distributed systems. Nothing does. But it gives you a coherent sequence: the system tried, then it succeeded or failed.
Use idempotency keys aggressively. If the process crashes after the external call but before writing succeeded, a reconciliation job can query the external system and append the missing event instead of duplicating the action.
🚨 Production warning: Do not treat “fire and forget” logging as an audit trail. If the ledger write happens after the response is sent, you will eventually lose ledger entries during restarts, queue backlogs, and network partitions. The ledger should be part of the critical path for important actions.
5. Never update an action record; emit the next lifecycle event
A common mistake is to create one row per AI action and update its status:
pending → approved → executing → succeeded
That feels clean. It is also a trap.
When you update the row, you lose the history. When did it become approved? Who approved it? Did it fail first? Was there a retry? Did the agent cancel and reissue the request? Did a compensation event happen later?
The ledger should be event-sourced. Each state transition is a new row.
For one action, the ledger might contain:
ledger_id action_id status
101 8f... requested
102 8f... preview_generated
103 8f... approval_requested
104 8f... approved
105 8f... execution_started
106 8f... failed
107 8f... execution_started
108 8f... succeeded
You can then reconstruct the current state by folding the events:
interface LedgerEvent {
eventId: string;
ledgerId: number;
actionId: string;
status: string;
actorType: string;
actorId: string;
occurredAt: string;
receivedAt: string;
metadata?: Record<string, unknown>;
}
interface ActionState {
status: string;
requestedBy?: string;
approvedBy?: string;
lastFailure?: string;
updatedAt?: string;
}
export function foldActionState(events: LedgerEvent[]): ActionState {
return events.reduce<ActionState>(
(state, event) => {
state.updatedAt = event.receivedAt;
switch (event.status) {
case "requested":
state.status = "requested";
state.requestedBy = event.actorId;
break;
case "approved":
state.status = "approved";
state.approvedBy = event.actorId;
break;
case "rejected":
state.status = "rejected";
break;
case "execution_started":
state.status = "executing";
break;
case "succeeded":
state.status = "succeeded";
state.lastFailure = undefined;
break;
case "failed":
state.status = "failed";
state.lastFailure = event.metadata?.errorMessage as string | undefined;
break;
case "compensated":
state.status = "compensated";
break;
}
return state;
},
{ status: "unknown" }
);
}
The current status is derived, not stored as the only truth.
This also makes debugging easier. You can query all events for an action and see the entire story in order:
SELECT
ledger_id,
event_id,
status,
actor_type,
actor_id,
occurred_at,
received_at
FROM ai_execution_ledger
WHERE action_id = $1
ORDER BY ledger_id;
If you need a convenient “current state” table for dashboards, build it as a projection. But treat the projection as a cache, not the source of truth.
6. Keep secrets and personal data out of the ledger
Immutability creates a privacy problem.
If your ledger stores raw prompts, raw tool inputs, or raw API responses, it will eventually contain things you should not keep forever:
- API keys
- Session tokens
- Customer emails
- Payment details
- Health information
- Internal notes
- Prompt content that includes personal data
This is especially awkward because immutability and privacy law can conflict. If someone requests deletion, you cannot simply delete from an immutable ledger.
The first rule is: do not store raw sensitive payloads unless you really need them.
Store hashes instead:
import { createHmac } from "node:crypto";
export function hmacSha256Hex(key: string, value: unknown): string {
return createHmac("sha256", key)
.update(canonicalJson(value), "utf8")
.digest("hex");
}
For example:
const inputHash = hmacSha256Hex(
process.env.LEDGER_HMAC_KEY!,
{
orderId,
customerId,
amount,
}
);
A plain SHA-256 hash can be enough for high-entropy values, but be careful. If the underlying value is low-entropy, such as a small customer ID, a short order reference, or a known email address, an attacker can brute-force or dictionary-attack the hash. HMAC with a protected server key is usually safer for identifiers and business payloads.
If you need the raw payload for debugging, do not put it directly in the ledger. Use one of these patterns instead.
Pattern A: Store a reference
Store the payload in a separate encrypted store with its own retention policy. The ledger stores only a reference.
{
"payloadStore": "s3://audit-payloads/2026/06/14/action-8f2c.json",
"payloadSha256": "9b1c...",
"encryptionKeyId": "arn:aws:kms:..."
}
The ledger remains small and durable. The payload store can have different access controls and retention rules.
Pattern B: Store encrypted data with crypto-shredding
Encrypt the payload with a unique data key. Store the encrypted payload and the encrypted data key. To “delete” the data, destroy the data key.
ledger row:
payload_ciphertext: ...
payload_key_id: ...
support access:
decrypt payload_key_id with KMS
decrypt payload_ciphertext
deletion request:
destroy payload_key_id
The ciphertext can remain in the ledger, but without the key it is effectively unreadable.
Pattern C: Store only redacted representations
If your support team needs context but not exact values, store a redacted version:
function redactPaymentInput(input: PaymentInput) {
return {
orderId: input.orderId,
amount: input.amount,
customerEmail: maskEmail(input.customerEmail),
cardLast4: input.cardLast4,
cardExpiry: input.cardExpiry,
};
}
The ledger becomes less precise, but it may be safer and more compliant.
💡 Practical note: Decide early what belongs in the ledger and what belongs in a separate evidence store. The ledger should contain enough to prove the sequence and context of actions, but it does not need to become a giant copy of every prompt, response, and customer record.
7. Verify the ledger continuously, not during the incident
A hash chain is useless if nobody checks it.
The verifier’s job is simple:
- Read a range of ledger rows.
- Check that each row’s
previous_hashmatches the previous row’sentry_hash. - Recompute each row’s
entry_hash. - Detect gaps, duplicates, or invalid hashes.
- Alert immediately.
A verifier might look like this:
const GENESIS_HASH = "0".repeat(64);
class LedgerIntegrityError extends Error {}
async function verifyLedgerRange(from: number, to: number): Promise<void> {
let expectedPrevious =
from === 1
? GENESIS_HASH
: await getEntryHashForLedgerId(from - 1);
const rows = await getLedgerRowsInRange(from, to);
for (const row of rows) {
if (row.ledgerId !== from) {
throw new LedgerIntegrityError(
`Gap detected: expected ledger_id ${from}, found ${row.ledgerId}`
);
}
if (row.previousHash !== expectedPrevious) {
throw new LedgerIntegrityError(
`Broken chain at ledger_id ${row.ledgerId}: previous_hash mismatch`
);
}
const recomputed = computeLedgerEntryHash(row);
if (recomputed !== row.entryHash) {
throw new LedgerIntegrityError(
`Invalid entry_hash at ledger_id ${row.ledgerId}`
);
}
expectedPrevious = row.entryHash;
from += 1;
}
}
Run this continuously, not quarterly.
A good production setup verifies:
- The latest batch every few minutes.
- A random historical sample every hour.
- A full scan weekly or monthly, depending on ledger size.
- Every checkpoint before it is anchored externally.
The verifier should be read-only. It should not have permission to insert or modify ledger rows. It should also send alerts to a channel that humans actually monitor.
A broken ledger chain is not a normal warning. It means one of several things:
- A bug corrupted the data.
- Someone manually modified the ledger.
- A database restore happened incorrectly.
- A replication or failover process broke ordering.
- Your hash canonicalization changed without a version bump.
The last one is common. If you change the hashing format, bump schema_version and support both versions during verification.
Do not silently “fix” a broken chain by rewriting hashes. That destroys the whole point of the ledger.
8. Anchor trust outside the primary database
A hash chain inside your database proves internal consistency, but it does not fully protect against someone with enough database access rewriting history and recomputing the hashes.
To strengthen the ledger, periodically anchor it outside the primary database.
The basic idea:
- Choose a checkpoint, such as every 10,000 ledger entries or every hour.
- Compute the latest verified
entry_hash, or a Merkle root over the batch. - Sign the checkpoint.
- Store it in an external immutable location.
- Record the anchor reference back in your system.
A checkpoint object could look like this:
{
"schemaVersion": 1,
"checkpointId": "01972b6e-8f3c-7a41-9c5e-3f5f8b2a9d11",
"createdAt": "2026-06-14T12:00:00Z",
"lastLedgerId": 1234567,
"lastEntryHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"batchMerkleRoot": "2c624232cdd221771294dfbb310aca000a0df6ac8b66b696d94ef0e6f4d3f4f8",
"verifierVersion": "ledger-verifier/1.4.2"
}
Then store that object in an immutable external system.
Good candidates include:
- Object storage with WORM retention, such as S3 Object Lock in compliance mode.
- GCS buckets with retention policy and bucket lock.
- Azure immutable blob storage with time-based retention or legal hold.
- A separate audit database with stricter access controls.
- A cryptographic timestamping authority.
- A transparency log or public verification system, if public verifiability is acceptable.
The right choice depends on your compliance needs.
For most applications, an external WORM checkpoint is a strong improvement. It gives you a point in time you can refer back to. If someone rewrites the primary ledger before the checkpoint, the external anchor will not match. If they rewrite after the checkpoint, the chain will diverge from the anchored history.
This is also where you can separate duties.
The service that writes ledger entries should not have permission to delete external anchors.
The service that verifies anchors should not have permission to write ledger entries.
The humans who can access production databases should not be the same humans who can delete compliance anchors.
That separation is not perfect, but it raises the cost of tampering significantly.
9. Choose the storage backend based on your threat model
There is no single correct storage system for an immutable execution ledger. The best choice depends on write volume, compliance pressure, query needs, and how much operational complexity you can absorb.
Here is how I think about the common options.
| Approach | Strength | Limitation | Best when |
|---|---|---|---|
| PostgreSQL append-only table with hash chain | Simple, SQL queryable, transactional with app data | Database administrators may still have power; global chain can bottleneck | Most product teams starting out |
| Append-only object storage checkpoints | Strong durability, external trust anchor | Not convenient for fine-grained queries | Archival, compliance anchoring |
| Kafka or similar log system | High throughput, replayable streams | Not a cryptographic ledger by itself; retention and compaction require care | High-volume event pipelines |
| Dedicated event store | Built for event-sourced systems | Extra infrastructure and operational cost | Teams already using event sourcing |
| Specialized ledger database or managed ledger service | Stronger tamper-evidence as a product feature | Vendor lock-in, cost, integration complexity | Regulated or high-assurance environments |
| Public blockchain or transparency log | Public verifiability | Cost, latency, confidentiality concerns | Public trust or notarization use cases |
For a typical SaaS product adding AI features, I would not start with a blockchain. I would start with an append-only PostgreSQL ledger, hash chaining, transactional writes, and periodic WORM checkpoints.
That gives you most of the operational benefit without turning your audit system into a distributed systems research project.
If you have high write volume, avoid one global hash chain. Use multiple streams:
ledger_stream: tenant_123
ledger_stream: agent_session_abc
ledger_stream: payments
ledger_stream: customer_support
Each stream can have its own chain. Then you batch stream roots into checkpoints.
This gives you better write concurrency and makes verification easier. A corrupt stream does not necessarily invalidate every other stream.
If your AI actions are low volume but high consequence, such as financial transfers, medical workflow changes, or legal document generation, you can afford stronger controls:
- Separate ledger writer service.
- Hardware-backed key management.
- External timestamping.
- Manual approval for critical actions.
- Read replicas dedicated to auditors.
- Crypto-shredding for personal data.
- Formal retention policies.
The important thing is to match the design to the actual risk.
A ledger for “summarize this support ticket” does not need the same controls as a ledger for “initiate refund to customer bank account.”
A practical rollout checklist
If you are adding this to an existing AI agent system, I would roll it out in this order.
Phase 1: Stop the bleeding
- Define a structured
ai_execution_ledgertable. - Add
action_id,tool_name,status,actor_id, and timestamps. - Block updates and deletes at the database role level.
- Add a trigger preventing mutations.
- Write one ledger event for every tool call that changes state.
Phase 2: Make it trustworthy
- Add
previous_hashandentry_hash. - Implement canonical serialization.
- Add
schema_version. - Store input and output hashes, not necessarily raw payloads.
- Add an idempotency key to prevent duplicate events.
Phase 3: Make it consistent
- Write ledger events in the same transaction as local database mutations.
- Use the outbox pattern for downstream publishing.
- For external APIs, append
execution_started, thensucceededorfailed. - Reconcile missing events using external idempotency keys.
Phase 4: Make it verifiable
- Build a verifier job.
- Verify recent entries continuously.
- Verify historical ranges periodically.
- Alert on broken chains, gaps, and hash mismatches.
- Add dashboards for failed, rejected, and compensated actions.
Phase 5: Make it durable and compliant
- Add external checkpoints.
- Store checkpoints in WORM storage or another independent immutable system.
- Encrypt sensitive payloads separately.
- Implement crypto-shredding for personal data.
- Define retention and legal hold procedures.
What I would choose
For most teams, the pragmatic answer is not a exotic distributed ledger. It is a boring, carefully protected append-only table with cryptographic linking.
I would use:
- PostgreSQL or another transactional database for the ledger.
- Append-only roles and triggers.
- A hash chain per stream, not one global chain.
- HMAC-SHA256 for input and output digests when values are sensitive.
- Transactional writes for local mutations.
- Event-sourced lifecycle states.
- A continuous verifier.
- External WORM checkpoints.
- Separate encrypted payload storage when raw data is needed.
That combination is implementable, queryable, and strong enough for many production AI systems.
The deeper lesson is that AI agents do not merely need observability. They need accountability infrastructure.
A trace is not just a debugging aid. It is the thing that lets you answer, weeks later, what happened, who authorized it, what changed, and whether the record can still be trusted.
If your AI agent can act, make sure the action leaves a trace. And make sure that trace cannot quietly disappear.