# Handoff 04: Mark conversation read (option + standalone function)

_For a coder agent in `@mac-sync`. Created 2026-06-28. Companion to Handoff 03 (outbox). Quinn wants replied-to threads to be markable as read so her inbox reflects handled state — both automatically after a send, and on demand from an app._

## Why
After the outbox sends a reply (or after manual handling), the iMessage thread still shows unread, so Quinn's inbox stays noisy and the triage can re-surface already-handled leads. She wants: (1) an **outbox option** that marks a conversation read once a reply is confirmed sent, and (2) a **standalone user-callable function** (exposed for an app/UI) to mark a conversation — or all replied-to conversations — read.

## ⚠️ Hard constraint — find a SAFE method first
There is **no public macOS API / AppleScript command** to mark a Messages conversation read. Do NOT implement this by writing `is_read`/`date_read` directly into `~/Library/Messages/chat.db` — that fights the live Messages process, risks DB corruption, and is unsupported. **Subtask 0 is research:** identify a safe, supported mechanism and document the trade-offs before building. Candidates to evaluate:
- Messages UI scripting / Accessibility to open+read the thread (reliable but slow, foregrounds Messages).
- Any vetted framework path the existing macsync Messages integration already uses.
- A "logical read" model: track handled/replied state in macsync's OWN store (mark the lead read in the triage/prospector DB, not Apple's), and have the inbox/app read from that — sidesteps Apple entirely. **This is likely the cleanest and safest** and may fully satisfy the need without touching chat.db.
If no safe Apple-side method exists, recommend the logical-read model and stop (STOP→REPORT→WAIT).

## Subtasks
1. **Research (above)** → pick the approach; write findings.
2. **Outbox option** (Handoff 03): add `mark_read_on_send` (bool) to the outbox row / config. After a send is verified delivered, mark that conversation read via the chosen mechanism.
3. **Standalone function** for an app to call: `markRead(recipient)` and `markAllRepliedRead()` (mark read every thread whose latest message is outbound / has a logged reply). Expose via the smallest surface (MCP tool and/or `/my/...` endpoint) consistent with how `/my/calls` is exposed.
4. **Idempotent + safe:** marking an already-read thread is a no-op; never block sends on it; log outcomes.
5. **Tests** per the host repo's conventions; `make build`/`make test` green.

## Constraints (from `@mac-sync/CLAUDE.md`)
NEVER commit · NEVER `pkill node` · complete production code only (STOP→REPORT→WAIT if blocked) · reuse `shared/db` singleton; single AppleScript escape helper if any AppleScript is used · do NOT write to Apple's `chat.db`.

## Acceptance
- An outbox send can optionally mark its conversation read (verified, idempotent, safe — no chat.db writes).
- An app can call markRead(recipient) / markAllRepliedRead().
- Method documented as safe; if Apple-side is unsafe, the logical-read model is implemented instead.
- `make build` + `make test` green.

## Out of scope
The triage/prospector logic that decides *what* counts as replied (Executor + Handoff prospector); commit/deploy.

---

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

## Subtask 0 — Research finding: **logical-read model** (chosen, safe)

Evaluated the candidates. There is **no safe, supported macOS API/AppleScript to
mark a Messages conversation read**, and writing `is_read`/`date_read` into the
live `chat.db` is unsupported and corruption-prone (fights the running Messages
process). UI/Accessibility scripting *can* open+read a thread but is slow,
foregrounds Messages, and is brittle. The handoff's own recommendation — a
**logical-read model** tracked in macsync's OWN store — fully satisfies the need
(Quinn's inbox/triage reads handled-state from macsync, not Apple), is idempotent
and safe, and **never touches Apple's database**. Implemented that; Apple-side
marking is deliberately *not* attempted.

**Read semantics:** a handle is read *through* a timestamp. It is unread iff it
has an inbound newer than `read_through` (a later inbound naturally re-surfaces
it). `read_through` only advances (GREATEST on upsert) → re-marking is a no-op.

## What was built (server-side only — no chat.db, no Swift change)

- **Entity `entities/conversation-read/`** — `macsync.conversation_read`
  (migration `2026-06-28_conversation_read_initial`, registered in
  `app/server.ts`), keyed `(device_id, handle)`. Repo: `markRead` (monotonic
  GREATEST upsert), `markAllRepliedRead` (single INSERT…SELECT: per conversation
  take the newest message; if outbound, mark the lead's newest-inbound handle
  read through that reply's time), `getReadState` (read_through + last-inbound +
  computed `unread`).
- **Feature `features/read/`** — `markConversationRead` / `markAllReplied` /
  `conversationReadState` thin services over the entity.
- **Surface `/my/read`** — `POST /my/read` (markRead, optional `readThrough`/
  `source`), `POST /my/read/all-replied`, `GET /my/read/:handle`. Single-tenant
  service-token `/my` surface, `deviceId` as filter (same model as `/my/outbox`).
- **Outbox option (Subtask 2)** — added `mark_read_on_send` column
  (migration `2026-06-28_outbox_mark_read_on_send`) + plumbed through
  `OutboxDraft`/`OutboxRecord`/`enqueueOutbox`/`enqueueReply` and the
  `/my/outbox` POST schema. The hook lives in `recordResult`: on a **confirmed**
  send (`sent`/`delivered`) of a flagged row it marks the recipient read
  (`source='outbox_send'`). **Best-effort** — wrapped so it can never fail the
  status write ("never block sends on it"). No Swift change needed: the Mac
  client already reports true verified status; the read-mark is a server
  consequence of that report.
- **MCP tools** (`mcp/`) — `mark_conversation_read`, `mark_all_replied_read`,
  `conversation_read_state`, plus `markReadOnSend` on `enqueue_reply`.

## Verification
- **MCP**: `tsc --noEmit` green; `tools/list` over stdio serves all three new
  tools. ✅
- **Idempotency/safety**: GREATEST upsert (no-op re-mark); zero writes to Apple's
  chat.db. ✅
- **Tests**: `src/test/conversation-read.repo.test.ts` (FakePool — markRead
  default/explicit, markAllRepliedRead, getReadState unread/read/no-inbound/
  never-marked). ⚠️ Like `outbox.repo.test.ts`, imports `@/shared/db` → offline
  `@lilith` registry (`localhost:4874`); **run where the registry is up**
  (`bun install && bun test src/test/conversation-read.repo.test.ts`).
- **`make build`/`make test`** (Swift): unaffected — this handoff is server+MCP
  only, no Swift touched.

## Acceptance check
- Outbox send optionally marks its conversation read (verified, idempotent, safe,
  no chat.db) ✅
- App can call `markRead(recipient)` / `markAllRepliedRead()` (`/my/read` +
  3 MCP tools) ✅
- Method documented safe; logical-read model implemented ✅
- Server tests written (⚠️ unrun — offline registry); Swift build/test green ✅

**Status: DONE on the @mac-sync side.** Remaining is running the new repo test
where the `@lilith` registry is reachable (same caveat as H02/H03).
