On February 20, 2026, India's IT Rules Amendment 2026 took effect, mandating that platforms remove illegal deepfakes within 3 hours of a government or court order and within 2 hours for nudity, sexual content, or impersonation complaints. Platforms must now embed permanent metadata with unique identifiers in all synthetic content and visibly label AI-generated media—or risk losing their safe harbour protection under Section 79 of the IT Act.
This is the world's most aggressive content-focused deepfake regulation. And it has a blind spot the size of a continent.
The rules tell platforms: label what you generated, delete what is illegal, record what you did. But they say nothing about a question regulators will inevitably need answered: did the AI system actually refuse to generate the harmful content it claims to have blocked?
This article walks through the technical gap, fact-checks the regulatory claims, and builds a working Python implementation of CAP-SRP (Content/Creative AI Profile – Safe Refusal Provenance)—a cryptographic framework that proves what AI systems refused to generate.
Repository: github.com/veritaschain/cap-spec
Table of Contents
- What India's Rules Actually Require (Fact-Checked)
- The Regulatory Gap: Published Content vs. Refused Attempts
- Grok: What Happens When There Is No Proof
- CAP-SRP Architecture Overview
- Implementation: Hash Chain Foundation
- Implementation: The Completeness Invariant
- Implementation: Ed25519 Signing
- Implementation: Merkle Tree Anchoring
- Implementation: Privacy-Preserving Prompt Hashing
- Implementation: Evidence Pack Generation
- Full Working Verifier
- Regulatory Compliance Mapping
- Limitations and Honest Assessment
- What's Next
What India's Rules Actually Require (Fact-Checked) {#what-indias-rules-actually-require}
Before building a solution, we need to be precise about the problem. I ran a detailed fact-check on the IT Rules Amendment 2026, and several claims circulating in the AI governance community need correction.
✅ Confirmed
Two-tier takedown deadlines are real, but they are separate provisions—not a range.
- 3 hours: For unlawful content flagged via court order or reasoned government officer intimation (Rule 3(1)(d)), down from the previous 36-hour window
- 2 hours: For complaints specifically about nudity, sexual content, morphed imagery, or impersonation
These are distinct legal triggers with different scopes. Conflating them as "2–3 hours" misrepresents the structure.
Permanent metadata and labeling are mandated, with two legally distinct obligations:
- Visible labeling: Prominent disclosure on visual content, prefixed announcements on audio
- Permanent metadata: Unique identifiers for traceability embedded in synthetic content
However, the metadata requirement includes a critical "where technically feasible" qualifier that most commentary omits. This is a significant legal caveat for implementation.
Safe harbour loss is real, but the trigger is narrower than often claimed. Platforms that "knowingly permit, promote, or fail to act against" unlawful synthetic content lose their Section 79 protection. The "knowingly" qualifier matters—it is not triggered by mere failure. Additionally, the amendment includes a positive safe harbour: platforms acting in good faith to proactively remove harmful AI content retain legal protection from wrongful-removal lawsuits.
⚠️ Partially Accurate or Misleading
"Provenance mechanisms within 10 days" mischaracterizes the timeline. The amendment was notified February 10 and enforced February 20. The 10-day figure is the total compliance window for the entire amendment—not a specific deadline for provenance implementation.
"Record all enforcement measures for future audit" could not be verified against the actual regulation text or any of the major law firm analyses (Cyril Amarchand Mangaldas, Hogan Lovells, Khaitan & Co., AZB & Partners). The amendment imposes enhanced due diligence and automated detection obligations, but no explicit "audit recording" mandate matching this specific claim was found.
"Mandatory for brands/creators" mischaracterizes the regulatory subject. The IT Rules Amendment targets platforms and intermediaries (SSMIs), not brands or creators directly. Content creators face only a self-declaration obligation when uploading AI-generated content.
What the Rules Actually Target
The regulation exclusively addresses content that exists:
- Labeling synthetic content ✓
- Removing unlawful synthetic content ✓
- Embedding metadata in generated content ✓
- Platforms detecting and acting on harmful synthetic media ✓
What it does not address:
- Tracking what AI systems refused to generate ✗
- Verifying that claimed safety measures actually operated ✗
- Providing third-party verification of moderation claims ✗
- Ensuring completeness of audit trails ✗
This is the gap.
The Regulatory Gap: Published Content vs. Refused Attempts {#the-regulatory-gap}
India's rules, the EU AI Act, the US TAKE IT DOWN Act, the Colorado AI Act—every major regulatory framework shares the same structural limitation. They all regulate what comes out of AI systems (the generated content) but not what AI systems claim to have prevented.
The current model works like this:
Regulator: "Did your AI block harmful requests?"
Platform: "Yes, we blocked 2.3 million."
Regulator: "How do we verify that?"
Platform: "...trust us?"
This is the "Trust Us" model of AI governance. It worked when AI systems were less capable. It no longer works when a single model can generate thousands of non-consensual intimate images per hour.
The shift we need:
Regulator: "Did your AI block harmful requests?"
Platform: "Here is a cryptographically signed evidence pack.
Every generation attempt has exactly one recorded
outcome. The hash chain is externally anchored.
You can verify independently."
Regulator: [runs cap-verify on evidence pack]
"Chain integrity: VALID. Completeness: VALID.
67.3% refusal rate. 0 orphan events."
Grok: What Happens When There Is No Proof {#grok-what-happens}
In January 2026, xAI's Grok AI generated an estimated 3 million sexualized images in 11 days, including content depicting minors. The Future of Life Institute rated xAI's safety practices as "F"—the lowest among major AI providers. Technical analysis revealed Grok lacked basic trust and safety layers: no detection of minors, no blocking for sexually suggestive poses, no C2PA watermarking.
When xAI claimed to have fixed the issues, regulators across five jurisdictions (EU, UK, India, Indonesia, California) had no way to independently verify whether:
- The safety measures now existed
- They were actually functioning
- The claimed refusal rates were real
- No requests were being silently dropped from logs
The Grok incident is the most compelling demonstration of why refusal provenance matters. Without it, the gap between "we fixed it" and "prove it" remains unbridgeable.
CAP-SRP Architecture Overview {#capsrp-architecture-overview}
CAP-SRP (Content/Creative AI Profile – Safe Refusal Provenance) creates tamper-evident audit trails of AI content moderation decisions. The core idea:
You don't prove the negative directly. You prove the positive—every attempt, every outcome, every decision—and you prove that the record is complete.
The architecture has five layers:
┌─────────────────────────────────────────────┐
│ Layer 5: Evidence Pack Generation │
│ (Self-contained, legally admissible) │
├─────────────────────────────────────────────┤
│ Layer 4: External Anchoring │
│ (RFC 3161 TSA, SCITT Transparency Service) │
├─────────────────────────────────────────────┤
│ Layer 3: Merkle Tree Aggregation │
│ (Efficient verification + inclusion proofs)│
├─────────────────────────────────────────────┤
│ Layer 2: Digital Signatures │
│ (Ed25519, RFC 8032) │
├─────────────────────────────────────────────┤
│ Layer 1: Hash Chain │
│ (SHA-256 linked events, RFC 8785 JCS) │
├─────────────────────────────────────────────┤
│ Layer 0: Event Logging │
│ (GEN_ATTEMPT → GEN | GEN_DENY | GEN_ERROR)│
└─────────────────────────────────────────────┘
Three conformance levels accommodate different organizational maturity:
| Level | Hash Chain | Completeness Invariant | External Anchoring | Retention | Target |
|---|---|---|---|---|---|
| Bronze | Required | Recommended | Optional | 6 months | SMEs |
| Silver | Required | Required | Daily | 2 years | Enterprise |
| Gold | Required | Required | Hourly + SCITT | 5 years | High-risk AI |
Implementation: Hash Chain Foundation {#implementation-hash-chain}
Let's build this from scratch. Every event in CAP-SRP is linked to the previous event via SHA-256 hashing, creating a tamper-evident chain.
"""
cap_srp/chain.py — Hash chain construction for CAP-SRP events.
Events are canonicalized per RFC 8785 (JSON Canonicalization Scheme)
before hashing, ensuring deterministic hash computation across
implementations.
"""
import hashlib
import json
import uuid
from datetime import datetime, timezone
from typing import Optional
def _canonicalize(obj: dict) -> str:
"""
RFC 8785-compliant JSON canonicalization.
For production, use a dedicated JCS library.
This simplified version handles the common cases:
- Sort keys lexicographically
- No whitespace
- Unicode normalization
"""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def generate_event_id() -> str:
"""Generate UUIDv7-style event ID (time-ordered)."""
return str(uuid.uuid7())
def compute_event_hash(event: dict) -> str:
"""
Compute SHA-256 hash of canonicalized event.
The Signature field is excluded before hashing to avoid
circular dependency (hash → sign → hash would change).
Args:
event: Event dictionary (Signature field excluded from hash)
Returns:
Hash string in format "sha256:{hex_digest}"
"""
# Remove signature before hashing
hashable = {k: v for k, v in event.items() if k != "Signature"}
canonical = _canonicalize(hashable)
digest = hashlib.sha256(canonical.encode("utf-8")).digest()
return f"sha256:{digest.hex()}"
def create_genesis_event(chain_id: str, provider_id: str) -> dict:
"""
Create the first event in a new audit chain.
The genesis event has PrevHash set to the zero hash,
establishing the chain root.
"""
event = {
"EventID": generate_event_id(),
"EventType": "CHAIN_INIT",
"ChainID": chain_id,
"Timestamp": datetime.now(timezone.utc).isoformat(),
"PrevHash": "sha256:" + "0" * 64,
"ProviderID": provider_id,
"SpecVersion": "CAP-SRP/1.0",
}
event["EventHash"] = compute_event_hash(event)
return event
def append_event(
chain: list[dict],
event_type: str,
payload: dict,
chain_id: str,
) -> dict:
"""
Create and append a new event to the chain.
Each event includes the hash of the previous event,
creating the tamper-evident linkage. Modifying any
historical event invalidates all subsequent hashes.
Args:
chain: Existing event list
event_type: One of GEN_ATTEMPT, GEN, GEN_DENY, GEN_ERROR
payload: Event-specific data
chain_id: Chain identifier
Returns:
The new event (also appended to chain)
"""
prev_event = chain[-1]
event = {
"EventID": generate_event_id(),
"EventType": event_type,
"ChainID": chain_id,
"Timestamp": datetime.now(timezone.utc).isoformat(),
"PrevHash": prev_event["EventHash"],
**payload,
}
event["EventHash"] = compute_event_hash(event)
chain.append(event)
return event
Chain Integrity Verification
The chain is valid if and only if every event's hash matches its recomputed value, and every PrevHash matches the preceding event's EventHash:
"""
cap_srp/verify.py — Chain integrity verification.
"""
from typing import NamedTuple
class ChainVerification(NamedTuple):
valid: bool
error: str | None = None
event_index: int | None = None
def verify_chain_integrity(events: list[dict]) -> ChainVerification:
"""
Verify the full hash chain.
Checks:
1. Each event's EventHash matches recomputation
2. Each event's PrevHash matches the previous EventHash
3. Genesis event has the zero-hash PrevHash
Time complexity: O(n)
Space complexity: O(1)
"""
if not events:
return ChainVerification(valid=False, error="Empty chain")
# Check genesis
if events[0]["PrevHash"] != "sha256:" + "0" * 64:
return ChainVerification(
valid=False, error="Invalid genesis PrevHash", event_index=0
)
for i, event in enumerate(events):
# Verify hash computation
computed = compute_event_hash(event)
if event["EventHash"] != computed:
return ChainVerification(
valid=False,
error=f"Hash mismatch: stored={event['EventHash']}, computed={computed}",
event_index=i,
)
# Verify chain linkage (skip genesis)
if i > 0:
if event["PrevHash"] != events[i - 1]["EventHash"]:
return ChainVerification(
valid=False,
error=f"Chain break: PrevHash does not match previous EventHash",
event_index=i,
)
return ChainVerification(valid=True)
Implementation: The Completeness Invariant {#implementation-completeness-invariant}
This is the mathematical core of CAP-SRP. The Completeness Invariant guarantees:
∑ GEN_ATTEMPT = ∑ GEN + ∑ GEN_DENY + ∑ GEN_ERROR
For any time window, the count of attempts must exactly equal the count of all outcomes. This is what makes selective logging detectable.
| Violation | Meaning | Implication |
|---|---|---|
| Attempts > Outcomes | Unmatched attempts exist | System is hiding results |
| Outcomes > Attempts | Orphan outcomes exist | System fabricated refusals |
| Duplicate outcomes | Multiple outcomes per attempt | Data integrity failure |
The critical architectural insight: GEN_ATTEMPT is logged before the safety evaluation runs. This creates an unforgeable commitment that a request existed, regardless of what follows. If the equation does not balance, the audit trail is provably invalid.
"""
cap_srp/completeness.py — Completeness Invariant verification.
The Completeness Invariant ensures every generation attempt
has exactly one recorded outcome, preventing:
- Selective omission (hiding inconvenient generations)
- Fabricated refusals (inflating safety statistics)
- Split-view attacks (showing different logs to different auditors)
"""
from dataclasses import dataclass, field
from datetime import datetime
ATTEMPT_TYPES = {"GEN_ATTEMPT"}
OUTCOME_TYPES = {"GEN", "GEN_DENY", "GEN_ERROR"}
@dataclass
class CompletenessResult:
"""Result of Completeness Invariant verification."""
valid: bool
total_attempts: int = 0
total_outcomes: int = 0
matched_pairs: int = 0
unmatched_attempts: list[str] = field(default_factory=list)
orphan_outcomes: list[str] = field(default_factory=list)
duplicate_outcomes: list[str] = field(default_factory=list)
@property
def refusal_rate(self) -> float | None:
"""Calculate the refusal rate if data is valid."""
if self.total_attempts == 0:
return None
deny_count = self.total_outcomes - self.matched_pairs # approximate
return deny_count / self.total_attempts if self.total_attempts > 0 else 0.0
def summary(self) -> str:
status = "✓ VALID" if self.valid else "✗ INVALID"
lines = [
f"Completeness Invariant: {status}",
f" Attempts: {self.total_attempts}",
f" Outcomes: {self.total_outcomes}",
f" Matched pairs: {self.matched_pairs}",
f" Unmatched attempts: {len(self.unmatched_attempts)}",
f" Orphan outcomes: {len(self.orphan_outcomes)}",
f" Duplicate outcomes: {len(self.duplicate_outcomes)}",
]
return "\n".join(lines)
def verify_completeness(
events: list[dict],
time_start: datetime | None = None,
time_end: datetime | None = None,
) -> CompletenessResult:
"""
Verify the Completeness Invariant for events in a time window.
For every GEN_ATTEMPT, there must exist exactly one event E where:
- E.EventType ∈ {GEN, GEN_DENY, GEN_ERROR}
- E.AttemptID == GEN_ATTEMPT.EventID
- E.Timestamp > GEN_ATTEMPT.Timestamp
Args:
events: All events (any order; will be filtered)
time_start: Optional window start (inclusive)
time_end: Optional window end (inclusive)
Returns:
CompletenessResult with detailed breakdown
"""
# Filter by time window if specified
filtered = events
if time_start or time_end:
filtered = []
for e in events:
ts = datetime.fromisoformat(e["Timestamp"])
if time_start and ts < time_start:
continue
if time_end and ts > time_end:
continue
filtered.append(e)
# Separate attempts and outcomes
attempts: dict[str, dict] = {}
outcomes: list[dict] = []
for e in filtered:
if e["EventType"] in ATTEMPT_TYPES:
attempts[e["EventID"]] = e
elif e["EventType"] in OUTCOME_TYPES:
outcomes.append(e)
# Match outcomes to attempts
matched_attempt_ids: set[str] = set()
orphan_outcomes: list[str] = []
duplicate_outcomes: list[str] = []
for outcome in outcomes:
attempt_id = outcome.get("AttemptID")
if attempt_id not in attempts:
# Outcome references a non-existent attempt
orphan_outcomes.append(outcome["EventID"])
continue
if attempt_id in matched_attempt_ids:
# Multiple outcomes for same attempt
duplicate_outcomes.append(outcome["EventID"])
continue
matched_attempt_ids.add(attempt_id)
# Unmatched attempts: logged but no outcome recorded
unmatched_attempts = [
aid for aid in attempts if aid not in matched_attempt_ids
]
is_valid = (
len(unmatched_attempts) == 0
and len(orphan_outcomes) == 0
and len(duplicate_outcomes) == 0
)
return CompletenessResult(
valid=is_valid,
total_attempts=len(attempts),
total_outcomes=len(outcomes),
matched_pairs=len(matched_attempt_ids),
unmatched_attempts=unmatched_attempts,
orphan_outcomes=orphan_outcomes,
duplicate_outcomes=duplicate_outcomes,
)
Testing the Invariant
"""
tests/test_completeness.py — Verify invariant catches violations.
"""
from cap_srp.chain import create_genesis_event, append_event
from cap_srp.completeness import verify_completeness
def build_test_chain():
"""Build a chain with 3 attempts: 1 generated, 1 denied, 1 error."""
chain_id = "test-chain-001"
chain = [create_genesis_event(chain_id, "test-provider")]
# Attempt 1 → Generated
attempt_1 = append_event(chain, "GEN_ATTEMPT", {
"PromptHash": "sha256:aaa...",
"ModelID": "test-model-v1",
"InputType": "text",
}, chain_id)
append_event(chain, "GEN", {
"AttemptID": attempt_1["EventID"],
"ContentHash": "sha256:bbb...",
"OutputType": "image",
}, chain_id)
# Attempt 2 → Denied (NCII risk)
attempt_2 = append_event(chain, "GEN_ATTEMPT", {
"PromptHash": "sha256:ccc...",
"ModelID": "test-model-v1",
"InputType": "text",
}, chain_id)
append_event(chain, "GEN_DENY", {
"AttemptID": attempt_2["EventID"],
"DenyReason": "NCII_RISK",
"PolicyID": "safety-policy-v3",
"Confidence": 0.97,
}, chain_id)
# Attempt 3 → Error
attempt_3 = append_event(chain, "GEN_ATTEMPT", {
"PromptHash": "sha256:ddd...",
"ModelID": "test-model-v1",
"InputType": "text",
}, chain_id)
append_event(chain, "GEN_ERROR", {
"AttemptID": attempt_3["EventID"],
"ErrorCode": "MODEL_TIMEOUT",
}, chain_id)
return chain
def test_valid_chain():
chain = build_test_chain()
result = verify_completeness(chain)
assert result.valid is True
assert result.total_attempts == 3
assert result.matched_pairs == 3
assert len(result.unmatched_attempts) == 0
print(result.summary())
def test_missing_outcome():
"""Simulate a platform hiding a generation result."""
chain = build_test_chain()
# Add an attempt with no outcome — the "hidden generation"
append_event(chain, "GEN_ATTEMPT", {
"PromptHash": "sha256:eee...",
"ModelID": "test-model-v1",
"InputType": "text",
}, chain[-1]["ChainID"])
result = verify_completeness(chain)
assert result.valid is False
assert len(result.unmatched_attempts) == 1
print(result.summary())
# Output:
# Completeness Invariant: ✗ INVALID
# Attempts: 4
# Outcomes: 3
# Matched pairs: 3
# Unmatched attempts: 1
# Orphan outcomes: 0
# Duplicate outcomes: 0
def test_fabricated_refusal():
"""Simulate a platform inflating its refusal count."""
chain = build_test_chain()
# Add a refusal that references no real attempt
append_event(chain, "GEN_DENY", {
"AttemptID": "nonexistent-attempt-id",
"DenyReason": "NCII_RISK",
"PolicyID": "safety-policy-v3",
"Confidence": 0.99,
}, chain[-1]["ChainID"])
result = verify_completeness(chain)
assert result.valid is False
assert len(result.orphan_outcomes) == 1
print(result.summary())
# Output:
# Completeness Invariant: ✗ INVALID
# Attempts: 3
# Outcomes: 4
# Matched pairs: 3
# Unmatched attempts: 0
# Orphan outcomes: 1
# Duplicate outcomes: 0
if __name__ == "__main__":
test_valid_chain()
print()
test_missing_outcome()
print()
test_fabricated_refusal()
Implementation: Ed25519 Signing {#implementation-ed25519-signing}
Every event is signed with Ed25519 (RFC 8032). This prevents post-hoc tampering—even by the platform operator. If the signing key is stored in an HSM (Gold level), the platform cannot retroactively modify events without detection.
"""
cap_srp/signing.py — Ed25519 event signing and verification.
Uses the cryptography library (pip install cryptography).
For production, key management should use HSM or cloud KMS.
"""
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.exceptions import InvalidSignature
from cap_srp.chain import compute_event_hash
def generate_keypair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
"""Generate a new Ed25519 keypair for event signing."""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
return private_key, public_key
def sign_event(event: dict, private_key: Ed25519PrivateKey) -> dict:
"""
Sign an event with Ed25519.
The signature covers the EventHash, which itself covers
all fields except Signature. This creates a two-step
integrity guarantee:
1. EventHash proves field integrity
2. Signature proves EventHash authenticity
Args:
event: Event dict with EventHash already computed
private_key: Ed25519 signing key
Returns:
Event dict with Signature field added
"""
# Ensure hash is computed
if "EventHash" not in event:
event["EventHash"] = compute_event_hash(event)
# Sign the hash bytes (not the raw event)
hash_hex = event["EventHash"].removeprefix("sha256:")
hash_bytes = bytes.fromhex(hash_hex)
signature = private_key.sign(hash_bytes)
event["Signature"] = f"ed25519:{base64.b64encode(signature).decode()}"
return event
def verify_event_signature(event: dict, public_key: Ed25519PublicKey) -> bool:
"""
Verify an event's Ed25519 signature.
Steps:
1. Recompute event hash (excluding Signature field)
2. Compare with stored EventHash
3. Verify signature over the hash bytes
Returns:
True if signature is valid, False otherwise
"""
# Step 1: Verify hash integrity
computed_hash = compute_event_hash(event)
if event.get("EventHash") != computed_hash:
return False # Event data was modified
# Step 2: Extract and decode signature
sig_str = event.get("Signature", "")
if not sig_str.startswith("ed25519:"):
return False
try:
signature = base64.b64decode(sig_str.removeprefix("ed25519:"))
except Exception:
return False
# Step 3: Verify signature over hash bytes
hash_bytes = bytes.fromhex(computed_hash.removeprefix("sha256:"))
try:
public_key.verify(signature, hash_bytes)
return True
except InvalidSignature:
return False
def sign_chain(chain: list[dict], private_key: Ed25519PrivateKey) -> list[dict]:
"""Sign all events in a chain."""
return [sign_event(event, private_key) for event in chain]
def verify_chain_signatures(
chain: list[dict], public_key: Ed25519PublicKey
) -> tuple[bool, list[int]]:
"""
Verify all signatures in a chain.
Returns:
Tuple of (all_valid, list_of_invalid_indices)
"""
invalid_indices = []
for i, event in enumerate(chain):
if not verify_event_signature(event, public_key):
invalid_indices.append(i)
return (len(invalid_indices) == 0, invalid_indices)
Implementation: Merkle Tree Anchoring {#implementation-merkle-tree}
For efficient verification, events are aggregated into Merkle trees. A regulator can verify that a specific event is included in the tree without downloading the entire chain—using an inclusion proof.
"""
cap_srp/merkle.py — Merkle tree construction and inclusion proofs.
Enables O(log n) verification of event inclusion, critical for
regulatory audits where verifying millions of events individually
is impractical.
"""
import hashlib
from dataclasses import dataclass
def _hash_pair(left: bytes, right: bytes) -> bytes:
"""Hash two child nodes to produce parent."""
return hashlib.sha256(left + right).digest()
def _hash_leaf(data: str) -> bytes:
"""Hash a leaf node (event hash string)."""
# Prefix with 0x00 to distinguish leaves from internal nodes
return hashlib.sha256(b"\x00" + data.encode("utf-8")).digest()
@dataclass
class MerkleProof:
"""Inclusion proof for a single leaf."""
leaf_hash: bytes
proof_hashes: list[bytes]
proof_directions: list[str] # "left" or "right"
root: bytes
def verify(self) -> bool:
"""Verify this inclusion proof."""
current = self.leaf_hash
for hash_val, direction in zip(self.proof_hashes, self.proof_directions):
if direction == "left":
current = _hash_pair(hash_val, current)
else:
current = _hash_pair(current, hash_val)
return current == self.root
class MerkleTree:
"""
Binary Merkle tree for event hash aggregation.
Usage:
tree = MerkleTree()
for event in chain:
tree.add_leaf(event["EventHash"])
tree.build()
root = tree.root_hex()
proof = tree.get_proof(index)
"""
def __init__(self):
self.leaves: list[bytes] = []
self.layers: list[list[bytes]] = []
self._built = False
def add_leaf(self, event_hash: str) -> int:
"""Add an event hash as a leaf. Returns leaf index."""
self.leaves.append(_hash_leaf(event_hash))
self._built = False
return len(self.leaves) - 1
def build(self) -> None:
"""Construct the Merkle tree from leaves."""
if not self.leaves:
raise ValueError("Cannot build tree with no leaves")
self.layers = [self.leaves[:]]
current_layer = self.leaves[:]
while len(current_layer) > 1:
next_layer = []
for i in range(0, len(current_layer), 2):
left = current_layer[i]
# If odd number of nodes, duplicate the last
right = current_layer[i + 1] if i + 1 < len(current_layer) else left
next_layer.append(_hash_pair(left, right))
self.layers.append(next_layer)
current_layer = next_layer
self._built = True
@property
def root(self) -> bytes:
if not self._built:
self.build()
return self.layers[-1][0]
def root_hex(self) -> str:
return f"sha256:{self.root.hex()}"
def get_proof(self, leaf_index: int) -> MerkleProof:
"""
Generate an inclusion proof for a specific leaf.
The proof consists of sibling hashes at each level,
sufficient to reconstruct the root from the leaf.
"""
if not self._built:
self.build()
if leaf_index >= len(self.leaves):
raise IndexError(f"Leaf index {leaf_index} out of range")
proof_hashes = []
proof_directions = []
index = leaf_index
for layer in self.layers[:-1]: # Skip root layer
if index % 2 == 0:
# Current node is left child; sibling is right
sibling_idx = index + 1
if sibling_idx < len(layer):
proof_hashes.append(layer[sibling_idx])
else:
proof_hashes.append(layer[index]) # Duplicate
proof_directions.append("right")
else:
# Current node is right child; sibling is left
proof_hashes.append(layer[index - 1])
proof_directions.append("left")
index //= 2
return MerkleProof(
leaf_hash=self.leaves[leaf_index],
proof_hashes=proof_hashes,
proof_directions=proof_directions,
root=self.root,
)
Usage: Anchor a Day's Events
from cap_srp.merkle import MerkleTree
# Build tree from a day's events
tree = MerkleTree()
for event in todays_events:
tree.add_leaf(event["EventHash"])
tree.build()
print(f"Daily Merkle root: {tree.root_hex()}")
# → sha256:7f3a9b2c... (this gets anchored externally)
# Generate proof for a specific event (e.g., event #42)
proof = tree.get_proof(42)
assert proof.verify() # O(log n) verification
Implementation: Privacy-Preserving Prompt Hashing {#implementation-privacy}
CAP-SRP never stores raw prompts. Instead, it uses salted hashes that allow verification without content exposure. This satisfies GDPR requirements while enabling regulatory verification.
"""
cap_srp/privacy.py — Privacy-preserving prompt and actor hashing.
Prompts are stored only as salted SHA-256 hashes. The salt is
stored separately and encrypted, disclosed only to authorized
auditors via legal process.
This means:
- The audit chain proves completeness without revealing content
- Regulators verify chain integrity without seeing prompts
- When legally required, salts can be disclosed for specific events
- GDPR crypto-shredding: delete the salt, and the hash becomes
permanently unverifiable → effective deletion
"""
import hashlib
import os
import base64
def generate_salt(length: int = 32) -> bytes:
"""Generate cryptographically secure random salt (256-bit)."""
return os.urandom(length)
def hash_prompt(prompt: str, salt: bytes) -> str:
"""
Create privacy-preserving prompt hash.
The prompt text is never stored in the audit trail.
Only this hash appears in events.
Args:
prompt: Raw prompt text
salt: Per-event random salt
Returns:
Hash string in format "sha256:{hex}"
"""
data = salt + prompt.encode("utf-8")
digest = hashlib.sha256(data).digest()
return f"sha256:{digest.hex()}"
def hash_actor(actor_id: str, salt: bytes) -> str:
"""
Create privacy-preserving actor identifier hash.
Allows correlation within the audit trail (same actor
across multiple requests) while preventing identification
without the salt.
"""
data = salt + actor_id.encode("utf-8")
digest = hashlib.sha256(data).digest()
return f"sha256:{digest.hex()}"
def create_salt_commitment(prompt_salt: bytes, actor_salt: bytes) -> str:
"""
Create a commitment to the salts used.
This commitment is stored in the event, proving that
specific salts were used without revealing them. During
audit, disclosing the salts allows verification that they
match the commitment.
"""
combined = prompt_salt + actor_salt
digest = hashlib.sha256(combined).digest()
return f"sha256:{digest.hex()}"
def verify_prompt_hash(
prompt: str, salt: bytes, stored_hash: str
) -> bool:
"""Verify a prompt against its stored hash (requires salt)."""
return hash_prompt(prompt, salt) == stored_hash
# --- GDPR Crypto-Shredding ---
def crypto_shred(salt_store: dict, event_id: str) -> None:
"""
Effectively 'delete' personal data by destroying the salt.
Without the salt, the prompt hash becomes permanently
unverifiable — achieving GDPR-compliant deletion while
preserving chain integrity (the hash still exists, but
can never be linked back to content).
"""
if event_id in salt_store:
# Securely overwrite before deletion
salt_store[event_id] = os.urandom(32)
del salt_store[event_id]
Implementation: Evidence Pack Generation {#implementation-evidence-pack}
Evidence Packs are self-contained, tamper-evident archives that a regulator can verify independently without any access to the platform's systems.
"""
cap_srp/evidence_pack.py — Generate self-contained evidence packs
for regulatory submission.
An Evidence Pack contains:
1. The event chain (or relevant subset)
2. Merkle tree with root and inclusion proofs
3. Signature verification material (public key)
4. Completeness Invariant verification results
5. Metadata (time period, provider, generation stats)
The pack is a single tar.gz file that a regulator can verify
using the open-source cap-verify tool.
"""
import json
import tarfile
import io
from datetime import datetime, timezone
from dataclasses import dataclass, asdict
from cap_srp.chain import compute_event_hash
from cap_srp.verify import verify_chain_integrity
from cap_srp.completeness import verify_completeness
from cap_srp.merkle import MerkleTree
@dataclass
class EvidencePackMetadata:
pack_id: str
provider_id: str
chain_id: str
period_start: str
period_end: str
total_events: int
total_attempts: int
total_generations: int
total_denials: int
total_errors: int
refusal_rate: float
spec_version: str = "CAP-SRP/1.0"
generated_at: str = ""
def __post_init__(self):
if not self.generated_at:
self.generated_at = datetime.now(timezone.utc).isoformat()
def generate_evidence_pack(
chain: list[dict],
provider_id: str,
chain_id: str,
output_path: str,
public_key_pem: bytes,
) -> EvidencePackMetadata:
"""
Generate a complete evidence pack for regulatory submission.
This creates a self-contained, verifiable archive containing
everything a regulator needs to independently validate the
audit trail.
"""
# Step 1: Verify chain integrity
chain_result = verify_chain_integrity(chain)
if not chain_result.valid:
raise ValueError(f"Chain integrity failed: {chain_result.error}")
# Step 2: Verify completeness
completeness = verify_completeness(chain)
# Step 3: Build Merkle tree
tree = MerkleTree()
for event in chain:
tree.add_leaf(event["EventHash"])
tree.build()
# Step 4: Compute statistics
attempts = [e for e in chain if e["EventType"] == "GEN_ATTEMPT"]
gens = [e for e in chain if e["EventType"] == "GEN"]
denials = [e for e in chain if e["EventType"] == "GEN_DENY"]
errors = [e for e in chain if e["EventType"] == "GEN_ERROR"]
total_attempts = len(attempts)
refusal_rate = len(denials) / total_attempts if total_attempts > 0 else 0.0
# Step 5: Create metadata
timestamps = [e["Timestamp"] for e in chain if "Timestamp" in e]
metadata = EvidencePackMetadata(
pack_id=f"VSO-EVIDPACK-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}-001",
provider_id=provider_id,
chain_id=chain_id,
period_start=min(timestamps),
period_end=max(timestamps),
total_events=len(chain),
total_attempts=total_attempts,
total_generations=len(gens),
total_denials=len(denials),
total_errors=len(errors),
refusal_rate=round(refusal_rate, 4),
)
# Step 6: Bundle into tar.gz
with tarfile.open(output_path, "w:gz") as tar:
# Events
_add_json(tar, "events.json", chain)
# Metadata
_add_json(tar, "metadata.json", asdict(metadata))
# Merkle root
_add_json(tar, "merkle_root.json", {
"root": tree.root_hex(),
"leaf_count": len(tree.leaves),
})
# Completeness report
_add_json(tar, "completeness.json", {
"valid": completeness.valid,
"total_attempts": completeness.total_attempts,
"total_outcomes": completeness.total_outcomes,
"matched_pairs": completeness.matched_pairs,
"unmatched_attempts": completeness.unmatched_attempts,
"orphan_outcomes": completeness.orphan_outcomes,
"duplicate_outcomes": completeness.duplicate_outcomes,
})
# Public key for signature verification
_add_bytes(tar, "public_key.pem", public_key_pem)
# Verification instructions
_add_bytes(tar, "VERIFY.md", VERIFY_INSTRUCTIONS.encode())
return metadata
def _add_json(tar: tarfile.TarFile, name: str, data) -> None:
content = json.dumps(data, indent=2, ensure_ascii=False).encode()
info = tarfile.TarInfo(name=name)
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
def _add_bytes(tar: tarfile.TarFile, name: str, data: bytes) -> None:
info = tarfile.TarInfo(name=name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
VERIFY_INSTRUCTIONS = """# Evidence Pack Verification
## Quick Verify
bash
pip install cap-srp
cap-verify ./evidence_pack.tar.gz
## Manual Verification Steps
1. Extract the archive
2. Verify chain integrity: recompute all EventHash values
3. Verify chain linkage: check PrevHash references
4. Verify signatures: validate Ed25519 signatures with public_key.pem
5. Verify completeness: run Completeness Invariant check
6. Verify Merkle root: reconstruct tree and compare root
## What Each File Contains
- `events.json`: Complete event chain
- `metadata.json`: Pack metadata and statistics
- `merkle_root.json`: Merkle tree root hash
- `completeness.json`: Completeness Invariant results
- `public_key.pem`: Ed25519 public key for signature verification
- `VERIFY.md`: This file
"""
Full Working Verifier {#full-working-verifier}
Here is a complete end-to-end demonstration that creates a chain, signs it, verifies it, and generates an evidence pack:
"""
demo.py — Complete CAP-SRP demonstration.
Run: python demo.py
This creates an audit chain simulating an AI image generation
service, signs all events, verifies integrity, checks the
Completeness Invariant, and generates an evidence pack.
"""
from cap_srp.chain import create_genesis_event, append_event
from cap_srp.signing import generate_keypair, sign_chain, verify_chain_signatures
from cap_srp.verify import verify_chain_integrity
from cap_srp.completeness import verify_completeness
from cap_srp.merkle import MerkleTree
from cap_srp.privacy import generate_salt, hash_prompt, hash_actor
from cap_srp.evidence_pack import generate_evidence_pack
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
)
def main():
# --- Setup ---
chain_id = "demo-chain-2026-02-19"
provider_id = "demo-ai-platform"
private_key, public_key = generate_keypair()
actor_salt = generate_salt()
# --- Build chain ---
chain = [create_genesis_event(chain_id, provider_id)]
# Scenario: 5 generation attempts
scenarios = [
# (prompt, should_deny, deny_reason)
("A sunset over mountains", False, None),
("Generate nude image of celebrity X", True, "NCII_RISK"),
("A cat wearing a hat", False, None),
("Child in suggestive pose", True, "CSAM_RISK"),
("Abstract art in watercolor style", False, None),
]
for prompt, should_deny, reason in scenarios:
# Generate per-prompt salt and hash
prompt_salt = generate_salt()
prompt_hash = hash_prompt(prompt, prompt_salt)
actor_hash = hash_actor("user-12345", actor_salt)
# Log attempt FIRST (before safety evaluation)
attempt = append_event(chain, "GEN_ATTEMPT", {
"PromptHash": prompt_hash,
"ActorHash": actor_hash,
"ModelID": "demo-model-v2",
"InputType": "text",
}, chain_id)
# Safety evaluation happens here...
if should_deny:
append_event(chain, "GEN_DENY", {
"AttemptID": attempt["EventID"],
"DenyReason": reason,
"PolicyID": "safety-policy-v3.1",
"Confidence": 0.98,
}, chain_id)
else:
append_event(chain, "GEN", {
"AttemptID": attempt["EventID"],
"ContentHash": f"sha256:{'ab' * 32}",
"OutputType": "image",
}, chain_id)
# --- Sign all events ---
chain = sign_chain(chain, private_key)
# --- Verify ---
print("=" * 60)
print("CAP-SRP Verification Report")
print("=" * 60)
# Chain integrity
chain_result = verify_chain_integrity(chain)
print(f"\n1. Chain Integrity: {'✓ VALID' if chain_result.valid else '✗ INVALID'}")
print(f" Events in chain: {len(chain)}")
# Signatures
sig_valid, invalid = verify_chain_signatures(chain, public_key)
print(f"\n2. Signatures: {'✓ ALL VALID' if sig_valid else f'✗ {len(invalid)} INVALID'}")
# Completeness
completeness = verify_completeness(chain)
print(f"\n3. Completeness Invariant:")
print(completeness.summary())
# Merkle tree
tree = MerkleTree()
for event in chain:
tree.add_leaf(event["EventHash"])
tree.build()
print(f"\n4. Merkle Root: {tree.root_hex()[:40]}...")
# Inclusion proof for a specific event
proof = tree.get_proof(3) # Verify 4th event
print(f" Inclusion proof for event #3: {'✓ VALID' if proof.verify() else '✗ INVALID'}")
# --- Generate evidence pack ---
public_key_pem = public_key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
metadata = generate_evidence_pack(
chain=chain,
provider_id=provider_id,
chain_id=chain_id,
output_path="evidence_pack.tar.gz",
public_key_pem=public_key_pem,
)
print(f"\n5. Evidence Pack Generated:")
print(f" Pack ID: {metadata.pack_id}")
print(f" Attempts: {metadata.total_attempts}")
print(f" Generated: {metadata.total_generations}")
print(f" Denied: {metadata.total_denials}")
print(f" Refusal rate: {metadata.refusal_rate:.1%}")
print(f" Saved to: evidence_pack.tar.gz")
print("\n" + "=" * 60)
print("Verification complete.")
print("=" * 60)
if __name__ == "__main__":
main()
Expected output:
============================================================
CAP-SRP Verification Report
============================================================
1. Chain Integrity: ✓ VALID
Events in chain: 11
2. Signatures: ✓ ALL VALID
3. Completeness Invariant:
Completeness Invariant: ✓ VALID
Attempts: 5
Outcomes: 5
Matched pairs: 5
Unmatched attempts: 0
Orphan outcomes: 0
Duplicate outcomes: 0
4. Merkle Root: sha256:7f3a9b2c4d8e1f6a0b5c3d7e...
Inclusion proof for event #3: ✓ VALID
5. Evidence Pack Generated:
Pack ID: VSO-EVIDPACK-2026-02-19-001
Attempts: 5
Generated: 3
Denied: 2
Refusal rate: 40.0%
Saved to: evidence_pack.tar.gz
============================================================
Verification complete.
============================================================
Regulatory Compliance Mapping {#regulatory-compliance-mapping}
Here is how CAP-SRP maps to the regulations that will be enforced in 2026:
India IT Rules Amendment 2026
| Requirement | What Rules Mandate | What CAP-SRP Adds |
|---|---|---|
| Takedown | 3h (court/govt) / 2h (nudity) | Proves refusal before content reaches platform |
| Metadata | Permanent metadata with unique IDs | Extends to refusal events, not just generations |
| Labeling | Visible AI content disclosure | Cryptographic verification of labeling compliance |
| Safe harbour | Good-faith proactive removal | Evidence of functioning prevention, not just removal |
The Indian rules focus on what happens after content is generated and published. CAP-SRP extends the audit trail upstream to the generation decision itself.
EU AI Act (August 2, 2026)
| Article | Requirement | CAP-SRP Implementation |
|---|---|---|
| Art. 12 | Automatic event logging, tamper-evident | Hash chain + Ed25519 signatures |
| Art. 12 | Traceability appropriate to purpose | Completeness Invariant ensures full coverage |
| Art. 26(6) | 6-month minimum retention | Bronze level: 6 months; Silver: 2 years; Gold: 5 years |
| Art. 50 | Machine-readable content marking | COSE_Sign1 format with CBOR encoding |
US TAKE IT DOWN Act (May 19, 2026)
CAP-SRP provides evidence of proactive prevention—demonstrating that safety measures were operational and effective before harmful content could be created. This strengthens the "good faith" defense for platforms.
Colorado AI Act (June 30, 2026)
The Act requires "reasonable care" and risk management aligned with NIST AI RMF. CAP-SRP's verifiable refusal records provide concrete, auditable evidence that safety measures were not just documented but functioning.
Limitations and Honest Assessment {#limitations}
Being transparent about what CAP-SRP is and is not:
What CAP-SRP proves: That a complete, tamper-evident record of all generation decisions exists, and that the record has not been modified after the fact.
What CAP-SRP does NOT prove: That a harmful output was not generated outside the logging system. The proof is only as strong as the completeness of the logging integration. A compromised system could theoretically avoid logging an attempt entirely before the hash chain captures it.
Framework maturity: CAP-SRP v1.0 was published in February 2026. The VeritasChain GitHub organization was created in November 2025. The framework has not yet been adopted by any major AI provider. The concept is sound, the code works, but it has not been battle-tested at scale.
The "first-mover" problem: CAP-SRP is currently the only specification proposing a standardized approach to refusal provenance. This could mean it fills a genuine gap, or it could mean the gap is not as urgent as argued. The underlying SCITT framework from the IETF (Supply Chain Integrity, Transparency, and Trust) is mature and has real implementations from Microsoft and DataTrails—CAP-SRP builds on that foundation.
The trust boundary: At some point, you must trust the system that generates the initial GEN_ATTEMPT event. If the AI provider's infrastructure is compromised at the kernel level, no logging framework can help. CAP-SRP pushes the trust boundary outward (via external anchoring) and narrows it (via HSM key management), but it cannot eliminate it.
What's Next {#whats-next}
If you want to explore further:
- GitHub Repo: veritaschain/cap-spec — Full specification, JSON schemas, test vectors
- SCITT Integration: The IETF's SCITT working group provides the transparency service layer. Draft architecture is at version 22, expected RFC publication Q1-Q2 2026
- C2PA Complement: Where C2PA proves what was generated, CAP-SRP proves what was refused. The two are designed to work together
- Post-Quantum Migration: The specification includes a migration path from Ed25519 to ML-DSA (FIPS 204) via composite signatures
The core insight is simple: if AI providers want regulators to believe their safety claims, they need to prove them. Not with press releases, not with internal dashboards, but with cryptographic evidence that any third party can verify independently.
India's 3-hour rule is just the beginning. As regulation tightens globally, the question will shift from "did you take it down?" to "did you prevent it?" The infrastructure for answering that question needs to exist before regulators ask.
This article is part of the Verifiable AI Provenance series. The CAP-SRP specification is open source under MIT license.
Disclaimer: CAP-SRP is a young specification. The regulatory mapping represents the author's analysis and should not be taken as legal advice. Always consult qualified legal counsel for compliance decisions.
Top comments (0)