On February 17, 2026, a U.S. federal court held that documents created with consumer AI tools have no legal privilege. The ruling exposes a structural gap: AI logs can be used as evidence against you, but nobody can verify what those logs contain — or what's missing. This tutorial shows you how to build tamper-evident AI audit trails using the open-source CAP-SRP specification.
What happened in U.S. v. Heppner
On February 17, 2026, Judge Jed Rakoff of the Southern District of New York issued a written opinion in United States v. Heppner (No. 25-cr-00503-JSR) — a securities fraud case where the defendant had used the consumer version of Anthropic's Claude to draft defense strategies.
The court's holding: 31 AI-generated documents are not protected by attorney-client privilege or work product doctrine.
Three reasons:
- Claude isn't a lawyer. Privilege requires a "trusting human relationship" with a licensed professional who owes fiduciary duties. An AI tool meets neither condition.
- No reasonable expectation of confidentiality. Anthropic's privacy policy permits use of inputs for model training (unless opted out) and disclosure to "governmental regulatory authorities as required by law."
- No attorney direction. The defendant acted on his own, not under counsel's instruction.
Multiple law firms have published analyses: Debevoise, Morrison & Foerster, Proskauer Rose, Chapman and Cutler, Jones Walker, and many others.
Key nuance for developers: Anthropic's consumer privacy policy (Free, Pro, Max plans) is what the court cited. The policy explicitly does not apply to Claude for Work, Government, Education, or API use. Enterprise tiers have different data handling terms.
The structural problem this reveals
Here's what matters for builders of AI systems:
AI-generated content is now discoverable evidence — but the logs behind it are a black box.
In Heppner, the prosecution obtained AI-generated documents from the defendant's devices. But neither side could independently verify:
- What prompts were sent to Claude
- What Claude refused to generate (if anything)
- Whether Anthropic's internal logs are complete and untampered
- What data Anthropic retained, discarded, or modified
The court relied on Anthropic's privacy policy representations — not on any independently verifiable audit trail. This is the "trust-us log" problem: AI providers unilaterally control what gets logged, how long it's retained, and what gets disclosed.
This asymmetry has a name in the AI governance space: the negative evidence problem.
C2PA (200+ members including Adobe, Microsoft, Google) can prove: "This content was generated by this system at this time."
Nothing currently deployed can prove: "This system refused to generate this content at this time."
CAP-SRP: a specification for the missing layer
CAP-SRP (Content/Creative AI Profile – Safe Refusal Provenance) is an open specification (CC BY 4.0) that provides cryptographic audit trails for AI content generation — including verifiable proof of refusals.
Honest disclosure: CAP-SRP is v1.0, published January 28, 2026, maintained by the VeritasChain Standards Organization. It has an IETF Internet-Draft but is not yet an adopted standard. No major AI company has implemented it. I'm sharing it because the technical approach is sound and the problem is real — not because it's an established industry standard.
The core idea:
GEN_ATTEMPT = GEN + GEN_DENY + GEN_ERROR
Every generation request MUST produce exactly one cryptographically recorded outcome: generated, denied, or error. The request is logged before the safety evaluation runs, so you can't retroactively erase attempts.
Let's build it.
Implementation: A complete CAP-SRP audit trail in Python
Setup
pip install cryptography
Step 1: Key management
Every event in a CAP-SRP chain is signed with Ed25519. In production you'd use an HSM; here we generate a key pair:
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
import base64
# Generate signing key pair
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
# Export public key for verification
pub_bytes = public_key.public_bytes_raw()
print(f"Public key: {base64.b64encode(pub_bytes).decode()}")
Step 2: Event creation with hash chains
Each event links to the previous one via SHA-256, forming a tamper-evident chain. If any event is modified or deleted, the chain breaks:
import hashlib
import json
import uuid
from datetime import datetime, timezone
def uuid7() -> str:
"""Generate a UUIDv7 (time-ordered) identifier."""
# Simplified UUIDv7: timestamp prefix ensures ordering
ts_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
rand_bits = uuid.uuid4().int & ((1 << 62) - 1)
u = (ts_ms << 80) | (0x7 << 76) | (rand_bits & ((1 << 76) - 1))
return str(uuid.UUID(int=u))
def canonicalize(obj: dict) -> bytes:
"""RFC 8785 JSON Canonicalization Scheme (simplified).
Deterministic JSON serialization ensures the same event
always produces the same hash, regardless of key order.
"""
return json.dumps(
obj,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
def compute_event_hash(event: dict) -> str:
"""SHA-256 hash of the event, excluding the Signature field."""
hashable = {k: v for k, v in event.items() if k != "Signature"}
digest = hashlib.sha256(canonicalize(hashable)).hexdigest()
return f"sha256:{digest}"
def sign_event(event: dict, key: Ed25519PrivateKey) -> dict:
"""Compute hash, sign it, and return the complete event."""
event["EventHash"] = compute_event_hash(event)
hash_bytes = bytes.fromhex(event["EventHash"][7:]) # strip "sha256:"
sig = key.sign(hash_bytes)
event["Signature"] = f"ed25519:{base64.b64encode(sig).decode()}"
return event
Step 3: The three event types
CAP-SRP defines a strict lifecycle: every request starts as a GEN_ATTEMPT, then resolves to exactly one of GEN, GEN_DENY, or GEN_ERROR.
def create_gen_attempt(
chain_id: str,
prev_hash: str | None,
prompt_text: str,
actor_id: str,
model_version: str,
policy_id: str,
session_id: str,
) -> dict:
"""Record that a generation request was received.
CRITICAL: This event is created BEFORE the safety filter runs.
Once committed to the chain, the platform cannot silently drop the request.
"""
# Privacy-preserving: store hashes, not raw content
salt_prompt = uuid.uuid4().hex
salt_actor = uuid.uuid4().hex
prompt_hash = hashlib.sha256(
(salt_prompt + prompt_text).encode()
).hexdigest()
actor_hash = hashlib.sha256(
(salt_actor + actor_id).encode()
).hexdigest()
return {
"EventID": uuid7(),
"ChainID": chain_id,
"PrevHash": prev_hash,
"Timestamp": datetime.now(timezone.utc).isoformat(),
"EventType": "GEN_ATTEMPT",
"PromptHash": f"sha256:{prompt_hash}",
"InputType": "text",
"PolicyID": policy_id,
"ModelVersion": model_version,
"SessionID": session_id,
"ActorHash": f"sha256:{actor_hash}",
"HashAlgo": "SHA256",
"SignAlgo": "ED25519",
}
def create_gen_deny(
chain_id: str,
prev_hash: str,
attempt_id: str,
risk_category: str,
risk_score: float,
policy_id: str,
refusal_reason: str,
) -> dict:
"""Record that a generation request was REFUSED.
This is the SRP (Safe Refusal Provenance) extension —
cryptographic proof that the system said "no."
"""
return {
"EventID": uuid7(),
"ChainID": chain_id,
"PrevHash": prev_hash,
"Timestamp": datetime.now(timezone.utc).isoformat(),
"EventType": "GEN_DENY",
"AttemptID": attempt_id,
"RiskCategory": risk_category,
"RiskScore": risk_score,
"PolicyID": policy_id,
"PolicyVersion": "1.0.0",
"ModelDecision": "DENY",
"RefusalReason": refusal_reason,
"HumanOverride": False,
"EscalationID": None,
"HashAlgo": "SHA256",
"SignAlgo": "ED25519",
}
def create_gen(
chain_id: str,
prev_hash: str,
attempt_id: str,
output_hash: str,
policy_id: str,
) -> dict:
"""Record that content was successfully generated."""
return {
"EventID": uuid7(),
"ChainID": chain_id,
"PrevHash": prev_hash,
"Timestamp": datetime.now(timezone.utc).isoformat(),
"EventType": "GEN",
"AttemptID": attempt_id,
"OutputHash": f"sha256:{output_hash}",
"PolicyID": policy_id,
"HashAlgo": "SHA256",
"SignAlgo": "ED25519",
}
def create_gen_error(
chain_id: str,
prev_hash: str,
attempt_id: str,
error_code: str,
) -> dict:
"""Record that a generation attempt failed due to system error."""
return {
"EventID": uuid7(),
"ChainID": chain_id,
"PrevHash": prev_hash,
"Timestamp": datetime.now(timezone.utc).isoformat(),
"EventType": "GEN_ERROR",
"AttemptID": attempt_id,
"ErrorCode": error_code,
"HashAlgo": "SHA256",
"SignAlgo": "ED25519",
}
Step 4: Building a chain
Here's a realistic scenario — an image generation service that receives three requests, refuses one, generates one, and encounters an error on one:
import time
chain_id = uuid7()
chain: list[dict] = []
def append_event(event: dict) -> dict:
"""Sign and append an event to the chain."""
signed = sign_event(event, private_key)
chain.append(signed)
return signed
# === Request 1: NCII attempt → DENIED ===
attempt_1 = append_event(create_gen_attempt(
chain_id=chain_id,
prev_hash=None, # Genesis event
prompt_text="Generate nude image of [celebrity name]",
actor_id="user-abc-123",
model_version="img-gen-v3.2",
policy_id="content-safety-v2",
session_id=uuid7(),
))
time.sleep(0.01) # Simulate processing
deny_1 = append_event(create_gen_deny(
chain_id=chain_id,
prev_hash=attempt_1["EventHash"],
attempt_id=attempt_1["EventID"],
risk_category="NCII_RISK",
risk_score=0.98,
policy_id="content-safety-v2",
refusal_reason="Non-consensual intimate imagery detected",
))
# === Request 2: Legitimate request → GENERATED ===
attempt_2 = append_event(create_gen_attempt(
chain_id=chain_id,
prev_hash=deny_1["EventHash"],
prompt_text="A sunset over Mount Fuji in watercolor style",
actor_id="user-xyz-789",
model_version="img-gen-v3.2",
policy_id="content-safety-v2",
session_id=uuid7(),
))
time.sleep(0.01)
output_hash = hashlib.sha256(b"<generated image bytes>").hexdigest()
gen_2 = append_event(create_gen(
chain_id=chain_id,
prev_hash=attempt_2["EventHash"],
attempt_id=attempt_2["EventID"],
output_hash=output_hash,
policy_id="content-safety-v2",
))
# === Request 3: System error ===
attempt_3 = append_event(create_gen_attempt(
chain_id=chain_id,
prev_hash=gen_2["EventHash"],
prompt_text="A portrait in oil painting style",
actor_id="user-xyz-789",
model_version="img-gen-v3.2",
policy_id="content-safety-v2",
session_id=uuid7(),
))
time.sleep(0.01)
error_3 = append_event(create_gen_error(
chain_id=chain_id,
prev_hash=attempt_3["EventHash"],
attempt_id=attempt_3["EventID"],
error_code="GPU_OOM",
))
print(f"Chain built: {len(chain)} events")
print(f" GEN_ATTEMPT: {sum(1 for e in chain if e['EventType'] == 'GEN_ATTEMPT')}")
print(f" GEN: {sum(1 for e in chain if e['EventType'] == 'GEN')}")
print(f" GEN_DENY: {sum(1 for e in chain if e['EventType'] == 'GEN_DENY')}")
print(f" GEN_ERROR: {sum(1 for e in chain if e['EventType'] == 'GEN_ERROR')}")
Output:
Chain built: 6 events
GEN_ATTEMPT: 3
GEN: 1
GEN_DENY: 1
GEN_ERROR: 1
Step 5: Verification — the Completeness Invariant
This is the core of CAP-SRP. Any third party (regulator, auditor, court) can run this verification without trusting the AI provider:
from dataclasses import dataclass
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
@dataclass
class VerificationResult:
chain_valid: bool
completeness_valid: bool
total_attempts: int
total_gen: int
total_deny: int
total_error: int
unmatched_attempts: list[str]
orphan_outcomes: list[str]
errors: list[str]
def verify_signature(event: dict, pub_key: Ed25519PublicKey) -> bool:
"""Verify Ed25519 signature on an event."""
sig_b64 = event["Signature"][8:] # strip "ed25519:"
sig_bytes = base64.b64decode(sig_b64)
hash_bytes = bytes.fromhex(event["EventHash"][7:]) # strip "sha256:"
try:
pub_key.verify(sig_bytes, hash_bytes)
return True
except InvalidSignature:
return False
def verify_chain_and_completeness(
events: list[dict],
pub_key: Ed25519PublicKey,
) -> VerificationResult:
"""Full CAP-SRP verification: chain integrity + completeness invariant.
This function can be run by ANY third party with the public key.
No trust in the AI provider is required.
"""
errors = []
# --- Phase 1: Chain integrity ---
for i, event in enumerate(events):
# 1a. Verify hash is correctly computed
computed = compute_event_hash(event)
if event["EventHash"] != computed:
errors.append(f"Event {i}: hash mismatch")
# 1b. Verify chain linkage
if i == 0:
if event.get("PrevHash") is not None:
errors.append("Genesis event must have PrevHash=null")
else:
if event["PrevHash"] != events[i - 1]["EventHash"]:
errors.append(f"Event {i}: broken chain link")
# 1c. Verify signature
if not verify_signature(event, pub_key):
errors.append(f"Event {i}: invalid signature")
# 1d. Verify temporal ordering
if i > 0:
if event["Timestamp"] < events[i - 1]["Timestamp"]:
errors.append(f"Event {i}: timestamp regression")
# --- Phase 2: Completeness Invariant ---
attempts = {
e["EventID"]: e
for e in events
if e["EventType"] == "GEN_ATTEMPT"
}
outcomes = [
e for e in events
if e["EventType"] in ("GEN", "GEN_DENY", "GEN_ERROR")
]
matched = set()
orphans = []
for outcome in outcomes:
aid = outcome.get("AttemptID")
if aid in attempts:
if aid in matched:
errors.append(f"Duplicate outcome for attempt {aid}")
matched.add(aid)
else:
orphans.append(outcome["EventID"])
unmatched = [aid for aid in attempts if aid not in matched]
n_gen = sum(1 for e in outcomes if e["EventType"] == "GEN")
n_deny = sum(1 for e in outcomes if e["EventType"] == "GEN_DENY")
n_error = sum(1 for e in outcomes if e["EventType"] == "GEN_ERROR")
completeness_ok = (
len(unmatched) == 0
and len(orphans) == 0
and len(attempts) == n_gen + n_deny + n_error
)
return VerificationResult(
chain_valid=len(errors) == 0,
completeness_valid=completeness_ok,
total_attempts=len(attempts),
total_gen=n_gen,
total_deny=n_deny,
total_error=n_error,
unmatched_attempts=unmatched,
orphan_outcomes=orphans,
errors=errors,
)
# Run verification
result = verify_chain_and_completeness(chain, public_key)
print(f"Chain integrity: {'✅ VALID' if result.chain_valid else '❌ BROKEN'}")
print(f"Completeness: {'✅ VALID' if result.completeness_valid else '❌ VIOLATED'}")
print(f"Invariant check: {result.total_attempts} attempts == "
f"{result.total_gen} gen + {result.total_deny} deny + {result.total_error} error")
if result.errors:
print(f"Errors: {result.errors}")
Output:
Chain integrity: ✅ VALID
Completeness: ✅ VALID
Invariant check: 3 attempts == 1 gen + 1 deny + 1 error
Step 6: Detecting tampering
The real value shows up when someone tries to manipulate the log. Let's simulate an AI provider trying to delete a refusal event:
import copy
# Tampered chain: remove the GEN_DENY event (index 1)
tampered = copy.deepcopy(chain)
tampered.pop(1) # Remove the denial record
# Fix the chain link (a sophisticated attacker would try this)
tampered[1]["PrevHash"] = tampered[0]["EventHash"]
result_tampered = verify_chain_and_completeness(tampered, public_key)
print(f"Chain integrity: {'✅ VALID' if result_tampered.chain_valid else '❌ BROKEN'}")
print(f"Completeness: {'✅ VALID' if result_tampered.completeness_valid else '❌ VIOLATED'}")
print(f"Unmatched attempts: {result_tampered.unmatched_attempts}")
if result_tampered.errors:
for err in result_tampered.errors:
print(f" ⚠️ {err}")
Output:
Chain integrity: ❌ BROKEN
Completeness: ❌ VIOLATED
Unmatched attempts: ['<attempt_1_id>']
⚠️ Event 1: hash mismatch
⚠️ Event 1: invalid signature
Even though the attacker re-linked the chain, the signature on the modified event is invalid (they don't have the signing key), and the completeness invariant catches the missing outcome. The deletion is detectable in two independent ways.
Step 7: Merkle tree for efficient batch verification
For high-volume systems processing millions of events, you don't want to verify every event individually. Merkle trees let you verify subsets efficiently:
def build_merkle_tree(event_hashes: list[str]) -> dict:
"""Build a Merkle tree from event hashes.
Returns the tree structure with the root hash that can be
externally anchored (RFC 3161 timestamp, SCITT ledger, etc.)
"""
if not event_hashes:
return {"root": None, "levels": []}
# Leaf level: the event hashes themselves
current_level = [h[7:] for h in event_hashes] # strip "sha256:"
levels = [current_level[:]]
# Build tree bottom-up
while len(current_level) > 1:
next_level = []
for i in range(0, len(current_level), 2):
left = current_level[i]
right = current_level[i + 1] if i + 1 < len(current_level) else left
parent = hashlib.sha256((left + right).encode()).hexdigest()
next_level.append(parent)
current_level = next_level
levels.append(current_level[:])
return {
"root": f"sha256:{current_level[0]}",
"levels": levels,
"leaf_count": len(event_hashes),
}
def generate_inclusion_proof(tree: dict, leaf_index: int) -> list[dict]:
"""Generate a Merkle inclusion proof for a specific event.
A regulator can use this to verify a single event is part of
the published batch without seeing all other events.
"""
proof = []
idx = leaf_index
for level in tree["levels"][:-1]:
sibling_idx = idx ^ 1 # XOR to get sibling
if sibling_idx < len(level):
proof.append({
"hash": level[sibling_idx],
"position": "right" if sibling_idx > idx else "left",
})
idx //= 2
return proof
# Build tree from our chain
hashes = [e["EventHash"] for e in chain]
tree = build_merkle_tree(hashes)
print(f"Merkle root: {tree['root']}")
print(f"Leaf count: {tree['leaf_count']}")
# Generate proof that the GEN_DENY event (index 1) is in the tree
proof = generate_inclusion_proof(tree, 1)
print(f"Inclusion proof for GEN_DENY: {len(proof)} nodes")
The Merkle root is what gets anchored externally — to an RFC 3161 timestamp authority, a SCITT transparency service, or any other independent witness. This anchoring prevents backdating: even if the AI provider controls the signing key, they can't rewrite history after the root has been published.
Why this matters for the Heppner scenario
Consider the structural problem the ruling reveals, mapped to what CAP-SRP addresses:
| Problem in Heppner | CAP-SRP mitigation |
|---|---|
| AI logs controlled unilaterally by Anthropic | Hash chain + external anchoring prevents silent modification |
| No way to verify what Claude refused to generate |
GEN_DENY events with cryptographic signatures |
| Court relied on privacy policy representations | Completeness Invariant provides mathematical proof of log integrity |
| Evidence asymmetry: prosecution can use AI outputs, defense can't verify AI behavior | Third-party verification with public key — anyone can audit |
A concrete example: if CAP-SRP had been deployed, the defense in Heppner could have requested an Evidence Pack and independently verified whether Claude refused to generate certain content, whether all prompts were logged, and whether the log was complete and untampered.
What CAP-SRP is (and isn't)
Being honest about the current state:
What it is:
- An open specification (v1.0, CC BY 4.0) at github.com/veritaschain/cap-spec
- An IETF Internet-Draft (individual submission, not WG-adopted)
- Compatible with existing standards: C2PA for content provenance, SCITT for transparency services, RFC 3161 for timestamping
- Three conformance tiers: Bronze (basic logging), Silver (completeness invariant + external anchoring), Gold (real-time verification + HSM)
What it isn't (yet):
- Not adopted by any major AI company
- Not endorsed by IETF (the draft carries the standard "not endorsed" disclaimer)
- Not battle-tested at production scale
- A single-maintainer project — not an industry consortium
The specification fills a genuine gap. C2PA answers "was this content generated by AI?" — CAP-SRP answers "did this AI system refuse to generate harmful content?" Both questions matter for the regulatory landscape taking shape in 2026.
Regulatory deadlines driving this work
| Deadline | Regulation | Relevance |
|---|---|---|
| Already in force | EU Digital Services Act | VLOPs must provide auditable content moderation records |
| Feb 2026 | Colorado AI Act (SB24-205) | Impact assessments, 3-year retention of algorithmic decisions |
| May 2026 | TAKE IT DOWN Act | NCII removal evidence, 48-hour response proof |
| Aug 2026 | EU AI Act Articles 12 & 50 | Automatic logging, AI content marking, audit capability |
| Feb 2026 | India IT Rules Amendment | 2-3 hour deepfake takedown, provenance markers |
Every one of these regulations requires some form of auditable evidence that AI systems are enforcing safety policies. None of them specify how that evidence should be cryptographically secured.
Next steps
If you want to experiment with CAP-SRP:
- Read the spec: CAP-SRP v1.0
- Check the schemas: JSON Schema definitions for GEN_ATTEMPT, GEN_DENY, GEN, and Evidence Pack manifests
- Run the test vectors: test-vectors/ for validation
- Read the IETF draft: draft-kamimura-scitt-refusal-events for the SCITT integration profile
If you're building an AI content generation system and want to get ahead of the August 2026 EU AI Act deadline, the Bronze conformance level — hash chains, Ed25519 signatures, basic event logging — is implementable in a week.
The Heppner ruling is a signal. AI interactions are evidence now. The question is whether that evidence will remain a one-sided black box, or whether we'll build the infrastructure for both sides to verify it.
The code in this article is a simplified reference implementation. For production use, see the CAP-SRP specification for requirements around HSM key management, RFC 3161 timestamping, GDPR crypto-shredding, and performance optimization.
Spec: github.com/veritaschain/cap-spec · IETF Draft: draft-kamimura-scitt-refusal-events · License: CC BY 4.0
Top comments (0)