# Spec 051 — Scheduling-Free Launch Runbook (T8.9)

Operational sequence for taking Scheduling-Free (Spec 051) from "code merged,
flags off" to "M2 announcement published." Every step below is a manual,
deliberate action — nothing in this runbook auto-triggers from a deploy. Per
the PRD Release Manifest: *"Rollout order: M1 code lands dark behind its kill
switch → M3–M12 land behind the hub/walkthrough/invite flags → flags enabled
and E2E-verified on pilot stores → M2 announcement publishes. The announcement
is a separately-timed manual action, never coupled to a deploy."*

**Companion doc:** [`051-rollback-playbook.md`](./051-rollback-playbook.md) —
read that FIRST if something in this sequence needs to be undone; this file
only covers the forward path.

---

## 0. Prerequisites (verify before starting)

- [ ] T7 integration gate signed off (`contracts/t7-criterion-audit.md`).
- [ ] T8.1 (this pass) — full suite green, coverage targets met/justified, PHPStan clean.
- [ ] T8.2 migration rehearsal complete.
- [ ] T8.3 perf gate (p95 ≤2s) passed.
- [ ] T8.4 security pass (this pass) — matrices green, token sweep clean, ingress
      redaction live (§7 of `051-launch-monitoring.md`).
- [ ] T8.5 accessibility pass complete.
- [ ] T8.6 live-transport deliverability VERIFIED (email + SMS) — **see Decision-Gate
      Checklist Q3 below; NOT yet run as of this runbook's authoring.**
- [ ] T8.7 kill-switch/rollback drill timed and documented.
- [ ] T8.8 pilot E2E on pc00 + one fixture store — full journey including phone
      activation, apps, clock-in.
- [x] T8.10 business acceptance — ACCEPTED IN FULL 2026-07-22 (evidence package: spec contracts/t8-10-acceptance-evidence.md).
- [ ] Apps-step self-completion audit (2026-08-10 finding): the apps step's
      Live-login half needs `managerOwnerWithLiveLogin > 0` over the
      `usa.role IN (1,2)` population (`AppAdoptionDetector`). A store with ZERO
      role-1/role-2 `userStoreAssignments` rows (owner administers via web
      permissions only, never added to the roster) can NEVER self-complete the
      apps step — it stays in-progress until someone clicks the manual "Mark
      done". Found live on fixture ob01. Pre-fleet check — count affected stores:
      `SELECT s.typeNum FROM kiosk_buykiosk.stores s LEFT JOIN kiosk_users.userStoreAssignments usa
       ON usa.typeNum = s.typeNum AND usa.isActive = 1 AND usa.role IN (1,2)
       WHERE s.dev = 0 GROUP BY s.typeNum HAVING COUNT(usa.id) = 0;`
      If the count is material, either backfill owner assignments or accept
      manual completion as the documented path for those stores.
- [ ] `BASE_URL` env var explicit in prod (2026-08-10 finding): background jobs build
      public links from `$_ENV['BASE_URL']` with fallback `https://buyerkiosk.com`
      (`InviteDeliveryJob::buildActivationUrl`, `VonageTextSender`,
      `TwilioDeliveryHandler`) — the fallback IS the prod domain so prod works unset,
      but set it explicitly anyway so a future domain/host change can't silently
      point invite SMS/email links at the wrong site. On dev boxes it MUST be set
      (`BASE_URL=https://dev2.buyerkiosk.com`) or every invite link goes to
      production — found live when the owner's invite SMS linked to prod. Worker
      restart required after setting (workers cache env at boot).

**None of the above is satisfied by this runbook.** This file assumes all
prior T8 gates are green and documents ONLY the flag-flip/publish sequence
and its verification steps.

---

## 1. Flag-flip order

Each flag is an env var, read via the house boolean pattern
(`ViteService.php:89-95`) and independently toggleable without a deploy
(NFR-8). **Every env var change requires restarting both the web process(es)
AND any running TaskEngine workers** to take effect consistently — a
long-running PHP-FPM pool or `php bin/task worker:start` process does not
pick up a `.env` edit mid-run (same caveat `campaign-alert-contract.md` §1
documents for `ONBOARDING_CAMPAIGN_ID`; it applies to every flag below).

| # | Flag | Effect when ON | Verification step | Rollback line |
|---|---|---|---|---|
| 1 | `SCHEDULING_FREE` (fleet) or `SCHEDULING_FREE_PILOT_STORES` (pilot list, CSV of typeNums) | Scheduling (+ AI scheduling, + chat via `isChatEntitled`) entitled without commercial premium; trial banner/endpoints disabled | `SchedulingEntitlement::isEntitled($typeNum)` returns true for the target store; hit `/admin/:typeNum/schedule` and confirm no marketing/upgrade page renders | Unset both vars → `isEntitled` reduces to `PremiumService::isPremiumActive()` (byte-identical to pre-051); re-enable gate, **no data loss** (Release Manifest) |
| 2 | *(pilot list step)* Add target store(s) to `SCHEDULING_FREE_PILOT_STORES` BEFORE fleet-wide `SCHEDULING_FREE=true` | Pilot-only rollout window before fleet flip | Confirm exactly the intended typeNums see entitlement; a non-pilot store must NOT | Remove the typeNum from the CSV; store reverts to commercial-premium-only gating |
| 3 | `ONBOARDING_HUB_ENABLED` | M3–M12 hub + checklist steps render at `/admin/:typeNum/schedule/get-started`; sidebar shows persistent "Setup" entry (D-4) | Hub renders; each step shows evidence-backed status; grandfathered pilot store auto-completes every detectable step (M3-10) | Unset → hub route falls back to legacy behavior (marketing page pre-051, or 302-to-calendar per the route matrix); **progress data retained**, nothing deleted |
| 4 | `ONBOARDING_INVITES_ENABLED` | `POST /:typeNum/api/team-members/:id/invite` and `/invite/all` become live (previously 404 dark-deploy); `InviteDeliveryJob`/`InviteSweepJob` actually send | Send one test invite to a real, disposable address/number on a pilot store; confirm delivery + activation ladder | Unset → invite endpoints 404 again; `InviteDeliveryJob`/`InviteSweepJob` **skip and leave rows queued/untouched** (see rollback playbook — tokens ALREADY ISSUED remain valid, this flag does not revoke them) |
| 5 | `ONBOARDING_WALKTHROUGH_ENABLED` | First-schedule guided walkthrough (M11) becomes active on the calendar page | Trigger the walkthrough on a pilot store; confirm all beats + AI-skip path | Unset → checklist step degrades to deep link + support article (M11-08), never a broken button |
| 6 | *(fleet-wide)* `SCHEDULING_FREE=true` with NO pilot list scoping | Every BK-native-eligible store gets scheduling free | Spot-check several non-pilot stores | Same as row 1 |

**Order rationale:** M1 (row 1, pilot-scoped) lands FIRST and dark — it only
changes entitlement, nothing user-visible changes until row 3 (hub) is also
on. Hub before invites before walkthrough matches the checklist's own step
dependency order (a store must see the hub before it can reach the invites
step; invites must work before the roster step feeds the apps/walkthrough
steps). Fleet-wide flip (row 6) is the LAST flag change before pilot
graduation — see §2.

---

## 2. Pilot entry / exit criteria

**Entry (a store becomes part of the pilot):**
- [ ] Store is BK-native-eligible (provider unset/none, or already
      `buyerkiosk` — never WIW/Homebase; M5's external-provider page is the
      correct experience for those, not this pilot).
- [ ] Store added to `SCHEDULING_FREE_PILOT_STORES`.
- [ ] `ONBOARDING_HUB_ENABLED`, `ONBOARDING_INVITES_ENABLED`,
      `ONBOARDING_WALKTHROUGH_ENABLED` all ON fleet-wide (these three are not
      pilot-scoped individually — only entitlement is; a pilot store simply
      gets entitlement early, then rides the same hub/invite/walkthrough
      code every other dark store would use once entitled).
- [ ] T8.8's full journey (activation → settings → positions → roster →
      invites → apps → walkthrough → availability → first publish → first
      clock-in) run and verified live on this exact store.

**Exit (pilot graduates to fleet-wide, i.e., row 6 above):**
- [ ] Zero unresolved P0/P1 findings from the pilot run.
- [ ] T8.6 deliverability gate passed (Q3 below) — invite emails/SMS are not
      landing in spam.
- [ ] Support-ticket rate for the pilot store(s) at or below the D-10
      baseline (`051-kpi-queries.md`).
- [ ] Business acceptance (T8.10) sign-off obtained.

**Re-testing a pilot store's first transitions:** `ob01`/`ob02`/`ob03` are the
disposable fixture stores for THIS PURPOSE (T0.7, risk R11) — use
`scripts/onboarding-reset-store.php <typeNum> --confirm` to replay a first
activation journey from a clean slate. **`pc00` is the real, human-used pilot
store and is NOT reset** — its onboarding progress is real and latched (M3-05);
do not run the reset script against it.

---

## 3. Grandfather-backfill + baseline-snapshot execution (exact commands)

Both scripts require a `--campaign=<slug>` matching the slug pattern
`^[a-z0-9][a-z0-9-]{0,39}$` (campaign-alert-contract.md §1) — pick the SAME
slug for both scripts AND the `ONBOARDING_CAMPAIGN_ID` env var that will be
set in §4 below (e.g. `launch-2026q3`). Run in this order:

**Step 1 — Grandfather backfill** (idempotent, safe to re-run; run BEFORE the
baseline snapshot so the snapshot's cohort classification sees the corrected
`activate`/`storeSettings` terminal rows):
```bash
# Dry run first - always.
php userfrosting/scripts/onboarding-grandfather-backfill.php --all-stores --campaign=launch-2026q3 --dry-run

# Review the candidate list, then:
php userfrosting/scripts/onboarding-grandfather-backfill.php --all-stores --campaign=launch-2026q3 --confirm
```
This inserts `activate` + `storeSettings` terminal rows (detection=`auto`,
evidence `{grandfathered:true,...}`) for every store already on
`schedulingProvider='buyerkiosk'` with no `activate` row yet — closing the I4
gap (a store that activated via the Premium `TrialService`/`AdminPremiumApiController`
off-path, post-051, would otherwise be misclassified as grandfathered by
`ActivationDetector`'s runtime branch). **Re-run this periodically** (or
on-demand whenever `ActivationDetector`'s "runtime grandfathered branch
firing" warning appears in logs) — see the script's own "Known residual gap"
docblock section.

**Step 2 — Baseline snapshot** (REFUSES to overwrite an existing snapshot for
the same campaign — exit 3 — so re-running with `--confirm` against an
already-snapshotted campaign is safe/inert, not destructive):
```bash
# Dry run first - always. Requires the D-10 ticket-taxonomy CSV (support's
# trailing-60-day scheduling-setup ticket classification, columns:
# date,ticketId,tag,storeTypeNum).
php userfrosting/scripts/onboarding-baseline-snapshot.php --campaign launch-2026q3 --dry-run --tickets-csv path/to/scheduling-setup-tickets.csv

# Review cohort counts (eligible / grandfathered / external) and the KPI
# baseline numbers, then:
php userfrosting/scripts/onboarding-baseline-snapshot.php --campaign launch-2026q3 --confirm --tickets-csv path/to/scheduling-setup-tickets.csv
```
Writes `kiosk_buykiosk.onboardingCampaignCohort` rows (one per store,
`UNIQUE(campaignId, typeNum)`) — the **immutable KPI denominator source**
for the entire campaign window (M2-04). Confirm the artifact JSON (checksum +
per-cohort counts) is committed somewhere durable (this runbook's own commit
history, or an ops ticket) — it is the only record tying `campaignId` back to
its baseline.

**Step 3 — Campaign registry line.** Append to THIS runbook (or the ops
ticket tracking this launch) the durable mapping recorded by the publish
sequence in §4 below:
```
{"campaignId": "launch-2026q3", "alertId": <id>, "publishedAt": "<ISO8601>"}
```

---

## 4. Publish sequence (M2 announcement — manual action, sequenced LAST)

Per M2-03 (*"publishable only as a manual action, sequenced after all
launch-gated features are verified live"*) and this plan's own naming: **the
announcement is the GATE CONSUMER, not a gate itself** — everything above
this line must already be true.

1. **Baseline snapshot already ran** (§3 — must precede this step; a
   snapshot taken AFTER the campaign env var is set, or after the alert is
   live, would double-count campaign-driven activity into the "baseline").
2. **Set `ONBOARDING_CAMPAIGN_ID=launch-2026q3`** (same slug as §3) on every
   web process and TaskEngine worker (restart required — see the flag-flip
   note above). From this moment, every `onboardingEventLog` write is
   stamped with this campaign.
3. **Publish the System Alert:**
   ```bash
   curl -X POST https://dev2.buyerkiosk.com/api/system-alerts/admin/alerts \
     -H "Content-Type: application/json" \
     -d '{
       "title": "Scheduling is now included — free, for every plan",
       "message": "<copy safe for every role per Decision D-3>",
       "ctaLabel": "Get Started",
       "ctaUrl": "/admin/schedule/get-started?source=alert",
       "targetType": "all"
     }'
   ```
   (Use the real admin UI/session-authenticated call in production — the
   `curl` above is illustrative of the exact payload shape per
   `campaign-alert-contract.md` §2.) Record the returned `alertId`.
4. **Append the campaign registry line** (§3 Step 3) with the real `alertId`
   and `publishedAt` timestamp — this is the ONLY durable record tying
   `campaignId` back to the alert that carried it (no dedicated DB table for
   this mapping, per `campaign-alert-contract.md` §4).
5. **Monitor** per `051-kpi-queries.md` and `051-launch-monitoring.md` from
   this moment — the 7-day M2-02 conversion window and the 7-day invite/app-
   adoption windows both anchor to `publishedAt` from this step, NOT to
   campaign close.

**Authorization for step 3 (announcement publish) is a MANUAL ACTION,
performed only AFTER T8.10 business acceptance sign-off records every
launch-gated Release Manifest feature as verified live on pilot.** No
automation in this codebase publishes a System Alert as a side effect of any
deploy, migration, or flag flip — this is enforced by process, not code.

---

## 5. Decision-Gate Checklist

Walk this list before authorizing step 3 above.

- [x] **Q1 — RESOLVED VOID 2026-07-22 (product owner): premium billing module never hooked up, no real paying subscribers — no commercial lever to execute; announcement blocker lifted.** Original text:
      Owned by product/billing (Spec 036 territory). Per the PRD: *"Announcement
      blocker (DR-2), not an SDD-start blocker"* — dark deploys and pilot
      testing may proceed with Q1 open, but **the M2 announcement (step 3
      above) MUST NOT be authorized while Q1 is open.** Verify current status
      with product/billing before proceeding.
- [x] **Q3 — Invite email/SMS deliverability: VERIFIED 2026-07-22 by the product
      owner.** Email: SendGrid engine → Gmail Primary INBOX (live smoke) +
      MailGenius 83/100 — DKIM signed/valid/ALIGNED pass, SPF authorized pass,
      Primary Inbox placement pass. SMS: live Vonage send received on the owner's
      phone through the full real path (do-not-text, flood, 037 billing under
      scheduling_invite). Optional non-blocking DNS improvements: SPF `?all`→`~all`
      (one character); BIMI record (branding; needs trademarked logo). Original
      gate text follows for reference: **D-9 amended 2026-07-22:**
      `InviteEmailTransport`'s email engine is now **SendGrid API PRIMARY,
      PHPMailer/SMTP secondary** (engine selected at send-time: `SENDGRID_API_KEY`
      present → SendGrid API; else `SMTP_HOST` present → PHPMailer/SMTP; else
      fail-fast outside dev, unchanged) — sender remains pinned
      `noreply@buyerkiosk.com` regardless of which engine is selected. The
      *verification* step is separate and launch-gated: T8.6 must confirm
      **aligned authentication results** (SPF pass + DKIM verdict + DMARC
      alignment in the `Authentication-Results` header) AND inbox-vs-spam
      placement on Gmail + Outlook — placement alone is not sufficient per
      D-9's own text. DNS posture as of D-9's audit: SPF authorizes prod IPs +
      `sendgrid.com`; DMARC is `p=none` (monitoring only, no enforcement, no
      alignment protection yet); DKIM signing status was UNVERIFIED at D-9
      time. **Operator step for T8.6:** the smoke run now exercises the
      SendGrid API path by default, which requires `SENDGRID_API_KEY` to be
      set in the root `.env` for whichever environment runs the smoke (this
      key was NOT present as of this runbook's authoring — set it before
      running T8.6, or the transport will fall through to the SMTP engine /
      fail-fast path instead of testing the intended primary engine).
      **If PHPMailer/SMTP has to be exercised instead** (e.g. investigating a
      SendGrid-specific deliverability regression), unset `SENDGRID_API_KEY`
      and set `SMTP_HOST` — PHPMailer is now pinned at v6.12.0 in
      `composer.lock` (matching `composer.json`'s `^6.9`), so the SMTP engine
      is genuinely usable, not just test-seam-compatible. **Do not authorize
      step 3 until T8.6 has an actual pass/fail result, not an assumption.**
- [ ] **Q4 — Detection thresholds + ticket taxonomy: SIGNED, D-10.** PRD
      defaults (≥2 active non-owner members / 80% pay+hours / 50% app
      adoption / 50% availability / 30-day app-login freshness) are locked
      into `SchedulingFlowDefinition` as of T2.1 — no action needed here
      beyond confirming the ticket-taxonomy CSV (§3 Step 2) uses the D-10
      column format (`date,ticketId,tag,storeTypeNum`).
- [ ] **Q5 — Team Chat entitlement: RATIFIED, D-8 → chat is FREE alongside
      scheduling.** **Documentation-hygiene finding (flag, not fixed this
      session):** `product-requirements.md`'s own "Open Questions" section
      (near the end of the Decisions Log) contains a STALE line reading
      *"chat stays premium in v1"* that contradicts D-8's actual, ratified
      text two paragraphs above it (*"Team Chat becomes FREE alongside
      scheduling... RATIFIED by product 2026-07-21"*) and contradicts the
      shipped code (`SchedulingEntitlement::isChatEntitled()` delegates to
      `isEntitled()`, proven by `SchedulingEntitlementTest`'s full matrix and
      `AllFlagsMatrixIntegrationTest`'s T7.5 chat-invariant check). **Ground
      truth for this launch: chat is FREE.** The stale PRD line should be
      corrected by whoever next edits that document, but does not block this
      launch — code and tests already agree, only the PRD's Open Questions
      summary line drifted after D-8 was later overridden.
- [ ] **Mobile `clientId` min-versions: PENDING mobile team replies.** Backend
      work for BOTH deliverables in `contracts/mobile-contract.md` is DONE on
      branch (T4.1/T4.1b — login passthrough both apps, Live refresh-rotation
      preservation, Team+ appId-scoped logout). What remains open is the
      **mobile teams' reply** with an owner + minimum shipped app version for
      each of Team+ and Live (tracking table, `mobile-contract.md` line 256+)
      — checked `docs/api/mobile-agent-requests.md` and
      `docs/api/live-agent-requests.md` this session: **no replies recorded
      yet.** Impact of proceeding without them: `appLoginObserved` under-
      counts adoption until each app ships the field (interim device-token OR
      -path already mitigates this per ADR-051-7 — M10 is manual-eligible, so
      this does NOT block launch, only affects M10's auto-detection accuracy).
- [ ] **Dev `user_id_master` row verification: MISSING in this local dev DB.**
      Checked this session: `kiosk_users.users` has **no row with `id=1`**
      (`config-userfrosting.php`'s `user_id_master => 1`). This is a
      dev-environment gap carried from the T3-T6 phase summary's carry-list
      (*"missing dev `user_id_master` row (launch verification)"*) — confirm
      the PRODUCTION `kiosk_users.users` table has a valid row at the
      configured master ID before relying on any master-account-special-cased
      code path (`AccountController`/`UserController` compare against this
      ID for install-time/immutable-admin semantics). Not a spec-051 concern
      per se, but flagged here because it was specifically named as a T8
      launch-verification carry-item.
- [ ] **PHPMailer version resolution: STILL MISMATCHED.** Verified this
      session: `composer.json` declares `phpmailer/phpmailer: ^6.9`
      (namespaced `PHPMailer\PHPMailer\PHPMailer`) but the checked-in
      `composer.lock` pins `v5.2.10` (global, non-namespaced `\PHPMailer`).
      `InviteEmailTransport::buildMailer()` constructs the NAMESPACED class
      unconditionally when no test factory is injected — **this will fatal
      with a class-not-found error on any REAL (non-test) send** until
      `composer update phpmailer/phpmailer` (or an equivalent re-lock) runs.
      This blocks T8.6 (Q3 verification, above) from ever succeeding via the
      real transport path — **resolve this BEFORE attempting T8.6**, not
      after.

**Announcement (§4 Step 3) authorization is a MANUAL ACTION taken only
AFTER T8.10 (business acceptance) records every Must-Have Release-Manifest
feature verified live on pilot, AND every checklist item above is either
resolved or explicitly accepted-as-a-known-risk by product.** This is a
process control, not a code gate — nothing in this codebase prevents
publishing early; discipline here is what the Release Manifest depends on.

---

## References

- `docs/specs/051-scheduling-onboarding/product-requirements.md` — Release
  Manifest, Decisions Log, Open Questions
- `docs/specs/051-scheduling-onboarding/implementation-plan.md` — T8 section
- `docs/specs/051-scheduling-onboarding/contracts/campaign-alert-contract.md`
- `docs/specs/051-scheduling-onboarding/contracts/mobile-contract.md`
- `docs/specs/051-scheduling-onboarding/contracts/t7-criterion-audit.md`
- `userfrosting/docs/runbooks/051-launch-monitoring.md`
- `userfrosting/docs/runbooks/051-rollback-playbook.md`
- `userfrosting/docs/runbooks/051-kpi-queries.md`
- `userfrosting/docs/runbooks/051-invite-observability.md`
