Audit Pipeline Specification
Status: living — kept in sync with the audit code. Last reviewed 2026-07.Scope: the end-to-end path that records every policy decision a Hexgate-wrapped agent makes, from the SDK enforcement point to durable storage in ClickHouse and the dashboard read view. This document is descriptive of the current implementation (PR
gp/feat/sdk_emit_audit_event + the platform audit endpoint). Where behaviour
is intentionally lossy or POC-grade, it says so explicitly.
1. Overview
Every time an agent proposes a tool call, the SDK’sPolicyEnforcer produces a
Decision (allow / deny / needs_approval). The audit pipeline ships a copy of
that decision — out of band, fire-and-forget — to the platform, which
validates it, resolves server-owned identity fields, and appends one immutable
row to a ClickHouse table. Audit emission is a side effect of enforcement:
it never changes, blocks, or fails the decision the agent acts on.
Design principles
- Enforcement is authoritative; audit is observational. The
Decisionreturned to the agent is the source of truth. Audit failures (network down, platform 503, saturation) degrade silently and never propagate to the caller. The sameDecisionis also surfaced locally byhexgate chat’s decision panel — same data, different sink, useful when iterating offline. - The server owns identity.
project_id,agent_version_id, andreceived_atare resolved/stamped server-side and are never trusted from the request body, even though the SDK sends some of them as empty strings. - One envelope, many event types. The first eight columns/fields are a
shared “envelope” intended to be reused by future event tables
(
tool_invocation, …).policy_decisionis the first concrete event. - Lossy under pressure, never blocking. Both the SDK (drop on saturation)
and the storage layer (byte caps, truncated
arguments) prefer dropping or truncating data over slowing the agent.
2. The audit record
2.1 Stamped at the emission site
hexgate/audit.py — AuditEvent stamps the two audit identifiers at
construction. They live on the event, not on Decision: they exist only for
audit emission, and the no-audit path never constructs an event (so a
decide() call without a sender mints neither).
The enforcer builds the
AuditEvent immediately after the Decision, so
occurred_at is decision time for practical purposes. The decision fields
(agent_name, tool_name, outcome, role, reason, error_type,
violations, hint, arguments) are populated by Decision.from_verdict()
from the policy engine’s Verdict plus host context.
2.2 Outcome and error_type
2.3 Wire payload — AuditEvent.as_payload()
hexgate/audit.py — AuditEvent wraps a Decision plus the caller identity
read from the active HexgateContext scope (user_id, session_id). as_payload()
produces a flat JSON object whose keys mirror the platform’s DecisionEvent:
project_id, agent_version_id, received_at) are
deliberately absent from the wire payload.
3. SDK emission layer
3.1 Where emission happens
PolicyEnforcer.decide() (hexgate/security/enforcer.py):
- Resolve
rolefrom the activeHexgateContextcontextvar. - Ask the policy engine for a
Verdict; lift it into aDecision. - If an
AuditSenderwas injected into this enforcer,emit()anAuditEvent. - Return the
Decisionto the adapter (synchronous, unaffected by step 3).
3.2 AuditSender — fire-and-forget POST
hexgate/tracing/_senders.py — shared by hexgate.audit (policy decisions)
and hexgate.tracing.usage (LLM token usage); neither module owns it. emit()
is synchronous and non-blocking; it schedules a
background asyncio.Task that performs the POST. Key behaviours:
- Bounded concurrency. An
asyncio.Semaphore(max_in_flight=32)caps concurrent POSTs. - Drop on saturation. If the semaphore is already exhausted,
emit()increments a dropped counter and returns immediately. A warning is logged on the 1st, 101st, 201st… drop (_dropped % 100 == 1). - No event loop → skip. If
emit()is called with no running loop (a sync entry point), it no-ops with a one-time warning. Sync agents therefore emit no audit unless wrapped inasyncio.run. - Single 503 retry.
_sendretries once on HTTP 503 aftermin(http_timeout, 2.0)s. Other>= 400responses are logged, not retried. - Network errors swallowed.
httpx.RequestErroris logged at WARNING and dropped; it never surfaces to the agent. - HTTP client:
httpx.AsyncClient, 5s timeout,Authorization: Bearer <api_key>header.
3.3 Loop-rebinding safety
asyncio primitives (the semaphore, and httpx’s connection pool) bind to the first event loop that drives them and reject use from any other loop. Because the sender is a process-global, a process that runs more than one event loop (repeatedasyncio.run, a job worker, a test suite, a notebook) would otherwise
crash on the second loop. AuditSender tracks the loop it is bound to and
rebuilds its client + semaphore when the running loop changes, so a reused
sender survives loop rotation. Construction stays eager so configure() remains
synchronous.
3.4 Configuration & lifecycle
hexgate.audit.configure(api_key=None, base_url=None) -> AuditSender | None
is a thin, decisions-specific wrapper around the shared
hexgate.tracing._senders.get_or_create_sender():
- Resolves
api_keyfrom the argument orHEXGATE_API_KEY; returnsNone(audit inert) when no key is resolvable. - Resolves
base_urlfrom the argument orHEXGATE_API_URL, defaulting to Hexgate Cloud (https://app.hexgate.ai); sethttp://localhost:8000when self-hosting. The endpoint is<base_url>/v1/audit/decisions. - Keyed by
(api_key, path). Senders live in a shared registrydict[tuple[str, str], AuditSender]inhexgate/tracing/_senders.py, reused by every event type that goes through it (currently policy decisions and LLM usage — seehexgate.tracing.usage). Callingconfigure()again with the same key returns the existing decisions sender (idempotent); a different key gets its own sender with its own bearer token. Keying on the pair rather thanapi_keyalone is what lets one process audit several tenants/keys and emit more than one event type per key without a usage sender silently reusing (and POSTing to) the decisions endpoint.
wrap_langchain_agent, wrap_openai_agent,
wrap_google_agent, wrap_pydantic_agent) and factory.enforce_policy call
configure() with their resolved key and inject the returned sender into the
PolicyEnforcer they build. bootstrap() also calls configure() (env key) so
local runs work without an explicit key.
Local mode (HEXGATE_LOCAL_MODE)
The gate lives in hexgate/tracing/_senders.py and applies to every event
type sharing the registry, not just decisions. Setting HEXGATE_LOCAL_MODE=1
makes configure() (and configure_usage_sender()) return None even when
HEXGATE_API_KEY is present in env. bootstrap(local_only=True) sets the var
before the first configure() call, and hexgate chat passes
local_only=True — so the inner-loop REPL never posts audit events even if
a key has been lingering in .env from an earlier platform session.
The gate is re-checked on every configure() call (not cached), so an adapter
wrapper that re-configures post-bootstrap still respects it. The truthy
value parser accepts 1 / true / yes / on (case-insensitive).
There are now two clean operating modes, not three:
A single INFO line (
sender suppressed for <path>: HEXGATE_LOCAL_MODE=1 (...)) is logged the first time a given path (e.g. /v1/audit/decisions)
is configured with both a key and local mode active — exactly the case where
the suppression would be surprising. The gate is logged per path, not
once per process, since decisions and LLM usage each warrant their own
first-suppression notice. The “no key anywhere” case stays quiet.
A separate WARNING fires from bootstrap() itself when both HEXGATE_API_KEY
and HEXGATE_LOCAL_POLICY are set — that combination is almost always a
forgotten env entry from an earlier session, and surfacing it at startup
saves a later debugging detour.
For the user-facing description of when each mode applies in practice — chat vs. serve, inner loop vs. team loop — see the “Which path do I pick?” page.
async shutdown() drains in-flight tasks and closes every sender’s HTTP client
across the whole shared registry — decisions and LLM usage alike. It is
safe to call multiple times and is the recommended teardown hook; either
hexgate.audit.shutdown() or hexgate.tracing.usage.shutdown() drains
everything, since both delegate to the same
hexgate.tracing._senders.shutdown(). Absent it, background sends still
pending when the event loop tears down are cancelled, not finished —
events emitted shortly before process exit are lost. GC closing the httpx
client does not flush anything.
4. Platform ingest endpoint
POST /v1/audit/decisions (platform/api/main.py → ingest_decision).
4.1 Request
- Auth:
Authorization: Bearer <hexgate_key>.require_projectverifies the key and resolves it to aproject_id. Missing/invalid → 401. - Body:
DecisionEvent(platform/api/schemas.py), a pydantic model that extendsAuditEnvelope. Field-level validation (max lengths, enum membership) happens here; a malformed body → 422 (FastAPI validation). - ClickHouse dependency:
require_clickhouseresolves the client and maps a connect failure to 503 withRetry-After: 5.
4.2 Server-side processing
- Clock-skew / retention guard. Reject
occurred_atmore than 5 minutes in the future (CLOCK_SKEW_FUTURE) or older than the 90-dayRETENTION_WINDOW→ 400. - Resolve
agent_version_id= latestAgentVersion.idfor(project_id, agent_name), or""if the agent isn’t registered. Unknown agents still log; the version is just empty. - Insert via
audit.insert_decision.project_id(bearer-resolved) andagent_version_id(platform lookup) are passed explicitly and override anything in the body.
4.3 Responses
4.4 Trust boundary
AuditEnvelope is intentionally narrower than the storage row. The body
carries only event_id, occurred_at, agent_name, session_id, user_id
(envelope) plus the decision fields. project_id, agent_version_id, and
received_at are server-owned and cannot be spoofed by the SDK.
5. Storage — ClickHouse
platform/clickhouse/init/schema.sql. Database hexgate_audit, table
policy_decision.
⚠️ The init/ directory runs once on an empty volume. Editing the schema
after first boot is ignored — use a real migration runner for changes.
5.1 Schema
occurred_atis event time (SDK),received_atis ingest time (server default). Reads order byreceived_at; retention/partitioning key offoccurred_at.- Sort key
(project_id, agent_name, outcome, occurred_at)optimizes the expected query shape: “decisions for a project/agent, filtered by outcome, newest within a window.” hint/argumentsare stored as ZSTD-compressed JSON strings, not native JSON, and are documented as potentially lossy (argumentsis SDK-truncated; see §6).- TTL 90 days — rows self-expire, consistent with the ingest retention guard.
5.2 Insert semantics
platform/api/audit.py — insert_decision:
- Byte caps before write:
argumentsJSON ≤ 8 KiB,hintJSON ≤ 4 KiB, elseAuditPayloadTooLarge→ 413.Noneserializes to"". - Insert settings:
async_insert=1,wait_for_async_insert=1,async_insert_deduplicate=1. Small inserts are batched server-side, but the call blocks until the batch flushes, so a write failure surfaces synchronously rather than being acked-then-dropped — an audit log must not silently lose acknowledged rows. - Dedup:
async_insert_deduplicateplus the uniqueevent_idprovides idempotency across SDK retries (the single 503 retry, or any at-least-once delivery): re-POSTing the sameevent_iddoes not create a duplicate row.
6. Privacy & data-handling notes
argumentscarries tool inputs (paths, payloads, possibly PII). It is transmitted to the platform and stored (compressed) for up to 90 days. The defaultbase_urlis plaintexthttp://localhost:8000; production deployments must setHEXGATE_API_URLto a TLS endpoint.- Default key-name redaction, always on.
AuditEvent.as_payload()replaces values whose key matchespassword|passwd|secret|token|api[-_]?key| credential|authorization(case-insensitive, recursive into nested dicts/lists) with"[REDACTED]"before transmission. This is a seatbelt, not a guarantee: values sensitive by content rather than key name — SQL strings, email bodies, free text — are captured verbatim. Operators whose tools carry such data need their own redaction before relying on this in production. - SDK truncation at the platform cap.
as_payload()measuresargumentsas the platform does (JSON,default=str); over 8 KiB it replaces the dict with{"_truncated": true, "original_bytes": N, "preview": <JSON prefix>}sized to fit the cap. Lossy, but the event is stored — the platform rejects (413) oversize payloads, so an untrimmed over-cap decision would not be stored at all.hint(4 KiB cap) is policy-engine-generated and is not SDK-trimmed.
7. Read path — aggregation endpoints
The rawGET /v1/audit/decisions?limit=N debug dump has been removed.
Reads are now project-scoped aggregation endpoints that group server-side in
ClickHouse (query-time GROUP BY; no rollups/materialized views). The table’s
sort key (project_id, agent_name, outcome, occurred_at) and LowCardinality
columns make these scans cheap. All time-axis logic keys off occurred_at
(event time), never received_at. See platform/api/audit.py (summarize,
timeseries, list_decisions).
windowis24h/7d/30d/90d, validated by aLiteral(bad value → 422) and bounded by the 90-day storage TTL.role=(empty value) selects the empty-role bucket; an absentrolemeans “no filter”. No sentinel string is reserved on the wire — the dashboard’s “(none)” is a display label only.- Concurrency. A client firing several of these reads at once (e.g. a
dashboard loading summary + timeseries + decisions together) would otherwise
hit “concurrent queries within the same session”. The shared, process-global
ClickHouse client is created with
autogenerate_session_id=False(platform/api/clickhouse.py) so the thread-safe HTTP pool serves concurrent queries in parallel; this also hardens the ingest path under load.
Still POC-grade on auth: the endpoints are project-scoped (gap #1 partly closed) but not yet gated behind theread_auditscope — they carry aTODO(auth)marker, matching the unauth posture of the other dashboard reads (/agents,/tokens). Scope enforcement must land before exposure beyond local development.
8. Failure-mode summary
9. Open items / known gaps
- Read path auth & scoping —
GET /v1/audit/decisionsis unauthenticated and cross-project (POC). Needsread_auditscope +project_idfilter. argumentsredaction is key-name-only — the default redactor strips sensitive-keyed values, but content-sensitive values (SQL, email bodies) pass through; no per-tool allow/deny lists orredactcallable yet.- Default transport is plaintext HTTP — safe only for localhost; require
TLS via
HEXGATE_API_URLelsewhere. - Sync agents emit nothing —
emit()requires a running loop; sync entry points silently produce no audit. - Schema evolution —
init/schema.sqlruns once; there is no migration runner wired up yet. - At-least-once, not exactly-once end to end — the SDK can drop on
saturation/network failure (audit is best-effort);
event_iddedup prevents duplicates but not gaps. - Write path is unscoped within a project —
POST /v1/audit/decisionsauthorizes viarequire_project(signature + project resolution only); any valid SDK bearer for the project can write audit, fetch policy, and register agents interchangeably. The biscuit attenuation primitive already exists (platform/api/biscuits.py); anemit_auditscope fact + endpoint check is the natural fix. Note existing minted tokens won’t carry the fact — needs a deprecation window or re-mint. - No rate limit or volume alerting on ingest — an exfiltrated key can
flood the log to bury real activity. Needs a per-project token bucket
(
429 + Retry-After; the SDK already logs-and-drops on ≥400) plus an ingest-volume-per-project alert.