Configuration
Every DUCTOR_* environment variable and YAML key, grouped by subsystem, with types and defaults.
Ductor is configured from a single typed Config struct, loaded once at
startup. Fields are set through a YAML file or inline YAML; scalar keys and
supported simple lists also use DUCTOR_* environment variables. This page
documents the loading model and
the most commonly-used keys, grouped by subsystem.
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. Scalar leaf keys follow the mechanical env-var naming rule below.
Structured lists, such as agent_rfq.bidder_targets, belong in YAML or
inline DUCTOR_CONFIG; a JSON-valued environment variable is not a supported
target-list contract.
How configuration loads
Configuration is resolved with Viper and
unmarshalled into internal/config/config.go. Precedence, highest to lowest:
- CLI flag (e.g.
--database-url) - Environment variable (
DUCTOR_*) - Inline YAML in
DUCTOR_CONFIG, or a config file - Registered defaults (from the code's
config.Default())
The env var rule
- Prefix is
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_MODErouter vs routing
Two prefixes look similar but are distinct. Queue and flow-control tuning lives
under router.* (DUCTOR_ROUTER_QUEUE_*, DUCTOR_ROUTER_FLOW_*), while
the routing engine's own knobs live under routing.*
(DUCTOR_ROUTING_*). This split is historical — mind the prefix.
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
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
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
Use the CLI to print the effective config, its source, and DSN-redacted values:
ductor validate-configCore & 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
| YAML key | Env var | Type | Default | Controls |
|---|---|---|---|---|
server.http_addr | DUCTOR_SERVER_HTTP_ADDR | string | :8080 | Legacy; opens no listener. The API, REST, MCP and /health are served on api.addr. The server warns at boot when the two disagree |
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.
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)
| 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
| 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)
| 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 | The one request listener: Connect, gRPC, REST, MCP, /health. Compose sets it to :8080 |
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 |
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.
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.
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.*)
| 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.*)
| 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 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
| 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
| 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
| 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
| 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)
| 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.*)
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 is a map keyed by an arbitrary provider name you choose (e.g.
primary, fallback). Each entry configures one upstream:
Prop
Type
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.
AI routing policy (ai.routing.*)
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 |
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.
MCP (mcp.*)
Ductor's inbound Model Context Protocol server (it also imports tools from external MCP servers — see the 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 |
mcp.admin_enabled | DUCTOR_MCP_ADMIN_ENABLED | bool | false | Mount the platform-admin plane; requires mcp.enabled |
mcp.admin_path | DUCTOR_MCP_ADMIN_PATH | string | /mcp/admin | Admin plane mount path |
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.
Clearing surfaces
The pricing, settlement, receipt, and agent-procurement planes each sit behind
their own gate. All default off except connector_shared_executor. The
operator view of what each gate guarantees and what fails closed is in
Clearing surfaces.
| YAML key | Env var | Type | Default | Controls |
|---|---|---|---|---|
connector_shared_executor.enabled | DUCTOR_CONNECTOR_SHARED_EXECUTOR_ENABLED | bool | true | Route notification dispatch, agent tool calls, and workflow connector steps through the shared executor that enforces execution admission |
connector_pricing.enabled | DUCTOR_CONNECTOR_PRICING_ENABLED | bool | false | Build the action price resolver; priced actions become billable and agent spend caps become enforceable |
connector_assurance.enabled | DUCTOR_CONNECTOR_ASSURANCE_ENABLED | bool | false | Resolve the certified/community trust axis on catalog reads |
connector_assurance.require_certified_for_agents | DUCTOR_CONNECTOR_ASSURANCE_REQUIRE_CERTIFIED_FOR_AGENTS | bool | false | Refuse agent-initiated actions that do not resolve certified; needs enabled |
settlement.journal_enabled | DUCTOR_SETTLEMENT_JOURNAL_ENABLED | bool | false | Co-write every pricing charge into the balanced double-entry journal |
work_receipt.enabled | DUCTOR_WORK_RECEIPT_ENABLED | bool | false | Issue and sign work receipts; requires active_key_id and the signing key |
work_receipt.active_key_id | DUCTOR_WORK_RECEIPT_ACTIVE_KEY_ID | string | "" | Key id bound into every signed receipt |
work_receipt.signing_private_key_pem | DUCTOR_WORK_RECEIPT_SIGNING_PRIVATE_KEY_PEM | string | "" | Ed25519 PKCS#8 private key; set through the env var, never in a file. Literal \n escapes are accepted |
work_receipt.keys[] | — | list | [] | Public verify keys: key_id, public_key_pem, not_before, not_after, disabled, revoked |
work_receipt.jwks_enabled | DUCTOR_WORK_RECEIPT_JWKS_ENABLED | bool | false | Serve the verify keys at /.well-known/work-receipts/jwks.json |
agent_rfq.enabled | DUCTOR_AGENT_RFQ_ENABLED | bool | false | Task-spec publication and worker discovery of open RFQs |
agent_rfq.bidder_enabled | DUCTOR_AGENT_RFQ_BIDDER_ENABLED | bool | false | Start bounded automatic discovery/pricing/submission for explicit targets; requires the complete durable execution and authority chain |
agent_rfq.bidder_poll_interval | DUCTOR_AGENT_RFQ_BIDDER_POLL_INTERVAL | duration | 5s (shipped YAML) | Automatic bidder cadence; must be positive when bidding is enabled; also runs a pass at startup |
agent_rfq.bidder_scan_limit | DUCTOR_AGENT_RFQ_BIDDER_SCAN_LIMIT | int | 16 (shipped YAML) | At most this many pending/new jobs per target/pass; must be in 1–64 when enabled |
agent_rfq.bidder_targets | —; YAML or DUCTOR_CONFIG | structured list | [] | 1–64 explicit stable target records required when bidding is enabled; fields below |
agent_rfq.award_execution_enabled | DUCTOR_AGENT_RFQ_AWARD_EXECUTION_ENABLED | bool | false | Launch awarded work, settle the hold, issue the receipt. The gate that moves money; requires enabled |
agent_rfq.recovery_sweep_tenant_ids | DUCTOR_AGENT_RFQ_RECOVERY_SWEEP_TENANT_IDS | []string | [] | Canonical nonzero tenant UUIDs whose awards the dispatch/recovery sweep visits; required when award execution is on. Held commit requires the stored session tenant in this same scope before financial effects |
agent_rfq.recovery_sweep_interval | DUCTOR_AGENT_RFQ_RECOVERY_SWEEP_INTERVAL | duration | 5m | Sweep cadence |
agent_rfq.recovery_sweep_run_on_startup | DUCTOR_AGENT_RFQ_RECOVERY_SWEEP_RUN_ON_STARTUP | bool | true | Run one sweep at boot |
agent_rfq.recovery_sweep_limit | DUCTOR_AGENT_RFQ_RECOVERY_SWEEP_LIMIT | int | 50 | Awards per sweep |
agent_rfq.discovery_limit | DUCTOR_AGENT_RFQ_DISCOVERY_LIMIT | int | 50 | Max open RFQs returned per discovery call |
agent_rfq.evaluation_max_model_requests | DUCTOR_AGENT_RFQ_EVALUATION_MAX_MODEL_REQUESTS | int | 4 (shipped YAML) | Bidder: full request ceiling reserved per new job; must be in 1–64 when enabled |
agent_rfq.evaluation_max_cost_micros | DUCTOR_AGENT_RFQ_EVALUATION_MAX_COST_MICROS | int64 | 50000 (shipped YAML) | Post-response provider-cost guard, not a pre-call money reservation; nonnegative, zero omits this guard |
agent_rfq.evaluation_timeout | DUCTOR_AGENT_RFQ_EVALUATION_TIMEOUT | duration | 60s (shipped YAML) | Positive when enabled; frozen job deadline also respects earlier market/task deadlines |
Hold-mode commits need every gate behind them
A market committed with commit_mode: hold escrows the buyer's money until
the award loop settles it. The loop is only constructed when
agent_rfq.enabled, agent_rfq.award_execution_enabled,
settlement.journal_enabled, and work_receipt.enabled are all on, the
recovery sweep names the tenant, and the workflow runtime is running. If any
of those is missing the commit is refused with PRECONDITION_FAILED rather
than parking funds nothing can release.
Enabled award execution also validates a positive recovery interval and requires the award service at startup. Tenant ids are trimmed and deduplicated, but must already be canonical nonzero UUIDs. This coverage is static configuration, not a worker-health or capacity signal. Immediate-settlement markets do not gain award execution from this list.
Receipt worker policy
These keys belong under work_receipt.worker. The values below are both
canonical zero-field defaults and the shipped YAML values. Enabled issuance
resolves and validates this policy before starting the worker; negative values
never select defaults. An issuance-disabled verification-only deployment ignores
unused worker policy while retaining validation of its public verification keys.
| YAML key | Env var | Type | Default | Valid range and meaning |
|---|---|---|---|---|
work_receipt.worker.lease_seconds | DUCTOR_WORK_RECEIPT_WORKER_LEASE_SECONDS | int | 60 | 10–3,600 seconds; database claim lease |
work_receipt.worker.batch_size | DUCTOR_WORK_RECEIPT_WORKER_BATCH_SIZE | int | 16 | 1–100 items per burst; each item is claimed separately just before processing |
work_receipt.worker.retry_delay_seconds | DUCTOR_WORK_RECEIPT_WORKER_RETRY_DELAY_SECONDS | int | 30 | 1–3,600 seconds; persisted retry delay after retryable processing failure |
work_receipt.worker.max_attempts | DUCTOR_WORK_RECEIPT_WORKER_MAX_ATTEMPTS | int | 10 | 1–100; a processing failure at this current-cycle claim count or later dead-letters |
work_receipt.worker.poll_interval | DUCTOR_WORK_RECEIPT_WORKER_POLL_INTERVAL | duration | 1s | 100ms–1m; idle or failed-drain polling cadence; successful nonempty bursts continue immediately |
work_receipt.worker.processing_timeout | DUCTOR_WORK_RECEIPT_WORKER_PROCESSING_TIMEOUT | duration | 30s | At least 1s, at most exactly half the effective lease; includes claim, assembly, signing, and finalization |
work_receipt.worker.shutdown_timeout | DUCTOR_WORK_RECEIPT_WORKER_SHUTDOWN_TIMEOUT | duration | 30s | 1s–5m; cancellation followed by joining the actual supervised loop, subject to an earlier Fx stop deadline |
Every claim increments attempts, including claims after crashes or lease
recovery. Lifetime claims never reset. The current cycle counts
attempts - claim_baseline; the initial cycle has baseline zero. An authorized
redrive records the current lifetime count as the next baseline and
advances retry_generation, with at most three redrive cycles. This is not a
failed-attempt counter: success can complete beyond the cycle's threshold,
while non-retryable evidence refusals can dead-letter immediately. Use the
receipt recovery controls
to inspect a failed request before requesting a redrive.
Lowering lease_seconds without lowering the default processing timeout can
make the policy invalid. An eleven-second lease permits at most 5.5s, not a
rounded six seconds. The deployment supplies a unique explicit owner per replica;
there is no operator owner override or shared static-owner fallback.
See bounded receipt issuance for shutdown behavior and recovery. Shutdown does not automatically finish all pending receipts or revive failed requests.
The snapshot/signing observer uses fixed timings: an immediate pass, a fifteen-second interval, a five-second database-read bound and at most thirty seconds to join on stop, subject to the remaining Fx deadline. There is no additional observer configuration flag. See clearing telemetry for interpreting sample freshness and database state.
Bounded bidder targets
The bidder values shown as shipped YAML come from configs/ductor.yaml, not
implicit fallback bounds for custom configuration. config.Default() leaves
the bidder values zero-valued. Supply positive request/scan/time bounds and a
nonempty target list explicitly when enabling bidding in your own document.
Each entry in agent_rfq.bidder_targets contains:
| YAML field | Type | Requirement |
|---|---|---|
id | string | Unique stable target id; 1–128 bytes without surrounding whitespace. |
tenant_id | string | Target tenant; same identifier bound as id. |
environment | string | Authorized task environment id or key; same identifier bound. |
worker_id | string | Registered agent worker owned by the current key subject; same identifier bound. |
credential_ref | string | Tenant-default secret name holding the actual machine API key; same identifier bound. |
pricing_preset.id | string | Exact published pricing preset id; same identifier bound. |
pricing_preset.version | int64 | Strictly positive immutable version. |
pricing_preset.hash | string | Exact published definition hash, 64 hex characters. |
lifetime_model_requests | int64 | Strictly positive durable lifetime request allowance for this target. |
Target records use a YAML file or inline YAML in DUCTOR_CONFIG. Do not assume
DUCTOR_AGENT_RFQ_BIDDER_TARGETS accepts JSON or derive per-index env vars.
The machine key resolves from the tenant-default secret partition, separately
from the task environment. Current claims must permit agent_rfq:read,
routing_market_bid:write, the environment, and worker ownership. Pricing uses its
own exact model-only preset, separate from the registered fulfilment source.
A new job atomically reserves the full request ceiling from its target's Postgres-backed lifetime allowance. The counter never resets or refunds; the initial persisted cap can only be reduced. Increasing configuration cannot restore consumed slots. A reduction below reservations prevents new jobs; existing jobs retain their frozen bounds. Stable target scope, original subject, credential reference, and pricing source cannot be changed in place. The allowance is per target, not a tenant money budget. See bounded autonomous bidding and the complete opt-in example for dependencies, secret encoding, durable recovery, zero-retry pricing, and cost limits.
Public bidder observation
reads persisted target caps and all-state job history without changing configuration
or allowances. It requires the RFQ front door and current caller read/environment/
worker-ownership authority; bidder_enabled can remain false. Persisted targets
can outlive current configuration. The view does not report current poller health,
actual model usage, or whether the current per-job ceiling can fit remaining slots.
Page limits are observation request bounds, separate from bidder_scan_limit.
There is no allowance reset or increase operation on this read surface.
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 |
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.
See External Conversation Bridges for Slack signature verification, participant bindings, ordered admission, same-thread replies, and single-use approval behavior.
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 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.*)
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_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
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
dynamicconfigregistry — a code-generated, typed, per-key registry (regenerated viamake 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.*)
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 |
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.
See Notification Platform for the send API and Delivery Reliability for payload limits, attempt semantics, observations, dead letters, and redrive.
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 for how these subsystems' endpoints are served and discovered.
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.