# Secrets, Variables & Contexts (/docs/management/secrets-and-variables)



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-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: &#x2A;*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.

<Callout type="warn" title="'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.
</Callout>

## Environment partitioning [#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](/docs/management/environments).)

## Secrets [#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](/docs/management/api-keys) secret.

<Callout title="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`.
</Callout>

`CreateSecret` — `POST /api/v2/secrets` (`secret:write`). `name`, `secret_type`,
and `value` are required; `environment` defaults to the `default` partition.

```bash
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.

<Callout type="info">
  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.
</Callout>

## Variables [#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.

```bash
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 [#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`.

<Callout title="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`.
</Callout>

`CreateContext` — `POST /api/v2/contexts` (`context:write`) — creates a document;
a duplicate `(type, id)` returns a conflict.

```bash
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 [#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 [#where-to-go-next]

<Cards>
  <Card title="Configuration" href="/docs/management/configuration">
    Runtime configuration and resolved config views that reference these stores.
  </Card>

  <Card title="Environments" href="/docs/management/environments">
    The control-plane environments that mirror the secret/variable partitions.
  </Card>

  <Card title="API keys" href="/docs/management/api-keys">
    The other write-only-at-rest credential, with the same "captured once" model.
  </Card>
</Cards>
