DEV Community

Cover image for A Federal Judge Just Ruled Your AI Chats Are Evidence. Here's How to Build Cryptographic Proof of What AI *Didn't* Generate.

A Federal Judge Just Ruled Your AI Chats Are Evidence. Here's How to Build Cryptographic Proof of What AI *Didn't* Generate.

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:

  1. 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.
  2. 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."
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()}")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
    }
Enter fullscreen mode Exit fullscreen mode

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')}")
Enter fullscreen mode Exit fullscreen mode

Output:

Chain built: 6 events
  GEN_ATTEMPT: 3
  GEN:         1
  GEN_DENY:    1
  GEN_ERROR:   1
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

Output:

Chain integrity:   ✅ VALID
Completeness:      ✅ VALID
Invariant check:   3 attempts == 1 gen + 1 deny + 1 error
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

Output:

Chain integrity:   ❌ BROKEN
Completeness:      ❌ VIOLATED
Unmatched attempts: ['<attempt_1_id>']
  ⚠️  Event 1: hash mismatch
  ⚠️  Event 1: invalid signature
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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:

  1. Read the spec: CAP-SRP v1.0
  2. Check the schemas: JSON Schema definitions for GEN_ATTEMPT, GEN_DENY, GEN, and Evidence Pack manifests
  3. Run the test vectors: test-vectors/ for validation
  4. 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)