# Move a gateway between environments

Add a QA environment, protect it, require provider evidence before a deploy, retire it cleanly, and promote a configuration with review.

Source: https://tokentriage.com/docs/build/environments-and-promotion

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

Your workspace starts with Development and Production. This page adds the ones you need in
between, decides who may deploy where, and moves one gateway's configuration from one
environment to another with a review you can read before anything activates.

You need `workspace.manage` to create, rename or retire an environment, `config.edit` to
write a draft or a release, and `deploy` to activate one. A protected environment adds
`deploy.protected`. [Who can do what in your company](/docs/build/organizations-and-access)
covers how those grants reach a person.

Each section carries an **Availability** line, because the operations behind this page land
across several increments of this private test.

## Find an application's environments

App Home, the gateway environment selector and promotion show the environments enabled
for the selected application. An application developer can read these with `app.read`;
workspace-wide read access is not required. Creating, renaming and retiring environments
still require `workspace.manage`, and selecting a protected target does not grant deployment
permission.

Use `cloud env list --org <organization> --app <application>` for the same scoped list.
The TypeScript SDK exposes `client.applications.environments(org, app)` and Python exposes
`client.applications.environments(org, app)`. Both call
`GET /organizations/{org}/applications/{app}/environments` in the customer API and support
pagination. Omitting `--app` retains the workspace list, which requires `workspace.read`.

## Add a QA environment

An environment is an ordinary row, not a fixed enumeration, so you add one when two sets
of traffic need different credentials, different limits or different authority.

Create it with a lowercase selector and a display name. Mark it protected at creation if
deploying there should need a second grant.

```console
$ tokentriage cloud env create --base-url https://app.example.com \
    --org org_… --workspace t_… \
    --selector qa --display-name "QA"
```

That command has not been run against the private deployment; the flags come from the
CLI's own definitions. The management key travels in `TOKENTRIAGE_MANAGEMENT_KEY`, not in
argv.

Two fields are fixed at creation and cannot be changed afterwards: `selector` and
`protected`. The update operation has no field for either, so a later decision to protect
an environment is a new environment, not an edit.

Creating an environment enables it for every active application in that workspace.
It does not deploy a gateway or issue a credential.

**Availability.** Environment create, update and the reference report are landed lane
operations. In the portal the same form is **Add environment** on an application's home,
under **Environments**: an environment name, a permanent selector, and a checkbox reading
"Deploying here needs explicit protected-environment permission". The checkbox is on the
create form and not on the rename form for the reason above.

## Rename it without moving anything

A display name is the only thing a rename changes. Pass the revision you read, so a
concurrent change is declined rather than overwritten.

```console
$ tokentriage cloud env update --base-url https://app.example.com \
    --org org_… --env env_… \
    --display-name "QA (EU)" --expected-revision 3
```

That command has not been run against the private deployment either.

What a rename leaves alone is worth stating, because this is the question support gets:
the environment's id, its selector, its protected flag, its issued keys, its provider
connection grants, its drafts and its applied release all stay as they were. The store's
update statement reaches `display_name`, `state` and the provider-test gate, and no other
column.

<Callout type="warning">

A rename does not repoint traffic. Keys and requests resolve an environment by id and
selector, so renaming Production to "Legacy" leaves it serving; retire it or roll its
deployment back if that is what you meant.

</Callout>

## Decide who may deploy to it

Protection is an authority statement, not a lifecycle state. A protected environment
admits a deploy only from a caller holding `deploy` **and** `deploy.protected`; a caller
with `deploy` alone is declined with `403 environment_protected_deploy_denied`, and no
retry by that caller will succeed.

| In this environment | `deploy` alone | `deploy` and `deploy.protected` |
|---|---|---|
| unprotected | deploys | deploys |
| protected | 403, by name | deploys |

Creating a release is a different decision and needs only `config.edit`. That split is
what lets a developer prepare a Production candidate that somebody else activates.

**Availability.** The deploy authority check lands with the deployment increment. Until
then a protected environment has nothing to refuse.

## Require a provider test before a deploy

An environment can insist that a release carry evidence that its connections work before
it may be deployed there. The flag is `require_fresh_provider_test`, it is off unless you
turn it on, and it lives on the environment rather than on the release: the same release
may go to QA without evidence and to Production with it.

```json
{
  "display_name": "Production",
  "require_fresh_provider_test": true,
  "expected_revision": 7
}
```

That body is the `customer.environments.update` request under `workspace.manage`. It has
not been sent against the private deployment. `cloud env update` carries no flag for the
gate yet, so set it from the portal or the lane.

In the portal the flag is a switch on the environment's row, labelled "Deploying here
needs a fresh provider test", and it reads its position from the environment rather than
from what you last clicked. Its own hint states the narrower claim when it is on: a
release may be deployed here only if a provider test was recorded against that exact
release's digest, and a test of an earlier version of the configuration does not count.
The switch sends the flag on its own. A rename sends the display name on its own. Neither
request can express the other's change, so renaming an environment cannot switch its
deploy gate off.

The gate reads the evidence recorded against the release's own effective digest, and
answers with the two words that tell you which recovery you need.

| Answer | Status | What to do |
|---|---|---|
| `provider_test_required` | 409 | run a provider test for this release's connections, then deploy |
| `provider_test_stale` | 409 | a test exists for a different digest; re-run it against this release |

A deploy admitted with nothing recorded returns the evidence the gate read, so a deploy
with no test behind it does not read as one that passed.

**Availability.** The flag and the reference report are landed; the deploy gate that reads
them lands with the deployment increment.

## Retire one without breaking something

Retirement is a two-step lifecycle, and the second step is the one that can be declined.
`active` to `retiring` closes new work: a draft save, a release create and a deploy into
the environment are all answered `409 environment_not_active`. `retiring` to `retired`
closes the environment, and it is declined while anything still points at it.

Read what points at it first, and the refusal holds no surprises.

```console
$ tokentriage cloud env references --base-url https://app.example.com \
    --org org_… --env env_…
```

That command has not been run against the private deployment.

The report groups by ledger, and every group is a named list rather than a count, so you
learn which gateway is still deployed and which credential is still live.

| Group | What blocks retirement | How to clear it |
|---|---|---|
| Deployments (`deployment_heads`) | a gateway still has a desired or applied release here | roll the gateway back or move it to another environment |
| Runtime keys (`runtime_credentials`) | a live key admits traffic in this environment | revoke the key. Retiring the environment does not revoke it for you |
| Provider connections (`provider_grants`) | a saved connection is granted to this environment | revoke the grant for this environment |
| Unpublished configuration (`draft_heads`) | a gateway still holds an editable draft here | nothing is serving; the draft is what the report names, and clearing it is the gateway's own cleanup |

A `retired` attempt while any group is non-empty comes back `409
environment_has_references` with this same report inside the problem body, so one request
tells you both that it stopped and what to remove.

Three sentences the report can produce are worth telling apart, because two of them look
like emptiness and only one is. A group the server listed as empty is the server saying
nothing of that kind points here. A step that was admitted and carried no report at all is
silence, and the portal says that rather than drawing four empty lists. And a deployment
that wired no reference readers answers `503 dependency_unavailable`, which means nobody
can tell you from here: ask whoever runs the deployment before you finish retiring.

**Availability.** Landed. In the portal the two steps are **Retire this environment**,
which opens a panel, then **Begin withdrawing this environment**; the panel's own button
**Check what points at this environment** takes the report first, and the second step is
**Finish retiring it**. The panel states the one-way rule before either step: there is no
control that brings an environment back, and the way back is a new environment with a new
selector. A retired row offers no step at all.

## Promote a configuration, with the review first

Promotion moves a configuration, not a credential. The review is a separate operation from
the apply so you can read what would happen while nothing has happened.

Ask for the review with the source subject, which is either a saved draft revision or an
applied release, and the destination environment. The answer carries five things worth
reading in this order.

1. **The destination plan and its diff** against what is applied there now, with the
   destination's effective digest.
2. **Every slot the plan needs**, each with the destination connections that are eligible
   for it.
3. **The destination's verdicts**: inventory eligibility, mandatory-controls generation and
   current draft revision.
4. **The source's provider-test evidence**, with whether it still applies.
5. **Every blocker**, and a `ready` flag that stays false while one stands.

The connection mapping is the part people expect to be automatic. It is not: the source's
connection is shown for reference and the destination's binding is a choice you make from
the eligible list. A slot nobody mapped is present in the review and carries
`connection_slot_unbound` rather than quietly inheriting Development's key.

Two renderings in that answer say less than a reader might assume, and that is the point of them. With no test recorded, the review reads "No
provider test recorded" instead of an implied pass. When a mapped connection's provider,
endpoint or declared account label has moved since the evidence was captured, the slot is
invalid with `connection_context_changed`, and the evidence stops applying: a test against
one account is not a test of another.

Then apply. The apply echoes the destination's mandatory generation, its draft revision and
its head revision, so the plan somebody read and the plan somebody applies are the same
plan, and a destination that moved in between loses with the word for what moved. It writes
a `promoted` draft revision into the destination, derives a release from it, and deploys
that release.

Rolling back is a separate operation, because a prior release was compiled in the past.
The head returns to a compatible earlier release after re-validating it under today's
controls and grants; a release whose mandatory generation has been superseded, or whose
connection grant has been withdrawn, is declined `409 rollback_incompatible` instead of
putting a withdrawn grant back into production. Nothing is recompiled: the digest stays
what it was, because re-deriving could activate a configuration nobody reviewed.

**Availability.** Promotion review and apply, deploy and rollback land with the promotion
and deployment increments. Their operation rows are already declared, and a deployment
with no release ledger behind them answers `503` with "deployments not configured".

The portal drives review and apply from the gateway's Configure tab, in **Promote this
configuration**. It promotes a saved draft revision, so it says there is nothing to
promote until you have saved one. Choosing a protected destination says so before you
review. Each slot in the plan carries a **Destination connection for …** picker over that
environment's eligible connections, and a slot nobody mapped keeps the plan from applying.
The evidence line reports what was recorded and whether it still applies, in those two
separate words, and it does not say "passed". The apply states the destination's
required-controls generation, its draft revision and its deployment head revision, so a
destination that moved loses with the slug naming what moved.

## When it is declined

Each answer below names one recovery. The status is fixed per slug, so a client can branch
on the word rather than on prose.

| Slug | Status | What to do |
|---|---|---|
| `environment_not_active` | 409 | the environment is retiring or retired; pick another one, or set it back to `active` |
| `environment_has_references` | 409 | clear the groups the attached report lists, then retire it |
| `environment_protected_deploy_denied` | 403 | ask somebody with `deploy.protected` here, or have the grant added |
| `provider_test_required` | 409 | record a provider test for the release's connections |
| `provider_test_stale` | 409 | re-run the test against this release's effective digest |
| `connection_context_changed` | 409 | re-review: the destination credential's account moved, so re-map and re-test |
| `mandatory_generation_stale` | 409 | re-review: required controls changed between the review and the apply |
| `connection_slot_unbound` | 422 | map every slot in the review before applying |
| `rollback_incompatible` | 409 | choose a different prior release, or promote forward instead |
| `head_revision_stale` | 412 | read the deployment head again and echo the revision it returns |
| `connection_not_eligible` | 403 | grant the connection for this environment and purpose, or pick an eligible one |

<Details summary="Why a stale provider test is a separate answer from a missing one">

They have different recoveries and different meanings. Nothing recorded means the
connections have not been exercised at all. A stale one means somebody did the work
against a different configuration, so the evidence exists and describes something else.
Folding both into one word would send half the callers to the wrong fix.

</Details>

## Review and apply from the CLI

Save a plan containing the `source` selector (including its `environment_id`) and
explicit destination `bindings` to `promotion-review.json`. The path environment is
always the destination:

```console
$ tokentriage cloud gateway promote review --base-url https://app.example.com \
    --org org_… --workspace t_… --app app_… --gateway gw_… \
    --env env_qa --plan-file promotion-review.json --json
```

Read every blocker and mapped connection context. For apply, save the same source and
bindings with each binding's reviewed `expected` context, plus
`expected_mandatory_generation`, `expected_draft_revision`, and
`expected_head_revision` from the destination review and deployment head. All three
must be present; zero is meaningful for a destination never deployed before.

```console
$ tokentriage cloud gateway promote apply --base-url https://app.example.com \
    --org org_… --workspace t_… --app app_… --gateway gw_… \
    --env env_qa --plan-file promotion-apply.json --yes --json
```

`--yes` is required even with `--json`. Apply writes the destination draft and desired
release; inspect the head afterwards to establish that it was actually applied.
A stale revision requires a new review, not replaying the old plan. Neither command
executes a provider request, and neither bypasses protected-deployment permissions or
a required fresh provider test. Management-key authorization is resolved for each request.

## What this page does not cover

- Editing the configuration you are promoting: [Configure, test and promote a
  gateway](/docs/build/gateway-builder).
- The document, the operation table and every slug's status: [Gateway configuration
  reference](/docs/reference/gateway-configuration).
- Creating and testing the connections a slot binds: [Add a saved provider
  connection](/docs/build/provider-connections).

<NextSteps items={[
  { title: 'Configure, test and promote a gateway', href: '/docs/build/gateway-builder', description: 'The builder flow that produces the configuration this page moves.' },
  { title: 'Gateway configuration reference', href: '/docs/reference/gateway-configuration', description: 'The document, the operations and every named refusal with its status.' },
  { title: 'Add a saved provider connection', href: '/docs/build/provider-connections', description: 'The connections a destination slot maps to, and how to test one.' },
  { title: 'Who can do what in your company', href: '/docs/build/organizations-and-access', description: 'Where deploy and deploy.protected come from.' }
]} />
