<!--
DEPRECATED: This template has moved to plugins/start/skills/solution-design/template.md
This file is kept for backward compatibility only.
It will be removed in a future version.
-->

# Solution Design Document

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings (ICO-1 through ICO-9)
- [x] Project commands are discovered from actual project files (test.sh, conductor, bin/task, docker-dev.sh)
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale (layered modular monolith inside Slim 2)
- [x] Every component in diagram has directory mapping (10 components → exact PSR-4 paths in Directory Map)
- [x] Every interface has specification (5 HTTP endpoints + 7 service contracts + 5 repo contracts + 1 job contract + 10 exception types)
- [x] Error handling covers all error types (23-row matrix in Runtime View)
- [x] Quality requirements are specific and measurable (PERF-1..5, SEC-1..6, REL-1..6, OPS-1..3 with concrete measurement methods)
- [x] Every quality requirement has test coverage (Scenarios 1–27 plus sub-scenarios 11b/c/d, 13b, 26b map directly to PRD F1–F13 + Quality Requirements)
- [x] **All architecture decisions confirmed by user** — 10 ADRs confirmed 2026-05-23
- [x] Component names consistent across diagrams (verified across Cycle A + Cycle C diagrams)
- [x] A developer could implement from this design

---

## Constraints

These constraints define the solution space for Phase 0 (Prerequisites) + Phase 1a (Foundations). They are inherited from the locked architectural analysis (2026-05-18, 32 decisions), the PRD acceptance criteria, and the existing BuyerKiosk codebase conventions.

### Language, Framework, and Stack
- **CON-1 — PHP 8.x / Slim 2.6.2.** All payroll backend code runs under the existing project stack. No new framework introduced. Classes follow PSR-4 autoloading under the `BuyerKiosk\Payroll\` namespace.
- **CON-2 — MySQL/MariaDB only.** Central DB (`kiosk_buykiosk`) holds the bulk of payroll tables; `kiosk_users` holds the `userPayrollProfiles` linking table (because it lives alongside the `users` table whose primary key it references); per-store DB (`kiosk_<typeNum>`) holds only the `scheduleTimePunches` extension. No NoSQL store, no separate payroll DB. Money fields are `INT` cents; hours fields are `DECIMAL` with explicit precision; FLOAT/DOUBLE forbidden anywhere in payroll schema (mirrors analysis §6 hard rules and PRD constraint).
- **CON-3 — Conductor migrations are the ONLY mechanism for schema changes.** Every new table, every column add, every index goes through a JSON file in `userfrosting/migrations/input/` and is recorded in `kiosk_buykiosk.migration_log`. Direct `ALTER TABLE` is forbidden (CLAUDE.md, hard project rule). Reapplying any migration is a no-op.
- **CON-4 — TaskEngine is the queue/scheduler.** Webhook async processing, future PTO accrual jobs, and future daily reconciliation jobs all run as `BaseJob` subclasses under `BuyerKiosk\Payroll\Jobs\`, dispatched through `JobDispatcher`. No new queue framework introduced.
- **CON-5 — Composer + autoload classmap only.** No new package manager. Any new third-party dependency requires an explicit composer require; the Phase 0/1a plan adds none.

### Coding Standards
- **CON-6 — camelCase column naming** for every new column (project convention, e.g., `payrollTenantId`, `effectiveFrom`, `evereeEventId`). Snake_case is reserved for legacy UF tables (`uf_user`, `uf_authorize_group`).
- **CON-7 — Slim 2 `Stop` exception is flow control, not failure.** Any try/catch that wraps controller logic MUST allow `\Slim\Exception\Stop` to propagate (per project skill `slim2-stop-exception-swallowed`). Catch-blocks use `\Throwable` filtering, not `\Exception`, to surface PHP 8 `TypeError`s explicitly (per BuyerKiosk memory `PHP 8.5 Throwable Gotcha`).
- **CON-8 — Test framework is PHPUnit via `./test.sh`.** Unit tests in `userfrosting/tests/Unit/Payroll/`; integration tests in `userfrosting/tests/Integration/Payroll/`. Mocks reuse the project's `PdoMockBuilder`, `RedisMock`, `StoreMock` fixtures.
- **CON-9 — PHPStan must stay green at current baseline.** Any consolidation of Employee classes or new payroll service must not regress static analysis (`./vendor/bin/phpstan analyse`).

### Security and Compliance
- **CON-10 — IRS retention windows are permanent.** No "hard delete" path may be added to `payRateHistory`, `payrollAuditLog`, `payrollRuns`, or `payrollRunLines`. Retention: 4 years wage records, 4 years W-4/W-2 references, 3 years I-9 references (analysis §6, PRD constraint).
- **CON-11 — SSN, bank account, W-4 detail, I-9 detail NEVER enter BK.** No column, no log, no `payload` JSON field carries these. Verification status (TIN verified Y/N, onboarding completed Y/N) is mirrored only via `userPayrollProfiles`. Hard rule, schema enforces by omission.
- **CON-12 — Append-only invariant on `payRateHistory`.** No code path issues `UPDATE` against `payRateHistory`. Supersession is INSERT only. Tombstones (explicit retirement) are also INSERTs. Application-level enforcement plus a regression test that asserts UPDATE attempts raise (mechanism choice deferred to Cycle B; see ADR-2).
- **CON-13 — Plaintext Everee API tokens never appear in logs, exceptions, or JSON dumps.** Tokens live only inside the API client's call boundary. Encryption mirrors the existing QuickBooks pattern (`BuyerKiosk\Security\Encryption`, OpenSSL AES-256-CBC, random-IV-prepended-base64); the master key is a NEW env var (`EVEREE_ENCRYPTION_KEY`) separate from `QB_ENCRYPTION_KEY` so the two integrations rotate independently.
- **CON-14 — All Everee endpoint traffic targets sandbox in Phase 0/1a.** Production credentials are NOT wired in any environment until Phase 1b's pre-launch readiness gate. Base URL is configurable per environment.
- **CON-15 — HMAC verification is mandatory on every webhook request.** No code path skips it. Per-tenant secrets are stored encrypted in `payrollTenants.webhookSecretEncrypted` (added defensively even if the partner answers "global signing" — the column remains unused but available).
- **CON-16 — Scheduling-provider gate is server-side.** Any payroll-mutating service or endpoint refuses calls from stores whose `Store::$schedulingProvider !== 'buyerkiosk'`. WhenIWork and Homebase stores are excluded at the data layer, not at the UI layer (locked decision §15 / §16 in analysis; PRD Feature 11).

### Data Modeling
- **CON-17 — One `payrollTenants` row per EIN; N stores per tenant.** `stores.payrollTenantId` is nullable FK; multiple `stores.typeNum` may share a single `payrollTenantId`. Reassignment requires an audit-logged migration step (no silent re-pointing).
- **CON-18 — PII source-of-truth model labels every new column.** Three categories: BK canonical (phone, hire/term dates, position+store, rate history), Onboarding-kickoff prefill (legal name, DOB, address — write-once then Everee is canonical), Never-enters-BK (SSN/bank/W-4/I-9). Each new column's category is recorded in the migration JSON description (PRD Feature 3).
- **CON-19 — `payRateHistory` is the canonical source for ALL compensation, hourly and salaried.** No parallel `annualSalaryCents` field on `users` or `userStoreAssignments`. Salary lives in `payRateHistory` with `rateType='salary_annual'` (overrides analysis §4.1 which had pulled salary onto `userStoreAssignments` — this PRD's reconsideration takes precedence).

### Partner and Operational
- **CON-20 — Partner-gated items do not block Phase 0.** Sandbox tenant creds, full webhook enumeration, idempotency-key support, HMAC algorithm — all are §13 partner-confirmation items. Phase 0 (schema + refactors) is fully Everee-independent. Phase 1a feature work that requires sandbox connectivity is individually addressable; any single confirmation unblocks a chunk.
- **CON-21 — Employee class consolidation is a hard prerequisite gate.** No Phase 1a feature work merges until `BuyerKiosk\Employee\Employee` is the single canonical class and `BuyerKiosk\Core\Employee` is either removed or aliased to it (currently the `LegacyAliases.php` map at line 32 still points the legacy global `Employee` at `Core\Employee` — that mapping flips in Phase 0).
- **CON-22 — Person-centric account verification must precede tenant model commit.** PRD Feature 2 verification report must be merged before any Phase 1a service that maps `users.id` → `evereeWorkerId` is implemented. If the verification surfaces a duplicate-user-creation bug in the cross-merchant path, scope is re-evaluated (analysis §11 + PRD risk row).

## Implementation Context

**IMPORTANT**: Implementers MUST read every HIGH/CRITICAL source listed below before authoring code in this scope. The architecture is heavily inherited from existing BuyerKiosk subsystems; deviating from a referenced pattern without explicit ADR coverage is grounds for review pushback.

### Required Context Sources

- **ICO-1 — Authoritative specs and locked decisions**
  ```yaml
  - doc: docs/specs/050-everee-payroll-foundations/product-requirements.md
    relevance: CRITICAL
    why: "Source of acceptance criteria. Every SDD design decision must trace back to a PRD AC."

  - doc: docs/everee-payroll-integration-analysis.md
    relevance: CRITICAL
    sections: [§3 32-decision ledger, §4.1 schema spec, §5 defense layers, §6 hard rules, §7 phase boundaries, §13 partner stack, §14 pre-flight checks]
    why: "Locked 2026-05-18 — overrides anything ambiguous in the PRD."

  - doc: CLAUDE.md
    relevance: HIGH
    sections: [Migration System rule, Slim 2 Stop exception note, PHP 8.5 Throwable gotcha, Bootstrap 5 modal rule (UI scope only)]
    why: "Project-wide conventions; failing to follow these blocks review."
  ```

- **ICO-2 — Encryption (mirror exactly)**
  ```yaml
  - file: userfrosting/src/BuyerKiosk/Security/Encryption.php
    relevance: CRITICAL
    sections: [encrypt() lines 12-18, decrypt() lines 20-38]
    why: "The OpenSSL AES-256-CBC implementation Everee tokens will reuse. No reimplementation."

  - file: userfrosting/src/BuyerKiosk/QuickBooks/QuickBooksService.php
    relevance: CRITICAL
    sections: [loadEncryption() 78-91, encryptValue() 123-135, decryptValue() 144-162, usage at 329 & 448]
    why: "Template for EvereeTokenStorage service — service-side encrypt/decrypt, fail-closed write-time enforcement, lenient decrypt for migration window."

  - file: userfrosting/config/qb-encryption.php
    relevance: HIGH
    why: "Shape of the per-integration encryption config file. Phase 0 mirrors this as config/everee-encryption.php."

  - file: userfrosting/tests/Unit/Security/EncryptionTest.php
    relevance: HIGH
    why: "Existing roundtrip / IV randomness / corruption tests — the token-leak regression test in PRD Feature 4 AC extends this shape."

  - file: userfrosting/tests/Unit/QuickBooks/Services/TokenEncryptionRequiredTest.php
    relevance: HIGH
    why: "Template for the encryption-required exception test on EvereeTokenStorage."
  ```

- **ICO-3 — Audit log shape (mirror with extensions)**
  ```yaml
  - file: userfrosting/migrations/input/20251222_022_011_schedule_audit_log.json
    relevance: CRITICAL
    why: "Reference migration for the scheduleAuditLog table. payrollAuditLog mirrors this column-for-column with payroll-specific entity types."

  - file: userfrosting/src/BuyerKiosk/MobileScheduling/Repositories/ScheduleAuditRepository.php
    relevance: CRITICAL
    why: "Pattern for PayrollAuditRepository — repo + ScheduleAuditEntry model pair, factory methods (log/logClockIn/logClockOut), findWithFilters() for admin queries."

  - file: userfrosting/src/BuyerKiosk/MobileScheduling/Models/ScheduleAuditEntry.php
    relevance: HIGH
    why: "Template for a PayrollAuditEntry model — JsonSerializable, action constants, factory methods, ACTOR_SYSTEM/EMPLOYEE/MANAGER conventions."
  ```

- **ICO-4 — TaskEngine (reuse, do not reinvent)**
  ```yaml
  - file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/BaseJob.php
    relevance: CRITICAL
    sections: [22-434 — lifecycle hooks, isRetry(), getAttempt()]
    why: "Base class for ProcessEvereeWebhookJob and all future payroll jobs."

  - file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/JobInterface.php
    relevance: HIGH
    sections: [14-79]
    why: "Static getName/getQueue/getScope/getTimeout contract every payroll job must implement."

  - file: userfrosting/src/BuyerKiosk/TaskEngine/Application/JobDispatcher.php
    relevance: HIGH
    sections: [dispatch() 61-113, dispatchManual() 128-144, dispatchPerStore() 158-180]
    why: "Webhook receiver uses dispatchManual() (or a system-actor variant) to enqueue async processing."

  - file: userfrosting/src/BuyerKiosk/Goals/Jobs/GoalForecastComputeJob.php
    relevance: MEDIUM
    why: "Concrete reference Job implementation. ProcessEvereeWebhookJob follows the same static-config shape."
  ```

- **ICO-5 — UF permissions and roles**
  ```yaml
  - file: userfrosting/migrations/input/20251220_013_010_schedule_permissions.json
    relevance: CRITICAL
    why: "Template for the new payroll permissions migration — uf_authorize_group inserts with (group_id, hook, conditions) triples. The 12 payroll permission keys ship as one analogous migration JSON."

  - file: userfrosting/src/BuyerKiosk/TeamMember/Services/RoleConfigService.php
    relevance: MEDIUM
    sections: [DEFAULT_ROLES constant 42-49]
    why: "Defines BK role names (Owner / Manager / Shift Lead / Employee / Buyer / Cashier). The 12 payroll permission keys map to UF group IDs (1/2/7/8/9), which themselves correspond to BK role concepts — the SDD includes the cross-walk."
  ```

- **ICO-6 — Compatibility shims and Employee consolidation**
  ```yaml
  - file: userfrosting/src/BuyerKiosk/Compatibility/LegacyAliases.php
    relevance: CRITICAL
    sections: [$legacyClassMap 26-211, autoloader 223-247, line 32 (current Employee mapping)]
    why: "Employee consolidation (PRD Feature 1) re-targets line 32 from BuyerKiosk\\Core\\Employee to BuyerKiosk\\Employee\\Employee, and re-exports any remaining Core\\Employee callsites to the modern class."

  - file: userfrosting/src/BuyerKiosk/Core/Employee.php
    relevance: HIGH
    why: "Stub (2.3KB) — to be removed or reduced to a thin re-export after consolidation."

  - file: userfrosting/src/BuyerKiosk/Employee/Employee.php
    relevance: CRITICAL
    why: "Surviving canonical class (13.5KB). PRD Feature 1 specifies this one wins."
  ```

- **ICO-7 — Migration JSON shape (worked examples)**
  ```yaml
  - file: userfrosting/migrations/input/20260520_001_buyqueue_checkoutStatus.json
    relevance: HIGH
    why: "Reference alter_table + update operation pair, with check_query, database routing ({{store}} vs kiosk_buykiosk vs kiosk_users), and idempotency guard."

  - file: userfrosting/migrations/input/20251203_001_quickbooks_stores_columns.json
    relevance: HIGH
    why: "Reference for adding nullable token-related columns to a central-DB table — closest analog to the payroll columns being added to stores and users."
  ```

- **ICO-8 — External Everee API and Flutter docs**
  ```yaml
  - url: https://developers.everee.com/docs
    relevance: CRITICAL
    sections: [Company Instance API, Worker API, Webhook signatures + event list, Pay Run preview/submit (Phase 1b only), Idempotency-Key header (subject to partner confirmation §13 item 3)]
    why: "Source of truth for endpoint contracts, header conventions (`authorization: basic <base64(token)>`, `x-everee-tenant-id`), and the 11 webhook event types the receiver must ingest."

  - url: https://developers.everee.com/docs/embedded-components
    relevance: LOW
    why: "Phase 1b/1c concern. Documented here so implementers know the embedded-component surface exists; this spec does not consume it."
  ```

- **ICO-9 — User memory and operational knowledge**
  ```yaml
  - memory: "User Tables (CRITICAL)" — kiosk_users.users is the canonical users table
    relevance: CRITICAL
    why: "All payroll userId FKs reference kiosk_users.users.id. The deprecated store-level employees table is NOT a payroll referent (WhenIWork-only legacy)."

  - memory: "Dev Environment" — local + ngrok at dev2.buyerkiosk.com; no remote server
    relevance: HIGH
    why: "Sandbox smoke test (PRD Feature 5 partner-gated AC) hits Everee from the local dev box, not a deployed environment."

  - memory: "PHP 8.5 Throwable Gotcha"
    relevance: HIGH
    why: "All payroll route handlers and webhook ingress use catch (\\Throwable $e), never catch (\\Exception $e)."

  - memory: "Migration System (CRITICAL)" — migration_log in central DB, MD5(description) creates operation_id
    relevance: CRITICAL
    why: "Every payroll migration JSON ships with a check_query and an idempotent SQL; changing description bumps the operation_id (force re-run path documented for any post-merge edit)."
  ```

### Implementation Boundaries

- **Must Preserve:**
  - `kiosk_users.users` schema for fields outside the PRD's named extensions (no touching `username`, `email`, `password`, `display_name`, etc. beyond the documented new columns).
  - `BuyerKiosk\Security\Encryption` interface — copying is allowed; modifying it is not (any change ripples to QuickBooks).
  - `JobInterface` / `BaseJob` contract — new jobs implement, no abstract changes.
  - `ScheduleAuditRepository` and `ShiftAuditRepository` interfaces — `payrollAuditLog` is a new sibling, not a replacement.
  - `$legacyClassMap` entries that are not Employee-related stay intact during Phase 0.
  - Public `/api/` route conventions (typeNum scoping, `checkAccess` enforcement, JSON response shape).

- **Can Modify:**
  - `LegacyAliases.php` line 32 and surrounding entries: re-target `Employee` and `EmployeeDaily` to the consolidated namespace.
  - `BuyerKiosk\Core\Employee.php`: reduce to a thin re-export or delete entirely once no callsite refers to it directly (verified by `grep -rn 'BuyerKiosk\\\\Core\\\\Employee' userfrosting/src` returning zero non-Compatibility hits).
  - Any payroll-touching test fixture or mock under `tests/Mocks/` or `tests/Fixtures/` — new mocks for `EvereeApiClient`, `PayRateService`, `PayrollAuditRepository` welcome.
  - `RoleConfigService::DEFAULT_ROLES` is NOT modified by this spec (permission-key mapping lives in `uf_authorize_group`, not the role list).

- **Must Not Touch:**
  - Any UI template under `userfrosting/templates/themes/default/` (Phase 0/1a ships no UI).
  - Any merchant-visible route (`routes/admin/`, `routes/workbook/`, etc.) — payroll routes land under `routes/api/payroll.php` only.
  - `QuickBooks/QuickBooksService.php` — pattern source, not editable from this scope.
  - The `scheduleAuditLog`, `scheduleShiftAudit`, `goalConfigAudit` tables — pattern sources, not editable.
  - Production Everee credentials anywhere (config, env, secrets manager).

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    subgraph BK[BuyerKiosk Web]
        WebUI[Admin / Workspace UI<br/>NOT in Phase 0/1a]
        Routes[Slim 2 Routes<br/>/api/payroll/*]
        Services[BuyerKiosk\\Payroll Services]
        DB[(MySQL<br/>kiosk_buykiosk + kiosk_users)]
        Queue[TaskEngine<br/>Job Queues]
        Audit[(payrollAuditLog)]
    end

    Engineer[BK Engineer / CS Operator] -->|HTTP| Routes
    Routes --> Services
    Services --> DB
    Services --> Audit
    Services -->|enqueue| Queue
    Queue -->|process async| Services

    Services -->|REST<br/>basic auth + tenant header| EvereeAPI[Everee Sandbox API]
    EvereeAPI -.->|webhook POST<br/>HMAC-signed| Routes

    Services -->|future Phase 1c| QBO[QuickBooks Online<br/>existing integration]

    subgraph Sandbox-Only[Sandbox Only in Phase 0/1a]
        EvereeAPI
    end
```

#### Interface Specifications

```yaml
# Inbound Interfaces (what calls BuyerKiosk in this scope)
inbound:
  - name: "Engineer / CS Operator (REST)"
    type: HTTPS
    format: REST/JSON
    authentication: "UF session cookie + checkAccess('uri_*') + checkStoreGroup(typeNum)"
    doc: "Slim 2 routes under userfrosting/routes/api/payroll.php (NEW)"
    data_flow: "Provisioning calls, rate-set calls, admin reads of audit/webhook logs"

  - name: "Everee Webhook Receiver"
    type: HTTPS
    format: JSON
    authentication: "HMAC signature header (per-tenant secret from payrollTenants.webhookSecretEncrypted; global secret from EVEREE_WEBHOOK_GLOBAL_SECRET as fallback if partner confirms global signing)"
    doc: "userfrosting/routes/api/payroll.php — POST /api/payroll/webhook/everee"
    data_flow: "11 event types: worker.created/updated/deleted/onboarding-*/tin-verification-status-changed/new-tax-forms-available, payment.paid/deposit-returned/updated-payment-method, payment-payables.status-changed"

# Outbound Interfaces (what BuyerKiosk calls in this scope)
outbound:
  - name: "Everee REST API (Sandbox)"
    type: HTTPS
    format: REST/JSON
    authentication: "HTTP Basic header: authorization: basic <base64(apiToken)> + x-everee-tenant-id: <tenantId>"
    doc: "https://developers.everee.com/docs (CRITICAL)"
    data_flow: "Company Instance create, Worker list/get/create (PRD Feature 6 + Phase 1a smoke test)"
    criticality: HIGH
    base_url: "configurable per environment (EVEREE_API_BASE_URL); sandbox in Phase 0/1a only (CON-14)"

  - name: "QuickBooks Online API"
    type: HTTPS
    format: REST/JSON
    authentication: "OAuth2 (existing integration)"
    doc: "userfrosting/src/BuyerKiosk/QuickBooks/QuickBooksService.php"
    data_flow: "Out of scope for Phase 0/1a — payrollCoaMappings table ships in Phase 0 but no JE generation until Phase 1c"
    criticality: LOW (in this scope)

# Data Interfaces
data:
  - name: "Central DB (kiosk_buykiosk)"
    type: MySQL/MariaDB
    connection: "BaseModel::dbConnectByName('kiosk_buykiosk') + PDO"
    doc: "All 12 new payroll tables + migration_log entries"
    data_flow: "Tenant config, rate history, audit log, webhook events"

  - name: "User DB (kiosk_users)"
    type: MySQL/MariaDB
    connection: "BaseModel default connection"
    doc: "users extension (legalFirstName/dob/address/phoneE164), userPayrollProfiles (NEW), uf_authorize_group entries"
    data_flow: "Person identity, payroll worker linkage, permission keys"

  - name: "Store DBs (kiosk_<typeNum>)"
    type: MySQL/MariaDB
    connection: "dbConnectByName($store->getDbName())"
    doc: "scheduleTimePunches extension (cashTipsCents, creditTipsCents, submittedToEvereeAt)"
    data_flow: "Punch-level tip columns (placeholder for never-shipping tips feature) + submission-lock signal"
```

### Cross-Component Boundaries

- **API Contracts:**
  - `POST /api/payroll/webhook/everee` is a PUBLIC contract once published to Everee dashboard — schema changes are breaking.
  - `BuyerKiosk\Payroll\EvereeApiClient` public method signatures are an internal contract — any later phase's payroll service consumes them; rename/relocate requires deprecation aliases.
  - `BuyerKiosk\Payroll\PayRateService::setRate / getRate / retireRate / listHistory` signatures are an internal contract — Phase 1b's pay-run code reads them.

- **Team Ownership:** Single team (BK backend) owns everything in Phase 0/1a. CS owns the partner-conversation tracking (PRD Feature 13 deliverable).

- **Shared Resources:**
  - `kiosk_buykiosk` central DB — shared with QBO, scheduling, billing, every other BK module. Migrations land in central DB; isolation via table naming (`payroll*` prefix).
  - `kiosk_users` — shared with every authenticating subsystem. Column additions to `users` follow PRD's PII source-of-truth labeling.
  - TaskEngine queues — shared with Goals, Scheduling, QBO. Payroll webhook job lands on `default` queue unless an audit (Feature 12) recommends a dedicated `payroll-webhooks` queue.
  - `migration_log` (central DB) — shared with every migration. No conflicts (operation IDs are MD5-hashed).

- **Breaking Change Policy:** A schema migration that changes a previously-shipped column type or drops a column requires its own spec; no in-place destructive migrations during Phase 0/1a.

### Project Commands

Discovered from `CLAUDE.md`, `test.sh`, `userfrosting/conductor`, and `userfrosting/bin/task`. No multi-component coordination — this is a single PHP service.

```bash
# Component: BuyerKiosk Web (PHP / Slim 2)
Location: /Users/rvanvuren/Projects/buyerkiosk-web

## Environment Setup
Install Dependencies: cd userfrosting && composer install
Environment Variables: .env (loaded via the project's dotenv loader; remember to populate $_ENV + $_SERVER, NOT just putenv() — see memory `dotenv_env_vs_getenv`)
  Required for this spec: EVEREE_ENCRYPTION_KEY, EVEREE_API_BASE_URL (sandbox URL), EVEREE_PARTNER_API_TOKEN (for the initial Company Instance create only), EVEREE_WEBHOOK_SIGNING_MODE (per_tenant default), EVEREE_WEBHOOK_GLOBAL_SECRET (only if signing mode is global), EVEREE_WEBHOOK_TIMESTAMP_TOLERANCE_SEC (optional, default 300)
Start Development: local PHP-FPM + nginx + ngrok → dev2.buyerkiosk.com (no remote server, per memory `Dev Environment`)

# Testing Commands
Unit Tests: ./test.sh --testsuite unit
Integration Tests: ./test.sh --testsuite integration
Full Suite + PHPStan: ./test.sh --stan
Targeted: cd userfrosting && ./vendor/bin/phpunit --filter "PayRateServiceTest"
Coverage: ./test.sh --coverage

# Code Quality
Static Analysis: cd userfrosting && ./vendor/bin/phpstan analyse
Targeted PHPStan: cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Payroll/

# Database Migrations (CRITICAL — only path for schema changes)
Author migration JSON: place under userfrosting/migrations/input/YYYYMMDD_NNN_descriptive_name.json
Apply migrations: php userfrosting/conductor run
Targeted dev-store application: see skill `buyerkiosk-conductor-targeted-migration` for single-store testing

# Build (no CSS/JS build needed for Phase 0/1a — backend only)
(none in scope)

# TaskEngine Workers (development)
Start worker: php userfrosting/bin/task worker:start
Start with queues: php userfrosting/bin/task worker:start --queues=high,default,low
Pool status: php userfrosting/bin/task worker:manager --status
Dispatch payroll webhook job (test): php userfrosting/bin/task job:dispatch process-everee-webhook
View queue status: php userfrosting/bin/task queue:status --detailed
Worker logs: tail -f logs/task-worker.log

# Docker (optional, alternative to local PHP-FPM)
./docker-dev.sh up
./docker-dev.sh test
./docker-dev.sh migrate
./docker-dev.sh mysql

# Deploy (NOT used by this spec; local + ngrok is the dev environment)
./deploy.sh   # do NOT invoke without explicit user permission
```

## Solution Strategy

- **Architecture Pattern: Layered modular monolith inside the existing BuyerKiosk Slim 2 app.** All new code lives under a single new PSR-4 namespace `BuyerKiosk\Payroll\` with conventional sub-namespaces (`Services\`, `Controllers\`, `Models\`, `Repositories\`, `Exceptions\`, `Jobs\`). No new service is broken out; no message bus is added; no microservice boundary is drawn. The Payroll module is one cohesive vertical slice within the monolith — the same shape as `BuyerKiosk\QuickBooks\`, `BuyerKiosk\MobileScheduling\`, `BuyerKiosk\Goals\`.

- **Integration Approach: New module, zero in-place modifications to existing modules.** The Phase 0 schema additions are non-destructive (nullable column adds, new tables). The Employee class consolidation (PRD Feature 1) is the only in-place refactor and is scoped to a discrete PR that ships before any Phase 1a code. The Phase 1a feature work plugs into existing patterns at a fixed set of seams: `BuyerKiosk\Security\Encryption` (reused verbatim), `BuyerKiosk\TaskEngine\Application\JobDispatcher` (called from the webhook receiver), `BuyerKiosk\Compatibility\LegacyAliases` (one line re-targeted), `uf_authorize_group` (one new migration JSON with 12 permission rows), and `BuyerKiosk\Core\Store` (one nullable FK column added). Everywhere else, the Payroll module is self-contained.

- **Justification:**
  - Matches the proven shape of the existing QuickBooks integration (`BuyerKiosk\QuickBooks\`) which is the closest analog — same kind of per-tenant encrypted token store, OAuth-or-Basic-auth REST client, webhook integration via QBO sync log, immutable audit trail. The Payroll module reusing that exact shape narrows the security review surface and lets engineers transfer mental model directly.
  - Keeps the blast radius of foundation choices small. A new namespace + nullable schema additions means a rollback is "revert the migration JSON and the PR" — no upgrade-in-place complexity.
  - Aligns with the locked decision to defer all merchant-visible UI to Phase 1b. With no UI in scope, a modular-monolith vertical slice is dramatically simpler than introducing a separate service.
  - The single architectural lever that DOES NOT mirror QBO — append-only `payRateHistory` with explicit retirement tombstones — is treated as a first-class design element (see ADR-2 in Cycle D) rather than absorbed silently into the existing pattern.

- **Key Decisions (full ADRs in §Architecture Decisions; one-line summaries here):**
  1. **`BuyerKiosk\Payroll\` is the canonical namespace** for everything in this spec; no code lands in `BuyerKiosk\Core\` or `BuyerKiosk\Employee\` beyond the consolidation move.
  2. **Encryption mirrors QBO exactly** (OpenSSL AES-256-CBC via `Security\Encryption`, separate master key `EVEREE_ENCRYPTION_KEY`). Upgrade to authenticated encryption (GCM) is captured as follow-up work, not as a Phase 0/1a deviation.
  3. **Append-only invariant is enforced at the application layer** by routing all writes through `PayRateService` (no direct repository writes from controllers; UPDATE is not in the repo's method surface). A regression test asserts that any new code path attempting an `UPDATE payRateHistory` raises. A DB-level trigger or `REVOKE UPDATE` is documented as a defense-in-depth follow-up; not blocking Phase 1a sign-off (see ADR-2).
  4. **Webhook secret model: per-tenant by default; global fallback only if partner confirms global signing.** Column is present on `payrollTenants` regardless. Config switch (env-var `EVEREE_WEBHOOK_SIGNING_MODE = per_tenant | global`) gates which secret the verifier reads. Decision is reversible without schema change.
  5. **Webhook async processing uses TaskEngine `default` queue initially.** Feature 12 audit may recommend a dedicated `payroll-webhooks` queue; if so, recommendation is implemented in-phase before Feature 8's async-AC is exercised.
  6. **Permission keys ship as a single migration JSON** writing 12 rows to `uf_authorize_group` mapping each key to UF group IDs. Cross-walk from BK role names (Owner / Manager / ShiftLead / Employee) to UF group IDs (9 / 7 / 8 / 1) is documented in the migration description AND in `docs/patterns/` (Phase 0 deliverable).
  7. **The Everee REST client is one class, `EvereeApiClient`,** with per-tenant credentials passed per-call (no token caching across instances). Retry/backoff/idempotency lives in the client; service classes consume a clean method surface.
  8. **Idempotency on POST is best-effort with documented fallback.** Client supports the `Idempotency-Key` header pattern; if partner confirms unavailability (§13 item 3), client documents the gap and Phase 1b's pay-run-submit path adds a pre-flight "is this run already on Everee?" check.

## Building Block View

### Components

Phase 0 + Phase 1a deliver one new namespace (`BuyerKiosk\Payroll\`) composed of seven service-layer building blocks plus their persistence repositories and one async job. Boundaries are drawn so each block has a single clear responsibility and Phase 1b/1c work can extend without re-wiring.

```mermaid
graph TB
    subgraph Inbound[Inbound boundaries]
        WebhookRoute[POST /api/payroll/webhook/everee<br/>Slim 2 route]
        AdminRoute[/api/payroll/admin/*<br/>provisioning + rate management<br/>checkAccess-gated]
    end

    subgraph Payroll[BuyerKiosk Payroll namespace]
        WebhookCtrl[EvereeWebhookController]
        AdminCtrl[PayrollAdminController]

        ProviderGate[SchedulingProviderGate<br/>middleware]
        TokenStorage[EvereeTokenStorage<br/>encrypt/decrypt service]
        ApiClient[EvereeApiClient<br/>HTTP + retries + idempotency]
        ProvSvc[EvereeProvisioningService]
        RateSvc[PayRateService<br/>append-only history]
        WebhookHandler[EvereeWebhookHandler<br/>HMAC verify + dedupe + enqueue]
        AuditSvc[PayrollAuditService]

        WebhookJob[ProcessEvereeWebhookJob<br/>BaseJob async handler]
    end

    subgraph Repos[Repositories]
        TenantRepo[(payrollTenants)]
        RateRepo[(payRateHistory)]
        WebhookRepo[(payrollWebhookEvents)]
        AuditRepo[(payrollAuditLog)]
        ProfileRepo[(userPayrollProfiles)]
    end

    subgraph Shared[Reused project infrastructure]
        Encryption[BuyerKiosk Security Encryption<br/>OpenSSL AES-256-CBC]
        Dispatcher[BuyerKiosk TaskEngine JobDispatcher]
        AuthHook[(uf_authorize_group<br/>permission keys)]
    end

    subgraph External[External Everee]
        EvereeAPI[Everee REST API<br/>Sandbox]
        EvereeWebhooks[Everee Webhooks]
    end

    AdminRoute -->|enforces| ProviderGate
    AdminRoute --> AdminCtrl
    AdminCtrl --> ProvSvc
    AdminCtrl --> RateSvc
    ProvSvc --> TokenStorage
    ProvSvc --> ApiClient
    ProvSvc --> TenantRepo
    ProvSvc --> AuditSvc
    RateSvc --> RateRepo
    RateSvc --> AuditSvc

    EvereeWebhooks -.->|POST signed| WebhookRoute
    WebhookRoute --> WebhookCtrl
    WebhookCtrl --> WebhookHandler
    WebhookHandler --> TokenStorage
    WebhookHandler --> WebhookRepo
    WebhookHandler --> Dispatcher
    Dispatcher --> WebhookJob
    WebhookJob --> AuditSvc
    WebhookJob --> ProfileRepo

    TokenStorage --> Encryption
    ApiClient -->|HTTPS| EvereeAPI
    AuditSvc --> AuditRepo

    AdminCtrl -.->|checkAccess| AuthHook
```

**Block responsibilities (one-liner each):**

- `EvereeWebhookController` — Slim 2 route entrypoint for `POST /api/payroll/webhook/everee`. Reads raw body + signature header, hands to `EvereeWebhookHandler`, returns 200 on ingest success, 401 on HMAC failure or replay-tolerance miss.
- `PayrollAdminController` — Slim 2 route entrypoint for engineer/CS-driven provisioning, rate-set, and admin reads of audit + webhook logs. UF-permission-gated via `checkAccess(...)`.
- `SchedulingProviderGate` — Middleware / service guard that refuses payroll mutations on stores whose `schedulingProvider != 'buyerkiosk'`. PRD Feature 11.
- `EvereeTokenStorage` — Service class wrapping `BuyerKiosk\Security\Encryption` for tenant API tokens and per-tenant webhook secrets. Only path to plaintext token; throws on missing master key (PRD Feature 4).
- `EvereeApiClient` — HTTP client targeting Everee REST. Handles Basic auth header, `x-everee-tenant-id`, retry/backoff with documented cap, 429 `Retry-After` honoring, idempotency-key on POST, structured exception taxonomy (PRD Feature 5).
- `EvereeProvisioningService` — Orchestrates Company-Instance creation (via API or wrapped portal step), token storage, `payrollTenants` row INSERT, `stores.payrollTenantId` linkage, audit-log entry; idempotent by EIN (PRD Feature 6).
- `PayRateService` — The pure-append-only rate history service. `setRate`, `getRate(asOfDate)`, `retireRate`, `listHistory`. No UPDATE path (PRD Feature 7).
- `EvereeWebhookHandler` — HMAC verification (per-tenant or global, config-switched), timestamp tolerance check, dedupe by `evereeEventId`, persist + enqueue (PRD Feature 8).
- `PayrollAuditService` — Single writer for `payrollAuditLog`. Mirrors `ScheduleAuditRepository` shape; called by every state-changing service (PRD Feature 9).
- `ProcessEvereeWebhookJob` — `BaseJob` subclass picked up by TaskEngine workers. Phase 1a ships ingestion + queue plumbing; per-event-type handlers may be Phase 1b ("missing handlers are documented but ingestion still succeeds" — PRD Feature 8 AC).

### Directory Map

Single component — the BuyerKiosk PHP monolith. New paths marked `# NEW`; modified paths marked `# MODIFY`; preserved paths marked `# PRESERVE` for orientation.

**Component**: BuyerKiosk Web (PHP / Slim 2)
```
userfrosting/
├── src/
│   └── BuyerKiosk/
│       ├── Payroll/                                            # NEW: entire namespace
│       │   ├── Controllers/
│       │   │   ├── EvereeWebhookController.php                 # NEW (PRD F8)
│       │   │   └── PayrollAdminController.php                  # NEW (PRD F6 + F7 admin paths)
│       │   ├── Services/
│       │   │   ├── EvereeApiClient.php                         # NEW (PRD F5)
│       │   │   ├── EvereeTokenStorage.php                      # NEW (PRD F4)
│       │   │   ├── EvereeProvisioningService.php               # NEW (PRD F6)
│       │   │   ├── PayRateService.php                          # NEW (PRD F7)
│       │   │   ├── EvereeWebhookHandler.php                    # NEW (PRD F8)
│       │   │   ├── EvereeWebhookPayloadRedactor.php            # NEW (CON-11 defense-in-depth — strips PII fields from inbound payloads BEFORE persistence; see ADR-11)
│       │   │   ├── PayrollAuditService.php                     # NEW (PRD F9)
│       │   │   └── SchedulingProviderGate.php                  # NEW (PRD F11)
│       │   ├── Repositories/
│       │   │   ├── PayrollTenantRepository.php                 # NEW
│       │   │   ├── PayRateHistoryRepository.php                # NEW (NO update() method by design)
│       │   │   ├── PayrollWebhookEventRepository.php           # NEW
│       │   │   ├── PayrollAuditRepository.php                  # NEW (mirrors ScheduleAuditRepository)
│       │   │   └── UserPayrollProfileRepository.php            # NEW
│       │   ├── Models/
│       │   │   ├── PayrollTenant.php                           # NEW
│       │   │   ├── PayRateEntry.php                            # NEW (immutable value object)
│       │   │   ├── PayrollWebhookEvent.php                     # NEW
│       │   │   ├── PayrollAuditEntry.php                       # NEW (mirrors ScheduleAuditEntry)
│       │   │   └── UserPayrollProfile.php                      # NEW
│       │   ├── Exceptions/
│       │   │   ├── EvereeApiException.php                      # NEW (base)
│       │   │   ├── EvereeAuthException.php                     # NEW (401/403)
│       │   │   ├── EvereeRateLimitException.php                # NEW (429 after honored Retry-After)
│       │   │   ├── EvereeValidationException.php               # NEW (4xx non-auth)
│       │   │   ├── EvereeUncertainStateException.php           # NEW (mid-POST timeout w/o idempotency key)
│       │   │   ├── EvereeEncryptionRequiredException.php       # NEW (fail-closed encrypt write when EVEREE_ENCRYPTION_KEY missing)
│       │   │   ├── TokenDecryptionException.php                # NEW
│       │   │   ├── PayRateImmutableException.php               # NEW (raised by any attempted UPDATE path)
│       │   │   ├── WebhookSignatureException.php               # NEW
│       │   │   └── SchedulingProviderGateException.php         # NEW
│       │   └── Jobs/
│       │       └── ProcessEvereeWebhookJob.php                 # NEW (extends TaskEngine BaseJob)
│       │
│       ├── Compatibility/
│       │   └── LegacyAliases.php                               # MODIFY: re-target Employee aliases at line 32+ to BuyerKiosk\Employee\Employee (PRD F1)
│       │
│       ├── Core/
│       │   └── Employee.php                                    # MODIFY/DELETE: reduce to a thin re-export OR remove once no callsite references it directly (PRD F1)
│       │
│       ├── Employee/
│       │   └── Employee.php                                    # PRESERVE: surviving canonical class (PRD F1)
│       │
│       ├── Security/
│       │   └── Encryption.php                                  # PRESERVE: reused verbatim by EvereeTokenStorage (CON-13, ICO-2)
│       │
│       ├── QuickBooks/
│       │   ├── QuickBooksService.php                           # PRESERVE: pattern source for EvereeTokenStorage (ICO-2)
│       │   └── config/qb-encryption.php                        # PRESERVE: shape source for new config file
│       │
│       └── TaskEngine/                                         # PRESERVE: reused for webhook async processing (ICO-4)
│
├── routes/
│   └── api/
│       └── payroll.php                                         # NEW: webhook + admin route definitions (Slim 2 route group)
│
├── config/
│   └── everee-encryption.php                                   # NEW: mirrors config/qb-encryption.php (PRD F4)
│
├── migrations/
│   └── input/
│       ├── 20260522_001_payroll_create_tenants.json            # NEW (PRD F3) — payrollTenants
│       ├── 20260522_002_payroll_create_runs.json               # NEW — payrollRuns + payrollRunLines + payrollRunSnapshots
│       ├── 20260522_003_payroll_create_webhook_events.json     # NEW — payrollWebhookEvents (UNIQUE on evereeEventId)
│       ├── 20260522_004_payroll_create_audit_log.json          # NEW — payrollAuditLog (mirrors scheduleAuditLog shape)
│       ├── 20260522_005_payroll_create_rate_history.json       # NEW — payRateHistory + composite index
│       ├── 20260522_006_payroll_create_coa_mappings.json       # NEW — payrollCoaMappings (table only; no UI)
│       ├── 20260522_007_payroll_create_pto_tables.json         # NEW — ptoAccrualPolicies / ptoAccrualBalances / ptoRequests
│       ├── 20260522_008_payroll_create_user_profiles.json      # NEW — userPayrollProfiles
│       ├── 20260522_009_users_legal_pii_columns.json           # NEW — kiosk_users.users + legalFirstName/legalLastName/dob/address fields/phoneE164
│       ├── 20260522_010_stores_payroll_tenant_fk.json          # NEW — stores.payrollTenantId nullable FK
│       ├── 20260522_011_user_store_assignments_classification.json  # NEW — employmentClassification enum
│       ├── 20260522_012_positions_payroll_columns.json         # NEW — workersCompClassCode + qboWageAccountId
│       ├── 20260522_013_schedule_time_punches_tips.json        # NEW — cashTipsCents + creditTipsCents + submittedToEvereeAt (per-store DB)
│       ├── 20260522_014_punch_type_enum_verification_note.json # NEW — schema-verification record for punchType ENUM-vs-VARCHAR (§14.2 PRD F3 AC; OBSERVABILITY, runs alongside the store-DB tips migration not before)
│       ├── 20260522_015_display_name_migration_helper.json     # NEW — display_name → legalFirstName/Last best-effort split (PRD F3 AC)
│       └── 20260522_016_payroll_permission_keys.json           # NEW — 12 uf_authorize_group rows (PRD F10)
│
├── docs/
│   ├── specs/050-everee-payroll-foundations/
│   │   ├── product-requirements.md                             # PRESERVE (Codex-reviewed)
│   │   ├── solution-design.md                                  # THIS DOCUMENT
│   │   ├── implementation-plan.md                              # Step 4 deliverable (not yet authored)
│   │   ├── person-centric-account-verification.md              # NEW (PRD F2 deliverable — SQL evidence + dev-store walkthrough + sign-off)
│   │   └── phase-0-preflight-checklist.md                      # NEW (PRD F13 deliverable — pilot-store rate audit + partner email status + pilot candidates + named owners)
│   ├── patterns/
│   │   ├── payroll-token-encryption.md                         # NEW (PRD F4 AC — pattern recorded in docs/patterns/)
│   │   ├── payroll-rate-history-append-only.md                 # NEW (PRD F7)
│   │   ├── payroll-webhook-hmac-rotation.md                    # NEW (PRD F8 AC — rotation procedure)
│   │   ├── payroll-permission-key-cross-walk.md                # NEW (BK roles ↔ uf_authorize_group group_ids)
│   │   └── taskengine-payroll-audit.md                         # NEW (PRD F12 deliverable)
│   └── interfaces/
│       └── everee-api.md                                       # NEW (PRD F5 — endpoint/header conventions + error taxonomy)
│
├── bin/                                                        # CLI tools (NEW; lightweight commands wrapping services)
│   └── payroll/
│       ├── backfill-rates.php                                  # NEW (PRD F7 backfill tool AC — CLI that calls PayRateService::setRate from CSV input)
│       └── verify-cross-merchant-users.php                     # NEW (PRD F2 deliverable — SQL evidence script + report generator)
│
└── tests/
    ├── Unit/
    │   └── Payroll/
    │       ├── Services/
    │       │   ├── EvereeApiClientTest.php                     # NEW (fixture-replay suite — PRD F5 AC sign-off gate)
    │       │   ├── EvereeTokenStorageTest.php                  # NEW (token-leak regression — PRD F4 AC)
    │       │   ├── PayRateServiceTest.php                      # NEW (append-only invariant + Feature 7 edge cases 1-6)
    │       │   ├── EvereeWebhookHandlerTest.php                # NEW (HMAC + dedupe + replay defense)
    │       │   ├── EvereeProvisioningServiceTest.php           # NEW (idempotent by EIN)
    │       │   ├── PayrollAuditServiceTest.php                 # NEW
    │       │   └── SchedulingProviderGateTest.php              # NEW (WIW + Homebase exclusion fixtures)
    │       └── Jobs/
    │           └── ProcessEvereeWebhookJobTest.php             # NEW
    ├── Integration/
    │   └── Payroll/
    │       ├── WebhookIngestionTest.php                        # NEW (DB-backed dedupe, queue enqueue verification)
    │       └── RateHistoryAppendOnlyTest.php                   # NEW (DB-level enforcement check)
    └── Fixtures/
        └── Payroll/
            ├── everee-response-list-workers.json               # NEW (recorded fixture)
            ├── everee-response-create-worker.json              # NEW
            ├── everee-response-4xx-validation.json             # NEW
            ├── everee-response-5xx-then-success.json           # NEW
            ├── everee-response-429-with-retry-after.json       # NEW
            ├── everee-webhook-worker-created.json              # NEW (HMAC-signed sample)
            └── everee-webhook-payment-paid.json                # NEW
```

**Notes on the layout:**
- Migration JSON file numbering `20260522_NNN_*` is illustrative; final numbering follows whatever sequence is current at PR time, but the per-table grouping is fixed (one migration per table family, sequenced so dependencies — e.g., `payrollTenants` before `stores.payrollTenantId` FK — apply in correct order).
- `routes/api/payroll.php` is a single file rather than a `routes/api/payroll/` subdirectory because Phase 0/1a ships only ~3–5 endpoints; later phases may split.
- `docs/patterns/` and `docs/interfaces/` entries are required PRD deliverables (Feature 4 ACs, Feature 5 docs, Feature 8 rotation procedure, Feature 12 audit) — they ship in the same PRs as the code they document.
- The PSR-4 namespace `BuyerKiosk\Payroll\` is auto-loaded via Composer with no manual configuration step (per CLAUDE.md "PSR-4 Autoloading" rule).

### Interface Specifications

Interface details are specified inline where they affect the storage model (this section) and the API + runtime model (later sections). Reference documentation that lives outside this SDD is enumerated below.

#### Interface Documentation References

```yaml
interfaces:
  - name: "Everee REST API"
    doc: docs/interfaces/everee-api.md
    status: NEW (PRD F5 deliverable)
    relevance: CRITICAL
    sections: [auth_headers, base_url_config, endpoints_used_in_phase_1a, error_taxonomy, idempotency_keys, retry_policy]
    why: "Single source of truth for endpoint contracts the EvereeApiClient consumes. Lives outside the SDD so Phase 1b/1c additions append without re-opening this spec."

  - name: "Everee Webhooks"
    doc: docs/interfaces/everee-api.md#webhooks
    status: NEW (PRD F8 deliverable)
    relevance: CRITICAL
    sections: [signed_payload_shape, hmac_algorithm, timestamp_header, event_types_enumerated]
    why: "Webhook receiver contract — what BK guarantees about ingestion (HMAC verified, idempotent, persisted, async-handled)."

  - name: "scheduleAuditLog convention"
    doc: userfrosting/src/BuyerKiosk/MobileScheduling/Repositories/ScheduleAuditRepository.php
    status: PRESERVE (reference; payrollAuditLog mirrors shape)
    relevance: CRITICAL
    why: "Audit-log write conventions, ScheduleAuditEntry model, findWithFilters pagination — payrollAuditLog mirrors all three."

  - name: "QBO Token Encryption pattern"
    doc: userfrosting/src/BuyerKiosk/QuickBooks/QuickBooksService.php (78-162) + userfrosting/src/BuyerKiosk/Security/Encryption.php
    status: PRESERVE (reference; EvereeTokenStorage mirrors)
    relevance: CRITICAL
    why: "Service-side encrypt/decrypt private methods, fail-closed write enforcement, separate per-integration master key."

  - name: "TaskEngine Job contract"
    doc: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/JobInterface.php + BaseJob.php
    status: PRESERVE (reference)
    relevance: HIGH
    why: "ProcessEvereeWebhookJob implements this contract."

  - name: "Conductor migration JSON shape"
    doc: userfrosting/migrations/input/ (worked examples in ICO-7)
    status: PRESERVE (project convention)
    relevance: CRITICAL
    why: "All 16 payroll migrations follow this shape: type + database + check_query + sql operations in JSON arrays."
```

#### Data Storage Changes

The Phase 0 + Phase 1a schema is the most consequential part of this SDD — migrations are effectively permanent (CON-3, CON-10). The schema is inherited from analysis §4.1 with the PRD-Codex-review amendments applied (no `annualSalaryCents` on `users`; nullable `stores.payrollTenantId` FK; `payrollTenants.webhookSecretEncrypted`).

**Universal column-type rules (apply to every table below):**
- **Money** — `INT` cents, never `FLOAT`/`DOUBLE`/`DECIMAL` (CON-2). Column suffix `*Cents`. Negative allowed only where business meaning warrants (e.g., refunds — none in Phase 0/1a).
- **Hours** — `DECIMAL(8,4)` for sub-minute precision; `DECIMAL(8,2)` for accrual balances and PTO requests where one-hundredth-of-an-hour is sufficient.
- **Dates** — `DATE` for calendar-day semantics (effective dates, pay period boundaries); `DATETIME` for UTC instants; `TIMESTAMP` only for `createdAt` columns that auto-default to NOW.
- **PII** — never plaintext for tokens or secrets (encrypted columns are `VARCHAR(512)` per QBO precedent). SSN/bank/W-4/I-9 fields are simply absent.
- **JSON columns** — `JSON` type (MySQL/MariaDB native), not TEXT-with-JSON. Indexed only via generated columns if at all (Phase 0 ships no JSON indexes).
- **Naming** — camelCase per CON-6.
- **Engine + charset** — `InnoDB`, `utf8mb4`, `utf8mb4_unicode_ci`, matching existing BK conventions.

**Database routing summary:**
- `kiosk_buykiosk` (central DB): 11 of the 12 new payroll tables — `payrollTenants`, `payrollRuns`, `payrollRunLines`, `payrollRunSnapshots`, `payrollWebhookEvents`, `payrollAuditLog`, `payRateHistory`, `payrollCoaMappings`, `ptoAccrualPolicies`, `ptoAccrualBalances`, `ptoRequests` — plus the `stores` extension.
- `kiosk_users`: extensions to `users`, the new `userPayrollProfiles` table (12th new table; lives here because it's a per-user join table), and the new `uf_authorize_group` rows.
- Per-store DB (`kiosk_<typeNum>`): extension to `scheduleTimePunches` only.
- `userStoreAssignments` lives in `kiosk_users` (existing pattern); the `employmentClassification` add lands there.

##### Table 1: `payrollTenants` (NEW — central DB)

One row per EIN. N stores share one tenant via `stores.payrollTenantId` (CON-17).

```sql
CREATE TABLE `payrollTenants` (
  `id`                          INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `evereeCompanyId`             VARCHAR(64)  NULL,                                  -- Everee's internal company UUID
  `evereeTenantId`              VARCHAR(64)  NULL,                                  -- value for x-everee-tenant-id header
  `evereeApiTokenEncrypted`     VARCHAR(512) NULL,                                  -- base64(IV+ciphertext); plaintext never persisted
  `webhookSecretEncrypted`      VARCHAR(512) NULL,                                  -- per-tenant HMAC secret (CON-15)
  `webhookPriorSecretEncrypted` VARCHAR(512) NULL,                                  -- prior secret accepted during rotation window (ADR-3); cleared when window closes
  `webhookPriorSecretExpiresAt` DATETIME NULL,                                      -- absolute timestamp at which the prior secret is no longer accepted; null when no rotation window is open
  `legalName`                   VARCHAR(255) NOT NULL,
  `ein`                         VARCHAR(11)  NOT NULL,                              -- XX-XXXXXXX format; UNIQUE enforces idempotent provisioning (PRD F6)
  `legalAddressLine1`           VARCHAR(255) NOT NULL,
  `legalAddressLine2`           VARCHAR(255) NULL,
  `legalCity`                   VARCHAR(100) NOT NULL,
  `legalState`                  CHAR(2)      NOT NULL,
  `legalZip`                    VARCHAR(10)  NOT NULL,
  `entityType`                  ENUM('llc','c_corp','s_corp','sole_prop','partnership') NOT NULL,
  `payFrequency`                ENUM('weekly','biweekly','semi_monthly','monthly')      NOT NULL,
  `payPeriodEndsOnDayOfWeek`    TINYINT UNSIGNED NULL,                              -- 0=Sunday..6=Saturday; null for monthly
  `payCutoffHoursBefore`        TINYINT UNSIGNED NULL,                              -- hours before period end when submission closes
  `twoPersonApprovalThresholdCents` INT NULL,                                       -- defense layer; nullable disables
  `brandingJson`                JSON NULL,                                          -- white-label theming; populated in Phase 1b
  `provisionedAt`               DATETIME NULL,                                      -- set when Everee Company Instance confirmed reachable
  `provisionedByUserId`         INT UNSIGNED NULL,
  `isActive`                    TINYINT(1) NOT NULL DEFAULT 0,                      -- demo-ready gate (PRD F6 AC, CrossFeatureEdgeCase F6)
  `createdAt`                   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updatedAt`                   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_ein` (`ein`),                                                    -- enforces 1 tenant per EIN (CON-17, PRD F6)
  KEY `idx_active` (`isActive`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Migration file:** `20260522_001_payroll_create_tenants.json` — single `create_table` operation, idempotent via `check_query` against INFORMATION_SCHEMA.TABLES.

##### Table 2: `payrollRuns` (NEW — central DB)

Schema ships in Phase 0; no service code exercises it until Phase 1b. PRD scopes the table here ("Pay run lifecycle is out of scope; only the schema exists in this phase, no service methods that exercise it end-to-end").

```sql
CREATE TABLE `payrollRuns` (
  `id`                       INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `payrollTenantId`          INT UNSIGNED NOT NULL,
  `evereePayRunId`           VARCHAR(64) NULL,                                      -- populated after submit
  `periodStart`              DATE NOT NULL,
  `periodEnd`                DATE NOT NULL,
  `status`                   ENUM('draft','submitted','funded','paid','failed','cancelled') NOT NULL DEFAULT 'draft',
  `submittedByUserId`        INT UNSIGNED NULL,
  `submittedAt`              DATETIME NULL,
  `approvedByUserId`         INT UNSIGNED NULL,
  `approvedAt`               DATETIME NULL,
  `totalGrossCents`          INT NULL,
  `totalNetCents`            INT NULL,
  `totalEmployerTaxCents`    INT NULL,
  `totalEmployeeTaxCents`    INT NULL,
  `employeeCount`            INT UNSIGNED NULL,
  `createdAt`                TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updatedAt`                TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_period` (`payrollTenantId`, `periodEnd` DESC),
  KEY `idx_status` (`status`),
  CONSTRAINT `fk_payroll_runs_tenant` FOREIGN KEY (`payrollTenantId`) REFERENCES `payrollTenants`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

##### Table 3: `payrollRunLines` (NEW — central DB)

Per-worker line per run. Schema ships Phase 0; no Phase 1a write path.

```sql
CREATE TABLE `payrollRunLines` (
  `id`                  INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `payrollRunId`        INT UNSIGNED NOT NULL,
  `userId`              INT UNSIGNED NOT NULL,
  `evereeWorkerId`      VARCHAR(64) NULL,
  `regularHours`        DECIMAL(8,4) NOT NULL DEFAULT 0,
  `overtimeHours`       DECIMAL(8,4) NOT NULL DEFAULT 0,
  `doubletimeHours`     DECIMAL(8,4) NOT NULL DEFAULT 0,
  `ptoHours`            DECIMAL(8,4) NOT NULL DEFAULT 0,
  `bonusAmountCents`    INT NOT NULL DEFAULT 0,
  `grossWagesCents`     INT NOT NULL DEFAULT 0,
  `netWagesCents`       INT NULL,                                                   -- populated post-Everee-funded webhook
  `evereePayableId`     VARCHAR(64) NULL,
  `status`              ENUM('staged','submitted','funded','paid','failed') NOT NULL DEFAULT 'staged',
  `lineSnapshotJson`    JSON NULL,                                                  -- punches + rate-at-submit (defense layer §5 of analysis)
  `createdAt`           TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_run` (`payrollRunId`),
  KEY `idx_user` (`userId`),
  CONSTRAINT `fk_run_lines_run` FOREIGN KEY (`payrollRunId`) REFERENCES `payrollRuns`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

##### Table 4: `payrollRunSnapshots` (NEW — central DB)

Defense layer — preserves the full source data at submission, approval, and paid moments. Phase 1b populates; Phase 0 ships the table only.

```sql
CREATE TABLE `payrollRunSnapshots` (
  `id`                INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `payrollRunId`      INT UNSIGNED NOT NULL,
  `snapshotType`      ENUM('pre_submit','post_approval','post_paid') NOT NULL,
  `sourceDataJson`    JSON NOT NULL,                                                -- punches + rates + classifications + tenant config
  `snapshotAt`        TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_run_type` (`payrollRunId`, `snapshotType`),
  CONSTRAINT `fk_snapshots_run` FOREIGN KEY (`payrollRunId`) REFERENCES `payrollRuns`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

##### Migration file 2-4: `20260522_002_payroll_create_runs.json`

Single migration JSON creates `payrollRuns`, `payrollRunLines`, `payrollRunSnapshots` as three sequenced `create_table` operations (FKs require parent-first).

##### Table 5: `payrollWebhookEvents` (NEW — central DB)

The idempotency table. UNIQUE on `evereeEventId` is the dedupe primitive (PRD F8 AC, CrossFeatureEdgeCase F8 concurrent-duplicate scenario).

```sql
CREATE TABLE `payrollWebhookEvents` (
  `id`                  BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `evereeEventId`       VARCHAR(64) NOT NULL,
  `evereeEventType`     VARCHAR(64) NOT NULL,                                       -- worker.created / payment.paid / etc.
  `evereeCompanyId`     VARCHAR(64) NULL,                                           -- for tenant resolution
  `payrollTenantId`     INT UNSIGNED NULL,                                          -- resolved at ingestion time if companyId matches a known tenant
  `payload`             JSON NOT NULL,
  `signatureHeader`     VARCHAR(256) NULL,                                          -- HMAC header value, retained for forensics
  `timestampHeader`     VARCHAR(64)  NULL,                                          -- Everee-signed timestamp for replay defense
  `hmacValid`           TINYINT(1) NOT NULL,                                        -- 1 only if both signature and timestamp tolerance passed
  `receivedAt`          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `processedAt`         DATETIME NULL,
  `processingError`     TEXT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_event_id` (`evereeEventId`),                                     -- DB-level idempotency primitive (PRD F8 + edge case)
  KEY `idx_type_received` (`evereeEventType`, `receivedAt` DESC),
  KEY `idx_unprocessed` (`processedAt`)                                             -- supports background re-processing scans
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Migration file:** `20260522_003_payroll_create_webhook_events.json`.

##### Table 6: `payrollAuditLog` (NEW — central DB)

Mirrors `scheduleAuditLog` shape (ICO-3). Single sink for tenant provisioning, rate changes, webhook outcomes, and (placeholder) future run/termination/punch-adjustment actions.

```sql
CREATE TABLE `payrollAuditLog` (
  `auditId`             BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `action`              VARCHAR(64) NOT NULL,                                       -- payroll.tenant.provisioned / payroll.rate.set / payroll.webhook.received / ...
  `entityType`          VARCHAR(50) NOT NULL,                                       -- tenant / rate / webhook_event / run / line
  `entityId`            BIGINT UNSIGNED NULL,                                       -- nullable for system-wide events
  `payrollTenantId`     INT UNSIGNED NULL,                                          -- denormalized for fast per-tenant queries
  `userId`              INT UNSIGNED NULL,                                          -- subject user (e.g., user whose rate changed)
  `actorUserId`         INT UNSIGNED NULL,                                          -- nullable to support system-actor; convention documented in pattern doc
  `actorType`           ENUM('employee','manager','system') NOT NULL DEFAULT 'manager',
  `previousValue`       JSON NULL,
  `newValue`            JSON NULL,
  `metadata`            JSON NULL,                                                  -- request-ip, request-id, sampling tag, severity, etc.
  `ipAddress`           VARCHAR(45) NULL,
  `userAgent`           VARCHAR(255) NULL,
  `createdAt`           TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`auditId`),
  KEY `idx_tenant_created` (`payrollTenantId`, `createdAt` DESC),
  KEY `idx_action_created` (`action`, `createdAt` DESC),
  KEY `idx_entity`         (`entityType`, `entityId`),
  KEY `idx_user`           (`userId`),
  KEY `idx_actor`          (`actorUserId`),
  KEY `idx_createdAt`      (`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Migration file:** `20260522_004_payroll_create_audit_log.json`.

##### Table 7: `payRateHistory` (NEW — central DB) — THE APPEND-ONLY TABLE

The most carefully constrained table in this spec. Schema-level invariant: no row is ever updated; supersession is INSERT only; tombstones are INSERT only. See ADR-2 (Cycle D) for the enforcement choice.

```sql
CREATE TABLE `payRateHistory` (
  `id`                    BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `userId`                INT UNSIGNED NOT NULL,
  `payrollTenantId`       INT UNSIGNED NOT NULL,
  `positionId`            INT UNSIGNED NOT NULL,
  `rateType`              ENUM('hourly','salary_annual') NOT NULL,
  `rateCents`             INT NOT NULL,                                             -- per-hour cents (hourly) OR gross annual cents (salary_annual)
  `effectiveFrom`         DATE NOT NULL,                                            -- inclusive start; store-local calendar day
  `effectiveUntil`        DATE NULL,                                                -- only set on explicit retirement tombstones (PRD F7 storage model)
  `isRetirementTombstone` TINYINT(1) NOT NULL DEFAULT 0,                            -- 1 iff this row was written by retireRate(); rateCents may be 0
  `setByUserId`           INT UNSIGNED NOT NULL,
  `note`                  TEXT NOT NULL,                                            -- REQUIRED — PRD F7 Rule 6 rejects empty notes
  `createdAt`             TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_userid_tenant_position_effective` (`userId`, `payrollTenantId`, `positionId`, `effectiveFrom` DESC, `id` DESC),
                                                                                    -- composite index supports getRate(asOfDate) tie-break by greatest (effectiveFrom,id)
  KEY `idx_tenant_position_recent` (`payrollTenantId`, `positionId`, `effectiveFrom` DESC),
  CONSTRAINT `fk_rate_history_tenant`   FOREIGN KEY (`payrollTenantId`) REFERENCES `payrollTenants`(`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Note: no FK on userId / positionId because those tables live in different databases (kiosk_users / per-store).
--       Referential integrity is enforced at the application layer via PayRateService input validation.
```

**Append-only enforcement (resolves PRD F7 AC "DB-prevented or application-level guard"):**

The SDD chooses **application-level enforcement + a regression test + a documented DB-level follow-up** (full ADR in Cycle D). Concretely:

1. `PayRateHistoryRepository` exposes only `insert(...)`, `findGreatestEffectiveFromAtOrBefore(...)`, `listOrdered(...)`. It exposes NO `update(...)` or `delete(...)` method. PHPStan + code-review catches any caller that tries to UPDATE via raw PDO.
2. An integration test in `tests/Integration/Payroll/RateHistoryAppendOnlyTest.php` constructs a raw PDO statement attempting `UPDATE payRateHistory SET ...` and asserts that it raises `PayRateImmutableException` (caught and re-thrown by a `PDO::ATTR_STATEMENT_CLASS` subclass that scans the SQL string for `UPDATE` against this table). The same test asserts that DELETE attempts raise.
3. The DB-level defense-in-depth path (`CREATE TRIGGER ... BEFORE UPDATE ON payRateHistory FOR EACH ROW SIGNAL SQLSTATE '45000' ...` OR `REVOKE UPDATE,DELETE ON payRateHistory FROM <app_user>`) is documented in `docs/patterns/payroll-rate-history-append-only.md` as the recommended follow-up. NOT blocking Phase 1a sign-off because: (a) the application-level path provides the regression-test gate the PRD requires, (b) `REVOKE` needs an environment-specific grant plan which we do not yet have for all environments, (c) triggers introduce a separate migration class (CREATE TRIGGER isn't a CREATE TABLE) and the conductor's idempotency check pattern doesn't yet cover triggers.

**Migration file:** `20260522_005_payroll_create_rate_history.json` — single `create_table` operation with the composite index.

##### Table 8: `payrollCoaMappings` (NEW — central DB)

Table ships in Phase 0 (analysis §7 placed it there); UI/CLI seam is deferred to Phase 1c per PRD Won't-Have deviation note. Table exists so Phase 1c's QBO JE generator has a schema to consume immediately.

```sql
CREATE TABLE `payrollCoaMappings` (
  `id`                INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `payrollTenantId`   INT UNSIGNED NOT NULL,
  `positionId`        INT UNSIGNED NULL,                                            -- null = tenant-default
  `mappingType`       ENUM('wage_expense','payroll_tax_expense','cash','liability_clearing') NOT NULL,
  `qboAccountId`      VARCHAR(64) NOT NULL,
  `qboAccountName`    VARCHAR(255) NULL,                                            -- cached display label
  `setByUserId`       INT UNSIGNED NOT NULL,
  `setAt`             TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_position_type` (`payrollTenantId`, `positionId`, `mappingType`),
  CONSTRAINT `fk_coa_tenant` FOREIGN KEY (`payrollTenantId`) REFERENCES `payrollTenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Migration file:** `20260522_006_payroll_create_coa_mappings.json`.

##### Table 9-11: `ptoAccrualPolicies`, `ptoAccrualBalances`, `ptoRequests` (NEW — central DB)

Tables ship in Phase 0; accrual engine + request UI deferred to Phase 1c per PRD Won't-Have. Schema follows analysis §4.1.

```sql
CREATE TABLE `ptoAccrualPolicies` (
  `id`                INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `payrollTenantId`   INT UNSIGNED NOT NULL,
  `name`              VARCHAR(100) NOT NULL,
  `accrualType`       ENUM('hours_per_hour_worked','lump_sum_annual','lump_sum_anniversary') NOT NULL,
  `accrualRate`       DECIMAL(8,6) NOT NULL,                                        -- hours-accrued-per-hour-worked OR lump-sum hours
  `maxBalanceHours`   DECIMAL(8,2) NULL,
  `carryoverHours`    DECIMAL(8,2) NULL,
  `appliesTo`         ENUM('all','hourly_only','salaried_only','specific_positions') NOT NULL DEFAULT 'all',
  `effectiveFrom`     DATE NOT NULL,
  `isActive`          TINYINT(1) NOT NULL DEFAULT 1,
  `createdAt`         TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_tenant_active` (`payrollTenantId`, `isActive`),
  CONSTRAINT `fk_pto_policy_tenant` FOREIGN KEY (`payrollTenantId`) REFERENCES `payrollTenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `ptoAccrualBalances` (
  `id`                INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `userId`            INT UNSIGNED NOT NULL,
  `payrollTenantId`   INT UNSIGNED NOT NULL,
  `policyId`          INT UNSIGNED NOT NULL,
  `balanceHours`      DECIMAL(8,2) NOT NULL DEFAULT 0,
  `lastAccruedAt`     DATETIME NULL,
  `lastUsedAt`        DATETIME NULL,
  `updatedAt`         TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_user_tenant_policy` (`userId`, `payrollTenantId`, `policyId`),
  CONSTRAINT `fk_pto_balance_policy` FOREIGN KEY (`policyId`) REFERENCES `ptoAccrualPolicies`(`id`) ON DELETE RESTRICT,
  CONSTRAINT `fk_pto_balance_tenant` FOREIGN KEY (`payrollTenantId`) REFERENCES `payrollTenants`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `ptoRequests` (
  `id`                  INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `userId`              INT UNSIGNED NOT NULL,
  `payrollTenantId`     INT UNSIGNED NOT NULL,
  `startDate`           DATE NOT NULL,
  `endDate`             DATE NOT NULL,
  `hours`               DECIMAL(8,2) NOT NULL,
  `requestType`         ENUM('pto','sick','personal') NOT NULL DEFAULT 'pto',
  `status`              ENUM('pending','approved','denied','cancelled') NOT NULL DEFAULT 'pending',
  `requestedAt`         TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `approvedByUserId`    INT UNSIGNED NULL,
  `approvedAt`          DATETIME NULL,
  `note`                TEXT NULL,
  `payrollRunLineId`    INT UNSIGNED NULL,                                          -- populated when paid out via a run line
  PRIMARY KEY (`id`),
  KEY `idx_user_status` (`userId`, `status`),
  KEY `idx_tenant_dates` (`payrollTenantId`, `startDate`, `endDate`),
  CONSTRAINT `fk_pto_request_run_line` FOREIGN KEY (`payrollRunLineId`) REFERENCES `payrollRunLines`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Migration file:** `20260522_007_payroll_create_pto_tables.json` — three `create_table` operations in dependency order.

##### Table 12: `userPayrollProfiles` (NEW — kiosk_users DB)

Links a BK `users.id` to an Everee worker per tenant. A single BK user may have multiple `userPayrollProfiles` rows when employed under multiple EINs (PRD person-centric model, analysis decision 8 + 24).

```sql
CREATE TABLE `userPayrollProfiles` (
  `id`                       INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `userId`                   INT UNSIGNED NOT NULL,                                 -- FK to kiosk_users.users (no DB-level FK; cross-DB enforcement at app layer)
  `payrollTenantId`          INT UNSIGNED NOT NULL,                                 -- references kiosk_buykiosk.payrollTenants (cross-DB)
  `evereeWorkerId`           VARCHAR(64) NULL,                                      -- populated post-onboarding-kickoff webhook
  `tinVerificationStatus`    ENUM('not_started','pending','verified','failed') NOT NULL DEFAULT 'not_started',
  `onboardingStatus`         ENUM('not_started','in_progress','completed','locked') NOT NULL DEFAULT 'not_started',
  `lifecycleStatus`          ENUM('active','terminated','rehired') NOT NULL DEFAULT 'active',
  `realTimePayEnrolled`      TINYINT(1) NOT NULL DEFAULT 0,                         -- pay-card / instant-pay opt-in (Phase 1c)
  `lastSyncedAt`             DATETIME NULL,                                        -- last successful read-from-Everee timestamp
  `createdAt`                TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updatedAt`                TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_user_tenant` (`userId`, `payrollTenantId`),                     -- enforces 1 profile per (user, tenant); cross-tenant duplication is allowed
  KEY `idx_everee_worker` (`evereeWorkerId`),                                      -- supports webhook handler "find user by Everee worker ID" lookups
  KEY `idx_status` (`onboardingStatus`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Migration file:** `20260522_008_payroll_create_user_profiles.json` — lands in `kiosk_users` DB.

##### Extension 1: `kiosk_users.users` (MODIFY)

Add the PII columns. Each column is labeled with its source-of-truth category in the migration JSON description (CON-18, PRD F3 PII source-of-truth model). **`annualSalaryCents` is explicitly NOT added** (CON-19).

```sql
ALTER TABLE `users`
  ADD COLUMN `legalFirstName` VARCHAR(100) NULL AFTER `lastName`,        -- ONBOARDING-KICKOFF prefill: BK collects once, Everee canonical thereafter
  ADD COLUMN `legalLastName`  VARCHAR(100) NULL AFTER `legalFirstName`,  -- ONBOARDING-KICKOFF prefill
  ADD COLUMN `dob`            DATE NULL AFTER `legalLastName`,           -- ONBOARDING-KICKOFF prefill
  ADD COLUMN `addressLine1`   VARCHAR(255) NULL AFTER `dob`,             -- ONBOARDING-KICKOFF prefill
  ADD COLUMN `addressLine2`   VARCHAR(255) NULL AFTER `addressLine1`,    -- ONBOARDING-KICKOFF prefill
  ADD COLUMN `city`           VARCHAR(100) NULL AFTER `addressLine2`,    -- ONBOARDING-KICKOFF prefill
  ADD COLUMN `state`          CHAR(2)      NULL AFTER `city`,            -- ONBOARDING-KICKOFF prefill
  ADD COLUMN `zip`            VARCHAR(10)  NULL AFTER `state`,           -- ONBOARDING-KICKOFF prefill
  ADD COLUMN `phoneE164`      VARCHAR(20)  NULL AFTER `zip`,             -- BK CANONICAL: work-contact phone in E.164 format
  ADD KEY `idx_dob` (`dob`);
```

**Migration files:**
- `20260522_009_users_legal_pii_columns.json` — the ALTER above, with PII category labels in the migration description.
- `20260522_015_display_name_migration_helper.json` — separate idempotent `update` operations that best-effort-split existing `display_name` values into `legalFirstName` / `legalLastName` where both are still NULL. Documented as a "best effort split"; the legal-name-confirmation seam (Phase 1b) corrects mistakes during onboarding.

##### Extension 2: `kiosk_buykiosk.stores` (MODIFY)

Nullable FK to `payrollTenants`. Multiple stores may share one tenant (CON-17, PRD F6).

```sql
ALTER TABLE `stores`
  ADD COLUMN `payrollTenantId` INT UNSIGNED NULL AFTER `schedulingProvider`,
  ADD KEY `idx_payroll_tenant` (`payrollTenantId`),
  ADD CONSTRAINT `fk_stores_payroll_tenant` FOREIGN KEY (`payrollTenantId`) REFERENCES `payrollTenants`(`id`) ON DELETE SET NULL;
```

**Migration file:** `20260522_010_stores_payroll_tenant_fk.json`.

**Reassignment rule:** Re-pointing a store from tenant A to tenant B is a separate operation that MUST be audit-logged via `PayrollAuditService`. The migration documents this rule; the service-level enforcement lives in `EvereeProvisioningService::reassignStore(typeNum, newTenantId)` (Phase 1a-optional / Phase 1b-required).

##### Extension 3: `kiosk_users.userStoreAssignments` (MODIFY)

Add `employmentClassification` enum, nullable. Set during Phase 1b onboarding.

```sql
ALTER TABLE `userStoreAssignments`
  ADD COLUMN `employmentClassification` ENUM('w2_hourly','w2_salaried') NULL AFTER `role`;
```

**Migration file:** `20260522_011_user_store_assignments_classification.json`.

##### Extension 4: `kiosk_buykiosk.positions` (MODIFY)

Workers' comp class code + per-position QBO wage account override (analysis §4.1).

```sql
ALTER TABLE `positions`
  ADD COLUMN `workersCompClassCode` VARCHAR(20) NULL,
  ADD COLUMN `qboWageAccountId`     VARCHAR(64) NULL;                              -- overrides tenant-default in payrollCoaMappings when set
```

**Migration file:** `20260522_012_positions_payroll_columns.json`.

##### Extension 5: per-store `scheduleTimePunches` (MODIFY)

Tips columns (placeholder for never-shipping tips feature — adding now avoids future migration cost per analysis §4.1 and PRD Won't-Have rationale) + submission-lock signal. **This is the only migration that runs on store DBs** — `database: "{{store}}"` in the JSON dispatches across every store DB.

```sql
ALTER TABLE `scheduleTimePunches`
  ADD COLUMN `cashTipsCents`        INT NULL,                                       -- placeholder per CON-19 / PRD Won't-Have (tips never ship in MVP)
  ADD COLUMN `creditTipsCents`      INT NULL,
  ADD COLUMN `submittedToEvereeAt`  DATETIME NULL;                                  -- lock signal for punch-edit policy (analysis §6 hard rule)
```

**Migration file:** `20260522_013_schedule_time_punches_tips.json` with `database: "{{store}}"`.

**Punch-type schema verification (PRD F3 AC §14.2):** This is an OBSERVABILITY check, not a gate. It runs ALONGSIDE the tips-column migration on the same store DBs. The conductor records whether `scheduleTimePunches.punchType` is `ENUM` (expected) or `VARCHAR` (legacy stragglers). The check ships as a separate JSON file (next entry) whose `check_query` consults `INFORMATION_SCHEMA.COLUMNS` and whose `sql` is a no-op INSERT into a recording table. This satisfies the §14.2 PRD F3 AC ("finding logged in the migration JSON's description or an adjacent note"). Renamed from "pre-flight" to "schema-verification" to avoid the implication that it must run before the tips-column add — neither operation depends on the other.

##### Schema-verification migration: `20260522_014_punch_type_enum_verification_note.json`

Inserts one row per checked store into a `migration_log`-adjacent recording (or simply uses the migration's own `description` field as the recording — implementation chosen at PR time per the conductor's preferred convention). Operation is purely advisory; downstream Payroll code does not depend on `punchType` being any particular type, so this is observability not enforcement.

##### Permission keys migration: `20260522_016_payroll_permission_keys.json`

Single migration JSON, 12 `insert` operations into `uf_authorize_group` (DB: `kiosk_users`). Mapping from PRD Feature 10 default role mapping to UF group IDs (per audit findings — ICO-5):

| Permission key | Owner (g9) | Manager (g7) | Shift Lead (g8) | Employee (g1) |
|---|---|---|---|---|
| `manage_payroll` | ✓ | — | — | — |
| `submit_pay_run` | ✓ | ✓ | — | — |
| `approve_pay_run` | ✓ | — | — | — |
| `approve_pay_run_above_threshold` | ✓ | — | — | — |
| `view_pay_run` | ✓ | ✓ | ✓ | — |
| `set_pay_rate` | ✓ | — | — | — |
| `view_own_earnings` | ✓ | ✓ | ✓ | ✓ |
| `kickoff_employee_onboarding` | ✓ | ✓ | — | — |
| `approve_pto_request` | ✓ | ✓ | — | — |
| `request_pto` | ✓ | ✓ | ✓ | ✓ |
| `terminate_employee` | ✓ | ✓ | — | — |
| `create_punch_adjustment` | ✓ | ✓ | — | — |

Each migration row INSERTs `(group_id, hook, conditions)` triples; `conditions` is the literal string `'always()'` per the scheduling-permissions template (ICO-5 reference). The cross-walk between BK role NAMES (Owner / Manager / Shift Lead / Employee) and UF group_ids (9 / 7 / 8 / 1) is preserved in `docs/patterns/payroll-permission-key-cross-walk.md` (Phase 0 deliverable) so future readers don't need to chase it through migration history.

**Site Admin (g2) and Super Admin (g9-as-Super):** Both UF group IDs that exist for BK-internal users (not store roles). Site Admin gets the same permissions as Owner; Super Admin gets all 12. The migration JSON enumerates both (so each permission row inserts up to 4 rows: target group + role-equivalent + 2 admin groups).

##### Migration ordering

```
001 payrollTenants            (no FK deps)
002 payrollRuns + Lines + Snapshots  (FK → payrollTenants)
003 payrollWebhookEvents      (no FK deps; nullable FK to payrollTenants resolved at app layer)
004 payrollAuditLog           (no FK deps; FK to tenant denormalized for performance)
005 payRateHistory            (FK → payrollTenants)
006 payrollCoaMappings        (FK → payrollTenants)
007 ptoAccrualPolicies + Balances + Requests   (FK → payrollTenants, payrollRunLines)
008 userPayrollProfiles       (cross-DB; app-layer FK to users and payrollTenants)
009 users PII columns         (kiosk_users)
010 stores.payrollTenantId    (FK → payrollTenants; depends on 001)
011 userStoreAssignments.employmentClassification  (kiosk_users; no FK deps)
012 positions.workersCompClassCode + qboWageAccountId  (no FK deps)
013 scheduleTimePunches tips + submittedToEvereeAt   (per-store; {{store}} routing)
014 punch_type enum schema-verification note   (advisory; ordering is non-critical — runs alongside 013, same per-store DBs)
015 display_name → legalFirstName/Last split   (depends on 009)
016 payroll permission keys (uf_authorize_group)   (no FK deps)
```

Conductor applies migrations in filename-lexical order; the numbering above guarantees that ordering matches dependency direction. Within a single JSON file, the operations array is executed top-to-bottom (a single file may bundle child-of-parent creates as long as the parent comes first in the array).

##### Storage-impact summary

| Concern | Where addressed |
|---|---|
| Money-as-INT cents | Every `*Cents` column above |
| Hours-as-DECIMAL | `regularHours`/`overtimeHours`/`doubletimeHours`/`ptoHours` (DECIMAL 8,4); balance / request hours (DECIMAL 8,2) |
| IRS 4yr retention | No DELETE path on `payRateHistory`, `payrollAuditLog`, `payrollRuns`, `payrollRunLines` (CON-10) |
| Idempotent webhook ingestion | UNIQUE `payrollWebhookEvents.evereeEventId` |
| Append-only rates | Repo has no `update()` / `delete()`; regression test asserts attempts raise; DB-level follow-up documented |
| Per-tenant HMAC secret | `payrollTenants.webhookSecretEncrypted` (column exists regardless of partner answer per CON-15) |
| One tenant per EIN | UNIQUE `payrollTenants.ein` |
| One profile per (user, tenant) | UNIQUE `userPayrollProfiles.(userId, payrollTenantId)` |
| Person-centric (1 user, many tenants) | No UNIQUE on `userPayrollProfiles.userId` alone; cross-tenant duplication is intentional |
| Cross-DB references | App-layer validation only; `kiosk_users.users` and per-store tables not FK-able from central DB |
| Migration idempotency | Every JSON has `check_query` against INFORMATION_SCHEMA; reapply is no-op |

##### Phase 0 non-schema deliverables (PRD F2 + F7 backfill + F13)

Three PRD acceptance criteria are non-schema deliverables that ship alongside the migrations. They are tracked here so an implementer reading Cycle B doesn't miss them.

**Deliverable 1 — Person-centric account model verification (PRD F2)**
- Artifact: `docs/specs/050-everee-payroll-foundations/person-centric-account-verification.md`
- CLI helper: `userfrosting/bin/payroll/verify-cross-merchant-users.php` — runs the SQL `SELECT u.id, COUNT(DISTINCT usa.typeNum) AS distinctStoreCount FROM kiosk_users.users u JOIN kiosk_users.userStoreAssignments usa ON u.id = usa.userId WHERE usa.isActive = 1 GROUP BY u.id HAVING distinctStoreCount >= 2` and dumps results to STDOUT + writes a structured summary into the verification report. **`COUNT(DISTINCT usa.typeNum)` is required** — counting rows alone can be inflated by historical reassignments at a single store (per PRD F2 AC).
- Required contents of the report (each AC is the file's checklist):
  - SQL query result showing at least one `users.id` with ≥2 `userStoreAssignments` rows across distinct `typeNum`s (real OR synthetic via a dev-store seeding script committed alongside).
  - Step-by-step dev-store walkthrough of the manage-employees flow (spec 014) hiring an already-existing `users.id` at a second store, with screen-recording link OR transcript of SQL row counts before/after.
  - Documented gap-remediation plan if duplicate-user creation is observed (escalation path: either expand Phase 0 scope OR open a separate spec before Phase 1a starts; the report explicitly records which path was taken).
  - Sign-off by at least one engineer OTHER than the report author — captured as a `Signed-off-by:` line in the report OR a PR approval comment.
- HARD GATE: PRD F2 must be merged before any Phase 1a service that maps `users.id → evereeWorkerId` is implemented (CON-22).

**Deliverable 2 — One-time rate backfill tool (PRD F7 AC final bullet)**
- Artifact: `userfrosting/bin/payroll/backfill-rates.php` — CLI script
- Invocation: `php userfrosting/bin/payroll/backfill-rates.php --csv=<path> --tenant-id=<id> --actor-user-id=<id>`
- CSV columns: `userId, positionId, rateType, rateCents, effectiveFrom, note`
- Implementation: thin wrapper that constructs `PayRateService` via the existing DI container and calls `setRate(...)` per CSV row. NO new business logic — defers entirely to the service. Validates the CSV shape and gracefully reports per-row errors without aborting subsequent rows.
- Idempotency: re-running with the same CSV produces additional `payRateHistory` rows per the append-only model (this is correct — each row is a new history entry; the tool does NOT attempt to "skip if same"). Operators are warned in the CLI help text.
- Acceptable for Phase 0/1a per PRD F7 AC ("CLI or scripted is acceptable for this phase"). No merchant-facing UI ships.

**Deliverable 3 — Phase 0 pre-flight readiness checklist (PRD F13)**
- Artifact: `docs/specs/050-everee-payroll-foundations/phase-0-preflight-checklist.md`
- Required contents per PRD F13 ACs:
  - Pilot-store rate-data audit: for each of the 5 pilot-candidate stores, current pay rates per employee per position captured (CSV or spreadsheet link) with rate-source documentation (manual vs. exported from current vendor). Owned by Customer Success; audit file lives in a CS-accessible location and is referenced from the checklist.
  - Partner-manager kickoff email sent listing the §13 question stack from the analysis doc; reply tracked in a shared inbox/thread; weekly status update until sandbox credentials AND HMAC algorithm are confirmed at minimum.
  - Pilot candidate list (5 stores) finalized and stored alongside the rate audit; criteria match analysis §11 (existing happy customer, single-state preferred, engaged owner).
  - Owner of each checklist item identified by name (e.g., "Ryan V. — partner-manager kickoff email"); generic "engineering" or "CS" attributions are not sufficient.
  - Engineering review captured: confirms no Phase 1a Must Have requires an unticked pre-flight item before it can begin. Recorded as a sign-off line by an engineer other than the checklist author.
- HARD GATE: Phase 1a does not start until each checklist item is either ticked OR explicitly waived with a documented reason.

#### Internal API Changes

Phase 0/1a ships exactly five HTTP endpoints. All live in `userfrosting/routes/api/payroll.php` as a single Slim 2 route group. Engineer/CS endpoints are UF-permission-gated; the webhook endpoint is public-but-HMAC-verified.

```yaml
endpoints:

  # 1. Webhook receiver (PUBLIC — HMAC-gated, no UF session)
  - id: webhook_receiver
    method: POST
    path: /api/payroll/webhook/everee
    auth: HMAC signature header (per-tenant secret from payrollTenants OR global secret per EVEREE_WEBHOOK_SIGNING_MODE config; timestamp tolerance ±N minutes, default 5)
    feature: PRD F8
    request:
      headers:
        x-everee-signature: REQUIRED (HMAC of body+timestamp)
        x-everee-timestamp: REQUIRED (Unix epoch seconds OR ISO-8601)
        x-everee-event-id: REQUIRED (UUID; UNIQUE-enforced at DB)
        x-everee-company-id: OPTIONAL (used for tenant resolution + per-tenant secret lookup)
      body: JSON event payload (Everee-posted bytes used INTACT for HMAC verification, then REDACTED before persistence — see "PII payload redaction" below and ADR-11)
    response_success:
      status: 200
      body: '{"received": true, "duplicate": <bool>}'
    response_error:
      401: 'HMAC verification failed OR timestamp outside tolerance — body: {"error": "invalid_signature"}; logged at high severity; does NOT increment dedupe slot'
      400: 'Malformed payload (missing required header, unparseable JSON) — body: {"error": "bad_request"}'
      500: 'Reserved — caught \\Throwable at the route boundary; never returned for HMAC/dedupe outcomes (those are 401/200)'

  # 2. Provisioning (Owner / Site Admin / Super Admin only)
  - id: tenant_provision
    method: POST
    path: /api/payroll/admin/:typeNum/tenants
    auth: UF session + checkAccess('manage_payroll') + checkStoreGroup(typeNum) + SchedulingProviderGate
    feature: PRD F6
    request:
      headers: standard UF session cookie
      body_json:
        legalName: string (1..255) REQUIRED
        ein: string (XX-XXXXXXX format) REQUIRED
        legalAddressLine1: string (1..255) REQUIRED
        legalAddressLine2: string (0..255) OPTIONAL
        legalCity: string (1..100) REQUIRED
        legalState: string (CHAR(2)) REQUIRED
        legalZip: string (5..10) REQUIRED
        entityType: enum 'llc'|'c_corp'|'s_corp'|'sole_prop'|'partnership' REQUIRED
        payFrequency: enum 'weekly'|'biweekly'|'semi_monthly'|'monthly' REQUIRED
        payPeriodEndsOnDayOfWeek: int 0..6 OPTIONAL (REQUIRED iff payFrequency != monthly)
        payCutoffHoursBefore: int OPTIONAL
        attachToStoreTypeNums: [string] OPTIONAL  # CSV of typeNums to set stores.payrollTenantId on
    response_success:
      status: 201 (new) or 200 (idempotent return of existing tenant matching EIN)
      body:
        payrollTenantId: int
        evereeCompanyId: string
        isActive: bool
        idempotentReturn: bool  # true if existing tenant was returned per F6 AC
    response_error:
      400: 'Validation error (bad EIN format, missing required field, payCutoff out of bounds) — body details enumerated by EvereeValidationException'
      403: 'UF permission denial OR SchedulingProviderGate rejection (store schedulingProvider != buyerkiosk)'
      502: 'Everee API call failed in a non-retryable way (e.g., partner-side EIN already attached elsewhere) — body: {"error": "everee_unavailable", "details": ...}'

  # 3. Set pay rate (Owner only; pure append-only INSERT)
  - id: rate_set
    method: POST
    path: /api/payroll/admin/:typeNum/rates
    auth: UF session + checkAccess('set_pay_rate') + checkStoreGroup(typeNum) + SchedulingProviderGate
    feature: PRD F7
    request:
      body_json:
        userId: int REQUIRED  # must reference kiosk_users.users.id with active userStoreAssignments for typeNum
        payrollTenantId: int REQUIRED  # must match a tenant attached to a store the userId is assigned to
        positionId: int REQUIRED  # active position
        rateType: enum 'hourly'|'salary_annual' REQUIRED
        rateCents: int REQUIRED  # > 0
        effectiveFrom: date (YYYY-MM-DD) REQUIRED
        note: string (1..N) REQUIRED  # PRD F7 Rule 6 — empty notes rejected
    response_success:
      status: 201
      body:
        rateHistoryId: int
        backdated: bool  # true iff effectiveFrom < today (audit log severity bumped)
    response_error:
      400: 'Validation failure (empty note, rateCents <= 0, unknown userId/tenant/position, rateType=salary_annual with rateCents not divisible by anything we care about — just must be > 0)'
      403: 'Permission denial OR SchedulingProviderGate rejection'
      409: 'NOT USED — concurrent setRate calls all succeed by design (PRD F7 Rule 3, Edge Case 1)'

  # 4. Retire pay rate (Owner only; INSERT tombstone)
  - id: rate_retire
    method: POST
    path: /api/payroll/admin/:typeNum/rates/retire
    auth: UF session + checkAccess('set_pay_rate') + checkStoreGroup(typeNum) + SchedulingProviderGate
    feature: PRD F7 (retireRate path)
    request:
      body_json:
        userId: int REQUIRED
        payrollTenantId: int REQUIRED
        positionId: int REQUIRED
        effectiveUntil: date REQUIRED  # the tombstone's effectiveUntil; column documents the explicit end date
        note: string REQUIRED
    response_success:
      status: 201
      body:
        rateHistoryId: int  # the new tombstone row
    response_error: same shape as rate_set (400 / 403)

  # 5. Read rate as-of date (any user with view_pay_run; ShiftLead+)
  - id: rate_get_as_of
    method: GET
    path: /api/payroll/admin/:typeNum/rates/as-of
    auth: UF session + checkAccess('view_pay_run') + checkStoreGroup(typeNum)
    feature: PRD F7 (getRate read path)
    request:
      query:
        userId: int REQUIRED
        payrollTenantId: int REQUIRED
        positionId: int REQUIRED
        asOf: date (YYYY-MM-DD) REQUIRED
    response_success:
      status: 200
      body:
        userId: int
        positionId: int
        rateType: enum or null  # null if no rate exists or rate was retired before asOf
        rateCents: int or null
        effectiveFrom: date or null
        rateHistoryId: int or null  # which row served the read (debug aid)
    response_error:
      400: 'Bad query param'
      403: 'Permission denial'
      404: 'NOT USED — missing rate returns 200 with null fields per PRD F7 spec'

# Endpoints explicitly OUT OF SCOPE for Phase 0/1a but namespace-reserved:
out_of_scope_reserved:
  - 'POST /api/payroll/admin/:typeNum/runs        — Phase 1b (pay run lifecycle)'
  - 'GET  /api/payroll/admin/:typeNum/runs/:id    — Phase 1b'
  - 'POST /api/payroll/admin/:typeNum/workers     — Phase 1b (worker sync trigger)'
  - 'POST /api/payroll/admin/:typeNum/coa         — Phase 1c (COA mapping config)'
  - 'GET  /api/payroll/me/earnings                — Phase 1b/1c (Team-app earnings tab)'

# Reference documentation
api_doc: docs/interfaces/everee-api.md  # NEW (PRD F5 deliverable; specifies outbound contract — endpoints BK CALLS, not the ones above)
```

**Response shape conventions (project-wide):**
- All success responses are JSON objects (never bare strings, never arrays at the root).
- Errors are `{"error": "<machine_code>", "message": "<human_message>", "details": <optional object>}`.
- The webhook receiver intentionally does NOT use the standard error shape — it MUST be parseable by the Everee retry machinery, so failures are bare `{"error": "invalid_signature"}` per Everee's reference clients.
- Slim 2's `Stop` exception is allowed to propagate through every route handler (CON-7); all catch blocks use `\Throwable` (CON-7 + memory `PHP 8.5 Throwable Gotcha`).

#### Application Data Models

PHP classes that materialize the schema. Models are thin value-object-style POPOs (no ORM); behavior lives on the service classes that operate on them. Models implement `JsonSerializable` where they round-trip through controller responses; they implement `__debugInfo()` / `jsonSerialize()` scrubbing where they carry encrypted data (CON-13, PRD F4 AC).

```pseudocode

# ============== MODELS (Value Objects) ==============

ENTITY: PayrollTenant (NEW; BuyerKiosk\Payroll\Models\PayrollTenant)
  FIELDS:
    id: int
    evereeCompanyId: ?string
    evereeTenantId: ?string
    evereeApiTokenEncrypted: ?string    # ciphertext only; NEVER serialized via jsonSerialize / __debugInfo
    webhookSecretEncrypted: ?string     # same scrubbing rule as token
    legalName: string
    ein: string
    legalAddressLine1: string
    legalAddressLine2: ?string
    legalCity: string
    legalState: string                  # CHAR(2)
    legalZip: string
    entityType: enum
    payFrequency: enum
    payPeriodEndsOnDayOfWeek: ?int
    payCutoffHoursBefore: ?int
    twoPersonApprovalThresholdCents: ?int
    brandingJson: ?array
    provisionedAt: ?DateTime
    provisionedByUserId: ?int
    isActive: bool
    createdAt: DateTime
    updatedAt: DateTime
  BEHAVIORS:
    fromRow(array $row): self                          # factory from PDO row
    toAuditJson(): array                               # returns sanitized snapshot (no encrypted fields) for payrollAuditLog before/after JSON
    jsonSerialize(): array                             # response shape — REMOVES *Encrypted fields
    __debugInfo(): array                               # var_dump safety — REMOVES *Encrypted fields

ENTITY: PayRateEntry (NEW; BuyerKiosk\Payroll\Models\PayRateEntry — IMMUTABLE)
  FIELDS:
    id: int
    userId: int
    payrollTenantId: int
    positionId: int
    rateType: enum 'hourly' | 'salary_annual'
    rateCents: int
    effectiveFrom: DateTimeImmutable                   # DATE in DB → store-local midnight as immutable
    effectiveUntil: ?DateTimeImmutable                 # set only on tombstones
    isRetirementTombstone: bool
    setByUserId: int
    note: string
    createdAt: DateTimeImmutable
  BEHAVIORS:
    fromRow(array $row): self
    isActiveAt(DateTimeImmutable $asOf): bool          # true iff effectiveFrom <= asOf AND (effectiveUntil IS NULL OR asOf < effectiveUntil)
                                                       # IMPORTANT: this is a row-local check answering "is THIS row covering asOf?" — it does NOT replace getRate.
                                                       # The boundary convention MATCHES getRate: at asOf == effectiveUntil the row is INACTIVE (retired).
                                                       # PRD F7 says getRate returns null when "asOfDate >= effectiveUntil" — implemented by `NOT(asOf < effectiveUntil)`,
                                                       # i.e., `asOf >= effectiveUntil` returns false from isActiveAt. The two are equivalent.
    isTombstone(): bool                                # convenience accessor
    # NO setters — class is fully immutable; supersession is a new INSERT, not a mutation

ENTITY: PayrollWebhookEvent (NEW; BuyerKiosk\Payroll\Models\PayrollWebhookEvent)
  FIELDS:
    id: int
    evereeEventId: string                              # UNIQUE; dedupe key
    evereeEventType: string
    evereeCompanyId: ?string
    payrollTenantId: ?int                              # resolved at ingest time
    payload: array                                     # decoded JSON
    signatureHeader: ?string
    timestampHeader: ?string
    hmacValid: bool
    receivedAt: DateTime
    processedAt: ?DateTime
    processingError: ?string
  BEHAVIORS:
    fromRow(array $row): self
    markProcessed(?string $error = null): void         # repo updates processedAt + processingError; not a class mutation in the immutable sense
    needsProcessing(): bool                            # processedAt IS NULL AND hmacValid

ENTITY: PayrollAuditEntry (NEW; BuyerKiosk\Payroll\Models\PayrollAuditEntry; mirrors ScheduleAuditEntry)
  FIELDS:
    auditId: int
    action: string                                     # e.g., 'payroll.tenant.provisioned'
    entityType: string                                 # 'tenant' | 'rate' | 'webhook_event' | 'run' | ...
    entityId: ?int
    payrollTenantId: ?int
    userId: ?int
    actorUserId: ?int
    actorType: enum 'employee' | 'manager' | 'system'
    previousValue: ?array
    newValue: ?array
    metadata: ?array
    ipAddress: ?string
    userAgent: ?string
    createdAt: DateTime
  BEHAVIORS:
    fromRow(array $row): self
    jsonSerialize(): array
    # Constants (match ScheduleAuditEntry style)
    const ACTOR_EMPLOYEE = 'employee'
    const ACTOR_MANAGER = 'manager'
    const ACTOR_SYSTEM = 'system'
    const ACTION_TENANT_PROVISIONED = 'payroll.tenant.provisioned'
    const ACTION_RATE_SET = 'payroll.rate.set'
    const ACTION_RATE_RETIRED = 'payroll.rate.retired'
    const ACTION_WEBHOOK_RECEIVED = 'payroll.webhook.received'
    const ACTION_WEBHOOK_PROCESSED = 'payroll.webhook.processed'
    const ACTION_WEBHOOK_DEDUPED = 'payroll.webhook.deduplicated'
    const ACTION_GATE_REJECTED = 'payroll.scheduling_provider_gate.rejected'
    const ACTION_PERMISSION_DENIED = 'payroll.permission.denied'
    const ACTION_API_CALL = 'payroll.api.everee_call'

ENTITY: UserPayrollProfile (NEW; BuyerKiosk\Payroll\Models\UserPayrollProfile)
  FIELDS:
    id, userId, payrollTenantId, evereeWorkerId,
    tinVerificationStatus, onboardingStatus, lifecycleStatus,
    realTimePayEnrolled, lastSyncedAt, createdAt, updatedAt
  BEHAVIORS:
    fromRow(array $row): self

# ============== SERVICE CLASSES (method surfaces) ==============

SERVICE: EvereeTokenStorage (NEW; BuyerKiosk\Payroll\Services\EvereeTokenStorage)
  CONSTRUCTOR:
    __construct(?Encryption $encryption)              # null encryption fails-closed on writes; lenient decrypt for migration window
  METHODS:
    encryptTokenForStorage(string $plaintextToken): string                              # throws EvereeEncryptionRequiredException if encryption is null
    decryptTokenForUse(PayrollTenant $tenant): string                                   # throws TokenDecryptionException on failure; ONLY caller authorized to invoke is EvereeApiClient
    encryptWebhookSecretForStorage(string $plaintextSecret): string
    decryptWebhookSecretForUse(PayrollTenant $tenant): string
    # private $encryption; no static state; no token caching across calls
    # METHOD IS NOT EXPOSED ON THE PayrollTenant MODEL — service-side only (CON-13)

SERVICE: EvereeApiClient (NEW; BuyerKiosk\Payroll\Services\EvereeApiClient)
  CONSTRUCTOR:
    __construct(EvereeTokenStorage $tokenStorage, string $baseUrl, LoggerInterface $logger, PayrollAuditService $audit, ?HttpClient $http = null)
  METHODS:
    listWorkers(PayrollTenant $tenant): array<EvereeWorkerDTO>                          # GET /workers?page=&size= (T15: paginated items[] envelope, drains all pages)
    getWorker(PayrollTenant $tenant, string $evereeWorkerId): EvereeWorkerDTO           # GET /workers/{id}
    createWorker(PayrollTenant $tenant, array $payload, ?string $idempotencyKey = null): EvereeWorkerDTO   # POST /embedded/workers/employee (T15: bare POST /workers is 405)
    createCompanyInstance(array $payload, ?string $idempotencyKey = null): array        # POST /companies — used by EvereeProvisioningService
                                                                                       # AUTH SHAPE FOR THIS METHOD IS PARTNER-LEVEL, NOT TENANT-LEVEL:
                                                                                       # The Company Instance does not yet exist, so there is no tenant token to encrypt-and-load.
                                                                                       # Auth header = `authorization: basic <base64(EVEREE_PARTNER_API_TOKEN)>` (partner-issued).
                                                                                       # x-everee-tenant-id header is OMITTED for this single endpoint.
                                                                                       # IF partner only supports portal-based provisioning (no API), the method instead
                                                                                       # accepts a manual-portal-payload seam — see EvereeProvisioningService.provisionManually().
    # Private:
    _request(string $method, string $path, PayrollTenant $tenant, ?array $body, ?string $idempotencyKey): array
      # 1. Build headers: authorization: basic <base64(decryptTokenForUse($tenant))>, x-everee-tenant-id, x-request-id (uuidv4)
      # 2. Apply Idempotency-Key header when provided (subject to CON-20 / partner confirmation)
      # 3. Send via HttpClient
      # 4. Handle status:
      #    200/201/204 -> parse JSON, return array
      #    401/403 -> throw EvereeAuthException
      #    4xx other -> throw EvereeValidationException(payload['errors'] if present)
      #    429 -> honor Retry-After (parse seconds or HTTP-date); retry ONCE; if still 429 throw EvereeRateLimitException
      #    5xx / network / timeout -> exponential backoff w/ cap (default: 3 attempts, 250ms/750ms/2.25s); if exhausted, throw EvereeApiException OR EvereeUncertainStateException (for POST timeouts w/o idempotency key)
      # 5. Audit every call: payroll.api.everee_call (endpoint, tenantId, httpStatus, latencyMs, retryCount)

SERVICE: EvereeProvisioningService (NEW; BuyerKiosk\Payroll\Services\EvereeProvisioningService)
  CONSTRUCTOR:
    __construct(PayrollTenantRepository $tenants, EvereeApiClient $api, EvereeTokenStorage $tokens, PayrollAuditService $audit)
  METHODS:
    provisionTenant(array $companyInput, int $actorUserId, array $attachToStoreTypeNums = []): PayrollTenant
      # Uses partner-level auth (EVEREE_PARTNER_API_TOKEN env var) on the initial Company-Instance create call;
      # subsequent calls (listWorkers verification, etc.) use the newly-minted tenant token via standard path.

    provisionManually(array $portalSuppliedIds, int $actorUserId, array $attachToStoreTypeNums = []): PayrollTenant
      # SEAM for when partner only supports portal-based Company Instance creation (no API).
      # CS / engineer creates the Company Instance in Everee's portal, then calls this method with:
      #   portalSuppliedIds = {evereeCompanyId, evereeTenantId, apiToken, legalName, ein, address, entityType, payFrequency}
      # The method encrypts the supplied apiToken, INSERTs the payrollTenants row with isActive=0,
      # then runs the standard verify-reachable + audit + attach flow.
      # Audit entry distinguishes API-provisioned from portal-provisioned via metadata.provisioning_path.
      # 1. Validate EIN format (XX-XXXXXXX)
      # 2. Lookup by EIN — if exists, RETURN existing (idempotent per PRD F6 + Cross-Feature Edge Case)
      # 3. Mint new API token (Everee-side; partner-confirmed flow OR wrapped portal step)
      # 4. encrypt + insert payrollTenants row with isActive=0
      # 5. Audit: payroll.tenant.provisioned (isActive=0)
      # 6. Optional: verify-reachable via listWorkers() in sandbox; on success update isActive=1 (UPDATE on payrollTenants is allowed; it's not the append-only table)
      # 7. For each attach typeNum: SchedulingProviderGate::assertAllowed(typeNum), then UPDATE stores SET payrollTenantId = tenant.id; audit each
    reassignStore(string $typeNum, int $newTenantId, int $actorUserId): void
      # Phase 1a-optional / Phase 1b-required; SDD documents the seam; impl may be deferred
      # MUST audit with before/after snapshot

SERVICE: PayRateService (NEW; BuyerKiosk\Payroll\Services\PayRateService)
  CONSTRUCTOR:
    __construct(PayRateHistoryRepository $repo, PayrollAuditService $audit)
  METHODS:
    setRate(int $userId, int $payrollTenantId, int $positionId, string $rateType, int $rateCents, DateTimeImmutable $effectiveFrom, int $actorUserId, string $note): PayRateEntry
      # 1. Validate: rateCents > 0; rateType in {'hourly','salary_annual'}; note non-empty (PRD F7 Rule 6)
      # 2. Compute backdated = effectiveFrom < today() in store-local TZ
      # 3. INSERT one row (isRetirementTombstone=0, effectiveUntil=NULL)
      # 4. Audit: payroll.rate.set (severity=high iff backdated)
      # 5. Return PayRateEntry
    getRate(int $userId, int $payrollTenantId, int $positionId, DateTimeImmutable $asOf): ?PayRateEntry
      # SELECT * FROM payRateHistory
      # WHERE userId=? AND payrollTenantId=? AND positionId=?
      #   AND effectiveFrom <= :asOf
      # ORDER BY effectiveFrom DESC, id DESC
      # LIMIT 1
      # IF row found AND row.isRetirementTombstone AND row.effectiveUntil <= asOf → return NULL
      # IF row found AND not tombstone AND effectiveUntil IS NOT NULL AND asOf >= effectiveUntil → return NULL  # belt-and-suspenders; should be impossible given step 1
      # ELSE return PayRateEntry::fromRow($row)
    retireRate(int $userId, int $payrollTenantId, int $positionId, DateTimeImmutable $effectiveUntil, int $actorUserId, string $note): PayRateEntry
      # 1. Look up the CURRENT-ACTIVE rate via findGreatestEffectiveFromAtOrBefore(today) so the tombstone can carry the prior row's rateType (required NOT NULL in schema).
      # 2. INSERT a tombstone row:
      #      isRetirementTombstone=1
      #      rateType         = prior_active.rateType  (copied from the row being retired)
      #      rateCents        = 0                      (the value is meaningless on a tombstone, recorded as 0 for the NOT-NULL constraint)
      #      effectiveFrom    = effectiveUntil         (record the explicit end-date semantically as "this row marks retirement starting at effectiveUntil")
      #      effectiveUntil   = effectiveUntil
      # 3. If no prior active row exists for the (user, tenant, position) tuple, raise EvereeValidationException — you cannot retire a rate that never existed.
      # 4. Audit: payroll.rate.retired
    listHistory(int $userId, int $payrollTenantId): array<int positionId, array<PayRateEntry>>
      # RETURNS: a map from positionId to a reverse-chronological array of PayRateEntry rows for that position.
      # Implementation: SELECT * FROM payRateHistory WHERE userId=? AND payrollTenantId=?
      #   ORDER BY positionId, effectiveFrom DESC, id DESC; then group by positionId in PHP before returning.
      # This shape:
      #   (a) HONORS PRD F7's "rates returned in reverse-chronological order" — within each position's array, rows are reverse-chronological.
      #   (b) MAKES the implicit-effectiveUntil derivation SAFE — consumers iterate one position at a time and the "next row's effectiveFrom" relationship is always within-group.
      #   (c) PRESERVES the PRD-mandated method signature (no new parameter); the type-shape is the only change.
      # If a future caller needs a single position's history, it can do `listHistory(...)->[$positionId] ?? []`.
  # NO METHOD: updateRate / deleteRate / mergeRates — repo doesn't expose these (CON-12, ADR-2)

SERVICE: EvereeWebhookHandler (NEW; BuyerKiosk\Payroll\Services\EvereeWebhookHandler)
  CONSTRUCTOR:
    __construct(PayrollTenantRepository $tenants, PayrollWebhookEventRepository $events, EvereeTokenStorage $tokens, JobDispatcher $dispatcher, PayrollAuditService $audit, int $timestampToleranceSec = 300)
  METHODS:
    ingest(string $rawBody, array $headers): IngestResult
      # 1. Required headers present? → bad_request 400
      # 2. Parse timestamp; reject if abs(now - ts) > $timestampToleranceSec → WebhookSignatureException (timestamp_replay) → 401 (CrossFeatureEdgeCase F8 replay)
      # 3. Resolve secret:
      #      IF config('EVEREE_WEBHOOK_SIGNING_MODE') == 'per_tenant':
      #          resolveTenantByCompanyId(headers['x-everee-company-id']) → if no tenant → 401 (unknown tenant)
      #          secret = $tokens->decryptWebhookSecretForUse($tenant)
      #          ALSO check rotation-window prior secret (CrossFeatureEdgeCase F8 — accept signature signed under previous secret during documented rotation)
      #      ELSE:
      #          secret = env('EVEREE_WEBHOOK_GLOBAL_SECRET')
      # 4. Compute HMAC over "$timestamp.$rawBody"; constant-time compare with header signature → mismatch → WebhookSignatureException → 401
      # 5. INSERT INTO payrollWebhookEvents — relies on UNIQUE evereeEventId for dedupe:
      #      try INSERT → success → audit payroll.webhook.received → enqueue ProcessEvereeWebhookJob → return {duplicate: false}
      #      catch unique-key-violation → audit payroll.webhook.deduplicated → return {duplicate: true}
      # 6. Return 200 in either branch

SERVICE: PayrollAuditService (NEW; BuyerKiosk\Payroll\Services\PayrollAuditService)
  CONSTRUCTOR:
    __construct(PayrollAuditRepository $repo)
  METHODS:
    logTenantProvisioned(PayrollTenant $tenant, int $actorUserId, array $metadata = []): void
    logRateSet(PayRateEntry $rate, int $actorUserId, bool $backdated, ?array $previousActiveRate = null): void
      # previousActiveRate captured before insert so before/after JSON is faithful
    logRateRetired(PayRateEntry $tombstone, int $actorUserId): void
    logWebhookReceived(PayrollWebhookEvent $event): void
    logWebhookDeduplicated(string $evereeEventId, DateTime $originalReceivedAt, DateTime $duplicateReceivedAt): void
    logWebhookProcessingFailed(PayrollWebhookEvent $event, string $error, int $retryCount): void
    logApiCall(string $endpoint, int $tenantId, int $httpStatus, int $latencyMs, int $retryCount): void   # sampled if needed
    logGateRejection(string $typeNum, string $schedulingProvider, string $attemptedAction, ?int $actorUserId): void
    logPermissionDenied(string $permissionKey, int $actorUserId, string $attemptedAction): void
    # Internal:
    _log(string $action, string $entityType, ?int $entityId, ?int $tenantId, ?int $userId, ?int $actorUserId, string $actorType, ?array $before, ?array $after, array $metadata): void

SERVICE: SchedulingProviderGate (NEW; BuyerKiosk\Payroll\Services\SchedulingProviderGate)
  CONSTRUCTOR:
    __construct(StoreController $storeFactory, PayrollAuditService $audit)
  METHODS:
    assertAllowed(string $typeNum, string $attemptedAction, ?int $actorUserId): void
      # Loads Store by typeNum (cached via StoreController)
      # IF Store::$schedulingProvider !== 'buyerkiosk' → audit payroll.scheduling_provider_gate.rejected → throw SchedulingProviderGateException
      # IMPORTANT: reads schedulingProvider, NOT wiwEnable (per memory `buyerkiosk-wiw-exclusion-source-of-truth`)
    middleware(): callable  # Slim 2 middleware factory; the route group binds it before the AdminController call

# ============== REPOSITORIES (method surfaces) ==============

REPOSITORY: PayRateHistoryRepository (NEW; INTENTIONALLY narrow surface)
  CONSTRUCTOR: __construct(PDO $db)
  METHODS:
    insert(PayRateEntry $entry): int   # returns new id
    findGreatestEffectiveFromAtOrBefore(int $userId, int $tenantId, int $positionId, DateTimeImmutable $asOf): ?PayRateEntry
    listOrdered(int $userId, int $tenantId): array<PayRateEntry>
    # NO update($entry); NO delete($id); NO upsert(...); NO bulkUpdate(...)
    # ADDING ANY OF THE ABOVE METHODS IS A REVIEW BLOCKER (ADR-2)

REPOSITORY: PayrollTenantRepository (NEW)
  insert(PayrollTenant $tenant): int
  update(PayrollTenant $tenant): void          # ALLOWED — payrollTenants is NOT append-only (only payRateHistory is)
  findById(int $id): ?PayrollTenant
  findByEin(string $ein): ?PayrollTenant       # supports idempotent provisioning (PRD F6)
  findByEvereeCompanyId(string $companyId): ?PayrollTenant   # supports webhook tenant resolution

REPOSITORY: PayrollWebhookEventRepository (NEW)
  insert(PayrollWebhookEvent $event): int      # may throw UniqueConstraintViolationException → handler treats as dedupe
  findById(int $id): ?PayrollWebhookEvent
  findByEvereeEventId(string $evereeEventId): ?PayrollWebhookEvent   # used by dedupe path + idempotency-key resolution
  findUnprocessed(int $limit): array<PayrollWebhookEvent>
  markProcessed(int $id): void                 # success path: sets processedAt = NOW(), clears processingError
  markFailed(int $id, string $error): void     # failure path: leaves processedAt = NULL, writes processingError so retries pick it up

REPOSITORY: PayrollAuditRepository (NEW; mirrors ScheduleAuditRepository)
  save(PayrollAuditEntry $entry): int
  findWithFilters(array $filters, int $limit, int $offset): array{entries, total}   # Phase 1b admin UI consumes

REPOSITORY: UserPayrollProfileRepository (NEW)
  insert(UserPayrollProfile $profile): int
  update(UserPayrollProfile $profile): void
  findByUserAndTenant(int $userId, int $tenantId): ?UserPayrollProfile
  findByEvereeWorkerId(string $evereeWorkerId): ?UserPayrollProfile

# ============== TASKENGINE JOB ==============

JOB: ProcessEvereeWebhookJob (NEW; extends BuyerKiosk\TaskEngine\Domain\Job\BaseJob)
  STATIC CONFIG:
    getName(): 'process-everee-webhook'
    getDisplayName(): 'Process Everee Webhook Event'
    getQueue(): 'default'  # may move to dedicated 'payroll-webhooks' queue per Feature 12 audit recommendation
    getScope(): 'global'   # webhooks aren't store-scoped; the job resolves the tenant from event payload
    getTimeout(): 30        # seconds; webhook handlers must be fast or punt to a follow-up job
  PAYLOAD SHAPE: {webhookEventId: int}
  HANDLE METHOD:
    handle(): JobResult
      # 1. Load PayrollWebhookEvent by webhookEventId
      # 2. Switch on evereeEventType:
      #    worker.created / worker.profile-updated / worker.deleted / worker.onboarding-completed / worker.onboarding-locked / worker.tin-verification-status-changed → call UserPayrollProfileRepository
      #    payment.paid / payment.deposit-returned / payment.updated-payment-method / payment-payables.status-changed → no-op in Phase 1a (handler stub logs "deferred-to-phase-1b" — PRD F8 AC)
      #    worker.new-tax-forms-available → no-op (Phase 1c — Team app W-2 download)
      #    unknown event type → log warning, mark processed with note 'unrecognized event type' (do NOT raise)
      # 3. On success: markProcessed(id) — sets processedAt=NOW(), clears processingError.
      #    On caught \Throwable: markFailed(id, $err->getMessage()) — leaves processedAt=NULL, writes processingError so TaskEngine's
      #    BaseJob retry layer picks it up on next worker pass.
      # 4. Audit: payroll.webhook.processed (success) OR payroll.webhook.processing_failed (failure)
      # NOTE: failure of an individual event does NOT prevent other events processing (BaseJob isolation per ICO-4)

# ============== EXCEPTIONS (taxonomy from CON-9 / Cycle A directory map) ==============

EvereeApiException                   (base; HTTP status, response body)
  ↳ EvereeAuthException              (401/403 — distinct from validation; CrossFeatureEdgeCase F5)
  ↳ EvereeValidationException        (4xx other; carries Everee error payload for upstream rendering)
  ↳ EvereeRateLimitException         (429 after honored Retry-After)
  ↳ EvereeUncertainStateException    (POST timeout w/o idempotency key — caller decides verify-then-retry vs alert)
EvereeEncryptionRequiredException    (write attempted with null encryption — fail-closed)
TokenDecryptionException             (decrypt failed; tenant token unreadable — re-provision required)
PayRateImmutableException            (raised by repo guards if any code path tries to UPDATE/DELETE)
WebhookSignatureException            (HMAC mismatch OR timestamp tolerance miss OR unknown tenant under per_tenant signing)
SchedulingProviderGateException      (store.schedulingProvider != 'buyerkiosk')

# ============== MODIFIED EXISTING ENTITIES ==============

ENTITY: BuyerKiosk\Core\Store (MODIFY — minimal)
  FIELDS (added by migration 010):
    + payrollTenantId: ?int
  BEHAVIORS:
    + getPayrollTenantId(): ?int        # standard getter; no new public method beyond the column accessor
    # NO new business behavior on Store itself — service classes interrogate it

# Reference domain model documentation
domain_doc: docs/patterns/payroll-token-encryption.md (NEW; companion docs for runtime patterns)
domain_doc: docs/patterns/payroll-rate-history-append-only.md (NEW)
```

#### Integration Points

This SDD has no inter-component communication (single monolith). All integration points are external services or shared internal infrastructure.

```yaml
# Inter-Component Communication: N/A (single PHP/Slim monolith)

# External System Integration
Everee_REST_API:
  - doc: docs/interfaces/everee-api.md (NEW)
  - sections: [auth_headers, base_url, idempotency_keys, retry_policy, error_taxonomy, used_endpoints]
  - integration: "EvereeApiClient is the sole code path. HTTPS + Basic auth header + x-everee-tenant-id. Sandbox-only in Phase 0/1a (CON-14)."
  - critical_data: |
      Outbound: legal name, EIN, address, entityType, payFrequency on company-instance create; legalName/legalLastName/dob/address/phoneE164 on worker create; idempotency-key on every POST when supported.
      Inbound: evereeCompanyId, evereeTenantId, evereeWorkerId, tinVerificationStatus, onboardingStatus, lifecycleStatus.
      NEVER inbound or outbound: SSN, bank account, W-4 / I-9 detail (CON-11).
  - failure_mode: "EvereeApiClient categorizes failures via exception taxonomy (Auth / Validation / RateLimit / UncertainState / generic ApiException). Service-class callers translate to user-facing error response (502 for partner outages, 400 for validation, 403 for auth)."

Everee_Webhooks:
  - doc: docs/interfaces/everee-api.md#webhooks (NEW)
  - sections: [signed_payload_shape, hmac_header_format, timestamp_header, event_types]
  - integration: "POST /api/payroll/webhook/everee — HMAC verified, timestamp-tolerance gated, deduped by evereeEventId UNIQUE constraint, enqueued to TaskEngine for async per-event-type handling."
  - critical_data: "11 enumerated event types (worker.* + payment.* + payment-payables.status-changed). Inbound payloads carry evereeCompanyId for tenant resolution."
  - failure_mode: "401 on signature/timestamp failure; 200 on dedupe; processing failures don't block ingestion (PRD F8 AC isolation)."

QuickBooks_Online_API:
  - doc: userfrosting/src/BuyerKiosk/QuickBooks/QuickBooksService.php (existing integration)
  - integration: "OUT OF SCOPE for Phase 0/1a. payrollCoaMappings table ships in Phase 0 but no JE generation until Phase 1c. Documented here so implementers don't accidentally pull QBO integration into Phase 1a scope."

# Shared Internal Infrastructure (reused, not modified)
BuyerKiosk_Security_Encryption:
  - doc: ICO-2
  - integration: "EvereeTokenStorage instantiates Encryption with master key loaded from EVEREE_ENCRYPTION_KEY env var via config/everee-encryption.php. Same OpenSSL AES-256-CBC primitive QBO uses."
  - critical_data: "Plaintext Everee API tokens and webhook secrets pass through; ciphertext stored."

BuyerKiosk_TaskEngine:
  - doc: ICO-4
  - integration: "EvereeWebhookHandler calls JobDispatcher::dispatch() to enqueue ProcessEvereeWebhookJob with payload {webhookEventId}. Worker process picks up via BaseJob lifecycle; markProcessed updates payrollWebhookEvents.processedAt."
  - critical_data: "Only the webhookEventId crosses the queue boundary; the full event row is re-loaded inside the job."

BuyerKiosk_TeamMember_RoleConfigService:
  - doc: ICO-5
  - integration: "READ-ONLY in Phase 0/1a — used to display role names for permission cross-walk documentation. Permission keys themselves live in uf_authorize_group (kiosk_users), not in this service."

UF_Authorize_Group_Table:
  - doc: userfrosting/migrations/input/20260522_016_payroll_permission_keys.json (NEW)
  - integration: "Single migration inserts 12 (group_id, hook, conditions) triples. Permissions enforced at controller level via $app->user->checkAccess('manage_payroll') and the like."

Compatibility_LegacyAliases:
  - doc: ICO-6
  - integration: "One-line modification (line 32+ of LegacyAliases.php) to re-point Employee aliases from BuyerKiosk\\Core\\Employee to BuyerKiosk\\Employee\\Employee. Phase 0 prereq; nothing in BuyerKiosk\\Payroll\\ depends on the alias resolution at runtime."
```

### Implementation Examples

**Purpose**: Provide strategic code examples to clarify complex logic, critical algorithms, or integration patterns. These examples are for guidance, not prescriptive implementation.

**Include examples for**:
- Complex business logic that needs clarification
- Critical algorithms or calculations
- Non-obvious integration patterns
- Security-sensitive implementations
- Performance-critical sections

Four areas warrant strategic code examples. The samples below show the expected logic shape; the actual implementation may differ in idioms (constructor injection style, type-hint syntax) without violating the design.

#### Example 1: PayRateService::getRate — Append-only point-in-time read

**Why this example**: This is the single most-load-bearing read in the entire schema. The PRD's pure-append-only model means `effectiveUntil` is meaningful only on tombstones, and the (effectiveFrom, id) tie-break is non-obvious. Future implementers will be tempted to "close" prior rows on supersession — this sample shows why they don't have to and shouldn't.

```php
<?php
namespace BuyerKiosk\Payroll\Services;

use DateTimeImmutable;
use BuyerKiosk\Payroll\Models\PayRateEntry;
use BuyerKiosk\Payroll\Repositories\PayRateHistoryRepository;
use BuyerKiosk\Payroll\Services\PayrollAuditService;

class PayRateService
{
    public function __construct(
        private PayRateHistoryRepository $repo,
        private PayrollAuditService $audit
    ) {}

    public function getRate(
        int $userId,
        int $payrollTenantId,
        int $positionId,
        DateTimeImmutable $asOf
    ): ?PayRateEntry {
        // Step 1: Select the row with the GREATEST effectiveFrom <= asOf.
        // Ties on effectiveFrom are broken by the GREATER id (most-recent INSERT wins —
        // PRD F7 Rule 3 / Edge Case 1). The composite index supports this in O(log n).
        $entry = $this->repo->findGreatestEffectiveFromAtOrBefore(
            $userId, $payrollTenantId, $positionId, $asOf
        );

        if ($entry === null) {
            // No row for this (user, tenant, position) on or before asOf.
            return null;
        }

        // Step 2: Tombstone handling. A tombstone row carries the explicit
        // retirement date in effectiveUntil. If asOf >= effectiveUntil, the rate
        // is retired and the function returns null (PRD F7 storage model).
        if ($entry->isRetirementTombstone() && $entry->effectiveUntil !== null
            && $asOf >= $entry->effectiveUntil) {
            return null;
        }

        // Step 3: We intentionally do NOT consult effectiveUntil of any other row.
        // The pure append-only model defines the implicit end of any non-tombstone
        // row as the next row's effectiveFrom. The "greatest effectiveFrom <= asOf"
        // selection in Step 1 already gives us the correct row; older rows are
        // simply ignored. This is why PayRateService never UPDATEs anything.
        return $entry;
    }
}
```

Key non-obvious properties this example documents:
- `getRate` never needs to "find the next row" — the ORDER-BY DESC + LIMIT 1 in `findGreatestEffectiveFromAtOrBefore` already does the work of implicit succession.
- A retroactive INSERT with an `effectiveFrom` between two existing rows is naturally handled — it slots in by virtue of the ORDER BY ordering. No UPDATE needed to "close" the prior row.
- Concurrent INSERTs of the same (user, tenant, position, effectiveFrom) are safe: ties break by `id`, so the most-recently-inserted row wins. Both INSERTs succeed; both are audit-logged.

#### Example 2: EvereeApiClient — Retry with 429 Retry-After honoring

**Why this example**: The PRD specifies 429 handling that mixes Retry-After honoring with a fallback exponential backoff. Getting this wrong causes either thundering-herd retries or unnecessary partner-conversation overhead. The sample shows the expected branching.

```php
<?php
namespace BuyerKiosk\Payroll\Services;

use BuyerKiosk\Payroll\Exceptions\{
    EvereeApiException, EvereeAuthException, EvereeValidationException,
    EvereeRateLimitException, EvereeUncertainStateException
};

class EvereeApiClient
{
    private const MAX_ATTEMPTS = 3;
    private const BACKOFF_BASE_MS = 250;        // 250ms / 750ms / 2.25s
    private const BACKOFF_MULTIPLIER = 3;

    private function performRequestWithRetries(
        string $method, string $path, PayrollTenant $tenant,
        ?array $body, ?string $idempotencyKey
    ): array {
        $attempt = 0;
        $retryBudgetMs = 30_000;  // total time we're willing to spend on retries
        $elapsedMs = 0;

        while (true) {
            $attempt++;
            $startMs = microtime(true) * 1000;

            $response = $this->sendOnce($method, $path, $tenant, $body, $idempotencyKey);
            $elapsedMs += (int) (microtime(true) * 1000 - $startMs);

            $status = $response->status;

            if ($status >= 200 && $status < 300) {
                $this->audit->logApiCall($path, $tenant->id, $status, $elapsedMs, $attempt - 1);
                return $response->jsonBody;
            }

            // Non-retryable cases first — surface to caller.
            if ($status === 401 || $status === 403) {
                throw new EvereeAuthException($status, $response->body);
            }
            if ($status >= 400 && $status < 429) {
                throw new EvereeValidationException($status, $response->jsonBody);
            }
            if ($status > 429 && $status < 500) {
                throw new EvereeValidationException($status, $response->jsonBody);
            }

            // 429 — honor Retry-After ONCE; fall through to backoff if still 429 next time.
            if ($status === 429) {
                if ($attempt > 1 || $elapsedMs >= $retryBudgetMs) {
                    throw new EvereeRateLimitException();
                }
                $waitMs = $this->parseRetryAfter($response->headers['Retry-After'] ?? null);
                if ($waitMs === null || $waitMs > $retryBudgetMs - $elapsedMs) {
                    throw new EvereeRateLimitException();
                }
                usleep($waitMs * 1000);
                $elapsedMs += $waitMs;
                continue;
            }

            // 5xx, network error, or timeout — apply exponential backoff.
            if ($attempt >= self::MAX_ATTEMPTS) {
                // For POST without idempotency key, raise UncertainState so caller can decide.
                if ($method === 'POST' && $idempotencyKey === null
                    && in_array($response->status, [0, 502, 503, 504], true)) {
                    throw new EvereeUncertainStateException($response->status, $response->body);
                }
                throw new EvereeApiException($response->status, $response->body);
            }

            $waitMs = self::BACKOFF_BASE_MS * (self::BACKOFF_MULTIPLIER ** ($attempt - 1));
            usleep($waitMs * 1000);
            $elapsedMs += $waitMs;
        }
    }

    private function parseRetryAfter(?string $header): ?int {
        if ($header === null || $header === '') return null;
        if (ctype_digit($header)) return (int) $header * 1000;           // seconds form
        $when = strtotime($header);                                       // HTTP-date form
        if ($when === false) return null;
        $diff = ($when - time()) * 1000;
        return $diff > 0 ? $diff : 0;
    }
}
```

Key non-obvious properties:
- 429 is honored ONCE; a second 429 raises `EvereeRateLimitException` rather than burning more retry budget (CrossFeatureEdgeCase F5 "429 with Retry-After larger than the documented retry-budget").
- POST without an idempotency key + network-class failure → `EvereeUncertainStateException` rather than the generic `EvereeApiException`. This signals the calling service that "verify-then-retry" may be needed.
- Audit logging only fires on a final outcome (success or after-all-retries-failed), not on every individual attempt — keeps the audit table from being noisy.

#### Example 3: EvereeWebhookHandler::ingest — HMAC + timestamp + dedupe

**Why this example**: Concentrates four cross-feature edge cases (F8 concurrent duplicate, F8 rotation-window signature, F8 timestamp replay, F8 unknown tenant) in one place. The sample makes the ordering explicit — timestamp tolerance is checked BEFORE HMAC compute (so a replay doesn't burn CPU on signature math) but AFTER required-header validation.

```php
<?php
namespace BuyerKiosk\Payroll\Services;

use BuyerKiosk\Payroll\Exceptions\WebhookSignatureException;

class EvereeWebhookHandler
{
    public function ingest(string $rawBody, array $headers): IngestResult
    {
        // 1. Required-header gate.
        $eventId   = $headers['x-everee-event-id']   ?? null;
        $timestamp = $headers['x-everee-timestamp']  ?? null;
        $signature = $headers['x-everee-signature']  ?? null;
        if (!$eventId || !$timestamp || !$signature) {
            throw new WebhookSignatureException('missing_required_header');
        }

        // 2. Timestamp tolerance (CrossFeatureEdgeCase F8 replay defense).
        //    Done BEFORE HMAC to avoid burning compute on captured-replay attacks.
        $tsEpoch = is_numeric($timestamp) ? (int)$timestamp : strtotime($timestamp);
        if ($tsEpoch === false || abs(time() - $tsEpoch) > $this->timestampToleranceSec) {
            throw new WebhookSignatureException('timestamp_out_of_tolerance');
        }

        // 3. Resolve the verifying secret.
        $companyId = $headers['x-everee-company-id'] ?? null;
        $tenant = $companyId ? $this->tenants->findByEvereeCompanyId($companyId) : null;
        $secrets = $this->resolveCandidateSecrets($tenant);
        // resolveCandidateSecrets() returns the current secret AND, when
        // tenant.webhookPriorSecretEncrypted is populated AND tenant.webhookPriorSecretExpiresAt > now(),
        // also returns the prior secret. Outside the window (or with no prior secret stored), only
        // the current secret is returned (CrossFeatureEdgeCase F8 rotation).

        if (empty($secrets)) {
            // per_tenant mode and no tenant resolves; the request can't be authenticated.
            throw new WebhookSignatureException('unknown_tenant');
        }

        // 4. HMAC verify (constant-time, against each candidate secret).
        $signedPayload = $timestamp . '.' . $rawBody;
        $verified = false;
        foreach ($secrets as $secret) {
            $computed = hash_hmac('sha256', $signedPayload, $secret);
            if (hash_equals($computed, $signature)) {
                $verified = true;
                break;
            }
        }
        if (!$verified) {
            throw new WebhookSignatureException('hmac_mismatch');
        }

        // 5. Insert with dedupe via UNIQUE constraint.
        $event = $this->buildEventRow($rawBody, $headers, $tenant, hmacValid: true);
        try {
            $this->events->insert($event);
            $this->audit->logWebhookReceived($event);
            $this->dispatcher->dispatch(
                ProcessEvereeWebhookJob::definition(),
                idempotencyKey: 'webhook:' . $eventId,
                typeNum: null,
                triggeredBy: null,
                payload: ['webhookEventId' => $event->id]
            );
            return new IngestResult(duplicate: false);
        } catch (UniqueConstraintViolationException $e) {
            // Already ingested — return 200 with duplicate=true. Don't re-process.
            $this->audit->logWebhookDeduplicated(
                $eventId,
                originalReceivedAt: $this->events->findByEvereeEventId($eventId)->receivedAt,
                duplicateReceivedAt: new DateTime()
            );
            return new IngestResult(duplicate: true);
        }
    }
}
```

Key non-obvious properties:
- Order matters — header presence → timestamp tolerance → secret resolution → HMAC verify → DB INSERT. Each gate is cheap-before-expensive.
- Rotation support is opt-in via `resolveCandidateSecrets` returning a multi-element array. During a documented rotation window, the receiver accepts signatures under the previous key; outside it, only the current key.
- Dedupe lives in the UNIQUE constraint, not in a pre-INSERT SELECT. Pre-INSERT SELECT introduces a TOCTOU race (CrossFeatureEdgeCase F8 concurrent-duplicate); UNIQUE-then-catch is atomic.

#### Example 4: EvereeTokenStorage — fail-closed write + scrubbed serialization

**Why this example**: PRD F4 AC requires that `var_dump`, JSON serialization, and exception stringification on a token-bearing tenant object NEVER reveal the plaintext. The sample shows the model-level scrubbing AND the service-level fail-closed encrypt path.

```php
<?php
namespace BuyerKiosk\Payroll\Models;

use JsonSerializable;

class PayrollTenant implements JsonSerializable
{
    public function __construct(
        public readonly int $id,
        public readonly string $legalName,
        public readonly string $ein,
        // ... other fields ...
        private readonly ?string $evereeApiTokenEncrypted,    // base64 ciphertext, private so leaks are deliberate
        private readonly ?string $webhookSecretEncrypted
    ) {}

    /** Repos read encrypted bytes via this; service classes pass to EvereeTokenStorage. */
    public function getEncryptedApiTokenForServiceUse(): ?string
    {
        return $this->evereeApiTokenEncrypted;
    }

    public function jsonSerialize(): array
    {
        // Public response shape — NEVER include encrypted fields.
        return [
            'id' => $this->id,
            'legalName' => $this->legalName,
            'ein' => $this->ein,
            // ... non-sensitive fields only ...
            // evereeApiTokenEncrypted / webhookSecretEncrypted intentionally OMITTED
        ];
    }

    public function __debugInfo(): array
    {
        // var_dump / print_r safety net.
        return $this->jsonSerialize() + [
            '_apiTokenPresent' => $this->evereeApiTokenEncrypted !== null,
            '_webhookSecretPresent' => $this->webhookSecretEncrypted !== null,
        ];
    }
}

namespace BuyerKiosk\Payroll\Services;

use BuyerKiosk\Security\Encryption;
use BuyerKiosk\Payroll\Exceptions\{TokenDecryptionException, EvereeEncryptionRequiredException};

class EvereeTokenStorage
{
    public function __construct(private ?Encryption $encryption) {}

    public function encryptTokenForStorage(string $plaintext): string
    {
        // Fail closed — mirrors QuickBooksService::encryptValue() (ICO-2).
        if ($this->encryption === null) {
            throw new EvereeEncryptionRequiredException(
                'EVEREE_ENCRYPTION_KEY is not configured. Refusing to store plaintext token.'
            );
        }
        return $this->encryption->encrypt($plaintext);
    }

    public function decryptTokenForUse(PayrollTenant $tenant): string
    {
        $cipher = $tenant->getEncryptedApiTokenForServiceUse();
        if ($cipher === null || $cipher === '') {
            throw new TokenDecryptionException('No API token stored for tenant ' . $tenant->id);
        }
        if ($this->encryption === null) {
            throw new TokenDecryptionException('Encryption not configured; cannot read tenant token.');
        }
        $plain = $this->encryption->decrypt($cipher);
        if ($plain === false) {
            throw new TokenDecryptionException(
                'Decryption failed for tenant ' . $tenant->id . ' — token unreadable; re-provision required.'
            );
        }
        return $plain;
    }
}
```

Key non-obvious properties:
- The encrypted bytes are `private readonly` on the model — there is no public getter that returns them in JSON output. The `getEncryptedApiTokenForServiceUse()` method is the deliberate escape hatch for service-side decryption.
- `jsonSerialize()` and `__debugInfo()` both omit encrypted fields. `__debugInfo` instead exposes a boolean presence flag so the diagnostic value is preserved without leaking secret material.
- Writes throw immediately if encryption is misconfigured (fail-closed) — mirrors the QBO `encryptValue()` behavior so deployments without `EVEREE_ENCRYPTION_KEY` set fail loudly at write time, not silently with plaintext storage.
- Reads are lenient about absent encryption (returns `TokenDecryptionException` rather than panicking) so the system stays operationally readable during incident response.

#### Test as Interface Documentation: PayRateService append-only invariant

The integration test below is itself a contract — it documents the behavior any future refactor must preserve, and it is the gate referenced in PRD F7 AC ("a regression test that asserts attempted UPDATE raises").

```php
<?php
namespace Tests\Integration\Payroll;

use PHPUnit\Framework\TestCase;
use BuyerKiosk\Payroll\Exceptions\PayRateImmutableException;

class RateHistoryAppendOnlyTest extends TestCase
{
    public function testSettingSupersedingRateInsertsNewRowWithoutMutatingPrior(): void
    {
        $service = $this->makeService();
        $first  = $service->setRate(userId: 100, tenantId: 1, positionId: 7,
            rateType: 'hourly', rateCents: 1500, effectiveFrom: new DateTimeImmutable('2026-01-01'),
            actorUserId: 9, note: 'initial');

        $second = $service->setRate(userId: 100, tenantId: 1, positionId: 7,
            rateType: 'hourly', rateCents: 1750, effectiveFrom: new DateTimeImmutable('2026-03-01'),
            actorUserId: 9, note: 'raise');

        // First row's effectiveUntil must remain NULL (no implicit close on supersession).
        $row1 = $this->fetchRowById($first->id);
        $this->assertNull($row1['effectiveUntil']);
        $this->assertEquals(1500, $row1['rateCents']);

        // getRate as of Feb returns the first row.
        $rateInFeb = $service->getRate(100, 1, 7, new DateTimeImmutable('2026-02-15'));
        $this->assertSame(1500, $rateInFeb->rateCents);

        // getRate as of April returns the second row.
        $rateInApr = $service->getRate(100, 1, 7, new DateTimeImmutable('2026-04-15'));
        $this->assertSame(1750, $rateInApr->rateCents);
    }

    public function testRawUpdateAttemptOnPayRateHistoryRaises(): void
    {
        $service = $this->makeService();
        $entry = $service->setRate(userId: 100, tenantId: 1, positionId: 7,
            rateType: 'hourly', rateCents: 1500, effectiveFrom: new DateTimeImmutable('2026-01-01'),
            actorUserId: 9, note: 'initial');

        $this->expectException(PayRateImmutableException::class);
        // Attempt to UPDATE via a raw PDO statement — the guarded PDO subclass scans the SQL
        // for UPDATE/DELETE against payRateHistory and raises before the query reaches MySQL.
        $this->guardedPdo()->exec("UPDATE payRateHistory SET rateCents = 9999 WHERE id = {$entry->id}");
    }
}
```

## Runtime View

Phase 0/1a has three primary runtime flows: **tenant provisioning** (engineer-driven), **setting a pay rate** (Owner-driven), and **webhook ingestion** (Everee-driven). All three are documented below with sequence diagrams, then a single consolidated error-handling matrix covers all failure modes.

### Primary Flow

#### Primary Flow A: Engineer provisions and verifies a sandbox tenant (PRD F6)

This is the journey from "I have legal-entity details for Prospect Store LLC" to "the tenant row exists, the API token is encrypted, the store is attached, the audit trail is readable."

1. Engineer sends a `POST /api/payroll/admin/:typeNum/tenants` request with the legal entity payload.
2. Slim 2 route resolves UF session → `checkAccess('manage_payroll')` → `checkStoreGroup(typeNum)` → `SchedulingProviderGate::assertAllowed(typeNum, 'tenant_provision', actorUserId)`.
3. `PayrollAdminController::createTenant` validates request payload (EIN format, required fields, payCutoff bounds).
4. Controller delegates to `EvereeProvisioningService::provisionTenant`.
5. Provisioning service checks for existing tenant by EIN — returns idempotently if found.
6. Provisioning service calls `EvereeApiClient::createCompanyInstance` (or wraps a documented manual portal step if partner does not yet expose the API).
7. On Everee success, plaintext API token is encrypted via `EvereeTokenStorage::encryptTokenForStorage` and a `payrollTenants` row is INSERTed with `isActive=0`.
8. Audit entry `payroll.tenant.provisioned` written with `isActive=0`.
9. Optional verification call (`listWorkers`) confirms reachability; on success the row is UPDATEd to `isActive=1` and a second audit entry is written.
10. Optional attach: for each `typeNum` in `attachToStoreTypeNums`, the provider gate is re-asserted, then `stores.payrollTenantId` is UPDATEd; each store attach is audit-logged.
11. Controller returns 201 (or 200 idempotent return) with `{payrollTenantId, evereeCompanyId, isActive, idempotentReturn}`.

```mermaid
sequenceDiagram
    actor Eng as BK Engineer
    participant Route as Slim 2 Route
    participant Gate as SchedulingProviderGate
    participant Ctrl as PayrollAdminController
    participant ProvSvc as EvereeProvisioningService
    participant TokenSvc as EvereeTokenStorage
    participant Api as EvereeApiClient
    participant Everee as Everee Sandbox API
    participant DB as MySQL central DB
    participant Audit as PayrollAuditService

    Eng->>Route: POST /api/payroll/admin/pc00/tenants {legal entity}
    Route->>Gate: assertAllowed(pc00, 'tenant_provision', actorId)
    Gate->>DB: SELECT schedulingProvider FROM stores WHERE typeNum=pc00
    DB-->>Gate: 'buyerkiosk'
    Gate-->>Route: ok
    Route->>Ctrl: createTenant(body, actorId)
    Ctrl->>ProvSvc: provisionTenant(input, actorId, attachTypeNums)
    ProvSvc->>DB: SELECT * FROM payrollTenants WHERE ein=?
    DB-->>ProvSvc: empty (new EIN)
    ProvSvc->>Api: createCompanyInstance(payload, idempotencyKey='prov:'+ein)
    Api->>Everee: POST /companies {legal entity}
    Everee-->>Api: 201 {evereeCompanyId, apiToken}
    Api-->>ProvSvc: {evereeCompanyId, apiToken}
    ProvSvc->>TokenSvc: encryptTokenForStorage(apiToken)
    TokenSvc-->>ProvSvc: ciphertext
    ProvSvc->>DB: INSERT INTO payrollTenants (...) isActive=0
    DB-->>ProvSvc: tenantId=42
    ProvSvc->>Audit: logTenantProvisioned(tenant=42, actorId, isActive=0)
    ProvSvc->>Api: listWorkers(tenant=42)
    Api->>Everee: GET /workers (basic auth + x-everee-tenant-id)
    Everee-->>Api: 200 []
    Api-->>ProvSvc: []
    ProvSvc->>DB: UPDATE payrollTenants SET isActive=1 WHERE id=42
    ProvSvc->>Audit: logTenantProvisioned(tenant=42, actorId, isActive=1)
    ProvSvc->>Gate: assertAllowed(pc00, 'tenant_attach', actorId)
    Gate-->>ProvSvc: ok
    ProvSvc->>DB: UPDATE stores SET payrollTenantId=42 WHERE typeNum=pc00
    ProvSvc->>Audit: log store-attach (before/after)
    ProvSvc-->>Ctrl: PayrollTenant{id=42, isActive=1}
    Ctrl-->>Route: 201 {payrollTenantId:42, ..., idempotentReturn:false}
    Route-->>Eng: 201 JSON response
```

#### Primary Flow B: Owner sets a pay rate (PRD F7)

The flow whose correctness is the most permanent. Pure INSERT; no UPDATE anywhere.

```mermaid
sequenceDiagram
    actor Owner as Store Owner
    participant Route as Slim 2 Route
    participant Gate as SchedulingProviderGate
    participant Ctrl as PayrollAdminController
    participant RateSvc as PayRateService
    participant Repo as PayRateHistoryRepository
    participant DB as MySQL
    participant Audit as PayrollAuditService

    Owner->>Route: POST /api/payroll/admin/pc00/rates {userId, tenant, position, rate, eff, note}
    Route->>Gate: assertAllowed(pc00, 'rate_set', ownerId)
    Gate-->>Route: ok
    Route->>Ctrl: setRate(body, ownerId)
    Note over Ctrl: Validate: rateCents>0,<br/>note non-empty,<br/>rateType in enum
    Ctrl->>RateSvc: setRate(userId, tenantId, positionId, rateType, rateCents, effFrom, ownerId, note)
    RateSvc->>Repo: findGreatestEffectiveFromAtOrBefore(userId, tenant, pos, today)
    Repo->>DB: SELECT ... ORDER BY effectiveFrom DESC, id DESC LIMIT 1
    DB-->>Repo: previous active row (or null)
    Repo-->>RateSvc: previous (for audit before/after JSON)
    RateSvc->>Repo: insert(new PayRateEntry{...})
    Repo->>DB: INSERT INTO payRateHistory (...) (no UPDATE on prior rows)
    DB-->>Repo: rateHistoryId=99
    Repo-->>RateSvc: PayRateEntry{id=99}
    Note over RateSvc: backdated = effFrom < today<br/>severity = high iff backdated
    RateSvc->>Audit: logRateSet(entry=99, ownerId, backdated, previousActiveRate)
    RateSvc-->>Ctrl: PayRateEntry{id=99}
    Ctrl-->>Route: 201 {rateHistoryId:99, backdated:false}
    Route-->>Owner: 201 JSON response
```

#### Primary Flow C: Everee webhook ingestion → async processing (PRD F8)

Two phases: synchronous ingestion (must be fast, HMAC-verified, deduped) and async per-event processing (TaskEngine job, isolated failures, idempotent retries).

```mermaid
sequenceDiagram
    participant Everee as Everee Webhooks
    participant Route as POST /api/payroll/webhook/everee
    participant Handler as EvereeWebhookHandler
    participant TenantRepo as PayrollTenantRepository
    participant TokenSvc as EvereeTokenStorage
    participant EventRepo as PayrollWebhookEventRepository
    participant DB as MySQL
    participant Dispatcher as JobDispatcher
    participant Queue as TaskEngine queue
    participant Worker as TaskEngine worker
    participant Job as ProcessEvereeWebhookJob
    participant ProfileRepo as UserPayrollProfileRepository
    participant Audit as PayrollAuditService

    Everee->>Route: POST /api/payroll/webhook/everee<br/>headers: x-everee-{event-id,timestamp,signature,company-id}<br/>body: JSON event
    Route->>Handler: ingest(rawBody, headers)
    Note over Handler: 1. Header presence check<br/>2. Timestamp tolerance ±5min<br/>3. Resolve tenant by companyId<br/>4. Load candidate secrets (current + rotation-window prior)
    Handler->>TenantRepo: findByEvereeCompanyId(companyId)
    TenantRepo-->>Handler: PayrollTenant
    Handler->>TokenSvc: decryptWebhookSecretForUse(tenant)
    TokenSvc-->>Handler: secret(s)
    Note over Handler: hash_hmac('sha256', ts.body, secret)<br/>constant-time compare
    Handler->>EventRepo: insert(webhookEvent)
    EventRepo->>DB: INSERT INTO payrollWebhookEvents (UNIQUE evereeEventId)
    alt new event
        DB-->>EventRepo: id=12345
        EventRepo-->>Handler: id=12345
        Handler->>Audit: logWebhookReceived(event=12345)
        Handler->>Dispatcher: dispatch(ProcessEvereeWebhookJob, idemKey='webhook:'+eventId, payload={webhookEventId:12345})
        Dispatcher->>Queue: push to default queue
        Handler-->>Route: IngestResult{duplicate:false}
        Route-->>Everee: 200 {received:true, duplicate:false}
    else duplicate (UNIQUE violation)
        DB-->>EventRepo: UniqueConstraintViolationException
        Handler->>EventRepo: findByEvereeEventId(eventId)
        EventRepo-->>Handler: original event
        Handler->>Audit: logWebhookDeduplicated(eventId, originalReceivedAt, duplicateReceivedAt)
        Handler-->>Route: IngestResult{duplicate:true}
        Route-->>Everee: 200 {received:true, duplicate:true}
    end

    Note over Worker,Job: Asynchronous — separate process

    Worker->>Queue: poll
    Queue-->>Worker: job {webhookEventId:12345}
    Worker->>Job: handle()
    Job->>EventRepo: find(12345)
    EventRepo-->>Job: PayrollWebhookEvent{type:'worker.onboarding-completed', payload:{...}}
    Note over Job: switch on event type:<br/>worker.* → update profile<br/>payment.* → no-op Phase 1a<br/>unknown → log + mark processed
    Job->>ProfileRepo: findByEvereeWorkerId(payload.workerId)
    ProfileRepo-->>Job: UserPayrollProfile
    Job->>ProfileRepo: update(profile with onboardingStatus='completed')
    Job->>EventRepo: markProcessed(12345)
    Job->>Audit: logWebhookProcessed(event=12345)
    Job-->>Worker: JobResult::success()
```

### Error Handling

A single matrix for every payroll-touching error mode, organized by feature. Every row specifies the exception type, the HTTP/audit outcome, and the recovery path.

| Trigger | Feature | Exception / Status | HTTP / Outcome | Audit entry | Recovery |
|---|---|---|---|---|---|
| Invalid EIN format on tenant create | F6 | `EvereeValidationException('bad_ein_format')` | 400 `{error:bad_request,details:...}` | none (validation; not state-changing) | Engineer corrects input and resubmits |
| Duplicate EIN on tenant create | F6 | none (idempotent return) | 200 with `{idempotentReturn:true}` | `payroll.tenant.provisioned` w/ severity=high + `metadata.idempotent=true` | None needed; existing tenant returned (CrossFeatureEdgeCase F6) |
| `SchedulingProviderGate` rejects (store is WIW/Homebase) | F11 | `SchedulingProviderGateException` | 403 `{error:scheduling_provider_excluded}` | `payroll.scheduling_provider_gate.rejected` w/ typeNum + schedulingProvider | Educate operator; no auto-retry. Document migration path if customer wants to switch to BK-native. |
| Everee API returns 401/403 | F5 | `EvereeAuthException` | 502 `{error:everee_auth_failed}` (controller translates from service-layer exception) | `payroll.api.everee_call` w/ httpStatus=401 | Re-mint tenant API token (PRD F4 token rotation seam); alert CS if token was thought-valid. Never auto-retry on 401 (CrossFeatureEdgeCase F5). |
| Everee API returns 429 | F5 | (internal) honored once via Retry-After | retry succeeds → 2xx; second 429 → `EvereeRateLimitException` → 502 | `payroll.api.everee_call` w/ httpStatus=429, retryCount=1 | Surface as transient backend error; CS retries later. Document partner conversation if 429s become endemic. |
| Everee API returns 4xx validation (non-auth) | F5 | `EvereeValidationException` | 400 with Everee's error payload bubbled into `details` | `payroll.api.everee_call` w/ httpStatus | Engineer / CS corrects input |
| Everee API returns 5xx after retry budget | F5 | `EvereeApiException` | 502 `{error:everee_unavailable}` | `payroll.api.everee_call` w/ httpStatus=5xx, retryCount=MAX | Manual retry later; track in CS partner-issue log |
| Everee POST timeout (network) WITHOUT idempotency key | F5 | `EvereeUncertainStateException` | 502 `{error:everee_uncertain_state, details:...}` | `payroll.api.everee_call` w/ network error | Caller decides: verify-via-GET-first OR alert ops. Phase 1b's pay-run-submit path will use pre-flight GET. |
| Token decryption fails (corrupted ciphertext / wrong master key) | F4 | `TokenDecryptionException` | 502 `{error:token_unreadable, payrollTenantId:X}` | `payroll.api.everee_call` w/ error=token_unreadable | Re-provision the tenant (mint new token via partner, encrypt-and-store). NO fallback to plaintext. (CrossFeatureEdgeCase F4) |
| Encryption not configured at write time | F4 | `EvereeEncryptionRequiredException` | 500 `{error:encryption_misconfigured}` (legitimately 500 — this is a deployment bug) | none (request never reached audit boundary) | Fix env config (`EVEREE_ENCRYPTION_KEY`); reapply. Same fail-closed posture as QBO. |
| Webhook HMAC mismatch | F8 | `WebhookSignatureException('hmac_mismatch')` | 401 `{error:invalid_signature}` | `payroll.webhook.received` w/ hmacValid=false (high severity) | Everee retries via its own machinery; receiver is idempotent so retries are safe. NO 5xx (would suppress legitimate retries — PRD F8 AC). |
| Webhook timestamp outside tolerance | F8 | `WebhookSignatureException('timestamp_out_of_tolerance')` | 401 `{error:invalid_signature}` | `payroll.webhook.received` w/ hmacValid=false, metadata.reason=timestamp | Defends against replay (CrossFeatureEdgeCase F8). Does NOT consume dedupe slot — legitimate retry of the same event ID still succeeds within tolerance. |
| Webhook unknown tenant under per_tenant signing | F8 | `WebhookSignatureException('unknown_tenant')` | 401 `{error:invalid_signature}` | `payroll.webhook.received` w/ hmacValid=false, metadata.unknownTenant | Verify Everee's company-id is set; check tenant existence; partner may be sending events for a tenant we haven't yet provisioned. |
| Webhook signed under previous secret during rotation | F8 | (none — multi-secret verifier accepts) | 200 `{received:true, duplicate:false}` | `payroll.webhook.received` w/ metadata.signedUnderPriorSecret=true | Operationally expected; rotation procedure documents the window |
| Webhook concurrent duplicate (UNIQUE violation) | F8 | (caught) `UniqueConstraintViolationException` → `IngestResult{duplicate:true}` | 200 `{received:true, duplicate:true}` | `payroll.webhook.deduplicated` | None — by design (CrossFeatureEdgeCase F8) |
| Webhook async processing fails (Job::handle throws) | F8 | (logged) `\Throwable` caught in `BaseJob` boundary | n/a (async; HTTP request already returned 200) | `payroll.webhook.processing_failed` w/ retryCount | `markFailed(id, error)` is called — leaves `processedAt=NULL` and writes `processingError`. TaskEngine retries per job-definition (`maxRetries=3`, `retryBackoff=60s`); after exhaustion, the row remains discoverable via `findUnprocessed(...)`. Manual reprocessing via `job:dispatch` CLI. |
| PayRateService: empty note | F7 | `EvereeValidationException('note_required')` | 400 `{error:bad_request,details:note}` | none | Engineer / CS provides a non-empty note (PRD F7 Rule 6) |
| PayRateService: rateCents <= 0 | F7 | `EvereeValidationException('invalid_rate')` | 400 `{error:bad_request,details:rateCents}` | none | Correct input |
| PayRateService: setRate concurrent double-click | F7 | none (both INSERTs succeed) | 201 returned to each request | `payroll.rate.set` x2 with same effectiveFrom but different ids | `getRate` returns greatest (effectiveFrom, id) — most-recent wins. PRD F7 Edge Case 1. |
| Any code path attempts `UPDATE payRateHistory` | F7 | `PayRateImmutableException` | 500 `{error:internal_error}` (this is a bug in our code; should never be triggered by a request) | NO audit entry — the `GuardedPdo` intercept fires at the PDO layer with no audit context; the exception is logged to `error_log` via Slim's error handler. Production observation comes from the application log + the regression test failing in CI. | Fix the calling code. Regression test asserts this raises. (ADR-2) |
| UF permission denied at controller boundary | F10 | (UF) `notAuthorized()` flow → Slim Stop | 403 `{error:permission_denied}` | `payroll.permission.denied` (high severity) | Operator requests the missing role from their owner; CS may grant via RoleConfig |
| Slim `\Slim\Exception\Stop` thrown by controller for early-return | all | propagates per CON-7 | as set by stop() | n/a | Convention — never caught by generic `\Exception` catch (per memory `slim2-stop-exception-swallowed`) |
| Unexpected `\TypeError` / `\ValueError` (PHP 8.x strict types) | all | `\Throwable` caught at route boundary | 500 `{error:internal_error}` w/ correlation id (NOT the message) | error_log entry (no audit) | Fix bug; the route's catch block uses `\Throwable` not `\Exception` (CON-7, memory) |

**Error response shape (project-wide):**
```json
{
  "error": "<machine_code>",
  "message": "<human-readable; safe-to-render>",
  "details": { /* optional, validation-specific */ },
  "correlationId": "<uuid>"  /* echoed from incoming X-Request-Id header if present, otherwise generated */
}
```

Webhook responses are an exception — they use Everee's lighter shape (`{"received": true, "duplicate": <bool>}` on success, `{"error": "invalid_signature"}` on HMAC failure) because the partner's retry machinery parses these specific keys.

### Complex Logic

Three algorithms warrant explicit pseudocode. Each is non-obvious enough that future implementers should reach for the spec rather than re-derive from code.

#### Algorithm 1: Point-in-time rate resolution (PayRateService::getRate)

```
ALGORITHM: getRateAsOf(userId, tenantId, positionId, asOf)
INPUT:  (userId, tenantId, positionId) — composite identity of the rate stream
        asOf                            — DateTimeImmutable, day at which we want the rate
OUTPUT: PayRateEntry | null              — the rate in effect at asOf, or null if no rate

1. CANDIDATE := SELECT * FROM payRateHistory
                WHERE userId        = :userId
                  AND payrollTenantId = :tenantId
                  AND positionId    = :positionId
                  AND effectiveFrom <= :asOf
                ORDER BY effectiveFrom DESC, id DESC
                LIMIT 1
                                          # Greatest effectiveFrom <= asOf; tie-break by greatest id (most-recent INSERT wins).

2. IF CANDIDATE is null:
       RETURN null                       # No row for this stream on or before asOf.

3. IF CANDIDATE.isRetirementTombstone
      AND CANDIDATE.effectiveUntil IS NOT NULL
      AND asOf >= CANDIDATE.effectiveUntil:
       RETURN null                       # Rate retired before asOf.

4. RETURN CANDIDATE                       # No "next row" lookup; the prior row is implicitly superseded.

INVARIANTS:
  - Step 1 NEVER consults effectiveUntil of any non-tombstone row.
  - There is no implicit interval-closing logic. The "end" of a non-tombstone row is the next row's effectiveFrom, derived only at listHistory time (not at getRate time).
  - Concurrent setRate calls produce two rows with the same effectiveFrom; the tie-break (id DESC) ensures deterministic resolution.

NEGATIVE-SPACE INVARIANT: no code path UPDATEs any row in payRateHistory. Implemented by repo surface (no update method) + regression test + future DB-level defense (ADR-2).
```

#### Algorithm 2: Webhook signature verification with rotation tolerance (EvereeWebhookHandler)

```
ALGORITHM: verifyWebhookSignature(rawBody, headers)
INPUT:  rawBody  — bytes of POST body as received
        headers  — request headers
OUTPUT: (verified: bool, tenant: PayrollTenant|null) OR raises WebhookSignatureException

1. PRECONDITIONS:
   IF headers lack x-everee-event-id  → raise 'missing_required_header'
   IF headers lack x-everee-timestamp → raise 'missing_required_header'
   IF headers lack x-everee-signature → raise 'missing_required_header'

2. TIMESTAMP TOLERANCE:
   ts := parseTimestampHeader(headers['x-everee-timestamp'])
   IF ts is invalid OR |now() - ts| > TIMESTAMP_TOLERANCE_SECONDS:
       raise 'timestamp_out_of_tolerance'    # Replay defense; done before HMAC compute.

3. TENANT + SECRET RESOLUTION:
   tenant := null
   IF config('EVEREE_WEBHOOK_SIGNING_MODE') = 'per_tenant':
       companyId := headers['x-everee-company-id']
       IF companyId is null:
           raise 'unknown_tenant'
       tenant := PayrollTenantRepository.findByEvereeCompanyId(companyId)
       IF tenant is null:
           raise 'unknown_tenant'
       secrets := [
           EvereeTokenStorage.decryptWebhookSecretForUse(tenant),
       ]
       IF tenant.webhookPriorSecretEncrypted IS NOT NULL
          AND tenant.webhookPriorSecretExpiresAt > now():
           secrets.append(EvereeTokenStorage.decryptWebhookPriorSecretForUse(tenant))   # tolerate signatures under previous key
       # If the prior-secret window has expired, the column may still be populated but is no longer
       # accepted; an out-of-band cleanup job (operator script) clears expired prior secrets.
   ELSE:                                                 # global signing
       secrets := [env('EVEREE_WEBHOOK_GLOBAL_SECRET')]

4. HMAC COMPARE (CONSTANT-TIME, EACH CANDIDATE):
   signedPayload := headers['x-everee-timestamp'] + '.' + rawBody
   FOR EACH secret IN secrets:
       expected := hash_hmac('sha256', signedPayload, secret)
       IF hash_equals(expected, headers['x-everee-signature']):
           RETURN (true, tenant)
   raise 'hmac_mismatch'

INVARIANTS:
  - Timestamp tolerance is checked BEFORE HMAC compute (cheap-first gating; defends against replay-storm DoS).
  - Constant-time comparison (hash_equals) — never `===` on the signature.
  - Multiple candidate secrets supports the rotation window without two-phase secret swap.
  - Unknown tenant under per_tenant signing returns 401, never silently global-fallback (security-relevant).
```

#### Algorithm 3: Webhook ingestion idempotency via DB UNIQUE (EvereeWebhookHandler)

```
ALGORITHM: ingestEventAtomic(verifiedEvent)
INPUT:  verifiedEvent — populated PayrollWebhookEvent with hmacValid=true (and signature already verified against the ORIGINAL rawBody bytes, NOT the redacted view)
OUTPUT: IngestResult{duplicate: bool}

0. REDACT PII FIELDS BEFORE PERSISTENCE (CON-11 defense-in-depth, ADR-11):
   verifiedEvent.payload := EvereeWebhookPayloadRedactor.redact(verifiedEvent.payload)
   # Strips any top-level OR nested keys matching the forbidden-PII allowlist:
   #   SSN-shaped: ssn, social, taxId (numeric forms), socialSecurityNumber, tin, fullTin
   #   Bank-shaped: accountNumber, routingNumber, bankAccount, bankRouting, micrLine
   #   Tax-form-shaped: w4, w-4, i9, i-9, formW4, formI9, fullW4, fullI9
   # Each redacted key is replaced with the literal string "<REDACTED-PII>" so the audit trail
   #   PRESERVES the fact-of-presence (we received it) without retaining the value.
   # Redactor logs a HIGH-SEVERITY application log entry on every redaction event so any drift
   #   on Everee's side (sending us PII we shouldn't see) surfaces immediately.

1. ATTEMPT INSERT:
   TRY:
       eventRow.id := PayrollWebhookEventRepository.insert(verifiedEvent)
   CATCH UniqueConstraintViolationException ON column evereeEventId:
       GOTO step 4 (dedupe path)

2. AUDIT + ENQUEUE (NEW EVENT PATH):
   PayrollAuditService.logWebhookReceived(eventRow)
   JobDispatcher.dispatch(
       ProcessEvereeWebhookJob.definition(),
       idempotencyKey: 'webhook:' + eventRow.evereeEventId,
       typeNum: null,
       triggeredBy: null,
       payload: { webhookEventId: eventRow.id }
   )

3. RETURN IngestResult(duplicate: false)

4. DEDUPE PATH:
   existing := PayrollWebhookEventRepository.findByEvereeEventId(verifiedEvent.evereeEventId)
   PayrollAuditService.logWebhookDeduplicated(
       evereeEventId: existing.evereeEventId,
       originalReceivedAt: existing.receivedAt,
       duplicateReceivedAt: now()
   )
   RETURN IngestResult(duplicate: true)

INVARIANTS:
  - Dedupe is enforced at the DB by UNIQUE evereeEventId. No application-level pre-INSERT SELECT (TOCTOU-safe).
  - The dedupe path is the SAME response shape as the success path (both 200). Everee's retry machinery should never see 4xx/5xx for a duplicate.
  - The async job's own idempotencyKey uses the SAME evereeEventId, so even if a duplicate INSERT somehow slipped through (it cannot, but defense-in-depth), TaskEngine's idempotency layer would deduplicate the job dispatch.
```

## Deployment View

Phase 0/1a deploys exclusively to the existing BuyerKiosk web environment — there is no new service, no new infrastructure, no container changes. The TaskEngine workers that pick up `ProcessEvereeWebhookJob` are the same workers already running for Goals, Scheduling, QBO, etc.

### Single Application Deployment

- **Environment:** BuyerKiosk PHP/Slim 2 monolith. Local development uses the developer's laptop + `ngrok` tunnel to `dev2.buyerkiosk.com` (per user memory `Dev Environment` — **there is no remote dev or staging server**; production is the only deployed environment). Phase 0/1a code merges to `master`, ships via `./deploy.sh` (NOT invoked from within this spec without explicit user approval).
- **Configuration:** Six NEW environment variables:
  - `EVEREE_ENCRYPTION_KEY` — 32-byte hex master key for tenant-token + webhook-secret encryption. **Different value from `QB_ENCRYPTION_KEY` so rotation is independent** (ADR-1). REQUIRED in every environment; fail-closed write enforcement raises `EvereeEncryptionRequiredException` if missing.
  - `EVEREE_API_BASE_URL` — Everee sandbox URL (set at partner-confirmation time). Phase 0/1a NEVER uses production URL (CON-14). Required for any non-no-op service test.
  - `EVEREE_PARTNER_API_TOKEN` — Partner-level API token used ONLY for the initial Company Instance create call (which runs before any tenant exists). NOT used for any per-tenant call (which use the encrypted per-tenant token). Provided by Everee at partnership setup. Sensitive — stored in env, not in DB.
  - `EVEREE_WEBHOOK_SIGNING_MODE` — `per_tenant` (default) or `global`. Per-tenant reads `payrollTenants.webhookSecretEncrypted`; global reads the next variable.
  - `EVEREE_WEBHOOK_GLOBAL_SECRET` — set only if signing mode is `global` (partner-confirmation outcome).
  - `EVEREE_WEBHOOK_TIMESTAMP_TOLERANCE_SEC` — optional, default 300; replay-defense window.
- **Dependencies:** Everee REST API (sandbox only in this scope). No additional packages, no new CDN, no new caching layer. Existing dependencies are sufficient — Composer install adds nothing.
- **Performance targets (single ngrok-tunneled dev box; production scales the same way):**
  - Webhook ingest (HMAC + dedupe + enqueue): **p95 < 250 ms** end-to-end. Everee's retry machinery requires fast acks; spending more time invites duplicate retries.
  - Webhook async processing (one event end-to-end through TaskEngine): **p95 < 5 s** per event; the worker pool already handles spikes via TaskEngine's backpressure.
  - Tenant provisioning (one round-trip to Everee plus DB writes): **p95 < 3 s** under nominal sandbox latency.
  - `setRate` / `getRate`: **p95 < 50 ms** (single indexed DB INSERT / SELECT).
- **Caching strategy:** None added by this spec. The existing Redis-backed `StoreController` cache continues to cache `Store` objects; **no payroll-tenant data caches in Redis** (token decryption is the only sensitive operation and it must not be cached).
- **Migration application:** All 16 migration JSONs are picked up by `php userfrosting/conductor run` on the next deployment. The `migration_log` table (central DB) tracks state — reapplying any migration is a no-op. **Apply order is filename-lexical and matches FK dependency order** (Cycle B §"Migration ordering"). If only a subset of migrations needs to ship in a partial release, the engineer can apply them targeted via the `buyerkiosk-conductor-targeted-migration` skill.

### Multi-Component Coordination

**No change.** Phase 0/1a is single-component (PHP monolith). No mobile-app coordination, no service-mesh changes, no separate worker deployment. The TaskEngine worker pool already serves multiple modules and the addition of `ProcessEvereeWebhookJob` does not require restarting workers (BaseJob discovery is dynamic via `taskengine_job_definitions`).

The one cross-app concern worth noting: **mobile apps (buyerkiosk-team, buyerkiosk-live-flutter) are unaffected by Phase 0/1a**. No new mobile-facing endpoint, no new field they read, no breaking change. The `users` PII columns (legalFirstName, dob, etc.) are nullable and existing mobile API responses do NOT include them. Per CLAUDE.md's inter-agent communication protocol, no entry in `docs/api/mobile-agent-requests.md` or `docs/api/live-agent-requests.md` is required by this spec.

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
existing_patterns_used:
  - pattern: userfrosting/src/BuyerKiosk/Security/Encryption.php  (and QuickBooksService.php encrypt/decrypt usage)
    relevance: CRITICAL
    why: "EvereeTokenStorage reuses this exact primitive — OpenSSL AES-256-CBC, random-IV-prepended-base64, env-sourced master key. NO new crypto."

  - pattern: userfrosting/src/BuyerKiosk/MobileScheduling/Repositories/ScheduleAuditRepository.php
    relevance: CRITICAL
    why: "PayrollAuditService mirrors this repo's shape — JSON before/after snapshots, factory methods, findWithFilters for admin queries, ACTOR_* constants on the model."

  - pattern: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/BaseJob.php
    relevance: HIGH
    why: "ProcessEvereeWebhookJob extends BaseJob; payroll-* jobs in later phases (PTO accrual, daily reconciliation) follow the same contract."

  - pattern: userfrosting/migrations/input/*.json + conductor migration JSON shape
    relevance: CRITICAL
    why: "Every schema change in this spec — 12 new tables + 5 extensions + 1 permissions migration — follows the existing JSON-with-check_query idempotent shape."

  - pattern: userfrosting/src/BuyerKiosk/Compatibility/LegacyAliases.php  (class_alias autoloader)
    relevance: HIGH
    why: "Employee class consolidation re-targets the existing entry rather than introducing a new shim mechanism."

  - pattern: userfrosting/migrations/input/20251220_013_010_schedule_permissions.json
    relevance: HIGH
    why: "Template for the new payroll permissions migration. (group_id, hook, conditions) triples into uf_authorize_group."

  - pattern: BuyerKiosk\TeamMember\Services\RoleConfigService DEFAULT_ROLES
    relevance: MEDIUM
    why: "Read-only reference for permission key cross-walk documentation; not modified."

new_patterns_created:
  - pattern: docs/patterns/payroll-token-encryption.md (NEW)
    relevance: CRITICAL
    why: "Records the EvereeTokenStorage design — fail-closed encrypt, lenient decrypt, separate master key per integration, model-level scrubbing of *Encrypted fields. Companion to QBO's implicit pattern."

  - pattern: docs/patterns/payroll-rate-history-append-only.md (NEW)
    relevance: CRITICAL
    why: "Documents the append-only invariant, the (effectiveFrom, id) tie-break, the tombstone convention, and the application-level + regression-test + future-DB-trigger enforcement stack. Most-load-bearing pattern in this spec."

  - pattern: docs/patterns/payroll-webhook-hmac-rotation.md (NEW)
    relevance: HIGH
    why: "Documents the multi-candidate-secret verification model + the rotation-window procedure for swapping HMAC secrets without losing in-flight events."

  - pattern: docs/patterns/payroll-permission-key-cross-walk.md (NEW)
    relevance: HIGH
    why: "Records the mapping BK role names (Owner / Manager / Shift Lead / Employee) ↔ uf_authorize_group group_ids (9 / 7 / 8 / 1) plus the 12 permission key → role default mappings."

  - pattern: docs/patterns/taskengine-payroll-audit.md (NEW; PRD F12 deliverable)
    relevance: HIGH
    why: "Audit of existing TaskEngine queues/workers/retry semantics with gap-analysis for payroll webhook processing and future PTO-accrual / daily-reconciliation jobs. Hard gate before Feature 8's async-AC is exercised (PRD F12)."
```

### Interface Specifications

```yaml
external_interfaces:
  - interface: docs/interfaces/everee-api.md (NEW; PRD F5 deliverable)
    relevance: CRITICAL
    sections: [auth_headers, base_url_config, endpoints_used_in_phase_1a, idempotency_keys, retry_policy, error_taxonomy, webhook_event_types, hmac_signature_format]
    why: "Single source of truth for the outbound contract — what BK guarantees and what BK requires from Everee. Authored alongside EvereeApiClient."

  - interface: Everee public developer docs (https://developers.everee.com/docs)
    relevance: CRITICAL
    why: "Authoritative source for the Everee side of the contract. SDD's docs/interfaces/everee-api.md mirrors and extends only the subset BK consumes."
```

### System-Wide Patterns

- **Security:**
  - **AuthN at controller layer** — UF session cookie + `$app->user->checkAccess('manage_payroll' | 'set_pay_rate' | 'view_pay_run' | ...)`. Webhook endpoint is HMAC-gated, no UF session.
  - **AuthZ scoping** — Every admin endpoint that mutates state ALSO requires `checkStoreGroup(typeNum)` + `SchedulingProviderGate::assertAllowed(typeNum, ...)`. Three layers (UF perm + store membership + scheduling-provider exclusion); each is independent.
  - **Encryption at rest** — Tenant API tokens + per-tenant webhook secrets stored as base64(IV + AES-256-CBC ciphertext). Master key in env var, separate from QBO's. Plaintext NEVER persists; never appears in logs / exceptions / JSON serialization.
  - **Encryption in transit** — Everee API + webhook receiver are HTTPS-only. ngrok dev tunnel terminates TLS; production is behind nginx + Let's Encrypt.
  - **Cross-DB references enforced at application layer** — `userPayrollProfiles.userId` and `payRateHistory.userId` reference `kiosk_users.users.id` but have no DB-level FK (cross-DB constraints don't exist in MySQL). Validation happens in service constructors / setRate input checks.

- **Error Handling:**
  - **Two-tier strategy** — service classes raise typed exceptions; route handlers catch them and translate to HTTP responses per the Cycle C error matrix.
  - **`\Throwable` catch at route boundaries** — never `\Exception` (CON-7 + memory `PHP 8.5 Throwable Gotcha`). PHP 8 `\TypeError` / `\ValueError` must surface as 500 with correlation ID, not be silently swallowed.
  - **Slim `\Slim\Exception\Stop` propagates** — per memory `slim2-stop-exception-swallowed`. Any service-level try/catch carefully filters Stop through.
  - **Webhook errors are 4xx, never 5xx** — Everee's retry machinery treats 5xx as transient and re-fires; 401 (HMAC fail) tells Everee "this signature was bad, do not retry" which is what we want for spoofed traffic. Internal 5xx-bugs in our webhook handler raise `\Throwable` and return 500 — those are real bugs, not legitimate Everee retries.
  - **Audit-log-on-error** — every service-level exception emits a `payroll.*` audit entry at high severity. Drift between "I saw this fail" and "the system recorded it" is impossible.

- **Performance:**
  - **No new caching layer.** Existing `StoreController` Redis cache for `Store` objects is reused unchanged. Payroll-tenant rows are read from DB on every request (low cardinality + low frequency; caching adds risk without measurable win).
  - **Async-by-default for webhook payloads.** Synchronous ingestion does HMAC + INSERT + dispatch only. All per-event-type processing is async via TaskEngine.
  - **Composite index on `payRateHistory`** `(userId, payrollTenantId, positionId, effectiveFrom DESC, id DESC)` — supports `getRate` in O(log n) with constant-time tie-break. Phase 1b's pay-run code (which iterates many users × positions) will hit this index hot.
  - **UNIQUE constraint as dedupe primitive** instead of pre-INSERT SELECT (TOCTOU-safe, single round trip).
  - **No batching** in Phase 0/1a. Webhook ingestion is one-event-at-a-time. Phase 1b/1c may add bulk operations on top (e.g., bulk worker sync); foundation supports either approach.

- **i18n / L10n:**
  - Phase 0/1a is engineer-facing only — no merchant-visible strings ship. PRD's `note` field on `payRateHistory` stores raw text in whatever encoding the operator typed (UTF-8 at the DB layer per the engine+charset rule). No translation infrastructure invoked.

- **Logging / Auditing:**
  - **Two-stream model** — `payrollAuditLog` (structured, queryable, IRS-retention) vs application logs (`logs/error_log`, `logs/buyerkiosk_com.php.error.log`, `logs/task-worker.log` for unstructured operational signal).
  - **Every state-changing service call writes an audit entry.** Every external API call (Everee REST) writes a `payroll.api.everee_call` entry — sampled if volume becomes a problem (Phase 1b decision).
  - **Audit log is APPEND-ONLY in spirit** — no DELETE path; UPDATE only for `processedAt` markers on webhook events. Schema doesn't enforce, but `PayrollAuditRepository` exposes no `update`/`delete`.
  - **Correlation IDs** — every request gets a UUID v4 correlation ID propagated through controllers → services → audit `metadata.correlationId`. Webhook async jobs preserve the originating request's correlation ID via the payload.

### Multi-Component Patterns

**Not applicable.** Phase 0/1a is single-component (the BuyerKiosk PHP monolith). No inter-service communication, no service discovery, no distributed transactions. The TaskEngine queue is in-process from the writer's perspective (one DB) and worker-process from the reader's perspective (same DB, async); no message-bus / saga / circuit-breaker pattern applies. Mobile apps are unaffected (Cycle D §Deployment).

### Implementation Patterns

#### Code Patterns and Conventions
- **PSR-4 autoloading** under `BuyerKiosk\Payroll\` — no manual `require_once`. Composer's `autoload` map already covers `BuyerKiosk\` (CLAUDE.md).
- **camelCase columns + camelCase PHP fields**; never snake_case in new code. PHP 8 `readonly` for VO fields where the model is immutable.
- **Constructor-injected dependencies** — every service class takes its repos / collaborators in the constructor; no static singletons, no service locator. Mirrors `QuickBooksService`, `ScheduleAuditRepository`, `GoalForecastComputeJob`.
- **Method visibility** — controller methods are public; service-class methods that callers should invoke are public; HTTP-request-shape detail (header parsing, body encoding) is private. Encryption methods on `EvereeTokenStorage` are public to service-layer callers only (no controller calls them directly).
- **Naming** — Service classes end in `Service` (`PayRateService`, `PayrollAuditService`); repositories in `Repository`; controllers in `Controller`; jobs in `Job`; exceptions in `Exception`. Mirrors every existing BuyerKiosk module.
- **One class per file** under PSR-4. No multi-class files.
- **Type declarations on every method** — PHP 8.x strict types; nullable types explicit.
- **No raw SQL outside repositories.** Service classes call repos; repos issue PDO statements with named parameters.

#### State Management Patterns
- **No in-memory state across requests.** Every request is fresh; nothing carries between them except DB rows + Redis cache (Stores only — see Performance).
- **Service classes are stateless once constructed.** Constructor injection sets collaborators; method calls are pure with respect to instance state.
- **PDO-managed transactions only where multi-statement consistency matters.** Examples: tenant provisioning (INSERT tenant + UPDATE store FK + audit-log entry → ONE transaction); webhook ingestion (INSERT event → audit-log entry → enqueue job — three operations but the enqueue is the queue's responsibility for durability, so the DB transaction wraps INSERT + audit only).
- **Caching boundary** — `StoreController` (existing, Redis-backed) caches `Store` objects including the new `payrollTenantId` column. **Cache invalidation on `payrollTenantId` change is the responsibility of whoever updates `stores`** (the existing `StoreController::updateStore` invalidation path covers it). `PayrollTenant` itself is NOT cached.

#### Performance Characteristics
- **Webhook synchronous ingest stays under 250 ms p95.** Anything heavier moves to the async job.
- **Async webhook processing isolates per-event failures** — `BaseJob` catches `\Throwable`, marks the row with `processingError`, leaves `processedAt=NULL` for the next worker pass.
- **TaskEngine retry policy** — `maxRetries=3`, `retryBackoff=60` seconds. Stored per-job-definition row; can be tuned per-job without code change.
- **`payRateHistory` reads are point-lookups** via the composite index. Even at Phase 1b's expected ~100 workers × ~3 positions × ~24 rate changes/year × 5 years = ~36,000 rows per pilot store, the index keeps `getRate` to a few key compares.
- **No connection pool changes.** Existing PDO usage via `BaseModel::dbConnectByName(...)` is reused.

#### Integration Patterns
- **REST via PHP `curl_*` or Guzzle (whichever existing project convention dictates).** `EvereeApiClient` constructor accepts an `?HttpClient` for test injection so unit tests use a fixture-replay client.
- **Idempotency keys passed as `Idempotency-Key` HTTP header on every POST when partner confirms support** (PRD F5 + §13 item 3). Internal idempotency key naming: `'webhook:' + evereeEventId` for the async job; `'prov:' + ein` for tenant provisioning; future patterns documented as added.
- **Webhook receiver pattern** — public route (no UF session) + signature header + raw-body HMAC compute. Mirror in `routes/api/payroll.php`. **Do NOT use Slim's standard JSON body parser** — read `$app->request->getBody()->getContents()` directly so the HMAC verification operates on the exact bytes Everee signed.
- **Async fan-out via TaskEngine** — webhook handler enqueues one job per ingested event. No batching, no aggregation; Phase 1b/1c may add aggregation for high-volume event types.

#### Component Structure Pattern

```pseudocode
# Slim 2 route binds middleware + controller method
ROUTE: POST /api/payroll/admin/:typeNum/rates
  MIDDLEWARE: [authn, checkAccess('set_pay_rate'), checkStoreGroup, SchedulingProviderGate]
  CONTROLLER: PayrollAdminController::setRate($typeNum)
    1. Parse JSON body via $app->request->getBody()
    2. Validate input shape (required fields, enum values, > 0 / non-empty)
    3. Resolve dependencies (PayRateService from container)
    4. Invoke $rateSvc->setRate(...)
    5. ON success: return JSON {rateHistoryId, backdated}
    6. ON typed exception: catch + map to HTTP code per error matrix
    7. ON \Throwable: catch + log + return 500 with correlationId

# Service class composes repository + audit
SERVICE: PayRateService
  CONSTRUCTOR: (PayRateHistoryRepository, PayrollAuditService)
  METHOD setRate(...):
    1. VALIDATE invariants raised by inputs (rateCents > 0, note non-empty)
    2. SNAPSHOT previous active rate (for audit before/after)
    3. INSERT new row via repo (one DB statement)
    4. COMPUTE backdated flag
    5. EMIT audit entry (severity=high iff backdated)
    6. RETURN immutable PayRateEntry

# Repository owns DB statements only
REPOSITORY: PayRateHistoryRepository
  insert(entry): one PDO prepared statement, named params
  findGreatestEffectiveFromAtOrBefore(...): one SELECT, ORDER BY DESC, LIMIT 1
  NO update(...) / delete(...) — append-only invariant (ADR-2)
```

#### Data Processing Pattern

```pseudocode
# Webhook event end-to-end (sync + async halves shown together for clarity)

# === SYNCHRONOUS (request thread) ===
function handlePost(req):
  raw      := req.body.bytes
  headers  := req.headers
  outcome  := EvereeWebhookHandler.ingest(raw, headers)   # see Algorithm 2 + 3
  return json({received: true, duplicate: outcome.duplicate}, status: 200)

# === ASYNCHRONOUS (TaskEngine worker) ===
function handle():
  event := PayrollWebhookEventRepository.find(payload.webhookEventId)
  try:
    switch event.evereeEventType:
      case 'worker.created':
        UserPayrollProfileRepository.upsertFromWorkerCreated(event.payload)
      case 'worker.profile-updated':
        UserPayrollProfileRepository.applyProfileUpdate(event.payload)
      case 'worker.onboarding-completed', 'worker.onboarding-locked':
        UserPayrollProfileRepository.applyOnboardingStatus(event.payload)
      case 'worker.tin-verification-status-changed':
        UserPayrollProfileRepository.applyTinStatus(event.payload)
      case 'worker.deleted':
        UserPayrollProfileRepository.applyLifecycleStatus(event.payload, 'terminated')
      case 'worker.new-tax-forms-available':
        log.info('Phase 1c handler; deferred')   # documented no-op (PRD F8 AC)
      case 'payment.paid', 'payment.deposit-returned',
           'payment.updated-payment-method', 'payment-payables.status-changed':
        log.info('Phase 1b handler; deferred')   # documented no-op
      default:
        log.warn('Unrecognized event type', event.evereeEventType)
    PayrollWebhookEventRepository.markProcessed(event.id)
    PayrollAuditService.logWebhookProcessed(event)
    return JobResult.success()
  catch (\Throwable err):
    PayrollWebhookEventRepository.markFailed(event.id, err.message)   # leaves processedAt=NULL
    PayrollAuditService.logWebhookProcessingFailed(event, err.message, attempt)
    return JobResult.failure(err.message)   # TaskEngine handles retry per JobDefinition
```

#### Error Handling Pattern

```pseudocode
# CLASSIFY (in service classes; raised as typed exceptions)
function classifyEvereeResponse(resp):
  if 200 <= resp.status < 300: return SUCCESS
  if resp.status in [401, 403]:                    raise EvereeAuthException(resp)
  if resp.status == 429:                            return HANDLE_429
  if resp.status >= 500 or resp.transport_error:   return RETRY_OR_FAIL
  if resp.status >= 400:                            raise EvereeValidationException(resp)

# LOG (in service-class catch blocks and route boundary)
function log(error, context):
  application_log.error(error.message, context: {correlationId, payrollTenantId, action})
  PayrollAuditService.log<Event>(...)             # depending on the action that raised

# RECOVER (route handler translates service exception → HTTP response)
function translateToHttp(exception):
  switch type(exception):
    EvereeAuthException:                  return 502 {error:'everee_auth_failed'}
    EvereeValidationException:            return 400 {error:'bad_request', details: exception.body}
    EvereeRateLimitException:             return 502 {error:'rate_limited'}
    EvereeUncertainStateException:        return 502 {error:'everee_uncertain_state'}
    TokenDecryptionException:             return 502 {error:'token_unreadable'}
    EvereeEncryptionRequiredException:    return 500 {error:'encryption_misconfigured'}    # deployment bug; not a normal error
    PayRateImmutableException:            return 500 {error:'internal_error'}              # code bug
    WebhookSignatureException:            return 401 {error:'invalid_signature'}
    SchedulingProviderGateException:      return 403 {error:'scheduling_provider_excluded'}
    \Slim\Exception\Stop:                 PROPAGATE (do not catch)
    \Throwable (catch-all):               return 500 {error:'internal_error', correlationId}

# RESPOND (always JSON; webhook-receiver uses lighter shape)
default_error_shape: {error, message, details?, correlationId}
webhook_error_shape: {error}     # Everee's retry machinery parses this specific key
```

#### Test Pattern

```pseudocode
# Three test tiers correspond to the three concerns

TIER 1: UNIT (tests/Unit/Payroll/Services/*Test.php)
  TEST_SCENARIO: "Service raises typed exception on bad input"
    SETUP: mock repo + mock audit
    EXECUTE: $svc->setRate(rateCents: 0, note: '')
    VERIFY: \BuyerKiosk\Payroll\Exceptions\EvereeValidationException raised
            audit.logRateSet NOT called

  TEST_SCENARIO: "EvereeApiClient honors 429 Retry-After once"
    SETUP: fixture HTTP client returning 429+Retry-After then 200
    EXECUTE: $client->createWorker(...)
    VERIFY: 2 HTTP calls made; first slept retry-after-ms; result returned

TIER 2: INTEGRATION (tests/Integration/Payroll/*Test.php)
  TEST_SCENARIO: "Webhook dedupe via UNIQUE constraint round-trip"
    SETUP: real DB connection, fixtures cleared
    EXECUTE: handler.ingest(rawBody, headers) twice with same evereeEventId
    VERIFY: first call returns duplicate=false, inserts row;
            second call returns duplicate=true, raises no exception, no second row

  TEST_SCENARIO: "PayRateHistory rejects raw UPDATE attempts"
    SETUP: real DB, one rate inserted
    EXECUTE: guardedPdo()->exec("UPDATE payRateHistory SET rateCents = 9999 WHERE id = :id")
    VERIFY: PayRateImmutableException raised; original row unchanged

TIER 3: FIXTURE-REPLAY (tests/Unit/Payroll/Services/EvereeApiClientTest.php)
  TEST_SCENARIO: "Recorded Everee responses replay deterministically"
    SETUP: load fixtures from tests/Fixtures/Payroll/everee-response-*.json
    EXECUTE: client.<endpoint>(...) with fixture-injected HTTP client
    VERIFY: success / 4xx / 5xx-retry / 429 / idempotency-key paths each green
            NO outbound network traffic (gate: PRD F5 Phase 1a sign-off AC)
```

<!-- Component / Data Processing / Error Handling / Test Patterns are documented in the preceding pseudocode blocks
     ("Component Structure Pattern", "Data Processing Pattern", "Error Handling Pattern", "Test Pattern" above).
     The duplicate template stubs that originally lived here have been intentionally removed. -->

### Integration Points

- **Connection Points:**
  - `BuyerKiosk\Security\Encryption` reused verbatim by `EvereeTokenStorage` (Cycle A ICO-2; identical primitive QBO already uses).
  - `BuyerKiosk\TaskEngine\Application\JobDispatcher` consumed by `EvereeWebhookHandler` to enqueue `ProcessEvereeWebhookJob`.
  - `BuyerKiosk\Compatibility\LegacyAliases` one-line modification re-targets `Employee` → `BuyerKiosk\Employee\Employee` (Phase 0 prereq; PRD F1).
  - `kiosk_users.uf_authorize_group` table receives 12 new permission rows via one migration JSON (PRD F10).
  - `kiosk_buykiosk.stores` table gets `payrollTenantId` nullable FK; the existing `StoreController` Redis-cache invalidation path covers it for free.
  - `kiosk_users.users` extended with PII columns; existing user-loading code paths are unaffected (nullable columns).
  - `BuyerKiosk\Core\Store` model gains one new accessor (`getPayrollTenantId()`); no new business behavior.
- **Data Flow (in):**
  - HTTP POSTs from BK Engineer / CS Operator to `/api/payroll/admin/...` endpoints (UF-session-authenticated, store-scoped).
  - HTTP POSTs from Everee to `/api/payroll/webhook/everee` (HMAC-authenticated, no UF session). 11 event types enumerated in PRD F8.
- **Data Flow (out):**
  - HTTPS calls to Everee REST API (sandbox only in this scope; per-tenant Basic auth + tenant header).
  - JSON responses to admin endpoints (controller-rendered; `*Encrypted` fields scrubbed by `jsonSerialize`).
- **Events triggered:**
  - `payroll.tenant.provisioned`, `payroll.tenant.token_rotated`, `payroll.rate.set`, `payroll.rate.read` (sampled), `payroll.rate.retired`, `payroll.webhook.received`, `payroll.webhook.deduplicated`, `payroll.webhook.processed`, `payroll.webhook.processing_failed`, `payroll.api.everee_call`, `payroll.permission.denied`, `payroll.scheduling_provider_gate.rejected` — all into `payrollAuditLog`.
- **Events consumed:**
  - Everee webhooks (11 event types). Per-event handlers in Phase 1a are stubs that update `userPayrollProfiles` for worker.* events and log/no-op for payment.* events (deferred to Phase 1b/1c per PRD F8 AC).
  - TaskEngine job pool dispatch: `process-everee-webhook` job listens on `default` queue.

## Architecture Decisions

The following ADRs are the formal record of architectural choices made by this SDD. **Each requires user confirmation before implementation begins.** The user-confirmation checkboxes are flipped during the Cycle D review step (Step 5 of the spec process).

> **Confirmation key:** Each ADR ships with a confirmation state. **All ADRs in this section were confirmed by the user on 2026-05-23.** An additional ADR (ADR-11) was added after the Codex SDD review and is also confirmed.

---

### ADR-1: Reuse `BuyerKiosk\Security\Encryption` verbatim with a separate master key per integration

- **Choice made:** Phase 0/1a uses the EXISTING `BuyerKiosk\Security\Encryption` class (OpenSSL AES-256-CBC, random-IV-prepended-base64) with a NEW master key sourced from env var `EVEREE_ENCRYPTION_KEY` via a NEW `config/everee-encryption.php` file mirroring `config/qb-encryption.php`. No new crypto library, no algorithm change.
- **Alternatives considered:**
  1. Adopt authenticated encryption (AES-256-GCM, libsodium `crypto_secretbox`) for Everee even though QBO uses CBC. Rejected for Phase 0/1a: introduces a second cryptographic primitive in the same codebase, broadens the security review surface for marginal gain when QBO's CBC is already audit-accepted.
  2. Share the QBO master key (`QB_ENCRYPTION_KEY`) across both integrations. Rejected: rotating one integration's key would force-rotate the other, coupling unrelated incident-response surfaces.
  3. Use a vault service (HashiCorp Vault / AWS Secrets Manager). Rejected: introduces new infrastructure for one secret; out of project scope.
- **Rationale:** Mirrors the proven QBO pattern exactly (security review stays narrow); separate keys decouple rotation; new env var is one deployment step. Upgrade to authenticated encryption is captured as a follow-up item, not blocked.
- **Trade-offs:** CBC mode is unauthenticated — corrupted ciphertext silently produces garbage on decrypt. Mitigated by `TokenDecryptionException` raised when decrypt returns `false` (the BuyerKiosk `Encryption::decrypt` API returns `false` on failure; we explicitly check). Also accepted: a master-key compromise in either integration is independent, but rotation requires re-encrypting every affected row.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-2: Append-only `payRateHistory` enforced at the application layer (with regression test); DB-level defense documented as follow-up

- **Choice made:** The `PayRateHistoryRepository` exposes only `insert`, `findGreatestEffectiveFromAtOrBefore`, and `listOrdered` — no `update`, no `delete`, no `upsert`. The Payroll module obtains its central-DB PDO via the DI container, which **always wraps it in a `GuardedPdo` subclass** (in BOTH production and tests). `GuardedPdo::exec` and `GuardedPdo::prepare` scan the SQL string for `UPDATE`/`DELETE` against `payRateHistory` and raise `PayRateImmutableException` before the query hits MySQL. An integration test (`tests/Integration/Payroll/RateHistoryAppendOnlyTest.php`) asserts the production-wired guard raises on attempted UPDATEs. The DB-level defenses (a `BEFORE UPDATE` trigger raising `SIGNAL SQLSTATE '45000'` OR a `REVOKE UPDATE, DELETE ON payRateHistory FROM <app_user>` grant change) are documented in `docs/patterns/payroll-rate-history-append-only.md` as recommended follow-up work, NOT blocking Phase 1a sign-off.
- **Alternatives considered:**
  1. DB trigger as the primary enforcement. Rejected for Phase 1a: triggers introduce a separate migration class that the conductor's `check_query`-based idempotency pattern doesn't yet cover cleanly; we'd have to extend conductor before we can ship.
  2. MySQL `REVOKE UPDATE` as the primary enforcement. Rejected: requires environment-specific grant plans (different DB users in local dev vs production) that we don't yet have; would block Phase 1a on operational work.
  3. Trust code review alone. Rejected: doesn't satisfy PRD F7 AC "schema-level UPDATE attempts on `payRateHistory` are either DB-prevented (trigger or REVOKE) or covered by an explicit application-level guard WITH A REGRESSION TEST" — the regression test is required.
- **Rationale:** Application-level enforcement gives us the PRD-required regression test now. The DB-level defense is a defense-in-depth layer that can ship in Phase 1b/1c without re-litigating this decision. Application enforcement also gives clearer error messages (`PayRateImmutableException` with stack trace) than the SQL-state signal a trigger would emit.
- **Trade-offs:** A malicious or accidental raw-PDO `UPDATE` outside the `GuardedPdo` wrapper would succeed. Mitigated by: (a) all payroll-related DB connections inside `BuyerKiosk\Payroll\` use `GuardedPdo`, (b) PHPStan custom rule (Phase 0 stretch) flags any payroll service constructing a non-guarded PDO, (c) future DB-level defense closes the gap permanently.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-3: Webhook secret model — per-tenant default with global fallback via config switch; column exists either way

- **Choice made:** `payrollTenants.webhookSecretEncrypted` ships in Phase 0 regardless of partner answer. A config-only env var `EVEREE_WEBHOOK_SIGNING_MODE` (`per_tenant` by default; alternative `global`) switches which secret the verifier reads. If `per_tenant`, the verifier resolves tenant via `x-everee-company-id` header and decrypts the per-tenant secret; if `global`, it reads `EVEREE_WEBHOOK_GLOBAL_SECRET` env var. Switching modes requires only an env update and a worker restart — no schema change, no data migration.
- **Alternatives considered:**
  1. Wait for partner to confirm before adding the column. Rejected: blocks Phase 0 schema work on a partner-conversation we don't control timing of (PRD risk row); deferring the column risks a destructive migration if the answer is "per-tenant."
  2. Ship the column only IF partner confirms per-tenant. Rejected: same partner-blocking risk.
  3. Hardcode per-tenant and let global signing be a future migration. Rejected: makes the code path unreachable without re-deploying; doesn't honor PRD's "reversible without schema change" goal.
- **Rationale:** Cheap insurance — a single nullable column add costs nothing schema-wise. Config switch is the simplest reversibility primitive available. Defers the partner answer to a runtime decision.
- **Trade-offs:** When `global` mode is active, `payrollTenants.webhookSecretEncrypted` columns will be NULL across all rows — slightly wasteful as schema bloat. Acceptable: column is single nullable INT-equivalent (VARCHAR(512)) per tenant, and tenant cardinality is low (one row per EIN).
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-4: TaskEngine `default` queue initially; dedicated `payroll-webhooks` queue is Feature 12 audit's call

- **Choice made:** `ProcessEvereeWebhookJob::getQueue()` returns `'default'` in Phase 1a. The Feature 12 audit (TaskEngine infrastructure deliverable; PRD F12) MAY recommend a dedicated `payroll-webhooks` queue — if so, the change is a one-line edit in the job class + a worker config update, executed before Feature 8's async-AC is exercised (which is the gate for Phase 1a sign-off).
- **Alternatives considered:**
  1. Dedicated queue from day one. Rejected: premature; we don't yet have empirical evidence webhook traffic will starve other jobs OR be starved by them.
  2. Per-tenant queue. Rejected: queue cardinality grows with tenants; overkill for the expected volume.
- **Rationale:** Smallest-step approach. The Feature 12 audit is a Phase 0 deliverable that exists precisely to make this call; we trust its output.
- **Trade-offs:** If the audit DOES recommend a dedicated queue, we ship a follow-up edit and operational change. Worst case: webhook processing competes with goals / scheduling / QBO jobs for worker slots during a payment-event burst; the existing `maxRetries=3` + `retryBackoff=60` already handles transient starvation.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-5: HMAC algorithm — `HMAC-SHA256` (subject to partner confirmation, with verifier supporting multi-secret rotation)

- **Choice made:** The HMAC implementation uses `hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret)`. Industry standard for webhook signing (Stripe, Square, GitHub all use this shape). If partner specifies a different algorithm (`sha512`, `sha1` — unlikely but possible), the change is one-line. The verifier always supports a multi-candidate-secret list to enable rotation without service interruption.
- **Alternatives considered:**
  1. Wait for partner confirmation. Rejected: blocks Phase 1a webhook work on partner conversation.
  2. Use a more exotic primitive (`HMAC-BLAKE2b`, Ed25519 signatures). Rejected: not industry-standard; partner unlikely to support; adds dependency.
- **Rationale:** SHA-256 is the safe default; partner has flagged HMAC algorithm + secret-rotation as §13 confirmation items but not as constraints. Pre-committing to SHA-256 lets implementation proceed; partner answer is consumed via a one-line algorithm change if needed.
- **Trade-offs:** If partner specifies SHA-1 (deprecated), we accept and update. If partner specifies SHA-512, we accept. The cryptographic guarantee is identical for our threat model.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-6: REST client implementation — plain HTTP client + custom retry/backoff, NOT a circuit-breaker library

- **Choice made:** `EvereeApiClient` implements its retry/backoff/429-handling/idempotency-key logic inline (per Cycle C Implementation Example 2). No `Ackintosh\Snidel`, no `php-circuit-breaker`, no Polly-equivalent. The HTTP transport itself uses whatever library is already available in the BuyerKiosk codebase (curl wrapper via existing `BaseModel` patterns OR Guzzle if already vendored — implementation chooses at PR time).
- **Alternatives considered:**
  1. Adopt a circuit-breaker library (e.g., `php-circuit-breaker`). Rejected: introduces a new vendored dependency for one client; the retry-and-fail-loudly pattern is simpler and sufficient.
  2. Reuse the QBO HTTP client primitive. Rejected: QBO uses an SDK; Everee has no published PHP SDK as of Phase 0 (per §13 item 6 — partner-confirmable).
- **Rationale:** Retry/backoff logic is ~80 lines (see Cycle C Example 2). A library would be more weight than value at Phase 0/1a volume. If volume justifies a circuit-breaker in Phase 1c, it can be introduced then.
- **Trade-offs:** Phase 1b/1c may need to add circuit-breaker semantics (open-state to stop hammering a dead partner). Recorded as a follow-up; not blocking.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-7: Idempotency-Key on Everee POST endpoints — best-effort, with documented residual-risk if partner unavailable

- **Choice made:** `EvereeApiClient` ALWAYS supports `Idempotency-Key` HTTP header on POST. Service classes generate keys via `'<prefix>:<stable_input>'` convention (e.g., `'prov:' + ein` for provisioning, `'create_worker:' + userId + ':' + tenantId` for worker create). If partner confirms idempotency-key support (§13 item 3), Everee dedupes; if partner cannot support, the header is sent but ignored partner-side and the team accepts the residual risk that a network timeout mid-POST could double-create. Phase 1b's pay-run-submit path will use a pre-flight "is this run already on Everee?" check to mitigate (out of scope here).
- **Alternatives considered:**
  1. Do not send the header until partner confirms. Rejected: requires deploy/no-deploy switch later.
  2. Block Phase 1a on partner answer. Rejected: §13 explicitly says "affects Feature 5 implementation, not blocking."
- **Rationale:** Header is free to send; if accepted, we get partner-side dedupe; if ignored, we lose nothing.
- **Trade-offs:** Documented residual risk on mid-POST timeouts when key isn't honored; captured in `EvereeUncertainStateException` (Cycle C error matrix).
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-8: `BuyerKiosk\Payroll\` is the SINGLE new namespace; no code lands in `Core\` or `Employee\` beyond consolidation

- **Choice made:** All Phase 0/1a code lives under `BuyerKiosk\Payroll\` (with `Services\`, `Controllers\`, `Models\`, `Repositories\`, `Exceptions\`, `Jobs\` sub-namespaces). The Employee consolidation is the only exception — it modifies `Core\Employee.php` (shrink/delete) and `Compatibility\LegacyAliases.php` (re-target) per PRD F1.
- **Alternatives considered:**
  1. Split across multiple namespaces (`BuyerKiosk\Everee\` + `BuyerKiosk\PayRate\` + `BuyerKiosk\Payroll\`). Rejected: artificial fragmentation; the three areas are coherently one feature.
  2. Land payroll classes in `BuyerKiosk\Core\`. Rejected: `Core\` already has too many responsibilities; adding payroll dilutes the namespace.
- **Rationale:** Mirrors `BuyerKiosk\QuickBooks\`, `BuyerKiosk\MobileScheduling\`, `BuyerKiosk\Goals\` — established BuyerKiosk convention.
- **Trade-offs:** Renaming the namespace later (if "Payroll" turns out to be the wrong umbrella) requires a one-shot PSR-4 rename. Recorded; not blocking.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-9: Permissions live in `uf_authorize_group`; ONE migration ships all 12 keys

- **Choice made:** The 12 payroll permission keys (PRD F10) ship as one migration JSON (`20260522_016_payroll_permission_keys.json`) inserting (group_id, hook, conditions) triples into `kiosk_users.uf_authorize_group`. Enforcement at controller level via `$app->user->checkAccess('manage_payroll')` etc. Cross-walk between BK role names (Owner/Manager/Shift Lead/Employee) and UF group IDs (9/7/8/1) is documented in `docs/patterns/payroll-permission-key-cross-walk.md` (Phase 0 deliverable).
- **Alternatives considered:**
  1. One migration per permission key (12 separate files). Rejected: noise; conductor handles bundle ops fine.
  2. Code-based permission registry (per the agent-audit's suggestion of a "permission registry class"). Rejected: not how BuyerKiosk does it today; would require a new abstraction.
  3. Embed permission key declarations in `RoleConfigService`. Rejected: per the existing audit, that service handles role names not permission keys.
- **Rationale:** Mirrors existing scheduling-permissions migration shape (ICO-5).
- **Trade-offs:** None notable; this is operationally identical to past permission additions.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-11: Defense-in-depth — `EvereeWebhookPayloadRedactor` + partner-level provisioning auth + per-tenant prior-secret column with expiry

- **Choice made:** Three independent decisions consolidated into one ADR because all three emerged from the Codex SDD review:
  1. **Inbound webhook payloads are REDACTED at ingestion** for any PII-shaped field that should never enter BK (CON-11). `EvereeWebhookPayloadRedactor::redact($payload)` runs after HMAC verification (the HMAC is computed against the original bytes) and before DB INSERT. Forbidden top-level OR nested keys (SSN-shaped / bank-shaped / W-4/I-9-shaped per CON-11) are replaced with `'<REDACTED-PII>'` so the audit trail preserves fact-of-presence without retaining the value. A high-severity application log entry fires on each redaction so Everee-side drift surfaces.
  2. **Tenant provisioning uses a partner-level API token** (`EVEREE_PARTNER_API_TOKEN` env var) for the initial `createCompanyInstance` call only — there is no per-tenant token at that moment. All subsequent calls use the per-tenant token. If partner only supports portal-based Company Instance creation, `EvereeProvisioningService::provisionManually` accepts pre-existing IDs from a documented manual portal seam.
  3. **HMAC rotation windows are tracked per tenant in the schema** via `payrollTenants.webhookPriorSecretEncrypted` + `payrollTenants.webhookPriorSecretExpiresAt`. The verifier accepts signatures under either the current secret or the prior secret while the expiry timestamp is in the future. An operator-run cleanup script clears expired prior secrets. Outside the window, only the current secret is accepted.
- **Alternatives considered:**
  - Redactor: relying solely on CON-11 prohibition and code review. Rejected: documents intent but doesn't enforce it; a single payload-shape change from Everee could store SSN in our DB. Defense-in-depth is cheap.
  - Partner-level auth: gate Phase 1a on partner-confirms-API-self-serve. Rejected: §13 explicitly says self-serve provisioning is partner-confirmation-gated, and our locked decision is to proceed without that gate.
  - Rotation columns: store prior secret in env vars (one global). Rejected: per-tenant rotation should not be coupled to global env rollouts; per-tenant columns + expiry are the natural model.
- **Rationale:** All three are defense-in-depth additions that close gaps the Codex review surfaced. None modifies a previously-confirmed ADR; each composes cleanly with the existing design.
- **Trade-offs:** Three small additions (one service, one env var, two columns) for substantial blast-radius reduction. Schema bloat is two nullable VARCHAR columns + one nullable DATETIME on a low-cardinality table; service complexity is one extra step in webhook ingestion and one extra method on the provisioning service.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

### ADR-10: PII source-of-truth labeled in migration descriptions; no separate `pii_columns` metadata table

- **Choice made:** Each new column on `kiosk_users.users` (legalFirstName, dob, address fields, phoneE164) carries its PII source-of-truth category (BK_CANONICAL / ONBOARDING_PREFILL / NEVER_ENTERS_BK) in the migration JSON's `description` field. No separate metadata table; no JSON catalog file. The `docs/patterns/payroll-token-encryption.md` and (new) `docs/patterns/payroll-pii-source-of-truth.md` documents reproduce the labels for human consumption.
- **Alternatives considered:**
  1. Separate `column_metadata` table tracking PII status. Rejected: introduces a metadata schema that needs its own maintenance ritual.
  2. PHPDoc annotations on model field declarations. Rejected: not enforced anywhere; would drift from migration ground truth.
- **Rationale:** Migrations are the canonical schema-authoring artifact; labels there are authoritative and version-controlled.
- **Trade-offs:** No machine-readable enumeration. Acceptable; PII-handling decisions are reviewed by humans, not automated.
- **User confirmed:** _Confirmed 2026-05-23 by ryanv2ts_

## Quality Requirements

Quality requirements are anchored to the PRD's Phase 1a exit metrics and the hard rules from CON-10..CON-22. Each requirement names how it is measured — leaving a requirement "validated by code review" without a deterministic check is not acceptable.

### Performance
- **PERF-1 — Webhook synchronous ingest p95 < 250 ms.** Measured by logging `(received_ts, response_ts)` in `payrollAuditLog.metadata` and computing p95 over a 100-event sample (real or fixture-driven). Target reflects Everee retry-machinery sensitivity — slower acks invite duplicate retries.
- **PERF-2 — Webhook async processing p95 < 5 s per event.** Measured by `processedAt - receivedAt` in `payrollWebhookEvents`. Target is generous because per-event handlers are mostly stubs in Phase 1a; tightens in Phase 1b.
- **PERF-3 — Tenant provisioning p95 < 3 s.** Bounded by one Everee API round-trip + 2 DB writes + 1 audit entry. Anything beyond 3 s indicates partner latency that needs CS attention.
- **PERF-4 — `setRate` p95 < 50 ms.** Single indexed INSERT plus audit write. Should never approach this bound; metric exists to detect index regression.
- **PERF-5 — `getRate(asOf)` p95 < 20 ms.** Single indexed SELECT with `LIMIT 1`. Phase 1b's pay-run code will hit this in tight loops.

### Usability
- **N/A — Phase 0/1a ships no merchant-visible UI.** Engineer + CS operator interactions happen via REST + audit-log SQL queries. Usability acceptance lives in Phase 1b.

### Security
- **SEC-1 — Zero plaintext token / secret exposures in logs across a 30-day post-merge window.** Measured by running a grep-based scan over `logs/` for the encrypted-but-decryptable token prefix patterns; expected count: 0.
- **SEC-2 — 100% of webhook requests HMAC-verified (Phase 1a target).** Measured by counting `payrollWebhookEvents.hmacValid=true / total`. Target: 1.000 in production once partner is wired up; sandbox tolerates lower while connectivity stabilizes.
- **SEC-3 — Zero unauthorized payroll-mutation requests succeed.** Measured by counting `payroll.permission.denied` audit entries with `metadata.successAfter=true` — expected count: 0. Any non-zero count indicates a controller missed a `checkAccess` call.
- **SEC-4 — Zero successful payroll mutations from WIW/Homebase stores.** Measured by `payroll.scheduling_provider_gate.rejected` entries cross-referenced against subsequent successful mutations from the same `typeNum` within the same actor session. Target: 0.
- **SEC-5 — Token rotation procedure documented before any production credentials are wired.** Measured by presence of a complete procedure in `docs/patterns/payroll-token-encryption.md`. Hard gate before Phase 1b production credentials provisioning.
- **SEC-6 — IRS retention enforced.** Measured by codebase scan asserting `payRateHistory`, `payrollAuditLog`, `payrollRuns`, `payrollRunLines` have no DELETE callsite in `BuyerKiosk\Payroll\*`. Hard project rule (CON-10).

### Reliability
- **REL-1 — Zero `payRateHistory` UPDATE attempts succeed.** Measured by the `RateHistoryAppendOnlyTest` integration test (must pass in CI) AND by zero `payroll.permission.denied` audit entries with `metadata.violation=append_only` in production over any 30-day window.
- **REL-2 — Webhook dedupe is 100% effective.** Measured by counting distinct `evereeEventId` rows in `payrollWebhookEvents` and asserting zero duplicates. UNIQUE constraint enforces; metric is for monitoring.
- **REL-3 — All migrations are idempotent.** Measured by re-running `php userfrosting/conductor run` on a fresh schema and asserting zero schema changes. Each migration JSON ships with `check_query`; the runner skips applied migrations.
- **REL-4 — Webhook async processing failure does not block subsequent events.** Measured by `BaseJob` isolation property; verified by integration test `WebhookIngestionTest::testFailedEventDoesNotBlockSubsequentEvents`.
- **REL-5 — Schema reapplication is a no-op across dev and production environments.** Measured by `migration_log` consistency — re-applying any migration leaves `status='applied'` (no new row).
- **REL-6 — Token decryption failures degrade gracefully (no fallback to plaintext).** `TokenDecryptionException` is the ONLY outcome; verified by code review + unit test `EvereeTokenStorageTest::testDecryptionFailureRaises`.

### Operability
- **OPS-1 — Every state-changing service call emits a `payrollAuditLog` entry.** Measured by static analysis — any service-class public method that writes (INSERT/UPDATE) but does not invoke `PayrollAuditService::*` is flagged at code review.
- **OPS-2 — Correlation IDs propagate through async boundaries.** Measured by checking `payrollWebhookEvents.metadata.correlationId == ProcessEvereeWebhookJob.payload.correlationId` for a 100-event sample.
- **OPS-3 — Worker logs (`logs/task-worker.log`) record every job start + outcome.** Measured by line count consistency with `payroll.webhook.processed` audit entries.

## Risks and Technical Debt

### Known Technical Issues

These are real, observed limitations in the existing codebase that this spec inherits or works around. Implementers must be aware of each before authoring code that interacts with them.

- **Two parallel `Employee` classes (`BuyerKiosk\Core\Employee` and `BuyerKiosk\Employee\Employee`).** PRD F1 consolidation is a HARD GATE before any Phase 1a work merges (CON-21). Current `LegacyAliases.php` line 32 aliases the unnamespaced legacy `Employee` to `Core\Employee` — the stub, not the canonical 13.5KB modern class. Consolidation flips this alias and shrinks/deletes `Core\Employee.php`.
- **`StoreController` Redis cache holds full Store-as-JSON.** Adding `stores.payrollTenantId` means existing cached Store objects (from before the migration) will return null for `getPayrollTenantId()` until Redis TTL expires or cache is flushed (memory `redis-store-cache-stale-columns` — already extracted as a skill). Mitigation: post-migration ops note to flush the StoreController cache, OR implement code that gracefully treats null as "no payroll tenant attached."
- **No DB-level FK from `kiosk_users.userPayrollProfiles` to `kiosk_buykiosk.payrollTenants`.** Cross-DB FK constraints don't exist in MySQL. Referential integrity is application-layer only (validated in service constructors and via `Repository::findById` guards). Acceptable for Phase 0/1a; documented so future engineers don't assume DB-enforced.
- **PHPStan baseline must stay green.** `cd userfrosting && ./vendor/bin/phpstan analyse` runs at the project's current level. New Payroll classes ship with full type declarations to avoid baseline regression.
- **TaskEngine `getName()` collision risk.** Job names are unique strings; `process-everee-webhook` must not collide with any existing job. Audited via `grep "getName(" userfrosting/src/BuyerKiosk/*/Jobs/`.
- **Slim 2's request body is a stream, not a string.** Webhook HMAC verification requires the EXACT bytes Everee signed — `$app->request->getBody()->getContents()` must be called BEFORE any framework body parsing happens. The `routes/api/payroll.php` webhook route MUST disable default JSON body parsing (or read body before it consumes the stream).
- **Slim 2 `\Slim\Exception\Stop` propagation hazard.** Generic `catch (\Exception)` blocks swallow `Stop` (memory `slim2-stop-exception-swallowed`). Every payroll route MUST use `catch (\Throwable)` and re-throw `Stop` if not a true error.
- **PHP 8.5 `\TypeError` on typed-parameter calls.** A controller passing a domain object where `?array` is expected will TypeError, which `catch (\Exception)` does NOT catch (memory `PHP 8.5 Throwable Gotcha`). Catch blocks use `\Throwable`.

### Technical Debt

Items this spec creates or accepts as carried-forward debt. Each is documented so it doesn't become invisible.

- **CBC mode (unauthenticated encryption) is used for token storage** (ADR-1). Industry best practice would be AES-256-GCM. The CBC mode mirrors QBO and keeps the security review narrow, but it leaves us without ciphertext-authenticity guarantees. Recorded as ADR-1 follow-up; upgrade path is mechanical (one library swap), not architectural.
- **Application-layer-only append-only enforcement** (ADR-2). DB-level trigger or `REVOKE` would close the gap permanently. Documented in `docs/patterns/payroll-rate-history-append-only.md` as next-cycle work.
- **No idempotency-key support guarantee from partner** (ADR-7). If partner confirms unavailability, mid-POST timeouts may double-create. Phase 1b's pay-run-submit path will add pre-flight verify; foundation work accepts the gap.
- **`payrollRunLines.lineSnapshotJson` schema is JSON-typed and unindexed.** Phase 1b populates it; Phase 1c reads it for reconciliation. If query patterns later require indexing on specific JSON paths, a virtual-column-based index can be added without schema migration of the JSON column itself.
- **`payrollCoaMappings` table ships without UI/CLI seam** (PRD Won't-Have deviation). Phase 1c authors the seam plus the QBO JE generator that consumes the mappings. The table-only ship is intentional but means CS cannot configure mappings until Phase 1c.
- **TaskEngine retry behavior is per-job-definition** (stored in `taskengine_job_definitions`). If Phase 1c needs per-event-type retry strategy for webhook processing, the model must be extended OR the job logic must branch internally. Recorded; not blocking.
- **`payrollTenants.brandingJson` is unused in Phase 0/1a.** Phase 1b populates it for white-label theming. The column ships in Phase 0 to avoid migration cost; debt is "we are storing structure we don't read yet."
- **`scheduleTimePunches.cashTipsCents` / `creditTipsCents` ship but tips will never be used in MVP.** Pure migration-cost-avoidance. Recorded; columns are nullable and harmless if unread.

### Implementation Gotchas

Non-obvious issues that have already burned the team or that are easy to trip over.

- **`Store::$wiwEnable` vs `Store::$schedulingProvider`** — `wiwEnable=1` only means "a WIW API token is configured" and is NOT a reliable WIW-store discriminator (memory + skill `buyerkiosk-wiw-exclusion-source-of-truth`). `SchedulingProviderGate` reads `Store::$schedulingProvider === 'buyerkiosk'`, NOT `wiwEnable`. Code reviewers explicitly check for this.
- **MariaDB strict mode `INT <> ''` truncation error** (memory + skill `mariadb-int-empty-string-comparison`). Any payroll migration's `check_query` that compares an INT column to an empty string in INSERT...SELECT context will fail with error 1292. Use `> 0` or `IS NOT NULL` instead. Audit all 16 migration `check_query` strings.
- **`migration_log.operation_id = md5(description)`** — changing a migration's `description` AFTER it has applied creates a NEW operation_id and the migration re-applies (memory `Migration System CRITICAL`). Cosmetic edits to migration descriptions are forbidden post-merge.
- **`{{store}}` template variable in migration JSON dispatches per-store.** The `scheduleTimePunches` tips migration is the ONLY per-store migration in this spec. Other migrations use `kiosk_buykiosk` or `kiosk_users` explicitly.
- **`uf_authorize_group` permission key INSERTs must specify all 4 admin/role groups.** Per the audit findings, each permission key needs rows for Owner-equivalent (g9), Site Admin (g2), and the role groups that should have it (Manager=g7, Shift Lead=g8, Employee=g1). The cross-walk doc captures this — implementers MUST read it before authoring the permission migration JSON.
- **PSR-4 autoload may need a fresh `composer dump-autoload`** after creating the first `BuyerKiosk\Payroll\` file. Mitigation: run `cd userfrosting && composer dump-autoload` in the first PR's local development; CI's `composer install` covers production.
- **PDO named-parameter reuse trap.** `:rateCents` used twice in a single prepared statement raises `HY093` (memory `PDO Gotcha CRITICAL`). Use unique names (`:rateCents_1`, `:rateCents_2`) and bind both with the same value. Most-likely trip point: `findGreatestEffectiveFromAtOrBefore` if the same `tenantId` is referenced twice.
- **`$_ENV` populated but `getenv()` empty** (memory + skill `dotenv_env_vs_getenv`). The BuyerKiosk dotenv loader populates `$_ENV` / `$_SERVER` but NOT `putenv()`. Any payroll config code that reads `getenv('EVEREE_ENCRYPTION_KEY')` will get empty string; use `$_ENV['EVEREE_ENCRYPTION_KEY'] ?? ''` instead OR pass the value explicitly to constructors (preferred — `config/everee-encryption.php` already does this).
- **Webhook events arrive faster than tenant is provisioned during a race window.** If Everee fires `worker.created` before our provisioning service has updated `payrollTenants.isActive=1`, the webhook handler's tenant lookup MAY succeed (row exists with `isActive=0`) — accept and process anyway. Verification: the handler resolves tenant by `evereeCompanyId`, not by `isActive`.
- **Master-key rotation requires re-encrypting every tenant row.** PRD F4 documents the procedure but does not implement rotation tooling. Operationally: rotation is rare; manual SQL UPDATEs (via the Payroll service layer, NOT direct ALTER) ship in a Phase 1b/1c migration when needed.

## Test Specifications

The critical scenarios below are the GATE conditions for Phase 1a sign-off — each maps directly to a PRD Phase 1a exit metric. Implementation cannot merge to master until every one of these scenarios is green in CI.

### Critical Test Scenarios

**Scenario 1: Tenant provisioning happy path (PRD F6 + F4)**
```gherkin
Given: A dev store with schedulingProvider='buyerkiosk'
  And: EVEREE_ENCRYPTION_KEY is configured
  And: A valid Everee sandbox base URL is configured
  And: No payrollTenants row exists for EIN '12-3456789'
 When: An authenticated Owner POSTs /api/payroll/admin/pc00/tenants with valid legal-entity payload + attachToStoreTypeNums=['pc00']
 Then: A payrollTenants row is INSERTed with isActive transitioning 0 -> 1
  And: The evereeApiTokenEncrypted column contains base64 ciphertext (not the plaintext)
  And: stores.payrollTenantId is set to the new tenant.id on typeNum=pc00
  And: Two payrollAuditLog entries exist (provisioned with isActive=0 and provisioned with isActive=1)
  And: HTTP response 201 body {payrollTenantId, evereeCompanyId, isActive:true, idempotentReturn:false}
```

**Scenario 2: Tenant provisioning idempotency (PRD F6 Cross-Feature Edge Case)**
```gherkin
Given: A payrollTenants row already exists for EIN '12-3456789'
 When: An authenticated Owner POSTs the same EIN (possibly with different legal-name spelling)
 Then: NO second row is INSERTed
  And: The existing tenant is returned
  And: HTTP 200 body {payrollTenantId:<existing>, idempotentReturn:true}
  And: A payrollAuditLog entry is written with metadata.idempotent=true at severity high
```

**Scenario 3: SchedulingProviderGate rejects WIW/Homebase stores (PRD F11)**
```gherkin
Given: A store with schedulingProvider='wiw' (regardless of wiwEnable value)
 When: An authenticated Owner POSTs any payroll-mutating endpoint for that typeNum
 Then: HTTP 403 with body {error:'scheduling_provider_excluded'}
  And: NO payrollTenants row is created
  And: A payroll.scheduling_provider_gate.rejected audit entry is written with typeNum + schedulingProvider in metadata
```

**Scenario 4: PayRateService append-only — supersession does NOT mutate prior row (PRD F7)**
```gherkin
Given: payRateHistory row exists: userId=100, tenantId=1, positionId=7, rateCents=1500, effectiveFrom='2026-01-01', effectiveUntil=NULL
 When: An Owner POSTs setRate with userId=100, tenantId=1, positionId=7, rateCents=1750, effectiveFrom='2026-03-01'
 Then: The original row is unchanged (effectiveUntil still NULL, rateCents still 1500)
  And: A NEW row is INSERTed with rateCents=1750, effectiveFrom='2026-03-01', effectiveUntil=NULL
  And: getRate(asOf='2026-02-15') returns rateCents=1500
  And: getRate(asOf='2026-04-15') returns rateCents=1750
  And: ONE NEW payroll.rate.set audit entry is written (the test's When-step makes exactly one setRate call; the pre-seeded row was inserted directly without an audit entry by the test fixture)
```

**Scenario 5: PayRateService rejects empty notes and non-positive rates (PRD F7 Rule 6)**
```gherkin
Given: A valid setRate request shape
 When: The note field is empty
 Then: HTTP 400 with body {error:'bad_request', details.note:'required'}
  And: NO payRateHistory row is created and NO audit entry is written

Given: A valid setRate request shape
 When: rateCents is 0 or negative
 Then: HTTP 400 with body {error:'bad_request', details.rateCents:'must_be_positive'}
  And: NO payRateHistory row is created
```

**Scenario 6: Append-only invariant — raw UPDATE attempts raise (PRD F7 AC, ADR-2)**
```gherkin
Given: A payRateHistory row exists
 When: Any code path attempts UPDATE payRateHistory via the GuardedPdo
 Then: PayRateImmutableException is raised before the SQL reaches MySQL
  And: The row is unchanged and no audit entry suggests the update succeeded
```

**Scenario 7: PayRateService concurrent setRate — both INSERTs succeed, greatest (effectiveFrom, id) wins (PRD F7 Edge Case 1)**
```gherkin
Given: A valid (userId, tenantId, positionId) tuple
 When: Two setRate calls arrive within milliseconds with the same effectiveFrom but different rateCents
 Then: BOTH INSERTs succeed
  And: getRate(asOf=today) returns the row with the GREATER id
  And: TWO payroll.rate.set audit entries exist
```

**Scenario 8: PayRateService tombstone retirement (PRD F7 retireRate path)**
```gherkin
Given: A payRateHistory row exists with effectiveFrom='2026-01-01', effectiveUntil=NULL
 When: An Owner POSTs /api/payroll/admin/pc00/rates/retire with effectiveUntil='2026-12-31'
 Then: A NEW row is INSERTed with isRetirementTombstone=1, effectiveUntil='2026-12-31', rateCents=0
  And: getRate(asOf='2026-06-30') still returns the original rate
  And: getRate(asOf='2027-01-15') returns null
  And: A payroll.rate.retired audit entry is written
```

**Scenario 9: EvereeApiClient retry — 5xx then success (PRD F5 sign-off gate)**
```gherkin
Given: An EvereeApiClient with the fixture-replay HTTP client
  And: Fixture sequence [500, 503, 200 createWorker success]
 When: createWorker(...) is called
 Then: 3 HTTP attempts occur with exponential backoff (250ms, 750ms)
  And: The 200 response body is returned
  And: A payroll.api.everee_call audit entry has retryCount=2, httpStatus=200
```

**Scenario 10: EvereeApiClient 429 with Retry-After honored once (PRD F5 + CrossFeatureEdgeCase F5)**
```gherkin
Given: An EvereeApiClient
  And: Fixture sequence [429 Retry-After:1, 429 Retry-After:1]
 When: createWorker(...) is called
 Then: First 429 -> client sleeps ~1 second; second 429 -> client raises EvereeRateLimitException
  And: NO third attempt occurs
  And: payroll.api.everee_call audit entry has httpStatus=429, retryCount=1
```

**Scenario 11: EvereeApiClient 401 — no auto-retry; raises EvereeAuthException (CrossFeatureEdgeCase F5)**
```gherkin
Given: An EvereeApiClient
  And: Fixture [401 Unauthorized]
 When: listWorkers(...) is called
 Then: EvereeAuthException is raised immediately (no retry)
  And: payroll.api.everee_call audit entry has httpStatus=401, retryCount=0
```

**Scenario 11b: EvereeApiClient — happy-path GET worker (PRD F5 sign-off gate)**
```gherkin
Given: An EvereeApiClient
  And: Fixture [200 with a worker JSON body matching the Everee API spec]
 When: getWorker(tenant, 'wk_123') is called
 Then: One HTTP GET to /workers/wk_123 occurs with header authorization: basic <base64(decryptedToken)> AND x-everee-tenant-id: <tenant.evereeTenantId>
  And: The parsed response body is returned as EvereeWorkerDTO
  And: A payroll.api.everee_call audit entry has httpStatus=200, retryCount=0
  And: NO Idempotency-Key header is sent (GET methods don't take it)
```

**Scenario 11c: EvereeApiClient — happy-path POST createWorker with idempotency key (PRD F5 sign-off gate)**
```gherkin
Given: An EvereeApiClient
  And: Fixture [201 with a created-worker JSON body]
 When: createWorker(tenant, payload, idempotencyKey='create_worker:100:42') is called
 Then: One HTTP POST to /workers occurs with Idempotency-Key: create_worker:100:42 in the headers
  And: The auth header carries basic <base64(decryptedToken)> + x-everee-tenant-id: <tenant.evereeTenantId>
  And: The parsed response body is returned as EvereeWorkerDTO
  And: A payroll.api.everee_call audit entry has httpStatus=201, retryCount=0, metadata.idempotencyKey='create_worker:100:42'
```

**Scenario 11d: EvereeApiClient — 4xx validation error surfaces Everee's error payload (PRD F5 sign-off gate)**
```gherkin
Given: An EvereeApiClient
  And: Fixture [422 with body {errors: [{field: 'dob', message: 'invalid date'}]}]
 When: createWorker(tenant, payload) is called
 Then: EvereeValidationException is raised with the response payload accessible via $e->getResponsePayload()
  And: NO retry attempt occurs (4xx is non-retryable)
  And: A payroll.api.everee_call audit entry has httpStatus=422, retryCount=0
  And: The controller catching the exception translates it to HTTP 400 with body {error: 'bad_request', details: {errors: [{field: 'dob', message: 'invalid date'}]}}
```

**Scenario 12: EvereeApiClient POST timeout WITHOUT idempotency key raises EvereeUncertainStateException**
```gherkin
Given: createWorker is called WITHOUT an idempotency key
  And: Fixture [transport error / timeout after partial body sent]
 When: createWorker(...) is called
 Then: After retry budget exhaustion, EvereeUncertainStateException is raised (NOT EvereeApiException)
  And: Audit log captures the network-class error
```

**Scenario 13: EvereeTokenStorage — token never leaks via debug / serialize (PRD F4 AC)**
```gherkin
Given: A PayrollTenant with evereeApiTokenEncrypted = 'base64ciphertext...'
 When: var_dump(tenant), print_r(tenant), json_encode(tenant), or (string)exception-containing-tenant is executed
 Then: The plaintext token NEVER appears in the captured output
  And: jsonSerialize() output omits *Encrypted fields
  And: __debugInfo() output omits *Encrypted fields but includes presence booleans
```

**Scenario 13b: Automated CI check flags direct token logging (PRD F4 AC)**
```gherkin
Given: A CI step that runs the project's token-leak static check (either a PHPStan custom rule contributed in this phase OR a grep-based scanner over new code under BuyerKiosk\Payroll\)
 When: A code change introduces a log(...) / Exception::__construct(...) / sprintf(...) statement that includes a known token-bearing variable (e.g., $apiToken, $plaintextToken, the return value of $tokenStorage->decryptTokenForUse(...))
 Then: The CI step FAILS with a clear message identifying the offending file + line
  And: The check's allowlist / docs/patterns/payroll-token-encryption.md explains how a future reviewer would EXTEND the rule when a new token-shaped name appears
  And: The check is documented in docs/patterns/payroll-token-encryption.md so future reviewers can extend it
```

**Scenario 14: EvereeTokenStorage — write fails-closed when encryption misconfigured (PRD F4 AC)**
```gherkin
Given: EVEREE_ENCRYPTION_KEY is NOT set in the environment
 When: EvereeTokenStorage::encryptTokenForStorage('plaintext') is called
 Then: EvereeEncryptionRequiredException is raised
  And: NO ciphertext is produced and NO database write occurs

Given: An existing tenant row with a token
 When: EvereeTokenStorage::decryptTokenForUse(tenant) is called with encryption misconfigured
 Then: TokenDecryptionException is raised with a clear message
  And: NO plaintext is returned (no fallback)
```

**Scenario 15: Webhook ingestion — valid signature path (PRD F8)**
```gherkin
Given: An ingestion endpoint with a configured webhook secret
  And: A POST with valid HMAC signature, current timestamp, unique evereeEventId
 When: The request hits POST /api/payroll/webhook/everee
 Then: A payrollWebhookEvents row is INSERTed with hmacValid=1
  And: A ProcessEvereeWebhookJob is enqueued with payload {webhookEventId:<new id>}
  And: A payroll.webhook.received audit entry is written
  And: HTTP 200 body {received:true, duplicate:false}
```

**Scenario 16: Webhook ingestion — concurrent duplicate (CrossFeatureEdgeCase F8)**
```gherkin
Given: evereeEventId X has already been ingested
 When: A second POST with the SAME evereeEventId X arrives (HMAC valid, timestamp valid)
 Then: The INSERT raises UniqueConstraintViolationException, caught
  And: NO second row is created and NO second job is enqueued
  And: A payroll.webhook.deduplicated audit entry is written
  And: HTTP 200 body {received:true, duplicate:true}
```

**Scenario 17: Webhook ingestion — bad HMAC -> 401 (PRD F8 + replay defense)**
```gherkin
Given: A configured webhook secret
 When: A POST arrives with an invalid signature
 Then: HTTP 401 body {error:'invalid_signature'}
  And: A payroll.webhook.received audit entry is written with hmacValid=0 at severity high
  And: NO payrollWebhookEvents row is INSERTed (event ID NOT consumed)
  And: A legitimate retry of the SAME event ID under the correct signature still succeeds within tolerance
```

**Scenario 18: Webhook ingestion — timestamp outside tolerance -> 401 (CrossFeatureEdgeCase F8)**
```gherkin
Given: Timestamp tolerance is 300 seconds
 When: A POST arrives with HMAC valid but x-everee-timestamp 10 minutes in the past
 Then: HTTP 401 body {error:'invalid_signature'}
  And: A payroll.webhook.received audit entry has hmacValid=0, metadata.reason='timestamp_out_of_tolerance'
  And: NO payrollWebhookEvents row is INSERTed
```

**Scenario 19: Webhook ingestion — rotation tolerance (CrossFeatureEdgeCase F8)**
```gherkin
Given: A documented rotation window is open with both current and previous secrets accessible
 When: A POST arrives signed under the PREVIOUS secret
 Then: The verifier accepts the signature
  And: HTTP 200 body {received:true, duplicate:false}
  And: A payroll.webhook.received audit entry has metadata.signedUnderPriorSecret=true
```

**Scenario 20: Webhook ingestion — unknown tenant under per_tenant signing (CrossFeatureEdgeCase F8)**
```gherkin
Given: EVEREE_WEBHOOK_SIGNING_MODE='per_tenant'
  And: A POST arrives with x-everee-company-id pointing to a Company ID we have NOT provisioned
 When: The handler attempts to resolve the tenant
 Then: HTTP 401 body {error:'invalid_signature'}
  And: A payroll.webhook.received audit entry has hmacValid=0, metadata.unknownTenant=true
```

**Scenario 21: Webhook async processing failure does not block subsequent events (PRD F8 AC)**
```gherkin
Given: Two webhook events are enqueued — A (handler will throw) and B (handler will succeed)
 When: TaskEngine workers process them
 Then: A is markProcessed with processingError set; TaskEngine retries per JobDefinition
  And: B is markProcessed with processingError=NULL
  And: Both events have distinct payrollAuditLog entries (one processing_failed, one processed)
  And: B's processing was NOT blocked by A's failure
```

**Scenario 22: All migrations are idempotent (PRD F3 AC + REL-3)**
```gherkin
Given: A fresh dev store and central DB
 When: php userfrosting/conductor run is executed twice in a row
 Then: First run applies all 16 payroll migrations and records them in migration_log
  And: Second run records ZERO additional schema changes
  And: All check_query queries return the expected already-applied outcome
```

**Scenario 23: Permission key cross-walk produces the expected role mappings (PRD F10)**
```gherkin
Given: The 20260522_016_payroll_permission_keys.json migration has been applied
 When: $app->user->checkAccess(<permission_key>) is invoked from each role's user session
 Then: Owner-equivalent users (group 9 + group 2) pass all 12 keys' checkAccess
  And: Manager users (group 7) pass: submit_pay_run, view_pay_run, view_own_earnings, kickoff_employee_onboarding, approve_pto_request, request_pto, terminate_employee, create_punch_adjustment
  And: Shift Lead users (group 8) pass: view_pay_run, view_own_earnings, request_pto
  And: Employee users (group 1) pass: view_own_earnings, request_pto
  And: Users in no payroll-relevant groups fail all checks
```

**Scenario 24: Employee class consolidation regression (PRD F1)**
```gherkin
Given: PRD F1 consolidation PR has been merged
 When: The full ./test.sh suite runs
 Then: Every test passes (including those touching legacy unnamespaced Employee references)
  And: PHPStan analyses cleanly with no NEW errors above the baseline
  And: $legacyClassMap['Employee'] resolves to BuyerKiosk\Employee\Employee::class
  And: No production code path still constructs BuyerKiosk\Core\Employee directly (verified by grep)
```

**Scenario 25: Person-centric account verification report exists with required evidence (PRD F2)**
```gherkin
Given: A dev store has been seeded such that at least one users.id has ≥2 userStoreAssignments rows across distinct typeNum values
 When: php userfrosting/bin/payroll/verify-cross-merchant-users.php is executed
 Then: The script prints the SQL query result with at least one matching users.id and emits a structured summary
  And: docs/specs/050-everee-payroll-foundations/person-centric-account-verification.md exists with: SQL evidence, dev-store walkthrough transcript, gap-remediation plan (or "no gaps found" outcome), Signed-off-by line from an engineer other than the author
  And: No production code under BuyerKiosk\Payroll\Services\ maps users.id -> evereeWorkerId in the absence of this verification report (verified by review gate before merge)
```

**Scenario 26: Rate backfill CLI tool processes CSV via PayRateService (PRD F7 backfill AC)**
```gherkin
Given: A CSV file with valid columns (userId, positionId, rateType, rateCents, effectiveFrom, note) and at least three rows targeting one tenant
 When: php userfrosting/bin/payroll/backfill-rates.php --csv=<path> --tenant-id=42 --actor-user-id=9 is executed
 Then: Each row produces exactly one payRateHistory INSERT via PayRateService::setRate
  And: Each row produces exactly one payroll.rate.set audit entry with actorUserId=9
  And: A row with rateCents=0 produces an exception entry in the CLI output WITHOUT aborting subsequent rows
  And: Re-running the same CSV produces additional payRateHistory rows (per the append-only model — by design, NOT a bug)
  And: The CLI exits with a non-zero status iff at least one row failed validation, otherwise 0
```

**Scenario 26b: TaskEngine infrastructure audit (PRD F12 — Phase 1a hard gate)**
```gherkin
Given: docs/patterns/taskengine-payroll-audit.md exists
 When: A reviewer reads the document before approving the PR that contains EvereeWebhookHandler's async-dispatch wiring
 Then: The document describes the existing TaskEngine queues, worker classes, and retry semantics in concrete terms (queue names, default backoff, isolation properties)
  And: It identifies any gaps blocking webhook async processing, daily reconciliation, or PTO accrual jobs
  And: Each identified gap carries an explicit "fix in this phase vs defer to Phase 1c" recommendation, with the in-phase fixes scoped (file list, estimated effort) and merged BEFORE Feature 8's async AC is exercised
  And: A second engineer signs off on the audit (PR approval comment OR Signed-off-by line in the document)
  And: The PR introducing the EvereeWebhookHandler async-dispatch reference cites the audit doc in its description
```

**Scenario 27: Phase 0 pre-flight checklist completeness gate (PRD F13)**
```gherkin
Given: docs/specs/050-everee-payroll-foundations/phase-0-preflight-checklist.md exists
 When: Phase 1a sign-off review reads the checklist
 Then: The pilot-store rate-data audit is referenced with a CS-accessible link (5 stores covered OR explicitly waived with documented reason)
  And: A named owner appears next to each of the four required items (no generic 'engineering' or 'CS' attributions)
  And: The partner-manager kickoff email status is tracked with a date AND a status update note (weekly cadence honored at minimum until sandbox credentials + HMAC algorithm are confirmed)
  And: The pilot candidate list (5 stores) is finalized and stored alongside the rate audit
  And: An engineering review sign-off line confirms no Phase 1a Must Have requires an unticked pre-flight item
```

### Test Coverage Requirements

- **Business Logic — 100% public-method coverage in `BuyerKiosk\Payroll\Services\*`.** Every conditional path; backdated branch on `setRate`; tombstone branch on `getRate`; empty-note rejection; concurrent INSERT tie-break.
- **Provisioning paths.** `EvereeProvisioningService::provisionTenant` — idempotent-return path; new-tenant path; per-store attach path; verification-call success/failure branches.
- **Webhook ingestion paths.** Every gate in Algorithm 2 (headers, timestamp, secret resolution, HMAC, dedupe).
- **EvereeApiClient retry / backoff / 429 / idempotency-key paths** — all five fixture scenarios listed in PRD F5 AC.
- **SchedulingProviderGate** — BK-native pass; WIW reject; Homebase reject; null `schedulingProvider` reject.
- **API endpoints** — every endpoint has at least one success test + one auth-denial test + one validation-error test. Webhook endpoint also has HMAC-bad and timestamp-bad tests.
- **Integration Points (external)** — `EvereeApiClient` exercised against recorded fixtures for: list workers, create worker (POST), 4xx validation, 5xx-then-success, 429 + Retry-After, idempotency-key passthrough. Live sandbox smoke test is partner-gated (PRD F5 AC), tracked separately; does NOT block CI.
- **Integration Points (internal)** — `BuyerKiosk\Security\Encryption` exercised through `EvereeTokenStorage`; `JobDispatcher` exercised through webhook ingestion (queue insertion verified via existing TaskEngine mock infrastructure OR against the real DB-backed queue table in integration tests).
- **Edge Cases** — every "Cross-Feature Edge Case" enumerated in the PRD has at least one test scenario above. Specifically: F4 decryption fail + master-key rotation; F5 401/429/timeout; F6 duplicate EIN + partial provisioning; F8 concurrent dup + rotation + replay + unknown tenant; F11 mixed-provider stores.
- **Performance** — Phase 0/1a does NOT require automated load testing. Targets in §Quality Requirements are monitored post-merge via the audit log + application logs; if a target is missed in production, a remediation spec is opened.
- **Security** — every payroll endpoint has at least one test for: missing UF session -> redirect/401, wrong permission -> 403, `SchedulingProviderGate` reject -> 403, plaintext-token-leak regression (PRD F4 AC).
- **Migration coverage** — each of the 16 migration JSONs has a smoke test asserting `check_query` correctly detects pre/post state on a dev store. The `RateHistoryAppendOnlyTest` integration test (Scenario 6) provides the regression gate PRD F7 requires.

**Test runner targets:**
- `./test.sh --testsuite unit` — must be green; covers Scenarios 4–14 + 23 + 24.
- `./test.sh --testsuite integration` — must be green; covers Scenarios 1–3 + 15–22.
- `./test.sh --stan` — PHPStan stays at current baseline level with the new Payroll module fully typed.
- `./test.sh --coverage` — coverage report shows no untested public-method paths in `BuyerKiosk\Payroll\Services\*`. New files start at 100% coverage by policy.

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|---|---|---|
| **Tenant** | One Everee Company Instance, identified by a unique EIN. In BK schema, one `payrollTenants` row. May span N stores (multiple `stores.typeNum` share one `payrollTenantId`). | All payroll mutations operate per-tenant; a worker may have separate rate histories per tenant. |
| **EIN** | Employer Identification Number — 9-digit IRS-issued tax identifier in `XX-XXXXXXX` format. The identity primitive that defines a Tenant. | UNIQUE constraint on `payrollTenants.ein` enforces 1 tenant per EIN. |
| **Worker** | An Everee-side representation of an employee. Linked to a BK `users.id` via `userPayrollProfiles.evereeWorkerId`. One user can be a Worker under multiple tenants (cross-merchant employment). | Webhook events (`worker.created`, `worker.profile-updated`, etc.) drive Worker state changes. |
| **Pay rate** | A compensation value tied to (userId, payrollTenantId, positionId) with an effective date. Stored append-only in `payRateHistory`. | Read via `PayRateService::getRate(asOf)` for any historical date. |
| **Tombstone** | A `payRateHistory` row that explicitly retires a rate (no successor expected). Marked by `isRetirementTombstone=1` and a non-NULL `effectiveUntil`. INSERTed via `retireRate()`. | Distinguishes ordinary supersession (next row's effectiveFrom implicitly bounds the prior) from explicit retirement. |
| **Pay run** | A periodic batch of pay for a tenant — has lifecycle states (draft / submitted / funded / paid / failed / cancelled). Schema ships in Phase 0; service code in Phase 1b. | `payrollRuns` + `payrollRunLines` + `payrollRunSnapshots` tables. |
| **Snapshot** | A point-in-time JSON copy of the source data (punches, rates, classifications) at a pay run milestone — `pre_submit`, `post_approval`, `post_paid`. Defense layer per analysis §5. | `payrollRunSnapshots` table; Phase 1b populates. |
| **PTO** | Paid Time Off. BK-owned (analysis decision 17) — BK runs the accrual engine, balances, requests; Everee receives PTO hours as a pay code at run time. Phase 1c implements the engine. | Tables `ptoAccrualPolicies`, `ptoAccrualBalances`, `ptoRequests` ship in Phase 0. |
| **W-2 hourly / W-2 salaried** | The two employment classifications in scope for MVP (analysis decision 13). 1099 / contractor is permanently deferred. | `userStoreAssignments.employmentClassification` column. |
| **Scheduling provider gate** | The hard-rule exclusion of WIW and Homebase stores from payroll. Operates server-side, before any payroll mutation. | `SchedulingProviderGate::assertAllowed`; reads `Store::$schedulingProvider`, NOT `wiwEnable`. |
| **PII source-of-truth model** | The categorization of every new column on `kiosk_users.users` into one of three categories: BK_CANONICAL (BK owns), ONBOARDING_PREFILL (BK collects once, Everee owns thereafter), NEVER_ENTERS_BK (SSN/bank/W-4/I-9 — not stored). | Labels live in migration JSON descriptions; CON-18; PRD F3. |
| **Pre-flight check** | Phase 0 advisory verification — e.g., the `scheduleTimePunches.punchType` enum-vs-varchar check. Does NOT block; records observations into migration_log notes. | PRD F3 AC + §14 analysis. |
| **Append-only invariant** | The hard rule that no code path UPDATEs or DELETEs `payRateHistory`. Supersession is INSERT; retirement is INSERT a tombstone. Schema is permanent (IRS retention). | CON-12, ADR-2, PRD F7. |
| **Idempotent provisioning** | Re-invoking tenant provisioning with the same EIN returns the existing tenant rather than creating a duplicate. | PRD F6 AC, CrossFeatureEdgeCase F6, ADR — also UNIQUE constraint on `payrollTenants.ein`. |

### Technical Terms

| Term | Definition | Context |
|---|---|---|
| **PSR-4** | PHP Standard Recommendation 4 — autoloading convention mapping namespace to directory. | All payroll code under `BuyerKiosk\Payroll\` auto-loads via Composer's classmap. |
| **PHPStan** | Static analysis tool used by the project. Must stay green at current baseline. | `cd userfrosting && ./vendor/bin/phpstan analyse`. |
| **Conductor** | The BuyerKiosk migration system. Reads JSON files in `migrations/input/`, applies them per-database via PDO, logs each operation to `kiosk_buykiosk.migration_log`. | CRITICAL — the only path for schema changes (CLAUDE.md hard rule). |
| **TaskEngine** | The BuyerKiosk background-job framework. Job classes extend `BaseJob`; dispatch via `JobDispatcher`; worker pools run via `php userfrosting/bin/task worker:start`. | ICO-4; `ProcessEvereeWebhookJob` is the first payroll job. |
| **JobDefinition** | A row in `taskengine_job_definitions` table holding job-static config (queue, retry policy, scope). | Maps `getName()` -> `getQueue()`, `maxRetries`, `retryBackoff`. |
| **HMAC** | Hash-based Message Authentication Code. Cryptographic primitive for webhook signature verification. SHA-256 in Phase 1a (ADR-5). | `hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret)` with constant-time compare. |
| **CBC mode** | Cipher Block Chaining — symmetric encryption mode used by `BuyerKiosk\Security\Encryption`. NOT authenticated (no MAC); mirrored from QBO. | ADR-1; upgrade to AES-256-GCM is recorded follow-up. |
| **TOCTOU** | Time-Of-Check-Time-Of-Use — a race condition where a check and a subsequent action are not atomic. Avoided by relying on DB UNIQUE constraints for dedupe instead of pre-INSERT SELECT. | Webhook dedupe (Algorithm 3) is TOCTOU-safe by design. |
| **Retry-After** | HTTP response header on 429 indicating when to retry. Either seconds (integer) or HTTP-date format. | `EvereeApiClient::parseRetryAfter`; honored ONCE. |
| **Idempotency-Key** | HTTP request header on POST allowing the server to dedupe retries client-side. Support is partner-confirmation-gated (§13 item 3, ADR-7). | `EvereeApiClient` always sends when supplied; partner-side support determines effect. |
| **`Slim\Exception\Stop`** | Slim 2's flow-control exception thrown by `$app->stop()`. NOT a true error. Must propagate through any try/catch (per memory `slim2-stop-exception-swallowed`). | All payroll catch blocks use `\Throwable` and explicitly let Stop through. |
| **`migration_log.operation_id`** | `md5(<migration description>)`. Changing the description after merge creates a new operation_id, causing the migration to re-apply (per memory `Migration System CRITICAL`). | Operational rule — never edit a merged migration's description. |
| **GuardedPdo** | PDO subclass that scans every executed SQL for forbidden patterns (UPDATE/DELETE on `payRateHistory`) and raises `PayRateImmutableException` before the SQL reaches MySQL. **Used in BOTH production and tests** — when the Payroll module obtains its PDO via the BuyerKiosk DI container, it always wraps the raw PDO in `GuardedPdo`. The wrapper has near-zero overhead (one substring match per `exec`/`prepare`). The "regression test" in ADR-2 verifies that the wrapper raises; the same wrapper protects production code from accidentally bypassing the repo's missing-method surface. | ADR-2 application-layer enforcement primitive. |
| **Fixture-replay** | Test pattern where the HTTP client is injected with pre-recorded responses to a sequence of requests. No outbound network traffic. | `EvereeApiClient` unit tests; PRD F5 Phase 1a sign-off gate. |
| **Sandbox** | Everee's non-production environment. ALL Phase 0/1a calls hit it. Production credentials not wired anywhere (CON-14). | `EVEREE_API_BASE_URL` env var; switched at Phase 1b's pre-launch gate. |
| **`uf_authorize_group`** | The UserFrosting ACL table storing `(group_id, hook, conditions)` triples. Hooks are permission keys; conditions are typically `always()`. | Permission enforcement at controller level via `$app->user->checkAccess($hook)`. |
| **`{{store}}` macro** | Conductor migration JSON template variable that dispatches a single migration across every per-store database. | Used only for `scheduleTimePunches` tips columns in this spec. |
| **Composite index** | An index covering multiple columns. `payRateHistory.idx_userid_tenant_position_effective` supports `getRate(asOf)` lookups in O(log n) with tie-break by id DESC. | PRD F7 AC; performance bound REL-1 + PERF-5. |

### API / Interface Terms

| Term | Definition | Context |
|---|---|---|
| **`/api/payroll/admin/:typeNum/tenants`** | NEW endpoint — provision a new tenant (POST). | Auth: UF session + `manage_payroll` + `checkStoreGroup` + `SchedulingProviderGate`. |
| **`/api/payroll/admin/:typeNum/rates`** | NEW endpoint — set a pay rate (POST). | Auth: UF session + `set_pay_rate` + `checkStoreGroup` + `SchedulingProviderGate`. |
| **`/api/payroll/admin/:typeNum/rates/retire`** | NEW endpoint — retire a pay rate as a tombstone (POST). | Auth: same as setRate. |
| **`/api/payroll/admin/:typeNum/rates/as-of`** | NEW endpoint — read a rate as-of a date (GET). | Auth: UF session + `view_pay_run` + `checkStoreGroup`. |
| **`/api/payroll/webhook/everee`** | NEW endpoint — public webhook receiver (POST). | Auth: HMAC signature header; NO UF session. |
| **`x-everee-tenant-id`** | Header BK sends to Everee on every API call. Carries `payrollTenants.evereeTenantId`. | `EvereeApiClient::_request` builds it. |
| **`x-everee-event-id`** | Header Everee sends on every webhook. The dedupe key — UNIQUE-enforced in `payrollWebhookEvents`. | `EvereeWebhookHandler::ingest` reads it. |
| **`x-everee-signature`** | Header Everee sends on every webhook. The HMAC digest of `timestamp . '.' . rawBody`. | Algorithm 2 verification. |
| **`x-everee-timestamp`** | Header Everee sends on every webhook. Unix-epoch seconds OR ISO-8601. Replay-defense tolerance is `EVEREE_WEBHOOK_TIMESTAMP_TOLERANCE_SEC` (default 300). | Checked before HMAC compute. |
| **`Idempotency-Key`** | HTTP header BK sends on every POST to Everee when supported. Naming convention: `'<prefix>:<stable_input>'` (e.g., `'prov:12-3456789'`). | ADR-7; partner-confirmation-gated semantics. |
| **`process-everee-webhook` (job name)** | The TaskEngine job that picks up async webhook processing. Queue: `default`; scope: `global`; timeout: 30s. | `getName()` constant; payload `{webhookEventId}`. |
| **`EVEREE_ENCRYPTION_KEY`** | Env var holding the 32-byte hex master key for Everee token + webhook-secret encryption. Separate from `QB_ENCRYPTION_KEY` per ADR-1. | Read via `config/everee-encryption.php`. |
| **`EVEREE_WEBHOOK_SIGNING_MODE`** | Env var: `per_tenant` (default) or `global`. Controls which secret the verifier reads. | Reversibility hook per ADR-3. |
| **`EVEREE_WEBHOOK_GLOBAL_SECRET`** | Env var holding the global HMAC secret. Set ONLY when signing mode is `global`. | Falsy in `per_tenant` mode. |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| [API Term] | [Specific meaning in this context] | [Related endpoints or operations] |
| [Protocol/Format] | [Technical specification] | [Where used in integrations] |
