Introduction
Website: varta.sh · Guides: comparisons & Prometheus setup
Varta is a zero-dependency, zero-allocation health protocol for
distributed local agents and networked clusters. Agents emit a fixed
32-byte heartbeat over a Unix domain socket (or UDP, with AEAD); an
observer (varta-watch) decodes the beats, detects stalls, fires
recovery commands, and exposes Prometheus metrics — all from a single
poll loop on a single thread, with no heap allocation on the steady-state
path.
Should I use Varta?
| You’re building… | Varta is a fit if… |
|---|---|
| Safety-critical systems (medical devices, robotics, industrial control) | You need bounded latency, no allocator surprises, and a structurally-excised attack surface (no HTTP server, no /bin/sh, no registry deps in the binary). |
| High-density single-host fleets (50–4096 local agents) | You want one low-cost observer per host, not a per-agent sidecar with its own runtime cost. |
| Embedded targets | varta-vlp is #![no_std]-clean. You want a wire format you can implement in C, Rust, Go, or Python from the same JSON test vectors. |
| Operator-attested service meshes | You want a heartbeat that’s authenticated by the kernel (peer-cred PID) for local IPC, with an explicit refusal of recovery for any beat origin that isn’t kernel-attested. |
Varta is not a tracing system, an APM, a load balancer, or a config-distribution tool. It carries one fact per beat: this PID is alive, and here’s how it feels about that.
The “Zero-Everything” philosophy
- Zero registry dependencies. Production crates
(
varta-client,varta-watch) have empty[dependencies]sections. The single exception isvarta-vlp’s optionalcryptofeature. - Zero heap allocation on the beat path after
Varta::connect(). Every encode/decode operates on stack buffers. Enforced by a guard-allocator test in CI. - Non-blocking I/O. The agent socket is non-blocking at connect
time.
WouldBlockis treated asDropped, never as an error that stalls the caller. - Zero unsafe in
varta-vlpandvarta-client; auditable, line-by- line opt-in unsafe invarta-watchfor platform syscalls.
How it works
flowchart LR
A1[agent 1] -.beat 32B.-> S[(UDS /run/varta/varta.sock)]
A2[agent 2] -.beat 32B.-> S
AN[agent N] -.beat 32B.-> S
S --> O{varta-watch<br/>poll loop}
O -- per-pid state --> T[Tracker<br/>≤ 4096 pids]
O -- stall detected --> R[Recovery<br/>exec, non-blocking spawn]
O -- /metrics --> P[Prometheus]
O -- TSV --> AL[Audit log]
- Agents call
Varta::connect()once, thenbeat(status, payload)on whatever cadence they like (typically every 100 ms – 1 s). - The observer polls the socket on a 100 ms read-timeout cadence,
decodes frames on the stack, updates per-pid state, fires recovery
commands for pids past their silence threshold, and serves
/metrics. All on one thread. - Recovery is opt-in and gated. Only kernel-attested beats are eligible; UDP / socket-mode-only beats are refused with a labelled counter.
Where to next
| Goal | Page |
|---|---|
Ship varta-watch to a host now | Install (Quickstart) |
| Wire metrics + alerts to Prometheus / Grafana | Monitoring & Alerting |
| Run on Kubernetes | Helm Chart |
| Implement the wire format in another language | VLP — Base Frame, Conformance & Test Vectors |
| Understand the threat model | Threat Model |
| Debug a production issue | Troubleshooting |
| Upgrade from v0.1.x | Upgrade Guide |
Install
Five paste-paths covering every mainstream operator audience. Pick the
one that matches your runtime. Every artifact in every path is built,
signed, and published by the same workflow
(.github/workflows/release.yml), so the supply chain is uniform
regardless of how you fetch it.
| Audience | One-paste install |
|---|---|
| Bare metal / VM | curl -fsSL https://varta.sh/install.sh | sh |
| Docker host | docker run … ghcr.io/aramirez087/varta-watch:0.3.0 … |
| Kubernetes (Helm) | helm install varta-watch oci://ghcr.io/aramirez087/charts/varta-watch --version 0.2.0 … |
| Rust developer | cargo binstall varta-watch |
| Source build | cargo install --path crates/varta-watch --features prometheus-exporter |
Kubernetes adopters who want raw manifests over Helm: keep using
observability/examples/kubernetes/. The Helm chart’s default render matches those files for container args, mounts, security context, ports, and probes — asserted by thehelm-parityCI gate. Helm-standard labels (helm.sh/chart,app.kubernetes.io/managed-by) are the only deltas.
Bare metal / VM — install.sh
curl -fsSL https://varta.sh/install.sh | sh
Knobs (env vars):
| Var | Default | Effect |
|---|---|---|
VERSION | latest release | Pin to a specific tag, e.g. VERSION=v0.3.0 |
INSTALL_DIR | /usr/local/bin | Target directory for the binary |
ASSUME_YES | 0 | 1 skips interactive prompts (required when piping curl) |
VERIFY_COSIGN | 0 | 1 requires cosign on $PATH and fails if absent |
SKIP_SYSTEMD | 0 | 1 skips systemd unit installation |
GH_REPO | aramirez087/Varta | Override repository for forks / mirrors |
The script:
- Detects OS + arch and computes the matching release-asset triple.
- Downloads the tarball +
.sha256and verifies integrity. - If
cosignis on$PATH, verifies the keyless signature against the GitHub Actions OIDC issuer + thearamirez087/Vartacertificate subject (otherwise prints a recommendation and continues). - Copies the binary to
$INSTALL_DIR/varta-watch. - On a systemd host running as root: creates the
vartauser, generates/etc/varta/prom.token(mode 0400), installs the unit at/etc/systemd/system/varta-watch.service, and prints thesystemctl enable --now varta-watchinvocation. Does not start the service automatically (operator-confirmation gate).
The installer itself is published as a release asset; the URL
https://varta.sh/install.sh redirects to the versioned, raw GitHub
URL so the script you run is reproducible.
Docker
docker run -d --name varta-watch \
--user 65532:65532 \
--restart unless-stopped \
--read-only \
--tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
-v /run/varta:/run/varta \
-v /etc/varta/prom.token:/etc/varta/prom.token:ro \
-p 127.0.0.1:9100:9100 \
ghcr.io/aramirez087/varta-watch:0.3.0 \
--socket=/run/varta/varta.sock \
--prom-addr=0.0.0.0:9100 \
--prom-token-file=/etc/varta/prom.token \
--self-watchdog-secs=4
Verify the image before pulling in production:
cosign verify ghcr.io/aramirez087/varta-watch:0.3.0 \
--certificate-identity-regexp '^https://github.com/aramirez087/Varta' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
See Container Image for the full reference.
Kubernetes (Helm)
helm install varta-watch \
oci://ghcr.io/aramirez087/charts/varta-watch \
--version 0.2.0 \
--create-namespace \
--namespace varta \
--set prometheusToken.token=$(openssl rand -hex 32)
Then helm test varta-watch -n varta runs an in-cluster pod that
scrapes /metrics with the bearer token and asserts the observer is
emitting varta_iterations_total. See Helm Chart for the
full values reference.
Rust developer
cargo binstall varta-watch # fetches the signed release tarball
# or
cargo install --path crates/varta-watch --features prometheus-exporter
cargo binstall resolves the platform triple via
[package.metadata.binstall] in
crates/varta-watch/Cargo.toml, pulls the same signed .tar.gz the
installer script uses, and verifies the sha256 sidecar before writing
to $CARGO_HOME/bin. No source build needed.
Verifying the artifacts
Every release ships:
varta-watch-vX.Y.Z-<triple>.tar.gz— the binary + LICENSE + systemd unitvarta-watch-vX.Y.Z-<triple>.tar.gz.sha256— checksumvarta-watch-vX.Y.Z-<triple>.tar.gz.cosign.bundle— keyless cosign signature bundlevarta-watch-vX.Y.Z-<triple>.sbom.cdx.json— CycloneDX SBOM- SLSA L3 build provenance attached via GitHub’s attestation API
install.sh— the installer script itself (so you can verify before running)
Verify any binary tarball:
cosign verify-blob \
--bundle varta-watch-v0.2.0-linux-amd64-musl.tar.gz.cosign.bundle \
--certificate-identity-regexp '^https://github.com/aramirez087/Varta' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
varta-watch-v0.2.0-linux-amd64-musl.tar.gz
Verify SLSA provenance (uses GitHub’s verify API; no install needed):
gh attestation verify varta-watch-v0.2.0-linux-amd64-musl.tar.gz \
--repo aramirez087/Varta
Troubleshooting
cosignnot found — the install script prints a recommendation and continues with sha256-only verification, which is still strong given HTTPS + the GitHub release-asset URL. To require cosign, setVERIFY_COSIGN=1.- systemd unit fails to start —
journalctl -u varta-watch -esurfaces the real error. The most common cause is a missing/etc/varta/prom.token(the installer creates it; manual installs must do so before the first start). - Container exits immediately — the binary fails fast on missing
required flags.
docker logs varta-watchshows the parse error. Compare against the canonical args in the Docker block above. - Helm test pod fails —
kubectl -n varta logs <pod>from the failing pod prints the exact curl output. The most common cause is a bearer token mismatch between the Secret and the binary’s--prom-token-filemount.
Guides
Practical comparisons and setup walkthroughs for teams evaluating Varta against familiar watchdog and observability patterns.
| Guide | When to read |
|---|---|
| Varta vs the alternatives | “I already have systemd / supervisord / k8s probes / an HTTP /health — why add this?” One decision matrix across all of them. |
Varta vs systemd WatchdogSec | You already rely on systemd unit watchdogs and want one observer for many agents. |
Varta vs HTTP /health checks | Every service exposes an HTTP probe today; you want sub-microsecond beats without a sidecar HTTP stack. |
| Prometheus setup walkthrough | You need scrape config, alert rules, and a Grafana dashboard in one sitting. |
The full operator reference remains in Monitoring & Alerting.
The loadable artefact bundle lives in observability/.
Varta vs the alternatives
If you run background workers, daemons, or edge agents, you already have some
liveness story — systemd WatchdogSec, a supervisord/monit config, Kubernetes
liveness probes, or an HTTP /health route a prober polls. Each is the right
tool when its assumptions hold: one process per unit, one orchestrator, one HTTP
server already in the binary. The question this page answers is narrower: when
you have many local processes and don’t want to add a unit file, a pod, or an
inbound TCP port to each one, what does adding varta-watch actually buy you —
and where do the incumbents still win?
At a glance
| Capability | systemd WatchdogSec | supervisord / monit | k8s liveness probes | HTTP /health polling | Varta |
|---|---|---|---|---|---|
| Watches many processes via one collector | No — one unit per process | Partial — one supervisor config, but per-process stanzas | No — one probe per container | No — one prober target per endpoint | Yes — one varta-watch ↔ thousands of agents (bounded table) |
| Per-process cost | One unit file + sd_notify | A config stanza + a managed child | A probe spec + kubelet exec/HTTP | A listening TCP port + handler per PID | One non-blocking UDS/UDP socket; 32-byte send(2), zero-alloc after connect |
| Liveness signal | App pings the watchdog (push) | Process-alive + optional check command | kubelet polls the probe (pull) | Prober polls the endpoint (pull) | App pushes 32-byte beats; observer synthesises stall on silence |
| Auto-recovery | Yes — unit Restart= | Yes — restart managed child | Yes — kubelet restarts container | No — prober only signals; recovery is external | Yes — per-PID debounced command (--recovery-exec), refused for unauthenticated origins |
| Metrics | Journal + unit state; no native Prometheus | Status via socket/CLI; no native Prometheus | Pod conditions / events; metrics via separate stack | Whatever the endpoint exposes (often blackbox exporter) | Native Prometheus exporter, TSV export, structured audit log, uniform labels |
| Runs without systemd | No | Yes | Yes | Yes | Yes |
| Runs without k8s | Yes | Yes | No | Yes | Yes |
| Cross-language | C / libsystemd or notify-socket protocol | Process-level, language-agnostic | Language-agnostic (exec/HTTP) | Any language that serves HTTP | Frozen 32-byte wire format; clients in Rust, Python, Go, Node, .NET, JVM |
| Safety-critical profile | Depends on distro/unit; no built-in profile | No | No | No | Class-A build removes HTTP server, arg parser, and shell exec (IEC 62304 Class C grade) |
Which one should you pick?
Pick systemd WatchdogSec if your processes are 1:1 with units, recovery is
just Restart=on-failure, and you don’t need per-beat payload (queue depth,
degraded mode) or native Prometheus metrics. It’s already on the host, costs
nothing extra, and is the simplest correct answer for a handful of long-running
daemons.
Pick supervisord / monit if you want a single process manager to start, restart, and check a modest set of children on a non-systemd host, and process-alive (plus an occasional check command) is a sufficient liveness signal. You get supervision and recovery without per-beat instrumentation or a metrics pipeline — and without each child needing to emit anything.
Pick k8s liveness probes if you’re already on Kubernetes and your workloads are containers the kubelet schedules. The probe + restart loop is built in, no extra component to run, and it’s the idiomatic choice — there’s no reason to add Varta for liveness of pods the orchestrator already restarts.
Pick HTTP /health polling if the workload is already an HTTP server,
/health is one more route, and your orchestrator or load balancer only speaks
HTTP. It’s also the right call when you need rich JSON diagnostics a human can
curl. Note it signals liveness but doesn’t recover anything — recovery stays
external.
Pick Varta when you have many local processes (tens to thousands) and adding
a unit file, pod, or inbound TCP port per PID is operationally heavy or simply
unavailable — embedded / no_std / non-systemd / inside a container with no
orchestrator. It fits high-frequency liveness (100 ms–1 s) where p99 matters,
polyglot fleets that need uniform metrics and labels, and safety-critical builds
that must not ship an HTTP server or a shell. It’s niche and local-first by
design; for a single 1:1 daemon, systemd is the better tool.
You don’t have to choose just one
These aren’t mutually exclusive, and the common production setup combines them:
run varta-watch itself as a single Type=notify systemd unit (or one k8s
sidecar), and let systemd/k8s watch the watcher while Varta watches the fleet.
systemd or the kubelet restarts the one observer if it stalls; Varta restarts the
many agents when their beats go silent. You get the incumbent’s battle-tested
supervision for the single long-lived component, and Varta’s
one-collector-many-agents model for everything that would otherwise need a unit,
pod, or HTTP port each. See
Varta vs systemd WatchdogSec for the hybrid
systemd recipe and
observability/examples/varta-watch.service
for the unit file.
Next steps
- Install (Quickstart)
- Varta vs systemd
WatchdogSec - Varta vs HTTP
/healthchecks - Prometheus setup walkthrough
Varta vs systemd WatchdogSec
systemd’s per-unit watchdog is excellent when one service equals one unit
and recovery is systemctl restart <unit>. Varta fits when many processes
on a host must report liveness to one operator-facing pipeline (Prometheus,
audit log, debounced recovery) without giving each agent an HTTP server or its
own systemd unit.
Comparison
| Dimension | systemd WatchdogSec | Varta |
|---|---|---|
| Scope | One unit ↔ one watched process | One varta-watch ↔ thousands of agents (bounded table) |
| Wire cost | sd_notify(WATCHDOG=1) — cheap, local | 32-byte VLP frame over UDS/UDP — also cheap; no HTTP parse |
| Observability | systemd journals + unit state | Native Prometheus metrics, TSV export, structured audit log |
| Recovery | Unit Restart= policy | Per-PID debounced command templates (--recovery-exec) |
| Cross-language | C/libsystemd or notify socket protocol | Official clients + frozen JSON vectors (Rust, Python, Go, …) |
| Safety-critical | Depends on unit file + distro | Class-A profile: no HTTP server, no shell, compile-time config |
When systemd alone is enough
- A single long-running daemon with a 1:1 unit file.
- Recovery policy is entirely
Restart=on-failureinside systemd. - You do not need per-beat custom payload (queue depth, degraded mode) in metrics.
When to add Varta
- Agent fleets (tens–thousands of workers) where creating a unit per PID is operationally heavy.
- Uniform metrics:
varta_beats_total, stall counters, recovery refusals with consistent labels across languages. - Kernel-attested beats on Linux (peer credentials) with an explicit refusal to run recovery for unauthenticated origins — see Peer Authentication.
- Hospital / embedded profiles that must not ship an HTTP
/metricsserver in the safety binary — useprometheus-exporteronly on a non–Class-A build.
Hybrid pattern (common in production)
Run varta-watch as a single Type=notify systemd service (see
observability/examples/varta-watch.service)
and keep application agents as lightweight processes that only call beat().
Let systemd restart the observer if it stalls; let Varta restart agents
when their beats go silent.
Next steps
Varta vs HTTP /health checks
HTTP health endpoints are the default in Kubernetes and many service meshes. They work well for request/response services behind a load balancer. Varta targets local agents and side processes that should not pay for an HTTP stack, TLS, JSON parsing, or a listening TCP port on every PID.
Comparison
| Dimension | HTTP /health | Varta |
|---|---|---|
| Per-check cost | Accept loop, parse, often JSON + mutex | One 32-byte datagram, stack encode, send(2) |
| Failure mode | Probe timeout blocks orchestrator path | Non-blocking socket → BeatOutcome::Dropped on the agent |
| Port surface | Listen on :8080 (or hostNetwork sidecar) | UDS path or UDP to observer — no per-agent listener |
| Payload | Unbounded body (risk) | 16-byte custom payload field in VLP v0.2 |
| AuthN story | mTLS / network policy | Kernel peer creds (Linux UDS), AEAD UDP optional |
| Metrics | Blackbox exporter or app-custom | First-class varta-watch Prometheus exporter |
When HTTP health is the right tool
- The workload is already an HTTP server and
/healthis one route. - Your orchestrator only speaks HTTP (some LB health checks).
- You need rich JSON diagnostics readable by curl.
When Varta is the better fit
- High-frequency liveness (100ms–1s) on hot paths where p99 matters.
- Embedded /
no_stdagents usingvarta-vlpwithout an HTTP library. - Bare-metal fleets monitored by Prometheus without kubelet probes.
- Safety-critical deployments where opening inbound TCP on every agent is unacceptable attack surface.
Migration sketch
- Stand up
varta-watchwith--socket(and optional secure UDP) — Install. - Replace the periodic HTTP self-check loop with
beat(Status::Ok, payload). - Point Prometheus at
varta-watchinstead of per-agent blackbox jobs. - Map old probe failures to alert rules in
observability/alerts/varta.rules.yml.
Keep HTTP /health for external traffic if customers still need it; use
Varta for internal liveness the platform owns.
Next steps
Prometheus setup walkthrough
This page is the fastest path from zero to scraping varta-watch, importing
the bundled Grafana dashboard, and paging on stalls. For alert semantics see
Monitoring & Alerting.
Prerequisites
varta-watchbuilt with the defaultprometheus-exporterfeature (not the Class-A excised profile).- A bearer token file when
--prom-addris set.
openssl rand -hex 32 | sudo tee /etc/varta/prom.token >/dev/null
sudo chmod 0400 /etc/varta/prom.token
1. Start the observer
varta-watch \
--socket /run/varta/varta.sock \
--threshold-ms 2000 \
--prom-addr 127.0.0.1:9100 \
--prom-token-file /etc/varta/prom.token \
--self-watchdog-secs 4
Verify metrics locally:
curl -s -H "Authorization: Bearer $(sudo cat /etc/varta/prom.token)" \
http://127.0.0.1:9100/metrics | head
2. Configure Prometheus scrape
Copy the job from
observability/examples/prometheus-scrape.yml
into your prometheus.yml:
scrape_configs:
- job_name: varta-watch
scheme: http
metrics_path: /metrics
static_configs:
- targets: ["127.0.0.1:9100"]
authorization:
type: Bearer
credentials_file: /etc/varta/prom.token
Reload Prometheus (POST /-/reload or SIGHUP).
3. Install recording + alert rules
sudo cp observability/recording-rules/varta.rules.yml /etc/prometheus/rules.d/
sudo cp observability/alerts/varta.rules.yml /etc/prometheus/rules.d/
curl -X POST http://localhost:9090/-/reload
Confirm under Status → Rules that groups varta-watch.sli and
varta-watch.critical appear.
4. Import Grafana dashboard
Grafana → Dashboards → Import → Upload
observability/dashboards/varta-health.json
and select your Prometheus datasource (24 panels, stall/recovery focused).
5. Route Alertmanager
Merge
observability/examples/alertmanager.yml
into your Alertmanager config. Replace PagerDuty / Slack placeholders before
reload.
Kubernetes (kube-prometheus)
Skip hand-edited prometheus.yml and apply:
kubectl apply -f observability/examples/kubernetes/varta-watch.deployment.yaml
kubectl apply -f observability/examples/kubernetes/varta-watch.servicemonitor.yaml
The release: label on the ServiceMonitor must match your Prometheus
operator selector — see Deployment Patterns.
Smoke test with a live agent
cargo run -p varta-client --example basic
Watch varta_beats_total increment and confirm the Grafana Beats row moves.
Related
VLP v0.2 — Base Frame Specification
Status: Normative. Wire version: 0x02. Document audience: anyone
building a Varta-compatible client, observer, packet decoder, or fuzz harness
in any language.
This document defines the on-wire layout of a Varta Lifeline Protocol (VLP)
heartbeat frame. The Rust reference implementation lives at
crates/varta-vlp,
but no part of this specification depends on Rust. A conforming
implementation in any language MUST produce and accept the byte sequences
described below.
A conformance test-vector suite is published at
tools/vlp-test-vectors.json;
running an implementation against that file is the definition of conformance
(see Conformance).
1. Conventions
The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are interpreted as in RFC 2119 / RFC 8174.
- Integers are unsigned, little-endian, of the width specified in each field row.
- Byte offsets are zero-based and inclusive on the left.
||denotes byte concatenation.0x..is hexadecimal;0b..is binary; ASCII byte literals appear in double quotes ("VA").- Capitalised type names (
Status,Frame,DecodeError) refer to the conceptual entities defined in this document, not to types in any specific language.
Terminology
| Term | Meaning |
|---|---|
| Agent | A process that emits VLP frames to declare its own liveness. |
| Observer | A process that receives VLP frames and tracks agent liveness. |
| Beat | A single 32-byte VLP frame transmitted from agent to observer. |
| Nonce | The monotonically-increasing 8-byte counter field at offset 16; see §3.5. |
| Wire | The byte sequence transmitted on the underlying transport (UDS, UDP, etc.). |
2. Frame Structure
A VLP frame is exactly 32 bytes:
offset │ size │ field │ type │ notes
───────┼──────┼────────────┼────────┼─────────────────────────────────────────
0 │ 2 │ magic │ u8[2] │ const [0x56, 0x41] (ASCII "VA")
2 │ 1 │ version │ u8 │ const 0x02 (this document)
3 │ 1 │ status │ u8 │ Status enum, see §3.2
4 │ 4 │ pid │ u32 │ LE — emitter's process id
8 │ 8 │ timestamp │ u64 │ LE — emitter-local monotonic value
16 │ 8 │ nonce │ u64 │ LE — strictly increasing per session
24 │ 4 │ payload │ u32 │ LE — opaque application-defined context
28 │ 4 │ crc32c │ u32 │ LE — CRC-32C over bytes 0..28
───────┴──────┴────────────┴────────┴─────────────────────────────────────────
total 32 bytes
Senders MUST emit exactly 32 bytes per beat. Receivers MUST reject any datagram whose length is not exactly 32 bytes (or, for secure transports, the corresponding wrapped length defined in VLP — Secure Transport).
3. Field Semantics
3.1 Magic (offset 0, 2 bytes)
The literal bytes 0x56 0x41 (ASCII "VA"). Senders MUST emit exactly
these two bytes. Receivers MUST reject any frame whose first two bytes
differ, with a BadMagic diagnostic.
3.2 Version (offset 2, 1 byte)
The literal byte 0x02. Senders MUST emit 0x02. Receivers MUST reject
any other version byte with a BadVersion diagnostic — including the
legacy 0x01 (VLP v0.1). Implementations MUST NOT attempt to
re-interpret a 0x01 frame; the v0.1 byte map is incompatible with
v0.2.
3.3 Status (offset 3, 1 byte)
A single byte enumerating the agent’s last reported health:
| Value | Name | Meaning |
|---|---|---|
0x00 | Ok | The agent is healthy and making progress. |
0x01 | Degraded | The agent is making progress under elevated trouble (retrying, throttled). |
0x02 | Critical | The agent is about to terminate. Emitted by panic hooks immediately before unwinding. |
0x03 | Stall | Observer-synthesized only — MUST NOT appear on the wire. See §3.7. |
Receivers MUST reject any other value with a BadStatus diagnostic.
3.4 PID (offset 4, 4 bytes, u32 little-endian)
The operating-system process identifier of the emitting agent, in the
agent’s own PID namespace. Senders MUST set this to the OS-reported PID
at frame-construction time, freshly per emit (caching across fork(2)
is a wire-protocol bug — see §3.7).
Receivers MUST reject pid values 0 (kernel/scheduler) and 1
(init/systemd) with a BadPid diagnostic. No legitimate Varta agent
runs at either; the rejection closes a “spoof init has stalled”
recovery-trigger attack vector on unauthenticated transports.
3.5 Timestamp (offset 8, 8 bytes, u64 little-endian)
A monotonic timestamp chosen by the emitter, typically nanoseconds
since an agent-local epoch. Observers do not interpret it; they only
compare consecutive timestamps for the same pid.
Receivers MUST reject timestamp == 0xFFFFFFFFFFFFFFFF (the
saturation sentinel) with a BadTimestamp diagnostic. Reaching the
sentinel via real elapsed nanoseconds requires ~584 years, so the
value is reserved.
3.6 Nonce (offset 16, 8 bytes, u64 little-endian)
A strictly increasing 64-bit counter, starting at 1 on the first
beat after session establishment. On exhaustion at
0xFFFFFFFFFFFFFFFE, the counter wraps to 0 and continues. The
sentinel value 0xFFFFFFFFFFFFFFFF (NONCE_TERMINAL) is reserved
for panic frames (see §3.7); regular beats MUST NOT use it.
3.7 Reserved Values
| Sentinel | Reserved by | Decoder behaviour |
|---|---|---|
status == 0x03 (Stall) on the wire | Observer-synthesis only | StallOnWire |
pid ∈ {0, 1} | OS kernel and init | BadPid |
timestamp == 0xFFFFFFFFFFFFFFFF | Saturation guard | BadTimestamp |
nonce == 0xFFFFFFFFFFFFFFFF paired with status ≠ Critical | Panic-frame sentinel | BadNonce |
nonce == 0xFFFFFFFFFFFFFFFF paired with status == Critical is the
unique on-wire marker for a panic-terminated agent. Receivers MUST
accept this pairing; downstream consumers (alert rules, recovery
filters) MAY use it to distinguish panic-terminal from operational-critical
frames.
The converse — that Critical always implies the terminal nonce — is
not enforced. An agent emitting Status::Critical for operational
alerts (queue full, shedding load) MAY use any regular nonce value.
3.8 Payload (offset 24, 4 bytes, u32 little-endian)
Application-defined opaque context. The protocol carries it through unchanged. Typical uses: queue depth, last error code, an index into a shared ring buffer. Implementations MUST NOT interpret this field at the protocol layer; downstream consumers define its meaning.
3.9 CRC-32C Trailer (offset 28, 4 bytes, u32 little-endian)
A CRC-32C (Castagnoli) checksum computed over bytes 0..28 of the frame. Wire layout is little-endian. See §4 for the parameter list and reference algorithm.
The CRC catches:
- Single-event-upset bit flips on non-ECC RAM (agent or observer host).
- NIC firmware corruption between RX queue and userspace.
- In-process memory corruption between encode and the transport write (or between transport read and decode).
For secure transports, the CRC is defence-in-depth against
in-process corruption on either side of the AEAD seal/open boundary;
AEAD tag failures surface as a transport-layer error
(AuthError/AEAD failure), never as BadCrc.
Receivers MUST verify the CRC after the magic and version checks
(so wrong-protocol bytes surface diagnostically as BadMagic /
BadVersion, not BadCrc) and before any field-range check (so
corruption cannot produce a “well-formed” frame with the wrong
meaning).
4. CRC-32C
VLP uses CRC-32C (Castagnoli) — the iSCSI/SCTP/ext4/Btrfs CRC, not the
IEEE 802.3 CRC-32. Parameters (Koopman notation CRC-32C/iSCSI):
| Parameter | Value |
|---|---|
| Width | 32 bits |
| Polynomial | 0x1EDC6F41 |
| Reflected polynomial | 0x82F63B78 |
| Initial value | 0xFFFFFFFF |
| Reflect input | yes |
| Reflect output | yes |
| Output XOR | 0xFFFFFFFF |
4.1 Reference vectors
| Input | CRC-32C |
|---|---|
| (empty) | 0x00000000 |
"a" (single byte 0x61) | 0xc1d04330 |
"123456789" (RFC 3720 appendix B) | 0xe3069283 |
| 32 zero bytes | 0x8a9136aa |
32 0xFF bytes | 0x62a8ab43 |
4.2 Byte-at-a-time algorithm
compute(bytes):
table[0..256] := build_table()
crc := 0xFFFFFFFF
for each byte b in bytes:
idx := (crc XOR b) AND 0xFF
crc := table[idx] XOR (crc >> 8)
return crc XOR 0xFFFFFFFF
build_table():
for i in 0..256:
c := i
repeat 8 times:
if c AND 1 != 0:
c := (c >> 1) XOR 0x82F63B78
else:
c := c >> 1
table[i] := c
return table
The Rust reference implementation lives at
crates/varta-vlp/src/crc32c.rs
and is const fn-evaluable — the 256-entry table is built at compile time.
Hardware CRC-32C is available on x86_64 (SSE 4.2) and ARMv8.1+; conforming implementations MAY use hardware acceleration as long as the output matches the reference vectors above.
5. Decode Order
Receivers MUST validate fields in the following order, returning the first encountered failure:
- Magic →
BadMagicif bytes 0..2 ≠0x56 0x41. - Version →
BadVersionif byte 2 ≠0x02. - CRC →
BadCrcifcompute(bytes[0..28]) ≠ read_u32_le(bytes[28..32]). - Status →
BadStatusif byte 3 ∉{0x00, 0x01, 0x02, 0x03}. - Stall-on-wire →
StallOnWireif status byte 3 =0x03. - PID range →
BadPidifpid ∈ {0, 1}. - Timestamp range →
BadTimestampiftimestamp == 0xFFFFFFFFFFFFFFFF. - Nonce/status pairing →
BadNonceifnonce == 0xFFFFFFFFFFFFFFFFandstatus ≠ Critical.
The order is normative: operators rely on observing BadMagic versus
BadCrc to distinguish “wrong protocol on this port” from “real VLP but
corrupted in flight”.
6. Encode Procedure
encode(status, pid, timestamp, nonce, payload) -> [32]byte:
out[0] := 0x56 # 'V'
out[1] := 0x41 # 'A'
out[2] := 0x02 # version
out[3] := status_byte(status)
out[4..8] := u32_le(pid)
out[8..16] := u64_le(timestamp)
out[16..24] := u64_le(nonce)
out[24..28] := u32_le(payload)
crc := crc32c_compute(out[0..28])
out[28..32] := u32_le(crc)
return out
Senders MUST compute the CRC over the encoded bytes 0..28, after every
preceding field has been written. Mutating any byte between encode and
the transport write will cause the receiver to reject the frame as
BadCrc.
7. Decode Procedure
decode(bytes: [32]byte) -> Frame | DecodeError:
if bytes[0..2] != [0x56, 0x41]:
return BadMagic
if bytes[2] != 0x02:
return BadVersion
stored_crc := u32_le(bytes[28..32])
computed_crc := crc32c_compute(bytes[0..28])
if stored_crc != computed_crc:
return BadCrc(expected=computed_crc, actual=stored_crc)
status_byte := bytes[3]
if status_byte not in {0x00, 0x01, 0x02, 0x03}:
return BadStatus(status_byte)
if status_byte == 0x03:
return StallOnWire
pid := u32_le(bytes[4..8])
timestamp := u64_le(bytes[8..16])
nonce := u64_le(bytes[16..24])
payload := u32_le(bytes[24..28])
if pid in {0, 1}:
return BadPid(pid)
if timestamp == 0xFFFFFFFFFFFFFFFF:
return BadTimestamp(timestamp)
if nonce == 0xFFFFFFFFFFFFFFFF and status_byte != 0x02:
return BadNonce(nonce, status)
return Frame{
status, pid, timestamp, nonce, payload
}
8. Worked Examples
8.1 Minimal Ok beat
status = Ok, pid = 2, timestamp = 0, nonce = 1, payload = 0:
56 41 02 00 02 00 00 00 00 00 00 00 00 00 00 00
01 00 00 00 00 00 00 00 00 00 00 00 e4 11 6b aa
8.2 Panic-hook terminal frame
status = Critical, pid = 2, timestamp = 999, nonce = 0xFFFF…FFFF,
payload = 0:
56 41 02 02 02 00 00 00 e7 03 00 00 00 00 00 00
ff ff ff ff ff ff ff ff 00 00 00 00 a2 99 ee ed
Additional encoded golden frames live in
tools/vlp-test-vectors.json.
9. Reference Implementations
Non-normative encoders/decoders in Python, C99, and Go live under
tools/reference-implementations/.
Each ships a verify_vectors driver that loads the conformance JSON and
asserts every entry round-trips.
- Python —
tools/reference-implementations/python/vlp.py(~80 lines, stdlib-only, Python 3.8+). - C99 —
tools/reference-implementations/c/vlp.c(~120 lines,<stdint.h>+<string.h>only). - Go —
tools/reference-implementations/go/vlp.go(~80 lines,encoding/binaryonly).
These snippets are examples, not part of the normative spec. The pseudocode in §6 and §7 is the authoritative reference.
10. Versioning Policy
The version byte 0x02 is the current wire format. Any change to field
order, width, or semantics requires bumping the version byte; receivers
emit BadVersion for unrecognised values. There is no silent backward
compatibility — a single decoder implementation can support multiple
versions only by branching on the byte at offset 2.
Future versions of this specification will increment VERSION and
publish a new document; the test-vector file at
tools/vlp-test-vectors.json carries a top-level spec_version field
matching the wire version.
11. Conformance
Run the test-vector suite against your implementation:
- Load
tools/vlp-test-vectors.json. - For each entry in
crc32c_vectors, run your CRC overinput_hex(after hex decoding) and compare againstexpected_crc_hex. - For each
encode_decode_roundtripentry inframe_vectors, encode theinputsblock and compare byte-for-byte againstexpected_wire_hex. Then decodeexpected_wire_hexand compare the recovered fields againstinputs. - For each
decode_errorentry inframe_vectors, decodewire_hexand confirm the decoder returnsexpected_decode_error.
See Conformance & Test Vectors for the full JSON schema and language-by-language driver recipes.
See also
- VLP — Secure Transport — AEAD-wrapped frame for untrusted networks.
- Conformance & Test Vectors — JSON schema for the test-vector file.
- Rust implementation rationale — design
choices in the reference implementation (
#[repr(C, align(8))], zero-allocation policy, compile-time layout proofs). Not normative.
VLP Secure Transport Specification
Status: Normative. Frame versions: Shared-key 60-byte, Master-key 64-byte. Document audience: anyone implementing a Varta-compatible client or observer that crosses an untrusted network.
This document defines the encrypted wrapping of a VLP base frame (see VLP — Base Frame) for transmission over a network where the underlying transport offers no authentication or confidentiality (the typical example is UDP).
The base 32-byte VLP frame remains the plaintext; the secure transport wraps it in a ChaCha20-Poly1305 AEAD construction (RFC 8439). Two wrapped forms are defined:
- Shared-key (60 bytes) — a single pre-shared symmetric key is used by every agent.
- Master-key (64 bytes) — every agent has a key derived from a single master via HKDF-SHA256; the agent’s PID is bound into the AEAD AAD.
1. Conventions
The keywords MUST, MUST NOT, SHOULD, MAY follow RFC 2119 / RFC 8174. The base-frame conventions in VLP — Base Frame §1 apply unchanged.
In addition:
Plaintextalways refers to the canonical 32-byte VLP frame defined in VLP — Base Frame §2.Ciphertextis the AEAD-encrypted form ofPlaintext, always 32 bytes.Tagis the 16-byte Poly1305 authentication tag.Nonce(in this document) refers to the 12-byte AEAD nonce, distinct from the 8-bytenoncefield inside the plaintext base frame.
2. AEAD Primitive
All secure transports use ChaCha20-Poly1305 AEAD as defined in RFC 8439.
| Parameter | Value |
|---|---|
| Symmetric key | 32 bytes (256 bits) |
| AEAD nonce | 12 bytes (96 bits) |
| AAD | variable (see §3, §4) |
| Plaintext | 32 bytes (one VLP base frame) |
| Ciphertext | 32 bytes |
| Tag | 16 bytes (128 bits) |
Implementations SHOULD use an externally-audited AEAD library
(libsodium, BoringSSL, golang.org/x/crypto/chacha20poly1305, RustCrypto
chacha20poly1305). Hand-rolled implementations of ChaCha20 or Poly1305
are strongly discouraged for production use.
The AEAD seal and open operations:
seal(key[32], nonce[12], aad[], plaintext[32]) -> (ciphertext[32], tag[16])
open(key[32], nonce[12], aad[], ciphertext[32], tag[16])
-> Some(plaintext[32]) if tag verifies, else None
Senders MUST never reuse a (key, nonce) pair. Nonce-reuse under a fixed
key catastrophically breaks both confidentiality and integrity
(Poly1305 key recovery, plaintext-XOR leak). The nonce-construction rules
in §5 prevent reuse for conforming implementations.
3. Shared-Key Wire Format (60 bytes)
A single 32-byte symmetric key K is provisioned out-of-band to every
agent and observer. The wire frame is:
offset │ size │ field │ notes
───────┼──────┼──────────────┼────────────────────────────────────────────
0 │ 8 │ iv_random │ per-session OS entropy or HKDF derivation
8 │ 4 │ iv_counter │ u32 LE — strictly increasing per emit
12 │ 32 │ ciphertext │ AEAD(K, nonce, "", plaintext)
44 │ 16 │ tag │ Poly1305 tag
───────┴──────┴──────────────┴────────────────────────────────────────────
total 60 bytes
- AEAD nonce =
iv_random || iv_counter(12 bytes total). - AAD = empty byte string.
- Plaintext = the 32-byte VLP base frame.
3.1 Encoder
encode_shared(key[32], iv_random[8], iv_counter: u32,
plaintext[32]) -> [60]byte:
nonce[0..8] := iv_random
nonce[8..12] := u32_le(iv_counter)
(ct, tag) := seal(key, nonce, b"", plaintext)
out[0..8] := iv_random
out[8..12] := u32_le(iv_counter)
out[12..44] := ct
out[44..60] := tag
return out
3.2 Decoder
decode_shared(key[32], wire[60]) -> Plaintext | AuthError:
iv_random := wire[0..8]
iv_counter := u32_le(wire[8..12])
ct := wire[12..44]
tag := wire[44..60]
nonce[0..8] := iv_random
nonce[8..12] := u32_le(iv_counter)
plaintext := open(key, nonce, b"", ct, tag)
if plaintext is None:
return AuthError
return plaintext # caller MUST then run base-frame decode (§7 of VLP)
Observers MUST run the base-frame decoder (VLP — Base Frame §7) on the recovered plaintext before trusting any field.
4. Master-Key Wire Format (64 bytes)
A single 32-byte master key M is provisioned out-of-band. Every agent
derives a per-agent key K_agent = HKDF(M, agent_pid) (see §6). The
agent’s PID is bound into the AEAD AAD so an observer cannot accept a
frame whose plaintext PID disagrees with the wire prefix.
offset │ size │ field │ notes
───────┼──────┼──────────────┼────────────────────────────────────────────
0 │ 4 │ agent_pid │ u32 LE — bound into AAD, NOT encrypted
4 │ 8 │ iv_random │ per-session OS entropy or HKDF derivation
12 │ 4 │ iv_counter │ u32 LE — strictly increasing per emit
16 │ 32 │ ciphertext │ AEAD(K_agent, nonce, agent_pid_bytes, pt)
48 │ 16 │ tag │ Poly1305 tag
───────┴──────┴──────────────┴────────────────────────────────────────────
total 64 bytes
- AEAD nonce =
iv_random || iv_counter(12 bytes). - AAD = the 4-byte little-endian encoding of
agent_pid(bytes 0..4 of the wire frame, verbatim). - Plaintext = the 32-byte VLP base frame.
- K_agent =
HKDF-SHA256(IKM=M, salt=empty, info="varta-agent-v1\0" || agent_pid_LE).
The plaintext frame still carries its own pid field at base-frame
offset 4..8. Observers MUST verify that agent_pid (wire offset 0..4)
equals the recovered plaintext pid and reject mismatches.
4.1 Encoder
encode_master(master[32], agent_pid: u32, iv_random[8], iv_counter: u32,
plaintext[32]) -> [64]byte:
K_agent := derive_agent_key(master, agent_pid)
aad := u32_le(agent_pid)
nonce[0..8] := iv_random
nonce[8..12] := u32_le(iv_counter)
(ct, tag) := seal(K_agent, nonce, aad, plaintext)
out[0..4] := aad
out[4..12] := iv_random
out[12..16] := u32_le(iv_counter)
out[16..48] := ct
out[48..64] := tag
return out
4.2 Decoder
decode_master(master[32], wire[64]) -> Plaintext | AuthError:
aad := wire[0..4] # agent_pid LE
agent_pid := u32_le(aad)
iv_random := wire[4..12]
iv_counter := u32_le(wire[12..16])
ct := wire[16..48]
tag := wire[48..64]
K_agent := derive_agent_key(master, agent_pid)
nonce[0..8] := iv_random
nonce[8..12] := u32_le(iv_counter)
plaintext := open(K_agent, nonce, aad, ct, tag)
if plaintext is None:
return AuthError
# Observer MUST also confirm plaintext.pid == agent_pid.
return plaintext
5. Nonce Construction & Uniqueness
The AEAD nonce is the 12-byte concatenation iv_random || iv_counter.
iv_random(8 bytes) MUST be drawn from cryptographic OS entropy at session start, or derived from a 16-byte session salt via the IV-prefix HKDF in §6.iv_counter(4 bytes, little-endian) MUST start at0for a new session and strictly increase by 1 per emit.- The pair
(iv_random, iv_counter)MUST NEVER be reused under the same key.
5.1 Counter exhaustion
A 32-bit iv_counter exhausts after 2^32 emits per session. Senders
MUST rotate iv_random (and reset iv_counter to 0) before the
counter wraps. Implementations MAY rotate proactively at any threshold;
the canonical Rust transport rotates well under 2^32.
5.2 fork(2)
A child process inherits its parent’s iv_random and iv_counter
after fork(2). If the child emits without first refreshing the IV,
both processes will use the same (iv_random, iv_counter) under the
same key — catastrophic nonce reuse. Senders MUST detect fork (e.g. by
comparing the cached PID at session establishment against the current
PID at emit time) and either:
- Re-read
iv_randomfrom OS entropy and resetiv_counter = 0, or - Refuse to emit until the application explicitly re-establishes the session.
The Rust reference implementation auto-detects fork and rotates IV
state transparently; see
book/src/architecture/vlp-transports.md — “Fork-safety on secure-UDP”
for the operational model.
6. HKDF Key Derivation
The master-key mode (and the IV-prefix rotation mode for both wire
formats) uses HKDF-SHA256 as defined in
RFC 5869. Three info
strings are reserved, all suffixed with -v1 for future versioning:
6.1 Per-agent key derivation
derive_agent_key(master_key[32], agent_id: u32) -> [32]byte:
info := b"varta-agent-v1\0" || u32_le(agent_id)
# info length = 15 (the literal incl. NUL) + 4 = 19 bytes.
return HKDF-SHA256(IKM=master_key, salt=empty, info=info, L=32)
IKM= master key (32 B).salt= empty string (HKDF treats this as0x00 * HashLenper RFC 5869 §2.2).info= the byte string"varta-agent-v1"(14 ASCII bytes) followed by one NUL byte (0x00), followed byagent_idin 4-byte little-endian encoding. Total info length: 19 bytes.L= 32 output bytes.
6.2 Per-session IV prefix derivation
derive_iv_prefix(session_salt[16], prefix_index: u32) -> [8]byte:
info := b"varta-iv-prefix-v1\0" || u32_le(prefix_index)
# info length = 19 + 4 = 23 bytes.
return HKDF-SHA256(IKM=session_salt, salt=empty, info=info, L=8)
The session salt is the IKM; the HKDF-Extract salt argument is empty
(RFC 5869 §2.2 treats empty salt as 0x00 * HashLen).
The Rust reference uses this to rotate iv_random over a session
without re-reading OS entropy on the hot path.
6.3 Per-epoch key derivation (reserved)
derive_epoch_key(agent_key[32], epoch: u64) -> [32]byte:
info := b"varta-epoch-v1\0" || u64_le(epoch)
# info length = 15 + 8 = 23 bytes.
return HKDF-SHA256(IKM=agent_key, salt=empty, info=info, L=32)
Epoch keys are reserved for forward compatibility — they are not currently used on the wire. Conforming implementations MAY skip this derivation.
6.4 Reference vectors
| Derivation | Inputs | Output |
|---|---|---|
| Agent key | master = 0001…1f, agent_id = 42 | 61f5951b2bf1905d5053df0abb027002cba62da1f16d93c6552ff61cb65f2599 |
| IV prefix | salt = 0102…10, prefix_index = 7 | 9fee777f36be69ce |
| Epoch key | agent = 0001…1f, epoch = 100 | cb9fe8cb3db0d8d667b7dd9e72adce07c669d3b27bc68ea69e3cc3c129d601ab |
Full info byte strings (so external implementers can confirm endianness
and the literal NUL):
| Derivation | info (hex) |
|---|---|
| Agent (agent_id=42) | 76617274612d6167656e742d7631002a000000 |
| IV prefix (prefix_index=7) | 76617274612d69762d7072656669782d76310007000000 |
| Epoch (epoch=100) | 76617274612d65706f63682d7631006400000000000000 |
7. Replay Protection
Observers MUST maintain per-sender state to reject replayed frames.
Known limitation (reference implementation). Per-sender replay state is reset when a PID is recycled and its session restarts after a silence gap, which opens a bounded window in which a captured frame of the dead session can be replayed — forging one liveness beat for the recycled PID (recovery commands remain gated and are not triggerable by the replay). A full fix needs a wire-level session/epoch identifier, deferred to a future VLP version. See
vlp-transports.md— “Secure UDP — session-restart replay window (H5)” for the exact window, bound, and root cause.
Replay state MUST be keyed by the AEAD-authenticated sender identity, never by the UDP source address. A source address is neither stable — a legitimate reconnect changes the source port — nor authenticated — a replay attacker can resend a captured ciphertext from any port. A source-keyed scheme therefore both drops legitimate reconnects and admits a replayed frame from a fresh port. The sender identity below is bound by the Poly1305 tag, so it is the only identity an observer can trust.
7.1 Shared-key mode
Key by the VLP frame PID read from the decrypted plaintext; within that
sender, track last_seen_counter per iv_random prefix. Accept a new frame
only if iv_counter > last_seen_counter for its prefix; reject equal or lesser
counters.
7.2 Master-key mode
Key by the on-wire agent_pid (which is also bound as the AEAD AAD), tracking
the same per-prefix counter monotonicity rule. The UDP source address is not
part of the key in either mode.
7.3 Bounded state
A real observer cannot retain unbounded per-sender state. The Rust
reference implementation bounds per-sender records to 1024 simultaneous
senders and refuses unknown senders at capacity after a stale-sender sweep; see
book/src/architecture/vlp-transports.md — “Secure UDP — replay-state
capacity boundary (H4)”
for the precise capacity rule and the threat-model implication
(loopback-default binding when secure-UDP is configured).
8. Worked Example — Shared-Key Seal
- Key:
0001020304050607 0809 0a0b0c0d0e0f 1011 1213 1415 1617 1819 1a1b 1c1d 1e1f(32 bytes) iv_random:1122334455667788iv_counter:0- Plaintext (a base VLP frame,
Status::Okpid=2ts=1000nonce=1payload=0):5641020002000000e80300000000000001000000000000000000000055d0861c
Resulting 60-byte wire frame:
1122334455667788 00000000
bcba1b202190b688a08e0a7ac909da44a2023cb7a421fd6428453fd12141c257
b6fd638c55fddf5c621020de1327975a
9. Worked Example — Master-Key Seal
- Master key:
0001020304…1f(same as above, 32 bytes) agent_pid:2(0x02000000LE)- Derived agent key:
db292f5843a0737aec785a9df270561b343d06e5fe8f89fce72f0869ba77afd5 iv_random:1122334455667788iv_counter:0- Plaintext: same as §8.
Resulting 64-byte wire frame:
02000000
1122334455667788 00000000
efe8fd8c226106641e01fc8fe649f79475e19b4f2093e063987f1c663a5d2f0b
73ba429fadc4c494e2723baff86af9cc
10. Stability
| Element | Stable? | Bump procedure |
|---|---|---|
| Shared-key wire layout (60 B) | Stable | Spec-version bump |
| Master-key wire layout (64 B) | Stable | Spec-version bump |
HKDF info string varta-agent-v1 | Versioned | Replace -v1 suffix; all agents must re-key |
HKDF info string varta-iv-prefix-v1 | Versioned | Same |
HKDF info string varta-epoch-v1 | Versioned | Same |
| AEAD primitive (ChaCha20-Poly1305) | Stable | Spec-version bump |
Implementations MUST NOT silently accept an unknown info-string version; any change to a derivation requires explicit re-keying across the deployment.
11. Reference Implementation
The Rust reference lives in
crates/varta-vlp/src/crypto.
The seal/open operations delegate to the RustCrypto
chacha20poly1305 crate (NCC Group audit 2020); the HKDF derivations use
the RustCrypto hkdf + sha2 crates. No hand-rolled ChaCha20, Poly1305,
or KDF logic exists in the workspace.
Cross-language references (Python cryptography, C libsodium, Go
golang.org/x/crypto/chacha20poly1305) live in
tools/reference-implementations/.
12. Conformance
Run the test-vector suite against your implementation. The
secure_frame_vectors array in
tools/vlp-test-vectors.json
contains:
shared_key_seal— encode a 60-byte wire frame; compare toexpected_wire_hex.master_key_seal— derive the agent key, encode a 64-byte wire frame; compare both the derived key and the wire bytes to the goldens.kdf_agent_key,kdf_iv_prefix,kdf_epoch_key— drive each HKDF derivation and compare to the published OKM.
See Conformance & Test Vectors for the JSON schema and end-to-end recipe.
See also
- VLP — Base Frame — the 32-byte plaintext layout this document wraps.
- Conformance & Test Vectors — JSON schema.
- Rust transport rationale — design trade-offs in the reference implementation (loopback default, bounded replay state, fork-safety auto-recovery). Not normative.
Conformance & Test Vectors
This document describes the cross-language conformance contract for the
Varta Lifeline Protocol. An implementation in any language is conformant
if and only if it agrees with the published test-vector suite at
tools/vlp-test-vectors.json.
The Rust reference implementation regenerates this file via:
cargo run -p varta-vlp --example gen_test_vectors --features "std crypto"
and asserts byte-equality on every entry via the integration test
crates/varta-vlp/tests/conformance_vectors.rs. The file is the source of
truth; if it drifts from the Rust implementation, the test fails before
CI passes.
1. JSON Schema
The top-level document is an object with these fields:
{
"spec_version": "0.2",
"description": "...",
"magic_hex": "5641",
"version_byte": 2,
"nonce_terminal_hex": "ffffffffffffffff",
"crc32c_vectors": [ ... ],
"frame_vectors": [ ... ],
"secure_frame_vectors": [ ... ]
}
1.1 crc32c_vectors (array)
Reference CRC-32C (Castagnoli) inputs and outputs. Each entry:
{
"id": "crc-rfc3720",
"description": "RFC 3720 appendix B reference vector.",
"input_hex": "313233343536373839",
"expected_crc_hex": "e3069283"
}
input_hex— the input byte string, lowercase hex.expected_crc_hex— the 4-byte CRC, lowercase hex, big-endian in the hex string (the leftmost hex pair is the most-significant CRC byte). On the wire, the CRC is little-endian; the JSON file presents the numeric value directly for readability.
1.2 frame_vectors (array)
Base-frame encode/decode goldens. Two kind values are defined.
Kind encode_decode_roundtrip:
{
"id": "frame-ok-minimal",
"description": "Minimum legal frame.",
"kind": "encode_decode_roundtrip",
"expected_decode_error": null,
"inputs": {
"status": "ok",
"pid": 2,
"timestamp": 0,
"nonce": 1,
"payload": 0
},
"expected_wire_hex": "56410200020000000000000000000000010000000000000000000000e4116baa"
}
statusis one of"ok","degraded","critical". ("stall"never appears as an input —Status::Stallis observer-synthesized only.)- All numeric fields are unsigned decimal integers; widths match the base-frame spec (pid: u32, timestamp: u64, nonce: u64, payload: u32).
expected_wire_hexis the canonical 32-byte wire frame, lowercase hex (64 characters).
A conformant implementation MUST:
- Encode
inputsand produce a byte sequence equal toexpected_wire_hex. - Decode
expected_wire_hexand recover fields equal toinputs.
Kind decode_error:
{
"id": "frame-error-bad-magic",
"description": "First byte 0x00 instead of 'V'.",
"kind": "decode_error",
"expected_decode_error": "BadMagic",
"wire_hex": "00410200020000006400000000000000010000000000000000000000421795be"
}
wire_hex— 32 bytes lowercase hex.expected_decode_error— one ofBadMagic,BadVersion,BadCrc,BadStatus,StallOnWire,BadPid,BadTimestamp,BadNonce.
A conformant implementation MUST decode wire_hex and surface the named
error variant.
1.3 secure_frame_vectors (array)
Secure-transport encode/decode + key-derivation goldens. Five kind
values are defined.
Kind shared_key_seal:
{
"id": "secure-shared-key-seal",
"description": "Shared-key 60-byte secure frame.",
"kind": "shared_key_seal",
"key_hex": "0001020304…1f",
"iv_random_hex": "1122334455667788",
"iv_counter": 0,
"plaintext_hex": "5641020002000000…1c",
"expected_wire_hex": "11223344…975a"
}
A conformant implementation MUST:
- Construct nonce =
iv_random || iv_counter_LE. - AEAD-seal
plaintext_hexunderkey_hexwith empty AAD. - Concatenate
iv_random || iv_counter_LE || ciphertext || tagand matchexpected_wire_hexexactly (60 bytes / 120 hex chars).
Kind master_key_seal:
{
"id": "secure-master-key-seal",
"kind": "master_key_seal",
"master_key_hex": "0001…1f",
"agent_pid": 2,
"derived_agent_key_hex": "db29…afd5",
"iv_random_hex": "1122334455667788",
"iv_counter": 0,
"plaintext_hex": "...",
"expected_wire_hex": "..."
}
Steps: derive agent_key = HKDF-SHA256(master, info="varta-agent-v1\0" || agent_pid_LE); the derived key MUST match derived_agent_key_hex. Then
seal under agent_key with AAD = agent_pid_LE and assemble the 64-byte
wire frame.
Kinds kdf_agent_key, kdf_iv_prefix, kdf_epoch_key: HKDF goldens.
Each carries the input material, the assembled info string (as
info_hex), and the expected output. See
VLP — Secure Transport §6 for the
exact derivation procedure.
2. Running the Suite
2.1 Python (stdlib only)
import json, sys
import vlp # your implementation
vectors = json.load(open("tools/vlp-test-vectors.json"))
for v in vectors["crc32c_vectors"]:
got = vlp.crc32c(bytes.fromhex(v["input_hex"]))
want = int(v["expected_crc_hex"], 16)
assert got == want, f"{v['id']}: {got:08x} != {want:08x}"
for v in vectors["frame_vectors"]:
if v["kind"] == "encode_decode_roundtrip":
i = v["inputs"]
wire = vlp.encode(i["status"], i["pid"], i["timestamp"],
i["nonce"], i["payload"])
assert wire.hex() == v["expected_wire_hex"], v["id"]
elif v["kind"] == "decode_error":
try:
vlp.decode(bytes.fromhex(v["wire_hex"]))
assert False, f"{v['id']}: expected error"
except vlp.DecodeError as e:
assert e.kind == v["expected_decode_error"], v["id"]
A full runner is at
tools/reference-implementations/python/verify_vectors.py.
2.2 Go (stdlib only)
import (
"encoding/hex"
"encoding/json"
"os"
)
type Doc struct {
Crc32cVectors []struct {
ID, InputHex, ExpectedCRCHex string
} `json:"crc32c_vectors"`
// ...
}
doc := Doc{}
buf, _ := os.ReadFile("tools/vlp-test-vectors.json")
json.Unmarshal(buf, &doc)
for _, v := range doc.Crc32cVectors {
input, _ := hex.DecodeString(v.InputHex)
got := vlp.CRC32C(input)
want, _ := strconv.ParseUint(v.ExpectedCRCHex, 16, 32)
if uint64(got) != want { /* fail */ }
}
Full runner: tools/reference-implementations/go/verify_vectors.go.
2.3 C99 (libsodium or hand-rolled)
The C reference at
tools/reference-implementations/c/
uses a minimal hand-rolled JSON tokenizer and libsodium for AEAD. Run:
cd tools/reference-implementations/c
make verify
./verify_vectors ../../vlp-test-vectors.json
2.4 Shell quick-spot via jq
# Spot-check one CRC vector:
jq -r '.crc32c_vectors[] | select(.id=="crc-rfc3720") | .input_hex' \
tools/vlp-test-vectors.json \
| xxd -r -p \
| your-crc-tool
3. Canonical Vectors (Inline)
For readers who want to confirm the spec without fetching the JSON, the canonical vectors are reproduced here.
3.1 CRC-32C
| id | input (hex) | CRC-32C |
|---|---|---|
crc-empty | (empty) | 00000000 |
crc-single-a | 61 | c1d04330 |
crc-rfc3720 | 313233343536373839 | e3069283 |
crc-thirty-two-zeros | 32 × 00 | 8a9136aa |
crc-thirty-two-ffs | 32 × ff | 62a8ab43 |
3.2 Base frames — encode/decode round-trips
| id | status | pid | ts | nonce | payload | wire (32 B hex) |
|---|---|---|---|---|---|---|
frame-ok-minimal | ok | 2 | 0 | 1 | 0 | 56410200020000000000000000000000010000000000000000000000e4116baa |
frame-degraded-typical | degraded | 12345 | 1234567890 | 100 | 3735928559 | 5641020139300000d2029649000000006400000000000000efbeadde7bbc775f |
frame-critical-operational | critical | 99 | 10000 | 5 | 42 | 5641020263000000102700000000000005000000000000002a00000037ecf63f |
frame-critical-terminal | critical | 2 | 999 | 18446744073709551615 | 0 | 5641020202000000e703000000000000ffffffffffffffff00000000a299eeed |
frame-ok-large-fields | ok | 3735928559 | 81985529216486895 | 1 | 66 | 56410200efbeaddeefcdab896745230101000000000000004200000000b228b8 |
frame-ok-nonce-wrapped-to-zero | ok | 2 | 1 | 0 | 0 | 56410200020000000100000000000000000000000000000000000000693259ac |
3.3 Base frames — decode errors
| id | wire (32 B hex) | error |
|---|---|---|
frame-error-bad-magic | 00410200…421795be | BadMagic |
frame-error-bad-version | 56410100…7a774318 | BadVersion |
frame-error-bad-crc | 56410200…6bfbb06f | BadCrc |
frame-error-bad-status | 564102ff…9a03661d | BadStatus |
frame-error-stall-on-wire | 56410203…cca7ed1c | StallOnWire |
frame-error-bad-pid-zero | 56410200 00000000 …8608c31f | BadPid |
frame-error-bad-pid-init | 56410200 01000000 …08ca8ca5 | BadPid |
frame-error-bad-timestamp | 56410200 02000000 ffffffffffffffff…30ddde54 | BadTimestamp |
frame-error-bad-nonce-… | 56410200 02000000 64000000 00000000 ffffffffffffffff 00000000 8c2889f8 | BadNonce |
(Full wire bytes for the truncated rows live in the JSON file.)
3.4 Secure-frame goldens — summary
Full bytes live in
tools/vlp-test-vectors.json;
the structural summary:
| id | kind | sizes |
|---|---|---|
secure-shared-key-seal | shared-key seal | 60 B wire |
secure-master-key-seal | master-key seal | 64 B wire (with 32 B derived agent key) |
kdf-agent-key | HKDF (varta-agent-v1) | 32 B OKM |
kdf-iv-prefix | HKDF (varta-iv-prefix-v1) | 8 B OKM |
kdf-epoch-key | HKDF (varta-epoch-v1) | 32 B OKM |
The HKDF goldens published in VLP — Secure Transport §6.4 are sufficient to confirm an implementation’s HKDF info-string assembly is byte-correct.
4. What “Conformant” Means
An implementation is conformant if and only if:
- It encodes every
frame_vectors[].inputsto bytes equal toexpected_wire_hex. - It decodes every
expected_wire_hexback to fields equal to the correspondinginputs. - It rejects every
decode_errorentry with the named error variant. - For implementations that include secure transport: it reproduces every
secure_frame_vectors[]byte-for-byte, including the HKDF derivations.
There is no partial conformance — BadMagic returned where BadCrc was
expected is a non-conforming bug, even if the frame is correctly
rejected.
5. Re-generating the file
The JSON file is checked into the repository so that:
- External implementers can clone the repo and run their conformance suite without any Rust toolchain.
- CI catches accidental wire-format drift via
cargo test -p varta-vlp --features "std crypto" --test conformance_vectors.
The generator that emits the file is
crates/varta-vlp/examples/gen_test_vectors.rs.
After any change to Frame::encode, the CRC table, the AEAD parameters,
or the HKDF info strings, regenerate the file:
cargo run -p varta-vlp --example gen_test_vectors --features "std crypto"
cargo test -p varta-vlp --features "std crypto" --test conformance_vectors
and commit the resulting JSON alongside the source change.
See also
Go client
The Go client (go get github.com/aramirez087/Varta/clients/go) is a first-class peer of the Rust varta-client crate. It tracks the same wire-format contract, passes the same tools/vlp-test-vectors.json conformance suite, and interoperates with the same varta-watch observer binary.
Install
go get github.com/aramirez087/Varta/clients/go
Requires Go 1.21+. The base module (UDS + plaintext UDP) has no registry dependencies. ConnectSecureUDP adds golang.org/x/crypto.
20-line example
package main
import (
"log"
"time"
varta "github.com/aramirez087/Varta/clients/go"
)
func main() {
// Connect once. path must match --socket on your observer.
agent, err := varta.Connect("/run/varta/varta.sock")
if err != nil {
log.Fatal(err)
}
defer agent.Close()
for {
if outcome := agent.Beat(varta.StatusOK, 0); outcome.IsDropped() {
// Four-way taxonomy mirrors the Rust client:
log.Printf("varta: dropped (%s)", outcome.DropReason())
}
time.Sleep(500 * time.Millisecond)
}
}
For payload encoding, fork-safety, the panic-handler subpackage, the full transport comparison, and the complete API parity matrix see the package README:
clients/go/README.md.
Transports
| Transport | Status | Notes |
|---|---|---|
| Unix Domain Sockets | Supported | varta.Connect(path). Stdlib-only. Classified BeatOrigin::KernelAttested only on observer platforms with pathname-UDS peer credentials; macOS observers treat pathname UDS as socket-mode-only, so recovery is refused. |
| Plaintext UDP | Supported | varta.ConnectUDP(host, port). Connected-mode socket. Beats classified NetworkUnverified; recovery refused. |
| Secure UDP (ChaCha20-Poly1305) | Supported | varta.ConnectSecureUDP(host, port, key). Adds golang.org/x/crypto. |
| Master-key secure UDP | Supported | varta.ConnectSecureUDPWithMaster(host, port, masterKey) |
Stability
- Wire format: VLP v0.2, governed by the spec.
- Go API: independent semver, tracked in
clients/go/CHANGELOG.md.
Source
Python client
The Python client (pip install varta) is a first-class peer of the
Rust varta-client crate. It tracks the same wire-format contract,
passes the same tools/vlp-test-vectors.json conformance suite, and
interoperates with the same varta-watch observer binary.
Install
pip install varta # base client (stdlib only)
pip install 'varta[secure]' # adds secure-UDP via `cryptography`
Requires Python 3.8+. Runs on Linux and macOS. The base install carries
zero third-party dependencies; the secure extra pulls in
cryptography for the
ChaCha20-Poly1305 AEAD primitive.
20-line example
import time
from varta import Varta, Status, DropReason
with Varta.connect("/run/varta/varta.sock") as agent:
while True:
outcome = agent.beat(Status.OK)
if outcome.is_dropped:
# Four-way taxonomy mirrors the Rust client:
assert outcome.reason in {
DropReason.KERNEL_QUEUE_FULL,
DropReason.NO_OBSERVER,
DropReason.PEER_GONE,
DropReason.STORAGE_FULL,
}
time.sleep(0.5)
For UDP and secure-UDP transports, fork-safety, the panic-hook family,
and the full parity matrix see the package README in the repo:
clients/python/README.md.
Transports
| Transport | Status | Notes |
|---|---|---|
| Unix Domain Sockets | Supported | Varta.connect(path). Classified BeatOrigin::KernelAttested only on observer platforms with pathname-UDS peer credentials; macOS observers treat pathname UDS as socket-mode-only, so recovery is refused. |
| Plaintext UDP | Supported | Varta.connect_udp((host, port)). Connected-mode socket. Beats classified NetworkUnverified; recovery refused. |
| Secure UDP (ChaCha20-Poly1305) | Supported | Varta.connect_secure_udp((host, port), key). Requires pip install 'varta[secure]'. |
| Master-key secure UDP | Supported | Varta.connect_secure_udp_with_master((host, port), mkey) |
Stability
- Wire format: VLP v0.2, governed by the spec.
- Python API: independent semver, tracked in
clients/python/CHANGELOG.md.
Source
Node.js client
The Node.js client (npm install @varta-health/client) is a first-class peer
of the Rust varta-client crate. It tracks the same wire-format
contract, passes the same tools/vlp-test-vectors.json conformance
suite, and interoperates with the same varta-watch observer binary.
Install
npm install @varta-health/client
Requires Node.js 18 LTS or newer. ESM-only. Ships compiled JavaScript
plus TypeScript declarations. The only registry dependency is an
optional native addon (node-unix-socket) for the UDS transport;
UDP and secure-UDP work without it. ChaCha20-Poly1305 and
HKDF-SHA256 come from Node’s built-in node:crypto.
20-line example
import { Varta, Status, DropReason } from "@varta-health/client";
const agent = Varta.connectUds("/var/run/varta.sock");
setInterval(() => {
const outcome = agent.beat(Status.Ok);
if (outcome.kind === "dropped") {
// Four-way taxonomy mirrors the Rust client:
const _: DropReason = outcome.reason;
} else if (outcome.kind === "failed") {
console.error("varta beat failed:", outcome.error);
}
}, 500);
For secure-UDP, fork-safety, the panic-hook family, and the full parity
matrix see the package README in the repo:
clients/node/README.md.
Transports
| Transport | Status | Notes |
|---|---|---|
| Unix Domain Sockets | Supported (0.2.0+) | Varta.connectUds(path). Requires the optional node-unix-socket addon (prebuilds for darwin x64/arm64 and linux x64/arm64 gnu+musl). Classified BeatOrigin::KernelAttested only on observer platforms with pathname-UDS peer credentials; macOS observers treat pathname UDS as socket-mode-only, so recovery is refused. |
| Plaintext UDP | Supported | Varta.connectUdp(host, port). Connected-mode socket; on Linux ICMP port unreachable surfaces as DropReason.NoObserver on a subsequent beat. macOS ICMP propagation is best-effort. |
| Secure UDP (ChaCha20-Poly1305) | Supported | Varta.connectSecureUdp(host, port, key) |
| Master-key secure UDP | Supported | Varta.connectSecureUdpWithMaster(host, port, masterKey) |
Stability
- Wire format: VLP v0.2, governed by the spec.
- Node API: independent semver, tracked in
clients/node/CHANGELOG.md.
Source
.NET client
The .NET client (dotnet add package Varta.Client) is a first-class
peer of the Rust varta-client crate. It tracks the same wire-format
contract, passes the same tools/vlp-test-vectors.json conformance
suite, and interoperates with the same varta-watch observer binary.
Install
dotnet add package Varta.Client
Targets net8.0 (LTS) and net10.0 (LTS). Pure managed code —
ChaCha20-Poly1305 (System.Security.Cryptography.ChaCha20Poly1305),
HKDF-SHA256 (System.Security.Cryptography.HKDF), and POSIX signals
(System.Runtime.InteropServices.PosixSignalRegistration) all come
from the BCL. Zero native dependencies.
20-line example
using Varta;
using var agent = global::Varta.Varta.Connect("/run/varta/observer.sock");
while (true)
{
BeatOutcome outcome = agent.Beat(Status.Ok, payload: 0);
if (outcome.IsDropped)
{
// Four-way taxonomy mirrors the Rust client:
DropReason _ = outcome.Reason;
}
else if (outcome.IsFailed)
{
Console.Error.WriteLine($"varta beat failed: {outcome.Error}");
}
await Task.Delay(500);
}
For secure UDP, fork-safety, the signal-handler family, and the full
parity matrix see the package README in the repo:
clients/dotnet/README.md.
Transports
| Transport | Status | Notes |
|---|---|---|
| Unix Domain Sockets | Supported (Linux, macOS) | Varta.Connect(path). Classified BeatOrigin::KernelAttested only on observer platforms with pathname-UDS peer credentials; macOS observers treat pathname UDS as socket-mode-only, so recovery is refused. PlatformNotSupportedException on Windows. |
| Plaintext UDP | Supported | Varta.ConnectUdp(host, port). Connected-mode socket; on Linux ICMP port unreachable surfaces as DropReason.NoObserver on a subsequent beat. |
| Secure UDP (ChaCha20-Poly1305) | Supported | Varta.ConnectSecureUdp(host, port, key) |
| Master-key secure UDP | Supported | Varta.ConnectSecureUdpWithMaster(host, port, masterKey) |
Stability
- Wire format: VLP v0.2, governed by the spec.
- .NET API: independent semver, tracked in
clients/dotnet/CHANGELOG.md.
Source
JVM (Java) client
The JVM client (implementation("health.varta:varta-client:0.2.0")) is a
first-class peer of the Rust varta-client crate. It tracks the same
wire-format contract, passes the same tools/vlp-test-vectors.json
conformance suite, and interoperates with the same varta-watch
observer binary.
Install
// build.gradle.kts
dependencies {
implementation("health.varta:varta-client:0.2.0")
// UDS transport requires a SOCK_DGRAM AF_UNIX provider.
runtimeOnly("com.kohlschutter.junixsocket:junixsocket-core:2.10.1")
}
Targets JDK 17 LTS minimum (Spring Boot 3.x baseline). The base jar has
zero runtime dependencies — ChaCha20-Poly1305 comes from standard
JCE (Cipher.getInstance("ChaCha20-Poly1305"), JDK 11+), HKDF-SHA256 is
implemented in-process via javax.crypto.Mac, CRC-32C uses the
hardware-accelerated java.util.zip.CRC32C (JDK 9+).
No shipping JDK exposes SOCK_DGRAM AF_UNIX in standard NIO, so the
client probes the classpath at connect() time and uses either
junixsocket (recommended) or a future zero-dep FFM module on JDK 22+.
20-line example
import health.varta.*;
import java.nio.file.Path;
try (Varta agent = Varta.connect(Path.of("/run/varta/observer.sock"))) {
while (true) {
BeatOutcome outcome = agent.beat(Status.OK, 0);
if (outcome instanceof BeatOutcome.Dropped d) {
// Four-way taxonomy mirrors the Rust client:
DropReason _ = d.reason();
} else if (outcome instanceof BeatOutcome.Failed f) {
System.err.println("varta beat failed: " + f.error());
}
Thread.sleep(500);
}
}
For secure UDP, fork-safety, the signal-handler family, and the full
parity matrix see the package README in the repo:
clients/java/README.md.
Transports
| Transport | Status | Notes |
|---|---|---|
| Unix Domain Sockets | Supported (Linux, macOS) | Varta.connect(path). Requires a UDS provider on the classpath (junixsocket recommended). Classified BeatOrigin::KernelAttested only on observer platforms with pathname-UDS peer credentials; macOS observers treat pathname UDS as socket-mode-only, so recovery is refused. NoUdsTransportException on Windows. |
| Plaintext UDP | Supported | Varta.connectUdp(addr). Connected-mode DatagramChannel; on Linux ICMP port unreachable surfaces as Dropped(NO_OBSERVER) on a subsequent beat. |
| Secure UDP (ChaCha20-Poly1305) | Supported | Varta.connectSecureUdp(addr, key) |
| Master-key secure UDP | Supported | Varta.connectSecureUdpWithMaster(addr, masterKey) |
Stability
- Wire format: VLP v0.2, governed by the spec.
- JVM API: independent semver, tracked in
clients/java/CHANGELOG.md.
Source
Container Image
Registry
ghcr.io/aramirez087/varta-watch:<version>
ghcr.io/aramirez087/varta-watch:<major>.<minor>
ghcr.io/aramirez087/varta-watch:latest # discouraged in production
Multi-arch: linux/amd64 + linux/arm64. Same digest for both — the
image index resolves at pull time.
What’s inside
- Base:
gcr.io/distroless/static-debian12:nonroot(pinned by digest; bumped via Renovate alongside the matching:debug-nonroottag). - Binary:
varta-watchbuilt withprometheus-exporter+json-logfeatures. Class-A (compile-time-config) builds are deliberately not published as the public image — see Safety Profiles. - User:
nonroot(UID 65532, matches the Helm chart and the example DaemonSet). - No shell, no
apt, no busybox in the default tag. A:debug-<ver>tag with the busybox shell is published separately for triage.
Running
docker run -d --name varta-watch \
--user 65532:65532 \
--restart unless-stopped \
--read-only \
--tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
-v /run/varta:/run/varta \
-v /etc/varta/prom.token:/etc/varta/prom.token:ro \
-p 127.0.0.1:9100:9100 \
ghcr.io/aramirez087/varta-watch:0.3.0 \
--socket=/run/varta/varta.sock \
--prom-addr=0.0.0.0:9100 \
--prom-token-file=/etc/varta/prom.token \
--self-watchdog-secs=4
--self-watchdog-secs 4 is useful even without systemd — the
in-process watchdog still aborts on wedge, and --restart unless-stopped brings the container back up.
Verifying the image
cosign verify ghcr.io/aramirez087/varta-watch:0.3.0 \
--certificate-identity-regexp '^https://github.com/aramirez087/Varta' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
CycloneDX SBOM is attached via cosign attest --type cyclonedx:
cosign verify-attestation ghcr.io/aramirez087/varta-watch:0.3.0 \
--type cyclonedx \
--certificate-identity-regexp '^https://github.com/aramirez087/Varta' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
| jq -r '.payload | @base64d | fromjson | .predicate'
SLSA L3 build provenance is registered with GitHub:
gh attestation verify --repo aramirez087/Varta \
oci://ghcr.io/aramirez087/varta-watch:0.3.0
Image labels
Every image carries the OCI standard label set:
org.opencontainers.image.title = varta-watch
org.opencontainers.image.description = Varta observer — receives VLP frames and surfaces stalls.
org.opencontainers.image.source = https://github.com/aramirez087/Varta
org.opencontainers.image.documentation = https://varta.sh/book/operations/container.html
org.opencontainers.image.vendor = Varta
org.opencontainers.image.licenses = MIT OR Apache-2.0
org.opencontainers.image.version = <tag>
org.opencontainers.image.revision = <git sha>
Building locally
The source Dockerfile lives at the repo root and uses BuildKit’s
$TARGETPLATFORM cross-compile pattern:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag varta-watch:local \
--load . # --push for registry publishing
CI smokes this on every PR (docker-build job in
.github/workflows/ci.yml) so a typo in the Dockerfile fails fast
instead of breaking adopters at the next tagged release.
Helm Chart
The chart at charts/varta-watch/
is published as an OCI artifact at
oci://ghcr.io/aramirez087/charts/varta-watch. Default render matches
the key observer-container fields from the raw manifests at
observability/examples/kubernetes/
(a helm-parity CI gate fails the build on drift).
Install
helm install varta-watch \
oci://ghcr.io/aramirez087/charts/varta-watch \
--version 0.1.1 \
--create-namespace \
--namespace varta \
--set prometheusToken.token=$(openssl rand -hex 32)
For production, source the token from your secret manager (SOPS, ESO, Vault Secrets Operator) and reference an existing Secret instead:
prometheusToken:
existingSecret:
name: varta-prom-token # provisioned out-of-band
key: token
Deployment modes
Switch via --set mode=…:
| Mode | Object | Use when |
|---|---|---|
daemonset | DaemonSet | One observer per node. UDS via hostPath:/run/varta. |
sidecar | Deployment (1) | Strict tenant isolation. UDS in an emptyDir of one Pod. |
The chart resolves the UDS volume type per mode (host path vs.
emptyDir) and emits exactly one of the two object kinds.
Each workload pod includes a uds-permissions init container that
prepares the socket parent before varta-watch binds. It makes the
directory owned by the configured observer UID/GID and mode 0755, so
Kubernetes fsGroup handling cannot leave /run/varta in a
group-writable state that the observer rejects.
Values reference
The full reference is the chart’s own
values.yaml.
Most-touched knobs:
| Path | Default | Notes |
|---|---|---|
mode | daemonset | daemonset | sidecar |
image.tag | "" (→ Chart.appVersion) | Pin to an immutable tag for production |
image.repository | ghcr.io/aramirez087/varta-watch | |
prometheusToken.token | "" | Set or use existingSecret.name |
prometheusToken.existingSecret.name | "" | Out-of-band Secret name |
uds.path | /run/varta/varta.sock | |
uds.hostPath | /run/varta | daemonset mode only |
udsInit.image.repository | busybox | Init image that prepares the UDS parent directory |
selfWatchdogSecs | 4 | Matches the example systemd unit’s half-WatchdogSec |
extraArgs | [] | Verbatim appended to argv |
prometheus.bindAddr | 0.0.0.0:9100 | "" disables the HTTP endpoint |
prometheus.serviceMonitor.enabled | true | |
prometheus.serviceMonitor.release | kube-prometheus-stack | Match your kube-prometheus selector |
prometheus.podMonitor.enabled | false | Alternative to ServiceMonitor |
dashboard.enabled | true | Emits sidecar-labelled ConfigMap with the dashboard JSON |
resources.{requests,limits} | 25m / 32 Mi / 250m / 128 Mi | |
namespace.create | true | Set false if you manage namespaces out-of-band |
Helm test
helm test varta-watch -n varta
Runs an in-cluster Pod that scrapes /metrics with the bearer token
and asserts the observer is emitting varta_iterations_total. The pod
is cleaned up after success (helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded).
Upgrading
helm upgrade varta-watch \
oci://ghcr.io/aramirez087/charts/varta-watch \
--version 0.2.0 \
-n varta \
--reuse-values
The chart follows SemVer independently from the varta-watch app
version. Breaking template changes (e.g. renaming a values key) bump
the chart’s major; bumping just the appVersion (a new binary release)
bumps the chart patch.
Verifying the chart artifact
cosign verify oci://ghcr.io/aramirez087/charts/varta-watch:0.1.1 \
--certificate-identity-regexp '^https://github.com/aramirez087/Varta' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
Loading the dashboard under kube-prometheus
dashboard.enabled: true ships a ConfigMap with the Grafana sidecar
label (default grafana_dashboard: "1") — the kube-prometheus-stack
Grafana sidecar auto-imports it. If you run a custom Grafana, point its
sidecar at the chart’s namespace or override dashboard.label /
dashboard.labelValue / dashboard.namespace.
Migrating from raw manifests
Adopters who previously used
observability/examples/kubernetes/
can switch to the chart with no operational disruption: the rendered
default keeps the observer container image repository, args, and mounts
in sync with the raw manifest. The CI helm-parity job asserts this on
every PR. Expected differences include Helm-standard labels
(helm.sh/chart, app.kubernetes.io/managed-by) and chart-managed init
containers for token staging and UDS directory preparation.
Deployment Patterns
Concrete recipes for the three most common varta-watch deployment
targets. All three files referenced live under
observability/examples/
and are CI-linted on every push.
Looking for the one-paste install paths? See Install (Quickstart) for
curl | sh,cargo binstall, Helm, and Docker one-liners. This page is the reference on the underlying recipes those paths assemble.
Pre-flight: the bearer token
varta-watch requires --prom-token-file whenever --prom-addr is
set. Generate the token once per host:
install -d -m 0750 -o varta -g varta /etc/varta
openssl rand -hex 32 | install -m 0400 -o varta -g varta /dev/stdin /etc/varta/prom.token
Anything that can read /etc/varta/prom.token can scrape /metrics.
Mirror the token to the Prometheus scraper via your secret manager
(Vault, SOPS, External Secrets Operator, etc.).
systemd (bare metal / VM)
Drop observability/examples/varta-watch.service into
/etc/systemd/system/varta-watch.service, then:
useradd --system --no-create-home --shell /usr/sbin/nologin varta
systemctl daemon-reload
systemctl enable --now varta-watch
systemctl status varta-watch
Key bindings the unit gets for free:
Type=notify+WatchdogSec=8s— systemd will SIGABRT-then-SIGKILL the process ifWATCHDOG=1doesn’t arrive every 4 seconds (half ofWatchdogSec).--self-watchdog-secs 4(passed inExecStart) — auto-enables when$WATCHDOG_USECis set; spawns the in-process watchdog thread that emitsWATCHDOG=1and callsprocess::abort()on wedge before systemd has to intervene.- Hardening flags (
ProtectSystem,NoNewPrivileges,MemoryDenyWriteExecute, etc.) — defence in depth; safe to leave on even if your kernel ignores some of them.
Validate the unit syntactically on Linux:
systemd-analyze verify observability/examples/varta-watch.service
Docker
docker run -d --name varta-watch \
--user 65532:65532 \
--restart unless-stopped \
--read-only \
--tmpfs /tmp \
--cap-drop ALL \
--security-opt no-new-privileges \
-v /run/varta:/run/varta \
-v /etc/varta/prom.token:/etc/varta/prom.token:ro \
-p 127.0.0.1:9100:9100 \
ghcr.io/aramirez087/varta-watch:0.3.0 \
--socket=/run/varta/varta.sock \
--prom-addr=0.0.0.0:9100 \
--prom-token-file=/etc/varta/prom.token \
--self-watchdog-secs=4
Verify the image before pulling it into production:
cosign verify ghcr.io/aramirez087/varta-watch:0.3.0 \
--certificate-identity-regexp '^https://github.com/aramirez087/Varta' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
The --self-watchdog-secs 4 flag stays useful even without systemd —
the in-process watchdog still aborts on wedge, and Docker’s
--restart unless-stopped brings the container back up.
Kubernetes (kube-prometheus)
The supported path is the Helm chart; the raw manifests remain for adopters who don’t want Helm and are CI-asserted to match the chart’s default render.
# Helm (recommended)
helm install varta-watch \
oci://ghcr.io/aramirez087/charts/varta-watch \
--version 0.2.0 \
--namespace varta --create-namespace \
--set prometheusToken.token=$(openssl rand -hex 32)
# Or raw manifests
kubectl apply -f observability/examples/kubernetes/varta-watch.deployment.yaml
kubectl apply -f observability/examples/kubernetes/varta-watch.servicemonitor.yaml
Two patterns supported by the bundle:
- Per-node
DaemonSet(default): one observer per node, agents share the UDS viahostPath:/run/varta. Easiest fit for existing workloads not deployed as pods of their own. - Sidecar: replace the
DaemonSetwith aDeploymentand mountemptyDir: {}at/run/varta. Only agents inside the same pod can reach the observer; useful for strict isolation (one Varta per tenant).
ServiceMonitor vs. PodMonitor:
- Use
ServiceMonitor(default) when you keep the headlessService. Discovery is viaServiceendpoints; Prometheus retrieves the pod list from the API server. - Use
PodMonitor(alternative manifest provided) when you remove theServicefor strict-sidecar deployments. Discovery is direct pod enumeration.
The release: label on the CRD must match your Prometheus CR’s
serviceMonitorSelector. The kube-prometheus-stack Helm chart defaults
to release: <chart-release-name>; adjust accordingly.
Loading the dashboard under kube-prometheus
Two options:
-
ConfigMapwith the sidecar label (recommended — Grafana auto-imports):kubectl create configmap varta-grafana-dashboard \ --from-file=observability/dashboards/varta-health.json \ -n monitoring kubectl label configmap varta-grafana-dashboard \ grafana_dashboard="1" \ -n monitoring -
grafanaDashboardsfield on aGrafanaDashboardCR if you run the grafana-operator.
Class-A safety-critical builds
The compile-time-config Cargo feature structurally excises the
Prometheus exporter from the binary (see
Compile-Time Configuration and
Safety Profiles). If you’re
deploying a Class-A profile:
- The deployment recipes on this page do not apply —
--prom-*flags are not recognised by the binary, and the CI strings audit rejectsGET /metricsliterals in the artifact. - Use the file exporter (
--export-file <path>) instead. The TSV schema is documented incrates/varta-watch/README.md. - For audit-log integrity, treat the on-disk audit log as the source of
truth; there is no
/metricsendpoint to scrape.
Verifying a deployment
Sanity checks for any of the three deployment patterns:
# 1. Token authenticates.
curl -sS -H "Authorization: Bearer $(cat /etc/varta/prom.token)" \
http://127.0.0.1:9100/metrics | head
# 2. Bad token rejected.
curl -i -H "Authorization: Bearer not-the-token" \
http://127.0.0.1:9100/metrics
# Expect: HTTP/1.0 401 Unauthorized
# 3. Stable label set present (alert rules depend on it).
curl -sS -H "Authorization: Bearer $(cat /etc/varta/prom.token)" \
http://127.0.0.1:9100/metrics \
| grep -E '^varta_rate_limited_total\{reason='
# Expect: both reason="per_pid" and reason="global" lines, value 0
Monitoring & Alerting
Quick walkthrough: Prometheus setup guide
varta-watch ships with a turn-key Prometheus + Grafana + Alertmanager
bundle. The artefacts live under observability/
in the repo; this chapter is the operator-facing prose tying them
together.
If you just want files-on-disk, the bundle README is at
observability/README.md.
Come back here for the why behind each alert and dashboard panel.
10-minute setup
-
Run
varta-watchwith theprometheus-exporterfeature enabled and a bearer token. The token file is mandatory whenever--prom-addris set:openssl rand -hex 32 > /etc/varta/prom.token chmod 0400 /etc/varta/prom.token varta-watch \ --socket /run/varta/varta.sock \ --prom-addr 127.0.0.1:9100 \ --prom-token-file /etc/varta/prom.token \ --self-watchdog-secs 4 -
Copy the rule files into your Prometheus config directory and reload:
cp observability/recording-rules/varta.rules.yml /etc/prometheus/rules.d/ cp observability/alerts/varta.rules.yml /etc/prometheus/rules.d/ curl -X POST http://localhost:9090/-/reload -
Paste
observability/examples/prometheus-scrape.yml’sscrape_configs:block into yourprometheus.yml, pointcredentials_fileat the token from step 1, and reload again. -
Import
observability/dashboards/varta-health.jsonin Grafana, selecting the Prometheus datasource on the import dialog. -
Wire
observability/examples/alertmanager.ymlinto your Alertmanager. Replace the PagerDuty key and Slack webhook before reloading.
For Kubernetes / kube-prometheus operators, use the CRDs in
observability/examples/kubernetes/
instead of editing prometheus.yml directly. See
Deployment Patterns for full systemd / Docker / K8s
recipes.
Severity model
Every Varta alert carries a severity label. Three values, three
routes:
| Severity | Operator action | Examples |
|---|---|---|
critical | Page on-call | VartaWatchStalled, VartaTrackerCapacityExceeded, VartaAuditRecordDropped |
warning | Ticket / investigate within working day | VartaIterationBudgetOverruns, VartaAuditFlushBudgetPressure |
info | Record for trend analysis | VartaAuthFailureBurst, VartaNamespaceConflict |
info alerts are not actionable per-event; they are the trend signal
that earlier action is needed (auth-failure clustering ⇒ rotate token,
namespace-conflict spikes ⇒ review --allow-cross-namespace-agents
policy).
Metrics by subsystem
Every varta-watch metric is in one of nine subsystems. Stable label
sets are emitted from the first scrape (every label value present at
zero), so by (label) queries and absent() rules are safe day-one.
Beat path (5 metrics)
| Metric | Type | Labels | Operational meaning |
|---|---|---|---|
varta_beats_total | counter | pid | Accepted beats per agent. Drops to 0 ⇒ silent agent. |
varta_stalls_total | counter | pid | Observer-detected stalls per agent. |
varta_status | gauge | pid | Last classification (0=Ok, 1=Degraded, 2=Critical, 3=Stall). Stall is observer-synthesised when the silence threshold is crossed — it is never on the wire (see VLP — Base Frame §3.7). |
varta_nonce_wrap_total | counter | Agent exhausted its u64 nonce — must be unreachable in practice. | |
varta_rate_limited_total | counter | reason | Frames dropped by per-pid or global token bucket. |
Decode / authentication (5 metrics)
| Metric | Type | Labels | Meaning |
|---|---|---|---|
varta_decode_errors_total | counter | kind | Wire-format rejects; kind ∈ {bad_magic, bad_version, bad_status, bad_pid, bad_timestamp, bad_nonce, stall_on_wire}. |
varta_frame_auth_failures_total | counter | Kernel peer-cred check disagreed with frame’s claimed PID — spoofing attempt. | |
varta_io_errors_total | counter | Socket receive errors. | |
varta_ctrl_truncated_total | counter | MSG_CTRUNC — kernel truncated the ancillary-data payload (credential metadata). | |
varta_truncated_datagrams_total | counter | Wrong-sized datagrams (not 32 bytes for UDS, not 60/64 for secure-UDP). |
Tracker / capacity (8 metrics)
| Metric | Type | Meaning |
|---|---|---|
varta_tracker_capacity | gauge | Configured --tracker-capacity. |
varta_tracker_capacity_exceeded_total | counter | Beats dropped at the cap ⇒ silent data loss; page. |
varta_tracker_evicted_total | counter | Dead-agent slot reclamation. Steady non-zero is benign. |
varta_tracker_eviction_scan_truncated_total | counter | Eviction window exhausted ⇒ precursor to capacity-exceeded; warn. |
varta_tracker_invariant_violations_total | counter | DO-178C defensive fall-through; must stay at 0 forever; page. |
varta_tracker_pid_index_probe_exhausted_total | counter | Open-addressed hash table blew its probe budget; page. |
varta_tracker_namespace_conflict_total | counter | Cross-PID-namespace agent refused. |
varta_tracker_eviction_scan_window_max | gauge | Configured --eviction-scan-window. |
Observer liveness (5 metrics)
| Metric | Type | Meaning |
|---|---|---|
varta_observer_iteration_seconds | histogram | Poll-loop wall time per iteration. 9 buckets including +Inf. |
varta_observer_iteration_budget_exceeded_total | counter | Iterations exceeding --iteration-budget-ms. |
varta_observer_clock_regression_total | counter | Backward monotonic clock jumps absorbed. |
varta_observer_clock_jump_forward_total | counter | Forward wall-clock jumps >5s (VM migration / NTP step). |
varta_observer_uds_rcvbuf_bytes | gauge | Effective SO_RCVBUF on the observer UDS socket. |
Recovery (11 metrics)
| Metric | Type | Labels | Meaning |
|---|---|---|---|
varta_recovery_outcomes_total | counter | outcome | Per-outcome counter. Labels: spawned, debounced, reaped_zero, reaped_nonzero, killed, spawn_failed, refused_unauthenticated_transport, refused_cross_namespace, refused_debounce_capacity, refused_outstanding_capacity, refused_socket_mode_only, refused_stale_child_kill_failed, skipped_agent_resumed, skipped_pid_recycled, skipped_stall_unverifiable. |
varta_recovery_refused_total | counter | reason | Recovery refused by policy. Labels: unauthenticated_transport, cross_namespace_agent, debounce_capacity, outstanding_capacity, socket_mode_only, stale_child_kill_failed. |
varta_recovery_duration_ns_sum | counter | Sum of child wall-clock durations (ns). | |
varta_recovery_duration_count_total | counter | Number of completions. sum/count ⇒ mean. | |
varta_recovery_last_fired_evictions_total | counter | LastFiredTable entries evicted at capacity. | |
varta_recovery_invariant_violations_total | counter | Recovery’s DO-178C defensive fall-through; must stay at 0. | |
varta_recovery_outstanding_probe_exhausted_total | counter | OutstandingTable hash probe-limit exceeded; page. | |
varta_recovery_reap_truncated_total | counter | Reap attempts cut by per-tick budget (64 max). | |
varta_recovery_audit_dropped_total | counter | Audit records dropped (ring full) — regulatory data-loss event; page. | |
varta_recovery_audit_flush_budget_exceeded_total | counter | Audit flush exceeded --audit-fsync-budget-ms. | |
varta_origin_conflict_total | counter | Beats refused because transport origin disagreed. |
Audit log (6 metrics)
| Metric | Type | Labels | Meaning |
|---|---|---|---|
varta_audit_fsync_seconds | histogram | fdatasync(2) wall time on the audit log. | |
varta_audit_fsync_budget_exceeded_total | counter | Fsyncs exceeding --audit-fsync-budget-ms. | |
varta_audit_rotation_budget_exceeded_total | counter | Rotation ops exceeding --audit-rotation-budget-ms. | |
varta_audit_ring_watermark_total | counter | level | Rising-edge counter; level ∈ {warning_75pct, critical_95pct}. |
varta_socket_bind_dir_fsync_failed_total | counter | Parent-directory fsync(2) failure on observer UDS bind. | |
varta_frame_rejected_pid_above_max_total | counter | Frames with pid > /proc/sys/kernel/pid_max (impossible PID). |
Scrape (8 metrics)
| Metric | Type | Labels | Meaning |
|---|---|---|---|
varta_observer_serve_pending_seconds | histogram | /metrics response time per tick. Independent of iteration histogram. | |
varta_observer_scrape_budget_exceeded_total | counter | Scrape work exceeding --scrape-budget-ms. | |
varta_observer_stage_seconds | histogram | stage | Per-stage latency breakdown. stage ∈ {drain_pending, poll, maintenance, recovery_reap, serve_pending, housekeeping}. |
varta_scrape_skipped_total | counter | /metrics served from cache (rate-limited). | |
varta_prom_auth_failures_total | counter | Bearer-token rejections. | |
varta_prom_connections_dropped_total | counter | reason | Connections closed before response. reason ∈ {drain, rate_limit, ip_table_full}. |
varta_prom_ip_state_probe_exhausted_total | counter | Per-IP rate-limit table hash probe exhausted. | |
varta_scrape_budget_exhausted_total | counter | Serve connection or deadline budget exhausted during a poll tick. |
Secure-UDP (4 metrics)
| Metric | Type | Labels | Meaning |
|---|---|---|---|
varta_frame_decrypt_failures_total | counter | AEAD decrypt/tag failure. | |
varta_sender_state_full_total | counter | Authenticated secure-UDP frames refused because the sender-state table was full. | |
varta_secure_aead_attempts_total | counter | Total AEAD trials. Constant keys.len() + master_key_configured per accepted beat (closes the key-rotation timing channel). | |
varta_log_suppressed_total | counter | kind | Per-kind rate-limited diagnostic log suppressions. |
Observer metadata (5 metrics)
| Metric | Type | Labels | Meaning |
|---|---|---|---|
varta_watch_uptime_seconds | gauge | Observer process uptime. Frozen ⇒ wedge; page. | |
varta_watch_last_poll_loop_timestamp_seconds | gauge | Unix timestamp of most recent poll tick. | |
varta_watch_pids_tracked | gauge | Current agent PIDs in tracker. | |
varta_pid_max_current | gauge | Cached /proc/sys/kernel/pid_max (refreshed every 60s). | |
varta_signal_handler_install_total | counter | mode | Signal-handler installs by mode ∈ {direct, libc}. |
Alert catalogue
The complete rule file is observability/alerts/varta.rules.yml. Below
is one line per alert linking the alert name (and its anchor) to the
metric and the operator action. Section anchors are stable: every alert
in the YAML references #<lowercased alertname> in its
annotations.runbook_url.
Critical (page on-call)
VartaWatchStalled
- Metric:
varta_watch_uptime_seconds - Trigger: uptime gauge not advancing for 1 minute.
- Why critical: stall detection and recovery are not happening on this host; agents that go silent will not get killed-and-respawned.
- Action: check the host (is the process alive?
kill -0 $(pidof varta-watch)); inspect kernel ring buffer for OOM kills; inspect the systemd journal forprocess::abort()from the self-watchdog thread; restart the unit if needed. The journal will name the stage that wedged.
VartaTrackerCapacityExceeded
- Metric:
varta_tracker_capacity_exceeded_total - Trigger: any non-zero increment over 5m.
- Why critical: new agents are not being added to the tracker — their beats are decoded and counted, but never reach the stall detector.
- Action: shard the deployment (see
Deployment Ceiling & Sharding)
or raise
--tracker-capacity. Checkvarta_tracker_evicted_totalrate first — if eviction is healthy and you’re still hitting the cap, sharding is mandatory.
VartaOutstandingProbeExhausted
- Metric:
varta_recovery_outstanding_probe_exhausted_total - Trigger: any non-zero increment over 5m.
- Why critical: the bounded
OutstandingTablehit its probe budget; new recoveries cannot be tracked. - Action: as above — shard or raise capacity.
VartaPidIndexProbeExhausted
- Metric:
varta_tracker_pid_index_probe_exhausted_total - Trigger: any non-zero increment over 5m.
- Why critical: the open-addressed PID hash blew its 64-probe budget.
- Action: as above. This usually fires before
VartaTrackerCapacityExceededon Linux hosts with highpid_max.
VartaAuditRecordDropped
- Metric:
varta_recovery_audit_dropped_total - Trigger: any non-zero increase over 5m.
- Why critical: for IEC 62304 Class C, every recovery decision must be auditable. A drop means at least one decision is unrecorded.
- Action: check disk latency, audit ring watermark history
(
varta_audit_ring_watermark_total), and the audit-flush budget; raise--audit-fsync-budget-msif the disk is slow but still healthy.
VartaInvariantViolation
- Metrics:
varta_tracker_invariant_violations_total,varta_recovery_invariant_violations_total - Trigger: any non-zero rate over 5m on either metric.
- Why critical: this is the DO-178C “no unproven panics” counter — an unreachable defensive fall-through executed.
- Action: file a bug. Attach the metric values, the
varta-watchversion, and any nearby stage-budget alarms.
VartaIterationP99High
- Metric:
varta_observer_iteration_secondshistogram - Trigger: p99 over 5m exceeds 500 ms.
- Why critical: stall-detection latency is being burned.
- Action: inspect
varta_observer_stage_secondsto find which phase contributes (likelyserve_pendingif it’s a scrape storm, ormaintenanceif it’s an audit-fsync stall).
VartaBeatPathP99High
- Recording rule:
varta:beat_path_seconds:p99_5m - Trigger: beat-path p99 (iteration − scrape) exceeds 200 ms.
- Why critical: the beat path itself is slow, independent of scrape
pressure. Scrape-storm alarms route off
varta_observer_serve_pending_seconds; this one is unaffected. - Action: check audit fsync p99 first
(
varta:audit_fsync_seconds:p99_5m), then disk latency, then tracker eviction window.
Warning (investigate within working day)
| Alert | Action |
|---|---|
VartaIterationBudgetOverruns | >10% of iterations over budget. Inspect per-stage breakdown. |
VartaScrapeStormPressure | >10% of /metrics serves over budget. Reduce scrape frequency or narrow scraper IP set. |
VartaTrackerEvictionTruncated | Eviction window exhausting. Precursor to VartaTrackerCapacityExceeded. Plan shard. |
VartaAuditFlushBudgetPressure | fdatasync(2) over budget consistently. Check disk; raise --audit-fsync-budget-ms if disk is healthy. |
VartaRecoveryReapTruncated | >64 children completing per tick. Reap is keeping up; queue may grow. |
VartaAuditRingWatermarkCritical | Ring crossed 95% fill at least once. Drops are imminent. |
VartaRateLimitingActive | Frames are being shed. Check for agent hot loops or authenticated malformed traffic before tuning limits. |
VartaClockJump | Forward wall-clock jump > 5s. VM migration / NTP step. Stall windows may be off. |
Info (record for trend analysis)
| Alert | Meaning |
|---|---|
VartaAuthFailureBurst | Bearer-token rejections. Misconfigured scraper or token-scanning probe. |
VartaNamespaceConflict | Cross-PID-namespace agent refused. By design (the namespace gate is working). |
VartaFrameDecodeAnomaly | Frames arriving with kind-specific decode failures. Likely client/observer skew. |
VartaAuditRingWatermarkWarn | Ring crossed 75% fill. Advance warning before critical_95pct. |
VartaFrameAuthFailure | Kernel peer-cred disagreed with frame’s PID. Spoofing or forged frame. |
VartaRecoveryRefused | Stall fired but recovery refused (by transport origin, namespace, or capacity). |
Dashboard tour
The single dashboard varta-health.json has six rows. Each row maps
1:1 onto a subsystem; alerts you receive route back to the relevant row
via the runbook_url annotation.
| Row | Panels | Reads from |
|---|---|---|
| Overview | uptime, agents tracked, beats/s, tracker utilisation | varta_watch_*, varta:tracker:utilization |
| Beat path | beats/s & stalls/s, beat-path latency (p50/p99), decode errors by kind | varta_beats_total, varta_stalls_total, varta:beat_path_seconds:p99_5m, varta:decode_errors:rate_5m |
| Observer iteration | iteration p50/p99/p999, per-stage p99, iteration & scrape budget overruns | varta:iteration_seconds:*, varta:stage_seconds:p99_5m, varta_observer_*_budget_exceeded_total |
| Recovery | outcomes stacked, duration mean, refusals stacked, audit fsync + ring + drops | varta:recovery_outcomes:rate_5m, varta:recovery_refused:rate_5m, varta:audit_fsync_seconds:p99_5m |
| Capacity & sharding | tracker utilisation + evictions, probe exhaustion, rate-limit drops, namespace conflicts | varta:tracker:utilization, all probe-exhaustion counters, varta:rate_limited:rate_5m |
| Security & integrity | bearer-auth failures, frame-auth failures, decrypt failures, AEAD attempts ratio, status mix | varta_prom_auth_failures_total, varta_frame_auth_failures_total, varta:secure_aead_attempts:ratio_5m |
See also
- SLOs & Tuning — recommended SLO model and threshold tuning.
- Deployment Patterns — systemd / Docker / Kubernetes recipes.
- Stall Detection & Liveness — the rationale behind the iteration-budget mechanism.
- Deployment Ceiling & Sharding — what to do when the tracker capacity alarms fire.
- Audit Logging — the durability model
behind
VartaAuditRecordDroppedand the ring watermark counters.
SLOs & Tuning
Service-level objectives for varta-watch. These are starting points;
tune to your latency budget and audit-durability needs.
Recommended SLOs
SLO-1 — Beat-path latency
99% of poll-loop iterations complete within 100 ms, measured over a 30-day rolling window.
- SLI:
varta:iteration_seconds:p99_5m(recording rule) - Error budget: 1% of 30 days = ~7.2 h above 100 ms.
- Polices it:
VartaIterationBudgetOverruns(warn at 10% of iterations >budget),VartaIterationP99High(page at p99 >500 ms). - Why 100 ms: the documented worst-case iteration is ~310 ms (see Observer Liveness latency-budget table). 100 ms is well within that bound and leaves headroom for occasional fsync stalls.
SLO-2 — Recovery responsiveness
99% of recoveries fire within
(stall_threshold + debounce_window + 100 ms)of stall detection, measured over a 7-day rolling window.
- SLI:
varta_recovery_outcomes_total{outcome="spawned"}rate vs.varta_stalls_totalrate, joined onpid. - Error budget: 1% of 7 days = ~1.7 h of mis-budgeted recoveries.
- Polices it:
VartaRecoveryReapTruncated(queue backlog),VartaBeatPathP99High(stall detection itself is slow). - Why this matters: the whole product is the chain “agent stops → stall detected → recovery fires”. If recovery responsiveness regresses but the individual stages look fine, the queue-shape (debounce + outstanding tables) is the culprit.
SLO-3 — Audit durability
Zero
varta_recovery_audit_dropped_totalincrements over any 7-day rolling window.
- SLI:
increase(varta_recovery_audit_dropped_total[7d]) - Error budget: zero. This is a hard SLO for IEC 62304 Class C deployments; for other deployments, treat as warning.
- Polices it:
VartaAuditRecordDropped(page on any non-zero),VartaAuditRingWatermarkCritical(warn at 95% fill). - Why this matters: the audit log is the regulatory record. Any drop is a hole in the recovery decision trail.
SLO-4 — Scrape availability
99.9% of Prometheus scrapes complete within
scrape_timeout(default 10s), measured over a 7-day rolling window.
- SLI: scraper-side
up{job="varta-watch"} == 1over total scrapes. - Error budget: 0.1% of 7 days = ~10 minutes.
- Polices it:
VartaScrapeStormPressure(warn at >10% serve overruns).
Tuning matrix
When an SLO is at risk, the dial that affects it most:
| Symptom | First dial to turn | See also |
|---|---|---|
VartaIterationP99High from serve_pending stage | --scrape-budget-ms, scraper interval, scraper IP whitelist | Observer Liveness |
VartaIterationP99High from maintenance stage | --audit-fsync-budget-ms, audit ring size | Audit Logging |
VartaTrackerEvictionTruncated followed by *CapacityExceeded | --tracker-capacity, --eviction-scan-window, or shard | Deployment Ceiling |
VartaRateLimitingActive{reason="per_pid"} | --max-beat-rate | |
VartaRateLimitingActive{reason="global"} | --global-beat-rate, --global-beat-burst | |
VartaAuditFlushBudgetPressure | --audit-fsync-budget-ms, disk latency investigation | Audit Logging |
VartaScrapeStormPressure | --prom-rate-limit-per-sec, scraper count | Peer Authentication |
Sizing examples
50-agent single host
--tracker-capacity 64(default 256 is fine but tighter is cheaper).--max-beat-rate 100(default).--scrape-budget-ms 250(default).- Single Prometheus scraping at 15s.
Iteration p99 ≤ 50 ms typical, p99.9 ≤ 200 ms under audit-flush bursts.
1000-agent shard
--tracker-capacity 2048.--eviction-scan-window 512.--max-beat-rate 200if your agents beat aggressively.--audit-fsync-budget-ms 100(twice the default to absorb disk hiccups).- Two-host HA scrape: stagger the two Prometheus scrapers by 7.5s so the
per-IP rate-limit table doesn’t see them as a coordinated burst; raise
--prom-rate-limit-burstto 20 as defence in depth.
4096-agent ceiling
This is the documented maximum per observer
(deployment ceiling).
Beyond this, shard. Two 2048-agent observers on the same host with
separate UDS paths is cheaper than one tuned-up 4096-agent observer,
because every linear scan (eviction_scan_window) and every probe
budget (PidIndex) is duplicated rather than expanded.
Trend signals
Three info-tier alerts are not alerts in the operational sense — they are SLO erosion signals you should be watching in a dashboard, not paging on:
VartaAuthFailureBurst— bearer-token scanning is happening. Rotate.VartaNamespaceConflict— agent placement is leaking into wrong PID namespaces; review the deployer.VartaFrameDecodeAnomaly{kind="bad_version"}— client/observer version skew on the wire format. Coordinate the upgrade.
The dashboard’s “Security & integrity” row surfaces all three.
Troubleshooting
Runtime issues after varta-watch is installed. For install-time
issues (cosign, systemd unit, container start) see Install
(Quickstart) → Troubleshooting.
The fastest debug surface is /metrics. If you can scrape it,
varta-watch is alive and the metric values pinpoint the layer at
fault. Every diagnostic below names the metric to check first.
My agent reports Sent but varta_beats_total{pid=…} stays at 0
The observer is rejecting the beat before counting it. Walk these in order:
-
varta_decode_errors_total— anykindis nonzero ⇒ wire-format problem. Most common:bad_version(agent on a different VLP release) orbad_magic(something other than a Varta agent is writing to the socket). -
varta_frame_auth_failures_total— nonzero ⇒ peer-cred PID disagrees with the frame’s claimed PID. Either an actual spoof attempt or the agent is callingbeat()from a thread/process whose PID differs from what was wired in. Fork without re-connect()is the usual culprit (v0.2.0+ auto-detects this; older clients do not). -
varta_truncated_datagrams_total— nonzero ⇒ kernel handed the observer a wrong-sized datagram. UDS frames must be exactly 32 bytes; secure-UDP frames must be exactly 60 (shared-key) or 64 (master-key). -
varta_tracker_capacity_exceeded_total— nonzero ⇒ the tracker is at--tracker-capacity(default 4096) and is dropping beats from new pids. Raise the cap or shard (see Deployment Ceiling & Sharding). -
varta_rate_limited_total{reason="per_pid"|"global"}— nonzero ⇒ rate limit kicked in. Defaults are 100/s per pid and 5000/s globally; raise or disable as appropriate (see Upgrade Guide). -
Socket path mismatch. The observer logs the path it bound to at startup. Confirm the agent’s
Varta::connect()argument is byte- identical, including any container mount remapping.
varta_status{pid=…} shows 3 (Stall) but my process is healthy
The observer hasn’t received a beat from this PID in
--threshold-ms (default depends on your config). Causes:
- Agent is blocked on something synchronous that doesn’t yield to the beat loop. The fix is on the agent — Varta is reporting the truth.
- Beat thread crashed silently. If the agent uses the
panic-handlerfeature, a terminalCriticalbeat should have been emitted. Checkvarta_statushistory; if it skipped from0to3without2, the panic-handler is missing or the process hard-died (segfault,kill -9, OOM). - Threshold is too tight for your cadence. If your agent beats
every 1 s but
--threshold-ms 500, every minor scheduling hiccup trips a stall.
Recovery never fires even though stalls are surfaced
In order of likelihood:
-
No recovery command configured. Recovery is opt-in. Check the observer was started with
--recovery-execor--recovery-exec-file. -
varta_recovery_refused_total{reason="…"}— non-zero on one of these labels:Label What to do unauthenticated_transportBeat origin is NetworkUnverified(plain UDP). Recovery requires kernel-attested origin. Use UDS, or secure-UDP with master-key.socket_mode_onlyPlatform without per-datagram peer-creds (e.g. OpenBSD). No fix — recovery is structurally refused here. cross_namespace_agentPID namespace mismatch. Set --allow-cross-namespace-agentsif you trust the source, or fix the deployment (Docker--pid=host, k8shostPID: true).debounce_capacityThe per-pid debounce window suppressed a duplicate spawn. Expected. outstanding_capacityThe OutstandingTableis full (≥--tracker-capacity). A previous recovery is still running for every slot. Investigate why children are not exiting. -
varta_recovery_outcomes_total{outcome="spawn_failed"}is nonzero. Inspect observer stderr for the actualio::Error. Most common:ENOENT(program path wrong, orPATHdoesn’t include it under the isolated env),EACCES(file not executable / wrong owner).
/metrics returns 401 even with the right token
- Token mismatch. Check the file the observer reads
(
--prom-token-file) and the value you’re sending byte-for-byte (xxdboth; a trailing newline counts). - Token file mode. The observer refuses to load tokens from a file
with mode broader than
0400/0600and owner other than the observer UID. - Bearer header form. The exporter accepts
Authorization: Bearer <hex>only.Token <hex>,Basic, query-string credentials are all rejected as 401.
/metrics is slow or times out under high pid count
varta_observer_serve_pending_seconds— bimodal latency ⇒ the serve-pending stage is consuming budget. Raise--scrape-budget-msor scrape less often.varta_scrape_budget_exhausted_total— incrementing ⇒ scrapes arriving faster than the observer can serve. The exporter falls back to a cached response (varta_scrape_skipped_totalincrements). This is a graceful degradation, not a bug.varta_prom_connections_dropped_total{reason="ip_table_full"}— the per-IP rate-limit table is full. Tune--prom-rate-limit-per-secand--prom-rate-limit-burst.
varta_watch_uptime_seconds is frozen
The self-watchdog hasn’t kicked. Either the observer is wedged in a syscall or the metric is being read from a cached scrape. Order of investigation:
ps— is the process actually running?kill -USR1 <pid>— observer logs current iteration state to stderr (if the signal handler is installed; default-on for direct mode, opt-out via--signal-handler-mode disabled).- If
--hw-watchdogwas passed, the kernel watchdog will reboot the host before this gauge can stay frozen long. Checkdmesgforwatchdog: BUGtraces. - If
--self-watchdog-secswas passed, the in-process watchdogSIGABRTs the process before systemd restarts it. Checkjournalctl -u varta-watchfor the abort line.
Namespace conflict on every beat in a container
varta_tracker_namespace_conflict_total is incrementing and beats are
refused. The observer container’s PID namespace differs from the agent
container’s. Fixes:
- Docker / podman: run the observer with
--pid=host. - Kubernetes: set
hostPID: trueon the observer Pod, OR colocate observer + agents in the same Pod with sharedshareProcessNamespace: true. - Explicit override: pass
--allow-cross-namespace-agentsto accept beats (but recovery for those agents will still be refused unless--strict-namespace-checkis left off; see Namespacing).
Where to file something the docs miss
- Wire-protocol surprises: open an issue against
varta-vlptaggedprotocol. - Observer behaviour gaps: tag
observer. - Anything security-shaped: see Security for private disclosure.
Upgrade Guide
This page lists every change between Varta releases that requires
operator action. For the human-readable release prose see
RELEASES/;
for the full diff see
CHANGELOG.md.
The convention on this page: a checkbox per breaking change so you can walk the list and tick off what you’ve handled.
v0.1.x → v0.2.0
Client API
-
BeatOutcome::Droppednow carries aDropReason. Match against the variant or use a_placeholder. Variants:KernelQueueFull,NoObserver,PeerGone,StorageFull.#![allow(unused)] fn main() { // Before if let BeatOutcome::Dropped = outcome { … } // After if let BeatOutcome::Dropped(_) = outcome { … } }
Observer defaults (rate limiting)
The observer now ships with default-on rate limits. Reset all three to zero if you want the v0.1 unlimited behaviour:
-
--max-beat-ratedefaults to100(per-pid). Disable with--max-beat-rate 0. -
--global-beat-ratedefaults to5000(process-wide). Disable with--global-beat-rate 0. -
--uds-rcvbuf-bytesdefaults to1048576(1 MiB). Skip tuning with--uds-rcvbuf-bytes 0.
Shell-mode recovery removed
The /bin/sh recovery path is gone. Operators using shell templates
must migrate to the exec path. Passing a removed flag is now a hard
startup error.
-
--recovery-cmd <TEMPLATE>→--recovery-exec <PROGRAM> [ARGS…]. The stalled pid is appended as the final argument. -
--recovery-cmd-file <PATH>→--recovery-exec-file <PATH>. -
--i-accept-shell-risk→ remove; no replacement needed.
If your recovery logic relied on shell features (pipes, redirects), wrap them in a script and exec the script:
#!/bin/sh
# /usr/local/bin/varta-recovery
PID="$1"
systemctl restart "myapp-${PID}"
varta-watch --recovery-exec /usr/local/bin/varta-recovery …
Full migration reference and rationale: Shell-Mode Recovery Removal.
Recovery child environment is isolated by default
Pre-v0.2.0 recovery children inherited the observer’s full process
environment. From v0.2.0 they see only PATH=/usr/bin:/bin plus
any explicit --recovery-env KEY=VALUE entries.
This is the secure default; observer environments routinely carry
credentials (AWS_*, OAuth tokens, Vault tokens) and inheriting them
into a recovery child turns every recovery template into a credential
exfiltration vector.
-
Audit your recovery scripts for environment dependencies. If they need any inherited variable, add it explicitly:
varta-watch --recovery-env HOME=/var/log/varta --recovery-env LANG=C … -
Or, as an escape hatch, restore full inheritance:
varta-watch --recovery-inherit-env …This emits a one-shot stderr warning at startup so the choice is visible in syslog / SIEM.
Full configuration matrix: Recovery — Async Spawn → env policy.
Wire format
- The frame layout is unchanged between v0.1 and v0.2. Existing agents continue to work. Future protocol bumps (v0.3+) will be called out here.
Defaults that didn’t change but worth re-verifying
-
--threshold-ms(silence detection) — unchanged. -
--recovery-debounce-ms— unchanged. - UDS socket path conventions (
/run/varta/varta.sockis the canonical recommendation; the binary takes whatever you pass via--socket).
New deployment surfaces
These are additive — adopt at your own pace:
- Container image —
ghcr.io/aramirez087/varta-watch:0.3.0, cosign-signed with keyless OIDC, SLSA L3 provenance. - Helm chart —
oci://ghcr.io/aramirez087/charts/varta-watch --version 0.2.0(the chart version is independent of the app version). -
curl | shinstaller —https://varta.sh/install.sh. -
cargo binstall varta-watch. - Python client —
pip install varta.
Pick a path: Install (Quickstart).
Verifying the upgrade
After upgrading, run the standard smoke checks from Deployment Patterns → Verifying a deployment. The two most important signals:
varta_watch_uptime_secondsis advancing.varta_beats_total{pid="<your_agent>"}is non-zero and increasing.
If either is wrong, head to Troubleshooting.
VLP Frame — Wire Layout (v0.2)
Audience: Rust contributors. This page documents the Rust implementation of the Varta Lifeline Protocol — design rationale, type-system choices, performance characteristics. If you are building a client in another language, start at the normative VLP specification — it is language-neutral and carries a published conformance-vector suite.
The Varta Lifeline Protocol carries a single message type: a 32-byte
fixed-layout health frame. Every byte position is pinned at the protocol level
so encode/decode is a handful of from_le_bytes / to_le_bytes calls and a
single CRC-32C pass — nothing else.
Byte map
offset │ size │ field │ notes
───────┼──────┼────────────┼──────────────────────────────────────────────
0 │ 2 │ magic │ const [0x56, 0x41] (ASCII "VA")
2 │ 1 │ version │ const 0x02 (v0.1 → BadVersion)
3 │ 1 │ status │ Status::{Ok=0, Degraded=1, Critical=2, Stall=3}
4 │ 4 │ pid │ u32 little-endian — emitter's process id
8 │ 8 │ timestamp │ u64 little-endian — emitter-local monotonic
16 │ 8 │ nonce │ u64 little-endian — strictly increasing
24 │ 4 │ payload │ u32 little-endian — opaque app context (v0.2)
28 │ 4 │ crc32c │ u32 LE CRC-32C over bytes 0..28 (v0.2)
───────┴──────┴────────────┴──────────────────────────────────────────────
total 32 bytes
Nonce semantics
The 8-byte nonce field at offset 16 carries two distinct kinds of value:
- Regular beats from
varta_client::Varta::beatuse a per-connection counter that starts at 1 on the first beat afterVarta::connectand increments monotonically. On exhaustion the counter wraps atNONCE_TERMINAL - 1 → 0— so the regular-beat stream cycles through1, 2, 3, …, u64::MAX - 1, 0, 1, 2, …and structurally never emitsNONCE_TERMINAL(==u64::MAX). - Panic frames from
varta_client::panic::install*hooks pin the nonce toNONCE_TERMINALand the status toStatus::Critical. This is the unique on-wire marker for a panic-terminated agent.
Frame::decode enforces the wire-side invariant
nonce == NONCE_TERMINAL ⇒ status == Status::Critical; any other status
paired with the sentinel is rejected as DecodeError::BadNonce. The
Kani harness in crates/varta-vlp/src/proofs.rs proves this for every
decodable byte pattern.
The converse — Status::Critical ⇒ nonce == NONCE_TERMINAL — is not
enforced. Agents legitimately emit Status::Critical at regular nonce
values for operational alerts (queue full, shedding load, etc.).
Downstream consumers (alert rules, log dashboards, recovery filters) that
need to distinguish “panic terminal” from “operational critical” must
inspect both status and nonce:
status | nonce | meaning |
|---|---|---|
Critical | NONCE_TERMINAL | panic-hook terminal frame |
Critical | any other value (including 0 after wrap) | operational critical alert |
Ok / Degraded | any value ≠ NONCE_TERMINAL | normal beat |
| any | NONCE_TERMINAL with status ≠ Critical | rejected at decode (BadNonce) |
Wrap to 0 is rare in practice (an agent emitting one beat per millisecond
takes ~584 million years to consume u64::MAX - 1), but it is structurally
correct and observable: an agent that does wrap will continue emitting
without observer ambiguity, because nonce 0 paired with Critical is
classified as “operational critical,” not “panic.” The Kani harness covers
the wire boundary; the wrap arithmetic itself lives in varta-client and
is straight-line code in Varta::beat.
v0.2 wire integrity (CRC-32C)
Bytes 28..32 carry a CRC-32C (Castagnoli, polynomial 0x1EDC6F41,
init 0xFFFFFFFF, reflected, output-XOR 0xFFFFFFFF) computed over
bytes 0..28. The CRC catches:
- Non-ECC RAM bit flips and cosmic-ray single-event upsets on the agent or the observer host.
- NIC firmware corruption between RX queue and userspace.
- In-process memory corruption between
Frame::encodeand the transport write (or between the transport read andFrame::decode), including the gap betweencrypto::seal/crypto::openand the frame-level codec on the secure-UDP transport. AEAD tag failures surface separately ascrypto::AuthError; the CRC is the defence-in-depth catch for everything that AEAD does not (in-process corruption on either side of the seal/open boundary).
Decode order is fixed: magic → version → CRC → status → pid → timestamp → nonce. CRC verification sits between version and field-range checks so
random bytes from a wrong-protocol sender still surface as BadMagic /
BadVersion (preserving the “this isn’t even VLP” diagnostic) while a
single-bit-flipped status byte surfaces as BadCrc, never as a valid
frame with the wrong meaning.
Implementation: crates/varta-vlp/src/crc32c.rs carries a const-fn 256-entry
lookup table; per-frame cost is ~28 cycles (~9 ns on Apple Silicon). Hardware
CRC-32C is available on x86_64 (SSE 4.2) and ARMv8.1+ via core::arch
intrinsics; a future target_feature cfg can drop the cost to ~1 cycle
without changing the wire format.
The payload field shrank from u64 (v0.1) to u32 (v0.2) to make room
for the CRC trailer inside the 32-byte budget. Agents needing more than
4 bytes of context should externalize the data and reference it from the
payload (e.g. as a slot index into a shared ring buffer).
The two compile-time assertions in crates/varta-vlp/src/lib.rs lock this in:
#![allow(unused)]
fn main() {
const _: () = assert!(core::mem::size_of::<Frame>() == 32);
const _: () = assert!(core::mem::align_of::<Frame>() == 8);
}
A drift in field order, padding, or width breaks the build. The integration
test frame_round_trip_matches_golden_bytes cross-checks a hand-computed
golden byte array against Frame::encode, so the layout is also pinned at
runtime.
Why #[repr(C, align(8))]
repr(C)pins field order to declaration order. Without it the compiler is free to reorder fields, which would silently break a wire format consumed by any tool that decodes by offset (includingvarta-watchitself).align(8)makes the struct’s start address 8-byte aligned, matching the natural alignment of the threeu64fields. The first 8 bytes (magic + version + status + pid) total exactly 8 bytes, so once the struct is 8-aligned theu64fields land on 8-byte boundaries with zero padding.size_oftherefore equals the sum of the field widths (32), and the const-assert proves it.- No
unsafeis required at the encode/decode boundary because we never transmute the struct to or from[u8; 32]. The body ofFrame::encodeandFrame::decodeis a sequence ofto_le_bytes/from_le_bytescalls against fixed-length array slices, all of which are checked at the type system level.
Why little-endian on the wire
- Every tier-1 target Varta will plausibly run on (x86_64, aarch64) is
little-endian natively, so
to_le_bytesis a no-op copy on the hot path. - Even on a hypothetical big-endian target the cost is one
bswap-class instruction per integer field — a rounding error against UDS write/read. - Pinning byte order in the spec means a frame captured on one host can be
decoded byte-for-byte on another, which keeps the
varta-watchrecovery command testable in isolation.
Why zero-dependency
- The protocol crate is the foundation everything else links against. Any
registry crate it pulls in (
bytes,byteorder,zerocopy, …) becomes a transitive obligation for every agent that wants to integrate Varta. Keeping[dependencies]empty preserves the “drop in one path dep, get health signaling” contract. - The whole crate is a struct, an enum, and four free functions. There is
nothing here that
coredoes not already provide. - Empty deps also keep the audit surface minimal: the only
unsafein the workspace will live invarta-clientandvarta-watch(where required for UDS plumbing), never in the protocol crate itself.
Cross-references
- Acceptance contract:
docs/acceptance/varta-v0-1-0.md - Crate root:
crates/varta-vlp/src/lib.rs - Integration tests:
crates/varta-vlp/tests/frame.rs
VLP Transports
Audience: Rust contributors. This page documents the Rust implementation of the VLP transport layer — trait shapes, feature-flag matrix, operational guidance. For the normative wire format of the base frame and the AEAD-wrapped secure frames, see the VLP specification and VLP secure-transport specification.
The Varta Lifeline Protocol (VLP) wire format is entirely transport-agnostic — a 32-byte,
8-byte-aligned #[repr(C)] frame. The transport layer is abstracted via traits that
allow swapping out the underlying socket type without modifying the protocol core.
Architecture
graph TD
VLP["<b>varta-vlp</b><br/>Frame (32 bytes) · Status · DecodeError<br/><i>Zero dependencies. Never changes.</i>"]
VLP --> CLIENT
VLP --> WATCH
subgraph CLIENT["varta-client"]
BT["BeatTransport"]
BT --> UdsT["UdsTransport"]
BT --> UdpT["UdpTransport<br/><i>(udp feat.)</i>"]
BT --> SecT["SecureUdpTransport<br/><i>(secure-udp feat.)</i>"]
end
subgraph WATCH["varta-watch"]
BL["BeatListener"]
BL --> UdsL["UdsListener"]
BL --> UdpL["UdpListener<br/><i>(udp feat.)</i>"]
BL --> SecL["SecureUdpListener<br/><i>(secure-udp feat.)</i>"]
end
Agent side (varta-client)
#![allow(unused)]
fn main() {
pub trait BeatTransport: Send + 'static {
fn send(&mut self, buf: &[u8; 32]) -> io::Result<usize>;
fn reconnect(&mut self) -> io::Result<()>;
}
}
Varta<T: BeatTransport> owns a transport and calls send(2) on every beat().
The default transport is UdsTransport (Unix Domain Socket). When the udp
feature is enabled, UdpTransport is available via Varta::connect_udp(addr).
When the secure-udp feature is enabled, SecureUdpTransport is available
via Varta::connect_secure_udp(addr, key) — every beat is encrypted with
ChaCha20-Poly1305 AEAD (RFC 8439).
Observer side (varta-watch)
#![allow(unused)]
fn main() {
pub trait BeatListener: Send + 'static {
fn recv(&mut self) -> RecvResult;
fn drain_decrypt_failures(&mut self) -> u64 { 0 } // default = 0
fn drain_truncated(&mut self) -> u64 { 0 } // default = 0
}
}
The Observer holds a Vec<Box<dyn BeatListener>> and polls all listeners
round-robin on each poll() call. When --udp-port is passed at the CLI,
a UdpListener is added alongside the UDS listener.
Transport comparison
| | UDS (default) | UDP (feature = “udp”) | Secure UDP (feature = “secure-udp”) |
|—|—|—|—|—|
| Addressing | Filesystem path | IP:PORT | IP:PORT |
| Encryption | None (kernel isolation) | None | ChaCha20-Poly1305 AEAD |
| Authentication | Kernel PID + UID via SO_PASSCRED / SCM_CREDS where available; socket permissions only on macOS pathname UDS | None | Poly1305 tag + PID in IV prefix (master-key mode) — wire-content only, not the sending process |
| Replay protection | None (local IPC) | None | Per-sender IV counter monotonicity |
| Trust model | Filesystem permissions + kernel credential attestation on supported kernels | Network segmentation | 256-bit pre-shared or per-agent derived key |
| Origin classification | KernelAttested on Linux / supported BSDs / illumos / Solaris; SocketModeOnly on macOS pathname UDS | NetworkUnverified | NetworkUnverified (cryptographic binding != kernel attestation) |
| Recovery-eligible by default? | Yes where kernel-attested; no on socket-mode-only targets | No (see [peer-authentication.md → Recovery eligibility]) | No (same gate; even master-key derivation cannot replace kernel attestation) |
| Frame size | 32 bytes | 32 bytes | 60 bytes (AEAD overhead) |
| Socket cleanup | UdsListener::drop unlinks socket | Kernel reclaims port | Kernel reclaims port |
| Use case | Local IPC, process monitoring | IoT/edge, microservices | Anything crossing untrusted networks |
Recovery-on-UDP is structurally rejected by default. Combining any recovery flag (
--recovery-exec/--recovery-exec-file) with--udp-portis a startup hard-error unless the operator passes the transport-qualified accept flag for that listener —--secure-udp-i-accept-recovery-on-unauthenticated-transportfor a secure-UDP listener, or--plaintext-udp-i-accept-recovery-on-unauthenticated-transportfor a plaintext one. That flag stamps the listener’s beatsOperatorAttestedTransport, which the runtime origin gate (Recovery::on_stall) accepts; without it, UDP beats stayNetworkUnverifiedand recovery is refused. It is the single switch — there is no separate runtime opt-in. Seebook/src/architecture/peer-authentication.mdfor the full threat model.
CLI additions
# Listen on UDS only (default)
varta-watch --socket /tmp/varta.sock --threshold-ms 500
# Listen on UDS + UDP (requires --features udp at build time)
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --udp-bind-addr 0.0.0.0
# UDP-only (no UDS)
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000
# UDP with ChaCha20-Poly1305 encryption
# Generate a 256-bit key (64 hex chars)
openssl rand -hex 32 > /tmp/varta.key
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --key-file /tmp/varta.key
# Rotation: accept old key while transitioning to new key
openssl rand -hex 32 > /tmp/varta-new.key
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --key-file /tmp/varta.key \
--accepted-key-file /tmp/varta-new.key
# Per-agent key derivation from master key
# The observer derives agent-specific keys from the PID embedded in
# each frame's iv_random prefix. Compromise of one agent's key does
# not reveal other agents' keys or the master key.
openssl rand -hex 32 > /tmp/varta-master.key
varta-watch --socket /tmp/varta.sock --threshold-ms 500 \
--udp-port 9000 --master-key-file /tmp/varta-master.key
The combined shared-key set from --key-file and --accepted-key-file
is capped at 8 keys. The observer trials every shared key on every secure-UDP
datagram to avoid leaking the active rotation slot through response timing, so
the cap is also a poll-loop work bound.
Feature flags
| Crate | Flag | Effect |
|---|---|---|
varta-vlp | crypto | Enables ChaCha20-Poly1305 AEAD (seal, open, Key). No_std-compatible — all four RustCrypto deps are default-features = false. |
varta-vlp | std | Opt-in std-dependent conveniences (Key::from_file, std::path::Path-typed helpers). Off by default so the crate is #![no_std] + alloc-free out of the box — ready for FreeRTOS/Zephyr targets. |
varta-client | udp | Enables UdpTransport, Varta::connect_udp(), install_panic_handler_udp() |
varta-client | secure-udp | Enables SecureUdpTransport, Varta::connect_secure_udp(); implies udp, varta-vlp/crypto, and varta-vlp/std (the secure_udp example calls Key::from_file). |
varta-watch | udp | Enables UdpListener, --udp-port / --udp-bind-addr CLI flags |
varta-watch | secure-udp | Enables SecureUdpListener, --key-file / --accepted-key-file / --master-key-file; implies udp-core |
varta-tests | udp | Enables UDP integration tests |
varta-bench | udp | Enables udp-latency benchmark subcommand |
Security
-
UDS: On Linux, the kernel attests the sender’s PID and UID via
SCM_CREDENTIALS. The observer rejects frames whereframe.pid != peer_pidorpeer_uid != observer_uid. Linux recovery eligibility also requires the observer to pin the sender’s/proc/<pid>/statstart-time generation before first contact can becomeKernelAttested; an unpinned first-contact beat is tracked asSocketModeOnly. On macOS pathname datagram sockets,LOCAL_PEERTOKENrequires a connected local socket and the observer falls back to--socket-mode 0600. On other platforms without per-datagram credentials, the only defence is--socket-mode. -
UDP (plaintext): No kernel credential mechanism exists.
peer_pidis always 0, which causes the observer to skip PID verification. Trust must be established at the network layer — firewall rules, VPC boundaries. -
UDP (secure): Every frame is encrypted with ChaCha20-Poly1305 (RFC 8439) using a 256-bit key. Primitives are provided by the
chacha20poly1305crate (RustCrypto, NCC Group audit 2020) — no hand-rolled crypto. Key derivation uses HKDF-SHA256 (RFC 5869) via thehkdf+sha2crates. Two key modes:- Shared key: A single pre-shared key for all agents (
--key-file). - Master key: Per-agent keys derived from the agent’s PID via HKDF-SHA256
(
--master-key-file). The PID is embedded in theiv_randomprefix so the observer can derive the correct agent key before decryption. Compromise of one agent’s key does not reveal other agents’ keys or the master key. Note: the HKDF-based KDF is incompatible with the ChaCha20-PRF KDF used in earlier releases — agents must re-key when upgrading from a pre-RustCrypto build if master-key mode was in use. - Replay attacks are blocked by enforcing monotonic IV counters per sender.
Key rotation is supported via
--accepted-key-file(no downtime required). - Panic-hook entropy:
install_panic_handler_secure_udpreads all IV material at install time and fails closed if the entropy chain (getrandom,getentropy,/dev/urandom) is unavailable. Forked-child panic prefixes are derived from that pre-read salt with HKDF, so the hook never calls the OS entropy chain. In chrooted environments without/dev, useinstall_panic_handler_secure_udp_accept_degraded_entropyto opt into a non-cryptographic fallback — seebook/src/architecture/peer-authentication.mdfor the full nonce-reuse risk analysis.
- Shared key: A single pre-shared key for all agents (
-
Recovery commands: Exec mode only (shell mode was permanently removed):
--recovery-exec: Command executed directly viaexecvp(2)with{pid}replaced in arguments; the pid is also appended as the final argument. No shell is involved.--recovery-exec-file: Read the program + args from a hardened file with mandatory ownership/permission checks (UID match, mode ≤ 0600).
Container / PID-namespace semantics
Frame.pid carries the agent’s PID in the agent’s PID namespace. The
observer’s kernel-attested peer PID (SO_PASSCRED / SCM_CREDS) is in the
observer’s namespace. When the two namespaces
differ:
- The pid in the frame cannot be used to identify a process the observer can
kill(2)orsystemctl restart— the same numeric PID refers to a different process in each namespace. - The existing
frame.pid == peer_pidcheck at observer ingress catches most cases (different namespaces usually produce different numeric pids), but same-pid collisions across containers (every container’s first process is PID 1) are invisible to that gate.
varta-watch therefore (Linux only):
- Reads
/proc/self/ns/pidonce at startup and caches the inode as the observer’s namespace identity. - For every kernel-attested beat (UDS), reads
/proc/<peer_pid>/ns/pidand compares the inode to the observer’s. Mismatch ⇒ drop the beat (varta_frame_namespace_mismatch_total++) and emitEvent::NamespaceConflict. - Per-pid tracker slots pin the namespace inode at first beat; a later beat
with a different
Some(_)inode is rejected asUpdate::NamespaceConflict(varta_tracker_namespace_conflict_total++). - Recovery commands refuse to spawn for cross-namespace stalls and log an
audit record with
reason=cross_namespace_agent(varta_recovery_refused_total{reason="cross_namespace_agent"}++).
Escape hatch — --allow-cross-namespace-agents
When agents are intentionally run with --pid=host (containers sharing the
host PID namespace), the observer’s namespace and the agents’ namespace agree
at the kernel level — the gate above is a no-op.
For deployments where the agent runs in a private namespace and the
operator has out-of-band PID translation (e.g. CNI metadata that lets a
recovery script translate container pids to host pids), pass
--allow-cross-namespace-agents. The audit log and metrics still fire, but
beats are admitted and recovery is permitted.
--strict-namespace-check
Treat namespace mismatch as a fatal startup error: on the first
Event::NamespaceConflict, the daemon logs a FATAL line and exits with a
non-zero status. Used in environments where the operator wants the daemon to
fail loudly rather than silently log audit refusals.
Non-Linux platforms
PID namespaces are a Linux kernel concept. On macOS and the BSDs,
observer_pid_namespace_inode() returns None and all comparisons
short-circuit to “match”. The CLI flags are accepted for portability but
have no runtime effect.
UDP transports
UDP listeners (plain or secure) have no kernel peer-cred mechanism.
peer_pid is 0; peer_pid_ns_inode is None. Recovery is already refused
for NetworkUnverified origins by the existing transport gate — namespace
mismatch adds nothing for UDP. See
peer-authentication.md for the full trust model.
Secure UDP — replay-state capacity boundary (H4)
SecureUdpListener keeps per-sender replay state in a bounded table
indexed by the authenticated VLP frame PID:
- Capacity:
MAX_SENDER_STATES = 1024simultaneously-tracked senders. - Known senders can advance their replay state even while the table is full.
- Unknown senders are accepted only if a stale-sender sweep frees a slot.
- If the table remains full, the authenticated frame is consumed and refused; no live sender’s replay state is evicted to admit it.
This is a fail-closed replay posture. A reachable network can still create an availability event by sending authenticated traffic for enough unique PIDs to fill the table, but it cannot make the listener forget an existing sender’s last counter and then accept a captured older ciphertext for that sender.
Why the table refuses instead of evicting
Any finite eviction shadow can be rotated out by enough authenticated chaff. The only replay-safe bounded behavior is to preserve live replay state and refuse new identities at capacity. This trades admission of new senders for nonce monotonicity of already-tracked senders, which matches the safety profile used elsewhere in Varta: capacity pressure is visible and alertable, but replay state is not sacrificed silently.
Mitigation
varta-watch defaults --udp-bind-addr to 127.0.0.1 when secure-UDP
keys are configured. Operators who genuinely need the listener to accept
non-loopback peers must pass --i-accept-secure-udp-non-loopback
explicitly — a CLI flag whose name signals the residual availability risk.
When the flag is set, a high-visibility startup warning is emitted to stderr
and the operator is expected to constrain network reach (firewall, private
VLAN, mTLS-fronted tunnel) so that no untrusted host can reach the bound port.
The recovery gate on NetworkUnverified origins (see
peer-authentication.md) remains independent
of this flag — opting in to non-loopback secure-UDP does NOT enable
recovery commands from UDP-origin beats. Those still require the
separate
--secure-udp-i-accept-recovery-on-unauthenticated-transport
acknowledgement.
Secure UDP — session-restart replay window (H5)
SecureUdpListener resets a sender’s per-PID replay high-water when a
recycled PID legitimately restarts its session. A process that dies and has
its PID reused by a fresh agent reconnects with a new session salt, so its
IV prefixes and VLP nonce sequence restart from the beginning; after
SESSION_RESTART_GAP (= --threshold-ms) of silence the listener treats the
first frame on an aged-out prefix as a session restart and resets the
per-sender max_regular_nonce so the new low-nonce session is admitted.
That reset opens a bounded replay window. An on-path attacker who captured a frame from the dead session — one whose nonce is higher than the recycled session’s reset baseline; no key is needed to resend a captured ciphertext — can replay it in the window after the reset. Because the captured nonce exceeds the reset baseline it is accepted, forging a single liveness beat attributed to the recycled PID (and advancing the high-water to the replayed value).
Bounded by. The attack requires all of: (a) a captured dead-session frame,
(b) the PID to be recycled, (c) SESSION_RESTART_GAP of silence, and (d) the
replay to land before the recycled agent’s own nonce climbs past the captured
value. The forged beat carries a NetworkUnverified origin (or, under the
explicit opt-in, OperatorAttestedTransport): recovery commands remain gated
behind --secure-udp-i-accept-recovery-on-unauthenticated-transport and are
not triggerable by a replayed beat alone. The residual impact is therefore
forged liveness (a recycled PID briefly appearing alive) plus a poisoned
nonce high-water — not arbitrary command execution.
Root cause. Nonce-based replay protection cannot distinguish a recycled agent’s fresh-but-low-nonce session from a replayed high-nonce frame of the dead session, because the recycled agent derives a new session salt (and thus new IV prefixes) that the observer cannot bind to: VLP v0.2 carries no wire-level session/epoch identifier. Closing the window fully requires a protocol change — a per-session epoch in the frame that the observer binds into replay state — so it is deferred to a future VLP version rather than patched in the listener. Any listener-only heuristic either re-opens the window or rejects a legitimately fast-beating recycled agent (its nonce can outpace any fixed post-reset jump bound), so no clean implementation-only fix exists.
Mitigation. The loopback-default binding (H4) and the recovery-origin gate
already constrain who can reach the port and what a replayed beat can do.
Operators who require strict cross-session replay rejection should keep
secure-UDP loopback-only or front it with an authenticated tunnel; a shorter
--threshold-ms narrows (but does not eliminate) the window.
Fork-safety on secure-UDP
After fork(2), a child process inherits its parent’s
SecureUdpTransport state — the 16-byte iv_session_salt, the
iv_prefix_index, and the iv_counter. Three nominally-independent
fields whose product defines the AEAD nonce. If the child ever calls
Varta::beat() without intervention, it derives the same 12-byte
ChaCha20-Poly1305 nonce its parent has already emitted under the same
key — a catastrophic confidentiality and integrity failure (Poly1305
key recovery, plaintext XOR leak).
How Varta enforces fork-safety structurally
Varta::connect snapshots both std::process::id() and a process-lineage
epoch maintained by a one-time pthread_atfork child callback. Every
Varta::beat reads the current PID and epoch and compares both — on either
mismatch, the wrapper invokes transport.reconnect() before building the
frame. The epoch changes on every fork and therefore remains distinct even
if a later descendant is assigned the original connect-time PID.
SecureUdpTransport::reconnect() re-reads OS entropy into a fresh
16-byte session salt, recomputes the IV prefix, and resets the prefix
index and counter to zero. The child’s first emitted frame therefore
uses an IV prefix derived from independent entropy — nonce collision
across the fork boundary is impossible.
Auto-recovery is silent: the caller observes BeatOutcome::Sent. The
event is observable via Varta::fork_recoveries() -> u64 (suggested
Prometheus name: varta_client_fork_recoveries_total). The local
session epoch resets too — nonce → 0, start → Instant::now(),
last_timestamp → 0, consecutive_dropped → 0 — so the child’s
wire stream looks like a fresh session to the observer.
Observer view
The observer’s per-sender state in SecureUdpListener is keyed by the
authenticated VLP frame PID, with a 1-deep IV-prefix history per sender
(see H4 replay-state capacity
above). When the forked child sends frames with a new IV prefix, the
observer transitions its current state into the prev_* slots and accepts
the new prefix as a fresh session — no replay error, no protocol-level
signal required. Fork-recovery is entirely transparent to the wire format.
Advanced callers
Callers using SecureUdpTransport directly receive the same protection.
The transport snapshots the process-lineage epoch at construction and checks
it before every seal, reconnecting before inherited AEAD state can be used in
a child.
Parent-pid stall window (transport-agnostic)
Auto-recovery handles the child. The parent does not get a free pass:
if the parent forks and then exit(0)s (the daemonise pattern), its PID
disappears from the kernel but the observer’s tracker slot for that PID
keeps aging. After --threshold-ms the slot stalls; if recovery is
configured for kernel-attested origins, the observer may fire a recovery
command for a PID that no longer exists. This applies to every transport
(UDS, plaintext UDP, secure UDP) — it is a property of the
silence-equals-stall contract, not of any particular wire format.
The fix is on the agent side. The recommended pattern is to emit a final
Status::Critical beat from the parent immediately before its terminal
exit() — the observer records the critical frame and treats subsequent
silence as expected closure rather than as a stall. See
crates/varta-client/README.md — Fork recovery & tracker semantics
for the operator-side patterns and the alternative --threshold-ms
widening approach.
Panic-hook parallel
install_panic_handler_secure_udp caches an 8-byte IV prefix plus a
16-byte fork salt at install time to avoid non-async-signal-safe entropy
reads inside the panic hook itself. The same fork hazard applies: a child
that panics would otherwise emit (cached_iv, iv_counter=0) — colliding
with the parent’s identical pair if the parent panicked too. The installer
snapshots install_pid; if the hook later sees a different PID, it derives
a child-specific IV prefix with HKDF-SHA256 over the pre-read fork salt,
the panic PID, the panic timestamp, and the AEAD counter. No getrandom,
getentropy, or /dev/urandom call happens from the hook body. The strict
variant fails closed at install time when no entropy source is reachable;
the accept-degraded-entropy variant falls back to fallback_iv_random()
and fallback_iv_session_salt() per the documented degraded-entropy policy.
Cross-references
- Observer liveness — the watcher’s own liveness story: in-process self-watchdog, systemd
sd_notify, hardware watchdog, and paired-observer pattern - Safety profiles — compile-time vs. runtime feature gating for production-safe builds
- Peer authentication — kernel-level PID attestation and transport trust classification
- Namespaces — dedicated reference for cross-namespace deployments
Future transports
Additional transports can be implemented by implementing BeatTransport (agent
side) and BeatListener (observer side) without touching the protocol core:
- Shared memory (
memfd,shm) — Wasm plugins writing directly to a shared ring buffer - Unix pipes (
pipe,fifo) — stdin/stdout health frames for supervised processes - WebSocket — for browser-based health dashboards
Varta Threat Model
This document outlines the formal threat model for the Varta health monitoring protocol. Varta is designed for high-assurance, zero-overhead health signalling in distributed systems and local IPC.
1. Scope and Assets
Scope
The scope of this threat model includes:
- varta-vlp: The 32-byte wire protocol and its cryptographic primitives.
- varta-client: The agent library integrated into application processes.
- varta-watch: The observer daemon that tracks agent health and triggers recovery.
- Transports: Unix Domain Sockets (UDS) and User Datagram Protocol (UDP).
Assets
- Agent Liveness State: The true health status of an agent process.
- Recovery Commands: The ability to execute privileged operations (e.g.,
systemctl restart) based on agent health. - Master/Session Keys: Cryptographic material used to secure UDP heartbeats.
- Metrics Data: Operational visibility provided via the Prometheus
/metricsendpoint. - System Availability: The continued operation of the observer itself.
2. Trust Boundaries
Varta operates across several trust boundaries:
| Boundary | Description |
|---|---|
| Agent / Observer (Local) | Communication over UDS on the same host. Trust is rooted in the Kernel (PID/UID attestation). |
| Agent / Observer (Network) | Communication over UDP (Plain or Secure). Trust is rooted in Cryptography (AEAD) or Network Segmentation. |
| Observer / Metrics Scraper | Communication over HTTP. Trust is rooted in Bearer Token authentication. |
| Observer / System | The interface where the observer executes recovery commands or writes audit logs. |
3. Threat Analysis (STRIDE)
S: Spoofing (Impersonating an Agent)
- Threat: An attacker process sends forged heartbeats for a target PID to hide a stall or trigger a false recovery.
- Mitigation (UDS): Kernel PID Attestation where the OS supports it. The observer uses
SO_PASSCRED(Linux) orSCM_CREDS/SCM_UCRED(supported BSD-family and illumos/Solaris targets) to verify the sender’s real PID. macOS pathname UDS is socket-mode-only and recovery-ineligible. - Mitigation (Secure UDP): ChaCha20-Poly1305 AEAD. Every frame is cryptographically signed. Master-key mode derives per-agent keys from PIDs to prevent cross-agent spoofing.
- Mitigation (Network): UDP heartbeats are tagged
NetworkUnverifiedand are ineligible for recovery commands by default.
T: Tampering (Modifying Heartbeats)
- Threat: An attacker modifies a heartbeat in transit to change its status (e.g., changing
OktoCritical). - Mitigation (Wire): CRC-32C Integrity. Every frame includes a CRC-32C trailer to catch bit flips and in-process corruption.
- Mitigation (Secure UDP): Poly1305 MAC. Any modification to an encrypted frame causes a decryption failure, and the frame is dropped.
R: Repudiation (Audit Log Evasion)
- Threat: A recovery action occurs, but there is no record of why or which agent triggered it.
- Mitigation: Opt-in Recovery Auditing. When
--recovery-audit-file <PATH>is configured, all recovery actions — including refusals, kills, and reaps — are logged to a structured TSV with kernel-attested PIDs where available. Theaudit-chainfeature adds SHA-256 hash chaining for tamper evidence. Without the flag, recovery actions are visible only via the Prometheusvarta_recovery_outcomes_total/varta_recovery_refused_totalcounters. For high-assurance deployments the audit file is strongly recommended and is required for IEC 62304 / DO-178C-grade installations.
I: Information Disclosure (Leaking Secrets)
- Threat: Cryptographic keys or Prometheus tokens are leaked via environment variables or insecure file permissions.
- Mitigation: Secret-File Hardening. Varta refuses to load keys from environment variables. Key files must be owned by the observer UID and have
0600permissions. - Mitigation (Memory): Zero-on-Drop. The
Keytype zeros its memory before being released. Panic hooks use a single-ownerBoxto minimize secret lifetime.
D: Denial of Service (Exhausting Observer Resources)
- Threat: An attacker floods the observer with connection requests or malformed frames to prevent it from monitoring legitimate agents.
- Mitigation (Metrics): Multi-layer DoS Protection. Rate limiting per source IP, connection budgets, and constant-time token comparison for the
/metricsendpoint. - Mitigation (Wire): Zero-Allocation Hot Path. The observer processes frames without allocating on the steady-state path, preventing heap exhaustion.
E: Elevation of Privilege (Exploiting Recovery)
- Threat: An attacker triggers a recovery command that executes an arbitrary shell script or targets a process outside its namespace.
- Mitigation: Exec-Only Recovery. Shell-mode recovery is removed. Commands are executed directly via
execvp(2)with no shell interpolation. - Mitigation (Namespace): Linux PID Namespace Gating. The observer verifies that the agent belongs to the same PID namespace before permitting recovery.
- Mitigation (Environment): Isolated Recovery Environment. Recovery children run with a sanitized, minimal environment to prevent
LD_PRELOADorPATHattacks.
4. Security Boundaries & Mitigations Summary
The “Kernel-First” Trust Model
For local IPC, Varta trusts the kernel over the wire format. A frame’s pid field is only used if the kernel attests that the sending process actually owns that PID.
Cryptographic Identity (Secure UDP)
For network communication, Varta uses a 256-bit key-based identity.
- Forward Secrecy: Deliberately not provided. A one-way unauthenticated-receiver heartbeat protocol has no handshake in which to negotiate ephemeral keys; adding a DH ratchet would require multi-round-trip session establishment, which contradicts Varta’s connectionless beat-and-forget model. Key rotation via
--accepted-key-file(multiple accepted keys, time-bounded rollover) is the recommended mitigation; see Peer Authentication for the full key-loading model and rotation procedure. - Replay Protection: Enforced via monotonic IV counters per sender.
Recovery Safety Gates
Recovery is the most privileged action Varta performs. It is guarded by:
- Origin Gating: Recovery is disabled for
NetworkUnverified(UDP) sources unless explicitly enabled with verbose CLI flags. - Platform Gating: On platforms without kernel PID attestation (e.g., OpenBSD), recovery is disabled.
- Execution Safety: Commands are never passed to a shell.
5. Residual Risks
- Compromised UID (Local): If an attacker gains the same UID as the observer, they can read the secret keys and potentially bypass socket-mode permissions.
- Master Key Leak: A leak of the master key allows an attacker to derive all agent keys and spoof any agent on the network.
- Clock Skew (UDP): Varta uses monotonic timestamps, but significant clock drift or resets on the agent side can lead to rejected heartbeats or false stall detections.
- Namespace Mapping: In complex container environments, PID 1 in a container may map to a different host PID.
varta-watchprovides PID-namespace gating via its own--allow-cross-namespace-agentsand--strict-namespace-checkflags. For multi-container recovery the observer container typically needs the runtime’s host-PID share (e.g. Docker/podman--pid=host, KuberneteshostPID: true) so that recovery targets and the observed PID namespace agree; otherwise namespace mismatches cause beats and recovery to be refused. See Namespacing. - No Forward Secrecy: As a one-way protocol, Varta does not provide forward secrecy. If a key is compromised, all past traffic encrypted with that key can be decrypted if captured.
Observer Liveness — “Who Watches the Watcher?”
varta-watch is the single observer for all agents on a host. If it crashes
or its poll loop hangs, no agent gets a Stall event and no recovery fires —
the entire monitoring layer fails silently. For life-support deployments this
is the most critical functional gap.
This document describes four independent, layered defenses. Deploy as many as your environment supports; each catches failure modes the others cannot.
Threat model
| Failure mode | L1 | L2 | L3 | L4 |
|---|---|---|---|---|
| Poll loop hangs (stuck in I/O or computation) | ✓ | ✓* | ✗ | ✓ |
| Process crash (SIGSEGV, stack overflow, OOM) | ✗ | ✓ | ✓† | ✓ |
| Watchdog thread dies silently (panic, signal) | ✗ | ✓‡ | ✓† | ✓ |
| Kernel hang / host deadlock | ✗ | ✗ | ✓ | ✗ |
| Misconfiguration (wrong socket path, wrong user) | ✗ | ✗ | ✗ | ✓ |
*systemd detects a hang only if WATCHDOG=1 stops arriving; the self-watchdog
ensures that also stops when the loop wedges.
†hardware watchdog fires when the kick loop stops; process crash achieves this.
‡since H5 the watchdog thread is the sole source of WATCHDOG=1; if it
dies, the emission stream stops and systemd’s WatchdogSec= fires.
L1 — In-process self-watchdog (--self-watchdog-secs)
A background thread checks that the main poll loop has ticked at least once
within the configured deadline. If not, it calls process::abort().
varta-watch --self-watchdog-secs 4 ...
-
The background thread is the only non-main thread in the binary. The beat path and observer loop remain single-threaded.
-
process::abort()produces SIGABRT, which appears injournalctl, enables core dumps, and triggersRestart=on-abortin systemd units. -
The deadline should be set to roughly 2× the expected worst-case poll latency (typically
--threshold-ms+ reaping time). -
Per-stage wedge detection (H6): in addition to the full-iteration check, the watchdog reads two atomics written by the main thread —
CURRENT_STAGE(which of the six loop phases is running) andLAST_STAGE_ENTRY_NS(monotonic ns at which that phase started). Each stage has an independent hard abort threshold inSTAGE_ABORT_NS(≥ 5× the stage’s soft budget):Stage Hard abort threshold drain_pending2 s poll2 s maintenance500 ms recovery_reap1 s serve_pending2 s housekeeping1 s A stage wedge (e.g. an
fdatasyncblocking indefinitely, or a singlewaitpidhanging) trips the per-stage threshold long before the full-iteration watchdog fires. The watchdog logs which stage wedged and aborts. Between iterationsCURRENT_STAGEis set tou8::MAX(idle sentinel) so the per-stage check is suppressed during throttle sleeps. -
H5 (post-2026-05-13): the watchdog thread is ALSO the sole emitter of systemd
WATCHDOG=1. Emission used to live on the main loop, which left a silent-failure window: if the watchdog thread died while the main loop remained healthy,WATCHDOG=1kept arriving from the main thread and systemd had no way to notice the in-process abort path was already gone. NowWATCHDOG=1emission is moved to the watchdog thread (via adup(2)-ed copy of the notify socket carved offSdNotifywithtake_watchdog_notifier). If the thread dies, the emission stream stops andWatchdogSec=fires. This is the only design where systemd can detect a dead watchdog while the main loop is still alive. -
Auto-enable: when
$WATCHDOG_USECis set by the service manager and--self-watchdog-secsis not passed, the watchdog thread is spawned unconditionally with a 4 s deadline. Operators with tighterWatchdogSec=values can override via the CLI. This collapses the L1+L2 layers structurally: enablingWatchdogSec=in the unit automatically buys both the in-process abort path and the WATCHDOG=1 emission stream.
L2 — systemd sd_notify watchdog integration
varta-watch speaks the sd_notify(3) protocol natively. Set
Type=notify in the service unit and configure WatchdogSec=:
[Service]
Type=notify
NotifyAccess=main
WatchdogSec=5s
Restart=on-watchdog
RestartSec=1s
TimeoutStartSec=10s
ExecStart=/usr/bin/varta-watch \
--socket /run/varta/agents.sock \
--threshold-ms 5000 \
--self-watchdog-secs 4 \
--hw-watchdog /dev/watchdog \
--heartbeat-file /run/varta/heartbeat
varta-watch sends:
READY=1after observer bind succeeds and all listeners are attachedWATCHDOG=1everyWATCHDOG_USEC / 2microseconds while the poll loop runsSTOPPING=1when the SHUTDOWN latch flips
If WATCHDOG=1 stops arriving, systemd kills and restarts the process. This
catches both crashes (no more sends) and hangs (LAST_TICK_NS stops advancing,
the self-watchdog aborts, systemd restarts).
$NOTIFY_SOCKET and $WATCHDOG_USEC are passed automatically by systemd;
no extra flags are needed.
L3 — Hardware watchdog (--hw-watchdog)
On hosts with a kernel hardware watchdog (e.g. /dev/watchdog), varta-watch
can kick it once per poll iteration. If the kick stops, the kernel reboots the
host — even if the OS itself is wedged.
varta-watch --hw-watchdog /dev/watchdog ...
At startup, varta-watch verifies that the opened descriptor is a character
device. A regular file, FIFO, or socket is rejected rather than silently
accepting writes while providing no watchdog protection.
On Linux builds with a pinned watchdog ioctl ABI (x86_64, aarch64, and
riscv64 today), varta-watch also verifies that the descriptor implements the
standard watchdog ioctl API and has crash-close semantics. It reads
WDIOC_GETSUPPORT and accepts the device only when it advertises
WDIOF_MAGICCLOSE or sysfs reports nowayout=1 for the same character
device. It then reads WDIOC_GETTIMEOUT, and if the current timeout is below
30 s it requests 30 s with WDIOC_SETTIMEOUT. Startup fails if the device is
not a watchdog, close behavior cannot be proven safe, the timeout cannot be
read, the kernel clamps the timeout below the 30 s floor, or the Linux
target’s ioctl encoding has not yet been pinned.
Magic close: on a clean shutdown (SIGTERM/SIGINT followed by graceful exit)
varta-watch writes the magic byte 'V' to disarm the watchdog before
exiting. A crash or hang leaves the watchdog armed; the kernel reboots after
its timeout. If startup validation rejects a device after opening it,
varta-watch also best-effort writes 'V' before returning the startup error
so a clean configuration failure does not leave an already-opened watchdog
running. On Linux nowayout=1 devices the kernel deliberately overrules magic
close; varta-watch accepts that mode because crash/hang protection is
stronger, but a clean service stop cannot disarm the hardware watchdog.
The /dev/watchdog device is typically root-owned (mode 0600). Run
varta-watch as root or grant the CAP_SYS_ADMIN capability, or use a
watchdog daemon (e.g. watchdog(8)) for the actual device management.
L4 — Paired observers (operational)
A second monitoring process scrapes the first observer’s liveness signals and
restarts it if they stall. This requires no code changes — use the existing
--heartbeat-file and /metrics primitives.
Heartbeat-file poller
#!/bin/sh
HEARTBEAT=/run/varta/heartbeat
while :; do
prev=$(awk '{print $1}' "$HEARTBEAT" 2>/dev/null || echo 0)
sleep 5
cur=$(awk '{print $1}' "$HEARTBEAT" 2>/dev/null || echo 0)
if [ "$cur" -le "$prev" ]; then
logger -t varta-watchdog "heartbeat stalled (loop_count=$prev); restarting"
systemctl restart varta-watch
fi
done
The first field in the heartbeat file is a monotonically increasing loop
counter. If it stops advancing, the observer is wedged or dead. Each update
uses an exclusively-created mode-0600 tempfile plus an atomic same-directory
rename, so stale files and symlinks are never opened or truncated. The parent
directory should still be writable only by the observer account.
Prometheus uptime scraper
/metrics exposes varta_watch_uptime_seconds. A second Prometheus instance
(or Alertmanager rule) can alert when the gauge stops increasing. The
canonical VartaWatchStalled rule ships in
observability/alerts/varta.rules.yml;
see Monitoring & Alerting
for the operator-facing rationale.
Threading note
--self-watchdog-secs spawns one background thread. This is the only
non-main thread in the varta-watch binary, and that property is a
load-bearing architectural invariant, not an accident. All agent beat
processing, stall detection, recovery spawning, and Prometheus serving happen
on the main thread. The watchdog thread reads two atomics (SHUTDOWN
and LAST_TICK_NS), calls process::abort() on wedge, and writes
WATCHDOG=1 to its own dup(2)-ed UnixDatagram fd; it never touches
shared mutable state. The dup-ed fd is independent kernel state — both
threads own their own descriptor and there is no synchronisation between
them on the notify path.
The single-threaded design is what lets the project preserve its zero-alloc,
ABI-stable beat contract: a beat is decoded into a stack-allocated
[u8; 32] and dispatched through the per-pid tracker without locking,
because nothing else holds a reference. Moving any phase of the loop to a
second thread would require a lock-free SPSC ring between threads at the
ingress and break that contract. Stall-detection latency under scrape load
is instead bounded by an explicit per-iteration latency budget — see below.
Why /metrics is on the poll thread
“Doesn’t scrape latency variance steal time from beat ingestion?”
It can, by up to ~200 ms per iteration — the structural cap of
PromExporter::serve_pending (100 ms serve deadline + 100 ms drain
deadline, see exporter.rs). The obvious mitigation is to spawn a second
thread that owns serve_pending and reads tracker state through a shared
snapshot. We deliberately do not do this. Three reasons:
- The beat path would acquire a lock on every tick. Whether via
Arc<Mutex<PromExporter>>or an SPSC snapshot ring, every record-side counter increment (pe.record_beat(...),pe.record_stall(...),pe.record_loop_tick(...)etc.) becomes either a mutex acquisition or a single-producer write into a wait-free queue. Neither is zero-overhead on the hot path, and both introduce per-architecture memory-ordering questions that the current&mut selfmodel eliminates by construction. - The zero-allocation invariant becomes harder to enforce. The beat
path is currently zero-alloc post-
connect, enforced by thevarta-testsguard allocator. A snapshot ring requires either a pre-sized arena (more state on the hot path) or per-snapshot allocation (kills the invariant). Both are worse than what we have. - The variance is already bounded and now observable. Scrape work
per iteration is capped at ~200 ms by
PROM_READ_DEADLINE = 10 ms,PROM_MAX_CONNECTIONS_PER_SERVE = 8,PROM_MAX_DRAIN_PER_SERVE = 50, the 100 ms serve deadline, and the per-IP token bucket. Operators see the variance throughvarta_observer_serve_pending_seconds(new — see “Observing scrape-induced latency” below); beat-path latency isiteration_seconds - serve_pending_secondsin PromQL.
Scrape-storm alarms and beat-path alarms therefore route off different metrics, and the load-bearing single-thread invariant is preserved.
Latency budget — worst-case poll iteration time
A bounded iteration time guarantees a bounded stall-detection latency. The
table below names the phases of the poll loop in main.rs and the
upper-bound source for each:
| Phase | Worst case | Source / constant | Observable as |
|---|---|---|---|
| 1. Drain queued stall events | O(queue)·~1 µs | Observer::poll_pending — one stack pop per call | varta_observer_stage_seconds{stage="drain_pending"} |
2. Observer::poll() (one recv each) | ≤ read_timeout·N | UDS recv(2) blocks up to --read-timeout-ms (default 100 ms) per listener; UDP listeners are non-blocking | varta_observer_stage_seconds{stage="poll"} |
| 3. Maintenance: counter drains + audit ring flush | ≤10 ms | Constant counter work + flush_pending(10 ms budget) draining the 256-line audit ring to BufWriter+fdatasync | varta_observer_stage_seconds{stage="maintenance"} |
4. Recovery::try_reap | ~64 µs | ≤64 waitpid(2, WNOHANG) syscalls; rotating cursor (bounded outstanding-pids fan) | varta_observer_stage_seconds{stage="recovery_reap"} |
5. PromExporter::serve_pending | ≤200 ms | 100 ms serve deadline + 100 ms drain deadline (see exporter.rs) | varta_observer_stage_seconds{stage="serve_pending"} + independent varta_observer_serve_pending_seconds histo |
| 6. Heartbeat-file write + watchdog kicks | <6 ms | write_heartbeat_atomic (rename) + one sendmsg(2) + one write(2) | varta_observer_stage_seconds{stage="housekeeping"} |
| Iteration total (worst case) | ~310 ms | UDS read_timeout (100 ms) + serve_pending (≤200 ms) + maintenance ≤10 ms + small fixed work | varta_observer_iteration_seconds |
Two observations the table makes explicit:
- The UDS read-timeout is the idle floor: with no incoming beats and no
scrape pressure, every iteration costs about
read_timeout. This is intentional — it yields CPU between recvs without busy-spinning. Lower the floor by lowering--read-timeout-ms, at the cost of a tighter idle poll loop. - The worst-case active iteration is bounded by
read_timeout + serve_pending, sincerecv(2)returns early as soon as a frame arrives andserve_pendingis the only other phase that can spend more than a few milliseconds.
The default soft budget is 250 ms (--iteration-budget-ms). Iterations
exceeding it increment varta_observer_iteration_budget_exceeded_total and
are visible in the varta_observer_iteration_seconds histogram. The budget
is advisory: hard wedges (seconds, never returning) remain the responsibility
of --self-watchdog-secs.
The idle sleep at the end of an iteration with no pending I/O (10 ms) is excluded from the histogram. Idle time is a throttling primitive, not work latency; including it would mask the bad iterations.
Tuning relationship
For a given --threshold-ms T, stall-detection latency is bounded by
T + per_iteration_worst_case. With defaults
(--threshold-ms 5000, --read-timeout-ms 100, default serve_pending bounds)
the worst case is ~310 ms, so a stalled agent surfaces no later than
~5.31 s after its last beat.
The soft --iteration-budget-ms (default 250 ms) sits between the typical
case (~100 ms idle floor) and the worst case (~310 ms under scrape storm)
so the budget-exceeded counter fires only during real scrape pressure, not
on every active iteration. Operators with higher --read-timeout-ms or
multiple listeners should raise the budget proportionally
(budget ≥ read_timeout × N_listeners + 150 ms).
--self-watchdog-secs should be set such that
self_watchdog_secs × 1000 ≥ 4 × iteration_budget_ms so transient overruns
during scrape bursts do not trigger false-positive aborts. The default
guidance (--self-watchdog-secs 4 with --iteration-budget-ms 250) gives a
16× margin (4000 ms ÷ 250 ms), well above the worst-case ratio.
Observing scrape-induced latency
Three metrics together let an operator separate scrape pressure from beat-path slowness:
varta_observer_iteration_seconds— wall time for the entire poll iteration (drain → poll → maintenance → recovery reap → serve_pending → heartbeat write → watchdog kicks). Bucketed by[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, +Inf]. Includes serve_pending — unchanged contract.varta_observer_serve_pending_seconds— wall time for theserve_pendingphase alone. Same bucket boundaries asiteration_secondsso the two are coherent. Configurable budget via--scrape-budget-ms(default 250 ms); values below the built-in structural cap also bound live scrape work, and overruns incrementvarta_observer_scrape_budget_exceeded_total.varta_observer_iteration_budget_exceeded_total— iterations exceeding--iteration-budget-ms(default 250 ms). Includes serve_pending time.
Beat-path latency is then a PromQL expression — the difference between iteration time and serve-pending time:
# P99 beat-path latency = P99(iteration_seconds) − P99(serve_pending_seconds).
# Note: subtracting quantiles is approximate (P99 of diff ≠ diff of P99s),
# but in practice serve_pending and the rest of the iteration are weakly
# correlated, so the approximation is monotonic with the true beat-path
# latency. Use sum_by-(le) rate() if you want exact derived histograms
# (compute beat_path_seconds in a recording rule from the two histos).
histogram_quantile(0.99,
sum by (le) (rate(varta_observer_iteration_seconds_bucket[5m])))
- histogram_quantile(0.99,
sum by (le) (rate(varta_observer_serve_pending_seconds_bucket[5m])))
Alarms that should fire on beat-path slowness route off
iteration_seconds - serve_pending_seconds or off
iteration_budget_exceeded_total minus scrape_budget_exceeded_total
when scrape overruns dominate the budget overruns.
Alarms that should fire on scrape-storm pressure route off
scrape_budget_exceeded_total and serve_pending_seconds quantiles
directly.
Per-stage histograms (varta_observer_stage_seconds)
Each of the six loop phases emits an independent histogram with the same
bucket boundaries as varta_observer_iteration_seconds:
| Label value | Phase |
|---|---|
drain_pending | Stall-event queue drain |
poll | Non-blocking I/O receive + frame decode + auth |
maintenance | Counter drains + audit-ring flush |
recovery_reap | Bounded waitpid(2, WNOHANG) + kill |
serve_pending | Prometheus /metrics accept + response loop |
housekeeping | Heartbeat write + watchdog kick |
Every stage emits every bucket from the first scrape (stable label set) so
absent() alert rules stay valid before the first observation.
Use rate(varta_observer_stage_seconds_sum[5m]) / rate(varta_observer_stage_seconds_count[5m])
per stage to isolate which phase is contributing to latency.
Audit-ring back-pressure metrics
The recovery audit log uses an in-memory ring (cap 256) to decouple
fdatasync from the hot path. record_spawn / record_complete enqueue
formatted lines; the maintenance phase drains them within a 10 ms budget.
| Metric | Meaning |
|---|---|
varta_recovery_audit_dropped_total | Lines dropped because the ring was full when they arrived |
varta_recovery_audit_flush_budget_exceeded_total | Ticks where flush_pending exhausted its budget before emptying the ring |
varta_recovery_reap_truncated_total | Ticks where try_reap hit REAP_MAX_PER_TICK=64 before checking all outstanding children |
Non-zero audit_dropped_total means audit records are being permanently
lost — either the disk is too slow, the budget is too tight, or the event
rate is unsustainably high. Non-zero audit_flush_budget_exceeded_total
is a precursor: lines are accumulating faster than they drain, but no data
is lost yet.
The canonical alert rules covering all three observer-liveness symptoms
(VartaAuditRecordDropped, VartaAuditFlushBudgetPressure,
VartaRecoveryReapTruncated) plus the iteration-budget /
beat-path-latency family (VartaIterationBudgetOverruns,
VartaIterationP99High, VartaScrapeStormPressure,
VartaBeatPathP99High) live in
observability/alerts/varta.rules.yml.
The beat-path-latency derivation uses the
varta:beat_path_seconds:p99_5m recording rule (defined in
observability/recording-rules/varta.rules.yml)
so dashboards and alerts read identical numbers.
Recommended Prometheus alerts
See Monitoring & Alerting for the catalogue with per-alert runbooks and severity routing.
Tracker bounded-work guarantee
Each beat frame triggers at most one call to find_evictable_slot when the
tracker is at capacity. That call scans at most eviction_scan_window slots
(default 256, configurable via --eviction-scan-window).
Per-frame slot reads ≤ eviction_scan_window.
A full table sweep — confirming every slot is ineligible — takes at most:
ceil(tracker_capacity / eviction_scan_window)
consecutive record() calls (the rotating cursor resumes where it stopped).
With defaults (capacity = 256, window = 256) this is 1 call. With
--tracker-capacity 4096 --eviction-scan-window 16 the sweep takes 256 calls —
each individual call still reads ≤ 16 slots, so the per-frame beat-path cost
stays bounded.
The varta_tracker_eviction_scan_window_max gauge (set once at startup) exposes
the configured window so dashboards can derive the worst-case sweep depth.
Operators alert on varta_tracker_eviction_scan_truncated_total to detect when
the cap engages under a unique-pid flood.
Combine this bound with the iteration-budget WCET derivation above:
iteration_max ≤ read_timeout × N_listeners + eviction_scan_window × slot_read_ns
Tick-latency budget and hardware-watchdog margin
Bench-derived p99 cap
Under the canonical stress profile — 4096-slot tracker, balanced
eviction policy, 30 agents × 100 Hz (≈ 3 000 beats/s) over UDS — the
varta_observer_iteration_seconds p99 is ≤ 5 ms.
Run the bench to reproduce the measurement on your hardware:
cargo build --workspace --release --features prometheus-exporter
cargo run -p varta-bench --release -- tick-distribution
The bench asserts p99 ≤ 5 ms and exits non-zero if the cap is breached,
printing the full bucket distribution and observed percentiles for triage.
It also reports varta_tracker_eviction_scan_truncated_total and
varta_observer_iteration_budget_exceeded_total so you can confirm the
eviction-scan cap engages under the test load without blowing the latency
budget.
Soft iteration budget
--iteration-budget-ms (default 250 ms) is the soft per-iteration
ceiling. Overruns increment varta_observer_iteration_budget_exceeded_total
but do not abort the loop. The default 250 ms gives 50× headroom over the
5 ms p99 cap; overruns therefore indicate genuine scrape-storm pressure, not
normal active-load variance. See the “Latency budget” section for the full
derivation.
Hardware-watchdog timeout floor
Operators deploying --hw-watchdog /dev/watchdog need a kernel watchdog
timeout of ≥ 30 s. On Linux builds with a pinned watchdog ioctl ABI,
varta-watch enforces this at startup by querying the device and raising
shorter timeouts when the driver supports WDIOC_SETTIMEOUT; on other Unix
targets, configure the device externally before starting the observer. The
derivation:
| Margin factor | Value | Note |
|---|---|---|
| p99 iteration time | ≤ 5 ms | Bench-certified under canonical load |
| Iteration budget (soft) | 250 ms | Default; raise for higher --read-timeout-ms |
| Self-watchdog deadline | 4 s | Default auto-set from $WATCHDOG_USEC |
| Recommended device timeout | ≥ 30 s | ≥ 6000× p99 cap, ≥ 7× self-watchdog deadline |
The observer kicks the hardware watchdog at the end of every poll iteration
(after heartbeat-file write and sd_notify). A single missed kick cannot
trip the device; a sustained stall of ≥ device-timeout will. The 30 s
floor provides ample budget for:
- Audit-log filesystem stalls (
varta_log_suppressed_total{kind="audit_io"}will show rate limiting if these recur) - Prometheus scrape contention (
serve_pending_secondsquantiles) - The H5 self-watchdog’s 4 s deadline with ≥ 7× margin
Round-robin fairness bound
Observer::poll() rotates the next_listener_start cursor on every
non-WouldBlock receive. Per-listener worst-case admission delay is therefore
bounded by N_listeners × per-listener-recv-cost. Under the canonical bench
profile (single UDS listener) this is simply the UDS recv latency; with
N additional UDP listeners add N × ~10 µs per iteration.
Eviction scan under stress
The bench will record non-zero varta_tracker_eviction_scan_truncated_total
when the tracker fills and the 256-slot eviction window exhausts without
finding a stalled slot. This is expected and by design — the cap proves the
per-frame cost stays bounded even under a unique-pid flood. The p99 assertion
holds even when the truncation counter is non-zero.
Debounce table semantics under load
The Recovery runner keeps a per-pid ledger of the most recent recovery
fire (LastFiredTable). Each subsequent stall for the same pid is
gated on now - last_fired[pid] >= debounce; closer-than-debounce
stalls return RecoveryOutcome::Debounced and never spawn a child.
Capacity and eviction policy
The ledger is a fixed-size, array-backed table with capacity
MAX_LAST_FIRED_CAPACITY = 4096. Capacity is sized to make the M8
adversarial-burst pattern costly: 4096 distinct pids would have to
stall faster than debounce cadence before the eviction policy is
engaged. Per-slot cost is Option<LastFiredSlot> ≈ 24 bytes →
~96 KiB total — within budget for the observer.
When the table is full and a stall arrives for a new pid, the policy is fail-closed:
- The oldest slot is identified by a single bounded linear scan.
- If that slot’s age is at least
debounce, it is evicted and the new pid takes its place. Per-pid debounce semantics are preserved because the evicted pid’s window has already elapsed. The eviction is counted invarta_recovery_last_fired_evictions_total(operators tune capacity on this signal). - If the oldest slot’s age is below
debounce, the recovery is refused. The runner returnsRecoveryOutcome::RefusedDebounceCapacity { pid }, emits aRefusedRecord { reason: "debounce_capacity" }to the audit log, and bumps bothvarta_recovery_outcomes_total{outcome="refused_debounce_capacity"}andvarta_recovery_refused_total{reason="debounce_capacity"}.
Eviction is debounce-respecting churn; refusal is suppression. Operators tune capacity on the first signal and alert on the second.
Clock-regression defense
All age comparisons use Instant::saturating_duration_since, which
returns Duration::ZERO on regression. ZERO-duration entries are
treated as “not eligible for eviction” — preventing a backwards
clock blip from auto-evicting the whole table.
Recommended alerts
# Alert immediately on any debounce-capacity refusal — this is either
# legitimate scale-out past 4096 concurrent stalls or the M8
# adversarial stall-burst pattern. Either case warrants paging.
rate(varta_recovery_refused_total{reason="debounce_capacity"}[5m]) > 0
# Warn on sustained eviction churn — debounce semantics are still
# intact, but capacity is becoming a bottleneck under steady-state
# load. Tune MAX_LAST_FIRED_CAPACITY or audit which pids are
# stalling.
rate(varta_recovery_last_fired_evictions_total[5m]) > 0.1
# Page on any non-zero invariant-violation count — the defensive
# fall-throughs in LastFiredTable should never fire in correct
# operation. Non-zero values indicate a code bug, not load.
varta_recovery_invariant_violations_total > 0
For the deployment-side answer to exceeding the 4096-agent cap —
running multiple varta-watch instances and fanning agents across
them — see
Deployment Ceiling & Sharding.
Bounded-WCET guarantee
Every LastFiredTable operation is a linear scan over a fixed-size
backing store. The unit test last_fired_table_prune_bounded_wcet
asserts the prune sweep completes in under 5 ms in debug builds at
full capacity (a future refactor that reintroduces O(n²) behaviour
disguised as “cleanup” is caught by this test).
The pre-M8 HashMap-based implementation was the source of the
debounce-bypass bug closed by this section: reactive pruning at the
top of on_stall (prune_threshold = debounce * 10) left the map
full of fresh entries under adversarial load, and the at_capacity
branch skipped the debounce check entirely. The new table never
skips the check; capacity pressure surfaces as a refusal or an
audited eviction.
Audit-log durability vs availability
The recovery audit log (crate::audit::RecoveryAuditLog) is the last
synchronous-disk path on the poll thread. Three operator-controlled
budgets keep a wedged filesystem (NFS stall, full disk, slow SSD
garbage-collection) from blocking the poll loop:
| Flag | Default | Meaning |
|---|---|---|
--audit-fsync-budget-ms | 50 | Soft per-call budget for one fdatasync(2). Overruns defer further fsyncs in the current drain to next tick. |
--audit-sync-interval-ms | 0 | Time-based fdatasync cadence (in addition to --recovery-audit-sync-every). 0 disables the time-based rule. |
--audit-rotation-budget-ms | 50 | Per-tick budget for the rotation state machine. Overruns preserve progress and resume on the next maintenance tick. |
The drain is deferral-aware: when one fsync exceeds
--audit-fsync-budget-ms, the remaining records in the same drain
are written to the BufWriter only — the fsync is reattempted on the
next tick. This bounds the worst-case poll stall on a slow disk to
one fsync per tick, while still progressing the audit chain on disk
(records sit in the BufWriter, durable through process restart via
the Drop impl’s best-effort flush + sync_data).
Rotation is a state machine (drive_audit_rotation): one sub-step
per call (one rename, then the fresh fd open, then the v2 header
write, then the chain-stitching boot record + fsync). Each call
honours --audit-rotation-budget-ms; if exceeded, state is
preserved on self and the next tick resumes from the same
sub-step. Recursion through direct_write_line → maybe_rotate → emit_boot → direct_write_line is structurally impossible because
the hot path never drives rotation directly — it only sets a
needs_rotation flag for the main loop to consume.
The default configuration preserves IEC 62304 Class C durability
byte-for-byte: --recovery-audit-sync-every=1 +
--audit-sync-interval-ms=0 means every record fsyncs before the
drain returns, and --audit-fsync-budget-ms=50 only ever takes
effect when a single fsync exceeds 50 ms — i.e. when the disk is
already stalling the poll loop. Operators who can accept relaxed
durability (e.g. cloud SRE deployments, not safety-critical) set
--recovery-audit-sync-every=64 --audit-sync-interval-ms=100 to
amortise fsync cost over many records while still pinning a
worst-case sync interval.
Audit-log observability
Four signals back the operator’s mental model:
varta_audit_fsync_seconds(histogram, sharesITERATION_BUCKET_BOUNDS_Swithiteration_seconds) — per-call wall time of eachfdatasync(2)on the audit fd.varta_audit_fsync_budget_exceeded_total(counter) — fsync calls whose wall time exceeded--audit-fsync-budget-ms.varta_audit_rotation_budget_exceeded_total(counter) — rotation drive calls that ran out of budget and deferred to the next tick.varta_audit_ring_watermark_total{level="warn"|"critical"}(counter) — rising-edge transitions of the in-memory ring fill across 75% and 95% ofAUDIT_RING_CAP(= 256). Counter increments once per excursion above each threshold; falling-edge re-arms the rising-edge trigger. Both label values are emitted from the first scrape (stable-label-set discipline).
Recommended alerts
# fsync wall-time is climbing past the budget — disk is degraded but
# the poll loop is still bounded. Investigate before audit records
# start dropping.
rate(varta_audit_fsync_budget_exceeded_total[5m]) > 0.1
# Critical watermark crossed — drain has fallen far enough behind
# that records will start dropping if the trend continues. Pages
# operator before audit_dropped_total increments.
rate(varta_audit_ring_watermark_total{level="critical"}[5m]) > 0
# p99 fsync wall-time exceeds 100 ms — disk is becoming a poll-loop
# bottleneck even under the deferral.
histogram_quantile(0.99, rate(varta_audit_fsync_seconds_bucket[5m])) > 0.1
The structural answer to “what happens when the disk is permanently slow” is visible degradation: every tick defers, both fsync and rotation budget-exceeded counters climb, ring watermarks fire, and operators see the regression before records start dropping or the self-watchdog aborts. The poll loop itself stays within budget.
Cross-references
- Safety profiles — compile-time vs. runtime feature gating for production-safe builds
- VLP transports — transport-level trust classification
- Peer authentication — kernel-level PID attestation
- Verification — symbolic verification of
Frame::decode(M7) and the LastFiredTable invariants on the verification roadmap
Recovery — Non-Blocking Spawn / Async Reap
This page documents how varta-watch keeps the observer loop responsive
while still firing recovery commands on stalled agents.
Implementation lives in
crates/varta-watch/src/recovery/
and is wired into the poll loop from
crates/varta-watch/src/main.rs.
Why this exists
varta-watch runs a single thread driving Observer::poll on a 100 ms
read-timeout cadence. When a pid crosses its silence threshold the
observer surfaces Event::Stall and the binary calls
Recovery::on_stall(pid).
A naive implementation would block the calling thread on the recovery child until it exits. That would freeze the entire poll loop — beat decoding, exporter pumping, Prometheus serving, and stall detection for every other pid — for the duration of one recovery command. A slow recovery template would take the observer offline.
Instead, Recovery::on_stall performs a non-blocking spawn and returns
immediately. Outstanding children are reaped (or killed past their
deadline) on subsequent observer ticks.
Constraints
These follow from the workspace-wide hard rules (see CLAUDE.md):
- Zero registry dependencies in
varta-watch(path-only). - No new threads. No
tokio, no executors. - No
unsafe. - Library code does not print; diagnostics live in
main.rsonly.
Public API
#![allow(unused)]
fn main() {
use std::process::ExitStatus;
use std::time::Duration;
#[derive(Debug)]
pub enum RecoveryOutcome {
/// A child process was forked and is now outstanding. The observer
/// has NOT waited on it. Reap on a later tick via `try_reap`.
Spawned { child_pid: u32 },
/// Previous invocation for this pid is still inside the per-pid
/// debounce window; nothing was spawned.
Debounced,
/// `Command::spawn` failed (e.g. fork failure, program not found).
SpawnFailed(std::io::Error),
/// A previously-`Spawned` child has exited and was reaped on this
/// tick. The observer never blocks waiting for this transition.
Reaped {
child_pid: u32,
status: ExitStatus,
duration_ns: u64,
},
/// A previously-`Spawned` child was killed via `kill(2)` on this tick
/// after a timeout or a recycled-pid stale-child reclaim.
Killed { child_pid: u32 },
/// `try_wait` or `kill` failed for an outstanding child. The pid is
/// still tracked; the observer will retry on the next tick.
ReapFailed(std::io::Error),
/// A recycled pid's previous recovery child could not be killed, so the
/// old outstanding slot is retained and the new lineage is not spawned.
RefusedStaleChildKillFailed { pid: u32, error: std::io::Error },
}
impl Recovery {
pub fn with_exec_and_timeout(
program: String,
args: Vec<String>,
debounce: Duration,
timeout: Option<Duration>,
) -> Self;
/// Spawn the configured program with the stalled pid appended as
/// the final argument. Returns immediately; never blocks.
pub fn on_stall(&mut self, pid: u32) -> RecoveryOutcome;
/// Drain completed (or deadline-exceeded) children for one tick.
/// Returns one outcome per state transition; empty when no children
/// have transitioned since the last call.
pub fn try_reap(&mut self) -> Vec<RecoveryOutcome>;
}
}
Lifecycle of one recovery
debounce-suppressed
┌──────────────► Debounced
│
Event::Stall ─┤ spawn ok
│ ┌────────────► Outstanding
└─► Recovery::on_stall(pid) ───┤
│ spawn err
└────────────► SpawnFailed
(terminal)
on every Observer tick:
Recovery::try_reap()
│
├─► child exited ─────► Reaped { child_pid, status } (terminal)
│
├─► deadline exceeded ─► kill(2) ─► Killed { child_pid } (terminal)
│
└─► try_wait/kill errno ─► ReapFailed(io::Error) (retry)
Outstanding-child storage
Outstanding records live in
OutstandingTable,
a BoundedIndex-backed slab keyed by stalled pid. The table is sized to
tracker::MAX_CAPACITY = 4096 at construction
(recovery/mod.rs:436), so the recovery system can never hold more
outstanding children than the tracker can hold pids — both bounded
collections share the same ceiling. Operators raise the cap with
--tracker-capacity; see
Deployment Ceiling & Sharding.
When the table is full a fresh on_stall returns the bounded equivalent
of Debounced and increments
varta_recovery_refused_total{reason="outstanding_capacity"}
(recovery/mod.rs:786). See
Bounded Collections for the table’s allocation
proof and the static-allocation rationale.
One outstanding child per stalled pid; if the pid stalls again while a child is still outstanding, the per-pid debounce window suppresses a duplicate spawn regardless of the table state.
If the pid’s start-time generation proves the OS recycled that numeric
pid while a previous recovery child is still outstanding, recovery first
tries to kill the stale child and move it to the bounded orphan reaper. A
new lineage is spawned only after that kill succeeds or the child has
already exited. Any other kill(2) failure is fail-closed as
RefusedStaleChildKillFailed: the old outstanding slot stays tracked, no
new child is spawned for the recycled pid, and the audit log records
stale_child_kill_failed.
Tick budget
Observer READ_TIMEOUT is 100 ms. try_reap is invoked once per
Observer::poll iteration. Worst-case latencies:
| Event | Latency upper bound |
|---|---|
Successful child → Reaped surfaces | one tick (≤ 100 ms) after exit |
Deadline exceeded → Killed surfaces | one tick (≤ 100 ms) after deadline |
kill(2) → Reaped of killed child | one further tick (≤ 100 ms) |
These are additive with the observer’s normal stall-detection latency; they do not affect beat decoding or exporter throughput on the critical path.
Default behaviour when --recovery-timeout-ms is omitted
Config::recovery_timeout = None is the default. In that mode
outstanding children are reaped on completion but never killed.
This preserves long-running-recovery semantics (e.g. a restart that
blocks on health checks).
Operators who want the kill-after behaviour set
--recovery-timeout-ms <MS> explicitly. The accepted minimum is 100 ms:
a value of 0 would make the reap gate kill every still-running child on
the first reap tick (silently neutering recovery), so 0 and any
sub-100 ms value are rejected at parse time. The kill is surfaced no
faster than one tick after the deadline. The never-kill default is reached
by omitting the flag, never by 0.
Concurrency model
- The
Recoverystruct is owned exclusively by the binary’s poll loop. It is!Sendby virtue of holdingstd::process::Childvalues, which is fine since the observer is single-threaded. - No locks anywhere on the recovery path.
- Debounce is per-pid; a repeat stall inside the debounce window
returns
Debouncedregardless of whether a child is still outstanding.
Recovery child environment policy
Recovery subprocesses run with an isolated environment by default:
the inherited observer environment is wiped, and the child only sees
PATH=/usr/bin:/bin plus any explicit --recovery-env KEY=VALUE
entries.
Rationale: observers typically run with secrets in their process
environment — AWS_*, GOOGLE_APPLICATION_CREDENTIALS, OAuth bearer
tokens, database URLs, Vault tokens. Inheriting that environment into
a recovery child means any recovery template (or any binary on the
recovery allowlist) becomes a credential-exfiltration vector. The blast
radius is catastrophic and silent. The observer default-clears.
Configuration matrix:
| Flags | Child env |
|---|---|
| (none) | PATH=/usr/bin:/bin only |
--recovery-env KEY=VAL (one or more) | PATH=/usr/bin:/bin + explicit allowlist |
--recovery-inherit-env | Full observer env inherited |
--recovery-inherit-env --recovery-env KEY=VAL | Inherited env + explicit overrides |
Operators whose recovery templates relied on inherited variables
(e.g. $HOME for log paths) have two options:
- Preferred — allowlist explicitly:
--recovery-env HOME=/var/log/varta. - Escape hatch — full inheritance: pass
--recovery-inherit-env. The observer emits a one-shot stderr warning at startup naming the risk so the choice is visible in SIEM/syslog audit trails.
Enforcement is centralised in Recovery::apply_env
(recovery/mod.rs);
all exec-mode children flow through it.
Out of scope
varta-vlp— frame ABI is frozen.varta-client— no agent-side change.- Observer poll cadence — still 100 ms read timeout.
- Exporter line schema.
- Panic-handler feature.
See also
- Stall Detection & Liveness — how the observer
surfaces
Event::Stallin the first place. - Bounded Collections — the static-allocation
proof for
OutstandingTableandTracker. - Deployment Ceiling & Sharding — what 4096 means in practice and how to scale past it.
- Audit Logging — every recovery decision (Spawned / Debounced / Refused / SpawnFailed / Skipped / Reaped / Killed / stale-child kill failure) emits a TSV record.
Shell-Mode Recovery Removal
Shell-mode recovery (RecoveryMode::Shell, --recovery-cmd,
--recovery-cmd-file, --i-accept-shell-risk, Cargo feature
unsafe-shell-recovery) has been permanently removed from all build
profiles of varta-watch.
Rationale
The shell path (/bin/sh -c <template>) was a shell-injection surface. Even
with the two-layer gate (compile feature + runtime flag), the presence of a
/bin/sh invocation in the observer binary represents an unnecessary RCE
vector. The exec path (--recovery-exec) subsumes every legitimate use case
without /bin/sh involvement.
Specific risks that motivated the removal:
- Shell injection — a template containing
$1and constructed from any operator-controlled input (config file, environment variable) can be weaponised to execute arbitrary commands with the observer’s authority. - Hardened container incompatibility — containers built with
no-new-privsorseccompprofiles that blockexecve("/bin/sh", ...)would silently fail recovery without any error surfaced to the operator. - ABI assumption — the path
/bin/shis a POSIX assumption but not a guarantee. Musl-based or busybox-minimal images may place the shell elsewhere or omit it entirely. - Strings-audit regression — the presence of
/bin/shin the binary caused the Class-A profile strings audit to require a feature-conditional exemption. Removal makes the audit unconditional.
Migration guide
| Removed flag | Replacement | Notes |
|---|---|---|
--recovery-cmd <TEMPLATE> | --recovery-exec <PROGRAM> [ARGS...] | The stalled pid is appended as the final argument by the observer. |
--recovery-cmd-file <PATH> | --recovery-exec-file <PATH> | File must contain the program path (and optional fixed args) on a single line, mode 0600. |
--i-accept-shell-risk | (removed — no replacement needed) | --recovery-exec requires no opt-in flag. |
Before
varta-watch \
--socket /run/varta/agents.sock \
--threshold-ms 5000 \
--recovery-cmd "systemctl restart myapp-\$1" \
--i-accept-shell-risk
After
varta-watch \
--socket /run/varta/agents.sock \
--threshold-ms 5000 \
--recovery-exec /usr/local/bin/restart-myapp
Where /usr/local/bin/restart-myapp is a script or binary that receives
the stalled pid as $1 (passed directly by the observer, not via shell
expansion).
Using a wrapper script
If your recovery logic requires shell features (pipes, conditionals), write
a thin wrapper script and invoke it via --recovery-exec:
#!/bin/sh
# /usr/local/bin/varta-recovery
set -euo pipefail
PID="$1"
echo "stall detected for pid $PID" >> /var/log/varta-recovery.log
systemctl restart "myapp-${PID}"
varta-watch \
--socket /run/varta/agents.sock \
--threshold-ms 5000 \
--recovery-exec /usr/local/bin/varta-recovery
The shell is now invoked once, inside a dedicated file that is under version control and auditable — not expanded from a command-line string at runtime.
Compile-time enforcement
Passing any of the removed flags now produces a hard error at startup:
error: --recovery-cmd has been removed; use --recovery-exec instead
This ensures operators whose deployment scripts still reference the old flags are notified immediately rather than silently running without recovery.
Cross-references
- Safety profiles — how
/bin/shabsence is audited - Peer authentication — why exec-only recovery is
consistent with the
KernelAttestedrecovery gate
Peer Authentication
Varta’s observer trusts the kernel, not the wire. Two layers of defence in-depth ensure that process identity cannot be spoofed by anything that can reach the Unix Domain Socket.
Layer 1: socket file permissions (--socket-mode)
During bind(2), the observer temporarily narrows its process umask so
the socket file is created as 0600 by default (owner read and write
only). It does not apply the mode with a post-bind pathname chmod(2);
that avoids a time-of-check/time-of-use window where a replaceable parent
directory could redirect the chmod to another file. The socket parent itself
must be a real directory rather than a symlink, must be owned by the observer
or root, and group/other-writable parents are accepted only when the sticky
bit is set (/tmp-style semantics). Only processes running under the same
UID as the observer can connect(2) to the default socket.
| Flag | Default | Format | Behaviour |
|---|---|---|---|
--socket-mode | 0600 | Octal (e.g. 0660) | File mode created by the bind-time umask. Pass 0660 to allow group access. |
Layer 2: kernel credential verification
Linux
The observer sets SO_PASSCRED on the socket after binding. Every
recvmsg(2) call then receives a SCM_CREDENTIALS ancillary message
containing a struct ucred { pid, uid, gid } populated by the kernel.
The observer compares ucred.pid against frame.pid from the VLP wire
format. If they disagree the frame is silently dropped and
varta_frame_auth_failures_total is incremented. The ucred.uid
field is implicitly trusted by Layer 1 (--socket-mode 0600 already
restricts access to the owning UID), but could be checked as a
fail-safe if a permission bypass is ever discovered.
macOS
macOS exposes LOCAL_PEERTOKEN / LOCAL_PEERPID for connected local
sockets, but Varta’s production UDS transport is a pathname
SOCK_DGRAM observer socket receiving from unconnected clients. On that
socket shape, Darwin returns ENOTCONN for the peer-token and peer-pid
queries after recvmsg(2), so the observer cannot bind frame.pid to a
kernel-attested sender.
The implementation still attempts the Darwin peer queries so connected
test fixtures catch constant or ABI drift, but pathname UDS beats
resolve to the PID-0 sentinel and are tagged BeatOrigin::SocketModeOnly.
Recovery is intentionally refused for those beats.
The Darwin queries are:
LOCAL_PEERPID(0x0002) — returns the peer’s PID directly.LOCAL_PEERCRED(0x0001) — returns astruct xucredwith the peer’s UID incr_uid.LOCAL_PEERTOKEN(0x0006) — returns anaudit_token_tcontaining the peer’s PID and effective UID on connected local sockets.
FreeBSD
On FreeBSD, the observer sets LOCAL_CREDS_PERSISTENT on the socket
(value 0x0003, at SOL_LOCAL). Every recvmsg(2) then receives a
SCM_CREDS2 ancillary message (type 0x08) containing a
struct sockcred2 { sc_version, sc_pid, sc_uid, sc_euid, sc_gid, ... }
populated by the kernel. The observer accepts version 0, extracts
sc_pid and sc_euid, and performs the same PID + UID verification as
on Linux.
Plain FreeBSD LOCAL_CREDS is intentionally not used: it emits
struct sockcred, which does not contain a sender PID and therefore
cannot enforce frame.pid == peer_pid.
DragonFly BSD
On DragonFly, the observer sets SO_PASSCRED on the socket (value
0x4000, at SOL_SOCKET). Every recvmsg(2) then receives a
SCM_CREDS ancillary message (type 0x03) containing a
struct cmsgcred { cmcred_pid, cmcred_uid, cmcred_euid, cmcred_gid, ... }
populated by the kernel. The observer extracts cmcred_pid and
cmcred_euid and performs the same PID + UID verification as on Linux.
NetBSD
NetBSD uses the same LOCAL_CREDS / SCM_CREDS concept, but not the
FreeBSD ABI. The observer enables LOCAL_CREDS with value 0x0004; the
kernel delivers SCM_CREDS type 0x10 containing a struct sockcred.
The observer extracts sc_pid and sc_euid and performs the same PID +
UID verification as on Linux.
The ancillary buffer is sized at 256 bytes — sufficient for FreeBSD’s
32-byte minimum sockcred2, DragonFly’s 84-byte cmsgcred, and
NetBSD’s 28-byte minimum sockcred, with generous headroom for future
kernel extensions.
illumos / Solaris
On illumos and Solaris the observer sets SO_RECVUCRED (value 0x0400,
level SOL_SOCKET = 0xffff) on the socket. Every recvmsg(2) then
receives a SCM_UCRED ancillary message whose payload is an opaque
ucred_t. The layout of ucred_t is not a stable ABI contract, so
field extraction goes through the ucred_getpid(3C) and ucred_geteuid(3C)
accessor functions from the system’s C library rather than a direct struct
cast.
This is a genuine per-datagram credential mechanism: the kernel attests
the sender’s identity on each recvmsg(2), not just at connection time.
The ancillary buffer is sized at 1 024 bytes to accommodate the additional
audit and MAC-label attributes that illumos attaches under Trusted Solaris
/ labelled-zone configurations.
ucred_t lifetime invariant. Both ucred_getpid(3C) and
ucred_geteuid(3C) are called inside extract_pid_uid while the cmsg
buffer is still on the recv_authenticated stack frame. Neither call
stores the pointer past its return; the buffer is released after the call
completes.
Zone isolation. ucred_getzoneid(3C) is available for extracting the
Solaris zone-id of the sender (analogous to the Linux PID-namespace inode).
Cross-zone detection is a planned follow-up; for now, peer_pid_ns_inode
is None on illumos/Solaris, which disables the cross-container recovery
refusal. See book/src/architecture/namespaces.md for the planned gate.
Platform support summary
| Platform | Mechanism | Per-datagram? | Recovery-eligible? |
|---|---|---|---|
| Linux | SO_PASSCRED + SCM_CREDENTIALS (struct ucred) | Yes | Yes, after /proc/<pid>/stat start-time generation is pinned |
| macOS pathname UDS | socket file permissions only (LOCAL_PEERTOKEN requires a connected local socket) | No | No |
| FreeBSD | LOCAL_CREDS_PERSISTENT + SCM_CREDS2 (struct sockcred2) | Yes | Yes (recycle-unverifiable¹, not field-validated²) |
| DragonFly | SO_PASSCRED + SCM_CREDS (struct cmsgcred) | Yes | Yes (recycle-unverifiable¹, not field-validated²) |
| NetBSD | LOCAL_CREDS + SCM_CREDS (struct sockcred) | Yes | Yes (recycle-unverifiable¹, not field-validated²) |
| illumos / Solaris | SO_RECVUCRED + SCM_UCRED + ucred_t (opaque) | Yes | Yes (recycle-unverifiable¹) |
| OpenBSD, AIX, HP-UX, other Unix | none — --socket-mode 0600 only | No | No |
¹ Recycle-unverifiable recovery. These platforms attest the sender PID per datagram (minting
KernelAttested, so recovery is eligible) but expose no/proc/<pid>/stat, soread_pid_start_timereturnsNoneand no start-time generation token can be pinned. A PID recycled inside the per-tick spawn-budget deferral window therefore cannot be distinguished from the original process. Rather than risk firingkill(2)/restart against an innocent recycled PID, the observer’s deferred-stall freshness re-check withholds recovery for aKernelAttestedstall that carries no generation, surfacing it asvarta_recovery_outcomes_total{outcome="skipped_stall_unverifiable"}. The immediate (same-tick) recovery path is unaffected — it has no deferral window for a recycle to occur in. On Linux aKernelAttestedslot always carries a generation, so this skip never fires there.
² BSD field-validation status. The FreeBSD, DragonFly, and NetBSD constants and credential layouts are guarded by compile-time checks and fabricated-buffer tests, but the project has not yet run live
varta-watchinstances on those kernels against real kernel-delivered credentials after the latest ABI split. Treat the targets as ABI-verified and not yet field-validated until each host signs off one accepted beat.
Socket-mode-only fallback
On platforms that do not provide per-datagram kernel credential passing
for Varta’s pathname UDS transport (currently: macOS, OpenBSD, and any
other Unix not listed above), varta-watch
falls back to a plain recv(2) receive path and tags each beat with
BeatOrigin::SocketModeOnly.
Trust model for SocketModeOnly beats. The only defence is filesystem
permissions (--socket-mode 0600, the default). Any process running
under the same UID as the observer can reach the socket and forge
frame.pid. Recovery commands must not and do not fire for these
beats — the recovery gate at recovery.rs::on_stall refuses with
RecoveryOutcome::RefusedSocketModeOnly and increments the Prometheus
counter varta_recovery_refused_total{reason="socket_mode_only"}. The
audit log records the refusal with reason=socket_mode_only.
At startup, varta-watch emits a warning on stderr when compiled for a
socket-mode-only platform:
[WARN] per-datagram PID verification is unavailable on this platform.
The only defence is --socket-mode (default 0600). Any process under the
same UID can impersonate any PID. Beats will be tagged SocketModeOnly;
recovery commands will not fire.
UDP transport authentication
For network-based agents that emit beats over UDP, the trust model is
cryptographic, not kernel-attested. UDP has no peer-credential
mechanism on any platform — recvmsg(2) cannot tell the observer who
sent a datagram, only where it claims to be from. Varta therefore
requires authentication at the AEAD layer, and refuses to bind an
unauthenticated UDP listener without two layers of explicit opt-in.
Compile-time features (crates/varta-watch/Cargo.toml)
| Cargo feature | What it enables | Production posture |
|---|---|---|
secure-udp | SecureUdpListener (ChaCha20-Poly1305 AEAD + per-sender replay) | Recommended |
unsafe-plaintext-udp | UdpListener (no authentication) | Forbidden in production |
udp-core | Internal — shared UDP socket wiring | (transitive) |
A build that does not include unsafe-plaintext-udp cannot link the
plaintext path at all. Passing --udp-port without keys to such a build
hard-errors at startup; there is no warn-and-continue path.
Runtime selection rules
When --udp-port is set, the observer chooses exactly one listener:
- If
--features secure-udpis compiled in and--key-file,--accepted-key-file, or--master-key-fileresolve to usable key material, bindSecureUdpListener. - Otherwise, only the plaintext path remains. It is bound only if
both
--features unsafe-plaintext-udpis compiled in and--i-accept-plaintext-udpwas passed on the command line. - Any other configuration is a hard error (
InvalidInput).
When the plaintext path is taken, a high-visibility varta_warn! is
emitted at startup naming the bound address, so the choice appears in
SIEM / syslog logs:
UDP on <addr> is running WITHOUT authentication (--i-accept-plaintext-udp).Any device with network reach to this port can inject heartbeats, suppressstall detection, or trigger false recovery commands. NOT for production /safety-critical use.
--i-accept-plaintext-udp is intentionally verbose: an operator who
types it is making an explicit statement that this build is for
development or testing, not for a hospital VLAN.
Why no kernel-level UDP credentials
Unix Domain Sockets can carry SCM_CREDENTIALS / SCM_CREDS / SCM_CREDS2
per-datagram on supported kernels. UDP carries none of those. Even on a single
host where --udp-bind-addr 127.0.0.1 is used, any local process can
send to that port — there is no equivalent of --socket-mode 0600 for
network sockets. AEAD is the only durable defence.
Recovery eligibility and transport-origin gating
Recovery commands (--recovery-exec and --recovery-exec-file) take the
stalled agent’s frame.pid and substitute it into
the spawned process (kill -9 {pid}, systemctl restart agent@{pid}.service,
etc.). That makes recovery a privileged action that targets an arbitrary
process by id — and means the wire-level frame.pid must be tied back to
the real sending process, not just to whoever holds an AEAD key.
The trust invariant
A recovery command MUST NEVER fire for a pid whose beat lifetime is not kernel-attested. In practice that means:
| Transport | Kernel-attested? | Recovery-eligible by default? |
|---|---|---|
| UDS on Linux / supported BSDs / illumos / Solaris | Yes — SO_PASSCRED / SCM_CREDS / SCM_CREDS2 / SCM_UCRED | Yes |
| UDS on macOS pathname sockets / OpenBSD / other socket-mode-only targets | No — socket file permissions only | No |
| Plaintext UDP | No — peer_pid is always 0 | No |
| Secure UDP | No — frame is cryptographically authenticated but the kernel does not attest the sending process; a holder of the AEAD key (or a per-agent key derived from a leaked master key) can forge a beat for any pid | No |
Internally each beat is tagged with a BeatOrigin
(KernelAttested, OperatorAttestedTransport, SocketModeOnly, or
NetworkUnverified). The tracker handles origin races monotonically by
trust: a stronger origin can replace a weaker preemption attempt for the
same pid, while weaker conflicting beats are rejected as
Event::OriginConflict (counter: varta_origin_conflict_total). This
prevents an attacker on an untrusted transport from pinning a pid before a
kernel-attested agent can prove liveness.
Two-layer enforcement
-
Startup hard-error. If any
--recovery-exec/--recovery-exec-fileis configured and--udp-portis set, the daemon refuses to start withConfigError::RecoveryRequiresAuthenticatedTransport. To proceed the operator must pass the transport-qualified accept flag for the listener in play —--secure-udp-i-accept-recovery-on-unauthenticated-transportfor a secure-UDP listener, or--plaintext-udp-i-accept-recovery-on-unauthenticated-transportfor a plaintext one. The flag is verbose by design (matches the--i-accept-<risk>convention) and shows up incargo tree/ startup banners. -
Runtime origin gate.
Recovery::on_stallspawns the recovery command only when the stalled slot’s pinned origin isKernelAttestedorOperatorAttestedTransport;NetworkUnverifiedandSocketModeOnlyorigins are always refused. The transport-qualified accept flag from step 1 is exactly what stamps the listener’s beats asOperatorAttestedTransport— so the single flag that clears the startup hard-error is also what makes the runtime gate pass; there is no separate runtime opt-in. A refused UDP stall returns the typedRecoveryOutcome::RefusedUnauthenticatedSource { pid }, incrementsvarta_recovery_refused_total{reason="unauthenticated_transport"}, and emits a structuredrefusedrecord into the recovery audit log (--recovery-audit-file).
Why secure-UDP isn’t enough
The secure-UDP master-key mode binds frame.pid to the 4-byte PID prefix
in iv_random[0..4] and derives a per-agent key from the master key.
That is a useful cryptographic binding for the UDP threat model — a
holder of a single derived agent key cannot forge frames for other
pids. But the binding lives at the protocol layer, not at the kernel
layer:
- A leak of the shared key lets anyone forge any pid.
- A leak of the master key lets anyone derive any agent key.
- A leak of any per-agent key still lets that agent forge its own pid to misbehave (e.g. stop sending → trigger recovery against its own pid during legitimate maintenance windows).
Kernel attestation has no such failure mode: the kernel knows which
process owns the socket fd, and that knowledge cannot be forged by any
amount of key material. This is why Varta never grants UDP variants
(plain or secure) recovery eligibility on its own: UDP beats default to
NetworkUnverified (recovery refused), and the only way to make them
recovery-eligible is for the operator to explicitly vouch for the
transport with the accept flag, which stamps the beats
OperatorAttestedTransport — an operator attestation, never a kernel
one.
Recovery command authentication boundary
--recovery-exec and --recovery-exec-file invoke the program directly
via execvp(2) — no shell, no metacharacter interpretation, no injection
surface. The stalled pid is appended as the final argument: never
interpolated into a command string.
Shell-mode recovery (--recovery-cmd / --recovery-cmd-file) was
permanently removed. All builds — SRE, Class-A, and default — use
exec-only recovery. No opt-in flag is required; exec-mode is the only
available recovery mode.
Prometheus /metrics endpoint exposure
The /metrics endpoint is HTTP/1.0 with mandatory bearer-token
authentication. When --prom-addr is set, --prom-token-file is
required: the observer refuses to start without it. Every scrape must
send Authorization: Bearer <hex> where <hex> is the lowercase 64-byte
hex form of the file’s 32 random bytes (the format produced by
openssl rand -hex 32). Missing or wrong tokens get
HTTP/1.0 401 Unauthorized and bump varta_prom_auth_failures_total.
The token file is loaded through the same hardened validator that
guards --key-file (see “Secret-file validation” below): regular file,
no symlinks, owned by the observer UID, mode 0o600 or stricter,
opened with O_NOFOLLOW.
The endpoint also retains four DoS-protection layers from earlier work, so that a hostile scraper cannot exhaust file descriptors or starve the observer’s poll loop even before the auth check runs:
- Serve budget — at most
PROM_MAX_CONNECTIONS_PER_SERVE=8accepted connections per outer poll tick, and a 100 ms wall-clock deadline. - Drain budget — after the serve budget is exhausted, an
additional
PROM_MAX_DRAIN_PER_SERVE=50connections may be accepted and immediately closed, so the kernel accept queue does not back up. - Per-source-IP token bucket — every accepted connection (in both
serve and drain phases) decrements a per-IP token bucket sized by
--prom-rate-limit-burst(default 10) and refilled at--prom-rate-limit-per-sec(default 5). Connections from an IP whose bucket is empty are closed without serving and counted asvarta_prom_connections_dropped_total{reason="rate_limit"}. - Per-IP table cap — the per-IP map is bounded to 1024 entries;
when full, stale entries (no activity in 60 s) are evicted first,
then if necessary the oldest entry is force-evicted and counted as
varta_prom_connections_dropped_total{reason="ip_table_full"}.
Token comparison is constant-time
The exporter compares the presented and expected tokens via
varta_vlp::ct_eq — the same constant-time XOR-and-OR routine that
guards Poly1305 tag verification. This prevents byte-by-byte timing
oracles from leaking the prefix of the token to a remote scraper.
Bind-address recommendation
The bearer token is the authoritative authentication boundary. Loopback
bind (127.0.0.1:<port> or [::1]:<port>) behind a reverse proxy
remains the recommended posture for defense in depth, but is no longer
the only defense. The observer still emits a startup varta_warn!
whenever the bound address is non-loopback, so the exposure is visible
in audit logs.
Prometheus scrape config
The standard authorization: block injects the bearer token verbatim:
scrape_configs:
- job_name: 'varta'
static_configs:
- targets: ['varta-host:9100']
authorization:
type: Bearer
credentials_file: /etc/prometheus/varta-prom.token
The credentials_file should be the same content as
--prom-token-file on the observer; Prometheus reads it with the same
0600-or-stricter expectation.
Secret-file validation
Every file containing key material — --key-file, --accepted-key-file,
--master-key-file, and the new --prom-token-file — flows through
validate_secret_file in varta-watch/src/config.rs. The validator
enforces:
- The path is not a symlink (
symlink_metadata+is_symlink). - The path resolves to a regular file (not a directory, FIFO, block/char device, etc.).
- The mode is
0o600or stricter (mode & 0o077 == 0). - The file is owned by the observer’s UID (kernel-attested via
stat.uid, not derived from the env). - The file is opened with
O_NOFOLLOWto close the TOCTOU window between the metadata check and the read.
A failure on any of these aborts startup with a typed ConfigError
naming the failing constraint (insecure permissions ..., must not be a symlink, owned by uid X, expected uid Y, etc.).
Why environment-variable keys are gone
Earlier releases offered --key-env <NAME> as a key-source fallback.
That flag is removed. Passing it now returns
ConfigError::RemovedFlag with an inline migration hint pointing at
--key-file. The motivation:
- On Linux,
/proc/<pid>/environis readable by any process running under the same UID; a peer with a UDS connection to the observer (which already has UID-restricted access) can read the master key out of the observer’s own environment. - In containers,
docker inspect <container>exposes every environment variable to anyone with read access to the Docker socket — typically all members of thedockergroup, which is often a superset of the in-container UID. systemd-journaldcaptures process environment on demand for crash reports; an env-var key ends up in/var/log/journalindefinitely.
File-based keys avoid all three exposures and slot into the same ownership/permission model as TLS private keys, SSH host keys, and any other long-lived secret an operator already knows how to manage.
The Key type in varta_vlp::crypto also lost its Copy derive and
gained a Drop impl that volatile-zeros the secret bytes before the
allocation is returned to the stack, closing a small but real leak
surface in core dumps and ASLR-defeated speculative reads.
Shutdown grace and systemd
--shutdown-grace-ms (default 5000, range 100..60000) bounds the time
Recovery::drop blocks waiting for outstanding recovery children to
exit after issuing SIGKILL during shutdown. Children that outlive the
grace are abandoned to PID 1 for reaping; the observer process exits
either way, so the bound on shutdown latency is deterministic.
In a systemd unit, TimeoutStopSec must be at least
shutdown_grace_ms + 2 s (roughly: grace + reap margin) to ensure
that systemd does not SIGKILL the observer mid-grace and leak an
unreaped recovery child:
[Service]
Environment=VARTA_SHUTDOWN_GRACE_MS=5000
ExecStart=/usr/local/bin/varta-watch --shutdown-grace-ms ${VARTA_SHUTDOWN_GRACE_MS} ...
TimeoutStopSec=7s
KillMode=mixed
KillMode=mixed is recommended: systemd sends SIGTERM to the main
observer process only; the observer then runs its own Drop sequence to
kill+reap any recovery children it had spawned. This is what the
shutdown-grace tunable is designed around.
Recovery command environment isolation
When --recovery-env KEY=VALUE is specified (repeatable), the recovery
child process runs with a sanitized environment:
- The child’s environment is cleared entirely.
PATHis set to/usr/bin:/bin(sufficient to locate common tools).- Only the explicitly-listed
KEY=VALUEpairs are exported.
Without --recovery-env, the child inherits PATH=/usr/bin:/bin only
(secure default since 2026-05-14). This eliminates LD_PRELOAD, IFS,
and other environment-injection vectors from recovery children entirely.
Exec safety
The {pid} substitution in --recovery-exec args is safe: a u32 PID
formatted as a decimal string contains only the characters 0–9 and
can never carry shell metacharacters (;, |, &, $, `, etc.).
Furthermore, since exec-mode never passes arguments through a shell,
metacharacter interpretation is structurally impossible.
Metrics
| Metric | Type | Description |
|---|---|---|
varta_frame_auth_failures_total | counter | Incremented every time a frame’s claimed PID does not match the kernel-verified sender PID (Linux only). |
varta_beats_total{pid="..."} | counter | Per-PID total of accepted beats (only incremented after authentication passes). |
varta_prom_connections_dropped_total{reason="..."} | counter | /metrics connections accepted but closed before serving. Reasons: drain (serve budget exhausted), rate_limit (per-IP token bucket empty), ip_table_full (per-IP state map force-evicted). |
varta_prom_auth_failures_total | counter | /metrics scrapes that arrived without Authorization: Bearer <hex> or with a wrong token. Always emitted on every scrape (even at zero), so absent() alert rules stay green-on-green until the first incident. |
varta_recovery_refused_total{reason="..."} | counter | Recovery commands NOT spawned because of a structural safety gate. Only reason currently defined: unauthenticated_transport (stalled slot’s pinned origin was NetworkUnverified and the operator did not enable UDP-origin recovery). Emitted at zero on every scrape. |
varta_origin_conflict_total | counter | Beats dropped because the beat’s transport origin was weaker than the slot’s pinned origin. Non-zero values indicate either operator misconfiguration (same pid emitted from two transports) or an active spoofing attempt. |
Trust model summary
Process ── connect(2) to UDS ──┐
├─ [FAIL] Kernel blocks (Layer 1: --socket-mode 0600, wrong UID)
├─ [PASS] Layer 2: SO_PASSCRED → ucred.pid (Linux)
│ Layer 2: LOCAL_CREDS_PERSISTENT → sockcred2.sc_pid (FreeBSD)
│ Layer 2: SO_PASSCRED → cmsgcred.pid (DragonFly)
│ Layer 2: LOCAL_CREDS → sockcred.sc_pid (NetBSD)
│ ├─ [PID MISMATCH] → Drop frame + bump counter
│ ├─ [UID MISMATCH] → Drop frame as IoError
│ └─ [PID MATCH + UID MATCH] →
↓
[SUCCESS] Observer trusts the PID → tracks,
surfaces stalls, triggers --recovery-exec
with {pid} as the final argument.
The trust boundary is the kernel: a frame is only accepted if the kernel
attests that the sending process’s PID matches the one encoded in the
VLP frame and that the sending process runs under the observer’s UID.
On Linux this is enforced per-datagram via SO_PASSCRED; on FreeBSD via
LOCAL_CREDS_PERSISTENT + SCM_CREDS2 + struct sockcred2; on
DragonFly via SO_PASSCRED + SCM_CREDS + struct cmsgcred; on NetBSD
via LOCAL_CREDS + SCM_CREDS + struct sockcred. macOS pathname
datagram sockets and platforms without kernel-level credential passing fall
back to --socket-mode 0600.
Security limitations
No forward secrecy
The KDF derives per-agent and per-epoch keys from a single master key. An epoch key can decrypt frames from past epochs if the agent key is compromised. True forward secrecy requires bidirectional ephemeral key exchange (e.g. X25519), which is incompatible with the connectionless, one-way heartbeat model.
When the master key is rotated, all agents must be updated atomically.
The observer reads the master key once at startup from --master-key-file. To
rotate keys, restart the observer with the new master key file. SIGHUP-based
hot-reload is planned for a future release.
Panic-hook entropy policy (secure UDP)
install_panic_handler_secure_udp reads all IV material at install time
(getrandom(2) on Linux, getentropy(3) on macOS/BSD, falling back to
/dev/urandom): an 8-byte prefix for the installing process plus a 16-byte
salt for forked children. If a forked child panics, the hook derives a
child-specific IV prefix with HKDF-SHA256 over the pre-read salt, PID,
timestamp, and AEAD counter. No file I/O or OS entropy call occurs inside the
panic handler itself (async-signal-safety).
Fail-closed default: if the entropy chain fails — common in chrooted
environments without a mounted /dev — the function returns
Err(PanicInstallError::EntropyUnavailable) and the hook is NOT registered.
This prevents a panic-time Critical frame from reusing a deterministic IV
under the same AEAD key, which would be a catastrophic nonce-reuse failure.
Degraded-entropy opt-in: use
install_panic_handler_secure_udp_accept_degraded_entropy to fall back to a
non-cryptographic IV prefix and fork salt derived from PID, TID, monotonic
time, and counters (SipHash-2-4). This always succeeds but accepts nonce-reuse
risk if the fallback inputs collide. The verbose function name is intentional
structural enforcement matching the project’s --i-accept-<risk> convention.
Little-endian only
The VLP wire format uses little-endian integer encoding natively.
Protocol correctness depends on the host being little-endian (all tier-1
targets — x86_64 and aarch64 — satisfy this). Building on a big-endian
host is a compile error. See book/src/architecture/vlp-frame.md for design
rationale.
Panic-hook key lifetime — accepted residual
The secure-UDP panic handler (install_panic_handler_secure_udp,
install_panic_handler_secure_udp_accept_degraded_entropy) captures a Key
by move into a Box<dyn Fn> registered via std::panic::set_hook. The Box
is the single owner of the captured Key for the lifetime of the
process — Key is !Clone (see crates/varta-vlp/src/crypto/mod.rs), so
no duplicate of the secret bytes can exist anywhere else in the address
space.
The !Clone invariant pins the count of in-memory copies to one. The
remaining concern is the lifetime of that one copy on process exit:
- Normal hook replacement (
std::panic::take_hook): the prior Box is dropped, the capturedKey’sZeroizeOnDropfires, and the 32 secret bytes are wiped before the heap page is returned to the allocator. OK. panic = "unwind"profile, normal process exit: the panic-hook Box is leaked by the runtime —Dropis not called on registry-held objects at exit. The capturedKeybytes persist in heap memory until the kernel reclaims the page. Linux does not zero pages on reclaim (memory contents are reused; zero-on-allocation guarantees apply only to new allocations into the same process).panic = "abort"profile: the panic-hook closure never runs, butset_hookstill owns the Box — same residual as the normal-exit case. Additionally, noDropruns anywhere duringabort().
This residual is accepted: there is no async-signal-safe mechanism
that can reliably wipe a heap-resident secret at process exit. atexit
handlers do not run on abort(), are not async-signal-safe, and race the
panic hook firing. mlock / memfd_secret cannot prevent the kernel
from copying the page during scheduler context switches or core dumps.
The minimum-surface design is to keep the captured Key alive in a
single Box and treat the OS process boundary as the security boundary:
inspecting the memory of a live process requires ptrace or
/proc/<pid>/mem privileges, at which point all in-memory secrets in
any design are accessible.
Cross-references
- Safety profiles — compile-time feature gating for dangerous recovery paths; production-safe build verification recipe
- Observer liveness — defending against
varta-watchitself crashing or hanging - VLP transports — transport-level trust classification and
BeatOriginsemantics
PID-namespace semantics
Varta agents and the varta-watch observer can run on the same host but in
different Linux PID namespaces (typical when agents run in containers and the
observer on the host, or vice-versa). This document defines what the protocol
does in that case, why, and how operators configure it.
Problem statement
std::process::id() (called by Varta::beat()) returns the agent’s PID in
the calling process’s PID namespace (see pid_namespaces(7)). The observer’s
kernel-attested peer PID (SO_PASSCRED / SCM_CREDS / SCM_UCRED) is the
PID as seen from the observer’s namespace.
Two consequences when namespaces differ:
- The numeric pid is meaningless across the boundary. PID 17 in container
A is a different process from PID 17 on the host.
kill(2)against PID 17 in the observer’s namespace targets the observer-namespace process, not the agent. - Collisions are guaranteed. Every container’s first process is PID 1. Two containerized agents binding the same observer socket will both claim PID 1.
Threat model
| Scenario | Risk |
|---|---|
| Host observer, host agents | None. |
Host observer, agent in --pid=host container | None — agent uses host PIDs. |
| Host observer, agent in private-PID container | Cross-namespace: kill targets wrong process. |
| Two private-PID containers, shared observer | Pid collisions: containers claim same pid. |
| Container observer, host agents | Cross-namespace. |
Detection
On Linux, every process’s PID namespace has a unique inode exposed at
/proc/<pid>/ns/pid (stat(1) it, or readlink(1) for the canonical
pid:[NNNN] form). Two processes share a PID namespace iff their
/proc/<pid>/ns/pid symlinks resolve to the same inode.
varta-watch caches its own inode at startup
(crate::peer_cred::observer_pid_namespace_inode()) and, for every
kernel-attested beat, reads the peer’s inode
(crate::peer_cred::read_pid_namespace_inode(peer_pid)). Both helpers are
allocation-free; the per-beat read is one readlink(2) syscall into a stack
buffer (sub-microsecond on modern Linux).
Non-Linux platforms (macOS, BSD) return None from both helpers and the
comparison short-circuits to “match”. UDP listeners set peer_pid_ns_inode = None because there is no kernel attestation; the existing UDP recovery
refusal gate is the relevant protection there.
PID recycling within a namespace (generation token)
A PID is not a stable identity even within one namespace: the kernel
recycles it once the holding process exits. If agent A (PID 1234) dies and the
OS reuses 1234 for a fresh agent B, B’s first beat carries nonce = 1 while
the observer’s slot for PID 1234 still holds A’s high-water nonce. Without
extra signal, B’s low-nonce beats are rejected as out-of-order, the slot’s
last_ns freezes, a false stall fires, and recovery is misdirected against
the healthy new process — all with no attacker involved. On Linux UDS
(KernelAttested) recovery is permitted, so this can kill or restart an
unrelated bystander; on macOS UDS (SocketModeOnly) and all UDP
(NetworkUnverified) recovery is already refused, so the residual there is
limited to monitoring accuracy.
The fix binds slot identity to (pid, generation), where generation is
the kernel-attested process start-time — field 22 (starttime) of
/proc/<pid>/stat, read via crate::peer_cred::read_pid_start_time(peer_pid)
(allocation-free, one open/read/close into a stack buffer, parsed from
the last ) so a comm containing spaces or parentheses cannot fool it). Two
processes that share a PID value cannot share a start-time, because the first
holds the PID until it exits.
When a beat for an already-tracked pid carries a different Some(_)
generation, the slot is reset to a fresh agent (nonce baseline, origin,
namespace inode, and silence timer all re-pinned) and the event is counted as
varta_tracker_pid_recycle_total. The generation check runs before the
origin / namespace / nonce checks — a recycled process legitimately differs on
all of them. A None on either side (“generation unknown”: non-Linux, UDP, or
unreadable /proc) is treated leniently by the tracker and never triggers a
reset, so prior PID-only behaviour is preserved exactly for non-recovery
transports and for already-pinned slots whose peer vanished. When the slot’s
pinned generation is None and a later beat carries Some(_) with an
accepted nonce, the token is pinned in place (same rule as the
namespace-inode None → Some upgrade) so a subsequent recycle can compare
(Some(G1), Some(G2)) instead of staying stuck at None. Out-of-order frames
must not pin generation. Replay protection is untouched: a low nonce under the
same generation is still dropped as out-of-order.
For Linux UDS first contact, the observer requires that Some(generation)
before the slot may pin recovery-eligible KernelAttested origin. If the
sender exits after recvmsg(2) but before the /proc/<pid>/stat start-time
read, the beat is still observable, but it is recorded as SocketModeOnly.
A later accepted beat that can read a concrete generation may upgrade the slot
to KernelAttested. This keeps transient dying-gasp frames from breaking
monitoring while preventing an unpinned numeric PID from driving {pid}
recovery after PID recycle.
The same generation is revalidated when a kernel-attested slot first becomes a
stall candidate. If /proc/<pid>/stat now returns a concrete different
generation, the old slot is retired and no Event::Stall is emitted. This
closes the silent-death case where no new beat arrives to trigger the beat-time
recycle gate: a dead agent’s stale KernelAttested origin must not drive
recovery against a healthy process that inherited the PID. A missing generation
read at stall time remains fail-open, because it may simply mean the original
agent exited and recovery should still restart it.
Cost. Beat-time recycle detection requires re-reading the generation on
every admitted KernelAttested beat — there is no way to observe PID reuse
without re-stat-ing the peer. This adds one /proc/<pid>/stat
open/read/close (three syscalls, allocation-free) per beat, on top of the
existing /proc/<pid>/ns/pid namespace read. The read is deferred until after
the global rate limiter admits the frame, so a flood cannot force a /proc
read per packet. Stall-time revalidation adds the same stack-buffered read only
for kernel-attested slots that have already crossed the stall threshold.
Non-Linux and non-attested transports skip the read entirely
(read_pid_start_time returns None).
Mitigation by deployment style
| Deployment | Default behaviour | Operator action |
|---|---|---|
| Single namespace (host or container) | Pass-through. | None. |
Containerized agents with --pid=host | Pass-through (same kernel-attested ns). | None. |
| Containerized agents with private PID namespace | Beats dropped at receive; recovery refused. Audit log shows reason=cross_namespace_agent. | Either fix the deployment (run agents with --pid=host) or accept the risk via --allow-cross-namespace-agents and arrange out-of-band PID translation in the recovery template. |
| Mixed: some agents same-ns, some cross-ns | Same-ns agents work; cross-ns agents refused and audit-logged. | Same as above; the gate is per-beat. |
| Operator wants fail-fast on misconfigure | Defaults silently drop and audit. | Pass --strict-namespace-check — daemon exits non-zero on first cross-ns beat. |
Audit and metrics inventory
| Surface | Linux signal |
|---|---|
varta_frame_namespace_mismatch_total (counter) | Kernel-attested frames dropped at receive (peer ns ≠ observer ns). |
varta_tracker_namespace_conflict_total (counter) | Beats dropped because the slot’s pinned ns inode disagreed with the beat’s (first-namespace-wins). |
varta_tracker_pid_recycle_total (counter) | Stale slot identities reset or retired because a kernel-attested process start-time mismatch proved the pid was recycled to a new process (recycle-safe identity). |
varta_recovery_refused_total{reason="cross_namespace_agent"} (counter) | Stalls refused at recovery time because the slot’s ns inode differed from the observer’s. |
varta_recovery_outcomes_total{outcome="refused_cross_namespace"} (counter) | Same event, broken down on the outcome axis. |
Audit log record with reason=cross_namespace_agent | TSV record in --recovery-audit-file. |
Event::NamespaceConflict | Emitted to consumers via Observer::poll() so file/Prom exporters can record it. |
All counters are emitted at every scrape even at zero, so absent() alert
rules stay green-on-green until the first event.
API surface
Observer::observer_pid_namespace_inode() -> Option<u64>— returns the observer’s cached PID-namespace inode (Linux only).Observer::with_allow_cross_namespace(bool) -> Self— opt out of the default refuse-and-audit behaviour. Wired from--allow-cross-namespace-agents.Observer::drain_cross_namespace_drops() -> u64— counter drain.Observer::drain_namespace_conflicts() -> u64— counter drain.Observer::drain_pid_recycles() -> u64— counter drain (PID-recycle slot resets/retirements).Tracker::record_with_generation(frame, now_ns, threshold_ns, origin, peer_pid_ns_inode, peer_generation)— the generation-aware record path;Tracker::record(..)is a shim passingpeer_generation = None.Tracker::pid_ns_inode_of(pid: u32) -> Option<Option<u64>>— observer-side introspection.Recovery::with_allow_cross_namespace(bool) -> Self— same opt-out at the recovery layer.Recovery::on_stall(pid, origin, cross_namespace_agent: bool)— caller-supplied cross-ns flag (typically derived fromEvent::Stall::pid_ns_inodevsObserver::observer_pid_namespace_inode()).Recovery::take_refused_cross_namespace() -> u64— counter drain.RecoveryOutcome::RefusedCrossNamespace { pid }— refusal variant.
CLI flags
--allow-cross-namespace-agents Permit beats and recovery for agents whose
kernel-attested PID namespace differs from
the observer's. Default off — beats dropped
at receive (counted) and recovery refused
(audit + counter).
--strict-namespace-check Fatal startup error on first cross-namespace
beat. Default off — log + counter only.
Edge cases
/proc/<peer_pid>/ns/pidunreadable (ptrace_may_accessdenial, peer exited betweenrecvmsgandreadlink,/procnot mounted): the helper returnsNone. The tracker’sNone → Someupgrade allows one-shot recovery so a transient/procunavailability does not pin a slot as permanently unknown./proc/<peer_pid>/statunreadable on first contact: the helper returnsNone, so the beat is tracked asSocketModeOnlyuntil a later accepted Linux UDS beat can pinSome(generation). Missing generation remains fail-open only after a slot already has recovery-eligible identity pinned.- Existing
frame.pid != peer_pidcheck fires first for most real cross-namespace traffic (the two namespaces almost always produce different numeric pids for the same process). The namespace gate is belt-and-suspenders for the surprising case where the pids happen to collide. unsafe_code = "deny"is workspace-wide. The newreadlinkFFI follows the establishedpeer_cred.rspattern (extern "C"+ one-lineunsafe { ... }blocks with a SAFETY comment).- Frame ABI is unchanged — the 32-byte
Frameis not touched. All state lives observer-side.
Cross-references
vlp-transports.md— overall transport model.peer-authentication.md— kernel-attested PID and theBeatOrigintrust classification.pid_namespaces(7)anduser_namespaces(7)man pages — kernel reference.
Recovery audit log (schema v2)
The recovery audit log (varta-watch/src/audit/) is the canonical
forensic record of every recovery action the daemon took or refused. It
exists to satisfy three operational requirements:
- Traceability. For an IEC 62304 Class C device — or an aviation ground-station — every recovery action must be reconstructable after the fact: what was spawned, when, why, with what outcome.
- Survivability. A power cut on the host must not silently drop the most recent audit records.
- Tamper-evidence. A reviewer must be able to detect retroactive editing of historical records.
Schema v1 (the pre-2026 format) satisfied only the first of these. Schema
v2 — the current format — satisfies all three when the daemon is built
with the audit-chain feature.
File format
One file-level header line, then one record per line. Fields are
tab-separated. Every record kind carries a leading seq column and a
trailing chain column. Free-form fields (program paths, refusal
reasons) have their \t, \n, and \r bytes replaced with a single
space at write time so a maliciously-chosen argv[0] can never inject
columns.
# varta-watch recovery audit v2
boot
seq wallclock_ms observer_ns boot daemon_pid prev_chain|- reason chain
A boot record opens every audit-log session and every post-rotation
generation. The reason column carries one of six stable tokens:
| reason | when it fires | prev_chain |
|---|---|---|
fresh | brand-new file with no prior content | - |
resume | clean v2 tail from a prior session | last chain |
legacy_v1 | existing file uses v1 schema; v2 section starts here | - |
corrupt_tail | v2 file with a torn last record (kernel partial write); the file is ftruncate’d to the last newline before this record is appended | last good chain if recoverable, else - |
schema_drift | header is neither v1 nor v2 | - |
rotation | rotation generation roll | last chain of pre-rotation file |
spawn
seq wallclock_ms observer_ns spawn agent_pid child_pid mode program source template_len chain
Emitted at the moment a recovery child is fork(2) + execvp(2)’d.
mode is always "exec" (shell mode was permanently removed); program
is argv[0]; source is either the literal "inline" (for
--recovery-exec) or the path-string for --recovery-exec-file.
The full argv is not logged — it may contain secrets, and the source
path is already auditable.
complete
seq wallclock_ms observer_ns complete agent_pid child_pid outcome exit_code|- signal|- duration_ns stdout_len stderr_len truncated chain
Emitted on reap, kill-after-timeout, or reap failure. outcome is one of
reaped, killed, reap_failed. exit_code and signal are mutually
exclusive: at most one is a number, the other is -.
refused
seq wallclock_ms observer_ns refused agent_pid reason chain
Emitted when a stall is detected but recovery is structurally declined
(e.g. unauthenticated transport, cross-namespace agent). reason is a
stable short token so SIEM consumers can alert on it without parsing
free text.
Current reason tokens:
| reason | meaning |
|---|---|
unauthenticated_transport | Recovery was refused for a non-attested transport. |
cross_namespace_agent | The agent PID namespace differed from the observer namespace. |
socket_mode_only | The platform can only enforce socket-file mode, not per-datagram credentials. |
debounced | A same-lineage recovery was still inside its debounce window. |
outstanding_in_flight | A same-lineage recovery child was already running. |
debounce_capacity | The debounce ledger was full and could not preserve the debounce invariant. |
outstanding_capacity | The outstanding-child table was full. |
orphan_reap_capacity | PID-recycle reclaim could not move another stale child into the bounded orphan reaper. |
stale_child_kill_failed | PID-recycle reclaim could not prove the previous lineage’s recovery child was stopped. |
spawn_failed | The recovery command failed before a child was created. |
skipped_agent_resumed | A deferred stall was skipped because the agent resumed before recovery fired. |
skipped_pid_recycled | A deferred stall was skipped because the PID was recycled before recovery fired. |
skipped_stall_unverifiable | A deferred kernel-attested stall could not prove PID generation freshness at fire time. |
Sequencing
seq is a u64 starting at 1 on the first boot record. It is strictly
monotonic within a daemon lifetime and across daemon restarts (the
new daemon resumes from last_seq + 1 after parsing the existing tail).
A consumer detects record loss as a gap: seq[i+1] - seq[i] > 1.
Durability cadence
Every record_* call is followed by BufWriter::flush() and
File::sync_data() (= fdatasync(2) on Linux) at a configurable cadence
controlled by --recovery-audit-sync-every <N>:
N = 1(default, IEC 62304 Class C-conforming): onefdatasyncper record.N > 1: onefdatasyncperNrecords. The daemon emits a startup warning and the build is not Class C-conforming. Up toN - 1records can be lost on power cut.N = 0: rejected at parse time.
In addition, the daemon unconditionally syncs:
- Before every rotation rename.
- After writing the post-rotation
bootrecord. - In
Drop(best-effort; not load-bearing for correctness).
Directory-entry durability
fsync(2) on the audit file does not persist the directory entry that
names it. The daemon therefore also fsyncs the audit file’s parent
directory: once at startup in create (a freshly-created file would
otherwise vanish entirely on power cut — including records whose
fdatasync had already returned), and once per rotation in a dedicated
final SyncingDir state-machine stage covering the generation renames,
the new live file’s create_new, and the EXDEV fallback’s copy/unlink
pair. The stage runs behind its own budget check so the Finalizing tail
keeps the exact two-fsync cost its --audit-rotation-budget-ms model is
sized for. A directory-fsync failure is a soft durability degradation
latched on the audit error channel, mirroring the UDS-bind posture.
File identity hardening
Audit startup opens the live path once with O_NOFOLLOW, verifies the opened
inode is a regular file owned by the observer UID with exactly one hard link,
then uses that same descriptor for tail recovery, optional truncation, and all
later appends. Leaf symlinks and multiply-linked files are rejected before any
audit bytes are changed.
Rotation creates every new live generation and EXDEV copy destination exclusively with mode 0600. The EXDEV fallback copies from a clone of the writer’s already-validated descriptor, not from a second pathname open, and keeps the exclusive destination descriptor as the new sink.
Tamper-evidence: the hash chain
When the daemon is built with --features audit-chain, every record’s
trailing chain column is the lowercase-hex SHA-256 of:
DOMAIN || 0x00 || kind || 0x00 || prev_chain_raw || 0x00 || body_with_seq
where:
DOMAIN = b"VARTA-AUDIT-v2". The trailingv2is the schema version; a future v3 mandatorily bumps this so chains across schemas cannot be confused.kindis the bytesb"boot"/b"spawn"/b"complete"/b"refused".prev_chain_rawis the raw 32-byte prior chain hash (not its hex form), or[0u8; 32]for the very first record in a fresh file.body_with_seqis the TSV line from theseqcolumn up to (but not including) the chain column — no trailing\n.- Four
0x00separators prevent field-boundary confusion: e.g.(kind="ab", body="cd")and(kind="abcd", body="")hash to distinct strings.
The construction is implemented once in
crates/varta-vlp/src/crypto/hash.rs::audit_chain_hash so callers
cannot accidentally drop the domain separation or transpose the input
order.
What this detects
- Any byte edited in any historical record. The edited record’s own chain stops matching, and every subsequent chain also stops matching.
- Any record deleted. The chain breaks at the deletion point.
- Any record inserted. Same — the chain over the synthetic record cannot match the next legitimate record.
- Records reordered. The chain validates only in original order.
What this does NOT detect
A pure SHA-256 hash chain — without a secret key — can be recomputed
end-to-end by an attacker with write access to the file. Tampering is
only detectable when the latest chain head is verified against an
externally trusted source. Operators in safety-critical deployments
should periodically export tail -1 audit.log | cut -f<last> to a
sealed log (Tang, AWS S3 with object-lock, a hardware HSM, etc.). The
daemon does not do this — it is an operational policy decision.
A future HMAC-keyed mode is out of scope for v2 to avoid forcing a key-distribution workflow on every Class C deployment.
When audit-chain is disabled
If the daemon is built without --features audit-chain:
- The
chaincolumn is the literal string-. - The daemon emits a startup warning explicitly stating that the build is not IEC 62304 Class C-conforming.
seqandfdatasynccadence still work — record loss is detectable; power-cut durability is preserved; only tamper-evidence is absent.
The build remains zero-registry-dep (the audit-chain feature
propagates the existing optional crypto deps in varta-vlp/crypto).
Rotation
When --recovery-audit-max-bytes <N> is set, the file rotates after any
write that pushes it over the threshold: PATH → PATH.1 → … →
PATH.5. Five generations are kept; the oldest is unlinked. The same
generation count as the event-stream FileExporter.
The chain spans rotation: the first non-header record in the new
generation is a boot with reason=rotation whose prev_chain column
is the final chain of the just-rotated file. A reviewer who pieces
generations together by seq order can replay-verify the chain across
the entire history.
Verification recipe
# 1. Confirm seq is strictly monotonic across all generations.
cat audit.log.5 audit.log.4 audit.log.3 audit.log.2 audit.log.1 audit.log \
| grep -v '^#' \
| awk -F'\t' 'NR==1 { prev = $1; next } $1 != prev+1 { print "GAP at seq", $1; exit 1 } { prev = $1 }'
# 2. Confirm chain validates (requires the daemon's
# audit_chain_hash helper exposed in a verification tool — out of scope
# for the daemon binary itself, see book/src/architecture/peer-authentication.md
# for the pattern).
# 3. Cross-check that the chain head matches the latest sealed-log entry
# the operator exports to their trusted store.
CLI surface
| Flag | Required | Default | Meaning |
|---|---|---|---|
--recovery-audit-file <PATH> | no | unset | Append audit records to PATH. Created mode 0600; leaf symlinks and multiply-linked files are rejected. |
--recovery-audit-max-bytes <N> | no | unbounded | Rotate after a write that pushes the file past N bytes. |
--recovery-audit-sync-every <N> | no | 1 | fdatasync cadence. 1 is the only Class C-conforming value. |
--audit-fsync-budget-ms <MS> | no | 50 | Soft per-call budget for one fdatasync(2). Overruns defer further fsyncs in the current drain to next tick; the poll loop never blocks on more than one slow fsync per tick. |
--audit-sync-interval-ms <MS> | no | 0 | Time-based fdatasync cadence. 0 disables; with a non-zero value the drain force-syncs after this many ms have elapsed since the last sync (in addition to --recovery-audit-sync-every). |
--audit-rotation-budget-ms <MS> | no | 50 | Per-tick budget for the rotation state machine. Overruns preserve progress and resume on the next maintenance tick. |
Durability vs availability
The default configuration is unchanged from Class C semantics:
--recovery-audit-sync-every=1 + --audit-sync-interval-ms=0 means
every record fsyncs before the drain returns, and
--audit-fsync-budget-ms=50 only ever takes effect when a single
fsync exceeds 50 ms — i.e. when the disk is already stalling the
poll loop. The new flag does not weaken durability for safety-critical
operators; it provides the structural guarantee that the poll loop
itself cannot block indefinitely on a wedged fsync.
Operators who can accept relaxed durability (e.g. cloud SRE
deployments, not safety-critical) set
--recovery-audit-sync-every=64 --audit-sync-interval-ms=100 to
amortise fsync cost over many records while still pinning a
worst-case sync interval.
See observer-liveness.md for the audit-log observability signals and recommended alerts.
Threat model
| Threat | Detected? | Mechanism |
|---|---|---|
| Record loss from buffer-only flush + power cut | yes | seq gap; durability cadence; rotation pre-rename sync |
| Record loss from process kill | yes | seq gap; resume boot on restart |
| Single record edit (any byte) | yes (with chain) | hash chain divergence |
| Bulk re-write by attacker with file-write access AND chain re-computation | no | requires an external sealed chain-head log |
| Schema downgrade (v2 → v1) | yes | schema_drift boot or first-line header check |
| Replay of a captured audit file in a different deployment | yes (with chain) | initial prev_chain = [0; 32] differs per host/lifetime |
Compile-time Configuration (Class-A profile)
The Class-A safety-critical profile builds varta-watch with the
compile-time-config Cargo feature. In this profile the runtime binary
has no argv parser, no Prometheus HTTP exporter, and a single
neutral --help body that mentions no flag names. Every operational
knob is supplied at compile time by build.rs from a static
KEY = VALUE file pointed to by the VARTA_CONFIG_FILE environment
variable.
The Class-A binary is verified by the CI safety-profiles job:
B=target/release/varta-watch
strings "$B" | grep -E -- "(GET /metrics|HTTP/1\.|--[a-z])"
# expect: no output
When to use this profile
- Hospital VLAN deployments where every CVE surface is a liability.
- IEC 62304 Class C medical devices (insulin pumps, holter monitors, ventilators) where the host configuration is part of the validated firmware.
- Avionics / industrial-control systems where the binary must boot from a signed image and accept no operator input post-deployment.
For SRE / cloud deployments use the default-feature build (or
--features prometheus-exporter for /metrics). The two profiles are
mutually exclusive at compile time via a compile_error! guard in
crates/varta-watch/src/lib.rs.
Build recipe
export VARTA_CONFIG_FILE=/etc/varta/varta.conf
cargo build -p varta-watch --release \
--no-default-features --features secure-udp,compile-time-config
secure-udp is the recommended companion feature — Class-A almost
always wants authenticated transport. Other features that combine
cleanly with compile-time-config: audit-chain, json-log.
The prometheus-exporter feature is forbidden in combination with
compile-time-config; cargo build fails with a clear compile_error!
diagnostic.
File grammar
Plain text, UTF-8. Lines that begin with # or are entirely whitespace
are ignored. Each remaining line is KEY = VALUE:
- The
=separator may have any amount of whitespace on either side. KEYmust be in theKNOWN_KEYScatalogue (see below).VALUEis the rest of the line after the first=, trimmed.- Quoting is not supported — paths and strings are taken verbatim.
- Repeated singleton keys are a build error; repeated list keys
(
recovery_env) accumulate. - Unknown keys are a build error that surfaces during
cargo build. - Cross-field safety rules are also build errors:
udp_portrequires a secure key source, recovery on secure UDP requiresi_accept_recovery_on_secure_udp = true, and non-loopback secure UDP requiresi_accept_secure_udp_non_loopback = true.
Example:
# /etc/varta/varta.conf
socket = /run/varta/varta.sock
threshold_ms = 5000
socket_mode = 0600
# Recovery: exec-mode only, never shell.
recovery_exec_cmd = /usr/local/sbin/varta-recover {pid}
recovery_audit_file = /var/log/varta/recovery.tsv
recovery_audit_sync_every = 1
# Authenticated UDP listener bound to loopback.
udp_port = 8443
udp_bind_addr = 127.0.0.1
secure_key_file = /etc/varta/agent.key
# Hospital deployment: medical-device clock semantics + strict mode.
clock_source = boottime
strict_namespace_check = true
Accepted keys
| Key | Type | Default | Notes |
|---|---|---|---|
socket | path | required | UDS path the observer binds. |
threshold_ms | u64 | required | Per-pid silence window. Minimum 10. |
socket_mode | octal | 0600 | UDS file mode after bind. |
read_timeout_ms | u64 | 100 | UDS read timeout per poll call. |
udp_port | u16 | none | Bind a UDP listener on this port. |
udp_bind_addr | ip | runtime default | Loopback for secure-UDP; 0.0.0.0 for plaintext. |
secure_key_file | path | none | 64-hex-char primary key (secure-udp). |
accepted_key_file | path | none | One key per line for rotation. |
master_key_file | path | none | 64-hex-char master for per-agent derivation. |
recovery_exec_cmd | string | none | program args … invoked via execvp. |
recovery_exec_file | path | none | Read recovery_exec_cmd from a hardened file. |
recovery_debounce_ms | u64 | 1000 | Per-pid debounce window. |
recovery_env | list-of-string | empty | KEY=VALUE; repeatable. Layered on top of the base env chosen by recovery_inherit_env. |
recovery_inherit_env | bool | false | Inherit observer env into recovery children (legacy). Default-secure clears env to PATH=/usr/bin:/bin. |
recovery_timeout_ms | u64 | none | Kill-after deadline for recovery children. |
recovery_audit_file | path | none | TSV recovery audit log. |
recovery_audit_max_bytes | u64 | none | Audit-file rotation byte cap. |
recovery_audit_sync_every | u32 | 1 | fdatasync cadence (1 = every record). |
recovery_capture_stdio | bool | false | Capture child stdio for audit. |
recovery_capture_bytes | u32 | 4096 | Stdio capture cap. Max 1048576. |
file_export | path | none | TSV event-stream sink. |
export_file_max_bytes | u64 | none | Event-file rotation cap. |
heartbeat_file | path | none | Per-tick liveness file. |
tracker_capacity | usize | 256 | Max tracked PIDs. |
tracker_eviction_policy | enum | strict | strict or balanced. |
eviction_scan_window | usize | 256 | Max slots scanned per eviction attempt. Range [1, 4096]. |
max_beat_rate | u32 | none | Per-pid beats/sec cap. |
clock_source | enum | monotonic | monotonic or boottime (Linux only). |
iteration_budget_ms | u64 | 250 | Per-iteration soft budget. Range [50, 60000]. |
scrape_budget_ms | u64 | 250 | Per-serve_pending budget; values below the built-in structural cap also bound live scrape work. Range [50, 60000]. |
shutdown_after_secs | u64 | none | Self-terminate after this uptime. |
shutdown_grace_ms | u64 | 5000 | Drop blocking time during shutdown. Range [100, 60000]. |
self_watchdog_secs | u64 | none | Self-watchdog deadline (auto-enables under systemd). |
hw_watchdog | path | none | Hardware watchdog device (/dev/watchdog). |
i_accept_plaintext_udp | bool | false | Runtime acknowledgement. |
i_accept_recovery_on_secure_udp | bool | false | Required when secure UDP is combined with recovery. |
i_accept_recovery_on_plaintext_udp | bool | false | Recovery on plaintext UDP. |
i_accept_secure_udp_non_loopback | bool | false | Required when secure UDP binds a non-loopback address. |
allow_cross_namespace_agents | bool | false | Permit cross-PID-namespace beats. |
strict_namespace_check | bool | false | Fatal exit on cross-namespace agent. |
inject_wedge_ms | u64 | none | Test-hooks only (requires test-hooks feature). |
Operational contract
--help(and any other argv) is rejected at startup. The binary exits non-zero with the neutral diagnostic “this binary was configured at compile time; refusing to accept command-line arguments”.- Diagnostic messages in stderr / sd_notify use neutral wording — no
--flag-namestrings appear anywhere in the binary. See the cerebrum entry onpub const &strbeing unconditionally linked for the rationale. - The configuration file is consumed once, at
cargo buildtime. The resulting binary is immutable: redeployment requires a new build. This is the structural feature operators rely on for Class-A release-gating.
See also
- Safety profiles overview
- Peer authentication — key-file requirements
- Observer liveness — self-watchdog wiring
Safety Profiles
varta-watch ships with a two-layer gate for every structurally-dangerous
capability: a compile-time Cargo feature that must be explicitly enabled,
AND a runtime flag that must be passed by the operator. Neither layer
alone is sufficient; both must be active.
This document defines what “production-safe” means for Varta and how to verify a binary before deploying it to a safety-critical environment.
Profile matrix
| Profile | Features | argv | /metrics | Recovery |
|---|---|---|---|---|
| SRE / cloud | prometheus-exporter (+ optional unsafe-*) | full GNU-style parser | HTTP /metrics + Bearer-token | exec only |
| Class-A safety-critical | secure-udp,compile-time-config | none (build-time fixed) | absent | exec only |
The two profiles are mutually exclusive: prometheus-exporter cannot
combine with compile-time-config (a compile_error! in
crates/varta-watch/src/lib.rs rejects the combination at build time).
This is the structural guarantee Class-A builds rest on — the Class-A
binary cannot ship with an HTTP server linked in.
Production-safe build
A production-safe varta-watch binary is built with default features only:
cargo build -p varta-watch --release
No --features argument is needed or wanted. Default features are empty.
What is absent from a production-safe build
| Dangerous capability | Cargo feature | Runtime flag |
|---|---|---|
| Plaintext (unauthenticated) UDP listener | unsafe-plaintext-udp | --i-accept-plaintext-udp |
Shell-mode recovery (/bin/sh -c) has been permanently removed from all
build profiles. /bin/sh does not appear in any varta-watch binary,
regardless of feature flags. Use --recovery-exec for all recovery
configurations.
Without the compile-time feature, the code path is not linked into the binary. A misconfigured deployment cannot accidentally enable the dangerous path at runtime.
Verification recipe
cargo build -p varta-watch --release
strings target/release/varta-watch | grep -F "/bin/sh" && echo "FAIL" || echo "OK"
The strings check is belt-and-suspenders: because /bin/sh is structurally
absent, the literal cannot appear in any binary regardless of features.
Unsafe features
unsafe-plaintext-udp
Compiles in the plaintext UdpListener transport. Any device with network
access to the bound port can inject heartbeats, suppress stall detection, or
trigger false recovery commands.
# varta-watch/Cargo.toml
[features]
unsafe-plaintext-udp = ["udp-core"]
Even with this feature, the listener will not bind unless
--i-accept-plaintext-udp is also passed at runtime.
This feature is structurally unavailable in Class-A builds —
compile-time-config + unsafe-plaintext-udp is rejected by a
compile_error! in crates/varta-watch/src/lib.rs. Mission-critical
deployments must use secure-udp (AEAD-authenticated, bounded per-sender
replay state) for any UDP transport; plaintext UDP has no replay protection and
can be used by a network attacker to suppress stall detection.
Class-A safety-critical features
prometheus-exporter (opt-in HTTP exposition)
The Prometheus /metrics endpoint, the bearer-token loader, the per-IP
rate-limit table, and every --prom-* argv flag live behind this
feature. When absent the binary contains zero HTTP / TCP-accept code
and the only exporter linked is FileExporter (one-way append-only
TSV sink — no listener, no network surface).
[features]
prometheus-exporter = []
Verification recipe (default build, feature off):
cargo build -p varta-watch --release
B=target/release/varta-watch
strings "$B" | grep -E -- "(GET /metrics|HTTP/1\.|WWW-Authenticate|Bearer realm)" \
&& echo "FAIL" || echo "OK"
compile-time-config (no argv parser, no runtime config)
Replaces the runtime argv parser with a build-script-generated constant
populated from $VARTA_CONFIG_FILE (a KEY = VALUE text file). When
the feature is on:
Config::from_argsis excluded from compilation; the 292-arm match block carrying every--flag-nameliteral is not linked.Config::HELPis a neutral one-liner that contains no flag names.- The binary refuses any argv tokens with
CompileTimeArgvForbidden.
Cannot be combined with prometheus-exporter, libc-signal-mode, or
unsafe-plaintext-udp — each combination is rejected at compile time by
a dedicated compile_error! in lib.rs. The Class-A binary is
structurally free of the HTTP exporter, the libc-signal indirection, and
the plaintext UDP listener; none of those code paths can be linked in.
export VARTA_CONFIG_FILE=/etc/varta/varta.conf
cargo build -p varta-watch --release \
--no-default-features --features secure-udp,compile-time-config
Verification recipe:
B=target/release/varta-watch
FORBIDDEN="GET /metrics|HTTP/1\.|WWW-Authenticate|--socket|--prom-addr|--help|--i-accept|/bin/sh"
strings "$B" | grep -E -- "$FORBIDDEN" && echo "FAIL" || echo "OK"
See compile-time-config.md for the canonical KEY=VALUE grammar and key catalogue.
Recommended transport for recovery
Use --recovery-exec for all recovery deployments. It invokes the program
directly via execvp(2) with no shell involved; shell metacharacters have no
effect and /bin/sh is never spawned.
Miri policy
Miri (cargo miri test) runs on every push under -Zmiri-strict-provenance and covers
the three unsafe-code clusters that cannot be audited by reading alone:
| Cluster | Miri target | What it proves |
|---|---|---|
peer_cred cmsg pointer-walk | cargo miri test -p varta-watch --lib peer_cred | No UB in the hand-written cmsghdr traversal; synthetic buffers only — no syscalls |
| Tracker slot-index arithmetic | cargo miri test -p varta-watch --lib tracker | No out-of-bounds indexing or stale pointer reads in the fixed-capacity slot array |
| Client classifier | cargo miri test -p varta-client --test classifier | BeatError is Copy-safe and errno extraction has no provenance issues |
Tests that require real syscalls (Unix datagram bind, recvmsg, process spawn) carry
#[cfg_attr(miri, ignore)] so they are silently skipped when Miri runs, without
requiring a separate test-filter command.
Clock source for stall detection
Stall threshold accounting depends on a monotonic time source. Which “monotonic” is correct depends on the deployment profile:
| Profile | --clock-source | Rationale |
|---|---|---|
| SRE / cloud server / VM | monotonic (default) | CLOCK_MONOTONIC pauses on host suspend, hypervisor pause, and live-migration freeze. A 30-minute host-suspend-for-maintenance must NOT fan out a stall alert across every agent. |
| Medical implant / holter / insulin pump (Linux) | boottime (Linux only) | CLOCK_BOOTTIME advances during suspend. A 4-hour deep-sleep IS a 4-hour silence; stall detection MUST fire on wake-up regardless of whether the device suspended itself. |
| Embedded sensor with deep sleep (Linux) | boottime (Linux only) | Same as medical — battery-conscious devices that aggressively suspend need stall semantics that count the suspended time. |
| macOS / iOS-hosted device with sleep semantics | monotonic-raw (macOS / iOS only) | CLOCK_MONOTONIC_RAW on Darwin is backed by mach_continuous_time and advances through sleep — the Darwin equivalent of Linux’s CLOCK_BOOTTIME. |
Platform support
boottime semantics require Linux’s CLOCK_BOOTTIME clock (clk_id 7,
available since 2.6.39). The Darwin equivalent is CLOCK_MONOTONIC_RAW
(clk_id = 4), backed by mach_continuous_time; it advances through
sleep just like CLOCK_BOOTTIME. Because the same numeric clk_id = 4
on Linux refers to CLOCK_MONOTONIC_RAW with different semantics (it
opts out of NTP slewing but still pauses during suspend), the two are
exposed as distinct ClockSource variants — boottime (Linux only) and
monotonic-raw (macOS / iOS only) — and each is rejected at startup on
the other family with ConfigError::ClockSourceUnsupported.
BSD operators have only monotonic: no kernel clock on FreeBSD /
NetBSD / OpenBSD / DragonFly advances through suspend in a way usable
by clock_gettime(2).
Example rejection messages:
clock source `boottime` is not supported on `macos` (Linux only; on
macOS use `monotonic-raw` for advance-through-sleep semantics)
clock source `monotonic-raw` is not supported on `linux` (macOS / iOS
only; on Linux use `boottime` for advance-through-sleep semantics)
This is structural enforcement: a misconfigured medical-device deployment exits non-zero rather than silently picking a clock that pauses on sleep.
Self-watchdog alignment
The in-process self-watchdog (--self-watchdog-secs) reads the same kernel
clock as the observer. An operator who configures boottime for the
observer gets watchdog deadline accounting that also advances during
suspend; an SRE operator on monotonic gets identical-to-historical
watchdog behaviour minus the previous wall-clock NTP-backward-step
foot-gun.
Verification recipe (Linux)
# Confirm the configured clock source is in effect.
journalctl -u varta-watch | grep -i 'clock' # binary logs no startup banner today;
# operators can read /proc/<pid>/maps
# to confirm clock_gettime imports.
# Behavioural smoke test — requires a real suspend / resume cycle:
systemctl suspend && sleep 60 && systemctl resume
curl -fsS http://localhost:9090/metrics -H "Authorization: Bearer <hex>" \
| grep -E 'varta_(stall_total|beats_total|watch_uptime_seconds)'
# Expect: with --clock-source boottime, varta_stall_total advanced during the
# suspend window; with --clock-source monotonic, it did not.
Cross-reference
The secure-udp transport applies the same “no surprises on the beat path”
posture: the IV-prefix derivation (H6) reads OS entropy only at connect()
and reconnect() — every steady-state beat uses a deterministic HKDF
counter-mode expansion. Together, H6 + H7 keep the agent and observer
loops free of any syscall that can block or stall under suspend.
Cross-references
- Observer liveness — defending against
varta-watchitself crashing or hanging - Peer authentication — kernel-level PID attestation and transport trust classification
Supply-Chain Posture
Varta is a safety-critical health-protocol library — the integrity of every dependency in the resolved graph is part of the safety case. This page documents the four pillars that defend that boundary and the procedure for moving them.
1. Production crates are dep-free
varta-client and varta-watch carry zero registry dependencies. The
[dependencies] section of each crate’s Cargo.toml is empty; the project
CI fails if a non-empty line ever appears there
(.github/workflows/ci.yml → zero-dep audit (varta-client) and zero-dep audit (varta-watch)). The only registry deps in the workspace live in
varta-vlp, and only when the optional crypto feature is enabled.
2. Direct crypto deps are exact-pinned
crates/varta-vlp/Cargo.toml declares the four optional crypto deps with
exact patch-version constraints:
chacha20poly1305 = { version = "=0.10.1", default-features = false, optional = true }
hkdf = { version = "=0.12.4", default-features = false, optional = true }
sha2 = { version = "=0.10.9", default-features = false, optional = true }
zeroize = { version = "=1.8.2", default-features = false, optional = true, features = ["derive"] }
The = prefix forbids caret/tilde resolution. A new minor or patch release
on crates.io cannot flow into a fresh checkout without a reviewable
Cargo.toml edit. The resolver, the Cargo.lock, and the reviewer’s
mental model agree exactly on which version is in use.
3. cargo-deny audits the transitive graph
deny.toml at the repo root configures four checks, gated by CI in the
supply-chain job:
| Section | Policy |
|---|---|
[advisories] | yanked = "deny". Any new RUSTSEC advisory or yanked crate in the resolved graph fails CI. |
[licenses] | OSI-permissive only: MIT, Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-2/3-Clause, ISC, Unicode-3.0, Zlib, Unlicense. No GPL/LGPL/AGPL fallback. |
[bans] | multiple-versions = "deny", wildcards = "deny". Explicit skip = [ getrandom ] because rand_core 0.9 and tempfile 3.x pull divergent majors in dev-deps only. |
[sources] | Only crates-io is allowed. unknown-registry and unknown-git are hard-denied — no [patch.crates-io.git = "..."] regression vector. |
cargo-deny itself is pinned in the CI install step (cargo install --locked --version 0.19.6 cargo-deny); the tool is part of the trusted
compute base. The minimum version is set by the RUSTSEC advisory
database — entries using CVSS:4.0 syntax (e.g. RUSTSEC-2026-0073) fail
to parse on cargo-deny < 0.19.
4. CI always passes --locked
Every cargo build, cargo test, cargo clippy, cargo run, and cargo miri test invocation in .github/workflows/ci.yml passes --locked.
This refuses any build whose resolver wants to update Cargo.lock. The
lockfile in main is the only one that builds.
The previous CI used the default resolver behaviour, which would silently
regenerate Cargo.lock whenever the manifest constraint admitted a newer
release. Combined with caret pins, that path let a compromised
chacha20poly1305 0.10.99 propagate to a green CI run. The = pin plus
--locked closes that gap from both sides.
Dep-bump procedure
To bump any direct crypto dep:
- Read the upstream changelog for the target version. RustCrypto crates ship security-relevant fixes in patch releases — note any CVE refs in the PR body.
- Edit
crates/varta-vlp/Cargo.tomland change the=X.Y.Zconstraint to the new version. - Run
cargo update -p <crate>to refreshCargo.lock. The lockfile change must be committed in the same PR. - Run
cargo deny checklocally. License or advisory changes in the new release fail this step. - Run the full SRE feature lane locally (
cargo test --workspace --locked --features '<...>'). Workspace tests + thevarta-testsend-to-end harness must stay green. - Open a PR with title
deps: bump <crate> X.Y.Z → X.Y.Wand link the upstream changelog in the body.
The same procedure applies to bumping cargo-deny itself — edit the
version in .github/workflows/ci.yml and document the change.
Why not cargo vet (yet)
cargo vet extends auditing to transitive trust: every crate version
needs an attestation from a trusted reviewer. Compelling, but it
multiplies reviewer burden across the entire dep graph and pulls
Anthropic / Mozilla / Bytecode-Alliance audit imports into the project.
Tracked as a follow-up; cargo-deny is the current line.
Bounded Collections
Varta’s observer is a single-threaded poll loop on the hot path, and a
DO-178C-style audit target on the cold paths. Both contexts require
every map-like structure to have a tight worst-case execution time
(WCET) bound. std::collections::HashMap is incompatible with this
posture for two reasons:
- SipHash randomisation.
HashMapre-seeds its hasher on every process start, so the per-process memory-access pattern is non-constant. Static WCET analysis cannot bound a structure whose probe sequence depends on a runtime random value. - Theoretical rehash. Even when pre-allocated with
HashMap::with_capacity, growth on collision-driven load is reachable in theory — a rehash event would blow the per-tick budget.
varta-watch therefore uses a custom, registry-dep-free
BoundedIndex<K> for every map-like structure, with thin
purpose-built wrappers for each call site.
BoundedIndex<K>
Defined in crates/varta-watch/src/probe_table.rs.
- Open-addressed
K → u32table. - Hash function: Murmur3 32-bit finalizer (
mix32) — deterministic across processes, branchless, good avalanche on 32-bit and IpAddr inputs (the only twoHash32implementors today). - Table size:
next_power_of_two(capacity * 2)— load factor stays ≤ 0.5 at peak, so the expected probe distance is ≤ 2 (Knuth TAOCP 6.4). - Probe budget: hard cap of 64 steps
(
BoundedIndex::MAX_PROBE) — ~32× headroom over the expected distance. Everyget/insert/removewalks at most this many slots before returningNone(lookup) orErr(ProbeExhausted)(insert). - Tombstone-aware: removal writes a sentinel rather than back-shifting, preserving in-flight probe chains. Inserts reuse the first tombstone they encounter so long churn doesn’t fill the table with deletion markers.
- Entry layout:
slot_idx == u32::MAX⇒ empty;slot_idx == u32::MAX - 1⇒ tombstone; otherwise the slot is occupied andkeyis initialised. This letsEntry<u32>stay 8 bytes — same as the pre-refactorPidIndex— so the tracker hot path has the same per-slot cache pressure as before.
BoundedIndex is registry-dep-free and inherits the same
zero-registry-dep posture as the rest of varta-watch.
Wrappers
Three thin types compose BoundedIndex with a value-bearing slab so the
caller can store more than just an integer index:
| Type | Key | Value | Capacity | Replaces |
|---|---|---|---|---|
PidIndex (newtype) | u32 | u32 (slot index) | tracker_capacity | inline PidIndex (legacy HashMap<u32, usize>) |
OutstandingTable<V> | u32 | V (e.g. Outstanding) | tracker_capacity | HashMap<u32, Outstanding> in Recovery |
IpStateTable<V> | IpAddr | V (e.g. PromIpState) | MAX_PROM_IP_STATES = 1024 | HashMap<IpAddr, PromIpState> in PromExporter |
All three:
- Pre-allocate the slab at construction and never reallocate.
- Maintain a free list of slab indices for O(1) insertion.
- Return
Err(Full)/Err(InsertError::Full)on capacity exhaustion; callers surface this as a structured refusal outcome (e.g.RecoveryOutcome::RefusedOutstandingCapacity) and bump a labelled Prometheus counter.
Probe-exhausted counters
Three counters surface probe-budget exhaustion to operators. All three should stay at 0 in correct operation; non-zero values indicate either a hash-collision pathology or a code bug:
varta_tracker_pid_index_probe_exhausted_total— PidIndex (hot path).varta_recovery_outstanding_probe_exhausted_total— OutstandingTable (cold recovery path).varta_prom_ip_state_probe_exhausted_total— IpStateTable (Prometheus accept path).
Fuzzing
Every bounded collection has a dedicated cargo fuzz target driving
arbitrary op sequences against the public API:
fuzz/fuzz_targets/bounded_index_u32.rsfuzz/fuzz_targets/bounded_index_ip.rsfuzz/fuzz_targets/outstanding_table.rsfuzz/fuzz_targets/ip_state_table.rs
The bounded_index_* targets compare against a HashMap oracle on
every op to validate set-equality semantics. The _table targets
assert structural invariants (len <= capacity, iter matches len,
post-remove get is None, etc.). All four targets are exercised at
30 s per push/PR in the CI fuzz-smoke job and at 30 min nightly in
fuzz-nightly.yml with corpus persistence.
Symbolic Verification — varta-vlp::Frame::decode
Rationale
Frame::decode (crates/varta-vlp/src/lib.rs:211) is the single
deserialisation entry point on the observer side of the wire. Every
byte that crosses a Varta socket flows through it. For an IEC 62304 /
DO-178C-grade deployment the cost of a panic, an unhandled byte
pattern, or a field-range gap inside this function is unbounded: the
observer poll loop is single-threaded and a panic terminates the
process.
Empirical coverage on the decode path is already strong:
cargo test -p varta-vlp— 11 integration tests incrates/varta-vlp/tests/frame.rscovering golden-bytes CRC, decode-error precedence, every field-range guard, and single-bit-flip CRC detection.cargo fuzz run frame_decode— libfuzzer against arbitrary 32-byte slices, 30-second smoke per CI run, longer-form runs documented infuzz/README.md.cargo fuzz run frame_roundtrip— encode→decode isomorphism over filtered-valid inputs.cargo miri test -p varta-vlp --all-features— pointer-provenance and undefined-behaviour interpretation of every test.
What none of those layers provide is a universal statement about the function. Fuzzing samples; Miri interprets execution traces; the unit tests exercise hand-chosen byte sequences. Symbolic verification with Kani closes the gap by proving properties over every possible 32-byte input.
Tool choice — Kani
- Mature. Backed by the Model Checking working group; used by rust-stdlib’s verification effort.
no_std-clean. Harnesses compile under the same#![cfg_attr(not(feature = "std"), no_std)]posture as the rest ofvarta-vlp; no allocator, nostd::*types in the proof bodies.- Zero-dep posture preserved. The Kani crate is injected by
cargo kaniat proof time. Nothing is added tocrates/varta-vlp/Cargo.toml, so the zero-registry-dependency audit in.github/workflows/ci.ymlcontinues to pass. - Stable toolchain. Unlike
verusorprusti, Kani runs on the pinned stable toolchain — matchingrust-toolchain.toml.
Creusot and Verus were considered. Both were rejected: Creusot adds a
Why3 toolchain dependency and an annotation burden disproportionate to
this proof surface; Verus requires the verus! macro that does not
coexist cleanly with the #![no_std] library surface.
Split-harness design
A naive single proof of “decode is correct” multiplies three sources of symbolic state:
- The 32-byte input domain (
2^256symbolic values). - The CRC-32C inner loop (28 iterations × 256-entry table lookup ×
u32state). - The seven sequential decode gates (magic, version, CRC, status, stall, pid, timestamp, nonce).
CBMC handles each of these well in isolation; combined, the path count saturates. The harnesses are therefore split so the symbolic cost of each proof stays bounded:
| Harness | What it proves | State scope |
|---|---|---|
crc_detects_bit_flip | Flipping a single bit in [0, 28*8) changes the CRC output | CRC + single bit position |
decode_never_panics | Frame::decode(&[u8; 32]) returns without panicking on every input | Decode, no CRC assumption |
decode_classification | When Ok(frame), all five field-range post-conditions hold | Decode, CRC assumed valid |
encode_decode_roundtrips | For constructable frames, decode(encode(f)) == Ok(f) | Encode + decode |
decode_error_precedence | The first failing gate in the documented order is the one returned | Decode |
Decoupling CRC-correctness from decode-correctness means a bug in either layer surfaces independently, with a focused counter-example.
Why no
crc_compute_is_total(determinism) harness? An earlier version of this suite included one; CBMC needed >19 minutes on it because two symbolic 28-iteration table-lookup expressions over the same input must be proved equivalent at the SMT level. The property is already free in Rust:crc32c::computeispub const fnwith no global state, no allocation, no FFI. The const-asserts incrc32c.rsexercise it concretely on the RFC 3720 reference vector, and panic-freedom is subsumed bydecode_never_panics(which callscomputeon the decode path).
Local invocation
# One-time setup
cargo install --locked kani-verifier
cargo kani setup
# Run all proofs (uses the `default-unwind = 64` from Kani.toml)
cargo kani -p varta-vlp
# Run a single harness (useful when iterating)
cargo kani -p varta-vlp --harness decode_never_panics
The default-unwind = 64 setting in crates/varta-vlp/Kani.toml covers
the 28-iteration CRC inner loop with margin. Decode harnesses only
iterate over fixed-size 32-byte arrays and are unwind-insensitive.
CI integration
The kani-proofs job in .github/workflows/ci.yml runs on every push
and on every PR that touches crates/varta-vlp/** or the workflow
itself. It is a required gate — a failed proof blocks merge. PRs
that do not touch the protocol crate skip the job and short-circuit to
“passing”, keeping turnaround time bounded for unrelated changes.
Job timeout is 30 minutes per harness (matrix fan-out — each harness runs in parallel in its own job). Locally, every per-PR harness runs in under two minutes on Apple Silicon.
Long-form proofs (nightly)
crc_detects_bit_flip is excluded from the per-PR matrix. Two
symbolic 28-iteration table-lookup expressions over inputs that differ
by one of 224 bit positions exceed CBMC’s 30-min budget — locally
observed at >13 min CPU time without completion. It runs in
.github/workflows/kani-nightly.yml with a 6 h budget on a daily
schedule and auto-opens a GitHub issue on failure.
CRC bit-flip detection is also guaranteed by the polynomial
construction of CRC-32C/Castagnoli (HD=8 for short messages); the
nightly harness is a structural sanity check on the table generator
and byte-at-a-time loop, not the source of the cryptographic
guarantee. The const-asserts and RFC 3720 reference vectors in
crc32c.rs already catch any per-PR regression in the table or
algorithm.
Roadmap
These harnesses are the first redemption of the
“Formal Verification: TLA+ or Kani proofs for core state machines”
roadmap item (see ROADMAP.md). Future candidates:
tracker::PidIndex::insert/::get— open-addressed probe-bound proofs that complement the existingvarta_tracker_pid_index_probe_exhausted_totalPrometheus signal.recovery::LastFiredTable::try_insert— eviction-policy proof showing the debounce invariant holds under capacity pressure (companion to the M8 fix landed in the same series).Status::try_from_u8total-coverage — minor; already total by construction, but a one-line harness is cheap.
Deployment ceiling and sharding
A single varta-watch instance is supported up to 4096 concurrently
tracked agents. This chapter is the operator-facing answer to two
questions: how do I detect that I’m approaching that cap? and what
do I do when I need to exceed it?
The 4096 figure is the size of the observer’s fixed-capacity tables, not a saturation point of the poll loop. The two concerns are distinct:
- The poll loop is single-threaded by load-bearing design. See
Stall Detection & Liveness for the rationale
(zero-alloc on the beat path,
&mut selfcorrectness model). The H5 / Issue #9 architectural decisions explicitly rejected splitting the beat path across threads. The horizontal answer is another observer process, not another thread. - The capacity tables are sized at 4096 for adversarial-burst resistance (the M8 debounce-bypass class). See Bounded Collections for why every observer table is a fixed-size array indexed by a bounded probe, and the “Debounce table semantics under load” section of Stall Detection & Liveness for the specific fail-closed behaviour at the ledger cap.
The deployment ceiling per observer instance
Three independent tables enforce the 4096-agent ceiling:
| Table | Constant | Defined in |
|---|---|---|
| Tracker (per-pid) | MAX_CAPACITY = 4096 | crates/varta-watch/src/tracker.rs |
| Debounce ledger | MAX_LAST_FIRED_CAPACITY = 4096 | crates/varta-watch/src/recovery/mod.rs |
| Outstanding children | Sized at construction from tracker capacity (≤ 4096) | crates/varta-watch/src/outstanding_table.rs |
Above 4096 agents on a single observer, the behaviour is graceful degradation, not failure:
- The tracker recycles slots, preferring dead agents (see the
--tracker-eviction-policyflag andvarta_tracker_evicted_total). - The debounce ledger evicts the oldest debounce-expired entry, or
refuses recovery when the oldest entry is still within debounce
(per the fail-closed policy in
observer-liveness.md). - The outstanding-children table refuses additional recovery spawns when full.
Every refusal path increments a stable-label Prometheus counter, so ceiling approach is detectable before it becomes user-visible.
Bench-certified envelope
The benchmark bench_observer_tick_p99_under_five_ms
(crates/varta-bench/src/main.rs) certifies the observer at the
canonical stress profile:
--tracker-capacity 4096(full ceiling)- 30 concurrent agents beating at 100 Hz ≈ 3000 beats/s
TICK_P99_MS_THRESHOLD = 5.0(poll-tick p99 ≤ 5 ms)
A realistic deployment of 4096 agents at typical 1 Hz beat cadence produces 4096 beats/s — within ~37% of the stress profile. The poll loop is not the bottleneck at the documented cap; the cap is structural (adversarial-burst resistance), not throughput-driven.
Detecting ceiling approach via existing metrics
All five capacity-pressure signals are already exported. No new metrics are required to monitor cap proximity:
| Pressure source | Watch metric | Meaning when non-zero |
|---|---|---|
| Tracker fullness | varta_tracker_capacity_exceeded_total | New agent dropped; tracker full |
| Tracker eviction churn | varta_tracker_eviction_scan_truncated_total | Eviction window exhausted without finding a victim |
| Debounce table at capacity | varta_recovery_last_fired_evictions_total | Old debounce ledger entries reclaimed |
| Recovery refused on cap | varta_recovery_refused_total{reason="debounce_capacity"} | Stall couldn’t fire because ledger full |
| Outstanding-children full | varta_recovery_outstanding_probe_exhausted_total | OutstandingTable PID-index probe exhausted |
Two further signals describe the configured envelope rather than pressure:
varta_tracker_capacity(gauge) — the configured ceiling.varta_tracker_evicted_total(counter) — healthy eviction of dead agents. Steady-state non-zero here is benign; co-monitor withvarta_tracker_eviction_scan_truncated_total(which signals the unhealthy case).
Recommended alerts
Three capacity-tier alerts cover the deployment-ceiling failure modes:
VartaTrackerEvictionTruncated(warning) — eviction window exhausting; structural cap approaching.VartaTrackerCapacityExceeded(critical) — new agents being dropped at the cap.VartaOutstandingProbeExhausted(critical) — outstanding-children index probe exhausting under load.
All three ship in
observability/alerts/varta.rules.yml;
see Monitoring & Alerting
for per-alert runbooks and the related liveness-tier rules
(VartaIterationBudgetOverruns, VartaBeatPathP99High) that
operators monitoring deployment scale should configure alongside.
Horizontal sharding pattern
When deployment needs more than 4096 agents on a single host, or wants
high availability across hosts, run multiple independent varta-watch
instances. The observer has no shared state between instances, so this
works without coordination, discovery, or any new code surface.
The pattern is:
-
Run N
varta-watchinstances, each bound to a distinct socket path and a distinct/metricsport. For example, with N = 2:varta-watch --socket /run/varta/0.sock --prom-addr 127.0.0.1:9100 \ --prom-token-file /etc/varta/token --recovery-audit-file /var/log/varta/0.tsv varta-watch --socket /run/varta/1.sock --prom-addr 127.0.0.1:9101 \ --prom-token-file /etc/varta/token --recovery-audit-file /var/log/varta/1.tsvEach instance carries its own 4096-slot ceiling and its own recovery audit log. Audit log paths (
--recovery-audit-file) must be distinct per instance — the file is mode-0600and not designed for cross-process sharing. -
Deployment-side agent fanout. Each agent computes a shard index and connects to the matching socket:
#![allow(unused)] fn main() { let shard = (process::id() as usize) % N; let path = format!("/run/varta/{shard}.sock"); let varta = Varta::connect(path)?; }The agent-side API takes the socket path as an argument at
Varta::connect()time (crates/varta-client/src/client.rs). Varta itself ships no discovery, routing, or sharding helper — deployment owns shard selection. -
Stable PID-based hashing is recommended.
varta-watchcorrelates beats by source PID; hashing on the agent’s own PID guarantees that an agent reaches the same observer for the entire lifetime of its process. Hashing on volatile identity (e.g. a request ID) would scatter an agent’s beats across observers and break stall detection. -
Per-shard Prometheus scraping. Each instance exposes its own
/metrics. Use one Prometheus scrape target per shard. Aggregation across shards (e.g.sum by (instance)) is a query-time concern, not a Varta concern.
Why we don’t fan-out inside one observer
The single-thread invariant for the beat path is a load-bearing project
decision documented in observer-liveness.md and in the H5 / Issue #9
architectural plans. Splitting beat ingestion across worker threads:
- breaks the zero-alloc guarantee on the beat path (shared state
forces atomic or locked access where today there is plain
&mut self), - does not improve stall-detection latency (stalls surface on the same thread that drains them),
- and removes the structural property that today guarantees no cross-pid correctness races inside the per-pid tracker.
If a deployment needs more than 4096 agents on a single host, the
answer is another varta-watch process, not another varta-watch
thread. This chapter documents that path; it is supported, recipe-driven,
and adds no new code surface.
Worked example — 8192-agent deployment
Two observer instances under a systemd template unit
varta-watch@.service with %i ∈ {0, 1}, each owning a 4096-slot
tracker. Agents shard by pid % 2:
agent (pid 12345) → pid % 2 = 1 → /run/varta/1.sock
agent (pid 54321) → pid % 2 = 1 → /run/varta/1.sock
agent (pid 99998) → pid % 2 = 0 → /run/varta/0.sock
Per-instance capacity: 4096 agents. Total deployment capacity: 8192
agents. Each instance independently meters its cap via the metric set
above; an operator alerted by VartaTrackerEvictionTruncated on either
instance knows precisely which half of the deployment is approaching
the ceiling.
The project does not ship a systemd unit today, so the exact unit file is a deployment detail rather than a Varta artefact.
Signal-Handler Installation
varta-watch installs SIGINT and SIGTERM handlers that flip a process-wide
AtomicBool shutdown latch. The installation path is more subtle than a plain
signal(2) call because glibc and musl silently rewrite the sa_restorer field
of struct sigaction — which is load-bearing on x86_64.
Background: why libc cannot be trusted for sa_restorer
When a Linux process receives a signal, the kernel pushes a signal frame onto
the user stack and jumps to the registered handler. On signal-handler return,
the CPU executes the instruction at sa_restorer; the kernel expects this to
call the rt_sigreturn(2) syscall to tear down the frame.
The standard libc wrappers (glibc::sigaction, musl::sigaction) unconditionally
overwrite sa_restorer with their own internal __restore_rt symbol before
passing the action to the kernel. This is correct for libc-linked programs, but
means the kernel ABI is not end-to-end owned — any change to libc’s
__restore_rt (or a statically linked binary that lacks it) would produce a
SIGSEGV on signal return.
For a safety-critical daemon that must detect stalls and trigger recovery, this libc substitution is an unacceptable black-box dependency. The direct path owns the trampoline.
Module layout
crates/varta-watch/src/signal_install/
├── mod.rs — public install(mode, handler) -> io::Result<()>
├── mode.rs — SignalHandlerMode { Direct, Libc }
├── linux/
│ ├── mod.rs — dispatch + compile_error! arch gate + smoke test
│ ├── direct.rs — rt_sigaction(2) direct-syscall path + readback
│ ├── libc_wrapper.rs — libc sigaction(3) fallback (no sa_restorer check)
│ ├── kernel_abi.rs — KernelSigAction per-arch structs + offset asserts
│ ├── syscall.rs — rt_sigaction_raw inline-asm per arch
│ └── trampoline.rs — varta_signal_restorer global_asm! (x86_64 only)
├── bsd.rs — macOS + FreeBSD libc sigaction(3)
└── sysv.rs — other-Unix POSIX signal(2) fallback
Supported Linux architectures
| Arch | KernelSigAction size | sa_restorer | vDSO signal-return |
|---|---|---|---|
| x86_64 | 32 B | yes | no (trampoline req’d) |
| aarch64 | 24 B | no | yes (__vdso_rt_sigreturn) |
| riscv64 | 24 B | no | yes (__vdso_rt_sigreturn) |
On any other Linux target the build fails with an explicit compile_error!
listing the accepted architectures.
Direct mode (default)
SignalHandlerMode::Direct issues rt_sigaction(2) via inline assembly,
bypassing libc entirely. After installation:
-
Readback: re-reads the active action via
rt_sigaction(sig, null, &old)and asserts every field matches what was written (handler pointer,SA_RESTARTflag, and on x86_64 theSA_RESTORERflag and restorer pointer). -
Live-delivery smoke test: installs a transient
SIGUSR1handler via the same direct path, deliversSIGUSR1to the process viakill(getpid(), SIGUSR1), waits up to 50 ms for the handler to set an atomic flag, then restores the previousSIGUSR1disposition. A failure here means the trampoline ABI is broken and the error is surfaced at startup rather than at the first real SIGTERM hours later in production.
Libc fallback mode
SignalHandlerMode::Libc calls sigaction(3) through an extern "C" declaration
(no libc crate — zero registry dependencies preserved). libc substitutes its
own __restore_rt; no readback or smoke test is performed because the check would
measure libc’s restorer rather than ours.
This mode is off by default and is intended for operators running on a kernel not yet certified against the direct path.
SRE builds: pass --signal-handler-mode=libc on the command line.
Class-A builds (compile-time-config): set signal_handler_mode = libc in
the baked config file (default is direct).
Observability
On SRE builds with the prometheus-exporter feature, the active mode is exported
as:
# HELP varta_signal_handler_install_total ...
# TYPE varta_signal_handler_install_total counter
varta_signal_handler_install_total{mode="direct"} 1
The value is always 1 in steady state. A dashboard alert on mode != "direct"
catches any inadvertent deployment with the libc fallback.
On Class-A builds (no /metrics endpoint), a single varta_info! log line at
startup records the active mode.
Adding a fourth Linux architecture
Follow this checklist:
-
kernel_abi.rs: add a#[cfg(target_arch = "...")]arm with theKernelSigActionstruct. Check<asm/signal.h>for the layout. Add aconstsize assertion and alayout_testsoffset check for every field. -
syscall.rs: addrt_sigaction_rawfor the new arch. Consult the architecture’s syscall ABI (syscall number, register convention, instruction).__NR_rt_sigaction = 134on all architectures using the generic Linux ABI (aarch64, riscv64, and most others); x86_64 uses 13. -
trampoline.rs: add aglobal_asm!trampoline only if the arch defines__ARCH_HAS_SA_RESTORERin its<asm/signal.h>. For architectures where signal-return goes through the vDSO (aarch64, riscv64, and the generic ABI), no trampoline is needed andSA_RESTORERmust not be set. -
mod.rscompile_error!: add the new arch to thenot(any(...))list. -
tests/signal_handler.rs: add alinux_<arch>_direct_syscall_roundtripstest gated on#[cfg(all(target_os = "linux", target_arch = "..."))]. The types and syscall wrapper come fromvarta_watch::__test_signal_abi. -
.github/workflows/ci.yml: addrustup target add <target>and acargo check --locked -p varta-watch --target <target>step tocross-compile-checks. -
.github/workflows/kernel-rc.yml: add a matrix row if a GitHub-hosted runner is available for the architecture.
The libc-signal-mode build feature
Operators who need a binary that contains zero inline assembly — for
example, a security review policy that forbids global_asm! in shipping
code, or a platform tool-chain that does not support inline-asm — can build
with:
cargo build -p varta-watch --no-default-features --features libc-signal-mode
When this feature is enabled:
trampoline.rs,direct.rs,syscall.rs, andkernel_abi.rsare excluded from compilation entirely. The resulting binary contains novarta_signal_restorersymbol and nort_sigaction(2)wrapper.- Only
libc_wrapper.rsis compiled. Signal handlers are installed via a directextern "C"call to libc’ssigaction(3)— nolibccrate dependency is added; the symbol is resolved at link time. - The runtime default flips to
SignalHandlerMode::Libc. Passing--signal-handler-mode=directat argv is rejected with aBadValueerror. - The startup readback and SIGUSR1 live-delivery smoke test are also excised (they exercise the direct-path trampoline, which no longer exists).
Trade-off: libc owns sa_restorer (__restore_rt) in this build.
Varta cannot prove end-to-end kernel-ABI ownership — a libc update that
changes __restore_rt semantics will silently affect signal-return
behaviour. For the vast majority of operators this is the right default
assumption (libc has tested it); for Class-A / IEC 62304 deployments,
the direct path is the certified choice.
Class-A incompatibility: libc-signal-mode and compile-time-config
cannot be combined — the build fails with compile_error!. Class-A
safety-critical builds must retain the direct path and the trampoline to
satisfy the end-to-end kernel-ABI ownership requirement.
To verify the trampoline is absent:
nm target/debug/varta-watch | grep varta_signal_restorer
# must return no output
Graceful Shutdown
varta-watch is a long-running observer; an orderly stop is the difference
between a clean systemd cycle and a directory full of stranded sockets,
zombie children, and a half-flushed audit log. This chapter describes what
happens between the moment a signal arrives and the moment the process
exits, and how to tune the cycle for your deployment.
Signal disposition
Two signals trigger an orderly stop; everything else is left to default disposition.
| signal | action |
|---|---|
SIGINT | set SHUTDOWN latch (Release ordering) |
SIGTERM | set SHUTDOWN latch (Release ordering) |
SIGHUP | not trapped — reload via systemctl reload (no in-process reload) or restart |
SIGPIPE | not handled here; broken pipes surface as io::Error::BrokenPipe and are classified by classify_send_error |
The latch is a static SHUTDOWN: AtomicI32 (32-bit lock-free atomic; the
crate’s compile_error! cfg gate enforces target_has_atomic = "32"). The
handler installed for SIGINT/SIGTERM is the only async-signal-safe code
path Varta executes from a signal context: it stores 1 and returns.
Installation strategy is per-platform (see Signal-Handler Installation):
- Linux — direct
rt_sigaction(2)syscall (libc bypassed) so Varta owns the kernel ABI byte-for-byte.--signal-handler-mode=libcopts into the libc wrapper for environments where the direct syscall is unavailable. - macOS / FreeBSD / DragonFly / NetBSD — libc
sigaction(3)wrapper. - Other Unix — POSIX
signal(2)fallback.
The shutdown sequence
After the latch is set, shutdown unfolds across the next iteration of the observer poll loop. Each step is deterministic and bounded.
- Latch observation. The poll loop checks
SHUTDOWN.load(Acquire)at two points per iteration (crates/varta-watch/src/main.rs:784, 877). The current iteration finishes — heartbeat write, iteration histogram, serve-pending drain — so partial-tick state is never lost. STOPPING=1to systemd. If$NOTIFY_SOCKETis set in the environment (systemdType=notifyinjects it automatically — there is no CLI flag), the main thread emitsSTOPPING=1\nto the service manager sosystemctl statusreflects the stop transitionally rather than as an unexpected exit (crates/varta-watch/src/main.rs:1559). The watchdog thread, which is the sole owner ofWATCHDOG=1(see Stall Detection & Liveness), observes the same latch and stops emitting;WatchdogSec=therefore does not fire during teardown.- Drop chain. Returning from
mainruns the Drop impls in declaration order. The load-bearing ones:Recovery— kill all outstanding children immediately, thentry_waitin a 10 ms-poll loop with a single shared deadline of--shutdown-grace-ms(default 5 s, range 100 ms..60 s). Children still alive at the deadline are reparented to PID 1 and the process exits anyway.RecoveryAuditLog— drain the in-memory ring viaflush_pending(Duration::MAX), then a finalfdatasync(2)so every append is durable on disk before the process leaves the kernel.PromExporter/ file exporters — close TCP sockets, flush buffers, fsync TSV outputs.UdsListener— verify the bound socket file’sdev/inostill match the values recorded at bind time, thenunlink(2)it. The dev/ino check is what prevents a restarted observer from clobbering a fresh socket bound by a different instance.
Tuning knobs
| flag | default | accepted range | role |
|---|---|---|---|
--shutdown-grace-ms | 5000 | 100..60000 | bound on Recovery::Drop’s child-reap window |
--audit-fsync-budget-ms | 50 | — | bound on per-fdatasync wall time during audit drain |
The systemd unit’s TimeoutStopSec= should be at least
shutdown_grace_ms + audit_fsync_budget_ms + ~200 ms slack. With the
defaults that is ≈ 5.3 s; round up to 10 s to be comfortable. If the
service exceeds TimeoutStopSec= systemd promotes the stop to SIGKILL,
which loses the guarantees in the next section.
What SIGKILL (or any uncatchable kill) costs
Varta deliberately makes its shutdown observable rather than instantaneous
because the alternative is silent loss. A SIGKILL (whether from systemd’s
timeout, an OOM-kill, or kill -9) skips the entire sequence above:
- Outstanding recovery children orphan. They reparent to PID 1 (or the closest subreaper) and complete on their own schedule, but their exit outcomes never reach the audit log.
- Audit log loses anything in the ring. Up to
AUDIT_RING_CAP = 256records that arrived after the lastflush_pendingare dropped; the finalfdatasyncis also skipped, so the last few flushed records may not survive a power loss. - UDS socket file is left on disk. The next start’s
probe-then-bindflow detects the stale inode (ECONNREFUSEDon probe,is_socket()+ dev/ino check before unlink) and cleans it up safely; no manual intervention is required. - Self-watchdog never disarms. If the hardware watchdog (
HwWatchdog) is in use, the magic-close sequence is skipped — the kernel continues toward the configured timeout, which may reboot the host. This is the intended behaviour for safety-critical deployments: an observer that cannot stop cleanly is not allowed to leave the watchdog quiescent.
The summary is “use SIGTERM, not SIGKILL,” and “tune TimeoutStopSec=
above the sum of the bounded budgets so systemd does not have to escalate.”
systemd unit example
[Service]
Type=notify
ExecStart=/usr/bin/varta-watch --socket /run/varta.sock \
--shutdown-grace-ms 5000 \
--audit-fsync-budget-ms 50 \
--recovery-audit-file /var/log/varta-audit.tsv
TimeoutStopSec=10
WatchdogSec=4
Restart=on-failure
Type=notify enables the READY=1 / STOPPING=1 / WATCHDOG=1 channel.
WatchdogSec=4 auto-enables the in-process self-watchdog (cerebrum:
“H5 auto-enable collapses L1+L2 layers”). TimeoutStopSec=10 leaves
~5 s of headroom above --shutdown-grace-ms for the audit drain and
listener cleanup.
Cross-references
- Recovery
Dropimpl:crates/varta-watch/src/recovery/mod.rs:904-928 - Signal handler installation:
crates/varta-watch/src/signal_install/ - Observer self-liveness (the WATCHDOG=1 channel): Stall Detection & Liveness
- Configuration constants:
DEFAULT_SHUTDOWN_GRACE_MS,MIN_SHUTDOWN_GRACE_MS,MAX_SHUTDOWN_GRACE_MSincrates/varta-watch/src/config/types.rs
Bench Harness Results
Per-metric measurements captured by the dependency-free varta-bench
harness. Each row corresponds to one acceptance contract assertion in
docs/acceptance/varta-v0-1-0.md.
Snapshot date: 2026-02-11.
rust-toolchain.tomlpins the stable channel rather than a specific version, so the exact compiler will drift as stable advances. Re-run the harness on your own host before citing numbers in production decisions.
Host
| Field | Value |
|---|---|
| OS | Darwin 25.4.0 (xnu-12377.101.15) arm64 |
| Hardware | Apple Silicon (Mac, T6050 series) |
| Rust toolchain | rustc 1.93.1 (01f6ddf75 2026-02-11) — stable channel pinned via rust-toolchain.toml |
| Working tree | epic/varta-v0-1-0--s06-integration-and-bench clean at run time |
Results
| Metric | Threshold | Measured | Status | Command |
|---|---|---|---|---|
latency | p99 < 1 µs | p99 = 916 ns | PASS | cargo run -p varta-bench --release -- latency |
cpu-50-agents | < 0.1 % | 0.0552 % | PASS | cargo run -p varta-bench --release -- cpu-50-agents |
binary-size | Δ < 20 KB | Δ = 3 872 B | PASS | cargo run -p varta-bench --release -- binary-size |
Auxiliary latency metrics (same run): p50 = 584 ns, p99.9 = 1042 ns.
Reproducibility
# Build the workspace once so varta-watch is in target/release.
cargo build --workspace --release
cargo run -p varta-bench --release -- latency
cargo run -p varta-bench --release -- cpu-50-agents # ~35 s wall
cargo run -p varta-bench --release -- binary-size # ~5 s wall
cpu-50-agents waits for the daemon to self-exit via
--shutdown-after-secs 35 before snapshotting getrusage(RUSAGE_CHILDREN),
so the measurement covers the full wall window over which the 50 agent
threads emit at 1 Hz. The wall is therefore the dominant cost.
Threshold notes
latency: thresholds are tagged HOST-DEPENDENT incrates/varta-bench/src/main.rs. Apple Silicon laptops show p99 ≈ 900 ns idle. Virtualised CI runners with noisy neighbours can spike — if the bench reportsSTATUS: WARNwith a measured value above 1 µs, the harness is doing its job and a CI gate should classify it as a soft failure (warning, not red).cpu-50-agents: the daemon is mostly blocked inrecvfrom(2)with the 100 ms read timeout. CPU usage scales sublinearly with agent count because the kernel batches wakeups. 0.0552 % of a 35 s wall is ~19 ms of daemon CPU.binary-size: link-time pulls inVarta::connect, theFramecodec, and theBeatOutcomeenum. The diff is dominated by Rust’s standard- library boilerplate forUnixDatagramplus a few KB of generated code for the encoder. The fixture crates uselto = false,codegen-units = 1,opt-level = 3so size comparisons are stable across runs.
Status
All three contract assertions PASS on the host above. No WARN or FAIL deviations to record for this session.
Glossary
Terms that recur throughout the Varta documentation. Where a term has a formal definition in the source, the canonical location is linked.
AEAD
Authenticated Encryption with Associated Data. Varta’s secure-UDP transport uses ChaCha20-Poly1305, a stream cipher (ChaCha20) paired with a polynomial MAC (Poly1305). Every secure-UDP frame is encrypted and integrity-checked in one pass. See VLP — Secure Transport.
Agent
A process that emits Varta heartbeats. Agents link varta-client (or a
language port) and call Varta::connect() once at startup, then
beat() on whatever cadence they like.
Audit log
The optional TSV file written by varta-watch when
--recovery-audit-file <PATH> is set. Records every recovery decision
— Spawned, Debounced, Refused, Reaped, Killed — with the
kernel-attested PID where available. Required for IEC 62304 / DO-178C
deployments. See Audit Logging.
Beat
One 32-byte VLP frame from agent to observer. The verb (agent.beat())
and the noun (varta_beats_total counter) share the term.
BeatOrigin
The observer’s enum tag for how the beat got here. The recovery gate uses this to decide whether to honour or refuse a stall.
| Variant | Meaning | Recovery eligibility |
|---|---|---|
KernelAttested | UDS with peer-cred PID verified | ✅ |
OperatorAttestedTransport | Secure-UDP with master-key (PID bound to key derivation) | ✅ |
SocketModeOnly | UDS on a platform without per-datagram peer-creds (e.g. OpenBSD) | ❌ refused with socket_mode_only |
NetworkUnverified | Plaintext UDP | ❌ refused with unauthenticated_transport |
New variants default to refused. See Threat Model and CLAUDE.md hard-constraint #8.
BeatOutcome
The return type of Varta::beat(). Three variants:
Sent— the kernel accepted the datagram.Dropped(DropReason)— the kernel returnedWouldBlockor similar expected failure. Not an error; the beat path is non-blocking by contract.Failed(io::Error)— an unexpected error (e.g.EBADFafter a socket-mode change). Surfaces to the caller.
Class-A / Class-C
Refers to safety-critical software classifications used by IEC 62304
(medical) and DO-178C (avionics). Class-A profile in Varta is the
structurally-excised binary built with the compile-time-config
feature instead of prometheus-exporter: no HTTP server, no /bin/sh,
no ---prefixed flag literals. CI’s safety-profiles job enforces
this via a strings audit. See Safety
Profiles.
Debounce window
The per-pid interval (--recovery-debounce-ms) during which a repeat
stall on the same pid does not spawn another recovery child. Returns
the Debounced outcome instead.
Frame
The 32-byte, fixed-layout, #[repr(C, align(8))] wire unit. Encoded
and decoded on the stack. See VLP — Base Frame.
Iteration
One full pass of the observer’s poll loop: drain pending → poll
sockets → maintenance → recovery reap → serve pending → housekeeping.
The total wall time is varta_observer_iteration_seconds; per-stage
breakdown is varta_observer_stage_seconds{stage=…}. Worst-case bound:
~310 ms. See Stall Detection &
Liveness.
Kani
A bit-precise model checker for Rust by AWS. Varta uses Kani harnesses
under crates/varta-vlp/ to exhaustively prove panic-freedom and
field-range correctness of Frame::decode. Runs as a nightly CI job.
See Symbolic Verification.
MAX_CAPACITY
The hard ceiling on simultaneously-tracked agent PIDs in
varta-watch: 4096. Both the Tracker and OutstandingTable
share this bound. Operators tune the actual cap with
--tracker-capacity. To scale past 4096 on one host, run multiple
observer instances and shard the agent population client-side; see
Deployment Ceiling &
Sharding.
Observer
varta-watch. The single-threaded process that decodes beats, tracks
per-pid state, fires recovery, and exposes Prometheus metrics.
OutstandingTable
The BoundedIndex-backed slab in varta-watch that holds outstanding
recovery children, keyed by stalled pid. Statically sized to
MAX_CAPACITY; zero heap allocation after construction. See
Bounded Collections.
Peer-cred / SO_PASSCRED
The Linux mechanism for the receiver of a UDS datagram to learn the
sender’s UID + PID, attested by the kernel rather than claimed by the
sender. BSDs use SCM_CREDS / SCM_CREDS2; macOS pathname datagram sockets do not
provide equivalent per-datagram attestation for Varta’s UDS transport.
Varta relies on peer credentials for KernelAttested BeatOrigin. See Peer
Authentication.
Recovery
The observer’s response to a stall: spawn a configured command
(--recovery-exec) with the stalled pid as the final argument. Spawn
is non-blocking; the child is reaped on a later observer tick. See
Recovery — Async Spawn.
Self-watchdog
The in-process watchdog thread (--self-watchdog-secs) that aborts
the observer (SIGABRT) if the main poll loop hasn’t advanced within
the configured deadline. Distinct from the kernel hardware watchdog
(--hw-watchdog) and from systemd WatchdogSec=. All three can be
used together for layered defence.
Shard
Running multiple varta-watch instances on one host, each bound to a
distinct socket path and /metrics port, with agents partitioning
themselves across the shards (typically by pid % N). The simplest
way to scale beyond MAX_CAPACITY = 4096 on a single host.
Stall
A pid that hasn’t beat in --threshold-ms. The observer surfaces
Event::Stall and the recovery layer decides whether to fire.
Synthesised by the observer; never on the wire (Status::Stall = 0x03
is reserved for that purpose — frames carrying it are decoded as
StallOnWire errors).
Status
The 1-byte field at offset 3 of every frame. Wire values: 0x00 Ok,
0x01 Degraded, 0x02 Critical. 0x03 is reserved for the
observer’s stall synthesis and is rejected on the wire.
Stage names
The six labels emitted under varta_observer_stage_seconds{stage=…},
always in this canonical order: drain_pending, poll, maintenance,
recovery_reap, serve_pending, housekeeping.
Tracker
The bounded open-addressed table in varta-watch that holds per-pid
state (last-seen-at, last-status, last-fired-recovery). Statically
sized to MAX_CAPACITY = 4096. Probe budget is bounded; see
Bounded Collections.
VLP
Varta Liveness Protocol. The wire format. v0.2 is current and frozen; future versions will be called out in the Upgrade Guide. See VLP — Base Frame.
Contributing to Varta
First, thank you for contributing! Varta is a high-assurance health protocol, and we maintain strict architectural and safety standards.
The Varta “Hard Constraints”
Every contribution must adhere to these load-bearing invariants:
- Zero Registry Dependencies: Production crates (
varta-vlp,varta-client,varta-watch) must have empty[dependencies]sections (other than internal path dependencies). - Zero Heap Allocation: No heap allocation is permitted on the
beat()path after connection. We verify this withzero_alloctests using a guard allocator. - Non-Blocking I/O: The beat path must never block.
WouldBlockis handled asDropped. - ABI Stability: Any change to the 32-byte
Framelayout is a breaking change and requires a VLP version bump. - Strict Linting: We run with
deny(unsafe_code)at the workspace level. Permitted unsafe blocks (e.g., for FFI) must be explicitly allowed with#[allow(unsafe_code)]to create an audit trail.
Development Workflow
Prerequisites
- Rust stable (for production builds)
- Rust nightly (for fuzzing and Miri)
cargo-fuzzandmiricomponents installed
The “JUSTIFY” Rule
If you must #[ignore] a test, the CI will fail unless you provide a // JUSTIFY: <reason> comment within 2 lines of the attribute. This ensures we don’t accidentally leave gaps in our safety coverage.
Running the Suite
# Lint & Format
cargo fmt
cargo clippy --workspace -- -D warnings
# Tests
cargo test --workspace
# Fuzzing (Mandatory for protocol changes)
cargo fuzz run frame_decode -- -max_total_time=30
# Miri (UB Audit)
cargo miri test -p varta-vlp
Pull Request Process
- Benchmarks: If your change touches the
beat()path, you must runcargo run -p varta-bench --release -- latencyand include the results in your PR description. - Documentation: Update
design.mdor crate READMEs if logic changes. - Zero-Alloc Verification: Ensure
cargo test -p varta-tests --test zero_allocstill passes.
Code of Conduct
We follow the Contributor Covenant. Please be respectful and professional.
Security Policy
Supported Versions
The following versions of Varta are currently being supported with security updates.
| Version | Supported |
|---|---|
| v0.2.x | :white_check_mark: |
| < v0.2 | :x: |
Reporting a Vulnerability
Varta is designed for high-assurance and safety-critical health monitoring. Security and protocol integrity are our highest priorities.
If you discover a security vulnerability or a protocol-level defect that could compromise system safety, please do not report it via a public issue.
Recommended Method: GitHub Private Vulnerability Reporting
Please use the GitHub Private Vulnerability Reporting feature. This allows you to securely disclose the vulnerability to the maintainers without making it public.
What to include
When reporting, please provide:
- A descriptive title.
- The specific crate and version affected.
- A clear description of the vulnerability or safety concern.
- Steps to reproduce (including hardware/OS context if relevant).
- A proof-of-concept if available.
Our Commitment
We will:
- Acknowledge your report within 48 hours.
- Provide a timeline for a fix and keep you updated.
- Give credit (if desired) in the eventual security advisory.
Varta Project Roadmap
This roadmap outlines the path from Varta’s current state to a “High-Assurance” v1.0.0 release suitable for safety-critical deployments.
Phase 1: Foundation (Current - v0.2.x) :white_check_mark:
Focus on protocol stability, local/network transport, and security audits.
- VLP Protocol Definition (32-byte frames).
- Zero-allocation UDS/UDP transport.
- AEAD encryption for networked agents.
- Fuzzing and Miri integration in CI.
- Initial Prometheus exporter.
Phase 2: Observability & Resilience (v0.3 - v0.5)
Enhancing the observer and providing more “industrial” features.
- Structured Logging: full
json-logsupport across all crates. - Tamper-Evident Logs: SHA-256 hash chaining for recovery audits.
- mdBook Documentation: A comprehensive “Varta Book” explaining protocol internals.
- Crates.io Publication: Formal release of production-ready crates.
Phase 3: Compliance & Integration (v0.6 - v0.9)
Preparing for formal certification standards (IEC 62304, ISO 26262).
- Static Analysis: Integrate
cargo-geigerand custom safety-profile audits. - Multi-Language SDKs: C/C++ bindings for legacy embedded systems.
- Hardware Watchdog Integration: Native drivers for Linux
watchdogdand platform-specific hardware timers. - Self-Diagnostic Suite: Integrated tests for observer clock drift and jitter.
Phase 4: High-Assurance v1.0
The stable, safety-certified release.
- Formal Verification: TLA+ or Kani proofs for core state machines.
- Third-Party Security Audit: Formal cryptographic and code audit by a specialized firm.
- ABI Freeze: Finalize the VLP wire format for long-term compatibility.
- v1.0.0 Release: LTS support for critical infrastructure.