# Configuration (/docs/management/configuration)



Not all of Ductor's configuration is baked in at boot. A meaningful slice is
**runtime-writable** — feature flags you toggle, per-tenant overrides you set, a
routing pipeline mode you flip — and takes effect across the fleet without a
redeploy. This page maps the config surface: what you can read, what you can
change at runtime, and what is fixed at process start via environment variables.

## Two tiers of configuration [#two-tiers-of-configuration]

| Tier                           | How it's set                                                   | When it takes effect  | Examples                                                                                                |
| ------------------------------ | -------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- |
| **Static (env/file)**          | `configs/ductor.yaml` + `DUCTOR_*` env vars, read once at boot | Process restart       | `database.url`, `cache.url`, `server.http_addr`, `connector.encryption_key`, `workflow_runtime.enabled` |
| **Dynamic (runtime-writable)** | API + CLI, backed by a store with pub/sub invalidation         | Live, across all pods | Feature flags, per-tenant config overrides, routing pipeline mode                                       |

The static tier is the deployment's shape — connection strings, ports, keys — and
is documented in [Getting Started](/docs/getting-started) and the deployment
guides. Everything below is the dynamic tier.

<Callout title="Two different 'dynamic config' systems — don't conflate them" type="warn">
  Ductor has **two** independent runtime-config mechanisms, and they solve
  different problems:

  1. The **dynamic-config registry** (`pkg/dynamicconfig`) — a
     Postgres-backed, Redis-invalidated store of typed operational settings with
     first-class **kill switches** and **gradual (percentage) rollout**. This is
     the durable, cross-pod control plane the services below write to.
  2. The **file-based feature-flag / A-B system** (`internal/config` —
     `flags.go`, `feature_config.go`, `abtest.go`, `file_source.go`) — flags and
     experiment buckets resolved from config files/env at load time.

  The APIs on this page (`ConfigService`, `TenantConfigService`,
  `RuntimeConfigService`) drive the registry side. The operational deep-dive on
  the registry, its kill switches, and gradual-change semantics lives in
  [Dynamic config](/docs/operations/dynamic-config).
</Callout>

<Callout title="Where it lives">
  Three services cover dynamic config, each with its own scope:
  `ConfigService` (system-wide, `/api/config`), `TenantConfigService`
  (per-tenant, `/api/tenants/{id}/config`), and `RuntimeConfigService`
  (per-artifact dependency refs, `/api/runtime-config`). Routing pipeline mode is
  a routing-domain setting changed through the CLI.
</Callout>

## System config: `ConfigService` [#system-config-configservice]

System-wide dynamic config lives under `/api/config`, `resource_type: "config"`.

| Operation                | RPC                 | HTTP                          | Action |
| ------------------------ | ------------------- | ----------------------------- | ------ |
| Snapshot the full config | `GetConfigSnapshot` | `GET /api/config`             | read   |
| Get version + hash       | `GetVersion`        | `GET /api/config/version`     | read   |
| List feature flags       | `ListFlags`         | `GET /api/config/flags`       | read   |
| Toggle a feature flag    | `ToggleFlag`        | `PUT /api/config/flags/{key}` | write  |
| Force a reload           | `ReloadConfig`      | `POST /api/config/reload`     | admin  |

### Snapshot and version [#snapshot-and-version]

`GetConfigSnapshot` returns the entire current system config as a key/value map —
this is your **export** of what the running config actually is:

```bash
curl -s https://api.ductor.io/api/config \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN"
```

`GetVersion` returns a `version`, a content `hash`, and `loaded_at` — cheap to
poll, and the right way to detect that config changed without pulling the whole
snapshot:

```json
{ "version": "2026-07-11T09:00:00Z", "hash": "sha256:1f3c…", "loaded_at": "2026-07-11T09:00:02Z" }
```

### Feature flags [#feature-flags]

`ListFlags` returns every flag with its `enabled` state and rollout
`percentage`. `ToggleFlag` updates one, keyed in the URL:

```bash
curl -s -X PUT https://api.ductor.io/api/config/flags/enable_advanced_routing \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true, "percentage": 25 }'
```

`percentage` (0–100) drives a gradual rollout and only applies when `enabled` is
true. The response echoes the updated `FeatureFlag`.

### Reload [#reload]

`ReloadConfig` forces an immediate re-read from the backing store and returns the
new `version` + `hash`. It requires the `admin` action (stricter than the `write`
used for flag toggles) because it re-loads the entire config:

```bash
curl -s -X POST https://api.ductor.io/api/config/reload \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN"
```

## Per-tenant config: `TenantConfigService` [#per-tenant-config-tenantconfigservice]

Per-tenant overrides live under `/api/tenants/{tenant_id}/config`,
`resource_type: "tenant_config"`.

| Operation                  | RPC                 | HTTP                                           | Action |
| -------------------------- | ------------------- | ---------------------------------------------- | ------ |
| Get tenant overrides (raw) | `GetTenantConfig`   | `GET /api/tenants/{tenant_id}/config`          | read   |
| Replace tenant config      | `SetTenantConfig`   | `PUT /api/tenants/{tenant_id}/config`          | write  |
| Get resolved config        | `GetResolvedConfig` | `GET /api/tenants/{tenant_id}/config/resolved` | read   |

`GetTenantConfig` returns only the tenant's own overrides — not inherited
defaults. `SetTenantConfig` **replaces the entire config** (it's a `PUT`, not a
merge), so read-modify-write the whole object:

```bash
curl -s -X PUT https://api.ductor.io/api/tenants/$DUCTOR_TENANT/config \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "config": { "default_strategy": "weighted_round_robin", "features": { "beta_scoring": true } } }'
```

`GetResolvedConfig` is the one you'll reach for when debugging "why is this tenant
behaving this way" — it returns the **fully merged** view (system defaults +
tenant overrides + optional pool-level overrides) plus a `sources` list showing
precedence order. Pass `pool_id` to fold in a pool's overrides too:

```bash
curl -s "https://api.ductor.io/api/tenants/$DUCTOR_TENANT/config/resolved?pool_id=$POOL_ID" \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN"
```

## Runtime config refs: `RuntimeConfigService` [#runtime-config-refs-runtimeconfigservice]

`RuntimeConfigService` (`/api/runtime-config`, `resource_type: "runtime_config"`)
is a **read-only, always-redacted** surface describing the runtime dependencies
an executable artifact needs — the secrets, variables, connection metadata, and
non-secret config it references. It never returns plaintext secret values; it
answers questions *about* config bindings:

| Question                                 | RPC                             | HTTP                                         |
| ---------------------------------------- | ------------------------------- | -------------------------------------------- |
| What refs does this artifact use?        | `ListArtifactConfigRefs`        | `GET /api/runtime-config/refs`               |
| Are its required refs satisfiable?       | `ValidateRuntimeConfigBindings` | `POST /api/runtime-config/bindings:validate` |
| What breaks if I change/rotate/delete X? | `PreviewRuntimeConfigImpact`    | `POST /api/runtime-config/impact:preview`    |
| Dry-run resolution (redacted)            | `ResolveRuntimeConfigDryRun`    | `POST /api/runtime-config/resolve:dry-run`   |
| Who consumes this ref?                   | `ListRuntimeConfigConsumers`    | `GET /api/runtime-config/consumers`          |
| Why is a ref unresolved?                 | `ExplainMissingRuntimeConfig`   | `POST /api/runtime-config/missing:explain`   |

Use `impact:preview` before rotating a secret or deleting a variable to see every
artifact that would be affected — it's the safe pre-flight for config changes that
ripple across workflows and connections.

## Routing pipeline mode [#routing-pipeline-mode]

Pipeline mode selects **how** a routing request is executed. It's resolved
per-request down a chain — **pool → tenant → global** — with each level able to
`inherit` from the next.

The resolver (`domain/pool/pipeline_mode.go`, `Resolve`) walks the chain and
returns the **first concrete (non-`inherit`) value**: pool override wins, else
the tenant override, else the global setting. If all three levels are `inherit&#x60;
(the out-of-the-box state, the safe fall-through for an unknown value), it
resolves to &#x2A;*`dag`** — so a fresh install routes through the DAG runtime with no
configuration.

```mermaid
flowchart LR
    Req["routing request"] --> Pool{"pool mode"}
    Pool -->|"concrete"| Use["use resolved mode"]
    Pool -->|"inherit"| Tenant{"tenant mode"}
    Tenant -->|"concrete"| Use
    Tenant -->|"inherit"| Global{"global mode"}
    Global -->|"concrete"| Use
    Global -->|"inherit"| Dag["dag (default)"]
```

| Mode      | Meaning                                                                                                                                                                                                                                                 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dag`     | Run the request through the DAG runtime. &#x2A;*The default when everything inherits.**                                                                                                                                                                 |
| `linear`  | Legacy inline pipeline in the routing goroutine — a kill-switch path.                                                                                                                                                                                   |
| `inherit` | Defer to the next level in the resolution chain.                                                                                                                                                                                                        |
| `shadow`  | **Retired.** A retained persisted enum value only — `PipelineMode.Valid()` still accepts it (so it appears in the CLI help and the [config reference](/docs/reference/configuration)), but both the write path and the router **reject it as retired**. |

<Callout title="Shadow is a retained value, not a usable mode" type="warn">
  `shadow` passes the enum's `Valid()` check because rows persisted before the
  DAG cutover may still carry it — but you cannot set it or run on it. The CLI's
  writer (`PipelineModeWriter.SetPoolPipelineMode` / `SetTenantPipelineMode&#x60;) and
  the DAG dispatcher both fail with &#x2A;"pipeline\_mode=shadow is retired; use
  routing.stage\_registry\_shadow with the isolated graph."* For actual
  linear-vs-DAG parity/shadow evaluation, use the **stage-registry shadow**
  mechanism (`WithStageRegistryShadow`), which runs one path authoritatively and
  spawns an isolated parity comparison — not `pipeline_mode=shadow`.
</Callout>

<Callout type="info">
  Pipeline mode is a routing-domain setting, **not an HTTP resource** — there is
  no `/api/...` endpoint to change it. It's set through the Ductor CLI (which
  writes the pool's `pipeline_mode` or `tenant_config.routing_pipeline_mode`) and
  propagated to every pod over a Redis pub/sub channel so the change lands
  fleet-wide within the kill-switch budget.
</Callout>

```bash
# Set a single pool to the linear kill-switch path
ductor pipeline-mode set --pool $POOL_ID --mode linear

# Set the tenant-wide default
ductor pipeline-mode set --tenant $DUCTOR_TENANT --mode dag
```

`--pool` and `--tenant` are mutually exclusive; exactly one is required. The
command accepts `inherit|linear|shadow|dag` at the flag level, but a `shadow`
write is rejected downstream as retired (see above).

## What's runtime-writable, at a glance [#whats-runtime-writable-at-a-glance]

| Setting                     | Runtime-writable?       | How                                                                         |
| --------------------------- | ----------------------- | --------------------------------------------------------------------------- |
| Feature flags               | Yes                     | `PUT /api/config/flags/{key}`                                               |
| Per-tenant config overrides | Yes                     | `PUT /api/tenants/{id}/config`                                              |
| Routing pipeline mode       | Yes                     | `ductor pipeline-mode set …` (CLI)                                          |
| Tenant quotas / features    | Yes                     | [`PATCH /api/tenants/{id}`](/docs/management/tenants)                       |
| Pool kill-switch            | Yes                     | [`POST /api/pools/{id}:set_enabled`](/docs/management/pools-and-recipients) |
| Routing rules               | Yes                     | [Rules API](/docs/management/rules) (hot-reloaded)                          |
| DB / cache URLs, ports      | No                      | `DUCTOR_*` env, restart required                                            |
| `connector.encryption_key`  | No (rotate via keyring) | `DUCTOR_CONNECTOR_ENCRYPTION_KEY`, restart                                  |
| `workflow_runtime.enabled`  | No                      | `DUCTOR_WORKFLOW_RUNTIME_ENABLED`, restart                                  |

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Tenants" href="/docs/management/tenants">
    Quotas, features, and the boundary tenant config refines.
  </Card>

  <Card title="Rules" href="/docs/management/rules">
    The other hot-reloaded, pub/sub-invalidated runtime surface.
  </Card>

  <Card title="Dynamic config" href="/docs/operations/dynamic-config">
    The registry, its kill switches, and gradual-rollout semantics in depth.
  </Card>

  <Card title="Getting started" href="/docs/getting-started">
    The static env/file configuration set at boot.
  </Card>
</Cards>
