Harden staging and production admission

04dcd2bbf846 · AtlantisPleb · · parent 36fe3c49d6d6

Harden staging and production admission

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added docs/operations/production-cutover.md
  • modified infra/staging/main.tf
  • modified ops/ci/contracts.sh
  • modified ops/ci/staging-infra.sh
  • added ops/production/preflight.sh

Diff

5 files changed, +337 -0

docs/operations/production-cutover.md added +177

@@ -0,0 +1,177 @@

1
# Cut over production from Sarah to OpenAgents
2
3
Date: 2026-08-20
4
5
Status: Production is fenced until the isolated staging gates and database
6
rehearsal pass for one exact candidate.
7
8
This runbook replaces the Sarah release on the existing three-node production
9
fleet. It preserves the `openagents.com` load balancer and the `sarah`
10
PostgreSQL database. Treat the first OpenAgents release as a structural
11
replacement with a maintenance window. Do not use the hot-load or relup lanes
12
for this boundary.
13
14
## Current production boundary
15
16
Production currently uses these resources in `openagentsgemini`:
17
18
- Cloud SQL instance `sarah-postgres`, database `sarah`, and role `sarah_app`.
19
- Compute Engine instances `sarah-fleet-1`, `sarah-fleet-2`, and
20
  `sarah-fleet-3` in three zones.
21
- Global backend service `sarah-backend` and the existing `openagents.com`
22
  HTTPS frontend.
23
- The Sarah release as the serving application and the idle Sarah Cloud Run
24
  service as the application rollback target.
25
26
Do not rename these provider resources during the application cutover. Rename
27
them only in a later infrastructure migration after OpenAgents passes its
28
production observation window.
29
30
## Production admission conditions
31
32
Use one exact Git SHA and application image digest for every condition. Do not
33
waive a failed condition by rebuilding from another commit during the cutover.
34
35
- The exact-SHA release gate passes, including the default, browser, cluster,
36
  direct-deployment, relup, interrupted-install, rolling-replacement,
37
  infrastructure, and packaged-release stages.
38
- The dedicated project `openagents-staging-20260820` passes the isolation
39
  validator. Staging does not use a production database, service account,
40
  bucket, or secret.
41
- The candidate passes all Gate 14 cases on staging.
42
- The same candidate passes controlled failure injection and a measured
43
  48-hour soak without a redeploy.
44
- A fresh production backup restores into a disposable Cloud SQL instance.
45
  The migration-lineage bridge, all remaining migrations, candidate startup,
46
  last-known-good startup, row-count checks, and integrity checks pass there.
47
- Production Cloud SQL has daily backups, point-in-time recovery, and deletion
48
  protection enabled. Record the maintenance window if enabling point-in-time
49
  recovery restarts the primary.
50
- The latest on-demand backup finishes successfully before the writer fence.
51
- You have the exact prior production startup metadata, image digest, database
52
  backup identifier, and DNS or load-balancer rollback command.
53
54
The existing database did not have automated backups, point-in-time recovery,
55
or deletion protection enabled during the 2026-08-20 audit. Keep production
56
fenced until an operator enables and verifies all three controls.
57
58
## Rehearse the production migration
59
60
Run this procedure before scheduling the production window:
61
62
1. Create an on-demand backup of `sarah-postgres` and wait for `SUCCESSFUL`.
63
2. Restore the backup into a new disposable Cloud SQL instance. Do not attach
64
   an application, scheduler, webhook, queue consumer, or public authorized
65
   network to the copy.
66
3. Connect the exact candidate release through Cloud SQL Auth Proxy.
67
4. Run `bin/migration-lineage check`. Require `prior`, the reviewed map digest,
68
   14 baseline entries required, zero baseline entries present, and zero
69
   missing schema facts.
70
5. Record content-free counts for users, visitors, conversations, messages,
71
   turns, forge targets, forge builds, forge deploys, voice calls, recordings,
72
   machines, and coding jobs. Record only counts and integrity statuses.
73
6. Apply the bridge with the successful backup identifier:
74
75
   ```sh
76
   OPENAGENTS_STAGING_SNAPSHOT_ID='BACKUP_ID' \
77
     bin/migration-lineage --apply
78
   ```
79
80
7. Require `prior_baselined`, `changed: true`, 14 baseline entries present, and
81
   zero missing facts.
82
8. Run `bin/migrate`, then rerun `bin/migration-lineage check`.
83
9. Run foreign-key, uniqueness, orphan, and invalid-state checks. Compare every
84
   content-free count with the pre-migration count and explain each expected
85
   change.
86
10. Start the candidate against the copy with traffic, Forge deployment,
87
    scheduled work, voice admission, and SCVs fenced. Require `/health`,
88
    `/status`, PostgreSQL connectivity, and the exact revision and image
89
    digest.
90
11. Start the last-known-good Sarah release against the migrated copy with
91
    traffic and workers fenced. This proves that the additive schema preserves
92
    the application rollback boundary.
93
12. Retain a sanitized receipt and delete the disposable instance after the
94
    staging and production decisions no longer need it.
95
96
Never repair a refused lineage by editing `schema_migrations`. Restore a new
97
copy, diagnose the mismatch, and update the reviewed migration map through the
98
normal release gate.
99
100
## Prepare the production window
101
102
1. Announce a maintenance window that includes the Cloud SQL safety-control
103
   change, writer drain, migration, three-node replacement, and rollback.
104
2. Freeze merges and deployments. Verify that local `HEAD`, `origin/main`, the
105
   release-gate receipt, candidate manifest, staging report, and soak report all
106
   identify the same SHA and image digest.
107
3. Drain new voice calls, SCVs, computer jobs, Forge builds, Forge deployments,
108
   turns, and background work. Wait for active work to reach zero.
109
4. Remove every Sarah node from external readiness and prevent automatic
110
   restart with old writer configuration. Verify that no application writer can
111
   reach the database.
112
5. Create the final on-demand backup and wait for success. Record the bounded
113
   backup identifier and completion time.
114
6. Run the same lineage classification and content-free integrity checks used
115
   in rehearsal. Abort if the classification, map digest, or schema facts differ.
116
117
## Migrate and replace the fleet
118
119
Run one migration job. Do not let each application node race the migration.
120
121
1. Apply the reviewed lineage bridge with the final backup identifier.
122
2. Run `bin/migrate` under the release advisory lock.
123
3. Require the final lineage classification and every integrity check from the
124
   rehearsal receipt.
125
4. Start one OpenAgents node with public readiness removed and all high-risk
126
   admissions fenced. Require database connectivity, exact revision and image
127
   identity, configuration readiness, boot convergence, and no unexpected
128
   errors.
129
5. Start the second node and require a two-node BEAM cluster before starting
130
   the third node.
131
6. Start the third node and require `beam=3`, `raft=3`, quorum, exact source SHA,
132
   exact image digest, and three healthy load-balancer backends.
133
7. Admit internal smoke traffic. Test health, status, login, one typed turn,
134
   durable reload, memory, issues, Git clone and fetch, a read-only computer
135
   job, and operator recording playback.
136
8. Admit public traffic gradually. Watch error rate, latency, database
137
   connections, mailbox growth, process count, work queues, voice state, Forge
138
   receipts, Ra membership, and backend health.
139
9. Enable voice, computers, work, Forge deployment, and SCVs one at a time only
140
   after their production canary passes.
141
142
Do not enable the SCV production-write profile during the application cutover.
143
Keep SCVs in read-only or propose mode until the normal production observation
144
window passes.
145
146
## Abort and roll back
147
148
Abort before public traffic if migration classification, integrity, candidate
149
startup, revision identity, cluster membership, or backend health differs from
150
the rehearsal.
151
152
- Before migration, restore Sarah readiness and leave the database unchanged.
153
- After the additive migration, stop every OpenAgents writer and start the
154
  recorded Sarah image against the migrated schema. The rehearsal must prove
155
  this path before the window.
156
- If the migrated database is damaged or the Sarah compatibility check fails,
157
  restore the final backup into a new Cloud SQL instance and repoint only after
158
  verifying its identity. Do not overwrite the failed primary during incident
159
  response.
160
- Keep the old Sarah Cloud Run service and startup metadata unchanged until the
161
  production observation window completes.
162
163
After rollback, verify typed chat, authentication, voice admission state,
164
Forge ref truth, Ra membership, and database integrity. Record the first
165
failure and rollback result without credentials or private product content.
166
167
## Close the cutover
168
169
Run the full production smoke after the fleet has served stable traffic. Keep
170
the backup, prior image, prior startup metadata, and Sarah Cloud Run fallback
171
through the observation window. Then rotate transitional credentials, remove
172
the old restart path, enable deletion protection on the fleet instances, and
173
schedule provider-resource renames as a separate infrastructure change.
174
175
Do not declare production ready until the staging soak and the disposable-copy
176
migration rehearsal complete for the exact candidate. Local parity and a
177
healthy staging deploy are necessary but do not replace those conditions.
infra/staging/main.tf modified +3

@@ -495,6 +495,7 @@ resource "google_sql_database_instance" "staging" {

495 495
  deletion_protection = true
496 496
497 497
  settings {
498
    edition                     = "ENTERPRISE"
498 499
    tier                        = var.database_tier
499 500
    availability_type           = "ZONAL"
500 501
    disk_type                   = "PD_SSD"

@@ -567,6 +568,8 @@ resource "google_compute_disk" "fleet_state" {

567 568
  zone   = var.zone
568 569
  size   = var.fleet_state_disk_gib
569 570
  labels = local.labels
571
572
  depends_on = [google_project_service.required]
570 573
}
571 574
572 575
resource "google_compute_instance" "fleet" {
ops/ci/contracts.sh modified +1

@@ -8,6 +8,7 @@ cd "$repo_root"

8 8
9 9
ops/ci/reference-check.sh
10 10
sh -n rel/overlays/bin/migration-lineage
11
sh -n ops/production/preflight.sh
11 12
sh -n ops/staging/cleanup-run.sh
12 13
sh -n ops/staging/finalize-report.sh
13 14
sh -n ops/staging/new-report.sh
ops/ci/staging-infra.sh modified +8

@@ -4,6 +4,14 @@ set -eu

4 4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5 5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6 6
terraform_root="$repo_root/infra/staging"
7
terraform_data_dir=$(mktemp -d /tmp/openagents-staging-infra.XXXXXX)
8
9
cleanup() {
10
  find "$terraform_data_dir" -depth -delete
11
}
12
13
trap cleanup EXIT INT TERM
14
export TF_DATA_DIR="$terraform_data_dir"
7 15
8 16
if ! command -v terraform >/dev/null 2>&1; then
9 17
  echo "terraform is required for the staging infrastructure gate" >&2
ops/production/preflight.sh added +148

@@ -0,0 +1,148 @@

1
#!/bin/sh
2
set -eu
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6
production_project=${OPENAGENTS_PRODUCTION_PROJECT_ID:-}
7
staging_report=${OPENAGENTS_STAGING_REPORT:-}
8
resilience_report=${OPENAGENTS_STAGING_RESILIENCE_REPORT:-}
9
git_sha=$(git -C "$repo_root" rev-parse --verify HEAD)
10
candidate="$repo_root/.git/openagents/staging-candidates/$git_sha/candidate-manifest.json"
11
isolation="$repo_root/.git/openagents/staging-isolation/$git_sha.json"
12
rehearsal="$repo_root/.git/openagents/production-rehearsal/$git_sha.json"
13
run_root=$(mktemp -d /tmp/openagents-production-preflight.XXXXXX)
14
15
cleanup() {
16
  find "$run_root" -depth -delete
17
}
18
19
trap cleanup EXIT INT TERM
20
21
: "${production_project:?OPENAGENTS_PRODUCTION_PROJECT_ID is required}"
22
: "${staging_report:?OPENAGENTS_STAGING_REPORT is required}"
23
: "${resilience_report:?OPENAGENTS_STAGING_RESILIENCE_REPORT is required}"
24
25
if [ "$production_project" != "openagentsgemini" ]; then
26
  echo "production project must be openagentsgemini" >&2
27
  exit 1
28
fi
29
30
for command_name in curl gcloud git jq; do
31
  if ! command -v "$command_name" >/dev/null 2>&1; then
32
    echo "$command_name is required" >&2
33
    exit 1
34
  fi
35
done
36
37
cd "$repo_root"
38
39
if [ -n "$(git status --porcelain --untracked-files=all)" ]; then
40
  echo "production preflight requires a clean worktree" >&2
41
  exit 1
42
fi
43
44
if [ "$git_sha" != "$(git rev-parse --verify refs/remotes/origin/main)" ]; then
45
  echo "production candidate must equal the fetched origin/main commit" >&2
46
  exit 1
47
fi
48
49
ops/ci/gate.sh --verify >/dev/null
50
51
for required_file in "$candidate" "$isolation" "$rehearsal" "$staging_report" "$resilience_report"; do
52
  if [ ! -f "$required_file" ]; then
53
    echo "production preflight evidence is incomplete" >&2
54
    exit 1
55
  fi
56
done
57
58
application_digest=$(
59
  jq -er --arg sha "$git_sha" '
60
    select(.git_sha == $sha) |
61
    .images.application.manifest_digest |
62
    select(test("^sha256:[0-9a-f]{64}$"))
63
  ' "$candidate"
64
)
65
66
jq -e --arg sha "$git_sha" '
67
  .schema == "openagents.staging-isolation.v1" and
68
  .git_sha == $sha and
69
  .status == "passed"
70
' "$isolation" >/dev/null
71
72
ops/staging/validate-report.sh --final "$staging_report" >/dev/null
73
ops/staging/validate-resilience-report.sh --final "$resilience_report" >/dev/null
74
75
jq -e \
76
  --arg sha "$git_sha" \
77
  --arg digest "$application_digest" \
78
  --slurpfile resilience "$resilience_report" '
79
  .candidate.git_sha == $sha and
80
  .candidate.application_manifest_digest == $digest and
81
  $resilience[0].candidate.git_sha == $sha and
82
  $resilience[0].candidate.application_manifest_digest == $digest
83
' "$staging_report" >/dev/null
84
85
jq -e --arg sha "$git_sha" --arg digest "$application_digest" '
86
  .schema == "openagents.production-migration-rehearsal.v1" and
87
  .git_sha == $sha and
88
  .application_image_digest == $digest and
89
  .status == "passed" and
90
  .backup_status == "SUCCESSFUL" and
91
  .classification_before == "prior" and
92
  .classification_after == "prior_baselined" and
93
  .baseline_entries_present == 14 and
94
  .missing_facts == 0 and
95
  .candidate_startup == "passed" and
96
  .last_known_good_startup == "passed" and
97
  .counts_match == true and
98
  .integrity_checks == "passed"
99
' "$rehearsal" >/dev/null
100
101
gcloud auth print-access-token >/dev/null
102
gcloud sql instances describe sarah-postgres \
103
  --project="$production_project" --format=json >"$run_root/sql.json"
104
gcloud sql backups list --instance=sarah-postgres \
105
  --project="$production_project" --format=json >"$run_root/backups.json"
106
gcloud compute instances list --project="$production_project" \
107
  --filter='name~^sarah-fleet-' --format=json >"$run_root/fleet.json"
108
gcloud compute backend-services get-health sarah-backend --global \
109
  --project="$production_project" --format=json >"$run_root/backend.json"
110
curl --fail --silent --show-error --max-time 10 \
111
  https://openagents.com/api/status >"$run_root/status.json"
112
113
jq -e '
114
  .state == "RUNNABLE" and
115
  .settings.backupConfiguration.enabled == true and
116
  .settings.backupConfiguration.pointInTimeRecoveryEnabled == true and
117
  .settings.deletionProtectionEnabled == true
118
' "$run_root/sql.json" >/dev/null
119
120
jq -e '
121
  any(.[];
122
    .status == "SUCCESSFUL" and
123
    .type == "ON_DEMAND" and
124
    ((.endTime | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) >= (now - 86400))
125
  )
126
' "$run_root/backups.json" >/dev/null
127
128
jq -e '
129
  [
130
    .[] |
131
    select(.name == "sarah-fleet-1" or .name == "sarah-fleet-2" or .name == "sarah-fleet-3")
132
  ] as $fleet |
133
  ($fleet | length) == 3 and
134
  all($fleet[]; .status == "RUNNING")
135
' "$run_root/fleet.json" >/dev/null
136
137
jq -e '
138
  [.. | objects | .healthState? | select(. != null)] as $states |
139
  ($states | length) == 3 and all($states[]; . == "HEALTHY")
140
' "$run_root/backend.json" >/dev/null
141
142
jq -e '
143
  .cluster.beam == 3 and
144
  .cluster.raft == 3 and
145
  .cluster.quorum == true
146
' "$run_root/status.json" >/dev/null
147
148
echo "Production preflight passed for $git_sha"

This page updates live while a promote is in flight · changelog