# Solution Design Document

**Spec ID:** 051
**Feature:** Scheduling Onboarding & Activation (Scheduling Included in All Plans)
**Companion PRD:** [product-requirements.md](./product-requirements.md)
**Status:** Revised after Codex SDD review (2026-07-21) — ready for Implementation Plan

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] / [PENDING-AUDIT] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has request/response schema, auth, CSRF, and error contract
- [x] Error handling covers send, event, activation, webhook, and worker recovery
- [x] Quality requirements are specific and measurable, with a valid p95 method
- [x] Every quality requirement has test coverage
- [x] Architecture decisions recorded (ADR-051-1..11); PRD variance (D-1 carve-out) amended in PRD
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

- **CON-1:** PHP 8.x backend on Slim 2.6.2 with Twig templates. New classes under `userfrosting/src/BuyerKiosk/` (PSR-4). PHPStan clean on every modified file. `catch (\Throwable)` in route handlers (PHP 8 TypeError escape hazard).
- **CON-2:** All schema changes via the conductor migration system (`userfrosting/migrations/input/*.json`). No manual SQL, ever. `migration_log` lives in the central DB.
- **CON-3:** Multi-store architecture: onboarding progress, observations, invite history, and event log are **central-DB** (`kiosk_buykiosk` / `kiosk_users`) tables keyed by `typeNum`/`userId`. Detection reads span central tables (`users`, `userStoreAssignments`, `userDeviceTokens`, `oauthRefreshTokens`, `stores`) AND per-store tables via `dbConnectByName` (**positions, shifts/published weeks, availability, timepunch, shift-task assignments are store-local**).
- **CON-4:** UI: Bootstrap 5.3.3 + `tokens.css`; every new modal follows the wrapper-relocation pattern and `bootstrap.Modal.getOrCreateInstance` (CLAUDE.md mandate). Syncfusion preferred where a component fits.
- **CON-5:** Identity: `kiosk_users.users` + `userStoreAssignments` are canonical. The deprecated store-level `employees` table is never touched. Store scoping always via `usa.typeNum = :typeNum AND usa.isActive = 1`.
- **CON-6:** Provider truth is `stores.schedulingProvider` (never legacy `wiwEnable`) — ADR-5 lineage from Spec 049.
- **CON-7:** PRD contracts are binding: Step Contract Table semantics (latched achievement + health indicators), Release Manifest flags/rollout order, permission matrix M3-11 (no new permission URIs).
- **CON-8:** The announcement rides Spec 040 System Alerts unchanged; invite SMS rides Spec 037 cost tracking (additive category — **including its three DB enum columns**, see migrations).
- **CON-9:** Destructive ops archive-first; all new tables camelCase; no `Date.now()`-style assumptions in migrations.
- **CON-10:** `PremiumService::isPremiumActive()` remains the **pure commercial predicate** — entitlement never mutates it. All safety flags default **false** (dark deploy); enabling is an explicit launch action.

## Implementation Context

### Required Context Sources

- **ICO-1 General application context**
```yaml
- doc: CLAUDE.md
  relevance: CRITICAL
  why: "Conventions: modal relocation, multi-store DB pattern, migration invariants, users-table-canonical, Syncfusion preference."

- doc: docs/specs/051-scheduling-onboarding/product-requirements.md
  relevance: CRITICAL
  why: "Step Contract Table, Release Manifest, Decisions D-1..D-7 (D-1 amended 2026-07-21), DR register — binding."
```

- **ICO-2 Premium gate (entitlement call sites)**
```yaml
- file: userfrosting/src/BuyerKiosk/Premium/PremiumService.php
  sections: [isPremiumActive() 53]
  relevance: CRITICAL
  why: "Commercial predicate — read by SchedulingEntitlement, NEVER modified (CON-10)."

- file: userfrosting/src/BuyerKiosk/Scheduling/Controllers/SchedulingPageController.php
  sections: [calendar() 88-110, settings() 172-183, timesheets() 209-220, renderMarketingPage() 515, getTrialBannerContext() 549]
  relevance: CRITICAL
  why: "Page-gate call sites swapping to SchedulingAccessPolicy + the route matrix."

- file: userfrosting/templates/themes/default/menus/sidebar.html
  sections: [scheduling dropdown 319-360, chat links 207-214]
  relevance: CRITICAL
  why: "Template call sites: scheduling → isSchedulingEntitled(); chat → isChatEntitled() (Q5 isolation)."

- file: userfrosting/templates/themes/default/scheduling/calendar.html
  sections: [AI clauses 38, 4728; trial banner include 12]
  relevance: HIGH
  why: "AI template clauses swap to isSchedulingEntitled(); checkAccess('uri_schedule_ai') remains the permission gate."

- file: userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileApiController.php
  sections: [premiumScheduling injection 173-174, getPremiumSchedulingInfo 5154-5167]
  relevance: CRITICAL
  why: "Mobile entitlement flags: scheduling+AI via SchedulingEntitlement; chat via isChatEntitled()."

- file: userfrosting/routes/premium.php
  relevance: HIGH
  why: "Trial start/activate/deactivate endpoints → 409 when scheduling free (M1-04)."

- file: userfrosting/src/BuyerKiosk/Premium/Middleware/PremiumGateMiddleware.php
  relevance: MEDIUM
  why: "Verified unwired dead code — deleted (ADR-051-8; PRD D-1 amended with this carve-out)."
```

- **ICO-3 Onboarding surfaces (hub, checklist, walkthrough)**
```yaml
- file: userfrosting/templates/themes/default/premium/marketing.html
  relevance: MEDIUM
  why: "Legacy marketing render inside calendar(); retained for kill-switch mode only. Hub is a NEW page (route matrix)."

- file: userfrosting/src/BuyerKiosk/Scheduling/Controllers/SchedulingController.php
  sections: [getConfig() 1894, updateConfig() 1976, getPositions() 1644, createPosition() 1681]
  relevance: HIGH
  why: "Settings + positions read/write paths for detectors, deep-linked steps, and starter-position creation (M7-02)."

- file: public_html/js/scheduling/ScheduleCalendar.js
  sections: [onCellSelect 2988, createShift 1613, onActionBegin 1864]
  relevance: CRITICAL
  why: "Walkthrough beat-advance events emitted here (ADR-051-4)."

- file: public_html/js/admin/scheduling/ai-scheduling.js
  relevance: CRITICAL
  why: "AI beat lifecycle (dispatch/poll/preview/apply) + M11-06 failure states the walkthrough must observe."

- file: userfrosting/src/BuyerKiosk/Replenishment/Controllers/ReplenishmentPageController.php
  sections: [showSetupWizard 179]
  relevance: LOW
  why: "House precedent for prerequisite-gated setup panel."
```

- **ICO-4 Detection signal sources**
```yaml
- file: userfrosting/src/BuyerKiosk/TeamMember/Services/TeamMemberService.php
  sections: [createTeamMember() 388-451 (note: username can be absent), stats 261-266]
  relevance: HIGH
  why: "Roster fields + the SMS-only no-username gap activation must close."

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Repositories/DeviceTokenRepository.php
  sections: [upsert 127-183, uk (userId,deviceId)]
  relevance: CRITICAL
  why: "pushRegistered signal; uk must become (userId,deviceId,appId) — Team+Live on one device currently overwrite each other."

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Services/JwtAuthService.php
  sections: [generateRefreshToken 113-137 (honors deviceInfo.clientId), refreshAccessToken 175-198 (updateLastUsed only — clientId survives rotation)]
  relevance: CRITICAL
  why: "appLoginObserved signal once apps send clientId (launch-gated deliverable)."

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Controllers/MobileAuthController.php
  sections: [login() 96-177 (deviceInfo assembly 155-160 — passthrough point), registerDeviceToken 266-360]
  relevance: CRITICAL
  why: "clientId passthrough edit + app_adopted event producers live here."

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Services/AvailabilityService.php
  relevance: HIGH
  why: "Availability detector + M12-04 coverage check source."

- file: userfrosting/src/BuyerKiosk/Stickiness/Jobs/StickinessRollupJob.php
  sections: [getMobileAppUsage 166-186]
  relevance: MEDIUM
  why: "House precedent: 30-day appId-grouped updatedAt window."
```

- **ICO-5 Invite delivery**
```yaml
- file: userfrosting/src/BuyerKiosk/TeamMember/Services/LoginAccessService.php
  sections: [sendInvitation() 307-340 (requires email; writes user-global token — superseded by invite-scoped tokens, ADR-051-11), INVITATION_EXPIRES_HOURS=24 (L45)]
  relevance: CRITICAL
  why: "Legacy issuance kept for compat; new flow issues invite-scoped hashed tokens."

- file: userfrosting/src/BuyerKiosk/TeamMember/Controllers/TeamMemberController.php
  sections: [sendInvitation 1016-1048]
  relevance: HIGH
  why: "Endpoint upgraded: object-scope guard (target has active assignment to route typeNum), delivery fan-out, honest per-channel response."

- file: userfrosting/src/BuyerKiosk/TaskEngine/
  relevance: CRITICAL
  why: "Outbox worker (InviteDeliveryJob) + sweep job ride the existing queue/worker infrastructure."

- file: userfrosting/src/BuyerKiosk/SMS/TextMessageService/TextMessageService.php
  sections: [sendCustomText 143, logBillingUsage 274-303, FloodProtector]
  relevance: CRITICAL
  why: "SMS orchestrator; invite sends keep do-not-text enforcement (M9-09 — the earlier skip idea is DROPPED) + flood check + 037 logging."

- file: userfrosting/src/BuyerKiosk/Billing/Enums/SmsCategory.php
  relevance: HIGH
  why: "PHP constant + THREE DB enum columns (billingSmsUsage.category, billingSmsCategoryConfig.category, billingLineItems category enum — migrations 036_001/003/004) must all gain 'scheduling_invite'."

- file: userfrosting/src/BuyerKiosk/SMS/Webhooks/TwilioDeliveryHandler.php
  sections: [current URL-secret auth + always-200 (L63)]
  relevance: HIGH
  why: "Extended: X-Twilio-Signature validation, durable-write-before-2xx, monotonic transitions, inviteMessages upsert, payload redaction."

- file: userfrosting/src/BuyerKiosk/UserEmployee/EmployeeInvitationManager.php
  sections: [sendInvitationEmail 356-438]
  relevance: HIGH
  why: "Email transport precedent (PHPMailer+SMTP env config, Twig templates/mail) — pattern copied; employeeId data model NOT reused (ADR-051-9)."

- file: userfrosting/src/BuyerKiosk/Core/Controllers/EmployeeInvitationController.php
  sections: [showInvitationPage 346-399, completeRegistration 441-476]
  relevance: MEDIUM
  why: "Set-password UX model; also has the /api/check-username precedent activation reuses."
```

- **ICO-6 Announcement + events**
```yaml
- file: userfrosting/src/BuyerKiosk/SystemAlerts/Services/SystemAlertService.php
  relevance: HIGH
  why: "Announcement authoring (M2) — used as-is. CTA points at the store-resolver hub route."

- file: userfrosting/src/BuyerKiosk/Premium/PremiumEventLogger.php
  relevance: MEDIUM
  why: "Event-log precedent; onboardingEventLog adds dedupeKey + real columns for campaignId/flowVersion/occurredAt."
```

### Implementation Boundaries

- **Must Preserve:** grandfathered stores' behavior (detect, never mutate); `PremiumService::isPremiumActive()` purity (CON-10); legacy `LoginAccessService` token semantics for existing callers; premium billing data + reads (Entitlement Inventory §F); Spec 040 as-is; existing permission URIs.
- **Can Modify:** `SchedulingPageController` gate call sites + routing; sidebar/calendar template entitlement fns; mobile flag builder; team-members invite endpoints; `TwilioDeliveryHandler` (additive); `MobileAuthController::login` (clientId passthrough); `DeviceTokenRepository` uk; `routes/premium.php` trial endpoints (409 when free).
- **Must Not Touch:** `stores.schedulingProvider` from any path except explicit activation; deprecated `employees` table + legacy `employee_invitations` flow (built alongside, ADR-051-9); billing premium reads; mobile app codebases (contract via update docs).

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Owner[Store Owner/Manager]
    Employee[Hourly Employee]
    BK[BK Platform Team]

    Owner -->|activate, checklist, invite| Hub[Getting Started Hub]
    Owner -->|guided walkthrough| Calendar[Schedule Calendar]
    Hub --> OnboardSvc[Onboarding Service + Detectors]
    OnboardSvc --> CentralDB[(central DBs)]
    OnboardSvc --> StoreDB[(kiosk_typeNum)]
    Hub -->|enqueue invites| Outbox[(inviteMessages outbox)]
    Worker[TaskEngine InviteDeliveryJob] --> Outbox
    Worker -->|email| Mail[PHPMailer/SMTP]
    Worker -->|SMS| SMS[TextMessageService + 037]
    Twilio[Twilio callbacks] -->|signed webhook| Webhook[TwilioDeliveryHandler ext]
    Webhook --> Outbox
    Employee -->|activation session| Activate[Activation Pages]
    Employee -->|Team+ login w/ clientId| MobileAPI[Mobile Scheduling API]
    MobileAPI --> CentralDB
    BK -->|publish announcement| Alerts[System Alerts]
    Alerts -->|CTA resolver route| Hub
    OnboardSvc -->|producer matrix| EventLog[(onboardingEventLog)]
```

### Project Commands

```bash
Location: /Users/rvanvuren/Projects/buyerkiosk-web
Install: cd userfrosting && composer install
Tests: ./test.sh | ./test.sh --testsuite unit | --testsuite integration | --coverage | --stan
Targeted: cd userfrosting && ./vendor/bin/phpunit --filter "Onboarding|Invite|Entitlement"
Static analysis: cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Onboarding/ --memory-limit=2G
CSS: php userfrosting/conductor build-css --minify
Migrations: php userfrosting/conductor run   # targeted: skill buyerkiosk-conductor-targeted-migration
TaskEngine worker: php userfrosting/bin/task worker:start
Local serving: Apache → ngrok → dev2.buyerkiosk.com (changes live on save; NO deploy step)
```

## Solution Strategy

- **Architecture Pattern:** generic onboarding framework (`BuyerKiosk\Onboarding\`) with declarative flow definitions and pluggable step detectors. Detection is read-only, computed on render/poll; **every successful detection upserts an observation snapshot** (last-known state for M3-08), and **terminal facts** (complete/skipped) are persisted separately and immutably (latched achievement).
- **Entitlement:** a dedicated predicate — `SchedulingEntitlement::isEntitled(t) = isSchedulingFree(t) OR PremiumService::isPremiumActive(t)` — consumed via new call-site swaps (`isSchedulingEntitled()` Twig fn, `SchedulingAccessPolicy` for pages/APIs, mobile flag builder). The commercial predicate is never touched (CON-10). Chat gets `isChatEntitled()` — a separate predicate for structural isolation — which **per D-8 (product-ratified 2026-07-21: chat goes free) delegates to `SchedulingEntitlement::isEntitled()`**: flags off ⇒ reduces to commercial premium (byte-identical pre-051 chat gating); flags on ⇒ chat ungates with scheduling. All flags default false: dark deploy → pilot allowlist → fleet enable.
- **Invites:** transactional-outbox delivery on TaskEngine. Enqueue is the durable acceptance (NFR-3); a worker claims rows via guarded UPDATE, sends, and records outcomes; a sweep recovers stale/failed rows; webhooks upgrade delivery states under signature validation. Tokens are **invite-scoped and hashed** (ADR-051-11), redeemed through a session-exchange flow that keeps secrets out of URLs after first touch.
- **Walkthrough:** vendored driver.js + engine advancing on application events (shift created, AI applied, week published) — never DOM position polling. Beat preconditions make it adaptive (M11-05); AI beat has explicit failure/timeout/zero-result states (M11-06) and a low-availability notice (M12-04).
- **Analytics:** every PRD event has a named producer, transaction owner, and dedupe key (producer matrix below); announcement KPIs read an immutable campaign cohort snapshot, not live store state.

## Entitlement Inventory & Ungating Design (M1-01)

Audit-verified facts: `PremiumGateMiddleware` unwired (deleted, ADR-051-8; PRD D-1 amended); chat premium coupling exists ONLY in sidebar links + mobile `features.chat`; AI has no PHP gate (two template clauses); `premiumPay*` in Break Policy = labor-law false positives; billing derives PREMIUM_MODULE charges from `premiumStatus` (§F).

### Flags (all default FALSE — CON-10)

| Env var | Default | Meaning |
|---|---|---|
| `SCHEDULING_FREE` | false | Fleet-wide free entitlement. Unset/absent/false ⇒ pre-051 behavior (missing prod var is SAFE). |
| `SCHEDULING_FREE_PILOT_STORES` | empty | Comma-separated typeNums entitled while `SCHEDULING_FREE=false` — dark-deploy pilot lever. |
| `ONBOARDING_HUB_ENABLED`, `ONBOARDING_INVITES_ENABLED`, `ONBOARDING_WALKTHROUGH_ENABLED` | false | Per-feature levers (Release Manifest). |

Read via the house boolean pattern (`ViteService.php:89-95`), memoized per request. Flag test matrix: unset / false / true / pilot-listed / pilot-not-listed.

### Edit list (call-site swaps — commercial predicate untouched)

| Site | file:line | Action |
|---|---|---|
| New predicate | `Scheduling/Onboarding/SchedulingEntitlement.php` | `isEntitled(t) = isSchedulingFree(t) || PremiumService::isPremiumActive(t)`; `isSchedulingFree` = env + pilot list. |
| New access policy | `Scheduling/Onboarding/SchedulingAccessPolicy.php` | Route-matrix decisions for every scheduling page + write API: entitlement ("is it included") × provider ("is BK scheduling activated"). |
| Chat predicate | new `isChatEntitled()` (PHP + Twig) | **Per D-8 (ratified 2026-07-21): delegates to `SchedulingEntitlement::isEntitled()`** — chat goes free with scheduling. Separate predicate retained so a future re-gating is a one-line change. Flags off ⇒ identical to commercial premium gating. |
| Twig fns | `Premium/PremiumTwigExtension.php` | ADD `isSchedulingEntitled`, `isChatEntitled` (real + fallback ext — fallback must not return false for entitled). `isPremiumActive` Twig fn left as-is (commercial). |
| Sidebar scheduling | `menus/sidebar.html:319` (+ else 355-360) | Swap conditional → `isSchedulingEntitled(typeNum)`. |
| Sidebar chat | `sidebar.html:207-214` | Swap → `isChatEntitled(typeNum)`. |
| Calendar AI clauses | `calendar.html:38, 4728` | Swap → `isSchedulingEntitled(typeNum)`; `checkAccess('uri_schedule_ai')` stays. |
| Page gates | `SchedulingPageController.php:94-108, 178-180, 215-217` | Replace premium check with `SchedulingAccessPolicy` per the route matrix below. Marketing render + `premium/marketing.html` retained for not-entitled mode only. |
| Trial banner | `SchedulingPageController.php:549-593` + `calendar.html:12` | `getTrialBannerContext()` returns null when `isSchedulingFree`. |
| Mobile flags | `MobileApiController.php:5154-5167` | `features.scheduling = features.aiScheduling = SchedulingEntitlement::isEntitled`; `status='active'` when entitled; `features.chat = isChatEntitled`. |
| Trial endpoints | `routes/premium.php` → `PremiumApiController` | 409 `{error:"Scheduling is included in all plans"}` when `isSchedulingFree`. Admin API untouched. |
| Trial expiry job | `TrialExpirationJob` | Keep running (keeps `premiumStatus` accurate for Q1). |
| Dead middleware | `PremiumGateMiddleware.php` | Delete (ADR-051-8). |
| Billing §F | `BillingService` / `BillingApiController` / `BillingConfigRepository` / `Store` premium columns | DO NOT TOUCH. ⚠ Stores at `premiumStatus='active'` keep being billed until Q1 executes its lever (bulk status transition or config change) — announcement blocker. |

### Route matrix (authoritative)

| Route | Not entitled (kill-switch mode) | Entitled + provider `buyerkiosk` | Entitled + provider unset/none | Entitled + external (wiw/homebase/unrecognized) |
|---|---|---|---|---|
| `GET /admin/:t/schedule` (calendar) | Legacy: premium? calendar : marketing render | Calendar | **302 → get-started** (never an empty calendar) | 302 → get-started (M5 view) |
| `GET /admin/:t/schedule/settings`, `/timesheets` | Legacy redirect behavior | Page | 302 → get-started | 302 → get-started |
| `GET /admin/:t/schedule/get-started` | 302 → `/schedule` (legacy handles it) | Hub: checklist | Hub: pitch + Get Started | M5 integration-aware page |
| `GET /admin/schedule/get-started` (no typeNum — **alert CTA target**) | resolves then applies rows above | Store resolver: exactly one assigned store → 302 with typeNum; multiple → store picker page | same | same |

PRD M3-01 interpretation (recorded): the hub is a new page; "the URL previously serving marketing" is satisfied because `/schedule` — where marketing rendered — now routes not-yet-activated stores to the hub.

## Building Block View

### Components

```mermaid
graph LR
    subgraph "Web UI"
        HubPage[get-started.html + OnboardingHub.js poll 5min]
        RosterUI[Team Members invite UI + batch status]
        Walkthrough[WalkthroughEngine.js + driver.js + beat configs]
        ActivateUI[activate.html session-based set-password]
    end
    subgraph "Onboarding framework"
        FlowDef[SchedulingFlowDefinition + starter positions]
        OnboardSvc[OnboardingService]
        Detectors[Detectors x10]
        ObsRepo[ObservationRepository]
        ProgressRepo[ProgressRepository]
        EventLogger[OnboardingEventLogger + dedupe]
        OnboardApi[OnboardingApiController]
        OnboardPage[OnboardingPageController + resolver]
        AccessPolicy[SchedulingAccessPolicy]
        Entitle[SchedulingEntitlement]
    end
    subgraph "Invites"
        InviteSvc[InviteDeliveryService enqueue/preview]
        InviteWorker[TaskEngine InviteDeliveryJob + sweep]
        InviteRepo[InviteRepository]
        MailT[InviteEmailTransport PHPMailer]
        SmsT[TextMessageService + scheduling_invite]
        ActivateCtl[ActivationController session exchange]
        Webhook[TwilioDeliveryHandler ext + signature]
    end

    HubPage --> OnboardApi --> OnboardSvc
    OnboardSvc --> FlowDef & Detectors & ObsRepo & ProgressRepo & EventLogger
    OnboardPage --> AccessPolicy --> Entitle
    HubPage & RosterUI --> InviteSvc --> InviteRepo
    InviteWorker --> InviteRepo & MailT & SmsT
    Webhook --> InviteRepo
    ActivateUI --> ActivateCtl --> InviteRepo
    Walkthrough --> OnboardApi
```

### Directory Map

```
userfrosting/src/BuyerKiosk/Onboarding/
├── Controllers/{OnboardingApiController,OnboardingPageController}.php
├── Models/{OnboardingProgress,OnboardingObservation,OnboardingUserProgress}.php
├── Repositories/{OnboardingProgressRepository,OnboardingObservationRepository,OnboardingUserProgressRepository}.php
├── Services/{OnboardingService,OnboardingEventLogger,FlowDefinition}.php
└── Detectors/{StepDetectorInterface,DetectionResult}.php

userfrosting/src/BuyerKiosk/Scheduling/Onboarding/
├── SchedulingFlowDefinition.php      # Step Contract Table + thresholds + starter-position catalog (M7-02)
├── SchedulingEntitlement.php         # isEntitled/isSchedulingFree (+ pilot list)
├── SchedulingAccessPolicy.php        # route matrix decisions
└── Detectors/ (10: Activation, StoreSettings, Positions, Roster, Invites, AppAdoption,
               Availability, PublishedWeek, ShiftTasks, FirstClockIn)

userfrosting/src/BuyerKiosk/TeamMember/
├── Services/{InviteDeliveryService,InviteEmailTransport}.php
├── Repositories/InviteRepository.php
└── Controllers/ActivationController.php

userfrosting/src/BuyerKiosk/TaskEngine/Jobs/{InviteDeliveryJob,InviteSweepJob,InviteExpiryJob}.php

# Small edits in existing files
Billing/Enums/SmsCategory.php                         # + SCHEDULING_INVITE='scheduling_invite'
SMS/TextMessageService/TextMessageService.php         # + sendEmployeeInviteText() (do-not-text ENFORCED, flood check, 037 logging)
SMS/Webhooks/TwilioDeliveryHandler.php                # + signature validation, durable-before-2xx, monotonic transitions, inviteMessages upsert, redaction
MobileScheduling/Controllers/MobileAuthController.php # + clientId passthrough (login), app_adopted producers (login + device-token)
MobileScheduling/Repositories/DeviceTokenRepository.php # uk → (userId,deviceId,appId)
Premium/PremiumTwigExtension.php                      # + isSchedulingEntitled, isChatEntitled (real + fallback)
Scheduling/Controllers/SchedulingPageController.php   # AccessPolicy routing; get-started + resolver routes
Scheduling/Controllers/SchedulingController.php       # publish + AI hooks emit events (producer matrix)
Workbook or MobileScheduling clock services           # first_clock_in milestone producer

# Templates & JS
templates/themes/default/scheduling/get-started.html + partials/onboarding/*
templates/themes/default/common/activate.html          # session-based set-password + username collection + role-aware app screen
templates/mail/scheduling-invite.html
public_html/js/onboarding/{OnboardingHub,WalkthroughEngine}.js + walkthroughs/first-schedule.js
public_html/js/vendor/driver.min.js (+css)
public_html/css/admin/modules/onboarding.css

# Migrations (conductor)
20260721_051_001_onboarding_tables.json       # onboardingProgress, onboardingObservations, onboardingUserProgress, onboardingEventLog, onboardingCampaignCohort (kiosk_buykiosk)
20260721_051_002_user_invites.json            # userInvites, inviteMessages (kiosk_users)
20260721_051_003_sms_category_enums.json      # extend ENUMs: billingSmsUsage.category, billingSmsCategoryConfig.category, billingLineItems category (+ default category config rows)
20260721_051_004_device_token_uk_appid.json   # userDeviceTokens uk (userId,deviceId,appId) + idx (userId,appId,updatedAt); archive-first for uk conflicts
scripts/onboarding-baseline-snapshot.php      # M2-04: cohort + baseline capture (writes onboardingCampaignCohort + JSON artifact)
```

### Data Model (central DBs)

```sql
-- kiosk_buykiosk.onboardingProgress — TERMINAL facts only, immutable (latched achievement)
id PK · typeNum VARCHAR(10) · flowKey VARCHAR(50) · stepKey VARCHAR(50)
status ENUM('complete','skipped') · detection ENUM('auto','manual') NULL (NULL when skipped)
flowVersion VARCHAR(20) · evidence JSON NULL (permission-NEUTRAL snapshot)
completedByUserId INT NULL · completedAt DATETIME
UNIQUE uk_store_flow_step (typeNum, flowKey, stepKey)

-- kiosk_buykiosk.onboardingObservations — last-known non-terminal state (M3-08)
id PK · typeNum · flowKey · stepKey
status ENUM('blocked','notStarted','inProgress','met') · numerator INT NULL · denominator INT NULL
evidence JSON (permission-neutral) · detectorVersion VARCHAR(20) · observedAt DATETIME · lastSuccessAt DATETIME
UNIQUE uk_obs (typeNum, flowKey, stepKey)      -- upserted on every successful detection

-- kiosk_buykiosk.onboardingUserProgress — per-user walkthrough state
id PK · userId · typeNum · itemKey VARCHAR(80)   -- 'walkthrough:firstSchedule:v1'
status ENUM('inProgress','completed','exited') · beat VARCHAR(40) NULL · updatedAt
UNIQUE uk_user_store_item (userId, typeNum, itemKey)

-- kiosk_buykiosk.onboardingEventLog — producer-matrix events
id PK · eventKey VARCHAR(60) · typeNum VARCHAR(10) NULL -- NULL for user-scoped events (app_adopted)
userId NULL
campaignId VARCHAR(40) NULL · flowVersion VARCHAR(20) NULL · occurredAt DATETIME · properties JSON
dedupeKey VARCHAR(120) NULL UNIQUE               -- e.g. 'step:pc00:scheduling:positions', 'appAdopted:123:team'
INDEX (typeNum,eventKey) · INDEX (eventKey,occurredAt) · INDEX (campaignId)

-- kiosk_buykiosk.onboardingCampaignCohort — immutable KPI denominators (M2-04)
id PK · campaignId VARCHAR(40) · typeNum · cohort ENUM('eligible','grandfathered','external')
baseline JSON (positions/roster/publish/ticket-rate metrics at snapshot) · snapshotAt DATETIME
UNIQUE (campaignId, typeNum)

-- kiosk_users.userInvites — invite identity (ADR-051-11: token is invite-scoped + hashed)
inviteId PK · userId · typeNum · invitedByUserId
kind ENUM('credential','storeNotify')
status ENUM('pending','activated','expired','revoked','done')   -- 'done' = storeNotify terminal
tokenHash CHAR(64) NULL · tokenExpiresAt DATETIME NULL · consumedAt DATETIME NULL · revokedAt DATETIME NULL
isCurrent TINYINT NULL                            -- 1 on the active invite; NULL on superseded (uk ignores NULLs)
batchId CHAR(26) NULL · createdAt · activatedAt NULL
UNIQUE uk_current (userId, typeNum, isCurrent) · INDEX (typeNum,status) · INDEX (batchId)

-- kiosk_users.inviteMessages — per-channel OUTBOX (state machine + retries)
messageId PK · inviteId FK · channel ENUM('email','sms')
state ENUM('queued','sending','sent','delivered','failed','bounced','dead')
attemptCount TINYINT DEFAULT 0 · nextAttemptAt DATETIME NULL · lockedAt DATETIME NULL
errorCode VARCHAR(40) NULL · stateReason VARCHAR(255) NULL
provider VARCHAR(20) NULL · providerMessageId VARCHAR(64) NULL
messageRef CHAR(12) NOT NULL                      -- opaque link tag → winning-channel attribution (replaces tamperable ?c=)
sentAt NULL · updatedAt
UNIQUE uk_provider_msg (provider, providerMessageId) · UNIQUE (messageRef) · INDEX (state, nextAttemptAt)
```

State machines: message `queued→sending→sent→{delivered|failed|bounced}`; retryable failures return to `queued` with backoff until `attemptCount≥3 → dead`; webhook transitions are **monotonic** (rank ordering; late `sent` never regresses `delivered`). Invite: `pending→activated` (guarded one-winner) | `expired` (sweep) | `revoked`. Retention: eventLog 24 months; invites retained, included in the Privacy module's user-deletion path.

## Runtime View

### Hub render / poll (state assembly + M3-08)
1. Route matrix (AccessPolicy) → view selection. Hub JS polls `GET /state` every 5 minutes while open.
2. `OnboardingService::getState`: load terminal rows + observations (2 queries) → run detectors for non-terminal steps (per-request memoized; central + store connections) → each success **upserts onboardingObservations** → prereq graph → statuses.
3. Detector failure → serve that step from its observation row + `stale:true, observedAt` (a never-observed step renders `notStarted, stale:true`). Never a 500.
4. Auto-step first transition to met: `INSERT IGNORE` progress row; **same transaction** writes the `onboarding.step_completed` event row (dedupeKey `step:{t}:{flow}:{step}`) — atomic, emit-once. Flow completion check appends `flow_completed` (dedupeKey) in the same transaction.
5. Evidence is stored permission-neutral (counts); wage-flavored strings are composed at render per caller permission (M3-11).

### Activation (M4)
`POST /activate` (CSRF + store-settings perm): guarded `UPDATE stores SET schedulingProvider='buyerkiosk' WHERE typeNum=:t AND (schedulingProvider IS NULL OR schedulingProvider='' OR schedulingProvider='none')`. rows=1 → audit + progress + event (one tx) → **200** `{activated:true}`. rows=0 → re-read: `'buyerkiosk'` → **200** `{activated:true, alreadyActive:true}` (idempotent, no event); external → **409** provider-conflict. 403 = permission. 422 = never (no body).

### Invite enqueue → worker → webhook
1. `POST /:id/invite` / `POST /invite-all` (CSRF; uri_employees; **object-scope guard**: target user must hold an active `userStoreAssignments` row for route typeNum): per member — supersede prior invite (isCurrent swap, old token revoked), create `userInvites` row + hashed token (24h), insert `inviteMessages` rows `state='queued'` per selected channel, all in one tx. Bulk: caps checked (per-run 200; store/day 500; recipient/day 3), **202** `{batchId}` returned immediately; TaskEngine dispatch after commit.
2. `InviteDeliveryJob`: claims rows via guarded `UPDATE ... SET state='sending', lockedAt=NOW() WHERE messageId=:id AND state='queued'` (pending→running race discipline). Email: PHPMailer/SMTP → `sent`/`failed(+errorCode)`. SMS: `sendEmployeeInviteText` — **do-not-text list enforced** (opted-out → `failed:'optedOut'`), FloodProtector, 037 logging with `scheduling_invite` → `sent` + provider + providerMessageId.
3. `InviteSweepJob` (5-min cron): re-queues `sending` rows with `lockedAt < NOW()-10min` (attempt++), promotes due `queued` retries, `attemptCount≥3 → dead`. **Provider-accepted-but-response-lost**: sweep marks ambiguous rows `sent(unconfirmed)` rather than resending SMS (at-most-once bias for user-visible messages).
4. `InviteExpiryJob` (daily): `pending` past `tokenExpiresAt` → `expired` + `invite.expired_unactivated` event.
5. Webhook (`TwilioDeliveryHandler` ext): X-Twilio-Signature validated against canonical public URL → durable state write (monotonic) **before** 200; transient write failure → 500 (Twilio retries); unknown providerMessageId → 200 ack + warn. Payloads redacted in logs. Vonage-sent invites remain at `sent` (no callback integration in v1 — documented).

### Activation redemption (session exchange — token never lingers in a URL)
1. `GET /activate/:token`: hash-lookup current invite; valid → create server-side activation session (10 min, httpOnly cookie, bound to inviteId + messageRef from the link) → **302 to tokenless `/activate`**. Invalid/expired/revoked → friendly page. Headers on all: `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, restrictive CSP. Token args `#[\SensitiveParameter]`; ingress log redaction rule documented for `/activate/*`. IP rate-limited.
2. `GET /activate` (session): set-password form; **collects username when the user has none** (SMS-only members — availability-checked, mobile login requires email OR username so this closes the login gap).
3. `POST /activate` (session + nonce): re-verify assignment active + account enabled → guarded `UPDATE userInvites SET consumedAt=NOW(), status='activated' WHERE inviteId=:id AND consumedAt IS NULL AND status='pending'` (one concurrent winner; loser sees friendly already-used page) → set credentials (`canLogin=1`, `active=1`) → `invite.activated` event with `winningChannel` resolved from the bound messageRef → role-aware app screen.

### Walkthrough (M11-02/05/06, M12-04)
Engine loads beat config; each beat declares `precondition` (already satisfied → render acknowledgment, auto-advance = M11-05), `advanceEvent` (calendar/AI/publish JS events), `failure states`. AI beat: pre-checks availability coverage via `/state` (<50% → non-blocking notice, M12-04); dispatch failure → inline retry; zero suggestions / no qualified candidate → explanation + skip affordance; timeout → progress indicator with "continue without AI"; locked/published week → publish beat acknowledges (M11-06). Engine unavailable → deep link + article fallback (M11-08). State saved per beat via `POST /walkthrough/:key/state` (`inProgress` supported).

## Interface Contracts

Conventions (all onboarding + invite endpoints): session auth + `checkStoreGroup(typeNum)`; **CSRF token required on every session POST**; error envelope `{error: string, code: string, requestId: string}`; JSON; permissions per PRD M3-11 noted per endpoint.

```yaml
GET /api/:t/schedule/onboarding/state        # perm: uri_schedule_manage
  200: {flowKey, flowVersion, activated, campaignId?, steps: [{stepKey, required, manualEligible, skippable,
        status: blocked|notStarted|inProgress|complete|skipped, stale: bool, observedAt?, health?: degraded,
        evidence: [string], numerator?, denominator?, canAccess: bool, blockedReason?, description, timeEstimate, deepLink}],
        milestones: {firstClockIn?: datetime}, appAdoption?: [{userId, invited, accountActive, appLoginObserved: {team, live}, pushRegistered: {team, live}}]}
POST /api/:t/schedule/onboarding/activate    # perm: uri_store_settings; 200 (incl. idempotent) | 403 | 409 provider-conflict
POST /api/:t/schedule/onboarding/steps/:stepKey/confirm    # storeSettings only; body: {} ; 200 | 422 wrong-step
POST /api/:t/schedule/onboarding/steps/:stepKey/complete   # manual-eligible only; 200 | 422 contract-violation
POST /api/:t/schedule/onboarding/steps/:stepKey/skip       # optional steps only; 200 | 422
POST /api/:t/schedule/onboarding/positions/starter         # M7-02; body {names: [string]}; creates via PositionRepository,
                                                           # case-insensitive dedupe vs existing; 200 {created:[], skippedExisting:[]}
POST /api/:t/schedule/onboarding/walkthrough/:key/state    # body {beat, status: inProgress|completed|exited}; 200
POST /api/:t/schedule/onboarding/provider-switch-interest  # 200; logs event + support handoff

POST /:t/api/team-members/:id/invite         # perm: uri_employees; body {channels?: [email|sms]}
  200: {inviteId, messages: [{channel, state, stateReason?}]}   # sync enqueue result, not delivery
  404 unknown member · 409 already-active-login (offers kind=storeNotify) · 422 no-reachable-channel
POST /:t/api/team-members/invite-preview     # body {userIds[]} → per-member channels + unreachable list (pre-send confirmation)
POST /:t/api/team-members/invite-all         # 202: {batchId, accepted, skippedNoChannel:[], skippedActive:[]} · 429 +Retry-After on caps
GET  /:t/api/team-members/invite-batches/:batchId   # {counts by state, perMember[]} — poll target
GET  /:t/api/team-members/invite-status?page=&perPage=   # paginated roster-wide {inviteStatus, channels[], appLoginObserved, pushRegistered}
POST /:t/api/team-members/:id/invite/revoke  # 200; revokes current invite + token

GET  /activate/:token   # public; session exchange + 302 (see Runtime) — never cached, never logged with token
GET  /activate          # public+session; set-password (+username when absent) form
POST /activate          # public+session+nonce; 200 → app screen | 410 session-expired | 409 already-consumed
GET  /admin/schedule/get-started             # store resolver (alert CTA target)
GET  /admin/:t/schedule/get-started          # hub per route matrix
```

## Event Producer Matrix

| Event | Producer (file/method) | Tx owner | dedupeKey |
|---|---|---|---|
| `onboarding.hub_viewed` | OnboardingPageController render | none (append) | — |
| `onboarding.activated` | activate endpoint (guarded UPDATE tx) | activation tx | `activated:{t}` |
| `onboarding.step_completed` | OnboardingService first-transition insert | progress tx | `step:{t}:{flow}:{step}` |
| `onboarding.step_skipped` / manual complete | step endpoints | step tx | `skip:{t}:{flow}:{step}` |
| `onboarding.flow_completed` | OnboardingService (same tx as final step) | progress tx | `flow:{t}:{flow}` |
| `onboarding.milestone_first_clock_in` | clock-in service hook (workspace + mobile paths) | punch tx (post-commit emit) | `clockin:{t}` |
| `onboarding.walkthrough_*` | walkthrough state endpoint | state tx | — |
| `invite.sent` | InviteDeliveryJob on sent | outbox update tx | `msgSent:{messageId}` |
| `invite.delivery_updated` | TwilioDeliveryHandler | webhook tx | `msgState:{messageId}:{state}` |
| `invite.activated` | ActivationController redemption tx | redemption tx | `inviteAct:{inviteId}` |
| `invite.expired_unactivated` | InviteExpiryJob | sweep tx | `inviteExp:{inviteId}` |
| `onboarding.app_adopted` | MobileAuthController login (clientId path) + registerDeviceToken | post-commit emit | `appAdopted:{userId}:{app}` |
| `schedule.week_first_published` | publish endpoint (SchedulingController) | publish tx (post-commit) | `pub:{t}:{weekStart}` |
| `ai.fill_generated` / `ai.fill_applied` | AiSchedulingApiController dispatch/apply | post-commit | `ai:{t}:{jobId}[:applied]` |
| `onboarding.provider_switch_interest` | interest endpoint | append | — |

`campaignId` stamps events between announcement publish and campaign close (config value); KPI denominators come from `onboardingCampaignCohort`, snapshotted by `scripts/onboarding-baseline-snapshot.php` (run = M2-04 launch gate; writes cohort rows + a JSON baseline artifact committed to the launch runbook).

`onboarding.app_adopted` is USER-scoped (`typeNum NULL` — a login/device-token event isn't tied to a single store); the adoption KPI itself is member-based (see the App adoption row in the PRD Success Metrics table), and per-store hub adoption panels read the `oauthRefreshTokens`/`userDeviceTokens` signal tables directly, so no per-store event fan-out of `app_adopted` is needed.

## Architecture Decisions

- [x] **ADR-051-1 — Generic framework, single flow shipped** (M3-09; one abstraction layer traded for reuse-without-schema-change).
- [x] **ADR-051-2 — Live detection + observation snapshots + immutable terminal facts:** observations satisfy M3-08 (last-known state incl. never-completed steps); terminal rows stay latched. Trade-off: one upsert per detector success — trivial.
- [x] **ADR-051-3 — Entitlement is a dedicated predicate; commercial `isPremiumActive()` untouched:** call sites swap to `isSchedulingEntitled`/`SchedulingAccessPolicy`; chat structurally isolated behind its own `isChatEntitled()` predicate (per D-8 as ratified 2026-07-21, it delegates to `SchedulingEntitlement::isEntitled()` — chat goes free with scheduling; the separate predicate keeps re-gating a one-line change). Supersedes the earlier chokepoint-short-circuit idea (rejected: conflated billing truth with entitlement; coupled chat accidentally rather than deliberately; unsafe default).
- [x] **ADR-051-4 — Event-advanced walkthrough beats** (DOM-position polling rejected: Syncfusion re-renders).
- [x] **ADR-051-5 — Invite history wraps LoginAccessService for legacy compat; new flow issues invite-scoped tokens** (see 051-11).
- [x] **ADR-051-6 — Central-DB placement for new tables;** store-local data (positions, shifts, availability, punches) read via per-store connections in detectors — no cross-DB SQL joins.
- [x] **ADR-051-7 — App adoption = two separate signals, clientId launch-gated:** `appLoginObserved` = `oauthRefreshTokens` w/ `clientId IN ('team','live')` and `COALESCE(lastUsedAt, createdAt) ≥ NOW()-30d` — **requires Team+/Live releases sending `clientId` at login (launch-gated deliverable: owners = mobile teams; min versions recorded in the launch runbook; contract-tested)**; clientId survives refresh rotation on the MobileScheduling (Team+) path (`JwtAuthService::refreshAccessToken` only bumps `lastUsedAt`, no new row is issued) — but the MobileApi (Live) refresh path revokes-and-regenerates the refresh-token row and today drops `clientId` in that rebuild, falling back to `'mobile-app'` even for a device that sent `clientId` at login; **T4 fixes this by carrying `clientId` forward into the regenerated row** (see mobile-contract.md Deliverable 1). `pushRegistered` = `userDeviceTokens` per appId (uk fixed to include appId) — supplementary, never hides a push-denier's real login. Interim (pre-release) detection may OR device-token freshness; step stays manual-eligible.
- [x] **ADR-051-8 — Delete PremiumGateMiddleware** (verified-unwired dead code; PRD D-1 amended 2026-07-21 with this explicit carve-out).
- [x] **ADR-051-9 — Build users-table activation; do NOT reuse deprecated employee_invitations flow.**
- [x] **ADR-051-10 — Transactional outbox + TaskEngine worker for invite delivery:** enqueue = durable acceptance (NFR-3); guarded claims (pending→running discipline); sweep recovery; at-most-once bias on ambiguous SMS outcomes. Synchronous send rejected (crash windows strand or duplicate).
- [x] **ADR-051-11 — Invite-scoped hashed tokens + session-exchange redemption:** fixes cross-store invalidation, winning-invite ambiguity, URL secret leakage (logs/referrers/history), and tamperable channel attribution (opaque per-message `messageRef`). `users.activationToken` left to legacy callers.

## Quality Requirements

- **Hub p95 ≤ 2s (NFR-1):** per-detector query budget table in implementation plan with EXPLAIN on the two new composite indexes (`userDeviceTokens(userId,appId,updatedAt)`; invite/status batched — one query per table per store, never per-member). Method: seeded 95th-percentile store profile (50 staff / 500 shifts) under 10 concurrent hub requests in the integration environment + `Server-Timing` telemetry in production for real p95.
- **Freshness (NFR-2):** compute-on-load + 5-minute hub polling (an open hub stays ≤5min stale); observations carry `observedAt` for honesty.
- **Invite durability (NFR-3):** outbox row precedes any transport call; sweep bounds `sending` limbo at 10 min; failure states visible ≤15 min via batch polling + webhook.
- **Accessibility (NFR-4):** walkthrough keyboard advance/exit, focus trap, aria-live coach marks; hub is semantic list markup; automated axe pass on hub + activation pages.
- **Security (NFR-6):** CSRF on all session POSTs; object-scope guards on member-targeted endpoints; hashed invite tokens; session-exchange redemption; signed webhooks; no-store/no-referrer/CSP on activation; token log redaction; permission-neutral stored evidence.
- **Auditability (NFR-7):** completedByUserId, activation audit row, invite actor per row.

## Risks and Technical Debt

### Known Technical Issues
- `userDeviceTokens` uk `(userId,deviceId)` lets Team+Live on one device overwrite each other — fixed by migration 051_004 (archive-first on conflicts).
- `oauthRefreshTokens.clientId` is `'mobile-app'` for all rows until apps ship the login field — `appLoginObserved` under-counts until then (mitigation: manual-eligible step + interim device-token OR).
- Billing keeps charging `premiumStatus='active'` stores until Q1 executes — announcement blocker (Entitlement §F).
- `INVITATION_EXPIRES_HOURS=24` is tight for store setup — one constant; product may bump to 72h (Q4-adjacent).
- Vonage-sent invite SMS get no delivery callbacks in v1 (state stops at `sent`).
- Email transport (SMTP) reports acceptance only; delivered/bounced awaits Q3 transport decision — adapter-swappable.
- Slim 2 `\Throwable` catch rule; PDO unique named params (HY093); MariaDB INT-vs-'' comparisons in migrations.

### Technical Debt
- Premium module dormant-but-present (D-1); only the unwired middleware is deleted.
- Two invite truths during transition (legacy `users.activationToken` for old callers; invite-scoped tokens for the new flow).
- Two activation pages during the employee_invitations deprecation window.
- Interim device-token OR in the adoption detector removed once min app versions are fleet-dominant.

### Open Items (owners; all pre-announcement gates unless noted)
- **Q1** premium billing transition — product/billing; lever = bulk status transition or config change. **OPEN.**
- ~~**Q3** email sender/domain/DNS/deliverability~~ — **RESOLVED** (PRD D-9, 2026-07-21 T0 decision gate): existing PHPMailer/SMTP path, sender `noreply@buyerkiosk.com`, `InviteEmailTransport` adapter (SendGrid API is the T8.6-failure contingency; SPF already includes sendgrid.com; DMARC p=none).
- ~~**Q4** thresholds + ticket taxonomy sign-off~~ — **RESOLVED** (PRD D-10, 2026-07-21 T0 decision gate): detection thresholds = PRD defaults, signed off; ticket taxonomy = manual `scheduling-setup` tagging + CSV baseline import (no helpdesk API).
- ~~**Q5** chat entitlement~~ — **RESOLVED** (PRD D-8, 2026-07-21 T0 decision gate): Team Chat stays premium in v1 (`isChatEntitled()` → commercial premium); structurally isolated per ADR-051-3; override window is before T1A merges.
- ~~driver.js spike (version/license/CSP/Syncfusion/keyboard)~~ — **RESOLVED/COMPLETE** (2026-07-21): verdict **GO-WITH-CAVEATS** — see `spikes/driver-js-spike.md`. Fallback support article authored regardless of verdict; T5 must account for the pointer-events/overlay-click-behavior integration constraints documented there before walkthrough engine buildout.
- Mobile `clientId` releases + min versions — mobile teams; gate for full M10 auto-detection (not for launch: manual-eligible). **OPEN.**
- Pilot entry/exit criteria + kill-switch rehearsal — platform; launch runbook. **OPEN.**

## Test Specifications

### Critical Test Scenarios

**S1 Activation race:** two concurrent activates → one stores transition, one event (dedupeKey), both 200.
**S2 Contract enforcement:** manual-complete on a non-manual step → 422, no row, no event.
**S3 Latched + health:** complete positions → delete all positions → status complete + health degraded, evidence shows current 0.
**S4 Detector failure:** store DB down → step served from observation w/ stale:true; never-observed step → notStarted+stale; hub 200.
**S5 Bulk invite partial failure:** mixed roster (both channels / email-only / opted-out phone / no channel) → 202 + batch shows failed:'optedOut' (do-not-text ENFORCED), skippedNoChannel listed; batch completes despite failures.
**S6 Flag matrix:** SCHEDULING_FREE unset/false/true × pilot-listed/not × provider states → route matrix holds; chat guard per D-8-as-ratified: flags off ⇒ chat surfaces identical to pre-051 (commercial premium gating); flags on / pilot-listed ⇒ chat entitled exactly where scheduling is entitled.
**S7 Grandfathered first render:** all detectable steps complete in one pass; flow_completed once; no pitch view.
**S8 Webhook:** invalid signature → 403 no write; duplicate callback → single transition (dedupe); out-of-order late `sent` after `delivered` → no regression (monotonic); durable-write failure → 500 (retry-safe).
**S9 Worker faults:** crash after claim → sweep re-queues at 10min; SMTP timeout → failed+retry backoff; provider-accepted-response-lost → sent(unconfirmed), no SMS duplicate; Redis dispatch outage → sweep picks up queued rows.
**S10 Redemption security:** token URL → 302 tokenless (no token in subsequent requests); concurrent redemption → one winner, loser friendly page; expired/revoked/superseded token → friendly page; deactivated-between-send-and-redeem → refused; cross-store IDOR on invite endpoints → 404/403; CSRF missing on onboarding POSTs → rejected.
**S11 SMS-only member end-to-end:** no email, no username → invited by SMS → activation collects username → mobile login succeeds with new username.
**S12 Billing category:** scheduling_invite usage row → category config resolves → invoice line-item generation includes it (through the 036 pipeline, not just the PHP enum).
**S13 Walkthrough states:** precondition-satisfied beat auto-acknowledges (M11-05); AI zero-suggestions/timeout/locked-week paths (M11-06); availability <50% notice (M12-04); engine-load failure → fallback link (M11-08).

### Coverage Requirements
- Unit ≥90%: detectors (met/notMet/blocked/exception), FlowDefinition graph, InviteDeliveryService (fan-out, caps, idempotent supersede), outbox claim/sweep logic, entitlement + access policy (full flag matrix), activation redemption guards, webhook transition ranking.
- Integration: hub state assembly (seeded store), invite enqueue→worker→webhook chain (mocked transports), batch endpoint, activation session flow, SMS billing pipeline (S12), event dedupe under concurrency.
- Contract: mobile login with/without clientId (both apps' payload shapes); mobile flag payload (M1-03).
- Browser E2E (scripted via Chrome DevTools MCP where possible, manual checklist otherwise): full checklist on pilot dev store, walkthrough beats on real calendar (incl. hidden-tab rAF workaround), kill-switch flip, mobile-browser activation, axe accessibility pass.
- Criterion-to-test matrix: maintained in the implementation plan (every M/S/NFR ID → test task).

## Glossary

- **Entitlement vs activation:** entitlement = scheduling included (flags/premium); activation = store chose BK-native scheduling (`schedulingProvider`). AccessPolicy composes both.
- **Observation:** last-known detector result (non-terminal); serves stale state (M3-08).
- **Outbox:** inviteMessages rows as durable send queue; TaskEngine worker drains it.
- **messageRef:** opaque per-message link tag; winning-channel attribution.
- **Session exchange:** token → short-lived server session → tokenless URLs.
- **Grandfathered store:** provider already BK-native pre-launch.

---

## Mobile Coordination Notes (publish to backend-api-updates docs at implementation)

1. **Entitlement flags (M1-03):** `premiumScheduling.status='active'`, `features.scheduling=true`, `features.aiScheduling=true` for all stores once enabled; **`features.chat=true` for all stores as well (D-8 ratified 2026-07-21: chat goes free with scheduling)** — nothing changes until flags flip. Validate current released clients tolerate this.
2. **Login `clientId` (ADR-051-7): REQUIRED deliverable** — Team+ sends `clientId:'team'`, Live sends `clientId:'live'` in the login body. Backend passthrough ships in 051; field optional/backward-compatible; min adopting versions recorded in launch runbook; contract tests on both payloads.
3. **Activation landing:** invite links land on a mobile-first web activation flow ending in app-store badges — no app changes required.
