Ductile: Configuration Specification¶
Version: 1.1 (Tiered Directory Model)
Date: 2026-02-25
Status: Approved
This document defines the configuration structure, integrity verification, and runtime compilation behavior for Ductile.
1. Directory Structure¶
Ductile uses a configuration directory, typically located at ~/.config/ductile/. Only
config.yaml is implicitly loaded; all other files must be referenced via include:.
~/.config/ductile/
├── config.yaml # [Operational] Service-level settings
├── webhooks.yaml # [High Security] Webhook endpoints & secrets (include explicitly)
├── tokens.yaml # [High Security] API token registry (include explicitly)
├── relay-instances.yaml # [Operational] Outbound named relay targets (include explicitly)
├── relay-ingress.yaml # [Operational] Inbound trusted relay peers (include explicitly)
├── routes.yaml # [Operational] Global routing rules (include explicitly)
└── scopes/ # [High Security] Token scope definitions
├── admin-cli.json
└── github-integration.json
2. Tiered Integrity Preflight¶
Before starting, the system verifies all files against a monolithic .checksums manifest located in the configuration root. Integrity is enforced in two tiers:
| Tier | Files | Missing/Mismatch Behavior |
|---|---|---|
| High Security | tokens.yaml, webhooks.yaml, scopes/*.json |
Hard Fail: System refuses to start (EX_CONFIG). |
| Operational | config.yaml, routes.yaml, relay-instances.yaml, relay-ingress.yaml |
Warn & Continue: Logs a warning but loads the file (Unless strict_mode: true is set, in which case it is a Hard Fail). |
2.1 The Seal (.checksums)¶
The .checksums file is a YAML manifest containing BLAKE3 hashes indexed by the absolute path of every authorized file.
- System Lock-in: Moving the configuration directory breaks the seal.
- Authorization: The ductile config lock command is the only way to update the manifest.
3. Monolithic Compilation (Grafting)¶
At runtime, the gateway compiles all discovered files into a single, monolithic configuration object.
3.1 Merge Logic¶
- Root First:
config.yamlis loaded first as the base. - Explicit Includes: Additional files are loaded from the
include:list (and any directories listed there) in order. - Precedence: Later entries override earlier ones (n-1 branching).
- Matching Branches:
- Maps (e.g.,
plugins:): Keys are merged. Duplicate keys are overridden by the later file. - Arrays (e.g.,
pipelines:,routes:): Items are appended to the list. - Scalars: Later values replace earlier ones.
- Maps (e.g.,
3.2 Modular Example¶
config.yaml (Root)
pipelines.yaml
Resulting Monolith:
3.3 Directory includes¶
include: entries may point at directories. Ductile loads *.yaml files
from that directory (non-recursive) in alphabetical order and merges them
as if they were listed explicitly.
3.4 Naming convention for operator-facing instance identifiers¶
When config introduces an operator-facing identifier for a Ductile instance, peer, or similarly named runtime endpoint, use lower-case hyphenated names:
home-primarylabvps-backup
Do not use:
- underscores:
home_primary - spaces:
home primary - mixed case:
HomePrimary
Recommended pattern:
Rationale:
- reads cleanly in YAML and logs
- maps directly to URL path segments
- avoids competing conventions for operator-facing identities
- keeps names distinct from Go identifiers and internal field names
service.name is an operator-facing identity field and should follow
this convention when it names a concrete Ductile instance rather than a
generic service label.
4. File Formats¶
4.1 config.yaml (Service settings)¶
service:
name: ductile
tick_interval: 60s
log_level: info
log_format: json
dedupe_ttl: 24h
job_log_retention: 30d
job_queue_retention: 24h
# Omit to use the default: max(1, CPU-1). Set to 1 to force global serial dispatch.
max_workers: 4
strict_mode: true # Enforce integrity & configuration checks on startup
plugin_roots:
- /opt/ductile/plugins
- /opt/ductile/plugins-private
api:
enabled: true
listen: 127.0.0.1:8080
state:
path: ./data/state.db
# macOS-only. Each path is stat()-ed once on cold start (after PID lock,
# before "ductile running" log). Triggers any pending TCC popup for the
# Files-and-Folders service that gates the path. Runs synchronously while
# the operator is at the keyboard for the deploy. No-op on non-darwin and
# when the list is empty. Skipped on SIGHUP reload (binary cdhash
# unchanged → existing grants still valid).
#
# Configure local-volume paths only. An unreachable network mount blocks
# os.Stat for the filesystem-level timeout (seconds to minutes) and
# delays gateway readiness during the cold-start prewarm.
tcc_paths:
- /Users/me/Documents/Obsidian # triggers Documents grant
- /Volumes/Projects # triggers NetworkVolumes grant
Relative paths (like ./data/state.db) are resolved against the directory containing config.yaml.
dedupe_ttl uses recent terminal rows in job_queue, so job_queue_retention
must be at least as long as dedupe_ttl. The defaults are both 24h.
Note: the core does not provision per-job filesystem workspaces; the
workspace:config section has been removed. Plugins that need a scratch path manage it themselves — seedocs/PLUGIN_DEVELOPMENT.md§9.
plugin_roots is the multi-root setting.
Discovery behavior: - Duplicate roots are ignored after first occurrence. - Roots are scanned in order; if duplicate plugin names exist across roots, the first discovered plugin is kept and later duplicates are ignored.
4.2 Plugin definitions (included file)¶
plugins:
echo:
enabled: true
parallelism: 1
notify_on_complete: true # Opt-in to job.completed lifecycle signals
schedules: # Optional; omit for event-driven plugins
- id: default
every: 5m
config:
message: "Hello"
4.2.1 Concurrency controls¶
service.max_workers: Global worker cap across all plugins. If omitted, Ductile usesmax(1, CPU-1). Set this to1to force whole-system serial dispatch.plugins.<name>.parallelism: Per-plugin concurrency cap.- Constraint:
1 <= parallelism <= max_workers.
Manifest interaction:
- Plugins may declare concurrency_safe: false in manifest.yaml; omitted
means true.
- The manifest hint is the plugin author's safety declaration. Operators use
plugins.<name>.parallelism to choose how much same-plugin concurrency to
allow within the global service.max_workers cap.
4.3 webhooks.yaml (High Security - Experimental)¶
[!IMPORTANT]
Webhook support is currently in early development and may not be fully functional in the current MVP.
webhooks:
- name: github
path: /webhook/github
plugin: github-handler
secret_ref: github_webhook_secret
signature_header: X-Hub-Signature-256
See WEBHOOKS.md for full configuration details, include-mode caveats, and signing examples.
4.4 tokens.yaml (High Security)¶
tokens:
- name: admin-cli
key: ${ADMIN_API_KEY}
scopes_file: scopes/admin-cli.json
scopes_hash: blake3:a3f8c2d9...
4.5 routes.yaml (Operational - Experimental)¶
[!IMPORTANT]
Global routing rules viaroutes.yamlare experimental. Most users should prefer thepipelinesDSL for orchestration.
4.6 relay-instances.yaml (Operational - Experimental)¶
relay-instances.yaml defines named outbound Remote Event Relay targets.
instances:
- name: lab
enabled: true
base_url: https://lab.example
ingress_path: /ingest/peer/home-primary
secret_ref: relay-lab-v1
key_id: v1
timeout: 10s
allow:
- backup.ready
- report.generated
Notes:
- name is the stable operator-facing alias used by sender-side config.
- base_url must be an absolute http or https URL.
- ingress_path is the receiver path that accepts the trusted relay request.
- secret_ref points at a tokens.yaml entry used as the shared HMAC secret.
- allow is an optional sender-side event-type allowlist.
4.7 relay-ingress.yaml (Operational - Experimental)¶
relay-ingress.yaml defines inbound trusted peers and the local acceptance policy for Remote Event Relay.
remote_ingress:
listen_path: /ingest/peer
max_body_size: 1MB
allowed_clock_skew: 5m
require_key_id: true
peers:
- name: home-primary
enabled: true
secret_ref: relay-lab-v1
key_id: v1
accept:
- backup.ready
baggage:
allow:
- trace_id
- requested_by
Notes:
- listen_path is the trusted relay ingress root mounted on Ductile's HTTP server.
- Relay ingress listens on api.listen; it does not introduce a separate listener address in Phase 1.
- allowed_clock_skew controls timestamp validation for replay-window hardening.
- require_key_id requires X-Ductile-Key-Id on inbound requests.
- peers[].accept is an optional receiver-side event-type allowlist.
- peers[].baggage.allow is a local policy for which remote baggage keys may seed new local root context.
Accepted relay requests are treated as fresh local root ingress events:
- the receiver performs normal local enqueue
- the receiver performs normal local exact-match routing
- no cross-instance event_context lineage is created
See REMOTE_EVENT_RELAY.md for a user-level guide and an end-to-end example.
5. Authentication Configuration¶
Ductile authentication is configured within the api section of the configuration (typically in config.yaml or a dedicated auth.yaml).
5.1 Scoped Tokens¶
For multi-user or production environments.
api:
auth:
tokens:
- token: admin_token
scopes: ["*"]
- token: readonly_token
scopes: ["plugin:ro", "jobs:ro", "events:ro"]
- token: operator_token
scopes: ["plugin:rw", "jobs:rw", "events:ro"]
5.2 Token Scopes¶
Scopes are explicit:
- *: Full admin access.
- plugin:ro, plugin:rw: Plugin and pipeline trigger access.
- jobs:ro, jobs:rw: Job read/write access.
- events:ro, events:rw: Event stream access.
6. Environment Interpolation¶
Interpolation of ${VAR} syntax happens after integrity verification but before YAML parsing.
- Secrets must never be stored in YAML files; use environment variables.
- Interpolation is forbidden in file paths (e.g., include: or directory walking) to ensure a static, verifiable tree.
6.1 Environment file includes¶
You can preload env vars from .env files before interpolation:
Notes: - Paths are resolved relative to the file declaring the include. - Existing process environment variables are not overridden.