# Sync-Merge Contract

**Created:** 2026-04-16
**Source:** RETRO-AGENT-FAILURE-PATTERNS.md M-5

This document catalogs every "local-only" field (not sourced from Shopify/GH) and every sync
entry point that merges external data into SQLite. Any field added to a data struct must declare
`local_only: true|false` per SYNC-01. Any sync entry point must document which fields it
overwrites vs. preserves per SYNC-02.

Cross-referenced from `.planning/DATA-FLOW.md` RULE-09.

---

## AGENT RULES

### SYNC-01: Every struct field declares local_only status

Every new field added to a data struct in `crates/core/src/` or `crates/service/src/` must be
annotated in this file's Local-only Fields Inventory with `local_only: true` or
`local_only: false`. If `true`, the field must also appear in the "At risk from" column
identifying which sync paths could destroy it.

### SYNC-02: Every sync entry point documents its merge behavior

Every function that writes to SQLite with data from an external source (Shopify, GH Project,
GH Issues) must be listed in the Sync Entry Points table below with: which fields it overwrites
unconditionally, which fields it preserves/merges, and which local-only fields it must not touch.

---

## Local-only Fields Inventory

Fields that are written locally (by user action or local computation) and NOT sourced from
Shopify or GH.

| Struct | Field | Type | Set By | At Risk From |
|--------|-------|------|--------|--------------|
| SerialInstance | `assigned_card_id` | `Option<String>` | User assignment in UI | `run_sync_cycle` product upsert — must preserve via `.as_deref()` pattern (`live_client.rs` ~1230); `cascade_shipment_state_to_units` also preserves it |
| ArchiveRecord | `state` / `manual_unarchive_override` | `i32` / `bool` | User archive toggle | `run_sync_cycle` card upsert — preserved by separate `archive_records` table (never touched by sync) |
| PendingEdit | all fields | various | Local edit queue | Never touched by sync (separate `pending_edits` table) |
| NoteEntry | `synced_at` | `Option<String>` | `mark_note_synced()` after GH post | `upsert_notes_for_card` diff-keyed on `(card_id, note_date, content)` — preserves `synced_at` for existing rows |
| NoteEntry | `author` | `Option<String>` | GH comment `author.login` (or `None` for optimistic local writes) | `upsert_notes_for_card` — diff-insert only; never overwrites existing rows |
| Card | `product_refs[].serial_id` | `Option<String>` | Unit assignment (`on_assign_unit` callback) | `run_sync_cycle` keeps existing `product_refs` with `serial_id` (`live_client.rs` ~1230-1231) |
| Recipient | `purpose_color` | `Option<String>` | Derived hex from GH API color enum; stored in SQLite | `upsert_recipient` — sync writes purpose_color on every cycle from GH Project data |

---

## Sync Entry Points

| Function | File:Line (approx) | External Source | Overwrites | Preserves | Notes |
|----------|---------------------|-----------------|------------|-----------|-------|
| `run_sync_cycle` | `live_client.rs:1051` | Shopify + GH Project | recipient fields, card fields, product fields | `assigned_card_id` on units, `product_refs[].serial_id`, archive_records (separate table never touched) | Primary sync — runs every 5 min via background thread |
| `save_note` | `live_client.rs:396` | Local origin → GH Issue comment | Inserts new note row via `upsert_notes_for_card` | All existing notes (diff-keyed upsert on `card_id+note_date+content`) | Local-originated; posts to GH asynchronously |
| `sync_products` | `live_client.rs` (called from `run_sync_cycle`) | Shopify + GH Issues | product `name`, `image_url`, `shopify_product_url` | `assigned_card_id` on units | Non-fatal — errors logged, sync cycle continues |
| `sync_card_issues` | `live_client.rs` (called from `run_sync_cycle`, Step 5c) | GH Issues | card issue body fields (`product_refs`, `shipment_status`) | Local edits in `pending_edits` table (flushed separately) | Runs after `detect_card_changes` to avoid double-detecting newly-created issues |
| `detect_card_changes` | `live_client.rs` (called from `run_sync_cycle`) | Compares old vs new cards | N/A (read-only snapshot) | N/A | Snapshots old cards BEFORE upsert loop to detect changes correctly |
| `cascade_shipment_state_to_units` | `live_client.rs:948-1035` | Derived from card `shipment_status` | unit `state` field | `assigned_card_id` via `.as_deref()` pattern | Called during sync; preserves local unit assignment |
| `upsert_notes_for_card` | `crates/service/src/db/sqlite.rs` | GH Issue comments | note `content`, `date`, `author` for new rows | `synced_at` on existing rows (diff-keyed insert — never DELETE+re-INSERT) | See SQLITE_TIPS.md: never DELETE-then-INSERT notes |

---

## Update obligation

Update this file in the same commit as any change to:
- A sync entry point (any function in the table above)
- A struct field in `crates/core/src/` or `crates/service/src/`
- A new write path to SQLite from external data

*Created: 2026-04-16 | Phase 20.4 M-5 | Maintain alongside DATA-FLOW.md*
