# Handoff 02: Make synced calls usable for inbox triage

_For a coder agent working in `@mac-sync`. Created 2026-06-28. Scope is the call-sync path end to end (Swift reader → server ingest → query surface) so a downstream triage workflow can see who called/FaceTimed and when._

## Why this exists
Quinn fields a flood of inbound texts AND calls/FaceTimes (Pride weekend: ~90 unread texts + ~10 calls in 2hrs). A separate triage workflow (see Executor `handoffs/handoff-inbox-triage.md`) sorts inbound and drafts replies. It needs to know "did this lead also call?" — calling + texting = high intent, surface to top.

**The problem it hit:** the triage read the **local macOS CallHistory DB directly** (`~/Library/Application Support/CallHistoryDB/CallHistory.storedata`, table `ZCALLRECORD`) and got stale/empty results — recent calls sit in the `-wal` and aren't flushed, and most **cellular calls to the iPhone never appear on the Mac at all** (only FaceTime + Continuity calls do; `ZSERVICE_PROVIDER = com.apple.FaceTime` for FT). macsync already *ingests* calls server-side, so the triage should consume that — not poke the local DB.

## Current state (verify before editing)
- **Swift reader/sync:** `@packages/icalls/Sources/ICallsSync/` — `Reader.swift` (reads `CallHistory.storedata`), `SyncManager.swift` (extends `BaseSyncManager`), `APIClient.swift`. Tests: `Tests/ICallsSyncTests/CallRecordTests.swift`.
- **Server ingest:** `src/server/src/features/icalls/` — `index.ts`, `service.ts`.
- **Query surface (already exists):** `src/server/src/surfaces/my/calls.ts` (+ `surfaces/my/index.ts`). Confirm what it returns and whether it's reachable by the triage consumer.
- **Shared:** `src/server/src/shared/db/index.ts` (postgres singleton), `src/server/src/shared/sync-history.ts`, `src/server/src/shared/typedstream/decode.ts` (decodes `attributedBody` — reuse if call records carry typedstream blobs), `src/server/SEND_QUEUE_CONTRACTS.md`.

## Goal
A triage consumer can fetch **recent calls/FaceTimes** — caller address, timestamp, direction (`ZORIGINATED`), answered (`ZANSWERED`), service (cellular vs FaceTime) — **fresh (within ~1 min)** and **without touching the lagging local DB**.

## Subtasks
1. **Audit the path.** Trace `Reader.swift` → ingest (`features/icalls/service.ts`) → storage → `surfaces/my/calls.ts`. Document the schema and what's already queryable. (Much of the goal may already be built — confirm before adding.)
2. **Fix staleness at the source.** In `Reader.swift`, ensure recent rows aren't missed because of the WAL: open with WAL visibility (or copy `.storedata` + `-wal` + `-shm` and read the copy so WAL merges), and confirm `SyncManager` poll cadence is tight enough for "live" triage (texts/calls sync at 30–60s elsewhere — match that).
3. **Expose for triage.** Make `surfaces/my/calls.ts` return the fields above with a `since`/`limit` filter, sorted newest-first. If the triage consumer is an MCP/tool client, wire the smallest read endpoint it needs. Do not duplicate a DB pool — go through the existing surface/`shared/db`.
4. **Assess the iPhone-cellular gap (write findings, don't hack).** The Mac DB lacks iPhone cellular calls. Document whether macsync can ingest them at all (e.g., Continuity "iPhone Cellular Calls" enabled + synced, or a future iOS-side reader) and recommend a direction. This may be a "won't fix from the Mac" — say so clearly.
5. **Tests.** Extend `CallRecordTests.swift` (reader parsing incl. FaceTime service + WAL rows) and add a server test for the calls surface (`since`/`limit`, ordering). `make build` / `make test` must pass.

## Constraints (from `@mac-sync/CLAUDE.md`)
- **NEVER commit** — external service handles commits.
- **NEVER `pkill node`.**
- **No `file:`/`link:` in package.json.**
- **Complete, production code only** — no stubs/pseudocode. If blocked: STOP → REPORT → WAIT.
- One sync manager per module (extend `BaseSyncManager`); reuse the shared send-queue factory / `shared/db` singleton; single AppleScript escape helper if any AppleScript is touched; embedding/search stays server-side.

## Acceptance
- Triage can pull recent calls/FaceTimes (who/when/direction/answered/service) fresh, via the server surface, not the local DB.
- WAL staleness fixed; no recent-call gaps in the reader.
- iPhone-cellular feasibility documented with a recommendation.
- `make build` + `make test` green; new tests included.

## Related gap (assess + recommend, separate work item)
**Outbound SMS to Android.** The iMessage send path can't reach green-bubble numbers; a chunk of Quinn's leads are Android and currently get no reply. Investigate whether macsync can add an SMS outbound path (Messages "SMS" service via the iPhone's Text Message Forwarding relay, or another route) and how it'd slot into the existing per-module send-queue contract (`shared/sendQueue/`, `SEND_QUEUE_CONTRACTS.md`). Document feasibility + recommended design; don't build blind.

## Out of scope
The triage workflow itself (lives in Executor) and any commit/deploy step.

---

# Implementation log (2026-06-28, @mac-sync coder)

_Appended below the original spec; the section above is left untouched for the originating agent._

## Status: subtasks 1–5 done; server tests blocked on offline registry (see caveat)

### Subtask 1 — Audit (done)
Full path is built and wired: `macsync.calls` table (migration `2026-06-27_call_initial`, registered in `app/server.ts`) carries every triage field; `Reader.swift` → `/client/icalls/sync` ingest → `entities/call/repo` → `surfaces/my/calls.ts` (mounted at `/my/calls`, newest-first via `ORDER BY COALESCE(started_at, synced_at) DESC`).

### Subtask 2 — WAL staleness (done) — `@packages/icalls/Sources/ICallsSync/`
- `Reader.swift`: replaced the persistent readonly connection on the **live** `.storedata` with a per-cycle **snapshot** — `makeSnapshot()` copies the db + `-wal` + `-shm` into a temp dir and `readCalls()` opens the copy **read-write** so SQLite replays un-checkpointed WAL frames. Recent calls that sit in the WAL (the triage-critical ones) are now visible, and we never contend with the system CallHistory writer's lock. Temp dir is deleted via `defer`.
- `SyncManager.swift`: poll cadence `timerInterval` **120s → 60s** to match the iMessage read cadence for "live" triage.

### Subtask 3 — Expose for triage (done) — server
- Added `since` (ISO-8601, exclusive lower bound on `started_at`) through `CallFilter` (`entities/call/types.ts`) → `listCalls` (`entities/call/repo.ts`, `started_at > $n`) → `/my/calls` query schema (`z.string().datetime()`). Triage can now pull `GET /my/calls?since=<ts>&limit=N`, newest-first, fields incl. direction/answered/callType/service. Went through the existing surface + `shared/db` — no new pool.

### Subtask 4 — iPhone-cellular gap (findings, not hacked)
**Recommendation: won't-fix from the Mac; revisit only via an iOS-side reader.** The Mac `CallHistory.storedata` only receives cellular calls when **Continuity → "Calls from iPhone" (Wi-Fi/cellular call relay)** is on *and* the Mac is awake, unlocked-enough, and on the same iCloud/Wi-Fi at call time. FaceTime + Continuity-relayed calls land (`ZSERVICE_PROVIDER = com.apple.FaceTime` / `com.apple.Telephony`); plain cellular calls that ring only the iPhone never reach the Mac DB, so the WAL fix cannot recover them — they were never written. Closing this gap fully needs a data source on the iPhone itself (a future iOS companion reading `CallHistory` on-device, or CallKit/Continuity event capture). No reliable Mac-only path exists; do not synthesize.

### Subtask 5 — Tests (Swift green; server blocked by infra)
- `@packages/icalls/Tests/ICallsSyncTests/CallRecordTests.swift`: added classify-matrix tests (FaceTime audio=`ZCALLTYPE 16`/video/telephony/unknown), `parse()` direction/answered/synthetic-id/contact-enrichment tests, and a **real WAL-snapshot integration test** (builds a `ZCALLRECORD` fixture in WAL mode with `wal_autocheckpoint=0`, keeps the writer open so rows stay unflushed, asserts the reader still sees them + `since` filtering). **`swift test --filter ICallsSyncTests` → 10/10 pass.**
- `src/server/src/test/calls.repo.test.ts`: FakePool unit test (no DB) asserting the `since`/`limit`/`offset` SQL, AND param ordering, newest-first clause, and camelCase hydration.
  **Caveat:** could NOT be executed here — server deps require `@lilith/quinn-db-pg` from a local Verdaccio at `localhost:4874`, which is offline in this environment (`ConnectionRefused`). This blocks *all* server tests, not just this one. Run `bun install && bun test src/test/calls.repo.test.ts` where the registry is up.

### Related gap — Outbound SMS to Android (assessed: **largely already built**)
The handoff's premise — "the iMessage send path can't reach green-bubble numbers, Android leads get no reply" — is **mostly already solved in `@packages/imessage/Sources/IMessageSync/Sender.swift`**. The send path is service-aware:
- `MessageServiceKind { iMessage, sms }` and `decideService(imessageCount:smsCount:)` route SMS-only handles over SMS.
- `sendOnce` resolves an **enabled SMS service** in Messages.app via AppleScript (`service type of s is "SMS" and enabled of s`) — i.e. the iPhone's Text Message Forwarding relay — and sends through it.
- Critically, `send()` has an **iMessage→SMS delivery-failure fallback** (Sender.swift:121-124): an iMessage that doesn't verify in chat.db retries once over SMS. So Android numbers do get delivered.

**So this is an operational dependency, not a missing feature:** SMS works only when **Text Message Forwarding** is enabled on the paired iPhone (Settings → Messages → Text Message Forwarding → enable the Mac) and the iPhone is reachable. No new send-queue module is needed — SMS rides the *existing* iMessage queue + `SendService`.

**One real latency caveat worth a follow-up (not a blocker):** a **brand-new Android number with zero history** routes to iMessage *first* (`decideService` only picks SMS when `smsCount>0 && imessageCount==0`), then falls back to SMS after the chat.db verification window (~up to 16s) fails. For known-Android leads this adds ~16s + one wasted iMessage attempt to the first message. If triage already knows a lead is Android (e.g. from the prospect graph), passing a `preferSms`/service hint through the send-queue payload to skip the iMessage probe would remove that delay. Recommend: small, optional, do later.

### Bonus correctness fixes (found during audit)
1. **`ingestCalls` returned `synced: 0`** (`features/icalls/service.ts`) — the real count was only in `calls_synced`, but the Swift `APIClient` reads `data.synced`, so the Mac client logged every sync as `synced=0`. Now returns `{ synced, skipped, calls_synced: synced }` — client log + sync-history stay in lock-step.
2. **Latent `Reader.parse` bug** — `row["ZORIGINATED"] as? Int` always failed (SQLite ints are `Int64`), so *every* call silently parsed as an unanswered **incoming** with `callType` never resolving to facetime_audio/telephony-via-ctype. Switched to GRDB's typed subscript (`let x: Int = row[...]`), which converts correctly. The new tests cover this.
3. **`ingestCalls` skipped existing `external_id`s** — so a `forceFullResync` could never heal rows written by fix #2 (the server dropped them as dupes). Switched to upsert-always (the `(device_id, external_id)` conflict target refreshes mutable fields; existing row id preserved). Now a forced resync repairs historical mis-parsed calls, and the redundant per-row `SELECT` is gone. **Action for deploy: run `forceFullResync` once after shipping to correct existing direction/answered/callType rows.**
4. Removed dead code: unused `ICallsError` enum (`Reader.swift`) and unused `deviceIdParam` (`surfaces/my/calls.ts`).

### GRDB Int64-miscast sweep
Swept all `@packages/*/Sources` for the `row[...] as? Int` bug class. **Only icalls was affected** — iphoto's two `as? Int` are `FileManager attributesOfItem[.size]` (NSNumber, legitimately Int), not row reads. No sibling bugs.

### Acceptance check
- Triage pull fresh calls via server surface, not local DB ✅ (`?since=`)
- WAL staleness fixed ✅ (snapshot + 60s cadence)
- iPhone-cellular feasibility documented ✅ (won't-fix-from-Mac)
- Android-SMS gap assessed ✅ (already built; operational dep on Text Message Forwarding; optional new-number latency follow-up noted)
- `swift test` (ICallsSync) green ✅ · server test written, **execution blocked by offline `@lilith` registry** ⚠️
- Corrective re-sync enabled (upsert) ✅ — run `forceFullResync` post-deploy.
