LOE: 21 (XL) | Rationale: 1 Critical gap adds a net-new full-stack feature (Conflict Resolution UI + 3 APIs); 3 High gaps require schema rename + migration + controller + UI changes; 5 Medium gaps touch controller validation, model fillable, routes, mock payloads, and UI filter components; 3 Low gaps are documentation-only. Combined scope spans backend models, migrations, 3 controllers, 5+ React components, and route definitions — well beyond a single sprint sprint item.

# Jira Task: Epic 8 Gap Resolution — Field Mapper & DLQ Dashboard Spec Alignment

**Title:** Resolve 12 Spec Gaps in Epic 8 (Field Mapper & DLQ Dashboard) Against [Design Document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#executive-summary) Authority
**Priority:** High
**Story Points:** 21
**Type:** Task (Full-Stack Implementation + Spec Patch)
**Assignee:** TBD
**Labels:** `data-sync`, `epic-8`, `field-mapper`, `dlq`, `conflict-resolution`, `schema-alignment`
**Blocks:** Epic 7 (Orchestrator) integration; Epic 5 (Reconciliation) conflict read path

---

## Context

A gap analysis performed against the authoritative [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#executive-summary) identified **12 discrepancies** in the Epic 8 implementation scope. These gaps range from a missing critical UI feature (the Stage 5 Conflict Resolution Dashboard) to schema naming mismatches, missing API endpoints, and incomplete mock payloads.

Epic 8 was correctly scoped to deliver the DLQ Dashboard and Field Mapper UI. However, the implementation was designed in partial isolation from the [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#executive-summary): it invented a non-standard table name, omitted a required conflict policy value, left `is_synced`/`is_readonly` inaccessible in the UI, and skipped the entire Conflict Resolution surface that [Stage 5](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#stage-5--conflict-ui--human-workflows) mandates.

**Risk of deferral:**

- **Gap 1 (Critical):** Records with `conflict_policy = 'manual'` will be permanently locked with no resolution path. The three-way merge service (Epic 7) will accumulate unreachable conflicts indefinitely.
- **Gap 2 (High):** If Epic 7 ships against `sync_error_queue` (the [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#appendix-d--erd-mermaid-format)-canonical name) and Epic 8 ships against `dead_letter_events` (its invented name), the DLQ dashboard will go dark the moment Epic 7 is deployed.
- **Gap 3 (High):** A sync field configured with `last-write-wins` policy is currently unstorable — the controller will reject it, and users have no path to select it in the UI.
- **Gap 4 (High):** `is_synced` and `is_readonly` columns exist in the database but cannot be managed by users, making field-level sync control impossible without direct DB access.

This ticket must be completed **before Epic 7 integration begins** to prevent the DLQ dashboard from requiring a full rewrite post-Epic-7 deployment.

---

## Current System Behavior

**Relevant Files:**

- `src/Domain/Sync/Models/DeadLetterEvent.php` — Eloquent model using non-canonical table name
- `src/Domain/Sync/Models/FieldMetadata.php` — Model with `is_synced`/`is_readonly` in `$fillable` but no UI exposure
- `src/App/Http/Controllers/Api/v1/SyncMetadataController.php` — Missing `last-write-wins` in validation, no `destroy` method
- `src/App/Http/Controllers/Api/v1/SyncDlqController.php` — `retry()` updates status only; no re-queue logic or documentation
- `resources/js/components/SyncFieldMapper.jsx` — Form missing `is_synced`/`is_readonly` toggles
- `resources/js/components/DlqDashboard.jsx` — Missing `error_class` filter; mock payloads use wrong envelope field names
- `database/migrations/` — `dead_letter_events` migration uses non-[design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#appendix-d--erd-mermaid-format) column names; `field_metadata` PK undefined
- `routes/api.php` — No DELETE route for field metadata; no conflict resolution routes

**Current Patterns:**

- All sync models use `protected $connection = 'mysql'` (global landlord DB) — must be preserved throughout
- Tenant scoping via `TenantService::getCurrentTenantId()` — `tenant_id` is never passed in frontend request bodies
- Routes use named route pattern: `route('api.v1.sync.*')`
- PHPStan level 5 enforced on all changed PHP files

---

## Description

Patch Epic 8's implementation deliverables and specifications to align fully with the [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#executive-summary). This covers four categories of work:

1. **Critical — Net-new feature:** Build the Conflict Resolution Dashboard (Stage 5) including 3 new API endpoints, 2 new React components, and backend resolution logic.
2. **High — Schema alignment:** Rename `dead_letter_events` → `sync_error_queue`, align columns, add `last-write-wins` policy, and expose `is_synced`/`is_readonly` in the Field Mapper form.
3. **Medium — Correctness fixes:** Fix mock payloads, document retry re-queue behavior, add `error_class` to model + UI, add DELETE endpoint for field metadata.
4. **Low — Documentation & future-proofing:** Define composite PK in migration, document `record_lock` integration intent, document `events.error` vs `events.dlq` distinction.

---

## Areas to Review

- `src/Domain/Sync/Models/DeadLetterEvent.php` (rename + schema alignment)
- `src/Domain/Sync/Models/FieldMetadata.php` (PK definition, confirm fillable)
- `src/App/Http/Controllers/Api/v1/SyncMetadataController.php` (validation fix, destroy method)
- `src/App/Http/Controllers/Api/v1/SyncDlqController.php` (retry docblock, error_class)
- `resources/js/components/SyncFieldMapper.jsx` (is_synced/is_readonly toggles, delete button)
- `resources/js/components/DlqDashboard.jsx` (error_class filter, mock payload correction)
- `database/migrations/` (rename migration, new conflict table migration, PK definition)
- `routes/api.php` (new conflict routes, DELETE metadata route)

---

## Deliverables

### Critical: Conflict Resolution Dashboard (Stage 5)

1. **Migration:** `create_conflicts_table` — aligned with [conflicts table](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#conflict-table) DDL (lines 258–270). **Canonical columns:** `conflict_id` (uuid PK), `account_id` (text — see Naming Note in Technical Notes), `record_type`, `record_id`, `base_version` (bigint, nullable — `current_state` projection version at merge time), `our_event_id` (uuid, nullable — FK to `events` table for the SuiteX event that triggered the conflict), `remote_version` (bigint, nullable — NetSuite version that conflicted), `remote_snapshot` (JSON — full NetSuite state at conflict time), `our_changes` (JSON — the SuiteX delta that triggered the conflict), `status` (text: `unresolved`, `resolved`, `locked`), `created_ts` (timestamp, default now). **Epic 8 UI extensions** (not in [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#conflict-table) DDL, added for dashboard UX): `conflicting_fields` (JSON — list of specific field IDs in conflict), `suggested_merge` (JSON, nullable — auto-computed merge suggestion for `last-write-wins` eligible fields), `resolved_by` (user ID), `resolved_at` (timestamp), `updated_at` (timestamp)
2. **Model:** `src/Domain/Sync/Models/SyncConflict.php` with `$connection = 'mysql'`, `$primaryKey = 'conflict_id'`, `$keyType = 'string'`, `$incrementing = false`, tenant scoping via `account_id`, and `$casts` for all JSON columns (`conflicting_fields`, `remote_snapshot`, `our_changes`, `suggested_merge` → `'array'`)
3. **Controller:** `src/App/Http/Controllers/Api/v1/SyncConflictController.php` with `index()`, `show()`, `resolve()` methods
4. **Routes:** 3 new named routes under `api.v1.sync.conflicts.*`
5. **React Component:** `resources/js/components/ConflictDashboard.jsx` — data table of unresolved conflicts
6. **React Component:** `resources/js/components/ConflictResolutionModal.jsx` — three-column merge view with action buttons
7. **Blade view:** `resources/views/sync/conflicts.blade.php` mounting `ConflictDashboard`

### High Priority: Schema Alignment

8. **Migration:** Rename `dead_letter_events` → `sync_error_queue`; rename `error_reason` → `reason`, `payload` → `details`; add `error_class` (NOT NULL, default `'validation_error'`) and `record_lock_id` (nullable) columns; expand `status` enum to unified set (`pending`, `retrying`, `exhausted`, `dismissed`, `resolved`) aligned with Epic 7 Error Handler contract; add indexes `idx_tenant_status`, `idx_record`, `idx_error_class`
9. **Model rename:** `DeadLetterEvent` → `SyncError` (`src/Domain/Sync/Models/SyncError.php`), `protected $table = 'sync_error_queue'`
10. **Controller update:** Add `last-write-wins` to `SyncMetadataController` validation rule
11. **UI update:** Add `last-write-wins` option to Field Mapper conflict policy dropdown with visual warning
12. **UI update:** Add `is_synced` toggle and `is_readonly` toggle to `SyncFieldMapper.jsx` form
13. **Controller update:** Add `is_synced` and `is_readonly` to `SyncMetadataController` validation rules

### Medium Priority: Correctness Fixes

14. **Mock payload fix:** Replace `eventType` with `operation` in all mock DLQ response payloads
15. **Mock payload fix:** Add missing envelope fields (`orderingKey`, `baseVersion`, `actorId`, `transactionGroupId`, `fullSnapshotRef`) to mock DLQ payloads
16. **Docblock:** Document intended production retry re-queue behavior in `SyncDlqController::retry()`
17. **Model + migration:** Add `error_class` to `SyncError.$fillable` and migration; add `error_class` filter to DLQ dashboard UI
18. **New endpoint:** `Route::delete('metadata/{recordType}/{fieldId}', ...)` + `SyncMetadataController::destroy()` + delete button in `SyncFieldMapper` table rows

### Low Priority: Documentation & Future-Proofing

19. **Migration:** Define composite PK `(tenant_id, record_type, field_id)` in `field_metadata` migration using `$table->primary([...])`
20. **Code comment:** Document `record_lock` integration requirements as `TODO` comments in `SyncError` model and `SyncDlqController`
21. **Code comment / spec update:** Document `events.error` vs `events.dlq` distinction and the unified status model (`pending`, `retrying`, `exhausted`, `dismissed`, `resolved`) in `SyncDlqController` class docblock

---

## Acceptance Criteria

### Critical: Conflict Resolution Dashboard

- [ ] `conflicts` table migration exists with [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#conflict-table)-canonical columns (`conflict_id` uuid PK, `account_id`, `record_type`, `record_id`, `base_version`, `our_event_id`, `remote_version`, `remote_snapshot`, `our_changes`, `status`, `created_ts`) plus Epic 8 UI extensions (`conflicting_fields`, `suggested_merge`, `resolved_by`, `resolved_at`, `updated_at`); `$connection = 'mysql'`; `account_id` is a non-nullable indexed column
- [ ] `SyncConflict` model uses `$primaryKey = 'conflict_id'`, `$keyType = 'string'`, `$incrementing = false`; includes `$casts` mapping all JSON columns (`remote_snapshot`, `our_changes`, `conflicting_fields`, `suggested_merge`) to `'array'`
- [ ] `GET /api/v1/sync/conflicts` returns a paginated list of unresolved conflicts scoped to the current tenant (via `account_id`); returns 200 with empty `data` array when no conflicts exist
- [ ] `GET /api/v1/sync/conflicts/{id}` returns [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#conflict-table) columns (`base_version`, `our_event_id`, `remote_version`, `remote_snapshot`, `our_changes`) and Epic 8 extensions (`conflicting_fields`, `suggested_merge`) for the given conflict ID; returns 404 if conflict belongs to a different tenant
- [ ] `POST /api/v1/sync/conflicts/{id}/resolve` accepts `resolved_fields` (merged field values approved by the user), updates conflict status to `resolved`, sets `resolved_by` and `resolved_at`, and publishes a merged event to `events.merged`; returns 200 on success. **MVP note:** `record_lock` release is deferred until the `record_lock` table is created (see Gap 11); for now, the resolve action must include a `TODO` comment marking where lock release will be integrated
- [ ] `POST /api/v1/sync/conflicts/{id}/resolve` returns 403 if the conflict belongs to a different tenant; never accepts `account_id` in the request body
- [ ] `ConflictDashboard.jsx` renders a data table showing `record_type`, `record_id`, `conflicting_fields` count, `created_ts`, and a Resolve action button
- [ ] `ConflictResolutionModal.jsx` displays our changes (SuiteX/Local) and NetSuite (Remote) values in a two-column diff layout for each conflicting field, with Base version reference for context
- [ ] `ConflictResolutionModal.jsx` provides four action buttons: **Accept SuiteX**, **Accept NetSuite**, **Accept Suggested Merge**, **Edit & Apply**; each calls `POST /api/v1/sync/conflicts/{id}/resolve` with the appropriate resolved field set
- [ ] If new events have arrived for the conflicted record since the conflict was created (`current_state.version > base_version`), the resolution modal displays a warning banner indicating the base state has changed, and offers the option to refresh the conflict context against the latest `current_state` before resolving ([design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#handling-simultaneous-changes-while-waiting-for-human-intervention): "conflict UI should show a timeline and allow human to rebase suggested merge onto the latest base")
- [ ] Resolving a conflict removes it from the `ConflictDashboard` table without a full page reload

### High Priority: Schema Alignment

- [ ] No migration, model, or controller references the name `dead_letter_events`; all references use `sync_error_queue`
- [ ] `SyncError` model class exists at `src/Domain/Sync/Models/SyncError.php` with `protected $table = 'sync_error_queue'` and `protected $connection = 'mysql'`
- [ ] `sync_error_queue` migration defines columns: `id`, `tenant_id`, `record_type`, `record_id`, `reason`, `details` (JSON), `error_class` (NOT NULL, default `'validation_error'`), `status` (enum: `pending`, `retrying`, `exhausted`, `dismissed`, `resolved`), `record_lock_id` (nullable FK), timestamps; indexes `idx_tenant_status (tenant_id, status)`, `idx_record (record_type, record_id)`, `idx_error_class (error_class)` are defined
- [ ] `SyncError` model includes `protected $casts = ['details' => 'array']`
- [ ] `SyncMetadataController` validation for `conflict_policy` is `'required|in:netsuite-wins,suitex-wins,last-write-wins,manual'`
- [ ] `SyncFieldMapper.jsx` conflict policy dropdown renders all four options; `last-write-wins` displays a warning tooltip: "Use sparingly — risk of silent data loss."
- [ ] `SyncFieldMapper.jsx` form includes an **is_synced** toggle (default: on) and an **is_readonly** toggle (default: off); both are submitted in the POST payload
- [ ] `SyncMetadataController` validation includes `'is_synced' => 'boolean'` and `'is_readonly' => 'boolean'`

### Medium Priority: Correctness Fixes

- [ ] No mock DLQ payload in any test fixture, controller, or component contains the key `eventType`; all use `operation`
- [ ] Mock DLQ payloads include all required envelope fields: `operation`, `orderingKey`, `baseVersion`, `actorId`, `transactionGroupId`, `fullSnapshotRef`
- [ ] `SyncDlqController::retry()` method has a PHPDoc block documenting the production re-queue sequence (read `details`, publish to `events.raw` via `SyncEventPublisherInterface`, update status only on success, leave `pending` on failure)
- [ ] `SyncError.$fillable` includes `error_class`; the `sync_error_queue` migration includes an `error_class` string column
- [ ] `DlqDashboard.jsx` includes an `error_class` filter dropdown alongside existing `status` and `record_type` filters
- [ ] `DELETE /api/v1/sync/metadata/{recordType}/{fieldId}` route exists with name `api.v1.sync.metadata.destroy`
- [ ] `SyncMetadataController::destroy()` scopes deletion to the current tenant by `(tenant_id, record_type, field_id)` and returns HTTP 204; returns 404 if not found; returns 403 if the record belongs to a different tenant
- [ ] `SyncFieldMapper.jsx` table rows include a delete icon/button; clicking it calls `DELETE /api/v1/sync/metadata/{recordType}/{fieldId}` and removes the row from state on 204 response

### Low Priority: Documentation & Future-Proofing

- [ ] `field_metadata` migration calls `$table->primary(['tenant_id', 'record_type', 'field_id'])` or defines a surrogate auto-increment PK with a unique composite index on `(tenant_id, record_type, field_id)` — either approach is acceptable; choice is documented with rationale in the migration file comment
- [ ] `SyncError` model and `SyncDlqController` contain `TODO` comments documenting the `record_lock` integration requirement: entry creation must lock the record; dismiss/resolve must release the lock; DLQ dashboard should display lock status per row
- [ ] `SyncDlqController` class docblock documents the `events.error` vs `events.dlq` distinction and the unified status model: `pending`, `retrying`, `exhausted`, `dismissed`, `resolved`

### PHPStan & Quality

- [ ] `phpstan level 5` passes with zero errors on all modified PHP files
- [ ] No `tenant_id` field appears in any frontend request body (POST, PUT, PATCH, DELETE) — all tenant scoping is server-side via `TenantService::getCurrentTenantId()`
- [ ] All new routes use named routes; no `url()` helper calls in new code
- [ ] All new Eloquent models set `protected $connection = 'mysql'`

---

## Validation & Testing

1. **Conflict Resolution happy path:** Create a row in `conflicts` with `status = 'unresolved'` for the current tenant's `account_id`. Navigate to the conflicts dashboard — confirm it appears. Open the resolution modal — confirm `our_changes`/`remote_snapshot` columns and `base_version` reference populate correctly. Click **Accept SuiteX** — confirm the row disappears from the table, status is `resolved` in the DB, and `resolved_at` is set.
2. **Conflict Resolution tenant isolation:** Attempt `GET /api/v1/sync/conflicts/{id}` with an ID belonging to a different tenant. Confirm HTTP 403 is returned.
3. **DLQ table rename:** Verify `sync_error_queue` exists in the DB; `dead_letter_events` must not exist. Insert a row, navigate to the DLQ dashboard — confirm it renders.
4. **`last-write-wins` roundtrip:** POST a field metadata record with `conflict_policy = 'last-write-wins'`. Confirm HTTP 201. Fetch it via `GET /api/v1/sync/metadata`. Confirm it appears. In the UI, open the dropdown — confirm `last-write-wins` option is present with its warning.
5. **`is_synced` / `is_readonly` toggles:** Submit the Field Mapper form with `is_synced = false`. Confirm the DB record has `is_synced = 0`. Re-fetch via API — confirm `is_synced` is `false` in the JSON response.
6. **Delete endpoint:** POST a field metadata row. Then call `DELETE /api/v1/sync/metadata/{recordType}/{fieldId}`. Confirm HTTP 204. Confirm the row no longer exists in the DB. Call the DELETE endpoint again — confirm HTTP 404.
7. **Mock payload envelope fields:** Review all mock DLQ fixtures — confirm no `eventType` key; confirm `operation`, `orderingKey`, `actorId`, `baseVersion`, `transactionGroupId`, `fullSnapshotRef` are all present.
8. **`error_class` filter:** Set `error_class = 'auth_error'` on a DLQ row. Use the `error_class` filter in the UI — confirm only that row appears when `auth_error` is selected.
9. **PHPStan:** Run `./vendor/bin/phpstan analyse src/Domain/Sync src/App/Http/Controllers/Api/v1/Sync* --level=5` — confirm zero errors.

---

## Related Files (No Changes Required)

- `src/Domain/Sync/Services/TenantService.php` — Defines `getCurrentTenantId()`; do not modify
- [Design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook) — Review: [Stage 5](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#stage-5--conflict-ui--human-workflows) (lines 160–168) for Conflict Resolution UI requirements; [Appendix D ERD](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#appendix-d--erd-mermaid-format) (lines 946–954) for `sync_error_queue` schema; [Appendix E Topics](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#appendix-e--topic--consumer-map) (lines 971–972) for `events.error` vs `events.dlq` topic distinction; [Event priority and collisions](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#event-priority-and-collisions-across-sources) (lines 391–394) for conflict policy definitions; [P2.2 Field-Level Metadata](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#p22-field-level-metadata-schema) (lines 2451–2461) for `field_metadata` DDL

---

## Technical Notes

### Naming Note: `account_id` vs `tenant_id`

The [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#executive-summary) and the canonical event envelope use `account_id` as the tenant discriminator across all sync tables (`current_state`, `write_ledger`, `record_lock`, `conflicts`, `events`). The existing SuiteX codebase uses `tenant_id` internally (e.g., `TenantService::getCurrentTenantId()`).

For the `conflicts` table: use `account_id` to stay aligned with the [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#conflict-table) and with the Orchestrator (Epic 7), which will write to this table using `account_id` from the canonical event envelope.

For the `sync_error_queue` and `field_metadata` tables: these were scaffolded by Epic 8 using `tenant_id` before design document alignment. The rename migration (Gap 2) preserves `tenant_id` to avoid breaking existing code. If the Orchestrator (Epic 7) writes to `sync_error_queue` using `account_id`, a column rename or alias will be needed at that integration point. Document this as a known adaptation in the migration file comment.

### Implementation Guidance

---

#### Gap 1 — Conflict Resolution Controller

```php
// src/App/Http/Controllers/Api/v1/SyncConflictController.php

class SyncConflictController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $accountId = TenantService::getCurrentTenantId();

        $conflicts = SyncConflict::where('account_id', $accountId)
            ->where('status', 'unresolved')
            ->orderBy('created_ts', 'desc')
            ->paginate(25);

        return response()->json($conflicts);
    }

    public function show(string $id): JsonResponse
    {
        $accountId = TenantService::getCurrentTenantId();
        $conflict  = SyncConflict::where('account_id', $accountId)
            ->where('conflict_id', $id)
            ->firstOrFail();

        return response()->json($conflict->only([
            'conflict_id', 'record_type', 'record_id',
            'base_version', 'our_event_id', 'remote_version',
            'remote_snapshot', 'our_changes',
            'conflicting_fields', 'suggested_merge',
            'created_ts',
        ]));
    }

    public function resolve(Request $request, string $id): JsonResponse
    {
        $accountId = TenantService::getCurrentTenantId();
        $conflict  = SyncConflict::where('account_id', $accountId)
            ->where('conflict_id', $id)
            ->firstOrFail();

        $validated = $request->validate([
            'resolved_fields' => 'required|array',
        ]);

        // Package resolved state into a merged canonical event and publish to events.merged.
        // TODO (Gap 11): Release the record_lock for the affected record after successful publish.
        // Deferred until record_lock table is created.
        $conflict->update([
            'status'      => 'resolved',
            'resolved_by' => $request->user()?->id,
            'resolved_at' => now(),
        ]);

        return response()->json(['message' => 'Conflict resolved.']);
    }
}
```

---

#### Gap 2 — Table Rename Migration

```php
// database/migrations/YYYY_MM_DD_000001_rename_dead_letter_events_to_sync_error_queue.php

public function up(): void
{
    Schema::rename('dead_letter_events', 'sync_error_queue');

    Schema::table('sync_error_queue', function (Blueprint $table): void {
        $table->renameColumn('error_reason', 'reason');
        $table->renameColumn('payload', 'details');
        $table->string('error_class', 50)->default('validation_error')->after('reason');
        $table->unsignedBigInteger('record_lock_id')->nullable()->after('error_class');

        $table->index(['tenant_id', 'status'], 'idx_tenant_status');
        $table->index(['record_type', 'record_id'], 'idx_record');
        $table->index('error_class', 'idx_error_class');
    });

    // Unified status enum aligned with Epic 7 Error Handler contract:
    // pending = retry-eligible, retrying = auto-retry in progress, exhausted = max retries exceeded (DLQ),
    // dismissed = operator dismissed, resolved = manually resolved via dashboard
    DB::statement("ALTER TABLE sync_error_queue MODIFY COLUMN status ENUM('pending', 'retrying', 'exhausted', 'dismissed', 'resolved') NOT NULL DEFAULT 'pending'");
}

public function down(): void
{
    DB::statement("ALTER TABLE sync_error_queue MODIFY COLUMN status ENUM('pending', 'retried', 'dismissed') NOT NULL DEFAULT 'pending'");

    Schema::table('sync_error_queue', function (Blueprint $table): void {
        $table->dropIndex('idx_error_class');
        $table->dropIndex('idx_record');
        $table->dropIndex('idx_tenant_status');
        $table->dropColumn(['error_class', 'record_lock_id']);
        $table->renameColumn('details', 'payload');
        $table->renameColumn('reason', 'error_reason');
    });

    Schema::rename('sync_error_queue', 'dead_letter_events');
}
```

---

#### Gap 2 — SyncError Model

```php
// src/Domain/Sync/Models/SyncError.php

class SyncError extends Model
{
    protected $connection = 'mysql';
    protected $table      = 'sync_error_queue';

    protected $fillable = [
        'tenant_id', 'record_type', 'record_id',
        'reason', 'details', 'error_class', 'status', 'record_lock_id',
    ];

    protected $casts = [
        'details' => 'array',
    ];

    // TODO (Gap 11): Creating a sync_error_queue entry must also create a record_lock entry.
    // Dismissing or resolving the error must release the lock.
    // The DLQ dashboard should display lock status per record.
}
```

---

#### Gap 3 — `SyncMetadataController` Validation Fix

```php
// Corrected validation in SyncMetadataController::store()

$validated = $request->validate([
    'field_id'        => ['required', 'regex:/^cust[a-zA-Z0-9_]+$/'],
    'record_type'     => 'required|string',
    'data_type'       => 'required|string',
    'conflict_policy' => 'required|in:netsuite-wins,suitex-wins,last-write-wins,manual',
    'is_synced'       => 'boolean',
    'is_readonly'     => 'boolean',
]);
```

---

#### Gap 9 — DELETE Route

```php
// routes/api.php — add inside the sync route group

Route::delete('metadata/{recordType}/{fieldId}', [SyncMetadataController::class, 'destroy'])
    ->name('api.v1.sync.metadata.destroy');
```

```php
// SyncMetadataController::destroy()

public function destroy(string $recordType, string $fieldId): JsonResponse
{
    $tenantId = TenantService::getCurrentTenantId();

    $deleted = FieldMetadata::where('tenant_id', $tenantId)
        ->where('record_type', $recordType)
        ->where('field_id', $fieldId)
        ->delete();

    abort_if($deleted === 0, 404);

    return response()->json(null, 204);
}
```

---

#### Gap 10 — `field_metadata` Composite PK

```php
// In the field_metadata migration up() method:

// Option A: Composite PK (simpler, no auto-increment)
$table->primary(['tenant_id', 'record_type', 'field_id']);

// Option B: Surrogate PK (better Eloquent compatibility; recommended if relationships are needed)
$table->id();
$table->unique(['tenant_id', 'record_type', 'field_id'], 'field_metadata_composite_unique');
```

> **Recommendation:** Use Option B (surrogate `id`) to preserve Eloquent relationship compatibility. Document the choice in the migration file comment.

---

#### Gap 7 — Retry Re-Queue Docblock

```php
/**
 * Retry a failed sync event by re-publishing its payload to the events.raw topic.
 *
 * MVP behavior (no broker available): updates status to `retried` only.
 *
 * Production behavior (when SyncEventPublisherInterface is bound):
 *   1. Read the full canonical payload from the `details` column.
 *   2. Re-publish to `events.raw` via SyncEventPublisherInterface::publish().
 *   3. Update status to `retried` ONLY after successful re-publication.
 *   4. If re-publication throws, leave status as `pending` and surface the exception.
 *
 * @throws \App\Exceptions\SyncPublishException if re-publication fails in production mode.
 */
public function retry(Request $request, int $id): JsonResponse
{
    // ...
}
```

---

#### Gap 12 — Status Model Docblock

```php
/**
 * SyncDlqController — manages the sync error queue and dead-letter events.
 *
 * Event source distinction (production):
 *   - events.error  : Transient failures eligible for retry. Shows retry count + next retry time.
 *   - events.dlq    : Exhausted all retries. Requires manual "Retry" action button in dashboard.
 *
 * Status lifecycle (unified with Epic 7 Error Handler contract):
 *   pending   -> retrying  -> (success)  -> resolved
 *                           -> (exhaust)  -> exhausted
 *   pending   -> dismissed
 *   exhausted -> retrying   (via manual retry action in dashboard)
 */
class SyncDlqController extends Controller
{
    // ...
}
```

---

### Gap Implementation Priority & MVP Blockers

| # | Gap | MVP Blocker? | Must complete before |
|---|-----|-------------|---------------------|
| 1 | Conflict Resolution Dashboard | Yes — records get permanently locked | Epic 7 integration |
| 2 | `sync_error_queue` table rename | Yes — Epic 7 publishes to this table name | Epic 7 integration |
| 3 | `last-write-wins` policy | Yes — valid [design document](https://github.com/SuiteDynamics/SuiteX/wiki/Design-%7C-NetSuite-Sync-%E2%80%94-Design,-Requirements,-Deliverables,-and-Runbook#event-priority-and-collisions-across-sources) policy option is unwritable | Epic 7 integration |
| 4 | `is_synced`/`is_readonly` UI controls | Yes — fields in DB are unmanageable from UI | Epic 7 integration |
| 5 | Mock payload `eventType` → `operation` | Medium — breaks envelope contract consistency | Epic 7 integration |
| 6 | Mock payload missing envelope fields | Medium — mock diverges from canonical schema | Epic 7 integration |
| 7 | Retry re-queue docblock | No — behavior doc only | Pre-Epic 7 integration recommended |
| 8 | `error_class` model + filter | No — loss of filter functionality only | Can trail Epic 7 launch |
| 9 | DELETE field metadata endpoint | No — UX gap, not a data integrity issue | Can trail Epic 7 launch |
| 10 | `field_metadata` PK definition | Yes — Eloquent relationships may fail without it | Before Epic 8 production deploy |
| 11 | `record_lock` integration docs | No — documentation for future Epic | Future epic |
| 12 | `events.error` vs `events.dlq` docs | No — documentation for future Epic | Future epic |
