Rings and releases
Release
Section titled “Release”A release is an immutable, content-addressed OCI artifact containing:
- pinned CLI versions per harness, each with verified install artifacts (URL, size, sha256 or npm sha512 integrity) resolved at build time,
- rendered per-harness files for each target OS (managed settings, requirements, system settings, managed MCP config, managed instructions), each with its sha256 and mode,
- warnings for anything an adapter could not enforce,
- the org name.
It is identified by digest and signed with ed25519 (ADR-0005); cosign can be added as a co-signature. halo release publish tags it v<version> and ring-<ring> for humans, but consumers never trust tags.
Ring pointer
Section titled “Ring pointer”A ring is served by a signed pointer, stored as its own OCI artifact under the tag ring-<name>.pointer:
{"org":"acme-corp","ring":"ring1-canary","digest":"sha256:...","manifest":"sha256:...","manifestSize":1234, "seq":1790784827,"issuedAt":"2026-09-30T16:13:47Z","expiresAt":"2026-10-07T16:13:47Z"}halod trusts the pointer, never the ring tag. Before it fetches anything it verifies the signature (domain-separated from release signatures) and then checks:
| Check | Refuses |
|---|---|
org equals the org in halod.yaml |
a release built for another org |
ring equals the ring halod follows |
a pointer copied from another ring |
seq is not lower than the last applied seq for that ring |
replay of an older pointer (freshness) |
expiresAt is in the future |
a frozen or stale registry |
| the release signature over the digest it names | a tampered release |
seq is max(served + 1, recorded + 1, unix time): served is the pointer the registry currently serves and recorded the last one this signer wrote (see signer continuity), so a replayed old pointer cannot make the signer reuse a number. halod stores the last applied {seq, digest} per ring in its state file.
Pointers expire after 7 days
Section titled “Pointers expire after 7 days”If nobody re-signs a pointer, halod refuses the ring after seven days and keeps the last good release. Run halo release refresh on a schedule well inside that window, for example daily. halod logs a warning when a pointer has less than 24 hours left.
halo release refresh --ring ring1-canary --registry ghcr.io/acme/halos --key halo.key.github/workflows/refresh-pointers.yml.example is a ready-to-copy scheduled workflow. See production deployment.
refresh refuses an expired pointer: it cannot tell a lapsed schedule from a replayed pointer, and re-signing it would launder a replay into a fresh one. To recover a ring whose pointer expired, point it at a release you choose: halo rollback --ring <R> --to <version>.
Signer continuity
Section titled “Signer continuity”halod validates what it reads; the signing commands validate what they write. publish, promote, refresh and rollback keep a signer state file ($XDG_STATE_HOME/halos/pointers.json, else ~/.local/state/halos/pointers.json; --state-file overrides) with the last pointer written per registry repo and ring. They refuse a registry that serves a lower seq, the same seq with a different digest, or no pointer where this signer wrote one. In CI, cache the file between runs or pass --expect-digest sha256:<release digest> to refresh/promote; a fresh runner without either is refused when the registry already serves a pointer, unless you pass --adopt-existing, which trusts the served pointer (no replay protection for that run).
Every signature is announced on stderr first, which is the line to read in a CI log:
Signing ring ring1-canary → version 1.4.2 (digest sha256:..., seq 1790784827)Design: ADR-0008.
A ring is an ordered cohort that points at a profile (Ring.profile) and, once published, at a release.
Membership
Section titled “Membership”usersand IdPgroupsare always in the ring (users checked first).- Remaining users are hashed:
hash(user, salt) % 10000falls into a ring by cumulativepercent. Basis-point precision, deterministic, and computed by one Go package shared byhalod,halo-proxy,halo-kongandhalo-shadow. - The ring with
default: truecatches everyone else. optIn: truelets a developer request the ring from the portal; approval opens a PR.
Experiment channels
Section titled “Experiment channels”When a running client-axis experiment enrolls a ring, halo release publish --ring <R> --release-version <V> writes more than the ring release:
| Written | Tags |
|---|---|
| Ring release | v<V>, ring-<R>, pointer ring-<R>.pointer |
| One variant release per variant | v<V>-x-<exp>.<variant>, ring-<R>.x-<exp>.<variant>, pointer ring-<R>.x-<exp>.<variant>.pointer |
Variants are pushed first and the ring last, so no device sees a ring manifest that names an unpublished channel. A channel name longer than 100 characters uses a sha256 fallback (<R>.x-<hash20>); see experiments.
- A channel pointer is a ring pointer whose
ringfield is the channel name: same signature, org binding,seqand 7-day expiry.halodkeeps a separate{seq, digest}mark per channel, so anti-rollback applies to each. - A variant release is only served on its own channel. Pointing any other ring or channel at it is refused.
refreshandrollbackinclude channels automatically.refreshre-signs the ring pointer and the pointer of every channel the ring’s current release names.rollback --to <version>moves the ring and each channel to the variant releases published with that version (each with a higherseq). Nothing extra to run.- Cross-ring
promotemoves only the ring pointer.halo release promote --from-ring A --to-ring Bpoints B at the release A’s signed pointer names (not thering-Atag, which is unauthenticated). That release was built for A, so B’s devices ignore itsexperimentssection and get control; B’s channels are not created. To run the experiment in B, enroll B in the experiment and publish B.
Lifecycle
Section titled “Lifecycle”| Transition | Who decides |
|---|---|
| draft to eval-gated | halo release publish (guardrails, render, sign, push) |
| eval-gated to ring0 | Eval scorecard passes the gate you set in CI |
| ring N to ring N+1 | halo exp analyze gives a promote verdict; halo exp promote opens a PR; a human merges; a human or CI then runs halo release promote |
| any to rolled-back | Human, or automatic on guardrail breach |
Automatic promotion is never allowed. Automatic rollback is.
Rollback
Section titled “Rollback”Rollback signs a new pointer with a higher seq that names an older release, so clients that refuse to go backwards still follow it.
halo rollback --ring ring1-canary --to 1.0.0 # release versionhalo rollback --ring ring1-canary --to sha256:... # or OCI manifest digest--to <version> resolves tag v<version> and is refused unless the release’s signed manifest carries that version, so a retagged v tag cannot redirect a rollback. --to sha256:... is the OCI manifest digest (the manifest field of halo release publish --output json), content-addressed; it is not the release digest that publish prints and --expect-digest takes. Rollback also works on a ring whose pointer has expired.
--registry, --key and (for a local registry) --plain-http are required as for publish. If the ring runs a client-axis experiment, its channels roll back with it. Dev containers pick it up on rebuild; halod on its next pull (default every 15 minutes). Traffic-axis changes roll back at the gateway without a release: kill the experiment (halo kill <name> --reason ..., effective at the next poll: by default 10 s at gateways and 60 s on devices), then pause or conclude it in policy (halo exp pause, halo exp conclude).