Reference — hort-server and hort-worker configuration

Canonical, exhaustive reference for the command-line surface and environment variables of the two v2 runtime binaries:

Scope: the v2 rewrite in crates/ only. The backend/ prototype and the prototype-migration how-tos are out of scope here.

Authority. The ground truth for every value below is the parsing code: crates/hort-server/src/config.rs (+ composition.rs, telemetry.rs) for hort-server and crates/hort-worker/src/config.rs (+ composition.rs, extra_ca.rs, telemetry.rs) for hort-worker. Where this page and that code disagree, the code wins and this page is the bug — fix it in the same change. The Helm-values mapping lives in how-to/deploy/values-reference.md; this page documents the binary surface the chart renders into.

Conventions used in the tables:


hort-server — command-line surface

hort-server is a multi-subcommand binary (clap). The subcommand is optional: a bare hort-server invocation is exactly equivalent to hort-server serve. Both ExecStart=/usr/local/bin/hort-server (systemd) and args: ["serve"] (k8s) are therefore correct and identical.

SubcommandPurposeConfig parsed
(none) / serveStart the HTTP service; run until SIGTERM/SIGINT.full Config
migrateApply pending DB migrations + re-assert events-role hardening, then exit. Init-container / pre-install-Job pattern.MinimalConfig
scrub [FLAGS]CAS integrity scrubber — re-hash stored blobs, detect drift.full Config
admin <SUB>Admin-user / service-token management (nested, required).MinimalConfig
reconcile-groups [--since]Heal artifacts whose ingest-path group commit dropped between the ArtifactIngested and ArtifactGroupMemberAdded transactions.MinimalConfig
verify-event-chainOffline tamper-evident event-chain verifier (ADR 0002): recomputes every per-stream hash chain, cross-checks live heads against anchored signed checkpoints, exits 0=ok/2=broken/3=missing_checkpoint/1=operational error. Read-only — never writes or migrates.MinimalConfig
enqueue-quarantine-release-sweepEnqueue one quarantine-release-sweep job and exit; the always-on worker dispatches it to QuarantineReleaseSweepHandler. The default executionPath: dsn-direct CronJob.MinimalConfig
seed-importParse an operator-supplied TSV describing a dependency cutover set and enqueue one seed-import job; the worker bulk-registers each item with a backdated quarantine_window_start (the time gate only — scan gate unchanged). One-shot, operator-invoked.MinimalConfig
enqueue-prefetch-tickEnqueue one prefetch-tick job and exit; the worker dispatches PrefetchTickHandler over every scheduled-eligible repo + tracked package.MinimalConfig
enqueue-prefetch-row-retention-sweepEnqueue one prefetch-row-retention-sweep job and exit; the worker deletes terminal kind LIKE 'prefetch%' rows older than a configurable horizon (default 7 days).MinimalConfig
enqueue-wheel-metadata-backfillEnqueue one wheel-metadata-backfill job and exit; the worker retrofits PEP 658 wheel_metadata ContentReferences for pre-existing PyPI wheels.MinimalConfig
validate-config [--strict]Offline gitops-config validation (CI pre-merge gate); see § validate-config.none — DSN-free; reads its own env directly
license [--full]Print hort's license identifier (and, with --full, the complete license texts) to stdout and exit.none — synchronous, no config, no DSN
attribution [--format json]Print the generated third-party attribution document (Markdown or JSON) to stdout and exit.none — synchronous, no config, no DSN

--help / --version work at the top level and on every subcommand. Unknown subcommand → clap InvalidSubcommand (non-zero exit). On error each subcommand prints Error: {err:?} (full anyhow context chain) and exits non-zero; a Tokio-runtime build failure prints error: building tokio runtime: {err}.

Why migrate/admin/reconcile-groups use MinimalConfig (ADR 0009): the runtime DSN is least-privilege (DML only) and these DB-only subcommands must not require the storage / public-base-url / OIDC surface. MinimalConfig parses exactly the five shared variables in § Core (shared). serve and scrub parse the full surface and enforce every interlock. Migrations are owned only by hort-server migrate — the serve path calls migrate::assert_current (a read-only check on _sqlx_migrations) and refuses to run migrate::run.

scrub flags

FlagTypeDefaultNotes
--concurrencyusize4Max in-flight re-hash tasks. Must be >= 1.
--sample-fractionf641.0Per-blob sampling probability; must be in [0.0, 1.0].

Exit code: 0 when there are no hash mismatches (even with missing / read-error blobs); 1 when any mismatch is found (so a CronJob can escalate); non-zero failure on config/connection error. The mismatch-handling behaviour itself is selected by HORT_CAS_SCRUB_ACTION_ON_MISMATCH.

admin subcommands

admin issue-svc-token — mint a service-account token.

The prior admin bootstrap subcommand is removed (it seeded the now-deleted HTTP-Basic-against- local-admin-row identity path — see docs/auth-catalog.md Entry 8). Minimal-setup bring-up uses issue-svc-token plus hort-cli auth login --paste; see crates/hort-server/README.md.

FlagTypeDefaultNotes
--nameString(required)Logical token name; derives the gitops SA backing-user username sa:<name>.
--permissionString (repeatable)["admin_task_invoke"]Permissions to grant.
--outputString"stdout"stdout \
--rotateflagoffForce revoke + re-mint; default is idempotent (exit 0, no rotation if it already exists).
--expires-in-daysu32365Token lifetime.

reconcile-groups flags

FlagTypeDefaultNotes
--sinceRFC 3339 timestamp(none → use-case default = last 7 days)Lower bound for the ArtifactIngested scan window.

validate-config

Offline gitops-config validation: runs the static subset of the boot-time apply pass — parse + cross-validate plus the snapshot-free linter rules, via the same StaticConfigValidator (crates/hort-app/src/lint/static_validate.rs) the apply path delegates to — over the tree in HORT_CONFIG_DIR, with no database and no running server. Implementation: crates/hort-server/src/cli/validate_config.rs. The CI-recipe walkthrough (deriving the env from Helm values) is in declare-gitops-config.md §5a.

Synchronous and DSN-free: it builds no Tokio runtime, parses neither Config nor MinimalConfig, and never reads HORT_DATABASE_URL / DATABASE_URL or connects to anything.

FlagTypeDefaultNotes
--strictflagoffPromote every warning — rule warnings, the zero-files warning, the HORT_UPSTREAM_USER_AGENT warning — from exit 0 to exit 1. The only flag; config inputs are env-only.

Environment inputs (read directly, not via Config):

VariableRequired?Behaviour
HORT_CONFIG_DIRYesThe gitops tree to validate — the same variable serve reads at boot. Unset or blank → exit 2.
HORT_STORAGE_BACKENDYesThe deployment storage-backend kind (filesystem \
HORT_UPSTREAM_USER_AGENTNoOutbound User-Agent override. Unset/empty → no finding. A non-empty value that is not a valid HTTP header value → warning (at boot the server silently falls back to its built-in default), exit 1 only under --strict. Linted with the same predicate the runtime applies (hort_adapters_upstream_http::validate_user_agent_override, crates/hort-adapters-upstream-http/src/lib.rs).
HORT_LOG_FORMATNojson → JSON tracing; anything else — including unset or an invalid value — → pretty. Unlike the other subcommands, an invalid value does not fail this command (lenient by design: a cosmetic log knob must never fail the gate).

Exit codes:

CodeMeaning
0Clean — no errors (warnings allowed when --strict is off).
1Validation error(s): a parse / cross-validate failure or any reject rule — or --strict with any warning present.
2Missing/invalid required env (HORT_CONFIG_DIR / HORT_STORAGE_BACKEND). Checked before any file is read.
3Operational — the config directory is unreadable / the directory walk errored.

Checks run vs. not run:

Checks
Run (offline)YAML parse + per-envelope domain validation + cross-validate + singleton-conflict; desired-internal ServiceAccountOidcIssuer reference consistency; the under-constrained federatedIdentities advisory (warning); the trustUpstreamPublishTime × empty-scanBackends cross-opt-in reject; the inert prefetchPolicy.maxAgeDays reject; provenance-policy validation against the binary's provenance-capable format set; the per-repo storage.backend vs HORT_STORAGE_BACKEND mismatch reject; the permission-grant linter (secure-default LintConfig base, desired-side overrides applied).
Not run (need the live deployment)Current-state checks (managed-by ownership, immutable-field changes). The scanBackends supported-backend check runs only at apply (validating against the binary's compiled-in scanner set, not the live worker registry — regression H20); the offline validator does not currently run it. A clean run is therefore necessary but not sufficient for a clean apply; the command prints a one-line footer saying so.

A directory that exists but contains zero YAML files validates clean — exit 0 — with the warning validated 0 config files — is HORT_CONFIG_DIR correct?; under --strict it is exit 1 (catches a typo'd-but-existing path in CI). The validator and its static facts (the provenance-capable format set, the linter defaults) are compiled into the binary, so the invoked image tag is exactly the version being validated against; there is no --version selector.


hort-server — environment variables

Core (shared — MinimalConfig)

Parsed by every subcommand except validate-config, which is DSN-free and reads its own env directly (see § validate-config).

VariableTypeDefaultRequired?Semantics
HORT_DATABASE_URLstringunset → falls back to DATABASE_URLYes¹Postgres connection DSN. Canonical operator var; tried first. Redacted in Debug.
DATABASE_URLstringunset → error if HORT_DATABASE_URL also unsetYes¹Fallback DSN. Read by sqlx-cli, the Tier-2 maybe_pool() test helpers, and 12-factor tooling; the chart now wires HORT_DATABASE_URL. Redacted in Debug.
HORT_LOG_FORMATpretty \jsonprettyNo
METRICS_INCLUDE_REPOSITORY_LABELbooltrueNoEmit the repository metric label; falserepository="_all" sentinel (cardinality control at scale). No HORT_ prefix.
PG_STATEMENT_TIMEOUT_MSu64unset → none (Postgres default)NoPer-session statement_timeout in ms. 0 rejected. No HORT_ prefix.
PG_ACQUIRE_TIMEOUT_SECSu6430NoPool acquire_timeout. 0 rejected. No HORT_ prefix.

¹ Exactly one of HORT_DATABASE_URL / DATABASE_URL is required; HORT_DATABASE_URL is the canonical operator var and is tried first, with bare DATABASE_URL as the documented compat fallback. With neither set the error surfaces the name "DATABASE_URL". This is identical to the worker.

Logging & tracing

VariableTypeDefaultRequired?Semantics
RUST_LOGtracing EnvFilter directiveinfoNoStandard tracing-subscriber filter; unset/unparsable falls back to info. There is no LOG_LEVEL knob.

HTTP server & binding (serve)

VariableTypeDefaultRequired?Semantics
HORT_API_BINDSocketAddr127.0.0.1:8080NoMain API listener. The binary default is loopback; the chart sets 0.0.0.0:8080 so kubelet probes reach it.
HORT_HTTP_HEADER_READ_TIMEOUT_SECSu6415NoSlowloris cap on request-line + headers.
HORT_HTTP_REQUEST_TIMEOUT_SECSu64300NoGlobal per-handler runtime deadline.
HORT_HTTP_OCI_UPLOAD_TIMEOUT_SECSu643600NoPer-route override for OCI blob-upload PATCH/PUT.
HORT_SHUTDOWN_GRACE_SECSu6460NoGraceful-drain wall-clock cap after SIGTERM/SIGINT.
HORT_PUBLISH_BODY_MAX_SIZEsize stringunset → 300 MiBNoPyPI/npm publish body cap. Size string ("300Mi", "1Gi", or a bare byte integer). "0" = refuse all publishes (kill-switch; not rejected).
HORT_METADATA_BLOB_MAX_SIZEsize string10Mi (10 MiB)NoCAS metadata-blob ceiling. Size string ("10Mi", or a bare byte integer). "0" = accept anything (not rejected).
METADATA_CAP_BYTES_<FORMAT>usizeunset → handler defaultNoPer-format metadata-cap override; <FORMAT> suffix is lowercased to the format key. No HORT_ prefix.

See tune HTTP transport timeouts for the timeout knobs in depth.

Trust, TLS & public URL

VariableTypeDefaultRequired?Semantics
HORT_PUBLIC_BASE_URLURLunset → noneCond.Public-facing base URL (scheme+authority only; must be http/https with a host).
HORT_TRUSTED_PROXY_CIDRSCSV of CIDRsemptyCond.Reverse-proxy allowlist for trusting X-Forwarded-*.
HORT_REQUIRE_HTTPSboolfalseNoRefuse to boot without positive TLS evidence (see interlocks).
HORT_EXTRA_CA_BUNDLEpathunset → system trust onlyNoExtra CA PEM bundle, layered onto the system store for all four outbound TLS surfaces (upstream proxy, S3/MinIO, OIDC discovery+JWKS, outbound webhook delivery). Fail-closed: set-but-unreadable / bad PEM / zero certs aborts boot. See extra-ca-bundle.md.

One of HORT_PUBLIC_BASE_URL or a non-empty HORT_TRUSTED_PROXY_CIDRS is mandatory — with neither set the binary fails at startup (ConfigError::TrustUnconfigured).

Authentication provider (OIDC)

VariableTypeDefaultRequired?Semantics
HORT_AUTH_PROVIDERdisabled \oidcdisabledNo
HORT_OIDC_ISSUER_URLstringunset → error when oidcCond.OIDC issuer (iss).
HORT_OIDC_AUDIENCEstringunset → error when oidcCond.Required token audience (aud).
HORT_OIDC_GROUPS_CLAIMstringgroupsNoIdP claim carrying group memberships.
HORT_JWKS_CACHE_TTL_SECSu64600NoJWKS cache TTL.
HORT_OIDC_CLI_CLIENT_IDstringunset → noneCond.OAuth client id hort-cli presents for the device flow (required iff token exchange is enabled).
HORT_JWKS_EVICTION_BACKOFF_SECSu6410NoPer-kid JWKS signature-mismatch eviction cooldown.
HORT_JWKS_RESP_BODY_MAX_SIZEsize string1Mi (1 MiB)NoDiscovery / JWKS response body cap. Size string ("1Mi", "4Mi", or a bare byte integer); a sub-1-byte value (incl. "0") is rejected.

Native tokens, OCI token signing & token exchange

VariableTypeDefaultRequired?Semantics
HORT_NATIVE_TOKENS_ENABLEDboolfalseNoEnable the native Bearer hort_<kind>_* token surface. Requires a resolved signing key.
HORT_TOKEN_EXCHANGE_ENABLEDboolfalseNoMount POST /api/v1/auth/exchange (RFC 8693). Requires HORT_NATIVE_TOKENS_ENABLED=true always; under HORT_AUTH_PROVIDER=oidc additionally requires HORT_OIDC_ISSUER_URL + HORT_OIDC_CLI_CLIENT_ID + HORT_PUBLIC_BASE_URL (these back the interactive discovery doc and are not consulted under disabled — federation-only mode).
HORT_OCI_TOKEN_SIGNING_KEYinline PEMunset → noneCond.Active OCI-token Ed25519 PKCS#8 signing key (inline). Redacted.
HORT_OCI_TOKEN_SIGNING_KEY_FILEpathunset → noneCond.Active signing key from a file (preferred over inline; mutually exclusive with it).
HORT_OCI_TOKEN_SIGNING_KEY_PREVinline PEMunset → noneNoPrevious signing key (verify-only, for rotation).
HORT_OCI_TOKEN_SIGNING_KEY_PREV_FILEpathunset → noneNoPrevious signing key from a file (mutually exclusive with the inline _PREV).
HORT_TOKEN_ALLOW_ADMINboolfalseNoPermit Permission::Admin in token issuance / exchange.
HORT_TOKEN_ALLOW_UNBOUNDED_SVCboolfalseNoPermit null-expiry service-account tokens via admin-mint.
HORT_BEARER_ALLOW_OVER_HTTPboolfalseNoAllow bearer auth (every bearer kind: PAT + CliSession JWT) over plaintext HTTP (transport-unsafe; logs one boot WARN + sets the unsafe-config gauge). Boot-fails when combined with an https:// HORT_PUBLIC_BASE_URL — a TLS-terminated deploy that also relaxes the bearer transport guard is self-contradictory (INFRA-13). Only WARNs when HORT_PUBLIC_BASE_URL is http://... or unset.

Rate limiting, lockout & load shedding

The HORT_AUTH_LOCKOUT_* env vars are removed — they powered the now-deleted authenticate_local HTTP-Basic-against-local-admin-row path. The HORT_PAT_LOCKOUT_* env vars (a distinct mechanism — bearer-path per-IP / per-token-prefix brute-force protection inside PatValidationUseCase) are unchanged.

VariableTypeDefaultRequired?Semantics
HORT_RATELIMIT_AUTH_PER_MINu3260NoPer-IP auth-attempt rate cap.
HORT_RATELIMIT_WRITE_PER_MINu32300NoPer-IP write-path rate cap.
HORT_MAX_INFLIGHTusize512NoWorkspace-wide concurrent-request cap.
HORT_MAX_INFLIGHT_PER_IPusize32NoPer-IP concurrent-request cap.
HORT_PAT_CACHE_SIZEusize10000NoPAT-validation cache size.
HORT_PAT_LOCKOUT_THRESHOLDu3230NoPer-IP PAT failed-attempt threshold.
HORT_PAT_LOCKOUT_WINDOW_SECSu64300NoPAT failed-attempt window.
HORT_PAT_LOCKOUT_DURATION_SECSu64900NoPAT lockout cooldown.

Storage

VariableTypeDefaultRequired?Semantics
HORT_STORAGE_BACKENDfilesystem \s3filesystemNo
HORT_STORAGE_FILESYSTEM_PATHpathunset → error when filesystemCond.Filesystem CAS root.
HORT_STORAGE_S3_BUCKETstringunset → error when s3Cond.S3 bucket name.
AWS_REGIONstringunset → error when s3Cond.S3 region (preferred). Falls back to AWS_DEFAULT_REGION.
AWS_DEFAULT_REGIONstringfallback for AWS_REGIONCond.Legacy region name; used only if AWS_REGION is unset.
AWS_ENDPOINT_URL_S3URLunset → noneNoS3 endpoint override (service-specific; wins over AWS_ENDPOINT_URL).
AWS_ENDPOINT_URLURLunset → noneNoS3 endpoint override (cross-service fallback).
AWS_ACCESS_KEY_IDstringunset → error when s3Cond.S3 access key (passed explicitly; the AWS SDK credential chain is not consulted).
AWS_SECRET_ACCESS_KEYstringunset → error when s3Cond.S3 secret key. Redacted.
HORT_STORAGE_S3_FORCE_PATH_STYLEboolfalseNoPath-style addressing (required true for MinIO/zot).
HORT_STORAGE_S3_ALLOW_HTTPboolfalseNoOpt-in to a plaintext S3 endpoint; cross-checked against the endpoint scheme (see interlocks).
HORT_S3_SSE_MODEbucket-default \sse256 \sse-kmsunset → no SSE opinion
HORT_S3_SSE_KMS_KEY_ARNstringunset → error when sse-kmsCond.KMS key ARN; required when HORT_S3_SSE_MODE=sse-kms.

Ephemeral store (Redis)

VariableTypeDefaultRequired?Semantics
HORT_EPHEMERAL_STORE_BACKENDmemory \redismemoryNo
HORT_REDIS_URLstringunset → error when redisCond.Redis URL. Redacted.
HORT_REDIS_URL_EVICTABLEstringunset → falls back to HORT_REDIS_URLNoPer-class evictable Redis override (per-class keyspace routing).
HORT_REDIS_URL_DURABLEstringunset → falls back to HORT_REDIS_URLNoPer-class durable Redis override.

Pull-through deduplication

VariableTypeDefaultRequired?Semantics
HORT_PULL_DEDUP_TTL_NOT_FOUND_SECSu6430NoNegative-cache TTL for upstream NotFound.
HORT_PULL_DEDUP_TTL_UNAVAILABLE_SECSu6410NoTTL for RateLimited / upstream-5xx / upstream-4xx / Unauthorized.
HORT_PULL_DEDUP_TTL_TIMEOUT_SECSu6410NoTTL for Timeout / NetworkError.
HORT_PULL_DEDUP_TTL_CHECKSUM_MISMATCH_SECSu6460NoTTL for ChecksumMismatch / ParseError / BodyTooLarge / PinMismatch / CaUnknown.
HORT_PULL_DEDUP_FOLLOWER_WAIT_SECSu64300NoFollower-wait ceiling for a concurrent in-flight fetch.
HORT_PULL_DEDUP_LEADER_TIMEOUT_SECSu64600NoHard ceiling on the leader's own fetch (issue #55); on expiry the leader is abandoned and the next caller elects fresh.
HORT_PULL_DEDUP_LEADER_LOCK_TTL_SECSu6490NoCluster-wide leader-lock lease (Layer B, issue #65). The heartbeat renews it every ttl / 3; a healthy heartbeat keeps a legitimately-slow leader's lock alive for the full HORT_PULL_DEDUP_LEADER_TIMEOUT_SECS, regardless of this value — this TTL instead bounds how long followers wait to detect a genuinely-dead (crashed, no heartbeat) leader before re-electing. Independent of the leader_deadline / HORT_STORAGE_PUT_TIMEOUT_SECS pairing in ADR 0050 — see that ADR's Relates note.

Pull-through stampede suppression is TTL-best-effort, not durable: the pulldedup: keyspace is registered in the Evictable class, so under the sustained pull-burst the negative cache exists to absorb, allkeys-lru eviction (or a Redis outage) can degrade cluster-wide coalescing toward per-replica or fully un-coalesced upstream retries. Correctness is never affected (the CAS + path-conflict short-circuit absorbs the fan-out idempotently); only backpressure efficacy degrades. This is an audited, accepted residual. There is no new metric — alarm on the existing signals only:

SymptomAlarm on (existing metrics — see docs/metrics-catalog.md)Reading
Redis-degraded coalescing (case 8 fail-open)Sustained non-zero rate of hort_pull_dedup_total{outcome="layer_b_unavailable"}Cluster-wide coalescing is off; every replica is un-coalescing independently.
Negative cache evicted under burst (case 9)hort_pull_dedup_total{outcome="negative_cache_hit"} rate collapsing while hort_pull_dedup_total{outcome="leader_started"} for the same format stays high, and/or evictable-class memory pressure visible as elevated hort_ephemeral_store_operations_total{class="evictable"} op rate (optionally with result="error")The Failed record is being evicted before its TTL; followers re-elect and re-hit the upstream instead of short-circuiting on the cached failure.

Operator action when the alarm fires: isolate the pulldedup: / evictable keyspace onto its own Redis instance via HORT_REDIS_URL_EVICTABLE (per-class routing — see the Ephemeral store table above) and/or raise that instance's maxmemory so the short-lived Failed records survive their TTL; alternatively, accept the degraded-to-per-replica backpressure posture (correctness holds either way — the trade-off is only upstream-amplification risk during a burst). Routing the negative-cache Failed records to the Durable class is the stronger structural fix; it is deferred pending a KEYSPACE_REGISTRY class-assignment review (load-bearing, user-coordination-gated).

The scan queue is fed by four trigger sources (ingest, cron, advisory, manual). The cron and manual paths can amplify enqueue volume faster than the worker drains it: a cron-rescan tick batches up to 1000 stale artifacts per invocation, and an unthrottled manual-rescan caller can enqueue repeatedly. This is an audited, accepted residual (see the open-items register in docs/adr/0000-historical-decisions-index.md). The security half is already neutralised — the fail-closed release predicate (ADR 0007) makes release contingent on a successful scan, so a deep scan queue can only delay availability of newly-ingested or just-rescanned artifacts, never weaken the quarantine gate. The residual is therefore an availability concern only; alarm on the existing signal — there is no new metric:

SymptomAlarm on (existing metric — see docs/metrics-catalog.md)Reading
Scan queue growing faster than the worker drains ithort_scan_queue_depth sustained above the worker's steady-state drain rate (depth not returning toward baseline across several heartbeat intervals; the heartbeat emits every 60 s)The worker cannot keep pace with the enqueue rate (cron batch cap + manual rescans + ingest first-scans). New/just-rescanned artifacts wait longer in pending; correctness is unaffected (the fail-closed release predicate holds the gate).

Metric-name note. Older audit prose referred to hort_jobs_pending_count{kind="scan"}; that metric name does not exist. Its shipped equivalent is hort_scan_queue_depth (see docs/metrics-catalog.md — a no-label gauge emitted by the worker heartbeat tick reading count(*) FROM jobs WHERE kind='scan' AND status='pending', which is exactly the "pending scan jobs" quantity the audit named). Treat hort_scan_queue_depth as the canonical name when wiring the alarm.

Operator action when the alarm fires: scale out worker replicas (the claim loop is FOR UPDATE SKIP LOCKED, so additional replicas drain cooperatively without coordination), and/or raise priority on the latency-sensitive trigger sources via admin so ingest first-scans stay ahead of routine cron rescans. Lengthening the cron-rescan schedule (scheduledTasks.cronRescanTick.schedule) reduces the batch-injection rate. The structural fix — a per-(principal, repository) manual-rescan enqueue rate cap with an HTTP 429 response — is deferred (it requires a KEYSPACE_REGISTRY durable prefix and an hort-http-core error-contract 429 change, both cross-cutting); the open item is recorded in the open-items register (docs/adr/0000-historical-decisions-index.md). Because the security half is already closed by the release predicate, this deferral carries no security risk — it is purely an availability-hardening follow-on.

The advisory-watch tick (AdvisoryWatchTickHandler) ingests the OSV bulk feed over TLS only; OSV publishes no signed manifest or per-zip hash for the bulk all.zip archives, so the feed is trusted-but-unauthenticated. This is an audited, accepted residual (see the open-items register in docs/adr/0000-historical-decisions-index.md) with this alarm as one of two compensating controls (the other is the reject-requires-scanner invariant — a poisoned advisory only ever enqueues a rescan that a real scanner adjudicates, so a poisoned advisory alone cannot reject a clean artifact). This alarm is the feed-poisoning / feed-suppression tripwire; it also serves the NIS2 21(2)(f) vulnerability-handling-efficacy signal. There is no new metric — alarm on the existing signals only:

SymptomAlarm on (existing metrics — see docs/metrics-catalog.md)Reading
Advisory injection (false/over-broad entries → mass cross-tenant rescans)A single advisory-watch tick's hort_advisory_diff_processed_total{result="ok"} for an ecosystem spiking far above that ecosystem's rolling baseline (catalog line 2911); corroborate with anomalous hort_advisory_diff_duration_seconds{ecosystem} (line 2912)The feed delivered an abnormal burst of "new/modified" advisories for this ecosystem — consistent with a poisoned/injected feed. Expect a hort_scan_jobs_enqueued_total{trigger_source="advisory"} surge and rising hort_scan_queue_depth shortly after.
Advisory suppression (entries silently dropped → vulnerable artifacts never re-flagged; compounds a broken feed)hort_advisory_diff_processed_total{result="ok"} collapsing toward ~0 across all ecosystems while the prior baseline was non-zero, and hort_advisory_ingest_count falling below its expected per-ecosystem floor (the feed-efficacy signal; see docs/metrics-catalog.md)The feed has gone silent or is being suppressed upstream. The advisory DB is no longer being populated; a suppressed feed and a broken/under-ingesting feed are indistinguishable here and share this signal.

Metric-name note. The shipped metrics are hort_advisory_diff_processed_total{ecosystem,result}, hort_advisory_diff_duration_seconds{ecosystem}, and hort_advisory_ingest_count{category} (the under-floor efficacy metric, not a per-tick diff counter — all documented in docs/metrics-catalog.md). No metric is added; the suppression tripwire deliberately reuses the existing hort_advisory_ingest_count under-floor alert rather than introducing a parallel signal.

Operator action when the alarm fires: treat it as a possible feed-poisoning or feed-suppression event, not a routine queue blip. Do not trust the next rescan wave at face value: cross-check OSV's published advisory volume out-of-band (the OSV web UI / a second mirror), inspect the affected ecosystem's recent hort_advisory_diff_* samples, and confirm whether a real upstream advisory drop explains the volume before letting the enqueued rescans proceed to operator attention. On a confirmed injection, the reject-requires-scanner invariant already bounds the blast radius to scan-queue amplification (no false rejections) — the queue-amplification mitigations in the Scan-queue backlog alarm above apply. On a confirmed suppression, escalate as a vulnerability-handling-efficacy incident (NIS2 21(2)(f)): the advisory DB is stale and the cron-rescan safety net re-runs the same poisoned/suppressed data, so it is not an independent recovery path. The stronger structural control — a second, authenticated feed (GitHub Advisory) — is deferred (see the open-items register in docs/adr/0000-historical-decisions-index.md); it is referenced, not scheduled here.

Notifications & webhooks

VariableTypeDefaultRequired?Semantics
HORT_NOTIFICATIONS_ENABLEDbooltrueNoEvent-notification substrate on/off.
HORT_NOTIFY_CHANNEL_CAPACITYu321024NoNotification broadcast channel capacity.
HORT_WEBHOOK_ALLOW_PLAINTEXTboolfalseNoAllow http:// webhook URLs (transport-unsafe gauge when on).
HORT_WEBHOOK_ALLOW_NONROUTABLE_TARGETSboolfalseNoSkip the webhook SSRF / routable-target check (unsafe gauge when on).
HORT_NATS_URLstringunset/empty → noneNoNATS JetStream notifier URL. Redacted.

Metrics

VariableTypeDefaultRequired?Semantics
HORT_METRICS_REQUIRE_AUTHbooltrueNoRequire admin auth on /metrics; false re-permits anonymous scrape (boot WARN).
HORT_METRICS_BINDSocketAddrunset → metrics on the main routerNoDedicated metrics listener address.
HORT_METRICS_PUBLIC_BINDboolfalseNoAllow the metrics listener to bind an unspecified address (0.0.0.0/::). Parsed before HORT_METRICS_BIND so the guard can consult it.

CAS scrubber

VariableTypeDefaultRequired?Semantics
HORT_CAS_SCRUB_ACTION_ON_MISMATCHalert \tombstonealertNo

Operational / gitops / misc

VariableTypeDefaultRequired?Semantics
HORT_CONFIG_DIRpathunset → noneNoGitops desired-state config directory; applied automatically at serve startup (no separate apply subcommand). Must be a directory if set. Also the input tree for validate-config, which requires it. See declare-gitops-config.md.
HORT_SECRETS_FILE_ROOTpathunset → unconstrainedNoContainment root for the mounted-file SecretPort; path-escape is rejected when set. See wire-secrets.md.
HORT_K8S_SECRET_WRITER_ENABLEDexact "true"disabledNoEnables the k8s Secret writer for the PAT-rotation reconciler. Only the literal string true enables it; true + in-cluster auth failure aborts boot (never a silent no-op).
HORT_RBAC_REFRESH_SECSu3230NoRBAC snapshot poll cadence.
HORT_UPSTREAM_RESOLVER_REFRESH_SECSu3260NoUpstream-resolver snapshot refresh cadence. Minimum 5 (lower → error).
HORT_UPSTREAM_ALLOWLIST_HOSTStri-state CSVunset → disabledNoApply-time upstream-host allowlist. __deny_all__ → strict deny; h1,h2 → host list; empty/unset → no enforcement.
HORT_STATEFUL_UPLOAD_STAGING_DIRpathderivedNoStateful-upload chunk staging root. Default: <HORT_STORAGE_FILESYSTEM_PATH>/stateful-upload-staging (filesystem) or /var/lib/hort/stateful-upload-staging (S3, + boot WARN).
HORT_OCI_LEGACY_CATALOG_ENABLEDboolfalseNoMount the Docker-legacy global /v2/_catalog.
HORT_OCI_MAX_SESSIONS_PER_PRINCIPALu3232NoPer-(repo, principal) OCI upload-session cap.
HORT_OCI_SESSION_MAX_AGE_SECSu643600NoOCI upload-session TTL. Hard-rejected above 7 days (604800) — a boot-time interlock.
HORT_OCI_PULLTHROUGH_RELEASE_WAIT_SECSu6410NoBounded-await ceiling for the cold-pull-through blob-GET release race (issue #65): when a Quarantined artifact's window has already elapsed (release-pending on its own scan, not a genuine time-hold), the handler polls for up to this long before falling back to the pre-existing 503 + Retry-After. 0 disables the wait entirely (pre-#65 behavior). Never delays a genuine, not-yet-elapsed time-quarantine — see hort-http-oci::blobs::maybe_bounded_await_release.
HORT_EVENT_CHAIN_CHECKPOINT_CADENCE_SECSu643600NoSigned-checkpoint cadence for the tamper-evident event chain (ADR 0002). Read by the always-on eventstore-checkpoint dsn-direct task.
HORT_EVENT_CHAIN_VERIFY_EXPECTED_INTERVAL_SECSu6486400NoExpected cadence of the verify-event-chain CronJob, used only to derive the staleness gauge (matches the chart's default verifyEventChain schedule). A query failure is logged and treated as "unknown" — never blocks boot.
HORT_EVENT_CHAIN_VERIFY_STALENESS_MULTIPLIERu323NoMultiplier on the expected interval above before the last-run gauge is considered stale (tolerates missed ticks before alarming).

hort-server — validation & interlocks

These cross-field rules are enforced at startup by serve / scrub (full Config). Each is fail-fast — the binary refuses to start rather than run misconfigured.

  1. Trust unconfigured (unconditional). HORT_PUBLIC_BASE_URL unset and HORT_TRUSTED_PROXY_CIDRS empty → boot fails (TrustUnconfigured). One of the two is mandatory.
  2. HORT_REQUIRE_HTTPS=true. Fails if HORT_PUBLIC_BASE_URL is http and HORT_TRUSTED_PROXY_CIDRS is empty (no positive TLS evidence). An https:// base URL or a non-empty CIDR list satisfies it.
  3. Metrics public-bind guard. HORT_METRICS_BIND on an unspecified IP while HORT_METRICS_PUBLIC_BIND=false → boot fails (MetricsPublicBindRefused).
  4. Storage required fields. filesystem requires HORT_STORAGE_FILESYSTEM_PATH; s3 requires HORT_STORAGE_S3_BUCKET, a region (AWS_REGION/AWS_DEFAULT_REGION), AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY.
  5. S3 allow-http ↔ endpoint scheme. http:// endpoint without HORT_STORAGE_S3_ALLOW_HTTP=true → error; the flag set with an https:// endpoint, or set with no endpoint (real AWS S3) → error.
  6. S3 SSE-KMS requires the ARN. HORT_S3_SSE_MODE=sse-kms without HORT_S3_SSE_KMS_KEY_ARN → error (never silently downgrades).
  7. OCI signing key source exclusivity. Setting both HORT_OCI_TOKEN_SIGNING_KEY_FILE and HORT_OCI_TOKEN_SIGNING_KEY (non-empty) → error; _FILE wins. Same rule for the _PREV pair.
  8. Native tokens need a key. HORT_NATIVE_TOKENS_ENABLED=true with no resolved active signing key → error.
  9. Token exchange dependency set. HORT_TOKEN_EXCHANGE_ENABLED=true requires HORT_NATIVE_TOKENS_ENABLED=true always (the exchange mints hort_* native tokens the server must be able to validate). Under HORT_AUTH_PROVIDER=oidc it additionally requires HORT_OIDC_ISSUER_URL, HORT_OIDC_CLI_CLIENT_ID, and HORT_PUBLIC_BASE_URL (they back the /.well-known/hort-client-config discovery doc + the interactive device flow). Under HORT_AUTH_PROVIDER=disabled none of those three are required — the federated-JWT branch validates against the gitops OidcIssuer rows, not the interactive IdP config (federation-only / no-Keycloak mode). The error names every missing variable.
  10. Redis URL under the Redis backend. HORT_EPHEMERAL_STORE_BACKEND=redis requires HORT_REDIS_URL (the per-class overrides fall back to it at composition time).
  11. HORT_CONFIG_DIR must be a directory if set non-empty.
  12. HORT_UPSTREAM_RESOLVER_REFRESH_SECS floor is 5.
  13. Auth-enabled startup gate. HORT_AUTH_PROVIDER=disabled is rejected unless a local admin row exists or HORT_NATIVE_TOKENS_ENABLED=true. oidc always passes without a DB query.
  14. HORT_EXTRA_CA_BUNDLE fail-closed. Set-but-unreadable / invalid PEM / zero certs aborts boot — never degrades silently to public-CA-only trust.

hort-worker — command-line surface

hort-worker claims jobs from the shared jobs table and dispatches each row to the TaskHandler registered for its kind (scan, cron-rescan-tick, advisory-watch-tick, staging-sweep, service-account-rotation, noop).

SubcommandPurpose
(none)Parse env → init tracing + Prometheus → load HORT_EXTRA_CA_BUNDLE → build composition → run the TaskDispatcher + heartbeat until SIGTERM/SIGINT. This is what every deployment manifest invokes.
healthcheckk8s livenessProbe exec gate.

healthcheck deliberately stays cheap (it runs every few seconds): it (1) parses WorkerConfig::from_env, then (2) opens a one-connection pool (acquire timeout 2000 ms) and runs a single SELECT 1 under an outer 2500 ms budget, then exits. It does not start the dispatcher, probe scanners, or initialise tracing/Prometheus. Exit 0 on success, 1 on any failure (hort-worker fatal: … to stderr). Unknown subcommand → clap InvalidSubcommand.

The worker is otherwise driven by Helm values and an HTTP admin-task endpoint (CronJob → POST /api/v1/admin/tasks/{kind}), not by additional CLI flags — see rotating-service-account-tokens.md.


hort-worker — environment variables

The scanner adapters (hort-adapters-scanner-trivy / -osv) and the advisory adapter (hort-adapters-advisory-osv) read no env vars themselves — every value reaches them through WorkerConfig fields wired in composition.rs. The worker uses the hort_app_role runtime DSN only (it asserts, never runs, migrations) and does not read HORT_NATS_URL, HORT_EPHEMERAL_STORE_BACKEND, a non-evictable HORT_REDIS_URL, or any AWS SDK credential-chain variable.

Core, identity & logging

VariableTypeDefaultRequired?Semantics
HORT_DATABASE_URLstringunset → falls back to DATABASE_URLYes¹Postgres DSN for the hort_app_role runtime role; tried first.
DATABASE_URLstringunset → error if HORT_DATABASE_URL also unsetYes¹Fallback DSN (the name the Helm chart wires).
HORT_LOG_FORMATpretty \jsonprettyNo
RUST_LOGEnvFilter directiveinfoNoStandard tracing filter (not read by healthcheck).
HORT_WORKER_IDstringderivedNoExplicit worker identity. Used verbatim if non-empty; else ${POD_NAME}-${8-hex}; else pod-${8-hex}.
POD_NAMEstringpodNok8s downward-API pod name; the worker-id prefix when HORT_WORKER_ID is unset.
METRICS_INCLUDE_SERVICE_ACCOUNT_LABELbooltrueNoPer-SA metric-label cardinality switch; false collapses the hort_rotation_lag_seconds SA label to _all. No HORT_ prefix.

¹ Exactly one of HORT_DATABASE_URL / DATABASE_URL is required; with neither set the error surfaces the name "DATABASE_URL".

Dispatcher tuning

VariableTypeDefaultRequired?Semantics
HORT_SCANNER_POLL_INTERVAL_SECSu645NoDispatcher poll interval.
HORT_SCANNER_BATCH_SIZEu324NoJobs claimed per poll (clamped to u16::MAX).
HORT_SCANNER_MAX_ATTEMPTSu325NoMax scan attempts before a job is failed.
HORT_SCANNER_LOCK_DURATION_SECSu64900 (15 min)NoJob lock / lease duration.

Scanner & advisory adapters

VariableTypeDefaultRequired?Semantics
HORT_SCANNER_TRIVY_ENABLEDbooltrueNoLoad-bearing. When false, the worker does NOT register the Trivy backend even if its --version probe would pass — the flag is the enabling gate; the probe is a secondary health check that only runs on flag-enabled backends. Set from worker.scanner.trivy.enabled.
HORT_SCANNER_OSV_ENABLEDbooltrueNoLoad-bearing, same contract as HORT_SCANNER_TRIVY_ENABLED. Set from worker.scanner.osv.enabled. Disabling both backends is a hard boot error (a scanner worker with no backends has nothing to scan).
HORT_SCANNER_TRIVY_BINpathtrivyNoTrivy binary path/name.
HORT_SCANNER_TRIVY_DB_DIRpathunset → Trivy default cacheNoTrivy --cache-dir; omitted when unset.
HORT_SCANNER_OSV_BINpathosv-scannerNoosv-scanner binary path/name.
HORT_ADVISORY_OSV_API_URLURLhttps://api.osv.dev/v1/querybatchNoOSV per-component querybatch endpoint.
HORT_ADVISORY_OSV_BULK_URLURLhttps://osv-vulnerabilities.storage.googleapis.comNoBase URL for per-ecosystem OSV bulk archives (advisory-watch tick).
HORT_ADVISORY_WATCH_ECOSYSTEMSCSVunset → built-in 8: npm, PyPI, crates.io, Maven, Go, RubyGems, NuGet, PackagistNoPer-tick ecosystem labels for the advisory watch.
HORT_REDIS_URL_EVICTABLEstringunset → in-memory cache (warn; single-process)NoEvictable Redis URL for the OSV advisory cache.

Storage (for staging-sweep + scanner content reads)

Same names and semantics as the hort-server Storage table — HORT_STORAGE_BACKEND (default filesystem), HORT_STORAGE_FILESYSTEM_PATH, HORT_STORAGE_S3_BUCKET, AWS_REGION / AWS_DEFAULT_REGION, AWS_ENDPOINT_URL_S3 / AWS_ENDPOINT_URL, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, HORT_STORAGE_S3_FORCE_PATH_STYLE, HORT_STORAGE_S3_ALLOW_HTTP. The filesystem CAS root must match the server's. HORT_STATEFUL_UPLOAD_STAGING_DIR is read by the staging-sweep task with the same derivation rule as the server.

Service-account rotation

VariableTypeDefaultRequired?Semantics
HORT_ROTATION_TARGET_NAMESPACESCSVemptyNok8s namespaces the rotation handler may write Secrets in. Empty → every SA is a safe namespace_not_authorized no-op.
HORT_PUBLIC_REGISTRY_HOSTstringunset → handler not registeredNoRegistry host for the dockerconfigjson.auths key. If unset, the service-account-rotation handler is not registered at all.

Extra CA / subprocess trust

VariableTypeDefaultRequired?Semantics
HORT_EXTRA_CA_BUNDLEpathunset → no-opNoOperator CA PEM bundle. See propagation below.
SSL_CERT_FILEpathset by the worker, never readThe worker sets this per-subprocess on each Trivy / osv-scanner Command; it never reads it from the environment.

HORT_EXTRA_CA_BUNDLE propagation (worker)

extra_ca::read_and_propagate runs once at boot, before composition:


Not consumed by the v2 binaries

Operators occasionally carry these forward from the prototype or the target-architecture docs. The v2 binaries do not read them — they have no effect:

TokenStatus
WASM_PLUGIN_DIR / PLUGINS_DIRNot read. WASM format modules are a future target (ADR 0005); v2 format handlers are compiled-in.
HORT_GROUP_MAPPINGS_PATHRemoved. hort-server serve reads it only to emit a one-shot deprecation WARN; it has no effect on behaviour. Use HORT_CONFIG_DIR.
LOG_LEVELNot read. Use RUST_LOG.
*_INSECURE_TLS, HORT_TLS_INSECURE, S3_INSECURE_TLS, OIDC_INSECURE_TLS, insecure_jwks_urlDo not exist by policy. The only internal-CA mechanism is HORT_EXTRA_CA_BUNDLE.
OTEL_*No OpenTelemetry env vars are read.

See also