CatalyzeUp
sondely/migration

Cutover Runbook

How Sondely's data moves from Cosmos (Mongo API, account cosmos-catalyzeup, databases impactpulse for dev and impactpulse_prod for prod) to Postgres in one cutover, how the move is proven before anyone depends on it, and how it is undone. The model it fills is on Data Model; the infrastructure it runs on is on Infrastructure and Deploy; the plan is on the overview.

The data moves big-bang: one write freeze, one migration, one switch of sondely.com to the new app. The code is proven before that day by the tests of the new app (Testing and Bugs), the rehearsals and the verification below.

State on 8 November 2026. The migration script and its verifier are built in CatalyzeUp/sondely-platform (scripts/migrate/). Rehearsal 1 ran on dev: migrate, then verify, against the real Cosmos dev database and the new dev Postgres, with counts, record hashes and recomputed scores all matching and nothing unresolved. The production data was then copied, as a rehearsal, from Cosmos impactpulse_prod into the new production Postgres (sondely_prod on psql-catalyzeup-sondely), and verification passes (Rehearsal 2). The Python app at sondely.com stays live and untouched, so that copy goes stale from the moment it was made; the migration is idempotent and is run again at the cutover. The new app is deployed: platform.sondely.com serves commit a003202 from main with database: ok, and platform-dev.sondely.com serves dev. The production reminder job is off on purpose (sondely_prod_reminders_enabled = false in catalyzeup-devops azure/sondely-platform.tf): the Python app still sends reminders to the same people, and both together would remind them twice. The cutover has not run; when it runs is Chip's call.

The new app runs in its own container apps (sondely-platform-dev and sondely-platform-prod, Infrastructure), not as a new image in impactpulse-prod. The cutover therefore moves sondely.com and the other production hosts from impactpulse-prod to sondely-platform-prod, and the rollback moves them back; the steps below are written that way.

Switchover checklist

Chip's steps on the day, short form. The full sequence with its go/no-go checks is Cutover day; this list says what can be done with what exists on 8 November 2026.

  1. Announce the window to the organizations on Sondely, in Eastern time, with the expected length.
  2. Freeze writes on the Python app. No freeze exists today: the WRITE_FREEZE switch described in Write freeze is built in neither the Python app, the new app nor catalyzeup-devops, and Container Apps has no setting that stops writes but keeps reads. What exists is taking impactpulse-prod out of service for the window, by deactivating its active revision (az containerapp revision deactivate, not rehearsed). That stops reads as well as writes, and the Python reminder loop with them: sondely.com, www.sondely.com, impactpulse.catalyzeup.ai and hit.wehappers.org answer nothing until step 4. Building WRITE_FREEZE first keeps the site readable during the window instead.
  3. Migrate again, then verify, against impactpulse_prod, with the commands of scripts/migrate/README.md in CatalyzeUp/sondely-platform:
    set +x
    export ADMIN_DATABASE_URL="$(az keyvault secret show --vault-name kv-catalyzeup --name admin-database-url-sondely-prod --query value -o tsv)"
    export MONGO_URL="$(az cosmosdb keys list --resource-group rg-catalyzeup --name cosmos-catalyzeup --type connection-strings --query 'connectionStrings[?description == `Primary Read-Only MongoDB Connection String`].connectionString | [0]' -o tsv)"
    export MONGO_DB=impactpulse_prod
    npm run migrate:dry-run
    npm run migrate -- --snapshot-ref cutover-YYYY-MM-DD
    npm run migrate:verify
    
    Empty sondely_prod first (sequence step 5): the rerun is idempotent, but an upsert never removes a row deleted from Cosmos since the rehearsal, and the verifier blocks on such rows. Go only on a go verdict.
  4. Move the hosts. In catalyzeup-devops azure/main.tf, point cloudflare_record.sondely_apex and sondely_www (and their sondely_apex_asuid and sondely_www_asuid TXT records) from module.impactpulse_prod to module.sondely_platform_prod, and terraform apply. Then delete the sondely.com and www.sondely.com hostname bindings from impactpulse-prod and bind them, with managed certificates, on sondely-platform-prod, as platform.sondely.com was bound (bindings are made outside Terraform). In azure/sondely-platform.tf set APP_BASE_URL and NEXT_PUBLIC_BASE_URL on sondely-platform-prod to https://sondely.com and apply. impactpulse.catalyzeup.ai (the WeHappers map calls it) and hit.wehappers.org (the HIT survey link) are also bound to impactpulse-prod today and move in the same step (WeHappers); hit.wehappers.org has no record in azure/main.tf.
  5. Turn reminders on: set sondely_prod_reminders_enabled = true in azure/sondely-platform.tf and terraform apply. This creates sondely-reminders-prod, which calls https://platform.sondely.com/api/v1/jobs/reminders every 15 minutes, so platform.sondely.com stays bound.
  6. Smoke test on sondely.com: /health answers status: ok and database: ok with the release commit, sign in, take a survey, ask Sona by text, call Sona by voice.
  7. Rollback, if a step from 4 on fails: point the four records back to module.impactpulse_prod, move the hostname bindings back, set sondely_prod_reminders_enabled = false again, and reactivate the Python revision. impactpulse-prod still reads Cosmos, which the migration never wrote to, so it resumes on the pre-cutover data. Writes made in Postgres after step 4 follow Writes made after cutover.

The migration script

scripts/migrate/migrate-from-mongo.ts in sondely-platform, run from the repository root with npm run migrate:dry-run, npm run migrate and npm run migrate:verify, from an Azure address that the Postgres firewall rule for Azure services admits (see Infrastructure). The commands and how to read the credentials are in the script's scripts/migrate/README.md.

Input Value
MONGO_URL Cosmos read-only connection string (the account's primary read-only MongoDB connection string). The script cannot write to Cosmos, which is what keeps the rollback path intact; a unit test also rejects any Mongo write call in the scripts
MONGO_DB impactpulse, impactpulse_prod, or a restored snapshot
ADMIN_DATABASE_URL Postgres owner role (admin-database-url-sondely-dev or -prod in kv-catalyzeup). The script writes every organization, so it uses the admin client without the tenant extension
--dry-run Reads both databases and prints, per collection, what would be inserted, updated, left unchanged, skipped and quarantined, with the reasons. Writes nothing, not even the run record
--snapshot-ref <name> Names the source snapshot on the MigrationRun row

Not built yet, though this runbook relies on them: the --rehearsal flag (refusing the production database), --only <collection>, the reviewed merge list file for email collisions (migration-merges.json), the read-by-read diff crawler, the Python score recompute, and snapshot creation. Each is named where the runbook uses it.

Design rules

Rule How
Pure transforms One function per collection, transform<Name>(doc, lookup) -> { rows, quarantine }, no I/O. Unit-tested with fixtures of every shape found in the code (response shapes A and B, the dev invite user, WeHappers imports, string dates)
Idempotent upsert by id Every row is written with upsert on its primary key. Derived ids (question groups, translations) are stable hashes of their source keys, so a re-run writes the same ids. Quarantine rows upsert on (collection, sourceId, reason)
Fresh target for the real run Upsert makes a rehearsal re-runnable, but it never removes a row whose source document was deleted in between. The production run therefore starts from an empty schema (prisma migrate deploy on a freshly created database), so the target is exactly the source
Deterministic order Collections in dependency order: organizations, users, organization_members, organization_invitations, surveys (with groups), questions, translations (built last from surveys and questions), survey_sessions, survey_responses, survey_assignments, api_keys, email_verifications, notification_preferences. Documents read sorted by _id
Batches in transactions 500 rows per transaction; a failed batch rolls back and fails the run. Earlier committed batches may remain, so the run is repeated on the same frozen source after the fix
Quarantine kept current A full run clears the quarantine entries it no longer produces, so a fixed transform leaves no stale hold behind (built 8 November 2026)
Nothing dropped silently Every source document ends as a row, a quarantine row, or both. The count equation below proves it
Logged One MigrationRun row per run with per-collection counts, and a structured log line per collection

Transforms and quarantine rules

Field mapping per collection is on Data Model. The rules for what does not fit:

Code Condition Severity Row migrated
NO_ORGANIZATION Survey with no organization_id (allowed by SurveyInDB) ERROR no
ORPHAN_ORGANIZATION Child row whose organization is gone. organizations/routes.py line 310 deletes the org, its members and invitations, and leaves its surveys, questions, responses, sessions, assignments and API keys behind ERROR no
ORPHAN_SURVEY Question, session, response, assignment or translation whose survey is gone. Survey delete (surveys/routes.py 392) removes questions and responses but not sessions or assignments ERROR no
ORPHAN_USER Membership, session or assignment whose user is gone ERROR no
UNKNOWN_ENUM Role, status, type or result type outside the enum. The legacy MEMBER role (from an unused Python model, granting nothing beyond a respondent) is not held: it migrates as RESPONDENT ERROR no
ORG_MISMATCH Assignment whose stored organization differs from its survey's; the source value is not corrected ERROR no
ORG_NAME_COLLISION Two organizations whose names match ignoring case; the run records the quarantine and writes no entity rows ERROR no, and the run is a no-go
BAD_DATE A required date field that is missing, or neither a BSON date nor a parseable ISO string. A user's email_verified stored as a boolean is not a bad date: true maps to the account's creation time, false to unverified (the production dry run of 8 November 2026 found imported accounts stored that way) ERROR no
EMAIL_CASE_COLLISION Two users whose emails differ only in case or surrounding spaces ERROR no, both held (below)
DUPLICATE_MEMBERSHIP Two members for one (organization, user) WARNING the kept one
DUPLICATE_ASSIGNMENT Two assignments for one (survey, user) WARNING the earliest
DUPLICATE_EXTERNAL_ID Two users with one google_id or apple_id WARNING yes, the newer one without the id
GROUP_UNKNOWN Question group_id names no group of its survey, or a survey lists a group id twice WARNING yes, groupId null
ANSWER_SCHEMA Stored answer fails the new answer schema (below) WARNING yes, verbatim
SESSION_SHAPE_RESPONSE Response written by /sessions/{id}/complete: _id only, no id, no score NOTE yes, id = _id
DEV_ONLY_FIELDS User document from the dev invite branch (password_hash, is_active, is_verified) NOTE yes, extra fields dropped
STATS_DRIFT Organization stats counter differs from the real count NOTE yes, counter dropped
UNEXPECTED_DOCUMENT Any document in sessions (expected empty), a required field of the wrong shape, or a response pointing at a missing session or another survey's ERROR no
QUESTION_ONLY_TRANSLATION A translation language found on questions only NOTE yes

Go/no-go on quarantine: no ERROR row may be unresolved at cutover. Each ERROR from the production rehearsal gets a resolution recorded on the row (resolution, for example "orphan of deleted org X, accepted as lost" or "org restored from survey creator") and accepted by Chip. The final run must produce the same ERROR set, by code and source id, as the accepted rehearsal, minus resolved ones; anything new stops the cutover.

Email case collisions

The Mongo index on users.email is unique and case-sensitive, so Ana@x.org and ana@x.org can both exist if an older write path did not lower-case. Every current write path does (auth/routes.py lines 133, 312, 541, 690, 779), so collisions can only be historical.

  1. The script groups users by lower(btrim(email)) before writing any user.
  2. A group of one is lower-cased and migrated.
  3. A group of two or more is held: every user in it, and every row that points to any of them (memberships, creator, inviter, assigner and translation approver references), goes to quarantine as EMAIL_CASE_COLLISION, with the group in detail. The script as built merges nobody.
  4. Resolution is planned as a reviewed mapping file, migration-merges.json: for each group, the surviving user id and the ids it absorbs. Default proposal: the user with a password or an external id, then the most memberships, then the oldest. Merging two accounts is Chip's decision per group, because it joins two people's histories. Not built yet; the production dry run of 8 November 2026 found no collision.
  5. With the mapping present, the script would rewrite foreign keys of the absorbed ids to the survivor and record the merge in MigrationRun.counts. A collision without a mapping blocks the cutover.

respondent_email on responses is not normalized: Python stores it as typed and compares it exactly in the cooldown check (surveys/routes.py line 553), so lower-casing it would change who can retake a survey.

Answers that fail the new schemas

Answers are an unchecked dictionary today (responses: Dict[str, Any]). The TS app validates new answers with a Zod schema per question type. Migrated answers are stored verbatim and validated as a report, never rewritten, because rewriting one would change a stored score and a person's history.

Check Fails when
Key The answer key is not a question of the survey (the question was deleted, or migration 0001 removed a COUNTRY question)
RATING Not a number, or outside scoring_config.min_value to max_value
BOOLEAN Not a boolean
SINGLE_CHOICE Not a string, or not one of options (an answer stored in a translated wording is the likely case)
MULTIPLE_CHOICE Not a list of strings, or an item not in options
TEXT Not a string
COUNTRY Not {country, region}

Each failure is a WARNING quarantine row naming the question and the reason. The report groups them by survey and reason. They do not block the cutover; they decide how strict the TS read path may be, and whether the scoring engine must tolerate them (it must, if the score recompute below is to match).

Rehearsals

Rehearsal 1: dev, for real

Run on 8 November 2026 for the data: the real Cosmos dev database migrated into sondely_dev on psql-catalyzeup-sondely-dev, then verified, with counts, hashes and recomputed scores matching and nothing unresolved. dev.sondely.com has not been switched; the new dev app serves platform-dev.sondely.com.

Step Detail
Source impactpulse on Cosmos, read-only key
Target the sondely_dev database on the dev server
Run Script, then the whole verification suite, with the Python dev app (impactpulse container app) frozen
Then dev.sondely.com is moved to sondely-platform-dev with the same day sequence as production
What it finds The dev-only shapes (dev invite users, seed data, shape B responses from session testing), and whether the runbook steps work as written
Done when dev.sondely.com runs on TS, the verification suite passes, and the runbook is corrected for whatever it got wrong

The dev Python app has written seed data and test documents for months; its quarantine report is expected to be noisier than production's and is the place to tune the transforms.

Rehearsal 2: a production snapshot

Run on 8 November 2026 in a different form from the plan below: read-only against impactpulse_prod itself rather than a snapshot, and written into the production database (sondely_prod on psql-catalyzeup-sondely) rather than a sondely_rehearsal database, while the Python app kept serving sondely.com. No snapshot was taken, and the read-by-read diff and the Python recompute did not run (not built).

  • The dry run's first pass held 223 of 267 users as BAD_DATE (a boolean email_verified), which orphaned 157 responses and 71 assignments; the transform was fixed the same day.
  • The migration then wrote 2 organizations, 267 users, 264 memberships, 1 survey with its 6 question groups and 2 translations, 24 questions, 4 sessions, 186 responses, 101 assignments and 1 API key. Nothing was held: the only quarantine rows are two STATS_DRIFT notes on the organizations.
  • Verification first failed on 14 responses whose legacy WeHappers metadata, a JSON column, came back from Postgres with a float's last digit changed (27.586206896551722 written, 27.58620689655172 read). The verifier now treats such a difference as equal within a relative 1e-12 (Per-record hashes), and a second run (snapshot ref prod-rehearsal-2026-11-08b, every record unchanged) verifies go: counts, record hashes and recomputed scores.

That production copy is not the cutover. It goes stale as the Python app keeps writing to Cosmos, and the cutover empties sondely_prod and migrates again (Switchover checklist).

Step Detail
Snapshot mongodump --uri <read-only prod string> --db impactpulse_prod --archive from the Azure runner, restored into a throwaway mongo:7 container on the runner. The account uses periodic backup (backupPolicy.type = Periodic, read from Azure), so a point-in-time restore is not a self-serve option; the dump is the snapshot. The archive is kept in blob storage as the record of what was rehearsed
Target a sondely_rehearsal database on the dev server, created and dropped by the rehearsal, never the production database
Python side for the diff The last production Python image, run in a container on the runner against the restored Mongo copy, with ENVIRONMENT=development (emails intercepted in memory by services/email.py, never sent), no REDIS_URL, and the write freeze on so the reminder loop does not run
TS side for the diff The candidate TS image, run in a container on the runner against sondely_rehearsal, reminder job off
Repeat Until two consecutive runs are clean: no unaccepted ERROR rows, hashes 100%, scores 100%, zero unexplained read diffs

Data size makes repetition cheap: the WeHappers import was 257 users and 177 results (technical/happers-migration.md), and the rest of production is a few organizations (inferred; the rehearsal counts replace this guess).

Verification suite

Four checks, run in this order after every migration, each writing its result to the MigrationRun row, headless from the CLI. As built on 8 November 2026, scripts/migrate/verify.ts (npm run migrate:verify) runs the counts, the record hashes and a TypeScript score recompute; the read-by-read diff and the Python recompute are not built. The verifier writes only its report and verdict, never a migrated row.

1. Counts

For every source collection:

source documents = rows written + ERROR quarantine rows, where rows merged by a reviewed mapping count once per source document.

As built, the equation is source = primary Postgres rows + distinct ERROR-held documents + duplicate WARNING-held documents + expired verification tokens skipped: duplicates are held as WARNING and not written, and expired tokens are skipped without quarantine. Unexpected target rows, missing quarantine, changed hashes and unresolved errors each block verification.

Plus the derived tables: QuestionGroup rows equal the sum of question_groups lengths over migrated surveys minus GROUP_UNKNOWN duplicates; SurveyTranslation rows equal the distinct (survey, language) pairs found in surveys.translations and questions.translations. Collections not migrated (otp_codes, oauth_states, schema_migrations) are counted and logged, not compared.

2. Per-record hashes

For each migrated source document, a canonical hash on each side must match.

Step Mongo side Postgres side
Select the document the row, with its child rows where the document embedded them (groups for a survey, translation entries for a survey or question)
Shape drop _id; drop fields the mapping drops (listed per collection on Data Model, for example stats, the dev-only user fields); fill absent mapped fields with their documented default rebuild the source document from the row with the inverse mapping: snake_case keys, enums in their stored case, geoCountry and geoRegion back into geo, assignedById null back to "system", group ids back to their keys, email as stored after lower-casing
Values dates as ISO 8601 UTC with milliseconds and Z same, from timestamptz(3)
Numbers JSON numbers per RFC 8785 (JCS), so 1 and 1.0 are one value same
Serialize RFC 8785 canonical JSON: keys sorted, no whitespace, UTF-8 same
Hash sha-256 hex sha-256 hex

Pass: 100% of migrated documents match. As built, two records whose hashes differ still match when the only differences are non-integer numbers within a relative 1e-12, because a float in a JSON column can come back from Postgres with its last digit changed; strings, integers, booleans, nulls and structure must match exactly. The inverse mapping is code, tested both ways: inverse(transform(doc)) == shape(doc) over every fixture. Answers, score breakdowns and group results are compared as stored, which is how the hash also proves nothing in the JSON columns moved.

3. Read-by-read diff

server/scripts/verify/read-diff.ts: every GET the Python API serves, asked of both backends with the same identity, answers compared.

Part How
Routes Enumerated from Python's own OpenAPI document (/api/v1/openapi.json), every GET path template, so a route nobody remembered is still crawled
Concrete URLs Path ids filled from the snapshot: every organization, survey (id and public_id), response, session, assignment, member, invitation and key id; list routes with their pagination (skip, limit) walked to the end
Identities Per organization, its OWNER and one ADMIN; per survey, one RESPONDENT who answered it; anonymous for public routes (/surveys/public/...); the WeHappers API key for the two routes its site calls. Tokens come from the test-only token route both backends expose in rehearsal (Testing and Bugs), never from a production secret
Compare Status code, then body after normalization
Normalization (each one named in the crawler source with the reason) Timestamps compared as instants, Python's naive values read as UTC; lists the API does not sort compared as sets keyed by id; organization stats (computed in TS, a stale counter in Python); fields Python returns from shape B responses or fails on (recorded, not hidden)
Pass Zero unexplained differences. A difference is either a TS bug (fixed, crawl again) or a Python bug the TS deliberately does not reproduce, recorded on Testing and Bugs with the route and an example

The crawler is read-only by construction (GET only) and also runs on cutover day, against frozen Python on Cosmos and TS on the freshly migrated Postgres.

4. Score recompute

For every response of shape A, three numbers per field (score, score_breakdown, group_results, overall_percentage):

Value Computed by
Stored the migrated row
Python recompute services/scoring.py (calculate_survey_score, aggregate_group_scores) run on the snapshot by server/scripts/verify/python_scores.py, with the survey's current questions and groups
TS recompute the TS scoring engine on the Postgres rows

As built, there is no Python recompute: the verifier recomputes every stored score with the TypeScript engine (src/lib/scoring) and any difference from the stored score beyond 1e-9 blocks verification, even when the hashes pass. The dev rehearsal passed that way.

Pass, as planned: TS recompute equals Python recompute for 100% of responses (floats within 1e-9). Stored versus recompute differences are expected and reported, not blocking: questions and groups can change after a response (migration 0001 rewrote groups; the WeHappers responses were scored by the import script). The report lists them by survey so a stored score is never "corrected" by accident.

Write freeze

Python has no maintenance mode today (no match for maintenance, read-only or freeze under backend/app). The freeze is a small Python change, shipped and tested before cutover:

Part Behaviour
Switch Environment variable WRITE_FREEZE=true on the container app, set through Terraform in catalyzeup-devops (a var.sondely_prod_write_freeze), never by hand. Not built yet, in either app, as of 8 November 2026
API Middleware answers every POST, PUT, PATCH, DELETE under /api/v1 with 503, Retry-After, and a JSON detail the frontend shows as a banner. GET, HEAD, OPTIONS and /health pass
Background reminder_loop is not started, so no reminder is sent or counted during the freeze
Startup run_migrations still runs; all five ids are applied, so it writes nothing (checked at go/no-go)
What it costs A respondent who submits during the freeze sees the banner and loses that submission. Hence a low-traffic window, and the take page warning

The WeHappers map only reads, so it keeps working through the freeze.

Cutover day

Times are chosen and stated in Eastern before the day; the window is low traffic for Sondely's organizations.

Preconditions (all true the day before)

Check Evidence
CI green on main and main deployed to sondely-platform-prod CI run URL; platform.sondely.com/health names the commit
Dev cut over and stable since dev.sondely.com on sondely-platform-dev, no open migration bug
Rehearsal 2 clean twice the two MigrationRun rows
Every ERROR quarantine row resolved and accepted quarantine report signed off by Chip
Email collision mapping reviewed, if any migration-merges.json merged
WeHappers key rotated (see WeHappers) map serves with the new key
Production Postgres, roles and secrets applied by Terraform on sondely-platform-prod (done on 7 November 2026) terraform plan clean, az containerapp show env names
The production hostnames ready to bind on sondely-platform-prod the custom domains and their certificates prepared, as for platform.sondely.com
Last Python image recorded today impactpulse:84be2d177b4939042a79b5d053216f5ca2c21240 (read from Azure on 5 November 2026); re-read on the day
schema_migrations in impactpulse_prod holds 0001 to 0005 query output

The sequence

# Step Go/no-go check
1 Record the starting state: Python image sha, Mongo counts per collection, newest updated_at and submitted_at Recorded in the run notes
2 Freeze: terraform apply with sondely_prod_write_freeze = true (new revision of the same Python image) A probe POST /api/v1/auth/request-otp gets 503 with the freeze body; GET /health is 200; logs show the reminder loop not started
3 Wait for the old revision to drain, then read Mongo's newest write timestamps again No write later than the freeze time
4 Archive: mongodump of impactpulse_prod with the read-only key to blob storage Archive size and document counts logged
5 Empty the production database: drop and recreate sondely_prod, then prisma migrate deploy as the migration role (restarting sondely-platform-prod does it) _prisma_migrations lists every migration, tables empty
6 Run migrate-from-mongo.ts against impactpulse_prod Count equation holds; ERROR set equals the accepted rehearsal set
7 Hashes 100% match
8 Score recompute TS equals Python, 100%
9 Run the read-by-read diff between platform.sondely.com (the new app on production Postgres, reminder job off) and frozen production Python. The crawler is not built yet Zero unexplained differences
10 Move the hosts: sondely.com, www.sondely.com, impactpulse.catalyzeup.ai and hit.wehappers.org unbound from impactpulse-prod and bound to sondely-platform-prod, with their DNS records in Terraform /health 200 with database: ok and the release commit on each host
11 Smoke test, headless Chromium (Playwright) against sondely.com: sign in as the smoke account, open an organization's dashboard, open a survey's results, take a published test survey anonymously on hit.wehappers.org, see the response counted All pass
12 WeHappers contract: call GET /api/v1/surveys and /surveys/{id}/responses?skip=0&limit=100 with the WeHappers key, directly (the site caches the map for a day, so the site itself proves nothing for 24 hours) HIT survey found, responses carry score and geo
13 Turn the reminder job on: sondely_prod_reminders_enabled = true in azure/sondely-platform.tf, then terraform apply (see Infrastructure) First tick logs, no duplicate EmailReminder rows
14 Watch: logs, error rate, Discord, for the first hours; deploy-monitor style check that production carries the sha No new error class

A no-go at steps 6 to 9 means nothing has changed for users except the freeze: fix, re-run from step 5 (the target is emptied each time), or unfreeze Python and pick another day. A no-go at step 10 or 11 is a rollback.

Rollback

Trigger Any failed check from step 10 on, or a production defect after cutover that cannot be fixed forward inside the window
Who decides Chip
  1. Freeze the new app (WRITE_FREEZE, to be built in the new app too), so the set of post-cutover writes stops growing.
  2. Move the hosts back to impactpulse-prod, which kept running the recorded Python image with MONGODB_URL and REDIS_URL for the whole window. Cosmos is untouched (the migration only ever used the read-only key), so Python resumes on exactly the pre-cutover data.
  3. Set sondely_prod_reminders_enabled = false and apply, so the new app's reminders stop before the Python app's start again.
  4. Decide what happens to the writes made in Postgres after the move (below), then unfreeze Python with terraform apply.
  5. Record what failed on Testing and Bugs and in the run notes. The next attempt starts again from the preconditions.

Writes made after cutover: an open decision for Chip

Every row created or changed in Postgres after the move exists only there. On rollback they are lost unless something carries them back.

Option Cost Risk
A. Accept the loss None Responses and edits made in the window are gone; volume is small (inferred from the data size) but a lost response is a person's answers
B. Export to a file and re-enter by hand A script listing rows with createdAt or updatedAt after the move, as CSV per table Manual, error-prone beyond a handful of rows
C. Reverse writer: export-to-mongo.ts A second transform, Postgres to Mongo, its own tests, and a Cosmos write key New code that runs only in an emergency, the least tested path on the worst day
D. Short window plus the audit log The copied Audit model already records every semantic write with before and after; a replay script reads it Depends on audit coverage of every write route, which phase 3 would have to prove

Recommendation: B, with the window kept short, because it carries the data back without new code on the rollback path. To be decided with the length of the window (decision 3 on the overview).

Decommissioning Cosmos and Redis

After the rollback window closes, by Terraform in catalyzeup-devops, never by a portal click:

Step Change
1 Final mongodump of impactpulse and impactpulse_prod to blob storage, kept as the archive of record (retention to be decided)
2 Remove module.impactpulse and module.impactpulse_prod, the Python container apps, once no host points at them
3 Remove azurerm_redis_cache.main and the redis_hostname output. Redis is used by nothing else in azure/main.tf
4 Remove azurerm_cosmosdb_account.main and the cosmosdb_connection_string output. Cosmos is used by nothing else in azure/main.tf (the legacy Happiness Index app at happiness.wehappers.org has its own MongoDB, per WeHappers Migration)
5 Delete backend/, the Python jobs in .github/workflows/ci.yml, and the Python stage of the Dockerfile
6 Update Azure Infrastructure and Infrastructure and Deploy in the same change

WeHappers

The overview listed as unknown whether WeHappers reads Sondely's database. Findings, from backend/app/main.py, the root Dockerfile (the one the deploy workflows build), catalyzeup-devops/azure/main.tf, the CatalyzeUp/wehappers.org repo and read-only Azure queries on 5 November 2026:

Question Answer
Does the wehappers container app share Sondely's database? No. It is the public wehappers.org site, a Next.js standalone server (wehappers.org/Dockerfile; the comment in main.tf still says Astro on nginx, which is stale). The module passes no env_vars and no secret_env_vars, and Azure shows no environment variable on it. It has no database connection at all
Does it depend on Sondely? Yes, as an API client of production. wehappers.org/src/app/api/happiness-map/route.ts calls https://impactpulse.catalyzeup.ai/api/v1/surveys and /api/v1/surveys/{id}/responses?skip=&limit=100 to build the world map, with IMPACTPULSE_API_KEY or, when unset, the hard-coded ip_live_wehappers_dev_key_00001. Unset is the case, and that key authenticates against production today (HTTP 200)
Where does WeHappers data live? Inside Sondely's database, as a tenant. The WeHappers organization, the HIT survey (public id c19ea72df1datmhjhs1i, linked from the site), the imported users and responses, all in impactpulse_prod. hit.wehappers.org is a custom domain bound to impactpulse-prod (read from Azure) and resolved to the organization through organizations.custom_domain
What is the WeHappers migration in main.py? A startup branch that imports scripts/migration-data/*.json into the database and seeds the dev key. It runs only when ENVIRONMENT is development or test and scripts/migration-data/users.json exists three directories above app/main.py. The image copies backend/ to /app, so that path is /scripts/migration-data/users.json, which the image does not contain: it never runs in a deployed container (inferred from the Dockerfile; confirm by searching the container logs for "WeHappers migration"). It is not ported
And migrations 0003 and 0004? They backfill geo and context on the imported WeHappers responses from files bundled in app/data/. They run in every environment but once only; the go/no-go checks they are applied. Not ported

What the cutover must do about it:

  1. Keep the two routes the map calls in the contract suite as a named scenario: the survey list with questions[].type, and paginated responses with score, responses and geo. The map finds the survey by a title containing "HIT" or "Happiness Index", so the list must stay visible to that key's organization.
  2. Rotate the key before cutover, independent of decision 2: mint an org-bound READ key for the WeHappers organization, put it in kv-catalyzeup, set IMPACTPULSE_API_KEY on module.wehappers through Terraform, deploy the site, confirm the map, then revoke ip_live_wehappers_dev_key_00001. The hard-coded key sits in the source of a repo (private today) and in a seed branch of main.py; the cutover should not depend on legacy bcrypt key support for it.
  3. Keep impactpulse.catalyzeup.ai served by the production app, since the site calls that host. It moves with the other hosts from impactpulse-prod to sondely-platform-prod at step 10.
  4. Resolve hit.wehappers.org to the WeHappers organization in the new app (its organization-by-host lookup) before the move: the HIT survey link points there. The smoke test takes the survey on that host.
  5. Do not touch the legacy Happiness Index app at happiness.wehappers.org (Vercel, its own MongoDB); retiring it is the separate plan on WeHappers Migration.