DEV Community

Cover image for Give Your AI a Black Box: A Verifiable Decision Log in ~150 Lines of Python

Give Your AI a Black Box: A Verifiable Decision Log in ~150 Lines of Python

In late May 2026, attackers took over more than 20,000 Instagram accounts by asking Meta's AI support assistant to add a recovery email to accounts they did not own. No exploit code. The assistant made a security decision — this email change is authorized — thousands of times, and the trail it left behind was not built to be examined by anyone outside the company.

That is a decision-provenance problem, and it is different from the content-provenance problem (C2PA, SynthID) that gets most of the attention. The question is not "where did this image come from?" It is "can someone prove what an automated system decided — when, on what inputs, under what logic — and prove the record wasn't quietly altered or trimmed?"

Most systems answer that question with logs. But a log is not a black box. In this post we'll build one that is, in a small amount of Python, using the event model from the VeritasChain Protocol (VCP) — an open spec for decision provenance in algorithmic trading. The same four properties apply to any high-stakes automated decision.

A record only behaves like an aircraft's black box when it has four properties ordinary logging does not:

  1. Tamper-evidence an outsider can check (hash chain)
  2. Independent verifiability (public-key signatures, not shared secrets)
  3. Omission detection (commit to the completeness of the set, not just each entry)
  4. External anchoring (publish commitments somewhere the operator can't rewrite)

We'll implement each one. The only dependency beyond the standard library is cryptography:

pip install cryptography
Enter fullscreen mode Exit fullscreen mode

The event model

VCP events have a three-layer shape: a header (who/what/when), a payload (the decision-specific data), and a security block (the cryptographic glue). A few rules worth internalizing up front: IDs are UUIDv7 (RFC 9562) so they sort chronologically; financial values are strings, never floats, so precision survives serialization; and account IDs are pseudonymized before they're ever written, which matters for GDPR.

Here's a real SIG (signal) event — an algorithm deciding to act:

{
  "header": {
    "event_id": "01934e3a-7b2c-7f93-8f2a-1234567890ab",
    "trace_id": "01934e3a-6a1b-7c82-9d1b-0987654321dc",
    "timestamp_int": "1732358400000000000",
    "timestamp_iso": "2025-11-23T12:00:00.000Z",
    "event_type": "SIG",
    "event_type_code": 1,
    "timestamp_precision": "MILLISECOND",
    "clock_sync_status": "BEST_EFFORT",
    "hash_algo": "SHA256",
    "venue_id": "DEMO-VENUE",
    "symbol": "XAUUSD",
    "account_id": "acc_h7g8i9j0k1l2m3n4"
  },
  "payload": {
    "vcp_gov": {
      "algo_id": "ALGO_001", "algo_version": "2.1.0", "algo_type": "HYBRID",
      "confidence": "0.87",
      "decision_factors": [{"name": "RSI", "weight": "0.3", "value": "72.5"}]
    }
  },
  "security": {
    "event_hash": "…", "prev_hash": "0000…0000",
    "signature": "…", "sign_algo": "Ed25519"
  }
}
Enter fullscreen mode Exit fullscreen mode

The event types form a lifecycle — SIG (signal) → ORD (order) → ACK (acknowledged) → EXE (executed), with REJ (rejected), CXL (cancelled), RSK (risk control), and others. Each has a stable integer code (SIG=1, ORD=2, ACK=3, EXE=4, REJ=6, …) so the meaning never drifts.

Let's set up the primitives. First, a compliant UUIDv7 and a pseudonymizer:

import time
import secrets
import hashlib
import json
from datetime import datetime, timezone
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey, Ed25519PublicKey,
)
from cryptography.exceptions import InvalidSignature

GENESIS = "0" * 64  # prev_hash of the very first event


def uuid7() -> str:
    """RFC 9562 UUIDv7: 48-bit ms timestamp + version/variant + random."""
    ts = int(time.time() * 1000).to_bytes(6, "big")
    rand = secrets.token_bytes(10)
    b = bytearray(16)
    b[0:6] = ts
    b[6] = (7 << 4) | (rand[0] & 0x0F)      # version = 7
    b[7] = rand[1]
    b[8] = (0b10 << 6) | (rand[2] & 0x3F)   # variant = 10
    b[9:16] = rand[3:10]
    h = b.hex()
    return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"


def pseudonymize(account_id: str, venue_id: str) -> str:
    """Salt + hash the account ID so raw identities never hit the log."""
    salt = f"vcp_{venue_id}_"
    digest = hashlib.sha256((salt + account_id).encode()).hexdigest()[:16]
    return f"acc_{digest}"


def new_header(event_type, code, symbol, account_id, venue_id, trace_id=None):
    now = datetime.now(timezone.utc)
    iso = now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
    return {
        "event_id": uuid7(),
        "trace_id": trace_id or uuid7(),
        "timestamp_int": str(time.time_ns()),
        "timestamp_iso": iso,
        "event_type": event_type,
        "event_type_code": code,
        "timestamp_precision": "MILLISECOND",
        "clock_sync_status": "BEST_EFFORT",
        "hash_algo": "SHA256",
        "venue_id": venue_id,
        "symbol": symbol,
        "account_id": pseudonymize(account_id, venue_id),
    }
Enter fullscreen mode Exit fullscreen mode

Property 1: tamper-evidence with a hash chain

The core idea is a hash chain: each event carries a hash of its own contents (event_hash) plus the hash of the event before it (prev_hash). Because each link commits to the previous one, editing any past event changes its hash, which breaks every event after it — and the break is detectable by anyone holding the chain.

The one subtlety is what exactly you hash. You need a canonical, deterministic serialization, or two parties will compute different hashes for the same data. VCP uses RFC 8785-style canonical JSON (sorted keys, no whitespace). The reference implementation commits to the event's identity and timing, the full payload, and the previous hash:

def event_hash(header: dict, payload: dict, prev_hash: str) -> str:
    """SHA-256 over canonical JSON (RFC 8785 style: sorted keys, compact)."""
    canonical = {
        "header": {
            "event_id": header["event_id"],
            "trace_id": header["trace_id"],
            "timestamp_int": header["timestamp_int"],
            "event_type_code": header["event_type_code"],
        },
        "payload": payload,
        "prev_hash": prev_hash,
    }
    blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()
Enter fullscreen mode Exit fullscreen mode

Now a logger that maintains the chain. I'll fold in Property 2 at the same time, because it's one line: sign each event_hash with an Ed25519 private key.

Property 2: independent verifiability with public-key signatures

This is the property that quietly matters most, and the one most "tamper-proof" logs get wrong. Some tools chain entries with a keyed hash (HMAC). That proves integrity to whoever holds the shared key — usually the operator. It does not let an independent auditor verify anything without being handed that secret. Public-key signatures fix this: the operator signs with a private key, and anyone with the public key can verify, while no one but the operator can produce valid signatures. VCP uses Ed25519 (and is migrating toward post-quantum ML-DSA / FIPS 204).

class DecisionLog:
    def __init__(self, signing_key: Ed25519PrivateKey, venue_id="DEMO-VENUE"):
        self.key = signing_key
        self.venue_id = venue_id
        self.prev = GENESIS
        self.events = []

    def emit(self, event_type, code, symbol, payload, account_id, trace_id=None):
        header = new_header(event_type, code, symbol, account_id,
                            self.venue_id, trace_id)
        h = event_hash(header, payload, self.prev)
        signature = self.key.sign(bytes.fromhex(h)).hex()  # sign the digest
        event = {
            "header": header,
            "payload": payload,
            "security": {
                "event_hash": h,
                "prev_hash": self.prev,
                "signature": signature,
                "sign_algo": "Ed25519",
            },
        }
        self.prev = h          # advance the chain
        self.events.append(event)
        return event
Enter fullscreen mode Exit fullscreen mode

Let's emit a realistic lifecycle: a signal, the order it produced, and the fill — all sharing one trace_id:

key = Ed25519PrivateKey.generate()
log = DecisionLog(key)

sig = log.emit("SIG", 1, "XAUUSD", {
    "vcp_gov": {
        "algo_id": "ALGO_001", "algo_version": "2.1.0", "algo_type": "HYBRID",
        "confidence": "0.87",
        "decision_factors": [
            {"name": "RSI", "weight": "0.3", "value": "72.5"},
            {"name": "MACD", "weight": "0.25", "value": "positive"},
        ],
    }
}, account_id="12345")

trace = sig["header"]["trace_id"]

log.emit("ORD", 2, "XAUUSD", {
    "trade_data": {"order_id": "ORD_001", "side": "BUY",
                   "order_type": "LIMIT", "price": "2650.50", "quantity": "1.00"}
}, account_id="12345", trace_id=trace)

log.emit("EXE", 4, "XAUUSD", {
    "trade_data": {"order_id": "ORD_001", "execution_price": "2650.55",
                   "executed_qty": "1.00", "slippage": "0.05", "commission": "2.50"}
}, account_id="12345", trace_id=trace)

for e in log.events:
    print(e["header"]["event_type"], e["security"]["event_hash"][:16],
          "<-", e["security"]["prev_hash"][:16])
Enter fullscreen mode Exit fullscreen mode
SIG 9f2c1a04e7b6d8c0 <- 0000000000000000
ORD 3b71e9aa55c2f014 <- 9f2c1a04e7b6d8c0
EXE c0d4f8819ab33e72 <- 3b71e9aa55c2f014
Enter fullscreen mode Exit fullscreen mode

Each prev_hash equals the previous event's event_hash. Now the verifier — and notice it needs only the public key, nothing secret:

def verify_chain(events, public_key: Ed25519PublicKey):
    prev = GENESIS
    for i, e in enumerate(events):
        sec = e["security"]
        # 1. recompute the hash — catches any altered field
        recomputed = event_hash(e["header"], e["payload"], sec["prev_hash"])
        if recomputed != sec["event_hash"]:
            return False, f"event {i}: hash mismatch (record was altered)"
        # 2. check the chain link — catches reordering / deletion
        if sec["prev_hash"] != prev:
            return False, f"event {i}: broken chain link"
        # 3. check the signature — catches forged records
        try:
            public_key.verify(bytes.fromhex(sec["signature"]),
                              bytes.fromhex(sec["event_hash"]))
        except InvalidSignature:
            return False, f"event {i}: invalid signature"
        prev = sec["event_hash"]
    return True, f"{len(events)} events verified"


pub = key.public_key()
print(verify_chain(log.events, pub))
Enter fullscreen mode Exit fullscreen mode
(True, '3 events verified')
Enter fullscreen mode Exit fullscreen mode

Now let's be the bad actor. Suppose someone changes the execution price after the fact — say a fill was worse than reported:

log.events[2]["payload"]["trade_data"]["execution_price"] = "2649.00"
print(verify_chain(log.events, pub))
Enter fullscreen mode Exit fullscreen mode
(False, 'event 2: hash mismatch (record was altered)')
Enter fullscreen mode Exit fullscreen mode

The edit changed the canonical bytes, so the recomputed hash no longer matches the stored one. Had the attacker also rewritten the stored hash to match, the chain link (and every subsequent prev_hash) would break instead, and the signature wouldn't verify. There is no single-field edit that survives all three checks without the private key.

Property 3: omission detection

Here's the harder attack, and the one most audit trails have no answer for: the operator doesn't edit an inconvenient record — they simply never write it, or they show the regulator a different log than the one that actually ran. This is the split-view problem. A hash chain alone doesn't stop it, because a chain over a curated subset is internally consistent.

The fix is to commit to the completeness of a batch: compute a Merkle root over the event hashes and publish that root. Later, anyone can recompute the root from the events you show them; if it doesn't match what you committed to, an event was dropped or swapped. Here's an RFC 6962-style Merkle tree (with domain-separation tags so a leaf can't be passed off as an internal node):

def _leaf(data: bytes) -> bytes:
    return hashlib.sha256(b"\x00" + data).digest()   # leaf prefix

def _node(left: bytes, right: bytes) -> bytes:
    return hashlib.sha256(b"\x01" + left + right).digest()  # node prefix

def merkle_root(event_hashes) -> str:
    if not event_hashes:
        return hashlib.sha256(b"").hexdigest()
    layer = [_leaf(bytes.fromhex(h)) for h in event_hashes]
    while len(layer) > 1:
        nxt = []
        for i in range(0, len(layer), 2):
            if i + 1 < len(layer):
                nxt.append(_node(layer[i], layer[i + 1]))
            else:
                nxt.append(layer[i])     # promote the odd node (RFC 6962)
        layer = nxt
    return layer[0].hex()


def inclusion_proof(event_hashes, index):
    """Audit path proving event_hashes[index] is in the committed set."""
    layer = [_leaf(bytes.fromhex(h)) for h in event_hashes]
    proof = []
    while len(layer) > 1:
        nxt = []
        for i in range(0, len(layer), 2):
            if i + 1 < len(layer):
                if i == index:
                    proof.append(("R", layer[i + 1].hex()))
                elif i + 1 == index:
                    proof.append(("L", layer[i].hex()))
                nxt.append(_node(layer[i], layer[i + 1]))
            else:
                nxt.append(layer[i])
        index //= 2
        layer = nxt
    return proof


def verify_inclusion(event_hash_hex, proof, root_hex) -> bool:
    cur = _leaf(bytes.fromhex(event_hash_hex))
    for side, sibling in proof:
        sib = bytes.fromhex(sibling)
        cur = _node(cur, sib) if side == "R" else _node(sib, cur)
    return cur.hex() == root_hex
Enter fullscreen mode Exit fullscreen mode

Commit to the batch, then prove a specific event belongs to it:

hashes = [e["security"]["event_hash"] for e in log.events]
root = merkle_root(hashes)
print("committed root:", root[:24], "")

# Prove the ORD event (index 1) is part of the committed set
proof = inclusion_proof(hashes, 1)
print("ORD in committed set:",
      verify_inclusion(hashes[1], proof, root))

# Now imagine the operator hides the ORD event entirely
tampered = [hashes[0], hashes[2]]   # ORD dropped
print("root after dropping ORD matches original:",
      merkle_root(tampered) == root)
Enter fullscreen mode Exit fullscreen mode
committed root: 7d1f0a9c4b2e8835ac6e3f10 …
ORD in committed set: True
root after dropping ORD matches original: False
Enter fullscreen mode Exit fullscreen mode

Once the root is published, the set is frozen. Dropping the order, swapping it, or inserting a fabricated event all produce a different root.

Property 4: external anchoring

The Merkle root is only as trustworthy as wherever you publish it. If you keep it on your own server, you can still rewrite it later. The point of anchoring is to put that commitment somewhere you don't control and can't quietly change — an append-only transparency log (the same mechanism Certificate Transparency uses for TLS certs), a SCITT transparency service, or a public blockchain. You get back an inclusion receipt and a timestamp you can't forge after the fact.

The code is just the interface; the security comes from the log being operated by someone other than you:

def anchor(merkle_root_hex: str) -> dict:
    """In production: submit to an append-only transparency log
    (RFC 6962 / SCITT / public chain) operated by an INDEPENDENT party.
    Returns a receipt you can later show to a third party."""
    return {
        "root": merkle_root_hex,
        "log": "transparency-log.example",
        "anchored_at": time.time_ns(),
        # ...inclusion receipt / signed checkpoint from the log
    }

receipt = anchor(root)
print("anchored:", receipt["root"][:24], "@", receipt["anchored_at"])
Enter fullscreen mode Exit fullscreen mode

That's the whole stack: hash chain (Property 1) makes each record tamper-evident; Ed25519 signatures (Property 2) make them verifiable by an outsider; Merkle commitments (Property 3) make omission detectable; anchoring (Property 4) puts the commitment beyond the operator's reach. Around 150 lines, all open primitives.

What this does not give you

This is the part to be honest about, because over-promising is how trust infrastructure loses trust.

A flight recorder does not fly the plane, and it does not stop the crash. This log would not have prevented the Meta takeovers. It is not a real-time safety system, it does not block a bad decision, and it does not judge whether a decision was right — it can prove an algorithm denied an order at a specific time on specific inputs under a specific model version, but whether that was fair or lawful is a question for humans and regulators. And it does not make any system "compliant." No logging standard makes you compliant with the EU AI Act or MiFID II any more than a black box makes an airline compliant with aviation law. What it does is generate the evidence those regimes increasingly ask for — the records an auditor requests, the trail an incident review reconstructs, the proof a dispute turns on.

That's a narrow claim and a strong one at the same time: it won't stop the failure, but it lets you prove what happened to someone who doesn't have to take your word for it.

A couple of practical notes the demo glossed over. We pseudonymized account IDs at write time; for real privacy you also want crypto-shredding — encrypt sensitive fields per-subject and delete the key to render them unrecoverable, which lets you honor a GDPR erasure request without breaking the hash chain (the hash is over ciphertext, which stays put). And the conformance "tiers" you'll see in VCP (Silver/Gold/Platinum) are really verification depth — clock-sync precision, whether Merkle proofs and external anchoring are mandatory — not separate protocols.

Where to go next

The full event model, JSON Schemas, and conformance tests live in the open spec at github.com/veritaschain/vcp-spec. The approach aligns with the IETF's SCITT (Supply Chain Integrity, Transparency and Trust) work on COSE-signed statements and receipts — though, to be precise, the VCP-related IETF submissions are individual Internet-Drafts at this stage, not adopted standards, and the protocol has not yet been deployed in production at an institution.

If you maintain a system that makes consequential automated decisions — trades, approvals, agent actions — the exercise worth doing is simple: pick one decision your code makes today, and ask whether you could prove to an outsider what it decided and that the record is intact. If the answer is "trust our logs," you have a logging system, not a black box. The gap between those two is the whole game.

Disclosure: I work on open standards for verifiable AI decision provenance, including VCP and the broader Verifiable AI Provenance (VAP) framework at the VeritasChain Standards Organization. The code here is a condensed teaching version; it's faithful to VCP's event model but trimmed for clarity. Corrections and pull requests welcome.

Top comments (0)