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.
- A
kill.audit.asynckill-switch flag (declared ataudit.rs:374, read ataudit.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, fromTRACELANE_KILLSWITCH_FLAGSinto 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 testkill_audit_async_cannot_suppress_the_recordpins 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. - 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.
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 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) 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 (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: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 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 pdfis queued for V1.1. V1 produces the equivalent human-readable content via--format textand--format json. - Per-tenant chain-state isolation hardening. V1’s 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).