# Solution Design Document: AI Smart Scheduling

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] 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 specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user** ✅ 2026-01-08
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

**CON-1: Technology Stack**
- PHP 8.x with Slim 2.6.2 framework (no Slim 4 migration)
- Twig 1.44.8 templates with Handlebars for client-side rendering
- MySQL multi-database architecture (central + per-store databases)
- Redis for caching and Ably for real-time messaging
- Bootstrap 5.3.3 CSS framework with custom design tokens
- OpenAI API (ChatGPT) for AI processing - model configurable via `.env`

**CON-2: Operational Constraints**
- TaskEngine for async AI processing (high priority queue)
- Rate limiting: Configurable via `AI_SCHEDULE_MAX_RUNS_PER_WEEK` env var (default: 5)
- Must use global `kiosk_users` database for employee data (deprecated store-level employee table NOT supported)
- Must integrate with existing scheduling infrastructure (Spec 025 templates, shifts, punches)
- Dual notification: Ably real-time for waiting users, email for offline users

**CON-3: Security & Auth**
- Session-based authentication via UserFrosting
- Store access via `checkStoreGroup($typeNum)` pattern
- Feature permissions via `checkAccess('permission_code')` pattern
- CSRF protection required for all POST/PUT/DELETE operations
- OpenAI API key stored in `.env`, never exposed to client

---

## Implementation Context

### Required Context Sources

```yaml
# PRD for this feature
- doc: docs/specs/026-ai-smart-scheduling/product-requirements.md
  relevance: CRITICAL
  why: "All 16 features and acceptance criteria to implement"

# Existing scheduling infrastructure
- doc: docs/specs/025-schedule-templates/
  relevance: HIGH
  why: "Template system we build upon - shift templates, overlay data"

# OpenAI integration pattern
- file: userfrosting/routes/api.php
  relevance: HIGH
  sections: [survey analysis endpoint at lines 2100-2300]
  why: "Existing ChatGPT integration pattern for API calls, prompting"

# Core scheduling models
- file: userfrosting/src/BuyerKiosk/Scheduling/Models/Shift.php
  relevance: CRITICAL
  why: "Shift entity we assign employees to"

- file: userfrosting/src/BuyerKiosk/Scheduling/Repositories/ShiftRepository.php
  relevance: HIGH
  why: "Query patterns for shifts, date ranges, employee assignments"

# Scheduling controllers
- file: userfrosting/src/BuyerKiosk/Scheduling/Controllers/TemplateController.php
  relevance: HIGH
  why: "Controller patterns for scheduling API"

# User/employee data
- file: userfrosting/models/BaseModel.php
  relevance: MEDIUM
  sections: [dbConnectByName, getUserById]
  why: "Database connection patterns, user data access"

# TaskEngine for background jobs
- file: userfrosting/src/BuyerKiosk/TaskEngine/Jobs/
  relevance: CRITICAL
  why: "Pattern for async AI job processing - high priority queue"

- file: userfrosting/src/BuyerKiosk/TaskEngine/
  relevance: HIGH
  why: "Job dispatcher, queue configuration, worker handling"

# Labor cost calculations
- file: userfrosting/src/BuyerKiosk/Scheduling/Services/LaborCostCalculator.php
  relevance: HIGH
  why: "Existing overtime and pay calculations"

- file: userfrosting/src/BuyerKiosk/Scheduling/Services/SchedulePoliciesService.php
  relevance: HIGH
  why: "Store-specific scheduling rules and policies"

# External documentation
- url: https://platform.openai.com/docs/api-reference/chat
  relevance: HIGH
  sections: [chat completions, response format, function calling]
  why: "OpenAI ChatGPT API for schedule optimization"
```

### Implementation Boundaries

- **Must Preserve**:
  - Existing shift model and lifecycle (draft → published → claimed)
  - Template system from Spec 025
  - Clock-in/punch functionality
  - Multi-provider support (WhenIWork, Homebase fallback)
  - Real-time Ably notifications for schedule changes

- **Can Modify**:
  - Add new columns to `scheduleShifts` for AI metadata
  - Extend template application logic for AI suggestions
  - Add new API endpoints for AI scheduling
  - Create new database tables for AI logs, usage tracking

- **Must Not Touch**:
  - WhenIWork/Homebase provider integrations
  - Timesheet export functionality
  - Existing schedule-display.js core rendering logic
  - Mobile API endpoints (`/api/mobile/*`)

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Manager[Store Manager] --> UI[Scheduling Calendar UI]
    UI --> API[AI Scheduling API]

    API -->|dispatch job| Queue[(Redis Queue)]
    Queue -->|high priority| TaskEngine[TaskEngine Workers]
    TaskEngine --> OpenAI[OpenAI ChatGPT API]
    TaskEngine --> StoreDB[(Store Database)]
    TaskEngine --> CentralDB[(Central Database)]

    TaskEngine -->|job complete| Ably[Ably Real-time]
    TaskEngine -->|optional| Email[Email Service]
    Ably -->|real-time update| UI

    API --> Redis[(Redis Cache)]

    subgraph Store Database
        StoreDB --> Shifts[scheduleShifts]
        StoreDB --> Templates[scheduleTemplates]
        StoreDB --> Availability[scheduleAvailability]
        StoreDB --> TimeOff[scheduleTimeOffRequests]
        StoreDB --> AISuggestions[aiScheduleSuggestions]
        StoreDB --> AIUsage[aiScheduleUsage]
        StoreDB --> AIJobs[aiScheduleJobs]
        StoreDB --> AISessionLogs[aiScheduleSessionLogs]
        StoreDB --> HourlyMetrics[hourlyStaffingMetrics]
    end

    subgraph Central Database
        CentralDB --> Users[kiosk_users.users]
        CentralDB --> UserStoreAssign[kiosk_users.userStoreAssignments]
        CentralDB --> UserPayRates[kiosk_users.userPayRates]
        CentralDB --> Stores[kiosk_buykiosk.stores]
        CentralDB --> StoreRoles[kiosk_buykiosk.storeRoles]
    end
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Web Calendar UI"
    type: HTTPS
    format: REST JSON
    authentication: Session (UserFrosting)
    endpoints:
      - POST /:typeNum/api/schedule/ai/generate     # Dispatch async job
      - GET /:typeNum/api/schedule/ai/job/:jobId    # Poll job status
      - GET /:typeNum/api/schedule/ai/usage         # Check rate limit
      - GET /:typeNum/api/schedule/ai/suggestions/:weekStart  # Get pending suggestion
      - POST /:typeNum/api/schedule/ai/apply        # Apply suggestions
      - POST /:typeNum/api/schedule/ai/dismiss      # Dismiss suggestions
    data_flow: "Manager triggers AI job, polls/waits for completion, reviews/applies suggestions"

# Outbound Interfaces
outbound:
  - name: "OpenAI ChatGPT API with Structured Outputs"
    type: HTTPS
    format: REST JSON
    authentication: Bearer token (API key)
    doc: https://platform.openai.com/docs/guides/structured-outputs
    data_flow: "Send scheduling prompt with JSON schema, receive guaranteed schema-compliant response"
    criticality: CRITICAL
    timeout: 60 seconds (async job, not blocking user)
    response_format:
      type: json_schema
      json_schema:
        name: schedule_assignments
        strict: true
        schema: # See OpenAI Structured Output Schema below
    model_configuration:
      primary_model: "${OPENAI_MODEL}" # env var, default: gpt-5-mini
      fallback_chain: ["gpt-5-mini", "gpt-5", "gpt-4o-mini"]  # automatic fallback
      notes: |
        - Use stable model IDs (avoid date-suffix snapshots in production)
        - Fallback chain attempts next model if primary returns model_not_found
        - Models validated at startup via GET /v1/models
        - Monitor OpenAI deprecations page for scheduled shutdowns

  - name: "Ably Real-time"
    type: WebSocket
    format: JSON
    authentication: Token auth
    channels:
      - "ai-schedule-{typeNum}-{jobId}"  # Job completion notification
      - "schedule-updates-{typeNum}"      # Schedule change broadcasts
    data_flow: "Notify UI when job complete, broadcast applied changes"
    criticality: HIGH

  - name: "Email Service"
    type: Internal (existing)
    format: Twig template
    data_flow: "Send completion notification if user opted for email"
    criticality: LOW

# Data Interfaces
data:
  - name: "Store Database"
    type: MySQL
    connection: "dbConnectByName($store->getDbName())"
    data_flow: "Shift data, templates, availability, AI suggestions"

  - name: "Central Database"
    type: MySQL
    connection: "dbConnectByName('kiosk_users')"
    data_flow: "User data, hours preferences, roles"

  - name: "Redis Cache"
    type: Redis
    connection: "Predis client via $_ENV['REDIS_URL']"
    data_flow: "Cache AI suggestions, rate limit counters, job queue"
```

#### OpenAI Structured Output Schema

The AI response MUST conform to this JSON Schema (enforced by OpenAI Structured Outputs with `strict: true`):

```json
{
  "name": "schedule_assignments",
  "strict": true,
  "schema": {
    "type": "object",
    "properties": {
      "assignments": {
        "type": "array",
        "description": "List of shift assignments",
        "items": {
          "type": "object",
          "properties": {
            "shiftId": {
              "type": "integer",
              "description": "The ID of the open shift being assigned"
            },
            "employeeId": {
              "type": ["integer", "null"],
              "description": "Employee ID to assign, or null if unfillable"
            },
            "reasoning": {
              "type": "string",
              "description": "Explanation for this assignment decision"
            },
            "confidenceScore": {
              "type": "number",
              "minimum": 0,
              "maximum": 1,
              "description": "Confidence in this assignment (0-1)"
            }
          },
          "required": ["shiftId", "employeeId", "reasoning", "confidenceScore"],
          "additionalProperties": false
        }
      },
      "summary": {
        "type": "object",
        "description": "Summary statistics for the generation",
        "properties": {
          "totalShiftsProcessed": { "type": "integer" },
          "assignedCount": { "type": "integer" },
          "unfilledCount": { "type": "integer" },
          "optimizationNotes": { "type": "string" }
        },
        "required": ["totalShiftsProcessed", "assignedCount", "unfilledCount", "optimizationNotes"],
        "additionalProperties": false
      }
    },
    "required": ["assignments", "summary"],
    "additionalProperties": false
  }
}
```

**Benefits of Structured Outputs:**
- **Guaranteed schema adherence** - No need to validate or retry malformed responses
- **Type safety** - Integer IDs, nullable fields, bounded scores all enforced
- **No hallucinated fields** - `additionalProperties: false` prevents extra data
- **Explicit reasoning** - Every assignment includes explanation

### Project Commands

```bash
# Development
cd userfrosting && composer install              # Install dependencies
php userfrosting/conductor build-css             # Build CSS (development)
php userfrosting/conductor build-css --minify    # Build CSS (production)

# Testing
./test.sh                                        # Run all tests
./test.sh --testsuite unit                       # Unit tests only
./test.sh --testsuite integration                # Integration tests only
./test.sh --coverage                             # With coverage report
./test.sh --stan                                 # Tests + PHPStan analysis

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse  # PHPStan analysis

# Database
php userfrosting/conductor run                   # Run migrations

# TaskEngine
php userfrosting/bin/task worker:start           # Start worker
php userfrosting/bin/task queue:status           # Check queues
php userfrosting/bin/task job:dispatch <job>     # Dispatch job

# Deployment
./deploy.sh                                      # Test + deploy
```

---

## Solution Strategy

### Architecture Pattern: Async Job-Based AI Orchestration

**Approach**: Layered architecture with TaskEngine-based async processing. User dispatches job, optionally waits for real-time completion via Ably, or receives email notification.

```
┌─────────────────────────────────────────────────────────────┐
│                    PRESENTATION LAYER                        │
│  ┌─────────────────┐  ┌──────────────────────────────────┐ │
│  │ AI Config Modal │  │ AI Preview/Apply Components      │ │
│  │ (dispatch job)  │  │ (show results when ready)        │ │
│  └─────────────────┘  └──────────────────────────────────┘ │
│                         ↑ Ably real-time notification       │
├─────────────────────────────────────────────────────────────┤
│                      API LAYER                               │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ AiSchedulingController (dispatch job, poll status)       ││
│  └─────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│                  TASKENGINE LAYER (Async)                    │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ AiScheduleGenerationJob (high priority queue)            ││
│  │  → Calls OpenAI with Structured Outputs                  ││
│  │  → Saves suggestions to DB                               ││
│  │  → Notifies via Ably + optional email                    ││
│  └─────────────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│                    SERVICE LAYER                             │
│  ┌────────────────────┐  ┌────────────────────────────────┐│
│  │ AiScheduleOptimizer│  │ AiPromptBuilder               ││
│  │ (Orchestration)    │  │ (Prompt + JSON Schema)        ││
│  └────────────────────┘  └────────────────────────────────┘│
│  ┌────────────────────┐  ┌────────────────────────────────┐│
│  │ AiUsageTracker     │  │ AiSuggestionRepository        ││
│  │ (Rate limiting)    │  │ (Persistence)                 ││
│  └────────────────────┘  └────────────────────────────────┘│
│  ┌────────────────────┐  ┌────────────────────────────────┐│
│  │OwnerPrefsService   │  │ AiNotificationService         ││
│  │ (Remember toggles) │  │ (Ably + Email)                ││
│  └────────────────────┘  └────────────────────────────────┘│
├─────────────────────────────────────────────────────────────┤
│                 EXISTING INFRASTRUCTURE                      │
│  ┌────────────────┐  ┌─────────────────┐  ┌──────────────┐│
│  │ShiftRepository │  │LaborCostCalc   │  │PolicyService ││
│  └────────────────┘  └─────────────────┘  └──────────────┘│
│  ┌────────────────┐  ┌─────────────────┐  ┌──────────────┐│
│  │AvailabilityRepo│  │TimeOffRepository│  │UserRepository││
│  └────────────────┘  └─────────────────┘  └──────────────┘│
└─────────────────────────────────────────────────────────────┘
```

**Integration Approach**:
1. AI layer sits ABOVE existing scheduling - doesn't modify core shift/template logic
2. TaskEngine handles async processing with retry and failure recovery
3. AI generates "suggestions" that are applied using existing shift assignment mechanisms
4. Dual notification: Ably for users waiting, email for users who navigate away

**Justification**:
- **Non-blocking UX** - User can wait or leave and get notified
- **TaskEngine reliability** - Built-in retry, failure handling, queue prioritization
- **Scalable** - High priority queue ensures AI jobs process quickly
- **Minimal disruption** - AI is additive, reuses existing infrastructure

**Key Decisions**:
1. **Async via TaskEngine** - High priority queue, dual notification (Ably + email)
2. **OpenAI Structured Outputs** - `strict: true` JSON Schema for guaranteed format
3. **Preview-first workflow** - Never auto-apply AI suggestions
4. **Suggestions persist until action** - No arbitrary expiration (expires when week passes)
5. **Owner preference memory** - Store toggles saved per-store for next time

---

## Building Block View

### Components

```mermaid
graph TB
    subgraph Frontend
        CalendarUI[Calendar View]
        AIButton[Generate with AI Button]
        ConfigModal[AI Config Modal]
        PreviewPanel[AI Preview Panel]
    end

    subgraph API["API Layer"]
        AiController[AiSchedulingController]
    end

    subgraph TaskEngine["TaskEngine (Async)"]
        Queue[(Redis High Priority Queue)]
        Job[AiScheduleGenerationJob]
    end

    subgraph Services["Service Layer"]
        Optimizer[AiScheduleOptimizer]
        PromptBuilder[AiPromptBuilder]
        UsageTracker[AiUsageTracker]
        OpenAIClient[OpenAIClient]
        NotifyService[AiNotificationService]
        OwnerPrefs[OwnerPrefsService]
    end

    subgraph Repositories
        SuggestionRepo[AiSuggestionRepository]
        ShiftRepo[ShiftRepository]
        AvailRepo[AvailabilityRepository]
        UserRepo[UserRepository]
    end

    subgraph External
        OpenAI[OpenAI API]
        StoreDB[(Store DB)]
        CentralDB[(Central DB)]
        Redis[(Redis)]
        Ably[Ably Real-time]
        Email[Email Service]
    end

    CalendarUI --> AIButton
    AIButton --> ConfigModal
    ConfigModal --> AiController

    %% Async dispatch flow
    AiController -->|dispatch| Queue
    Queue --> Job
    Job --> Optimizer

    %% Service layer
    Optimizer --> PromptBuilder
    Optimizer --> UsageTracker
    Optimizer --> OpenAIClient
    Optimizer --> SuggestionRepo

    PromptBuilder --> ShiftRepo
    PromptBuilder --> AvailRepo
    PromptBuilder --> UserRepo
    OwnerPrefs --> CentralDB

    OpenAIClient --> OpenAI
    UsageTracker --> Redis
    SuggestionRepo --> StoreDB
    UserRepo --> CentralDB

    %% Notification flow
    Job --> NotifyService
    NotifyService --> Ably
    NotifyService --> Email
    Ably -->|real-time update| PreviewPanel
```

### Directory Map

```
userfrosting/src/BuyerKiosk/Scheduling/
├── Controllers/
│   ├── SchedulingController.php          # EXISTING - shift CRUD
│   ├── TemplateController.php            # EXISTING - templates
│   └── AiSchedulingController.php        # NEW - AI endpoints (dispatch, poll, apply)
├── Services/
│   ├── LaborCostCalculator.php           # EXISTING - cost calculations
│   ├── SchedulePoliciesService.php       # EXISTING - OT rules
│   ├── AiScheduleOptimizer.php           # NEW - orchestration
│   ├── AiPromptBuilder.php               # NEW - prompt + JSON schema construction
│   ├── AiUsageTracker.php                # NEW - rate limiting
│   ├── AiNotificationService.php         # NEW - Ably + email notifications
│   ├── OwnerPrefsService.php             # NEW - owner toggle preference memory
│   └── AiDefaultPrefsService.php         # NEW - save/load optimization defaults
├── Repositories/
│   ├── ShiftRepository.php               # EXISTING - shift queries
│   ├── AvailabilityRepository.php        # EXISTING - availability
│   ├── AiSuggestionRepository.php        # NEW - suggestion persistence
│   ├── AiJobRepository.php               # NEW - job status tracking
│   ├── AiSessionLogRepository.php        # NEW - session log access (admin debugging)
│   └── HourlyMetricsRepository.php       # NEW - staffing/performance metrics
├── Models/
│   ├── Shift.php                         # EXISTING - shift entity
│   ├── AiSuggestion.php                  # NEW - suggestion entity
│   └── AiJob.php                         # NEW - job status entity
└── Clients/
    └── OpenAIClient.php                  # NEW - API client with Structured Outputs

userfrosting/src/BuyerKiosk/TaskEngine/Jobs/
├── AiScheduleGenerationJob.php           # NEW - async AI generation job
├── HourlyMetricsCollectorJob.php         # NEW - collect staffing/performance metrics (hourly)
├── HourlyMetricsBackfillJob.php          # NEW - 90-day backfill from historical data
└── AiScheduleCleanupJob.php              # NEW - cleanup expired logs/metrics (daily)

userfrosting/routes/
├── scheduling.php                        # EXISTING
└── groups/
    └── ai-scheduling.php                 # NEW - AI routes

userfrosting/migrations/input/
├── 20260108_001_ai_schedule_suggestions.json   # NEW - suggestions table (store DB)
├── 20260108_002_ai_schedule_usage.json         # NEW - usage tracking (store DB)
├── 20260108_003_ai_schedule_jobs.json          # NEW - job status tracking (store DB)
├── 20260108_004_stores_ai_prefs.json           # NEW - owner + default prefs on kiosk_buykiosk.stores
├── 20260108_005_users_hours_prefs.json         # NEW - hours preferences on kiosk_users.users
├── 20260108_006_schedule_shifts_ai_columns.json # NEW - AI columns on shifts (store DB)
├── 20260108_007_ai_session_logs.json           # NEW - session logging table (store DB)
└── 20260108_008_hourly_staffing_metrics.json   # NEW - historical metrics table (store DB)

public_html/js/workspace/modules/workbook/
├── schedule-display.js                   # EXISTING - main schedule UI
└── ai-scheduling.js                      # NEW - AI UI components (modal, preview, Ably listener)

userfrosting/templates/themes/default/workbook/partials/
├── schedule-panel.html                   # EXISTING
├── ai-config-modal.html                  # NEW - configuration modal
└── ai-preview-panel.html                 # NEW - preview interface

userfrosting/templates/themes/default/emails/
└── ai-schedule-complete.html             # NEW - completion notification email
```

### Interface Specifications

#### Data Storage Changes

```yaml
# Store Database - aiScheduleJobs (NEW)
Table: aiScheduleJobs (NEW)
  jobId: varchar(36), PRIMARY KEY (UUID)
  weekStart: date, NOT NULL
  status: enum('pending', 'processing', 'completed', 'failed'), DEFAULT 'pending'
  createdByUserId: int, NOT NULL
  createdAt: datetime, NOT NULL
  startedAt: datetime, NULLABLE
  completedAt: datetime, NULLABLE
  optimizationPriorities: text (JSON array)
  customInstructions: text, NULLABLE
  includeOwnerIds: text (JSON array)
  notifyByEmail: boolean, DEFAULT false
  errorMessage: text, NULLABLE
  suggestionId: int, NULLABLE (FK to aiScheduleSuggestions when complete)
  INDEX: idx_week_status (weekStart, status)
  INDEX: idx_created (createdAt)

# Store Database - aiScheduleSuggestions
Table: aiScheduleSuggestions (NEW)
  suggestionId: int, AUTO_INCREMENT, PRIMARY KEY
  jobId: varchar(36), NOT NULL (FK to aiScheduleJobs)
  weekStart: date, NOT NULL
  weekEnd: date, NOT NULL
  generatedAt: datetime, NOT NULL
  generatedByUserId: int, NOT NULL
  optimizationPriorities: text (JSON array)
  customInstructions: text, NULLABLE
  includeOwnerIds: text (JSON array)
  openAiModel: varchar(50)
  openAiPromptTokens: int
  openAiCompletionTokens: int
  requestDurationMs: int
  suggestions: longtext (JSON - array of assignments per schema)
  status: enum('pending', 'applied', 'dismissed', 'expired'), DEFAULT 'pending'
  appliedAt: datetime, NULLABLE
  appliedByUserId: int, NULLABLE
  dismissedAt: datetime, NULLABLE
  acceptedCount: int, DEFAULT 0
  rejectedCount: int, DEFAULT 0
  INDEX: idx_week (weekStart)
  INDEX: idx_status (status)
  UNIQUE INDEX: idx_pending_week (weekStart) WHERE status = 'pending'

# Store Database - aiScheduleUsage
Table: aiScheduleUsage (NEW)
  usageId: int, AUTO_INCREMENT, PRIMARY KEY
  payWeekStart: date, NOT NULL
  runCount: int, DEFAULT 0
  lastRunAt: datetime, NULLABLE
  UNIQUE INDEX: idx_pay_week (payWeekStart)

# Central Database - kiosk_users.users (MODIFY)
Table: users (MODIFY in kiosk_users database - add hours preferences)
  ADD COLUMN: hoursRequested decimal(5,2), NULLABLE (target weekly hours)
  ADD COLUMN: hoursMin decimal(5,2), NULLABLE (minimum weekly hours)
  ADD COLUMN: hoursMax decimal(5,2), NULLABLE (maximum weekly hours - hard cap)
  # Note: These are global defaults; can be overridden per-schedule in AI config
  # Empty/NULL hoursRequested = flexible (no target, schedule as needed)
  # hoursMax is a HARD CAP - AI never exceeds this
  # Role is stored in userStoreAssignments.role (per-store), NOT on users table

# Central Database - stores (MODIFY)
Table: stores (MODIFY in kiosk_buykiosk)
  ADD COLUMN: aiScheduleOwnerPrefs text, NULLABLE (JSON - owner toggle preferences)
  # Example: {"includeOwnerIds": [123, 456], "excludeOwnerIds": [789]}
  ADD COLUMN: aiScheduleDefaultPrefs text, NULLABLE (JSON - saved optimization defaults)
  # Example: {"optimizationPriorities": ["hours_fairness", "labor_cost"], "customInstructions": "Prefer morning shifts for new hires"}

# Modify: scheduleShifts
Table: scheduleShifts (MODIFY)
  ADD COLUMN: aiSuggestionId int, NULLABLE, FK to aiScheduleSuggestions
  ADD COLUMN: aiAssignedAt datetime, NULLABLE
  ADD INDEX: idx_ai_suggestion (aiSuggestionId)

# Store Database - aiScheduleSessionLogs (NEW) - Full AI Session Logging (PRD Feature 13)
Table: aiScheduleSessionLogs (NEW)
  logId: int, AUTO_INCREMENT, PRIMARY KEY
  suggestionId: int, NOT NULL, FK to aiScheduleSuggestions
  promptSent: longtext, NOT NULL (full system + user messages sent to AI)
  responseReceived: longtext, NOT NULL (raw AI response)
  appliedAssignments: text, NULLABLE (JSON - shift IDs that were accepted)
  rejectedAssignments: text, NULLABLE (JSON - shift IDs that were rejected)
  manualChangesAfter: text, NULLABLE (JSON - changes made after AI apply)
  createdAt: datetime, NOT NULL
  INDEX: idx_suggestion (suggestionId)
  INDEX: idx_created (createdAt)
  # Retention: 90 days (cleanup via TaskEngine job)

# Store Database - hourlyStaffingMetrics (NEW) - Historical Data Collection (PRD Features 11 & 12)
Table: hourlyStaffingMetrics (NEW)
  metricId: int, AUTO_INCREMENT, PRIMARY KEY
  metricDate: date, NOT NULL
  hourSlot: tinyint, NOT NULL (0-23, represents start of hour)
  # Staffing data (Feature 11)
  staffCountTotal: int, DEFAULT 0
  staffCountByPosition: text, NULLABLE (JSON - {cashier: 2, buyer: 3, lead: 1})
  # Performance data (Feature 12)
  salesVolume: decimal(10,2), NULLABLE
  transactionCount: int, NULLABLE
  buyCount: int, NULLABLE
  avgBuyWaitMinutes: decimal(5,2), NULLABLE
  createdAt: datetime, NOT NULL
  UNIQUE INDEX: idx_date_hour (metricDate, hourSlot)
  INDEX: idx_date (metricDate)
  # Retention: 1 year rolling (cleanup via TaskEngine job)
  # Backfill: 90-day backfill from timePunches + buyQueue data
```

#### Internal API Changes

```yaml
# Dispatch AI Generation Job (Async)
Endpoint: Dispatch AI Schedule Generation
  Method: POST
  Path: /:typeNum/api/schedule/ai/generate
  Auth: Session + checkStoreGroup + checkAccess('uri_schedule_ai')
  Request:
    weekStart: date (YYYY-MM-DD), required
    optimizationPriorities: string[] (ordered list), required
      # Options: "labor_cost", "hours_fairness", "seniority", "position_coverage"
    customInstructions: string, optional (max 500 chars)
    includeOwnerIds: int[], optional (owner user IDs to include)
    notifyByEmail: boolean, optional (default: false)
    saveOwnerPrefs: boolean, optional (default: true, saves toggle state)
    saveDefaultPrefs: boolean, optional (default: true, saves priorities + custom instructions)
    hoursOverrides: object, optional (per-schedule hours overrides)
      # Example: {userId: {hoursRequested: 25, hoursMin: 20, hoursMax: 32}}
      # Overrides global kiosk_users values for this generation only
    forceRegenerate: boolean, optional (default: false)
      # If true, replaces existing AI assignments for this week (requires confirmation)
  Response:
    success: true
    data:
      jobId: string (UUID)
      status: "pending"
      ablyChannel: "ai-schedule-{typeNum}-{jobId}"
      estimatedDuration: int (seconds, ~30-60)
      runsRemaining: int
      existingAiAssignments: int (count of AI assignments that will be replaced if forceRegenerate)
    error (if failed):
      success: false
      error: string (message)
      code: string (ERROR_RATE_LIMITED | ERROR_NO_OPEN_SHIFTS | ERROR_PENDING_JOB_EXISTS | ERROR_EXISTING_AI_ASSIGNMENTS)

# Poll Job Status
Endpoint: Get Job Status
  Method: GET
  Path: /:typeNum/api/schedule/ai/job/:jobId
  Auth: Session + checkStoreGroup
  Response:
    success: true
    data:
      jobId: string
      status: "pending" | "processing" | "completed" | "failed"
      createdAt: datetime
      startedAt: datetime, nullable
      completedAt: datetime, nullable
      errorMessage: string, nullable (if failed)
      suggestionId: int, nullable (if completed)

# Get Pending Suggestion for Week
Endpoint: Get Suggestion
  Method: GET
  Path: /:typeNum/api/schedule/ai/suggestions/:weekStart
  Auth: Session + checkStoreGroup
  Response:
    success: true
    data:
      suggestion: object (full suggestion with assignments) or null
      hasStaleWarnings: boolean (true if shifts changed since generation)
      staleShiftIds: int[] (shift IDs that changed)

# Get Full Suggestion Details
Endpoint: Get Suggestion Details
  Method: GET
  Path: /:typeNum/api/schedule/ai/suggestions/detail/:suggestionId
  Auth: Session + checkStoreGroup
  Response:
    success: true
    data:
      suggestionId: int
      generatedAt: datetime
      status: "pending" | "applied" | "dismissed" | "expired"
      assignments: array of:
        shiftId: int
        employeeId: int | null
        employeeName: string | null
        shiftStart: datetime
        shiftEnd: datetime
        position: string
        reasoning: string
        confidenceScore: float (0-1)
        laborCost: decimal
        hoursAfterAssignment: decimal
        isOvertime: boolean
        isStale: boolean (shift changed since generation)
      summary:
        totalShifts: int
        filledCount: int
        unfilledCount: int
        totalLaborCost: decimal
        overtimeHours: decimal
        hoursByEmployee: object {userId: hours}

# Get Usage Status
Endpoint: Get AI Usage
  Method: GET
  Path: /:typeNum/api/schedule/ai/usage
  Auth: Session + checkStoreGroup
  Response:
    success: true
    data:
      payWeekStart: date
      payWeekEnd: date
      runsUsed: int
      runsAllowed: int (from env, default 5)
      runsRemaining: int
      lastRunAt: datetime, nullable

# Get Owner Preferences
Endpoint: Get Owner Preferences
  Method: GET
  Path: /:typeNum/api/schedule/ai/owner-prefs
  Auth: Session + checkStoreGroup
  Query:
    weekStart: date (YYYY-MM-DD), optional (for availability status calculation)
  Response:
    success: true
    data:
      includeOwnerIds: int[] (from saved preferences)
      owners: array of:
        userId: int
        name: string
        availabilityStatus: "available" | "unavailable" | "partial" | "no_data"
          # "available" = has availability for all days of target week
          # "unavailable" = has no availability for target week
          # "partial" = has availability for some days only
          # "no_data" = no availability records set
        availableDayCount: int (0-7, days available in target week)
        hasHoursPrefs: boolean (true if hoursRequested/Max/Min set)
        hoursRequested: decimal | null
        hoursMax: decimal | null

# Get Default Preferences (NEW - for pre-populating config modal)
Endpoint: Get Default Preferences
  Method: GET
  Path: /:typeNum/api/schedule/ai/default-prefs
  Auth: Session + checkStoreGroup
  Response:
    success: true
    data:
      optimizationPriorities: string[] | null (saved defaults)
      customInstructions: string | null (saved default)
      includeOwnerIds: int[] (from owner prefs)

# Apply AI Suggestions
Endpoint: Apply AI Suggestions
  Method: POST
  Path: /:typeNum/api/schedule/ai/apply
  Auth: Session + checkStoreGroup + checkAccess('uri_schedule_ai_apply')
  Request:
    suggestionId: int, required
    acceptedShiftIds: int[], required (shift IDs to apply)
  Response:
    success: true
    data:
      appliedCount: int
      rejectedCount: int
      shifts: array (updated shift objects)
    error (if failed):
      success: false
      error: string
      code: string (ERROR_SUGGESTION_APPLIED | ERROR_SHIFT_MODIFIED | ERROR_WEEK_PASSED)

# Dismiss AI Suggestions
Endpoint: Dismiss AI Suggestions
  Method: POST
  Path: /:typeNum/api/schedule/ai/dismiss
  Auth: Session + checkStoreGroup
  Request:
    suggestionId: int, required
  Response:
    success: true
    data:
      status: "dismissed"

# Get Session Logs (Admin Debugging - PRD Feature 13)
Endpoint: Get AI Session Logs
  Method: GET
  Path: /:typeNum/api/schedule/ai/logs
  Auth: Session + checkStoreGroup + checkAccess('uri_admin_ai_logs')
  Query:
    startDate: date, optional (default: 30 days ago)
    endDate: date, optional (default: today)
    limit: int, optional (default: 50, max: 100)
    offset: int, optional (default: 0)
  Response:
    success: true
    data:
      logs: array of:
        logId: int
        suggestionId: int
        weekStart: date
        generatedAt: datetime
        generatedByUserName: string
        status: "pending" | "applied" | "dismissed" | "expired"
        acceptedCount: int
        rejectedCount: int
        promptTokens: int
        completionTokens: int
      pagination: {total, limit, offset}

# Get Session Log Detail (Admin Debugging)
Endpoint: Get AI Session Log Detail
  Method: GET
  Path: /:typeNum/api/schedule/ai/logs/:logId
  Auth: Session + checkStoreGroup + checkAccess('uri_admin_ai_logs')
  Response:
    success: true
    data:
      logId: int
      promptSent: string (full prompt)
      responseReceived: string (raw AI response)
      appliedAssignments: int[] (shift IDs)
      rejectedAssignments: int[] (shift IDs)
      manualChangesAfter: array of {shiftId, changeType, timestamp}

# Get Cross-Store Usage (Owner/Admin - PRD Feature 9 visibility)
Endpoint: Get AI Usage Across Stores
  Method: GET
  Path: /admin/api/schedule/ai/usage-report
  Auth: Session + checkAccess('uri_admin_ai_usage_report')
  Query:
    payWeekStart: date, optional (default: current pay week)
  Response:
    success: true
    data:
      payWeekStart: date
      payWeekEnd: date
      stores: array of:
        typeNum: string
        storeName: string
        runsUsed: int
        runsAllowed: int
        lastRunAt: datetime | null
      totals:
        totalRuns: int
        activeStores: int (stores with at least 1 run)
```

#### Ably Message Format

```yaml
# Job Completion Notification
Channel: ai-schedule-{typeNum}-{jobId}
Event: job-complete
Payload:
  jobId: string
  status: "completed" | "failed"
  suggestionId: int, nullable
  errorMessage: string, nullable
  summary:
    filledCount: int
    unfilledCount: int
    totalLaborCost: decimal

# Schedule Updated (after apply)
Channel: schedule-updates-{typeNum}
Event: ai-schedule-applied
Payload:
  suggestionId: int
  appliedCount: int
  affectedShiftIds: int[]
```

#### Application Data Models

```pseudocode
ENTITY: AiSuggestion (NEW)
  FIELDS:
    suggestionId: int (PK)
    weekStart: date
    generatedAt: datetime
    generatedByUserId: int
    optimizationPriorities: array<string>
    customInstructions: string|null
    includeOwnerIds: array<int>
    openAiModel: string
    openAiPromptTokens: int
    openAiCompletionTokens: int
    requestDurationMs: int
    suggestions: array<SuggestionAssignment>
    appliedAt: datetime|null
    appliedByUserId: int|null
    acceptedCount: int
    rejectedCount: int

  BEHAVIORS:
    + isExpired(): bool  // Suggestions expire when weekEnd date passes (not time-based)
    + getAcceptanceRate(): float
    + getTotalLaborCost(): decimal

ENTITY: SuggestionAssignment (Value Object)
  FIELDS:
    shiftId: int
    employeeId: int|null (null if unfillable)
    employeeName: string|null
    shiftStart: datetime
    shiftEnd: datetime
    position: string
    reasoning: string
    confidenceScore: float (0-1, from AI response)
    laborCost: decimal
    hoursAfterAssignment: decimal
    isOvertime: bool

ENTITY: Shift (MODIFIED)
  FIELDS:
    ... existing fields ...
    + aiSuggestionId: int|null (NEW)
    + aiAssignedAt: datetime|null (NEW)

  BEHAVIORS:
    ... existing methods ...
    + wasAiAssigned(): bool (NEW)
```

#### Integration Points

```yaml
# OpenAI ChatGPT Integration
OpenAI_ChatGPT:
  endpoint: https://api.openai.com/v1/chat/completions
  model: "${OPENAI_MODEL}" (default: gpt-5-mini)
  fallback_chain: ["gpt-5-mini", "gpt-5", "gpt-4o-mini"]
  authentication: Bearer ${OPENAI_API_KEY}
  timeout: 60000ms (async job, not blocking user)
  integration: |
    Send structured prompt with:
    - List of open shifts (date, time, position, requirements)
    - List of available employees (availability, hours prefs, pay rate, role)
    - Current pay period hours per employee
    - Optimization priorities (ordered list)
    - Custom instructions
    Receive JSON with employee-to-shift assignments, reasoning, and summary (via Structured Outputs)

# Redis Integration
Redis:
  usage_key: "ai_schedule_usage:{typeNum}:{payWeekStart}"
  suggestion_cache: "ai_schedule_suggestion:{typeNum}:{weekStart}"
  cache_ttl: "until weekEnd + 1 day" (not fixed 24h - tied to schedule week lifecycle)
  integration: Rate limit counter + suggestion caching
  # Suggestions persist until user action OR schedule week passes (per ADR-4)
```

### Implementation Examples

#### Example: AI Prompt Construction

**Why this example**: The prompt structure is critical for AI quality. This shows the expected data format.

```php
// AiPromptBuilder::buildPrompt()
// Constructs the system and user messages for OpenAI

$systemMessage = <<<PROMPT
You are an expert employee scheduling AI. Generate optimal shift assignments.

CONSTRAINTS (MUST FOLLOW):
1. Only assign employees to shifts they're qualified for (by role level)
2. Never exceed an employee's hoursMax limit (hard cap)
3. Try to meet each employee's hoursMin when possible
4. Optimize toward each employee's hoursRequested target
5. Only assign during employee's available hours
6. Respect approved time-off requests
7. Opening/closing shifts require Role ≤3 (Owner, Manager, or Shift Lead)

OPTIMIZATION PRIORITIES (in order):
{$prioritiesText}

OUTPUT FORMAT:
Return a JSON object matching the provided schema with:
- "assignments": array of shift assignments (each with shiftId, employeeId, reasoning, confidenceScore)
- "summary": object with totalShiftsProcessed, assignedCount, unfilledCount, optimizationNotes

For unfillable shifts, set employeeId to null with reasoning explaining why.
PROMPT;

$userMessage = <<<DATA
OPEN SHIFTS:
{$shiftsJson}

AVAILABLE EMPLOYEES (with hours preferences):
{$employeesJson}
# Each employee includes: id, name, role, payRate, availability windows,
# hoursRequested (target), hoursMin, hoursMax (hard cap)

CURRENT PAY PERIOD HOURS:
{$hoursJson}
# Hours already worked/scheduled this pay period per employee

CUSTOM INSTRUCTIONS:
{$customInstructions}

Generate optimal assignments following the schema.
DATA;
```

#### Example: Rate Limiting Logic

**Why this example**: Rate limiting is business-critical for cost control.

```php
// AiUsageTracker::canGenerate()
// Checks if store has remaining AI runs for current pay week

public function canGenerate(string $typeNum): UsageCheckResult
{
    $payWeekStart = $this->getPayWeekStart($typeNum);
    $key = "ai_schedule_usage:{$typeNum}:{$payWeekStart}";

    $usage = $this->redis->get($key);
    $runCount = $usage ? (int)$usage : 0;

    if ($runCount >= self::MAX_RUNS_PER_WEEK) {
        return UsageCheckResult::denied(
            runsUsed: $runCount,
            runsAllowed: self::MAX_RUNS_PER_WEEK,
            resetDate: $this->getPayWeekEnd($typeNum)
        );
    }

    return UsageCheckResult::allowed(
        runsUsed: $runCount,
        runsRemaining: self::MAX_RUNS_PER_WEEK - $runCount
    );
}

public function recordRun(string $typeNum): void
{
    $payWeekStart = $this->getPayWeekStart($typeNum);
    $key = "ai_schedule_usage:{$typeNum}:{$payWeekStart}";

    // Increment counter, set expiry to end of pay week + 1 day
    $this->redis->incr($key);
    $this->redis->expireat($key, $this->getPayWeekEnd($typeNum)->addDay()->timestamp);

    // Also persist to database for reporting
    $this->persistUsage($typeNum, $payWeekStart);
}
```

---

## Runtime View

### Primary Flow: Async AI Schedule Generation

```mermaid
sequenceDiagram
    actor Manager
    participant UI as Calendar UI
    participant Controller as AiSchedulingController
    participant UsageTracker as AiUsageTracker
    participant Queue as Redis Queue
    participant Ably as Ably

    Manager->>UI: Click "Generate with AI"
    UI->>UI: Show Config Modal (load saved owner prefs)
    Manager->>UI: Set priorities, click Generate
    UI->>Controller: POST /ai/generate

    Controller->>UsageTracker: canGenerate(typeNum)

    alt Rate Limited
        UsageTracker-->>Controller: denied
        Controller-->>UI: 429 Rate Limited
        UI-->>Manager: Show limit message
    end

    UsageTracker-->>Controller: allowed
    Controller->>Controller: Create job record (status: pending)
    Controller->>Queue: Dispatch AiScheduleGenerationJob (high priority)
    Controller->>UsageTracker: recordRun()

    Controller-->>UI: {jobId, ablyChannel, status: pending}
    UI->>Ably: Subscribe to ai-schedule-{typeNum}-{jobId}
    UI->>UI: Show "Generating..." state (can navigate away)

    Note over Manager,UI: User can wait OR leave (email notification if enabled)
```

### Background Flow: TaskEngine Job Processing

```mermaid
sequenceDiagram
    participant Queue as Redis Queue
    participant Job as AiScheduleGenerationJob
    participant Optimizer as AiScheduleOptimizer
    participant PromptBuilder as AiPromptBuilder
    participant OpenAI as OpenAI API
    participant DB as Store Database
    participant Notify as AiNotificationService
    participant Ably as Ably
    participant Email as Email Service

    Queue->>Job: Dequeue (high priority)
    Job->>DB: Update job status: processing

    Job->>Optimizer: generateSuggestions(params)
    Optimizer->>PromptBuilder: buildPrompt(weekStart)

    PromptBuilder->>DB: Get open shifts
    PromptBuilder->>DB: Get employees + availability
    PromptBuilder->>DB: Get time-off requests
    PromptBuilder->>DB: Get current hours
    PromptBuilder-->>Optimizer: prompt with JSON schema

    Optimizer->>OpenAI: POST /chat/completions (Structured Outputs)
    Note over OpenAI: strict: true JSON schema

    alt API Error
        OpenAI-->>Optimizer: Error/Timeout
        Optimizer->>DB: Update job status: failed
        Optimizer->>Notify: notifyFailure(jobId)
        Notify->>Ably: Publish job-complete (failed)
        Notify->>Email: Send failure notification (if enabled)
    end

    OpenAI-->>Optimizer: Guaranteed JSON (schema-compliant)

    Optimizer->>Optimizer: Enrich with labor costs, OT flags
    Optimizer->>DB: Save suggestion
    Optimizer->>DB: Update job status: completed, link suggestionId

    Optimizer->>Notify: notifySuccess(jobId, suggestionId)
    Notify->>Ably: Publish job-complete (success + summary)
    Notify->>Email: Send completion email (if enabled)
```

### UI Flow: Real-time Update on Completion

```mermaid
sequenceDiagram
    participant Ably as Ably
    participant UI as Calendar UI
    actor Manager

    Ably->>UI: job-complete event
    UI->>UI: Parse completion payload

    alt Job Failed
        UI->>UI: Show error toast with message
    end

    alt Job Succeeded
        UI->>UI: Fetch full suggestion details
        UI->>UI: Show Preview Panel with assignments
        UI-->>Manager: "AI schedule ready for review!"
    end
```

### Secondary Flow: Apply Suggestions

```mermaid
sequenceDiagram
    actor Manager
    participant UI as Preview Panel
    participant Controller as AiSchedulingController
    participant ShiftRepo as ShiftRepository
    participant DB as Store Database
    participant Ably as Ably

    Manager->>UI: Uncheck unwanted assignments
    Manager->>UI: Click "Apply Selected"
    UI->>Controller: POST /ai/apply

    Controller->>DB: Get suggestion by ID

    alt Week Has Passed
        Controller-->>UI: 410 Gone (ERROR_WEEK_PASSED)
        UI-->>Manager: "This schedule week has passed."
    end

    alt Already Applied
        Controller-->>UI: 409 Conflict (ERROR_SUGGESTION_APPLIED)
        UI-->>Manager: "Suggestions already applied."
    end

    Controller->>Controller: Check for stale shifts
    Note over Controller: Warn if shifts modified since generation

    Controller->>DB: Begin transaction

    loop Each accepted shift
        Controller->>ShiftRepo: assignEmployee(shiftId, employeeId)
        ShiftRepo->>DB: UPDATE scheduleShifts
    end

    Controller->>DB: Update suggestion (status: applied, counts)
    Controller->>DB: Commit transaction

    Controller->>Ably: Publish ai-schedule-applied

    Controller-->>UI: Success response
    UI->>UI: Refresh calendar
    UI-->>Manager: Show success toast
```

### Error Handling

| Error Type | Detection | User Message | Recovery |
|------------|-----------|--------------|----------|
| Rate Limited | UsageTracker check | "You've used all {n} AI runs this pay week. Resets on [date]." | Wait for reset or manual scheduling |
| No Open Shifts | Pre-dispatch validation | "No open shifts found for this week. Create shifts first." | Add open shifts via template or manual |
| Pending Job Exists | Job record check | "A generation is already in progress for this week." | Wait for completion or check status |
| Existing AI Assignments | Pre-dispatch check | "This week has {n} existing AI assignments. Regenerating will replace them." | User confirms with forceRegenerate=true |
| OpenAI Timeout | Job failure | "AI generation timed out. Please try again." | Retry (new job) |
| OpenAI API Error | Job failure | "AI service encountered an error." | Retry (new job) |
| Model Not Found | OpenAI 404 | "AI model unavailable, trying fallback..." | Auto-fallback to next model in chain |
| Job Failed | Job status check | "[Error message from job]" | Retry or contact support |
| Week Passed | Apply validation | "This schedule week has already passed." | Generate for future week |
| Already Applied | Suggestion status | "These suggestions have already been applied." | View current schedule |
| Shifts Modified | Stale check | "Warning: {n} shifts changed since generation." | Review stale items or regenerate |
| Permission Denied | checkAccess failure | "You don't have permission to use AI scheduling." | Contact admin |
| Session Log Access Denied | checkAccess failure | "Admin access required to view AI session logs." | Contact admin |

### Re-optimization Flow (PRD Secondary Journey)

When a manager wants to regenerate AI suggestions for a week that already has AI assignments:

1. **Detection**: On clicking "Generate with AI", API checks for existing `aiAssignedAt` on shifts
2. **Warning Modal**: UI shows "This week has {n} existing AI assignments. Regenerating will replace them with new suggestions."
3. **Confirmation**: User must check "I understand" and click "Regenerate"
4. **Execution**: API called with `forceRegenerate: true`
5. **Result**: New job dispatched, existing AI-assigned shifts are included in open shifts pool

### Manager Shift Locking (PRD Feature 8)

**UI Treatment for Locked Manager Shifts:**
- Locked shifts displayed with visual indicator (lock icon, muted styling)
- Preview panel shows locked shifts in separate "Locked Shifts" section (read-only)
- Locked shifts included in labor cost calculations but NOT in AI assignment pool
- Tooltip: "This manager's recurring schedule cannot be modified by AI"

**Identification Logic:**
- Shifts created from manager recurring schedule templates are locked
- Flag set via `isManagerRecurring` on shift or template origin

### Complex Logic: Optimization Algorithm

```
ALGORITHM: AI Schedule Optimization
INPUT: weekStart, priorities[], customInstructions, includeOwnerIds[]
OUTPUT: SuggestionResult

1. GATHER DATA:
   openShifts = ShiftRepository.getOpenShifts(weekStart, weekEnd)
   employees = UserRepository.getSchedulableEmployees(storeId, includeOwnerIds)
   availability = AvailabilityRepository.getForWeek(weekStart, employeeIds)
   timeOff = TimeOffRepository.getApprovedForWeek(weekStart, employeeIds)
   currentHours = TimesheetRepository.getPayPeriodHours(employeeIds)
   policies = SchedulePoliciesService.getOvertimeRules(storeId)

2. FILTER EMPLOYEES:
   FOR each employee:
     - Remove if on time-off for entire week
     - Remove if already at maxHours
     - Flag managers with recurring schedules as "locked"
     - Flag owners based on includeOwnerIds (exclude by default)

3. BUILD PROMPT:
   prompt = AiPromptBuilder.build(
     shifts: openShifts,
     employees: filtered employees with availability windows,
     currentHours: hoursMap,
     priorities: ordered priority list,
     customInstructions: text
   )

4. CALL OPENAI:
   response = OpenAIClient.chat(prompt, model, timeout=30s)
   IF error: THROW AiServiceException

5. PARSE & VALIDATE:
   assignments = JSON.parse(response.content)
   FOR each assignment:
     - VALIDATE: employee exists and is in filtered list
     - VALIDATE: shift exists and is open
     - VALIDATE: employee available during shift hours
     - VALIDATE: employee role >= shift minRole
     - CALCULATE: hours after this assignment
     - CALCULATE: labor cost for this shift
     - FLAG: if overtime

6. CALCULATE SUMMARY:
   summary = {
     totalShifts: openShifts.count,
     filledCount: assignments.filter(a => a.employeeId != null).count,
     unfilledCount: assignments.filter(a => a.employeeId == null).count,
     totalLaborCost: sum of labor costs,
     overtimeHours: sum of OT hours,
     hoursByEmployee: grouped hours map
   }

7. PERSIST:
   suggestion = AiSuggestionRepository.save(
     weekStart, generatedBy, priorities, assignments, summary
   )

8. RETURN:
   SuggestionResult(suggestion, summary, runsRemaining)
```

---

## Deployment View

### Single Application Deployment

- **Environment**: Web server (Apache/Nginx + PHP-FPM)
- **No change to existing deployment** - Feature is additive
- **Configuration**: Add to `.env`:
  ```
  OPENAI_API_KEY=sk-...
  OPENAI_MODEL=gpt-4-turbo
  AI_SCHEDULE_MAX_RUNS_PER_WEEK=5
  ```

### Dependencies

- **OpenAI API**: Required for AI functionality - if unavailable, feature shows error (no fallback)
- **Redis**: Required for rate limiting - existing Redis infrastructure used
- **No new infrastructure required**

### Performance Considerations

- OpenAI API latency: 5-25 seconds typical
- UI shows loading state during generation
- Suggestions cached in Redis for 24 hours (avoid re-fetching)
- Rate limiting prevents runaway API costs

---

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: docs/patterns/psr4-autoloading.md
  relevance: HIGH
  why: "All new classes follow PSR-4 namespace structure"

- pattern: docs/patterns/namespace-structure.md
  relevance: HIGH
  why: "New classes in BuyerKiosk\Scheduling namespace"

# New patterns created
- pattern: docs/patterns/openai-integration.md (NEW)
  relevance: HIGH
  why: "Standardize OpenAI API interaction across features"
```

### System-Wide Patterns

- **Security**:
  - API key never exposed to client
  - All endpoints require session auth + permission check
  - CSRF protection on POST/PUT/DELETE
  - Rate limiting prevents abuse

- **Error Handling**:
  - Service layer throws typed exceptions
  - Controller catches and returns JSON error response
  - All errors logged with context

- **Performance**:
  - Redis caching for suggestions (24h TTL)
  - Redis for rate limit counters (pay-week TTL)
  - No synchronous blocking - user sees loading state

- **Logging**:
  - Full AI sessions logged (prompt, response, outcome)
  - Usage metrics tracked for reporting
  - Errors logged with stack traces

### Component Structure Pattern

```pseudocode
COMPONENT: AiConfigModal
  INITIALIZE:
    - Load current usage status
    - Load default optimization priorities
    - Fetch owner list for toggles

  HANDLE:
    - Loading: show spinner
    - Rate limited: disable generate button, show remaining runs
    - Ready: enable generate button

  RENDER:
    - Priority selector (drag-to-reorder)
    - Custom instructions textarea
    - Owner toggles with availability status
    - Generate button with usage counter

COMPONENT: AiPreviewPanel
  INITIALIZE:
    - Receive suggestion from generation
    - All assignments pre-checked

  HANDLE:
    - Each assignment: checkbox for accept/reject
    - Expand/collapse reasoning
    - Summary stats (cost, hours, unfilled)

  RENDER:
    - List of assignments with employee avatar, shift details
    - Expandable reasoning per assignment
    - Summary panel
    - Apply Selected button
```

---

## Architecture Decisions

- [x] **ADR-1: Asynchronous AI Processing via TaskEngine**
  - **Choice**: Async TaskEngine job (high priority queue) with dual completion notification
  - **Rationale**:
    - Frees user from waiting at screen during AI processing
    - TaskEngine provides job queuing, retry handling, and failure recovery
    - Dual notification: email for offline users, Ably for real-time UI updates
    - Better UX for complex schedules that may take longer
  - **Implementation**:
    - Job dispatched to `high` priority queue
    - User can wait at interface (Ably message updates UI when complete)
    - OR leave and receive email notification with link to view results
    - UI subscribes to Ably channel `ai-schedule-{typeNum}-{jobId}`
  - **Trade-offs**:
    - More complex implementation than sync
    - Need to handle job failure states
  - **User confirmed**: ✅ 2026-01-08

- [x] **ADR-2: OpenAI Structured Outputs with JSON Schema**
  - **Choice**: Use OpenAI Structured Outputs feature with `strict: true` JSON Schema
  - **Rationale**:
    - **Guaranteed schema adherence** - Model output ALWAYS matches defined schema
    - No need to validate or retry malformed responses
    - Type-safe integer IDs, nullable fields, bounded confidence scores
    - `additionalProperties: false` prevents hallucinated fields
  - **Implementation**:
    - Use `response_format: { type: "json_schema", json_schema: { name: "...", strict: true, schema: {...} } }`
    - Requires gpt-4o-mini or gpt-4o-2024-08-06 and later models
    - See "OpenAI Structured Output Schema" section for full schema
  - **Trade-offs**:
    - Limited to newer model versions
    - Schema must follow Structured Outputs constraints (no unsupported types)
  - **User confirmed**: ✅ 2026-01-08

- [x] **ADR-3: Configurable Rate Limiting**
  - **Choice**: Default 5 runs per pay week per store, configurable via `.env`
  - **Rationale**:
    - Pay week aligns with scheduling cycle
    - Per-store prevents one manager from consuming all quota
    - `.env` configuration allows easy adjustment per environment
  - **Configuration**:
    - `AI_SCHEDULE_MAX_RUNS_PER_WEEK=5` (default)
  - **Trade-offs**:
    - Requires deployment to change (not runtime admin setting)
  - **User confirmed**: ✅ 2026-01-08

- [x] **ADR-4: Suggestion Lifecycle Management**
  - **Choice**: Suggestions persist until explicitly accepted/declined OR schedule week passes
  - **Rationale**:
    - No arbitrary time-based expiration
    - User maintains full control over when to act on suggestions
    - Automatic cleanup when week ends (suggestions no longer relevant)
  - **Expiration Rules**:
    - Active until: user clicks "Apply" or "Dismiss"
    - Auto-expires: when `weekEnd` date passes
    - Can regenerate: replaces pending suggestion for same week
  - **Trade-offs**:
    - Shifts may change between generation and application (show warnings)
  - **User confirmed**: ✅ 2026-01-08

- [x] **ADR-5: Owner Scheduling with Preference Memory**
  - **Choice**: Owners excluded by default; remember last preference per store
  - **Rationale**:
    - Owners often have irregular schedules - safe default is exclusion
    - Stores where owners work regularly shouldn't re-toggle every time
    - Preference stored per-store for consistency across managers
  - **Implementation**:
    - Default: all owners excluded
    - Save toggle state to `kiosk_buykiosk.stores.aiScheduleOwnerPrefs` (JSON)
    - Pre-populate toggles from saved preference on next generation
  - **Trade-offs**:
    - Shared across managers (intentional for consistency)
  - **User confirmed**: ✅ 2026-01-08

---

## Quality Requirements

| Requirement | Metric | Target | Test Method |
|-------------|--------|--------|-------------|
| Response Time | AI generation time | < 30 seconds (95th percentile) | Load test with real OpenAI calls |
| Availability | Feature uptime | 99.5% (excluding OpenAI outages) | Monitor endpoint health |
| Acceptance Rate | AI suggestions accepted | > 85% | Track accept/reject in production |
| Accuracy | Valid assignments | 100% pass validation | Unit tests for all business rules |
| Cost Control | API spend | < $50/month per active store | Monitor token usage |
| Security | Auth bypass | 0 vulnerabilities | Penetration testing |
| Usability | Time to generate | < 2 minutes total flow | UX testing with managers |

---

## Risks and Technical Debt

### Known Technical Issues

- **OpenAI API latency variability**: Response time can range from 5-30 seconds
- **Token limits**: Large schedules with many employees may hit token limits

### Technical Debt

- **Survey analysis code** in `routes/api.php` should be extracted to service class (we'll follow better pattern)
- **No existing OpenAI client wrapper** - we'll create one for reuse

### Implementation Gotchas

- **Pay period calculation**: Must use store's configured pay period start day
- **Timezone handling**: All times stored in UTC, display in store timezone
- **Role levels**: LOWER roleId = HIGHER privilege. Mapping:
  - Role 0 = Employee (general)
  - Role 1 = Owner (highest privilege)
  - Role 2 = Manager
  - Role 3 = Shift Lead
  - Role 4 = Buyer
  - Role 5 = Cashier (lowest privilege)
  - Opening/closing: Role ≤3 required (Owner, Manager, or Shift Lead)
- **Role storage**: Per-store role in `kiosk_users.userStoreAssignments.role`, NOT on users table
- **Global users DB**: Employee data in `kiosk_users.users` table, not store's deprecated `employees` table
- **Pay rates**: Check `kiosk_users.userPayRates` for per-store overrides, fallback to `kiosk_users.users.hourlyRate`

---

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Successful AI Generation**
```gherkin
Given: Store has open shifts for next week
And: Employees have availability configured
And: Store has remaining AI runs
When: Manager clicks Generate with AI
Then: AI returns valid assignments
And: All assignments pass business rules
And: Usage counter is incremented
And: Suggestions are displayed for review
```

**Scenario 2: Rate Limit Enforcement**
```gherkin
Given: Store has used 5 AI runs this pay week
When: Manager attempts to generate
Then: System returns rate limit error
And: UI shows remaining runs = 0
And: Generate button is disabled
```

**Scenario 3: No Open Shifts**
```gherkin
Given: Store has no open shifts for target week
When: Manager attempts to generate
Then: System returns "no open shifts" error
And: No API call is made to OpenAI
And: Usage counter is NOT incremented
```

**Scenario 4: OpenAI Failure Recovery**
```gherkin
Given: OpenAI API returns 500 error
When: Generation is attempted
Then: User sees "AI service unavailable" message
And: Error is logged with context
And: Usage counter is NOT incremented
And: User can retry
```

**Scenario 5: Apply Partial Suggestions**
```gherkin
Given: AI generated 10 assignments
And: Manager unchecked 3 assignments
When: Manager clicks Apply Selected
Then: 7 shifts are assigned
And: 3 shifts remain open
And: Suggestion record shows 7 accepted, 3 rejected
```

**Scenario 6: Hours Override Per-Schedule**
```gherkin
Given: Employee has hoursMax=40 in kiosk_users
And: Manager sets hoursOverride of hoursMax=32 for this generation
When: AI generates schedule
Then: AI respects the override (32 hours, not 40)
And: Override is stored with the suggestion for audit
```

**Scenario 7: Re-optimization Warning**
```gherkin
Given: Week has 5 existing AI-assigned shifts
When: Manager clicks Generate with AI
Then: Warning modal shows "This week has 5 existing AI assignments"
And: User must confirm before proceeding
When: User confirms with forceRegenerate
Then: New job is dispatched
And: Existing AI assignments are treated as open shifts
```

**Scenario 8: Session Logging**
```gherkin
Given: AI generation completes successfully
When: Suggestion is applied (7 accepted, 3 rejected)
Then: Session log records full prompt sent
And: Session log records raw AI response
And: Session log records accepted/rejected shift IDs
When: Manager manually modifies a shift later
Then: Session log is updated with manual change
```

**Scenario 9: Model Fallback on Deprecation**
```gherkin
Given: Primary model (gpt-5-mini) returns 404 model_not_found
When: OpenAI client attempts generation
Then: Client automatically tries next model (gpt-5)
And: If successful, generation completes
And: Used model is recorded in suggestion record
```

**Scenario 10: Hourly Metrics Collection**
```gherkin
Given: HourlyMetricsCollectorJob runs at top of hour
When: Job executes for 10am hour slot
Then: Job queries timePunches for staff count
And: Job queries buyQueue for buy metrics
And: Job queries transactions for sales metrics
And: Metrics are saved to hourlyStaffingMetrics table
```

### Test Coverage Requirements

- **Business Logic**: All role qualification rules, overtime calculations, availability matching, hours preferences
- **Rate Limiting**: Counter increment, reset, boundary conditions
- **API Integration**: Success, timeout, error responses, model fallback (mocked)
- **Database**: Suggestion persistence, shift updates, transaction rollback, session logging
- **Security**: Permission checks, CSRF, input validation, session log access control
- **Hours Management**: hoursRequested/Min/Max validation, per-schedule overrides
- **Re-optimization**: Existing AI assignment detection, forceRegenerate flow
- **Historical Data**: Metrics collection job, backfill job, retention cleanup

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Open Shift | A shift without an assigned employee | The input to AI optimization |
| Pay Week | 7-day period for payroll calculation | Rate limiting resets each pay week |
| Optimization Priority | Factor to weight in scheduling | labor_cost, hours_fairness, seniority, position_coverage |
| Hours Requested | Employee's target weekly hours | AI tries to match this target |
| Hours Max | Employee's hard cap on weekly hours | AI never exceeds this |
| Hours Min | Employee's minimum weekly hours | AI tries to meet this when possible |
| Hours Override | Per-schedule hours adjustment | Temporarily overrides global settings |
| Locked Shift | Manager recurring shift exempt from AI | Visible but not modifiable by AI |
| Session Log | Complete record of AI interaction | Prompt, response, and outcomes for debugging |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| Suggestion | AI-generated employee-to-shift assignments | Stored for review before application |
| TypeNum | Store identifier (e.g., "ou00") | Used for multi-tenant data access |
| Role Level | Employee qualification tier (0-5, LOWER = higher privilege) | 1=Owner, 2=Manager, 3=Shift Lead, 4=Buyer, 5=Cashier |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| chat/completions | OpenAI endpoint for ChatGPT | Main AI integration point |
| response_format | OpenAI parameter for structured output | Forces JSON response |
