# Implementation Plan: Event CRM Contacts

## Overview

Convert the feature design into a series of prompts for a code-generation LLM that will implement each step with incremental progress. Make sure that each prompt builds on the previous prompts, and ends with wiring things together. There should be no hanging or orphaned code that isn't integrated into a previous step. Focus ONLY on tasks that involve writing, modifying, or testing code.

The work is split into three vertical slices that each end in something runnable: (1) backend foundations and the `Contact` model, (2) the API endpoints with their property tests, (3) the Vue SPA wired to the API. Property tests use Pest 4 + `eris/eris` on the backend and Vitest + `@vue/test-utils` + `fast-check` on the frontend, each running ≥100 iterations and tagged `[P{N}]`. Backend tests run on in-memory SQLite, so the `withTag` scope must work on both MySQL (production) and SQLite (tests).

## Tasks

- [x] 1. Set up backend dependencies, API routing, and JSON error envelope
  - [x] 1.1 Install `eris/eris` and enable `routes/api.php`
    - Run `composer require --dev giorgiosironi/eris` to add the PBT library
    - Create empty `routes/api.php`
    - Update `bootstrap/app.php` `withRouting(...)` to register `api: __DIR__.'/../routes/api.php'`
    - _Requirements: 2.1, 3.1, 4.1, 5.1, 6.1_

  - [x] 1.2 Implement the global JSON error envelope in `bootstrap/app.php`
    - Register `withExceptions(...)` render closures for `ValidationException` (422), `ModelNotFoundException` / `NotFoundHttpException` (404), `BadRequestHttpException` (400), and a generic `\Throwable` (500), each restricted to `api/*` requests
    - Each branch returns a JSON object with a `message` string of length 1..500; validation also returns `errors` keyed by field
    - Strip framework-internal markers (file paths, stack traces, exception class names) from 500 responses
    - _Requirements: 13.1, 13.4, 13.5, 13.6, 2.9_

  - [x] 1.3 Add `EnsureJsonRequestBody` middleware and apply it to the API group
    - Create `app/Http/Middleware/EnsureJsonRequestBody.php` that calls `json_decode` on any non-empty body and throws `BadRequestHttpException` on failure
    - Register the middleware in the `api` group in `bootstrap/app.php`
    - _Requirements: 13.4_

  - [x] 1.4 Smoke test: API routes are registered and JSON error envelope works
    - Pest test asserting `php artisan route:list` shows the five `apiResource` routes under `/api/contacts`
    - Pest test posting malformed JSON to `/api/contacts` returns 400 with `{message: string}` and content-type `application/json; charset=utf-8`
    - Pest test that a forced unhandled exception yields a 500 body with no `file`, `trace`, or class-name fields
    - **Property 16: Error response envelope** — _Validates: Requirements 13.4, 13.5, 13.6, 2.9_
    - **Property 17: Response content-type and timestamp format** — _Validates: Requirements 13.1, 13.3_

- [x] 2. Implement the `Contact` data model and migration
  - [x] 2.1 Create the `contacts` migration
    - `database/migrations/xxxx_create_contacts_table.php` per the design table spec (`id`, `name`, `email`, `phone`, `company`, `role`, `notes`, `tags` JSON default `[]`, `created_at(3)`, `updated_at(3)`)
    - Add indexes `contacts_created_at_id_idx (created_at desc, id desc)` and `contacts_name_idx (name)`
    - _Requirements: 1.1, 1.9, 6.2_

  - [x] 2.2 Implement the `Contact` Eloquent model
    - `app/Models/Contact.php` with `$fillable`, `casts()` (`tags => array`, datetimes with `:Y-m-d H:i:s.v`)
    - `scopeSearch(Builder $q, ?string $term)` short-circuits on null/whitespace, otherwise applies case-insensitive `LIKE` across `name`, `email`, `company`, `role`, `notes`
    - `scopeWithTag(Builder $q, ?string $tag)` short-circuits on null/whitespace, detects driver: MySQL uses `JSON_SEARCH(LOWER(tags), 'one', LOWER(?))`, SQLite uses `LOWER(tags) LIKE '%"<lowered>"%'`
    - _Requirements: 1.1, 1.8, 1.9, 6.2, 7.1, 7.2, 7.3_

  - [x] 2.3 Create `ContactFactory`
    - `database/factories/ContactFactory.php` producing valid contacts (Faker name/email/phone, 0..10 random tags) for use in feature tests and property generators
    - _Requirements: 1.1, 1.8_

  - [x] 2.4 Property test: search and tag scopes are sound
    - Pest + eris test in `tests/Unit/Models/ContactScopesTest.php`
    - Generates a random population, then asserts that for any trimmed `q` and `tag`, `Contact::query()->search($q)->withTag($tag)->get()` returns exactly `{c | match(c)}` per the design's `match` predicate, on SQLite
    - **Property 14: Search and tag filter soundness** — _Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5_
    - Run with `withIterations(100)` minimum

- [x] 3. Implement validation rules and form requests
  - [x] 3.1 Implement custom validation rules
    - `app/Rules/ValidEmail.php` enforcing the exact format from Requirement 1.3 (single `@`, non-empty local-part, domain with at least one `.`, no whitespace)
    - `app/Rules/UniqueCaseInsensitive.php` rejecting `tags` arrays whose lowercased trimmed entries contain duplicates
    - _Requirements: 1.3, 1.8_

  - [x] 3.2 Implement `StoreContactRequest`
    - `app/Http/Requests/StoreContactRequest.php` with `prepareForValidation` that trims string fields, normalises tags (trim + drop empties), and strips `id`, `created_at`, `updated_at` from the input
    - Rules per design: `name` required string 1..120; `email` nullable string ≤254 + `ValidEmail`; `phone` nullable string ≤32 + regex; `company`/`role` nullable ≤120; `notes` nullable ≤2000; `tags` array max:10 + `UniqueCaseInsensitive`; `tags.*` string min:1 max:32
    - _Requirements: 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8_

  - [x] 3.3 Implement `UpdateContactRequest`
    - Extends `StoreContactRequest` with `isFullReplace()` returning `$this->isMethod('put')`
    - For `PATCH`, switches every rule to `sometimes` while preserving the per-field constraints
    - For `PUT`, keeps `name` required and treats omitted optional fields as null/`[]` in `validated()`
    - Strips server-managed fields in `prepareForValidation`
    - _Requirements: 4.1, 4.2, 4.6, 4.7, 4.8_

  - [x] 3.4 Implement `IndexContactRequest`
    - Validates `q` (nullable string ≤120 after trim), `tag` (nullable string ≤32 after trim), `per_page` (nullable integer between:1,100), `page` (nullable integer min:1)
    - `prepareForValidation` rewrites whitespace-only `q`/`tag` to `null`
    - _Requirements: 6.3, 6.4, 6.5, 6.6, 6.7, 7.3, 7.6_

- [x] 4. Implement `ContactResource` and the `ContactController`
  - [x] 4.1 Implement `ContactResource`
    - `app/Http/Resources/ContactResource.php::toArray` returns the full Contact shape per design
    - `created_at`/`updated_at` formatted as `Y-m-d\TH:i:s\Z` in UTC
    - `tags` is the stored array (preserving insertion order), defaulting to `[]`
    - On a row missing any required field, throws `\LogicException` so the global handler emits a 500 (Requirement 3.5)
    - _Requirements: 1.1, 3.5, 13.1, 13.2, 13.3_

  - [x] 4.2 Implement `ContactController`
    - `app/Http/Controllers/Api/ContactController.php` with `index`, `store`, `show`, `update`, `destroy`
    - `index` applies `search($q)->withTag($tag)->orderByDesc('created_at')->orderByDesc('id')->paginate($perPage)` and wraps in `ContactResource::collection`
    - `store` returns 201 with the new resource; `show` returns 200; `update` honours `isFullReplace()` (PUT resets omitted optional fields to null/`[]`, PATCH leaves them unchanged); `destroy` returns 204 empty body and rethrows DB errors so the global 500 handler runs
    - For PUT, if every resulting field equals current stored value, leave `updated_at` unchanged
    - _Requirements: 2.1, 2.2, 3.1, 4.1, 4.2, 4.3, 4.4, 4.5, 4.7, 4.8, 5.1, 5.4, 6.1, 6.2, 6.8, 6.9, 13.2_

  - [x] 4.3 Wire API routes
    - `routes/api.php` registers `Route::apiResource('contacts', ContactController::class)`
    - Constrain `{contact}` to `whereNumber('contact')` so non-integer ids return 400 (Requirement 3.3) via the global handler
    - _Requirements: 2.1, 3.1, 3.2, 3.3, 4.1, 4.5, 5.1, 5.2, 6.1_

  - [ ] 4.4 Property test: create-then-read round trip
    - `tests/Feature/Api/ContactCreateTest.php` using eris `forAll(genContactPayload())`
    - For any valid payload `P`, `POST /api/contacts` returns 201, then `GET /api/contacts/{id}` returns trimmed `P` with `created_at == updated_at`
    - **Property 1: Create-then-read round trip** — _Validates: Requirements 1.1, 2.1, 2.2, 13.2_
    - Tag with `[P1]`, ≥100 iterations

  - [ ] 4.5 Property test: server-managed identity is immutable
    - In `tests/Feature/Api/ContactUpdateTest.php`, eris generates a Contact and arbitrary update sequences whose bodies attempt to override `id`, `created_at`, `updated_at`
    - Asserts post-state `id` and `created_at` equal pre-state values for every step
    - **Property 2: Server-managed identity is immutable** — _Validates: Requirements 1.1, 2.8, 4.8_
    - Tag with `[P2]`, ≥100 iterations

  - [ ] 4.6 Property test: length and character validation
    - In `tests/Feature/Api/ContactCreateTest.php`, eris generates per-field violations (`F ∈ {name,email,phone,company,role,notes}`, plus non-string and empty-after-trim variants)
    - Each invalid request must respond 422 with an `errors[F]` entry and persist nothing
    - **Property 3: Length and character validation** — _Validates: Requirements 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.3, 2.4, 2.5, 4.6_
    - Tag with `[P3]`, ≥100 iterations

  - [ ] 4.7 Property test: tag list invariant
    - eris generates valid and invalid `tags` lists (>10, empty entry, >32 chars, duplicates case-insensitive, non-string)
    - Asserts accept iff valid, else 422 keyed on `tags`, no persistence
    - **Property 4: Tag list invariant** — _Validates: Requirements 1.8, 2.6, 4.6_
    - Tag with `[P4]`, ≥100 iterations

  - [ ] 4.8 Property test: atomicity of failed writes
    - eris seeds a population, snapshots the table, fires an arbitrary invalid `POST`/`PATCH`/`PUT`, asserts post-state equals snapshot byte-for-byte
    - **Property 5: Atomicity of failed writes** — _Validates: Requirements 1.10, 2.3, 2.4, 2.5, 2.6, 4.6_
    - Tag with `[P5]`, ≥100 iterations

  - [ ] 4.9 Property test: validation aggregates one error per field
    - eris generates a non-empty subset `K` of fields to invalidate, asserts the 422 response's `errors` keys equal `K` exactly
    - **Property 6: Validation aggregates one error per field** — _Validates: Requirements 2.7_
    - Tag with `[P6]`, ≥100 iterations

  - [ ] 4.10 Property test: `updated_at` monotonicity
    - In `tests/Feature/Api/ContactUpdateTest.php`, generate a Contact and an update body
    - Assert: if the update changes ≥1 stored field, post-`updated_at` > pre-`updated_at`; else equals pre-`updated_at`; `created_at` unchanged in both cases
    - **Property 7: `updated_at` monotonicity** — _Validates: Requirements 1.9, 4.3, 4.4_
    - Tag with `[P7]`, ≥100 iterations

  - [ ] 4.11 Property test: PATCH preserves omitted, PUT replaces all
    - Generate state `S`, generate PATCH body `F`, assert post-state == `S` ⊕ `F`
    - Generate valid PUT body, assert omitted optional fields become null / `[]` and required fields equal body
    - **Property 8: PATCH preserves omitted fields, PUT replaces all** — _Validates: Requirements 4.1, 4.2, 4.7_
    - Tag with `[P8]`, ≥100 iterations

  - [ ] 4.12 Property test: GET is read-only
    - In `tests/Feature/Api/ContactReadTest.php`, snapshot the table, fire an arbitrary interleaving of `GET /api/contacts` and `GET /api/contacts/{id}`, assert post-state equals snapshot byte-for-byte (including `updated_at`)
    - **Property 9: GET is read-only** — _Validates: Requirements 3.4_
    - Tag with `[P9]`, ≥100 iterations

  - [ ] 4.13 Property test: missing or malformed identifiers
    - In `tests/Feature/Api/ContactDeleteTest.php` and `ContactReadTest.php`, generate `i` absent from storage and `i` failing the integer pattern
    - For `M ∈ {GET, PATCH, PUT, DELETE}` on `/api/contacts/{i}`: 404 + `{message: string}` for absent valid id; 400 for malformed id; no DB lookup for malformed id
    - **Property 10: Missing or malformed identifiers** — _Validates: Requirements 3.2, 3.3, 4.5, 5.2_
    - Tag with `[P10]`, ≥100 iterations

  - [ ] 4.14 Property test: delete eliminates the contact
    - In `tests/Feature/Api/ContactDeleteTest.php`, seed a population, delete a random contact `c`, assert subsequent `GET /api/contacts/{c.id}` returns 404, and `c.id` does not appear in any subsequent paginated `GET /api/contacts` under any valid `q`/`tag`/`page`/`per_page`
    - **Property 11: Delete eliminates the contact** — _Validates: Requirements 5.1, 5.3_
    - Tag with `[P11]`, ≥100 iterations

  - [ ] 4.15 Property test: list response shape and pagination
    - In `tests/Feature/Api/ContactListTest.php`, generate a population `P`, generate `per_page ∈ [1,100]` (or omitted) and `page ≥ 1`
    - Assert: `|data| ≤ per_page`; `|data| = 0` when `page > meta.last_page`; concatenation of `data` over `1..last_page` equals `P` ordered by `(created_at desc, id desc)`; `meta.{total, per_page, current_page, last_page}` correct
    - **Property 12: List response shape and pagination** — _Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.8, 6.9_
    - Tag with `[P12]`, ≥100 iterations

  - [ ] 4.16 Property test: pagination parameter validation
    - Generate invalid `per_page` (non-int, <1, >100) and invalid `page` (non-int, <1)
    - Assert 422 keyed on the offending parameter and no `data` array of contacts in the response body
    - **Property 13: Pagination parameter validation** — _Validates: Requirements 6.7_
    - Tag with `[P13]`, ≥100 iterations

  - [ ] 4.17 Property test: search and tag parameter validation
    - In `tests/Feature/Api/ContactSearchTest.php`, generate trimmed `q` >120 chars or trimmed `tag` >32 chars
    - Assert 422 keyed on the offending parameter and no filtered `data` array
    - **Property 15: Search and tag parameter validation** — _Validates: Requirements 7.6_
    - Tag with `[P15]`, ≥100 iterations

  - [ ] 4.18 Property test: error response envelope
    - In `tests/Feature/Api/ContactErrorEnvelopeTest.php`, fire requests yielding 400, 404, 422, 500
    - Assert: top-level keys ⊆ `{message, errors}`; `message` length 1..500; `errors` (when present) is non-empty object of non-empty string arrays; no Contact payload fields; no framework-internal markers
    - **Property 16: Error response envelope** — _Validates: Requirements 2.9, 13.4, 13.5, 13.6_
    - Tag with `[P16]`, ≥100 iterations

  - [x] 4.19 Property test: response content-type and timestamp format
    - In `tests/Feature/Api/ContactErrorEnvelopeTest.php` (and shared with 4.4/4.15), assert every non-empty body has `Content-Type: application/json; charset=utf-8` and any `created_at`/`updated_at` matches `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`
    - **Property 17: Response content-type and timestamp format** — _Validates: Requirements 13.1, 13.3_
    - Tag with `[P17]`, ≥100 iterations

  - [x] 4.20 Example tests for non-property backend criteria
    - `tests/Feature/Api/ContactReadTest.php`: latency under 1000ms (Requirement 3.1), corrupted-row 500 (Requirement 3.5)
    - `tests/Feature/Api/ContactDeleteTest.php`: DB failure path returning 500 with row preserved (Requirement 5.4) using a mocked failing transaction
    - _Requirements: 3.1, 3.5, 5.4_

- [x] 5. Backend checkpoint
  - Ensure all backend tests pass, ask the user if questions arise.

- [x] 6. Set up frontend dependencies, build pipeline, and SPA shell
  - [x] 6.1 Install frontend dependencies and configure Vite for Vue
    - `npm install vue@^3 @vitejs/plugin-vue vue-router@^4 pinia@^2 axios`
    - `npm install -D vitest @vue/test-utils@next fast-check happy-dom`
    - Update `vite.config.js` to register `@vitejs/plugin-vue`
    - Add `package.json` script `"test": "vitest run"`
    - _Requirements: 8.1, 9.1, 9.2_

  - [x] 6.2 Add Blade SPA root view and catch-all route
    - Create `resources/views/app.blade.php` with `<div id="app"></div>` and the Vite directives for `resources/js/app.js` and `resources/css/app.css`
    - Add `Route::get('/{any}', fn () => view('app'))->where('any', '^(?!api).*');` in `routes/web.php`
    - _Requirements: 8.1, 9.1, 9.2_

  - [x] 6.3 Bootstrap the Vue app
    - Replace `resources/js/app.js` with a `createApp(App)`, `app.use(createPinia())`, `app.use(router)`, mount `#app`
    - Add `resources/js/App.vue` (responsive layout shell with `<router-view />` and a toast container)
    - _Requirements: 8.1, 9.1, 9.2_

- [x] 7. Implement frontend API client, store, and routing
  - [x] 7.1 Implement `resources/js/api/contacts.js`
    - axios instance with `baseURL: '/api'`, `timeout: 10_000`, `Accept: application/json`
    - Export `list(params)`, `get(id)`, `create(payload)`, `update(id, body)`, `partialUpdate(id, body)`, `remove(id)`
    - Response interceptor normalises errors to `{ kind: 'http'|'network'|'timeout', status, message, fields }`
    - _Requirements: 8.2, 8.9, 11.1, 11.2, 12.2_

  - [x] 7.2 Implement Pinia `contactsStore`
    - `resources/js/stores/contacts.js` with state `{ contacts, meta, filters, loading, error, inflightId, pendingIds }`
    - Actions `fetchPage`, `fetchOne`, `create`, `update`, `remove`, `setFilters` per design
    - `fetchPage` increments `inflightId` and only commits responses whose token equals the current `inflightId`
    - `remove` adds to `pendingIds` while in flight
    - _Requirements: 8.4, 9.5, 10.2, 10.5, 10.6, 11.3, 12.2, 12.3_

  - [x] 7.3 Configure Vue Router
    - `resources/js/router/index.js` with named routes `contacts.index` (`/`), `contacts.create` (`/contacts/new`), `contacts.edit` (`/contacts/:id/edit`)
    - Edit route's `beforeEnter` calls `store.fetchOne(id)`; on 404 redirects to `/` and pushes a toast
    - _Requirements: 11.1, 11.7_

  - [x] 7.4 Implement composables
    - `resources/js/composables/useDebouncedRef.js` (300ms default, configurable)
    - `resources/js/composables/useToast.js` for non-blocking ephemeral notifications with auto-dismiss
    - `resources/js/composables/useMediaQuery.js` for the 768px breakpoint
    - _Requirements: 9.1, 9.2, 10.2, 10.3, 12.3, 12.5_

- [x] 8. Implement the Contact form
  - [x] 8.1 Implement `ContactForm.vue`
    - `resources/js/components/ContactForm.vue` with inputs for `name`, `email`, `phone`, `company`, `role`, `notes`, and a tag chip input
    - Props: `modelValue`, `mode`, `submitting`, `serverErrors`; emits `submit(payload)`, `cancel()`
    - Local validation: `name` required after trim, all length limits per Requirement 8.1
    - Tag chip behaviour: Enter / comma / blur pushes a trimmed value; deduplicate case-insensitively; reject empties; cap at 10
    - Submit button bound to `:disabled="submitting"`
    - Merges local errors with `serverErrors` for display
    - _Requirements: 8.1, 8.2, 8.3, 8.5, 8.6, 8.7, 11.2, 11.5, 11.6_

  - [x] 8.2 Implement `ContactFormView.vue`
    - `resources/js/views/ContactFormView.vue` mounts `<ContactForm />`
    - Create mode: empty initial value; on `submit`, calls `store.create`, clears form on 201, emits success toast, navigates to list (Requirement 8.4)
    - Edit mode: pre-populates from `store.fetchOne` result; on `submit`, calls `store.update` (PUT); on 200 closes form (Requirement 11.3); on 404 removes contact and toasts (Requirement 11.4)
    - Maps API error envelope into `serverErrors`; on 4xx≠404, 5xx, network, timeout: keep entered values, re-enable submit, show toast
    - _Requirements: 8.4, 8.7, 8.8, 8.9, 11.1, 11.2, 11.3, 11.4, 11.5, 11.7, 11.8_

  - [ ] 8.3 Property test: frontend submit gating and validation
    - `resources/js/__tests__/ContactForm.spec.js` using Vitest + `@vue/test-utils` + `fast-check`
    - For arbitrary form states, mount `ContactForm`, call submit, assert: emit `submit` count is 1 iff `name` trims non-empty AND every field ≤ its max AND `submitting === false`; else 0 emits and inline errors on offending fields with other values preserved
    - **Property 18: Frontend submit gating and validation** — _Validates: Requirements 8.2, 8.3, 8.5, 8.6, 11.2, 11.6_
    - `fc.assert(prop, { numRuns: 100 })`, tag `[P18]`

  - [ ] 8.4 Property test: edit form pre-population round trip
    - `resources/js/__tests__/ContactEditFlow.spec.js` mounts `ContactFormView` in edit mode with mocked `axios`
    - `fast-check` generates a Contact `c`; assert form fields equal `c`'s values exactly after the mocked `GET /api/contacts/{c.id}` resolves
    - Submitting unchanged emits a `PUT /api/contacts/{c.id}` whose JSON body equals those values
    - **Property 23: Edit form pre-population round trip** — _Validates: Requirements 11.1, 11.2_
    - `numRuns: 100`, tag `[P23]`

- [x] 9. Implement the list view, search, and delete flow
  - [x] 9.1 Implement responsive list components
    - `resources/js/components/ContactRow.vue` (table row, ≥768px)
    - `resources/js/components/ContactCard.vue` (stacked card, <768px)
    - `resources/js/components/Pagination.vue` (prev/next + page numbers, disables while loading)
    - `resources/js/components/TagChip.vue`, `resources/js/components/Toast.vue`, `resources/js/components/ConfirmModal.vue`
    - _Requirements: 9.1, 9.2, 9.8, 12.1_

  - [x] 9.2 Implement `ContactListView.vue`
    - `resources/js/views/ContactListView.vue` composes search input, tag selector, list (chooses Row vs Card via `useMediaQuery('(min-width: 768px)')`), `<Pagination>`, `<ConfirmModal>`, and a retry banner
    - On mount: `store.fetchPage(1)` with `per_page=25` (Requirement 9.3)
    - Search input bound through `useDebouncedRef(300)` then `store.setFilters({ q })` and `store.fetchPage(1)` (Requirement 10.2)
    - Tag selector triggers refresh within 100ms without debounce (Requirement 10.3)
    - Three exclusive UI states `loading | error | empty | data` per design; pagination disabled while loading; retry button on error
    - Delete control opens `<ConfirmModal>`; Confirm calls `store.remove`; 204 removes row + success toast (auto-dismiss 5s); 404 removes row + already-gone toast; other failures keep row, re-enable control, retry toast
    - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 12.1, 12.2, 12.3, 12.4, 12.5, 12.6_

  - [ ] 9.3 Property test: frontend post-success behaviour
    - In `resources/js/__tests__/ContactListView.spec.js`, mock axios; for any of `201` create, `200` edit, `204` delete, assert: list reflects the change (row appended/updated/removed), source form/modal closes, control re-enabled, success toast for delete auto-dismisses after 5000ms (use fake timers)
    - **Property 19: Frontend post-success behaviour** — _Validates: Requirements 8.4, 11.3, 12.3_
    - `fc.assert(prop, { numRuns: 100 })`, tag `[P19]`

  - [ ] 9.4 Property test: frontend error handling consistency
    - For arbitrary failure kinds (4xx≠404, 5xx, network, timeout, 404 on edit/delete), assert: form values preserved, control re-enabled, error toast shown, previously-rendered Contacts remain visible, list view exposes a retry control; 404 on edit/delete removes the contact + non-blocking toast
    - **Property 20: Frontend error handling consistency** — _Validates: Requirements 8.7, 8.8, 8.9, 9.6, 11.4, 11.5, 11.7, 11.8, 12.5, 12.6_
    - `numRuns: 100`, tag `[P20]`

  - [ ] 9.5 Property test: list UI state truth table
    - `fast-check` generates `(loading, error, contacts.length)` triples; assert for each: `loading_indicator_visible ⇔ loading`, `pagination_disabled ⇔ loading`, `error_banner_visible ⇔ error≠null`, `empty_state_visible ⇔ (!loading ∧ !error ∧ contacts.length=0)`
    - **Property 21: List UI state truth table** — _Validates: Requirements 9.4, 9.7, 9.8_
    - `numRuns: 100`, tag `[P21]`

  - [ ] 9.6 Property test: debounced search last-write-wins
    - Use `fc.scheduler` or fake timers to generate sequences `(t_i, s_i)` of search-input changes; assert exactly one `GET /api/contacts` is fired with `q` equal to the trimmed final value `s_n` after a ≥300ms idle period, and earlier in-flight responses are discarded
    - For tag-selector changes: assert a `GET /api/contacts` with the new `tag` is fired within 100ms without debounce
    - **Property 22: Debounced search last-write-wins** — _Validates: Requirements 10.2, 10.3, 10.5_
    - `numRuns: 100`, tag `[P22]`

  - [ ] 9.7 Example tests for non-property frontend criteria
    - `ContactListView.spec.js`: viewport 760px renders cards, viewport 768px renders rows (Requirements 9.1, 9.2)
    - `ConfirmModal.spec.js`: renders Confirm + Cancel actions with the contact's full name (Requirement 12.1)
    - `ContactForm.spec.js`: the seven inputs render with the expected `maxlength` attributes (Requirement 8.1)
    - _Requirements: 8.1, 9.1, 9.2, 12.1_

- [x] 10. Final integration and wiring
  - [x] 10.1 Wire routes, store, and views end-to-end
    - Confirm `ContactListView` → form navigation, `Toast` container in `App.vue`, error interceptor flows into the store, and the Pinia store is the single source for `contacts` and `error`
    - Verify `npm run build` produces a working bundle and `php artisan serve` returns the SPA shell on `/` and JSON on `/api/contacts`
    - _Requirements: 8.4, 9.3, 9.5, 11.3, 11.4, 12.3_

- [x] 11. Final checkpoint
  - Ensure all backend and frontend tests pass, ask the user if questions arise.

## Notes

- Tasks marked with `*` are optional and can be skipped for a faster MVP. The 23 property test tasks (4.4–4.19, 8.3, 8.4, 9.3–9.6) and the example tests (1.4, 4.20, 9.7) are all optional sub-tasks under non-optional parent tasks.
- Each property test sub-task explicitly references its property number from the design (Property 1..23) and the requirement clauses it validates, per the design's PBT conventions ([P{N}] tag, ≥100 iterations).
- Property 14 lives under task 2.4 because it exercises the `Contact` scopes directly; the controller-level versions are covered transitively by Properties 12 and 15 and the integration the route layer adds.
- Backend property tests run on in-memory SQLite (`phpunit.xml`), so the `withTag` scope must include the SQLite fallback path. Frontend tests use Vitest + happy-dom and mock axios.
- Checkpoints (5, 11) are the recommended pause points before moving from backend → frontend and before declaring the feature complete.

## Task Dependency Graph

```json
{
  "waves": [
    { "id": 0, "tasks": ["1.1", "2.1", "2.3", "3.1", "6.1"] },
    { "id": 1, "tasks": ["1.2", "1.3", "2.2", "3.2", "3.4", "4.1", "6.2", "7.1"] },
    { "id": 2, "tasks": ["1.4", "2.4", "3.3", "4.2", "6.3", "7.2", "7.4", "9.1"] },
    { "id": 3, "tasks": ["4.3", "4.20", "7.3", "8.1"] },
    { "id": 4, "tasks": ["4.4", "4.5", "4.6", "4.7", "4.8", "4.9", "4.10", "4.11", "4.12", "4.13", "4.14", "4.15", "4.16", "4.17", "4.18", "4.19", "8.2", "9.2"] },
    { "id": 5, "tasks": ["8.3", "8.4", "9.3", "9.4", "9.5", "9.6", "9.7", "10.1"] }
  ]
}
```
