> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tracelane.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Audit control mapping — EU AI Act Article 12

> Technical control mapping from Tracelane Audit capabilities to EU AI Act Article 12 recordkeeping topics, with source citations and coverage gaps.

# 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`](https://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`](/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:

| Event type                 | Emitted by                                                           | What it records                                                     |
| -------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `chat.completions.request` | the `Chat` route, `crates/gateway/src/admission.rs:825`              | every `/v1/chat/completions` call proxied by the gateway            |
| `embeddings.request`       | the `Embeddings` route, `crates/gateway/src/admission.rs:901`        | every `/v1/embeddings` call proxied by the gateway                  |
| `messages.request`         | the `Messages` route, `crates/gateway/src/anthropic_messages.rs:936` | every `/v1/messages` (Anthropic-native) call proxied by the gateway |
| `guardrail.verdict`        | `crates/gateway/src/guardrail/recorder.rs:107-115`                   | every guardrail-layer decision                                      |
| `eval.verdict`             | `crates/gateway/src/prompt_routes.rs:96`                             | prompt promotion / rollback decisions                               |

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`](https://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:

```bash theme={"system"}
tracelane-audit verify --file ledger.ndjson --tenant-pubkey <key>
```

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`](/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: `security@tracelane.dev` (PGP key
fingerprint on the GitHub Security page).
