# Solution Design Document

**Spec ID:** 049
**Feature:** Configurable Break Policy System
**Companion PRD:** [product-requirements.md](./product-requirements.md)

## 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 (see Architecture Decisions section)
- [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 service classes live under `userfrosting/src/BuyerKiosk/`. PSR-4 autoloading. PHPStan must remain clean on every modified file.
- **CON-2:** All schema changes go through the conductor migration system at `userfrosting/conductor` with JSON files under `userfrosting/migrations/input/`. **No direct table edits ever.**
- **CON-3:** Multi-store data architecture is invariant:
  - Shared DB `kiosk_buykiosk` → preset library (BK-maintained, identical for all stores).
  - Per-store DB `kiosk_<typeNum>` → adopted policy + compliance log.
- **CON-4:** UI must use Bootstrap 5.3.3 with the `tokens.css` design system. Modals must follow the wrapper-relocation pattern (CLAUDE.md mandate — page templates render inside `#wrapper > #page-wrapper`, which creates a stacking context that traps Bootstrap 5 modal backdrops). Use `bootstrap.Modal.getOrCreateInstance(el)` not `new bootstrap.Modal(el)` to avoid duplicate-backdrop bugs.
- **CON-5:** User identity is canonical via `kiosk_users.users` + `kiosk_users.userStoreAssignments`. The legacy store-level `employees` table is deprecated for new code. For WIW-integrated stores, the legacy `employees` table still seeds employee identities — but those stores are out of v1 scope (see PRD).
- **CON-6:** Must not regress the May 2026 hot-fix that nets unpaid breaks out of `workedHours` in `TimesheetController::buildDayBreakdown`.
- **CON-7:** Feature flag must be plumbed end-to-end so an off state preserves all current behavior (no compliance writes, no engine consult, no UI changes for the employee).
- **CON-8:** Permission model uses existing `uri_schedule_manage` for manager-facing surfaces and `checkStoreGroup($typeNum)` for store scoping.

## Implementation Context

### Required Context Sources

- **ICO-1 General application context**
```yaml
- doc: CLAUDE.md
  relevance: CRITICAL
  why: "Project conventions: Bootstrap 5 modal wrapper-relocation, multi-store DB pattern, migration system invariants, users table canonical, WhenIWork legacy notes."

- doc: docs/specs/049-break-policy-system/product-requirements.md
  relevance: CRITICAL
  why: "Business requirements, in-scope/out-of-scope decisions, success metrics, and the four decisions locked in via AskUserQuestion."
```

- **ICO-2 Backend domain — Scheduling**
```yaml
- file: userfrosting/src/BuyerKiosk/Scheduling/Models/TimePunch.php
  relevance: HIGH
  why: "TimePunch model + BREAK_TYPE_PAID / BREAK_TYPE_UNPAID constants. Engine reads these as input."

- file: userfrosting/src/BuyerKiosk/Scheduling/Repositories/TimePunchRepository.php
  sections: [calculateWorkedHours (437-522)]
  relevance: CRITICAL
  why: "Hours calculation hub. Will be modified to consult BreakPolicyEvaluator when policy enabled."

- file: userfrosting/src/BuyerKiosk/Scheduling/Controllers/TimesheetController.php
  sections: [buildDayBreakdown (1188-1268)]
  relevance: CRITICAL
  why: "Day breakdown carries the May 2026 hot-fix. Must integrate evaluator output without regression."

- file: userfrosting/src/BuyerKiosk/Scheduling/Repositories/ShiftRepository.php
  relevance: HIGH
  why: "Shift lookups for engine input. Also where the recent onlyPublished flag work lives."
```

- **ICO-3 Mobile + Workspace clock surfaces**
```yaml
- file: userfrosting/src/BuyerKiosk/MobileScheduling/Services/MobileClockService.php
  sections: [line 569 — BREAK_TYPE_UNPAID fallback]
  relevance: HIGH
  why: "Mobile break-start endpoint. Must ignore incoming breakType field when policy enabled."

- file: public_html/js/workspace/modules/workbook/time-punch.js
  relevance: HIGH
  why: "Workspace clock currently hardcodes type: 2. Will be reduced to a typeless start-break signal."

- file: userfrosting/src/BuyerKiosk/Workbook/Controllers/TimePunchController.php
  sections: [1472-1573]
  relevance: HIGH
  why: "Workspace break-start backend handler. Mirror of mobile change."

- file: public_html/js/scheduling/TimesheetDetail.js
  sections: [207-210]
  relevance: MEDIUM
  why: "Manager Add-Punch modal Paid/Unpaid dropdown. RETAINED — manager override stays in v1."
```

- **ICO-4 Migration system + permissions**
```yaml
- file: userfrosting/conductor
  relevance: HIGH
  why: "Migration runner CLI. Used to run preset library + schema migrations."

- file: userfrosting/migrations/input/
  relevance: HIGH
  why: "Where break-policy migrations live (new subdir: break-policies/)."

- file: userfrosting/models/BaseModel.php
  relevance: HIGH
  why: "dbConnectByName + queue helpers. Engine + repos use these for store DB access."
```

### Implementation Boundaries

- **Must Preserve:**
  - May 2026 hot-fix in `TimesheetController::buildDayBreakdown` (`workedHours` net of unpaid breaks; `grossWorkedHours` and `unpaidBreakHours` exposed separately) — evaluator output must reconcile to identical totals for stores on `FLSA-default` with `defaultBreakType = unpaid`.
  - All current `TimePunch` columns and constants. Engine reads them; does not migrate them.
  - Existing permission gates (`uri_schedule_manage`).
  - Manager Add-Punch modal Paid/Unpaid dropdown (manager override is a v1 feature).
- **Can Modify:**
  - `TimePunchRepository::calculateWorkedHours` (add evaluator branch behind feature flag).
  - `TimesheetController::buildDayBreakdown` (add compliance metadata to response payload).
  - `MobileClockService::startBreak` (ignore breakType with warning log when policy enabled).
  - Workspace clock JS (stop sending `type: 2`).
  - Workspace `TimePunchController::startBreak` (accept type-less payload).
- **Must Not Touch:**
  - Pre-cutover punch records — no rewrite.
  - WIW import pipeline — out of v1 scope.
  - The `employees` legacy table — stays as-is for WIW stores.
  - `users` / `userStoreAssignments` schema.

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Manager[Store Manager]
    Employee[Hourly Employee]
    BKEng[BK Platform Engineer]

    Manager -->|adopt/edit/resolve| BreakPolicy[Break Policy System]
    Employee -->|start/end break| Clock[Mobile + Workspace Clock]
    Clock --> BreakPolicy
    BKEng -->|ship preset migration| SharedDB[(kiosk_buykiosk<br/>preset library)]

    BreakPolicy --> SharedDB
    BreakPolicy --> StoreDB[(kiosk_typeNum<br/>store policy +<br/>compliance log)]
    BreakPolicy --> Timesheet[Timesheet UI]
    BreakPolicy --> Dashboard[Compliance Dashboard UI]

    WIW[When I Work] -.->|out of v1 scope| Clock
```

#### Interface Specifications

```yaml
inbound:
  - name: "Manager Web (admin pages)"
    type: HTTP/HTTPS via Slim 2 routes
    format: HTML (Twig) + JSON for AJAX
    authentication: Session + uri_schedule_manage permission + checkStoreGroup(typeNum)
    data_flow: "Adopt preset, edit rule, change preset, resolve violation, export CSV."

  - name: "Workspace Clock (employee surface)"
    type: HTTPS
    format: JSON (existing /api routes)
    authentication: Session + checkStoreGroup
    data_flow: "Start break / end break (no type in payload after Phase 4)."

  - name: "Mobile App API"
    type: HTTPS
    format: REST JSON
    authentication: JWT (existing mobile token flow)
    data_flow: "Start break / end break. Accepts breakType for legacy compatibility but ignores it when feature flag on."

outbound:
  - name: "Compliance Log (write)"
    type: MySQL (per-store DB)
    connection: Existing PDO via dbConnectByName
    data_flow: "Append-only writes on timesheet save/recompute."
    criticality: HIGH

data:
  - name: "Shared DB (kiosk_buykiosk)"
    type: MySQL
    connection: PDO via BaseModel::dbConnect()
    data_flow: "Preset library (read-only at runtime; write only via migrations)."

  - name: "Store DB (kiosk_<typeNum>)"
    type: MySQL
    connection: PDO via dbConnectByName($store->getDbName())
    data_flow: "Adopted policy + compliance log (per store)."
```

### Cross-Component Boundaries

- **Public contracts:**
  - The `BreakPolicyEvaluator::evaluate()` public method signature. Once shipped, additive changes only.
  - Mobile API `POST /api/mobile/clock/break/start` — `breakType` field stays in the schema for legacy mobile clients but its meaning changes from "input" to "ignored with warning log."
- **Team ownership:** Scheduling / Timesheet team owns the entire module end-to-end (services, controllers, UI, migrations, presets). BK Platform Engineering owns preset content (state law research; yearly refresh migrations).
- **Shared resources:** The shared `kiosk_buykiosk.breakPolicyPreset` table is read by every store at the moment of preset adoption. Reads are infrequent and cacheable.
- **Breaking change policy:** Preset library changes are additive (new presets / new versions). The evaluator's input/output DTO shapes are versioned implicitly via the spec version. Schema migrations are forward-only.

### Project Commands

```bash
# Component: BuyerKiosk Web (the only component touched in v1)
Location: /Users/rvanvuren/Projects/buyerkiosk-web

## Environment Setup
Install Dependencies: cd userfrosting && composer install
Start Development: served via local Apache → ngrok → dev2.buyerkiosk.com

# Testing
Unit Tests: ./test.sh --testsuite unit
Integration Tests: ./test.sh --testsuite integration
All Tests: ./test.sh
Targeted: cd userfrosting && ./vendor/bin/phpunit --filter "BreakPolicy"
Coverage: ./test.sh --coverage
Tests + Static Analysis: ./test.sh --stan

# Code Quality
Static Analysis: cd userfrosting && ./vendor/bin/phpstan analyse --memory-limit=2G
Targeted Static Analysis: cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Scheduling/

# Build & CSS
CSS Build (dev): php userfrosting/conductor build-css
CSS Build (prod): php userfrosting/conductor build-css --minify
CSS Watch: php userfrosting/conductor build-css --watch

# Database Operations
Migrate: php userfrosting/conductor run
Migrate single store (targeted): see skill buyerkiosk-conductor-targeted-migration

# Docker (optional, mirrors prod env)
./docker-dev.sh up | shell | logs | migrate | test
```

## Solution Strategy

- **Architecture Pattern:** Layered, with a **pure-function rules engine** at the core. Persistence-free `BreakPolicyEvaluator` service holds all business logic. Repositories load policy + punches → evaluator returns DTOs → controllers write compliance log + render UI. This isolates the load-bearing logic for high-coverage unit tests.
- **Integration Approach:** Drop-in branch behind a per-store feature flag inside `TimePunchRepository::calculateWorkedHours` and `TimesheetController::buildDayBreakdown`. When the flag is off, current code path runs unchanged. When on, the evaluator runs and its output reshapes the hours numbers + writes the compliance log.
- **Justification:**
  - **Pure-function evaluator** because break-rule logic is gnarly, jurisdiction-specific, payroll-affecting, and inevitably wrong on the first cut. Test surface must be airtight. I/O-free design lets us drive thousands of fixture-based test cases cheaply.
  - **Cloned rules on adoption** (not live-bound to the preset) because we cannot retroactively change a store's behavior when BK ships a new preset version. Stores opt into updates explicitly.
  - **Per-store feature flag** lets us pilot Phase 3 with one CA store before broader rollout.
  - **Compliance log is append-only and idempotent**, written at timesheet save/recompute time rather than per-punch. This keeps the punch insert hot path simple and makes reprocessing safe.
- **Key Decisions** (confirmed with user via AskUserQuestion; see Architecture Decisions):
  - Premium pay: flag-only in v1 (no auto-add to payroll totals).
  - WIW-integrated stores: out of scope in v1.
  - City presets: out of scope in v1 (data model still supports `jurisdiction = 'city'` for v2).
  - Yearly preset refresh: explicit opt-in per store via banner + diff preview.

## Building Block View

### Components

```mermaid
graph LR
    subgraph "Manager UI"
        SettingsUI[Store Settings →<br/>Break Policy Page]
        TimesheetUI[Timesheet Detail<br/>+ Compliance Column]
        DashboardUI[Compliance<br/>Dashboard Page]
    end

    subgraph "Employee UI"
        MobileClockUI[Mobile<br/>Start/End Break]
        WorkspaceClockUI[Workspace<br/>Start/End Break]
    end

    subgraph "Controllers"
        PolicyController[BreakPolicyController]
        ComplianceController[ComplianceController]
        TimesheetCtrl[TimesheetController]
        ClockSvc[MobileClockService<br/>TimePunchController]
    end

    subgraph "Domain"
        Evaluator[BreakPolicyEvaluator<br/>pure-PHP service]
        PolicyRepo[BreakPolicyRepository]
        PresetRepo[BreakPolicyPresetRepository]
        ComplianceRepo[BreakComplianceRepository]
        PunchRepo[TimePunchRepository]
    end

    subgraph "Storage"
        SharedDB[(kiosk_buykiosk<br/>presets)]
        StoreDB[(kiosk_typeNum<br/>policy + log)]
    end

    SettingsUI --> PolicyController
    TimesheetUI --> TimesheetCtrl
    DashboardUI --> ComplianceController
    MobileClockUI --> ClockSvc
    WorkspaceClockUI --> ClockSvc

    PolicyController --> PolicyRepo
    PolicyController --> PresetRepo
    ComplianceController --> ComplianceRepo
    TimesheetCtrl --> PunchRepo
    TimesheetCtrl --> Evaluator
    TimesheetCtrl --> ComplianceRepo
    PunchRepo --> Evaluator
    Evaluator -.->|reads policy DTO| PolicyRepo

    PresetRepo --> SharedDB
    PolicyRepo --> StoreDB
    ComplianceRepo --> StoreDB
    PunchRepo --> StoreDB
```

### Directory Map

```
userfrosting/
├── migrations/input/break-policies/        # NEW: preset + schema migrations
│   ├── 049_001_shared_preset_library_schema.json   # NEW: shared DB tables
│   ├── 049_002_store_policy_schema.json            # NEW: per-store tables
│   ├── 049_010_preset_flsa_default.json            # NEW: FLSA default preset data
│   ├── 049_011_preset_ca_2025.json                 # NEW: CA-2025 preset data
│   ├── 049_012_preset_ny_2025.json                 # NEW: NY-2025 preset data
│   ├── 049_013_preset_or_2025.json                 # NEW: OR-2025 preset data
│   ├── 049_014_preset_wa_2025.json                 # NEW: WA-2025 preset data
│   ├── 049_015_preset_co_2025.json                 # NEW: CO-2025 preset data
│   ├── 049_016_preset_il_2025.json                 # NEW: IL-2025 preset data
│   └── 049_020_backfill_stores_to_flsa.json        # NEW: backfill existing stores
├── src/BuyerKiosk/Scheduling/
│   ├── Services/
│   │   └── BreakPolicy/                    # NEW namespace for the engine
│   │       ├── BreakPolicyEvaluator.php    # NEW: pure-PHP rules engine
│   │       ├── DTO/
│   │       │   ├── BreakClassification.php # NEW: per-break output DTO
│   │       │   ├── ShiftComplianceReport.php # NEW: per-shift output DTO
│   │       │   ├── EvaluatorInput.php      # NEW: input DTO (shift+punches+policy)
│   │       │   └── PolicyRuleDTO.php       # NEW: rule input DTO
│   │       └── BreakPolicyFeatureFlag.php  # NEW: per-store flag accessor
│   ├── Repositories/
│   │   ├── BreakPolicyRepository.php       # NEW: store policy + cloned rules
│   │   ├── BreakPolicyPresetRepository.php # NEW: shared preset reads
│   │   ├── BreakComplianceRepository.php   # NEW: compliance log writes/reads
│   │   ├── TimePunchRepository.php         # MODIFY: branch in calculateWorkedHours
│   │   └── ShiftRepository.php             # READ-ONLY (no change)
│   ├── Controllers/
│   │   ├── BreakPolicyController.php       # NEW: manager Store Settings actions
│   │   ├── ComplianceController.php        # NEW: dashboard + resolve + CSV export
│   │   └── TimesheetController.php         # MODIFY: surface compliance metadata
│   └── Models/
│       ├── BreakPolicyPreset.php           # NEW
│       ├── BreakPolicyPresetRule.php       # NEW
│       ├── StoreBreakPolicy.php            # NEW
│       ├── StoreBreakPolicyRule.php        # NEW
│       └── BreakComplianceLog.php          # NEW
├── src/BuyerKiosk/MobileScheduling/Services/
│   └── MobileClockService.php              # MODIFY: ignore breakType when flag on
├── src/BuyerKiosk/Workbook/Controllers/
│   └── TimePunchController.php             # MODIFY: accept type-less startBreak payload
└── routes/
    └── groups/
        └── break-policy.php                # NEW: admin + API routes for module

public_html/
├── js/scheduling/
│   ├── BreakPolicyPage.js                  # NEW: Store Settings page JS
│   ├── ComplianceDashboard.js              # NEW: dashboard JS
│   └── TimesheetDetail.js                  # MODIFY: render compliance column
├── js/workspace/modules/workbook/
│   └── time-punch.js                       # MODIFY: stop sending type: 2
└── css/admin/modules/
    └── break-policy.css                    # NEW: page styles

templates/themes/default/admin/scheduling/
├── break-policy.html                       # NEW: Store Settings → Break Policy page
└── compliance-dashboard.html               # NEW: Compliance Dashboard page

tests/Unit/Scheduling/Services/
└── BreakPolicy/
    ├── BreakPolicyEvaluatorTest.php        # NEW: ≥95% coverage of evaluator
    ├── PresetFlsaDefaultTest.php           # NEW: integration matrix per preset
    ├── PresetCa2025Test.php                # NEW
    ├── PresetNy2025Test.php                # NEW
    ├── PresetOr2025Test.php                # NEW
    ├── PresetWa2025Test.php                # NEW
    ├── PresetCo2025Test.php                # NEW
    └── PresetIl2025Test.php                # NEW
```

### Interface Specifications

#### Interface Documentation References

```yaml
interfaces:
  - name: "Conductor Migration Schema"
    doc: userfrosting/conductor source + existing migrations under userfrosting/migrations/input/
    relevance: CRITICAL
    sections: [migration_id format, check_query semantics, status='skipped' behavior]
    why: "Preset & schema migrations follow the existing JSON shape. See user memory: 'Migration System (CRITICAL)' for skip/idempotency rules."

  - name: "uri_schedule_manage permission"
    doc: existing UF permission hooks
    relevance: HIGH
    sections: [permission_hooks]
    why: "Gate for all manager-facing surfaces."
```

#### Data Storage Changes

**Shared DB (`kiosk_buykiosk`)** — new tables for the BK-maintained preset library:

```sql
-- Shared preset library (read-only at runtime; written only by migrations)

Table: breakPolicyPreset (NEW)
  id                        INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  presetKey                 VARCHAR(50)  NOT NULL UNIQUE   -- e.g. "CA-2025"
  name                      VARCHAR(100) NOT NULL
  description               TEXT
  jurisdiction              ENUM('federal','state','city') NOT NULL
  jurisdictionCode          VARCHAR(10)  NOT NULL          -- e.g. "CA", "NY", "US"
  effectiveDate             DATE         NOT NULL
  version                   INT UNSIGNED NOT NULL DEFAULT 1
  supersededByPresetKey     VARCHAR(50)  NULL
  createdAt                 TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
  updatedAt                 TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  INDEX idx_jurisdiction (jurisdiction, jurisdictionCode)

Table: breakPolicyPresetRule (NEW)
  id                                       INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  presetId                                 INT UNSIGNED NOT NULL
  priority                                 INT UNSIGNED NOT NULL
  ruleType                                 ENUM('rest','meal','secondMeal','flsaCutoff','autoDeduct') NOT NULL
  triggerShiftMinHours                     DECIMAL(4,2) NULL
  triggerShiftMaxHours                     DECIMAL(4,2) NULL
  triggerStartTimeWindow                   VARCHAR(20)  NULL    -- e.g. "11:00-14:00"
  durationMinutes                          INT UNSIGNED NOT NULL
  durationToleranceMinutes                 INT UNSIGNED NOT NULL DEFAULT 0
  isPaid                                   BOOLEAN      NOT NULL
  isRequired                               BOOLEAN      NOT NULL DEFAULT FALSE
  mustStartBeforeHourOfShift               DECIMAL(4,2) NULL
  classifyShortBreaksUnderMinutesAsPaid    INT UNSIGNED NULL    -- FLSA: 20
  premiumPayOnViolation                    BOOLEAN      NOT NULL DEFAULT FALSE
  premiumPayHours                          DECIMAL(4,2) NOT NULL DEFAULT 0.00
  notes                                    TEXT
  INDEX idx_preset_priority (presetId, priority)
  FOREIGN KEY (presetId) REFERENCES breakPolicyPreset(id) ON DELETE CASCADE
```

**Per-store DB (`kiosk_<typeNum>`)** — new tables for adopted policy + compliance:

```sql
Table: storeBreakPolicy (NEW)
  id                        INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  basedOnPresetKey          VARCHAR(50)  NULL    -- "CA-2025" or null if fully custom
  basedOnPresetVersion      INT UNSIGNED NULL
  isCustomized              BOOLEAN      NOT NULL DEFAULT FALSE  -- any rule overridden
  enabled                   BOOLEAN      NOT NULL DEFAULT TRUE   -- store-level kill switch
  defaultBreakType          ENUM('paid','unpaid') NOT NULL DEFAULT 'unpaid'
  createdAt                 TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
  updatedAt                 TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

Table: storeBreakPolicyRule (NEW)
  id                                       INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  policyId                                 INT UNSIGNED NOT NULL
  priority                                 INT UNSIGNED NOT NULL
  ruleType                                 ENUM('rest','meal','secondMeal','flsaCutoff','autoDeduct') NOT NULL
  triggerShiftMinHours                     DECIMAL(4,2) NULL
  triggerShiftMaxHours                     DECIMAL(4,2) NULL
  triggerStartTimeWindow                   VARCHAR(20)  NULL
  durationMinutes                          INT UNSIGNED NOT NULL
  durationToleranceMinutes                 INT UNSIGNED NOT NULL DEFAULT 0
  isPaid                                   BOOLEAN      NOT NULL
  isRequired                               BOOLEAN      NOT NULL DEFAULT FALSE
  mustStartBeforeHourOfShift               DECIMAL(4,2) NULL
  classifyShortBreaksUnderMinutesAsPaid    INT UNSIGNED NULL
  premiumPayOnViolation                    BOOLEAN      NOT NULL DEFAULT FALSE
  premiumPayHours                          DECIMAL(4,2) NOT NULL DEFAULT 0.00
  notes                                    TEXT
  isOverride                               BOOLEAN      NOT NULL DEFAULT FALSE
  sourcePresetRuleId                       INT UNSIGNED NULL   -- back-pointer for reset
  INDEX idx_policy_priority (policyId, priority)
  FOREIGN KEY (policyId) REFERENCES storeBreakPolicy(id) ON DELETE CASCADE

Table: breakComplianceLog (NEW)
  id                        INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  employeeId                INT UNSIGNED NOT NULL          -- references kiosk_users.users.id
  shiftId                   INT UNSIGNED NULL              -- references shifts table
  punchId                   INT UNSIGNED NULL              -- references timePunch
  violationType             ENUM('missingMeal','lateMeal','shortMeal','missingRest','autoDeduct') NOT NULL
  ruleId                    INT UNSIGNED NULL              -- storeBreakPolicyRule.id (may be null for autoDeduct synthesis)
  shiftDate                 DATE         NOT NULL
  details                   JSON         NULL
  premiumPayHours           DECIMAL(4,2) NOT NULL DEFAULT 0.00
  resolved                  BOOLEAN      NOT NULL DEFAULT FALSE
  resolvedByUserId          INT UNSIGNED NULL
  resolvedAt                TIMESTAMP    NULL
  createdAt                 TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
  INDEX idx_employee_date (employeeId, shiftDate)
  INDEX idx_unresolved (resolved, shiftDate)
  UNIQUE KEY uk_idempotent (employeeId, shiftDate, violationType, ruleId, punchId)
```

The `UNIQUE KEY uk_idempotent` makes timesheet recompute safe: re-running the evaluator on the same shift will `INSERT ... ON DUPLICATE KEY UPDATE` rather than duplicate rows.

**Feature flag storage:** Reuse existing per-store config mechanism (Store row in `kiosk_buykiosk.stores`). Add column `breakPolicyEnabled TINYINT(1) NOT NULL DEFAULT 0` via migration. No new infrastructure.

#### Internal API Changes

```yaml
# Manager — Store Settings → Break Policy
Endpoint: Get policy
  Method: GET
  Path: /admin/:typeNum/scheduling/break-policy/policy
  Permission: uri_schedule_manage + checkStoreGroup(typeNum)
  Response:
    success:
      policy: {id, basedOnPresetKey, basedOnPresetVersion, isCustomized, enabled, defaultBreakType}
      rules: [PolicyRuleDTO...]
      updateAvailable: bool   # true if preset has been superseded since adoption
      availablePresetVersion: int | null

Endpoint: Adopt or change preset
  Method: POST
  Path: /admin/:typeNum/scheduling/break-policy/preset
  Body: { presetKey: "CA-2025" }
  Response: { policy, rules, diff: [...] }   # idempotent on same key+version

Endpoint: Preview diff
  Method: POST
  Path: /admin/:typeNum/scheduling/break-policy/preset/preview
  Body: { presetKey }
  Response: { diff: [{ruleType, current, proposed, changeType: 'added'|'removed'|'modified'}] }

Endpoint: Update rule
  Method: PUT
  Path: /admin/:typeNum/scheduling/break-policy/rules/:ruleId
  Body: PolicyRuleDTO partial
  Response: { rule, policy: {isCustomized: true} }

Endpoint: Reset rule to preset
  Method: POST
  Path: /admin/:typeNum/scheduling/break-policy/rules/:ruleId/reset
  Response: { rule, policy }

Endpoint: Set defaultBreakType
  Method: PUT
  Path: /admin/:typeNum/scheduling/break-policy/policy
  Body: { defaultBreakType: 'paid' | 'unpaid' }
  Response: { policy }

# Manager — Compliance Dashboard
Endpoint: List violations
  Method: GET
  Path: /admin/:typeNum/scheduling/compliance/violations
  Query: { fromDate, toDate, employeeId?, violationType?, resolved? }
  Response: { violations: [BreakComplianceLog...], totals: {countByType, totalPremiumPayHours} }

Endpoint: Resolve violation
  Method: POST
  Path: /admin/:typeNum/scheduling/compliance/violations/:id/resolve
  Body: { note? }
  Response: { violation }

Endpoint: Export CSV
  Method: GET
  Path: /admin/:typeNum/scheduling/compliance/violations/export.csv
  Query: same as list
  Response: text/csv

# Timesheet integration (existing endpoint — adds metadata to response)
Endpoint: Get timesheet detail
  Method: GET (existing)
  Path: /admin/:typeNum/scheduling/timesheet/:employeeId
  Response: adds:
    days[].compliance:
      hasViolations: bool
      violations: [{type, ruleId, premiumPayHours, ...}]
    weekly.premiumPayHours: decimal

# Employee — Mobile
Endpoint: Start break (existing — behavior changed)
  Method: POST (existing /api/mobile/clock/break/start)
  Body: { ... } -- breakType field is IGNORED when store has breakPolicyEnabled = 1
  Behavior: log warning if breakType is supplied while flag is on
  Response: unchanged

# Employee — Workspace clock (existing — behavior changed)
  /api workspace startBreak endpoint stops sending type: 2 from JS.
  Backend accepts type-less payload and persists current TimePunch row.
```

#### Application Data Models

```pseudocode
ENTITY: BreakPolicyPreset (NEW, shared DB)
  FIELDS: id, presetKey, name, description, jurisdiction, jurisdictionCode,
          effectiveDate, version, supersededByPresetKey, createdAt, updatedAt
  BEHAVIORS:
    isSuperseded(): bool
    isCurrentVersion(): bool

ENTITY: BreakPolicyPresetRule (NEW, shared DB)
  FIELDS: id, presetId, priority, ruleType, triggerShiftMinHours,
          triggerShiftMaxHours, triggerStartTimeWindow,
          durationMinutes, durationToleranceMinutes,
          isPaid, isRequired, mustStartBeforeHourOfShift,
          classifyShortBreaksUnderMinutesAsPaid,
          premiumPayOnViolation, premiumPayHours, notes
  BEHAVIORS:
    matchesShift(shiftStart, shiftEnd, durationHours): bool
    toDTO(): PolicyRuleDTO

ENTITY: StoreBreakPolicy (NEW, per-store DB)
  FIELDS: id, basedOnPresetKey, basedOnPresetVersion, isCustomized,
          enabled, defaultBreakType, createdAt, updatedAt
  BEHAVIORS:
    isUpdateAvailable(currentPreset: BreakPolicyPreset): bool
    adoptPreset(preset: BreakPolicyPreset): void   -- clones rules

ENTITY: StoreBreakPolicyRule (NEW, per-store DB)
  FIELDS: id, policyId, priority, ruleType, triggerShiftMinHours,
          triggerShiftMaxHours, triggerStartTimeWindow,
          durationMinutes, durationToleranceMinutes,
          isPaid, isRequired, mustStartBeforeHourOfShift,
          classifyShortBreaksUnderMinutesAsPaid,
          premiumPayOnViolation, premiumPayHours, notes,
          isOverride, sourcePresetRuleId
  BEHAVIORS:
    matchesShift(shiftStart, shiftEnd, durationHours): bool
    matchesBreak(breakStart, durationMinutes, hourOfShiftAtStart): bool
    toDTO(): PolicyRuleDTO
    resetToPreset(): void

ENTITY: BreakComplianceLog (NEW, per-store DB; APPEND-ONLY at app layer)
  FIELDS: id, employeeId, shiftId, punchId, violationType, ruleId,
          shiftDate, details (JSON), premiumPayHours,
          resolved, resolvedByUserId, resolvedAt, createdAt
  BEHAVIORS:
    resolve(userId, note): void

DTO: PolicyRuleDTO (engine input — value object, immutable)
  FIELDS: ruleId, priority, ruleType, triggerShiftMinHours,
          triggerShiftMaxHours, triggerStartTimeWindow,
          durationMinutes, durationToleranceMinutes,
          isPaid, isRequired, mustStartBeforeHourOfShift,
          classifyShortBreaksUnderMinutesAsPaid,
          premiumPayOnViolation, premiumPayHours

DTO: EvaluatorInput
  FIELDS:
    shift: { start: DateTime, end: DateTime, durationHours: float }
    punches: [{ type: 'clockIn'|'clockOut'|'breakStart'|'breakEnd',
                timestamp: DateTime, punchId: int }]  -- ordered by timestamp
    rules: [PolicyRuleDTO]   -- pre-sorted by priority asc
    defaultBreakType: 'paid'|'unpaid'

DTO: BreakClassification (per break punch pair)
  FIELDS:
    breakStartPunchId: int, breakEndPunchId: int
    durationMinutes: int
    isPaid: bool
    ruleApplied: int|null      -- ruleId of matched rule, null if defaultBreakType
    isCompliant: bool
    violations: [Violation]    -- subset of: lateMeal, shortMeal

DTO: Violation
  FIELDS: type, ruleId, premiumPayHours, details (assoc array)

DTO: ShiftComplianceReport (per shift)
  FIELDS:
    classifications: [BreakClassification]
    missingBreaks: [Violation]      -- missingMeal, missingRest, autoDeduct
    lateBreaks: [Violation]
    premiumPayHours: float
    totalUnpaidBreakMinutes: int
    totalPaidBreakMinutes: int
```

#### Integration Points

```yaml
# Inter-component integration (all inside BuyerKiosk Web)
- from: TimePunchRepository::calculateWorkedHours
  to: BreakPolicyEvaluator::evaluate
  protocol: in-process PHP call (no I/O)
  data_flow: "When store.breakPolicyEnabled = 1, evaluator output drives unpaidBreakHours / grossWorkedHours / workedHours."

- from: TimesheetController::buildDayBreakdown
  to: BreakPolicyEvaluator + BreakComplianceRepository
  protocol: in-process PHP call
  data_flow: "Evaluator returns ShiftComplianceReport; controller writes log (idempotent UPSERT) and surfaces compliance metadata in JSON response."

- from: BreakPolicyController::adoptPreset
  to: BreakPolicyPresetRepository + BreakPolicyRepository
  protocol: in-process
  data_flow: "Reads preset + rules from kiosk_buykiosk; writes storeBreakPolicy + storeBreakPolicyRule rows to per-store DB (clone)."

# Out-of-scope integrations (documented for completeness)
- WIW (When I Work) punch import:
    relevance: HIGH for v2; OUT OF SCOPE for v1
    note: "WIW-integrated stores keep current classification behavior. Detect via existing store integration flag; skip evaluator branch when source = WIW."
```

### Implementation Examples

#### Example: BreakPolicyEvaluator — top-level algorithm

**Why this example:** This is the load-bearing piece of the entire system. Clarifying the algorithm here prevents subtle off-by-one mistakes (e.g., "started after hour 5" vs "ended after hour 5").

```php
// Pseudocode. Real impl uses typed DTOs + DateTimeImmutable.
function evaluate(EvaluatorInput $input): ShiftComplianceReport {
    $rules = $input->rules; // already sorted by priority asc
    $flsaCutoffRule = findRule($rules, ruleType: 'flsaCutoff');
    $breakPairs = pairBreakStartsAndEnds($input->punches);
    $classifications = [];

    foreach ($breakPairs as $pair) {
        $durationMinutes = $pair->end->diff($pair->start)->totalMinutes();
        $hourOfShiftAtStart = ($pair->start->ts - $input->shift->start->ts) / 3600;

        // Step 2–3: find first matching rule (excluding flsaCutoff which is final classifier)
        $matched = null;
        foreach ($rules as $rule) {
            if ($rule->ruleType === 'flsaCutoff' || $rule->ruleType === 'autoDeduct') continue;
            if (!$rule->matchesShift($input->shift)) continue;
            if (!$rule->matchesBreak($pair, $hourOfShiftAtStart)) continue;
            $matched = $rule;
            break;
        }

        $isPaid = $matched?->isPaid ?? $input->defaultBreakType === 'paid';
        $violations = [];

        // Step 4: lateMeal
        if ($matched && $matched->mustStartBeforeHourOfShift !== null
            && $hourOfShiftAtStart > $matched->mustStartBeforeHourOfShift) {
            $violations[] = new Violation(
                type: 'lateMeal',
                ruleId: $matched->ruleId,
                premiumPayHours: $matched->premiumPayOnViolation ? $matched->premiumPayHours : 0,
            );
        }

        // Step 5: shortMeal
        if ($matched && $durationMinutes < $matched->durationMinutes - $matched->durationToleranceMinutes) {
            $violations[] = new Violation(
                type: 'shortMeal',
                ruleId: $matched->ruleId,
                premiumPayHours: $matched->premiumPayOnViolation ? $matched->premiumPayHours : 0,
            );
        }

        // Step 6: FLSA cutoff — always final
        if ($flsaCutoffRule && $durationMinutes < $flsaCutoffRule->classifyShortBreaksUnderMinutesAsPaid) {
            $isPaid = true;
        }

        $classifications[] = new BreakClassification(
            breakStartPunchId: $pair->startPunchId,
            breakEndPunchId: $pair->endPunchId,
            durationMinutes: $durationMinutes,
            isPaid: $isPaid,
            ruleApplied: $matched?->ruleId,
            isCompliant: count($violations) === 0,
            violations: $violations,
        );
    }

    // Step 8: required-rule walk
    $missing = [];
    foreach ($rules as $rule) {
        if (!$rule->isRequired) continue;
        if (!$rule->matchesShift($input->shift)) continue;
        $satisfied = anyClassificationMatchesRule($classifications, $rule);
        if (!$satisfied) {
            $missing[] = new Violation(
                type: $rule->ruleType === 'rest' ? 'missingRest' : 'missingMeal',
                ruleId: $rule->ruleId,
                premiumPayHours: $rule->premiumPayOnViolation ? $rule->premiumPayHours : 0,
            );
        }
    }

    // Step 9: autoDeduct synthesis (does NOT create a TimePunch row)
    $autoDeductRule = findRule($rules, ruleType: 'autoDeduct');
    if ($autoDeductRule && !anyMealBreakTaken($classifications)
        && $input->shift->durationHours > $autoDeductRule->triggerShiftMinHours) {
        $missing[] = new Violation(type: 'autoDeduct', ruleId: $autoDeductRule->ruleId, premiumPayHours: 0);
    }

    // Step 10: aggregates
    return new ShiftComplianceReport(
        classifications: $classifications,
        missingBreaks: $missing,
        lateBreaks: filterByType($classifications, 'lateMeal'),
        premiumPayHours: sumPremiumPay($classifications, $missing),
        totalUnpaidBreakMinutes: sumWhere($classifications, fn($c) => !$c->isPaid, fn($c) => $c->durationMinutes),
        totalPaidBreakMinutes:   sumWhere($classifications, fn($c) => $c->isPaid,  fn($c) => $c->durationMinutes),
    );
}
```

#### Example: Preset clone on adoption (idempotent)

**Why this example:** Adoption must clone preset rules into the store DB **and** set back-pointers (`sourcePresetRuleId`) for the Reset action. Idempotency matters because the same preset version can be re-adopted (e.g., to wipe overrides).

```php
function adoptPreset(string $typeNum, string $presetKey): StoreBreakPolicy {
    $preset = $presetRepo->findCurrentByKey($presetKey);
    if (!$preset) throw new PresetNotFoundException($presetKey);

    $storeDb = dbConnectByName(storeController($typeNum)->getStore()->getDbName());
    $storeDb->beginTransaction();
    try {
        $policy = $policyRepo->getOrCreateForStore($storeDb);
        $policyRepo->wipeRules($storeDb, $policy->id);
        foreach ($presetRepo->rulesForPreset($preset->id) as $presetRule) {
            $policyRepo->insertRule($storeDb, $policy->id, $presetRule, isOverride: false,
                                    sourcePresetRuleId: $presetRule->id);
        }
        $policyRepo->update($storeDb, $policy->id, [
            'basedOnPresetKey' => $preset->presetKey,
            'basedOnPresetVersion' => $preset->version,
            'isCustomized' => false,
        ]);
        $storeDb->commit();
    } catch (\Throwable $e) {
        $storeDb->rollBack();
        throw $e;
    }

    return $policyRepo->find($policy->id);
}
```

#### Test Examples as Interface Documentation

```php
// Test as contract for FLSA-default behavior on a TX store (no required rules)
public function testFlsaDefaultClassifies18MinBreakAsPaidAndNoMissingMealViolation(): void {
    $input = EvaluatorInputFactory::shiftWithSingleBreak(
        shiftHours: 9.0,
        breakStartHourOfShift: 4.0,
        breakDurationMinutes: 18,
        rules: PresetFixtures::flsaDefault()->rules,
        defaultBreakType: 'unpaid',
    );

    $report = (new BreakPolicyEvaluator())->evaluate($input);

    $this->assertCount(1, $report->classifications);
    $this->assertTrue($report->classifications[0]->isPaid, 'FLSA <20 min must be paid');
    $this->assertCount(0, $report->missingBreaks, 'FLSA-default has no required rules');
}

// Test as contract for CA late-meal premium
public function testCa2025MissedMealAtHour5_15TriggersLateMealAndOneHourPremium(): void {
    $input = EvaluatorInputFactory::shiftWithSingleBreak(
        shiftHours: 8.0,
        breakStartHourOfShift: 5.25,  // 5h 15m
        breakDurationMinutes: 30,
        rules: PresetFixtures::ca2025()->rules,
        defaultBreakType: 'unpaid',
    );

    $report = (new BreakPolicyEvaluator())->evaluate($input);

    $lateMeal = collect($report->classifications[0]->violations)
        ->firstWhere('type', 'lateMeal');
    $this->assertNotNull($lateMeal);
    $this->assertEqualsWithDelta(1.0, $report->premiumPayHours, 0.001);
}
```

## Runtime View

### Primary Flow: Manager adopts CA-2025 for a CA store

```mermaid
sequenceDiagram
    actor Manager
    participant UI as Break Policy Page
    participant Ctrl as BreakPolicyController
    participant PresetRepo as BreakPolicyPresetRepository
    participant PolicyRepo as BreakPolicyRepository
    participant StoreDB as kiosk_pc00

    Manager->>UI: Click "Change preset" → CA-2025
    UI->>Ctrl: POST /preset/preview {presetKey: "CA-2025"}
    Ctrl->>PresetRepo: findCurrentByKey("CA-2025") + rulesForPreset
    Ctrl-->>UI: diff[]
    Manager->>UI: Confirm
    UI->>Ctrl: POST /preset {presetKey: "CA-2025"}
    Ctrl->>StoreDB: BEGIN
    Ctrl->>PolicyRepo: getOrCreateForStore
    Ctrl->>PolicyRepo: wipeRules + insertRule × N (with sourcePresetRuleId)
    Ctrl->>PolicyRepo: update {basedOnPresetKey, basedOnPresetVersion, isCustomized=false}
    Ctrl->>StoreDB: COMMIT
    Ctrl-->>UI: {policy, rules, diff}
    UI-->>Manager: Render new rule list
```

### Primary Flow: Engine classifies a clocked-out shift

```mermaid
sequenceDiagram
    actor Employee
    participant Clock as Mobile/Workspace Clock
    participant ClockSvc as MobileClockService / TimePunchController
    participant PunchRepo as TimePunchRepository
    participant Evaluator as BreakPolicyEvaluator
    participant ComplianceRepo as BreakComplianceRepository
    participant TimesheetCtrl as TimesheetController

    Employee->>Clock: End shift (clockOut)
    Clock->>ClockSvc: POST .../clock/end
    ClockSvc->>PunchRepo: persist clockOut punch
    Note over PunchRepo: punch insert hot path stays simple — no evaluator here

    Manager->>TimesheetCtrl: GET timesheet detail
    TimesheetCtrl->>PunchRepo: loadShiftPunches
    TimesheetCtrl->>PolicyRepo: loadPolicyWithRules (cached)
    alt store.breakPolicyEnabled = 1
        TimesheetCtrl->>Evaluator: evaluate(input)
        Evaluator-->>TimesheetCtrl: ShiftComplianceReport
        TimesheetCtrl->>ComplianceRepo: upsertViolations (idempotent via unique key)
        TimesheetCtrl-->>Manager: day rows + compliance metadata
    else flag off
        TimesheetCtrl-->>Manager: current behavior (unchanged)
    end
```

### Error Handling

| Error class | Where | Handling |
|---|---|---|
| Invalid preset key on adopt | `BreakPolicyController::adoptPreset` | 404 with JSON error; UI surfaces "Preset not found" toast. No DB writes. |
| Concurrent adopt from two tabs | `BreakPolicyController` | Transaction-wrapped clone; second writer wins. UI refreshes on next load. |
| Engine receives malformed punch list (e.g., breakStart with no breakEnd) | `BreakPolicyEvaluator` | Returns partial classification with a `details.warning` note; never throws. Caller logs the anomaly. |
| Engine receives a shift with no rules at all | `BreakPolicyEvaluator` | Every break falls back to `defaultBreakType`; no missing-rule violations. |
| Compliance write conflict (unique key) | `BreakComplianceRepository::upsertViolation` | UPSERT pattern; updates `details` if changed, never duplicates. |
| Feature flag off but caller tries to use evaluator | `TimePunchRepository::calculateWorkedHours` | The flag check is the *first* line; flag-off path never calls the evaluator. |
| TX store on FLSA-default with 12h shift no break | Evaluator | No violations logged (FLSA has no `isRequired` rules; PRD decision Q2). |
| Database connection failure | All repos | Bubble up via existing UF error handler. Phase 2 read-path preview falls back to current classification on error. |
| PHP `TypeError` from typed signatures | Controllers | Use `catch (\Throwable $e)` per user-memory note about PHP 8.5 strict-type behavior in Slim 2. |

### Complex Logic

The evaluator's 10-step algorithm is documented above under "Implementation Examples". The single subtlety worth restating: **`flsaCutoff` is a final classifier**, applied *after* a matched rule sets `isPaid`. This is why CA-2025 ships both a meal rule (30 min unpaid) *and* a separate `flsaCutoff` rule (anything <20 min → paid) — the FLSA carve-out beats the meal rule for very-short attempted-meals.

## Deployment View

### Single Application Deployment

- **Environment:** Same as the rest of BuyerKiosk Web — local dev served via Apache + ngrok → `dev2.buyerkiosk.com`; production via the existing deploy pipeline (per user memory: do not invoke deploy commands without explicit permission).
- **Configuration:** New `stores.breakPolicyEnabled` column (default 0). No new env vars.
- **Dependencies:** No new third-party libraries. Composer manifest unchanged.
- **Performance:** Evaluator is in-process pure PHP — sub-millisecond per shift on representative inputs. Timesheet view already loads N shifts × M punches; adding O(R) rule scan per break is negligible (R ≤ 5 per preset).

### Multi-Component Coordination

- **Deployment Order (within BuyerKiosk Web):**
  1. Schema migrations (shared + per-store) via `php userfrosting/conductor run`.
  2. Preset library data migrations (seven presets).
  3. Backfill migration (sets every store's policy to FLSA-default, `enabled = false` initially).
  4. Application code deploy (controllers, services, JS, CSS).
  5. Feature flag flip per pilot store via direct UPDATE or admin tool.
- **Version Dependencies:** Code expects the schema. Migrations must run before code path that reads new tables — use the existing migration_log gating.
- **Feature Flags:** `stores.breakPolicyEnabled` is the master switch per store. Off by default at Phase 3 cutover; flipped to on per-pilot-store, then progressively across the fleet.
- **Rollback Strategy:** Flip `breakPolicyEnabled = 0` for an affected store. Code path reverts to current behavior immediately. Compliance log rows are preserved (append-only); they just stop being written. Schema migrations are not rolled back (they are additive and harmless when unused).
- **Data Migration Sequencing:**
  - Shared DB first (preset library tables + rows).
  - Per-store DB next (policy + rules + compliance log tables).
  - Backfill last (creates `storeBreakPolicy` row for each store with `basedOnPresetKey = 'FLSA-default'`, `enabled = true`, `defaultBreakType = 'unpaid'`).

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used in this feature
- pattern: "Multi-store DB access via dbConnectByName($store->getDbName())"
  source: userfrosting/models/BaseModel.php + CLAUDE.md
  relevance: CRITICAL
  why: "Every per-store table access goes through this pattern."

- pattern: "Bootstrap 5 modal wrapper-relocation"
  source: CLAUDE.md (MANDATORY)
  relevance: CRITICAL
  why: "Every new modal (Change preset, Resolve violation, Edit rule) must follow."

- pattern: "Conductor migration system (JSON files + check_query semantics)"
  source: userfrosting/conductor + user memory "Migration System (CRITICAL)"
  relevance: CRITICAL
  why: "Preset and schema migrations follow the standard pattern (with idempotent check_query for skips and proper migration_id formatting)."

- pattern: "PdoMockBuilder for repository unit tests"
  source: tests/Mocks/PdoMockBuilder
  relevance: HIGH
  why: "Repositories will be tested via fluent PDO mocks."

# New patterns created for this feature
- pattern: "Pure-function rules engine with DTOs"
  status: NEW
  relevance: HIGH
  why: "First time we encapsulate payroll-affecting logic as a no-I/O service. Worth documenting as a reusable pattern (e.g., docs/patterns/pure-function-rules-engine.md)."
```

### Interface Specifications

```yaml
- interface: "Existing Slim 2 admin routes under /admin/:typeNum/scheduling/"
  relevance: HIGH
  why: "New routes piggyback on this prefix and existing permission middleware."

- interface: "Existing mobile API /api/mobile/clock/break/*"
  relevance: HIGH
  why: "Behavior change only (ignore breakType field); no schema change."

# NEW interfaces
- interface: "BreakPolicyEvaluator::evaluate(EvaluatorInput): ShiftComplianceReport"
  status: NEW
  relevance: CRITICAL
  why: "Public service contract. Once shipped, additive changes only."
```

### System-Wide Patterns

- **Security:** Permission gate `uri_schedule_manage` + `checkStoreGroup($typeNum)` on every admin route. Mobile API uses existing JWT pattern. Compliance log access scoped to store DB → already store-isolated.
- **Error Handling:** Use `catch (\Throwable $e)` not `catch (\Exception $e)` per user-memory PHP 8.5 gotcha. All controller paths log via the existing UF logger to `logs/buyerkiosk_com.php.error.log`.
- **Performance:** Policy reads cached per request (memoized on the controller). Evaluator is sub-ms. Compliance log writes batched per timesheet save. Manager dashboard query uses `idx_unresolved` for fast filter-by-status.
- **i18n/L10n:** Out of scope for v1 — UI strings are English-only. Premium-pay symbols left untranslated.
- **Logging/Auditing:**
  - `break_policy.*` event names per PRD (six events total).
  - Override and reset actions log `userId` for audit trail.
  - Compliance log itself is append-only; `resolved` flag is the only mutation.
  - Mobile API "breakType ignored" path emits a `warning` log with `userId`, `typeNum`, `breakType` for tracking until clients are upgraded.

### Multi-Component Patterns

Not applicable — single component (BuyerKiosk Web) in v1.

### Implementation Patterns

#### Code Patterns and Conventions
- PSR-4 autoloading under `BuyerKiosk\Scheduling\Services\BreakPolicy\…`.
- Typed properties (`readonly` where practical for DTOs).
- camelCase for table/column names per CLAUDE.md.
- `DateTimeImmutable` for all timestamps in DTOs; no mutable DateTime.
- Methods returning DTOs, not arrays, wherever the engine and controllers exchange data.

#### State Management Patterns
- Stateless services. The evaluator holds no instance state. Repositories receive a `\PDO` connection per call (no service-locator state). Caching of the resolved policy is per-request only.

#### Performance Characteristics
- Engine target: ≤ 1 ms per shift on representative inputs (≤ 10 punches, ≤ 5 rules).
- Timesheet endpoint target: existing latency budget + ≤ 5 % added overhead end-to-end with policy on.
- Compliance dashboard target: 7-day window for a 50-employee store < 500 ms server time.

#### Integration Patterns
- All external integration is in-process. No HTTP, no queue. Mobile API change is a behavior-only patch on an existing endpoint.

#### Component Structure Pattern

```pseudocode
COMPONENT: BreakPolicyController(typeNum)
  REQUIRE: uri_schedule_manage, checkStoreGroup(typeNum)
  STATE: storeController, policyRepo, presetRepo

  ACTION: getPolicy() -> {policy, rules, updateAvailable}
    storeController.getStore() then policyRepo.loadWithRules() then presetRepo.isPresetSuperseded()
  ACTION: adoptPreset(presetKey) -> {policy, rules, diff}
    transactional clone (see Example above)
  ACTION: updateRule(ruleId, partial) -> {rule, policy}
    validate -> apply -> set isOverride=true if differs from sourcePresetRule
  ACTION: resetRule(ruleId) -> {rule, policy}
    look up sourcePresetRuleId -> reload from preset -> clear isOverride
```

#### Data Processing Pattern

```pseudocode
FUNCTION: classifyShiftForTimesheet(shift, employee, typeNum)
  VALIDATE: shift exists, employee assigned to store
  AUTHORIZE: caller has uri_schedule_manage + checkStoreGroup(typeNum)
  IF store.breakPolicyEnabled = 0:
    RETURN currentClassificationPath(shift)  // unchanged
  TRANSFORM: load policy + punches -> EvaluatorInput
  EXECUTE: report = evaluator.evaluate(input)
  PERSIST: complianceRepo.upsertViolations(report, shift, employee)
  RESPOND: {workedHours = grossHours - report.totalUnpaidBreakMinutes/60,
            grossWorkedHours, unpaidBreakHours, paidBreakHours,
            compliance: {hasViolations, violations, premiumPayHours}}
```

#### Error Handling Pattern

```pseudocode
FUNCTION: handlePolicyOperation(op)
  TRY:
    op()
  CATCH PresetNotFound -> 404 JSON
  CATCH StorePolicyMissing -> 404 JSON (should not happen post-backfill; log warning)
  CATCH PDOException -> log + 500 (UF default handler)
  CATCH \Throwable -> log + 500 (covers PHP 8.5 TypeError)
```

#### Test Pattern

```pseudocode
TEST_SCENARIO: "Evaluator classifies CA shift with on-time 30-min meal break as compliant"
  SETUP: ca2025 fixtures, shift 8h starting 09:00, break 11:30-12:00
  EXECUTE: evaluator.evaluate(input)
  VERIFY:
    classifications[0].isPaid == false
    classifications[0].isCompliant == true
    missingBreaks empty
    premiumPayHours == 0.0

TEST_SCENARIO: "Compliance log upsert is idempotent across timesheet recomputes"
  SETUP: shift with one violation, run evaluate twice
  EXECUTE: two upsert calls
  VERIFY:
    breakComplianceLog count for (employee, shiftDate, violationType, ruleId, punchId) == 1
```

### Integration Points

- **Connection points:**
  - `TimePunchRepository::calculateWorkedHours` — evaluator branch behind flag.
  - `TimesheetController::buildDayBreakdown` — surfaces compliance metadata + writes compliance log.
  - `MobileClockService::startBreak` / `TimePunchController::startBreak` — ignore breakType field.
  - `Store Settings` admin page navigation — new Break Policy entry.
  - `Timesheet Detail` page — new Compliance column.
  - Admin sidebar — new Compliance link.
- **Data flow:** Store policy loaded once per request → engine consults rules → compliance log written on save → UI reads from controller payload.
- **Events:** Six analytics events per PRD (`break_policy.*`). Emit via existing event-tracking sink.

## Architecture Decisions

- [x] **ADR-1 — Pure-function rules engine:** The evaluator has zero I/O; all inputs are DTOs.
  - Rationale: Maximizes unit-test coverage and determinism for payroll-affecting logic. A stateless function with structured inputs is the cheapest possible thing to drive thousands of test cases against.
  - Trade-offs: Caller (`TimesheetController`) carries the responsibility for loading the policy + punches and persisting the compliance log. Acceptable because those concerns live naturally in the controller.
  - User confirmed: ✅ Implicit via brief design ("Pure-PHP service. No I/O — accepts arrays/DTOs.").

- [x] **ADR-2 — Clone preset rules on adoption (no live binding):** When a store adopts a preset, every rule is copied into `storeBreakPolicyRule`. Preset changes do not retroactively modify adopted policies.
  - Rationale: A yearly CA-2026 preset shipment cannot silently change a store's payroll behavior. Operators must explicitly opt in via the "Update available" banner.
  - Trade-offs: Stores miss preset improvements until they opt in. Storage cost is trivial (~5 rules × N stores).
  - User confirmed: ✅ Explicit in brief.

- [x] **ADR-3 — Per-store feature flag, off by default:** `stores.breakPolicyEnabled` gates the entire code path.
  - Rationale: Phased rollout safety. We can ship code in master long before any store sees behavior changes.
  - Trade-offs: One extra column read on every timesheet view. Negligible.
  - User confirmed: ✅ Explicit in brief.

- [x] **ADR-4 — Premium pay handling is flag-only in v1:** Engine logs `premiumPayHours` on violations and surfaces them on the timesheet, but does NOT auto-add them to `regularHours`.
  - Rationale: Auto-adding is legally fraught if the calc is wrong. Manager adds via the existing timesheet edit flow with audit trail. v2 can add a per-store opt-in.
  - Trade-offs: Manager friction; risk of non-compliance if managers ignore the flag. Mitigated by Compliance Dashboard.
  - User confirmed: ✅ via AskUserQuestion.

- [x] **ADR-5 — WIW-integrated stores excluded in v1:** The evaluator only runs against BK-native punches. WIW-sourced punches keep current classification behavior.
  - Rationale: WIW import path may classify breaks differently; the WIW handoff design is a project of its own. Scope-narrowing for v1 safety.
  - Trade-offs: WIW stores in CA / NY don't get state-aware compliance in v1. Acceptable because most BK direct customers are not on WIW.
  - User confirmed: ✅ via AskUserQuestion.

- [x] **ADR-6 — City presets out of v1 scope:** Data model supports `jurisdiction = 'city'` and the algorithm can stack inheritance (federal → state → city), but no city presets ship.
  - Rationale: Scope discipline; SF / NYC / Chicago / Berkeley each require separate legal research. Data model is forward-compatible.
  - Trade-offs: Operators in those cities under-classified vs strictest applicable law. Mitigated by allowing operators to customize their state preset rules manually.
  - User confirmed: ✅ via AskUserQuestion.

- [x] **ADR-7 — FLSA-default stores do NOT flag missing meal breaks:** The compliance log only writes violations for rules with `isRequired = true`. FLSA has no required rules, so a TX store on FLSA-default with a 12-hour no-break shift produces zero violations.
  - Rationale: Compliance flagging follows the active policy. Operators wanting visibility switch to a state preset.
  - Trade-offs: Operators on FLSA-default get no nudges about long shifts without breaks. Acceptable — they chose FLSA-default.
  - User confirmed: ✅ Implicit in brief recommendation (Q2).

- [x] **ADR-8 — Yearly preset refresh = opt-in per store via banner:** New preset versions (e.g., CA-2026) ship as migrations. Existing CA stores see "Update available" and adopt at their own pace. Old policy version preserved for historical reporting.
  - Rationale: Same as ADR-2 — silent changes to payroll behavior are unacceptable.
  - Trade-offs: BK can't force a fleet-wide update mid-year. Acceptable.
  - User confirmed: ✅ via AskUserQuestion.

## Quality Requirements

- **Performance:**
  - Evaluator: ≤ 1 ms per shift (P99) on inputs ≤ 10 punches and ≤ 5 rules.
  - Timesheet endpoint: ≤ 5 % added latency with policy enabled.
  - Compliance Dashboard: 7-day × 50-employee view < 500 ms server time.
- **Usability:**
  - Store Settings → Break Policy page renders on first paint with current policy visible.
  - Diff preview before any preset change.
  - Zero employee-facing UI changes (apart from removing the break-type picker entirely).
- **Security:**
  - All manager routes gated by `uri_schedule_manage` + `checkStoreGroup($typeNum)`.
  - Compliance log writes constrained to caller's store DB only.
  - No PII added to logs beyond what already exists.
- **Reliability:**
  - Feature flag off ⇒ zero behavior change.
  - Compliance log idempotent under recompute (unique key).
  - Evaluator pure (deterministic ⇒ replayable).
- **Testability:**
  - Evaluator: ≥ 95 % line and branch coverage.
  - Each shipped preset has an integration test against a representative shift matrix.
  - Repository layer covered by PdoMockBuilder unit tests.
  - PHPStan clean on all modified files.

Each quality requirement maps to test coverage in the Test Specifications section.

## Risks and Technical Debt

### Known Technical Issues

- The May 2026 hot-fix in `buildDayBreakdown` is the current source of truth for net `workedHours`. The evaluator must reconcile to identical totals on `FLSA-default` to avoid regressing payroll for the fleet at Phase 3 cutover.
- Mobile clients in the wild may still send `breakType` in payloads. Backward-compat means accept-and-ignore, not 400.
- Workspace clock JS currently hardcodes `type: 2`. Removing this is straightforward but requires care that no other code path reads the type from the response.
- Bootstrap 5 modal stacking bug is endemic in this codebase (CLAUDE.md mandate). Every new modal in this feature MUST apply the body-relocation fix and use `bootstrap.Modal.getOrCreateInstance(el)`.

### Technical Debt

- `BREAK_TYPE_PAID` / `BREAK_TYPE_UNPAID` constants on `TimePunch` become semi-vestigial post-cutover. They remain readable from existing rows; new rows under the engine ignore them. Slated for cleanup in v2.
- The `employees` legacy table still seeds identities for WIW stores. Not touched in v1 but worth a note.

### Implementation Gotchas

- **PHP 8.5 `\Throwable` vs `\Exception`** — Slim 2 controllers must use `catch (\Throwable $e)`. `TypeError` from strict signatures is NOT caught by `catch (\Exception)`. (Memory note.)
- **Migration system idempotency** — `migration_log.migration_id = filename + md5(description)`. Changing the description text creates a new operation ID; reusing a description text causes the runner to skip even if `check_query` is removed.
- **MariaDB `INT <> ''`** — never compare INT columns to empty strings in `INSERT ... SELECT` context (causes error 1292 even with 0 rows). Backfill migration must use `> 0` or `IS NOT NULL` semantics.
- **PDO duplicate named params** — never reuse `:foo` twice in a single statement. Use `:foo1`, `:foo2` and bind both with the same value.
- **`isset($je->TotalAmt)`** anti-pattern not applicable here, but worth flagging that we should validate numeric DECIMAL fields with explicit `!== null` checks not `isset()` for JE-like values.

## Test Specifications

### Critical Test Scenarios

**Scenario 1 — Primary happy path (CA-2025 compliant shift):**
```gherkin
Given: A CA pilot store with policy adopted from CA-2025
And: An employee shift from 09:00 to 17:00 (8h)
And: A 10-min paid rest break at 11:00 and a 30-min unpaid meal at 13:00
When: TimesheetController loads the day
Then: workedHours == 7.5
And: paidBreakMinutes == 10
And: unpaidBreakMinutes == 30
And: compliance.hasViolations == false
And: premiumPayHours == 0
```

**Scenario 2 — CA missed-meal premium:**
```gherkin
Given: CA-2025 store, 8h shift
And: A 30-min meal break started at hour 5:15 of the shift
When: TimesheetController loads the day
Then: A lateMeal violation is logged for this shift
And: premiumPayHours == 1.0 on the day
And: weekly total premiumPayHours includes the 1.0
```

**Scenario 3 — FLSA-default zero-violation despite long no-break shift:**
```gherkin
Given: A TX store on FLSA-default
And: A 12h shift with no break punches at all
When: TimesheetController loads the day
Then: compliance.hasViolations == false
And: No rows written to breakComplianceLog
```

**Scenario 4 — Feature flag off ⇒ behavior unchanged:**
```gherkin
Given: Any store with breakPolicyEnabled = 0
And: An 8h shift with mixed paid/unpaid break punches
When: TimesheetController loads the day
Then: workedHours, grossWorkedHours, unpaidBreakHours match the May 2026 hot-fix output exactly
And: BreakPolicyEvaluator is never invoked
And: No rows written to breakComplianceLog
```

**Scenario 5 — Idempotent compliance log on recompute:**
```gherkin
Given: A shift with one lateMeal violation
When: TimesheetController loads the day twice in a row
Then: breakComplianceLog has exactly one row matching (employeeId, shiftDate, lateMeal, ruleId, punchId)
And: createdAt is preserved from first write
```

**Scenario 6 — Preset adoption clones rules with sourcePresetRuleId back-pointers:**
```gherkin
Given: A store currently on FLSA-default
When: Manager adopts CA-2025
Then: storeBreakPolicy.basedOnPresetKey == "CA-2025"
And: All storeBreakPolicyRule.sourcePresetRuleId values point to live breakPolicyPresetRule rows
And: All storeBreakPolicyRule.isOverride == false
```

**Scenario 7 — Reset overridden rule restores preset values:**
```gherkin
Given: A CA store with one customized rule (isOverride == true)
When: Manager clicks Reset on that rule
Then: All rule fields match the source preset rule
And: isOverride == false
And: policy.isCustomized recalculated (false if no other overrides)
```

**Scenario 8 — Employee surfaces do not send break type:**
```gherkin
Given: A store with breakPolicyEnabled = 1
When: Employee taps Start Break on workspace clock
Then: Request payload contains no "type" field
And: TimePunch record persisted with current code's default

When: Employee sends Start Break to mobile API with breakType=1
Then: The breakType field is ignored
And: A warning is logged with userId + typeNum + breakType received
```

**Scenario 9 — Update available banner fires:**
```gherkin
Given: A CA store on CA-2025 (version 1)
And: BK ships CA-2025 (version 2) via migration
When: Manager opens Break Policy page
Then: updateAvailable == true
And: A diff preview is shown before adoption
```

**Scenario 10 — Backfill matches existing behavior:**
```gherkin
Given: All existing stores backfilled to FLSA-default with defaultBreakType=unpaid
And: breakPolicyEnabled = 1 on a non-pilot store
When: Replay 30 days of historical punches through the evaluator (read-only diff)
Then: Aggregate workedHours / grossWorkedHours / unpaidBreakHours match May 2026 hot-fix output for every shift
```

### Test Coverage Requirements

- **Business Logic:**
  - Evaluator algorithm: ≥ 95 % line and branch.
  - Each shipped preset has a `Preset<Name>Test` running a fixture matrix (compliant shift, missed meal, late meal, short meal, second-meal trigger if applicable, FLSA carve-out).
  - Backfill diff replay over historical pc00 / ou00 sample data.
- **User Interface:**
  - Manual smoke per CLAUDE.md (no E2E framework in scope for v1).
  - Bootstrap 5 modal wrapper-relocation verified per page (Change preset, Resolve violation, Edit rule).
- **Integration Points:**
  - `TimePunchRepository::calculateWorkedHours` branch covered by PdoMockBuilder tests for flag on/off.
  - `TimesheetController::buildDayBreakdown` covered by feature tests asserting `compliance` payload shape.
  - `MobileClockService::startBreak` covered by a Slim 2 controller test asserting warning log + ignored field.
- **Edge Cases:**
  - Break crossing the `mustStartBeforeHourOfShift` boundary.
  - Empty rule list / orphaned policy row.
  - Punch list missing clockOut.
  - Duplicate adoption of the same preset version.
  - Concurrent rule edits (last-writer-wins).
- **Performance:**
  - Evaluator microbenchmark: 10k synthetic shifts run in < 500 ms.
- **Security:**
  - Permission gate tests on every new admin route.
  - Cross-store access denial test (uri_schedule_manage on store A cannot read store B's policy).

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Preset | A named, versioned bundle of break rules maintained by BK (e.g., `CA-2025`, `FLSA-default`). | Stored in shared DB; adopted by stores. |
| Policy | A store's adopted set of break rules. Cloned from a preset at adoption time. | Stored in per-store DB. |
| Rule | A single break rule (rest, meal, secondMeal, flsaCutoff, autoDeduct) with trigger conditions and outcomes. | Both preset rules and policy rules. |
| Override | A policy rule whose field values differ from its source preset rule. | `isOverride = true` on the row. |
| Premium Pay | An additional hour (or fraction) of regular pay owed when a state-specific rule is violated (e.g., CA missed meal). | Flag-only in v1 — surfaced for manager action, not auto-paid. |
| Compliance Violation | A logged finding: `missingMeal`, `lateMeal`, `shortMeal`, `missingRest`, `autoDeduct`. | Written to `breakComplianceLog`. |
| FLSA Cutoff | Federal rule that any break shorter than 20 minutes is paid time. | Special `ruleType = flsaCutoff` applied as a final classifier. |
| Auto-Deduct | A virtual unpaid deduction synthesized when no meal break was taken on a long shift. | Affects payroll totals + log entry; does NOT create a punch row. |
| Backfill | The one-time migration that creates a `storeBreakPolicy` row defaulted to `FLSA-default` for every existing store. | Phase 3. |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| Conductor | BuyerKiosk's migration runner CLI (`php userfrosting/conductor run`). | Schema + preset data migrations. |
| typeNum | Store identifier pattern `[a-z][a-z]\d+` (e.g., `pc00`, `ou00`). | Routing + per-store DB name. |
| WIW | When I Work — third-party WFM that integrates with some BK stores. | Out of v1 scope. |
| DTO | Data Transfer Object — typed value carrier between layers. | Engine inputs/outputs. |
| PSR-4 | PHP autoloading standard used by Composer. | All new classes follow `BuyerKiosk\…` namespace. |
| PHPStan | Static analyzer; must stay clean on modified files. | CI gate. |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| `uri_schedule_manage` | Permission required for any manager-facing scheduling admin route. | Existing UF permission hook. |
| `checkStoreGroup(typeNum)` | UF method that verifies caller belongs to the store. | Store scoping on every admin route. |
| `dbConnectByName($dbName)` | BaseModel helper to open a per-store PDO connection. | Used by every per-store repository. |
| `breakPolicyEnabled` | New per-store TINYINT(1) column on `kiosk_buykiosk.stores`. | Feature flag. |
