# Individual Migration — Plan

**Status:** Active. Cohort CS-approved 2026-08-05. Both former blockers have an agreed direction (Stripe adoption via Cashier's subscription webhook, SPPLT-17962; webhook ownership = 2.0, SPPLT-18920). **Target: individuals migrated to 2.0 by 2026-09-01.**
**Last updated:** 2026-08-11

### How to use this doc

This is the **spine**: what we're building, the rules the script must obey, the architecture, the build order, and rollout. It is written to be read by a person top-to-bottom *and* loaded by an agent building a ticket.

- **Building a ticket?** Load: the **Jira ticket** (the per-command spec) + **§2 Invariants** (the rules you must not violate) + the relevant slice of the **[field map](individual-migration-field-map.md)** (column-level Legacy→2.0).
- **Need the *why* / evidence / the audit numbers?** → **[decisions & audit log](individual-migration-decisions.md)**.
- **Doc map:** this plan = rules + build order · [field map](individual-migration-field-map.md) = columns · [decisions](individual-migration-decisions.md) = why · epic [SPPLT-17954](https://solidprofessor.atlassian.net/browse/SPPLT-17954) = per-command specs.

**Delivery shape:** CLI-only Artisan pipeline (admin UI deferred). Build in two phases (core record, then activity), **one all-at-once prod cutover**. Elite-tier (<225 LOC) PRs around a Walking Skeleton.

---

## 1. What we're building

Move SolidProfessor's **individual** (self-serve) customers from legacy 1.0 to 2.0 via a CLI Artisan pipeline that reads legacy (SQL Server) **directly** and writes 2.0 (Aurora/MySQL) — mirroring the commercial pipeline. It replaces the broken `IndividualSeeder` (see [decisions §C](individual-migration-decisions.md#c-why-we-replace-the-seeder-it-is-a-broken-artifact-not-a-spec)).

**Universe = the 43,284 individuals who ever had a plan row.** CS-approved scope (2026-08-05):

| Group | Count | Action |
|---|---|---|
| **Paying** | **488** (Stripe-verified ~484; drifts daily; may shrink ~3–4 as VAR cases settle) | Migrate + **adopt** existing Stripe `cus_`/`sub_` (never create new); synthesize `orders`. Cards **not** migrated. |
| **Accessed in last 4 years, non-paying** | **~8,273** | Migrate + grant trial via `account.trial_ends_at` (legacy future `expires`, else migration date + 30 days). |
| **Left behind** | **~34,523** | Tom pushes the list to Marketo (a generic "moved to the new product" email) — **not a build item here.** |

**Out of scope:** all other individuals beyond the 43,284; assignments; saved searches / preferences / notification settings; migrating *historical* orders (we synthesize `orders` for migrated subs, not past order history). Soft-deleted individuals are **excluded** (incl. the ~43 that hold a Stripe id).

Why the cohort is *computed* (not "~40K"), with the tier table → [decisions §B](individual-migration-decisions.md#b-cohort-derivation--why-484-payers-not-40k).

---

## 2. Invariants — rules the script MUST follow

The consolidated, single-source rules. Each links to its evidence. An agent (and reviewer) checks the script against this list.

**Cohort & identity**

1. **MUST** compute the cohort in two steps. **(a) Classify:** ORG iff active-alias-joins-real-admin OR is-an-admin; else INDIVIDUAL ("no plan row required" defines who is an *individual* — it is **not** the migration filter). **MUST NOT** copy the seeder's "exclude any alias row" filter — it drops 18 real payers. **(b) Restrict to the migrating cohort:** individual **AND** has ≥1 `customers_plans_assc` row (the 43,284 identity slice) **AND** (holds a live Stripe sub [paying] **OR** accessed legacy in the last 4 years). Classification alone selects ~225k; the plan-row + paying/accessed-4y restriction is what yields the ~8,761 we actually migrate. → [decisions §B](individual-migration-decisions.md#b-cohort-derivation--why-484-payers-not-40k) · [§1 cohort table](#1-what-were-building)
2. **MUST** keep `deleted_at IS NULL` — exclude soft-deleted individuals, **including** the ~43 that hold a Stripe id. No Stripe-holder exception.
3. **Duplicate-user filter (O9) = NEITHER (resolved Q4, 2026-08-20).** Use **no** email-dedup clause: `duplicates_that_cannot_migrate` has zero cohort overlap, and `.DEDUP_LEGACY` runs the other way (excluding it would wrongly drop 203 members, 24 payers). Instead do a **per-row `User::withTrashed()->where('email',…)` check** → skip to *needs-attention*. Two additions: `LTRIM`/`RTRIM` the legacy email on read (SQL Server `RTRIM` won't strip tabs; 4 cohort emails carry trailing whitespace) and triage on a whitespace-stripped form too (2 cohort members shadow a TAB-bearing 2.0 email an exact lookup misses). Salted twins are dormant → migrate legacy over the top, no merge ([decisions §D.6](individual-migration-decisions.md#d6-email-collisions--triaged-by-sam-product-2026-08-0607)). **Reporting must exclude `%.DEDUP_LEGACY`** (archive row + new row are the same human).

**Idempotency & the irreversible mutation**

4. **MUST** check `LegacyModelRetriever::fetchLegacyModel(Model::class, $legacyId)` before every create. Every command is idempotent (re-runs are safe no-ops).
5. **MUST** enumerate **Phase 2** (achievements, tests, bookmarks, logins, lesson views) **from the 2.0 side** — walk users/accounts carrying `legacy_id` meta, pull legacy activity by the stable numeric `idcustomer`. **MUST NOT** drive Phase 2 off the "not-yet-migrated" base filter (it skips already-migrated individuals). Reference: `MigrateCommercialUserTests`, **not** the looser Activity command. Each Phase 2 command **MUST** have a test proving it works for an *already-migrated + mutated* customer — preprod can't reproduce the mutation, so this test is the only place it's proven. → [decisions §E](individual-migration-decisions.md#e-migration-is-delta-friendly-but-the-legacy-mutation-is-irreversible)
6. The prod user step **destructively mutates** the legacy row (salts email, revokes tokens) in prod only — **you cannot migrate the same individual twice.** This gates the cutover (§7).

**Writes & side effects**

7. **MUST** use `CreateAccount::run()` (never `Account::create()`) and the shared Actions (`MigrateLegacyUser`, `MigrateLegacyAddresses`/`Phones`).
8. **MUST** control side-effect suppression via a **`--suppress-side-effects` command option (default ON)** — **not** a Pennant flag and **not** `env()` (Q6 answered, JP 2026-08-20: a per-user LD flag is meaningless for an anonymous CLI, and an LD outage is indistinguishable from "deliberately off"; a command option is deterministic + fail-safe). **`Event::forget()` alone covers under half the surface**, so also: `config(['mail.default' => 'array'])`, `config(['services.segment.enabled' => false])`, and `User::disableSearchSyncing()` + `Account::disableSearchSyncing()` (Scout/Algolia is otherwise ~26k blocking calls) — *then* the explicit forgets. **Never** forget `App\Users\Events\UserCreated` (its `GenerateIdentifier` is required) or `eloquent.creating` (backs `UsesUuid` — forgetting it yields null UUIDs). ⚠️ **Preprod does NOT fail safe** (live Segment key + real SMTP) — apply the same guards there. Full surface → SPPLT-17957. **The deferred class suppression can't reach is resolved (Q19, Liz 2026-08-20):** retroactive certs **must** carry the legacy `earned_at`, never `now()` (inv 19); the silent first-view listener and the "almost done" nudges on backfilled progress are **accepted non-issues** — no handling.
9. **MUST** wrap each row in `DB::transaction` inside `rescue()`, log failures to `individual_migration_errors` with the right `MigrationErrorType`, and never let one row kill the run.
10. **Legacy write-backs** happen in **prod only** (`safelyUpdateLegacy()` no-ops elsewhere) and **MUST** bump `customers.modifiedDate = now()` alongside the uuid (SPPLT-17539). Write-back target is `user.uuid` (O6, recommended — confirm with tech lead). **⚠️ Cross-DB ordering (ties to inv 6):** the legacy mutation (salt email + revoke tokens + write-back) writes to **SQL Server, which cannot roll back with the 2.0 MySQL `DB::transaction`** — so it **MUST be the last thing, after the per-row 2.0 commit.** If it runs mid-transaction (as commercial does — `MigrateCommercialUsers.php:181-194`) and a later step throws, 2.0 rolls back but legacy stays mutated and the re-run filter skips that customer forever. Detail in SPPLT-17960.
11. `is_password_reset_required` **MUST** = `! empty($item->encPass)` (preserves SSO flows). `onboarding_strategy` = `CATCH_ALL` (label-only). `affiliate_id`/`reseller_id` **NOT** migrated (individuals carry no partner). → [decisions §A/§B/§D](individual-migration-decisions.md#e-decisions-with-reasoning)

**Trial cohort**

12. **MUST** set `accounts.trial_ends_at` (Cashier's **generic** trial — no subscription row) for the ~8,273 non-payers: legacy future `expires` if present, else `migration_date + 30 days`. No `expirein` parsing. On lapse the user buys a real membership via the normal purchase path. **MUST NOT** ever set both `trial_ends_at` **and** an active subscription on one account — Cashier silently wipes the trial (cohorts are mutually exclusive; the buy-a-plan flow clears the trial before minting the sub). Rendering the trial correctly (membership page reads the accounts table; cancel button shown for trials) is **JP's bug-ticket work, not this command.** → [decisions §H](individual-migration-decisions.md#h-trial-access-via-accounttrial_ends_at)

**Stripe / subscriptions (payers)**

13. **MUST adopt** each payer's existing `cus_`/`sub_`. **MUST NOT** `newSubscription()->create()` (mints a duplicate sub) or direct-INSERT. Adopt via **Cashier's subscription webhook**; the `si_` item id **arrives in the webhook payload** (no Stripe read). → [decisions §D.8](individual-migration-decisions.md#d8-stripe-adoption-mechanism--proven-end-to-end-in-preprod-john-2026-08-06)
14. **MUST** trigger ingestion with a **metadata-only** subscription update (`proration_behavior:'none'`), verified payment-neutral. **MUST NOT** pass `default_payment_method`/`default_source`/`collection_method`/`billing_cycle_anchor`, or use metadata keys `type`/`name`. Exact call on SPPLT-17962 / [decisions §D.8](individual-migration-decisions.md#d8-stripe-adoption-mechanism--proven-end-to-end-in-preprod-john-2026-08-06).
15. **MUST wire each adopted payer fully into 2.0's billing lifecycle** — synthesize the `orders` row(s) *and* ensure the adopted sub's `stripe_price` maps to a `MembershipPlan` (`sp-m`/`sp-a` are both in `membership_plans`). Adoption creates only the subscription + item rows, and legacy passes Stripe almost nothing on a purchase — no order id to carry ([decisions §D.5](individual-migration-decisions.md#d5-legacy-passes-stripe-almost-nothing-on-a-subscription-purchase-john-2026-08-10)). **This is load-bearing, not record-keeping:** after cutover, migrated payers rely on *2.0's own* dunning/expiry emails, which ride on the order chain — a sub with no order row or an unmapped price **crashes the `invoice.payment_failed` handler before the "update your card" email sends.** → [decisions §D.9](individual-migration-decisions.md#d9-20s-dunningexpiry-emails-ride-on-the-order-lifecycle-verified-2026-08-11) · SPPLT-18899/18898/18900
16. **MUST** join `planid → plans.plan_id` (scrambled — never `rowid` or `rowid-1`), **`LTRIM`/`RTRIM` every Stripe id in and out** (2022 migration left ~566 broken), read period end via `$subscription->currentPeriodEnd()` (basil moved it onto items). **Note: `currentPeriodEnd()` does a Stripe retrieve per item (~484 across the run)** — so the run is *not* read-free even though adoption's `si_` needs no read (inv 13); size rate-limit/backoff for it. Exclude the **2 Stripe-`canceled`** subs; **migrate** the `past_due`/declining ones carrying their real status. → [decisions §D.1–D.4](individual-migration-decisions.md#d-verified-facts--their-evidence)
17. **MUST NOT** migrate card data — legacy holds cards as a Stripe `default_source` (no raw card data anywhere). Adoption is payment-neutral: valid cards keep auto-charging; 46 already-expired + declining cards re-enter at renewal (CS comms, §7). → [decisions §D.7](individual-migration-decisions.md#d7-card-census--488488-on-a-legacy-source-read-only-live-stripe-2026-08-06)

**Activity (Phase 2)**

18. **MUST window Phase-2 activity to the last 4 years** — logins, bookmarks, lesson views, **and** test attempts. Apply `whereDate(<date col>, '>', now()->subYears(4))` **per activity row** (never the outer user query), with **4 hardcoded as a single named constant** (e.g. `ACTIVITY_WINDOW_YEARS` on the base command) — **no `--range` option.** Individuals are fixed at 4 (unlike EDU's operator-selected 2/3/4), and applying it unconditionally avoids EDU's falsy-`$range` trap (a missing option there migrates all history). Columns: `logins.date`, `cust_bookmarks.rowdate`, `weblms_views_details.rowdate`, `sptests_results.rowdate`. → [decisions §I](individual-migration-decisions.md#e-decisions-with-reasoning)
19. **MUST NOT window achievements/certs** — bring **all** earned certs regardless of date (EDU parity, Sam-confirmed). **MUST preserve the legacy earned date** — map `certs_cust_rel_drm.rowdate` → `user_achievement_versions.earned_at` (field-map), never `now()`. **The migration MUST NOT produce a `now()`-dated cert** (Q19a, Liz 2026-08-20): certs come from the direct achievements migration carrying the real legacy date, so 2.0's completion-triggered achievement listener must not re-grant a fresh cert when backfilled test/view activity lands. Achievements migrate **before** (or the listener is otherwise prevented from firing on) the activity backfill, and the grant must be idempotent on an existing cert so it can't overwrite the preserved `earned_at` with the cutover date. Verify in preprod that a migrated cert keeps its legacy `earned_at`.
20. **Lesson views MUST** read `weblms_views_details` (row-level, like EDU) — not the seeder's aggregate `weblms_views` — **no dedup / no collapse** (every row-level detail migrates); `viewable_id` is legitimately **NULL** for individuals (no assignments — correct, not the per-assignment-progress bug). **Mirror EDU's *shape*, not its reader:** iterate with an **id cursor (`lazyById`/`chunkById` by `rowid`), NOT EDU's `each()`/OFFSET** — EDU stamps `uuid` back per row and filters `whereNull('uuid')`, so OFFSET paging **skips rows once a scope exceeds one page** (prod-only, so a preprod reconcile can't catch it). **Idempotency = deterministic `uuid = UUIDv5(fixed namespace, legacy rowid)` + batched `insertOrIgnore`** on the existing `uuid UNIQUE` index (Q9 / lesson-view brief Option B) — NOT `create()`/`firstOrCreate` and NOT the per-row legacy write-back. Rationale: `user_lesson_views` has **no `legacy_id` column**, so `firstOrCreate` has no legacy key (keying on user+lesson would collapse repeat views — violates no-dedup); the per-row legacy `UPDATE` write-back is the whole cost driver (**4–6 h with it vs 15–45 min without**); and a deterministic UUIDv5 is never null, sidestepping the empty-`sql_mode` null-`uuid` coercion. **Build-time guards (Q9/Q20):** pre-filter `lesson_id`/`user_id`/`recorded_at`/`uuid` all non-null before building a multi-row insert; **skip-and-log** the 309 unresolvable lessons → 4,623 rows / 147 users (Q20, EDU default — you can't attach a view to a lesson absent from 2.0); apply a **cutoff timestamp** since `weblms_views_details` still takes live writes. → [decisions §I](individual-migration-decisions.md#e-decisions-with-reasoning)

---

## 3. Architecture

**Six Artisan commands** under `app/Interfaces/Console/Commands/Legacy/Individual/`, extending a new `IndividualMigrationCommand` base:

| Command | Phase | Purpose | Ticket |
|---|---|---|---|
| `legacy:migrate-individual-accounts` | 1 | Account + AccountUser link (+ trial_ends_at) | [SPPLT-17958](https://solidprofessor.atlassian.net/browse/SPPLT-17958) |
| `legacy:migrate-individual-users` | 1 | User, avatar, addresses, phones; link with role `Individual` (`CreateIndividualAccount.php:52` `->assignRole('Individual')` — NOT LEARNER) | [SPPLT-17960](https://solidprofessor.atlassian.net/browse/SPPLT-17960) / [17961](https://solidprofessor.atlassian.net/browse/SPPLT-17961) |
| `legacy:migrate-individual-subscriptions` | 1 | Adopt Stripe sub + synthesize orders | [SPPLT-17962](https://solidprofessor.atlassian.net/browse/SPPLT-17962) |
| `legacy:migrate-individual-user-achievements` | 2 | Certificates + achievement versions | [SPPLT-17966](https://solidprofessor.atlassian.net/browse/SPPLT-17966) |
| `legacy:migrate-individual-user-tests` | 2 | Test attempts + answer submissions | [SPPLT-17967](https://solidprofessor.atlassian.net/browse/SPPLT-17967) |
| `legacy:migrate-individual-user-activity` | 2 | Logins, bookmarks, lesson views | [17968](https://solidprofessor.atlassian.net/browse/SPPLT-17968) / [17969](https://solidprofessor.atlassian.net/browse/SPPLT-17969) / [17971](https://solidprofessor.atlassian.net/browse/SPPLT-17971) |

**Base class** — traits `InteractsWithLegacy` + `MigratesUsers` + `WithSnapshotConfig`; PHP attribute signatures; options `--limit / --legacy-id / --migration-id / --user-id / --dry-run / --use-temp-table`; the corrected cohort filter in a `Concerns\LegacyIndividualQueries` trait; Telescope stopped in the constructor.

**Tracking infra** — build `App\Individual\Migration\` mirroring `App\Academic\Migration\` (models with explicit `$fillable`, `MigrationStatus`/`MigrationErrorType` enums, `IndividualMigrationPlan` orchestrator, `GenerateMigrationLogFile` listener, DB migrations for `individual_migrations` + `individual_migration_errors`). **Duplicate academic's hardened `MigrationJob`** (SPPLT-15691), don't hoist (resolved Q5, 2026-08-20) — with lock key `'individual-migration'` (distinct from academic's so they don't block each other). Hoisting would force one 113-line, zero-test class to carry two queue selections, two `uniqueId()` contracts, two lock keys and two error-ledger relations; duplication also gets `ShouldBeUnique` lock isolation free via the FQCN hash (`AcademicMigrationCommand` is already a near-copy — precedent exists). 🔴 **The individual pipeline also needs its own SQS queue + worker** — do NOT share `plt-prod-backend-sqs-migration` (a lock-blocked job dead-letters in ~10 min); that's a Terraform prerequisite tracked as Q17. Reuse decision → [decisions §C/§G](individual-migration-decisions.md#e-decisions-with-reasoning).

**Orchestrator** (`IndividualMigrationPlan`, mirrors `AcademicMigrationPlan`) — **Mode A** bulk (accounts → users → subscriptions → **achievements FIRST** → then parallel {tests, activity}); **Mode B** single-`legacy_id` re-run. **Achievements MUST precede tests/logins/bookmarks/lesson-views** (inv 19, Q19a) so certs exist with their real legacy `earned_at` before any completion-triggered achievement logic could fire on backfilled activity and mint a `now()`-dated cert.

**Live-run hardening** — runs live, no maintenance window: dedicated `sqlsrv` connection, per-row transactions, dedicated low-priority queue with `WithoutOverlapping('individual-migration')`, throttle lesson-views if I/O heavy. Batch sizes: `lazyById(1000)` for most, `lazyById(500)` outer for activity (finer for lesson views pending the spike). Smoke-test `--limit=100` in preprod first.

---

## 4. Build order & tickets

One PR per row, child of the epic, Elite-tier where possible, ordered around a **Walking Skeleton** (first end-to-end value is one full individual via single-ID mode, dark-launched behind the flag — item 6a, not after the whole pipeline). Keep each `handle()` thin; push non-trivial logic into `app/Individual/Migration/Services/`.

| PR | Ticket | Contents |
|---|---|---|
| 1a | [17955](https://solidprofessor.atlassian.net/browse/SPPLT-17955) | Tracking schema + models + enums |
| 1b | [17956](https://solidprofessor.atlassian.net/browse/SPPLT-17956) | `MigrationJob` (lock `'individual-migration'`) + S3 log listener — **duplicate, not hoist** (Q5); needs a dedicated SQS queue + worker (Q17) |
| 2 | [17957](https://solidprofessor.atlassian.net/browse/SPPLT-17957) | Base command + `LegacyIndividualQueries` trait + side-effect suppression |
| 3a | [17958](https://solidprofessor.atlassian.net/browse/SPPLT-17958) | `migrate-individual-accounts` (CreateAccount, CATCH_ALL, trial_ends_at, write-back) |
| ~~3b~~ | [17959](https://solidprofessor.atlassian.net/browse/SPPLT-17959) | ~~affiliate/reseller/trial verify~~ — **CLOSED** (no partner; trial in 3a) |
| 4a | [17960](https://solidprofessor.atlassian.net/browse/SPPLT-17960) | `migrate-individual-users` identity + password-reset flag + write-back `user.uuid` |
| 4b | [17961](https://solidprofessor.atlassian.net/browse/SPPLT-17961) | Avatar + addresses + phones (user-level, WORK — O5 resolved) |
| 5 | [17962](https://solidprofessor.atlassian.net/browse/SPPLT-17962) | `migrate-individual-subscriptions` — adopt Stripe + synthesize orders (8–13 days) |
| 6a | [17963](https://solidprofessor.atlassian.net/browse/SPPLT-17963) | `IndividualMigrationPlan` single-individual mode (the dark-launched skeleton) |
| 6b | [17964](https://solidprofessor.atlassian.net/browse/SPPLT-17964) | Bulk mode + queue isolation + delta scheduling |
| 7 | [17965](https://solidprofessor.atlassian.net/browse/SPPLT-17965) | Phase 1 preprod validation (preprod only — no prod cutover here) |
| 8 | [17966](https://solidprofessor.atlassian.net/browse/SPPLT-17966) | Achievements |
| 9 | [17967](https://solidprofessor.atlassian.net/browse/SPPLT-17967) | Tests (match `custid` only) |
| 10a | [17968](https://solidprofessor.atlassian.net/browse/SPPLT-17968) | Logins |
| 10b | [17969](https://solidprofessor.atlassian.net/browse/SPPLT-17969) | Bookmarks |
| 11 | [17970](https://solidprofessor.atlassian.net/browse/SPPLT-17970) | **SPIKE** — lesson-view volume + strategy + window/dedup (O1/O3/O4) |
| 12 | [17971](https://solidprofessor.atlassian.net/browse/SPPLT-17971) | Lesson-view migration per spike outcome |
| 12.5 | [17977](https://solidprofessor.atlassian.net/browse/SPPLT-17977) | **Production cutover** (single, gated — §7) |
| 13 | [17972](https://solidprofessor.atlassian.net/browse/SPPLT-17972) | Delete old seeders (after two clean prod runs) |

**Cross-cutting blockers (not in the build order):** webhook ownership [SPPLT-18920](https://solidprofessor.atlassian.net/browse/SPPLT-18920); order-listener guards [SPPLT-18899](https://solidprofessor.atlassian.net/browse/SPPLT-18899)/[18898](https://solidprofessor.atlassian.net/browse/SPPLT-18898); UI display defects under the bugs epic [SPPLT-18894](https://solidprofessor.atlassian.net/browse/SPPLT-18894) → [decisions §H](individual-migration-decisions.md#h-accompanying-app-defects-bugs-epic-spplt-18894).

Branch `feature/SPPLT-XXXXX`; single-concern PRs; 1 approval; system tests <2s; `pint --dirty` before push.

---

## 5. Field mapping

Column-level Legacy→2.0 mapping for every data area lives in the **[field map](individual-migration-field-map.md)** (source table.column → 2.0 table.column → transform → shared/individual, with `file:line` citations). Per-command specifics live in each Jira ticket. The plan does not duplicate columns — the field map is the single source.

---

## 6. Testing & validation

- **System tests** (Pest `it()`), one per command minimum, in `tests/System/.../Legacy/Individual/`. **<2s each** (backend merge gate) — **fixture/mock the legacy `sqlsrv` read; never point at a live/snapshot DB.** Scenarios: happy path, idempotency rerun, partial-failure logging, `--dry-run`, `--legacy-id`.
- **The load-bearing test:** each Phase 2 command must prove it migrates activity for an *already-migrated + mutated* customer (invariant 5) — preprod can't reproduce the mutation.
- **Preprod validation** (after tests): dry-run `--limit=100`; real `--limit=1000` + spot-check 10; full run against live legacy, reconcile counts; `is_active` spot-check (SPPLT-17196). Keep Stripe in **test mode** for all preprod rehearsals.
- CI: `pint`, Pest, SonarQube ≥40% coverage / ≤10% duplication on new code.

---

## 7. Rollout & cutover

**Principle:** deploying code is continuous and harmless (commands are dormant until invoked); *running the migration in prod is irreversible* (invariant 6), so it happens **once, all at once, at the end**, only after a full preprod proof. No snapshot needed (preprod write-backs no-op — how EDU was validated). Full reasoning → [decisions §J](individual-migration-decisions.md#e-decisions-with-reasoning).

**Gate — all must hold before any real prod run:** all Phase 1 + Phase 2 commands built & merged; lesson-view spike resolved; full Phase 1→Phase 2 preprod run reconciles counts; Phase 2 "already-migrated customer" tests pass; **`SPPLT-18899` / `18898` / `18900` landed** — the order-listener guards that let 2.0's dunning/expiry emails actually fire for migrated payers (not just avoid double-billing 500s — [decisions §D.9](individual-migration-decisions.md#d9-20s-dunningexpiry-emails-ride-on-the-order-lifecycle-verified-2026-08-11)); **`SPPLT-18939` landed** so payers can act on those emails.

**Cutover steps (single pass):**
1. Bulk prod run with the suppress-side-effects flag ON, low-traffic window, tail logs.
2. Verify — reconcile counts, spot-check migrated individuals end-to-end.
3. **Cut Stripe webhook ownership to 2.0 ([SPPLT-18920](https://solidprofessor.atlassian.net/browse/SPPLT-18920)):** right after the paying run, not per-user. **Mechanics (resolved Q7, 2026-08-20):** disable **one** endpoint — **`we_Dg1xXPuhqnqEAL`** (`app.solidprofessor.com/webhooks/stripe_webhook.php`, enabled, subscribed to `*`). The `autorun/stripeSubscriptions.php` job needs **no off-switch — it is unscheduled dead code** (no scheduled task invokes it; UPDATE-only, never inserts, never writes `active_until`) — worth deleting the file since it holds a live-key write path, but it isn't a cutover gate. Also clean up the stale **disabled** 2.0 endpoint `we_0KoXczyRiosah0ummJ8tJKfc`. **What the double-billing is:** legacy writes an order on `invoice.payment_succeeded` and 2.0 writes one on `invoice.paid`; **writing `accounts.stripe_id` during the subscriptions run (not this step) is what arms 2.0's path** — so from that moment every renewal produces *duplicate order rows + receipts* until legacy is switched off (not a double *charge* — Stripe raises one invoice). So this isn't deferrable cleanup: the duplication starts at migration, and this step is what ends it. 🔴 **Separately, legacy is still *selling* subscriptions** (`POST /membership/subscription`, ~1.3/day) — closing that purchase path is its own cutover step (Q16), not this one.
4. **Card handling — no special CS campaign.** Migrated payers rely on **2.0's own** dunning/expiry emails (payment-declined, card-expiring, renewal), which are 2.0-owned and link to the 2.0 billing page ([decisions §D.9](individual-migration-decisions.md#d9-20s-dunningexpiry-emails-ride-on-the-order-lifecycle-verified-2026-08-11)). Valid cards keep auto-charging; the 46 expired + declining cards get the decline/expiry email and re-enter. For those emails to fire, the gate's `SPPLT-18899/18898/18900` (billing-lifecycle guards) and `SPPLT-18939` (so they can act) must have shipped. **Stripe config confirmed (Q8, 2026-08-20):** `customer.source.expiring`/`invoice.upcoming` both reach the 2.0 endpoint and both have handlers; **Smart Retries is OFF** — custom cadence 1/3/5 days (3 attempts) then the **subscription cancels** and the invoice is left past-due. So the decline email must land within that 3-attempt window; after it, the sub is gone.
5. Recurring delta runs until legacy is shut down (idempotency + the Phase-2 enumeration rule cover delta individuals).
6. Decommission old seeders (PR 13) after two clean prod runs.

**Rollback:** none for the data — the legacy mutation is one-way and 2.0 writes are additive. Fix bad data with a forward corrective command keyed by `legacy_id`, not a rollback. This is why the gate exists.

---

## 8. Open items & owners

| Item | Owner | Blocks | Notes |
|---|---|---|---|
| **O6** — write-back uuid target (`user.uuid` recommended) | Tech lead | PR 4a | legacy may read `customers.uuid` |
| ~~**O9** — dedup filter~~ | Eng + Data | PR 2 | ✅ **Resolved (Q4)** — neither filter; per-row `withTrashed()->where('email')` check + `LTRIM`/`RTRIM` + whitespace-stripped triage (§rule 3) |
| ~~Hoist `MigrationJob` vs duplicate~~ | Eng | PR 1b | ✅ **Resolved (Q5)** — duplicate, not hoist; dedicated SQS queue + worker still to provision (Q17) |
| Lesson-view **row-count** confirmation (O4) | Spike PR 11 | PR 12 | Window (O1) + strategy (O3) **resolved 2026-08-11 = EDU pattern** (4y sliding, PHP/Eloquent, `weblms_views_details`, no dedup — [decisions §I](individual-migration-decisions.md#e-decisions-with-reasoning)); spike now just confirms the count is manageable for the PHP path |
| Email-collision VAR cases | Sam (CS) | paying cohort size | 2 resolved / 2 pending (2026-08-11); cohort may shrink ~3–4 → [decisions §D.6](individual-migration-decisions.md#d6-email-collisions--triaged-by-sam-product-2026-08-0607) |
| ~~Webhook ownership off-switch mechanics~~ | Eng | cutover step 3 | ✅ **Resolved (Q7)** — disable endpoint `we_Dg1xXPuhqnqEAL`; `autorun/stripeSubscriptions.php` is dead code (no off-switch). Open tail = close legacy's still-live purchase path (Q16). |
| Left-behind Marketo list | Tom | — | not a build item |

**Pre-flight (before PR 1a):** read the reference files; confirm `OnboardingStrategyEnum::CATCH_ALL` still exists; resolve O6/O9; audit the side-effect listener set; decide hoist-vs-duplicate.
