Qyra

Upgrade runbook

Upgrade sequences for Kubernetes, docker compose, and automated deployments

🛠 This page is for engineering teams self-hosting their own Qyra instance. If you're on Qyra Cloud, upgrades are handled for you automatically.

Safety-gated upgrades are in Beta. Every command on this page ships in a released image and the recovery paths are supported, but we're still refining the workflow on our own instances, so some sequences and outputs will change. Open an issue if a step doesn't fit your deployment.

Upgrade safety tells you whether an upgrade is safe to roll and whether there are required stops on the way. This page tells you how to run it: the exact sequence for each deployment shape, the commands that inspect and drive migrations, and what to do when something gets stuck.

Read them in that order. Decide first, then execute.

No Qyra account is needed to upgrade safely

Nothing in this runbook requires a Qyra login, a personal access token, or an authenticated instance:

  • qyra upgrade-check reads the public release-safety index over plain HTTPS. It never contacts your instance and never asks who you are.
  • The migrate commands run inside your own Qyra container and authenticate with the same database environment variables the server already uses (PGHOST, PGUSER, PGPASSWORD, and friends). There is no second credential to provision.

That matters for air-gapped and locked-down deployments: the decision layer runs in CI with no secrets, and the execution layer runs in your cluster with credentials that already exist.

What shipped when

Every command on this page is in a released image. Version-fence your runbook accordingly:

CapabilityAvailable from
Migration lease runtime, migrate status, migrate wait, migrate unlockQyra 1.123.0
Migration run ledger, parked stateQyra 1.124.0
migrate preflightQyra 1.125.0
qyra upgrade-checkQyra CLI 1.126.0
/api/v1/livez and /api/v1/readyz probesQyra 1.129.0
/api/v1/readyz keeps working pods in service during a parked migrationQyra 1.169.1

On Qyra 1.129.0 to 1.168.x, do not point a readiness probe at /api/v1/readyz. A parked migration marked every working pod as not ready, so a single stuck migration removed the whole backend from the load balancer. Fixed in 1.169.1. See configuring health probes for the current setup.

If you are upgrading from something older, that is fine. These are properties of the image you are upgrading to, and of the CLI you run the check with. The one place the old world still shows up is rolling back across the 1.123.0 boundary.

The command surface

qyra upgrade-check

Answers the span question from the public index, with no login and no instance access. Full detail, including the JSON shape and the exit-code contract, is on upgrade safety.

qyra upgrade-check --from 1.130.0 --to 1.138.0
qyra upgrade-check --from 1.130.0 --to 1.138.0 --json

Exit 0 means the whole span is proven safe to roll. Anything else, including a version the index cannot see, exits non-zero. Both --from and --to are required and must be X.Y.Z release versions.

upgrade-check only answers forward spans. Asking it about a rollback (a --to older than --from) is an error, not a verdict. Rollback guidance is further down this page.

The migrate commands

These ship inside the Qyra image and are the runtime execution layer. Invoke them the same way the image's own entrypoint does:

pnpm -F backend migrate-production <command> [flags]

You do not need to change directory first. The image's working directory is /usr/app/packages/backend, which is where the entrypoint runs this command, and both kubectl exec and docker compose exec inherit it. The chart's migration Job runs the same command from /usr/app instead. Either directory works.

CommandWhat it does
upRuns pending Knex and Graphile Worker migrations. This is the default when no command is given, and it is what the image entrypoint and the Helm migration Job run
preflightChecks migration safety without changing the database. Also runs automatically at the start of every up
statusPrints the migration lease, the Knex ledger, and recent migration run history
waitWaits for migrations to finish. Follows a live migrator without racing it, and claims the lease itself if work is pending and no live holder exists
unlockClears migration locks for recovery, with attribution
FlagValid onMeaning
--timeout-ms <ms>up, waitHow long to wait for another process to finish before giving up. Defaults to 30 minutes, or to MIGRATION_WAIT_TIMEOUT_MS
--jsonstatus, preflightEmit the payload as a single JSON object instead of human-readable lines
--strictup, preflightPromote preflight warnings to blockers
--forceup, preflight, unlockOverride blocking preflight checks, an actively held lease, or a legacy Knex lock
--actor <identity>unlockRequired on unlock. Records who released the lock
-h, --helpallPrint usage

Running them in context

The commands need the deployment's database environment, so run them where that environment already exists.

Against a running pod:

kubectl exec deploy/qyra-backend -- \
  pnpm -F backend migrate-production status

exec bypasses the image entrypoint, so this inspects without triggering a migration.

The examples on this page assume a release called qyra, matching the helm upgrade command above. The chart names the backend Deployment <fullname>-backend and the migration Job <fullname>-migrate, where <fullname> is your release name when it already contains qyra, and <release>-qyra when it does not. fullnameOverride replaces it outright. If your release is named differently, list the real names:

kubectl get deploy,job -l app.kubernetes.io/instance=<release>

Reading preflight

Preflight probes the live database and reports one line per check, then a decision:

[RED PASS] version-path: The migration ledger structurally matches the target artifact direct-predecessor or up-to-date path
[RED PASS] postgres-version: ...
[RED PASS] migration-privileges: ...
[YELLOW WARN] long-transactions: ...
[INFO INFO] pending-migrations: ...
Preflight decision: proceed-with-warnings (0 red, 1 yellow)
CheckSeverityWhat it catches
version-pathred (yellow when the image has no baked artifact)A ledger that diverges from the image's migration files, an unreadable release-safety artifact, or unresolved required stops
postgres-versionredA PostgreSQL server older than major version 12
migration-privilegesredThe migration role cannot create in its schema, or does not own tables the pending migrations touch
held-locksyellowExisting locks on tables the pending migrations will touch
long-transactionsyellowTransactions running longer than 5 minutes against those tables
disk-headroomyellowLess than 5 GiB free, when you tell it how much there is via MIGRATION_PREFLIGHT_DISK_HEADROOM_BYTES
pending-migrationsinfoThe inventory of what is about to run, including whether each migration runs in a transaction

The decision is one of proceed, proceed-with-warnings, abort, or force-proceed. Any red failure aborts; --strict makes yellow warnings abort too; --force turns an abort into force-proceed and prints a loud override banner. A standalone preflight that aborts exits non-zero, which makes it a usable CI gate.

Reading status

status reports one of four states:

StateMeaning
idleNobody holds the lease and nothing is parked
migratingA process holds the lease and is heartbeating
staleA process holds the lease but its heartbeat has expired, 75 seconds after the last one. Another process will take over
parkedMigration failed its retries and stopped. It will not retry on this app version until a human intervenes

Alongside the state it prints the lease holder (hostname, pod, app version, current migration, last heartbeat), the parked details if any, the completed and pending Knex migration counts, the ledger classification, and recent migration runs with their outcomes. Any prior unlock is recorded against the run that followed it, so the audit trail survives.

--json gives you the same payload for automation.

When you need wait

wait blocks until the database is migrated. While another process holds a live lease, it follows that process and never migrates alongside it. If migrations are still pending and the lease is unheld or has expired, wait claims the lease and runs them itself. It gives up after 30 minutes by default; change that with --timeout-ms or MIGRATION_WAIT_TIMEOUT_MS.

So wait is not a read-only command. It will not race a live migrator, but it will become the migrator when there is work to do and nobody is doing it. Use status when you only want to look.

You rarely need to run wait by hand. On the non-Job path, up already falls back to this exact behaviour when it loses the race for the lease, which is how the pod that does not migrate ends up waiting for the pod that does.

Kubernetes and Helm

This is step one, not advice. Production recovery is forward-only: there are no down-migrations to unwind a bad upgrade, so a current backup is what makes the worst case survivable.

Take a fresh backup, and confirm it restores. If you run point-in-time recovery, confirm the window covers the whole upgrade.

Read the release notes for every release you are crossing, then check the span:

qyra upgrade-check --from 1.130.0 --to 1.138.0

Green means upgrade.mode: RollingUpdate is advised. Anything else means upgrade.mode: Recreate and a maintenance window. The upgrade check supplies this decision. The chart does not fetch or calculate the verdict. If the check reports required stops, upgrade to the first stop and let it finish before continuing. See upgrade safety for how verdicts compose across a span.

Set the release-wide mode from the upgrade check. This example is for a false or unknown verdict:

upgrade:
  mode: Recreate
migrationJob:
  enabled: true

Set upgrade.mode: RollingUpdate for a true verdict. Leave upgrade.mode empty to preserve the legacy per-component Deployment strategies. When the mode is empty and a legacy backend or enabled worker strategy is Recreate, the chart still uses the shutdown barrier with migrationJob.enabled.

Chart 2.16.284 and later supports upgrade.mode and the automatic shutdown barrier. Chart 2.16.283 and earlier needs the manual fallback in the next step.

With chart 2.16.284 or later, migrationJob.enabled: true, and upgrade.mode: Recreate, the chart automatically removes the release-managed HPA, scales all database-capable Qyra workloads to zero, waits for their pods to terminate, and then runs the pre-upgrade migration Job. Do not scale workloads manually for this path.

Helm restores the configured replicas and release-managed HPA after a successful upgrade. If the shutdown, migration, or upgrade fails, the release can remain stopped. Follow recovery or manual rollback before restoring workloads.

Use this fallback with chart 2.16.283 and earlier, and for custom or manual deployments. For a false or unknown verdict with migrationJob.enabled: true, scale every Qyra application workload to zero before you run helm upgrade.

First, inventory the release-managed HorizontalPodAutoscalers (HPAs). Remove them, or suspend them if your platform supports that, before you scale workloads. An active HPA can immediately scale a deployment back up:

kubectl get hpa -l app.kubernetes.io/instance=qyra
kubectl delete hpa -l app.kubernetes.io/instance=qyra

Then list the workloads for your release and scale the backend and every enabled worker deployment to zero:

kubectl get deployments -l app.kubernetes.io/instance=qyra
kubectl scale deployments -l app.kubernetes.io/instance=qyra --replicas=0

Verify that all Qyra application pods have terminated before continuing. Do not run helm upgrade while any of those pods are Running or Terminating:

kubectl get pods -l app.kubernetes.io/instance=qyra

If old application code remains running while the migration Job changes the database schema, it can run against an incompatible schema.

Keep the migration Job enabled. Helm recreates the release-managed HPAs and restores the configured replica counts only after a successful upgrade. If the migration Job or upgrade fails, the workloads stay stopped. Follow recovery or manual rollback before you restore the HPAs or workloads.

up runs preflight automatically, so this step buys you the answer before you commit to the deploy rather than during it. Run the one-off Job on the new tag and read its report.

Worth doing when the span ships heavy migrations, when the database is large, or when you want a green light before opening a maintenance window.

Bump image.tag in your values and upgrade:

helm repo update qyra
helm upgrade -f values.yml qyra quanvio/qyra

With migrationJob.enabled: true, the chart runs migrations in a pre-install,pre-upgrade hook Job and the backend pods then start without migrating, so replicas never race for the lock. This is the recommended setup for any multi-replica deployment. For a false or unknown verdict, chart 2.16.284 and later runs the automatic shutdown barrier; chart 2.16.283 and earlier uses the manual fallback. Without the Job, the pods migrate at startup and the lease runtime arbitrates between them: one pod wins and migrates, the rest wait.

Follow the migration:

kubectl logs -f job/qyra-migrate     # when migrationJob is enabled
kubectl exec deploy/qyra-backend -- \
  pnpm -F backend migrate-production status

Then confirm the instance is actually ready. On 1.129.0 and later, /api/v1/readyz returns 200 only when the schema gate has passed and the migration run ledger is clean:

curl -sS -o /dev/null -w '%{http_code}\n' https://qyra.example.com/api/v1/readyz

A 503 carries a reason: schema_pending (migrations still outstanding), migration_parked (a migration failed and stopped), migration_ledger_unavailable, or db_unavailable. /api/v1/livez answers without touching the database, which is why it is the right liveness probe and the wrong readiness signal.

/api/v1/health also answers, but it queries the database on every call. That makes it a fine manual check to run after a deploy, and a poor probe, since a brief database blip fails it on every pod at once. Worker pods serve their own handler at this same path, backed by in-memory worker state rather than the database, and workers do not serve /api/v1/readyz at all. For which endpoint belongs on which probe, see the production deployment checklist.

Confirm the version too, then upgrade the Qyra CLI to match.

Redeploy the previous image tag. Code rollback is the supported mitigation: it takes the new code out of service while leaving the migrated schema in place, which is the safe direction. Read rolling back before you reach for a database rollback, which is a different and much heavier operation.

Docker compose

A single-container compose deployment has no zero-downtime upgrade path. Recreating the container stops the old version, boots the new one, and runs migrations before the server accepts traffic. A green rollingUpdateSafe verdict does not change that: it certifies that old and new code may overlap, and compose never overlaps them. Plan for a few minutes of downtime, more if the release ships heavy migrations.

Step one here too, for the same reason. If your Postgres runs in the compose stack, back up the volume as well as the database.

qyra upgrade-check --from 1.130.0 --to 1.138.0

Respect required stops: upgrade to the stop, let it come up cleanly, then continue.

Pull the new image first, then run preflight against it without letting the entrypoint migrate:

docker compose pull qyra
docker compose run --rm --entrypoint pnpm qyra \
  -F backend migrate-production preflight

Pin the new tag (or pull it, if you track a floating tag), then recreate:

docker compose pull qyra
docker compose up --detach --remove-orphans

The new container runs migrations on the way up, so the server is unavailable until they finish. Follow along with docker compose logs -f qyra.

docker compose exec qyra \
  pnpm -F backend migrate-production status
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/readyz

Expect idle with no pending migrations, and 200.

Automated upgrades

The Qyra repository ships a generic reference automation at examples/upgrade-automation: a GitHub Actions workflow plus two composite actions that keep a deployment on the newest release the public safety gate can reach. It is deliberately generic, sends no telemetry, and keeps all of its evidence in your own repository. Copy it and point it at the file that pins your image tag.

The loop it implements is the sequence to copy even if you build your own:

Schedule, manual dispatch, or a repository_dispatch event when a release lands. These are detection mechanisms only. There is no upgrade window and no veto delay: a release is considered as soon as a trigger notices it.

Read the currently pinned version, then run qyra upgrade-check against the public index to pick the newest green-reachable target. Required stops resolve hop by hop, so the automation steps to a stop rather than over it, and never crosses a red break silently. Unknown or incomplete safety data fails closed and retries on the next run.

The bump lands as a pull request carrying the full verdict JSON, so the evidence for the decision is attached to the change that acts on it.

  • Green verdict: auto-merge, zero-touch. Nobody is asked to approve a machine-verified safe hop.
  • Not green: hold the pull request and notify a channel with a plain explanation of what stopped it. Yellow and unknown both count as not green.

Merging the pin triggers your existing deployment workflow. The automation does not deploy; it drives the thing that does.

Poll /api/v1/readyz until it returns 200 and the served version matches the version you pinned. Require three consecutive green polls, inside a configurable budget that defaults to about 20 minutes. One green poll can catch an old pod that has not been replaced yet.

If verification fails, freeze: open a freeze issue, escalate to the channel, and stop planning further upgrades until a human closes it. There is no auto-rollback. Recovery is forward-only, and an automation that rolls back unattended is an automation that can undo a migration nobody watched.

Pass a checked mode to Helm

The reference automation only promotes green-reachable targets. Use the route below when your deployment pipeline intentionally upgrades to a checked target that can require Recreate. The chart does not fetch the verdict. The pipeline passes the checked mode to Helm.

set -euo pipefail

: "${CURRENT_VERSION:?Set CURRENT_VERSION}"
: "${TARGET_VERSION:?Set TARGET_VERSION}"
: "${TARGET_IMAGE_TAG:=$TARGET_VERSION}"
: "${VALUES_FILE:=values.yml}"

version_gte() {
  local current_major current_minor current_patch minimum_major minimum_minor minimum_patch
  IFS=. read -r current_major current_minor current_patch <<<"$1"
  IFS=. read -r minimum_major minimum_minor minimum_patch <<<"$2"
  if (( 10#$current_major != 10#$minimum_major )); then
    (( 10#$current_major > 10#$minimum_major ))
    return
  fi
  if (( 10#$current_minor != 10#$minimum_minor )); then
    (( 10#$current_minor > 10#$minimum_minor ))
    return
  fi
  (( 10#$current_patch >= 10#$minimum_patch ))
}

set +e
CHECK_JSON="$(qyra upgrade-check --from "$CURRENT_VERSION" --to "$TARGET_VERSION" --json)"
CHECK_EXIT=$?
set -e

if ! jq -e \
  --arg from "$CURRENT_VERSION" \
  --arg to "$TARGET_VERSION" \
  '
    (.fromVersion == $from) and
    (.toVersion == $to) and
    (.safe | type == "boolean") and
    (.verdict == true or .verdict == false or .verdict == "unknown") and
    (.requiredStops | type == "array" and all(.[]; type == "string")) and
    (.minPreviousVersion == null or (.minPreviousVersion | type == "string")) and
    (.missingRanges | type == "array" and all(.[]; type == "object" and (.afterVersion | type == "string") and (.beforeVersion | type == "string")))
  ' >/dev/null <<<"$CHECK_JSON"; then
  printf '%s\n' 'upgrade-check returned no usable verdict; aborting.' >&2
  exit 1
fi

MINIMUM_VERSION="$(jq -r '.minPreviousVersion // empty' <<<"$CHECK_JSON")"
if [[ -n "$MINIMUM_VERSION" ]] && ! version_gte "$CURRENT_VERSION" "$MINIMUM_VERSION"; then
  printf 'Current version %s is below the minimum direct-upgrade version %s.\n' "$CURRENT_VERSION" "$MINIMUM_VERSION" >&2
  exit 1
fi

EARLIEST_INTERMEDIATE_STOP="$(jq -r --arg target "$TARGET_VERSION" '
  [.requiredStops[] | select(. != $target) | {version: ., parts: (split(".") | map(tonumber))}]
  | sort_by(.parts)
  | .[0].version // empty
' <<<"$CHECK_JSON")"
if [[ -n "$EARLIEST_INTERMEDIATE_STOP" ]]; then
  printf 'Upgrade first to required stop %s, then check the next hop.\n' "$EARLIEST_INTERMEDIATE_STOP" >&2
  exit 1
fi

REQUIRED_STOP_COUNT="$(jq -r '.requiredStops | length' <<<"$CHECK_JSON")"
if [[ "$REQUIRED_STOP_COUNT" != "0" ]]; then
  STOP_TARGET_VERDICT="$(jq -r --arg target "$TARGET_VERSION" '
    if .requiredStops | length == 1 and .[0] == $target then .verdict else "invalid" end
  ' <<<"$CHECK_JSON")"
  case "$STOP_TARGET_VERDICT:$CHECK_EXIT" in
    true:1) UPGRADE_MODE=RollingUpdate ;;
    false:1|unknown:1) UPGRADE_MODE=Recreate ;;
    *)
      printf '%s\n' 'The required-stop target has an unusable verdict; aborting.' >&2
      exit 1
      ;;
  esac
else
  CHECK_STATE="$(jq -r '[.safe, .verdict] | @tsv' <<<"$CHECK_JSON")"
  case "$CHECK_STATE:$CHECK_EXIT" in
    $'true\ttrue:0') UPGRADE_MODE=RollingUpdate ;;
    $'false\tfalse:1'|$'false\tunknown:1') UPGRADE_MODE=Recreate ;;
    *)
      printf '%s\n' 'upgrade-check returned an unusable result; aborting.' >&2
      exit 1
      ;;
  esac
fi

helm upgrade -f "$VALUES_FILE" \
  --set-string upgrade.mode="$UPGRADE_MODE" \
  --set-string image.tag="$TARGET_IMAGE_TAG" \
  qyra quanvio/qyra

--set-string overrides any static upgrade.mode in the values file. Derive the mode for every upgrade. An earlier required stop is a separate target. When requiredStops is exactly [TARGET_VERSION] and the minimum version is satisfied, true selects RollingUpdate; false or unknown selects Recreate. A current version below minPreviousVersion aborts the direct hop. Malformed output, an execution failure, or a fetch failure has no usable verdict and aborts the pipeline.

Auto-apply when green, hold when not. A proven-safe hop is exactly the case where human review adds latency and no information; everything else is exactly the case where it adds both. Keep the freeze switch manual and obvious, so disarming upgrades during an incident is one action rather than a code change.

Recovery

The migration Job failed

On the migrationJob.enabled path, a failed upgrade shows up as a failed Job. Read its logs before you reach for anything else. The migration's own error says what broke; the commands below only tell you the state it left behind.

kubectl logs job/qyra-migrate
kubectl describe job/qyra-migrate

The Job retries before it gives up. migrationJob.backoffLimit defaults to 10, so Kubernetes marks the Job failed only after 11 attempts. Each attempt runs in its own pod, so kubectl logs job/... shows you one of them and kubectl get pods shows the rest.

Those logs delete themselves. migrationJob.ttlSecondsAfterFinished defaults to 100, so Kubernetes removes the Job about 100 seconds after it finishes — failed or succeeded — and the pod logs go with it. If you are debugging upgrades, raise that value in your values file, or ship the Job's logs off the cluster before the window closes.

If you missed the window, the database still has the durable record. migrate status reports the parked details and recent migration runs with their outcomes, and that record outlives the Job object.

To retry after you have fixed the cause, re-run helm upgrade to recreate the hook Job. See what resumes on its own.

A migration is stuck

Start by looking, not by fixing:

kubectl exec deploy/qyra-backend -- \
  pnpm -F backend migrate-production status
  • migrating with a recent heartbeat: it is working. Migrations on large tables can take a long time. Leave it alone.
  • stale: the holder died. The lease expires 75 seconds after its last heartbeat, and another process takes it over automatically. No action needed in most cases.
  • parked: the migration failed its retries (three attempts with backoff) and stopped deliberately. The same app version will refuse to retry, which is what stops a crash-looping pod from hammering a half-applied migration. Fix the cause, then deploy a fixed version, or unlock with attribution and retry.

Do not edit the knex_migrations_lock table by hand on 1.123.0 and later. The lease runtime holds locks that live migrations legitimately own, and clearing them manually can let a second migrator start on top of the first. Use migrate status to inspect and migrate unlock to release.

Releasing a lock

kubectl exec deploy/qyra-backend -- \
  pnpm -F backend migrate-production unlock --actor "alex@example.com"

--actor is mandatory and is recorded against the next migration run, so an unlock is always attributable afterwards.

unlock refuses, by design, when the lease is actively held by a live process, or when a pre-lease Knex lock is still held. Both refusals mean "something may still be running". Terminate the holder first. Only then reach for --force, which overrides the refusal.

After an unlock: what resumes on its own

DeploymentBehaviour
Kubernetes, pods migrating at startupSelf-resumes. The waiting followers re-race for the freed lease and one of them takes over
Kubernetes with migrationJob.enabledMay need re-triggering. If the hook Job exhausted its backoffLimit, nothing is left to claim the lease. Re-run helm upgrade to recreate the hook Job
docker composeNeeds a container restart: docker compose restart qyra

Rebuilding an instance from scratch

Three things are needed to stand an instance back up, so keep all three recoverable:

  • the Postgres backup,
  • the QYRA_SECRET (it decrypts data at rest — losing it means losing access to encrypted data), and
  • your Helm values.

Rolling back

Rolling back means redeploying an older image. It does not unwind the database, and Qyra does not run down-migrations in production.

  • Back up first. Always, and before the upgrade rather than after you need it. A backup restore is the only path that undoes a schema change, and it costs you everything written since the snapshot.
  • Prefer small spans. One release back is a decision. Ten releases back is an archaeology project. Frequent, small upgrades keep the rollback target close.
  • Roll back promptly. Schema compatibility is not data compatibility. The new version may have written values the old code mishandles or cannot read, and that risk grows every hour the new version serves traffic. A rollback ten minutes in is a very different proposition from one ten days in.
  • upgrade-check will not help here. It answers forward spans only; reverse spans are an error, not a verdict. Use the guidance on this page instead.

The 1.123.0 fence

The image you roll back to determines what happens when it meets a database that is ahead of it:

  • 1.123.0 and later: the migrate command classifies the ledger itself. A database carrying migrations the image does not have is recognised as database-ahead and the image starts normally. ALLOW_MISSING_MIGRATIONS is a deprecated no-op on this path and logs a warning saying so.
  • Before 1.123.0: the image validates the migration directory at boot and treats any database-only migration as a corrupt migration directory. It will refuse to start. Set ALLOW_MISSING_MIGRATIONS=true on that deployment so it can start against the newer database.

So a rollback from 1.130.0 to 1.124.0 needs nothing extra, while a rollback from 1.130.0 to 1.122.0 needs ALLOW_MISSING_MIGRATIONS=true.

Migration batch granularity

The lease runtime applies each migration as its own Knex batch, rather than grouping a whole deploy into one batch as stock Knex does. That changes the granularity of the development-tooling rollback: knex migrate:rollback unwinds one migration per invocation, not one deploy per invocation.

This matters mid-incident, when someone reaches for a rollback expecting a whole deploy to come off in one command. It will not. Production recovery remains forward-only regardless.

For contributors

If you write migrations, the safety verdict this runbook depends on is generated from declarations in the migration files themselves. A migration containing a detected breaking operation must declare it in the same file:

export const breaking = {
    reason: 'old pods read legacy_column',
    requiredStop: true,
};

Raw SQL that the static lint cannot classify needs an explicit export const classification = { kind: 'safe' | 'breaking', reason: '...' }. Declaring a break is not a way to make CI pass: it flips the release to not rolling-safe and advises every self-hosted deployment to use Recreate. Try an expand-only redesign first.

The full rules, including the idempotency contract for transaction: false migrations and the down() requirements, live in packages/backend/src/database/migrations/CLAUDE.md in the Qyra repository.