v2 Production Transition Runbook
| Reference Number | Version | Effective Date | Supersedes | Authority Level |
|---|---|---|---|---|
| SOP-DEP-002 | 1.16 | 2026-10-23 | 1.15 | Normative |
Important
v1.11 (2026-10-14), documentation modernization. §VIII and §X’s verification checklist cited verification routes by join_code: /admin/export-students?join_code=<owned-join-code> and POST /student/switch-class/<join_code>. Checked against the live routes — /admin/export-students takes no query parameter at all (the class comes from the admin’s session/sidebar context via _resolve_admin_class_context), and /student/switch-class/<class_id> takes class_id as its path parameter, not join_code. Corrected both to match, bringing this document in line with SOP-DEP-001 §XI, which already states the canonical route takes class_id and that join_code there is the retired alias form.
I. Purpose
Provide the explicit operator workflow for moving the current v2 branch from live-test candidate to production transition.
II. Scope
Production transition work for main after successful live testing, including Cloudflare Access gating, migration execution, post-deploy verification, rollback decision points, and operator sign-off.
III. Authority Level
Normative (SOP Tier). Subordinate to INV-CORE-000.
IV. Dependencies
INV-CORE-000_CORE_INVARIANTS.mdSOP-DB-004_Live_V2_Migration_And_Versioning_Contract.mdSOP-DEP-001_Live_Test_Runbook.md
V. Preconditions
mainis the approved deployment branch.- Live-test runbook has been executed successfully.
- Migration compliance status has been reviewed.
- Backup, maintenance window, and rollback contacts are confirmed.
- Team-owned production and pre-production database targets are identified before any migration step begins.
- Named production operator, independent verifier, and rollback approver are assigned before the maintenance window starts.
V.1 Single-maintainer limitation
This project has one maintainer. The GitHub production environment therefore has no required reviewers: its
only protection is a deployment branch policy that limits deploys to main (verified 2026-10-13). A release
dispatch is not gated by a second person’s approval in GitHub. Where this runbook refers to environment
approval (§IX.1), it means the environment’s branch policy plus the operator’s own go-ahead, recorded under the
solo-operator exception (SOP-DEP-001 §XIII); it is not independent review. Each release record states this.
When a second maintainer joins, add them as required reviewers on the production environment and remove this
subsection.
VI. Pre-Production Checklist
- Enable Cloudflare Access gating if the deployment requires a protected window.
- Verify current production backup or snapshot and test restore instructions.
- Confirm migration head state and target revision.
- Confirm operator sign-off from engineering and operations.
- Reconfirm smoke checklist and escalation path.
- Confirm the student-setup memory store is running on the production host. The username-retention check
(FEAT-IDEN-002, SPEC-IDEN-001; memory-only under INV-ARC-018 §IX) has needed it since release
29b99b14(2026-09-29). On a new or rebuilt host, provision and verify it perinfra/student-setup/README.md, setSTUDENT_SETUP_REDIS_URLin the application environment, and restart the workers. There is no fallback: without the store, student claim and recovery setup fail closed with a 503. After the release, exercise claim and recovery setup as that README’s step 6 describes.
Current branch verification references:
- access gate: Cloudflare Zero Trust policy for the application hostname
- release workflow:
.github/workflows/release-v2.yml(manual exact-SHA release) - deployment transport: GitHub Actions connects to the production host through
Tailscale using
PRODUCTION_TAILSCALE_HOST; public-IP SSH is not the approved deployment path; - transport smoke test:
.github/workflows/tailscale-ssh-smoke-test.yml - migration safety check:
bash scripts/check-migrations.sh
The production release lineage is configured as the protected environment
variable V2_RELEASE_LINEAGE_REF. A release request MUST provide a full
40-character commit SHA that is an ancestor of that approved lineage. A branch
name or moving branch tip is not itself a release artifact.
Cloudflare Access gate verification
Before a restricted work window, confirm an unauthenticated browser reaches Access, an unauthorized identity cannot enter, and an authorized operator can reach the normal application sign-in. Verify the origin cannot be reached around Cloudflare. Health endpoints are public and need no credential; while the gate is closed the status collector reads nothing from the application (SPEC-OPS-006 1.6). Host-local probes do not traverse Access. Never record tokens in logs or deployment evidence.
At the end of the window, change the Access policy only when public access is
intended and release checks have passed. Deploying the app does not change that
policy. Remove obsolete MAINTENANCE_* settings from deployment configuration;
there is no application flag, sysadmin bypass, or query-token alternative.
VII. Upgrade Flow
- Restrict the application hostname using Cloudflare Access.
- Confirm the Access login is shown to an unauthenticated visitor, unauthorized admission is denied, and authorized operator access works.
- Confirm migration head state and run the migration safety check.
- Apply migrations.
- Deploy application code.
- Run post-deploy smoke checks before reopening the app.
VII.A Tagged release procedure
Every tagged release follows these steps in order, whether or not it carries a migration. A step is skipped only where this section says so, and the release record says which step and why. Commands run from the operator’s Mac at the repository root; database reads use read-only, aggregate-only queries with class ids, never names.
Roles are those of §V. A single operator may hold all three under the solo-operator exception (SOP-DEP-001
§XIII), whose conditions the record must show met or name as a deviation. Checks run by an AI assistant are
advisory and are recorded as such, never as the independent verifier.
Before the window
- Release PR. Move
[Unreleased]inCHANGELOG.mdunder## [X.Y.Z] - <UTC date> — <title>, and check that every PR merged since the previous tag has an entry (git log --merges vPREV..origin/main) and that every entry in the section belongs to a PR in that range (git merge-base --is-ancestor <merge> vPREVis false). Subsections appear only from this list, in this order, each once: Added, Changed, Deprecated, Removed, Fixed, Security, Investigated, no code change. Each entry also names its release-notes section in an invisible tag after its bullet,- <!-- notes: fixed|security-reliability|accessibility|internal[, highlight] --> **Headline** ...(step 13). Required checks green and review threads resolved; merge. The merge commit, now the tip ofmain, is the release SHA. - Full suite on the release SHA, locally (about four hours; CI’s run times out). It passes before dispatch. Exception: a security or data-integrity fix may be dispatched first and the suite run afterwards; the release record says so and later records the result.
- Configuration and migration checks.
flask db headsshows one head;bash scripts/check-migrations.shreports “Safe to deploy”.git diff vPREV <SHA> -- requirements.txt migrations/versionslists what is new.- Read the CHANGELOG for new required settings, and confirm by name only (never values) that production’s
/root/classroom-economy/.envhas them. - For each new migration, run any pre-check its docstring names against production, read-only.
- Host:
ssh root@cth 'cd /root/classroom-economy && git rev-parse HEAD && git status --porcelain && systemctl is-active cth-student-setup redis-server'. HEAD is the previous release; the tree is clean apart fromvenv.old/.
In the window
-
Restrict access with Cloudflare Access (§VI gate verification).
-
Backup.
bash infra/db-backup/prerelease.sh backupstops the application, takes one point into~/cth-backup/prerelease-<UTC date>/and verifies it. The source URL comes from~/cth-backup/source_url; no one types a credential. The application stays stopped until the release workflow restarts it. -
Point against live.
bash infra/db-backup/prerelease.sh countsmust reportmanifest == live. -
Restore check. The owner copies the
agekey to the clipboard and runsumask 077; pbpaste > ~/cth-backup/identity.txt. Thenbash infra/db-backup/prerelease.sh restorechecks the key againstrecipients.txt, restores into the in-memory database, runs the manifest and no-resurrection checks, deletes the identity withrm -P, closes the SSH tunnel to production, and stops the verify container. Both checks must PASS. A run abandoned beforerestorecloses the tunnel withprerelease.sh close-tunnel. -
Balance baseline, read with the application stopped. Each snapshot is compared only with the ledger rows at or below its
reconciled_through_posting_sequence(DOM-LED-001§VIII.2); later rows are pending and are reported apart:WITH s AS (SELECT class_id, seat_id, account_type, posted_balance_cents, reconciled_through_posting_sequence AS cursor FROM ledger_balance_snapshot), l AS (SELECT t.class_id, t.account_type, count(*) AS txns, coalesce(sum(t.amount_cents) FILTER (WHERE t.posting_sequence <= s.cursor), 0) AS ledger_posted_cents, coalesce(sum(t.amount_cents) FILTER (WHERE s.cursor IS NULL OR t.posting_sequence IS NULL OR t.posting_sequence > s.cursor), 0) AS pending_cents FROM ledger_transaction t LEFT JOIN s ON s.class_id = t.class_id AND s.seat_id = t.seat_id AND s.account_type = t.account_type GROUP BY 1, 2), ss AS (SELECT class_id, account_type, count(*) AS snapshots, sum(posted_balance_cents) AS snapshot_cents FROM s GROUP BY 1, 2) SELECT coalesce(l.class_id, ss.class_id) AS class_id, coalesce(l.account_type, ss.account_type) AS account, l.txns, l.ledger_posted_cents, ss.snapshots, ss.snapshot_cents, l.pending_cents, l.ledger_posted_cents IS NOT DISTINCT FROM ss.snapshot_cents AS posted_matches FROM l FULL JOIN ss ON ss.class_id = l.class_id AND ss.account_type = l.account_type ORDER BY 1, 2;posted_matchesis true in every row; keep the table, includingpending_cents, for the record. -
Tag and dispatch.
git tag -a vX.Y.Z <SHA> -m "vX.Y.Z" && git push origin vX.Y.Z, thengh workflow run release-v2.yml -f revision=<full SHA> -f lineage_ref=main. Dispatch once only: a second dispatch races the first. Watch the run through its health step.
After the release
-
§VIII verification. Host HEAD is the release SHA; the service is active;
127.0.0.1:8000/healthanswersok;/docsfollowed to its final response is 200 (curl -L), not just its 308; the scheduler has started; the journal has noERROR,CRITICAL,TracebackorWORKER TIMEOUTlines since the restart;alembic_versionis the single head; the step 8 query matches the baseline, apart from what the release itself is meant to change. The operator runs the browser checks in §VIII. -
Reopen by changing the Access policy (§VI).
-
Release record PR.
docs/ops/audits/DEPLOY_<UTC date>_<short SHA>.md, in the shape of the previous one: identity table, contents, migrations, §VI checks (backup stem,taken_at, revision,artifact_sha256, both restore results), baseline, release run, §VIII results, rollback, follow-ups. Each follow-up is an issue on the CTH Backlog project, opened before the record merges, and the record links it rather than describing open work in prose. In the same PR, update the version inREADME.mdandDEVELOPMENT.md, and make the first line of the CHANGELOG section exactly:Released as `<short SHA>` on <YYYY-MM-DD> at <HH:MM> UTC by release run [<run id>](<run url>). No migration.|Migration `<rev>`. Record: <record link>.<record link>is a Markdown link whose text is the record’s file name and whose target isdocs/ops/audits/DEPLOY_<date>_<sha>.md, relative to the repository root.Further sentences may follow on that line; nothing goes before it.
-
GitHub release, after step 12’s CHANGELOG is final. The notes are generated, never hand-written:
python3 scripts/release_notes.py X.Y.Z > /tmp/notes.md gh release create vX.Y.Z --verify-tag --title "$(python3 scripts/release_notes.py X.Y.Z --title)" --notes-file /tmp/notes.mdThe notes are grouped for readers: Highlights (entries tagged
highlight), then Fixed, Security & Reliability, Accessibility and Internal Changes, each listing one headline per entry with the full entries collapsed, and the deployment record and release run linked at the end (owner, 2026-10-22). The script refuses a section out of shape (heading,Released asline, subsection names and order), an entry without a known notes tag, and a tag that does not point at theReleased asSHA. Fix the CHANGELOG, not the notes.--rawprints the section verbatim.
Untagged hotfixes follow steps 3-12 and record any step they skip; they have no tag, so no step 13.
VIII. Post-Migration Verification
Verify on the host:
- app boots normally
/admin/login,/student/loginand/docsload- no unexpected migration head drift is present
Then the browser validation: SOP-DEP-001B for a release (the features the release changes, features connected
to those changes, and features with a known problem), or SOP-DEP-001A for a first deployment or a release the
owner designates as full. Features outside a release’s scope are covered by the release suite, not checked in the
browser.
IX. Rollback Policy
- If migration fails before app reopen, rollback to the pre-deploy backup/snapshot.
- If post-deploy smoke checks fail and cannot be corrected within the maintenance window, rollback.
- If migration is structurally successful but business behavior is incorrect, keep Cloudflare Access restricted until rollback or fix-forward is approved.
IX.1 Exact-SHA Release Failure Handling
The release workflow does not automatically downgrade production. A failed release MUST remain in an operator-controlled state until the production operator and rollback approver select rollback or fix-forward.
| Failure point | Required action |
|---|---|
| Lineage, SHA, environment approval, Tailscale, or SSH preflight | No production mutation is authorized; correct the release request or transport configuration. |
| Migration fails before restart | Keep the existing service running if possible, keep the maintenance window controlled, capture migration output, and stop for operator review. Do not retry blindly. |
| Migration succeeds but restart fails | Keep the environment controlled, inspect service logs/status, and restore service using the approved operator procedure. Do not run an automatic downgrade. |
Restart succeeds but /health fails |
Keep Cloudflare Access restricted, capture the exact deployed SHA and health/service evidence, then choose approved fix-forward or restore-from-backup. |
| Non-backward-compatible migration requires rollback | Rollback MUST use the verified backup/restore procedure and named rollback approval; alembic downgrade is not an automatic production recovery mechanism. |
Every failure record MUST include the requested SHA, approved lineage ref, migration result, service state, health result, and operator decision.
X. Transition Record Template
Use this template for the production transition record:
Production Transition Record
Date:
Branch:
Commit SHA:
Production operator:
Independent verifier:
Rollback approver:
Maintenance window:
Pre-window confirmation
- Backup/snapshot reference:
- Restore procedure verified:
- flask db heads:
- flask db current:
- bash scripts/check-migrations.sh:
Cloudflare Access gate
- Enable action:
- Access login, denial, and operator admission verified:
Deployment
- Migration result:
- Application deploy result:
Post-deploy verification
- /admin/login:
- /student/login:
- /docs:
- POST /admin/current-class:
- /student/add-class:
- POST /student/switch-class/<class_id>:
- /admin/export-students:
- /verify/hallpass/<teacher_public_token>:
Outcome
- Reopen time:
- Go/no-go:
- Rollback needed:
- Operator sign-off:
- Independent verifier sign-off:
- Rollback approver sign-off:XI. Sign-Off Record
Record:
- deployment time
- operator
- branch SHA
- migration result
- smoke-check status
- reopen time
- rollback decision
XII. Deferral Boundary
- This runbook is limited to launch-critical transition steps and operator records.
- Broader operational taxonomy changes and route-family cleanup are outside this runbook and are tracked as issues on the CTH Backlog project.
- Version 1.5 deferred them until after two documents. One,
SPECS/V2_ADMIN_ROUTE_REFACTOR.md, was never written. The other,MAP-CLASS-002(archived 2026-09-28), described theclass_id-first model that INV-ARC-019 and DOM-IDEN-001 now govern. Version 1.6 changes no procedure in this runbook.
XIII. Amendment
Revisions to this document require incrementing the version number, updating the Effective Date, and populating the Supersedes field. Subordinate to CORE changes.
Version 1.16 (2026-10-23): §VI Cloudflare Access gate verification: health endpoints are public and need no credential; the status collector reads nothing from the application while the gate is closed (DOM-OPS-001 2.14, SPEC-OPS-006 1.6). Owner ruling, 2026-10-23.
Version 1.15 (2026-10-22): §VII.A step 12: each release-record follow-up is filed as an issue on the CTH Backlog project and linked from the record; §XII names the project instead of the archived post-launch tracker. Owner, 2026-10-22: the backlog moved to GitHub Issues.
Version 1.14 (2026-10-22): §VII.A steps 1 and 13: each CHANGELOG entry carries an invisible release-notes tag (fixed, security-reliability, accessibility, internal, optionally highlight); scripts/release_notes.py renders Highlights, Fixed, Security & Reliability, Accessibility and Internal Changes with full entries collapsed and the deployment record linked last, and refuses an untagged entry. Owner, 2026-10-22: the verbatim notes were unreadable. Follows 1.13 (#1632).
Version 1.13 (2026-10-22): §VII.A step 7: prerelease.sh restore also closes the SSH tunnel to production, and an abandoned run closes it with prerelease.sh close-tunnel. The tunnel had never been closed: the v2.2.4 one was still listening six days later and blocked the v2.3.0 backup.
Version 1.12 (2026-10-22): §VIII’s fixed browser list is replaced by SOP-DEP-001B for releases and SOP-DEP-001A for a first deployment or a release the owner designates as full; the host checks stay. Owner rulings, 2026-10-22 (v2.3.0).
Version 1.10 (2026-10-13): adds §V.1, the single-maintainer limitation: the production environment has no
required reviewers, and environment approval means the branch policy plus the operator’s recorded go-ahead under
the solo-operator exception. Owner decision of 2026-10-13 (Semgrep triage, priority 4). No procedure changes.
Version 1.9 (2026-10-12): adds §VII.A, the step-by-step tagged release procedure, and
infra/db-backup/prerelease.sh, which it runs. Owner rulings 2026-10-12: every tagged release takes the backup
and restore check, with or without a migration; the full suite passes before dispatch unless a security or
data-integrity fix goes first. Release notes come only from the CHANGELOG section, generated by
scripts/release_notes.py in a fixed shape (owner request 2026-10-12; the first conforming section is [2.2.3]).
v2.0.0 through v2.2.3 each followed a different variant of this procedure.
Version 1.8 (2026-10-06): §IV dependencies: archived SOP-DB-009 is replaced by SOP-DB-004 (classification, evidence and rollback rules), and SOP-DEP-016, which does not exist, is removed; rollback decisions are SOP-DEP-001 §XIV and SOP-DB-004 §IX.4. No procedure changes.
Version 1.7 (2026-09-30): §VI adds item 6, the student-setup memory store that the username-retention check (#1442, released 2026-09-29) requires on every production host.
Last updated: 2026-10-22