@mapier/imsg-sdk 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,818 @@
1
+ # Gateway contract — ImsgGateway ↔ FakeGateway
2
+
3
+ ## Purpose
4
+
5
+ `Gateway` is the only boundary between the agent and iMessage. Two implementations:
6
+
7
+ - **ImsgGateway** — real. Wraps `openclaw/imsg` (JSON-RPC subprocess + CLI) running on the
8
+ host Mac. See `docs/host-mac-control.md` for how that host is reached and configured.
9
+ - **FakeGateway** — dev/simulator/eval. No Mac, no `imsg` binary, no Messages.app.
10
+
11
+ Most development happens against FakeGateway, with no way to check behavior against real
12
+ iMessage in the loop. That makes FakeGateway's fidelity the whole ballgame: if it lies about
13
+ what iMessage does, every feature built against it either breaks on the host Mac or, worse,
14
+ assumes a capability iMessage doesn't have.
15
+
16
+ Two rules follow:
17
+
18
+ 1. **FakeGateway must reproduce every behavioral invariant in §2.** Not "close enough" —
19
+ exact, including the annoying ones (id monotonicity, reaction toggling, most-recent-only
20
+ targeting).
21
+ 2. **FakeGateway may never offer more capability than ImsgGateway.** If the real `imsg` CLI
22
+ can't do it, FakeGateway must not let the agent do it either. A capability that only exists
23
+ in the fake is a trap: code gets built against it, then breaks the first time it touches a
24
+ real Mac.
25
+ 3. **Fidelity is about SETUP, not just messaging.** The trap in rules 1–2 also springs from the
26
+ *state a user can be in*, not only the operations we can run. Any sim tool that fabricates a
27
+ user (dev-mint, god-mode create — `docs/sim-dev-tools.md`) must land them in a state a real
28
+ user could actually reach: a real user reaches onboarding *because they texted the prefill
29
+ first*, so their DM thread exists before any link completes. A sim shortcut that mints a link
30
+ for a never-texted user puts them in an impossible state and makes green sim runs lie about
31
+ prod. When you add a setup shortcut, ask "could a real user be here?" — if not, seed whatever
32
+ the real arrival would have created (e.g. the DM thread via `gateway.ensureDm`), or the test
33
+ is a fiction.
34
+
35
+ Conformance is enforced by one shared contract test suite (§4) parameterized over both
36
+ implementations. Against FakeGateway it runs in normal CI. Against ImsgGateway it runs on the
37
+ host Mac — manual today, CI-optional once that runner exists.
38
+
39
+ Ground truth: the invariants below were verified live against **imsg 0.12.0** on the host Mac
40
+ (2026-07-02 conformance smoke, `scripts/live-smoke.ts`) and re-verified against **imsg 0.12.3**
41
+ (2026-07-07 upgrade smoke, `scripts/live-smoke.ts 3996`). Earlier drafts cited imsg's docs;
42
+ where the live binary disagreed with a docs reading (reaction state in history,
43
+ `chats.list` field names), the live behavior below is authoritative.
44
+
45
+ Read-only gateway changes may use `scripts/live-smoke.ts <chatId> --read-only` when
46
+ Messages.app UI automation is unavailable. That mode verifies history, portable chat
47
+ directory, bounded attachment-aware history, and end-bound filtering only; it makes no
48
+ claim about send, reaction, or group mutation behavior.
49
+
50
+ ## 1. Interface
51
+
52
+ ```ts
53
+ interface Gateway {
54
+ readonly capabilities: {
55
+ emojiTapback: boolean;
56
+ stickerSend: boolean;
57
+ groupPhoto: boolean;
58
+ groupParticipants: boolean;
59
+ namePhotoSharing: boolean;
60
+ };
61
+ subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
62
+ history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
63
+ listChats(limit?: number): Promise<GatewayChat[]>;
64
+ historyRange(chatId: number, range: GatewayHistoryRange): Promise<ImsgMessage[]>;
65
+ send(target: { chatId: number } | { to: string }, text: string): Promise<SendResult>;
66
+ react(chatId: number, reaction: Reaction, expectedGuid?: string): Promise<ReactResult>;
67
+ createGroup(handles: string[], firstMessage: string): Promise<{ chatId: number }>;
68
+ recentReactions(chatId: number, limit?: number): Promise<ReactionNote[]>;
69
+ resolveDmChat(handle: string): Promise<number | null>;
70
+ resolveGroupChat(request: GroupChatResolutionRequest): Promise<GroupChatResolution>;
71
+ sendStatus(guid: string): Promise<SendStatus>;
72
+ checkHandle(address: string, opts?: { aliasType?: 'phone' | 'email' }): Promise<HandleCheck>;
73
+
74
+ // Tier 2 (bridge-backed — imsg launch required). See "Tier 2" below.
75
+ tapback(chatId: number, targetGuid: string, reaction: Reaction, remove?: boolean): Promise<ReactResult>;
76
+ emojiTapback(chatId: number, targetGuid: string, emoji: string, remove?: boolean): Promise<ReactResult>;
77
+ sendRich(
78
+ chatId: number,
79
+ text: string,
80
+ opts?: { effect?: string; replyToGuid?: string; subject?: string },
81
+ ): Promise<SendResult>;
82
+ sendAttachment(
83
+ chatId: number,
84
+ filePath: string,
85
+ opts?: { audio?: boolean; replyToGuid?: string },
86
+ ): Promise<SendResult>;
87
+ sendSticker(
88
+ chatId: number,
89
+ filePath: string,
90
+ opts?: { attachToGuid?: string; partIndex?: number },
91
+ ): Promise<SendResult>;
92
+ sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
93
+ sendRichLink(chatId: number, url: string): Promise<SendResult>;
94
+ editMessage(chatId: number, targetGuid: string, text: string): Promise<{ ok: boolean }>;
95
+ unsendMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
96
+ deleteMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
97
+ setTyping(chatId: number, on: boolean): Promise<{ ok: boolean }>;
98
+ markRead(chatId: number): Promise<{ ok: boolean }>;
99
+ renameGroup(chatId: number, name: string): Promise<{ ok: boolean }>;
100
+ setGroupPhoto(chatId: number, filePath?: string): Promise<{ ok: boolean }>;
101
+ addParticipant(chatId: number, handle: string): Promise<{ ok: boolean }>;
102
+ removeParticipant(chatId: number, handle: string): Promise<{ ok: boolean }>;
103
+ leaveGroup(chatId: number): Promise<{ ok: boolean }>;
104
+ shareNamePhoto(chatId: number): Promise<{
105
+ ok: boolean;
106
+ skipped?: 'not-offered';
107
+ effectStarted?: boolean;
108
+ }>;
109
+ }
110
+
111
+ interface ReactionNote {
112
+ id: number; // the reaction's global rowid
113
+ // 'custom' covers an arbitrary-emoji tapback (iOS 18+, imsg surfaces it as
114
+ // type "custom" with the real emoji in `emoji`). Sending requires the
115
+ // patched-fork emojiTapback capability.
116
+ reaction: Reaction | 'custom';
117
+ emoji?: string;
118
+ }
119
+
120
+ interface ReactResult {
121
+ ok: boolean;
122
+ skipped?: 'already-reacted' | 'stale-target' | 'not-reacted';
123
+ }
124
+
125
+ type GatewayEvent = ImsgMessage; // message or reaction; see §2 shape
126
+
127
+ type Reaction = 'love' | 'like' | 'dislike' | 'laugh' | 'emphasis' | 'question';
128
+
129
+ interface SendResult {
130
+ ok: boolean;
131
+ id?: number;
132
+ guid?: string;
133
+ }
134
+
135
+ interface GroupChatResolutionRequest {
136
+ conversationExternalId: string;
137
+ expectedParticipantExternalIds: readonly string[];
138
+ excludedParticipantExternalIds?: readonly string[];
139
+ }
140
+ ```
141
+
142
+ ### Tier 2 (bridge-backed)
143
+
144
+ Everything below requires the IMCore bridge (`imsg launch`, SIP disabled — see
145
+ `docs/imsg-polls.md` for the host setup and `docs/host-mac-control.md`).
146
+ These operations, including the primary `createGroup()` path, do not drive
147
+ Messages.app UI automation: they call the injected dylib's private IMCore API directly
148
+ through openclaw's per-request v2 IPC queue (built for concurrent access), so
149
+ `ImsgGateway` does not serialize them through the UI-automation Mutex. All of
150
+ them are wired over the long-lived `imsg rpc` child (`src/imsg/rpc.ts`), the
151
+ same transport as `send`/`history`/`chats.list` — openclaw exposes every verb
152
+ below as a JSON-RPC method (confirmed against openclaw's `RPCServer.swift`
153
+ `kSupportedRPCMethods` dispatch table and `docs/rpc.md` "Bridge Message
154
+ Actions"), so none of it needed a new `execFile` CLI subprocess.
155
+
156
+ **Patched-fork capability discovery.** Stock imsg can advertise the same RPC
157
+ method names while still lacking Mapier's macOS-26 fixes and custom `emoji`
158
+ parameter. `ImsgGateway` therefore uses `IMSG_BIN` (falling back to `imsg`) for
159
+ both the long-lived RPC process and its one-time `status --json` probe.
160
+ Patched verbs require the injected bridge's explicit selector markers
161
+ `emojiTapbackSend`, `stickerSend`, `groupPhotoUpdate`, `groupAddParticipant`, and
162
+ `groupRemoveParticipant`; Name & Photo requires both `namePhotoShouldOffer`
163
+ and `namePhotoShare`; emoji additionally requires the selected RPC binary
164
+ to report `rpc_features:["tapback.emoji"]`. That second half prevents a
165
+ patched dylib behind a stock CLI from false-advertising custom emoji support.
166
+ Missing status, malformed JSON, or absent markers fail closed.
167
+ `FakeGateway.capabilities` mirrors this gate and can disable the same surface
168
+ for parity tests. A remote adapter (e.g. imsg-agent's connector) must gate on
169
+ the capability descriptor it actually negotiated; the presence of a gateway
170
+ method on a wire never enables a feature by itself.
171
+
172
+ **Guid-targeting window.** `tapback`, `editMessage`, `unsendMessage`, and
173
+ `deleteMessage` can target ANY message guid, but `ImsgGateway` locates that
174
+ guid by scanning `history(chatId, 30)` — the same 30-message ceiling
175
+ `recentReactions` already documents above — so a guid older than that is as
176
+ invisible to these methods as it is to `recentReactions`. `tapback` and
177
+ `deleteMessage` pre-check against that scan before firing, so they return
178
+ `{ ok: false }` without touching anything; `editMessage`/`unsendMessage` fire
179
+ the RPC first and only find the ceiling on the VERIFY pass, so a guid outside
180
+ the window still reports `{ ok: false }` but may have mutated the real row on
181
+ the way there — "no confirmable effect," not a guaranteed no-op. FakeGateway
182
+ must enforce the identical window regardless: finding the target guid by
183
+ scanning ALL of `this.messages` unconditionally would let the fake succeed
184
+ at targeting a message the real path can't reach (parity rule 2) — a
185
+ guid-targeted verb "working" in the fake against a message 40 rows back but
186
+ refusing on the real path is exactly the trap this contract exists to
187
+ prevent.
188
+
189
+ - **`tapback(chatId, targetGuid, reaction, remove?)`** — a second, bridge-based
190
+ tapback path alongside `react()`. What it adds: targets **any** message guid
191
+ in the chat, not just the newest incoming bubble; works in **group** chats
192
+ (the bridge has no focused-window check to defeat, unlike the AppleScript
193
+ automation `react()` depends on); explicit add/remove instead of toggle.
194
+ What it does **not** add by itself: an arbitrary/custom emoji. openclaw's own bridge
195
+ handler (`RPCServer+BridgeMessageHandlers.swift` `handleTapback` →
196
+ `normalizeBridgeReactionType`, backed by the closed `BridgeReactionKind` enum
197
+ in `IMsgBridgeProtocol.swift` and the `kindMap` in the injected
198
+ `IMsgInjected.m` `handleSendReaction`) accepts only the same 6 canonical
199
+ kinds `react()` does, and openclaw's own CHANGELOG documents this as
200
+ deliberate: "fix: reject unsupported custom emoji reaction sends instead of
201
+ taking a no-op AppleScript path" (#55). So `reaction` stays the closed
202
+ `Reaction` type here too.
203
+ - **`emojiTapback(chatId, targetGuid, emoji, remove?)`** — Mapier-fork extension
204
+ backed by `IMEmojiTapback` + `IMTapbackSender`. It targets any guid in the
205
+ same 30-message scan window as `tapback`, uses explicit add/remove guards,
206
+ and verifies history for `type:"custom"` plus the exact emoji before
207
+ returning success. The RPC sends `emoji` instead of `kind`; sending both
208
+ would fall back into stock imsg's classic-6 normalizer. Live-verified on the
209
+ host Mac with 👻 and 💀; chat.db records `associated_message_type=2006`.
210
+ - **`sendRich(chatId, text, opts?)`** — real and confirmed present on the
211
+ host's selected patched imsg 0.13.x (`send.rich`, `RPCServer+BridgeMessageHandlers.swift`
212
+ `handleSendRich`; shipped well before 0.12.3 per CHANGELOG). Targets an
213
+ **existing** chat only — unlike `send()` there is no `{ to: handle }`
214
+ find-or-create form. `opts.effect` is a Messages expressive-send effect
215
+ short name; valid values are bubble `impact`, `loud`, `gentle`, `invisibleink` and
216
+ screen `confetti`, `lasers`, `fireworks`, `balloons`, `sparkles`, `spotlight`,
217
+ `echo`, `love`, `celebration`. `opts.replyToGuid` sets an inline reply target;
218
+ `opts.subject` sets the Messages subject line. Subject is write-only through
219
+ this Gateway because `ImsgMessage` has no subject field. The `url` submode is
220
+ exposed separately as `sendRichLink` because openclaw treats URL and
221
+ text/subject/effect/reply as mutually exclusive modes.
222
+ - **`sendAttachment(chatId, filePath, opts?)`** — `send.attachment`
223
+ (`handleSendAttachment`). Targets an **existing** chat only. `opts.audio:true`
224
+ sends the file as a native voice-note bubble; `opts.replyToGuid` sets an
225
+ inline reply target. A successful send echoes an outbound row, but file
226
+ content and audio mode are not asserted from the synchronous send response.
227
+ Attachment metadata may later appear on history/watch rows through the
228
+ read-side `attachments[]` shape described below.
229
+ - **`sendSticker(chatId, filePath, opts?)`** — patched-fork `send.sticker`,
230
+ selector-gated by `stickerSend`. Targets an **existing iMessage chat only**;
231
+ there is no find-or-create form. It accepts PNG/APNG/GIF/JPEG files up to
232
+ 500 KiB, 618×618, and 100 frames, and can optionally attach to a message guid/part.
233
+ Success maps the always-present `transfer_guid` to `SendResult.guid`.
234
+ FakeGateway conservatively echoes an outbound row but does not claim file
235
+ validity or rendered sticker metadata, which the send response cannot verify.
236
+ **Host-verified 2026-07-14/15 — delivers.** Initially failed
237
+ `{"error":"Dylib error: Could not securely open sticker directory"}`: the
238
+ injected dylib's sticker secure-open fd-walk started at the home directory,
239
+ which the Messages sandbox EPERMs (`open("/Users/…")` denied). Fixed in the
240
+ fork (`mapier/deploy`, "sticker sandbox-safe secure-open") by anchoring
241
+ the per-component `O_NOFOLLOW`+uid walk at the shallowest sandbox-openable
242
+ ancestor instead of home. After rebuild+inject, `send.sticker` delivers — a
243
+ real sticker row lands with `is_sticker=1, is_sent=1, error=0`.
244
+ - **`checkHandle(address, opts?)`** — bridge-backed Apple IDS reachability via
245
+ `handles.check`; iMessage only, never SMS. FakeGateway returns available only
246
+ from evidence it actually owns: participants of known chats or an explicit
247
+ test/sim reachability override. A well-formed address with no evidence returns
248
+ `available:false`/`idStatus:0`. This deliberately under-claims users Apple IDS
249
+ might find reachable rather than inventing reachability the fake cannot know.
250
+ **Host-verified 2026-07-14 (both branches):** a real iMessage test phone →
251
+ `available:true, id_status:1`; an unregistered number → `available:false,
252
+ id_status:0`. The live false-branch confirms the fake's evidence-based model
253
+ is faithful, not an over-claim.
254
+ - **`sendPoll(chatId, question, options)`** — `poll.send`
255
+ (`handlePollSend`), targeting an **existing** chat only and requiring at
256
+ least two options. The RPC's `guid` becomes `SendResult.guid` (the fork
257
+ docs' earlier `messageGuid` name was wrong — see the ledger below), which
258
+ identifies the balloon row. Poll creation has a real readback postcondition:
259
+ the balloon and caption rows described under "Native poll readback" below.
260
+ - **`sendRichLink(chatId, url)`** — the `send.rich` URL submode
261
+ (`handleSendRichLink`), targeting an **existing** chat only. The caller
262
+ supplies only the bare URL; the bridge builds the preview title/image from
263
+ the page. **The submode is queued/async: it returns `{ ok, queued }` with NO
264
+ guid** (unlike text/poll/attachment, which return a guid) — the URL-preview
265
+ balloon lands as its own row later. `SendResult.guid` is therefore always
266
+ undefined here; FakeGateway must not return one either.
267
+ - **`editMessage(chatId, targetGuid, text)`** — `message.edit`
268
+ (`handleMessageEdit`). **Edits mutate the existing row's text IN PLACE: no
269
+ new row, no `subscribe()` event.** `MessageWatcher`'s cursor is a ROWID
270
+ high-water mark (`WHERE ROWID > cursor`), so an UPDATE to an existing row is
271
+ structurally invisible to the watch stream — the only way to observe an
272
+ edit is re-reading `history()` and comparing text for a guid you already
273
+ have. This is a new invariant: elsewhere in this doc "same guid" has meant
274
+ "same immutable content" (e.g. poll rows); edited messages break that.
275
+ - **`unsendMessage`/`deleteMessage(chatId, targetGuid)`** — `message.unsend`
276
+ (`retractMessagePart:`) / `message.delete` (`deleteChatItems:`), two
277
+ distinct native APIs. **Live-verified on the host Mac:** unsend retracts in
278
+ place (row persists and text clears); delete dispatches successfully but
279
+ has no stable `history()` postcondition (the row persisted in one live run
280
+ and was absent in another). imsg exposes no `is_unsent`/`is_deleted` marker,
281
+ so after a guid pre-check, delete `{ok:true}` means only that the RPC
282
+ dispatched. The fake conservatively leaves its history row unchanged.
283
+ - **`setTyping(chatId, on)`/`markRead(chatId)`** — `typing`/`read`
284
+ (`handleTyping`/`handleRead`). **Fire-and-forget: no observable effect
285
+ through anything this Gateway exposes.** `is_read`/`date_read` are real
286
+ imsg message fields per `docs/json.md`, but the Gateway does not model them
287
+ as a verification surface. `ok: true` means
288
+ "the RPC call was dispatched without error," never "confirmed."
289
+ - **`renameGroup`/`addParticipant`/`removeParticipant`** — `group.rename`
290
+ (`setDisplayName`) / `group.addParticipant` / `group.removeParticipant`
291
+ (`RPCServer+ChatHandlers.swift`). Verified against `chats.list`'s
292
+ `display_name`/`participants` fields — the same source `createGroup`'s
293
+ content-verify and `resolveDmChat` already read via `ImsgRpc.chats()`.
294
+ The long-lived RPC process keeps a stale chat-metadata view after its own
295
+ mutation on macOS 26, while a fresh `imsg chats` process sees the update.
296
+ Verification therefore polls fresh, read-only CLI processes (using the
297
+ same `IMSG_BIN`) for up to 20 seconds before returning `ok:false`.
298
+ - **`setGroupPhoto`/`leaveGroup`** — `group.setIcon` / `group.leave`. No field
299
+ in imsg's JSON output reflects a group photo, and `participants[]` always
300
+ excludes the local user (see "Participants exclude the local user" above),
301
+ so leaving never changes anything queryable either — both are
302
+ fire-and-forget through the Gateway, same caveat as `setTyping`/`markRead`.
303
+ The patched host path is **receiver-verified** (2026-07-15, iMessage group →
304
+ the icon rendered on the recipient device). The fork **stages** the image
305
+ into the Messages Attachments tree (`stageAttachment`) before transferring it
306
+ via `createNewOutgoingGroupPhotoTransferWithLocalFileURL:`, then persists an
307
+ `at_0_...` group-photo GUID, posted as a silent group-action event with no
308
+ attachment bubble. Staging is load-bearing: an unstaged path is unreadable by
309
+ sandboxed imagent, so the GUID binds locally but the file never uploads (no
310
+ attachment row, no delivery — the original failure). A custom emoji /
311
+ solid / gradient icon is just a rendered PNG sent through this same path — no
312
+ special API.
313
+ **`leaveGroup` is currently dead on the live host** (observed 2026-07-17,
314
+ macOS 26.5.1, fork 0.13.0): `group.leave` returns `-32603 Internal error`
315
+ regardless of group size, and `imsg status` lists no leave-related selector
316
+ (its neighbors `deleteChat`/`editMessage` are also ✗ on this build). The
317
+ gateway degrades to `{ ok: false }`. Treat leave as unavailable until the
318
+ fork restores the selector; imsg-sdk issue #5 tracks it.
319
+
320
+ All Tier-2 group-management methods reject a DM `chatId` (`ok: false`) on
321
+ both implementations — none of Apple's group primitives apply to a 1:1 chat.
322
+
323
+ - **`shareNamePhoto(chatId)`** — Apple Messages' native **Share Name & Photo**
324
+ flow, not a vCard or Contacts attachment. It first calls
325
+ `contacts.shouldShareContact` and requires `available:true` plus
326
+ `should_offer:true`; otherwise it fails or returns the idempotent
327
+ `skipped:"not-offered"`. Only then does it call
328
+ `contacts.shareContactCard`, and success requires `requested:true`. The
329
+ result carries `effectStarted:false` for a preflight failure and
330
+ `effectStarted:true` when the RPC share invocation began but did not return a
331
+ verified success. A consumer maps the latter to reconciliation instead of
332
+ claiming the effect never started. The
333
+ runtime invokes this once per process on the first allowlisted inbound text
334
+ in a DM; reaction-only and blocked senders never trigger it. That process
335
+ guard is required: live verification saw Apple's advisory briefly return
336
+ `true` once after a successful share before settling to `false`, so
337
+ `should_offer` alone is not a durable idempotency key across restarts.
338
+ FakeGateway models the settled state and skips its second call. On
339
+ 2026-07-14 the host returned `requested:true` for the disposable test group
340
+ and the receiving iPhone displayed Mapi's new contact info, closing the
341
+ receiver-side verification gap.
342
+
343
+ **Host-verification ledger.** Exercised through `ImsgGateway` on the host Mac
344
+ 2026-07-14 (`scripts/tier2-smoke.ts`, chat 3996):
345
+
346
+ - **`sendRich` subject** — live-verified: `chat.db message.subject` populated,
347
+ and the bold subject line rendered receiver-visible on the test phone.
348
+ - **`sendPoll`** — live-verified: renders as a native interactive poll on the
349
+ receiving device; `poll.send` returns the balloon guid as `guid` (the earlier
350
+ `messageGuid` read was wrong and is fixed), confirmed via poll readback.
351
+ - **`sendRichLink`** — live-verified delivered after the Mapier fork fixed the
352
+ bridge's deferred `dispatch_after` path (`mapier/richlink-sync-fix`). The host
353
+ recorded an `is_sent=1` URLBalloonProvider row and `send_status` reported
354
+ delivery. The RPC still returns only `{ok, queued}` with no guid, so that
355
+ immediate response alone is not delivery proof.
356
+ - **`sendAttachment`** — `send.attachment` returns a guid synchronously;
357
+ attachment-join row (and `is_audio_message=1` for the `audio:true` leg)
358
+ verified via a direct `chat.db` read. Opt-in via `--attachment`/`--audio-file`.
359
+
360
+ Each leg prints an EYEBALL step because a local write is not proof of
361
+ receiver-visible delivery (the group-photo lesson). The `sendRich`
362
+ text/effect/reply path was already live-verified.
363
+
364
+ - **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
365
+ chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
366
+ events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
367
+ `ImsgWatch` (`src/imsg/watch.ts`) owns a dedicated `ImsgRpc.subscribe` process wrapping
368
+ `watch.subscribe` with `include_reactions:true` and `attachments:true`. When the caller
369
+ supplies a durable cursor, the SDK retires that read-side process every 10 seconds and
370
+ resumes a fresh process from the last observed global rowid. The overlap-free handoff is
371
+ lossless because the cursor is exclusive, and duplicate replay at the boundary is discarded.
372
+ The rotation timer starts when the process is created; it does not wait for the native
373
+ `watch.subscribe` acknowledgement, which can itself remain pending. This bounds a native
374
+ subscription that remains process-alive but silently stops reading `chat.db`; transport
375
+ heartbeat alone is not delivery health. The paired native watcher gives an unjoined row a
376
+ bounded two-second window to acquire its chat association, then skips it so that malformed
377
+ row cannot hold the global cursor behind the SDK's ten-second supervision deadline. The RPC
378
+ emits a content-free `watch.warning`, which the SDK reports as
379
+ `imsg_watch_unresolved_chat_row_skipped`, whenever that recovery path is used, and advances the
380
+ rotating cursor through that row so a tail orphan is not retried forever. A cursorless
381
+ subscription keeps its first native session until its first event establishes a resumable rowid.
382
+ **Single consumer.** The event queue is instance-shared; a second concurrent subscriber
383
+ would silently steal events, so both implementations throw on one. Documented divergence:
384
+ ImsgGateway allows one `subscribe()` per gateway *lifetime* (the SDK rotates its internal
385
+ read process; callers still do not re-subscribe), while FakeGateway allows
386
+ *sequential* re-subscription (one active at a time) so the simulator can model
387
+ reconnect-after-downtime against a single in-memory store.
388
+
389
+ - **listChats(limit?)** — read-only portable chat directory for backfill. Rows
390
+ carry the local execution-only `chatId`, portable conversation GUID, group bit,
391
+ canonical participants, and last-message timestamp. A full page means the caller has
392
+ not proved directory completeness. Rows missing portable GUID or participants are
393
+ omitted and therefore also prevent a completeness claim.
394
+ - **historyRange(chatId, range)** — oldest-first, reaction-row-free messages within an
395
+ inclusive `startAt` and exclusive `endBefore` time window. It optionally includes
396
+ attachment metadata. A backfill consumer recursively splits a full window rather than
397
+ treating a bounded response as complete; a still-full minimum window becomes a
398
+ disclosed backfill gap.
399
+
400
+ **Lifecycle.** Every JSON-RPC request has a 30-second monotonic owner timeout. A timeout
401
+ rejects the request, retires the whole RPC child, and invokes the unexpected-exit callback;
402
+ the owner must fail closed because any in-flight mutation may already have started. This also
403
+ bounds a native RPC process that remains alive before accepting its first request (including a
404
+ headless macOS permission wait). Spawn failures follow the same callback path instead of
405
+ escaping as an unhandled child-process error.
406
+
407
+ The Gateway contract has no abort or close signal: quiescing a consumer does
408
+ not promise that an outstanding `AsyncIterator.next()` inside `ImsgGateway.subscribe()` was
409
+ canceled. The process owner stops the Gateway during final shutdown. An intentional
410
+ `ImsgRpc.stop()` closes the child without invoking its unexpected-exit callback; an exit
411
+ that occurs before an intentional stop still invokes that callback so the process owner can
412
+ fail closed (covered by `tests/imsg-rpc.test.ts`).
413
+
414
+ - **history(chatId, limit)** — most recent `limit` messages in a chat, both sides, reactions
415
+ and attachment metadata included. `ImsgRpc.history` (`src/imsg/rpc.ts`)
416
+ calls `messages.history` with `attachments:true` and re-sorts
417
+ oldest-first by `id` because RPC order is undocumented — any Gateway impl must return the
418
+ same normalized order.
419
+ - **send(target, text)** — one outbound text. `target` is `{ chatId }` for an existing thread
420
+ (DM or group) or `{ to: handle }` for a DM by handle, find-or-creating the thread. Returns
421
+ `{ ok, id?, guid? }`; `ok: false` on failure — see §2 failure modes.
422
+ - **react(chatId, reaction, expectedGuid?)** — send a tapback to the chat's most-recent
423
+ **incoming** bubble (the real CLI's own semantics — our outbound messages are never the
424
+ target); there is no message-id targeting because the real path doesn't have one (§2).
425
+ `expectedGuid` is a **precondition, not targeting**: if provided and the newest incoming
426
+ bubble is no longer that guid at fire time, the react is skipped
427
+ (`{ ok: true, skipped: 'stale-target' }`) — a queued react must not tag a message that
428
+ arrived after the decision was made. Implementations also skip
429
+ (`skipped: 'already-reacted'`) when the same reaction from us is already active on the
430
+ target — our own `react()` never un-reacts (toggle protection; see §2). `ok: false` means
431
+ the reaction did not verifiably land (§2 "exit 0 lies").
432
+ - **createGroup(handles, firstMessage)** — create a group thread and send its first message
433
+ in one call through the native `chats.create` IMCore bridge. An older native binary that
434
+ conclusively returns JSON-RPC `method not found` uses the guarded GUI compose fallback.
435
+ Returns the new `chatId`, located by **content**: the thread whose recent history contains
436
+ our `firstMessage`, not merely one matching the participant set (§2). Throws if the message
437
+ never appears — the compose can fail silently. No standalone "create empty group" or
438
+ "add participant" exists.
439
+ - **recentReactions(chatId, limit?)** — user tapbacks on OUR messages, newest-first; `limit`
440
+ bounds the returned notes (~10 default). Read from history's per-message `reactions[]`
441
+ aggregates (§2), so it survives process restarts. **Scan ceiling:** only the chat's most
442
+ recent 30 messages are scanned (the real path reads `history(chatId, 30)`) — a tapback on
443
+ an older message is invisible, and FakeGateway enforces the same window so features can't
444
+ be built against reach the real gateway doesn't have. Feeds "[reacted X to your message]"
445
+ acknowledgment notes into agent context.
446
+ - **resolveDmChat(handle)** — the numeric `chat_id` of the existing DM thread with `handle`, or
447
+ `null` if none exists. **Read-only** — it must never create a thread (the real path reads
448
+ `chat.db` via `chats.list` and cannot mint one; FakeGateway may not exceed that). The runtime
449
+ keeps an in-memory `handle → chat_id` cache warmed by inbound traffic; this recovers it on a
450
+ cold miss — a link completion replaying after a pod restart, before the user's next message,
451
+ would otherwise defer forever (imsg-agent's `handleLinkCompletion`). The real path derives
452
+ the DM row as the non-group chat whose sole `participants[]` entry is the handle (live-verified
453
+ 2026-07-08 against imsg 0.12.3, `scripts/chats-probe.ts`: 37/37 DM rows carried exactly one
454
+ participant = the peer handle; DM-vs-group is `!is_group`, never participant count). The method
455
+ itself passed its host-Mac smoke the same day — `scripts/resolve-dm-smoke.ts` (the manual rule-1
456
+ gate for this method; re-run it on imsg upgrades): known test-phone handle → its exact chat_id,
457
+ unknown handle → null with no thread composed, repeat resolve stable.
458
+ - **resolveGroupChat(request)** — read-only portable group resolution. It makes one high-ceiling
459
+ local `chats.list` read and uses each group row's portable `guid` and `participants` directly;
460
+ it does not open every group's message history. It accepts a result only when the
461
+ scan is complete, exactly one row has the requested GUID, and its normalized participant set
462
+ exactly equals `expectedParticipantExternalIds` after removing the explicitly declared pod/self
463
+ identities. Invalid input, an incomplete directory, no match, duplicate matches, an unreported
464
+ membership, and membership drift are distinct fail-closed outcomes. `guid` and `participants`
465
+ are optional on the imsg wire, so a group row without `guid` makes the directory incomplete;
466
+ an otherwise readable matching group without `participants` yields
467
+ `participant_directory_unavailable`, never `participant_mismatch`: reporting drift
468
+ about members that were never read would silently disable matching against a correct ops chat,
469
+ which is the exact "`imsg` lies" failure class this contract exists to catch. `FakeGateway` can
470
+ reach both degraded states through `setGroupDirectoryComplete` and
471
+ `setGroupParticipantsUnreported`, so neither branch is fake-unreachable. Live verification on
472
+ 2026-07-14 against imsg 0.12.3 read all 3,922 visible chats in about one second; all 96 group rows
473
+ reported a GUID and participants, and `scripts/resolve-group-smoke.ts` passed exact resolution,
474
+ stable repeat resolution, membership mismatch, and unknown-GUID rejection. That live run is
475
+ the verification of record; no separate evidence row exists. The returned numeric `chat_id` is an ephemeral
476
+ process-local execution target: it is never configuration, durable state, or connector payload.
477
+ `scripts/resolve-group-smoke.ts` is the read-only host-Mac rule-1 gate.
478
+ - **sendStatus(guid)** — read-only `message.send_status` lookup in chat.db; it
479
+ does not require the bridge. Missing rows normalize to `pending` with null
480
+ status fields. Recipient read-receipt settings legitimately leave
481
+ `date_read`/`date_delivered` null for many users; normalized delivery state is
482
+ reliable regardless. FakeGateway reports `sent` only for guids it emitted and
483
+ never invents delivered/read evidence; unknown guids are `pending` with null fields.
484
+ **Host-verified 2026-07-14:** a freshly-sent guid returned
485
+ `send_state:"delivered"` with `is_sent/is_delivered:true`, `date_delivered`
486
+ populated, and `date_read:null` (recipient read-receipts off) — the exact
487
+ `status_fields` shape the wrapper types model.
488
+
489
+ ## 2. Behavioral invariants FakeGateway must reproduce
490
+
491
+ This is the heart of the doc. Every item here is either load-bearing for agent logic today or
492
+ a hard ceiling on what iMessage can do — FakeGateway gets both right or it isn't a fake, it's
493
+ a different product.
494
+
495
+ - **Monotonic ids, global, never reused.** `id` mirrors `chat.db` rowid semantics: it strictly
496
+ increases across **all** chats, not per-chat. The cursor protocol (`id > cursor`, exclusive)
497
+ in imsg-agent (`handleInbound`) depends on this — `if (msg.id <= (await store.getCursor())) return;`. A
498
+ FakeGateway that resets ids per chat, or reuses one, breaks cursor advance silently.
499
+
500
+ - **Echo of own sends.** A successful `send()` later arrives back through the event stream
501
+ as a message with `is_from_me: true`, and shows up in `history()`. Cursor advance, history
502
+ hydration (imsg-agent's `toModelMessages`), and same-role message merging all
503
+ assume this echo happens. FakeGateway must emit it, not just record the send silently.
504
+
505
+ - **history() hides reaction ROWS but carries reaction STATE.** Verified live on imsg 0.12.0
506
+ (2026-07-02) and imsg 0.12.3 (2026-07-07): `messages.history` returns no `is_reaction` rows
507
+ (they'd duplicate the reacted
508
+ message), but every message carries a `reactions[]` aggregate — the current tapback state on
509
+ that bubble: `{ id, type, emoji?, is_from_me, sender?, created_at? }` per reaction.
510
+ Consequence: reaction-state logic (toggle guard, `recentReactions`) reads history — it is
511
+ authoritative and restart-safe. The subscribe stream (`include_reactions: true`) still
512
+ delivers reaction EVENTS for realtime wake-ups, but is never the source of state: a process
513
+ that just booted has seen no stream events yet.
514
+ Caveat: in `reactions[]`, trust `is_from_me`, not `sender` — the live binary fills `sender`
515
+ with the chat peer's handle even for our own reactions.
516
+ FakeGateway must reproduce both halves: filter reaction rows out of `history()` AND maintain
517
+ `reactions[]` on each message.
518
+ Text can be present in FakeGateway even where the real store's raw `text` column would be
519
+ `NULL` (real iMessage decodes `attributedBody` — see `docs/host-mac-control.md` §4).
520
+ FakeGateway just stores text plainly; only the outward guarantee that `text` is populated
521
+ matters.
522
+
523
+ - **Message event shape** (`ImsgMessage`, `src/types.ts`):
524
+ `id, chat_id, is_group, guid, sender, sender_name, is_from_me, text, created_at,
525
+ reactions[]`.
526
+ Reaction events additionally carry:
527
+ `is_reaction, reaction_type, reaction_emoji, is_reaction_add, reacted_to_guid`.
528
+ Fake↔live reaction-event shape verified against the 2026-07-03 live capture.
529
+ FakeGateway events must be structurally identical — same fields, same optionality.
530
+ When attachment metadata was requested, a message may additionally carry
531
+ `attachments[]`: `filename` plus optional `transfer_name`, `uti`, `mime_type`,
532
+ `total_bytes`, `is_sticker`, `missing`, and local absolute `original_path`.
533
+ Field names are the LIVE RPC wire shape (host-verified 2026-07-14 via
534
+ `messages.history attachments:true` against the injected 0.13.0 build) — the
535
+ fork's `docs/attachments.md` table lists `byte_size`/`path`, but the binary
536
+ emits `total_bytes`/`original_path`; the wire wins. The file is already on
537
+ the host Mac; no download verb exists. `missing:true` means Messages aged the
538
+ local file out. The fork omits `attachments` when none exist, and FakeGateway
539
+ does the same. **Host-verified 2026-07-14:** a `send-attachment` PNG and a
540
+ prior voice-note `.aiff` both read back through history with the full field
541
+ set populated.
542
+ Host paths are execution details and never cross a remoting wire.
543
+
544
+ - **`chat_guid` is populated on every message and history row** (live-verified 2026-07-02
545
+ against imsg 0.12.0 and re-verified 2026-07-07 against imsg 0.12.3): DMs are
546
+ `any;-;<handle>` (e.g. `any;-;+17738867338`), groups are
547
+ `any;+;<32-hex>`. It is the stable, portable thread identifier — `chat_id` is a local
548
+ chat.db ROWID and must never be persisted off-Mac (the Supabase store keys
549
+ `agent_message_log`/`agent_turns` on `chat_guid`, v0/design.md §4). FakeGateway synthesizes
550
+ the same formats per chat.
551
+
552
+ - **Portable group resolution never trusts a saved numeric rowid.** `chat_id` can change across
553
+ Mac state and never identifies a portable thread. Both gateways therefore resolve a group GUID
554
+ afresh, require a complete unique directory result, and compare exact normalized membership
555
+ independently from the set of handles authorized to act. A partial scan or participant mismatch
556
+ disables the dependent workflow; it never falls back to a saved numeric chat id.
557
+
558
+ - **A DM's chat_id is recoverable from the handle at any time, but only if the thread exists.**
559
+ `chats.list` (`src/imsg/rpc.ts`) is authoritative and restart-safe: the real gateway can map a
560
+ handle back to its `chat_id` whenever a DM thread is present, because chat.db persists the
561
+ peer handle as the row's sole `participants[]` entry (live-verified — see §1 `resolveDmChat`).
562
+ It cannot conjure a chat_id for a handle that has never messaged (no row exists yet). This is
563
+ why the runtime's cold-cache recovery is a *resolve-or-defer*, never a create: `resolveDmChat`
564
+ returns the id if the thread exists and `null` otherwise, and the completion stays unprocessed
565
+ (store-redelivered) until inbound traffic actually creates the DM. FakeGateway mirrors both
566
+ halves — its `resolveDmChat` finds an existing DM chat and returns `null` for an unknown handle,
567
+ never minting one (that would let the sim deliver where the real gateway defers).
568
+
569
+ - **Reactions toggle — and our own react() must therefore guard.** On the platform, reacting
570
+ with a type that already exists from the same sender on the target bubble **removes** it
571
+ (event with `is_reaction_add: false`) — it does not no-op or stack. Tapbacks are per-sender:
572
+ we can only ever toggle OUR OWN reactions; another party's tapback is untouchable. FakeGateway
573
+ must model toggle for *other parties* (fixture driver `injectReaction` simulates a user's
574
+ tapback, toggling on repeat). But the Gateway's own `react()` never un-reacts: it checks the
575
+ target's `reactions[]` in history and skips with `{ skipped: 'already-reacted' }` instead of
576
+ firing a removal (live incident 2026-07-01: an unguarded react removed an agent-account heart
577
+ placed manually on the host Mac). Toggle is platform truth; the guard is our contract on top
578
+ of it.
579
+
580
+ - **react() targets the most-recent INCOMING bubble in the chat, full stop.** The real path
581
+ (`src/imsg/react.ts`, `imsg react --chat-id <id> --reaction <r>`) takes no message id, and
582
+ per its own help text reacts to "the most recent incoming message" — our own outbound
583
+ bubbles are never the target. FakeGateway's `react()` signature must not accept a message id
584
+ either, and must apply the reaction to the chat's newest incoming message only. Building a
585
+ "react to message N" feature against a fake that supports arbitrary targeting produces a
586
+ feature the real gateway cannot deliver.
587
+ Reaction set is closed: exactly the 6 tapback types in `REACTIONS`
588
+ (`src/imsg/react.ts`) — `love, like, dislike, laugh, emphasis, question`. No arbitrary-emoji
589
+ sending.
590
+ **DM-only.** The real automation (`scripts/react.applescript`, ours — `imsg react` was
591
+ dropped for firing into the focused chat when its search-select race loses) verifies the
592
+ focused thread by comparing handle digits against the window title, which group titles
593
+ don't carry. `react()` on a group chat returns `{ ok: false }` on both implementations.
594
+
595
+ - **react() exit 0 lies — success must be verified.** Observed live (2026-07-02): one
596
+ `imsg react` run exited 0 having applied nothing anywhere; another applied the tapback in
597
+ the WRONG chat (the UI automation acts on whatever chat Messages.app has focused when chat
598
+ selection fails). ImsgGateway therefore polls the target chat's history after firing and
599
+ reports `ok: true` only once the reaction appears on the intended message's `reactions[]`;
600
+ otherwise `ok: false`. Callers must treat `ok: false` as "did not happen" — never assume a
601
+ fired react landed. This is also why the 20× react precision gate (issue #13) exists.
602
+ **Cmd+T can silently fail — and the fallout is a SENT TEXT, not a no-op.** Observed live
603
+ (2026-07-17, display asleep on the host): the tapback picker never opened, the reaction key
604
+ landed in the compose field, and the script's trailing Return sent a literal `"1"` to the
605
+ chat. `react.applescript` therefore guards after typing the reaction key: if the focused UI
606
+ element is a text input containing the key, it clears the field and aborts with an error
607
+ (surfacing as `{ ok: false }`) instead of pressing Return. A failed react must never mutate
608
+ the conversation.
609
+
610
+ - **Group semantics.**
611
+ - `is_group: true`, chat identified by a hex-string identifier for groups (per
612
+ `docs/host-mac-control.md` §4: "numeric chat_id per thread; hex identifier = group chat").
613
+ - Messages carry `participants[]` in group chats.
614
+ - Groups are created **only** via `createGroup` — sending the first message is what creates
615
+ the thread. The real path is the native `chats.create` IMCore bridge; guarded `Cmd+N`
616
+ compose automation is only a compatibility fallback for binaries without that RPC method.
617
+ There is no "create empty group". Adding/removing a member on an *existing* group is possible —
618
+ see "Tier 2" above (`addParticipant`/`removeParticipant`, bridge-backed) — but only through
619
+ those methods, not through `createGroup`.
620
+ - **Compose guard**: if Cmd+N fails to enter compose (sibling of the react Cmd+T failure,
621
+ observed live 2026-07-17), every scripted keystroke would land in the focused chat's
622
+ compose field and each Return would send it — recipient handles texted into an open
623
+ thread. `create-group.applescript` verifies the window title changed after Cmd+N (chat
624
+ title → "Messages") and aborts before typing anything if it didn't.
625
+ - **Group create can fail silently** (observed live 2026-07-02: recipient flapped from
626
+ iMessage to SMS registration; compose exited 0, no thread created, no message sent —
627
+ SMS handles cannot join an iMessage group). And Messages may **reuse an existing thread**
628
+ with the same participant set instead of creating a new one. Both are why the new thread
629
+ is located by content (recent history contains `firstMessage`) with a timeout that throws,
630
+ never by participant-set match alone.
631
+ - DMs are keyed by handle: `send({ to: handle }, text)` finds-or-creates the DM thread.
632
+
633
+ - **Latency & ordering are not instant.** Real sends take ~1–3s (AppleScript round trip);
634
+ real events arrive with debounce (~500ms) and can batch after a downtime gap (catch-up from
635
+ cursor on reconnect). FakeGateway should support a configurable artificial latency and a
636
+ catch-up/backlog mode so mailbox debounce and per-chat serialization
637
+ (imsg-agent's `Mailbox`) get exercised the way they will in production, not just against
638
+ an instant no-op fake.
639
+
640
+ - **Failure modes to simulate**, all of which the agent must survive without corrupting state:
641
+ - send failure (network/imsg error) — `send()` resolves `{ ok: false }` or rejects.
642
+ - react failure (UI-automation flake, nonzero exit from `imsg react`).
643
+ - gateway process death mid-stream — on reconnect, the agent must resume from its stored
644
+ cursor without re-processing or double-replying to anything at or before that cursor
645
+ (imsg-agent's `handleInbound` + stored cursor).
646
+
647
+ - **Native poll readback (imsg 0.12.3, live-verified 2026-07-07, full capture
648
+ in `docs/imsg-polls.md`).** Poll activity arrives as regular message rows with a `poll`
649
+ payload (`MessagePoll`, `src/types.ts`) on BOTH subscribe and history — unlike reaction
650
+ rows, history does NOT hide them, so poll state is restart-safe. Three row shapes:
651
+ - **Creation is TWO rows**: a balloon row (`poll.kind: "created"`,
652
+ `metadata.associated_message_type` 3 — observed 0 transiently right after send, Messages
653
+ restamps it, so classification must never key on the balloon's value — question +
654
+ options, imsg-synthesized display text
655
+ `"Sent a poll"`) followed by a plain caption row carrying the question with
656
+ `reply_to_guid` = the balloon guid — Messages never renders the poll title on the
657
+ balloon, so imsg always sends the caption.
658
+ - **A vote is a NEW row** (`metadata.associated_message_type` 4000, `poll.kind: "vote"`):
659
+ `poll.votes[]` is that participant's CURRENT selections (state, not an event log — a
660
+ re-vote is a new row with the new full selection), `option_text` pre-resolved by imsg,
661
+ `original_guid` = the balloon guid. Display text is a junk single space.
662
+ - **An added option ("Add Choice") is a NEW row** (`metadata.associated_message_type` 2)
663
+ re-carrying the FULL updated option list — but imsg mislabels it `poll.kind: "created"`
664
+ and its `poll.question` is unreliable. Display text is a junk U+FFFD. Consumers must
665
+ classify rows via `pollEventKind` and resolve the question through `original_guid` → the
666
+ balloon row (`src/polls.ts`) — never trust `kind`/`question` on non-balloon rows.
667
+ The Gateway creates polls through `sendPoll`; the manual host-Mac CLI equivalent is
668
+ `imsg poll send` (bridge-injected — `docs/imsg-polls.md`). FakeGateway reuses
669
+ `injectPollCreate` for the same balloon+caption postcondition. Vote and option-update
670
+ fixture drivers (`injectPollVote` / `injectPollAddOption`) reproduce the remaining shapes
671
+ above including the junk display texts and the kind mislabel; there is still no Gateway
672
+ poll vote/unvote surface. Open/unverified: the wire shape of a bare vote retraction (the
673
+ fake refuses empty selections until it's observed live) and multi-option selections.
674
+
675
+ - **Forbidden surface — none of this exists on the real path, so FakeGateway must not offer
676
+ it:**
677
+ - voting or unvoting on native polls through the Gateway (`sendPoll` creates polls only)
678
+ - sending an arbitrary/custom-emoji tapback through stock `tapback` — that
679
+ verb deliberately stays on the closed 6-type `Reaction` set. The separate
680
+ `emojiTapback` verb exists only when the patched bridge advertises its
681
+ capability marker
682
+ - outbound threaded replies through plain `send()` — inline replies exist only via
683
+ `sendRich`'s `replyToGuid` (Tier 2); `send()` has no reply parameter
684
+
685
+ Moved out of this list once Tier 2 landed (see "Tier 2" above for what each one actually
686
+ does and does not add): typing indicators, read receipts, editing/unsending/deleting
687
+ messages, reacting to an arbitrary (non-most-recent) message, and participant management
688
+ on an existing group (add/remove/rename/photo/leave).
689
+
690
+ - **Inbound media.** Real messages can carry `attachments[]` with empty `text`. FakeGateway must
691
+ be able to emit the exact attachment metadata with `text: undefined`/empty so
692
+ this path gets exercised — the agent currently ignores attachments gracefully
693
+ (imsg-agent's `toModelMessages`, `if (!m.text) continue;`), and that "gracefully" needs a fixture to
694
+ keep being true.
695
+
696
+ ## 3. FakeGateway initial design
697
+
698
+ - **Backing store**: `sim_messages` table (Supabase local/dev) or in-memory for unit tests.
699
+ Auto-increment `id` is the rowid analog and must satisfy the global-monotonic invariant in
700
+ §2. `sim_chats` holds `is_group` / `participants`. Reactions are stored as message-shaped
701
+ rows with an association back to the target (mirroring `chat.db`'s
702
+ `associated_message_type` / `associated_message_guid` shape described in
703
+ `docs/host-mac-control.md` §4), not as a separate table — but `history()` must **filter
704
+ them out** while maintaining each message's `reactions[]` aggregate, mirroring the real
705
+ path (§2); reaction ROWS reach the agent only through the subscribe stream, reaction STATE
706
+ through history.
707
+ - **Drivers**:
708
+ 1. Programmatic API for eval fixtures — inject an inbound message, reaction, or poll
709
+ event (`injectInbound` / `injectReaction` / `injectPollCreate` / `injectPollVote` /
710
+ `injectPollAddOption`), assert on outbound sends/reacts.
711
+ 2. The simulator web UI (multi-fake-user iMessage-style chat) reads/writes the same store,
712
+ so a human can drive the same fixtures the eval harness does.
713
+ - **Determinism knobs**: `latency: 0` + no debounce batching for unit tests (fast, deterministic);
714
+ realistic timings for integration runs that need to exercise the mailbox debounce/serialize
715
+ path for real.
716
+ - **Hosted-Dev persistence**: `snapshot()` returns a versioned
717
+ `FakeGatewaySnapshot`; `devRestoreSnapshot()` accepts that exact contract.
718
+ The snapshot includes chats and current group names, messages, sent GUIDs,
719
+ reachable-address evidence, Name & Photo sharing state, and the degraded
720
+ group-directory fixture state. Restoring it must preserve `sendStatus()`,
721
+ `checkHandle()`, Name & Photo idempotency, current group metadata, and both
722
+ global monotonic counters. This driver-only API is not a `Gateway`
723
+ capability and has no real-gateway counterpart.
724
+
725
+ ## 4. Conformance test suite
726
+
727
+ One spec file, parameterized by `Gateway` implementation, using the existing `node:test`
728
+ harness (`tsx --test`, see `package.json`). Required cases:
729
+
730
+ - cursor monotonicity (global, exclusive, never reused)
731
+ - send echo (own send reappears via subscribe + history with `is_from_me: true`)
732
+ - history returns both sides, **hides reaction rows**, and carries `reactions[]` aggregates
733
+ - reaction toggle round-trip via the fixture driver (`injectReaction` → event
734
+ `is_reaction_add: true` → inject same again → `is_reaction_add: false`)
735
+ - own `react()` toggle-guard: second `react()` of the same type returns
736
+ `{ skipped: 'already-reacted' }`, no removal event
737
+ - stale-target skip: `react(chat, r, expectedGuid)` after a newer incoming message arrived
738
+ returns `{ skipped: 'stale-target' }`, no reaction applied
739
+ - react targets the most-recent INCOMING message only — our own newer outbound does not
740
+ change the target
741
+ - recentReactions surfaces user tapbacks on our messages only, newest-first
742
+ - group create → send-by-chat-id works immediately after
743
+ - catch-up after simulated downtime (events since cursor arrive, nothing before it re-fires)
744
+ - poll create lands as a balloon row with `poll` payload plus a plain caption row, both
745
+ visible in history (poll rows are never hidden)
746
+ - a vote arrives as a NEW row carrying the voter's current selections with resolved
747
+ `option_text`; a re-vote is another new row with the new full selection
748
+ - a user-added option re-carries the full option list, mislabeled `kind:"created"` with
749
+ `metadata.associated_message_type` 2 and junk display text
750
+ - poll fixtures refuse unverified real-path behavior (unknown option ids, empty selections)
751
+ - send failure surfaces as `{ ok: false }` (or rejection) without corrupting cursor state
752
+ - `resolveDmChat(handle)` returns an existing DM's `chat_id`, `null` for a handle with no
753
+ thread, and creates nothing on the miss (read-only — mirrors the real path reading chat.db)
754
+ - portable group resolution accepts only a complete unique GUID hit with exact normalized
755
+ membership; missing GUID and participant drift fail closed without creating a thread
756
+ - attachment metadata round-trips intact through history and subscribe; plain
757
+ messages omit `attachments`
758
+ - `sendStatus` returns pending/null fields for an unknown guid and sent-or-better
759
+ for guids emitted by `send`/`sendRich`, without inventing delivery/read evidence
760
+ - `checkHandle` reports known phone/email chat participants reachable, conservatively
761
+ reports a well-formed unknown unavailable, and rejects malformed input
762
+ - `tapback` targets an explicit message guid (older than the newest incoming), not just the
763
+ newest incoming; add/remove are explicit (repeat add skips `already-reacted`, remove of an
764
+ inactive reaction skips `not-reacted`, never a toggle); works in a group chat; `ok: false`
765
+ for an unknown message guid
766
+ - `emojiTapback` sends a custom emoji to an explicit guid, carries
767
+ `type:"custom"` + the exact emoji in history, and uses the same explicit
768
+ add/remove skip semantics; disabling its capability makes it return
769
+ `ok:false` without mutation
770
+ - `tapback`/`editMessage`/`unsendMessage`/`deleteMessage` all return `ok: false` — without
771
+ mutating anything — for a guid outside the 30-message scan window (the guid-targeting
772
+ window above), even though the message still exists in the fake's full store
773
+ - `sendRich` sends to an existing chat with an inline reply target, and returns `ok: false`
774
+ for a chat that doesn't exist (no find-or-create, unlike `send`)
775
+ - `sendRich` accepts a subject while preserving the observable text echo (subject is
776
+ write-only through `ImsgMessage`)
777
+ - `sendAttachment` and its `audio:true` mode echo an outbound row, preserve an optional
778
+ inline-reply guid, and return `ok:false` for an unknown chat; the synchronous
779
+ send response does not prove attachment/audio readback metadata
780
+ - `sendSticker` echoes an outbound row and guid for a known chat, returns
781
+ `ok:false` for an unknown chat, and fails closed when `stickerSend` is absent
782
+ - `sendPoll` emits the native balloon+caption two-row shape, returns the balloon guid, and
783
+ returns `ok:false` for fewer than two options or an unknown chat
784
+ - `sendRichLink` echoes an outbound row and returns `ok:false` for an unknown chat; preview
785
+ metadata is not queryable through `ImsgMessage`
786
+ - `editMessage` changes the target row's text in place — same row count before/after, no
787
+ new row
788
+ - `unsendMessage` retracts the target row (text clears, row persists);
789
+ `deleteMessage` is dispatch-only with no stable history postcondition (the
790
+ fake conservatively preserves the row); both return `ok: false` for an
791
+ unknown guid
792
+ - `setTyping`/`markRead` dispatch `{ ok: true }` against an existing chat and `{ ok: false }`
793
+ against an unknown one, with no queryable state either way
794
+ - `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
795
+ afterward (`chat_name`/`participants`) and return `ok: false` against a DM
796
+ - `setGroupPhoto`/`leaveGroup` return `ok: true` against a group and `ok: false` against a DM
797
+ - patched sticker, group-photo, and participant verbs return `ok:false` without mutation
798
+ when their `imsg status` capability markers are absent
799
+ - `recentReactions` surfaces an inbound custom-emoji reaction (`reaction: 'custom'`) with the
800
+ real `emoji` intact
801
+ - Fake-only restart coverage snapshots and restores a world, then proves
802
+ `sendStatus`, `checkHandle`, Name & Photo idempotency, current group metadata,
803
+ fixture state, and monotonic IDs survive exactly
804
+
805
+ FakeGateway runs this suite in CI on every PR. ImsgGateway runs it as a manual runbook step on
806
+ the host Mac (see `docs/host-mac-control.md`) until a host-Mac CI runner exists — at that point
807
+ it becomes CI-optional, not CI-required, since it depends on physical-Mac state (unlocked
808
+ session, TCC grants, an idle test phone).
809
+
810
+ **Rule for contributors**: any new capability added to FakeGateway requires, in the same PR:
811
+
812
+ 1. Proof the real path supports it — a manual test run against ImsgGateway on the host Mac,
813
+ pasted into the PR description.
814
+ 2. A conformance test added to this suite, passing against both implementations.
815
+ 3. An update to this doc (§1 interface and/or §2 invariants).
816
+
817
+ Skip any of the three and the capability is forbidden surface, full stop — delete it rather
818
+ than merge it half-specified.