Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Promote between environments

Promotion moves one service from one instance to another: dev to QA to production, or a template instance out to every region. The unit is a package: a service’s channels, their workflows, and the connectors, plugins and models those workflows reference, as one versioned artifact. Five verbs, one artifact file, and no shared storage between instances; the file is the only thing that travels.

Before you start

You need orion-server on the machine that runs the verbs, and network access to each instance. Every instance with admin_auth.enabled = true, which production should be, needs an admin token:

export ORION_ADMIN_TOKEN=…    # sent as the admin bearer token

Every subcommand except lint calls an instance’s admin API, and an unset token means every call is refused. lint needs no server and no token, which is what lets it run as a CI gate on a runner holding no production credentials.

The five verbs

Export from the source, lint offline, plan against the target, apply, and diff:

orion-server package export -s https://dev.orion.internal \
  --tag pkg:payments --name payments --version 1.4.0 -o payments-1.4.0.json

orion-server package lint  -f payments-1.4.0.json
orion-server package plan  -s https://qa.orion.internal   -f payments-1.4.0.json
orion-server package apply -s https://qa.orion.internal   -f payments-1.4.0.json
orion-server package diff  -s https://prod.orion.internal -f payments-1.4.0.json
VerbNeedsWritesWhat it does
exportSource instanceNothingCapture selected channels plus their closure into one versioned artifact
lintNothing; fully offlineNothingValidate entity shapes with the same validators the POST endpoints run, check closure completeness against requires, verify the content hash
planTarget instanceNothingPre-flight: receipt immutability, the exact per-entity action apply would take, requires verification, every activation gate
applyTarget instanceEverythingClaim the receipt, stage all entities, activate in dependency order, reload once, flip the receipt to applied
diffAny instanceNothingCompare the instance’s content hashes against the artifact’s; exits non-zero on drift

Use lint as the PR gate and plan as the pre-deploy gate. Use diff as the post-deploy check and as a scheduled job, because it is how you learn that production drifted from what you shipped.

Note

export is one way to obtain an artifact; orion-server compile is the other. It builds the same shape from a directory of definitions with no instance to export from, and resolves the set’s shared constants, errors and fragments on the way, which is why it exists: the admin API has no set to resolve them against. lint, plan, apply and diff cannot tell the two apart.

Select what ships

Membership is a tag. Give every entity of a service the same label when you create it:

{ "channel_id": "payments", "tags": ["pkg:payments"], "...": "..." }

Export selects channels, by tag or by explicit id, and computes the closure from there. Each channel brings its workflow, and each workflow brings every connector it references:

# Everything tagged pkg:payments, plus the closure
orion-server package export -s https://dev.orion.internal \
  --tag pkg:payments --name payments --version 1.4.0 -o payments-1.4.0.json

# …or hand-pick channels by id
orion-server package export -s https://dev.orion.internal \
  --channels payments,payment-refunds --name payments --version 1.4.0 \
  -o payments-1.4.0.json

A channel_call target outside the selection is not pulled in. It lands in the artifact’s requires block, on the theory that a channel you did not select belongs to somebody else’s package. plan then verifies each requirement exists and is active on the target before anything is written.

What apply does, in order

Knowing the phases is what lets you interpret a failure:

  1. Claim the receipt as staged. This is the atomic immutability check. A reused applied version with different content is refused here, and it doubles as the guard against two applies running at once.
  2. Stage every entity as a draft, in dependency order: plugins, then connectors, then models, then workflows, then channels. A package that carries plugins activates them here, reload included, before anything else is staged. A workflow’s create-time gate validates every function it names against the registry the engine is serving, so a workflow calling a plugin function cannot be staged until the plugin is active and loaded. Connector import reloads the connector registry server-side, so workflow activation later sees them.
  3. Activate in dependency order, with the reload deferred. Each activation is marked in the database but the engine is not rebuilt yet.
  4. Reload the engine once, which is also one config-epoch bump in a cluster.
  5. Flip the receipt to applied.

Two properties fall out of that ordering. However many entities the package carries, the running engine rebuilds once. A package that brings plugins rebuilds it twice: once to admit them and once for everything else. Every replica converges on the whole package, never on a half-applied one. And every call is stamped with X-Orion-Change-Context: package=<name>@<version>, so the audit trail filters back into the promotion that caused it.

A plugin travels with its component only when the export was run with --include-artifacts. Otherwise it travels as manifest and digest, and plan refuses a target that does not already hold that digest, naming the flag. A plugin the source itself no longer serves at the digest the workflows ran against is recorded under requires.plugins. plan checks the target has it active, the same boundary a channel_call outside the selection makes.

A model never travels as bytes. It is carried as its manifest and its artifact reference: the storage connector, the key and the digest. The target fetches the object through its own connector of that name when it admits the model. Every such connector the package does not carry is recorded under requires.storage and checked by plan and apply before anything is written. A model a workflow names that the source does not serve active is recorded under requires.models, which plan checks the target serves. Activation is refused until a node has admitted the artifact, so apply waits for the target’s verdict on each model it staged. It polls GET /models/{id} with progress on stderr and a 900 s ceiling, then activates the model ahead of the workflows that name it.

When an apply fails midway

The deferred reload is what makes a partial apply safe. Entities activate in the database while the running engine is still serving the previous estate, so until phase 4 live traffic is unaffected:

Fails duringTarget stateLive trafficRecovery
1 — receipt claimNothing writtenUnaffectedFix the cause and re-run. A reused applied version needs a version bump.
2 — stagingSome entities have new draft versions; nothing activatedUnaffected; drafts serve nothingFix the artifact and re-run apply. A staged receipt may be re-claimed.
3 — activationEntities before the failure are active in the database; those after are still drafts. The engine has not been reloadedUnaffected; the old engine is still servingFix the cause and re-run apply (it is idempotent), or POST /engine/reload to serve what did activate
4 — reloadEvery entity is active in the databaseUnaffected until a reload happensPOST /api/v1/admin/engine/reload
5 — receipt flipThe estate is live and correct; the receipt still reads stagedCorrectRe-run apply; it converges the receipt

In every case the receipt stays staged, which is what makes a corrected re-run at the same version legal. Only an applied version is content-immutable.

Tip

A failed apply that you cannot fix at once is not an emergency: nothing is serving the half-applied estate. Leave it staged, fix the artifact, and re-run.

Roll back

Re-apply the previous artifact version:

orion-server package apply -s https://prod.orion.internal -f payments-1.3.0.json

Entities roll forward carrying the older content; nothing moves backward, and the receipt history records both moves. That is the whole rollback procedure. There is no separate command, because a rollback is a promotion of something you already shipped. Keep the artifacts. A rollback you cannot perform is a rollback you do not have, and the artifact file is the only thing needed to perform one.

Verify

Confirm the target runs what you shipped, and read its receipts:

orion-server package diff -s https://prod.orion.internal -f payments-1.4.0.json
orion-cli packages list
orion-cli packages get payments    # current applied version + history

diff prints no drift and exits 0. Receipts are what enforce immutability and what make rollback mechanical. They are also the answer to “what is running here”, which a database dump cannot give you as directly.

Secrets survive the trip, if authored as references

Connector exports are masked, which is what makes them safe to commit. It also decides how a connector must be authored to be promotable at all:

Authored asExports asRe-imports?
"token": "env://STRIPE_KEY""env://STRIPE_KEY"Yes: a reference names a variable; it is not itself a credential
"token": "sk_live_...""******"No: the import is refused

The refusal is deliberate. Importing ****** would store it as a real credential and fail at the first request. It fails here instead, where you are looking at the file. lint treats an env:// reference that is unset on the machine running it as a warning rather than an error. A CI runner needs no production secrets to check a bundle.

Promote without packages

To move selected workflows or channels between instances without the package machinery, the per-kind endpoints take the same tags:

curl -s "$ORION/api/v1/admin/workflows/export?tag=payments" | jq '.data' > workflows.json
curl -s -X POST "$ORION/api/v1/admin/workflows/import?dry_run=true" \
  -H 'Content-Type: application/json' --data @workflows.json

Each /export emits exactly what its /import accepts, and each export reads inside one repeatable-read transaction, so the snapshot is consistent. You give up the closure computation, the receipt, and the single-reload apply. The on_conflict modes that govern importing over an existing estate are specified in Promoting over an existing estate.

Next steps

Last verified 14 September 2026