Every team deploying AI agents into production says the same thing when asked about accountability: "We have audit logs."
It sounds complete. It sounds responsible. And in most cases, it is not actually an answer to the question being asked.
Audit logs and Decision Provenance Records are not two names for the same thing. They answer different questions, they are produced at different moments, and when something goes wrong, only one of them gives you what you actually need. This post explains the difference, why it matters, and what a real authorization record looks like.
The question your logs cannot answer
When an AI agent takes an action in a production system, there are two distinct questions you might want to ask afterward.
The first is: what happened? What did the agent do, when, with what parameters, and what was the result? This is the question your logs answer. Observability platforms, tracing tools, and database change logs all answer this question well. If you have good instrumentation, you can reconstruct a complete picture of every action the agent took.
The second question is: why was it allowed? Under what policy? Based on what state at decision time? Was that policy the correct one? Would the action be allowed under the policy you have today?
This is the question your logs cannot answer. Not because your logging is incomplete. Because logs are produced after execution, and authorization decisions are made before it. These are different events. Recording one does not record the other.
Most teams have the left column. Almost no teams have the right column. And when something goes wrong, when an agent does something it shouldn't, or when an auditor asks why it did something it did. the left column alone leaves you unable to answer the question that matters.
What a log actually contains
A well-instrumented agent deployment produces something like this when an action executes:
{
"timestamp": "2026-02-21T14:32:07Z",
"service": "agent-runtime",
"agent_id": "agent-prod-7f3k",
"action": "stripe.refund",
"params": {
"amount": 450.00,
"customer_id": "cus_8f3k2",
"order_id": "ORD-4421"
},
"result": "success",
"duration_ms": 312,
"trace_id": "t_9e1c..."
}{
"timestamp": "2026-02-21T14:32:07Z",
"service": "agent-runtime",
"agent_id": "agent-prod-7f3k",
"action": "stripe.refund",
"params": {
"amount": 450.00,
"customer_id": "cus_8f3k2",
"order_id": "ORD-4421"
},
"result": "success",
"duration_ms": 312,
"trace_id": "t_9e1c..."
}{
"timestamp": "2026-02-21T14:32:07Z",
"service": "agent-runtime",
"agent_id": "agent-prod-7f3k",
"action": "stripe.refund",
"params": {
"amount": 450.00,
"customer_id": "cus_8f3k2",
"order_id": "ORD-4421"
},
"result": "success",
"duration_ms": 312,
"trace_id": "t_9e1c..."
}
This is useful. You know what ran, when it ran, with what parameters, and whether it succeeded. If the refund was wrong, you can find this record and confirm it happened.
What this record does not contain:
Which policy was evaluated before this action ran
What the policy said about this action class
What the system's state was at evaluation time
Whether any policy was evaluated at all
What decision was made and why
Whether the policy in effect then is the same as the policy in effect now
Whether this action would be permitted under your current policy
If an auditor asks "was this refund authorized?" your log says: it happened. That is not authorization evidence. That is execution evidence. The distinction matters enormously in regulated environments, and it is starting to matter in unregulated ones too.
What a Decision Provenance Record contains
A DPR is produced at the moment of authorization evaluation, before execution. It records the inputs to the decision and the decision itself, in a form that makes the decision deterministically replayable.
{
"provenance_id": "dpr_c7d8f2...",
"seq": 1847,
"namespace": "tenant-acme-prod",
"request_hash": "sha256:a4f2e9...",
"policy_hash": "sha256:9e1c3b...",
"profile_hash": "sha256:7b4a1d...",
"state_hash": "sha256:f2c8a3...",
"decision": "PERMIT",
"created_at": "2026-02-21T14:32:06Z",
"prev_hash": "sha256:3d9f1c...",
"record_hash": "sha256:8a2e4f..."
}{
"provenance_id": "dpr_c7d8f2...",
"seq": 1847,
"namespace": "tenant-acme-prod",
"request_hash": "sha256:a4f2e9...",
"policy_hash": "sha256:9e1c3b...",
"profile_hash": "sha256:7b4a1d...",
"state_hash": "sha256:f2c8a3...",
"decision": "PERMIT",
"created_at": "2026-02-21T14:32:06Z",
"prev_hash": "sha256:3d9f1c...",
"record_hash": "sha256:8a2e4f..."
}{
"provenance_id": "dpr_c7d8f2...",
"seq": 1847,
"namespace": "tenant-acme-prod",
"request_hash": "sha256:a4f2e9...",
"policy_hash": "sha256:9e1c3b...",
"profile_hash": "sha256:7b4a1d...",
"state_hash": "sha256:f2c8a3...",
"decision": "PERMIT",
"created_at": "2026-02-21T14:32:06Z",
"prev_hash": "sha256:3d9f1c...",
"record_hash": "sha256:8a2e4f..."
}
Each field has a specific role:
The hash chain is what makes this tamper-evident rather than just tamper-apparent. Each record includes the hash of the previous record. If any record is modified, its hash changes. The next record's prev_hash no longer matches. The break propagates forward and is detectable anywhere in the chain.
The replay capability
The most important property of a DPR is not that it records what was decided. It is that it makes the decision replayable.
Because the DPR binds the exact canonical action hash, the exact policy hash, and the exact state hash, you can reconstruct the decision that was made, and re-evaluate it under any policy version you choose, without re-executing any tool and without re-running any agent reasoning.
def replay_decision(dpr_record, policy_override=None, state_override=None):
action = resolve_action(dpr_record["request_hash"])
if policy_override:
policy = parse_policy(policy_override)
else:
policy = resolve_policy(dpr_record["policy_hash"])
if state_override:
state = state_override
else:
state = resolve_state(dpr_record["state_hash"])
return evaluate(action, policy, state)def replay_decision(dpr_record, policy_override=None, state_override=None):
action = resolve_action(dpr_record["request_hash"])
if policy_override:
policy = parse_policy(policy_override)
else:
policy = resolve_policy(dpr_record["policy_hash"])
if state_override:
state = state_override
else:
state = resolve_state(dpr_record["state_hash"])
return evaluate(action, policy, state)def replay_decision(dpr_record, policy_override=None, state_override=None):
action = resolve_action(dpr_record["request_hash"])
if policy_override:
policy = parse_policy(policy_override)
else:
policy = resolve_policy(dpr_record["policy_hash"])
if state_override:
state = state_override
else:
state = resolve_state(dpr_record["state_hash"])
return evaluate(action, policy, state)
This gives you three forensic capabilities that logs alone cannot provide:
1. Confirm what was decided. Re-evaluate the original decision from its recorded inputs. If the result differs from the recorded decision, something is wrong, either the record was tampered with or the evaluation engine has a bug.
2. Counterfactual analysis. Re-evaluate the same action under your current policy. Would this action be permitted today? If not, when did the policy change that would have blocked it?
3. Policy audit trail. For any action in the DPR log, determine which policy version was in effect and whether that policy version was the correct one for that time and environment.

Side by side: log vs DPR during an incident
Here is what an incident investigation looks like in practice with each approach.
Scenario: An AI agent processed a bulk refund affecting 847 customers. The total was $223,000. Nobody authorized it intentionally. The investigation starts 48 hours later.
With logs only:
With DPR records:
dprs = query_dprs(
namespace="tenant-acme-prod",
tool="stripe.refund",
time_range=("2026-02-19", "2026-02-21")
)
for dpr in dprs:
action = resolve_action(dpr.request_hash)
policy = resolve_policy(dpr.policy_hash)
state = resolve_state(dpr.state_hash)
print(f"""
Action: stripe.refund(amount={action.params.amount},
customer={action.params.customer_id})
Policy: v1.2.1, rule: "bulk_refund_allow" (MISCONFIGURED)
State: account_status=active, risk_level=low
Decision: PERMIT
Chain: intact (seq 1841 through 2687)
""")
for dpr in dprs:
result = replay_decision(dpr, policy_override=current_policy)
if result.decision != "PERMIT":
print(f"seq {dpr.seq}: would be DENIED under current policy")
print(f" Rule that fires: {result.matched_rule}")
Conclusion: "Policy v1.2.1 was missing a bulk refund rule.
The agent correctly evaluated policy and received PERMIT.
The policy was the failure point. Here is the exact rule
that was absent and when
dprs = query_dprs(
namespace="tenant-acme-prod",
tool="stripe.refund",
time_range=("2026-02-19", "2026-02-21")
)
for dpr in dprs:
action = resolve_action(dpr.request_hash)
policy = resolve_policy(dpr.policy_hash)
state = resolve_state(dpr.state_hash)
print(f"""
Action: stripe.refund(amount={action.params.amount},
customer={action.params.customer_id})
Policy: v1.2.1, rule: "bulk_refund_allow" (MISCONFIGURED)
State: account_status=active, risk_level=low
Decision: PERMIT
Chain: intact (seq 1841 through 2687)
""")
for dpr in dprs:
result = replay_decision(dpr, policy_override=current_policy)
if result.decision != "PERMIT":
print(f"seq {dpr.seq}: would be DENIED under current policy")
print(f" Rule that fires: {result.matched_rule}")
Conclusion: "Policy v1.2.1 was missing a bulk refund rule.
The agent correctly evaluated policy and received PERMIT.
The policy was the failure point. Here is the exact rule
that was absent and when
dprs = query_dprs(
namespace="tenant-acme-prod",
tool="stripe.refund",
time_range=("2026-02-19", "2026-02-21")
)
for dpr in dprs:
action = resolve_action(dpr.request_hash)
policy = resolve_policy(dpr.policy_hash)
state = resolve_state(dpr.state_hash)
print(f"""
Action: stripe.refund(amount={action.params.amount},
customer={action.params.customer_id})
Policy: v1.2.1, rule: "bulk_refund_allow" (MISCONFIGURED)
State: account_status=active, risk_level=low
Decision: PERMIT
Chain: intact (seq 1841 through 2687)
""")
for dpr in dprs:
result = replay_decision(dpr, policy_override=current_policy)
if result.decision != "PERMIT":
print(f"seq {dpr.seq}: would be DENIED under current policy")
print(f" Rule that fires: {result.matched_rule}")
Conclusion: "Policy v1.2.1 was missing a bulk refund rule.
The agent correctly evaluated policy and received PERMIT.
The policy was the failure point. Here is the exact rule
that was absent and when
The difference is not just more information. It is a different category of answer. Logs tell you what the agent did. DPRs tell you whether the system was operating correctly when it allowed the agent to do it.
The "what happened" vs "why it was allowed" gap in compliance
This distinction is not academic. It is directly relevant to the compliance questions that regulated industries are starting to ask about AI agents.
SOC 2 Type II auditors ask about change management, access control, and the evidence trail supporting authorization decisions. "We have logs" answers the first part of access control, you know what was accessed. It does not answer the authorization part, you cannot demonstrate that access was individually authorized under a specific policy at a specific time.
HIPAA requires covered entities to maintain records demonstrating that access to protected health information was authorized. A log showing that an agent accessed a patient record is not evidence of authorization. A DPR showing that the access was evaluated against a specific policy and permitted for a documented reason is.
SOX requires that financial controls operate as designed and that evidence of their operation is maintained. For AI agents that touch financial data or initiate financial transactions, the question is not just whether a transaction occurred. it is whether the authorization control worked as intended for that specific transaction.
Why most teams don't have this
The honest reason is that building a DPR system correctly is hard in ways that are not obvious at first.
The first challenge is canonicalization. The DPR binds to the hash of the canonical action. If two semantically identical actions produce different canonical representations, they produce different hashes and the DPR cannot be correlated with the action it authorized. Canonicalization must be deterministic, complete, and stable across framework versions and runtime environments.
action_1 = {
"tool": "stripe",
"operation": "refund",
"params": {"amount": 450.0, "customer_id": "cus_8f3k2"}
}
action_2 = {
"tool": "stripe",
"operation": "refund",
"params": {"customer_id": "cus_8f3k2", "amount": 450.0}
}
hash(json.dumps(action_1)) != hash(json.dumps(action_2))
hash(canonical(action_1)) == hash(canonical(action_2))
action_1 = {
"tool": "stripe",
"operation": "refund",
"params": {"amount": 450.0, "customer_id": "cus_8f3k2"}
}
action_2 = {
"tool": "stripe",
"operation": "refund",
"params": {"customer_id": "cus_8f3k2", "amount": 450.0}
}
hash(json.dumps(action_1)) != hash(json.dumps(action_2))
hash(canonical(action_1)) == hash(canonical(action_2))
action_1 = {
"tool": "stripe",
"operation": "refund",
"params": {"amount": 450.0, "customer_id": "cus_8f3k2"}
}
action_2 = {
"tool": "stripe",
"operation": "refund",
"params": {"customer_id": "cus_8f3k2", "amount": 450.0}
}
hash(json.dumps(action_1)) != hash(json.dumps(action_2))
hash(canonical(action_1)) == hash(canonical(action_2))
The second challenge is state capture. The DPR's state_hash only has forensic value if the evaluation-relevant state is actually captured and stored. If policy decisions depend on account status, risk scores, user tier, or session context, those values must be snapshotted at evaluation time and retrievable later. State that is not captured cannot be replayed.
The third challenge is immutability. A DPR chain that can be modified after the fact is not a DPR chain. It is a mutable log with extra steps. The hash chaining only provides tamper-evidence if the chain is actually append-only and the chain head is externally anchored.
These are solvable problems. But they require treating authorization as a first-class concern rather than something added later.
What Faramesh produces per decision
Every action that passes through Faramesh's authorization boundary produces an exact DPR conforming to the specification. Here is the full structure:
{
"provenance_id": "dpr_c7d8f2a1...",
"seq": 1847,
"namespace": "acme-corp-production",
"request_hash": "sha256:a4f2e9b3c1d8f7a2e5b9c4d1f8a3e6b2",
"policy_hash": "sha256:9e1c3b7a2f5d8c4e1b9a3f7d2c5e8b1",
"profile_hash": "sha256:7b4a1d9c3f2e5b8a4d1c7f3e2a5b9d1",
"state_hash": "sha256:f2c8a3e1b5d9f4c7a2e8b3d1f5c9a2e",
"decision": "PERMIT",
"created_at": "2026-02-21T14:32:06.847Z",
"prev_hash": "sha256:3d9f1c8a2e5b7d4f1c9a3e6b2d8f5c1",
"record_hash": "sha256:8a2e4f1c7b3d9a5e2f8b1c4d7f3a9e2"
}{
"provenance_id": "dpr_c7d8f2a1...",
"seq": 1847,
"namespace": "acme-corp-production",
"request_hash": "sha256:a4f2e9b3c1d8f7a2e5b9c4d1f8a3e6b2",
"policy_hash": "sha256:9e1c3b7a2f5d8c4e1b9a3f7d2c5e8b1",
"profile_hash": "sha256:7b4a1d9c3f2e5b8a4d1c7f3e2a5b9d1",
"state_hash": "sha256:f2c8a3e1b5d9f4c7a2e8b3d1f5c9a2e",
"decision": "PERMIT",
"created_at": "2026-02-21T14:32:06.847Z",
"prev_hash": "sha256:3d9f1c8a2e5b7d4f1c9a3e6b2d8f5c1",
"record_hash": "sha256:8a2e4f1c7b3d9a5e2f8b1c4d7f3a9e2"
}{
"provenance_id": "dpr_c7d8f2a1...",
"seq": 1847,
"namespace": "acme-corp-production",
"request_hash": "sha256:a4f2e9b3c1d8f7a2e5b9c4d1f8a3e6b2",
"policy_hash": "sha256:9e1c3b7a2f5d8c4e1b9a3f7d2c5e8b1",
"profile_hash": "sha256:7b4a1d9c3f2e5b8a4d1c7f3e2a5b9d1",
"state_hash": "sha256:f2c8a3e1b5d9f4c7a2e8b3d1f5c9a2e",
"decision": "PERMIT",
"created_at": "2026-02-21T14:32:06.847Z",
"prev_hash": "sha256:3d9f1c8a2e5b7d4f1c9a3e6b2d8f5c1",
"record_hash": "sha256:8a2e4f1c7b3d9a5e2f8b1c4d7f3a9e2"
}
Each hash is independently resolvable. The action can be recovered from request_hash. The policy bytes can be recovered from policy_hash. The state snapshot can be recovered from state_hash. Given these three, any decision in the log can be replayed deterministically.

The chain in practice
The DPR chain is not a collection of independent records. It is a linked structure where the integrity of any record depends on all preceding records. Here is what verification looks like:
def verify_chain(dprs: list[DPR]) -> VerificationResult:
prev_hash = "0" * 64
for i, dpr in enumerate(dprs):
if dpr.prev_hash != prev_hash:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
expected_prev=prev_hash,
found_prev=dpr.prev_hash,
error="chain break detected -- record may have been tampered with"
)
computed = sha256(canonical_json({
"seq": dpr.seq,
"namespace": dpr.namespace,
"request_hash": dpr.request_hash,
"policy_hash": dpr.policy_hash,
"profile_hash": dpr.profile_hash,
"state_hash": dpr.state_hash,
"decision": dpr.decision,
"created_at": dpr.created_at,
"prev_hash": dpr.prev_hash
}))
if computed != dpr.record_hash:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
error="record hash mismatch -- record content has been altered"
)
if i > 0 and dpr.seq != dprs[i-1].seq + 1:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
error=f"sequence gap detected between {dprs[i-1].seq} and {dpr.seq}"
)
prev_hash = dpr.record_hash
return VerificationResult(valid=True, records_verified=len(dprs))def verify_chain(dprs: list[DPR]) -> VerificationResult:
prev_hash = "0" * 64
for i, dpr in enumerate(dprs):
if dpr.prev_hash != prev_hash:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
expected_prev=prev_hash,
found_prev=dpr.prev_hash,
error="chain break detected -- record may have been tampered with"
)
computed = sha256(canonical_json({
"seq": dpr.seq,
"namespace": dpr.namespace,
"request_hash": dpr.request_hash,
"policy_hash": dpr.policy_hash,
"profile_hash": dpr.profile_hash,
"state_hash": dpr.state_hash,
"decision": dpr.decision,
"created_at": dpr.created_at,
"prev_hash": dpr.prev_hash
}))
if computed != dpr.record_hash:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
error="record hash mismatch -- record content has been altered"
)
if i > 0 and dpr.seq != dprs[i-1].seq + 1:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
error=f"sequence gap detected between {dprs[i-1].seq} and {dpr.seq}"
)
prev_hash = dpr.record_hash
return VerificationResult(valid=True, records_verified=len(dprs))def verify_chain(dprs: list[DPR]) -> VerificationResult:
prev_hash = "0" * 64
for i, dpr in enumerate(dprs):
if dpr.prev_hash != prev_hash:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
expected_prev=prev_hash,
found_prev=dpr.prev_hash,
error="chain break detected -- record may have been tampered with"
)
computed = sha256(canonical_json({
"seq": dpr.seq,
"namespace": dpr.namespace,
"request_hash": dpr.request_hash,
"policy_hash": dpr.policy_hash,
"profile_hash": dpr.profile_hash,
"state_hash": dpr.state_hash,
"decision": dpr.decision,
"created_at": dpr.created_at,
"prev_hash": dpr.prev_hash
}))
if computed != dpr.record_hash:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
error="record hash mismatch -- record content has been altered"
)
if i > 0 and dpr.seq != dprs[i-1].seq + 1:
return VerificationResult(
valid=False,
break_at_seq=dpr.seq,
error=f"sequence gap detected between {dprs[i-1].seq} and {dpr.seq}"
)
prev_hash = dpr.record_hash
return VerificationResult(valid=True, records_verified=len(dprs))
A sequence gap means records were deleted. A hash mismatch means a record was modified. A prev_hash mismatch means the chain was restructured. All three are detectable. None can be fixed without being detected.
Logs are necessary. They are not sufficient.
This post is not an argument against logs. Logs are necessary. Execution logs, traces, and observability data are essential for understanding what an agent did, debugging failures, and measuring performance. None of that changes.
The argument is about what logs do not provide. They do not record authorization decisions. They do not bind actions to the policies that permitted them. They do not enable replay or counterfactual analysis. They do not produce tamper-evident evidence that authorization controls operated correctly for specific actions.
For AI agents that take real actions in real systems, the question "why was this allowed?" is not optional. It gets asked after incidents. It gets asked by auditors. It gets asked by the engineering team trying to understand whether a policy failure or a bypass caused a problem.
If your answer is "we have logs," you have half of what you need. The other half is a record of the authorization decision that preceded each action. That record is the DPR.