Low-level design
Each diagram on this page traces one code path in the order the code runs. The numbered list under each diagram gives the file and line range for every stage, so you can check the drawing against the source. For what each binary does as a whole, see Components.
Request path inside halo-proxy
Section titled “Request path inside halo-proxy”Everything below runs inside Proxy.ServeHTTP (cmd/halo-proxy/proxy.go:187-363). The reverse proxy is built once, in newProxy (cmd/halo-proxy/proxy.go:74-122).
-
Snapshot.
p.snap.Get()returns the compiled policy and reloads it when the file changes (internal/gateway/snapshot.go:32-61). If the policy is missing and there is no--next-hopor--default-upstream, the answer is503 policy_unavailable(cmd/halo-proxy/proxy.go:244-249). -
Identity.
authenticatepicks a verifier from the configured mode (internal/identity/resolve.go:68-83):- OIDC JWT: go-oidc against the issuer’s JWKS (
internal/identity/oidc.go:71-91). With the default user claimemail, the token must also carryemail_verified=trueunlesstrustUnverifiedEmailis set (internal/identity/oidc.go:93-110). - Trusted header: honoured only when the TCP peer is in the trusted proxy CIDRs (
internal/identity/identity.go:72-100). - None.
Gemini CLI’s
x-goog-api-keyis verified like a bearer token. A failure is401withWWW-Authenticate, an identity header sent twice is400, and--allow-anonymousdowngrades a failure to default routing (cmd/halo-proxy/proxy.go:391-429). - OIDC JWT: go-oidc against the issuer’s JWKS (
-
Body bound. Every body is wrapped in
MaxBytesReader. Model calls are read in full for inspection, and oversize is a hard413(cmd/halo-proxy/proxy.go:257-279). The default limit is 32 MiB (cmd/halo-proxy/config.go:104). -
Kill list.
KillSwitch.Applyreturns a copy of the policy with every killed experiment set topausedand every killed toggle removed. Killed experiments therefore route to control and mirror nothing (internal/gateway/killswitch.go:280-305). The list is polled every 10 s by default and accepted only if its ed25519 signature verifies, it is at most 10 minutes old, and it is newer than the current list (internal/gateway/killswitch.go:60-82,internal/gateway/killswitch.go:159-245). -
Decision.
PrepareVerifiedcollects every clientx-halo-*header name for removal, builds the request info from the verified subject only, and callsDecide(internal/gateway/prepare.go:139-180).Decideresolves the subject’s ring. It then applies a traffic toggle’s route for the alias (internal/gateway/toggles.go:12-26), and the first traffic-axis experiment by name whose variant routes this alias. A client-axis experiment is attributed but never routes (internal/gateway/decision.go:60-144).admitenforces the model allowlist:404for unknown paths,400for models that are not a policy alias (internal/gateway/prepare.go:103-137). The request body is rewritten to the upstream model. -
Route order.
RouteOrdersorts targets into priority tiers. Within a tier the weighted pick goes first, chosen byassign.Pickover the sticky keyuser + session(internal/gateway/route.go:14-58,internal/assign/assign.go:28-53).assignis the single source of hashing (AGENTS.md invariant 3). -
Attempts.
buildAttemptsdrops a target, with a log line, when:- its upstream kind cannot serve the client’s wire protocol
- its URL fails
CheckURL - it would SigV4-sign for a host that is neither AWS nor in
signHosts - translation fails in
BuildOutbound
(
cmd/halo-proxy/route.go:48-97,internal/gateway/upstreamauth/apikey.go:99-121,internal/gateway/translate.go:39-81). If no target is left, the answer is502 bad_upstream(cmd/halo-proxy/proxy.go:308-313). -
Header hygiene. Hygiene runs after the decision. That is safe because the decision never read client
x-halo-*headers.- The client
x-halo-*headers and the identity and groups headers are deleted. - The gateway’s own
x-halo-ring|release|experiment|variantare stamped. - Client credentials are stripped unless
--forward-authis set (cmd/halo-proxy/proxy.go:324-355,internal/gateway/upstreamauth/apikey.go:47-51).
- The client
-
Mirror. For shadow experiments,
shadow.Jobsbuilds jobs for eligible steps (first turns, or every step withturns: all) that carry only allowlisted headers, and the mirror queues them asynchronously (cmd/halo-proxy/proxy.go:431-441,internal/shadow/mirror.go:130-150). -
Failover.
routeTransportwalks the attempts (cmd/halo-proxy/route.go:105-167):- At most 3 attempts by default.
- A target whose breaker is open is skipped. With more than one target, the breaker opens after 5 consecutive failures and allows one half-open probe after 30 s (
internal/gateway/breaker.go:46-92). - A connect error, a 5xx or a 429 moves to the next target.
- Retries happen only before any byte reaches the client.
Each attempt in
tryinstalls that target’s own credential (cmd/halo-proxy/route.go:178-253):- Vertex: a metadata-server or service-account token (
internal/gateway/upstreamauth/vertex.go:205-214). - Azure OpenAI, and OpenAI or Gemini with a policy
credential: the secret from the env var or file it names, read at use time (internal/gateway/upstreamauth/apikey.go:21-45,internal/gateway/upstreamauth/apikey.go:55-98). - Anthropic and orchestrator upstreams: no credential of the gateway’s own; only the operator’s per-upstream
upstreamHeadersfrom the YAML config, or the caller’s credential with--forward-auth(cmd/halo-proxy/route.go:190-192). - Bedrock: SigV4 in the transport (
internal/gateway/upstreamauth/bedrock.go:139-194,internal/gateway/upstreamauth/bedrock.go:206-208).
If every target fails, the answer is
503 no_available_target(cmd/halo-proxy/proxy.go:135-153). -
Stream.
FlushInterval: -1flushes every write (cmd/halo-proxy/proxy.go:107-111). A Bedrock event stream is converted to SSE when the client spoke the Anthropic protocol (cmd/halo-proxy/route.go:221-226,internal/gateway/eventstream.go:18-20). -
Deferred metrics. A
deferrecords the request after the response completes (cmd/halo-proxy/proxy.go:195-240):halo.gateway.requestsandhalo.gateway.latency_ms, sent over OTLP/HTTP to the collector’s gateway receiver, for decided model calls only (not auth failures or count-tokens). The unit is the verified subject, never a client header. The served attempt setshalo.gateway.provider,halo.gateway.targetandhalo.gateway.failover(cmd/halo-proxy/proxy.go:205-209,cmd/halo-proxy/proxy.go:286-288,internal/telemetry/gwmetrics/gwmetrics.go:192-218).- The Prometheus histograms on the admin listener.
- One access-log line. Bodies, headers and query strings are never logged.
Release pipeline
Section titled “Release pipeline”halo release publish --ring R --release-version V --key K (cmd/halo/cmd_release.go:181-236):
-
Load.
policy.Loadreadshalos.yamland every*.yamlunder the directory in sorted order, decoding strictly so unknown fields are errors (internal/policy/load.go:79-153). -
Validate.
loadValidrefuses to continue on any validation error (cmd/halo/root.go:184-198).Org.Validateruns the semantic checks, then the 16 built-in guardrails, then anyExtraGuardrails(which can only add issues, ADR-0007) (internal/policy/validate.go:56-79,internal/policy/guardrails.go:18-40), among themguardNoBypass(internal/policy/guardrails.go:48-69). -
Build ring.
release.BuildRingbuilds one release per variant of every running client-axis experiment that enrolls the ring, then the ring release itself. The ring release’s manifest lists those experiments with their salt, weights and channels (internal/release/channels.go:24-59). -
Render.
release.Buildcalls each enabled adapter’sRenderfor darwin, linux and windows. Warnings land in the manifest; nothing is silently dropped (internal/release/release.go:110-189). -
Backstop.
CheckRenderedfails the build if any rendered file contains a permission-bypass token (policy.BypassToken, case-insensitive: modes such asbypassPermissionsanddanger-full-accessand the harnesses’ bypass CLI flags). It scans each decoded JSON/TOML value (the error names the field) and then the raw bytes of every file, so a file that does not parse, or is not JSON/TOML, is still covered. Policy validation,halo ci configand vendor admin sync call the same function. It also runs per-harness checks (internal/release/check.go:17-83). It is called once per harness and OS (internal/release/release.go:160-162). -
Pack.
packwrites a deterministic USTAR tar (manifest.jsonplusblobs/<sha256>, epoch timestamps). The release digest issha256(tar)(internal/release/release.go:191-223). -
Sign. The signer is ed25519, or a
MultiSigner{ed25519, Cosign}when--cosign-keyor--keylessis set (cmd/halo/cmd_release.go:104-123).publishsigns the tar digest before pushing anything. Co-signatures are stored as extra manifest annotations (internal/bundle/bundle.go:50-73). cosign runs as an externalcosign sign-blob, without a transparency-log upload unless keyless (internal/bundle/sign.go:152-171). halod verifies only ed25519, so a cosign-only signer is refused (internal/bundle/pointer.go:82-92). -
Push and tag. Config and layer blobs are pushed if missing, and the OCI manifest is packed with the signature annotations.
v<version>is tagged, and moving an existing tag is an error (internal/bundle/bundle.go:79-123).PublishRingpublishes the variant channels before the ring, and runs every pointer preflight before any pointer moves (internal/bundle/channels.go:53-95). -
Ring pointer.
writePointerbuilds{org, ring, digest, manifest, seq, issuedAt, expiresAt}:seqismax(current+1, unix now, recorded+1).- The time to live is 7 days.
- The signature is ed25519 over
"halos.pointer.v1 " + sha256(payload). - It is pushed as its own artifact under
ring-<r>.pointer, and the informationalring-<r>tag is moved (internal/bundle/pointer.go:21-58,internal/bundle/pointer.go:239-300).
Rollback is
PromoteRingto an older version: a new pointer with a higherseq(internal/bundle/channels.go:103-150).
Release binaries and images are signed separately. GoReleaser signs keyless with cosign (.goreleaser.yaml:159-171) in the tag-triggered release workflow, and its verify job checked the v0.3.0 signatures; see tech stack.
halod apply loop
Section titled “halod apply loop”halod run loops: Once every interval (default 15m), and with a kill switch, PollKill every killSwitch.interval (default 60s) in between. Both run on one goroutine, so state.json has a single writer (cmd/halod/main.go:163-190, cmd/halod/config.go:70-71, cmd/halod/config.go:233-242).
-
Kill list.
refreshKillrestores the persisted list, fetches and verifies the current one with the kill public key (not the release key), and persists it once accepted. A restart therefore cannot be fed an older envelope (cmd/halod/agent.go:319-337,cmd/halod/keys.go:68-86). -
Ring. A fixed
ringwins. Otherwise halod callsGET ringEndpoint?user=&host=with the device token, and validates the returned ring, subject and groups. If that fails, the last-known ring is used, except on the first run (cmd/halod/agent.go:214-226,cmd/halod/agent.go:400-438). -
Pointer.
bundle.PullRingreadsring-<r>.pointerand checks its type and size and the ed25519 signature over the domain-separated statement. It then requires org = config,seq≥ the last appliedseq, the same digest when theseqis unchanged, and an unexpired pointer (internal/bundle/pointer.go:96-135,internal/bundle/pointer.go:390-430). halod accepts any configured key that is not listed inrevokedKeys(cmd/halod/keys.go:40-65). -
Release.
fetchReleasefetches the manifest by the digest the pointer names, never through a tag. It verifies the signature over the layer digest before downloading, caps the layer size (256 MiB by default), and checks the digest while reading. It then requires release digest = pointer digest and release org = pointer org (internal/bundle/pointer.go:467-506,internal/bundle/pointer.go:416-429). -
Variant. If the ring release lists client-axis experiments,
selectVariantcomputes the device’s variant withpolicy.Experiment.ResolveVariant, the same hash the gateway uses. It skips experiments on the kill list and pulls the variant channel with the same pointer checks. If that channel fails, the whole cycle fails: falling back would silently move treatment devices to control (cmd/halod/agent.go:269-317). -
Toggles.
activeTogglesevaluates the client toggles in the signed manifest against the ring, subject and groups, minus the killed ones.withTogglesmerges their per-harness fragments (cmd/halod/toggles.go:27-96). -
Check targets. For every file,
checkTargetrequires (cmd/halod/agent.go:537-547,cmd/halod/paths.go:65-83,cmd/halod/paths.go:119-141):- a path inside the adapter’s managed directories or files
- mode
0644or lower - every existing parent root-owned, not group- or world-writable, and not a planted symlink
A file that fails is refused and the rest still apply.
-
Write.
ensureFilewrites only when the content hash differs, and flags drift when the release did not change.fsutil.WriteAtomicwrites a temp file, fsyncs it and renames it over the target (cmd/halod/agent.go:549-583,internal/fsutil/fsutil.go:15-59). -
Stale files. Files the previous release owned that this one does not ship are removed, but only inside managed locations (
cmd/halod/agent.go:505-520). -
State and report.
state.jsonrecords the digest, owned files, pointer marks, installs and kill list. The status is printed as one JSON line and POSTed toreportURLwith the device token (cmd/halod/agent.go:523-532,cmd/halod/agent.go:660-692).
Between pulls, PollKill reacts to kill-list changes without a registry round trip (cmd/halod/agent.go:352-394):
- If the device’s experiment was just killed, halod applies the cached, already-verified ring release.
- If the experiment was unkilled, or toggle kills changed while the device is on a variant, halod runs a full cycle.
Controller loop
Section titled “Controller loop”halo-server --controller runs the loop every --interval (5m by default, at least 1m) (cmd/halo-server/main.go:76). After a failed tick it retries sooner, from 15 s up to the interval (internal/controller/controller.go:356-376). halo controller run runs the same loop.
-
Tick. One tick has a 2-minute budget. It loads the served policy and walks the running experiments by name (
internal/controller/controller.go:85-125). -
Rollout-owned. An experiment that backs an active Rollout is skipped here; the rollout drives it (
internal/controller/rollouts.go:61-70). -
Killed. A killed running experiment is not evaluated, because its evidence is all control. Humans are told once (
internal/controller/controller.go:127-138,internal/controller/controller.go:310-322). -
Evidence.
promote.ClickHousereadshalo_metricsover ClickHouse’s HTTP interface with server-side bound parameters (internal/promote/source.go:38-136).Samplesreturns one value per (variant, unit) and the evidence source of the row (internal/promote/source.go:219-253). -
Guardrails. Each guardrail is tested with
stats.SeqGuardrail, an always-valid mSPRT confidence sequence, at alpha divided by the number of guardrails. Any failure is arollback(internal/promote/promote.go:117-137,internal/stats/guardrail.go:37-71). -
Budgets. Exceeding
maxDaysormaxSpendUSDisexpired(internal/promote/promote.go:139-146). -
Sample floor. Fewer than
minSamplesper arm iscontinue(internal/promote/promote.go:148-152). -
Primary. A significant regression is
rollback. A significant improvement with every guardrail passing ispromote(internal/promote/promote.go:154-171). -
Rollback. The kill switch is tripped first, but only when the deciding evidence is gateway-sourced, because CLI and eval telemetry can be forged or are opinion (
internal/controller/controller.go:182-213). The kill appends tokillswitch.jsonlwith an fsync (internal/controller/killstore.go:112-149). Every gateway sees it at its next kill-list poll (10 s by default). The controller then opens a pause PR and notifies, even if the kill failed (internal/controller/controller.go:215-232). -
Promote or expired. The controller opens a PR concluding the experiment, then notifies (
internal/controller/controller.go:234-250). PRs are opened byGitWriter, which commits on a new branch in a temporary worktree, pushes it and runsgh pr create(internal/controller/writer.go:31-58,internal/promote/pr.go:311-328). Nothing merges. Each action is recorded incontroller-state.jsonl, so it happens at most once per experiment run (internal/controller/controller.go:252-268). -
Rollouts. For each Rollout (
internal/controller/rollouts.go:42-136):- its state is loaded from
rollouts/<name>.json, and a file whose hash chain does not verify is never acted on (internal/rollout/state.go:154-186) - evidence and scorecards are gathered, and
rollout.Evaluatedecidesadvance,hold,rollback,pauseorcomplete(internal/rollout/rollout.go:84-151)
rollbackandpausehalt the rollout. A traffic-axis rollback on gateway evidence also trips the kill switch. Both then open a PR (internal/controller/rollouts.go:138-181).advanceandcompleteonly open a PR (internal/controller/rollouts.go:183-195). - its state is loaded from
Data model
Section titled “Data model”Every document is apiVersion: halos.dev/v1 (internal/policy/types.go:6-13). policy.Load joins them into one Org (internal/policy/types.go:34-53). The JSON Schemas are in schemas/.
| Kind | Go type | Key fields | References |
|---|---|---|---|
halos.yaml (root) |
Root (internal/policy/load.go:21-33) |
org, identity (issuer, clientID, audience, claims, adminGroups), selfService, simple-mode fields |
none |
Gateway |
Gateway (internal/policy/types.go:81-97) |
baseURL, protocols, auth, models (alias to ModelRoute), upstreams, engine |
Routes name upstreams; a route is one upstream+model or weighted, prioritized targets (internal/policy/types.go:109-129) |
Profile |
Profile (internal/policy/types.go:223-240) |
extends, harnesses (exact version pin, model, overrides), models, permissions, mcp, hooks, telemetry, egress |
extends another Profile; harnesses.*.model must be a Gateway alias |
Ring |
Ring (internal/policy/types.go:315-337) |
order, profile, release (pinned digest), membership (users, groups, percent, default, optIn) |
Profile |
Experiment |
Experiment, Variant (internal/policy/types.go:377-402) |
type (ab, canary, shadow), axis (client, traffic), status, rings, variants, metrics, stopping, salt |
Rings; traffic variants override Gateway alias routes; client variants name a Profile |
Toggle |
Toggle (internal/policy/toggle.go:28-84) |
owner, expires, default, axis, ordered rules (rings, groups, users, percent, effect), client or traffic.routes |
Rings in rules; traffic toggles override Gateway alias routes |
Rollout |
Rollout, RolloutStep (internal/policy/rollout.go:58-129) |
axis, status, step, experiment, change (release or alias), baseline, steps (strategy, ring, percent, bake, gates, onFailure) |
The backing Experiment; Rings per step; baseline.release for rollback |
Ring and variant assignment both go through internal/assign (internal/policy/resolve.go:22-74).
Trust zones and keys
Section titled “Trust zones and keys”| Secret | Held by (private part) | Verified by | Code |
|---|---|---|---|
| Release key (ed25519) | The publisher: halo release publish --key |
halod (pubkey/pubkeys minus revokedKeys) |
internal/bundle/sign.go:52-67, cmd/halo/cmd_release.go:104-123, cmd/halod/keys.go:40-65 |
| Kill-list key (ed25519, a separate key) | halo-server --killswitch-key-file |
halo-proxy --killswitch-pubkey-file, halo-kong killswitch_pubkey, halod killSwitch.pubkey |
cmd/halo-server/main.go:207-218, internal/gateway/killswitch.go:44-75, internal/server/killswitch.go:162-197 |
| Gateway token | halo-server --gateway-token-file (random hex or base64, at least 32 characters and 128 bits; compared as sha256, in constant time) |
Presented by gateways on GET /api/v1/gateway/killswitch |
cmd/halo-server/main.go:268-300, internal/server/killswitch.go:130-148 |
| Fleet token | halo-server --token-file |
MDM and devcontainer fleets on POST /api/v1/fleet/report |
internal/server/server.go:273-277, internal/server/server.go:296-300 |
| Device token | Minted at POST /api/v1/enroll from a one-time enrollment token; halo-server stores only its hash; expires after --device-ttl |
halod on fleet ring, report and kill-list calls | internal/server/portal.go:510-608, internal/server/devices.go:110-165, internal/server/devices.go:302-322 |
| Developer JWT | The IdP | halo-proxy and halo-kong (go-oidc, JWKS) | internal/identity/oidc.go:71-91 |
| Console session | halo-server (OIDC code flow, state and nonce checked, sealed cookie) | halo-server | internal/server/auth.go:111-132, internal/server/auth.go:322-400 |
| OTLP gateway token | Collector env HALO_OTLP_GATEWAY_TOKEN |
Gateway receiver :4319, which stamps halo.source=gateway; halo-proxy sends it from --telemetry-token-file |
internal/telemetry/collector.go:71-102, cmd/halo-proxy/config.go:183 |
| OTLP eval token | Collector env HALO_OTLP_EVAL_TOKEN, which must differ from the gateway token |
Eval receiver :4320 (halo.source=eval) |
internal/telemetry/collector.go:92-95 |
| CLI receivers | Optional token file | :4317 gRPC and :4318 HTTP (halo.source=cli) |
internal/telemetry/collector.go:71-86 |
| Shadow token | HALO_SHADOW_TOKEN |
halo-shadow /mirror |
cmd/halo-shadow/main.go:79 |
| Provider credentials | The gateway host: the env var or file a policy credential names (Azure OpenAI, OpenAI, Gemini), read at use time; the AWS default chain (Bedrock); GOOGLE_APPLICATION_CREDENTIALS or the metadata server (Vertex); operator upstreamHeaders (Anthropic, orchestrator) |
Never taken from the client, except with --forward-auth |
internal/gateway/upstreamauth/apikey.go:21-45, internal/gateway/upstreamauth/bedrock.go:108-125, internal/gateway/upstreamauth/vertex.go:202-214 |
Only gateway-sourced evidence may trip the kill switch automatically. CLI telemetry can be forged by anyone who can reach the collector, and eval evidence is a model’s opinion; both open the pause PR, and a human decides (internal/controller/controller.go:193-200).