### Overview
SuiteX needs **global protection against accidental overwrites** and **real-time awareness of concurrent editing** for *any record in the system*, not just iPaaS resources.

This design introduces two complementary layers:
- **Proactive**: a tenant-safe **record presence + dirty-edit watcher** that notifies users when the record they have open has been updated elsewhere *and they have unsaved changes* (regardless of whether they originally opened the record for “view” or “edit”).
- **Defensive**: a **save-time optimistic concurrency guard** that blocks stale saves (HTTP 409) to guarantee we never overwrite a more recent update.

### Decisions (Confirmed / Updated)
- Frontend: **Livewire** forms/editors (with small Alpine/JS hooks where needed).
- Notification style: **Persistent banner (sticky)** shown when an external update occurs **while the current tab has unsaved changes**; remains until manually dismissed.
- Overwrite policy: **No force-overwrite**; stale saves are **blocked** (409) with user guidance.
- Audit metadata: record “who changed this” using `updated_by` where available; surface display name in UI when present.
- Polling fallback: **30 seconds** when websockets are unavailable.
- Scope: **All records** that use the standard “record editor” pattern; iPaaS `Flow` and `Node` become consumers of this global system, not special cases.
- Diffing: Not required for MVP; banner + save-time 409 is sufficient.
- Broadcasting conventions: Reuse the **Gantt broadcasting** conventions for multi-tab filtering and sender exclusion:
  - Send `tab_id` from the client and include it in broadcast payloads (tab-level filtering)
  - Send `X-Socket-ID` header when available and wrap broadcasts with `BroadcastHelper::toOthers(...)` (prevents “Invalid socket ID undefined” and excludes the originating socket when possible)
- Table/list pages: Support **Gantt-style list-level channels + throttled refresh** to prevent stale table data without spamming notifications:
  - Prefer list-level channels (e.g., project channel) over subscribing to each row
  - Auto-refresh silently when not inline-editing
  - If inline-editing with unsaved changes, prompt before any refresh that could disrupt the draft

### Goals
- Provide **real-time or near-real-time** signals about concurrent activity on the same record.
- Notify users **only when it matters**: if their current tab has **unsaved changes** and the underlying record was updated elsewhere.
- Prevent silent overwrites by detecting stale submissions and returning a clear conflict response.
- Keep the implementation **tenant-safe**, consistent with Redis/broadcasting conventions, and robust to websocket outages.
- Support **multi-tab** and **multi-user** workflows without special casing.

### Non-Goals
- Automatic merging or a full diff/merge editor.
- Long-lived “hard locks” that prevent others from editing (this design is advisory + defensive, not exclusive locking).
- Perfect real-time accuracy under all network failures; the defensive 409 guard is the source of truth.

### Primary User Stories
- As a user, if I have unsaved changes on a record and someone else saves a newer version, I want a clear persistent warning so I do not overwrite their work.
- As a user, if I attempt to save stale data, I want the system to block it and show who changed the record and when, with safe next steps.
- As a user, if I open the same record in multiple tabs, I want each tab to be treated like a separate editor so I avoid self-overwrites.

### Key Scenarios
1. Two users open the same record, both start editing. One saves; the other sees a sticky banner within seconds and is prevented from overwriting at save time.
2. A user opens a record, makes changes, leaves the tab open, and later saves after someone else updated the record. The save is blocked with 409.
3. A single user has two tabs open for the same record; saving in one tab notifies the other tab if it has unsaved changes.
4. A user is only “viewing” initially, but starts typing (unsaved changes exist). From that moment, external updates should trigger the same warning behavior.

### UX / Behavior
#### Sticky banner (proactive)
Displayed when:
- The current tab has **unsaved changes**, and
- The system detects the underlying record was updated elsewhere (another user or another tab), and
- The update is newer than the tab’s loaded version.

Banner content:
- Message: “This record was updated by {userName} at {time} while you have unsaved changes.”
- Actions: [Reload Latest], [Dismiss]

Notes:
- Same-user updates (multi-tab) are treated the same as other-user updates.
- If `updated_by` is unknown, use “another user”.

#### Save-time conflict handling (defensive)
If stale, return **409 Conflict** with metadata. UI shows a modal:
- Title: “Your version is out of date”
- Details: last updated by, timestamp
- Actions: [Reload Latest], [Copy My Draft], [Cancel]

“Copy My Draft” is client-side: copy the current unsaved form state to clipboard or download a JSON/text draft for manual re-application.

#### List/table behavior (React tables)
Tables are “passive viewers” by default and should not display global notifications. However, tables can become stale and then fail inline updates with 409; to avoid this, we use the same pattern as the implemented Gantt overlay: **accumulate external changes → throttle → refresh**.

Behavior rules:
- **When not inline-editing** (no unsaved row draft):
  - Apply external updates silently via row patching or background refresh (no banner/toast).
- **When inline-editing** (unsaved row draft exists):
  - Do **not** auto-reload rows or the list.
  - Show a table-local prompt: “Updates were made while you’re editing. Reload now?”
  - Actions: [Reload] [Keep Editing]
  - If user chooses Reload, refresh (row-level if possible; list-level if needed). If user chooses Keep Editing, keep accumulating pending changes and keep prompt visible.

### Technical Design
#### Resource scope
- Entities: **Any tenant-scoped record** displayed in a Livewire editor/detail form.
- Tenancy: all operations scoped to tenant context and `tenant_connection`.

To make this generic, we refer to a record as:
- `record_type`: a stable string identifier for the model (preferred: morph alias if available; otherwise a canonical class-based key).
- `record_id`: the primary key.

#### Data model and versioning
- Base version: `updated_at` as the server version (optimistic concurrency).
- Recommended enhancement: `updated_by` for richer notifications (model-specific; can be rolled out incrementally).
- Optional future enhancement: `lock_version` integer for precision and to avoid timestamp-resolution edge cases.

#### Global record presence + dirty-edit watcher (proactive layer)
We maintain short-lived “presence” state per record so we can:
- know that the record is currently open in one or more tabs
- know which tabs have **unsaved changes** (“dirty”)
- notify dirty editors when a newer server version arrives

##### Presence state storage (Redis, tenant-safe)
Store presence in Redis using tenant-aware keys:
- `tenant_{tenantId}_record_presence:{recordType}:{recordId}`

Structure:
- A hash or JSON blob containing one entry per browser tab:
  - `tab_id`: unique per browser tab (generated on page load; same concept as Gantt `tabId`)
  - `user_id`, `user_name` (display)
  - `mode`: `view` or `edit` (advisory; can always start as `view`)
  - `dirty`: boolean (true when the user has unsaved changes)
  - `last_seen_at`: server timestamp or monotonic time marker

Expiry:
- Each session entry is refreshed via a heartbeat (websocket ping or periodic HTTP) and expires after a short TTL (example: 90 seconds) if the tab disappears.

##### Broadcast channels (private, tenant-scoped)
Use a predictable private channel pattern aligned with the implemented Gantt approach (private channels + authorization in `routes/channels.php`):
- `private:record.{recordType}.{recordId}`

Tenant isolation is enforced the same way as Gantt:
- Channel authorization verifies the **current tenant context is set** (e.g., `tenant_connection` database exists)
- Record lookup is performed within the tenant database
- Role/permission checks are enforced for the record type

Optional unification refactor (recommended for long-term consistency):
- Migrate Gantt channel from `private:project.{projectId}` to `private:record.project.{projectId}` (or `private:record.projecttask_project.{projectId}` if we want task-vs-project clarity).
- During migration, subscribe clients to **both** channels temporarily to avoid breaking older pages.

##### Presence update flows
1. **On record page open**
   - Client generates `tab_id`
   - Client registers presence: `mode=view`, `dirty=false`
   - Client joins the record channel

2. **On first local change (unsaved changes)**
   - Client marks itself `dirty=true` and optionally `mode=edit`
   - Client updates server-side presence state

3. **On save success**
   - Client sets `dirty=false`
   - Server broadcasts `record.updated` after commit (see below)

4. **On page close / tab close**
   - Best-effort unregister; TTL cleanup is the real guarantee

##### What triggers user-facing notifications
When a `record.updated` event is received:
- If this tab is **dirty**, and
- The event’s `updated_at` is newer than this tab’s loaded version,
Then show the sticky banner.

If the tab is not dirty, we may optionally show a lightweight “Updated” toast, but it is not required by this design.

#### Real-time update notification mechanism (global)
##### Producer
On successful update to any record that participates:
- emit a broadcast event with minimal metadata

Event payload:
- `tenant_id`
- `record_type`
- `record_id`
- `updated_at_iso`
- `updated_by_id` (nullable)
- `updated_by_name` (nullable)
- `tab_id` (nullable; used for multi-tab filtering)

Transport:
- Laravel Broadcasting with Redis driver

Client:
- Livewire editor subscribes to the record channel (Echo + small Alpine/JS hook)
- when event arrives:
  - if `event.tab_id` is present and equals the current tab’s `tab_id`, ignore it (Gantt pattern)
  - otherwise compare server version to local version and apply the “dirty banner” rule above

Fallback (websockets unavailable):
- Poll every 30 seconds for:
  - current server `updated_at` (+ `updated_by`)
  - optionally, current presence summary (to keep “who is here” hints accurate if we add them later)
- If server version changed and tab is dirty, show the same sticky banner.

#### Save-time conflict detection (defensive layer)
Every editor includes a hidden `client_version` set to the record’s `updated_at` when loaded.

On save, the server compares `client_version` to the current `updated_at` in the database:
- If equal: proceed with save; update `updated_at` and `updated_by`, broadcast `record.updated`.
- If not equal: reject with **HTTP 409 Conflict** and structured payload (no override path):

```json
{
  "error": "resource_conflict",
  "message": "The record was updated more recently.",
  "record": { "type": "project", "id": 123 },
  "current_version": "2026-01-21T18:15:22Z",
  "updated_by": { "id": 42, "name": "Alice" }
}
```

UI handles 409 by showing the modal and offering safe actions.

#### Events & contracts
- Broadcast event name: `record.updated`
- Emitted **after transaction commit** to avoid clients being notified of rolled-back updates.
- Presence events are not required to be broadcast to all listeners for MVP; the minimum requirement is persisted presence state + the `record.updated` broadcast.

Broadcast emission must follow the existing Gantt resilience pattern:
- Broadcast failures must **not** block the underlying update (log and continue)
- When a request includes a valid `X-Socket-ID`, use `BroadcastHelper::toOthers(broadcast(...))` to exclude the originating socket
- When `X-Socket-ID` is missing/invalid (CLI, jobs, API), broadcast normally (no `->toOthers()`), avoiding “Invalid socket ID undefined”

#### List/table sync (project-level and list-level channels)
For tables that display collections (example: Project page showing a table of project tasks), subscribing to each row’s channel does not scale. Instead we extend the Gantt-style approach:

##### Channel strategy
- Prefer a **list-level channel** that represents the collection context, not the row:
  - Example (already implemented by Gantt): `private:project.{projectId}`
- Tables subscribe to the list-level channel and listen for list-relevant events (for project tasks, reuse the existing `.task.updated` event emitted on the project channel).

Optional unification refactor (recommended for long-term consistency):
- Move list-level channels under the unified record namespace (e.g., `private:record.project.{projectId}`) while temporarily subscribing to both old and new channels during migration.

##### Tiered update strategy (patch → row refresh → list refresh)
When a list-level event arrives, the table uses a tiered strategy:
1. **Patch row in-place (preferred)**:
   - If the event includes a safe, minimal `row_patch` (only fields already displayed in the table), update the row locally.
2. **Row refetch (fallback)**:
   - If the patch is missing or incomplete, refetch the single row from the server and replace it in the table.
3. **Invalidate + refresh list (fallback of last resort)**:
   - For structural changes (create/delete/reorder/bulk), invalidate the list query and refetch the entire dataset.

##### Throttled invalidation / refresh (Gantt-style)
To avoid refetch storms:
- Accumulate affected row IDs (e.g., `Set<task_id>`) and a “needs full refresh” flag.
- Start/reset a throttle timer (recommended 2–5 seconds; Gantt uses a 5s delay before showing the overlay).
- When the timer fires:
  - If “needs full refresh” is set, refresh the list (Tier 3).
  - Else refetch the affected rows (Tier 2) or apply queued patches (Tier 1).

##### Inline-edit safety
If any inline editor on the table has unsaved changes (“dirty”):
- Do not apply Tier 2 or Tier 3 automatically (they can disrupt the draft through rerenders, sorting changes, pagination changes, etc.).
- Continue accumulating pending changes and show the table-local prompt described in UX/Behavior.
- Tier 1 patching is allowed only if it does not target the actively edited row; otherwise queue it until the user reloads or finishes editing.

##### Save-time conflict on inline edit
Inline edits should include the row’s `client_version` (`updated_at`) on save.
- If 409 occurs, treat it as a “row stale” case:
  - Refetch the row and re-render it.
  - Preserve the user’s draft input locally so they can reapply or retry.

#### Security & tenancy
- All **Redis keys** include tenant ID (e.g., `tenant_{tenantId}_...`) to prevent cross-tenant leakage.
- Broadcast channels follow the existing Gantt pattern: tenant isolation is enforced via **channel authorization** (current tenant context + record lookup + permission checks), not necessarily by embedding tenant ID in the channel name.
- Channel authorization and any presence endpoints must enforce:
  - authenticated user
  - tenant membership
  - permission to view the record
- Event payload must not include any unrelated data beyond the minimal metadata above.

#### Performance & reliability
- Broadcast payloads are small and infrequent (human edits).
- Presence heartbeats must be cheap (single Redis write per interval).
- Polling fallback is capped at 30 seconds to limit load.
- No critical-path dependency on websockets: the 409 guard ensures integrity.

### Rollout Plan (Phased)
1. Phase A — Defensive 409 save guard (global MVP)
   - Implement `client_version` checks for record saves in the shared “record update” path(s).
   - Return 409 with structured payload on mismatch.
   - Implement the conflict modal pattern in the shared Livewire editor base.
2. Phase B — Global `record.updated` broadcast + sticky dirty banner
   - Emit `record.updated` after successful updates.
   - Subscribe editors to `private:record.{recordType}.{recordId}`.
   - Show sticky banner only when the tab is dirty.
   - Add polling fallback (30s) for environments without websockets.
3. Phase C — Presence + dirty watcher persistence (global awareness)
   - Persist short-lived record presence per tab (`tab_id` + TTL).
   - Add heartbeats and “dirty” toggles so the system can accurately track unsaved changes.
4. Phase D — Enhancements (optional)
   - Replace timestamp version with `lock_version`.
   - Add non-blocking “who is here” UI (avatars/list) if desired.
   - Add field-level diff previews.

### Telemetry & Observability
- Metrics:
  - count of 409 conflicts
  - frequency of dirty-banner events
  - polling fallback usage rate
- Logs:
  - `tenant_id`, `record_type`, `record_id`
  - `updated_by`, `client_version`, `server_version`
  - presence heartbeat failures (if applicable)

### Risks & mitigations
- Timestamp precision / clock skew:
  - use server `updated_at` values, not client clocks
  - consider `lock_version` for precision if needed
- Users dismiss banners and still try to save:
  - 409 guard prevents overwrite
- Presence accuracy:
  - TTL-based cleanup prevents “ghost editors”
  - websockets down: polling still protects users from overwrites at save time
- `updated_by` not universally available:
  - show generic “another user” until rolled out per model

### Acceptance Criteria
- When another user (or another tab of mine) updates a record **while I have unsaved changes on that record**, I see a sticky banner within 5 seconds (push) or 30 seconds (polling) that remains until manually closed.
- If I attempt to save stale data, I receive a 409 Conflict; the UI prevents overwrite and offers reload/copy/cancel (no force overwrite).
- On tables backed by list-level channels (example: Project tasks table), external updates are applied silently via patching or throttled refresh when the user is not inline-editing.
- If the user is inline-editing with unsaved changes and external updates occur, the table does not auto-refresh; it prompts the user and allows cancelling the reload.
- Tenancy isolation is enforced for broadcasts, presence, and conflicts.
- Saving fresh data still works without false positives.
