Horkos
The approval broker for agent-requested privileged runs — Phase 3 of
warrant-approval-gated-runs. Agents file requests; Erich approves in the
UI; Horkos consumes the resulting warrant and dispatches the workflow as
horkos-forge. A first-party FastAPI + SQLite service.
Né Warrant (renamed + extracted to its own repo 2026-08; Ὅρκος is the
Greek daimon of oaths). The minted approval artifact is still called a
warrant — Horkos is the service that mints and consumes them — which is
why warrant-policy.yaml and the Warrant request: #N PR stamp keep their
names, while the dispatch identity was renamed to horkos-forge
(step 1 of blumeops#1039).
Quick Reference
| Property | Value |
|---|---|
| URL | https://horkos.ops.eblu.me (tailnet; caddy site) |
| Namespace | horkos |
| Source | https://forge.eblu.me/eblume/horkos (own repo; auto-releases on merge to its main) |
| Image | registry.ops.eblu.me/blumeops/horkos (locally built Nix, default.nix in the horkos repo) |
| Manifests | argocd/manifests/horkos/ — ArgoCD app auto-syncs (the reviewed pin-PR merge is the gate; manual until 2026-09-04) |
| Storage | 1Gi PVC (SQLite at /data/horkos.db — migrated from warrant’s DB, schema unchanged) |
| Agent auth | Authentik talos-m2m client-credentials JWT (JWKS-verified) |
| Human auth | Authentik OIDC code flow (horkos client), admins group, MFA per the authentik flow |
| Dispatch identity | horkos-forge PAT (write:repository,write:issue on blumeops), op://blumeops/horkos-forge-token |
Instance config
The app’s per-instance identity is delivered by the deployment, not the image (eblume/horkos#52): scalars as env in argocd/manifests/horkos/deployment.yaml (HORKOS_OIDC_URL, HORKOS_HUMAN_ISSUER, HORKOS_HUMAN_CLIENT_ID, HORKOS_PUBLIC_URL, HORKOS_REGISTRY, HORKOS_REPO_OWNER, HORKOS_REPO_NAME, HORKOS_POLICY_FILE, HORKOS_PR_ASSIGNEE), the release-publisher topology as release-targets.json in the horkos-release-targets ConfigMap, mounted at /etc/horkos and selected by HORKOS_RELEASE_TARGETS_FILE. Two config surfaces, deliberately — the same shape as talos’s env scalars plus repos.json/lanes.json. From the instance-document release on, every field is required at boot and a missing one names itself in the crash. Optional HORKOS_GIT_EMAIL sets the author email of the release publisher’s pin-PR commits; unset, a neutral noreply default is used.
The document also carries the merge-hook warrant filers as an optional auto_filers key (eblume/horkos#54): one entry per watched prefix, {path_prefix, action, inputs}, where an inputs value carrying {sha} is bound to the merged commit and must be the action’s binds_sha input in warrant-policy.yaml. A main merge touching the prefix files the warrant request itself (bound to the 40-hex SHA) for a human to approve. The key is optional and may be [] — an instance with no filers boots; the deployed instance runs the one filer above, watching argocd/apps/ (the app-of-apps root) and filing argocd-sync-apps.yaml at the merged SHA.
The flow
agent: mise run request-run <workflow> <sha> …
→ policy check (warrant-policy.yaml on blumeops main) → PR comment + heph task
→ POST /api/requests (talos-m2m JWT)
human: horkos.ops.eblu.me → sign in → read the diff → approve…
→ confirm page (full inputs, commit/PR/diff links)
→ warrant minted: single-use, TTL'd, {action, sha, inputs} frozen
→ consumed → workflow_dispatch as horkos-forge → run recorded on the warrant
Denials close the request. Anything already executed should be denied with a note — the queue records intent to execute, not history.
The policy file stays in blumeops, deliberately: policy and service live in different repos, so the autonomy boundary and the code enforcing it can never change in a single PR.
API
POST /api/requests— file a request{action, sha, inputs, why, pr, pr_repo}(Bearer JWT);pr_repois theowner/nameholdingpr, default blumeopsPOST /api/requests/{id}/supersede{by}— retire one’s own pending request in favour of a later one (Bearer JWT)GET /api/requests[?status=…],GET /api/warrants— read the queue and the decisionsPOST /api/requests/{id}/decision{decision, note}— approve/deny (session;admins)POST /requests/{id}/decide— the UI form (CSRF-signed);GET /requests/{id}/confirmGET /healthz— includesdispatch: armed | armed-no-token | disarmedand the running version (stamped by the release build viaHORKOS_VERSION)
Release publishing
For image targets, a tag push in the source repo delivers a Forgejo hook to horkos, which waits up to 10 minutes for the built image to appear in the registry and then opens the pin PR bumping the target’s blumeops kustomization — merging that pin is the deploy for the image-target services (talos, horkos itself).
When the image lands after the wait budget — a cold nix build, for
instance — the release_events row ends failed with the stable image_never_appeared:
error code rather than opening the pin. Recovery is per-target: with the
target’s release_workflow field set — release.yaml for both the
eblume/horkos and eblume/talos image targets, and cv-release.yaml for
the eblume/cv artifact target — the source repo’s
action_run_success for that workflow re-runs the newest failed row that is
newer than the newest published release for the repo; every other failed row
is marked superseded, because publishing a version below the newest
candidate would open a downgrade pin PR that superseding never closes. The
failed→pending claim is atomic, so a recovery sweep and a manual Forgejo
Redeliver of the tag webhook can’t both publish. A failed row is also
logged at WARNING naming repo, version, and reason, and the home page’s
Recent releases table shows the last ten rows with their state — no
separate alerting
(eblume/horkos#61, deployed in v0.5.44).
The artifact flow is the same shape with a different failure code: when a
cv tag push outlives the same wait budget for its Forgejo release asset
(cv-vX.Y.Z.tar.gz), the row ends failed with asset_never_appeared:
instead, and the action_run_success sweep from cv-release.yaml
re-publishes it on the same rules — the re-dispatch re-waits on the now-present
asset and proceeds to the ansible-role pin PR
(eblume/horkos#62, deployed in v0.5.44).
A target without
release_workflow set never auto-recovers — its failed row stays failed
for a manual Redeliver from the Forgejo webhook’s Recent deliveries.
Operating it
-
Deploy: merge the auto-opened
horkos-release-vX.Y.Zpin PR. That is the whole deploy — the app auto-syncs, like talos. Confirm withcurl -s https://horkos.ops.eblu.me/healthz(versionfield) once ArgoCD’s reconciliation interval has passed. -
Disarm: set
HORKOS_DISPATCH_ENABLED: "0"in the deployment and sync. The credential stays in place, ignored; decisions still record, and dispatch falls back to a human in the forge UI. -
Check the power is real:
curl -s https://horkos.ops.eblu.me/healthz—armed-no-tokenmeans the ExternalSecret isn’t resolving (the failure that once looked exactly like “disabled”). -
Rotate the dispatch PAT:
mise run horkos-forge-provision -- --rotate(gilbert; needs an ephemeralwrite:admintoken, see the script). One live dispatch key is the invariant: the new PAT is minted ashorkos-forgeand verified (its id in horkos-forge’s token list + a push-reporting read ofeblume/blumeops) before anything is stored, then every predecessor whose name matcheshorkos-forge-*orwarrant-dispatch-*is revoked and the token list is re-read to assert exactly one dispatch token remains (--keep-othersopts out). After a rotation, force ESO to pick up the new value before restarting: the k8s Secrethorkos/horkos-dispatchholds the predecessor until ESO’s 1h refresh, and a rollout restart before that re-reads a dead token.kubectl --context=k3s-ringtail -n horkos annotate externalsecret horkos-dispatch force-sync=$(date +%s) --overwrite kubectl --context=k3s-ringtail -n horkos rollout restart deployment/horkos curl -s https://horkos.ops.eblu.me/healthz # expect armed, not armed-no-token -
Scope: only actions with
class: warrantinwarrant-policy.yamlare requestable — todayargocd-deploy.yaml,argocd-sync-apps.yaml,deploy-fly.yaml,forge-bot-pat-rotate.yaml(the talos Forgejo bot PAT rotation), andrun-script.yaml(one-off scripts). Its former deny member,provision-ringtail.yaml, was retired in #1527; the class remains defined for any future workflow that must read the human-onlyblumeopsvault. -
Cluster credential for run-scripts: the
run-scriptServiceAccount in this namespace (ClusterRolerun-script-pv-ops:persistentvolumesget/list/delete,persistentvolumeclaimsget/list— no secrets, and no other cluster-scope grants; the namespacedrun-script-talos-rolloutRole intaloscovers the CI pod roll withget/list/patch/watchon the namedtalosDeployment) backsblumeops-ci/k3s-run-script, the only non-root k3s credential. Rotation is a human act on ringtail: re-mintkubectl create token run-script -n horkos --duration=8760has root,op item editthe vault item, and file the rotation reminder a month before expiry; deleting the ServiceAccount is the kill switch. -
Test it: the service suite lives in the horkos repo (
scripts/testthere, and its CI).mise run horkos-testhere covers the blumeops-side client tooling (request-run,verify-runs). Neither needs a forge token, cluster, or vault.
Run attribution
The run a warrant names comes from the dispatch itself. Forgejo’s dispatch
endpoint answers 204 with no body by default, but with return_run_info: true
in the request body it answers 201 naming the run it created — so there is
nothing to infer, and no window in which an unrelated run could be taken for
ours.
A dispatch that answers any other way — 204 from a forge that ignores the
flag, or a body carrying no run number — leaves run_number null and links
the workflow’s run list. That is the intended outcome, not a degraded one: a
warrant asserts that a human authorized a specific run (invariant 5), so a
confidently wrong link is worse than an honest absence. dispatched_at is
recorded alongside, which makes a bad link falsifiable after the fact.
Since v0.3.4, GET /api/requests serializes each request’s latest warrant
(decision, decided_by, run_number, run_url, dispatched_at) so the
recorded link is consumable outside the database — mise run verify-runs
uses it to close approval tasks against the exact run their approval caused.
Forge-side inference remains only where there is NO recorded warrant link at
all (pre-stamp tasks or a warrant outage). A warrant record without a
run_number is reported, never guessed, in four ways: request status
dispatched means the dispatch ran but the forge never named the run (a
pre-return_run_info forge / 204 answer) — dispatched-norun, left for a
human to link or re-dispatch; request status dispatch_failed means the
dispatch attempt failed — reported, its reason on the warrant note, left
open; a voided request (void_reason pr-closed / policy-retired, from
eblume/horkos#35’s void handlers) is settled rather than never-
dispatched — the sweep closes the tracking task with the void reason instead
of leaving it open; any other run-less status (approved / denied /
superseded) was never
dispatched, and when the warrant note records the pre-dispatch no-op reason
( | not dispatched: …), the sweep surfaces it (eblume/horkos#13). The run
heuristic is refused for all four, never consulted (eblume/horkos#12).
Superseding
A PR that takes review feedback moves its head SHA, so the request bound to
the old commit is dead on arrival of the new one — but it stays pending, and
the queue then shows two near-identical entries with nothing to say which is
live. POST /api/requests/{id}/supersede {"by": <new id>} retires the old one:
status superseded, with superseded_by naming its replacement, rendered in
the UI as superseded → #N.
It is the only write an agent identity may make to an existing request, and it
can only ever reduce (invariant 4). superseded is not pending, so the
decision path refuses it: no warrant, no dispatch, no way back. The route is
scoped to the caller’s own still-pending requests, so an agent can neither
retire another identity’s request nor undo a human’s decision.
mise run request-run <workflow> <sha> --supersedes <id> is the whole loop
from the agent’s seat: file the new request, retire the old one, note the
supersession on its PR comment, and close its heph tracking task (matched by
title and the Warrant request: #<id> stamp — an ambiguous match closes
nothing and says so).
Known gaps
- UI ergonomics — keyboard patterns, filtering, search, embedded diff
view (heph
01KZ4NH4MF…). Approval ergonomics is the approve-fatigue mitigation, so this is substance, not polish. - Hardware-backed approval — TOTP today; the flow-slug design means a
WebAuthn stage is an authentik change, not a Horkos change (heph
01KZ0G21KZ…). - Secret leasing — deliberately never built; CI reading its own secrets
from
blumeops-cimay make it unnecessary. Stale Authentik leftovers— done 2026-08-23: the oldWarrantprovider/app (left behind because blueprints don’t prune) were deleted via the Authentik API, alongside the orphanedwarrantnamespace/PVC and the ghost dashboard tile. The pre-migrationwarrant.dbis parked atringtail:/root/warrant.db.pre-namespace-delete-2026-08-23.
Related
- warrant-approval-gated-runs — the program, its invariants, and why
- request-a-privileged-run — the request side, from an agent’s seat
- blumeops-ci-item-migration — the vault tier and what never moves
- authentik — identity provider (
talos-m2mandhorkosblueprints)