# Configuration (/docs/reference/configuration)



Ductor is configured from a single typed `Config` struct, loaded once at
startup. Every field is settable three ways: a YAML file, an inline YAML blob,
or a `DUCTOR_*` environment variable. This page documents the loading model and
the most commonly-used keys, grouped by subsystem.

<Callout title="Large surface, one rule">
  Ductor exposes a very large configuration surface (hundreds of leaf keys across
  many optional subsystems). This reference covers the keys you'll actually reach
  for. **Every** leaf is bindable by the same mechanical rule below, so if you
  see a YAML key in `configs/ductor.yaml` that isn't listed here, its env var is
  derived exactly the same way.
</Callout>

## How configuration loads [#how-configuration-loads]

Configuration is resolved with [Viper](https://github.com/spf13/viper) and
unmarshalled into `internal/config/config.go`. Precedence, highest to lowest:

1. **CLI flag** (e.g. `--database-url`)
2. **Environment variable** (`DUCTOR_*`)
3. **Inline YAML** in `DUCTOR_CONFIG`, or a **config file**
4. **Registered defaults** (from the code's `config.Default()`)

### The env var rule [#the-env-var-rule]

* Prefix is &#x2A;*`DUCTOR_`**.
* A YAML key path maps to an env var by uppercasing and replacing dots with
  underscores.

```
database.url            → DUCTOR_DATABASE_URL
server.http_addr        → DUCTOR_SERVER_HTTP_ADDR
router.queue.enabled    → DUCTOR_ROUTER_QUEUE_ENABLED
tracing.pressure.mode   → DUCTOR_TRACING_PRESSURE_MODE
```

<Callout type="warn" title="router vs routing">
  Two prefixes look similar but are distinct. Queue and flow-control tuning lives
  under &#x2A;*`router.*`** (`DUCTOR_ROUTER_QUEUE_*`, `DUCTOR_ROUTER_FLOW_*&#x60;), while
  the routing engine's own knobs live under &#x2A;*`routing.*`**
  (`DUCTOR_ROUTING_*`). This split is historical — mind the prefix.
</Callout>

### Config file [#config-file]

If neither `DUCTOR_CONFIG` nor `--config` is set, Ductor searches for a
`ductor.yaml` in `$HOME`, the current directory, and `/etc/ductor`. Override with
`--config <path>`.

### Inline YAML [#inline-yaml]

`DUCTOR_CONFIG` may contain a full YAML document. When set, it is parsed directly
and **takes precedence over any config file** (the file is skipped). Invalid YAML
aborts startup. This is the recommended way to inject config in Kubernetes — the
Helm chart mounts it as `DUCTOR_CONFIG` from a Secret.

### .env files [#env-files]

In development, a `./.env` file is auto-loaded. It loads only when
`DUCTOR_ENVIRONMENT=development` or `DUCTOR_LOAD_DOTENV=true`. A missing `.env`
is ignored.

### Validate what resolved [#validate-what-resolved]

Use the CLI to print the effective config, its source, and DSN-redacted values:

```bash
ductor validate-config
```

***

## Core & logging [#core--logging]

| YAML key           | Env var                   | Type        | Default       | Controls                                                     |
| ------------------ | ------------------------- | ----------- | ------------- | ------------------------------------------------------------ |
| `environment`      | `DUCTOR_ENVIRONMENT`      | string      | `development` | Deployment environment; `development` enables `.env` loading |
| `security_profile` | `DUCTOR_SECURITY_PROFILE` | string      | \`\`          | Named posture; `enterprise` forces fail-closed controls      |
| (inline)           | `DUCTOR_CONFIG`           | YAML string | —             | Inline YAML config, overrides the file                       |
| (loader)           | `DUCTOR_LOAD_DOTENV`      | bool        | —             | Force `.env` loading outside development                     |
| `log.level`        | `DUCTOR_LOG_LEVEL`        | string      | `info`        | Log level (`debug`/`info`/`warn`/`error`)                    |
| `log.format`       | `DUCTOR_LOG_FORMAT`       | string      | `json`        | `json` or `text`                                             |
| `log.add_source`   | `DUCTOR_LOG_ADD_SOURCE`   | bool        | `false`       | Include source file/line in logs                             |

## Server [#server]

| YAML key                      | Env var                              | Type     | Default | Controls                                        |
| ----------------------------- | ------------------------------------ | -------- | ------- | ----------------------------------------------- |
| `server.http_addr`            | `DUCTOR_SERVER_HTTP_ADDR`            | string   | `:8080` | Public HTTP listener (REST + Connect)           |
| `server.metrics_addr`         | `DUCTOR_SERVER_METRICS_ADDR`         | string   | `:9090` | Prometheus metrics listener                     |
| `server.read_header_timeout`  | `DUCTOR_SERVER_READ_HEADER_TIMEOUT`  | duration | `10s`   | HTTP read-header timeout                        |
| `server.read_timeout`         | `DUCTOR_SERVER_READ_TIMEOUT`         | duration | `30s`   | HTTP read timeout                               |
| `server.write_timeout`        | `DUCTOR_SERVER_WRITE_TIMEOUT`        | duration | `60s`   | HTTP write timeout                              |
| `server.idle_timeout`         | `DUCTOR_SERVER_IDLE_TIMEOUT`         | duration | `120s`  | HTTP idle timeout                               |
| `server.shutdown_drain_delay` | `DUCTOR_SERVER_SHUTDOWN_DRAIN_DELAY` | duration | `5s`    | Graceful-shutdown drain delay                   |
| `server.role`                 | `DUCTOR_SERVER_ROLE`                 | string   | `all`   | Comma role set: `all`/`api`/`worker`/`executor` |

The `server.*` struct also carries an internal transport plane
(`server.internal_*`) and an executor plane (`server.exec_*`) with their own
mTLS, runner-kind, and payload/timeout settings — see the executor flags in the
[CLI reference](/docs/reference/cli).

## Database [#database]

| YAML key                                       | Env var                                               | Type     | Default | Controls                               |
| ---------------------------------------------- | ----------------------------------------------------- | -------- | ------- | -------------------------------------- |
| `database.url`                                 | `DUCTOR_DATABASE_URL`                                 | string   | —       | Postgres connection URL (**required**) |
| `database.auto_migrate`                        | `DUCTOR_DATABASE_AUTO_MIGRATE`                        | bool     | `false` | Run migrations at startup              |
| `database.statement_timeout`                   | `DUCTOR_DATABASE_STATEMENT_TIMEOUT`                   | duration | `30s`   | Per-statement timeout (0 = off)        |
| `database.idle_in_transaction_session_timeout` | `DUCTOR_DATABASE_IDLE_IN_TRANSACTION_SESSION_TIMEOUT` | duration | `60s`   | Idle-in-transaction timeout            |
| `database.pool.max_conns`                      | `DUCTOR_DATABASE_POOL_MAX_CONNS`                      | int      | `25`    | Pool max connections                   |
| `database.pool.min_conns`                      | `DUCTOR_DATABASE_POOL_MIN_CONNS`                      | int      | `5`     | Pool min connections                   |
| `database.pool.max_conn_lifetime`              | `DUCTOR_DATABASE_POOL_MAX_CONN_LIFETIME`              | duration | `5m`    | Max connection lifetime                |
| `database.pool.max_conn_idle_time`             | `DUCTOR_DATABASE_POOL_MAX_CONN_IDLE_TIME`             | duration | `5m`    | Max connection idle time               |
| `database.pool.health_check_period`            | `DUCTOR_DATABASE_POOL_HEALTH_CHECK_PERIOD`            | duration | `30s`   | Idle-conn health-check cadence         |

## Cache (Redis / Dragonfly) [#cache-redis--dragonfly]

| YAML key                    | Env var                            | Type   | Default | Controls                             |
| --------------------------- | ---------------------------------- | ------ | ------- | ------------------------------------ |
| `cache.url`                 | `DUCTOR_CACHE_URL`                 | string | —       | Redis / Dragonfly URL (**required**) |
| `cache.force_single_client` | `DUCTOR_CACHE_FORCE_SINGLE_CLIENT` | bool   | `true`  | Force single-node client mode        |

## Authentication & authorization [#authentication--authorization]

| YAML key                        | Env var                                | Type   | Default | Controls                                 |
| ------------------------------- | -------------------------------------- | ------ | ------- | ---------------------------------------- |
| `auth.enabled`                  | `DUCTOR_AUTH_ENABLED`                  | bool   | `true`  | Enable authentication                    |
| `auth.allow_anonymous`          | `DUCTOR_AUTH_ALLOW_ANONYMOUS`          | bool   | `false` | Allow anonymous requests                 |
| `auth.api_key_enabled`          | `DUCTOR_AUTH_API_KEY_ENABLED`          | bool   | —       | Enable DB-backed API-key auth            |
| `auth.api_keys`                 | `DUCTOR_AUTH_API_KEYS`                 | string | \`\`    | Static (comma-separated) API keys        |
| `auth.service_token_secret`     | `DUCTOR_AUTH_SERVICE_TOKEN_SECRET`     | string | —       | HS256 secret for internal service tokens |
| `auth.audience_resolver`        | `DUCTOR_AUTH_AUDIENCE_RESOLVER`        | string | —       | JWT audience mode (`""` / `host`)        |
| `authz.allow_when_unconfigured` | `DUCTOR_AUTHZ_ALLOW_WHEN_UNCONFIGURED` | bool   | `true`  | Fail-open when no authorizer is wired    |

## API (Connect/gRPC + OIDC) [#api-connectgrpc--oidc]

| YAML key                                 | Env var                                         | Type      | Default                             | Controls                             |
| ---------------------------------------- | ----------------------------------------------- | --------- | ----------------------------------- | ------------------------------------ |
| `api.enabled`                            | `DUCTOR_API_ENABLED`                            | bool      | `true`                              | Enable the Connect/gRPC API listener |
| `api.addr`                               | `DUCTOR_API_ADDR`                               | string    | `:50052`                            | API listener address                 |
| `api.oidc_issuer`                        | `DUCTOR_API_OIDC_ISSUER`                        | string    | \`\`                                | OIDC issuer URL                      |
| `api.oidc_audience`                      | `DUCTOR_API_OIDC_AUDIENCE`                      | string    | \`\`                                | OIDC audience                        |
| `api.max_request_body_size`              | `DUCTOR_API_MAX_REQUEST_BODY_SIZE`              | int64     | `4194304` (4 MiB)                   | Max request body                     |
| `api.allowed_hosts`                      | `DUCTOR_API_ALLOWED_HOSTS`                      | \[]string | `[localhost,127.0.0.1,::1,0.0.0.0]` | Allowed `Host` headers               |
| `api.max_concurrent_requests`            | `DUCTOR_API_MAX_CONCURRENT_REQUESTS`            | int       | `0` (unlimited)                     | Global concurrency cap               |
| `api.max_concurrent_requests_per_tenant` | `DUCTOR_API_MAX_CONCURRENT_REQUESTS_PER_TENANT` | int       | `0` (unlimited)                     | Per-tenant concurrency cap           |

<Callout type="warn" title="Set allowed_hosts in production">
  `api.allowed_hosts` defaults to loopback plus `0.0.0.0` for local development.
  In production, restrict it to the hostnames Ductor actually serves.
</Callout>

## Routing engine (`routing.*`) [#routing-engine-routing]

| YAML key                                                         | Env var                                                                 | Type     | Default                 | Controls                                                                |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------- | -------- | ----------------------- | ----------------------------------------------------------------------- |
| `routing.pipeline_mode`                                          | `DUCTOR_ROUTING_PIPELINE_MODE`                                          | string   | `dag` (compile default) | Global pipeline: `linear`/`dag`/`shadow`/`inherit`                      |
| `routing.rule_cache_size`                                        | `DUCTOR_ROUTING_RULE_CACHE_SIZE`                                        | int      | `1000`                  | Rule cache size                                                         |
| `routing.rule_cache_ttl`                                         | `DUCTOR_ROUTING_RULE_CACHE_TTL`                                         | duration | `30s`                   | Rule cache TTL                                                          |
| `routing.capacity_reservation_ttl`                               | `DUCTOR_ROUTING_CAPACITY_RESERVATION_TTL`                               | duration | `1h`                    | Capacity reservation safety TTL                                         |
| `routing.default_timeout_ms`                                     | `DUCTOR_ROUTING_DEFAULT_TIMEOUT_MS`                                     | int      | `5000` (effective)      | Routing default timeout                                                 |
| `routing.max_routing_depth`                                      | `DUCTOR_ROUTING_MAX_ROUTING_DEPTH`                                      | int      | `3` (effective)         | Max routing recursion depth                                             |
| `routing.dry_run_default`                                        | `DUCTOR_ROUTING_DRY_RUN_DEFAULT`                                        | bool     | `false`                 | Default dry-run                                                         |
| `routing.persist_explain`                                        | `DUCTOR_ROUTING_PERSIST_EXPLAIN`                                        | bool     | `false`                 | Persist explain traces                                                  |
| `routing.bundle_file`                                            | `DUCTOR_ROUTING_BUNDLE_FILE`                                            | string   | —                       | External routing bundle YAML path                                       |
| `routing.bundle_tenant`                                          | `DUCTOR_ROUTING_BUNDLE_TENANT`                                          | string   | —                       | Tenant for bundle apply (required with `bundle_file`)                   |
| `routing.strategy_deployments.timeout`                           | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_TIMEOUT`                           | duration | `5s`                    | Remote strategy call timeout                                            |
| `routing.strategy_deployments.fail_policy`                       | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_FAIL_POLICY`                       | string   | `closed`                | Fail open/closed on remote strategy error                               |
| `routing.strategy_deployments.credentials.<ref>.token_env`       | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TOKEN_ENV`       | string   | —                       | Name of the env var holding a bearer token for `auth_ref: <ref>`        |
| `routing.strategy_deployments.credentials.<ref>.token_file`      | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TOKEN_FILE`      | string   | —                       | File path holding a bearer token (alternative to `token_env`)           |
| `routing.strategy_deployments.credentials.<ref>.tls_ca_file`     | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_CA_FILE`     | string   | —                       | PEM CA bundle that verifies the remote strategy server cert (TLS ≥ 1.2) |
| `routing.strategy_deployments.credentials.<ref>.tls_cert_file`   | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_CERT_FILE`   | string   | —                       | Client certificate for mTLS (must be set with `tls_key_file`)           |
| `routing.strategy_deployments.credentials.<ref>.tls_key_file`    | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_KEY_FILE`    | string   | —                       | Client key for mTLS (must be set with `tls_cert_file`)                  |
| `routing.strategy_deployments.credentials.<ref>.tls_server_name` | `DUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_SERVER_NAME` | string   | —                       | Overrides the SNI / certificate verification hostname                   |

The `credentials` map resolves the `auth_ref`/`tls_ref` names on a remote strategy
deployment into concrete transport security, and resolution **fails closed** — a
deployment whose ref points at a missing or incomplete entry never registers. Each
entry is keyed by an operator-chosen ref name (the `<ref>` above), so the map is
normally set in YAML or inline `DUCTOR_CONFIG` rather than per-leaf env vars; the
secret values themselves live in the env var named by `token_env` or the files the
other keys point at, never in the config document. An entry must resolve at least
one of `token_env`/`token_file` or TLS material, and `tls_cert_file`/`tls_key_file`
must be set together. See
[Writing a custom strategy → Secure the transport](/docs/strategies/writing-a-custom-strategy#secure-the-transport-required).

`routing.*` has many more advanced sub-blocks (`capacity_reconciliation`,
`pin_cache`, `market_controls`, `middleware`, `runtime.queue`, `runtime.flow`,
`dag_bridge`). Consult `internal/config/routing.go` for the full set.

## Queue & flow control (`router.*`) [#queue--flow-control-router]

| YAML key                               | Env var                                       | Type     | Default | Controls                           |
| -------------------------------------- | --------------------------------------------- | -------- | ------- | ---------------------------------- |
| `router.queue.enabled`                 | `DUCTOR_ROUTER_QUEUE_ENABLED`                 | bool     | `false` | Enable the tiered queue            |
| `router.queue.concurrency`             | `DUCTOR_ROUTER_QUEUE_CONCURRENCY`             | int      | `50`    | Global concurrency (0 = unlimited) |
| `router.queue.tenant_concurrency`      | `DUCTOR_ROUTER_QUEUE_TENANT_CONCURRENCY`      | int      | `200`   | Per-tenant concurrency             |
| `router.queue.workers`                 | `DUCTOR_ROUTER_QUEUE_WORKERS`                 | int      | `10`    | Worker count                       |
| `router.queue.visibility_timeout`      | `DUCTOR_ROUTER_QUEUE_VISIBILITY_TIMEOUT`      | duration | `30s`   | Item visibility timeout            |
| `router.queue.retry_delay`             | `DUCTOR_ROUTER_QUEUE_RETRY_DELAY`             | duration | `5s`    | Retry delay                        |
| `router.queue.max_scavenge_recoveries` | `DUCTOR_ROUTER_QUEUE_MAX_SCAVENGE_RECOVERIES` | int      | `3`     | Recoveries before DLQ              |
| `router.flow.concurrency.limit`        | `DUCTOR_ROUTER_FLOW_CONCURRENCY_LIMIT`        | int      | `100`   | Flow concurrency limit             |
| `router.flow.adaptive.enabled`         | `DUCTOR_ROUTER_FLOW_ADAPTIVE_ENABLED`         | bool     | `false` | Adaptive flow control              |

## Workflow runtime (`workflow_runtime.*`) [#workflow-runtime-workflow_runtime]

| YAML key                                         | Env var                                                 | Type     | Default               | Controls                             |
| ------------------------------------------------ | ------------------------------------------------------- | -------- | --------------------- | ------------------------------------ |
| `workflow_runtime.enabled`                       | `DUCTOR_WORKFLOW_RUNTIME_ENABLED`                       | bool     | `true`                | Enable the DAG runtime               |
| `workflow_runtime.poll_interval`                 | `DUCTOR_WORKFLOW_RUNTIME_POLL_INTERVAL`                 | duration | `5s`                  | Coordinator poll interval            |
| `workflow_runtime.batch_size`                    | `DUCTOR_WORKFLOW_RUNTIME_BATCH_SIZE`                    | int      | `50`                  | Wakeup batch size                    |
| `workflow_runtime.coordinator_tick_timeout`      | `DUCTOR_WORKFLOW_RUNTIME_COORDINATOR_TICK_TIMEOUT`      | duration | `30s`                 | Per-tick timeout                     |
| `workflow_runtime.recovery_stale_after`          | `DUCTOR_WORKFLOW_RUNTIME_RECOVERY_STALE_AFTER`          | duration | `30s`                 | Stale-run recovery threshold         |
| `workflow_runtime.continue_as_new_after_history` | `DUCTOR_WORKFLOW_RUNTIME_CONTINUE_AS_NEW_AFTER_HISTORY` | int      | `1000`                | ContinueAsNew after N history events |
| `workflow_runtime.continue_as_new_after_wakeups` | `DUCTOR_WORKFLOW_RUNTIME_CONTINUE_AS_NEW_AFTER_WAKEUPS` | int      | `500`                 | ContinueAsNew after N wakeups        |
| `workflow_runtime.max_nodes_per_run`             | `DUCTOR_WORKFLOW_RUNTIME_MAX_NODES_PER_RUN`             | int      | `10000`               | Hard node cap per run                |
| `workflow_runtime.max_step_output_size`          | `DUCTOR_WORKFLOW_RUNTIME_MAX_STEP_OUTPUT_SIZE`          | int64    | `104857600` (100 MiB) | Step output cap                      |
| `workflow_runtime.max_run_state_size`            | `DUCTOR_WORKFLOW_RUNTIME_MAX_RUN_STATE_SIZE`            | int64    | `268435456` (256 MiB) | Run-state cap                        |
| `workflow_runtime.state_checksum_mode`           | `DUCTOR_WORKFLOW_RUNTIME_STATE_CHECKSUM_MODE`           | string   | `audit`               | `off`/`audit`/`fail`                 |

Run-mode concurrency is an opt-in nested block:

| YAML key                                                 | Type     | Default               | Controls                                                               |
| -------------------------------------------------------- | -------- | --------------------- | ---------------------------------------------------------------------- |
| `workflow_runtime.run_concurrency.enabled`               | bool     | `false`               | Acquire and renew a run-level concurrency lease before advancing work. |
| `workflow_runtime.run_concurrency.lease_ttl`             | duration | `30m`                 | Ownership horizon; must be at least `3s`.                              |
| `workflow_runtime.run_concurrency.resume_backoff`        | duration | `30s`                 | Retry cadence when a parked run cannot reacquire a slot.               |
| `workflow_runtime.run_concurrency.early_release.enabled` | bool     | `false`               | Release the slot while waiting on an event, approval, or digest.       |
| `workflow_runtime.run_concurrency.reclaimer.enabled`     | bool     | `false`               | Enable leader-elected expired/orphan lease cleanup.                    |
| `workflow_runtime.run_concurrency.reclaimer.interval`    | duration | `30s`                 | Reclaimer sweep cadence.                                               |
| `workflow_runtime.run_concurrency.reclaimer.key_pattern` | string   | `{wf-conc}:conc:wf:*` | Redis scan boundary for run leases.                                    |

See [Run-concurrency lease ownership](/docs/operations/workflow-runtime#run-concurrency-lease-ownership)
for rollout and monitoring guidance.

The workflow runtime has an extensive set of optional feature blocks
(`sharding.range_id_fencing`, `result_externalization`,
`scatter`, `stream`, `transition_compaction`, `defer`, `ops.*`,
`worker_compatibility`, `inline_run`, `bridge_*`). See
`internal/config/config_workflow.go`.

## Events & tracing [#events--tracing]

| YAML key               | Env var                       | Type    | Default                                                      | Controls                      |
| ---------------------- | ----------------------------- | ------- | ------------------------------------------------------------ | ----------------------------- |
| `events.enabled`       | `DUCTOR_EVENTS_ENABLED`       | bool    | `false`                                                      | Enable Redis event streams    |
| `events.stream_prefix` | `DUCTOR_EVENTS_STREAM_PREFIX` | string  | (Ductor prefix)                                              | Stream key prefix             |
| `tracing.enabled`      | `DUCTOR_TRACING_ENABLED`      | bool    | `false`                                                      | Enable OTLP tracing           |
| `tracing.endpoint`     | `DUCTOR_TRACING_ENDPOINT`     | string  | `http://victoriatraces:10428/insert/opentelemetry/v1/traces` | OTLP endpoint                 |
| `tracing.backend`      | `DUCTOR_TRACING_BACKEND`      | string  | `victoriatraces`                                             | Backend hint                  |
| `tracing.sample_rate`  | `DUCTOR_TRACING_SAMPLE_RATE`  | float64 | `0.1`                                                        | Head-sampling fraction        |
| `tracing.grpc`         | `DUCTOR_TRACING_GRPC`         | bool    | `false`                                                      | gRPC exporter instead of HTTP |
| `tracing.insecure`     | `DUCTOR_TRACING_INSECURE`     | bool    | `true`                                                       | Disable TLS on the exporter   |

## Connector [#connector]

| YAML key                                   | Env var                                           | Type   | Default | Controls                                                      |
| ------------------------------------------ | ------------------------------------------------- | ------ | ------- | ------------------------------------------------------------- |
| `connector.encryption_key`                 | `DUCTOR_CONNECTOR_ENCRYPTION_KEY`                 | string | —       | Base64 32-byte AEAD master key (**required** for connections) |
| `connector.encryption_key_id`              | `DUCTOR_CONNECTOR_ENCRYPTION_KEY_ID`              | uint8  | `0`     | Active wire `key_id`                                          |
| `connector.rotation_keys`                  | (list)                                            | list   | —       | Decrypt-only rotation keyring                                 |
| `connector.action_rate_limit.enabled`      | `DUCTOR_CONNECTOR_ACTION_RATE_LIMIT_ENABLED`      | bool   | `false` | Per-tenant action rate limiting                               |
| `connector.tenant_provider_policy.enabled` | `DUCTOR_CONNECTOR_TENANT_PROVIDER_POLICY_ENABLED` | bool   | `false` | Per-tenant provider policy                                    |
| `connector.connect_session.enabled`        | `DUCTOR_CONNECTOR_CONNECT_SESSION_ENABLED`        | bool   | `false` | Hosted OAuth connect-session flow                             |
| `connector.connect_session.base_url`       | `DUCTOR_CONNECTOR_CONNECT_SESSION_BASE_URL`       | string | \`\`    | Connect-link origin                                           |
| `connector.connection_recovery.enabled`    | `DUCTOR_CONNECTOR_CONNECTION_RECOVERY_ENABLED`    | bool   | `false` | Auto reconnect/refresh recovery                               |

## Archival & retention [#archival--retention]

| YAML key                   | Env var                           | Type     | Default          | Controls                    |
| -------------------------- | --------------------------------- | -------- | ---------------- | --------------------------- |
| `archival.enabled`         | `DUCTOR_ARCHIVAL_ENABLED`         | bool     | `false`          | Enable archival             |
| `archival.backend`         | `DUCTOR_ARCHIVAL_BACKEND`         | string   | `local`          | `local` / `s3` / `gcs`      |
| `archival.retention_days`  | `DUCTOR_ARCHIVAL_RETENTION_DAYS`  | int      | `30`             | Retention days              |
| `archival.interval`        | `DUCTOR_ARCHIVAL_INTERVAL`        | duration | `1h`             | Sweep interval              |
| `archival.storage_path`    | `DUCTOR_ARCHIVAL_STORAGE_PATH`    | string   | `./data/archive` | Local backend path          |
| `archival.s3_bucket`       | `DUCTOR_ARCHIVAL_S3_BUCKET`       | string   | \`\`             | S3 bucket                   |
| `archival.s3_region`       | `DUCTOR_ARCHIVAL_S3_REGION`       | string   | `us-east-1`      | S3 region                   |
| `retention.enabled`        | `DUCTOR_RETENTION_ENABLED`        | bool     | `false`          | Enable the retention worker |
| `retention.interval`       | `DUCTOR_RETENTION_INTERVAL`       | duration | `24h`            | Sweep interval              |
| `retention.decisions_ttl`  | `DUCTOR_RETENTION_DECISIONS_TTL`  | duration | `2160h` (90d)    | Decisions TTL               |
| `retention.audit_logs_ttl` | `DUCTOR_RETENTION_AUDIT_LOGS_TTL` | duration | `4320h` (180d)   | Audit-logs TTL              |

## Billing, entitlements & rate limits [#billing-entitlements--rate-limits]

| YAML key                  | Env var                                       | Type    | Default  | Controls                      |
| ------------------------- | --------------------------------------------- | ------- | -------- | ----------------------------- |
| `stripe.api_key`          | `STRIPE_API_KEY` (or `DUCTOR_STRIPE_API_KEY`) | string  | —        | Stripe API key                |
| `stripe.webhook_secret`   | `DUCTOR_STRIPE_WEBHOOK_SECRET`                | string  | —        | Stripe webhook signing secret |
| `stripe.default_currency` | `DUCTOR_STRIPE_DEFAULT_CURRENCY`              | string  | `usd`    | Default currency              |
| `entitlement.enforcement` | `DUCTOR_ENTITLEMENT_ENFORCEMENT`              | string  | `report` | `report` / `off` / `strict`   |
| `ratelimit.global_rps`    | `DUCTOR_RATELIMIT_GLOBAL_RPS`                 | float64 | `10000`  | Global requests/sec           |
| `ratelimit.tenant_rps`    | `DUCTOR_RATELIMIT_TENANT_RPS`                 | float64 | `1000`   | Per-tenant requests/sec       |
| `ratelimit.fail_mode`     | `DUCTOR_RATELIMIT_FAIL_MODE`                  | string  | `open`   | Behavior on Redis outage      |

## Identity (SCIM / SAML) [#identity-scim--saml]

| YAML key                     | Env var                             | Type     | Default    | Controls                  |
| ---------------------------- | ----------------------------------- | -------- | ---------- | ------------------------- |
| `identity.scim.enabled`      | `DUCTOR_IDENTITY_SCIM_ENABLED`      | bool     | `false`    | SCIM 2.0 provisioning     |
| `identity.scim.base_path`    | `DUCTOR_IDENTITY_SCIM_BASE_PATH`    | string   | `/scim/v2` | SCIM router base path     |
| `identity.saml.enabled`      | `DUCTOR_IDENTITY_SAML_ENABLED`      | bool     | `false`    | SAML SSO service provider |
| `identity.saml.sp_entity_id` | `DUCTOR_IDENTITY_SAML_SP_ENTITY_ID` | string   | —          | SP entity ID              |
| `identity.saml.acs_base_url` | `DUCTOR_IDENTITY_SAML_ACS_BASE_URL` | string   | —          | ACS base URL (HTTPS)      |
| `identity.saml.session_ttl`  | `DUCTOR_IDENTITY_SAML_SESSION_TTL`  | duration | `8h`       | SAML session TTL          |

## AI inference (`ai_inference.*`) [#ai-inference-ai_inference]

The AI inference subsystem is the proxy every AI-assisted feature (routing
strategies, the chat agent) calls through. It is **off by default**; enabling it
wires a provider pool and a per-provider circuit breaker.

| YAML key                                              | Env var                                                      | Type     | Default           | Controls                                                        |
| ----------------------------------------------------- | ------------------------------------------------------------ | -------- | ----------------- | --------------------------------------------------------------- |
| `ai_inference.enabled`                                | `DUCTOR_AI_INFERENCE_ENABLED`                                | bool     | `false`           | Enable the AI inference proxy                                   |
| `ai_inference.strategy`                               | `DUCTOR_AI_INFERENCE_STRATEGY`                               | string   | `random_weighted` | Provider-selection strategy across the pool                     |
| `ai_inference.max_retries`                            | `DUCTOR_AI_INFERENCE_MAX_RETRIES`                            | int      | `2`               | Retries after the initial call (`0` = one attempt, `-1` = none) |
| `ai_inference.retry_backoff`                          | `DUCTOR_AI_INFERENCE_RETRY_BACKOFF`                          | duration | `500ms`           | Base exponential backoff between retries                        |
| `ai_inference.connect_timeout`                        | `DUCTOR_AI_INFERENCE_CONNECT_TIMEOUT`                        | duration | `10s`             | TCP connect timeout to a provider                               |
| `ai_inference.response_timeout`                       | `DUCTOR_AI_INFERENCE_RESPONSE_TIMEOUT`                       | duration | `30s`             | Non-streaming response timeout                                  |
| `ai_inference.stream_timeout`                         | `DUCTOR_AI_INFERENCE_STREAM_TIMEOUT`                         | duration | `10m`             | Streaming response timeout                                      |
| `ai_inference.circuit_breaker.enabled`                | `DUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_ENABLED`                | bool     | (module default)  | Per-provider fail-fast breaker                                  |
| `ai_inference.circuit_breaker.consecutive_failures`   | `DUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_CONSECUTIVE_FAILURES`   | int      | —                 | Failures before the breaker opens                               |
| `ai_inference.circuit_breaker.open_timeout`           | `DUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_OPEN_TIMEOUT`           | duration | —                 | How long the breaker stays open                                 |
| `ai_inference.circuit_breaker.reset_interval`         | `DUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_RESET_INTERVAL`         | duration | —                 | Failure-count reset window                                      |
| `ai_inference.circuit_breaker.half_open_max_requests` | `DUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_HALF_OPEN_MAX_REQUESTS` | uint32   | —                 | Probe requests allowed while half-open                          |

### Providers (`ai_inference.providers.<name>.*`) [#providers-ai_inferenceprovidersname]

`providers` is a map keyed by an arbitrary provider name you choose (e.g.
`primary`, `fallback`). Each entry configures one upstream:

<TypeTable
  type="{
  type: { type: &#x22;string&#x22;, description: &#x22;Provider kind: openai / anthropic / gemini / bedrock / ollama&#x22; },
  base_url: { type: &#x22;string&#x22;, description: &#x22;Override the provider's base URL (self-hosted / proxy)&#x22; },
  api_key_env: { type: &#x22;string&#x22;, description: &#x22;Name of the env var holding this provider's API key&#x22; },
  models: { type: &#x22;[]string&#x22;, description: &#x22;Model ids this provider serves&#x22; },
  region: { type: &#x22;string&#x22;, description: &#x22;Region (e.g. for bedrock)&#x22; },
  max_concurrent: { type: &#x22;int&#x22;, description: &#x22;Per-provider in-flight cap&#x22; },
  weight: { type: &#x22;float&#x22;, description: &#x22;Selection weight under random_weighted&#x22; },
  quality_score: { type: &#x22;float&#x22;, description: &#x22;Relative quality hint for selection&#x22; },
  enabled: { type: &#x22;bool&#x22;, description: &#x22;Per-provider on/off (pointer: unset = enabled)&#x22; },
}"
/>

<Callout type="warn" title="Provider API keys never live in config">
  A provider's API key is **not** a config value. `api_key_env` names an
  environment variable — Ductor reads the secret from that env var at runtime.
  Never put the key itself in a YAML file or in `DUCTOR_CONFIG`. This keeps
  provider secrets out of rendered config, `validate-config` output, and the
  `/api/config/export` surface.
</Callout>

### AI routing policy (`ai.routing.*`) [#ai-routing-policy-airouting]

Server-level cost and model-selection policy for AI-assisted routing decisions.
Distinct from `ai_inference.*` (the transport) and from per-pool AI strategy
config (which is dynamic, pool-level config, not here).

| YAML key                               | Env var                                       | Type      | Default                                          | Controls                                |
| -------------------------------------- | --------------------------------------------- | --------- | ------------------------------------------------ | --------------------------------------- |
| `ai.routing.max_cost_per_decision_usd` | `DUCTOR_AI_ROUTING_MAX_COST_PER_DECISION_USD` | float64   | `0` (disabled)                                   | Hard cap on LLM spend per routing event |
| `ai.routing.monthly_budget_usd`        | `DUCTOR_AI_ROUTING_MONTHLY_BUDGET_USD`        | float64   | `0` (disabled)                                   | Monthly pool-level LLM spend cap        |
| `ai.routing.model_priority`            | `DUCTOR_AI_ROUTING_MODEL_PRIORITY`            | \[]string | `[claude-haiku-4-5-20251001, claude-sonnet-4-6]` | Cost/quality ladder, cheapest first     |

<Callout title="Enterprise profile forces nonzero budgets">
  Under `security_profile: enterprise`, zero cost caps are rejected at load:
  `max_cost_per_decision_usd` defaults to `1.00`, `monthly_budget_usd` to
  `1000.00`, and `model_priority` must be non-empty. In other profiles `0`
  means the guard is disabled.
</Callout>

## MCP (`mcp.*`) [#mcp-mcp]

Ductor's inbound Model Context Protocol server (it also imports tools from
external MCP servers — see the [glossary](/docs/reference/glossary)).

| YAML key                   | Env var                           | Type   | Default | Controls                                              |
| -------------------------- | --------------------------------- | ------ | ------- | ----------------------------------------------------- |
| `mcp.enabled`              | `DUCTOR_MCP_ENABLED`              | bool   | `false` | Mount the inbound MCP server                          |
| `mcp.path`                 | `DUCTOR_MCP_PATH`                 | string | `/mcp`  | HTTP mount path                                       |
| `mcp.expose_unwired_tools` | `DUCTOR_MCP_EXPOSE_UNWIRED_TOOLS` | bool   | `false` | Advertise tools with no wired backend in `tools/list` |

`expose_unwired_tools` only affects **discovery**: `tools/call` still returns
`ErrNotImplemented` for an unwired tool regardless of the flag. Discovery is
filtered; execution is not.

## AI chat agent (`aichat.*`) [#ai-chat-agent-aichat]

Mounts the durable chat API: turn admission, session list/detail, resumable events,
input responses, turn cancellation, and feedback under `/api/v1/chat`. When a Slack
connector is bound for agent conversations, the same runtime also serves the signed
`POST /agent-webhook/slack` bridge. Approval links add `GET` and `POST`
`/agent-approval`.

| YAML key                         | Env var                                 | Type     | Default | Controls                                                                                                |
| -------------------------------- | --------------------------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `aichat.enabled`                 | `DUCTOR_AICHAT_ENABLED`                 | bool     | `false` | Mount the chat agent endpoint                                                                           |
| `aichat.durable_runtime`         | `DUCTOR_AICHAT_DURABLE_RUNTIME`         | bool     | `false` | Use the canonical journal-backed runtime; required when chat is enabled                                 |
| `aichat.model`                   | `DUCTOR_AICHAT_MODEL`                   | string   | \`\`    | Model id requested via the AI inference proxy; required when chat is enabled                            |
| `aichat.max_iterations`          | `DUCTOR_AICHAT_MAX_ITERATIONS`          | int      | `8`     | LLM-call budget per user turn                                                                           |
| `aichat.max_tool_calls`          | `DUCTOR_AICHAT_MAX_TOOL_CALLS`          | int      | `16`    | Tool-execution budget per user turn                                                                     |
| `aichat.approval_links_enabled`  | `DUCTOR_AICHAT_APPROVAL_LINKS_ENABLED`  | bool     | `false` | Mint encrypted, expiring browser approval links for external conversation bridges                       |
| `aichat.approval_links_base_url` | `DUCTOR_AICHAT_APPROVAL_LINKS_BASE_URL` | URL      | \`\`    | Externally reachable HTTPS origin or path prefix for `/agent-approval`; required when links are enabled |
| `aichat.approval_link_ttl`       | `DUCTOR_AICHAT_APPROVAL_LINK_TTL`       | duration | `15m`   | Approval bearer-link lifetime; must be greater than zero and at most `24h`                              |

<Callout type="warn" title="aichat prerequisites">
  The chat agent needs `aichat.durable_runtime=true`, a model,
  `ai_inference.enabled` with a matching provider, Postgres (journal and transcript
  persistence), and a **ready operational MCP backend** (its tools). Configuration
  validation fails at startup when these invariants are not met.
</Callout>

See [External Conversation Bridges](/docs/ai/external-conversation-bridges) for Slack
signature verification, participant bindings, ordered admission, same-thread replies,
and single-use approval behavior.

## Analytics (`analytics.*`) [#analytics-analytics]

A read-only proxy that lets the dashboard query Ductor's Prometheus metrics
(stored in VictoriaMetrics) without the browser reaching VM directly. It
validates PromQL against the K1 metric catalog and enforces tenant scoping.

| YAML key                           | Env var                                   | Type     | Default                 | Controls                                                                        |
| ---------------------------------- | ----------------------------------------- | -------- | ----------------------- | ------------------------------------------------------------------------------- |
| `analytics.enabled`                | `DUCTOR_ANALYTICS_ENABLED`                | bool     | `false`                 | Enable the proxy + catalog endpoints                                            |
| `analytics.victoria_metrics_url`   | `DUCTOR_ANALYTICS_VICTORIA_METRICS_URL`   | string   | `http://localhost:8428` | Upstream VictoriaMetrics base URL                                               |
| `analytics.allow_unscoped_metrics` | `DUCTOR_ANALYTICS_ALLOW_UNSCOPED_METRICS` | bool     | `false`                 | Allow tenant queries without a real `tenant_id` label (enterprise rejects this) |
| `analytics.max_query_range`        | `DUCTOR_ANALYTICS_MAX_QUERY_RANGE`        | duration | `744h` / 31d (built-in) | Cap the `start..end` window of one query                                        |
| `analytics.min_step`               | `DUCTOR_ANALYTICS_MIN_STEP`               | duration | `5s` (built-in)         | Smallest step resolution the proxy forwards                                     |

## Entitlement enforcement [#entitlement-enforcement]

Entitlement enforcement decides what happens when a tenant's plan/feature facts
are missing, stale, or deny an action.

| YAML key                          | Env var                                  | Type   | Default  | Controls                                                                                                                                             |
| --------------------------------- | ---------------------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entitlement.enforcement`         | `DUCTOR_ENTITLEMENT_ENFORCEMENT`         | string | `report` | `report` (evaluate + admit, emit events) / `off` (legacy fail-open, deny-list only) / `strict` (fail closed on missing/stale facts)                  |
| `entitlement.grace_unprovisioned` | `DUCTOR_ENTITLEMENT_GRACE_UNPROVISIONED` | bool   | `true`   | **Strict mode only.** Admit never-provisioned tenants under a loud bootstrap grace so flipping strict on doesn't instantly deny un-onboarded tenants |

`grace_unprovisioned` only softens the never-had-any-entitlement case. Genuine
gaps — a stale snapshot, a present-but-incomplete snapshot, or a tenant with
sources but no snapshot — still fail closed under `strict`.

## Key provider / BYOK (`key_provider.*`) [#key-provider--byok-key_provider]

Bring-Your-Own-Key boot path for the connector-credential master key. When
disabled (the default), the legacy `connector.encryption_key` path is used
unchanged. When enabled, `uri` dispatches to a registered KEK backend and
`wrapped_master` is unwrapped at boot into the 32-byte AEAD master.

| YAML key                      | Env var                              | Type   | Default | Controls                                                                                 |
| ----------------------------- | ------------------------------------ | ------ | ------- | ---------------------------------------------------------------------------------------- |
| `key_provider.enabled`        | `DUCTOR_KEY_PROVIDER_ENABLED`        | bool   | `false` | Enable the BYOK boot path                                                                |
| `key_provider.uri`            | `DUCTOR_KEY_PROVIDER_URI`            | string | —       | KEK backend URI: `local://`, `awskms://`, `gcpkms://`, `vault://`                        |
| `key_provider.key_ref`        | `DUCTOR_KEY_PROVIDER_KEY_REF`        | string | —       | Backend key reference (e.g. a KMS key ARN/alias); empty falls back to the key in the URI |
| `key_provider.wrapped_master` | `DUCTOR_KEY_PROVIDER_WRAPPED_MASTER` | string | —       | Base64 wrapped 32-byte master key (for `local://`, the unwrapped key)                    |

## Security profiles (`security_profile`) [#security-profiles-security_profile]

`security_profile` (top-level, `DUCTOR_SECURITY_PROFILE`) applies a named posture
**after** static config is resolved. The `enterprise` profile forces fail-closed
controls — among them it requires `key_provider.enabled`, rejects a `local`
key provider, rejects `analytics.allow_unscoped_metrics`, and forces the nonzero
AI budget defaults described above. Config that would otherwise load happily is
rejected under this profile, so validate it explicitly with `ductor
validate-config` before rollout.

## Dynamic configuration [#dynamic-configuration]

Ductor has **two distinct** dynamic-config systems — don't conflate them:

* **`dynamic_config.*`** — a file-based hot-reload source. It watches a
  YAML file and re-reads on change so a subset of config can be updated without a
  restart.
* **The `dynamicconfig` registry** — a code-generated, typed, per-key registry
  (regenerated via `make dynamicconfig-generate`) that backs the writable
  runtime surface exposed at `/api/config/{schema,export}`.

| YAML key                             | Env var                                     | Type     | Default | Controls                      |
| ------------------------------------ | ------------------------------------------- | -------- | ------- | ----------------------------- |
| `dynamic_config.enabled`             | `DUCTOR_DYNAMIC_CONFIG_ENABLED`             | bool     | `false` | Enable file-based hot reload  |
| `dynamic_config.config_file`         | `DUCTOR_DYNAMIC_CONFIG_CONFIG_FILE`         | string   | \`\`    | Path to the watched YAML file |
| `dynamic_config.watch_interval`      | `DUCTOR_DYNAMIC_CONFIG_WATCH_INTERVAL`      | duration | `30s`   | Change-watch interval         |
| `dynamic_config.poll_interval`       | `DUCTOR_DYNAMIC_CONFIG_POLL_INTERVAL`       | duration | `30s`   | File SHA poll interval        |
| `dynamic_config.propagation_latency` | `DUCTOR_DYNAMIC_CONFIG_PROPAGATION_LATENCY` | duration | `5s`    | Cross-pod propagation target  |

## Notifications (`notification.*`) [#notifications-notification]

Notification management and dispatch are separately gated. Subscriber, topic,
integration, layout, and preference APIs require the notification substrate;
provider sends additionally require dispatch. Topic sends require the durable fan-out
queue and never fall back to an in-process loop.

| YAML key                             | Env var                                     | Type | Default | Controls                                                                                                   |
| ------------------------------------ | ------------------------------------------- | ---- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `notification.enabled`               | `DUCTOR_NOTIFICATION_ENABLED`               | bool | `false` | Enable the notification subscriber, preference, transform, and integration substrate                       |
| `notification.dispatch.enabled`      | `DUCTOR_NOTIFICATION_DISPATCH_ENABLED`      | bool | `false` | Allow `TriggerNotification` to dispatch; disabled triggers fail with HTTP `412` before writing deliveries  |
| `notification.fanout.queue_disabled` | `DUCTOR_NOTIFICATION_FANOUT_QUEUE_DISABLED` | bool | `false` | Disable the durable topic queue; topic trigger admission then fails because no synchronous fallback exists |
| `notification.digest.enabled`        | `DUCTOR_NOTIFICATION_DIGEST_ENABLED`        | bool | `false` | Coalesce channels carrying digest preferences; otherwise sends remain immediate                            |
| `notification.channel_providers`     | `DUCTOR_NOTIFICATION_CHANNEL_PROVIDERS`     | map  | `{}`    | Ordered provider keys per channel for legacy provider selection                                            |
| `notification.defaults`              | `DUCTOR_NOTIFICATION_DEFAULTS`              | map  | `{}`    | Optional per-channel defaults such as sender addresses                                                     |

<Callout type="warn" title="Durable fan-out is mandatory in enterprise mode">
  When `security_profile=enterprise` and notification dispatch is enabled,
  configuration validation requires `notification.fanout.queue_disabled=false`.
  In every profile, a topic trigger fails before acceptance if durable payload,
  manifest, or queue storage is unavailable.
</Callout>

See [Notification Platform](/docs/notifications) for the send API and
[Delivery Reliability](/docs/notifications/delivery-reliability) for payload limits,
attempt semantics, observations, dead letters, and redrive.

## Other subsystems [#other-subsystems]

Ductor ships many more optional subsystems, each config-gated and off by default
unless noted: `webhooks.*`, `inbox.*`, `realtime.*`,
`visibility.*`, `template.*`, `inbound_mail.*`, `dashboard.*`, `editor.*`,
`cases.*`, `tables.*`, `approval.*`, `multi_region.*`, `per_namespace.*`,
`bridge.*`, `egress.*`, `payload.*`, `audit.*`, `consent.*`, `fraud.*`,
`yield.*`, `marketplace.*`, `reliability.*`, `debug.*`, `tls.*`, `cors.*`. Each
follows the same env-var rule; consult the corresponding file in
`internal/config/` for defaults.

See the [API surface reference](/docs/reference/api-surface) for how these
subsystems' endpoints are served and discovered.

<Callout title="tenant.enabled is special">
  `DUCTOR_TENANT_ENABLED` is a legacy multi-tenant flag read directly from Viper
  rather than through the `Config` struct — it has no field on `Config`. Set it
  as an env var when you need to toggle tenancy.
</Callout>
