G-11 Audit evidence (WORM)
Brick G of the Enterprise AI Platform. Technology: Hash chained eai.audit_events + immutable WORM object storage (S3 Object Lock compliance mode). French version: ../../fr/bricks/G-11-audit-evidence.md.
Role and responsibilities
- Record every mutation and policy decision with prev_hash/hash chain and sequence per tenant.
- Export signed periods to WORM storage and verify integrity at any time.
Position and interactions
- Execution path: synchronous write on every mutation, batch export.
- Written in the same transaction as the business change (eai.audit.audit()).
Open source and developed parts
- Open source reused: PostgreSQL, S3 Object Lock (SeaweedFS, Ceph RGW or cloud).
- Developed in the platform: Hash chain, verify endpoint, export with manifest and sha256, evidence mapping to controls.
Data model
Control Plane tables (schema eai): eai.audit_events, eai.audit_exports.
Every tenant table has tenant_id uuid NOT NULL and a row level security policy on current_setting('app.tenant_id', true)::uuid. ClickHouse usage_events is the analytics replica of eai.usage_events (ReplacingMergeTree keyed by event_id).
Contract: contracts/v1/audit-event.schema.json (schema_version 1.0). Usage is always reported with contracts/v1/usage-event.schema.json, audit with contracts/v1/audit-event.schema.json.
API endpoints
| Method | Path |
|---|---|
| GET | /v1/audit/events |
| GET | /v1/audit/events/{id} |
| GET | /v1/audit/verify |
| POST | /v1/audit/export |
GET /v1/audit/events
Response:
{
"items": [
{
"event_id": "aud_901",
"sequence": 90311,
"action": "model.invoke",
"decision": "allow",
"outcome": "success",
"hash": "9c1e..."
}
],
"next_cursor": "c_2"
}
GET /v1/audit/events/{id}
Response:
{
"event_id": "aud_901",
"actor": {
"type": "agent",
"id": "agt_ap"
},
"resource": {
"type": "model",
"id": "gpt-4o-mini"
},
"prev_hash": "77ab...",
"hash": "9c1e..."
}
GET /v1/audit/verify
Response:
{
"valid": true,
"checked": 90311,
"first_sequence": 1,
"last_sequence": 90311,
"broken_at": null
}
POST /v1/audit/export
The format field selects ndjson (default) or csv (one row per event, nested objects as JSON cells, the canonical event in the last event column); the signature covers the file in the chosen format. Another value gives a 422.
Request:
{
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z",
"retention_days": 3650
}
Response:
{
"export_id": "aex_3",
"status": "completed",
"uri": "s3://eai-audit-worm/tenant/2026-09.jsonl",
"sha256": "e3b0c442...",
"object_lock": "COMPLIANCE"
}
SDK examples
Python
from enterprise_ai import EnterpriseAI
client = EnterpriseAI(base_url="<control-plane-url>", api_key="eai_live_xxx", tenant="acme", locale="en")
client.audit.events(action="model.invoke", since="2026-10-01T00:00:00Z")
client.audit.get("aud_901")
client.audit.verify()
client.audit.export(since="2026-09-01T00:00:00Z", until="2026-10-01T00:00:00Z")
TypeScript
import { EnterpriseAI } from "@enterprise-ai/sdk";
const client = new EnterpriseAI({ baseUrl: "<control-plane-url>", apiKey: process.env.EAI_API_KEY, tenant: "acme", locale: "en" });
await client.audit.list({ action: "model.invoke", from: "2026-10-01T00:00:00Z" });
await client.audit.get("aud_901");
await client.audit.verify();
await client.audit.export({ from: "2026-09-01T00:00:00Z", to: "2026-10-01T00:00:00Z" });
Java
EnterpriseAIClient client = EnterpriseAIClient.builder()
.baseUrl("<control-plane-url>")
.apiKey(System.getenv("EAI_API_KEY"))
.tenant("acme")
.locale(Locale.ENGLISH)
.build();
client.audit().events(Map.of("action", "model.invoke", "from", "2026-10-01T00:00:00Z"));
client.audit().event("aud_901");
client.audit().verify();
client.audit().export(Map.of("from", "2026-09-01T00:00:00Z", "to", "2026-10-01T00:00:00Z"));
Go
client, err := eai.NewClient(eai.WithBaseURL("<control-plane-url>"), eai.WithAPIKey(os.Getenv("EAI_API_KEY")), eai.WithTenant("acme"), eai.WithLocale("en"))
if err != nil { log.Fatal(err) }
ctx := context.Background()
_, err = client.Audit.Events(ctx, eai.WithQuery(map[string]string{"action": "model.invoke", "from": "2026-10-01T00:00:00Z"}))
_, err = client.Audit.Event(ctx, "aud_901")
_, err = client.Audit.Verify(ctx)
_, err = client.Audit.Export(ctx, map[string]any{"from": "2026-09-01T00:00:00Z", "to": "2026-10-01T00:00:00Z"})
Configuration
Environment variables: EAI_S3_BUCKET.
Helm values keys (deploy/helm/enterprise-ai/values.yaml): controlPlane.config.EAI_S3_BUCKET.
Security
- No UPDATE or DELETE grants on eai.audit_events for the application role.
- Readers: auditor and security_officer roles.
- Tenant resolved from the credential, never from the body; PostgreSQL row level security on every tenant table.
- Every mutation and policy decision writes a hash chained audit event (G-11).
- Errors use the standard envelope with message_i18n en and fr.
Observability
- Daily verify job; alert on broken_at not null.
- W3C traceparent propagated, X-Request-Id returned, spans carry eai.tenant_id, eai.project, eai.agent_id, eai.run_id.
FinOps units
- WORM storage cost allocated per tenant (storage_gb_hours).
Failure modes and resilience
- Audit write fails -> the business transaction is rolled back (no unaudited change).
Standalone adoption
Usable as an append-only evidence ledger by other systems through the audit API.
Acceptance tests
- Tampering one row makes GET /v1/audit/verify return valid=false with broken_at.
- Deleting an exported object before retention fails at storage level.
Evidence Service (kit section 11)
The eai.domains.evidence domain (routes /v1/evidence, migrations V150 to V154) completes the chain:
- transactional outbox
eai.audit_outboxwritten in the transaction of the audited change, drained by theaudit_gatewayjob (SIEM HTTP and NATS JetStream, idempotency key = event id, exponential backoff, no sampling, sequence gap detection); - Ed25519 signed seals (
eai.audit_seals) over ranges of events: sha256 Merkle root of the RFC 8785 canonical events, hash of the previous seal, key id; verified byGET /v1/evidence/seals/{id}/verifyand offline bypython -m eai.domains.evidence.verify bundle.json; - integrity monitor (
audit_integrityjob) recomputing chains, seals, signatures and timestamps, opening aneai.audit_integrity_alertsalert and asecurity.audit.integrity_alertevent; - three distinct timestamps:
event_time(from the caller, lead bounded byEAI_AUDIT_CLOCK_SKEW_SECONDS),ingested_at(database clock at insert) andsealed_at; - sensitive payloads encrypted with a Tink envelope (key outside the database: OpenBao transit), only the ciphertext digest is chained; decryption reserved to the
auditorandsecurity_officerroles, with a reason, itself audited; - legal hold (tenant, case, time range) blocking purge and erasure, released by two distinct approvers, purge evidence (counts and digests, never the contents);
- execution configuration of a run (model, prompt, tool, skill, plugin) rebuilt by
GET /v1/evidence/runs/{id}/configuration.
Tests: services/control-plane/tests/test_evidence_gateway.py, services/control-plane/tests/test_evidence_config_redaction.py.