Hangar

Docs · Autopilot

The Go AI gateway (A-9)

Status: first slice, built and tested, off by default, not deployed. The Python gateway (src/autopilot/serve.py, the repo’s Dockerfile, gateway-image.yaml) is still the gateway on every cluster and is still the reference: it is not removed. Nothing selects the Go image until the managing cutover does. This page says what the Go gateway (gateway/) already does, how it is proven equal to the Python one, and exactly what is left.

What it is

One static binary, autopilot-gateway, on distroless static (nonroot, uid 65532, no shell: the same bases as Clearance’s image, gateway/Dockerfile):

Subcommand Does
serve the MCP server (PORT, default 8080), the model proxy (MODEL_PORT, default 8081, only with MODEL_KEY_FILE), the expiry sweep and the outbox drain
conformance-control the test-only conformance control interface; off unless GATEWAY_CONFORMANCE_CONTROL=1, loopback only
api not ported: exits 2 and says so. The read API is still the Python autopilot-gateway api
version the image’s version (AUTOPILOT_GATEWAY_VERSION, else the build’s)

It is built on:

  • MCP: the official Go SDK (github.com/modelcontextprotocol/go-sdk v1.8.0): streamable HTTP, stateless, JSON responses, a bearer token verified before any tool runs (401 otherwise), protected-resource metadata, GET /healthz. The four tools it carries keep their Python names and result shapes: tools_list, agents_list, session_open, call.
  • Clearance: the Go client Clearance ships (github.com/jfillman/clearance/client, R-1e), pinned to the same release as the Python client and the test core (CLEARANCE_REF in .github/workflows/test.yaml; bump the three together). Autopilot’s code reaches Clearance only through this API.
  • The model proxy: a streaming reverse proxy. A response body is relayed chunk by chunk with a flush and is never held: the first server-sent event reaches the run while the provider is still producing the rest. Usage is metered from the events as they pass (or from a bounded copy of a plain answer) and charged when the call ends, however it ends; every allowed call gets exactly one outcome (ok, error:Upstream<status>, error:StreamInterrupted, …).
  • The gateway database: the same tables, columns and writer lock as the Python gateway (gateway-database.md). gateway/internal/pgstate/schema.sql is generated from the Python SCHEMA; a session row either implementation wrote loads in the other. Sessions are saved durably after every operation, with concurrent saves grouped into one transaction.

Tool names, tiers, arguments, costs and the baseline deny lists are the Python registry’s (gateway/internal/tools/registry.json, generated by tools/gen_gateway_registry.py; tests/test_gateway_go.py fails when it is stale). The bundled agent definitions parse to the same scope and agent the Python parser sends the core.

Timeouts, pools and what never blocks

  • Every tool call runs under a context deadline (GATEWAY_CALL_TIMEOUT_SECONDS, default 60). The backend sees it and returns when it ends; the outcome is still reported, under a fresh bounded context (error:Timeout).
  • Every backend has its own HTTP client: its own timeout and its own connection pool (backends/), so a slow Loki cannot starve Prometheus. The Clearance client has its own pool and the outage policy’s 250 ms per attempt, one retry and a breaker (the client’s, unchanged); the model proxy has an upstream pool of its own.
  • No process-wide lock. One session’s operations are serialized (so a session cannot overspend by racing itself); other sessions run in parallel. The model proxy holds a session only for the decision and for the final charge, never while a response streams.
  • A panic in a backend is an error:Panic outcome, not a crash. A failed save of sessions exits the process (crash-only, as the Python gateway does); the restart reloads them.

How it is proven equal to the Python gateway

  1. The R-3 replay corpus (Clearance’s golden corpus, testdata/golden/corpus.jsonl, recorded from the Python engine over its whole test suite). gateway/internal/replay replays it through the Go gateway’s own code against a real, unmodified core (with Hangar’s bundle, or without it for the lines recorded without the Hangar pack): for every line it can take, the evaluation the Go gateway builds equals the Python one field for field, the decision (allow, rule, name, message, hint, tier, problems) is identical, the spend leaves the session as the Python one did, the grant a session.open makes is identical, and the decision record the core writes has the corpus line’s fields. Against clearance v0.31.0 (the CI pin) and v0.35.0: 341 tool calls, 261 session opens, 42 model calls; 329 spends, 605 records and 247 grants compared; zero differences. TestReplayDetectsDrift shows a changed field is noticed. The corpus is not edited; the lines the replay cannot take are listed below.
  2. The conformance harness that ships in the Clearance release (clearance conformance --role runtime) drives the Go gateway’s control interface and passes all seven runtime assertions, as it does the Python one (tests/test_gateway_go.py).
  3. The load test (gateway/internal/loadtest): real MCP tools/call requests over HTTP to the real handler, against a fake core and backend that each wait a fixed time (2 ms and 1 ms), per call timing of every wait subtracted from the request’s wall time. At 200 concurrent sessions each calling every ~100 ms, the gateway’s own overhead per call (excluding the decision, the outcome post and the backend) is p99 under 1 ms in memory, and under 3 ms typically with the sessions saved durably to a Postgres on the same laptop. In a closed loop with no think time (12,000 calls a second on a machine the load generator, the fake core and the gateway share) the p99 is about 12 ms: that measures the machine saturating, not the gateway, and is reported, not asserted. AUTOPILOT_LOADTEST=1 go test -run TestGatewayOverheadPerToolCall ./internal/loadtest runs it and asserts the 5 ms bar; CI runs it in report mode (AUTOPILOT_LOADTEST=report) and prints the numbers in the job summary.

What the replay does not cover (and why)

Corpus lines Why
session.spawn (35), a tool.call or model.call by a run acting as its session (8) need the run backend (run.spawn, and the run pod’s identity, Gateway._acting); not in this slice
triggered session.open (31) open_triggered, holds and the trigger ledger are not ported
activation and probe lines (the rest) the core’s own decision engine; they carry no gateway request
a request with a number beyond int64 (11 lines) Python integers are unbounded; the core’s types, and so the Go client’s, are int64. The core rejects such a request too, so there is nothing to compare
one model.call with no routes a gateway with no model routes answers M006 without asking the core (same in both)

What is not in this slice

The Go gateway starts only with what it carries; serve refuses to start when the environment asks for something it does not (CLEARANCE_OIDC_ISSUER, TOWER_ISSUER, GITHUB_PR_ENABLED, AGENT_XRS_ENABLED, EVENTS_ENABLED, GLIDEPATH_API_URL, BACKSTAGE_URL, APP_API_SERVICES, ARGO_SYNC_ENABLED, CLEARANCE_EPHEMERAL), so nobody believes a feature is live when it is not.

Not ported, and still the Python gateway’s:

  • Authentication beyond workloads: the Go gateway verifies Kubernetes service-account tokens (TokenReview) only. The people’s OIDC verifier (Dex), the GitHub Actions verifier and the foreign-cluster workload verifier (Tower’s backend) are not ported, so people cannot sign in to it and Tower cannot reach it.
  • The run tree and runs: the RunBackend (AgentRun XRs), run.spawn, run.close, launch, run_finish, session_close, the run pod’s identity (acting_as: until it is ported a run’s model and tool calls are refused by the core’s R001 as another principal’s), the expiry sweep’s run deletion and backstop outcomes (the Go sweep closes expired sessions and drains the outbox only).
  • The human inbox (human.request), person controls (freeze, end, hold, release, run_agent), run results and artifacts, the trigger scheduler, the event bridge, the reconciliation sweep and the trigger ledger.
  • Publishing the definition set to the core (PUT /v1/definitions) and the Agent XR loader: the Go gateway reads the bundled agents/ and the pinned set the Python loader keeps in gateway_definitions, and publishes nothing. It does not validate definitions against the JSON schema (the loader that wrote the set did).
  • Backends: metrics.query and logs.query (Prometheus, Loki) are ported with per-backend timeouts and pools. The rest (catalog.read, argo.app.get, argo.sync.lower, app.api.get, repo.*, xr.request, glidepath.*, airframe.*, artifact.*) have no handler, so a call to them is refused as having no backend, as in a Python gateway that does not configure them.
  • api (clearance-api’s runtime reads, api_serve.py, read_api.py, reads), Prometheus metrics on METRICS_PORT, OTLP logs, and a held outbox reason (since clearance v0.28.0 the core takes every kind the gateway writes).
  • Release plumbing: no gateway-go-v* tag, image workflow or Apron change. The Python image stays what gateway-image.yaml builds.

The 7-day canary, moving every definition to it, and removing the Python gateway are the managing agent’s later steps, after the remainder above is ported.

Running it (locally)

cd gateway && go build -o autopilot-gateway ./cmd/autopilot-gateway
GATEWAY_DSN=postgresql://... CLEARANCE_CORE_URL=http://clearance-core:8080 \
  CLEARANCE_CORE_TOKEN_FILE=/var/run/secrets/tokens/clearance CLEARANCE_AGENTS_DIR=../agents ./autopilot-gateway serve

It reads the variables the Python gateway reads, for the part it carries: PORT, MODEL_PORT, GATEWAY_DSN, CLEARANCE_CORE_URL, CLEARANCE_CORE_TOKEN_FILE, CLEARANCE_AGENTS_DIR, CLEARANCE_AUDIENCE, MODEL_TOKEN_AUDIENCE, CLEARANCE_RESOURCE_URL, CLEARANCE_EXPIRE_SECONDS, GATEWAY_OUTAGE_ALLOW_READS, MODEL_ROUTES, MODEL_KEY_FILE, PROMETHEUS_URL, LOKI_URL, LOG_LEVEL; and three of its own: GATEWAY_CALL_TIMEOUT_SECONDS (60), MODEL_TIMEOUT_SECONDS (300), GATEWAY_PG_CONNS (16). It takes the gateway database’s writer lock, so it cannot run beside the Python gateway on the same database.

Tests: cd gateway && go vet ./... && go test -race ./.... The replay needs a clearance checkout (CLEARANCE_SRC) and the core binary (CLEARANCE_CORE_BIN); the database tests need a scratch Postgres (AUTOPILOT_TEST_PG); without them those tests skip. pytest tests/test_gateway_go.py runs all of it with the suite’s core.