Skip to main content

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’s PolicyEnforcer 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

  1. Enforcement is authoritative; audit is observational. The Decision returned to the agent is the source of truth. Audit failures (network down, platform 503, saturation) degrade silently and never propagate to the caller. The same Decision is also surfaced locally by hexgate chat’s decision panel — same data, different sink, useful when iterating offline.
  2. The server owns identity. project_id, agent_version_id, and received_at are resolved/stamped server-side and are never trusted from the request body, even though the SDK sends some of them as empty strings.
  3. 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_decision is the first concrete event.
  4. 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.pyAuditEvent 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.pyAuditEvent 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:
Server-resolved fields (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):
  1. Resolve role from the active HexgateContext contextvar.
  2. Ask the policy engine for a Verdict; lift it into a Decision.
  3. If an AuditSender was injected into this enforcer, emit() an AuditEvent.
  4. Return the Decision to the adapter (synchronous, unaffected by step 3).
The sender is injected per enforcer, not looked up globally — see §3.4.

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 in asyncio.run.
  • Single 503 retry. _send retries once on HTTP 503 after min(http_timeout, 2.0)s. Other >= 400 responses are logged, not retried.
  • Network errors swallowed. httpx.RequestError is 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 (repeated asyncio.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_key from the argument or HEXGATE_API_KEY; returns None (audit inert) when no key is resolvable.
  • Resolves base_url from the argument or HEXGATE_API_URL, defaulting to Hexgate Cloud (https://app.hexgate.ai); set http://localhost:8000 when self-hosting. The endpoint is <base_url>/v1/audit/decisions.
  • Keyed by (api_key, path). Senders live in a shared registry dict[tuple[str, str], AuditSender] in hexgate/tracing/_senders.py, reused by every event type that goes through it (currently policy decisions and LLM usage — see hexgate.tracing.usage). Calling configure() 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 than api_key alone 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.
Each adapter wrapper (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.pyingest_decision).

4.1 Request

  • Auth: Authorization: Bearer <hexgate_key>. require_project verifies the key and resolves it to a project_id. Missing/invalid → 401.
  • Body: DecisionEvent (platform/api/schemas.py), a pydantic model that extends AuditEnvelope. Field-level validation (max lengths, enum membership) happens here; a malformed body → 422 (FastAPI validation).
  • ClickHouse dependency: require_clickhouse resolves the client and maps a connect failure to 503 with Retry-After: 5.

4.2 Server-side processing

  1. Clock-skew / retention guard. Reject occurred_at more than 5 minutes in the future (CLOCK_SKEW_FUTURE) or older than the 90-day RETENTION_WINDOW400.
  2. Resolve agent_version_id = latest AgentVersion.id for (project_id, agent_name), or "" if the agent isn’t registered. Unknown agents still log; the version is just empty.
  3. Insert via audit.insert_decision. project_id (bearer-resolved) and agent_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_at is event time (SDK), received_at is ingest time (server default). Reads order by received_at; retention/partitioning key off occurred_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 / arguments are stored as ZSTD-compressed JSON strings, not native JSON, and are documented as potentially lossy (arguments is SDK-truncated; see §6).
  • TTL 90 days — rows self-expire, consistent with the ingest retention guard.

5.2 Insert semantics

platform/api/audit.pyinsert_decision:
  • Byte caps before write: arguments JSON ≤ 8 KiB, hint JSON ≤ 4 KiB, else AuditPayloadTooLarge → 413. None serializes 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_deduplicate plus the unique event_id provides idempotency across SDK retries (the single 503 retry, or any at-least-once delivery): re-POSTing the same event_id does not create a duplicate row.

6. Privacy & data-handling notes

  • arguments carries tool inputs (paths, payloads, possibly PII). It is transmitted to the platform and stored (compressed) for up to 90 days. The default base_url is plaintext http://localhost:8000; production deployments must set HEXGATE_API_URL to a TLS endpoint.
  • Default key-name redaction, always on. AuditEvent.as_payload() replaces values whose key matches password|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() measures arguments as 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 raw GET /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).
  • window is 24h / 7d / 30d / 90d, validated by a Literal (bad value → 422) and bounded by the 90-day storage TTL. role= (empty value) selects the empty-role bucket; an absent role means “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 the read_audit scope — they carry a TODO(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

  1. Read path auth & scopingGET /v1/audit/decisions is unauthenticated and cross-project (POC). Needs read_audit scope + project_id filter.
  2. arguments redaction 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 or redact callable yet.
  3. Default transport is plaintext HTTP — safe only for localhost; require TLS via HEXGATE_API_URL elsewhere.
  4. Sync agents emit nothingemit() requires a running loop; sync entry points silently produce no audit.
  5. Schema evolutioninit/schema.sql runs once; there is no migration runner wired up yet.
  6. At-least-once, not exactly-once end to end — the SDK can drop on saturation/network failure (audit is best-effort); event_id dedup prevents duplicates but not gaps.
  7. Write path is unscoped within a projectPOST /v1/audit/decisions authorizes via require_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); an emit_audit scope fact + endpoint check is the natural fix. Note existing minted tokens won’t carry the fact — needs a deprecation window or re-mint.
  8. 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.