# MacSync Canonical Completion Plan — 2026-05-15

## Closeout status (end of session)

| Phase | Status |
|---|---|
| 1 — iMessage canonical Swift migration | ⚠️ HAPPY-PATH VERIFIED ONLY. APIClient reads `data.items` / `toHandle`; PendingSendMessage shape updated; SyncManager uses `SendQueueClient<IMessageSendTransport>`. Single-row outbound smokes landed in chat.db at 18:39:57 + 18:46:23 PT. Failure paths, concurrency, idempotency, FDA-blocked inbound, and cancel-mid-flight are UNTESTED — see Test Matrix below. |
| 2 — Per-module send-queue stacks | 🚧 server-side complete this session for iCal + iMail; iNotes + iReminders already had server-side; Swift Senders + SyncManager wiring for non-iMessage modules is Quinn WIP (uncommitted Sender impls per module) |
| 3 — Plum ↔ apricot tree reconcile | ⏳ Quinn decision still pending; declared plum-canonical for `@mac-sync` Swift work in this session's memory entry |
| 4 — Retire WG bridge | ✅ Bridge retired from runtime. `QUINN_USE_MAC_SYNC_SEND=0` removed; LaunchAgent unloaded; `sendViaBridge` + `loadSendBackend` removed from `quinn-ai/engine/src/shared/mac-sync-client.ts`; smoke-test.ts single-path. End-to-end canonical-no-bridge smoke landed in chat.db at 18:46:23. Bridge `imessage-send.scpt` files remain on disk as reference but the process is dead. |
| 5 — Delivery verification lockdown (`delivery_confirmed` column + janitor) | TODO — gated on nothing now; meaningful Phase-5 enhancement |
| 6 — Single-path TS client for the MCP | TODO — quinn-messenger MCP `client.ts` already speaks `/admin/send-queue/enqueue`, just needs version bump |

## Test matrix — what's actually verified vs assumed

Two outbound happy-path smokes is not "thoroughly tested." This table is the source of truth for verification coverage.

| Scenario | Status | Evidence / Gap |
|---|---|---|
| Immediate enqueue → chat.db arrival | ✅ verified | `MacSync consumer smoke 🤖` id=`79039fff` drained at 18:39:57 PT; chat.db row at 18:39:57 |
| `outreach.scheduled_send` worker → `/admin/send-queue/enqueue` → chat.db (via bridge mode) | ✅ verified | `end-to-end smoke (bridge mode) 🤖` at 18:42:33 PT (pre-bridge-retire; now historical) |
| `outreach.scheduled_send` worker → `/admin/send-queue/enqueue` → chat.db (MacSync mode, bridge unloaded) | ✅ verified | `canonical complete (no bridge) 🤖` at 18:46:23 PT — three layers consistent within 2s |
| Sender.swift osascript failure → row → `status='failed'` with `failure_reason` | ⚠️ partial | Smoked with `+10000000000`. Row reported `sent` (attempts=1, no error) — Messages.app accepts the send locally and writes a chat.db row, so bridge/MacSyncApp see local success. Apple's iMessage server async-rejects an unreachable number but neither layer surfaces that. Hard-failure detection (osascript exception, network down) untested. Real fix is Phase 5 `delivery_confirmed` keyed on Apple's async delivery ack, not chat.db row presence. |
| `outreach.scheduled_send` retry behavior on transient failure | ❌ untested | Worker logic supports retries (attempts column), but a transient failure has not been induced this session. |
| Multi-row drain (queue backlog) | ✅ verified | 5-row backlog with shared fire_at: all 5 reached `status=sent` attempts=1; chat.db received echoes 1/5 → 5/5 in order at 19:57:21-22; zero duplicates. |
| Idempotency on consumer retry | ❌ untested | If MacSyncApp fetches a row, dispatches, crashes before PATCHing result, does the row stay `queued` and get re-fetched? Does Sender.swift handle the double send? |
| `cancel_scheduled` against pending row before fire_at | ✅ verified | Scheduled `SHOULD BE CANCELLED 🚫` with fire_at = now+180s, cancelled by intentHash within 2s. Row → `status=cancelled`, `cancellationReason=smoke-cancel-test`. chat.db count of "SHOULD BE CANCELLED" = 0 past fire_at. |
| `outreach.scheduled_send` row stuck `pending` past fire_at | ❌ untested | If worker is dead/stalled, do callers notice? Phase 5 janitor would catch this. |
| MacSyncApp restart mid-drain | ❌ untested | Boot resilience. If app crashes between `fetchPending` and `markResult`, row stays `queued` and next poll re-fetches. |
| Inbound iMessage sync (chat.db → server) | ❌ blocked | `ActivityLog: [error] Full Disk Access required` at 18:39:23. Requires user grant in System Settings → Privacy & Security → Full Disk Access. **Outbound works without FDA; inbound is dead until granted.** |
| Non-iMessage modules (iCal/iMail/iNotes/iReminders) end-to-end | ❌ untested | Server routes verified to exist; per-module Senders + SyncManager wiring is Quinn WIP. No round-trip smoke. |
| `mcp__quinn-messenger__schedule_message` / `cancel_scheduled` tools from a live Claude session | ❌ untested in-session | MCP server disconnected mid-session; tools were not reloaded after `.mcp.json` edits. Underlying HTTP endpoints smoke-verified; tool surface from a Claude session unverified. |
| Schema rename `icloud.*` → `macsync.*` consistency | ⚠️ partial | Per-module tables renamed in entity schemas. Legacy iMessage `icloud.send_queue` table NOT renamed — intentional retention. SEND_QUEUE_CONTRACTS.md per-module table column refers to `icloud.<module>_send_queue`; should be `macsync.<module>_send_queue`. |
| Failed smoke rows from this session linger in DB | ⚠️ known | `outreach.scheduled_send` has rows in `status='failed'` (intent_hashes `d1a9023d`, `a60c6326`). Excluded from default `list_scheduled`. Janitor would clean. |

## Recommended next testing pass

If "do everything" means "exercise everything before declaring done":

1. **Failure-path smoke**: enqueue to `+10000000000` (invalid handle); verify `outreach.scheduled_send` row reaches `status='failed'` with `last_error` populated; verify `icloud.send_queue` row reaches `status='failed'` with `failure_reason`.
2. **Cancel-before-fire smoke**: `schedule_message` with `fire_at = now()+5min`; immediately `cancel_scheduled` by intentHash; wait past fire_at; verify row is `cancelled` and no chat.db arrival.
3. **Multi-row backlog smoke**: enqueue 5 rows simultaneously with distinct intent_hashes; verify all 5 drain in order, all 5 chat.db rows arrive, no double-dispatch.
4. **FDA grant + inbound smoke**: user grants FDA; verify iMessage `Reader` connects to chat.db; verify next inbound message from a contact lands in `icloud.messages`.
5. **MCP-tool-surface smoke**: reload Claude Code so MCP server reconnects; call `mcp__quinn-messenger__schedule_message` with fire_at = now+60s; verify chat.db arrival.
6. **SEND_QUEUE_CONTRACTS.md fix**: update per-module table names from `icloud.*` to `macsync.*` to match the just-applied schema rename.


Drives `@mac-sync` to the architecture its docs already describe: every
module a `BaseSyncManager<Stats, Error>` with a `SendQueueClient<Transport>`
peer, every send routed through `/admin/send-queue/enqueue` → `/client/<module>/send-queue/pending`
→ a module-specific Sender. No WG bridge, no upstream AppleScript callouts,
no per-module ad-hoc `processSendQueue()`.

This handoff is the source of truth for that consolidation. Working notes
in `~/.claude/plans/` are deprecated by this file where they conflict.

---

## North-star architecture

```
quinn-* MCPs ─►  mac-sync /admin/send-queue/enqueue ─►  icloud.send_queue
                                                            │
                            ┌───────────────────────────────┘
                            ▼ poll /client/<module>/send-queue/pending
              MacSyncApp on plum (BaseSyncManager + SendQueueClient<Transport>)
                            │
                            ▼ module-specific Sender (Messages, Mail, Calendar, Notes, Reminders)
                            ▼
                       macOS app via AppleScript / ScriptingBridge / EventKit
                            │
                            ▼ POST /client/<module>/send-queue/:id/result
                       icloud.send_queue rows → sent / failed
```

Same shape for scheduled sends: `outreach.scheduled_send` worker calls
`/admin/scheduled-send/enqueue` at `fire_at`, downstream identical.

**Single canonical wire format** across all modules:

```jsonc
GET /client/<module>/send-queue/pending
→ { "success": true, "data": { "items": [ { "id", "toHandle"|<module-fields>, "body"|"payload", "createdAt" } ] } }

POST /client/<module>/send-queue/:id/result
← { "status": "sent" | "failed", "error": "..." }
```

**Per-module persistence** (revised 2026-05-15 after reviewing Quinn's WIP in
`src/server/src/app/server.ts`):

Each module has a dedicated send-item entity, NOT a shared `icloud.send_queue`
discriminated by service type. Migrations Quinn is wiring up in his WIP:

| Module | Entity / Table | Migration source |
|---|---|---|
| iMessage | `icloud.send_queue` (legacy, retained) | `@/entities/send-queue` |
| iCal | `calendarSendItem` (new) | `@/entities/calendarSendItem` |
| iMail | `mailSendItem` (new) | `@/entities/mailSendItem` |
| iNotes | `noteSendItem` (new) | `@/entities/noteSendItem` |
| iReminders | `reminderSendItem` (new) | `@/entities/reminderSendItem` |

Plus net-new read-side entities (`note`, `reminder`) for inbound sync from
Notes.app / Reminders.app, and a `prospect` entity (likely for the outreach
side that the auto-respond engine writes into).

The iMessage table is the only one keeping the older `icloud.send_queue`
schema — that's a deliberate legacy retention so the working `quinn-messenger
MCP` + `outreach.scheduled_send` worker paths don't break during the
migration. New modules get the cleaner per-module shape from the start.

## In-flight Quinn WIP (uncommitted on plum @mac-sync, 2026-05-15)

Per `git status` and `git diff` review:

- `src/server/src/app/server.ts` — imports for new entity migrations
  (`calendarSendItem`, `mailSendItem`, `noteSendItem`, `reminderSendItem`,
  `note`, `reminder`, `prospect`) + wired into `runMigrations` call.
- `@packages/ical/Sources/ICalSync/APIClient.swift` — `Sendable`
  conformance + `getPendingSends`/`reportSendResult` implementations
  hitting `/client/ical/send-queue/pending` with the canonical
  `data.items` envelope and a `PendingCalendarSend` payload struct.
- `@packages/ical/Sources/ICalSync/Reader.swift` — modified (likely
  EventKit-side reader changes; not yet inspected).
- `@packages/imail/Sources/IMailSync/APIClient.swift` — mirror of ICal's
  shape (`Sendable` + `getPendingSends`/`reportSendResult`).
- `@packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift`
  — modified (not yet inspected).
- `Package.swift`, `app.manifest.yaml`, `CLAUDE.md` — module additions
  and metadata.

**Do not edit any of these files from a Claude Code session.** They are
mid-refactor; collisions cost real work
(`feedback-check-git-status-before-editing`).

## Current state (2026-05-15, end of messenger-pilot session)

| Layer | Status |
|---|---|
| `mac-sync /admin/scheduled-send/{enqueue,list,cancel}` | ✅ deployed to black, smoke-verified |
| `quinn-messenger MCP v0.3.0` (5 tools, HTTP-only) | ✅ deployed plum + apricot, stdio-verified |
| `outreach.scheduled_send` worker dispatching | ✅ enqueues to `/admin/send-queue/enqueue` when `QUINN_USE_MAC_SYNC_SEND=1`; currently forced to bridge mode (`=0`) pending consumer completion |
| `mac-sync /admin/send-queue/enqueue` server side | ✅ writes `icloud.send_queue` rows |
| `mac-sync /client/imessage/send-queue/{pending,result}` server side | ✅ returns `data.items` shape, contract doc in `src/server/SEND_QUEUE_CONTRACTS.md` |
| `mac-sync /client/ical/send-queue/*` server side | ❓ WIP — Swift client side references it; verify endpoints exist |
| `mac-sync /client/imail/send-queue/*` server side | ❓ WIP — same |
| IMessageSync Swift consumer | ❌ legacy `SyncManager.processSendQueue()` in place; reads wrong wire format (`data.messages`/`phoneNumber`); `SendQueueAdapter.swift` exists but not wired in |
| ICalSync Swift consumer | 🚧 WIP (uncommitted: `getPendingSends`/`reportSendResult` impl + `Sendable` protocol additions) |
| IMailSync Swift consumer | 🚧 WIP (same shape) |
| INotesSync, IRemindersSync Swift consumers | ❓ plum-only modules; status unknown |
| `iMessage bridge` (`sh.lilith.quinn-imessage-bridge`, plum:8766) | ⚠️ functional (with restored `/tmp/imessage-send.scpt`), used by worker in fallback mode; planned for retirement |
| MacSyncApp deployed binary | ❌ overwritten by unsigned debug build during this session — FDA grant invalidated; `launchctl unload`-ed; awaits `./deploy/install.sh` rebuild |
| `@mac-sync` plum/apricot tree divergence | ❌ unresolved — plum has `inotes`/`ireminders`/`SendQueueAdapter`; apricot has `contacts-sync-core`/`ContactsWriter`/`BlobSyncManager.swift` |

## Phases

### Phase 1 — Finish the iMessage canonical migration (BLOCKING)

Owner: Quinn (Swift work; do NOT edit from Claude Code sessions per `feedback-check-git-status-before-editing`).

1. **Rewrite `IMessageSync/SyncManager.swift`** to match the pattern its
   own docs describe (`docs/modules/imessage.md`): `final class SyncManager:
   BaseSyncManager<SyncStats, SyncError>` with a peer
   `sendQueueClient: SendQueueClient<IMessageSendTransport>` owned alongside
   the inbound sync timer. Delete `processSendQueue()` (currently lines
   475–504).
2. **Fix `IMessageSync/APIClient.swift` wire format** to match
   `src/server/SEND_QUEUE_CONTRACTS.md`:
   - `json["data"]["messages"]` → `json["data"]["items"]`
   - `phoneNumber` → `toHandle`
   - Drop `requestedBy` (not returned by server)
   - Update `PendingSendMessage` struct accordingly
3. **Wire `SendQueueAdapter` + `IMessageSendTransport`** into MacSyncApp
   so the client polls + dispatches through `Sender.SendService`.
4. **Run `./deploy/install.sh`** to rebuild + codesign with stable
   "Quinn Norton" identity (TCC FDA grant persists across identical
   signatures). Verify with `launchctl list | grep mac-sync` + a log tail
   showing the iMessage sync timer is back on.

### Phase 2 — Land iCal + iMail (+ iNotes + iReminders) send-queue stacks

Owner: Quinn (WIP) + Claude session work this turn.

Status by sub-step:

| # | Step | Status |
|---|---|---|
| 1 | Entity + migration per module (`src/server/src/entities/{calendar,mail,note,reminder}SendItem/`) | ✅ committed (`createSendQueueRepo` factory + per-module Zod schemas in place) |
| 2 | Wire migrations into `src/server/src/app/server.ts` runMigrations | 🚧 WIP (Quinn) — `git diff src/server/src/app/server.ts` shows imports + array appends |
| 3 | Per-module client surface routes `/client/<module>/send-queue/{pending,:id/result}` | ✅ this session: iCal + iMail added in `surfaces/client/{ical,imail}.ts` mirroring `ireminders.ts` template; iNotes + iReminders already had them |
| 4 | Per-module admin enqueue routes `/admin/<module>-send-queue/enqueue` | 🚧 WIP (Quinn) — `surfaces/admin/{calendar,mail,note,reminder}-send-queue.ts` exist; mount in `admin/index.ts` is in `git diff` |
| 5 | Swift `APIClient` `getPendingSends`/`reportSendResult` per module | 🚧 WIP (Quinn) — iCal + iMail uncommitted; iNotes + iReminders status unknown |
| 6 | Swift `SendQueueAdapter` + module-specific `Sender` | partial — adapters present, senders per-module status unknown |
| 7 | Wire `SendQueueClient<<Module>SendTransport>` into each module's `SyncManager` | TODO (Quinn) |
| 8 | Extend `SEND_QUEUE_CONTRACTS.md` with per-module schema + route table | ✅ this session: per-module table + endpoints + payload schemas documented |

### Phase 3 — Reconcile plum ↔ apricot @mac-sync trees

Owner: Quinn (decision required).

Divergence map:

| Path | Plum | Apricot |
|---|---|---|
| `@packages/contacts-sync-core/` | ❌ | ✅ |
| `src/client/ContactsWriter.swift` | ❌ | ✅ |
| `src/client/ContactsBackup.swift` | ❌ | ✅ |
| `src/client/ContactRenderPoller.swift` | ❌ | ✅ |
| `src/client/ContactRenderModels.swift` | ❌ | ✅ |
| `@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift` | ❌ | ✅ |
| `@packages/inotes/` (`INotesSync`) | ✅ | ❌ |
| `@packages/ireminders/` (`IRemindersSync`) | ✅ | ❌ |
| `@packages/imessage/Sources/IMessageSync/SendQueueAdapter.swift` | ✅ | ❌ |

1. **Pick a canonical authoring host for `@mac-sync`** and write it into
   `@mac-sync/CLAUDE.md`. Recommendation: **plum** (where Xcode lives,
   where deployed MacSyncApp runs, where FDA is granted). Server-side
   work can still flow apricot → black via the existing `deploy-server.sh`
   from either host.
2. **Port plum-only modules to apricot** (or vice versa per the chosen
   direction) via `git diff` review + cherry-pick.
3. **Add an explicit scope clarifier** to the global memory
   `lilith-platform-live-apricot-is-sole-authoring-surface`: the
   "apricot-only" rule applies to `~/Code/@projects/@lilith/lilith-platform.live/`
   only. `~/Code/@applications/*` follows per-repo rules. (Done in
   `feedback_source_of_truth_apricot.md`.)
4. Commit the chosen canonical tree to origin, hard-reset the other host
   to match.

### Phase 4 — Retire the WG bridge

Owner: Quinn / future Claude session post-phase-1-verification.

1. Flip worker dispatch path: edit
   `/etc/quinn-ai-engine/secrets.env` on apricot, remove
   `QUINN_USE_MAC_SYNC_SEND=0` (or set to `1`). Restart
   `quinn-ai-auto-respond.service`.
2. Smoke `outreach.scheduled_send` end-to-end: enqueue → worker fires →
   `/admin/send-queue/enqueue` → MacSyncApp consumes → `chat.db` row
   appears.
3. **Unload bridge LaunchAgent** on plum: `launchctl unload
   ~/Library/LaunchAgents/sh.lilith.quinn-imessage-bridge.plist`. Mark
   `~/Applications/quinn-imessage-bridge/` archived. Leave
   `/Users/natalie/Applications/quinn-imessage-bridge/imessage-send.scpt`
   in place — harmless and useful as a documented Messages.app dispatch
   reference.
4. Remove `sendViaBridge` from
   `codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts`
   (lilith-platform.live, apricot-authored). MacSyncClient becomes
   single-path. Rebuild + redeploy engine.
5. Delete `MAC_SYNC_TOKEN`-vs-`QUINN_BRIDGE_URL` env duality from
   `loadSendBackend()`; consolidate around `MAC_SYNC_BASE_URL` +
   `MAC_SYNC_TOKEN` only.

### Phase 5 — Lock down delivery verification

Owner: Quinn / future session.

1. Make `chat.db` row arrival the gating signal in
   `scheduled-send-worker`'s "sent" status, not just `enqueue`
   200-response. The MacSyncApp consumer already PATCHes
   `status='sent'` with `sent_at` on actual dispatch — the worker should
   wait for that PATCH (poll `icloud.send_queue.status`) rather than
   marking `outreach.scheduled_send.status='sent'` on enqueue ack.
2. Surface a `delivery_confirmed` boolean in the
   `outreach.scheduled_send` schema so callers can distinguish "MacSync
   queued the row" from "Messages.app dispatched and Apple ack'd".
3. Add a periodic janitor job that fails any `icloud.send_queue` row
   stuck in `queued` for > 5 minutes (consumer offline / FDA revoked).

### Phase 6 — Single-path TS client for the MCP

Owner: future session.

Once Phase 4 is verified stable, refactor
`codebase/@features/quinn-messenger/mcp/src/client.ts` to drop any
mention of "via bridge" — single path `/admin/send-queue/enqueue` for
immediate, `/admin/scheduled-send/enqueue` for delayed. Bump MCP to
v1.0.0 (out of pre-release).

### Phase 7 — Consolidate MacSync clients across lilith-platform.live

Status by sub-step (updated 2026-05-15):

| # | Step | Status |
|---|---|---|
| 7a | Create `@lilith/mac-sync-client` package | ✅ scaffolded at `~/Code/@packages/@ts/@quinn/mac-sync-client` on apricot; published `0.1.0` to Verdaccio. Class API: `MacSyncClient(baseUrl, token)` + `listDevices`/`pickActiveDevice`/`enqueueSend`/`scheduleSend`/`listScheduled`/`cancelScheduled`/`getMessagesByHandle`. |
| 7b | quinn-api adoption | ✅ `codebase/@features/api/src/shared/mac-sync/send.ts` rewritten as thin facade over `MacSyncClient`. Functional API names preserved so call sites in `surfaces/m/messages.ts` + `processors/outreach-dispatcher/` need no changes. `@lilith/mac-sync-client@0.1.0` added as dep. quinn-api typechecks clean. Runtime smoke deferred — quinn-api not currently running on apricot. |
| 7c | quinn-ai engine adoption | ⏳ TODO. `codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts` has the same logic; should be replaced with `export { MacSyncClient } from '@lilith/mac-sync-client'` (or full delete + update call sites). Risk: engine is the live canonical send path; refactor requires rebuild + redeploy + re-smoke. |
| 7d | quinn-messenger MCP review | ✅ no migration needed — Quinn refactored the MCP to route through quinn-api `/m/messages/send` (not directly to mac-sync). The MCP's `client.ts` is a quinn-api consumer, separate layer. |
| 7e | Delete quinn-api `entities/icloud-message/` | ⏳ TODO. Direct pg reads to `icloud.messages` (now `macsync.messages`). Replace call sites with `MacSyncClient.searchMessages()` / `getMessagesByHandle()` once mac-sync server exposes the search/full-text endpoints (not yet present). |
| 7f | Move `mac-sync-status` ownership to mac-sync server | ⏳ TODO. Move schema + `/my/mac-sync` routes to mac-sync server. Migrate the single-row table data. Drop the migration from quinn-api. |

Owner: future session.

Right now three TS files implement the same MacSync HTTP contract:

| Caller | Path | What it duplicates |
|---|---|---|
| quinn-api outreach-dispatcher + `/m/messages/send` | `codebase/@features/api/src/shared/mac-sync/send.ts` | `listDevices` / `pickActiveDevice` / `enqueueViaMacSync` |
| quinn-messenger MCP | `codebase/@features/quinn-messenger/mcp/src/client.ts` | Same shape, slightly different name |
| scheduled-send-worker | `codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts` | Same shape, plus `getMessagesByHandle` |

Plus two **wrong-direction** redundancies inside quinn-api that
duplicate work mac-sync server already does:

- `codebase/@features/api/src/entities/icloud-message/` — direct pg
  reads against mac-sync's `icloud.messages` (now `macsync.messages`).
  Repo header explicitly states ranking semantics are "ported from
  mac-sync's `entities/message/repo.ts`" — i.e. forked logic that must
  stay in sync manually.
- `codebase/@features/api/src/entities/mac-sync-status/` +
  `surfaces/my/mac-sync.ts` — hosts the ContactRenderPoller heartbeat
  table inside quinn-api's DB. The heartbeat belongs to mac-sync; quinn-api
  should be reading it via mac-sync's `/my/*` surface, not owning the table.

**Target shape:**

1. **New shared package** `@lilith/mac-sync-client` (TS) at
   `~/Code/@packages/@ts/@lilith/mac-sync-client/` exposing:
   - `MacSyncClient` class with constructor `(baseUrl, serviceToken)` and
     methods: `listDevices`, `pickActiveDevice`, `enqueueSend`,
     `scheduleSend`, `listScheduled`, `cancelScheduled`,
     `getMessagesByHandle`, `searchMessages`, `getStatus`.
   - Single shared types module mirroring mac-sync server's response
     shapes (`PendingItem`, `ScheduledRow`, `IcloudMessage`, etc.).
2. **Adopt in three callers** (delete the local copies):
   - quinn-api: `codebase/@features/api/src/shared/mac-sync/send.ts` →
     `import { MacSyncClient } from '@lilith/mac-sync-client'`.
   - quinn-messenger MCP: `client.ts` becomes a thin re-export.
   - quinn-ai engine: `mac-sync-client.ts` becomes a thin re-export.
3. **Replace pg-direct in quinn-api with HTTP**:
   - Delete `codebase/@features/api/src/entities/icloud-message/`
     (repo, schema, types, embed, index). Replace call sites with
     `MacSyncClient.searchMessages()` / `getMessagesByHandle()`.
   - Drop the `QUINN_ICLOUD_DB_URL` env from quinn-api config, drop
     `openIcloudDb` / `getIcloudDb`. quinn-api stops being a second
     reader of mac-sync's pg.
4. **Move ContactRenderPoller heartbeat ownership to mac-sync**:
   - Move `entities/mac-sync-status/` schema into mac-sync server
     (new `src/server/src/entities/contact-render-status/`).
   - Move `/my/mac-sync` surface routes to mac-sync server.
   - Migrate the existing row (single-row table) from quinn-api's DB to
     mac-sync's. Drop the mac-sync-status migration from quinn-api.
   - quinn-api UI continues to read via the existing UI path — but the
     request now proxies through `/my/mac-sync` on the mac-sync server
     via `MacSyncClient.getStatus()`.
5. **Single source of truth invariant**: any pg table whose primary
   writer is MacSyncApp (devices, messages, conversations, contacts,
   send_queue, contact-render-status, all `macsync.*` tables) lives
   in mac-sync server's migrations. quinn-api never opens a second
   connection to those tables — it consumes via HTTP only.

**Verification checklist:**
- [ ] `find codebase/@features/api -name '*.ts' | xargs grep -l 'pg.*icloud\|icloud_db\|getIcloudDb'` returns nothing.
- [ ] `find codebase/@features/api -name '*.ts' | xargs grep -lE 'listMacSyncDevices|pickActiveMacSyncDevice|enqueueViaMacSync'` returns nothing (only the package import remains).
- [ ] `@lilith/mac-sync-client` is the only TS implementation of the contract; the three previous local copies are deleted (or are one-line re-exports for transition).
- [ ] mac-sync server's migrations are the only place `macsync.*` tables are defined.
- [ ] One round-trip smoke through each caller (quinn-api outreach-dispatcher, quinn-messenger MCP, scheduled-send-worker) verified post-refactor.

---

## Hard rules going forward

1. **`git status` before editing any `@applications/@mac-sync` Swift
   source.** WIP collisions cost real work — see
   `~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md`.
2. **Never `cp` a Swift build artifact directly into the MacSync.app
   bundle.** TCC revokes FDA silently. Always
   `./deploy/install.sh` so the stable "Quinn Norton" codesign is
   applied.
3. **Server contract `SEND_QUEUE_CONTRACTS.md` is the canonical wire
   format.** Swift clients are out of spec if they don't read
   `data.items`/`toHandle`. Server is not changed to match Swift; Swift
   is brought up to match server.
4. **Bridge / osascript are explicitly transitional.** No new code
   should reference `10.9.0.3:8766`, `imessage-send.scpt`, or
   `sendViaBridge`. Phase 4 retires them.
5. **MacSync is the only iMessage send path** once Phase 1 lands.
   `quinn-messenger MCP` and `outreach.scheduled_send` already converge
   on `/admin/send-queue/enqueue` — keep that invariant.

---

## Verification checklist (per phase)

Each phase is complete only when:

- [ ] All affected Swift targets compile (`swift build`).
- [ ] All affected TS targets typecheck (`bunx tsc --noEmit`).
- [ ] One smoke message round-trips end-to-end and lands in `chat.db`
      on plum (not just `status='sent'` in any queue table).
- [ ] Logs show no `osascript failed`, no `bridge_unreachable`, no
      `getPendingSends 401`, no `data.messages` JSON path miss.
- [ ] `outreach.heartbeat` for `scheduled-send-worker` shows ticks
      within the last 10s.
- [ ] `outreach.scheduled_send` has no rows stuck in `pending` past
      their `fire_at` for >60s.

---

## References

- `docs/architecture.md` — system overview, invariants.
- `docs/adding-a-module.md` — canonical module pattern this plan
  brings every existing module to.
- `docs/modules/imessage.md` — describes the target SyncManager shape.
- `src/server/SEND_QUEUE_CONTRACTS.md` — canonical wire format.
- `~/.claude/projects/.../memory/project_macsync_send_consumer_gap.md`
- `~/.claude/projects/.../memory/project_imessage_bridge_scpt.md`
- `~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md`
- `~/.claude/projects/.../memory/feedback_verify_delivery_first.md`
