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).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. 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
The per-tenant tamper-evident ledger records gateway request and control events. The request routes below share an admission pipeline. These are the event types relevant to this mapping:
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. 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 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 off-host archive of the canonical chain rows and anchor bundles, 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.
- A configuration flag can select the synchronous append instead of the queued one; it takes effect on a service restart. The synchronous path is fail-closed like the queued path: when a control plane is configured and the ledger append fails, the request is refused, so the flag can defer how a record is written but not suppress one. The 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; 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.
- 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.
crates/gateway/src/audit.rs and is independently reviewable.
Article 12(2)(b) — risk-situation identification
Every ledger row carries a structuredevent_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’s ledger retention window is seven years. Two properties back 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 window 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. Workspaces without the Enterprise export grant are not covered by this mapping.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) atpackages/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.
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).
For a review, the recommended practice is to 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:Article 12(3)(d) — prevention of unauthorised access
Tenant isolation is enforced at two independent layers — see/security/tenant-isolation:
- 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. - Postgres-side: every ledger read is keyed on the caller’s tenant —
each statement in
crates/gateway/src/db/ledger.rsfilters on a boundtenant_idparameter, with the tenant resolved from the validated claim — and control-plane actions are recorded in theadmin_audit_logtable (apps/web/db/schema.ts). Postgres row-level security is not enabled; scoping is by query filter.
Known gaps
This mapping describes what ships today. The following are not shipped:- 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. No job deletes audit data today.
- PDF export. Not available.
- Per-tenant chain-state isolation hardening. The per-tenant
seqand 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).