# Spec 051 — Migration Rehearsal (T8.2) + Perf Gate (T8.3)

**Status:** Executed 2026-07-22 on branch `051-scheduling-onboarding` (HEAD b3f1a41dd).

**Merge note:** `docs/runbooks/scheduling-free-launch.md` (T8.9, authored by a parallel agent)
is the canonical launch runbook. This file's content is intended to be merged into that
document's **§Migration rehearsal** section when convenient — kept separate here per the
task's own coordination instruction to avoid a concurrent-edit collision on the shared file.

---

## T8.2 — Migration Rehearsal

### Why a from-scratch clone was needed

This dev environment already has all six 051 migrations **applied** (T1B landed earlier in
this same spec effort). Cloning `kiosk_buykiosk`/`kiosk_users`'s current schema verbatim would
make every migration op's `check_query` report "already applied" immediately — a rehearsal
that times nothing. The rehearsal DBs (`kiosk_buykiosk_rehearsal`, `kiosk_users_rehearsal`)
were therefore built by:

1. Cloning the **real dev row data** for the 8 touched tables (`CREATE TABLE ... LIKE` +
   `INSERT ... SELECT` — verified empirically, matching `contracts/fixture-stores.md`'s own
   note, that `LIKE` does **not** carry `FOREIGN KEY` constraints across, so no manual FK
   stripping was needed).
2. **Reverting** the clone to the pre-051 state: stripped `scheduling_invite` back out of the
   3 billing enums, deleted the 4 `task_job_definitions` rows the two job migrations insert,
   deleted the 18 migration_log rows for the six 051 migration IDs, and reverted
   `userDeviceTokens`'s unique key back to `(userId, deviceId)` (no `appId` column in the key,
   no `idx_userId_appId_updatedAt` index). The `onboardingProgress`/`onboardingObservations`/
   `onboardingUserProgress`/`onboardingEventLog`/`onboardingCampaignCohort`/`userInvites`/
   `inviteMessages`/`userDeviceTokensAppIdConflictArchive` tables were simply **not cloned** —
   migrations 001/002/004-op1 create them fresh.
3. **Synthesizing bulk data**: `userDeviceTokens` → 50,000 rows (per the task's explicit ask,
   "to make the uk-swap timing meaningful") and, in the same spirit, `billingSmsUsage` → 50,000
   rows (dev only had 0 real rows for this table — not explicitly requested, but added so the
   *other* lock-risk op family, the enum ALTERs, also gets a meaningful row-count rehearsal;
   flagged here as a deliberate scope addition per the Deviation Protocol).

`stores` and `task_job_definitions` were cloned with real dev row counts (13, 43) per the
task's ask, even though none of the six migrations' ops actually reference `stores` directly.

### Runner adaptation (and a real finding)

The runner reuses `migrations/migrate.php`'s op-dispatch functions (`createTableOp`,
`alterTableOp`, `insertDataOp`, `multiSqlOp`, `addIndexOp`) directly — these take a `PDO $db`
+ `$databaseName` and have no hardcoded database-name assumptions. Three adaptations were
needed beyond that, the third being a genuine finding:

1. `op['database']` remapped `kiosk_buykiosk → kiosk_buykiosk_rehearsal`,
   `kiosk_users → kiosk_users_rehearsal` before `dbConnectByName()`.
2. `migration_log` bookkeeping uses bespoke preload/has/mark functions against
   `kiosk_buykiosk_rehearsal.migration_log` — the real `migrations/methods/migrationsLog.php`
   functions hardcode `dbConnectByName($_ENV['DB_NAME'])` (the **real** central DB), so reusing
   them here would have contaminated/queried the real environment's migration log.
3. **Finding**: migrations `003` and `004`'s `check_query` strings are schema-hardcoded —
   e.g. `... WHERE TABLE_SCHEMA = 'kiosk_buykiosk' AND TABLE_NAME = 'billingSmsUsage' ...`.
   Unlike `SHOW TABLES LIKE '...'` (connection-context-relative, used by 001/002), an
   `INFORMATION_SCHEMA` query with a literal schema name is **not** relative to the connection's
   database — it always checks the literal named schema regardless of which DB you're
   connected to. Naively adapting `scripts/apply-051-t1b-migrations.php`'s own pattern (which
   only remaps `op['database']`) against a differently-named clone would have made 003/004's
   check queries silently see the **real** `kiosk_buykiosk`/`kiosk_users` (already migrated)
   and skip every op immediately — a rehearsal that reports "all skipped" while never actually
   exercising the ALTERs. Fixed by also text-remapping `'kiosk_buykiosk'`/`'kiosk_users'`
   literals inside `check_query` strings before evaluating them. **Worth carrying forward**: any
   future rehearsal-style reuse of the `apply-051-*.php` pattern against a differently-named
   database needs this same check_query remap, not just the `database` field remap.

### Lock-risk op observation methodology

For the two lock-risk operation families (R3: the 3 enum ALTERs in `003`; the archive
multi_sql + uk-swap ALTER + add_index in `004`), each op ran in a **separate PHP subprocess**
(the parent can't poll `SHOW FULL PROCESSLIST` while its own connection is synchronously
blocked inside `$db->exec()`). Concurrently, a second subprocess repeatedly ran
`SELECT COUNT(*) FROM <table>` every 50ms against the SAME table for up to 15s, timestamping
each attempt — this proves/disproves whether the DDL blocks ordinary reads (a `LOCK=SHARED`/
`EXCLUSIVE`/COPY-algorithm signature) or not (`LOCK=NONE`/INPLACE/INSTANT). The parent polled
`SHOW FULL PROCESSLIST` every 30ms for the DDL connection's `State`/`Info` during the op.

### Timing table (50,000-row scale: `userDeviceTokens` and `billingSmsUsage`)

Two passes run — pass 1 (cold) and pass 2 (idempotency check). All 18 ops succeeded on pass 1;
**all 18 fully skipped on pass 2** (idempotency confirmed at prod-scale row counts, matching
T1B.5's own dev-scale result). `wallMs` includes PHP subprocess spawn overhead (harness
artifact — a real conductor run applies these in-process, no subprocess spawn per op);
`childElapsedMs` is the actual SQL execution time inside the subprocess.

| Migration | Op type | wallMs | childElapsedMs | Risky? | Concurrent-read prober (min/avg/max ms) |
|---|---|---:|---:|---|---|
| 051_001 onboarding_tables (×5 creates) | create_table | 174/136/99/105/104 | 91/37/27/27/22 | no | — |
| 051_002 user_invites (×2 creates) | create_table | 100/105 | 31/27 | no | — |
| 051_003 billingSmsUsage enum ALTER | alter_table | 171 | 73 | **yes** | 4.86 / 16.37 / 25.42 |
| 051_003 billingSmsCategoryConfig enum ALTER | alter_table | 169 | 66 | **yes** | 0.33 / 3.29 / 12.01 |
| 051_003 billingLineItems enum ALTER | alter_table | 166 | 59 | **yes** | 0.46 / 1.44 / 4.12 |
| 051_004 archive table create | create_table | 137 | 33 | no | — |
| 051_004 archive-then-delete conflicts | multi_sql | 171 | 75 | **yes** | 3.34 / 7.43 / 18.92 |
| 051_004 uk swap (drop+add unique key) | alter_table | 199 | 125 | **yes** | 3.47 / 9.78 / 23.37 |
| 051_004 add (userId,appId,updatedAt) index | add_index | 204 | 119 | **yes** | 5.43 / 9.08 / 19.51 |
| 051_005 invite job defs (×3 inserts) | insert | 106/70/69 | 1.08/1.10/0.51 | no | — |
| 051_006 retention job def | insert | 71 | 0.63 | no | — |
| **Total (18 ops)** | | **2,359ms** | **813ms** | | |

### Lock observations — verdict: no blocking, INPLACE/INSTANT throughout

For **every** risky op, the concurrent-read prober's latencies stayed in the low single- to
low-double-digit milliseconds (max observed: 25.42ms, indistinguishable from ordinary query
jitter) — **no read ever stalled waiting for the DDL to finish**, at 50,000 rows in both
`userDeviceTokens` and `billingSmsUsage`. `SHOW FULL PROCESSLIST` samples during each op never
showed a `Waiting for table metadata lock` state for the concurrent reader. This is the
expected/correct signature for:

- **ENUM-extend `MODIFY COLUMN`** (003): appending new enum values at the end without changing
  the underlying storage size (≤255 values ⇒ 1-byte storage) — MariaDB 12.1 treats this as a
  metadata-only, non-blocking change.
- **`DROP INDEX` + `ADD UNIQUE KEY` combined ALTER, and `ADD INDEX`** (004): standard InnoDB
  secondary-index operations use `ALGORITHM=INPLACE` by default, which permits concurrent DML
  and only briefly holds a metadata lock at statement start/end (too brief to be visible at a
  30ms poll interval here).

**Data-integrity finding**: the post-migration `userDeviceTokensAppIdConflictArchive` table
had **0 rows** even at 50,000 rows (verified: `COUNT(DISTINCT userId,deviceId,appId) = COUNT(*) = 50000`)
— confirming migration 004's own documented claim ("this op will find zero doomed rows in
practice, since the old uk is a strict subset of the new key's columns") holds at prod-shaped
scale, not just on the empty dev table.

### Projected production duration

At 50,000 rows — an order of magnitude above this platform's current real table sizes
(`userDeviceTokens`/`oauthRefreshTokens` are in the low hundreds in dev; this is a genuinely
small multi-store B2B platform, not a mass-consumer app) — total actual DDL/DML execution time
across all 18 ops was **813ms**. A real `conductor run` applies these in-process (no
subprocess-per-op spawn overhead, which accounts for essentially all of the 2,359ms − 813ms =
1,546ms gap above), so the **projected production migration window is well under 1 second**,
with zero observed read-blocking at any point. R3 (enum/uk migrations lock or partially fail
on prod-scale tables) is **closed** — no mitigation beyond what's already in the migration
files (additive enums, archive-first uk swap) is warranted.

### Cleanup

`kiosk_buykiosk_rehearsal` and `kiosk_users_rehearsal` dropped after the rehearsal; verified
absent via `SHOW DATABASES LIKE '%rehearsal%'` (empty). Real `kiosk_buykiosk`/`kiosk_users` row
counts spot-checked unchanged before/after (only expected delta: `migration_log` +1 row for
the new `20260722_051_007_perf_indexes` migration, see T8.3 below).

---

## T8.3 — Perf Gate (NFR-1)

### Seeded profile (ob03, prefix `t83_`)

| Element | Count | Notes |
|---|---:|---|
| Central users (kiosk_users) | 50 | 1 owner (role=1) + 49 staff (role=4), all `canLogin=1, active=1, enabled=1` |
| userStoreAssignments | 50 | all `isActive=1` |
| Shifts (scheduleShifts) | 500 | across 10 weeks (Monday-anchored), round-robin across the 49 staff |
| Published weeks (schedulePublished) | 10 | one per week, `publishedByUserId` = owner |
| Availability (scheduleAvailability) | 29 (~60% of 49 staff) | one weekly recurring row each |
| Device tokens + refresh tokens (app adoption) | 30 users | alternating `team`/`live`, owner always `live` (satisfies the M10-06 manager/owner-Live-login half of the conjunction) |
| Positions (schedulePositions) | 1 | "T83 Cashier" |

Activation: **`SchedulingActivationService::activate()`** called directly (the real service,
not raw SQL), per the task's explicit "service call" instruction.

**Seeding-order bug found and fixed during this task**: the first seeding attempt inserted
device/refresh tokens **before** calling `activate()`, backdated 2 days. `AppAdoptionDetector`'s
I3 fix ("pre-activation credit exclusion") correctly excluded all of them as pre-activation
noise, so the `apps` step stayed `notStarted` even at 60% adoption. Fixed by moving activation
to immediately after central-user seeding, before any adoption-signal writes — a genuine,
useful confirmation that the I3 fix works correctly against a real (non-grandfathered)
activation.

**Operational finding — shared fixture-store hazard**: mid-session, ob03's `schedulingProvider`
and store-local scheduling data were found reset back to baseline by an evidently-concurrent
process (archive-table rows appeared with `resetRunId`s and position names — `T13Q Cashier`,
`T7 Walk Cashier` — matching other Onboarding integration test suites' own fixture prefixes,
not this task's `t83_`). ob03 is shared fixture infrastructure; another agent/test run appears
to have executed the Integration test suite (which resets ob03 in its own `finally` blocks)
during this task's perf-test window. Re-seeding immediately before each measurement worked
around it. **This is a real, worth-flagging gap in the T0.7 fixture-store strategy**: nothing
currently reserves/locks a fixture store for the duration of a manual perf-testing session, so
concurrent automated test runs against the same store can silently clobber long-running manual
work. Recommend the launch runbook note this explicitly for anyone running ad-hoc perf/manual
testing against `ob01`-`ob03` (avoid overlapping with an Integration-suite run).

### Server-Timing implementation

`OnboardingApiController::state()` now times two phases and emits a `Server-Timing` header
(W3C format): `total` (the whole handler body) and `detector` (the
`OnboardingService::getState()` call alone — the framework + detector-sweep phase where the
NFR-1 query budget is spent). Small additive edit — timing wraps only, no logic changed.

- `src/BuyerKiosk/Onboarding/Controllers/OnboardingApiController.php` — `state()` +
  new private `setServerTimingHeader()`.
- Test added: `testServerTimingHeaderIsPresentWithTotalAndDetectorEntries` in
  `tests/Unit/Onboarding/Controllers/OnboardingApiControllerStateTest.php` — asserts the header
  is present and matches `^total;dur=\d+(\.\d+)?, detector;dur=\d+(\.\d+)?$`.
- **Validation**: 17/17 tests in that file green; 542/542 (1 pre-existing skip, unrelated)
  across the full `tests/Unit/Onboarding` + `tests/Unit/Scheduling/Onboarding` suites; PHPStan
  clean on the touched file.
- **Confirmed live**: the header appears on real HTTP responses through Apache, e.g.
  `server-timing: total;dur=38.55, detector;dur=21.38`.

### Load test: method, results, gate

**Method**: `dev2.buyerkiosk.com` was found to resolve through **ngrok to a real public AWS
endpoint** (`dig` confirms a ngrok CNAME → real internet IPs) — hitting it directly would
measure ngrok tunnel + real internet round-trip latency, not local Apache performance (a single
homepage request through ngrok took 2.76s vs 230ms via loopback). Used `CURLOPT_RESOLVE` to pin
the TLS SNI/`Host` header to `dev2.buyerkiosk.com` (serving the correct named vhost) while
forcing the TCP connection to `127.0.0.1` — the task's own sanctioned "localhost vhost"
alternative, giving a true local-Apache measurement.

**Auth**: rather than fabricate legacy group/permission rows for a synthetic user (this
codebase's authorization is a legacy `uf_group`/`uf_authorize_group` system, separate from
`userStoreAssignments` — see `checkStoreGroup()`), a real PHP session file was written directly
to `/var/tmp` (confirmed via a temporary public probe script, immediately removed, to be
Apache/mod_php's actual resolved session save path) logging in as the existing dev account
`rvanvuren` (id=28, Administrator group — `all_stores` + `uri_schedule_manage` both `always()`)
via `\UserFrosting\UserLoader::fetch()`, matching `middleware/UserSession.php`'s own
`$_SESSION["userfrosting"]["user"]` session-key convention exactly. `ONBOARDING_HUB_ENABLED`
was already `true` in the live `.env`; `SCHEDULING_FREE_PILOT_STORES` (already `ob01`) was
temporarily extended to `ob01,ob03` for the test window and restored MD5-identical afterward
(verified: `f96feb7644da0ce11d37e3478a7345c7` before and after). **Note**: the app's dotenv load
(`Dotenv::createImmutable(__DIR__."/../")` in `config-userfrosting.php`) resolves to the
**repo-root** `.env`, not `userfrosting/.env` — a genuinely easy mistake (there are two
different `.env` files in this tree with different content) worth flagging for anyone else
toggling flags for a live-Apache test.

**Load** (real HTTP, GET `/api/ob03/schedule/onboarding/state`, session-cookie authenticated,
10 concurrency lanes via `curl_multi`, connection reuse enabled — keep-alive matches a real
client's steady-state behavior — 30 iterations/lane, 300 requests total):

| Metric | Value |
|---|---:|
| Requests completed | 300 / 300 |
| HTTP status | 300× `200` |
| Errors | 0 |
| Total wall time | 5,446.6 ms |
| Throughput | 55.1 req/s |
| min | 49.81 ms |
| **p50** | **172.29 ms** |
| p90 | 198.14 ms |
| **p95** | **209.09 ms** |
| p99 | 328.58 ms |
| max | 382.10 ms |

**GATE: p95 ≤ 2s → PASS** (209ms, ~9.6× headroom).

Server-Timing samples from the SAME requests show the actual `OnboardingApiController::state()`
processing cost is only ~10–33ms (`total;dur=...`) — the ~150–200ms client-observed floor is
Apache/mod_php's fixed per-request bootstrap (full framework init, Sentry, Dotenv parse, Twig
setup) common to **every** route on this traditional (non-persistent-process) PHP deployment,
not onboarding-specific cost. This is useful context: the onboarding hub's own logic contributes
a small fraction of the observed latency; the NFR-1 budget is met with large headroom on both
readings.

### Query-count regression (steady state, t83_ profile)

Reproduced `OnboardingServiceQueryCountTest::testPopulatedActivatedStoreSteadyStateQueryCount()`'s
priming-pass + measured-pass methodology as a standalone script (that test owns its own,
much smaller, `t13q_` fixture on ob03 and asserts "ob03 must start unactivated" as a
precondition — incompatible with running alongside this task's much larger, already-activated
profile on the same store).

| | Central queries | Store queries | Raw total |
|---|---:|---:|---:|
| Pinned baseline (`OnboardingServiceQueryCountTest`, 2 staff / 1 shift fixture) | 11 | 4 | 15 |
| **This run (50 staff / 500 shifts / 10 published weeks)** | **10** | **4** | **14** |

**Store-side count is bit-for-bit identical** (4 queries) at 500× the shift volume and 25× the
staff volume of the pinned baseline's fixture — direct confirmation the detector queries are
genuinely aggregate/batched, not row-count-driven (no N+1 growth as the NFR-1 concern requires).

**The 1-query central delta is fully explained, not a regression**: `storeSettings` remains
`notStarted` in this profile (real `SchedulingActivationService::activate()` only ever writes
the `activate` terminal row — `storeSettings` requires an explicit manual `/confirm` action by
design, M6-02; it never auto-completes via a service-call activation, unlike the pinned
baseline's use of `onboarding-grandfather-backfill.php`, which retroactively force-inserts
*both* rows for legacy-store migration purposes). With one required step still non-terminal,
`OnboardingService::maybeRecordFlowCompletion()`'s per-poll loop returns early at the first
non-terminal required step and never reaches its own `onboarding.flow_completed` `INSERT IGNORE`
query — accounting for exactly the missing 1 central query. Query count is confirmed to vary
with **flow-completion state** (8 for a virgin store → 10 here → 11 at full completion), never
with roster/shift **row count**.

### Index decision (EXPLAIN evidence)

**`oauthRefreshTokens(userId, clientId, lastUsedAt)` — SHIPPED**
(`migrations/input/20260722_051_007_perf_indexes.json`, applied via
`scripts/apply-051-perf-indexes.php`, idempotency-verified twice, `migration_log` row confirmed).

`EXPLAIN` on the `AppAdoptionDetector` refresh-token aggregate
(`WHERE userId IN (<ob03's 50 userIds>) AND clientId IN ('team','live') GROUP BY userId, clientId`)
against 113 real dev rows:

| | Before | After |
|---|---|---|
| `type` | `ALL` (full scan) | `range` |
| `key` | `NULL` | `idx_userId_clientId_lastUsedAt` |
| `Extra` | `Using where; Using temporary; Using filesort` | `Using index condition` |

`ANALYZE SELECT` (MariaDB's `EXPLAIN ANALYZE` equivalent) with `FORCE INDEX` confirms real rows
touched drops from 113 (full table) to 30 (exactly the matching rows), eliminating the
temp-table + filesort materialization entirely. **Honest caveat**: at the current 113-row scale,
MariaDB's own cost-based optimizer still *naturally* chooses the full-scan plan (a scan of 113
rows is cheap regardless) — the index doesn't change today's *chosen* plan, but removes the
structural temp-table/filesort/full-scan pattern that would degrade as this table grows past the
point where a full scan of a large table costs more than a selective range lookup. Shipping it
now is zero-risk (purely additive, and this exact op type — `add_index` — was independently
confirmed in the T8.2 rehearsal to complete in ~100–200ms with zero blocking even at 50,000
rows, two orders of magnitude above this table's current size).

**`userStoreAssignments(typeNum, isActive)` — SKIPPED (documented, with numbers)**

`EXPLAIN` on the Roster/Invites detectors' shared join shape
(`... INNER JOIN userStoreAssignments usa ON usa.userId=u.id WHERE usa.typeNum=:t AND usa.isActive=1 AND u.enabled=1 AND usa.role<>1`)
against 181 real rows (`ANALYZE TABLE` run first to rule out stale statistics):

- **Without** the candidate index: `type=ALL, key=NULL, rows=181` (full scan) — despite
  `possible_keys` already listing `idx_typeNum`.
- **With** the candidate composite index added experimentally to the (throwaway, already-built)
  rehearsal clone and the equivalent query re-run there (130 rows, comparable shape):
  **still `type=ALL, key=NULL`** — MariaDB's optimizer chooses a full scan of this small a
  table *regardless of which indexes are available*, so the composite index would not change
  today's query plan at all.

**Decision**: skip. This is genuinely "fine at scale" today — a 181-row (or even a 1,000-row)
full scan executes in sub-millisecond, and the optimizer's own experimentally-verified
indifference to the candidate index at comparable row counts means shipping it now would add
write-path overhead (every `userStoreAssignments` insert/update pays for one more index) with
zero read-side benefit. Documented here rather than shipped speculatively; revisit if/when this
table's row count grows by 1-2 orders of magnitude (multi-hundred-store platform scale).

### Cleanup (verified zero-leftover)

`scripts/onboarding-reset-store.php ob03 --confirm` (resets `schedulingProvider` + archives/
deletes central onboarding* rows + store-local scheduling data) + a companion script deleting
every `t83_`-prefixed central row (users, userStoreAssignments, userDeviceTokens,
oauthRefreshTokens) by the exact IDs recorded at seed time. Verification sweep after cleanup:

```
ob03.schedulingProvider = 'none'                    (expect 'none')          ✓
leftover t83_ users: 0                                                        ✓
leftover ob03 userStoreAssignments: 0                                         ✓
leftover t83_ device tokens: 0                                                ✓
leftover t83_ refresh tokens: 0                                               ✓
leftover ob03 scheduleShifts: 0                                               ✓
leftover ob03 schedulePositions: 0                                            ✓
leftover ob03 scheduleAvailability: 0                                         ✓
leftover ob03 schedulePublished: 0                                            ✓
```

`kiosk_buykiosk_rehearsal` / `kiosk_users_rehearsal` dropped (`SHOW DATABASES LIKE '%rehearsal%'`
returns empty). `userfrosting/.env` and repo-root `.env` both restored MD5-identical to their
pre-task state.

---

## Summary for the T8 launch-gate checklist

- [x] **T8.2**: all six 051 migrations rehearsed on a prod-shaped clone; 18/18 ops succeed pass 1,
      18/18 skip pass 2 (idempotent); zero read-blocking observed on either lock-risk op family
      at 50,000-row scale; projected prod duration well under 1 second; R3 closed.
- [x] **T8.3**: 95th-percentile store profile seeded and load-tested; **p95 = 209ms ≤ 2s gate,
      PASS**; query-count regression confirms row-count independence (store side identical,
      central delta fully explained by flow-completion state); Server-Timing live in production
      code + tested; one index shipped with EXPLAIN evidence, one index evaluated and
      deliberately skipped with EXPLAIN evidence; all seeded/temporary state cleaned up and
      verified zero-leftover.

**Deviations from the task's literal ask** (Deviation Protocol):
1. Synthesized 50,000 extra `billingSmsUsage` rows beyond the explicit ask (only
   `userDeviceTokens` was named) — done so both lock-risk op families get a meaningful
   prod-scale rehearsal, not just one.
2. Load test ran against the local Apache vhost via `--resolve`-pinned loopback rather than the
   literal `dev2.buyerkiosk.com` DNS name, because that name resolves through ngrok to a real
   public endpoint — using the task's own explicitly sanctioned "localhost vhost" alternative.
3. `20260722_051_007_perf_indexes.json` ships only the `oauthRefreshTokens` index, not the
   `userStoreAssignments` one named alongside it in the task text — the latter is a documented
   skip decision, not an oversight (see EXPLAIN evidence above).
