# Moving the live clinic to its own production host — cutover runbook

Companion to `docs/TENANCY.md` (fresh-install steps) and `docs/RESTORE.md`
(post-restore verification). This document covers moving the **existing** live
installation at clinic.tmsoagency.com onto a dedicated host, ending the
shared-box arrangement that has already damaged the live database twice.

The gate for every stage is `php artisan system:verify` — non-zero exit means
stop and fix, never "note it and continue".

## What the new host must provide

- PHP 8.3 with `pdo_mysql`, `mbstring`, `intl`, `gd`, `zip`, `sodium`, `fileinfo`
- MySQL 8.x with two users per `TENANCY.md`: a runtime user with **no DDL**
  rights and a migration user with them
- Cron (one entry: `php artisan schedule:run` every minute)
- TLS for the final hostname
- No developer tooling, no test database, no `.env.testing` — that absence is
  the point of the move

## Stage 1 — Build the target (live site untouched)

1. Upload the release; point the vhost/document root at `public/`.
2. Create the database and both MySQL users.
3. Write `.env` fresh on the target — **do not copy this box's file** (it
   carries dev-machine paths and this box's keys). Set: `APP_URL`, `APP_KEY`
   (copied exactly — it decrypts national IDs and settings secrets), DB
   credentials for both connections, `PRODUCT_*`, the same
   `BACKUP_ENCRYPTION_KEY`, and `QUEUE_CONNECTION=sync` (no worker on
   cPanel; `system:verify` explains).
   Bucket credentials (`BACKUP_S3_*`) are only needed here if this install
   configures them server-side. Anything entered on Settings → Backups lives
   in the `settings` table and therefore arrives with the Stage 2 dump —
   re-entering it in `.env` would override what the clinic set. The secret is
   encrypted under `APP_KEY`, which is the other reason step 3 copies that key
   exactly rather than generating a new one.
   Windows paths are the known trap: forward slashes or single quotes —
   `system:verify` lints for exactly the escape mistake that took this site
   down on 4 Aug.
4. `php artisan system:verify` → expect FAILs only about the empty database.

## Stage 2 — Rehearse the data move (still no cutover)

5. Take a fresh dump from this box (02:30 archive or a manual
   `backups:run --keep-local`), restore it on the target, then run the whole of
   `docs/RESTORE.md` steps 4–7 there: migrations ledger populated,
   `sequences:reconcile`, caches cleared, write-test patient.
6. Copy `storage/app/private/**` (patient documents) and compare file counts
   and a sampled sha256 against the source.
7. `php artisan system:verify` → must pass with WARNs at worst.
8. Rehearse until Stages 5–7 are boring. The rehearsal copy is then discarded;
   cutover repeats it against a final dump.

## Stage 3 — Cutover (short maintenance window, out of clinic hours)

9. On this box: `php artisan down`, stop `Clinic-Scheduler`, take the final
   dump + a final copy of `storage/app/private`.
10. Restore both onto the target; rerun RESTORE.md steps 4–7 and
    `system:verify` there.
11. Point DNS for the clinic hostname at the new host; confirm TLS issues for
    it. (Lower the DNS TTL a day in advance.)
12. Add the cron entry. Confirm the licence heartbeat fires from the new host
    (`license:heartbeat` appears in the schedule and the licence server sees
    it — this is also how we know cron works).

## Stage 4 — Post-cutover verification (the approval checklist, in order)

- `/up` returns HTTP 200 over HTTPS on the real hostname
- Sign in; wrong-password path still throttles
- Register a test patient (MRN allocates), book, open an encounter, issue a
  prescription, print it — then archive the test patient
- Upload a patient document; download it signed-in; confirm the raw storage
  URL serves nothing
- `php artisan reminders:dispatch` runs clean (log driver)
- `php artisan backups:run` uploads to the bucket and `backups:verify` passes
  the decrypt round-trip **from the new host**
- `php artisan system:verify` — zero FAILs

## Stage 5 — Only now, the production optimizations

These are what the shared box could never safely run:

```bash
composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan route:cache
php artisan view:cache
```

Then re-run `system:verify` and click through a patient record — route caching
has bitten module routes on the dev box before, which is precisely why this
happens *after* cutover verification, on a host where it can be tested against
the real thing.

## Stage 6 — Decommission the old role, keep the box

- This machine stops serving clinic.tmsoagency.com (remove/park the vhost);
  it remains the dev environment.
- Its copy of the live database is now *stale by design* — rename it
  (`clinic_dev`) so no one mistakes it for live again.
- The 02:30 box backup keeps running for the box's own apps; the clinic's real
  backups now run from the production host via `backups:run`.

## Rollback

Until DNS is switched, rollback is "do nothing". After DNS: `php artisan up`
on this box and point DNS back — which is why the old installation is left
intact and untouched for at least a week after cutover.
