# Phase A: Defensive 409 Save Guard

**Status**: Not Started  
**Priority**: High (Foundation for all subsequent phases)  
**Estimated Effort**: 3-5 days  
**Dependencies**: None (can start immediately)

---

## Overview

Implement the **defensive layer** of overwrite protection: a server-side optimistic concurrency check that blocks stale saves with HTTP 409 Conflict, plus a frontend conflict modal to guide users through safe resolution.

This phase provides immediate protection against silent overwrites **without requiring any broadcasting infrastructure**. It's the foundation that makes all subsequent phases safe to deploy incrementally.

---

## Goals

- Prevent silent overwrites when a record has been modified between load and save
- Provide clear, actionable feedback when a conflict occurs
- No dependency on websockets or Redis broadcasting
- Works for all standard Livewire record editors

---

## Non-Goals

- Real-time notifications (that's Phase B)
- Presence tracking (that's Phase C)
- List/table sync (that's Phase B)

---

## Technical Implementation

### Backend Changes

#### 1. Add `updated_by` Column (Optional but Recommended)

For models where we want to show "who changed it":

```sql
-- Example migration (tenant database)
ALTER TABLE flows ADD COLUMN updated_by BIGINT UNSIGNED NULL;
ALTER TABLE nodes ADD COLUMN updated_by BIGINT UNSIGNED NULL;
-- Repeat for other models...
```

Update model observers to capture `updated_by`:

```php
// In model observer or base model trait
static::saving(function ($model) {
    if ($model->isDirty() && !$model->wasRecentlyCreated) {
        $model->updated_by = auth()->id();
    }
});
```

#### 2. Add Optimistic Concurrency Check to Update Controllers

For each record update endpoint:

```php
public function update(Request $request, $id)
{
    $validated = $request->validate([
        'client_version' => 'required|string', // ISO timestamp
        // ... other fields
    ]);

    $record = YourModel::findOrFail($id);
    
    // Compare client version with server version
    if ($validated['client_version'] !== $record->updated_at->toJSON()) {
        // Use BaseApiController's conflictResponse() helper (aligns with API structure)
        return $this->conflictResponse(
            record: ['type' => 'your_model_type', 'id' => $record->id],
            currentVersion: $record->updated_at->toJSON(),
            updatedBy: $record->updated_by ? [
                'id' => $record->updatedByUser->id ?? null,
                'name' => $record->updatedByUser->name ?? 'Unknown',
            ] : null
        );
    }

    // Proceed with update...
    $record->update($validated);
    
    return $this->successResponse(['record' => $record]);
}
```

#### 3. Use BaseApiController's conflictResponse() Helper

All API controllers should extend `BaseApiController` which provides the standardized `conflictResponse()` method per `docs/architecture/api/api-structure.md`.

**BaseApiController Implementation** (add to `src/App/Http/Controllers/Api/BaseApiController.php`):

```php
/**
 * Return conflict response (optimistic concurrency failure)
 *
 * @param array $record Record identifier (type + id)
 * @param string $currentVersion Server's current version
 * @param array|null $updatedBy Who updated (id + name)
 * @return JsonResponse
 */
protected function conflictResponse(array $record, string $currentVersion, ?array $updatedBy = null): JsonResponse
{
    return $this->errorResponse('The record was updated more recently.', 409, [
        'code' => 'RESOURCE_CONFLICT',
        'current_version' => $currentVersion,
        'updated_by' => $updatedBy,
        'record' => $record,
    ]);
}
```

**Usage in API Controllers**:

```php
namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Api\BaseApiController;

class YourController extends BaseApiController
{
    public function update(Request $request, $id)
    {
        $record = YourModel::findOrFail($id);
        
        // Check concurrency
        if ($request->input('client_version') !== $record->updated_at->toJSON()) {
            return $this->conflictResponse(
                record: ['type' => 'your_model', 'id' => $record->id],
                currentVersion: $record->updated_at->toJSON(),
                updatedBy: $record->updated_by ? [
                    'id' => $record->updatedByUser->id,
                    'name' => $record->updatedByUser->name,
                ] : null
            );
        }
        
        // Safe to proceed...
        $record->update($validated);
        return $this->successResponse(['record' => $record]);
    }
}
```

### Frontend Changes

#### 1. Add `client_version` Hidden Field to Forms

For all Livewire record editors:

```blade
{{-- In your form blade template --}}
<form wire:submit.prevent="save">
    <input type="hidden" name="client_version" value="{{ $record->updated_at->toISOString() }}" />
    
    {{-- Rest of form fields --}}
</form>
```

For Alpine.js/React forms:

```javascript
// Store loaded version when component mounts
data() {
    return {
        clientVersion: this.$el.dataset.updatedAt, // ISO string
        // ... other data
    }
}

// Include in save request
async save() {
    const response = await fetch(this.saveUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            client_version: this.clientVersion,
            // ... form data
        })
    });
    
    if (response.status === 409) {
        const conflict = await response.json();
        this.showConflictModal(conflict);
    }
}
```

#### 2. Create Conflict Modal Component

**Livewire Component**: `resources/views/components/conflict-modal.blade.php`

```blade
<div x-data="{ show: @entangle('showConflict') }" 
     x-show="show"
     class="fixed inset-0 z-50 overflow-y-auto"
     style="display: none;">
    
    {{-- Backdrop --}}
    <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>

    {{-- Modal --}}
    <div class="flex min-h-full items-center justify-center p-4">
        <div class="relative bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
            <h3 class="text-lg font-semibold text-gray-900 mb-4">
                Your version is out of date
            </h3>
            
            <div class="text-sm text-gray-600 mb-6">
                <p class="mb-2">
                    This record was updated by 
                    <strong>{{ $conflictData['updated_by']['name'] ?? 'another user' }}</strong>
                    at <strong>{{ $conflictData['current_version'] ?? 'unknown time' }}</strong>.
                </p>
                <p>
                    To avoid overwriting their changes, please choose one of the following options:
                </p>
            </div>

            <div class="flex flex-col gap-3">
                <button 
                    wire:click="reloadLatest"
                    class="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
                    Reload Latest Version
                </button>
                
                <button 
                    wire:click="copyDraft"
                    class="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
                    Copy My Draft to Clipboard
                </button>
                
                <button 
                    wire:click="closeConflictModal"
                    class="w-full px-4 py-2 text-gray-600 hover:text-gray-900">
                    Cancel
                </button>
            </div>
        </div>
    </div>
</div>
```

**Livewire Controller Methods**:

```php
class YourEditorComponent extends Component
{
    public bool $showConflict = false;
    public array $conflictData = [];
    public array $draftBackup = [];

    public function save()
    {
        // Make save request with client_version
        $response = Http::post($this->saveUrl, [
            'client_version' => $this->record->updated_at->toISOString(),
            // ... form data
        ]);

        if ($response->status() === 409) {
            $this->conflictData = $response->json();
            $this->draftBackup = $this->formData; // Backup current draft
            $this->showConflict = true;
            return;
        }

        // Success handling...
    }

    public function reloadLatest()
    {
        $this->record->refresh();
        $this->formData = $this->record->toArray();
        $this->showConflict = false;
        $this->dispatch('notify', message: 'Loaded latest version');
    }

    public function copyDraft()
    {
        $draftJson = json_encode($this->draftBackup, JSON_PRETTY_PRINT);
        $this->dispatch('copy-to-clipboard', text: $draftJson);
        $this->dispatch('notify', message: 'Draft copied to clipboard');
    }

    public function closeConflictModal()
    {
        $this->showConflict = false;
    }
}
```

**JavaScript for Clipboard** (in your app.js or inline):

```javascript
document.addEventListener('copy-to-clipboard', (e) => {
    navigator.clipboard.writeText(e.detail.text);
});
```

#### 3. React Component (for React-based forms)

**ConflictModal.jsx**:

```jsx
import React from 'react';

export function ConflictModal({ conflict, onReload, onCopyDraft, onCancel }) {
    if (!conflict) return null;

    return (
        <div className="fixed inset-0 z-50 overflow-y-auto">
            <div className="fixed inset-0 bg-gray-500 bg-opacity-75" />
            
            <div className="flex min-h-full items-center justify-center p-4">
                <div className="relative bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
                    <h3 className="text-lg font-semibold text-gray-900 mb-4">
                        Your version is out of date
                    </h3>
                    
                    <div className="text-sm text-gray-600 mb-6">
                        <p className="mb-2">
                            This record was updated by{' '}
                            <strong>{conflict.updated_by?.name || 'another user'}</strong>
                            {' '}at{' '}
                            <strong>{new Date(conflict.current_version).toLocaleString()}</strong>.
                        </p>
                        <p>
                            To avoid overwriting their changes, please choose one of the following options:
                        </p>
                    </div>

                    <div className="flex flex-col gap-3">
                        <button 
                            onClick={onReload}
                            className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
                            Reload Latest Version
                        </button>
                        
                        <button 
                            onClick={onCopyDraft}
                            className="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
                            Copy My Draft to Clipboard
                        </button>
                        
                        <button 
                            onClick={onCancel}
                            className="w-full px-4 py-2 text-gray-600 hover:text-gray-900">
                            Cancel
                        </button>
                    </div>
                </div>
            </div>
        </div>
    );
}
```

---

## Rollout Strategy

### Step 1: Core Infrastructure (1 day)
1. Add `conflictResponse()` method to `BaseApiController` (per API structure standard)
2. Create the base conflict modal component (Livewire + React versions)
3. Add `updated_by` relationship to User model if not present
4. Update `docs/architecture/api/api-structure.md` with 409 status code

### Step 2: Pilot Model (1 day)
1. Pick one high-value model (e.g., `Flow` or `Project`)
2. Add `updated_by` migration
3. Update API controller to use `conflictResponse()` (ensure it extends `BaseApiController`)
4. Add `client_version` to the form
5. Wire up conflict modal
6. Test thoroughly

### Step 3: Rollout to Remaining Models (2-3 days)
1. Create a checklist of all record editors
2. For each editor:
   - Add `updated_by` column if desired
   - Update controller
   - Add `client_version` field
   - Include conflict modal
3. Update inline editors (tables, quick-edit forms)

### Step 4: Testing & Documentation (1 day)
- Write automated tests (see below)
- Document the pattern in developer docs
- Create a "How to add 409 guard" guide

---

## Testing

### Unit Tests

```php
// tests/Unit/Traits/ChecksOptimisticConcurrencyTest.php
use Tests\TestCase;
use App\Traits\ChecksOptimisticConcurrency;

describe('ChecksOptimisticConcurrency', function () {
    it('allows save when versions match', function () {
        $record = Flow::factory()->create();
        $controller = new class {
            use ChecksOptimisticConcurrency;
        };

        expect(fn() => $controller->checkConcurrency($record, $record->updated_at->toISOString()))
            ->not->toThrow(Exception::class);
    });

    it('throws 409 when versions mismatch', function () {
        $record = Flow::factory()->create();
        $staleVersion = now()->subMinutes(5)->toISOString();
        
        $controller = new class {
            use ChecksOptimisticConcurrency;
        };

        expect(fn() => $controller->checkConcurrency($record, $staleVersion))
            ->toThrow(HttpResponseException::class);
    });

    it('includes updated_by in conflict response', function () {
        $user = User::factory()->create(['name' => 'Alice']);
        $record = Flow::factory()->create(['updated_by' => $user->id]);
        
        try {
            $controller = new class {
                use ChecksOptimisticConcurrency;
            };
            $controller->checkConcurrency($record, now()->subMinutes(5)->toISOString());
        } catch (HttpResponseException $e) {
            $response = $e->getResponse();
            expect($response->status())->toBe(409);
            
            $data = json_decode($response->getContent(), true);
            expect($data['updated_by']['name'])->toBe('Alice');
        }
    });
});
```

### Feature Tests

```php
// tests/Feature/FlowConcurrencyTest.php
describe('Flow update concurrency', function () {
    it('prevents stale updates', function () {
        $flow = Flow::factory()->create();
        $clientVersion = $flow->updated_at->toISOString();

        // Simulate another user updating the record
        $flow->update(['name' => 'Updated by Alice']);

        // Try to save with stale version
        $response = $this->actingAs($user)
            ->postJson("/api/flows/{$flow->id}", [
                'client_version' => $clientVersion,
                'name' => 'My stale update',
            ]);

        $response->assertStatus(409);
        $response->assertJsonStructure([
            'error',
            'message',
            'record',
            'current_version',
            'updated_by',
        ]);

        // Verify the record wasn't overwritten
        expect($flow->fresh()->name)->toBe('Updated by Alice');
    });

    it('allows fresh updates', function () {
        $flow = Flow::factory()->create();
        $clientVersion = $flow->updated_at->toISOString();

        $response = $this->actingAs($user)
            ->postJson("/api/flows/{$flow->id}", [
                'client_version' => $clientVersion,
                'name' => 'Fresh update',
            ]);

        $response->assertOk();
        expect($flow->fresh()->name)->toBe('Fresh update');
    });
});
```

---

## Acceptance Criteria

- [ ] `BaseApiController` has `conflictResponse()` helper method
- [ ] All standard record edit forms include `client_version` field
- [ ] All record update endpoints check `client_version` before saving
- [ ] 409 response uses standardized `BaseApiController` format (success: false, errors object, timestamp)
- [ ] 409 response includes: RESOURCE_CONFLICT code, current_version, updated_by, record
- [ ] Conflict modal renders correctly in both Livewire and React contexts
- [ ] "Reload Latest" refreshes the form with current data
- [ ] "Copy My Draft" copies unsaved form state to clipboard
- [ ] "Cancel" closes modal without action
- [ ] Unit tests cover the conflict response
- [ ] Feature tests verify stale updates are blocked
- [ ] No false positives (fresh saves work normally)
- [ ] API documentation updated with 409 status code

---

## Risks & Mitigations

| Risk | Mitigation |
|------|-----------|
| **Timestamp precision issues** | Use ISO 8601 strings with milliseconds; consider `lock_version` integer in future if needed |
| **Missing `updated_by` on legacy data** | Handle null gracefully; display "another user" |
| **Frontend doesn't send `client_version`** | Server returns 422 validation error if missing |
| **Users frustrated by conflicts** | Phase B (real-time notifications) will reduce surprise conflicts |

---

## Next Steps

After Phase A is complete, proceed to **Phase B** to add proactive notifications that warn users *before* they attempt to save stale data.
