# Methodology — Sync settings UI

**Lane thv2-69** · Trading Hub v2 · 2026-04-28

## Purpose

Give Kadeem a single place to:

1. See which devices are paired to his Trading Hub account.
2. Force a fresh pull from the sync backend on any device that looks stale.
3. Unpair a device (revokes that device's session token + removes from device map).
4. Resolve cross-device write conflicts that LWW + CRDT could not auto-merge.
5. Inspect the merged sync state as JSON (read-only, debug aid).
6. Audit the last 10 sync errors (per `feedback_silent_failures.md` — never silently bury).

## Surface contract

### Page

`pages/sync-settings.html` — CSP-safe, no inline JS.

Renders five sections, in order:

| Section            | Always visible? | Trigger to expand                  |
|--------------------|-----------------|-------------------------------------|
| Status card        | yes             | n/a                                 |
| Conflicts queue    | when `> 0`      | always expanded when present        |
| Paired devices     | yes             | n/a                                 |
| Sync error log     | yes (collapsed) | auto-opens when `errors_24h > 0`    |
| Merged state JSON  | yes (collapsed) | manual user expand                  |

The collapsed defaults follow `feedback_progressive_disclosure.md`: surface what's actionable, hide what's diagnostic.

### Endpoints

| Endpoint                            | Method | Purpose                                      |
|-------------------------------------|--------|----------------------------------------------|
| `/api/sync/state/summary`           | GET    | Page top status + counts                     |
| `/api/sync/devices/list`            | GET    | Paired devices list                          |
| `/api/sync/devices/unpair`          | POST   | Remove a device + invalidate its session     |
| `/api/sync/get`                     | POST   | Fetch merged envelope for JSON viewer        |
| `/api/sync/auth/logout`             | POST   | Self-revoke when user unpairs current device |

All endpoints are auth-gated by `Authorization: Bearer <session_token>`. All require `FF_CROSS_DEVICE_SYNC=1` and Upstash KV configured. No in-memory fallback — state is precious; refusing is safer than silently losing.

## Engine module — `engine/sync-state-summary.js`

Pure-fn computation of the canonical summary contract:

```js
{
  paired_devices_count,
  last_successful_sync_at,         // ms epoch | null
  last_successful_sync_ms_ago,     // ms | null
  drift_detected,
  drift_reason,
  conflicts_pending,
  conflicts_by_kind,
  errors_24h,
  errors_24h_by_code,
  last_error,
  status,                          // 'synced'|'drifting'|'offline'|'error'|'never_paired'
  status_message,
  generated_at
}
```

### Status precedence

1. `paired_devices_count === 0` → **never_paired**
2. `conflicts_pending > 0` → **error** (never auto-resolved)
3. `drift_detected` → **drifting**
4. `errors_24h > 0` AND last sync stale → **error**
5. Last sync `null` OR > 10 min ago → **offline**
6. Otherwise → **synced**

This precedence is intentional: conflicts dominate everything else because silently letting one device's edit win is precisely the failure mode the cross-device-sync architecture exists to prevent.

### Drift detection

A device is considered drifting if:
- It has been **idle > 5 min** (so it's not just busy syncing right now), AND
- Its `last_seen_version` is strictly less than the canonical envelope version.

This avoids false positives from a device that synced 2s ago and is about to push a write.

## Conflict resolution UI

Per `feedback_silent_failures.md`: every conflict surfaces a side-by-side comparison ("This device" vs "Other device") and two buttons. The user must pick. We never auto-resolve.

When user clicks a choice, the UI POSTs a `conflict_resolution` block to `/api/sync/put`, and the next merge call honors the user's pick by stamping a fresh `_ts` on the chosen value.

Conflict kinds we surface (per `engine/cross-device-sync.js`):

| Kind                          | Title                                  |
|-------------------------------|----------------------------------------|
| `watchlist_add_vs_remove`     | Watchlist disagreement                 |
| `decision_outcome_conflict`   | Decision outcome conflict              |
| `decision_divergence`         | Decision edited on both devices        |
| `scalar_tie`                  | Setting changed simultaneously         |
| `patch_id_collision`          | Calibration patch collision            |

## Last-device safeguard

If the user attempts to unpair the only paired device, a confirmation modal warns "this will disable cross-device sync entirely" before submitting. The endpoint honors the request regardless — the warning is a UI courtesy, not an authorization gate.

If the user unpairs the device they're currently signed in on, the response carries `you_unpaired_self: true`; the UI then chains `/api/sync/auth/logout`, clears the local token, and redirects to `/pages/sync-pair.html`.

## Test floor

`src/lib/sync-state-summary.test.js`: 31 cases covering schema / aggregation / drift / status precedence / integration / determinism.

`src/lib/sync-settings-render.test.js`: 29 cases covering format helpers / shape helpers / view composition / status labels.

Total: **60 vitest cases**, target floor was 25.

## Coordination with lane 64

Lane 64 (auth + initial pairing UI) owns WRITES to `thv2:user-devices:<userId>`. This lane (69) consumes only. The fingerprint contract is documented at `docs/sync-device-fingerprinting.md`.

## Charter compliance

- **`feedback_silent_failures.md`** — every error mode surfaces a visible message; conflicts never auto-resolve.
- **`feedback_progressive_disclosure.md`** — conflict section hidden when 0; error log auto-collapses; JSON viewer collapsed by default.
- **`feedback_security_incident.md`** — no plaintext UA / IP / fingerprint inputs persisted; auth-gated mutation endpoints with rate limits.
- **`feedback_thv2_best_practices.md`** — pure-fn summary engine; testable in isolation; no `Date.now()` in pure paths (caller stamps `nowMs`).
