Reference

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:

  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

  • 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_MODE

router 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-config

Core & logging

YAML keyEnv varTypeDefaultControls
environmentDUCTOR_ENVIRONMENTstringdevelopmentDeployment environment; development enables .env loading
security_profileDUCTOR_SECURITY_PROFILEstring``Named posture; enterprise forces fail-closed controls
(inline)DUCTOR_CONFIGYAML string—Inline YAML config, overrides the file
(loader)DUCTOR_LOAD_DOTENVbool—Force .env loading outside development
log.levelDUCTOR_LOG_LEVELstringinfoLog level (debug/info/warn/error)
log.formatDUCTOR_LOG_FORMATstringjsonjson or text
log.add_sourceDUCTOR_LOG_ADD_SOURCEboolfalseInclude source file/line in logs

Server

YAML keyEnv varTypeDefaultControls
server.http_addrDUCTOR_SERVER_HTTP_ADDRstring:8080Legacy; 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_addrDUCTOR_SERVER_METRICS_ADDRstring:9090Prometheus metrics listener
server.read_header_timeoutDUCTOR_SERVER_READ_HEADER_TIMEOUTduration10sHTTP read-header timeout
server.read_timeoutDUCTOR_SERVER_READ_TIMEOUTduration30sHTTP read timeout
server.write_timeoutDUCTOR_SERVER_WRITE_TIMEOUTduration60sHTTP write timeout
server.idle_timeoutDUCTOR_SERVER_IDLE_TIMEOUTduration120sHTTP idle timeout
server.shutdown_drain_delayDUCTOR_SERVER_SHUTDOWN_DRAIN_DELAYduration5sGraceful-shutdown drain delay
server.roleDUCTOR_SERVER_ROLEstringallComma 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 keyEnv varTypeDefaultControls
database.urlDUCTOR_DATABASE_URLstring—Postgres connection URL (required)
database.auto_migrateDUCTOR_DATABASE_AUTO_MIGRATEboolfalseRun migrations at startup
database.statement_timeoutDUCTOR_DATABASE_STATEMENT_TIMEOUTduration30sPer-statement timeout (0 = off)
database.idle_in_transaction_session_timeoutDUCTOR_DATABASE_IDLE_IN_TRANSACTION_SESSION_TIMEOUTduration60sIdle-in-transaction timeout
database.pool.max_connsDUCTOR_DATABASE_POOL_MAX_CONNSint25Pool max connections
database.pool.min_connsDUCTOR_DATABASE_POOL_MIN_CONNSint5Pool min connections
database.pool.max_conn_lifetimeDUCTOR_DATABASE_POOL_MAX_CONN_LIFETIMEduration5mMax connection lifetime
database.pool.max_conn_idle_timeDUCTOR_DATABASE_POOL_MAX_CONN_IDLE_TIMEduration5mMax connection idle time
database.pool.health_check_periodDUCTOR_DATABASE_POOL_HEALTH_CHECK_PERIODduration30sIdle-conn health-check cadence

Cache (Redis / Dragonfly)

YAML keyEnv varTypeDefaultControls
cache.urlDUCTOR_CACHE_URLstring—Redis / Dragonfly URL (required)
cache.force_single_clientDUCTOR_CACHE_FORCE_SINGLE_CLIENTbooltrueForce single-node client mode

Authentication & authorization

YAML keyEnv varTypeDefaultControls
auth.enabledDUCTOR_AUTH_ENABLEDbooltrueEnable authentication
auth.allow_anonymousDUCTOR_AUTH_ALLOW_ANONYMOUSboolfalseAllow anonymous requests
auth.api_key_enabledDUCTOR_AUTH_API_KEY_ENABLEDbool—Enable DB-backed API-key auth
auth.api_keysDUCTOR_AUTH_API_KEYSstring``Static (comma-separated) API keys
auth.service_token_secretDUCTOR_AUTH_SERVICE_TOKEN_SECRETstring—HS256 secret for internal service tokens
auth.audience_resolverDUCTOR_AUTH_AUDIENCE_RESOLVERstring—JWT audience mode ("" / host)
authz.allow_when_unconfiguredDUCTOR_AUTHZ_ALLOW_WHEN_UNCONFIGUREDbooltrueFail-open when no authorizer is wired

API (Connect/gRPC + OIDC)

YAML keyEnv varTypeDefaultControls
api.enabledDUCTOR_API_ENABLEDbooltrueEnable the Connect/gRPC API listener
api.addrDUCTOR_API_ADDRstring:50052The one request listener: Connect, gRPC, REST, MCP, /health. Compose sets it to :8080
api.oidc_issuerDUCTOR_API_OIDC_ISSUERstring``OIDC issuer URL
api.oidc_audienceDUCTOR_API_OIDC_AUDIENCEstring``OIDC audience
api.max_request_body_sizeDUCTOR_API_MAX_REQUEST_BODY_SIZEint644194304 (4 MiB)Max request body
api.allowed_hostsDUCTOR_API_ALLOWED_HOSTS[]string[localhost,127.0.0.1,::1,0.0.0.0]Allowed Host headers
api.max_concurrent_requestsDUCTOR_API_MAX_CONCURRENT_REQUESTSint0 (unlimited)Global concurrency cap
api.max_concurrent_requests_per_tenantDUCTOR_API_MAX_CONCURRENT_REQUESTS_PER_TENANTint0 (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 keyEnv varTypeDefaultControls
routing.pipeline_modeDUCTOR_ROUTING_PIPELINE_MODEstringdag (compile default)Global pipeline: linear/dag/shadow/inherit
routing.rule_cache_sizeDUCTOR_ROUTING_RULE_CACHE_SIZEint1000Rule cache size
routing.rule_cache_ttlDUCTOR_ROUTING_RULE_CACHE_TTLduration30sRule cache TTL
routing.capacity_reservation_ttlDUCTOR_ROUTING_CAPACITY_RESERVATION_TTLduration1hCapacity reservation safety TTL
routing.default_timeout_msDUCTOR_ROUTING_DEFAULT_TIMEOUT_MSint5000 (effective)Routing default timeout
routing.max_routing_depthDUCTOR_ROUTING_MAX_ROUTING_DEPTHint3 (effective)Max routing recursion depth
routing.dry_run_defaultDUCTOR_ROUTING_DRY_RUN_DEFAULTboolfalseDefault dry-run
routing.persist_explainDUCTOR_ROUTING_PERSIST_EXPLAINboolfalsePersist explain traces
routing.bundle_fileDUCTOR_ROUTING_BUNDLE_FILEstring—External routing bundle YAML path
routing.bundle_tenantDUCTOR_ROUTING_BUNDLE_TENANTstring—Tenant for bundle apply (required with bundle_file)
routing.strategy_deployments.timeoutDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_TIMEOUTduration5sRemote strategy call timeout
routing.strategy_deployments.fail_policyDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_FAIL_POLICYstringclosedFail open/closed on remote strategy error
routing.strategy_deployments.credentials.<ref>.token_envDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TOKEN_ENVstring—Name of the env var holding a bearer token for auth_ref: <ref>
routing.strategy_deployments.credentials.<ref>.token_fileDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TOKEN_FILEstring—File path holding a bearer token (alternative to token_env)
routing.strategy_deployments.credentials.<ref>.tls_ca_fileDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_CA_FILEstring—PEM CA bundle that verifies the remote strategy server cert (TLS ≥ 1.2)
routing.strategy_deployments.credentials.<ref>.tls_cert_fileDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_CERT_FILEstring—Client certificate for mTLS (must be set with tls_key_file)
routing.strategy_deployments.credentials.<ref>.tls_key_fileDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_KEY_FILEstring—Client key for mTLS (must be set with tls_cert_file)
routing.strategy_deployments.credentials.<ref>.tls_server_nameDUCTOR_ROUTING_STRATEGY_DEPLOYMENTS_CREDENTIALS_<REF>_TLS_SERVER_NAMEstring—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 keyEnv varTypeDefaultControls
router.queue.enabledDUCTOR_ROUTER_QUEUE_ENABLEDboolfalseEnable the tiered queue
router.queue.concurrencyDUCTOR_ROUTER_QUEUE_CONCURRENCYint50Global concurrency (0 = unlimited)
router.queue.tenant_concurrencyDUCTOR_ROUTER_QUEUE_TENANT_CONCURRENCYint200Per-tenant concurrency
router.queue.workersDUCTOR_ROUTER_QUEUE_WORKERSint10Worker count
router.queue.visibility_timeoutDUCTOR_ROUTER_QUEUE_VISIBILITY_TIMEOUTduration30sItem visibility timeout
router.queue.retry_delayDUCTOR_ROUTER_QUEUE_RETRY_DELAYduration5sRetry delay
router.queue.max_scavenge_recoveriesDUCTOR_ROUTER_QUEUE_MAX_SCAVENGE_RECOVERIESint3Recoveries before DLQ
router.flow.concurrency.limitDUCTOR_ROUTER_FLOW_CONCURRENCY_LIMITint100Flow concurrency limit
router.flow.adaptive.enabledDUCTOR_ROUTER_FLOW_ADAPTIVE_ENABLEDboolfalseAdaptive flow control

Workflow runtime (workflow_runtime.*)

YAML keyEnv varTypeDefaultControls
workflow_runtime.enabledDUCTOR_WORKFLOW_RUNTIME_ENABLEDbooltrueEnable the DAG runtime
workflow_runtime.poll_intervalDUCTOR_WORKFLOW_RUNTIME_POLL_INTERVALduration5sCoordinator poll interval
workflow_runtime.batch_sizeDUCTOR_WORKFLOW_RUNTIME_BATCH_SIZEint50Wakeup batch size
workflow_runtime.coordinator_tick_timeoutDUCTOR_WORKFLOW_RUNTIME_COORDINATOR_TICK_TIMEOUTduration30sPer-tick timeout
workflow_runtime.recovery_stale_afterDUCTOR_WORKFLOW_RUNTIME_RECOVERY_STALE_AFTERduration30sStale-run recovery threshold
workflow_runtime.continue_as_new_after_historyDUCTOR_WORKFLOW_RUNTIME_CONTINUE_AS_NEW_AFTER_HISTORYint1000ContinueAsNew after N history events
workflow_runtime.continue_as_new_after_wakeupsDUCTOR_WORKFLOW_RUNTIME_CONTINUE_AS_NEW_AFTER_WAKEUPSint500ContinueAsNew after N wakeups
workflow_runtime.max_nodes_per_runDUCTOR_WORKFLOW_RUNTIME_MAX_NODES_PER_RUNint10000Hard node cap per run
workflow_runtime.max_step_output_sizeDUCTOR_WORKFLOW_RUNTIME_MAX_STEP_OUTPUT_SIZEint64104857600 (100 MiB)Step output cap
workflow_runtime.max_run_state_sizeDUCTOR_WORKFLOW_RUNTIME_MAX_RUN_STATE_SIZEint64268435456 (256 MiB)Run-state cap
workflow_runtime.state_checksum_modeDUCTOR_WORKFLOW_RUNTIME_STATE_CHECKSUM_MODEstringauditoff/audit/fail

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

YAML keyTypeDefaultControls
workflow_runtime.run_concurrency.enabledboolfalseAcquire and renew a run-level concurrency lease before advancing work.
workflow_runtime.run_concurrency.lease_ttlduration30mOwnership horizon; must be at least 3s.
workflow_runtime.run_concurrency.resume_backoffduration30sRetry cadence when a parked run cannot reacquire a slot.
workflow_runtime.run_concurrency.early_release.enabledboolfalseRelease the slot while waiting on an event, approval, or digest.
workflow_runtime.run_concurrency.reclaimer.enabledboolfalseEnable leader-elected expired/orphan lease cleanup.
workflow_runtime.run_concurrency.reclaimer.intervalduration30sReclaimer sweep cadence.
workflow_runtime.run_concurrency.reclaimer.key_patternstring{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 keyEnv varTypeDefaultControls
events.enabledDUCTOR_EVENTS_ENABLEDboolfalseEnable Redis event streams
events.stream_prefixDUCTOR_EVENTS_STREAM_PREFIXstring(Ductor prefix)Stream key prefix
tracing.enabledDUCTOR_TRACING_ENABLEDboolfalseEnable OTLP tracing
tracing.endpointDUCTOR_TRACING_ENDPOINTstringhttp://victoriatraces:10428/insert/opentelemetry/v1/tracesOTLP endpoint
tracing.backendDUCTOR_TRACING_BACKENDstringvictoriatracesBackend hint
tracing.sample_rateDUCTOR_TRACING_SAMPLE_RATEfloat640.1Head-sampling fraction
tracing.grpcDUCTOR_TRACING_GRPCboolfalsegRPC exporter instead of HTTP
tracing.insecureDUCTOR_TRACING_INSECUREbooltrueDisable TLS on the exporter

Connector

YAML keyEnv varTypeDefaultControls
connector.encryption_keyDUCTOR_CONNECTOR_ENCRYPTION_KEYstring—Base64 32-byte AEAD master key (required for connections)
connector.encryption_key_idDUCTOR_CONNECTOR_ENCRYPTION_KEY_IDuint80Active wire key_id
connector.rotation_keys(list)list—Decrypt-only rotation keyring
connector.action_rate_limit.enabledDUCTOR_CONNECTOR_ACTION_RATE_LIMIT_ENABLEDboolfalsePer-tenant action rate limiting
connector.tenant_provider_policy.enabledDUCTOR_CONNECTOR_TENANT_PROVIDER_POLICY_ENABLEDboolfalsePer-tenant provider policy
connector.connect_session.enabledDUCTOR_CONNECTOR_CONNECT_SESSION_ENABLEDboolfalseHosted OAuth connect-session flow
connector.connect_session.base_urlDUCTOR_CONNECTOR_CONNECT_SESSION_BASE_URLstring``Connect-link origin
connector.connection_recovery.enabledDUCTOR_CONNECTOR_CONNECTION_RECOVERY_ENABLEDboolfalseAuto reconnect/refresh recovery

Archival & retention

YAML keyEnv varTypeDefaultControls
archival.enabledDUCTOR_ARCHIVAL_ENABLEDboolfalseEnable archival
archival.backendDUCTOR_ARCHIVAL_BACKENDstringlocallocal / s3 / gcs
archival.retention_daysDUCTOR_ARCHIVAL_RETENTION_DAYSint30Retention days
archival.intervalDUCTOR_ARCHIVAL_INTERVALduration1hSweep interval
archival.storage_pathDUCTOR_ARCHIVAL_STORAGE_PATHstring./data/archiveLocal backend path
archival.s3_bucketDUCTOR_ARCHIVAL_S3_BUCKETstring``S3 bucket
archival.s3_regionDUCTOR_ARCHIVAL_S3_REGIONstringus-east-1S3 region
retention.enabledDUCTOR_RETENTION_ENABLEDboolfalseEnable the retention worker
retention.intervalDUCTOR_RETENTION_INTERVALduration24hSweep interval
retention.decisions_ttlDUCTOR_RETENTION_DECISIONS_TTLduration2160h (90d)Decisions TTL
retention.audit_logs_ttlDUCTOR_RETENTION_AUDIT_LOGS_TTLduration4320h (180d)Audit-logs TTL

Billing, entitlements & rate limits

YAML keyEnv varTypeDefaultControls
stripe.api_keySTRIPE_API_KEY (or DUCTOR_STRIPE_API_KEY)string—Stripe API key
stripe.webhook_secretDUCTOR_STRIPE_WEBHOOK_SECRETstring—Stripe webhook signing secret
stripe.default_currencyDUCTOR_STRIPE_DEFAULT_CURRENCYstringusdDefault currency
entitlement.enforcementDUCTOR_ENTITLEMENT_ENFORCEMENTstringreportreport / off / strict
ratelimit.global_rpsDUCTOR_RATELIMIT_GLOBAL_RPSfloat6410000Global requests/sec
ratelimit.tenant_rpsDUCTOR_RATELIMIT_TENANT_RPSfloat641000Per-tenant requests/sec
ratelimit.fail_modeDUCTOR_RATELIMIT_FAIL_MODEstringopenBehavior on Redis outage

Identity (SCIM / SAML)

YAML keyEnv varTypeDefaultControls
identity.scim.enabledDUCTOR_IDENTITY_SCIM_ENABLEDboolfalseSCIM 2.0 provisioning
identity.scim.base_pathDUCTOR_IDENTITY_SCIM_BASE_PATHstring/scim/v2SCIM router base path
identity.saml.enabledDUCTOR_IDENTITY_SAML_ENABLEDboolfalseSAML SSO service provider
identity.saml.sp_entity_idDUCTOR_IDENTITY_SAML_SP_ENTITY_IDstring—SP entity ID
identity.saml.acs_base_urlDUCTOR_IDENTITY_SAML_ACS_BASE_URLstring—ACS base URL (HTTPS)
identity.saml.session_ttlDUCTOR_IDENTITY_SAML_SESSION_TTLduration8hSAML 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 keyEnv varTypeDefaultControls
ai_inference.enabledDUCTOR_AI_INFERENCE_ENABLEDboolfalseEnable the AI inference proxy
ai_inference.strategyDUCTOR_AI_INFERENCE_STRATEGYstringrandom_weightedProvider-selection strategy across the pool
ai_inference.max_retriesDUCTOR_AI_INFERENCE_MAX_RETRIESint2Retries after the initial call (0 = one attempt, -1 = none)
ai_inference.retry_backoffDUCTOR_AI_INFERENCE_RETRY_BACKOFFduration500msBase exponential backoff between retries
ai_inference.connect_timeoutDUCTOR_AI_INFERENCE_CONNECT_TIMEOUTduration10sTCP connect timeout to a provider
ai_inference.response_timeoutDUCTOR_AI_INFERENCE_RESPONSE_TIMEOUTduration30sNon-streaming response timeout
ai_inference.stream_timeoutDUCTOR_AI_INFERENCE_STREAM_TIMEOUTduration10mStreaming response timeout
ai_inference.circuit_breaker.enabledDUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_ENABLEDbool(module default)Per-provider fail-fast breaker
ai_inference.circuit_breaker.consecutive_failuresDUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_CONSECUTIVE_FAILURESint—Failures before the breaker opens
ai_inference.circuit_breaker.open_timeoutDUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_OPEN_TIMEOUTduration—How long the breaker stays open
ai_inference.circuit_breaker.reset_intervalDUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_RESET_INTERVALduration—Failure-count reset window
ai_inference.circuit_breaker.half_open_max_requestsDUCTOR_AI_INFERENCE_CIRCUIT_BREAKER_HALF_OPEN_MAX_REQUESTSuint32—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 keyEnv varTypeDefaultControls
ai.routing.max_cost_per_decision_usdDUCTOR_AI_ROUTING_MAX_COST_PER_DECISION_USDfloat640 (disabled)Hard cap on LLM spend per routing event
ai.routing.monthly_budget_usdDUCTOR_AI_ROUTING_MONTHLY_BUDGET_USDfloat640 (disabled)Monthly pool-level LLM spend cap
ai.routing.model_priorityDUCTOR_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 keyEnv varTypeDefaultControls
mcp.enabledDUCTOR_MCP_ENABLEDboolfalseMount the inbound MCP server
mcp.pathDUCTOR_MCP_PATHstring/mcpHTTP mount path
mcp.expose_unwired_toolsDUCTOR_MCP_EXPOSE_UNWIRED_TOOLSboolfalseAdvertise tools with no wired backend in tools/list
mcp.admin_enabledDUCTOR_MCP_ADMIN_ENABLEDboolfalseMount the platform-admin plane; requires mcp.enabled
mcp.admin_pathDUCTOR_MCP_ADMIN_PATHstring/mcp/adminAdmin 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 keyEnv varTypeDefaultControls
connector_shared_executor.enabledDUCTOR_CONNECTOR_SHARED_EXECUTOR_ENABLEDbooltrueRoute notification dispatch, agent tool calls, and workflow connector steps through the shared executor that enforces execution admission
connector_pricing.enabledDUCTOR_CONNECTOR_PRICING_ENABLEDboolfalseBuild the action price resolver; priced actions become billable and agent spend caps become enforceable
connector_assurance.enabledDUCTOR_CONNECTOR_ASSURANCE_ENABLEDboolfalseResolve the certified/community trust axis on catalog reads
connector_assurance.require_certified_for_agentsDUCTOR_CONNECTOR_ASSURANCE_REQUIRE_CERTIFIED_FOR_AGENTSboolfalseRefuse agent-initiated actions that do not resolve certified; needs enabled
settlement.journal_enabledDUCTOR_SETTLEMENT_JOURNAL_ENABLEDboolfalseCo-write every pricing charge into the balanced double-entry journal
work_receipt.enabledDUCTOR_WORK_RECEIPT_ENABLEDboolfalseIssue and sign work receipts; requires active_key_id and the signing key
work_receipt.active_key_idDUCTOR_WORK_RECEIPT_ACTIVE_KEY_IDstring""Key id bound into every signed receipt
work_receipt.signing_private_key_pemDUCTOR_WORK_RECEIPT_SIGNING_PRIVATE_KEY_PEMstring""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_enabledDUCTOR_WORK_RECEIPT_JWKS_ENABLEDboolfalseServe the verify keys at /.well-known/work-receipts/jwks.json
agent_rfq.enabledDUCTOR_AGENT_RFQ_ENABLEDboolfalseTask-spec publication and worker discovery of open RFQs
agent_rfq.bidder_enabledDUCTOR_AGENT_RFQ_BIDDER_ENABLEDboolfalseStart bounded automatic discovery/pricing/submission for explicit targets; requires the complete durable execution and authority chain
agent_rfq.bidder_poll_intervalDUCTOR_AGENT_RFQ_BIDDER_POLL_INTERVALduration5s (shipped YAML)Automatic bidder cadence; must be positive when bidding is enabled; also runs a pass at startup
agent_rfq.bidder_scan_limitDUCTOR_AGENT_RFQ_BIDDER_SCAN_LIMITint16 (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_CONFIGstructured list[]1–64 explicit stable target records required when bidding is enabled; fields below
agent_rfq.award_execution_enabledDUCTOR_AGENT_RFQ_AWARD_EXECUTION_ENABLEDboolfalseLaunch awarded work, settle the hold, issue the receipt. The gate that moves money; requires enabled
agent_rfq.recovery_sweep_tenant_idsDUCTOR_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_intervalDUCTOR_AGENT_RFQ_RECOVERY_SWEEP_INTERVALduration5mSweep cadence
agent_rfq.recovery_sweep_run_on_startupDUCTOR_AGENT_RFQ_RECOVERY_SWEEP_RUN_ON_STARTUPbooltrueRun one sweep at boot
agent_rfq.recovery_sweep_limitDUCTOR_AGENT_RFQ_RECOVERY_SWEEP_LIMITint50Awards per sweep
agent_rfq.discovery_limitDUCTOR_AGENT_RFQ_DISCOVERY_LIMITint50Max open RFQs returned per discovery call
agent_rfq.evaluation_max_model_requestsDUCTOR_AGENT_RFQ_EVALUATION_MAX_MODEL_REQUESTSint4 (shipped YAML)Bidder: full request ceiling reserved per new job; must be in 1–64 when enabled
agent_rfq.evaluation_max_cost_microsDUCTOR_AGENT_RFQ_EVALUATION_MAX_COST_MICROSint6450000 (shipped YAML)Post-response provider-cost guard, not a pre-call money reservation; nonnegative, zero omits this guard
agent_rfq.evaluation_timeoutDUCTOR_AGENT_RFQ_EVALUATION_TIMEOUTduration60s (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 keyEnv varTypeDefaultValid range and meaning
work_receipt.worker.lease_secondsDUCTOR_WORK_RECEIPT_WORKER_LEASE_SECONDSint6010–3,600 seconds; database claim lease
work_receipt.worker.batch_sizeDUCTOR_WORK_RECEIPT_WORKER_BATCH_SIZEint161–100 items per burst; each item is claimed separately just before processing
work_receipt.worker.retry_delay_secondsDUCTOR_WORK_RECEIPT_WORKER_RETRY_DELAY_SECONDSint301–3,600 seconds; persisted retry delay after retryable processing failure
work_receipt.worker.max_attemptsDUCTOR_WORK_RECEIPT_WORKER_MAX_ATTEMPTSint101–100; a processing failure at this current-cycle claim count or later dead-letters
work_receipt.worker.poll_intervalDUCTOR_WORK_RECEIPT_WORKER_POLL_INTERVALduration1s100ms–1m; idle or failed-drain polling cadence; successful nonempty bursts continue immediately
work_receipt.worker.processing_timeoutDUCTOR_WORK_RECEIPT_WORKER_PROCESSING_TIMEOUTduration30sAt least 1s, at most exactly half the effective lease; includes claim, assembly, signing, and finalization
work_receipt.worker.shutdown_timeoutDUCTOR_WORK_RECEIPT_WORKER_SHUTDOWN_TIMEOUTduration30s1s–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 fieldTypeRequirement
idstringUnique stable target id; 1–128 bytes without surrounding whitespace.
tenant_idstringTarget tenant; same identifier bound as id.
environmentstringAuthorized task environment id or key; same identifier bound.
worker_idstringRegistered agent worker owned by the current key subject; same identifier bound.
credential_refstringTenant-default secret name holding the actual machine API key; same identifier bound.
pricing_preset.idstringExact published pricing preset id; same identifier bound.
pricing_preset.versionint64Strictly positive immutable version.
pricing_preset.hashstringExact published definition hash, 64 hex characters.
lifetime_model_requestsint64Strictly 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 keyEnv varTypeDefaultControls
aichat.enabledDUCTOR_AICHAT_ENABLEDboolfalseMount the chat agent endpoint
aichat.durable_runtimeDUCTOR_AICHAT_DURABLE_RUNTIMEboolfalseUse the canonical journal-backed runtime; required when chat is enabled
aichat.modelDUCTOR_AICHAT_MODELstring``Model id requested via the AI inference proxy; required when chat is enabled
aichat.max_iterationsDUCTOR_AICHAT_MAX_ITERATIONSint8LLM-call budget per user turn
aichat.max_tool_callsDUCTOR_AICHAT_MAX_TOOL_CALLSint16Tool-execution budget per user turn
aichat.approval_links_enabledDUCTOR_AICHAT_APPROVAL_LINKS_ENABLEDboolfalseMint encrypted, expiring browser approval links for external conversation bridges
aichat.approval_links_base_urlDUCTOR_AICHAT_APPROVAL_LINKS_BASE_URLURL``Externally reachable HTTPS origin or path prefix for /agent-approval; required when links are enabled
aichat.approval_link_ttlDUCTOR_AICHAT_APPROVAL_LINK_TTLduration15mApproval 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 keyEnv varTypeDefaultControls
analytics.enabledDUCTOR_ANALYTICS_ENABLEDboolfalseEnable the proxy + catalog endpoints
analytics.victoria_metrics_urlDUCTOR_ANALYTICS_VICTORIA_METRICS_URLstringhttp://localhost:8428Upstream VictoriaMetrics base URL
analytics.allow_unscoped_metricsDUCTOR_ANALYTICS_ALLOW_UNSCOPED_METRICSboolfalseAllow tenant queries without a real tenant_id label (enterprise rejects this)
analytics.max_query_rangeDUCTOR_ANALYTICS_MAX_QUERY_RANGEduration744h / 31d (built-in)Cap the start..end window of one query
analytics.min_stepDUCTOR_ANALYTICS_MIN_STEPduration5s (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 keyEnv varTypeDefaultControls
entitlement.enforcementDUCTOR_ENTITLEMENT_ENFORCEMENTstringreportreport (evaluate + admit, emit events) / off (legacy fail-open, deny-list only) / strict (fail closed on missing/stale facts)
entitlement.grace_unprovisionedDUCTOR_ENTITLEMENT_GRACE_UNPROVISIONEDbooltrueStrict 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 keyEnv varTypeDefaultControls
key_provider.enabledDUCTOR_KEY_PROVIDER_ENABLEDboolfalseEnable the BYOK boot path
key_provider.uriDUCTOR_KEY_PROVIDER_URIstring—KEK backend URI: local://, awskms://, gcpkms://, vault://
key_provider.key_refDUCTOR_KEY_PROVIDER_KEY_REFstring—Backend key reference (e.g. a KMS key ARN/alias); empty falls back to the key in the URI
key_provider.wrapped_masterDUCTOR_KEY_PROVIDER_WRAPPED_MASTERstring—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 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 keyEnv varTypeDefaultControls
dynamic_config.enabledDUCTOR_DYNAMIC_CONFIG_ENABLEDboolfalseEnable file-based hot reload
dynamic_config.config_fileDUCTOR_DYNAMIC_CONFIG_CONFIG_FILEstring``Path to the watched YAML file
dynamic_config.watch_intervalDUCTOR_DYNAMIC_CONFIG_WATCH_INTERVALduration30sChange-watch interval
dynamic_config.poll_intervalDUCTOR_DYNAMIC_CONFIG_POLL_INTERVALduration30sFile SHA poll interval
dynamic_config.propagation_latencyDUCTOR_DYNAMIC_CONFIG_PROPAGATION_LATENCYduration5sCross-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 keyEnv varTypeDefaultControls
notification.enabledDUCTOR_NOTIFICATION_ENABLEDboolfalseEnable the notification subscriber, preference, transform, and integration substrate
notification.dispatch.enabledDUCTOR_NOTIFICATION_DISPATCH_ENABLEDboolfalseAllow TriggerNotification to dispatch; disabled triggers fail with HTTP 412 before writing deliveries
notification.fanout.queue_disabledDUCTOR_NOTIFICATION_FANOUT_QUEUE_DISABLEDboolfalseDisable the durable topic queue; topic trigger admission then fails because no synchronous fallback exists
notification.digest.enabledDUCTOR_NOTIFICATION_DIGEST_ENABLEDboolfalseCoalesce channels carrying digest preferences; otherwise sends remain immediate
notification.channel_providersDUCTOR_NOTIFICATION_CHANNEL_PROVIDERSmap{}Ordered provider keys per channel for legacy provider selection
notification.defaultsDUCTOR_NOTIFICATION_DEFAULTSmap{}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.