# Design Document

## Overview

The Event CRM Contacts feature is delivered as a thin Laravel 13 / PHP 8.3 JSON API wrapped by a Vue 3 single-page application. The API exposes a single `Contact` resource through a conventional set of REST endpoints under `/api/contacts`. The Vue SPA is mounted by a single Blade entry point, fetches data via an `axios` client, manages state in a small Pinia store, and routes between a list view, a create form, and an edit form using Vue Router.

The product's defining trait is "fast capture at an event": every interaction must work on a phone screen, validate locally before hitting the network, and stay responsive when the network is flaky. The design therefore biases toward:

- A **flat, single-table** `contacts` schema with a JSON `tags` column. No tag entity, no joins, no eager loading concerns. Tag filtering is server-side using MySQL `JSON_CONTAINS`.
- **Server-authoritative validation** mirrored in the form so users get immediate feedback while the API stays the source of truth (`name` rules, length limits, email format, tag rules all enforced in `FormRequest` classes).
- A **single API Resource** (`ContactResource`) so every endpoint serializes contacts identically — same field set, same ISO 8601 timestamps, same tag ordering.
- **Centralized exception handling** in `bootstrap/app.php` so every failure path (`ValidationException`, `ModelNotFoundException`, malformed JSON, generic 500) produces the consistent `{ "message": "..." }` shape required by Requirement 13.

### Research Notes

A small set of platform decisions was confirmed against the actual project setup before drafting the architecture:

- **Vue is not yet installed.** `package.json` has only Vite + Tailwind 4 + the Laravel Vite plugin. Vue 3, `@vitejs/plugin-vue`, `vue-router`, `pinia`, and `axios` need to be added. Citing Laravel docs: a Laravel + Vue SPA is wired by declaring a Vue plugin in Vite, mounting an `<App />` to a `<div id="app">` in a Blade root view, and serving any non-API path with a catch-all route that returns that view ([Laravel Vite docs](https://laravel.com/docs/13.x/vite), content paraphrased for licensing compliance).
- **No `routes/api.php` yet.** Laravel 13 ships without API routing by default; it must be enabled by passing `api: __DIR__.'/../routes/api.php'` to `withRouting()` in `bootstrap/app.php` and creating the file. Without that step, defining the resource routes in `web.php` would expose them at `/contacts` instead of `/api/contacts`.
- **MySQL is the configured database.** `config/database.php` defaults to `sqlite` only as a fallback; the `.env` is configured for MySQL. The `tags` column uses MySQL's native `JSON` type (`$table->json('tags')`), and the `JSON_CONTAINS` function is used for tag filtering. Tests run against in-memory SQLite (`phpunit.xml`), so the model and controller code path must avoid MySQL-only SQL where possible — tag filtering is therefore wrapped behind a model scope that detects the active driver and falls back to a `LIKE` against the JSON-encoded text on SQLite (only used in tests).
- **Pest 4 is installed** (`composer.json` `pestphp/pest: ^4.7`). For property-based testing in PHP, the most active library is `pestphp/pest-plugin-faker`-style example generation combined with the `eris/eris` PBT library, but the most common Pest-friendly choice today is **`thephpleague/period`-style generators with Pest's `dataset()` helper** for deterministic example sweeps and **[`mhujer/pest-plugin-pbt`](https://packagist.org/) (or `eris/eris`)** for true randomized PBT. We pick **`eris/eris`** as the property-based testing library because it is the mature, framework-agnostic PBT library for PHP and integrates cleanly with Pest's `it(...)` blocks. (Content paraphrased from package listings; license-compliant.)

### High-Level Diagram

```mermaid
flowchart LR
    Browser[User Browser]
    subgraph Frontend [Vue 3 SPA - resources/js]
      Router[Vue Router]
      Store[Pinia contactsStore]
      ListView[ContactListView]
      FormView[ContactFormView]
      ApiClient[axios api/contacts client]
    end
    subgraph Backend [Laravel 13 API]
      Routes[routes/api.php]
      Controller[ContactController]
      Requests[StoreContactRequest / UpdateContactRequest / IndexContactRequest]
      Resource[ContactResource]
      Model[Contact Eloquent model]
      DB[(MySQL contacts table)]
      Exceptions[Exception handler in bootstrap/app.php]
    end

    Browser --> Router --> ListView
    Router --> FormView
    ListView <--> Store
    FormView <--> Store
    Store <--> ApiClient
    ApiClient -- HTTPS JSON --> Routes --> Controller
    Controller --> Requests
    Controller --> Model --> DB
    Controller --> Resource --> Browser
    Controller -. errors .-> Exceptions -. JSON 4xx/5xx .-> Browser
```

## Architecture

### Backend Layering

The backend follows Laravel's conventional resourceful layering, with one controller, three form requests, one resource, one model, and one migration.

| Layer | File | Responsibility |
| --- | --- | --- |
| Routing | `routes/api.php` | Declares the `/api/contacts` resource routes and binds `{contact}` to the model. |
| Controller | `app/Http/Controllers/Api/ContactController.php` | Translates HTTP requests into model operations and resource responses. No business logic beyond delegation. |
| Form Requests | `app/Http/Requests/IndexContactRequest.php`, `StoreContactRequest.php`, `UpdateContactRequest.php` | Owns validation rules per endpoint. Performs trimming/normalisation in `prepareForValidation`. |
| Resource | `app/Http/Resources/ContactResource.php` | Single source of truth for the JSON shape returned to the client. |
| Model | `app/Models/Contact.php` | Eloquent model with `tags` cast to `array`, scopes for search and tag filter, and millisecond-precision timestamp casts. |
| Migration | `database/migrations/xxxx_create_contacts_table.php` | Creates the `contacts` table and indexes. |
| Exception handler | `bootstrap/app.php` (`withExceptions(...)`) | Maps validation, not-found, JSON-parse, and unhandled exceptions to consistent JSON error bodies. |

### Frontend Layering

The Vue SPA is structured around a single feature, so it intentionally avoids feature folders.

| Layer | Path | Responsibility |
| --- | --- | --- |
| Entry | `resources/js/app.js` | Creates the Vue app, installs Pinia and the router, mounts to `#app`. |
| Root | `resources/js/App.vue` | Top-level layout with mobile/desktop responsive shell, holds the `<router-view />`. |
| Routing | `resources/js/router/index.js` | Three named routes: `contacts.index`, `contacts.create`, `contacts.edit`. |
| Store | `resources/js/stores/contacts.js` | Pinia store holding the current page of contacts, pagination meta, search/tag filters, in-flight flags, and last-error state. |
| API client | `resources/js/api/contacts.js` | Wraps `axios` with a 10-second timeout, exposes `list`, `get`, `create`, `update`, `remove`. |
| Views | `resources/js/views/ContactListView.vue`, `ContactFormView.vue` | Page-level components that compose the smaller pieces. |
| Components | `resources/js/components/ContactRow.vue`, `ContactCard.vue`, `ContactForm.vue`, `TagChip.vue`, `Pagination.vue`, `ConfirmModal.vue`, `Toast.vue` | Reusable building blocks. |
| Composables | `resources/js/composables/useDebouncedRef.js`, `useToast.js` | Small utilities for debounce and ephemeral notifications. |

### Request/Response Lifecycle

For a successful create:

```mermaid
sequenceDiagram
    participant U as User
    participant F as ContactForm.vue
    participant S as Pinia store
    participant A as axios client
    participant C as ContactController
    participant R as StoreContactRequest
    participant M as Contact model
    participant D as MySQL

    U->>F: Submits form
    F->>F: Local validation (name non-empty, lengths)
    F->>S: store.create(payload)
    S->>A: POST /api/contacts
    A->>C: HTTP 201 path
    C->>R: validate()
    R-->>C: validated data
    C->>M: Contact::create(data)
    M->>D: INSERT
    D-->>M: row
    M-->>C: model
    C-->>A: 201 + ContactResource JSON
    A-->>S: response
    S-->>F: success
    F->>F: Reset, navigate to list
```

For a debounced search:

```mermaid
sequenceDiagram
    participant U as User
    participant L as ContactListView
    participant S as Pinia store
    participant A as axios client
    participant C as ContactController

    U->>L: Types "ali"
    L->>L: useDebouncedRef (300ms)
    Note over L: Cancels older pending fetch token
    L->>S: store.fetchPage(q="ali")
    S->>A: GET /api/contacts?q=ali (cancel token N)
    A->>C: 200
    C-->>A: paginated JSON
    A-->>S: response if token N still latest
    S-->>L: render
```

## Components and Interfaces

### Backend Components

#### `routes/api.php`

```php
Route::apiResource('contacts', ContactController::class);
```

`api.php` is wired by adding `api: __DIR__.'/../routes/api.php'` to `Application::configure(...)->withRouting(...)` in `bootstrap/app.php`. By default this gives the `/api` URL prefix and the `api` middleware group, satisfying the `/api/contacts` URL contract.

#### `ContactController`

A standard resourceful controller with five public methods. It delegates validation to `FormRequest` classes and serialization to `ContactResource`.

```php
final class ContactController extends Controller
{
    public function index(IndexContactRequest $request): AnonymousResourceCollection;
    public function store(StoreContactRequest $request): JsonResponse;        // 201
    public function show(Contact $contact): ContactResource;                  // 200
    public function update(UpdateContactRequest $request, Contact $contact);  // 200
    public function destroy(Contact $contact): Response;                       // 204
}
```

Notable behaviors:

- `index` calls `Contact::query()->search($q)->withTag($tag)->orderByDesc('created_at')->orderByDesc('id')->paginate($perPage, ['*'], 'page', $page)` and wraps it with `ContactResource::collection(...)`. Laravel's pagination automatically yields the `meta.{total,per_page,current_page,last_page}` shape required by Requirement 6.1.
- `update` is invoked for both `PUT` and `PATCH`. The `UpdateContactRequest` exposes a flag (`$request->isFullReplace()` based on `$request->isMethod('put')`) that the controller uses to decide whether omitted optional fields should be reset to their empty value (Requirement 4.2) or left alone (Requirement 4.7).
- `destroy` uses `DB::transaction` only when needed; for a hard delete the controller catches `\Throwable` and rethrows so the global exception handler turns it into a 500 with the contact retained (Requirement 5.4).
- `show`, `update`, and `destroy` rely on Laravel's implicit route-model binding. `Contact::class` therefore needs the standard binding key `id`. A non-numeric `{id}` triggers a `RouteNotFoundException` which the exception handler re-shapes to a 400 (Requirement 3.3) when the segment fails the integer pattern, and to a 404 when the integer is well-formed but no row matches.

#### Form Requests

```php
final class StoreContactRequest extends FormRequest {
    protected function prepareForValidation(): void {
        $this->merge([
            'name'    => is_string($this->name)    ? trim($this->name) : $this->name,
            'email'   => is_string($this->email)   ? trim($this->email) : $this->email,
            'phone'   => is_string($this->phone)   ? trim($this->phone) : $this->phone,
            'company' => is_string($this->company) ? trim($this->company) : $this->company,
            'role'    => is_string($this->role)    ? trim($this->role) : $this->role,
            'notes'   => is_string($this->notes)   ? trim($this->notes) : $this->notes,
            'tags'    => $this->normaliseTags($this->tags),
        ]);
    }

    public function rules(): array {
        return [
            'name'    => ['required', 'string', 'min:1', 'max:120'],
            'email'   => ['nullable', 'string', 'max:254', new ValidEmail()],
            'phone'   => ['nullable', 'string', 'max:32', 'regex:/^[0-9 +\-()]*$/'],
            'company' => ['nullable', 'string', 'max:120'],
            'role'    => ['nullable', 'string', 'max:120'],
            'notes'   => ['nullable', 'string', 'max:2000'],
            'tags'    => ['array', 'max:10', new UniqueCaseInsensitive()],
            'tags.*'  => ['string', 'min:1', 'max:32'],
        ];
    }
}
```

- `UpdateContactRequest` extends `StoreContactRequest` for `PUT` and switches all fields to optional for `PATCH` while preserving the same per-field rules (`sometimes` instead of `required`).
- `IndexContactRequest` validates `q` (`nullable|string|max:120` after trim), `tag` (`nullable|string|max:32` after trim), `per_page` (`nullable|integer|between:1,100`), and `page` (`nullable|integer|min:1`). Empty or whitespace-only `q`/`tag` values are rewritten to `null` in `prepareForValidation` to satisfy Requirement 7.3.
- `ValidEmail` is a custom rule that enforces the exact format from Requirement 1.3 (single `@`, non-empty local part, domain with at least one dot, no whitespace) — Laravel's default `email` rule rejects some inputs the requirement allows and accepts some it forbids.
- `UniqueCaseInsensitive` validates that the lowercased trimmed values in `tags` contain no duplicates.
- All form requests strip server-managed fields (`id`, `created_at`, `updated_at`) in `prepareForValidation` before they reach `validated()` (Requirements 2.8, 4.8).

#### `ContactResource`

```php
final class ContactResource extends JsonResource {
    public function toArray($request): array {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'phone'      => $this->phone,
            'company'    => $this->company,
            'role'       => $this->role,
            'notes'      => $this->notes,
            'tags'       => $this->tags ?? [],
            'created_at' => $this->created_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
            'updated_at' => $this->updated_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
        ];
    }
}
```

The explicit ISO 8601 `Y-m-d\TH:i:s\Z` format pins the wire shape required by Requirement 13.3 (seconds precision, trailing `Z`). The `tags` array is serialised in the order it was stored (Requirement 13.2).

#### `Contact` Eloquent Model

```php
final class Contact extends Model {
    protected $fillable = ['name', 'email', 'phone', 'company', 'role', 'notes', 'tags'];

    protected function casts(): array {
        return [
            'tags'       => 'array',
            'created_at' => 'datetime:Y-m-d H:i:s.v',
            'updated_at' => 'datetime:Y-m-d H:i:s.v',
        ];
    }

    public function scopeSearch(Builder $q, ?string $term): Builder { /* see below */ }
    public function scopeWithTag(Builder $q, ?string $tag): Builder { /* see below */ }
}
```

- `scopeSearch` short-circuits when `$term` is `null` or whitespace-only. Otherwise it produces `WHERE LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(company) LIKE ? OR LOWER(role) LIKE ? OR LOWER(notes) LIKE ?` with `'%'.strtolower($term).'%'`.
- `scopeWithTag` short-circuits when `$tag` is `null` or whitespace-only. On MySQL it uses `JSON_CONTAINS(tags, JSON_QUOTE(LOWER(?)))` against a generated lowercased mirror column or, more simply, `WHERE JSON_SEARCH(LOWER(tags), 'one', LOWER(?)) IS NOT NULL`. On SQLite (tests) it falls back to `WHERE LOWER(tags) LIKE ?` with `%"'.strtolower($tag).'"%'`. The driver detection lives in a private helper.

#### Exception Handler (`bootstrap/app.php`)

The handler centralises the response shape:

```php
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (ValidationException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'message' => 'The given data was invalid.',
                'errors'  => $e->errors(),
            ], 422);
        }
    });
    $exceptions->render(function (ModelNotFoundException|NotFoundHttpException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json(['message' => 'Resource not found.'], 404);
        }
    });
    $exceptions->render(function (BadRequestHttpException|JsonParseException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json(['message' => 'Malformed JSON request body.'], 400);
        }
    });
    $exceptions->render(function (\Throwable $e, $request) {
        if ($request->is('api/*') && ! $e instanceof HttpExceptionInterface) {
            return response()->json(['message' => 'An unexpected error occurred.'], 500);
        }
    });
});
```

A small `EnsureJsonRequestBody` middleware applied to the API route group attempts `json_decode` on any non-empty body and throws `BadRequestHttpException` on failure (Requirement 13.4) before the controller runs.

### Frontend Components

#### `ContactForm.vue`

```text
Props: modelValue: Contact | null, mode: 'create' | 'edit', submitting: boolean,
       serverErrors: Record<string, string[]>
Emits: submit(payload), cancel()
Local state:
  fields: { name, email, phone, company, role, notes, tags }
  tagDraft: string
  localErrors: Record<string, string>
```

- All length limits and the `name`-required rule are enforced locally before emitting `submit`. Local errors are merged with `serverErrors` for display.
- Tags are entered as a chip list: typing then pressing `Enter`, comma, or blur pushes a trimmed value onto the list, deduplicating case-insensitively, capping at 10, rejecting empty.
- The submit button is bound to `:disabled="submitting"` (Requirement 8.3).

#### `ContactListView.vue`

Owns the list page. Composes `<SearchBar>`, `<TagSelect>`, `<ContactRow>`/`<ContactCard>` (chosen at runtime via `useMediaQuery('(min-width: 768px)')`), `<Pagination>`, and `<ConfirmModal>`.

- Uses a `useDebouncedRef(searchInput, 300)` composable for `q` (Requirement 10.2) and a plain `watch` on the tag selector that triggers within ~10ms (Requirement 10.3).
- Cancels in-flight fetches by tracking a monotonically increasing `requestId` in the store; responses with stale ids are dropped (Requirement 10.2 last-write-wins).
- Holds three independent UI states: `loading`, `error`, `empty`. The `empty` state is shown only when `!loading && !error && contacts.length === 0` (Requirements 9.4, 9.7).

#### Pinia store: `contactsStore`

```ts
state: {
  contacts: Contact[]
  meta: { total: number, per_page: number, current_page: number, last_page: number }
  filters: { q: string, tag: string | null }
  loading: boolean
  error: { message: string, kind: 'network'|'http'|'timeout' } | null
  inflightId: number
  pendingIds: Set<number>          // contact ids whose delete is in flight
}
actions:
  fetchPage(page?: number): Promise<void>
  create(payload): Promise<Contact>
  update(id, payload): Promise<Contact>
  remove(id): Promise<void>
  setFilters({ q?, tag? }): void
```

`fetchPage` increments `inflightId` and only commits the result if the response's id matches the latest. `remove` adds to `pendingIds` while the request is in flight (Requirement 12.2).

#### `api/contacts.js`

```js
import axios from 'axios';
const http = axios.create({ baseURL: '/api', timeout: 10_000, headers: { Accept: 'application/json' } });
export const list   = (params)    => http.get('/contacts', { params });
export const get    = (id)        => http.get(`/contacts/${id}`);
export const create = (payload)   => http.post('/contacts', payload);
export const update = (id, body)  => http.put(`/contacts/${id}`, body);   // PATCH variant for partial edits
export const remove = (id)        => http.delete(`/contacts/${id}`);
```

The 10-second timeout matches Requirements 8.9 and 12.2. A single response interceptor normalises errors to `{ kind, status, message, fields }` so views never need to introspect axios internals.

### Routing (Frontend)

| Path | Component | Notes |
| --- | --- | --- |
| `/` | `ContactListView` | Default route. |
| `/contacts/new` | `ContactFormView` (mode = `create`) | Mounted with an empty form. |
| `/contacts/:id/edit` | `ContactFormView` (mode = `edit`) | Calls `store.fetchOne(id)`; redirects to `/` if 404. |

A catch-all Laravel route serves the SPA shell so that deep links work:

```php
Route::get('/{any}', fn () => view('app'))->where('any', '^(?!api).*');
```

## Data Models

### `contacts` table (MySQL)

| Column | Type | Nullable | Notes |
| --- | --- | --- | --- |
| `id` | `BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY` | No | Server-generated, immutable. |
| `name` | `VARCHAR(120)` | No | Stored trimmed. |
| `email` | `VARCHAR(254)` | Yes | Stored trimmed; format-validated. |
| `phone` | `VARCHAR(32)` | Yes | Allowed chars: digits, space, `+ - ( )`. |
| `company` | `VARCHAR(120)` | Yes | |
| `role` | `VARCHAR(120)` | Yes | |
| `notes` | `TEXT` | Yes | Length capped to 2000 in validation. |
| `tags` | `JSON` | No (default `'[]'`) | Array of distinct, trimmed labels (max 10, each ≤32 chars). |
| `created_at` | `DATETIME(3)` | No | Millisecond precision UTC. |
| `updated_at` | `DATETIME(3)` | No | Millisecond precision UTC. |

Indexes:

- `INDEX contacts_created_at_id_idx (created_at DESC, id DESC)` — supports the default ordering and pagination.
- `INDEX contacts_name_idx (name)` — supports common substring searches starting with the name.

The migration:

```php
Schema::create('contacts', function (Blueprint $t) {
    $t->id();
    $t->string('name', 120);
    $t->string('email', 254)->nullable();
    $t->string('phone', 32)->nullable();
    $t->string('company', 120)->nullable();
    $t->string('role', 120)->nullable();
    $t->text('notes')->nullable();
    $t->json('tags');
    $t->timestamp('created_at', 3)->useCurrent();
    $t->timestamp('updated_at', 3)->useCurrent()->useCurrentOnUpdate();
    $t->index(['created_at', 'id'], 'contacts_created_at_id_idx');
    $t->index('name', 'contacts_name_idx');
});
```

### Contact JSON shape (wire format)

```json
{
  "id": 42,
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "phone": "+44 20 7946 0958",
  "company": "Analytical Engines Ltd",
  "role": "Mathematician",
  "notes": "Met at the Babbage talk; interested in numerical methods.",
  "tags": ["math", "speaker"],
  "created_at": "2025-03-14T09:12:33Z",
  "updated_at": "2025-03-14T09:12:33Z"
}
```

### Paginated list shape

```json
{
  "data": [ { /* Contact */ }, { /* Contact */ } ],
  "meta": {
    "total": 137,
    "per_page": 25,
    "current_page": 2,
    "last_page": 6
  },
  "links": {
    "first": "http://.../api/contacts?page=1",
    "last":  "http://.../api/contacts?page=6",
    "prev":  "http://.../api/contacts?page=1",
    "next":  "http://.../api/contacts?page=3"
  }
}
```

The `links` block is supplied automatically by Laravel's resource collection and is additive to the contract — Requirement 6.1 only requires `data` and `meta`.

### Error response shape

```json
{ "message": "The given data was invalid.", "errors": { "name": ["The name field is required."] } }
```

Validation responses include `errors`; non-validation errors (`400`, `404`, `500`) include only `message`.

### API contract summary

| Method | Path | Success | Errors |
| --- | --- | --- | --- |
| `GET` | `/api/contacts?q&tag&page&per_page` | `200` paginated list | `422` invalid params |
| `POST` | `/api/contacts` | `201` Contact | `400` bad JSON, `422` validation |
| `GET` | `/api/contacts/{id}` | `200` Contact | `400` bad id format, `404` missing, `500` malformed row |
| `PATCH` | `/api/contacts/{id}` | `200` Contact | `404`, `422` |
| `PUT` | `/api/contacts/{id}` | `200` Contact | `404`, `422` |
| `DELETE` | `/api/contacts/{id}` | `204` empty | `404`, `500` |


## Correctness Properties

*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*

The properties below are derived from the prework analysis. Properties have been consolidated where multiple acceptance criteria reduce to the same invariant (for example all length-bound validation rules collapse into one parameterised property; all 404-on-missing rules collapse into one).

### Property 1: Create-then-read round trip

*For any* valid Contact payload `P`, after a successful `POST /api/contacts`, performing `GET /api/contacts/{id}` with the assigned `id` SHALL return a Contact whose `name`, `email`, `phone`, `company`, `role`, `notes`, and `tags` (in the originally stored order) equal the trimmed values of `P`, and whose `created_at` equals `updated_at`.

**Validates: Requirements 1.1, 2.1, 2.2, 13.2**

### Property 2: Server-managed identity is immutable

*For any* Contact and *for any* sequence of subsequent valid `PATCH` or `PUT` requests, the Contact's `id` and `created_at` SHALL be unchanged from their values at creation, regardless of whether the request body included `id`, `created_at`, or `updated_at` overrides.

**Validates: Requirements 1.1, 2.8, 4.8**

### Property 3: Length and character validation

*For any* field `F ∈ {name, email, phone, company, role, notes}` with declared maximum length `L_F`, a write request whose `F` exceeds `L_F` characters (or is non-string, or for `name` is empty after trimming, or for `email`/`phone` violates its character-class rule) SHALL produce HTTP 422 with a Validation_Error entry keyed on `F` and SHALL NOT persist any change.

**Validates: Requirements 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.3, 2.4, 2.5, 4.6**

### Property 4: Tag list invariant

*For any* `tags` value submitted in a write request, the request SHALL be accepted iff the value is a list of strings, contains at most 10 entries, every entry trims to length 1..32, and the lower-cased entries form a set with no duplicates; otherwise the request SHALL produce HTTP 422 keyed on `tags` and SHALL NOT persist any change.

**Validates: Requirements 1.8, 2.6, 4.6**

### Property 5: Atomicity of failed writes

*For any* pre-state `S` of the contacts table and *for any* invalid `POST`, `PATCH`, or `PUT` request, the post-state SHALL equal `S` byte-for-byte across every field of every Contact.

**Validates: Requirements 1.10, 2.3, 2.4, 2.5, 2.6, 4.6**

### Property 6: Validation aggregates one error per field

*For any* write request whose body invalidates a non-empty subset `K` of fields, the response SHALL be a single HTTP 422 whose `errors` object's keys equal exactly `K` (one entry per field, no duplicates, no extras).

**Validates: Requirements 2.7**

### Property 7: `updated_at` monotonicity

*For any* Contact and *for any* update request, if the request changes at least one stored field then post-`updated_at` > pre-`updated_at`; if the request leaves every field equal to its current stored value, post-`updated_at` = pre-`updated_at`. In both cases `created_at` is unchanged.

**Validates: Requirements 1.9, 4.3, 4.4**

### Property 8: PATCH preserves omitted fields, PUT replaces all

*For any* Contact `C` with state `S`, *for any* `PATCH` body containing fields `F`, the post-state equals `S` overlaid with `F` (omitted fields unchanged). *For any* valid `PUT` body, the post-state's optional fields omitted from the body equal their empty value (`null` for strings, `[]` for `tags`) and required fields equal the body's values.

**Validates: Requirements 4.1, 4.2, 4.7**

### Property 9: GET is read-only

*For any* sequence of `GET /api/contacts` and `GET /api/contacts/{id}` requests interleaved over a population `P`, the post-state of the contacts table SHALL be byte-for-byte equal to the pre-state, including every Contact's `updated_at`.

**Validates: Requirements 3.4**

### Property 10: Missing or malformed identifiers

*For any* HTTP method `M ∈ {GET, PATCH, PUT, DELETE}` and *for any* identifier `i` that is syntactically valid but absent from storage, `M /api/contacts/{i}` SHALL respond with HTTP 404 and a body whose only top-level fields are `message` (non-empty string). *For any* identifier `i` that fails the integer pattern, `GET /api/contacts/{i}` SHALL respond with HTTP 400 and the same body shape, and SHALL NOT perform a database lookup.

**Validates: Requirements 3.2, 3.3, 4.5, 5.2**

### Property 11: Delete eliminates the contact

*For any* Contact `c` present in storage, after a successful `DELETE /api/contacts/{c.id}` (HTTP 204 with empty body), every subsequent `GET /api/contacts/{c.id}` SHALL respond 404 and `c.id` SHALL NOT appear in the `data` array of any subsequent `GET /api/contacts` response under any combination of valid query parameters.

**Validates: Requirements 5.1, 5.3**

### Property 12: List response shape and pagination

*For any* population `P` of contacts, any `per_page ∈ [1,100]` (defaulting to 25 when omitted), and any `page ≥ 1`, `GET /api/contacts?per_page=…&page=…` SHALL return a body where:

- `|data| ≤ per_page` and `|data|` equals 0 when `page > meta.last_page` else equals the size of the corresponding slice of `P` ordered by `(created_at desc, id desc)`,
- the concatenation of `data` across `page = 1..meta.last_page` equals `P` ordered by `(created_at desc, id desc)`,
- `meta.total = |P|`, `meta.per_page` equals the requested (or default) value, `meta.current_page` equals the requested (or default) value, `meta.last_page = max(1, ceil(|P| / meta.per_page))`.

**Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.8, 6.9**

### Property 13: Pagination parameter validation

*For any* request to `GET /api/contacts` whose `per_page` is not an integer in `[1,100]` or whose `page` is not an integer in `[1, ∞)`, the response SHALL be HTTP 422 keyed on the offending parameter, and the response body SHALL NOT contain a `data` array of contacts.

**Validates: Requirements 6.7**

### Property 14: Search and tag filter soundness

*For any* population `P`, any trimmed `q ∈ [0..120]` characters, and any trimmed `tag ∈ [0..32]` characters, let `match(c) = (q is empty OR any of c.name, c.email, c.company, c.role, c.notes contains q case-insensitively) AND (tag is empty OR c.tags contains tag case-insensitively)`. Then for any `per_page` and `page`, the concatenation across pages of `data` from `GET /api/contacts?q=…&tag=…&per_page=…&page=…` SHALL equal `{ c ∈ P | match(c) }` ordered by `(created_at desc, id desc)`. Whitespace-only `q` or `tag` SHALL be treated identically to omission.

**Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5**

### Property 15: Search and tag parameter validation

*For any* request whose trimmed `q` exceeds 120 characters or whose trimmed `tag` exceeds 32 characters, the response SHALL be HTTP 422 keyed on the offending parameter, and the response body SHALL NOT contain a `data` array of filtered contacts.

**Validates: Requirements 7.6**

### Property 16: Error response envelope

*For any* request that produces an HTTP status `s ∈ {400, 404, 422, 500}` from `/api/contacts*`, the response body SHALL be a single JSON object whose top-level keys are a subset of `{message, errors}`, where `message` is a string of length 1..500, `errors` (when present) is a non-empty object whose values are non-empty arrays of strings, and the object SHALL NOT contain any Contact payload fields (`id`, `name`, `email`, `phone`, `company`, `role`, `notes`, `tags`, `created_at`, `updated_at`). The response SHALL NOT include framework-internal markers (file paths, stack traces, exception class names).

**Validates: Requirements 2.9, 13.4, 13.5, 13.6**

### Property 17: Response content-type and timestamp format

*For any* response with a non-empty body from `/api/contacts*`, the `Content-Type` header SHALL equal `application/json; charset=utf-8`, and any `created_at`/`updated_at` field present SHALL match the regex `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`.

**Validates: Requirements 13.1, 13.3**

### Property 18: Frontend submit gating and validation

*For any* Contact_Form state, submitting SHALL trigger exactly one `POST` (for create) or one `PUT` (for edit) request iff the form's `name` trims to a non-empty string and every other field respects its declared max length and there is no submission already in flight; otherwise SHALL trigger zero requests and SHALL display inline error(s) on the offending fields while preserving the user's other entered values.

**Validates: Requirements 8.2, 8.3, 8.5, 8.6, 11.2, 11.6**

### Property 19: Frontend post-success behaviour

*For any* successful response (`201` create, `200` edit, `204` delete), the Contact_List_View SHALL reflect the change (new row appended, existing row updated, existing row removed), the source form/modal SHALL close, the submit/delete control SHALL re-enable, and a non-blocking success toast SHALL be shown for the delete case (auto-dismissing after 5 seconds).

**Validates: Requirements 8.4, 11.3, 12.3**

### Property 20: Frontend error handling consistency

*For any* failure observed by the Frontend (HTTP 4xx, HTTP 5xx, network error, or 10-second timeout), the user-entered values in the active Contact_Form SHALL be preserved, the originating control SHALL be re-enabled, an error message SHALL be displayed without obscuring previously rendered Contacts, and on the list view a retry control SHALL be available; if the failure is `404` on edit/delete, the Contact SHALL be removed from the list with a non-blocking notification.

**Validates: Requirements 8.7, 8.8, 8.9, 9.6, 11.4, 11.5, 11.7, 11.8, 12.5, 12.6**

### Property 21: List UI state truth table

*For any* combination of `loading`, `error`, and `contacts.length` in the Contact_List_View, the visible state SHALL satisfy: `loading_indicator_visible ⇔ loading`; `pagination_disabled ⇔ loading`; `error_banner_visible ⇔ (error ≠ null)`; `empty_state_visible ⇔ (!loading ∧ !error ∧ contacts.length = 0)`.

**Validates: Requirements 9.4, 9.7, 9.8**

### Property 22: Debounced search last-write-wins

*For any* sequence `(t_i, s_i)` of search-input changes where `s_n` is the final value before idle period of ≥300ms, the Frontend SHALL issue exactly one `GET /api/contacts` request whose `q` parameter equals the trimmed `s_n`, and SHALL render only the response to that request (any earlier in-flight responses are discarded). For any tag-selector change `t`, the Frontend SHALL issue a `GET /api/contacts` with the new `tag` value within 100ms without applying the search debounce.

**Validates: Requirements 10.2, 10.3, 10.5**

### Property 23: Edit form pre-population round trip

*For any* existing Contact `c`, activating the edit control and waiting for the `GET /api/contacts/{c.id}` `200` response SHALL render a Contact_Form whose field values equal `c`'s persisted values exactly, and submitting that form unchanged SHALL produce a `PUT` request whose JSON body equals those values.

**Validates: Requirements 11.1, 11.2**

## Error Handling

### Backend Error Catalog

| Cause | HTTP | Response body | Notes |
| --- | --- | --- | --- |
| Invalid JSON body | 400 | `{ "message": "Malformed JSON request body." }` | Triggered by `EnsureJsonRequestBody` middleware via `BadRequestHttpException`. |
| Malformed `{id}` segment | 400 | `{ "message": "Invalid contact identifier." }` | Route pattern `whereNumber('contact')` rejects non-numeric segments before the controller runs. |
| Validation failure | 422 | `{ "message": "The given data was invalid.", "errors": { ... } }` | Standard Laravel `ValidationException` shape. |
| Contact not found | 404 | `{ "message": "Contact not found." }` | Implicit route-model binding throws `ModelNotFoundException`. |
| Corrupted record (Requirement 3.5) | 500 | `{ "message": "An unexpected error occurred." }` | Detected in `ContactResource::toArray` when required field is null; throws `LogicException`. |
| Unhandled exception | 500 | `{ "message": "An unexpected error occurred." }` | Generic catch in `withExceptions(...)`. Stack traces, file paths, and class names are scrubbed. |
| Delete database failure (Requirement 5.4) | 500 | `{ "message": "An unexpected error occurred." }` | Wrapped `Throwable` from `$contact->delete()`. The row is preserved because the failed delete throws before the response. |

A custom `Handler::shouldReport` is unnecessary: Laravel logs unhandled `Throwable`s by default, and the API JSON response is generated solely from the `withExceptions` render closures.

### Frontend Error Catalog

| Cause | UI behaviour |
| --- | --- |
| Local form validation failure | Inline error on each offending field; submit blocked; entered values retained. |
| API 422 | Field-mapped server errors merged into local error map; submit re-enabled; values retained. |
| API 4xx (other than 404 on edit/delete) | Toast message; control re-enabled; values retained; list contacts preserved. |
| API 404 on edit/delete | Non-blocking toast; the Contact is removed from the list; modal/form closed. |
| API 5xx | Toast message; control re-enabled; values retained; list contacts preserved. |
| Network error | Toast message; control re-enabled; values retained. |
| 10-second timeout | Toast message; control re-enabled; values retained. List view shows a retry control. |
| List error active | Hides empty-state; shows an error banner with a retry button; preserves previously rendered Contacts. |

### Race Conditions and Concurrency

- The list view uses an integer `inflightId` token guarded in the Pinia store. Only the response whose token equals the current `inflightId` is committed; earlier responses are dropped. This guards against out-of-order responses caused by debounced search.
- Two concurrent updates to the same Contact are not specifically protected — the second write wins (last-write-wins is acceptable for a single-user event-capture tool).
- Optimistic concurrency (e.g., `If-Match: <updated_at>`) is out of scope for this iteration.

## Testing Strategy

### Test Pyramid

| Layer | Tooling | What it covers |
| --- | --- | --- |
| Property-based backend tests | Pest 4 (`pestphp/pest`) + `eris/eris` PBT library, in-memory SQLite, `RefreshDatabase` | Properties 1–17 against the live `ContactController`. |
| Property-based frontend tests | Vitest + `@vue/test-utils` + `fast-check` | Properties 18–23 against mounted components with mocked `axios`. |
| Example unit tests | Pest 4 | Latency-bound criteria (3.1, 9.3, 9.5), the corrupted-row case (3.5), the DB-failure case (5.4), explicit table-vs-card layout (9.1, 9.2), and Requirement 8.1 (form rendering existence). |
| Smoke tests | Pest 4 | Booting the API route group, verifying the `/api/contacts` endpoints are registered, and asserting the migration runs cleanly. |

### Property-based Testing Library Choices

- **Backend (PHP):** `eris/eris` is the property-based testing library. It is installed via Composer (`composer require --dev giorgiosironi/eris`) and integrated with Pest 4 by calling `\Eris\Generator\…` inside `it(...)` blocks. Eris is the de-facto PBT library for PHP and works well with PHPUnit-derived test runners. We do not roll our own PBT framework.
- **Frontend (JS):** `fast-check` (https://fast-check.dev/) is the property-based testing library. It pairs naturally with Vitest's `test(name, () => fc.assert(fc.property(...)))` pattern.

### PBT Configuration Conventions

- Every property test runs **at least 100 iterations** (`fc.assert(prop, { numRuns: 100 })` for fast-check; `forAll(...)->withIterations(100)` for eris).
- Every property test is tagged with a comment of the form `// Feature: event-crm-contacts, Property {N}: {short property text}` and an `it(...)` description that includes `[P{N}]`. This lets a developer find a failing property's design counterpart from the test failure name alone.
- Failing examples are minimised by the framework's built-in shrinker. Reproduction seeds are logged on failure (eris prints the seed; fast-check prints the failing input and the seed).

### Generator Strategy

Backend generators that map directly to the data model:

- `genName()` — random Unicode strings of trimmed length 1..120.
- `genOverlongName()` — strings whose trimmed length is 0 or ≥ 121.
- `genEmail()` / `genInvalidEmail()` — emails respecting / violating Requirement 1.3.
- `genPhone()` / `genInvalidPhone()` — strings using only the allowed character class / containing forbidden characters.
- `genTagsValid()` — lists of 0..10 trimmed strings of length 1..32 with no case-insensitive duplicates.
- `genTagsInvalid()` — generators per violation (>10 entries, empty entry, >32-char entry, duplicate, non-string).
- `genContactPayload()` — composed from the above; can yield arbitrary subsets of optional fields.
- `genPopulation(n)` — inserts `n` valid contacts directly via the model factory.

Frontend generators:

- Form payloads generated identically to backend (kept in a shared TypeScript-style JSDoc spec).
- Sequences of search/tag changes paired with virtual time advancements use `fc.scheduler` to verify the debounced last-write-wins behaviour deterministically.

### Test Layout

```
tests/
  Feature/
    Api/
      ContactCreateTest.php          # Properties 1, 3, 4, 5, 6, 17
      ContactReadTest.php            # Properties 1, 9, 10, 17
      ContactUpdateTest.php          # Properties 2, 3, 4, 5, 6, 7, 8, 17
      ContactDeleteTest.php          # Properties 10, 11, 16
      ContactListTest.php            # Properties 12, 13, 16, 17
      ContactSearchTest.php          # Properties 14, 15, 16
      ContactErrorEnvelopeTest.php   # Properties 16, 17 + 13.4 / 13.5 / 5.4 examples
  Unit/
    Models/
      ContactScopesTest.php          # Search and tag scope unit cases
resources/js/__tests__/
  ContactForm.spec.js                # Property 18, 19 (create), 20 (edit/create)
  ContactListView.spec.js            # Properties 19, 20, 21, 22
  ContactEditFlow.spec.js            # Property 23, 11.x examples
  components/
    ConfirmModal.spec.js             # 12.1 example + Property 19 (delete)
```

### Test Configuration

- `phpunit.xml` already configures `DB_CONNECTION=sqlite` with an in-memory database for tests. The `Contact` model's `withTag` scope must therefore detect SQLite and apply the JSON-as-text fallback so backend property tests can exercise tag filtering without MySQL.
- `composer require --dev giorgiosironi/eris` adds the PBT library.
- A new `package.json` script `npm run test` runs Vitest against the `resources/js/__tests__` directory. `vitest`, `@vue/test-utils`, `@vitejs/plugin-vue`, and `fast-check` are added as dev dependencies.

### What is NOT Property-Tested

These criteria intentionally remain example tests rather than property tests:

- Latency-bound criteria (Requirements 3.1 "within 1000ms", 9.3 / 9.5 "render within 3 seconds"). Performance is sensitive to the test runner and would produce flaky property tests.
- Requirement 3.5 (corrupted-row 500 response): requires forcing a malformed DB state, which is a single deterministic example.
- Requirement 5.4 (DB delete failure): requires a mocked failure injection, which is one example, not a varying property.
- Layout-mode selection (table vs card) at the 768px breakpoint (Requirements 9.1, 9.2): the breakpoint itself is verified with two examples (760px and 768px viewports); the field-presence portion is covered by Property 19/20.
- Modal structural existence (Requirement 12.1, "Confirm and Cancel actions"): one component-mounting example.
- Requirement 8.1 (form-input existence): one example asserting the seven inputs render with the expected `maxlength` attributes.

### Acceptance for the Design Phase

The design is complete when:

- All Acceptance Criteria from the requirements document are referenced by at least one Correctness Property, an EXAMPLE test, or a SMOKE test (see prework analysis in context).
- The backend layering and frontend layering are clear enough to derive the task list without further decisions.
- Every API contract behaviour visible to the Frontend (status codes, body shapes, header) is enumerated and traceable to a property.
