@mapier/imsg-sdk 0.1.5 → 0.2.1

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,817 @@
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
+ Unlike `react()`/`createGroup()`, none of these drive Messages.app UI
147
+ 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 (sending the message is what creates the thread on the real path — see §2).
434
+ Returns the new `chatId`, located by **content**: the thread whose recent history contains
435
+ our `firstMessage`, not merely one matching the participant set (§2). Throws if the message
436
+ never appears — the compose can fail silently. No standalone "create empty group" or
437
+ "add participant" exists.
438
+ - **recentReactions(chatId, limit?)** — user tapbacks on OUR messages, newest-first; `limit`
439
+ bounds the returned notes (~10 default). Read from history's per-message `reactions[]`
440
+ aggregates (§2), so it survives process restarts. **Scan ceiling:** only the chat's most
441
+ recent 30 messages are scanned (the real path reads `history(chatId, 30)`) — a tapback on
442
+ an older message is invisible, and FakeGateway enforces the same window so features can't
443
+ be built against reach the real gateway doesn't have. Feeds "[reacted X to your message]"
444
+ acknowledgment notes into agent context.
445
+ - **resolveDmChat(handle)** — the numeric `chat_id` of the existing DM thread with `handle`, or
446
+ `null` if none exists. **Read-only** — it must never create a thread (the real path reads
447
+ `chat.db` via `chats.list` and cannot mint one; FakeGateway may not exceed that). The runtime
448
+ keeps an in-memory `handle → chat_id` cache warmed by inbound traffic; this recovers it on a
449
+ cold miss — a link completion replaying after a pod restart, before the user's next message,
450
+ would otherwise defer forever (imsg-agent's `handleLinkCompletion`). The real path derives
451
+ the DM row as the non-group chat whose sole `participants[]` entry is the handle (live-verified
452
+ 2026-07-08 against imsg 0.12.3, `scripts/chats-probe.ts`: 37/37 DM rows carried exactly one
453
+ participant = the peer handle; DM-vs-group is `!is_group`, never participant count). The method
454
+ itself passed its host-Mac smoke the same day — `scripts/resolve-dm-smoke.ts` (the manual rule-1
455
+ gate for this method; re-run it on imsg upgrades): known test-phone handle → its exact chat_id,
456
+ unknown handle → null with no thread composed, repeat resolve stable.
457
+ - **resolveGroupChat(request)** — read-only portable group resolution. It makes one high-ceiling
458
+ local `chats.list` read and uses each group row's portable `guid` and `participants` directly;
459
+ it does not open every group's message history. It accepts a result only when the
460
+ scan is complete, exactly one row has the requested GUID, and its normalized participant set
461
+ exactly equals `expectedParticipantExternalIds` after removing the explicitly declared pod/self
462
+ identities. Invalid input, an incomplete directory, no match, duplicate matches, an unreported
463
+ membership, and membership drift are distinct fail-closed outcomes. `guid` and `participants`
464
+ are optional on the imsg wire, so a group row without `guid` makes the directory incomplete;
465
+ an otherwise readable matching group without `participants` yields
466
+ `participant_directory_unavailable`, never `participant_mismatch`: reporting drift
467
+ about members that were never read would silently disable matching against a correct ops chat,
468
+ which is the exact "`imsg` lies" failure class this contract exists to catch. `FakeGateway` can
469
+ reach both degraded states through `setGroupDirectoryComplete` and
470
+ `setGroupParticipantsUnreported`, so neither branch is fake-unreachable. Live verification on
471
+ 2026-07-14 against imsg 0.12.3 read all 3,922 visible chats in about one second; all 96 group rows
472
+ reported a GUID and participants, and `scripts/resolve-group-smoke.ts` passed exact resolution,
473
+ stable repeat resolution, membership mismatch, and unknown-GUID rejection. That live run is
474
+ the verification of record; no separate evidence row exists. The returned numeric `chat_id` is an ephemeral
475
+ process-local execution target: it is never configuration, durable state, or connector payload.
476
+ `scripts/resolve-group-smoke.ts` is the read-only host-Mac rule-1 gate.
477
+ - **sendStatus(guid)** — read-only `message.send_status` lookup in chat.db; it
478
+ does not require the bridge. Missing rows normalize to `pending` with null
479
+ status fields. Recipient read-receipt settings legitimately leave
480
+ `date_read`/`date_delivered` null for many users; normalized delivery state is
481
+ reliable regardless. FakeGateway reports `sent` only for guids it emitted and
482
+ never invents delivered/read evidence; unknown guids are `pending` with null fields.
483
+ **Host-verified 2026-07-14:** a freshly-sent guid returned
484
+ `send_state:"delivered"` with `is_sent/is_delivered:true`, `date_delivered`
485
+ populated, and `date_read:null` (recipient read-receipts off) — the exact
486
+ `status_fields` shape the wrapper types model.
487
+
488
+ ## 2. Behavioral invariants FakeGateway must reproduce
489
+
490
+ This is the heart of the doc. Every item here is either load-bearing for agent logic today or
491
+ a hard ceiling on what iMessage can do — FakeGateway gets both right or it isn't a fake, it's
492
+ a different product.
493
+
494
+ - **Monotonic ids, global, never reused.** `id` mirrors `chat.db` rowid semantics: it strictly
495
+ increases across **all** chats, not per-chat. The cursor protocol (`id > cursor`, exclusive)
496
+ in imsg-agent (`handleInbound`) depends on this — `if (msg.id <= (await store.getCursor())) return;`. A
497
+ FakeGateway that resets ids per chat, or reuses one, breaks cursor advance silently.
498
+
499
+ - **Echo of own sends.** A successful `send()` later arrives back through the event stream
500
+ as a message with `is_from_me: true`, and shows up in `history()`. Cursor advance, history
501
+ hydration (imsg-agent's `toModelMessages`), and same-role message merging all
502
+ assume this echo happens. FakeGateway must emit it, not just record the send silently.
503
+
504
+ - **history() hides reaction ROWS but carries reaction STATE.** Verified live on imsg 0.12.0
505
+ (2026-07-02) and imsg 0.12.3 (2026-07-07): `messages.history` returns no `is_reaction` rows
506
+ (they'd duplicate the reacted
507
+ message), but every message carries a `reactions[]` aggregate — the current tapback state on
508
+ that bubble: `{ id, type, emoji?, is_from_me, sender?, created_at? }` per reaction.
509
+ Consequence: reaction-state logic (toggle guard, `recentReactions`) reads history — it is
510
+ authoritative and restart-safe. The subscribe stream (`include_reactions: true`) still
511
+ delivers reaction EVENTS for realtime wake-ups, but is never the source of state: a process
512
+ that just booted has seen no stream events yet.
513
+ Caveat: in `reactions[]`, trust `is_from_me`, not `sender` — the live binary fills `sender`
514
+ with the chat peer's handle even for our own reactions.
515
+ FakeGateway must reproduce both halves: filter reaction rows out of `history()` AND maintain
516
+ `reactions[]` on each message.
517
+ Text can be present in FakeGateway even where the real store's raw `text` column would be
518
+ `NULL` (real iMessage decodes `attributedBody` — see `docs/host-mac-control.md` §4).
519
+ FakeGateway just stores text plainly; only the outward guarantee that `text` is populated
520
+ matters.
521
+
522
+ - **Message event shape** (`ImsgMessage`, `src/types.ts`):
523
+ `id, chat_id, is_group, guid, sender, sender_name, is_from_me, text, created_at,
524
+ reactions[]`.
525
+ Reaction events additionally carry:
526
+ `is_reaction, reaction_type, reaction_emoji, is_reaction_add, reacted_to_guid`.
527
+ Fake↔live reaction-event shape verified against the 2026-07-03 live capture.
528
+ FakeGateway events must be structurally identical — same fields, same optionality.
529
+ When attachment metadata was requested, a message may additionally carry
530
+ `attachments[]`: `filename` plus optional `transfer_name`, `uti`, `mime_type`,
531
+ `total_bytes`, `is_sticker`, `missing`, and local absolute `original_path`.
532
+ Field names are the LIVE RPC wire shape (host-verified 2026-07-14 via
533
+ `messages.history attachments:true` against the injected 0.13.0 build) — the
534
+ fork's `docs/attachments.md` table lists `byte_size`/`path`, but the binary
535
+ emits `total_bytes`/`original_path`; the wire wins. The file is already on
536
+ the host Mac; no download verb exists. `missing:true` means Messages aged the
537
+ local file out. The fork omits `attachments` when none exist, and FakeGateway
538
+ does the same. **Host-verified 2026-07-14:** a `send-attachment` PNG and a
539
+ prior voice-note `.aiff` both read back through history with the full field
540
+ set populated.
541
+ Host paths are execution details and never cross a remoting wire.
542
+
543
+ - **`chat_guid` is populated on every message and history row** (live-verified 2026-07-02
544
+ against imsg 0.12.0 and re-verified 2026-07-07 against imsg 0.12.3): DMs are
545
+ `any;-;<handle>` (e.g. `any;-;+17738867338`), groups are
546
+ `any;+;<32-hex>`. It is the stable, portable thread identifier — `chat_id` is a local
547
+ chat.db ROWID and must never be persisted off-Mac (the Supabase store keys
548
+ `agent_message_log`/`agent_turns` on `chat_guid`, v0/design.md §4). FakeGateway synthesizes
549
+ the same formats per chat.
550
+
551
+ - **Portable group resolution never trusts a saved numeric rowid.** `chat_id` can change across
552
+ Mac state and never identifies a portable thread. Both gateways therefore resolve a group GUID
553
+ afresh, require a complete unique directory result, and compare exact normalized membership
554
+ independently from the set of handles authorized to act. A partial scan or participant mismatch
555
+ disables the dependent workflow; it never falls back to a saved numeric chat id.
556
+
557
+ - **A DM's chat_id is recoverable from the handle at any time, but only if the thread exists.**
558
+ `chats.list` (`src/imsg/rpc.ts`) is authoritative and restart-safe: the real gateway can map a
559
+ handle back to its `chat_id` whenever a DM thread is present, because chat.db persists the
560
+ peer handle as the row's sole `participants[]` entry (live-verified — see §1 `resolveDmChat`).
561
+ It cannot conjure a chat_id for a handle that has never messaged (no row exists yet). This is
562
+ why the runtime's cold-cache recovery is a *resolve-or-defer*, never a create: `resolveDmChat`
563
+ returns the id if the thread exists and `null` otherwise, and the completion stays unprocessed
564
+ (store-redelivered) until inbound traffic actually creates the DM. FakeGateway mirrors both
565
+ halves — its `resolveDmChat` finds an existing DM chat and returns `null` for an unknown handle,
566
+ never minting one (that would let the sim deliver where the real gateway defers).
567
+
568
+ - **Reactions toggle — and our own react() must therefore guard.** On the platform, reacting
569
+ with a type that already exists from the same sender on the target bubble **removes** it
570
+ (event with `is_reaction_add: false`) — it does not no-op or stack. Tapbacks are per-sender:
571
+ we can only ever toggle OUR OWN reactions; another party's tapback is untouchable. FakeGateway
572
+ must model toggle for *other parties* (fixture driver `injectReaction` simulates a user's
573
+ tapback, toggling on repeat). But the Gateway's own `react()` never un-reacts: it checks the
574
+ target's `reactions[]` in history and skips with `{ skipped: 'already-reacted' }` instead of
575
+ firing a removal (live incident 2026-07-01: an unguarded react removed an agent-account heart
576
+ placed manually on the host Mac). Toggle is platform truth; the guard is our contract on top
577
+ of it.
578
+
579
+ - **react() targets the most-recent INCOMING bubble in the chat, full stop.** The real path
580
+ (`src/imsg/react.ts`, `imsg react --chat-id <id> --reaction <r>`) takes no message id, and
581
+ per its own help text reacts to "the most recent incoming message" — our own outbound
582
+ bubbles are never the target. FakeGateway's `react()` signature must not accept a message id
583
+ either, and must apply the reaction to the chat's newest incoming message only. Building a
584
+ "react to message N" feature against a fake that supports arbitrary targeting produces a
585
+ feature the real gateway cannot deliver.
586
+ Reaction set is closed: exactly the 6 tapback types in `REACTIONS`
587
+ (`src/imsg/react.ts`) — `love, like, dislike, laugh, emphasis, question`. No arbitrary-emoji
588
+ sending.
589
+ **DM-only.** The real automation (`scripts/react.applescript`, ours — `imsg react` was
590
+ dropped for firing into the focused chat when its search-select race loses) verifies the
591
+ focused thread by comparing handle digits against the window title, which group titles
592
+ don't carry. `react()` on a group chat returns `{ ok: false }` on both implementations.
593
+
594
+ - **react() exit 0 lies — success must be verified.** Observed live (2026-07-02): one
595
+ `imsg react` run exited 0 having applied nothing anywhere; another applied the tapback in
596
+ the WRONG chat (the UI automation acts on whatever chat Messages.app has focused when chat
597
+ selection fails). ImsgGateway therefore polls the target chat's history after firing and
598
+ reports `ok: true` only once the reaction appears on the intended message's `reactions[]`;
599
+ otherwise `ok: false`. Callers must treat `ok: false` as "did not happen" — never assume a
600
+ fired react landed. This is also why the 20× react precision gate (issue #13) exists.
601
+ **Cmd+T can silently fail — and the fallout is a SENT TEXT, not a no-op.** Observed live
602
+ (2026-07-17, display asleep on the host): the tapback picker never opened, the reaction key
603
+ landed in the compose field, and the script's trailing Return sent a literal `"1"` to the
604
+ chat. `react.applescript` therefore guards after typing the reaction key: if the focused UI
605
+ element is a text input containing the key, it clears the field and aborts with an error
606
+ (surfacing as `{ ok: false }`) instead of pressing Return. A failed react must never mutate
607
+ the conversation.
608
+
609
+ - **Group semantics.**
610
+ - `is_group: true`, chat identified by a hex-string identifier for groups (per
611
+ `docs/host-mac-control.md` §4: "numeric chat_id per thread; hex identifier = group chat").
612
+ - Messages carry `participants[]` in group chats.
613
+ - Groups are created **only** via `createGroup` — sending the first message is what creates
614
+ the thread (real path: `scripts/create-group.applescript`, driving `Cmd+N` compose +
615
+ keystroke recipients + Return to send, ~7s). There is still no "create empty group" on
616
+ the AppleScript path. Adding/removing a member on an *existing* group is now possible —
617
+ see "Tier 2" above (`addParticipant`/`removeParticipant`, bridge-backed) — but only through
618
+ those methods, not through `createGroup`.
619
+ - **Compose guard**: if Cmd+N fails to enter compose (sibling of the react Cmd+T failure,
620
+ observed live 2026-07-17), every scripted keystroke would land in the focused chat's
621
+ compose field and each Return would send it — recipient handles texted into an open
622
+ thread. `create-group.applescript` verifies the window title changed after Cmd+N (chat
623
+ title → "Messages") and aborts before typing anything if it didn't.
624
+ - **Group create can fail silently** (observed live 2026-07-02: recipient flapped from
625
+ iMessage to SMS registration; compose exited 0, no thread created, no message sent —
626
+ SMS handles cannot join an iMessage group). And Messages may **reuse an existing thread**
627
+ with the same participant set instead of creating a new one. Both are why the new thread
628
+ is located by content (recent history contains `firstMessage`) with a timeout that throws,
629
+ never by participant-set match alone.
630
+ - DMs are keyed by handle: `send({ to: handle }, text)` finds-or-creates the DM thread.
631
+
632
+ - **Latency & ordering are not instant.** Real sends take ~1–3s (AppleScript round trip);
633
+ real events arrive with debounce (~500ms) and can batch after a downtime gap (catch-up from
634
+ cursor on reconnect). FakeGateway should support a configurable artificial latency and a
635
+ catch-up/backlog mode so mailbox debounce and per-chat serialization
636
+ (imsg-agent's `Mailbox`) get exercised the way they will in production, not just against
637
+ an instant no-op fake.
638
+
639
+ - **Failure modes to simulate**, all of which the agent must survive without corrupting state:
640
+ - send failure (network/imsg error) — `send()` resolves `{ ok: false }` or rejects.
641
+ - react failure (UI-automation flake, nonzero exit from `imsg react`).
642
+ - gateway process death mid-stream — on reconnect, the agent must resume from its stored
643
+ cursor without re-processing or double-replying to anything at or before that cursor
644
+ (imsg-agent's `handleInbound` + stored cursor).
645
+
646
+ - **Native poll readback (imsg 0.12.3, live-verified 2026-07-07, full capture
647
+ in `docs/imsg-polls.md`).** Poll activity arrives as regular message rows with a `poll`
648
+ payload (`MessagePoll`, `src/types.ts`) on BOTH subscribe and history — unlike reaction
649
+ rows, history does NOT hide them, so poll state is restart-safe. Three row shapes:
650
+ - **Creation is TWO rows**: a balloon row (`poll.kind: "created"`,
651
+ `metadata.associated_message_type` 3 — observed 0 transiently right after send, Messages
652
+ restamps it, so classification must never key on the balloon's value — question +
653
+ options, imsg-synthesized display text
654
+ `"Sent a poll"`) followed by a plain caption row carrying the question with
655
+ `reply_to_guid` = the balloon guid — Messages never renders the poll title on the
656
+ balloon, so imsg always sends the caption.
657
+ - **A vote is a NEW row** (`metadata.associated_message_type` 4000, `poll.kind: "vote"`):
658
+ `poll.votes[]` is that participant's CURRENT selections (state, not an event log — a
659
+ re-vote is a new row with the new full selection), `option_text` pre-resolved by imsg,
660
+ `original_guid` = the balloon guid. Display text is a junk single space.
661
+ - **An added option ("Add Choice") is a NEW row** (`metadata.associated_message_type` 2)
662
+ re-carrying the FULL updated option list — but imsg mislabels it `poll.kind: "created"`
663
+ and its `poll.question` is unreliable. Display text is a junk U+FFFD. Consumers must
664
+ classify rows via `pollEventKind` and resolve the question through `original_guid` → the
665
+ balloon row (`src/polls.ts`) — never trust `kind`/`question` on non-balloon rows.
666
+ The Gateway creates polls through `sendPoll`; the manual host-Mac CLI equivalent is
667
+ `imsg poll send` (bridge-injected — `docs/imsg-polls.md`). FakeGateway reuses
668
+ `injectPollCreate` for the same balloon+caption postcondition. Vote and option-update
669
+ fixture drivers (`injectPollVote` / `injectPollAddOption`) reproduce the remaining shapes
670
+ above including the junk display texts and the kind mislabel; there is still no Gateway
671
+ poll vote/unvote surface. Open/unverified: the wire shape of a bare vote retraction (the
672
+ fake refuses empty selections until it's observed live) and multi-option selections.
673
+
674
+ - **Forbidden surface — none of this exists on the real path, so FakeGateway must not offer
675
+ it:**
676
+ - voting or unvoting on native polls through the Gateway (`sendPoll` creates polls only)
677
+ - sending an arbitrary/custom-emoji tapback through stock `tapback` — that
678
+ verb deliberately stays on the closed 6-type `Reaction` set. The separate
679
+ `emojiTapback` verb exists only when the patched bridge advertises its
680
+ capability marker
681
+ - outbound threaded replies through plain `send()` — inline replies exist only via
682
+ `sendRich`'s `replyToGuid` (Tier 2); `send()` has no reply parameter
683
+
684
+ Moved out of this list once Tier 2 landed (see "Tier 2" above for what each one actually
685
+ does and does not add): typing indicators, read receipts, editing/unsending/deleting
686
+ messages, reacting to an arbitrary (non-most-recent) message, and participant management
687
+ on an existing group (add/remove/rename/photo/leave).
688
+
689
+ - **Inbound media.** Real messages can carry `attachments[]` with empty `text`. FakeGateway must
690
+ be able to emit the exact attachment metadata with `text: undefined`/empty so
691
+ this path gets exercised — the agent currently ignores attachments gracefully
692
+ (imsg-agent's `toModelMessages`, `if (!m.text) continue;`), and that "gracefully" needs a fixture to
693
+ keep being true.
694
+
695
+ ## 3. FakeGateway initial design
696
+
697
+ - **Backing store**: `sim_messages` table (Supabase local/dev) or in-memory for unit tests.
698
+ Auto-increment `id` is the rowid analog and must satisfy the global-monotonic invariant in
699
+ §2. `sim_chats` holds `is_group` / `participants`. Reactions are stored as message-shaped
700
+ rows with an association back to the target (mirroring `chat.db`'s
701
+ `associated_message_type` / `associated_message_guid` shape described in
702
+ `docs/host-mac-control.md` §4), not as a separate table — but `history()` must **filter
703
+ them out** while maintaining each message's `reactions[]` aggregate, mirroring the real
704
+ path (§2); reaction ROWS reach the agent only through the subscribe stream, reaction STATE
705
+ through history.
706
+ - **Drivers**:
707
+ 1. Programmatic API for eval fixtures — inject an inbound message, reaction, or poll
708
+ event (`injectInbound` / `injectReaction` / `injectPollCreate` / `injectPollVote` /
709
+ `injectPollAddOption`), assert on outbound sends/reacts.
710
+ 2. The simulator web UI (multi-fake-user iMessage-style chat) reads/writes the same store,
711
+ so a human can drive the same fixtures the eval harness does.
712
+ - **Determinism knobs**: `latency: 0` + no debounce batching for unit tests (fast, deterministic);
713
+ realistic timings for integration runs that need to exercise the mailbox debounce/serialize
714
+ path for real.
715
+ - **Hosted-Dev persistence**: `snapshot()` returns a versioned
716
+ `FakeGatewaySnapshot`; `devRestoreSnapshot()` accepts that exact contract.
717
+ The snapshot includes chats and current group names, messages, sent GUIDs,
718
+ reachable-address evidence, Name & Photo sharing state, and the degraded
719
+ group-directory fixture state. Restoring it must preserve `sendStatus()`,
720
+ `checkHandle()`, Name & Photo idempotency, current group metadata, and both
721
+ global monotonic counters. This driver-only API is not a `Gateway`
722
+ capability and has no real-gateway counterpart.
723
+
724
+ ## 4. Conformance test suite
725
+
726
+ One spec file, parameterized by `Gateway` implementation, using the existing `node:test`
727
+ harness (`tsx --test`, see `package.json`). Required cases:
728
+
729
+ - cursor monotonicity (global, exclusive, never reused)
730
+ - send echo (own send reappears via subscribe + history with `is_from_me: true`)
731
+ - history returns both sides, **hides reaction rows**, and carries `reactions[]` aggregates
732
+ - reaction toggle round-trip via the fixture driver (`injectReaction` → event
733
+ `is_reaction_add: true` → inject same again → `is_reaction_add: false`)
734
+ - own `react()` toggle-guard: second `react()` of the same type returns
735
+ `{ skipped: 'already-reacted' }`, no removal event
736
+ - stale-target skip: `react(chat, r, expectedGuid)` after a newer incoming message arrived
737
+ returns `{ skipped: 'stale-target' }`, no reaction applied
738
+ - react targets the most-recent INCOMING message only — our own newer outbound does not
739
+ change the target
740
+ - recentReactions surfaces user tapbacks on our messages only, newest-first
741
+ - group create → send-by-chat-id works immediately after
742
+ - catch-up after simulated downtime (events since cursor arrive, nothing before it re-fires)
743
+ - poll create lands as a balloon row with `poll` payload plus a plain caption row, both
744
+ visible in history (poll rows are never hidden)
745
+ - a vote arrives as a NEW row carrying the voter's current selections with resolved
746
+ `option_text`; a re-vote is another new row with the new full selection
747
+ - a user-added option re-carries the full option list, mislabeled `kind:"created"` with
748
+ `metadata.associated_message_type` 2 and junk display text
749
+ - poll fixtures refuse unverified real-path behavior (unknown option ids, empty selections)
750
+ - send failure surfaces as `{ ok: false }` (or rejection) without corrupting cursor state
751
+ - `resolveDmChat(handle)` returns an existing DM's `chat_id`, `null` for a handle with no
752
+ thread, and creates nothing on the miss (read-only — mirrors the real path reading chat.db)
753
+ - portable group resolution accepts only a complete unique GUID hit with exact normalized
754
+ membership; missing GUID and participant drift fail closed without creating a thread
755
+ - attachment metadata round-trips intact through history and subscribe; plain
756
+ messages omit `attachments`
757
+ - `sendStatus` returns pending/null fields for an unknown guid and sent-or-better
758
+ for guids emitted by `send`/`sendRich`, without inventing delivery/read evidence
759
+ - `checkHandle` reports known phone/email chat participants reachable, conservatively
760
+ reports a well-formed unknown unavailable, and rejects malformed input
761
+ - `tapback` targets an explicit message guid (older than the newest incoming), not just the
762
+ newest incoming; add/remove are explicit (repeat add skips `already-reacted`, remove of an
763
+ inactive reaction skips `not-reacted`, never a toggle); works in a group chat; `ok: false`
764
+ for an unknown message guid
765
+ - `emojiTapback` sends a custom emoji to an explicit guid, carries
766
+ `type:"custom"` + the exact emoji in history, and uses the same explicit
767
+ add/remove skip semantics; disabling its capability makes it return
768
+ `ok:false` without mutation
769
+ - `tapback`/`editMessage`/`unsendMessage`/`deleteMessage` all return `ok: false` — without
770
+ mutating anything — for a guid outside the 30-message scan window (the guid-targeting
771
+ window above), even though the message still exists in the fake's full store
772
+ - `sendRich` sends to an existing chat with an inline reply target, and returns `ok: false`
773
+ for a chat that doesn't exist (no find-or-create, unlike `send`)
774
+ - `sendRich` accepts a subject while preserving the observable text echo (subject is
775
+ write-only through `ImsgMessage`)
776
+ - `sendAttachment` and its `audio:true` mode echo an outbound row, preserve an optional
777
+ inline-reply guid, and return `ok:false` for an unknown chat; the synchronous
778
+ send response does not prove attachment/audio readback metadata
779
+ - `sendSticker` echoes an outbound row and guid for a known chat, returns
780
+ `ok:false` for an unknown chat, and fails closed when `stickerSend` is absent
781
+ - `sendPoll` emits the native balloon+caption two-row shape, returns the balloon guid, and
782
+ returns `ok:false` for fewer than two options or an unknown chat
783
+ - `sendRichLink` echoes an outbound row and returns `ok:false` for an unknown chat; preview
784
+ metadata is not queryable through `ImsgMessage`
785
+ - `editMessage` changes the target row's text in place — same row count before/after, no
786
+ new row
787
+ - `unsendMessage` retracts the target row (text clears, row persists);
788
+ `deleteMessage` is dispatch-only with no stable history postcondition (the
789
+ fake conservatively preserves the row); both return `ok: false` for an
790
+ unknown guid
791
+ - `setTyping`/`markRead` dispatch `{ ok: true }` against an existing chat and `{ ok: false }`
792
+ against an unknown one, with no queryable state either way
793
+ - `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
794
+ afterward (`chat_name`/`participants`) and return `ok: false` against a DM
795
+ - `setGroupPhoto`/`leaveGroup` return `ok: true` against a group and `ok: false` against a DM
796
+ - patched sticker, group-photo, and participant verbs return `ok:false` without mutation
797
+ when their `imsg status` capability markers are absent
798
+ - `recentReactions` surfaces an inbound custom-emoji reaction (`reaction: 'custom'`) with the
799
+ real `emoji` intact
800
+ - Fake-only restart coverage snapshots and restores a world, then proves
801
+ `sendStatus`, `checkHandle`, Name & Photo idempotency, current group metadata,
802
+ fixture state, and monotonic IDs survive exactly
803
+
804
+ FakeGateway runs this suite in CI on every PR. ImsgGateway runs it as a manual runbook step on
805
+ the host Mac (see `docs/host-mac-control.md`) until a host-Mac CI runner exists — at that point
806
+ it becomes CI-optional, not CI-required, since it depends on physical-Mac state (unlocked
807
+ session, TCC grants, an idle test phone).
808
+
809
+ **Rule for contributors**: any new capability added to FakeGateway requires, in the same PR:
810
+
811
+ 1. Proof the real path supports it — a manual test run against ImsgGateway on the host Mac,
812
+ pasted into the PR description.
813
+ 2. A conformance test added to this suite, passing against both implementations.
814
+ 3. An update to this doc (§1 interface and/or §2 invariants).
815
+
816
+ Skip any of the three and the capability is forbidden surface, full stop — delete it rather
817
+ than merge it half-specified.