Skip to main content

Audit control mapping — EU AI Act Article 12

Effective date: 2026-06-16 (V1 launch). Coverage: every workspace on the Enterprise plan (the Article-12 export grant, f_audit_addon = TRUE in workspace_entitlements).
What this page is — and is not. This is a technical control mapping: it describes what Tracelane ships, cited to source, grouped under the Article 12 topics engineering and compliance teams most often ask about. It is not a conformance statement, a certification, an attestation, or legal advice, and Tracelane makes no representation that deploying these controls satisfies any regulatory obligation. Whether your deployment meets an obligation is a determination for you and your assessor. Every capability below is either linked to the source that implements it or explicitly marked as not shipped.
Written for engineering and compliance teams evaluating what the Audit add-on actually records, and for third-party reviewers who need to verify a ledger export independently.
Status — what is live vs. on the roadmap. The per-tenant SHA-256 hash chain is live and universal (every workspace, including the free tier) and is independently verifiable today by recomputing the chain offline. Ed25519 Merkle-root batch signing, Merkle aggregation, and Sigstore Rekor v2 public-transparency anchoring are live in production for Enterprise workspaces: each batch’s Merkle root is Ed25519-signed and best-effort anchored to the public Sigstore Rekor v2 log (log2025-1.rekor.sigstore.dev), and each anchor’s inclusion proof + signed checkpoint are captured so a reviewer can re-verify offline against the public log. Two honest scope notes: (1) anchoring is best-effort — if the public log is unreachable a batch stays signed-locally-but-not-yet-anchored, and the hash chain + Ed25519 signature still hold (the tamper-evidence guarantee never depends on the external log being up); (2) the chain today covers gateway-proxied events (LLM provider calls, guardrail verdicts and prompt-promotion verdicts routed through the Tracelane gateway) — spans captured via the SDK/OTLP self-host path are recorded full-fidelity but are not chained or anchored (batch-digest coverage of that path is the next increment). The tamper-evidence guarantee rests on the live hash chain + offline recompute; the Ed25519 signature and Rekor inclusion proof are additional, independently-verifiable layers on top.
For the reviewer-runnable verifier, see /audit/verifier-cli.

Control mapping

Article 12(2)(a) — automatic log generation

Exactly five runtime events are written into the per-tenant tamper-evident ledger, and it is worth being precise about which. The three request events are published by one shared admission pipeline (crates/gateway/src/admission.rs:759-765), each route contributing its own event type: Tool invocations, agent-to-agent handoffs and MCP invocations are captured as spans but are not chained — they are not in the ledger today. How the write happens. The write is asynchronous by design. The gateway hot path performs an acked JetStream publish before it dispatches to the provider (crates/gateway/src/audit.rs, AuditChain::publish); a publish failure returns 503 audit_unavailable rather than serving an unrecorded request (crates/gateway/src/admission.rs, the audit step). The head-advance itself — sequence assignment under a per-tenant row lock, the ledger row and the chain head in one Postgres transaction, the commit — runs off the request path in a single head-writer consumer (crates/gateway/src/audit_consumer.rs), which acks the JetStream message only after the append commits. A crash between commit and ack replays into an idempotent no-op: no gap, no duplicate sequence number. The ClickHouse copy the dashboards read is written after the commit and never on the path that assigns a sequence; export and verification read the Postgres store and do not fall back to the copy. What “acked” does and does not guarantee — corrected 2026-09-20, restated by failure mode 2026-09-21. Until 2026-09-20 this paragraph said the acked publish meant the event was “durably captured before the request is served”. That overstated it. The ack means the JetStream server has written the event to its file store; the store is fsynced on the server’s sync_interval, which production runs at 100 ms (the production NATS configuration, read back from the running server’s /jsz; nats-server’s own default is 2 minutes). The event reaches the canonical Postgres ledger when the head-writer consumes it, normally within milliseconds — the lag is audit_backlog on /health. The production node is a single un-replicated JetStream server; a replicated stream is not deployed. So, failure by failure:
  • The nats-server process crashes: nothing is lost — the OS still holds the written pages and flushes them.
  • The gateway crashes: nothing is lost — unconsumed events wait in the stream and are consumed on restart.
  • The OS crashes or the node loses power: events acknowledged in the last ≤ 100 ms that had not yet been committed to the ledger can be lost. A request served in that window has no ledger row, and the chain cannot tell you which one — the sequence simply does not contain it.
  • The production host or its single volume is lost: the same in-flight buffer is lost (the unconsumed backlog, normally milliseconds deep), and nothing else of the ledger — the chain rows and anchor bundles are not on that node; they live in the Postgres control plane, off the host.
  • The ledger’s own store is lost: what protects the ledger is Tracelane’s own hourly archive of the canonical chain rows and anchor bundles to object storage, each file count-checked against the store before upload — recovery point ≤ 1 hour. That is the number we state. We do not rest any durability claim on the database provider’s point-in-time-recovery window: its retention for our project has not been read and verified, so it is not claimed.
Two honest notes on the failure modes.
  1. A kill.audit.async kill-switch flag (declared at audit.rs:374, read at audit.rs:1053-1057) selects the synchronous append instead of the queued one. Corrected 2026-08-14 — this sentence previously read “forces the synchronous append fleet-wide with no redeploy”, and that overstated the control in two ways. The flag set is read once, at process start (kill_switch.rs:65-113, from TRACELANE_KILLSWITCH_FLAGS into an immutable snapshot; a periodic refresh exists in the code only when a feature-flag service is configured, which production does not), so changing it takes effect on a service restart — it is not a live runtime toggle. And in the current production deployment no flag is set at all, so today this fallback is reached only by the conditions below (JetStream unavailable), never by an operator. We are correcting this rather than restating it because a conformance statement that credits us with a control we cannot exercise is exactly the kind of claim this document exists not to make. Corrected again 2026-09-21 — this note said the fallback was “deliberately fail-open: if the append fails it logs a warning and the request is served with no audit record”. That has been false since 2026-08-11. The synchronous fallback is fail-closed like the queued path: when a control plane is configured and the ledger append fails, the request is refused (audit.rs:1060-1088; the test kill_audit_async_cannot_suppress_the_record pins it). The flag can therefore defer how a record is written; it can no longer suppress one. The honest limit that remains is the deployment tier with no control plane — dev and self-host without Postgres — where the record is a process-local hash chain plus a ClickHouse table written through a bounded, batched, retried queue (audit.rs:1740); a request whose row cannot be queued is refused, rows still queued when the process exits are lost, and that tier is not the tamper-evident ledger this statement describes and is not claimed as one.
  2. An operator with control of that flag can therefore change the failure mode, and the flag’s state is not itself recorded in the ledger. What the chain proves is that the records it does contain have not been altered — not that no record was ever suppressed upstream of it.
The implementation is open-source under Apache-2.0 at crates/gateway/src/audit.rs and is independently reviewable.

Article 12(2)(b) — risk-situation identification

Every ledger row carries a structured event_type, the identifying actor (the subject of the validated claim), the event_time, the per-tenant seq, and the prev_hash / row_hash pair that chains it. The payload is metadata by construction — never prompt or response content. For a chat.completions.request row it is model, warn_aft_id (the AFT-1 identifier when a guardrail fired, otherwise null) and trace_id (crates/gateway/src/admission.rs:851-866); for an embeddings.request row it is model, input_count and trace_id (admission.rs:925-935); for a messages.request row it is model, warn_aft_id, stream and trace_id (crates/gateway/src/anthropic_messages.rs:979-990). Each of the three carries business_reference in addition when the caller supplied one (admission.rs:755-757). Payloads pass through PII redaction (crates/policy/src/pii.rs) and then a structural cap: any string value longer than 256 characters is replaced by a [content-redacted: len=N, sha256=…] marker (crates/gateway/src/audit.rs:70), so long content cannot enter the ledger even by accident — while the length and hash preserve the ability to prove a specific value was present. A guardrail.verdict payload is the serialized verdict (rail, decision, matched signature); an eval.verdict payload is the promotion decision. The practical consequence: the ledger answers “which calls ran, when, by whom, against which model, and what did the guardrail layer decide” — cryptographically. It does not contain the prompts or completions themselves. Those live full-fidelity in the span store, which is queryable but not chained.

Article 12(2)(c) — retention floor

The Enterprise plan carries a 180-day minimum retention commitment for ledger data. Two properties carry it and both are checkable against a running system: no scheduled job deletes ledger data — the retention sweep operates on span tables only — and the ledger tables carry no TTL, so no row expires on its own. Stated precisely, because the limit matters more than the reassurance: that floor is operational and contractual rather than programmatically enforced. Retention holds because nothing expires ledger rows automatically, not because a runtime check refuses a shorter value. A shorter contractual term would be a process failure, not one the software currently refuses. Workspaces without the Enterprise export grant are not covered by this mapping. There is no per-plan expiry of ledger data to describe: no ledger table carries a TTL and the retention sweep operates on span tables only, as this document states below. Ledger data is retained indefinitely on every plan today.

Article 12(3)(a) — reproducible verification procedure

The audit-ledger format — row hash function, per-tenant chain construction, Merkle aggregation, Rekor anchor procedure — is pinned by three independent reference verifiers (Rust, Python, TypeScript) at packages/verifier-{rust,python,typescript}/, which produce byte-identical VerifyReport JSON for the same input. The verifier-roundtrip CI job runs all three over the shared vectors in evals/audit-ledger/ on every change and fails if they disagree, demonstrating that the procedure is specification-driven, not implementation-locked. The live conformity check is the per-tenant SHA-256 hash-chain replay, the per-batch Ed25519 Merkle-root signature, and — for anchored batches — the Sigstore Rekor v2 inclusion proof, all verified offline. Tracelane operates no private fork of Sigstore Rekor: each Merkle-root anchor is published to the public log2025-1.rekor.sigstore.dev transparency log operated by the Linux Foundation Open Source Security Foundation (OpenSSF), so a reviewer can re-verify each anchor independently — offline against the bundled inclusion proof + signed checkpoint — without trusting any Tracelane HTTPS endpoint. Public anchoring is operating in production (first anchor: log index 19398597, 2026-07-13). Forgery resistance (why a real Rekor entry is not enough). Rekor v2 is a permissionless log: anyone can submit any well-formed entry and receive a real inclusion proof + checkpoint. A “real Rekor entry” alone therefore proves only that some body was admitted to the log — not that Tracelane admitted it. Tracelane’s verifier closes this gap: it accepts an anchor only when the batch’s Ed25519 signature verifies against the workspace’s own public key (delivered out-of-band, not taken from the anchor bundle), and that signature cryptographically binds the anchor’s ECDSA key, anchor state, and log index. The published conformance vectors include forged-anchor.ndjson — a genuine, publicly-queryable Rekor entry planted by an attacker’s own key over a tampered chain — and all three reference verifiers reject it while accepting the legitimately-anchored anchored.v1.ndjson.

Article 12(3)(b) — operator log access

GET /v1/audit/export?since=<iso8601>&until=<iso8601>&limit=<n> streams the complete per-tenant ledger as NDJSON, seq-paginated and uncapped (crates/gateway/src/audit_export.rs). Each line carries the exact field set the three reference verifiers consume: format, tenant_id, seq, event_time, event_type, actor, payload, prev_hash, row_hash, rekor_entry_id. The dashboard’s Download control on /audit proxies the same endpoint. Authentication is the ordinary bearer credential — a WorkOS JWT or a tlane_… API key — and the tenant is resolved only from the validated claim, never from a query parameter or request body. The endpoint is gated on the Enterprise export entitlement (FeatureKey::AuditAddon): an unentitled caller receives 403 entitlement_required and zero ledger bytes, and if the entitlement source is unavailable the export is refused rather than served (fail-closed). One honest limitation on the credential model: API keys are workspace-scoped and revocable, but they are not per-endpoint-scoped and carry no expiry unless you set them — the api_keys table has both a scope and an expires_at column (apps/web/db/schema.ts). Access for a review is therefore narrowed operationally: mint a key for the workspace under review, and revoke it when the review closes.

Article 12(3)(c) — independent third-party review

A workspace owner exports the range under review (§12(3)(b)) and hands the reviewer two things: the NDJSON export, and the workspace’s Ed25519 public key delivered out-of-band (a key taken from the bundle itself would prove nothing). The reviewer then runs:
Every check it performs is offline — no Tracelane endpoint is contacted and no trust in Tracelane-operated infrastructure is required: the hash-chain replay, the per-batch Ed25519 signature against the workspace’s own public key, and — for anchored batches — the Sigstore Rekor v2 inclusion proof + signed checkpoint against the pinned public-log key. These Rekor proofs are produced in production today and travel inside the export bundle. The release workflow that publishes the static verifier binary is configured to Cosign-sign it via GitHub Actions OIDC and attach build provenance and a CycloneDX SBOM; that workflow has not yet run on a tag, so today the binary is built from source (Apache-2.0) rather than downloaded as a signed artefact. The verification itself is unaffected either way.

Article 12(3)(d) — prevention of unauthorised access

Tenant isolation is enforced at two independent layers — see /security/tenant-isolation:
  1. ClickHouse-side: every query carries a WHERE tenant_id = ? filter (a CI guard, scripts/ci/check-tenant-isolation.py, blocks any new query that omits it) plus per-tier resource caps.
  2. Postgres-side: every ledger read is keyed on the caller’s tenant — each statement in crates/gateway/src/db/ledger.rs filters on a bound tenant_id parameter, with the tenant resolved from the validated claim — and control-plane actions are recorded in the admin_audit_log table (apps/web/db/schema.ts). Postgres row-level security is not enabled; scoping is by query filter.

Known gaps and V1.1 follow-ups

This mapping describes V1 launch. The following are not shipped today:
  • Ledger coverage of the SDK/OTLP path. Only gateway-proxied events are chained. Spans captured via the SDK or OTLP direct path are recorded full-fidelity but are not chained or anchored.
  • Ledger coverage of tool, A2A and MCP invocations. Recorded as spans; not written as ledger events.
  • Automated audit-data retention cleanup job. V1 does not delete audit data, so the 180-day floor is trivially satisfied; V1.1 ships a scheduled cleanup that enforces the retention ceiling.
  • PDF export. tracelane-audit export --format pdf is queued for V1.1. V1 produces the equivalent human-readable content via --format text and --format json.
  • Per-tenant chain-state isolation hardening. V1’s per-tenant seq and per-tenant Ed25519 keypair already enforce per-tenant cryptographic integrity. Further in-process isolation, as defence in depth against operator-side error, is on the roadmap.
  • Signed, published verifier binaries. Configured but not yet run on a tag — see §12(3)(c).

Contact

For technical questions about what the ledger records, or to request the export in an alternative format: [email protected] (PGP key fingerprint on the GitHub Security page).