README
¶
halo-server
Serves the Halos API and console. Optionally runs the automated experiment
loop (--controller) and the gateway kill switch.
Core flags
| Flag | Default | Purpose |
|---|---|---|
--policy-dir |
(required) | Served policy repo checkout. SIGHUP reloads. |
--token-file |
(required) | Bearer token halod uses for POST /api/v1/fleet/report. |
--listen |
127.0.0.1:8080 |
API/console listen address. |
--data-dir |
in-memory | JSONL logs: fleet, requests, devices, audit, killswitch.jsonl, controller-state.jsonl. |
--verdicts-file |
Verdicts JSON array shown in the console; the controller (and halo exp analyze --verdicts-file) upserts it. --verdicts is a deprecated alias. |
|
--portal-config |
Portal/self-service JSON config. | |
--audit-config |
Audit log rotation and SIEM export JSON config (ADR-0016). Requires --data-dir. |
|
--trusted-proxy-cidrs |
Reverse proxies whose X-Forwarded-For is trusted for rate limiting. |
|
--device-ttl |
90d | Device token lifetime. |
HA on Postgres (ADR-0021)
| Flag | Default | Purpose |
|---|---|---|
--database-url-file |
File holding a Postgres URL. State (devices, requests, session revocations, audit chain, kill list, controller log, rollout state, reports, enrollment tokens, login states) lives there instead of --data-dir (the two are exclusive), so several replicas can serve. The controller runs on the replica holding the leader lease. |
|
--auto-migrate |
off | Apply pending schema migrations at start; otherwise the schema must already be current. |
--leader-lease-ttl |
15s | Controller lease length (>= 3s); a failed leader is replaced within about this long. |
--database-max-conns |
16 | Connection pool size per replica; replicas x this must stay under the server's max_connections. |
--database-insecure-tls |
off | Accept a URL that does not verify the server certificate and host name (below sslmode=verify-full) to a non-loopback host. Test environments only; loopback and Unix sockets never need it. |
halo-server migrate --database-url-file F # apply migrations, print the schema version
halo-server import --from-data-dir DIR --database-url-file F # copy a STOPPED server's data dir into an empty database, then verify counts and the audit chain
--killswitch-key-file and --controller accept --database-url-file in place of --data-dir. /readyz also fails while the database is unreachable, and --metrics-listen adds halo_leader, halo_leader_changes_total, halo_db_errors_total and halo_db_open_connections. Integration tests run against a real Postgres when HALO_TEST_PG_DSN is set.
Kill switch
Instant rollback on the traffic plane, with no PR merge needed. Killed experiments are treated as not running by halo-proxy and halo-kong: control routing and no shadow mirroring.
| Flag | Purpose |
|---|---|
--killswitch-key-file |
ed25519 PKCS#8 PEM private key (halo keys generate --name killswitch). It signs the kill list. Use a dedicated key, not the release key. Requires --data-dir (kills must survive a restart). |
--gateway-token-file |
Bearer token (at least 32 characters and 128 bits of estimated entropy; openssl rand -hex 32) that gateways present to GET /api/v1/gateway/killswitch. |
The key needs the token; startup fails otherwise, and fails if the key is set without --data-dir. The token alone serves only GET /api/v1/gateway/posture (below). Without a key the server is honest about it: no kill list is served, kill and unkill return 501, GET /api/v1/capabilities reports killSwitch: false, and the in-process controller reports rollbacks as not enforced ("merge the pause PR urgently") instead of recording a kill nobody reads. The gateway endpoint returns
{"payload": base64(JSON {version, experiments, issuedAt}), "signature": base64(ed25519("halo-killswitch-v1\n" + payload))}.
Gateways reject a list if:
- the signature is bad,
- it is older than 10 minutes or more than 1 minute in the future, or
- it is not strictly newer than the list they already hold.
On a fetch failure a gateway keeps its last-known list. A gateway that has never fetched a list kills nothing.
Admin endpoints (session plus admin role, same-origin, audit-logged as experiment.kill or experiment.unkill):
POST /api/v1/experiments/{name}/killwith body{"reason": "..."}(required, 500 characters max; HTTP 422 without one)POST /api/v1/experiments/{name}/unkillwith body{"reason": "..."}(optional)GET /api/v1/killswitchlists active kills with who, when and why.
A kill does not expire. Pausing or concluding the experiment in policy does not lift it. Unkill it explicitly before you restart the experiment.
Audit
Privileged actions append to the hash-chained audit.jsonl in --data-dir, fsynced before the action is acknowledged. They fail closed: kill, unkill, device enrollment, session and device revocation and experiment-status PRs return HTTP 500 if the append fails. A kill whose audit append fails stays applied (fail safe), a failed unkill is reverted, and a revocation stays in force. audit.jsonl has a single writer: do not point halo controller run at a data dir this server is using.
With --audit-config, rotation (maxBytes, maxAgeSeconds) renames the active audit.jsonl to
audit-<first seq, 20 digits>.jsonl and starts a new one; the hash chain runs on across segments. halo audit verify --data-dir DIR checks the whole chain. export.sinks forwards every entry, at least once and in order,
to a Splunk HEC endpoint, an HTTPS webhook (signed like the controller's) or RFC 5424 syslog over TLS, each with a
cursor in <data-dir>/audit-forward/. Export never blocks or fails an action. See the
SIEM export guide.
Controller (--controller)
On every tick the controller evaluates each status: running experiment
against ClickHouse evidence and then:
- rollback: kills the experiment at once if the deciding evidence is gateway-sourced and a kill key is configured, opens a PR that sets
status: pausedwith the evidence in the body, and notifies. Rollbacks decided on CLI telemetry or eval (LLM-judge) evidence are not auto-killed (killOutcome: not_gateway_evidence); without a kill key the notification says to merge urgently. - promote: opens a PR that concludes the experiment and notifies. Before merging, a human adds the rollout change (ring release or model route).
- expired: opens a PR that concludes the experiment and notifies.
The controller never merges a PR. It takes each action at most once per
experiment run; the record lives in controller-state.jsonl. When the
experiment is next seen not running, that record resets. Errors are retried
at 15s, 30s and so on, up to the tick interval. A running experiment that is
already killed is held, not evaluated; notifications tell humans to unkill
before resuming. Notifications are tracked per channel and a failed channel is
retried on later ticks without re-posting to the others.
| Flag | Default | Purpose |
|---|---|---|
--controller |
off | Enable the loop. Requires --clickhouse-url and --data-dir. |
--interval |
5m |
Tick interval (minimum 1m). Each tick is bounded to 2m. --controller-interval is a deprecated alias. |
--clickhouse-url, --clickhouse-database, --clickhouse-user, --clickhouse-password-file |
Evidence source (halo_metrics table). |
|
--policy-repo-dir |
Dedicated clone used to open PRs. It is hard-reset to origin/<base> before each PR, so it must be separate from --policy-dir and the portal policyRepoDir. Without it, no PRs are opened. Needs git and an authenticated gh. |
|
--policy-repo-subdir |
Policy root inside the clone. | |
--policy-repo-base |
main |
PR base branch. |
--notify-slack-url-file |
Slack incoming-webhook URL. Posts {text, blocks}. |
|
--notify-webhook-url-file |
Generic webhook URL. Posts the JSON event. | |
--notify-webhook-secret-file |
HMAC-SHA256 secret. Adds X-Halo-Timestamp: <unix s> and X-Halo-Signature: sha256=<hex HMAC(secret, timestamp + "." + body)>. Receivers must verify it and reject timestamps more than 5 minutes off (Go and Python snippets: docs site, CLI reference, "Verifying the webhook signature"). |
|
--metrics-listen |
off | Serves /metrics with halo_server_audit_rotate_errors_total, the halo_server_audit_forward_* export series (with sinks) and, with --controller, halo_controller_ticks_total, halo_controller_verdicts_total{verdict} and halo_controller_errors_total. Unauthenticated, so keep it internal. |
Webhook URLs are secrets. They are read from files and never logged.
For CI or cron, halo controller run --once --policy-dir . --clickhouse URL --data-dir DIR
runs the same loop once. Its PRs come from the CI checkout. Its kills reach the
gateways only when DIR is halo-server's --data-dir (a shared volume).
Run only one controller per data dir. halo controller run also appends its
kills to audit.jsonl, so it must not run against a data dir a live
halo-server is writing; use --controller there. Pass --killswitch-served to
halo controller run only when this server (with --killswitch-key-file)
serves that data dir; otherwise its messages say the kill is only recorded.
Device posture
GET /api/v1/gateway/posture?subject=<id> (gateway token) answers {"compliant": bool, "reason": "..."} for the
posture gate of rings with posture: warn|enforce. Compliant means the subject has at least one enrolled device and
every enrolled device (not revoked, not expired) reported within --posture-max-age (default 45m), with no drift, on
its ring's current verified release or one of that release's experiment channels (checked only when the portal config
names a registry). See ADR-0011.