Skip to main content

Run secrets resolve

POST …/state/runs/{runId}/secrets/resolve is the only route on the platform that returns secret values to a machine. A running job hands it the scope references its plan declared and receives the decrypted values — if, and only if, two independent gates both pass.

POST /v1/organizations/{orgId}/projects/{projectId}/state/runs/{runId}/secrets/resolve

Two gates

  1. Bearer authorization — the caller's token must hold secret.value.use for this workspace + project (workflow actors by token-bound scope; users and service principals via role policy). Denied as a resource-hiding 404.
  2. A live job lease — the body's (runId, jobId, runnerId, leaseEpoch) must match a lease that is live right now. A lapsed or reassigned lease answers 409 lease_lost, the same envelope as the :heartbeat/:complete verbs.

The gates are independent by design: a stolen bearer token alone can never pull values, because it does not hold a live lease on a claimed job.

Request

{
"runnerId": "runner-8f2c",
"jobId": "api.prod.deploy",
"leaseEpoch": 1,
"refs": [
"secret://acme/project:storefront/env:prod/STRIPE_API_KEY",
"secret://acme/project:storefront/env:prod/DATABASE_URL@3",
"secret://acme/project:storefront/env:stage/SMOKE_TEST_TOKEN"
],
"optionalRefs": [
"secret://acme/project:storefront/env:prod/SENTRY_DSN"
]
}

Every reference must arrive fully pinned — contextual omission is bound by the CLI at expand time, before the wire. The workspace and project segments must name this route's own scope (verified against membership and projects, never trusted textually); a component: segment is an assertion cross-checked against the lease-derived component from the leased job row — a mismatch is 422, and the segment is never a grant.

Response

{
"data": {
"secrets": {
"STRIPE_API_KEY": "sk_live_…",
"DATABASE_URL": "postgres://…",
"SMOKE_TEST_TOKEN": "tok_…",
"SENTRY_DSN": "https://…"
},
"secretsByEnv": {
"prod": {
"STRIPE_API_KEY": "sk_live_…",
"DATABASE_URL": "postgres://…",
"SENTRY_DSN": "https://…"
},
"stage": {
"SMOKE_TEST_TOKEN": "tok_…"
}
},
"ttlSeconds": 300
},
"meta": { "requestId": "req_5f2d1c0b9a8e7f6d5c4b3a35", "cursor": null }
}

ttlSeconds (300) is how long the runner may cache served values.

Multi-environment grouping

One resolve call may carry references for several environments — the deploy shape where a stage deploy renders a template whose prod section needs prod's wiring too. References group by their env: dimension, and each group resolves with its own environment id: policy is evaluated once per environment, so grouping changes fan-out, never evaluation scope.

  • secretsByEnv maps environment slug → key → value and is collision-free.
  • The flat secrets map is retained for older clients. A key served for two environments keeps the last group's value (declaration order) there — env-aware clients should read secretsByEnv.

Caps and validation

RuleFailure
At most 50 refs per resolve — refs and optionalRefs share the cap422
At most 8 distinct environments per resolve422 — "At most 8 distinct environments per resolve"
Unknown environment slug404 (resource-hiding, like every other miss)
Same key pinned at conflicting versions within one environment422
Malformed reference422 — the error names the parse-failure class but never echoes the input, since a malformed value in a secret-shaped slot may itself be a secret

Optional references

optionalRefs carry the same grammar, grouping, and environment constraints as refs, with one difference: a key that has no head at its rung is simply skipped — absent from the response — instead of failing the whole resolve with a 404. This is the wire-now-seed-later shape: a plan can reference a secret that a later job (or a human) will publish, without the first run failing on its absence.

Everything else still fails hard:

  • A policy-denied key fails the resolve — optional softens absence, never denial.
  • An unservable version pin fails the resolve.
  • A key requested both hard and optional stays fail-closed — hard wins.
  • A resolve of only optional refs is legal, but a malformed value present in refs is never masked by it.