Article 12 of the EU AI Act requires high-risk AI systems to "technically allow for the automatic recording of events (logs) over the lifetime of the system." Most teams read this and enable logging. That may satisfy the letter of the obligation today.

It will not survive the first audit.

A log you hold in your own database is mutable. You can alter, delete, or back-date any row in it — and nothing in the log itself would show that you did. A market-surveillance authority, an external auditor, or a counterparty in a dispute has no way to verify the log's integrity independently. At best, they can ask you to confirm it. That is not evidence. That is your word.

This guide shows the implementation gap and how to close it: from mutable logs to verifiable records, with working Python code.

What Articles 12 and 19 actually require.

Three obligations apply to the record itself.

On timing: the main high-risk obligations (Annex III stand-alone) were moved to December 2027 by the May 2026 Omnibus agreement — confirm against the Official Journal, as publication was pending at the time of writing. The content-marking obligations (Article 50, August 2026) and GPAI obligations (August 2025) are not delayed. Penalties for the high-risk record-keeping obligations reach €15M or 3% of global turnover.

For a broader reading of how the Act treats decision records, see The EU AI Act wants logs you can prove.

The implementation gap.

The gap has two parts.

Integrity. A row in a relational database, a line in a log file, or an event in an observability system can be modified after the fact. Nothing in the record prevents this. A verifiable record computes a cryptographic digest of its contents at capture time; any subsequent modification breaks the digest. This is instant and requires no authority to check.

Existence in time. Even an immutable record is only as trustworthy as the party holding it, unless its existence at a specific time is committed to public infrastructure that the holding party cannot control. An anchor commits the record's digest to a public append-only log; its timestamp becomes a mathematical fact, checkable by any independent party with no account and no trust in the record's producer.

A database row gives you neither. A hash chain gives you integrity but not operator-independent existence in time. A public anchor gives you both.

For the distinction in detail, see Tamper-evident is not tamper-proof.

Step 1 — install.

pip install "determs[anchor]"

The base package captures and verifies records locally. The [anchor] extra adds OpenTimestamps for public anchoring. Nothing else is required; no account, no API key.

Step 2 — wrap your LLM client.

Drop the wrapper around your existing Anthropic or OpenAI client. Every call then produces a structured record automatically — no change to the rest of your code.

import anthropic
from determs.anthropic import wrap as wrap_anthropic
from determs.storage import FileStorage

client = wrap_anthropic(
    anthropic.Anthropic(),
    agent_id="credit-risk-model",   # stable identifier for this AI system
    storage=FileStorage("./records"),
)

# Use exactly as before.
response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=512,
    messages=[{"role": "user", "content": "Review application #A-20411..."}],
)
# A record was written to ./records/<action_id>.json.
# Nothing was sent to a remote service. Everything stays local.

The record is plain JSON. It carries the agent identifier, model name, the full input, the model response, and four SHA-256 digests computed over RFC 8785 canonical forms: subject_digest, record_digest, input_digest, and output_digest. You can store it in S3, Postgres, a file archive, or hand it to a compliance team on a USB key.

Step 3 — verify a record.

At any point — now, in six months, in a dispute — any party with the record and the CLI can verify it:

$ determs verify --record ./records/<action_id>.json

{
  "verified": true,
  "profile": "ai.agent.action",
  "record_digest": "44f2b54990f7df71a16ff603b606225bcf2800004fa107c42950076c4b581fe4",
  "checks": {
    "subject_digest": true,
    "record_digest": true,
    "input_digest": true,
    "output_digest": true
  }
}

If any byte of the record has been modified since capture — a content string, a tool argument, a timestamp — the digests diverge and verify exits non-zero. No quiet corruption. No authority required to run the check.

Step 4 — anchor records before the retention window.

Anchoring commits each record's digest to public infrastructure. Only the digest is transmitted — the decision payload (inputs, outputs, any personal data) never leaves your environment.

import json, glob
from determs.anchor import anchor_record

# Anchor any un-anchored records.
for path in glob.glob("./records/*.json"):
    vdr = json.load(open(path))
    if not vdr.get("anchor"):
        vdr = anchor_record(vdr)          # network call — submits digest to OpenTimestamps
        json.dump(vdr, open(path, "w"), indent=2)

OpenTimestamps returns a pending proof immediately. The proof is upgraded to complete — including a Bitcoin block reference — within a few hours, once the aggregate Merkle root is committed to the chain. You can run determs verify again after ~2 hours to confirm the upgrade.

Anchor records as close to capture time as practical. Article 19's retention window requires that the records' existence in time be provable across the period; the anchor's block timestamp establishes that provably.

Step 5 — build an evidence pack for the compliance window.

At audit time — or proactively at each reporting period — bundle the anchored records for a system and period into a self-verifying evidence pack. Only digests and anchors enter the pack; the decision payloads stay in your environment.

import json, glob
from determs.registry import entry_from_vdr
from determs.compliance import build_evidence_pack

# Load all anchored records for this system and period.
records = [json.load(open(f)) for f in glob.glob("./records/*.json")]
entries = [entry_from_vdr(r) for r in records if r.get("anchor")]

pack = build_evidence_pack(
    entries,
    system="credit-risk-model",
    period_from="2026-01-01T00:00:00Z",
    period_to="2026-06-30T23:59:59Z",   # ≥ 6 months, per Art. 19
)

json.dump(pack, open("./evidence-pack-H1-2026.json", "w"), indent=2)

The pack is a self-contained JSON artefact. An auditor verifies it with:

from determs.compliance import verify_evidence_pack
import json

pack = json.load(open("./evidence-pack-H1-2026.json"))
report = verify_evidence_pack(pack)
# report["pack_digest_ok"]  — membership integrity check
# report["anchors"]         — per-record anchor status against public infra

No account with Determs is required. No running service. The proof resolves against public infrastructure.

The mapping.

How each obligation lines up with what this implementation provides:

What this is — and what it isn't.

This is the evidence layer: verifiable, provably-retained records of what your AI system did. It is not a complete compliance programme. It will not classify your system under Annex III, write your risk assessment, or stand in for a DPIA.

It is also honest about its own scope: an anchor proves a record existed at time T and is unaltered. It does not prove the underlying decision was correct, or that the action described in the record actually occurred exactly as described. The record is accountable, not correct — meaning the producer is locked into a specific claim. That accountability is what an audit needs; correctness validation is a separate layer, yours to address with testing, human review, and your compliance programme.

For the distinction between tamper-evident and operator-independent verification, see Tamper-evident is not tamper-proof. For the conceptual background on why a log you hold yourself is not evidence, see The EU AI Act wants logs you can prove.

If you operate a high-risk AI system and are working through what Article 12 requires in practice, the format is open (CC-BY-4.0), the implementation is real, and you can verify a record yourself in the browser before writing a line of code. Read the spec or open a discussion if you have implementation questions.