Secrets, Variables & Contexts
The tenant config and data plane — encrypted Secrets, plaintext Variables, and the composite-keyed Context JSON store — with a disambiguation table and the Context-store name-collision warning.
Three sibling stores hold tenant-scoped configuration and data outside of your routing topology. They look similar at the API — all tenant-scoped, all optimistically locked — but they exist for different reasons, and mixing them up leaks credentials or breaks references. Read the disambiguation table first.
Secret vs Variable vs Context
| Secret | Variable | Context | |
|---|---|---|---|
| Holds | credentials, keys, certs | non-sensitive config | arbitrary JSON object |
| Stored as | AEAD ciphertext (encrypted) | plaintext | JSON document |
| Read back | metadata only — never the value | value is returned | full data payload |
| Natural key | (tenant, environment, name) | (tenant, environment, name) | (tenant, type, id) |
| Environment-partitioned | yes | yes | no |
| Referenced at step time | ${{ SECRETS.name.key }} | ${{ VARS.name.key }} | over the API |
| Scope family | secret:read / secret:write | variable:read / variable:write | context:read / context:write |
| Path | /api/v2/secrets | /api/v2/variables | /api/v2/contexts |
The rule of thumb: if leaking the value would matter, it's a Secret. If it's
config you're comfortable reading back in plaintext, it's a Variable. If it's
structured tenant data you want to look up by a (type, id) key, it's a Context.
'Context store' is not the workflow run context
The Context store documented here is a durable, tenant-scoped JSON store
you create and read over /api/v2/contexts. It is unrelated to the ephemeral
run context — the per-execution data bag a workflow run threads through its
steps. Same word, different thing: one persists across runs and is addressed by
(type, id); the other lives and dies with a single run. Don't reach for the
Context store to pass data between steps of one run, and don't expect run-scoped
values to survive in the Context store.
Environment partitioning
Secrets and Variables are partitioned by environment — production, staging,
development, or the default partition (stored as an empty string). A name is
unique per (tenant, environment), so PROVIDER_API_KEY can hold different
values in staging and production. Resolution falls back: a lookup in a
non-default environment that misses falls through to default; default itself
has no further fallback. (This is the secrets/variables partition model in
domain/secrets/environment.go; it is distinct from the richer
environments control plane.)
Secrets
A Secret's value is encrypted at rest with AEAD (XChaCha20-Poly1305), bound to the owning tenant and secret name as additional authenticated data so ciphertext can't be swapped between tenants or secrets. The plaintext is write-only: you send it on create and update, and it is never returned by any read — the same treatment as an API key secret.
Where it lives
Secrets are served by SecretService under /api/v2/secrets; the domain
aggregate is domain/secrets/secret.go. Reads return SecretMetadata, a
projection that omits the ciphertext entirely. Supported secret_type values
are custom, ssh_key, mtls, and ca_cert.
CreateSecret — POST /api/v2/secrets (secret:write). name, secret_type,
and value are required; environment defaults to the default partition.
curl -s -X POST https://api.ductor.io/api/v2/secrets \
-H "Authorization: Bearer $DUCTOR_TOKEN" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "PROVIDER_API_KEY",
"secret_type": "custom",
"environment": "production",
"value": "sk-live-...",
"description": "Outbound provider credential"
}'Reads never include the value. GetSecretMetadata
(GET /api/v2/secrets/{id}, secret:read) and ListSecrets
(GET /api/v2/secrets, secret:read) return name, type, environment, version,
and audit fields only. ListSecrets filters by environment, secret_type, and
name_prefix.
UpdateSecret — PUT /api/v2/secrets/{id} (secret:write) — rotates the value
and/or description. It requires the current version for optimistic concurrency;
a stale version is rejected with a conflict. The previous value is not
recoverable. DeleteSecret (DELETE /api/v2/secrets/{id}, secret:write) is
irreversible.
Because reads are metadata-only, the way to use a secret's value is a step-time
expression: ${{ SECRETS.name.key }} resolves the plaintext during workflow
step execution. If you delete or rename a secret a step still references, that
step fails with an expression-resolution error on its next run — rotate the
reference before you delete.
Variables
A Variable is the plaintext sibling of a Secret: same (tenant, environment, name) key, same optimistic-locking and environment fallback, but the value is
stored as plaintext and is returned on read. Use variables for non-sensitive
configuration you reference by name — thresholds, feature toggles, endpoint hints
— and reach for a Secret the moment the value is a credential.
CreateVariable (POST /api/v2/variables, variable:write) requires name and
value. GetVariable and ListVariables (variable:read) return the value;
ListVariables filters by environment and name_prefix. UpdateVariable
(PUT /api/v2/variables/{id}, variable:write) requires the current version.
DeleteVariable is irreversible.
curl -s -X POST https://api.ductor.io/api/v2/variables \
-H "Authorization: Bearer $DUCTOR_TOKEN" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "MAX_RETRIES",
"environment": "production",
"value": "5",
"description": "Outbound retry ceiling"
}'Variables resolve at step time via ${{ VARS.name.key }}; a running workflow
picks up an updated value on its next step execution. Because list results
include values, narrow them with name_prefix rather than dumping the whole set.
Contexts
A Context is a tenant-scoped JSON document addressed by the composite natural key
(tenant, type, id) — for example type customer, id acme. Both type and
id must match ^[a-zA-Z0-9_-]+$ and be at most 100 characters. The data
field is always a JSON object (never null, never a bare array). Contexts are
optimistically locked by an integer version.
Where it lives
Contexts are served by ContextService under /api/v2/contexts; the domain
aggregate is domain/contexts/context.go. Reads and writes are gated by
context:read and context:write.
CreateContext — POST /api/v2/contexts (context:write) — creates a document;
a duplicate (type, id) returns a conflict.
curl -s -X POST https://api.ductor.io/api/v2/contexts \
-H "Authorization: Bearer $DUCTOR_TOKEN" \
-H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{
"type": "customer",
"id": "acme",
"data": { "tier": "gold", "region": "us-east" }
}'GetContext (GET /api/v2/contexts/{type}/{id}, context:read) returns the
data, version, and audit fields. ListContexts (GET /api/v2/contexts,
context:read) paginates with limit (default 50, max 200) and offset, and
filters by exact type, exact id, or a substring search across type:id.
UpdateContext — PUT /api/v2/contexts/{type}/{id} (context:write) — replaces
the data. Supply the current version; a mismatch is rejected with a conflict,
so concurrent writers must reload and retry. DeleteContext
(DELETE /api/v2/contexts/{type}/{id}, context:write) is irreversible.
Operations at a glance
| Store | List | Create | Get | Update | Delete |
|---|---|---|---|---|---|
| Secrets | GET /api/v2/secrets | POST /api/v2/secrets | GET /api/v2/secrets/{id} | PUT /api/v2/secrets/{id} | DELETE /api/v2/secrets/{id} |
| Variables | GET /api/v2/variables | POST /api/v2/variables | GET /api/v2/variables/{id} | PUT /api/v2/variables/{id} | DELETE /api/v2/variables/{id} |
| Contexts | GET /api/v2/contexts | POST /api/v2/contexts | GET /api/v2/contexts/{type}/{id} | PUT /api/v2/contexts/{type}/{id} | DELETE /api/v2/contexts/{type}/{id} |
Where to go next
Environments
Manage a tenant's dev/staging/production environments — lifecycle, the default and production flags, protection policy, readiness validation, comparison, and artifact/provider bindings.
Publishers
Manage traffic sources and lead vendors — publisher CRUD, pause/resume ingestion, volume caps and quality thresholds, per-publisher pricing, and delivery statistics.