deployment specification - noetl/worker GitHub Wiki

Deployment Specification

NATS is fully removed from the command path (2026-08-01). The worker claims commands from the EHDB bus (NOETL_COMMAND_BUS=ehdb + NOETL_COMMAND_BUS_CLAIM_ADDR) and its pool comes from NOETL_FEED_FILTER_SUBJECT β€” the legacy NATS_FILTER_SUBJECT name is no longer read. A worker that cannot resolve a pool now refuses to start rather than silently joining shared, which previously stalled every execution with nothing in the logs (noetl/ai-meta#218). NatsSubscriber, NatsCommandSource and the NATS lag poller are gone; CommandNotification, segment_from_filter and claim_outcome moved to src/dispatch.rs because they were NATS-named but on the live EHDB path. This is about the internal bus only. nats is also a user-facing tool kind: a playbook step can point it at the user's own broker to store or publish business data, the same way postgres, duckdb and the object-store kinds reach user-supplied services. That tool lives in the separate noetl-tools crate with its own async-nats, takes its endpoint from the step's url or a keychain credential, and is fully supported β€” verified on v5.91.2 by tests/user_nats_tool_dispatch.rs. See nats-mcp-tool-kinds. EHDB is internal transient infrastructure and is never exposed as a user store.

The worker's own async-nats dependency still ships for state_builder's WAL-rehydrate path (live under shard_read_verify, reads NATS_URL), the materializers' NATS arms and spool_runtime's nats_object backend. noetl/ai-meta#219 once proposed dropping the dependency wholesale; it is closed as won't-remove β€” doing so would have taken the user-facing tool and the rehydrate path with it. Recovery path: the DR IaC in playbooks/211-t3-events-migration/nats-dr/. See noetl/ai-meta#212 and #220.

This page is the durable reference for deploying noetl-worker into any environment. It covers the runtime contract the binary expects, the resources it consumes, the network surface it exposes, and β€” critically β€” every environment variable it reads, with the why behind each one.

This page is the single source of truth for the deployment shape. Any code change that adds, renames, removes, or shifts the meaning of an env var MUST update the Environment Variables section in the same change set. Same rule for ports, dependencies, and runtime requirements. See agents/rules/wiki-maintenance.md.

The matching deployment manifests live in noetl/ops (Helm chart + kind overlays). This wiki page describes what the manifests need to provide; the manifests are the implementation.

Component summary

Field Value
Repo noetl/worker
Binary noetl-worker
Container image noetl-worker (built from the repo's Dockerfile)
Image versioning crates.io version pinned in Cargo.toml; semver releases tagged vX.Y.Z
Current version see Cargo.toml package.version
Language / runtime Rust 1.91+; Tokio multi-threaded
Process model Single binary, single process per pod
Role EHDB command-bus claimer + tool dispatch. Stateless atomic-compute block per agents/rules/execution-model.md.

Runtime contract

What the binary expects from its environment to start cleanly:

  1. EHDB command-bus coordinator reachable at ${NOETL_COMMAND_BUS_CLAIM_ADDR} (the writer's claim face, :9101), with NOETL_COMMAND_BUS=ehdb. Hard requirement. 1b. NOETL_FEED_FILTER_SUBJECT set (e.g. noetl.commands.system.>). It is the input the worker's EHDB pool is derived from. A worker that cannot resolve a pool refuses to start rather than defaulting to shared β€” a crashloop an operator sees in seconds beats a cluster that looks healthy while nothing progresses (noetl/ai-meta#218).
  2. noetl-server reachable at ${NOETL_SERVER_URL} (HTTP). The worker's per-command server_url override (set by the publishing server on every command notification per noetl/ai-meta#53) takes precedence at runtime; the env var is the initial default for one-server deployments.
  3. Worker pool slot: WORKER_POOL_NAME identifies which pool this worker belongs to. Server-side runtime registration is keyed by (kind, name); multiple workers in one pool share work.
  4. Worker identity: WORKER_ID distinguishes this individual worker. When unset, the worker generates a uuid (fine for ephemeral pods; for StatefulSets prefer setting it explicitly from the pod ordinal so logs + metrics stay readable across restarts).
  5. KEDA-managed scaling: the worker is scaled by KEDA on EHDB per-subject lag, scraped from the writer's :9102 /metrics (ehdb_feed_subject_lag{subject="commands.<pool>.shard.<n>"}). The old nats-jetstream trigger is gone. See Scaling.

Network surface

Ports

Port Protocol Purpose Bind
9090 HTTP Prometheus scrape endpoint at /metrics + /healthz + /readyz + /livez, and the read-only EHDB tier-query route GET /ehdb/tiers/{tier} ${WORKER_METRICS_BIND} (default 0.0.0.0:9090)
9100 TCP Command-bus writer host only β€” feed ingest; the server publishes commands here ${NOETL_COMMAND_BUS_INGEST_BIND}
9101 TCP Command-bus writer host only β€” claim coordinator; worker replicas compete for commands here ${NOETL_COMMAND_BUS_CLAIM_BIND}
9102 HTTP Command-bus writer host only β€” /metrics carrying ehdb_feed_total_lag / ehdb_feed_shard_lag{shard} / ehdb_feed_subject_lag{subject} (the KEDA scale signal). ⚠ Not a cheap read β€” it also samples the durability window and takes the engine lock, so a slow tier op blocks it; see cmdbus-writer-tier-service ${NOETL_COMMAND_BUS_METRICS_BIND}

The scrape port also carries the EHDB Data Query Interface data-plane read handler (noetl/ai-meta#178): GET /ehdb/tiers/{tier} where tier ∈ eventlog|kv|object|vector. The noetl-server relays raw-tier queries here over a direct synchronous HTTP read (not the NATS drive) via NOETL_EHDB_WORKER_QUERY_URL. The handler is read-only, guarded (worker/src/ehdb/guard.rs refuses control-plane roles), disabled-by-default (a strict outcome=disabled no-op unless NOETL_EHDB_ENABLED is truthy β€” no new env var of its own), bounded (limit/top_k ≀ 1000), and secret-free (it relays the tier *Outcome view structs verbatim). It reads the same local_reference log / durable per-shard segment stores the tier mirrors write through; the raw eventlog scan k-way-merges across the durable shards this replica owns. Because the mirror is pod-local and this Service is headless, point the server's query URL at a single-replica pool for coherent reads. Apart from this read route the worker exposes no API β€” all other traffic flows out (NATS pull, HTTP to server).

Dependencies (outbound)

Target Protocol Why
NATS JetStream TCP 4222 (default) Pull commands from noetl.commands.* subjects. Receive notifications with the publishing server's URL.
noetl-server HTTP (default port 8082) Fetch command details (GET /api/commands/{event_id}), claim atomically (POST /api/commands/{event_id}/claim), emit lifecycle events (POST /api/events), put result blobs (POST /api/result_store/...). Per-command server_url from the NATS notification overrides this default.
External APIs (Auth0 / Duffel / OpenAI / ...) HTTPS Whatever the executing playbook tool calls. Credentials resolved via NoETL keychain or NOETL_KEYCHAIN_ENV_VARS allowlist (see Keychain credentials).

The worker does NOT call Postgres directly. Per agents/rules/data-access-boundary.md, NoETL platform data is accessible via server API only.

Resources

Recommended starting point for production. Workers scale on EHDB per-subject backlog (KEDA), so each replica should be sized for one concurrent playbook dispatch.

Resource Request Limit Notes
CPU 100m 500m Per-replica; CPU-bound only during tool execution (Python eval, JSON transforms). Idle workers consume <5m.
Memory 128Mi 768Mi The limit must cover the memory-backed /dev/shm tmpfs (charged to the pod cgroup, sized to the Arrow IPC cache budget β€” see Shared memory below) plus the worker RSS. With the 256 MB cache budget the /dev/shm tmpfs is 320Mi, so the limit is 768Mi (320Mi shm + worker RSS headroom).
Ephemeral storage 100Mi 500Mi Tool execution scratch (Python eval temp dirs).

WORKER_MAX_CONCURRENT (default 1) governs how many commands a single worker pod processes in parallel. Stay at 1 unless the pool's tools are I/O-bound (HTTP fetches dominated by external latency); CPU-bound tools should scale out via more replicas, not more concurrency per pod.

Shared memory (/dev/shm)

Required: a memory-backed /dev/shm sized to exceed the Arrow IPC cache budget.

The worker allocates one Arrow IPC shared-memory cache per process at init (ArrowIpcSharedMemoryCache::new(), budget NOETL_IPC_CACHE_BUDGET_BYTES, default 256 MB). The cache stages call.done results that exceed the broker's 100 KB inline budget into POSIX shared-memory regions backed by /dev/shm (via the shared_memory crate's shm_open+ftruncate+mmap).

The Kubernetes container-runtime default for /dev/shm is a 64 MiB tmpfs. When the cache writes past 64 MiB the store page-faults against the full tmpfs and the kernel delivers SIGBUS β€” the worker dies with exit code 135 and crash-loops. Mount a memory-backed emptyDir at /dev/shm sized above the cache budget:

spec:
  containers:
    - name: noetl-worker
      env:
        - name: NOETL_IPC_CACHE_BUDGET_BYTES
          value: "268435456"   # 256 MiB β€” pin next to sizeLimit below
      volumeMounts:
        - name: dshm
          mountPath: /dev/shm
      resources:
        limits:
          memory: "768Mi"      # >= sizeLimit + worker RSS
  volumes:
    - name: dshm
      emptyDir:
        medium: Memory
        sizeLimit: 320Mi        # >= NOETL_IPC_CACHE_BUDGET_BYTES + headroom

Three values move together and must stay coherent:

Value Constraint
NOETL_IPC_CACHE_BUDGET_BYTES the cache budget (256 MiB default)
/dev/shm emptyDir sizeLimit > the budget (320Mi = budget + 64Mi headroom for tmpfs page rounding)
container memory limit > sizeLimit + worker RSS (768Mi); the tmpfs is charged to the pod cgroup

If you raise the cache budget, raise the sizeLimit and the memory limit in lockstep. The committed manifests wiring this up live in noetl/ops ci/manifests/noetl/worker-*.yaml (fixed in ops#193).

Health probes

Probe Path Initial delay Period Failure threshold Effect
Liveness /healthz 10s 10s 3 Pod restart
Readiness /readyz 3s 3s 20 Removed from Service endpoints / rollout blocked until warm

/healthz returns 200 whenever the process is responding (it does not check upstream NATS / control plane β€” those have their own failure modes the heartbeat covers), so a slow warmup never trips a liveness restart.

/readyz (noetl/ai-meta#130) returns 503 warming during boot and 200 ready once boot warmup has completed β€” on the drive pool that means the system/orchestrate wasm module is Cranelift-compiled + cached (the noetl_worker_ready gauge flips 0β†’1). Gating readiness on warmup hides the one-time ~1.5s compile (the cold-start, ~99% Module::new) from the first real request: Kubernetes only routes / completes a rollout once worker_ready=1. The warmup also completes before the pull loop starts claiming, so the first orchestration is warm even where the probe is not wired (e.g. the metrics port is not fronted by a Service).

⚠ The writer role's deployed probes are weaker than this

The table above describes the HTTP probes the binary serves on :9090. The prod noetl-cmdbus-writer StatefulSet does not use them β€” it probes cmdbus-claim (:9101) over TCP, for liveness as well as readiness:

livenessProbe:  { tcpSocket: { port: cmdbus-claim }, timeoutSeconds: 1,
                  periodSeconds: 20, failureThreshold: 3 }
readinessProbe: { tcpSocket: { port: cmdbus-claim }, timeoutSeconds: 1,
                  periodSeconds: 5,  failureThreshold: 3 }

A listening socket passes a TCP probe whatever the process is doing behind it, so the writer reports Ready with Restart Count: 0 through a stall of any length. On 2026-09-22 that hid an 18-hour degradation in which the tier service's read_execution ran to a ~36s tail and took the whole product with it (noetl/ai-meta#351). Same class as noetl/ai-meta#322, and worse: there only readiness was TCP.

Full detail: cmdbus-writer-tier-service.

NATS layout

  • Stream: noetl_commands (default; override via NATS_STREAM). Subjects: noetl.commands.>. Historical β€” the NATS layout below is retained for the DR path only.
  • Consumer: pull-based, durable. Default name worker-pool (override via NATS_CONSUMER). Filter subject: noetl.commands (override via NATS_FILTER_SUBJECT).
  • Subjects the worker reads: per-pool filter shapes vary; the server publishes commands to noetl.commands.{system|shared}.<execution_id> per the Phase F sharding-design. A "shared" pool worker filters on noetl.commands.shared.*; a "system" pool worker filters on noetl.commands.system.*.

Scaling

The worker is the scale-out unit per agents/rules/execution-model.md. KEDA reads NATS consumer lag and scales the Deployment between minReplicas and maxReplicas.

Standard KEDA ScaledObject for the worker pool:

triggers:
  - type: nats-jetstream
    metadata:
      account: $G
      natsServerMonitoringEndpoint: "nats.noetl.svc:8222"
      stream: noetl_commands
      consumer: worker-pool
      lagThreshold: "5"

Workers can be added, removed, or restarted freely β€” state lives in the server's event log + cache; the worker is stateless.

Snowflake ID generation

The worker mints its own snowflake IDs via src/snowflake.rs for client-side event IDs (per agents/rules/observability.md Principle 3). The 10-bit node id comes from either:

  1. NOETL_SNOWFLAKE_NODE_ID (preferred β€” set per pod by the deployment manifest).
  2. NOETL_SHARD_ID (back-compat alias for the same idea).
  3. Derived from NOETL_NODE_ID / NODE_NAME / pod hostname via FNV-1a hash to 10 bits.

For multi-replica deployments, set NOETL_SNOWFLAKE_NODE_ID explicitly to avoid hash collisions producing duplicate IDs.

The epoch comes from NOETL_SNOWFLAKE_EPOCH_MS if set, otherwise defaults to a build-time constant. Match the server's epoch (2024-01-01T00:00:00Z UTC) to keep IDs mutually orderable across producers.

Environment variables

All env vars the binary reads at startup or runtime, with the why behind each one.

Worker identity + pool

Variable Default Required Why
WORKER_ID (uuid v4 generated at startup) recommended Unique identifier for this worker pod. Embedded in event metadata + runtime registration so the server can track which worker handled which command. For StatefulSet pods, derive from the pod ordinal so logs/metrics stay stable across restarts.
WORKER_POOL_NAME default yes for non-default pools Pool the worker registers under. Drives the NATS subject filter (system pool reads noetl.commands.system.*, shared pool reads noetl.commands.shared.*). Must match the pool's runtime row name on the server side.
WORKER_HEARTBEAT_INTERVAL (see code; ~10s) no Seconds between heartbeat POSTs to /api/worker/pool/heartbeat. Lower = faster failover detection; higher = less HTTP load. Tune to match the server's NOETL_RUNTIME_OFFLINE_SECONDS.
WORKER_MAX_CONCURRENT 1 no Number of commands this pod processes concurrently. See Resources.
WORKER_METRICS_BIND 0.0.0.0:9090 no Bind address for the metrics + health HTTP server. Override only when you don't want to accept scrape traffic on all interfaces.
WORKER_NATS_LAG_POLL_INTERVAL (see code) no Seconds between consumer-lag polls used by the worker's own lag-aware logic (independent of KEDA's external poll). Tune only when the default is shown to be too lazy or too aggressive.

Server endpoint

Variable Default Required Why
NOETL_SERVER_URL (built-in fallback, typically http://noetl.noetl.svc:8082) yes Initial default URL for the noetl-server HTTP API. Note: as of noetl/worker#41 the per-command server_url from the NATS notification overrides this at runtime, so a worker that picks up a command published by a different server replica (or by the Rust server vs Python server) will correctly route lifecycle events back to the publishing server. Use an https:// URL when the server's TLS listener is on (see Transport security below).

Transport security (mTLS client)

Secrets Wallet Phase 4b (noetl/ai-meta#61, noetl/worker#56) — the worker half of the mTLS transport the server opts into in Phase 4a (noetl/server#103, NOETL_TLS_*). The worker's control-plane HTTP client (credential fetch, command claims, event posts) speaks plain HTTP by default; the env below makes it present a client certificate so the worker→server credential channel is authenticated + encrypted (the resolved secret no longer travels plaintext on the wire).

Variable Default Required Why
NOETL_TLS_CLIENT_CERT β€” with NOETL_TLS_CLIENT_KEY PEM client-certificate-chain path (the mTLS identity the worker presents). Both cert + key together, or neither (setting one is a fail-fast misconfig).
NOETL_TLS_CLIENT_KEY β€” with NOETL_TLS_CLIENT_CERT PEM private-key path for the identity cert. Never logged.
NOETL_TLS_CA β€” for a private-CA server PEM CA-bundle path the worker adds as a trust root when verifying the server's certificate. Needed when the server cert is signed by an internal CA rather than a public root. Independent of the identity (a publicly-trusted server needs none).

Built on the rustls-tls reqwest backend the worker already uses (reqwest::Identity::from_pem + Certificate::from_pem_bundle). Set NOETL_SERVER_URL to the server's https:// URL when these are on. The cert/key/CA are mounted from a K8s Secret; the manifests live in noetl/ops.

Init-container caveat (Phase 4c): the wait-for-api init container curls /api/health over plain HTTP β€” against an mTLS server it can't complete and blocks the pod in Init. It needs the client cert (mTLS curl) or a non-mTLS health path (parallels the server's mTLS httpGet-probe caveat).

Sealed credential delivery (Phase 5c)

Secrets Wallet Phase 5c (noetl/worker#58, tracks noetl/ai-meta#61) β€” defense in depth on top of the Phase-4 mTLS transport. mTLS encrypts the wire; sealing encrypts the credential payload to a key only this worker holds. The cleartext exists only briefly inside the worker process after unseal; never on the wire, never in the server's HTTP response body.

Variable Default Required Why
NOETL_SEALED_CREDENTIALS false when sealing Master toggle. When true / 1 / yes and WORKER_ID is set, the worker calls GET /api/credentials/{alias}/sealed?worker_id=<WORKER_ID> instead of the plaintext path. The server (v2.32.0 + the Phase-5b endpoint, server#109) seals the response to the X25519 public key this worker registered on POST /api/worker/pool/register.

Wire shape. The worker generates a long-lived X25519 keypair once at startup, includes the base64 public half in its register payload's runtime JSON blob:

{
  "name": "<WORKER_ID>",
  "component_type": "worker_pool",
  "runtime": { "kind": "rust", "worker_public_key": "<base64-32-byte-x25519-pub>" },
  "status": "ready",
  ...
}

The private half stays in-process for the worker's lifetime β€” only the public half ever leaves. The server-side deployment-specification Β§ Sealed credential delivery describes the endpoint shape; the wire envelope is {alg, v, eph_pub (b64), ciphertext (b64)} carried by the same noetl_sealed algorithm constant on both sides (x25519-hkdf-sha256-chacha20-poly1305, v=1).

Zeroize. After the auth-alias resolver has injected the resolved fields into the tool config, the worker zeroizes the string values in the intermediate credential.data map so an OOM core dump or a debugger snoop on the worker pod can't recover the secret from the post-dispatch heap.

Sealing requires a worker name on the server side. The server looks up worker_public_key by the name (kind=worker_pool) of the runtime row. Workers identify themselves at register time with WORKER_ID, so that env var is required for sealing to work; absent β†’ the wrapper logs a WARN and falls back to the plaintext path.

Command bus β€” NATS or EHDB (noetl/ai-meta#194 L1 T4)

This is live in production. Since 2026-07-27 the shastaratech prod cluster runs NOETL_COMMAND_BUS=ehdb β€” commands reach workers over the EHDB feed, not NATS. NATS is still installed as the rollback path; deleting it (T5) is a separate, irreversible approval.

The worker plays two roles here, and they are configured separately.

As a consumer (every worker pool) it claims commands from the shard coordinator over the network instead of subscribing to NATS. As a writer host (one designated deployment per command shard) it owns the durable command log and serves the ingest / claim / metrics faces that the server and all other workers talk to.

Variable Default Required Why
NOETL_COMMAND_BUS nats no Transport selector: nats (today's default) / ehdb (claim from the shard coordinator, no NATS subscriber is created) / shadow (NATS authoritative, EHDB mirrored for parity comparison). Read once at process start.
NOETL_COMMAND_SHARD_COUNT 1 no Shard modulus. Must match the server's value β€” a mismatch routes commands to a shard nobody claims. Prod runs 1 (the #202 Option-2 decision); this is a different axis from NOETL_SHARD_COUNT (#166 state sharding), which is unaffected.
NOETL_COMMAND_BUS_CLAIM_ADDR β€” in ehdb mode host:port of the shard coordinator to claim from. A writer host in ehdb mode also needs this β€” it is a consumer of its own shard too. Omitting it on the writer was one of three config bugs the kind rig caught before prod.
NOETL_COMMAND_BUS_HOST false on the writer true on the deployment that hosts this shard's writer.
NOETL_COMMAND_BUS_SHARD 0 on the writer Which shard this writer owns.
NOETL_COMMAND_BUS_WRITER_DIR β€” on the writer Durable command-log directory. MUST be a PVC β€” a pod-local path loses the command log on reschedule. Prod uses premium-rwo (PD-SSD) 20Gi.
NOETL_COMMAND_BUS_INGEST_BIND β€” on the writer Ingest listen address; the server publishes here.
NOETL_COMMAND_BUS_CLAIM_BIND β€” on the writer Claim listen address; consumers compete here.
NOETL_COMMAND_BUS_METRICS_BIND β€” on the writer /metrics listen address (ehdb_feed_total_lag, ehdb_feed_shard_lag{shard} β€” the KEDA signal).
NOETL_COMMAND_BUS_ACK_WAIT_SECS 30 no Redelivery window. A claim unacked past this is redelivered to another consumer β€” the mechanism behind the 0-loss pod-kill result.

Pool isolation is not a separate flag: each consumer subscribes with a subject filter (commands.<pool>.>) derived from its NATS_FILTER_SUBJECT segment, and the coordinator only ever hands a member a matching command. A user-pool worker therefore cannot claim a system command β€” proven on prod (0 system claims during the canary).

Rollback is a single env flip back to nats on the server and every worker pool, valid for as long as NATS remains installed.

Full cutover procedure, the three-round prod execution log, and the T5 checklist: ehdb wiki β€” Runbook: L1 Command-Bus Cutover.

NATS

Variable Default Required Why
NOETL_FEED_FILTER_SUBJECT β€” yes The routing filter subject the worker's EHDB pool is derived from (noetl.commands.system.> β†’ pool system). Replaces the legacy NATS_FILTER_SUBJECT, whose fallback was removed. Unset / unresolvable β†’ the worker refuses to start (noetl/ai-meta#218).
NOETL_COMMAND_BUS nats yes β€” set to ehdb Any value other than ehdb is a startup error: the NATS transport no longer exists.
NOETL_COMMAND_BUS_CLAIM_ADDR β€” yes The writer's claim face, e.g. noetl-cmdbus-writer-0.noetl.svc.cluster.local:9101.
NATS_USER β€” when NATS auth NATS user.
NATS_PASSWORD β€” when NATS auth NATS password. Should come from a K8s Secret.
NATS_STREAM noetl_commands no JetStream stream name. Override only when running multiple NoETL deployments on one NATS cluster.
NATS_CONSUMER worker-pool no Durable consumer name. All workers in one pool share the same consumer name.
NATS_SUBJECT noetl.commands no Base subject prefix. Pool-specific filter (noetl.commands.system.* vs noetl.commands.shared.*) appends below this.
NATS_FILTER_SUBJECT (derived from pool) no Explicit override for the consumer's filter subject. Use only when the pool routing scheme needs a custom filter.

Snowflake ID generation

Variable Default Required Why
NOETL_SNOWFLAKE_NODE_ID β€” per-replica in prod 10-bit node id (0–1023) for the worker's snowflake generator. Each pod in a deployment MUST set a distinct value to avoid id collisions producing duplicate event IDs. Same shape as NOETL_SERVER_MACHINE_ID on the server side.
NOETL_SHARD_ID β€” (alias) Back-compat alias for NOETL_SNOWFLAKE_NODE_ID; same semantics.
NOETL_NODE_ID (HOSTNAME) β€” Fallback identifier hashed to derive the 10-bit node id when neither NOETL_SNOWFLAKE_NODE_ID nor NOETL_SHARD_ID is set. Read at startup.
NODE_NAME (set by container runtime via downward API) β€” Same fallback chain as NOETL_NODE_ID; standard K8s downward-API value.
NOETL_SNOWFLAKE_EPOCH_MS (build-time default: 2024-01-01T00Z) no Epoch the snowflake timestamps count from. Override only to match an alternate epoch elsewhere in the system β€” but the noetl-server uses 2024-01-01, so changing this on the worker breaks cross-component ordering.

Keychain credentials (NOETL_KEYCHAIN_ENV_VARS)

Variable Default Required Why
NOETL_KEYCHAIN_ENV_VARS β€” when env-aliased credentials used Comma-separated allowlist of env var names that hold credentials playbook steps can reference by alias. Example: NOETL_KEYCHAIN_ENV_VARS=NOETL_FLIGHT_BEARER_TOKEN,OPENAI_API_KEY permits a playbook to write bearer_token: NOETL_FLIGHT_BEARER_TOKEN and the worker resolves it from the env at dispatch time. The values themselves are loaded from the same env (the variable name is the alias and the var holds the secret). See src/executor/command.rs::KEYCHAIN_ENV_ALLOWLIST_VAR. When unset, env aliasing is disabled and all credentials must go through the NoETL keychain API.

The mechanism is a bridge for credentials that already exist as env vars in the worker's environment (because they come from GKE Workload Identity, an existing K8s Secret mount, or similar already-in-place trust per agents/rules/execution-model.md). Net-new business-logic credentials belong in the NoETL keychain, not in this allowlist.

Misc / standard tooling

Variable Default Required Why
HOSTNAME (set by container runtime) β€” Fallback for snowflake node-id derivation.
RUST_LOG (build default) no Standard tracing-subscriber filter. Set to noetl_worker=debug for targeted debugging.
NOETL_IPC_CACHE_BUDGET_BYTES 268435456 (256 MB) no Arrow IPC shared-memory cache budget for same-node zero-copy reads. The pod's /dev/shm must be a memory-backed tmpfs sized above this value, or the worker SIGBUSes (exit 135) when the cache fills past the 64 MiB k8s default β€” see Shared memory. Tune up only when working sets exceed 256 MB; raise the /dev/shm sizeLimit and the pod memory limit in lockstep.
NOETL_PLAYBOOK_AWAIT_CHILD_RESULT true no Sub-playbook result return (noetl/ai-meta#136, noetl-tools 3.18.0). Kill-switch for the playbook tool's opt-in return_result: true mode. When a kind: playbook step sets return_result: true (the travel itinerary-planner's `mcp/google-places

Event result staging (results-by-reference)

Variable Default Required Why
NOETL_EVENT_RESULT_CONTEXT_MAX_BYTES 102400 (100 KB) no Inline budget for a tool result's serialised context on the call.done event. A result within budget goes inline ({status, context}); a result over budget is staged in the durable result store (noetl.result_store) + the shm cache, and the event carries a {data:{_ref}} placeholder + a reference block instead β€” keeping the event log lean. The server orchestrator resolves those references when driving state (hydrate_result_references, noetl/server#197). Lower it to push more results through the reference path (ops tuning, or forcing the path in kind validation β€” =256 was used to validate every PFT result by reference); raise it to keep more results inline. Read per-result in src/executor/command.rs::inline_context_max_bytes.
NOETL_RESULT_MATERIALIZER_ENABLED false no Result materializer β€” shadow Feather tier (noetl/ai-meta#104 Phase B, system pool only). Truthy spawns a SEPARATE noetl_events consume-loop (consumer noetl_result_materializer, own ack cursor) that, for an over-budget result, fetches the authoritative payload (read-only), tiers it (tabular β†’ Arrow Feather, non-tabular β†’ JSON, small β†’ no write) and writes the body to the derived Β§7 physical_key via the server's PUT /api/internal/objects/{key} β€” shadow, alongside noetl.result_store; nothing reads it until the resolve path is enabled. Never alters the authoritative result, never fails an event. Default off β†’ not spawned (true no-op).
NOETL_RESULT_URI_RESOLVE false no Resolve-by-URN read path (noetl/ai-meta#104 Phase C, consume pool). Truthy makes a step that binds the bulk of an over-budget upstream result resolve it by canonical logical URI β†’ cell placement (server registry) β†’ Β§7 key β†’ object bytes (GET /api/internal/objects/{key}) instead of the legacy noetl.result_store fetch. On any registry miss / object miss / decode error it falls back fail-safe to the authoritative resolve_ref (noetl_worker_result_resolve_total{outcome} records resolved_* vs fallback_*). Default off β†’ byte-identical legacy read path.
NOETL_RESULT_MINT_AUTHORITATIVE false no Phase D minting flip (noetl/ai-meta#104 Phase D). ONE flag that makes the URN β†’ Feather/GCS tier the authoritative result store: on the system pool it makes the result materializer the authoritative tier writer (implies NOETL_RESULT_MATERIALIZER_ENABLED); on the consume pool it makes resolve-by-URN the primary read path (implies NOETL_RESULT_URI_RESOLVE). A tier miss / parse failure still falls back fail-safe to the dual-written noetl.result_store (rollback safety), recorded on noetl_worker_result_mint_authoritative_total{path} (tier = authoritative tier served; legacy_fallback = reversible fallback served). Default off β†’ byte-identical to Phase A–C (true no-op). The dual-write to result_store continues until the OQ5-gated retirement decision (NOT Phase D), so flag-off rolls back cleanly.
NOETL_SIDE_EFFECT_BARRIER false no Side-effect durability barrier (noetl/ai-meta#104 Phase E, consume pool). Before the worker (re-)dispatches a side-effecting tool (per the registry classifier noetl_tools::registry::kind_is_side_effecting, noetl-tools 3.17.0), it checks whether the cycle's derived result URN already resolves to a durable result (the Phase C resolve-by-URN read path). If it does β€” the cycle already ran to a durable completion on a prior drive β€” the worker skips re-execution and adopts the recorded result, so an external side effect (HTTP POST, DB write, payment) fires exactly once across a crash-resume / re-drive. Non-side-effecting cycles are never blocked (idempotent recompute is fine); a side-effecting cycle whose result is not durable re-executes normally. Adopt-only: resolve_by_urn returns Some only on a durable tier hit, so the barrier can only ever turn a duplicate side effect into a single one β€” never drop work. Outcomes on noetl_worker_side_effect_barrier_total{outcome} (skipped = adopted durable result; executed = re-executed). Default off β†’ byte-identical to today (the barrier block is short-circuited by the cheap flag read; dispatch unchanged).
NOETL_RESULT_TIER_DR false no Result-tier DR re-derive (noetl/ai-meta#104 Phase F, system pool only). The Feather/JSON result tier is derivable from the WAL β€” an object's bytes are the deterministic encode of the authoritative payload and its location is computed from the logical URI β€” so a missing or corrupt tier object can be rebuilt from its source by re-running the materialization for its URN. Truthy puts the result materializer in verify-and-repair mode: for each over-budget result it re-derives the object + Β§7 key and rewrites it only when the durable object is missing or byte-divergent (corrupt); a healthy object is left untouched. The flag alone spawns the materializer (DR-only mode β€” no need for NOETL_RESULT_MATERIALIZER_ENABLED). Never alters the authoritative result_store source; the rewrite is byte-identical to a fresh materialization. A WAL event re-delivery is therefore a targeted DR repair. Outcomes on noetl_worker_result_tier_dr_total{outcome} (present / rederived / source_gone / error). Default off β†’ byte-identical to Phase B/D (normal write path; this branch never runs).

Off-server state builder shadow (RFC #115 Phase 4)

System worker pool only. The off-server state builder reconstructs orchestrator WorkflowState from the noetl_events WAL (not the materialized noetl.event table), walking the one-level prev_event_id chain and caching the built spine keyed by the immutable chain head (noetl/ai-meta#115 Phase 4). The shadow loop is observation-only β€” it proves on the running cluster that the builder reads the WAL with zero noetl.event scans and that the chain-walk + pool-side cache (hit / incremental tail-advance / cold-rebuild) behave, without touching the drive. The drive cutover that makes the drive consume this builder's state is staged behind the server's NOETL_STATE_BUILDER=offserver.

Variable Default Required Why
NOETL_STATE_BUILDER_SHADOW unset (off) no Truthy (1/true/yes/on) spawns the off-server state-builder shadow loop (system pool). It opens its own ephemeral DeliverAll/AckNone pull consumer on noetl_events (never the materializer's durable noetl_materializer consumer), replays the retained WAL into a pool-side per-execution chain index, and exercises the chain walk + cache, emitting the state_builder metrics below. Observation-only β€” no drive impact; safe to leave on for monitoring. Default off β†’ every other worker unaffected.
NOETL_STATE_BUILDER_STREAM noetl_events no The JetStream WAL stream the shadow drains. Mirror of the materializer's NOETL_MATERIALIZER_STREAM; only override when the stream is renamed.
NOETL_STATE_BUILDER_BATCH 200 no Bounded pull batch (clamped 1–1000).
NOETL_STATE_BUILDER_TIMEOUT_MS 2000 no Pull-batch expiry wait.
NOETL_STATE_BUILDER_IDLE_SLEEP_MS 500 no Sleep when a drain comes back empty (keeps the idle loop off the CPU).

WAL chain-index bounds (noetl/ai-meta#166 Phase 1)

Live in production on both system-pool shards and undocumented here until 2026-08-05. Found by diffing env::var("NOETL_*") call sites in worker@main against this page.

The chain index the builder caches is unbounded by default. That is what took the system pool to ~748 Mi steady and then OOM-killed it at a 768 Mi limit, wedging the off-server drive and stopping Muno turns and login (noetl/ai-meta#163). These three are the cure, and prod runs all of them.

Variable Default Required Why
NOETL_STATE_INDEX_TTL_SECS unset β€” no TTL recommended Evicts an execution's cached spine this many seconds after last use. Parsed as u64; 0 or a non-numeric value is discarded and treated as unset, i.e. unbounded β€” the failure is silent. Prod: 900.
NOETL_STATE_INDEX_MAX_BYTES unset β€” no ceiling recommended Byte ceiling for the whole index; eviction is by least-recently-used above it. Same parse rule, same silent fallback to unbounded. Prod: 268435456 (256 MiB).
NOETL_STATE_INDEX_REHYDRATE_ON_MISS unset (off) yes, with the two above Truthy rebuilds a spine evicted by TTL/ceiling on the next reference. Without it, bounding the index turns a memory problem into a correctness one β€” an evicted execution simply misses. Set this whenever either bound is set. Prod: true.

The interaction is the part worth remembering: TTL and ceiling are safe only in combination with rehydrate-on-miss. Setting either bound alone trades an OOM for silent state loss.

NOETL_STATE_INDEX_MAX_EXECUTIONS and NOETL_STATE_INDEX_REHYDRATE_CONCURRENCY exist in the same code path and are not set in prod.

EHDB tier drivers (NOETL_EHDB_*) β€” default-off; four tiers now shadow in prod

Documented 2026-08-05. These were invisible to three earlier sweeps because their names live in constants (pub const EHDB_KV_MODE_ENV: &str = "NOETL_EHDB_KV") and are read as env::var(CONST) β€” a pattern matching a literal inside the call sees nothing.

EHDB's storage tiers are selected per tier, not by one switch. Every one defaults to off.

Updated 2026-08-10 β€” the "none in prod" statement above was true until this date and is no longer. NOETL_EHDB_{ENABLED,MODE,CLIENT_ROLE,LOCAL_REFERENCE_LOG} plus EVENTLOG/PROJECTION/KV/OBJECT = shadow are now set on the three prod data-plane worker Deployments (noetl-worker-rust, noetl-worker-system-pool, noetl-worker-system-pool-shard1). NOETL_EHDB_VECTOR remains off β€” it has no live call site.

Shadow only. No tier serves anything: it is a dual-write plus parity compare and never touches the authoritative path. Prod parity is holding (mirrored / materialized, last_degraded 0, zero parity_mismatch).

Shadow runs on the worker Deployments, not the writer, because the mirror seams fire per-worker on that worker's own activity; the writer claims only commands.cmdbus.> and would mirror nothing. It needs no PVC β€” the live path passes authoritative_sequence: None, so parity rests on the engine's own count and monotonic-order invariant, and a fresh log after a restart is correct. Durable storage is a primary concern, and primary is where the writer's PVC comes in.

⚠ Do not set any tier to primary β€” but the reason changed on 2026-08-11, and the old reason is no longer true.

Superseded (was true until ai-meta#247): runtime_hook_env armed the mirror only in shadow, so selecting primary silently disarmed verification β€” measured in kind on one binary under identical load, shadow mirrored 30 events and primary mirrored 0.

Now: runtime_hook_env arms the mirror for both shadow and primary (arming is monotonic β€” it can only add mirroring), and the event-log tier has a real authoritative serve path gated by primary_serve::decide. Serving requires three conditions, not one: the tier is primary, a durable tier service is reachable, and parity held on this operation. Any missing condition demotes to the incumbent and records primary_unavailable β€” never a silent wrong answer, never a failed caller.

⚠ Which append path carries the decision depends on how the tier is configured β€” and the serve-ready configuration is not the default one. decide has three call sites, and no single one of them covers every configuration:

call site reached when
eventlog::mirror_live_event (this worker's own hook) NOETL_EHDB_EVENTLOG_MIRROR_SOURCE=worker β€” the default. server disarms the hook before the tier mode is consulted, so this site never runs there
metrics_server::ehdb_tier_append_handler, Resolution::Local branch NOETL_EHDB_TIER_QUERY_SOURCE=local β€” the default
eventlog::serve_service_append, the Resolution::Service branch of the same handler NOETL_EHDB_TIER_QUERY_SOURCE=service

Superseded (was true until worker 8d46b33, worker#273, 2026-08-12): earlier text here described the serve path as riding the append path without naming which one, which read as "any primary flip serves". On MIRROR_SOURCE=server + TIER_QUERY_SOURCE=service β€” the only configuration on which the tier is both complete (#258) and not per-pod fragments (#257 PR 4) β€” the flip reached neither of the first two sites. It produced served_primary = 0, in fact the whole noetl_ehdb_eventlog_ops_total family absent, and no log line, while events flowed and landed correctly. The third site closes that; a property test walks all eight input combinations and asserts its verdict equals decide's, so the sites cannot drift apart about what primary means.

🟒 FLIPPED 2026-08-13 β€” the event-log tier is primary and SERVING in prod on worker v5.118.1 / server v3.81.0. The paragraph that stood here said no tier was primary anywhere, the tier-service address was set nowhere and NOETL_EHDB_TIER_QUERY_SOURCE was unset everywhere; all three are now false. Live prod values are in the Live prod configuration table below. Serving evidence: served_primary=232 with every demote outcome 0, serve_state: served_primary in the endpoint reply, and the IS SERVING log line. The pre-flip gate ran 20 executions on a fresh store: 0 divergence, 20/20 full-set match, tier-service append errors 0, store parses 580/580, and all five replicas returned the same 29 records. The full precondition list is playbooks/257-serve-readiness/GO-LIVE.md. See Umbrella: EHDB Primary Serve Path (ai-meta#257).

Live prod configuration (2026-08-13, shastaratech-noetl-prod ns noetl):

where variable value
writer (noetl-cmdbus-writer) NOETL_EHDB_TIER_SERVICE_BIND 0.0.0.0:9110
writer NOETL_EHDB_TIER_SERVICE_DIR /data/eventbus/ehdb-tier β€” on the eventbus RWO PVC, not pod-local
worker pools (Γ—3) NOETL_EHDB_EVENTLOG primary
worker pools NOETL_EHDB_TIER_QUERY_SOURCE service
worker pools NOETL_EHDB_TIER_SERVICE_ADDR noetl-cmdbus-writer-0.noetl.svc.cluster.local:9110
worker pools NOETL_EHDB_EVENTLOG_MIRROR_SOURCE server (worker hook disarmed)
server NOETL_EHDB_EVENTLOG_MIRROR_SOURCE server
server NOETL_EHDB_WORKER_QUERY_URL http://noetl-worker-rust-metrics.noetl.svc.cluster.local:9090
server NOETL_EHDB_CROSSSTORE_PARITY_ENABLED true

⚠ served_primary is per-pod. The server's mirror relay targets the noetl-worker-rust-metrics Service, so appends land on whichever app=noetl-worker-rust pod it reaches, and the system pools receive none. Sum across replicas when alerting β€” a single pod reading 0 is not a demotion.

⚠ NOETL_EHDB_TIER_QUERY_SOURCE=service without NOETL_EHDB_TIER_SERVICE_ADDR falls back to local with only a WARN. Setting the query source alone gives downgraded_local β€” a read that looks correct and answers from a pod-local fragment. Always set the pair.

⚠ Only the event-log tier can be flipped today. kv, object, projection and vector have a serve_primary_cycle, but its only caller is bin/ehdb-selfcheck, so setting them primary cannot change what any caller receives β€” it is a no-op with a warning. The RFC's suggested cutover order (KV first, event-log last) describes a desired blast-radius order, not a runnable one: the only wired tier is the one it names last. Wiring the other four is unstarted work, tracked on ai-meta#257.

The flip-time signal says which case a tier is in β€” one line and one operation="runtime_hook" counter increment per tier per process (ai-meta#259 corrected these; the old text told operators a serving tier was inert, at the exact moment they were running a cutover):

⚠ Not yet released. The three-outcome table below is worker#263, which is open and unmerged. A binary built from main today records only the single primary_not_wired outcome. Check noetl_worker_build_info{version} against the release that carries #263 before reading these outcomes as current.

⚠ And it fires from runtime_hook_env, which NOETL_EHDB_EVENTLOG_MIRROR_SOURCE=server disarms. On the serve-ready configuration this signal is unreachable β€” so it cannot mislead there, but it also says nothing there, and its silence is not evidence of anything. It is the signal for the MIRROR_SOURCE=worker configuration. On the serve-ready one, read serve_state and the transition log line instead (below). Landing #263 would not have made a primary flip serve: the same disarm routes around both the warning and the first serve call site.

outcome level meaning operator response
primary_not_wired WARN tier has no runtime serve path (kv / object / projection / vector) nothing was promoted; flipping again will not help
primary_no_tier_service WARN serve path exists, NOETL_EHDB_TIER_SERVICE_ADDR unset set the address; until then every op demotes (no_durable_service)
primary_armed INFO serve path armed and a tier service is configured watch served_primary against parity_diverged / no_durable_service

primary_armed is not a claim that the tier is serving β€” reachability and per-op parity are only knowable per operation. It says the flip was accepted.

⚠ Reachability is measured, never assumed, and never inferred from a clock. It is derived from real tier-service operations β€” the append is the probe β€” and a demotion stays in force until a real operation succeeds. A lapsed cooldown permits a retry; it does not restore belief. (Before worker#262 it did, so five seconds after a tier service died the tier decided it was authoritative again with nothing having contacted it.) A service that answers but rejects a record is reachable β€” that is a data problem, and demoting there would let one poisoned payload disable authoritative serving platform-wide.

⚠ Prod workers run ephemeral-storage: 1Gi (request = limit) and local_reference is an unbounded pod-local JSONL log β€” the 8 MiB segment rotation belongs to durable_segment, not this backend. Watch df -h /tmp.

The bus has been live in prod since T5; the storage tiers are shadow-only (ehdb#241).

Variable Purpose
NOETL_EHDB_ENABLED master switch for the EHDB client surface
NOETL_EHDB_SEAL_MAX_AGE_MS (unset)
NOETL_EHDB_SEAL_SWEEP_INTERVAL_MS 1000
NOETL_EHDB_FENCING off
NOETL_EHDB_ELECTION false
NOETL_EHDB_ELECTION_LEASE_SECS 15
NOETL_EHDB_ELECTION_RENEW_SECS 5
NOETL_EHDB_TIER_APPEND_BATCH false
NOETL_CLAIM_LOOP_FAILURE_STREAK 3
NOETL_CLAIM_LOOP_UNHEALTHY_SECS 180
NOETL_EHDB_CATALOG_MIRROR_SOURCE (unset)
NOETL_EHDB_MODE, NOETL_EHDB_CLIENT_ROLE, NOETL_EHDB_CAPABILITIES client posture and declared capabilities
NOETL_RUN_MODE run posture the contract checks against
NOETL_EHDB_EVENTLOG driver for the event-log tier
NOETL_EHDB_KV driver for the KV tier
NOETL_EHDB_OBJECT driver for the object/blob tier
NOETL_EHDB_PROJECTION driver for the projection tier β€” see the note below; its meaning WIDENED in ai-meta#265
NOETL_EHDB_VECTOR driver for the vector tier
NOETL_EHDB_LOCAL_REFERENCE_LOG local reference-log mode
NOETL_EHDB_TENANT, NOETL_EHDB_NAMESPACE scoping for the Flight SQL surface

The projection tier's shadow means more since ai-meta#265, and prod already sets it.

⚠ Two corrections worth stating plainly, because both were recorded wrongly before:

  1. Prod has set NOETL_EHDB_PROJECTION=shadow on all three worker deployments for some time. The 2026-08-14 kind-gate exit note claiming "no NOETL_EHDB_PROJECTION* variable exists on any prod workload" was wrong. A gate's exit assertion is itself a representation.
  2. What that value reaches changed. Before #265 it selected a mode whose only caller was bin/ehdb-selfcheck. Since A1/A2 the same value additionally reaches the tier-generic store (projection.jsonl, its own file and its own lock) and the write-path serve decision through primary_serve::decide. shadow still resolves to "the incumbent is authoritative" and the tier receives nothing at all unless the server carries NOETL_EHDB_PROJECTION_MIRROR_SOURCE=server.

The worker's half of the projection tier is deliberately small:

  • No pod-local write path, by design. A local resolution is refused 503 with a reason. The event log has one only because it predates the tier service; giving the projection tier one would build N disjoint per-pod fragments that a primary flip would promote while the incumbent holds everything.
  • Therefore NOETL_EHDB_TIER_QUERY_SOURCE=service and a reachable NOETL_EHDB_TIER_SERVICE_ADDR are hard prerequisites for the projection tier, not tuning.
  • The worker cannot read noetl.projection_snapshot at all (data-access-boundary.md), so it has no worker mirror mode β€” the mirror is server-authored or absent.
  • worker/src/ehdb/projection.rs still carries shadow_project / fold_window_authoritative, which compare EHDB to a second copy of EHDB's own logic. That is a useful engine-conformance drive and it is not serve-readiness evidence; the evidence is the server-side cross-store comparator against the incumbent's rows.

Server-side variables (NOETL_EHDB_PROJECTION_MIRROR_SOURCE, ..._PARITY_ENABLED, ..._READ_SOURCE, ..._MIRROR_ASYNC, ..._PARITY_LAG_TOLERANCE_SECS) are documented on the noetl/server deployment-specification page. Cutover order and gating: playbooks/265-projection-tier/CUTOVER.md.

Writer-fronted tier service (ai-meta#257). Unset everywhere today; the client is a strict no-op without an address, which is what keeps primary demoting and the flag-off path byte-identical.

Variable Default Purpose
NOETL_EHDB_TIER_SERVICE_BIND unset Server side (the writer). host:port to serve the tier protocol on. Unset β‡’ no listener.
NOETL_EHDB_TIER_SERVICE_DIR unset Server side. Directory backing the tier store. ⚠ Pod-local unless a PVC is mounted β€” the same caveat as the durable event log, and it matters more here because primary serves from this store.
NOETL_EHDB_TIER_SERVICE_ADDR unset Client side (workers). host:port of the writer's tier service; accepts DNS names, not only IP literals (worker#257). Unparseable β‡’ fail closed, never a default address.
NOETL_EHDB_TIER_SERVICE_TIMEOUT_MS 2000 Client connect + read timeout. Also the cost of the one-operation lag: the first read after the service dies pays this before demoting. That request is slower, not wrong β€” the incumbent still answers it. Kept short because the event-log tier serves primary, so a blocked read is latency a user feels. Appends no longer use this bound β€” see the next row.
NOETL_EHDB_TIER_SERVICE_APPEND_TIMEOUT_MS 4000 Append bound, separate from the read bound (ai-meta#324). The two have opposite risk: a slow read should give up quickly, but a slow append that gives up early means the record was not written β€” and under the pre-#320 mirror that was an event the tier would never hold. Sharing one 2 s bound made the safe choice for reads the unsafe one for writes. ⚠ Measured, not guessed: under load the writer's tier append exceeded 2 s and returned 502 … "timed out after 2s"; the server's mirror retried, which is why #320 reports zero loss but a visible recovered count. 4 s sits under the server's own 5 s per-attempt APPEND_TIMEOUT, so a slow append finishes inside one attempt instead of consuming a retry β€” a guard asserts that ordering, because a worker budget above the server's is unreachable by construction. Junk or 0 falls back to the default.
NOETL_EHDB_TIER_QUERY_SOURCE local Which store this worker's tier route reads from and appends to (ai-meta#257 PR 4; introduced by worker#272, server half server#347). local (default) uses this pod's own tier store. service resolves through the writer's tier service at NOETL_EHDB_TIER_SERVICE_ADDR. ⚠ This IS flip precondition P3 β€” see the note below. Unrecognised values resolve to local.

⚠ local at more than one replica is not one tier β€” it is N disjoint fragments, and this is the single most consequential thing on this page. The server's mirror hop resolves the pool's ClusterIP Service, so each event lands on whichever pod that hop reached. Measured at three replicas: one execution's 13 events read back as 0 / 13 / 0 records, and all three replicas labelled their answer local. The cross-store comparator, pinned at each pod in turn, returned divergent / match / divergent for the same execution. Two thirds of reads report the tier as empty while one third reports a clean full-set match β€” so a single-replica sample has a 1-in-3 chance of reading the full set and declaring success. Prod runs multiple replicas.

Setting service on every replica makes the tier one store by construction: the same three replicas then read 13 / 13 / 13 with match from each, and the pod-local stores stay empty because nothing is written to them.

Failure behaviour is deliberately asymmetric, so a misconfiguration cannot degrade quietly into the fragmented state:

situation behaviour
service, address set, service unusable fails loud β€” 503. It does not fall back to local
service, no address set downgrades to local with a WARN, recorded as downgraded_local
service, address malformed disables the read path (misconfigured) rather than being ignored

Point NOETL_EHDB_TIER_SERVICE_ADDR at the writer's stable per-pod DNS (noetl-cmdbus-writer-0.noetl.svc.cluster.local:9110), never the headless Service β€” that round-robins, and the premise is one process.

Attribution rides noetl_worker_ehdb_query_ops_total{operation="tier_query_source.read"} with outcomes service / downgraded_local / misconfigured.

⚠ The label alone is not evidence. A defect can keep tier_query_source: service truthful-looking while serving the pod-local store β€” one mutation did exactly that, passing the label check and failing only the data checks. Compare counts, not labels.

Tier-route reply fields (tier_query_source from worker#272; serve_state from worker#273). GET|POST /ehdb/tiers/{tier} carries two fields that exist so an operator can read the serve decision from something that cannot be absent the way a metric can (Registry::gather prunes empty families, so a labelled series does not exist until it fires):

field tiers values
tier_query_source all local / service β€” which store answered. Necessary for attribution, insufficient as proof; see the warning above
serve_state event log only β€” a bare serve_state on a kv body would describe the wrong thing served_primary / not_primary / no_durable_service / parity_diverged / unknown

⚠ unknown is a real answer and is not not_primary. It means this pod has decided nothing β€” it received none of the execution's appends, because the server's mirror hop resolved to a different replica. Expect served_primary on the replicas that received appends and unknown on the rest; in the three-replica rehearsal that was 1–2 of 3 per execution. Requiring all replicas to report served_primary would be asserting a property of kube-proxy.

Do not read serve_state as parity. It is the tier's authority to answer; cross-store parity against noetl.event is the server comparator's job.

The matching transition log line is emitted once per transition, in both directions β€” not per append, because an append happens 13 times per execution and a line on each is a line nobody reads, which is operationally the same as the no-line state this was added to fix:

NOETL_EHDB_EVENTLOG=primary IS SERVING: the event-log tier answered
authoritatively through the writer-fronted tier service
    serve_state="served_primary" outcome="served_primary"

NOETL_EHDB_EVENTLOG=primary is NOT serving β€” the incumbent answers
(demoted: no_durable_service)
    serve_state="no_durable_service" outcome="unavailable"

The serve series is pinned at 0 at the bind site of the route carrying server-authored appends, gated on EHDB enabled + tier not off, on both shadow and primary β€” so the flip changes values, never which series exist, and a disabled build's /metrics stays byte-identical. Seven outcomes: mirrored / parity_mismatch / primary_divergence / primary_unavailable / rejected / served_primary / unavailable. served_primary 0 means "did not serve"; the family absent means "this binary predates the serve path" β€” and noetl_worker_build_info{version} answers which.

Authoritative event identity (ai-meta#258).

Variable Default Purpose
NOETL_EHDB_EVENTLOG_MIRROR_SOURCE worker Which component mirrors into the event-log tier (noetl/ai-meta#258). worker (default) keeps today's behaviour: this worker's emit chokepoint mirrors the events it emits. server disarms this worker's event-log mirror entirely β€” including the tier-service append that rides the same hook β€” because the server's write chokepoint then mirrors the complete authoritative set, this worker's own events included (they reach noetl.event through POST /api/events, so the server writes them). The disarm is not optional: with both halves mirroring, every worker-emitted event is appended twice and the server's cross-store comparator reports a count divergence on every healthy execution. server also arms POST /ehdb/tiers/eventlog on the worker's query port, which is how the server's appends arrive; without it that route answers 501 unconfigured and appends nothing, so a build carrying this change behaves exactly as the build before it. Set the same value on the server. Anything unrecognised resolves to worker, so a typo restores the old mirror rather than leaving the tier silently empty while NOETL_EHDB_EVENTLOG still reads shadow.
NOETL_EHDB_AUTHORITATIVE_ID_STAMP false Write the server-assigned event_id into a mirrored payload the producer left without one. Five live emit paths (spool_runtime Γ—2, subscription Γ—2) send event_id: None and let the server assign a snowflake, so the mirrored copy of those events carries no identity and cannot be matched against noetl.event at all. Off β‡’ those records are mirrored unidentified, recorded as authoritative_id{outcome="unstamped"} rather than passing silently. A producer-supplied id is never overwritten in either state β€” a stamp that overwrote one would make the two stores agree by construction.

The verdict rides the tier's existing family as noetl_ehdb_eventlog_ops_total{operation="authoritative_id"}, with outcomes agreed / stamped / unstamped / disagreed / not_json / not_supplied. disagreed is a real cross-store identity divergence caught at the append site. Count, ordering and payload parity against the authoritative log are not checked here β€” they are compared server-side (noetl/server#343), because per data-access-boundary.md the worker may not read noetl.*.

Per-tier limits and tuning, same default-off caveat:

Variable Tier
NOETL_EHDB_EVENTLOG_DURABLE_DIR event log β€” ⚠ pod-local unless a PVC is mounted
NOETL_EHDB_EVENTLOG_SHARED_DIR event log β€” same PVC caveat
NOETL_EHDB_EVENTLOG_SEGMENT_MAX_BYTES event log
NOETL_EHDB_EVENTLOG_MAX_PAYLOAD_BYTES event log
NOETL_EHDB_EVENTLOG_GC_INTERVAL_SECS event log
NOETL_EHDB_KV_MAX_VALUE_BYTES KV
NOETL_EHDB_OBJECT_MAX_BYTES object
NOETL_EHDB_PROJECTION_MAX_BATCH projection
NOETL_EHDB_VECTOR_MAX_DIMENSIONS vector
NOETL_EHDB_DATAPLANE_MAX_PAYLOAD_BYTES data plane
NOETL_EHDB_DATAPLANE_MAX_READ_LIMIT data plane
NOETL_EHDB_EVENTSTREAM_MAX_PAYLOAD_BYTES event stream
NOETL_EHDB_EVENTSTREAM_MAX_CONSUME_LIMIT event stream
NOETL_EHDB_RAG_TOP_K RAG retrieval
NOETL_EHDB_RAG_MAX_CHUNK_BYTES RAG retrieval
NOETL_EHDB_RAG_TIME_BUDGET_MS RAG retrieval
NOETL_EHDB_SYSTEM_MAX_MODULE_BYTES system/wasm store
NOETL_EHDB_SYSTEM_MAX_CAPABILITIES system/wasm store
NOETL_EHDB_READINESS_TIMEOUT_SECONDS readiness gate

Flight SQL β€” NOETL_EHDB_FLIGHT_SQL, NOETL_EHDB_FLIGHT_SQL_BIND, NOETL_EHDB_FLIGHT_SQL_TOKEN_ALIAS, NOETL_EHDB_FLIGHT_SQL_TOKEN_FIELD, NOETL_EHDB_FLIGHT_SQL_ALLOW_LOOPBACK_UNAUTH β€” is documented in its own section below.

⚠ NOETL_EHDB_FLIGHT_SQL_ALLOW_LOOPBACK_UNAUTH disables authentication on the loopback interface. Development only.

Auto-sink (NOETL_AUTOSINK_*) β€” default off, not in prod

Variable Purpose
NOETL_AUTOSINK Master switch. Truthy makes the auto-sink task eligible to spawn β€” it still needs a positive interval, so setting this alone does nothing.
NOETL_AUTOSINK_INTERVAL_SECS Sweep interval; non-positive keeps the task unspawned.
NOETL_AUTOSINK_MIN_BYTES Minimum size before a sink is worthwhile.
NOETL_AUTOSINK_TARGET Sink destination.

Keychain strictness

Variable Default Why
NOETL_KEYCHAIN_STRICT off Truthy makes an unresolved keychain alias fail loudly instead of rendering empty, and distinguishes NotFound from FetchFailed. Shipped in v5.97.0. Deliberately default-off: a playbook naming an alias it does not strictly need would start failing, which is the right end state but a behaviour change that wants an explicit opt-in.
NOETL_TEST_H5_SOURCE_PROBE β€” Test probe; not deployment configuration.

Embedded events-bus writer (NOETL_EVENT_BUS_*)

Set only on the pod that hosts the writer. NOETL_EVENT_BUS_HOST is the master switch; the rest configure the faces it binds. None is set on the prod worker pools β€” prod runs the writer as its own StatefulSet (noetl-cmdbus-writer), so these are the in-process alternative.

Variable Default Why
NOETL_EVENT_BUS_HOST off Host the events-bus writer in this process.
NOETL_EVENT_BUS_INGEST_BIND unset Ingest face (9103 in the deployed layout).
NOETL_EVENT_BUS_CLAIM_BIND unset Claim face (9104).
NOETL_EVENT_BUS_SSE_BIND unset SSE face (9105) β€” the gateway's NOETL_EVENT_FEED_ADDR target.
NOETL_EVENT_BUS_METRICS_BIND unset Metrics face (9106) β€” the events-lag port nothing currently scrapes.
NOETL_EVENT_BUS_KV_BIND unset KV face (9107) β€” the gateway's NOETL_KV_ADDR target.
NOETL_EVENT_BUS_WAL_BIND unset WAL fan-out face (9108) β€” the state builder's NOETL_EVENT_BUS_WAL_ADDR target.
NOETL_EVENT_BUS_SHARD 0 This writer's shard ordinal.
NOETL_EVENT_SHARD_COUNT 1 Total event shards.
NOETL_EVENT_BUS_WRITER_DIR unset On-disk root for the log. ⚠ pod-local unless a PVC is mounted β€” the same trap as the durable eventlog dir above.
NOETL_EVENT_BUS_KV_DIR unset On-disk root for KV. Same PVC caveat.
NOETL_EVENT_BUS_CURSOR_PERSIST_SECS β€” How often consumer cursors are flushed. A longer interval widens the redelivery window after an unclean restart.
NOETL_EVENT_BUS_ACK_WAIT_SECS 30 Redelivery window for an unacked claim.
NOETL_EVENT_BUS_CURSOR_FALLBACK unset Where a consumer with no persisted cursor starts.

Materializer tuning (NOETL_MATERIALIZER_*, NOETL_RESULT_MATERIALIZER_*)

Two independent drain loops with an identical knob set and identical defaults. Neither is set in prod, so both run on the defaults below.

Variable Default Why
NOETL_MATERIALIZER_BATCH 200 Pull-batch size, clamped 1–1000 β€” an out-of-range value is silently clamped, not rejected.
NOETL_RESULT_MATERIALIZER_BATCH 200 As above, result loop.
NOETL_MATERIALIZER_TIMEOUT_MS 2000 Pull-batch expiry wait.
NOETL_RESULT_MATERIALIZER_TIMEOUT_MS 2000 As above, result loop.
NOETL_MATERIALIZER_IDLE_SLEEP_MS 500 Sleep after an empty drain.
NOETL_RESULT_MATERIALIZER_IDLE_SLEEP_MS 500 As above, result loop.
NOETL_MATERIALIZER_ERROR_BACKOFF_MS 2000 Sleep after a failed drain.
NOETL_RESULT_MATERIALIZER_ERROR_BACKOFF_MS 2000 As above, result loop.
NOETL_MATERIALIZER_CONSUMER per-loop constant Durable consumer name. ⚠ Changing it starts a new consumer at the fallback position rather than resuming β€” the #166 Phase 5 shards depend on these names.
NOETL_RESULT_MATERIALIZER_CONSUMER per-loop constant As above, result loop.
NOETL_RESULT_MATERIALIZER_STREAM constant Stream the result loop drains.
NOETL_MATERIALIZER_FAULT_FAIL_FIRST 0 Fault injection β€” fails the first N drains deliberately. Non-zero in production would be a mistake.

State-builder tuning

Variable Default Why
NOETL_STATE_BUILDER_CONSUMER constant Durable consumer name; same rename caveat as above.
NOETL_STATE_BUILDER_UNHEALTHY_SECS 45 How long the drain may be continuously unable to serve before /livez fails. Sized longer than the in-process self-heal window so a transient consumer loss recovers without a restart while a wedge the self-heal cannot clear trips the k8s backstop (~45s + periodSecondsΓ—failureThreshold β‰ˆ 135s). Referenced by the liveness probe documented above.
NOETL_STATE_BUILDER_REBUILD_SECS 5 Cold-rebuild retry interval.
NOETL_STATE_BUILDER_DURABLE off Durable-consumer mode for the builder.
NOETL_STATE_BUILDER_DRIVE_RETRIES unset Retry count when the drive asks for state the index has not caught up to β€” the Incomplete outcome, which is "not yet knowable", not "the chain is broken".
NOETL_STATE_BUILDER_DRIVE_RETRY_MS unset Delay between those retries.
NOETL_OFFSERVER_SPINE_ORDER off Spine causal-ordering mode (#117).
NOETL_SINK_GATE_EVICTION off SinkGate eviction primitive (#199); default-off, not in prod.

Chain-index rehydration

Companions to the TTL / byte-ceiling bounds above β€” these govern the refill after an eviction, which is what makes bounding the index safe.

Variable Default Why
NOETL_STATE_INDEX_REHYDRATE_BATCH 500 Messages per refill pull, clamped 1–2000.
NOETL_STATE_INDEX_REHYDRATE_POLL_MS 300 Poll interval, clamped 50–5000.
NOETL_STATE_INDEX_REHYDRATE_DEADLINE_MS 3000 Wall-clock budget for one rehydrate. ⚠ Exceeding it yields an incomplete spine, so this trades latency against a ColdRebuild.
NOETL_STATE_INDEX_REHYDRATE_MAX_MESSAGES 100000 Hard ceiling per rehydrate.
NOETL_STATE_INDEX_MAX_EXECUTIONS unset Cap on executions held, alongside the byte ceiling.
NOETL_STATE_INDEX_REHYDRATE_CONCURRENCY unset Parallel rehydrates.

Remaining

Variable Default Why
NOETL_CONTAINER_COMPLETION_POLL off Poll for container-step completion instead of waiting on the callback.
NOETL_SUBSCRIPTION_PATH unset Subscription playbook path for the subscription runtime.
NOETL_CLAIM_DEAD_LETTER off Park a command the claim path can never resolve (server returns 404) instead of retrying it forever. Off = the pre-fix behaviour: an unresolvable command is retried indefinitely and, in enough quantity, starves the pool of claim slots β€” the ai-meta#249 wedge, reproduced with 6 poison commands (+13 failures / 0 successes while healthy work sat RUNNING). On = the command is acked and counted on noetl_worker_claim_dead_lettered_total. Never a silent drop β€” parking is recorded at ERROR with the command id.
NOETL_PROVIDER_ERROR_TERMINATES off Treat an MCP provider result carrying isError as a terminal step failure (call.error + command.failed) instead of call.done + command.completed(success). Off = the ai-meta#246 behaviour, where a provider that failed on credentials or an API error still advanced the DAG down its success arc. ⚠ Enabling this is a real semantics change: playbooks that today continue past a failed provider hop will begin failing. Releases a pending sink-gate entry (released_provider_error) so the terminal path cannot leak an eviction block.
NOETL_STATE_AFFINITY_MAX_REDIRECTS, NOETL_STATE_AFFINITY_NAK_DELAY_MS β€” Sub-knobs of the affinity policy. ⚠ Read only by the unreachable AffinityConfig::from_env β€” see the decorative-flag note above.

Bus source selectors and shard identity

Found 2026-08-05 by a second pass. The first pass matched only env::var("NOETL_*") and missed everything read through the crate's env_bool / env_u32 / env_u64 / env_addr helpers β€” 55 variables in this binary. All eight below are set in prod.

Each consumer picks its transport independently. Post-T5 they are all ehdb, and there is nothing else to point them at.

Variable Default Required Why
NOETL_MATERIALIZER_SOURCE unset yes, in prod Transport the event materializer drains. Prod: ehdb.
NOETL_RESULT_MATERIALIZER_SOURCE unset yes, in prod Same for the result materializer. Prod: ehdb.
NOETL_STATE_MATERIALIZER_SOURCE unset yes, in prod Same for the state materializer. Prod: ehdb.
NOETL_STATE_BUILDER_SOURCE unset yes, in prod Same for the off-server state builder. Prod: ehdb.

⚠ They are four independent selectors, not one switch. A cutover that misses one leaves that single consumer pointed elsewhere while the other three move, and nothing cross-checks them.

Variable Default Required Why
NOETL_SHARD_INDEX 0 yes, per shard This pod's shard ordinal. Prod: 0 on noetl-worker-system-pool, 1 on -shard1 β€” the #166 Phase 5 two-shard system pool.
NOETL_STATE_INDEX_SWEEP_SECS 15 no How often the chain-index eviction sweep runs. Completes the bounds documented above: TTL and byte-ceiling decide what is evicted, this decides how often anything is. Prod: 30.

NOETL_STATE_AFFINITY_ROUTE β€” set in prod, read by nothing

Variable Prod value Effect
NOETL_STATE_AFFINITY_ROUTE true none

Its only reader is sharding::AffinityConfig::from_env, which has no caller. AffinityConfig::decide() is exercised only by sharding.rs's own unit tests, and record_affinity_decision β€” the counter for those decisions β€” has no caller either, so the metric is absent from a live pod.

The steering block that used all three was deleted by 14a3f1f chore(nats): remove the NATS command path during T5 and was not reinstated on the EHDB path. The likely reading is that it is superseded rather than lost β€” Phase 5 put system workers on per-shard durable consumers and L1 T4 made pool isolation and shard routing subject dimensions, so the writer routes and worker-side steering is redundant. That is inference, not proof; the code cannot tell the two apart.

Until it is settled, read this variable as decorative: setting it true does not enable anything. Tracked on noetl/ai-meta#242 and #166.

Events-bus addresses (post-T5)

Also live on both system-pool shards and previously undocumented. Both are read as trimmed strings and an empty value is filtered to None, so a blank setting is indistinguishable from unset.

Variable Default Required Why
NOETL_EVENT_BUS_CLAIM_ADDR unset yes, in prod Materializer's claim face on the writer. Prod: noetl-cmdbus-writer-0.noetl.svc.cluster.local:9104.
NOETL_EVENT_BUS_WAL_ADDR unset yes, in prod WAL fan-out the state builder drains. Prod: …:9108.

Result staging identity

Variable Default Required Why
NOETL_RESULT_CELL_REGION local no Region label on minted result references. Defaulting silently to local in a real region makes references look local-tier. Prod: usc1 on the server and both system pools.
NOETL_RESULT_PRODUCER_STAGE unset (off) no Truthy enables the producer-stage result path. Prod: true on noetl-worker-rust and both system pools.

Internal API token

Variable Default Required Why
NOETL_INTERNAL_API_TOKEN unset yes, for system pools Bearer credential for the server's /api/internal/* routes, which is how workers reach noetl.* at all (agents/rules/data-access-boundary.md). Set on both system pools in prod. Supply from a K8s Secret; value deliberately not recorded here.

State-builder metrics (:9090/metrics): noetl_worker_state_builder_wal_events_total (events consumed from the WAL β€” the WAL-read proof, RFC tenet 5) Β· noetl_worker_state_builder_event_scans_total (noetl.event scans the builder issued β€” the no-scan proof, RFC tenet 3; stays 0) Β· noetl_worker_state_builder_builds_total{outcome=cache_hit|incremental|cold_rebuild|incomplete} (cache effectiveness + correctness) Β· noetl_worker_state_builder_chain_hops (chain-walk depth per cold rebuild) Β· noetl_worker_state_builder_build_duration_seconds{outcome=…} β€” build latency by the same four outcomes, new in v5.96.0. Until it shipped the counter said which path a build took and nothing said what it cost, which is why #156 could quantify the per-hop floor in kind and not on prod.

Object-store state-shard writer (noetl/ai-meta#166 Phase 2 β€” shadow)

The write-side sibling of the #104 result materializer: a third read-model projector off the noetl_events WAL (durable consumer noetl_state_materializer, self-ensured at startup) that projects each execution's slim event-chain (event_id, prev_event_id, event_type, node_name, status, the #104 result_ref URN, a bounded extracted block, and a payload column β€” the verbatim shard_chain_payload (slim projection + prev_event_id) the Phase-3 cold-load reader reconstructs a byte-equivalent chain from) into a per-execution Arrow Feather shard on object store at …/shard=sNNNN/.../execution=<eid>/state/<open|sealed>.feather (co-located with the same execution's result bytes via the shared FNV-1a shard_key). Append-while-live, seal-on-terminal. Written by the shadow writer; read by the Phase-3 cold-load path (below) when NOETL_STATE_SHARD_READ is on β€” otherwise the drive serves from the Phase-1 bounded in-memory index and the writer is output-neutral.

READ-ONLY w.r.t. noetl.* (object PUT only β€” preserves the #103 sole-writer); never fails an event (errors counted + WARN-logged with execution_id, batch acked, idempotent key β†’ safe redelivery); bounded by terminal eviction + idle-TTL sweep + a max-open ceiling so it never reproduces the unbounded Phase-1 index it complements. Default off β†’ not spawned (true no-op). Instant rollback = unset NOETL_STATE_SHARD_WRITE.

Variable Default Required Purpose
NOETL_STATE_SHARD_WRITE false no Master toggle (system pool only). Truthy (1/true/yes/on) spawns the shadow state-shard writer. Default off β†’ not spawned.
NOETL_STATE_SHARD_CONSUMER noetl_state_materializer no Durable pull-consumer name on noetl_events. Self-ensured (idempotent create_consumer) so no server release is required.
NOETL_STATE_SHARD_STREAM noetl_events no The WAL stream drained. Override only if the stream is renamed.
NOETL_STATE_SHARD_BATCH 200 no Bounded pull batch (clamped 1–1000).
NOETL_STATE_SHARD_TIMEOUT_MS 2000 no Pull-batch expiry wait.
NOETL_STATE_SHARD_IDLE_SLEEP_MS 500 no Sleep when a drain returns empty (also runs the idle sweep).
NOETL_STATE_SHARD_ERROR_BACKOFF_MS 2000 no Backoff after a drain error.
NOETL_STATE_SHARD_EXTRACTED_BUDGET 4096 no Byte cap on the inline extracted snippet kept per event (the slim-chain memory lever). The authoritative body is always re-fetchable by the result_ref URN.
NOETL_STATE_SHARD_MAX_OPEN 8192 no Resident open-shard ceiling β€” evict least-recently-active open shards beyond this. The abandoned-execution backstop; the writer must never itself OOM.
NOETL_STATE_SHARD_OPEN_TTL_SECS 3600 no Idle sweep: an open shard untouched this long (wedged / never-terminal execution) is evicted.
NOETL_STATE_SHARD_OPEN_MIN_INTERVAL_SECS 30 no Min interval between open-shard rewrites per execution (RFC Β§4.3 cadence / write-amplification throttle). The open shard rewrites to the same object key, which object stores rate-limit (~1 mutation/s/object); without this an execution spanning many drain cycles amplifies into HTTP 429s. The terminal sealed write (distinct key) is never throttled.

Cell placement reuses the result tier's NOETL_RESULT_CELL_ENV / _REGION / NOETL_RESULT_CELL / NOETL_RESULT_SHARD_COUNT so a state shard lands in the same cell + s{fnv:04} folder as that execution's result bytes.

State-materializer metrics (:9090/metrics): noetl_worker_state_materializer_drained_total Β· …_rows_total (slim rows projected) Β· …_shards_written_total{seal=open|sealed} Β· …_shard_bytes_total Β· …_errors_total (encode/write failures β€” never fails an event) Β· …_evicted_total{reason=idle|max_open} Β· …_open_shards (resident working set, O(live executions)) Β· …_cycle_duration_seconds. Writer lag rides ehdb_events_group_lag{group="noetl_state_materializer"} β€” it used to ride a worker-side JetStream gauge, which T5 left with nothing writing it (noetl/ai-meta#242).

Object-store state-shard cold-load (noetl/ai-meta#166 Phase 3)

The read side that makes the Phase-1 bounded index cheap: on an off-server drive cache miss (the execution's chain was evicted by TTL/byte pressure, or this pod never indexed it), the drive cold-loads the execution's state by reading the Phase-2 Feather state shard from object store β€” one keyed GET /api/internal/objects/{key} (~tens of ms) β€” instead of replaying the retained noetl_events WAL (a scan of up to the whole 24h window, bounded to the rehydrate deadline). The shard's payload column carries the verbatim shard_chain_payload per event, so the reconstructed WalEventIndex chain is byte-equivalent to the WAL-replay / in-memory path by construction (same payload β†’ same apply β†’ same spine β†’ same drive commands). The drive makes routing decisions (next.arcs, when, loop counters, fan-in) from this spine, so byte-equivalence is the correctness bar.

Correctness never depends on the shard alone (belt-and-suspenders):

  • No shard (never written / GC'd) β†’ object_get 404 β†’ fall back to the WAL replay (unchanged).
  • Stale open shard (writer cadence lags the WAL tip) β†’ the reconstructed chain can't reach expected_head β†’ the advance_to staleness guard returns Incomplete β†’ fall back to the WAL replay, which supplies the tail.
  • NOETL_STATE_SHARD_READ_VERIFY dual-build guard β†’ after a shard build serves, also run the WAL replay and byte-compare the two spines; on any divergence increment state_equivalence_mismatch_total and serve the WAL build. Never serves divergent state.

Read-only w.r.t. object store + noetl.*. Byte-equivalence assumes the slim index (NOETL_STATE_INDEX_SLIM=on, the prod default): with slim off the shard stores the slim projection while the WAL stores the full envelope β€” still output-equivalent to the drive, but not byte-identical. Default off β†’ the miss path is byte-identical to today's WAL replay. Instant rollback = unset NOETL_STATE_SHARD_READ.

Variable Default Required Purpose
NOETL_STATE_SHARD_READ false no Master toggle (system pool only). Truthy spawns nothing β€” it gates the cold-load on the drive miss path. On β†’ try the shard before the WAL replay; off β†’ WAL replay only (byte-identical to today).
NOETL_STATE_SHARD_READ_VERIFY false no Equivalence dual-build guard β€” after a shard build serves, also run the WAL replay and byte-compare. A validation/canary knob: it pays BOTH reads (no latency win), used to prove state_equivalence_mismatch_total == 0 on kind + a prod canary, then turned off.

The reader resolves the shard object key deterministically β€” tenant/project default to default/default (the worker mints result URNs with no tenant/project), the folder shard + cell come from the same NOETL_RESULT_CELL_* env the writer read, and the date partition derives from execution_id β€” so no carried metadata is needed.

State-reader metrics (:9090/metrics): noetl_worker_state_shard_reads_total{outcome=hit|miss|fallback} (the payoff distribution β€” hit = shard served, one object read replacing a WAL scan) Β· noetl_worker_state_shard_read_duration_seconds (cold-load latency vs the WAL-replay miss cost) Β· noetl_worker_state_equivalence_mismatch_total (the VERIFY divergence tripwire β€” MUST stay 0). The served-outcome also shows on noetl_worker_state_builder_drive_builds_total{outcome=served_shard} (vs served_rehydrated for the WAL path).

Cold-start plug-in warmup (noetl/ai-meta#130)

The off-server orchestrate drive runs the system/orchestrate wasm module via wasmtime. On the first dispatch after a fresh pod start the module is fetched from the server catalog and Cranelift-JIT-compiled (Module::new) before it is cached by digest β€” a one-time ~1.5s cost (β‰ˆ99% compile, β‰ˆ1% fetch of the ~1.6MB module) that the subsequent warm hops do not pay. Boot warmup compiles the module once at startup, behind the /readyz gate, so the first real drive is a cache hit.

Variable Default Required Why
NOETL_WARM_ORCHESTRATE_PLUGIN auto (drive pool) no Force boot-time compile of system/orchestrate@1 on/off (1/true/yes/on vs 0/false/no/off). Unset it defaults to the pool that actually drives orchestrate β€” the system pool, detected by running the off-server state-builder drain (NOETL_STATE_BUILDER set) or the CQRS materializer (NOETL_MATERIALIZER_ENABLED). A leaf tool pool never receives system/orchestrate, so it does not warm (no wasted startup/RAM). The warmup runs before the pull loop claims, so the first orchestration is warm even when /readyz is not wired; failure is non-fatal (the first dispatch lazy-loads).

Warmup metrics (:9090/metrics): noetl_worker_plugin_load_seconds{phase="fetch"|"compile"} (the cold-start attribution β€” compile is the dominant phase) Β· noetl_worker_plugin_warm_total{outcome="warmed"|"skipped"|"error"} (boot warmup outcome) Β· noetl_worker_ready (0 while warming, 1 once warm β€” the /readyz source of truth).

EHDB command bus (noetl/ai-meta#194 L1 T4)

NOETL_COMMAND_BUS selects which transport carries commands: nats (default, unchanged), shadow (the writer exists and receives the server's publishes, but NATS stays authoritative and the worker keeps consuming NATS), or ehdb (the worker claims from the EHDB bus and creates no NATS subscriber β€” the shape T5 needs).

One worker per shard is the writer host (NOETL_COMMAND_BUS_HOST). It opens the shard's durable command log and serves three faces: ingest (the server publishes here), claim (worker replicas compete here), and a Prometheus lag endpoint (the KEDA signal). Every other worker is a claimer and needs only NOETL_COMMAND_BUS_CLAIM_ADDR.

Variable Default Required Why
NOETL_COMMAND_BUS nats no Transport selector (nats / shadow / ehdb). Anything unrecognised is nats, so a typo degrades to the default rather than to a dark bus.
NOETL_COMMAND_BUS_HOST false no This worker hosts its shard's writer. Exactly one pod per shard; a second host on the same volume is not supported (single-writer-per-shard is what makes the shard log ascending).
NOETL_COMMAND_BUS_SHARD 0 for a host Which shard this writer owns.
NOETL_COMMAND_SHARD_COUNT 1 no Routing modulus. Must match the server's β€” it is the divisor in the commands.<pool>.shard.<n> subject both sides derive.
NOETL_COMMAND_BUS_WRITER_DIR β€” for a host The command log's directory. Mount a PVC here. It holds the log and the committed cursor, so a pod-local emptyDir throws away the writer's durable state on every restart.
NOETL_COMMAND_BUS_INGEST_BIND β€” (never binds) for a host Where the server publishes commands (0.0.0.0:9100).
NOETL_COMMAND_BUS_CLAIM_BIND β€” (never binds) for a host Where worker replicas compete for commands (0.0.0.0:9101).
NOETL_COMMAND_BUS_METRICS_BIND β€” (never binds) for a host The lag / committed-cursor endpoint (0.0.0.0:9102); see the metrics note below.
NOETL_COMMAND_BUS_CLAIM_ADDR β€” in ehdb mode The claim coordinator as host:port. A DNS service name works and is preferred (noetl-cmdbus-writer.noetl.svc.cluster.local:9101) β€” it is resolved at connect time, so a restarted writer's new pod IP is picked up on redial.
NOETL_COMMAND_BUS_ACK_WAIT_SECS 30 no How long a claimed-but-unacked command waits before it is redelivered to another replica (the crash-recovery window). Too low redelivers work still in progress; too high delays recovery from a killed pod.
NOETL_COMMAND_BUS_CURSOR_PERSIST_MS 1000 no How often the host persists its committed cursor to NOETL_COMMAND_BUS_WRITER_DIR (noetl/ai-meta#208). This is what a restart resumes from; before it existed, every restart replayed the whole shard log. The cursor may lag reality by one interval, which is the safe direction (a few already-acked commands are redelivered and answered "already claimed"). 0 disables the ticker β€” the shutdown persist still runs, so a graceful restart is still exact.
NOETL_COMMAND_BUS_CURSOR_FALLBACK tail no Where the host starts when no cursor has been persisted yet β€” the first boot after #208 shipped, or a wiped volume. tail serves only commands appended after the restart; beginning replays the whole log (the pre-#208 behaviour, kept as a rollback lever). tail leans on the control plane's orphaned-command guardrail (#171) to re-issue anything skipped by that one-time gap, which is cheaper than stalling dispatch behind a full replay.

Restart behaviour (#208). A writer restart is a routine event β€” node drain, image bump, OOM, HPA move β€” and both halves of surviving it are now in the binary, shipped in v5.81.3 and prod-validated 2026-07-30: the writer pod was deleted mid-stream under continuous load and 38 executions β†’ 38 COMPLETED, 114 = 38 Γ— 3 commands, 0 duplicates / 0 loss, redial in ~2.7 s, resuming from the committed cursor rather than replaying the shard.

⚠️ This mattered more than it looked. Before v5.81.3 the pre-fix bug had left prod's user-playbook dispatch silently dead for ~2.4 days (writer restarted 2026-07-28 07:49Z; every user-pool claimer wedged on a half-open socket with no error anywhere) β€” masked because the system pool happened to restart later and kept its own commands flowing. If you are debugging "executions stuck at command.issued", check the claimers' last log line against the writer's start time before anything else.

  • Claimers detect a writer that stopped answering (TCP keepalive plus a coordinator heartbeat on the claim connection) and redial on their own, so restarting the writer alone no longer requires restarting the workers. A redial logs EHDB claim_next failed; reconnecting to the claim coordinator with the claim address, member and filter.
  • The host resumes from its committed cursor instead of replaying, and logs the decision once at startup: EHDB command-bus claim coordinator resumed shard=0 from_cursor=N origin=persisted|fallback_tail|fallback_beginning. origin=fallback_* on a pod that should have had a cursor means the volume did not survive β€” check that the PVC is really mounted.
  • On SIGTERM the host persists the cursor and seals the log. The seal matters: the storage engine reopens from its durable manifest, so records still in an unsealed part are invisible to the restarted process even though every append was fsynced. Give the pod enough terminationGracePeriodSeconds for the seal to finish β€” prod runs 90 s (raised from the 30 s default for exactly this). ⚠️ The seal covers graceful stops. An ungraceful stop (SIGKILL, node loss) can still lose the unsealed tail of the active part, and the seal itself races in-flight ingest β€” noetl/ai-meta#209, open. On a command bus that is a loss class, so it should land before T5 removes the NATS fallback.
  • The persisted cursor is clamped to the reopened log's tip. This is not a micro-optimisation: the engine resumes from its durable manifest, so a log that lost an unsealed part can reopen behind the persisted cursor β€” and because the writer then re-issues keys from its recovered sequence, a cursor no future key can exceed would filter out every record and take the bus permanently dark. Clamping costs at most a small redelivery.

Shutdown sequence + its knobs (#209, #226). A process may host more than one writer β€” the command bus and the events feed β€” and the phases run across every host at once rather than finishing one host before starting the next: stop all ingest β†’ quiesce once β†’ persist all cursors β†’ seal all engines. Host-at-a-time left the second host accepting and appending for the whole of the first host's sequence and then spent its own deadline from a standing start, out of the same shared budget.

Variable Default Required Meaning
NOETL_EHDB_SHUTDOWN_TIMEOUT_MS 15000 no Total budget for the whole shutdown sequence, across every host. Bounded so a wedged uploader cannot hold the pod past terminationGracePeriodSeconds and turn a graceful stop into the SIGKILL that loses the tail. Must stay comfortably below the grace period.
NOETL_EHDB_SHUTDOWN_QUIESCE_MS 250 no How long already-accepted publishers get to land and be acked after the listeners close, before the seal. Paid once for all hosts, not per host. Connections already accepted are owned by tasks inside ehdb-feed and cannot be cancelled from outside, so this window is what lets their appends reach the pre-seal part; anything that misses it is refused by the closed writer and republished to the replacement.
NOETL_EHDB_SHUTDOWN_STOP_TIMEOUT_MS 2000 no How long to wait for each host's faces to confirm their listener is actually closed before sealing anyway. A face that will not stop is a bug, but sealing late is worse than sealing with a listener open β€” the seal is what prevents loss. A timeout is logged, not fatal.

Verify a graceful stop from the terminating pod's own log: it should end with EHDB writer hosts all sealed β€” shutdown complete and Worker stopped. An ERROR line saying the hosts did not all seal β€” or the absence of both lines β€” means an unsealed tail, and the next incarnation will resume clamped=true over a log that came back below its own persisted cursor. The same fact is on /metrics as noetl_worker_shutdown_hosts_sealed vs _total; see Observability.

Metrics (:9102, host only): ehdb_feed_shard_lag{shard} + ehdb_feed_total_lag (the KEDA trigger β€” undelivered + delivered-but-unacked) Β· ehdb_feed_shard_committed{shard} (the acked-through cursor, i.e. what a restart would resume from). Β· ehdb_feed_shard_inflight{shard} + ehdb_feed_total_inflight (delivered-but-unacked depth, from ehdb v0.7.0). Across a writer restart the committed cursor should pick up roughly where it left off; a jump back toward 0 with a lag spike to the size of the log is the replay regression #208 fixed.

⚠️ Do not read the resume log line as proof on its own. On the prod validation the resume took the durable path (origin=persisted) yet logged from_cursor=0, and ehdb_feed_shard_committed reset 408 β†’ 165 across the restart β€” the counter is segment-relative, not a global monotonic offset. No replay happened (proven by exact command accounting), but the line cannot distinguish "resumed" from "replayed from 0". Until noetl/ehdb#304 lands, confirm a resume by counting delivered commands, not by reading the cursor.

⚠️⚠️ inflight must be sampled, never defaulted. render_snapshot publishes this family unconditionally, and per its own HELP text high lag with zero in-flight is the stalled-consumer reading. So a writer that fills the field with a literal 0 does not report "unknown" β€” it reports an outage, on every scrape, for a bus that is working. The pair is what separates two opposite conditions that lag alone renders identically:

lag inflight what is happening
high 0 nothing is being worked on β€” consumers absent, stalled, or not polling
high at cap consumers saturated and making progress
0 0 drained

⚠️ And a drained bus cannot tell you the sampler works: lag 0 / inflight 0 is both the honest drained reading and what a hardcoded zero looks like. The discriminating state is claimed-but-unacked work, which is why tests/cmdbus_writer_metrics.rs claims three records without acking and asserts the scrape reads inflight=3 while lag stays at 6 β€” lag counts undelivered plus unacked, so a poll-without-ack moves it by zero. Before ehdb v0.7.0 the coordinator had no accessor for the value, so 0 was the only thing a writer could compile (noetl/ehdb#402).

⚠️ The exposed lag is whole-shard, not per-pool. ehdb_feed_total_lag counts every subject past one committed cursor, so on a single-shard deployment carrying both commands.shared.shard.0 and commands.system.shard.0 it over-reports either pool's own queue. Safe for scaling up; the hazard is that a stuck system-pool command pins the global cursor and therefore pins lag high indefinitely. Per-subject lag is noetl/ehdb#303, open. See the ops wiki KEDA page for how this feeds the user pool's autoscaler.

The writer Deployment is not yet a checked-in noetl/ops manifest β€” it lives with the cutover automation in noetl/ai-meta (cmdbus-writer-shard0.yaml, its PVC, and the staged shadow β†’ canary β†’ flip β†’ rollback scripts). The claimer side is the NOETL_COMMAND_BUS* env on the ordinary worker deployments in noetl/ops.

EHDB Data Query Interface + Flight SQL external driver (noetl/ai-meta#178 + #184)

Two read-only EHDB surfaces the data-plane worker can expose, both disabled by default and gated behind NOETL_EHDB_ENABLED:

  • In-cluster tier query (#178) β€” the GET /ehdb/tiers/{tier} route on the existing metrics/query port :9090 the noetl-server relays to (see Ports). Adds no new worker env var of its own β€” it rides NOETL_EHDB_ENABLED + the tier mirror config.
  • External Flight SQL driver (#184) β€” a dedicated Arrow Flight SQL listener on port 8092 serving the projection tier read-only to apps outside the platform. Inert unless opted in; fail-closed (a non-loopback bind with no token alias is refused at startup, so it never serves open).
Variable Default Required Why
NOETL_EHDB_FLIGHT_SQL β€” (listener never binds) to enable the driver Opt-in for the external Flight SQL projection endpoint. Truthy and a data-plane role and a satisfiable auth mode are all required before the :8092 listener binds; otherwise the worker is byte-identical to one without the driver.
NOETL_EHDB_FLIGHT_SQL_BIND 0.0.0.0:8092 no Listener socket address. An unparseable value logs an error and leaves the endpoint unstarted (fail-closed, not fail-open).
NOETL_EHDB_FLIGHT_SQL_TOKEN_ALIAS β€” for any non-loopback bind Keychain alias of the scoped read token the driver requires on every Flight SQL request. A non-loopback bind without this alias is refused β€” the driver never serves an unauthenticated external listener. The token is resolved from the keychain, never an env literal.
NOETL_EHDB_FLIGHT_SQL_TOKEN_FIELD (see code) no Field within the keychain entry to read the token value from, when the alias resolves to a multi-field secret.
NOETL_EHDB_FLIGHT_SQL_ALLOW_LOOPBACK_UNAUTH β€” (off) no Harness escape hatch: run without a token only when the bind is loopback. Never set on a cluster/GKE deployment β€” it is for local validation against 127.0.0.1 only.

The driver is read-only and serves the projection tier through the same ProjectionDriver read contract as #178 (built on it, not a fork); it opens no write path and relays no secrets. The kind deployment + a pyarrow validation client live in noetl/ops (ci/manifests/noetl/ehdb-flightsql-deployment.yaml, ci/validation/ehdb_flightsql_client.py).

Projector loop (noetl/ai-meta#103 phase 2b) β€” all six documented 2026-10-04

Not armed anywhere. NOETL_PROJECTOR_ENABLED is unset on every prod workload, so no loop runs and the orchestrator self-writes the snapshot exactly as today. Blast radius is zero until two flags are flipped β€” this one, and the server-side NOETL_PROJECTOR_OWNS_SNAPSHOT, which has its own default of false.

Added here because drift-audit.sh env-docs reported them absent (Rule 2a).

Variable Default Required Notes
NOETL_PROJECTOR_ENABLED unset (off) no Arms the projector loop, which drains the EHDB events feed and advances noetl.projection_snapshot via POST /api/internal/projection/advance. Truthy set is 1/true/yes/on, case-insensitive. ⚠ Setting it fails startup rather than degrading if NOETL_INTERNAL_API_TOKEN or NOETL_EVENT_BUS_CLAIM_ADDR is empty β€” a projector with nothing to connect to would otherwise look armed and do nothing.
NOETL_PROJECTOR_GROUP DEFAULT_PROJECTOR_GROUP no Consumer group for the feed. ⚠⚠ Startup refuses the materializer's group outright. Sharing a group splits the feed between the two consumers, so each sees roughly half the events and neither reports an error β€” the projector would advance half the executions and look healthy. That is a silent-wrong state, so it is rejected loudly at config time rather than detected later.
NOETL_PROJECTOR_BATCH 64 no Events claimed per poll, clamped to 1..=1000.
NOETL_PROJECTOR_IDLE_SLEEP_MS 500 no Sleep when the feed is empty.
NOETL_PROJECTOR_ERROR_BACKOFF_MS 2000 no Sleep after a failed pass.
NOETL_PROJECTOR_TIMEOUT_MS 1000 no Per-call HTTP timeout for advance. ⚠ Bounded on purpose and generous on purpose: advance recomputes a snapshot per execution so a large batch is legitimately slow, and a timeout below that turns a working-but-slow projector into a failing one β€” but it must be bounded, because reqwest::Client::new() has no timeout at all, the defect noetl/worker#324 fixed in this crate.

⚠ All six are read through env_u32/env_u64/env::var helpers rather than a single config struct, so a read-set scan that greps only env::var("NOETL_…") undercounts this module.

Step-failure and dead-letter semantics β€” documented 2026-10-04

Both change what a run does, not what it reports, which is why both default off.

Variable Default Required Notes
NOETL_EXECUTION_FAIL_ON_STEP_ERROR off no Does a failing step fail its execution? (noetl/server#434.) Truthy 1/true/yes/on. Off β‡’ byte-identical to the long-standing behaviour. Same posture and reason as NOETL_EXECUTION_STATUS_FROM_STEPS (noetl/ai-meta#251): any playbook relying on an erroring step to continue begins failing when this is on, so it must be a deliberate flip rather than a side effect of deploying an image. ⚠⚠ LIVE as true on all four prod workloads β€” noetl-worker-rust, noetl-worker-system-pool, noetl-worker-system-pool-shard1 and noetl-cmdbus-writer β€” and it had no row on this page.
NOETL_MATERIALIZER_DEAD_LETTER off no Park an un-insertable event instead of redelivering it for ever. Truthy 1/true/yes/on. Off β‡’ today's delivery guarantee. On, an event the server will never accept is acked and parked in noetl.event_dead_letter instead of holding the head of the ordered drain while every other execution's command.completed queues behind it. That blockage is measured: prod 2026-09-27 had five playbook.failed events from 09-24 still looping three days later, 120,545 project errors, 271s projection lag, 1,283 executions abandoned. ⚠ Enabling it changes delivery guarantees, so it is a deliberate flip for the same reason poison-command dead-lettering is (noetl/ai-meta#249). Not set on prod. ⚠ The landing table is provisioned by the server at startup; it was also missing from the DDL the deploy applies until noetl/noetl#709.

EHDB tier store bounds β€” documented 2026-10-04

Variable Default Required Notes
NOETL_EHDB_TIER_SEAL_MAX_BYTES unset β‡’ never seal no Byte threshold at which the tier store seals the active segment and starts a new one. Unset, unparsable or 0 β‡’ no sealing β‡’ byte-identical to an unsealed store, which has exactly one segment: the file that exists now. ⚠ Fail-safe direction is deliberately off β€” a typo must not start rotating a primary-serving tier's store behind the operator's back. Exists because an unbounded store OOM-killed noetl-cmdbus-writer-0 repeatedly on 2026-09-20, at a 4 GiB limit and again at 8 GiB: raising the limit is a delay, bounding the store is the fix. ⚠⚠ LIVE on noetl-cmdbus-writer as 268435456 (256 MiB), undocumented here until now.
NOETL_EHDB_TIER_SHED_AFTER_MS 1000 no How long an accepted tier request waits for a concurrency permit before it is shed with an explicit busy error. Chosen against the client budget, not picked round: the prod tier client times out at 2s, so a request that has not started work within 1s is better answered than left to expire silently. Waiters are additionally capped at TIER_MAX_INFLIGHT Γ— TIER_MAX_WAITERS_MULTIPLE (8), because accepting unconditionally must not turn a memory spike into an unbounded task list.

Shadow-tier mirror sources (noetl/ai-meta#348) β€” documented 2026-10-04

Variable Default Required Notes
NOETL_EHDB_KV_MIRROR_SOURCE worker no Which component mirrors into the KV shadow tier. worker (default) = the worker's emit chokepoint; server = the server's write chokepoint, and the worker's own mirror disarms. ⚠ Anything unrecognised β€” including an empty value β€” resolves to worker, so a typo restores the pre-change mirror rather than disarming both halves and leaving the tier silently empty while NOETL_EHDB_* still reads shadow. Not set on prod.
NOETL_EHDB_OBJECT_MIRROR_SOURCE worker no The same, for the object shadow tier. Each tier gets its own variable rather than borrowing the event log's, because a tier whose producer is configured by another tier's variable is a coupling nobody would predict from reading either one. Not set on prod.

Secrets handling

Same shape as the server:

Secret Storage Mount as
NATS_PASSWORD K8s Secret valueFrom.secretKeyRef
Any value listed in NOETL_KEYCHAIN_ENV_VARS K8s Secret (when used) valueFrom.secretKeyRef per allowlisted name

Per agents/rules/execution-model.md "Secrets and credentials rule": business-logic credentials (third-party API tokens, tenant database DSNs) belong in the NoETL keychain via the server's /api/keychain/* routes. The allowlist mechanism above is a bridge only for credentials that already live in the pod env via existing trust (GKE Workload Identity, etc.).

Observability

  • noetl_worker_build_info{version} β€” always 1; the version label identifies the running binary.

    Read this first when a metric you expect is missing. Registry::gather prunes metric families with no children, so every labelled metric is absent from /metrics until something increments it β€” registering it is not enough. That makes absence ambiguous between "this has never happened" and "this binary predates the metric", and the two want opposite responses.

    Where the label set is closed the series are pinned at 0 at startup so a scrape reads 0 rather than nothing. Where it is open β€” as on noetl_worker_event_emit_failed_total{event_type}, which takes a free-form string β€” pinning cannot work at all, and this gauge is the only way to tell the two cases apart. Added in noetl/ai-meta#238; before it, nothing on /metrics carried the version, so the question was answered from the Deployment's image tag β€” a different representation, which can disagree with what is actually running.

  • Silent-failure signals β€” counters added 2026-08-05 closing the warn/error-without-metric triage (#238). Each covers a path that previously logged and counted nothing; all labelled sets are pinned at 0 at startup.

    metric labels what a non-zero value means
    noetl_worker_materializer_skipped_total β€” Data loss. A drained message had no event_id, so it was never projected β€” and was acked anyway. Under NOETL_EVENT_INGEST_PUBLISH_ONLY the materializer is the sole writer of noetl.event, so the event is permanently absent from the durable log. The materializer's six other counters do not move on this path.
    noetl_worker_materializer_ack_failed_total stage non_event_batch is the serious one: without that ack the batch poison-loops forever, so a sustained rate is a stalled materializer. after_project costs a redelivery, never a row. per_handle is a partial ack.
    noetl_worker_materializer_drain_failed_total β€” Drain polls failing outright. A materializer that cannot drain is a durable log that stops being written.
    noetl_worker_ehdb_claim_reconnect_total feed, reason Claim-coordinator reconnects. feed=commands stalls dispatch; feed=events stalls the materializer. One production pod logged 85 of these in 24 h with no metric at all before this.
    noetl_worker_claim_failed_total β€” Commands whose claim failed and were nacked. ⚠ This path retries with no backoff (#244), so a persistently failing command spins at full speed β€” measured at ~1000 log lines/second. The rate is the signal; the log flood is the symptom.
    noetl_worker_state_builder_replay_end_total reason Why a cold-rebuild replay stopped. Three of the four (complete, deadline, max_messages) are ordinary termination; feed_error means the rebuilt state is INCOMPLETE. Read the ratio, not the count.
    noetl_worker_event_emit_failed_total event_type An event was abandoned after its retry window. On the container-poll resume path a lost call.done means the execution never continues (#227).
    noetl_ehdb_dataplane_ops_total{operation="reachability"} outcome Whether the EHDB tier service was reached on a real operation. unreachable is a transport failure and demotes the tier; reached / rejected promote it (a service that answers, even to refuse a record, has been reached). This is the input to the serve decision, so it is the series that explains a primary_unavailable. ⚠ Deliberately not pinned at 0 β€” it is only reachable from a configured tier client, which keeps the flag-off path's /metrics byte-identical; absence here correctly means "no tier op has ever run".
    noetl_worker_state_builder_drive_builds_total outcome Seven outcomes, not the three an older doc comment claimed. fallback_incomplete and served_shard_mismatch are the interesting ones.

    Unlike the server, this crate builds its metrics eagerly in WorkerMetrics::new, so an unlabelled metric is always present at 0 and never absent-because-untouched. Verified against a released image: v5.105.0 declares 77 metrics and serves 45, with zero unlabelled-absent.

  • Metrics: Prometheus surface at :9090/metrics per agents/rules/observability.md. Surface includes NATS pull rate, command claim outcomes, tool dispatch durations per tool kind, result-store PUT latency.

  • EHDB tier queries (noetl/ai-meta#178): noetl_worker_ehdb_query_ops_total{tier,operation,outcome} (counter) + noetl_worker_ehdb_query_duration_seconds (gauge, last op) β€” recorded by the GET /ehdb/tiers/{tier} read handler. Like the other EHDB families, a disabled outcome records nothing, so a worker with EHDB off (the default) emits a byte-identical /metrics. Each query also rides an ehdb.query tracing span carrying execution_id.

  • EHDB tier service β€” the server half (noetl/ai-meta#260, introduced by worker#270): the writer-fronted face on :9110 that primary promotes to authoritative. Until #260 it recorded nothing β€” its whole observability was four tracing lines, three on failure paths β€” so the only prod-side signal was a demotion counter recorded by the client, which cannot tell an unhealthy service from an unhealthy network.

    metric labels what it says
    noetl_ehdb_dataplane_ops_total{operation="tier_service.<op>"} outcome One per handled request. <op> ∈ health / append / read_execution / scan / unsupported / conn. Deliberately the same family as tier_client.<op>, so a prefix match puts both halves of one request side by side.
    noetl_ehdb_tier_service_duration_seconds operation Request latency histogram. Incremented by the same function as the counter, so the two cannot drift apart. Measures decode β†’ store β†’ encode, excluding the frame read and write, which are dominated by how fast the caller is.
    noetl_ehdb_tier_service_store_appends_total β€” Records this process has written. A counter, so a restart reads as a reset rather than a plateau.
    noetl_ehdb_tier_service_store_sequence β€” Highest global sequence the store holds β€” "does the tier I am about to promote actually contain anything", answerable without reading it. Sampled at startup, so a restart in front of a populated store does not read as empty.
    noetl_ehdb_tier_service_store_bytes β€” Store size on disk, sampled at startup and after each append.

    Outcomes, and the one distinction that matters for alerting: ok / hit / miss / invalid / unavailable / error / unsupported, plus accepted / closed / protocol_error / write_error / accept_error on conn. degraded is not !ok β€” a malformed request is ok=false, degraded=false (the caller got a correct refusal; the service is fine), while unavailable and error are degraded because a tier promoted here could not answer. Alert on degraded, never on !ok, or a client sending bad frames pages someone.

    On reads, hit vs miss is the load-bearing pair: an all-miss read stream means the store is serving, and empty.

    ⚠ These ARE pinned at 0 β€” but only once the listener binds. All 20 series are created at 0 from a single call site immediately after the bind in event_bus.rs, and below that bind the pin is unconditional: not gated on a store being configured, on tier mode, or on any NOETL_EHDB_*. (A pin inside a config branch is not a pin β€” server#315 shipped that mistake and left its skip reasons absent on exactly the configuration worth debugging.) With NOETL_EHDB_TIER_SERVICE_BIND unset nothing is pinned and /metrics is byte-identical to a build without the module, which is correct: absence then means this process has no tier service, and noetl_worker_build_info{version} above answers "is this binary too old to have the metric".

    Hosted by the process that sets NOETL_EVENT_BUS_HOST β€” the writer, not an ordinary worker pod β€” because that is the process holding the durable RWO volumes. The writer's Service already publishes :9090, so this needs no PodMonitoring change; noetl/ops#255 only has to be applied.

  • Materializer / consumer lag β€” ehdb_events_group_lag{group}, exposed by the EHDB writer, not by the worker.

    This replaced a pair of worker gauges (noetl_worker_nats_consumer_pending + _ack_pending, labelled {stream, consumer}) written by a periodic JetStream lag poller. T5 deleted JetStream and the poller with it, so from that point the gauges were never written again β€” and because a labelled metric is absent from /metrics until something increments it, they simply stopped appearing rather than reading zero. The recording rule and the three backlog alerts built on them went permanently inert with no error anywhere. Both gauges are removed outright in noetl/ai-meta#242.

    The signal itself still matters and is unchanged in meaning: under the server's NOETL_EVENT_INGEST_PUBLISH_ONLY gate it is the earliest indication that published events are piling up un-materialized, and it climbs even when the materializer loop has stalled or died β€” the loop cannot report its own lag, so the measurement has to come from outside it. That is now the writer rather than a per-pod poller, which also removes the need to dedup across system-pool pods. It is what the CQRS flip guardrail alerts read (see noetl/ai-meta#103 and the ops runbook noetl-cqrs-publish-only-flip.md).

  • NATS resilience / self-heal (noetl/ai-meta#163): two independent in-process reconnect paths keep a NATS bounce from taking the worker down. (1) The state-builder drain (system pool) recovers a lost pull consumer β€” noetl_worker_state_builder_consumer_recreate_total{reason}

    • the noetl_worker_state_builder_healthy gauge the /livez probe reads. (2) The main command loop (every pool) survives a hard NATS disconnect (nats-0 pod delete, full client disconnect) in-process instead of exit(1) + k8s crash-restart + full WAL replay: on a pull/ack/nack disconnect it rebuilds the NatsSubscriber with bounded exponential backoff (250 msβ†’10 s) and re-binds the same durable consumer (server-side cursor preserved; claim_command atomicity keeps exactly-once), recorded on noetl_worker_command_loop_reconnect_total{reason} β€” reason=pull|ack|nack (a disconnect surfaced on that op and the subscriber was rebuilt) / connect_error (a rebuild attempt failed, retrying). A rising pull/ack/nack rate with the pod's restart_count staying flat is the fix working. Like the sibling counter it is a labelled family, so a worker that never hit a disconnect emits no series for it (byte-identical /metrics).
  • Graceful-shutdown seal (noetl/ai-meta#226): noetl_worker_shutdown_hosts_sealed + noetl_worker_shutdown_hosts_total, written once, immediately before exit. sealed < total on a terminating pod means an unsealed tail, and the next incarnation's resume over that log will clamp. A pod that terminates without ever writing them never reached the seal at all.

    This exists because a partial seal used to be observable only on the next boot, as a clamped=true resume over a log that had come back below its own persisted cursor β€” i.e. after the records were already gone. In prod the terminating pod logged sealing EHDB writer hosts before exit hosts=2, sealed the command bus, and stopped: no events seal, no Worker stopped, and no expiry line from the 15 s shutdown budget either, because the starving seal had also stopped tokio's timer. seal_all now logs at ERROR when any host is left unsealed, in the terminating pod's own log, while an operator is still watching.

  • Events-consumer reattach (noetl/ai-meta#225): noetl_worker_events_consumer_redials_total{face} β€” face is group_claim (the :9104 named-group claim path) or wal (the :9108 fan-out the off-server state builder subscribes to). A rising count around a writer restart is the consumers reattaching; a flat count while a group cursor is not advancing is the wedge.

    Before #225 this counter could not have moved even while the consumers were wedged: neither events face carried the TCP keepalive

    • negotiated heartbeat the command claim path got in #208, so a half-open socket left the parked read neither yielding nor erroring β€” and the redial paths downstream were unreachable for want of an Err. noetl.event took no writes for 3h24m with /readyz ready.
  • Materializer counters: noetl_worker_materializer_drained_total / _projected_total / _duplicates_total / _acked_total / _project_errors_total + _cycle_duration_seconds β€” the ack-after-materialize loop's throughput + the no-loss redelivery surface (project_errors = a batch left un-acked to redeliver).

  • Scrape path (the metrics above only guard the flip if something scrapes them): the :9090/metrics port is exposed by a headless *-metrics Service per pool. On the kind dev cluster a VictoriaMetrics VMServiceScrape selects them; on prod (GKE) the cluster runs Google Managed Prometheus, which does NOT honor prometheus.io/scrape annotations β€” the worker pods are scraped by a GMP PodMonitoring (ci/manifests/noetl/gmp/podmonitoring-noetl.yaml, ops wiki Production monitoring (GMP)). Without that object the lag gauge + materializer counters never reach Managed Prometheus and the CQRS flip guardrail is blind.

  • Tracing: tracing spans on every NATS message, every tool dispatch. execution_id is a span field.

  • Logs: structured JSON via tracing-subscriber.

Validation procedure

When changing any of the above, validate per agents/rules/deployment-validation.md:

  1. cargo build --release --bins
  2. cargo test --quiet
  3. Build image locally + load into kind: kind load docker-image …
  4. Apply the manifests against kubectl --context kind-noetl
  5. Smoke-test:
    • Submit a playbook execution via the noetl CLI.
    • Confirm the worker claims the command, runs the tool, and POSTs lifecycle events back.
    • For env-var changes: verify kubectl exec … env | grep <VAR> shows the value AND the worker's startup log line confirms it was honored.

Only after kind passes does the change roll forward to Cloud Build + GKE.

See also

⚠️ **GitHub.com Fallback** ⚠️