---
title: "Run migrations safely"
summary: "How to declare a migration job, what data_effect controls, how job result evidence is captured and verified, and how the migration-backup policy gate works."
description: "Migration jobs, data effects, result evidence, and the backup-report gate."
status: shipped
read_when:
  - "Adding a schema migration to a release"
  - "Understanding why a deploy halted before workload replacement"
  - "Configuring require_migration_backup for an environment"
---
## Declare the job

```yaml
workloads:
  migrate:
    role: job
    image: ghcr.io/acme/shop:1.4.0
    command: ["./bin/migrate"]
    data_effect: migration
    when: pre_release
    needs: [{name: postgres, condition: healthy}]
```

`data_effect` is **required** on a job, and it is what the rollback and abort
gates read:

| Value | Meaning |
| --- | --- |
| `none` | Touches no durable data. Rollback is unconstrained. |
| `migration` | Changes schema or data in a way a rollback may not undo. |
| `destructive` | Removes data. |
| `unknown` | You cannot state it. Treated as the most cautious case. |

`when` decides when it happens: `manual` (default), `pre_release`, or
`post_release`.

## Report what actually happened

A job can write JSON or `key=value` data to `$OB_RESULT_FILE` using the
`onebox.run/job-result/v1alpha1` protocol. Provider-aware evidence records
`changed`, `provider`, and ordered `before_revisions` / `after_revisions`.

```sh
#!/bin/sh
./bin/migrate
cat > "$OB_RESULT_FILE" <<EOF
{"changed": true, "provider": "atlas", "after_revisions": ["202607130001"]}
EOF
```

> **A missing result is not a silent pass**
>
> A missing or invalid result from a migration becomes `changed=unknown` and
> **halts before workload replacement**, unless a strong or break-glass local confirmation
> authorized that exact plan. Atlas results must extend history without rewriting
> it.

## Verify the revisions

```yaml
verifications:
  - migration_revisions:
      job: migrate
      provider: atlas
      applied_revisions: ["202607130001"]
```

This binds the expected provider and applied revisions to the evidence captured
during the release, so a migration that silently applied something else fails
verification instead of activating.

## Gate on a backup report

An environment can require a plan-bound report about an existing backup before
a release with migration risk:

```yaml
environments:
  production:
    policy:
      require_migration_backup: true
      migration_backup_maximum_age: 24h
      require_migration_restore_test: true
      migration_backup_key_material: [production-kms-key]
```

> **This does not make Onebox take a backup**
>
> It requires a **secret-free report bound to the exact plan**. The report is an
> operator or tool assertion about observed backup results; it is not independent
> proof. Onebox validates the report contract and timing, but does not create,
> store, or independently verify the backup.

Write the plan-bound template, fill its placeholders from real backup-system
results, then bind that exact report into the local confirmation and execution:

```sh
ob plan \
  --out ob-plan.json \
  --backup-report-out ob-backup-report.json

# Fill ob-backup-report.json from the backup system's actual results.
ob approve \
  --plan ob-plan.json \
  --backup-report ob-backup-report.json \
  --out ob-approval.json

ob deploy \
  --plan ob-plan.json \
  --approval ob-approval.json \
  --backup-report ob-backup-report.json
```

### The backup report

The report template is projected directly from the plan, with protected
resources already filled in and deliberately invalid `REPLACE-...` values. It
uses `onebox.run/backup-report/v1alpha1` and records artifact, integrity,
restore-test, and key-usability observations — never backup bytes or secrets.

| Field | What it holds |
| --- | --- |
| `schema_version` | `onebox.run/backup-report/v1alpha1` |
| `plan_digest`, `operation_digest` | exact executable-plan bindings copied by Onebox |
| `application`, `environment`, `server` | exact execution target copied by Onebox |
| `reported_by`, `reported_at` | bounded reporter label and RFC 3339 observation time |
| `resources[].resource` | copied from the plan's `migration_backup.resources` |
| `resources[].backup_id` | your backup system's identifier for the artifact |
| `resources[].created_at` | RFC 3339 time the backup was taken |
| `resources[].integrity` | `artifact_digest`, `method`, `validated_at` |
| `resources[].restore_test` | `state` is `passed` or `not_tested`. `passed` **requires** `method`, `tested_at` and a lowercase `sha256:` `validation_digest`; `not_tested` refuses all three |
| `key_material[]` | one per entry in the plan's `required_key_material`: `name`, `backup_id`, `created_at`, `integrity`, and `usability` (`method`, `validated_at`, `validation_digest`) |

Unknown fields are refused, so a typo is named rather than ignored.

## When you have to go anyway

```sh
ob deploy --plan ob-plan.json --approval ob-approval.json \
  --override-migration-backup "incident reason"
```

An audited override requires the exact plan's strong or break-glass local
confirmation. It is recorded, not silent.

## Approval ceremony

When approval policy is enabled, migrations and unknown data effects use the
**strong ceremony**, where the operator types the release ID. That is the
difference between confirming a routine deploy and confirming one that may not be
reversible.

## Release-wide policy

```yaml
deployment:
  migration_policy: manual   # manual · auto · expand-only
```

`expand-only` is your promise, not a check Onebox performs. It declares that
every migration in this project is additive — that a release running the old
code against the new schema still works. Onebox never inspects your SQL; it
takes the declaration and, on the strength of it, keeps the rollback gate
**open** after a `data_effect: migration` job runs, so a failed verify can roll
back automatically instead of halting.

That makes it the right setting for a genuinely expand-only workflow and the
wrong one everywhere else. Under `manual` or `auto`, a migration job closes the
gate: recovery stops and asks you, because rolling the application back over a
schema it can no longer read is worse than staying put.

> **It relaxes a gate, it does not add one**
>
> Nothing about `expand-only` prevents a destructive migration from running. If
> you set it and then ship a `DROP COLUMN`, auto-rollback will happily return the
> application to code that expects the column to exist.
