Managing Resources

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

SecretVariableContext
Holdscredentials, keys, certsnon-sensitive configarbitrary JSON object
Stored asAEAD ciphertext (encrypted)plaintextJSON document
Read backmetadata only — never the valuevalue is returnedfull data payload
Natural key(tenant, environment, name)(tenant, environment, name)(tenant, type, id)
Environment-partitionedyesyesno
Referenced at step time${{ SECRETS.name.key }}${{ VARS.name.key }}over the API
Scope familysecret:read / secret:writevariable:read / variable:writecontext: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.

CreateSecretPOST /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.

UpdateSecretPUT /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.

CreateContextPOST /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.

UpdateContextPUT /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

StoreListCreateGetUpdateDelete
SecretsGET /api/v2/secretsPOST /api/v2/secretsGET /api/v2/secrets/{id}PUT /api/v2/secrets/{id}DELETE /api/v2/secrets/{id}
VariablesGET /api/v2/variablesPOST /api/v2/variablesGET /api/v2/variables/{id}PUT /api/v2/variables/{id}DELETE /api/v2/variables/{id}
ContextsGET /api/v2/contextsPOST /api/v2/contextsGET /api/v2/contexts/{type}/{id}PUT /api/v2/contexts/{type}/{id}DELETE /api/v2/contexts/{type}/{id}

Where to go next