@mapier/imsg-sdk 0.5.0 → 0.7.0

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.

Potentially problematic release.


This version of @mapier/imsg-sdk might be problematic. Click here for more details.

@@ -53,6 +53,7 @@ claim about send, reaction, or group mutation behavior.
53
53
  interface Gateway {
54
54
  readonly capabilities: {
55
55
  emojiTapback: boolean;
56
+ tapbackPart: boolean;
56
57
  stickerSend: boolean;
57
58
  groupPhoto: boolean;
58
59
  groupParticipants: boolean;
@@ -63,6 +64,9 @@ interface Gateway {
63
64
  mentionFormatting: boolean;
64
65
  sharedLocations: boolean;
65
66
  locationRequest: boolean;
67
+ extensionCardSend: boolean;
68
+ extensionCardUpdate: boolean;
69
+ multipartSend: boolean;
66
70
  };
67
71
  subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
68
72
  history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
@@ -78,8 +82,20 @@ interface Gateway {
78
82
  checkHandle(address: string, opts?: { aliasType?: 'phone' | 'email' }): Promise<HandleCheck>;
79
83
 
80
84
  // Tier 2 (bridge-backed — imsg launch required). See "Tier 2" below.
81
- tapback(chatId: number, targetGuid: string, reaction: Reaction, remove?: boolean): Promise<ReactResult>;
82
- emojiTapback(chatId: number, targetGuid: string, emoji: string, remove?: boolean): Promise<ReactResult>;
85
+ tapback(
86
+ chatId: number,
87
+ targetGuid: string,
88
+ reaction: Reaction,
89
+ remove?: boolean,
90
+ partIndex?: number,
91
+ ): Promise<ReactResult>;
92
+ emojiTapback(
93
+ chatId: number,
94
+ targetGuid: string,
95
+ emoji: string,
96
+ remove?: boolean,
97
+ partIndex?: number,
98
+ ): Promise<ReactResult>;
83
99
  sendRich(
84
100
  chatId: number,
85
101
  text: string,
@@ -97,13 +113,23 @@ interface Gateway {
97
113
  ): Promise<SendResult>;
98
114
  sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
99
115
  sendRichLink(chatId: number, url: string): Promise<SendResult>;
116
+ sendExtensionCard(
117
+ chatId: number,
118
+ card: ExtensionCard,
119
+ opts?: { update?: ExtensionCardHandle },
120
+ ): Promise<ExtensionCardResult>;
121
+ sendMultipart(
122
+ chatId: number,
123
+ parts: readonly MultipartPart[],
124
+ opts?: { replyToGuid?: string; effect?: string; subject?: string },
125
+ ): Promise<MultipartResult>;
100
126
  editMessage(chatId: number, targetGuid: string, text: string): Promise<{
101
127
  ok: boolean;
102
128
  skipped?: 'unchanged';
103
129
  effectStarted?: boolean;
104
130
  }>;
105
- unsendMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
106
- deleteMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
131
+ unsendMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
132
+ deleteMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
107
133
  setTyping(chatId: number, on: boolean): Promise<{ ok: boolean }>;
108
134
  markRead(chatId: number): Promise<{ ok: boolean }>;
109
135
  renameGroup(chatId: number, name: string): Promise<GroupMutationResult>;
@@ -201,7 +227,66 @@ interface GroupMutationResult {
201
227
  | 'refused' // proven rejection before IMChat — effectStarted:false
202
228
  | 'fire-failed' // the call errored, may have applied — effectStarted:true
203
229
  | 'unverified' // fired, never confirmed in the window — effectStarted:true
204
- | 'verify-read-failed'; // fired, the verification read broke — effectStarted:true
230
+ | 'verify-read-failed' // fired, the verification read broke — effectStarted:true
231
+ | 'preflight-read-failed'; // a PREflight read broke, nothing fired — effectStarted:false
232
+ }
233
+
234
+ // unsendMessage / deleteMessage (M2B-43). Same shape, same shared reason
235
+ // table: a success is bare, a failure always names where certainty was lost.
236
+ interface MessageRemovalResult {
237
+ ok: boolean;
238
+ effectStarted?: boolean;
239
+ reason?: GroupMutationFailure;
240
+ }
241
+
242
+ // sendExtensionCard (M2B-46). A card belonging to a third-party iMessage app
243
+ // extension; the balloon identifier is composed from teamId + extensionBundleId
244
+ // on the native side and never accepted whole.
245
+ interface ExtensionCard {
246
+ teamId: string; // exactly 10 characters of A-Z0-9
247
+ extensionBundleId: string; // the MESSAGES EXTENSION's id, reverse-DNS, no ':'
248
+ appName: string;
249
+ appStoreId?: number; // positive integer; without it an unmatched card leads nowhere
250
+ caption: string;
251
+ subcaption?: string;
252
+ summaryText?: string; // the line left behind once an update replaces this card
253
+ url: string; // the app's own state; any scheme, real cards use data:
254
+ liveLayout?: boolean; // default true — see the verb entry for what each value shows
255
+ }
256
+
257
+ // The ONLY update target this SDK accepts, and the reason it is a pair rather
258
+ // than a guid: every update must name the session's FIRST card.
259
+ interface ExtensionCardHandle {
260
+ sessionId: string;
261
+ firstCardMessageGuid: string;
262
+ }
263
+
264
+ interface ExtensionCardResult {
265
+ ok: boolean;
266
+ sessionId?: string;
267
+ messageGuid?: string; // THIS row (the card, or the update row) — not the update target
268
+ handle?: ExtensionCardHandle; // what a later update passes back as opts.update; present exactly when `updatable`
269
+ updatable?: boolean; // always present on ok:true
270
+ balloonBundleId?: string;
271
+ liveLayout?: boolean;
272
+ effectStarted?: boolean;
273
+ reason?: GroupMutationFailure; // only unsupported / refused / fire-failed are reachable
274
+ }
275
+
276
+ // sendMultipart (M2B-55). One message made of several parts, in the order
277
+ // given — that order is each part's NUMBER, the same number tapback's
278
+ // `partIndex` names. Audio is not a part: the voice flag is message-wide, so a
279
+ // voice note still goes alone through sendAttachment.
280
+ type MultipartPart =
281
+ | { kind: 'text'; text: string; textFormatting?: TextFormatRange[] } // the same ranges sendRich takes
282
+ | { kind: 'file'; filePath: string }; // a path on the HOST Mac
283
+
284
+ interface MultipartResult {
285
+ ok: boolean;
286
+ guid?: string; // absent when Messages had not exposed the row yet; never synthesized
287
+ partCount?: number; // the host's own count of the parts it built
288
+ effectStarted?: boolean;
289
+ reason?: GroupMutationFailure; // only unsupported / refused / fire-failed are reachable
205
290
  }
206
291
  ```
207
292
 
@@ -238,6 +323,15 @@ confirmed-mention attribute key resolves at runtime (imsg#13); emoji
238
323
  additionally requires the selected RPC binary
239
324
  to report `rpc_features:["tapback.emoji"]`. That second half prevents a
240
325
  patched dylib behind a stock CLI from false-advertising custom emoji support.
326
+ Part-level tapbacks require `tapbackPart`, which the helper computes from the
327
+ part's own chat item plus an associated-message initializer taking a range —
328
+ and which no other tapback marker may stand in for, because a helper without it
329
+ does not reject `part_index`, it writes a corrupt row (M2B-45, above).
330
+ Multipart sends require `multipartSend`, composed by the helper from the
331
+ file-transfer centre plus the attachment IMMessage initializer, and equally
332
+ un-substitutable: a helper predating file parts reads only each part's `text`,
333
+ so it DROPS every file part without a word and answers ok with a short
334
+ `parts_count` (M2B-55, below).
241
335
  Missing status, malformed JSON, or absent markers fail closed.
242
336
  `FakeGateway.capabilities` mirrors this gate and can disable the same surface
243
337
  for parity tests. A remote adapter (e.g. imsg-agent's connector) must gate on
@@ -250,11 +344,17 @@ guid by scanning `history(chatId, 30)` — the same 30-message ceiling
250
344
  `recentReactions` already documents above — so a guid older than that is as
251
345
  invisible to these methods as it is to `recentReactions`. `tapback` and
252
346
  `deleteMessage` pre-check against that scan before firing, so they return
253
- `{ ok: false }` without touching anything, and so does `editMessage` (it also
254
- refuses a message that is not `is_from_me`); `unsendMessage` fires the RPC
347
+ `ok: false` without touching anything (`tapback`/`emojiTapback`/`deleteMessage`
348
+ say so explicitly: `effectStarted:false, reason:'refused'`), and so does
349
+ `editMessage`
350
+ (it also refuses a message that is not `is_from_me`); `unsendMessage` fires the RPC
255
351
  first and only finds the ceiling on the VERIFY pass, so a guid outside the
256
- window still reports `{ ok: false }` but may have mutated the real row on the
257
- way there — "no confirmable effect," not a guaranteed no-op. FakeGateway
352
+ window reports `ok: false, effectStarted: true, reason: 'unverified'` and may
353
+ have mutated the real row on the way there — "no confirmable effect," not a
354
+ guaranteed no-op. Until M2B-43 that case returned `{ ok: true }`: the verify
355
+ predicate read an ABSENT row as a cleared one, so an out-of-window (or
356
+ merely lagging) guid reported a retraction nobody had observed. Unsend leaves
357
+ a tombstone row, so absence is missing evidence, never success. FakeGateway
258
358
  must enforce the identical window regardless: finding the target guid by
259
359
  scanning ALL of `this.messages` unconditionally would let the fake succeed
260
360
  at targeting a message the real path can't reach (parity rule 2) — a
@@ -262,7 +362,7 @@ guid-targeted verb "working" in the fake against a message 40 rows back but
262
362
  refusing on the real path is exactly the trap this contract exists to
263
363
  prevent.
264
364
 
265
- - **`tapback(chatId, targetGuid, reaction, remove?)`** — a second, bridge-based
365
+ - **`tapback(chatId, targetGuid, reaction, remove?, partIndex?)`** — a second, bridge-based
266
366
  tapback path alongside `react()`. What it adds: targets **any** message guid
267
367
  in the chat, not just the newest incoming bubble; works in **group** chats
268
368
  (the bridge has no focused-window check to defeat, unlike the AppleScript
@@ -276,13 +376,88 @@ prevent.
276
376
  deliberate: "fix: reject unsupported custom emoji reaction sends instead of
277
377
  taking a no-op AppleScript path" (#55). So `reaction` stays the closed
278
378
  `Reaction` type here too.
279
- - **`emojiTapback(chatId, targetGuid, emoji, remove?)`** — Mapier-fork extension
379
+ - **`emojiTapback(chatId, targetGuid, emoji, remove?, partIndex?)`** — Mapier-fork extension
280
380
  backed by `IMEmojiTapback` + `IMTapbackSender`. It targets any guid in the
281
381
  same 30-message scan window as `tapback`, uses explicit add/remove guards,
282
382
  and verifies history for `type:"custom"` plus the exact emoji before
283
383
  returning success. The RPC sends `emoji` instead of `kind`; sending both
284
384
  would fall back into stock imsg's classic-6 normalizer. Live-verified on the
285
385
  host Mac with 👻 and 💀; chat.db records `associated_message_type=2006`.
386
+
387
+ **A tapback failure says which failure it is** (M2B-40). `tapback` and
388
+ `emojiTapback` are the only reaction path a consumer has left once
389
+ `reaction.apply` lost its minters (M2B-37), and they used to answer a bare
390
+ `ok:false` from four different positions. On `ok:false` they now carry the
391
+ same `reason` / `effectStarted` pair the group verbs do (`GroupMutationFailure`,
392
+ one shared table, so a reason cannot mean two things):
393
+
394
+ - **`unsupported`** (`effectStarted:false`) — `emojiTapback` without the
395
+ `emojiTapback` capability marker.
396
+ - **`refused`** (`effectStarted:false`) — the guid is not in the 30-message
397
+ scan window, the emoji is empty, or the helper rejected the call before its
398
+ send: a `-32602`, or a `-32603` whose `error.data` carries one of
399
+ `TAPBACK_PROVEN_REJECTIONS` (`gateway/tapback.ts`, read off
400
+ `handleSendReaction`'s returns above `[sender send]` / `[chat sendMessage:]`).
401
+ - **`fire-failed`** (`effectStarted:true`) — the call errored without proving
402
+ the helper stopped short. That deliberately includes `send-reaction failed:
403
+ …`: it wraps an NSException caught around the whole build-and-send block, so
404
+ the text cannot say which side of the send raised it.
405
+ - **`unverified`** (`effectStarted:true`) — fired cleanly, and six 1 s history
406
+ reads never showed the wanted state.
407
+ - **`verify-read-failed`** (`effectStarted:true`) — fired cleanly, then a
408
+ verification read threw. This used to escape as an exception.
409
+
410
+ `react()` is unchanged and still answers a bare `ok:false`; an absent
411
+ `effectStarted` must keep being treated as ambiguous.
412
+
413
+ **A tapback lands on ONE part of a message** (M2B-45). A message carrying two
414
+ photos has parts 0 and 1, so `partIndex: 1` reacts to the second photo alone.
415
+ Omitted or `0` is what both verbs always did, and the RPC for it is
416
+ unchanged — `part_index` is not sent at all, so a host that has never heard
417
+ of parts sees the same request it always did. Both verbs, add and remove,
418
+ classic and emoji, behave identically here.
419
+
420
+ - **Capability-gated on `tapbackPart`, and a part >= 1 without it is
421
+ `unsupported` (`effectStarted:false`), never a quiet part 0.** This marker
422
+ guards a SILENT corruption rather than a rejection, which is why nothing
423
+ else may stand in for it: an older helper *accepts* `part_index` and writes
424
+ a row whose `p:N/<guid>` prefix disagrees with its range, so the recipient's
425
+ phone draws the reaction on **every** photo while Messages discards the
426
+ sender's own row — unverifiable and unremovable. Production Macs run such a
427
+ helper. Downgrading to part 0 instead would react to the wrong photo and
428
+ report success, so the SDK refuses before building the RPC.
429
+ - **`partIndex` must be a non-negative integer**, checked client-side
430
+ (`tapbackPartRefusal`, `gateway/tapback.ts`): `1.5`, `-1` and `NaN` are
431
+ `refused` with nothing sent, matching the native's `invalid params`. A part
432
+ that cannot be read is never taken as 0.
433
+ - **A part the message does not have is refused before anything is sent** —
434
+ the helper counts the parts and returns `Message <guid> has no part <n>`,
435
+ which `TAPBACK_PROVEN_REJECTIONS` matches as `refused`
436
+ (`effectStarted:false`). FakeGateway enforces the same rule from its own
437
+ part count: the number of attachments, minimum 1. That model is
438
+ deliberately conservative — a text-plus-photos message really has more
439
+ parts than the fake counts, so the fake refuses parts the host would accept
440
+ and never the reverse (parity rule 2).
441
+ - **The guards and the verify compare the PART, not just the reaction.** One
442
+ sender can hold the same reaction type on part 0 and part 1 as two separate
443
+ reactions; they used to collapse into one. So reacting to photo 2 while
444
+ photo 1 already carries that reaction fires normally instead of answering
445
+ `already-reacted`, a remove on part 1 leaves part 0's alone, and the
446
+ verify-after-fire pass looks for the reaction on the part that was asked
447
+ for.
448
+ - **`react()` is deliberately left part-BLIND.** It drives AppleScript, which
449
+ has no part-level form, and its tapback is a toggle whose guard exists to
450
+ stop a second react from switching the first one off. Whether that toggle
451
+ would reach a reaction sitting on part 1 is Messages.app behaviour nobody
452
+ has verified on a host, so its guard keeps matching any part exactly as it
453
+ always did and declines instead. Only the bridge verbs compare parts.
454
+ - **Reading back:** a reaction that named a part carries `target_part` on the
455
+ `reactions[]` aggregate and `reaction_target_part` on a reaction event row;
456
+ `recentReactions` surfaces it as `ReactionNote.targetPart`. All three are
457
+ **absent** when the reaction named the whole message, so every comparison
458
+ goes through `reactionPart()` / `reactionOnPart()` (exported), which read
459
+ absent as 0. `reacted_to_guid` stays the bare message guid — the part never
460
+ appears in it.
286
461
  - **`sendRich(chatId, text, opts?)`** — real and confirmed present on the
287
462
  host's selected patched imsg 0.13.x (`send.rich`, `RPCServer+BridgeMessageHandlers.swift`
288
463
  `handleSendRich`; shipped well before 0.12.3 per CHANGELOG). Targets an
@@ -380,6 +555,131 @@ prevent.
380
555
  guid** (unlike text/poll/attachment, which return a guid) — the URL-preview
381
556
  balloon lands as its own row later. `SendResult.guid` is therefore always
382
557
  undefined here; FakeGateway must not return one either.
558
+ - **`sendExtensionCard(chatId, card, opts?)`** — `extension_card.send`
559
+ (`RPCServer+ExtensionCardHandler.swift`, `handleSendExtensionCard`), the kind
560
+ of balloon a third-party iMessage app extension sends. Targets an
561
+ **existing** chat only. The balloon identifier is composed on the native side
562
+ from `teamId` + `extensionBundleId` and is never accepted whole, so a caller
563
+ cannot claim an arbitrary app's balloon. Two capability markers gate it, read
564
+ from `imsg status --json` like every other patched verb: `extensionCardSend`
565
+ (the payload initializer polls also use) and `extensionCardUpdate` (that plus
566
+ the associated-message initializer). **Neither implies the other** — a host
567
+ can send cards and be unable to replace one — so a `sendExtensionCard` with
568
+ an `update` needs both and is `unsupported` without the second.
569
+
570
+ **What the recipient sees** (all three observed on a real phone, 2026-09-20):
571
+ with `liveLayout` true (the default) and the app installed, the installed
572
+ extension draws the card itself and the `caption` is never seen; with
573
+ `liveLayout:false` and the app installed, the app's icon beside *this card's*
574
+ caption and subcaption — the mode to use when the text is the point; without
575
+ the app, a small static card that links to the App Store page only when
576
+ `appStoreId` is given. **Cards never carry a picture.**
577
+
578
+ **The first-card rule, which is what shapes this API.** An update must always
579
+ name the session's **FIRST** card — the same `updates_message_guid` for every
580
+ update in the session, including the fifth, plus that session's id. It is not
581
+ a chain. Aiming an update at a *previous update's* guid does not replace
582
+ anything: that card arrives as a separate new card and stays in the thread as
583
+ an orphan, and it looks exactly like success (`ok:true`, a real guid,
584
+ delivered). **Nothing on the Mac can catch this**, because it cannot know
585
+ which guid began a session. So this verb does not take a guid at all: a first
586
+ send returns an `ExtensionCardHandle` as `result.handle` (`{sessionId,
587
+ firstCardMessageGuid}`); every update passes it back **unchanged** as
588
+ `opts.update` and gets the *same* handle out, never one built from its own
589
+ row. A caller doing `handle = result.handle` after each update therefore
590
+ stays correct by construction. A
591
+ handle assembled by hand out of an update's `messageGuid` is still wrong and
592
+ still cannot be detected — by the SDK or by the host — which is why
593
+ `ExtensionCardHandle` is documented as opaque.
594
+
595
+ **`updatable` answers the only question on a success.** `guid` is omitted by
596
+ the native handler when Messages has not exposed the row's guid yet, and
597
+ `ok:true` either way. Rather than invent one, `updatable:false` (with no
598
+ `update`) says plainly that this card can never be updated — send a fresh one
599
+ instead. On `ok:true`, `messageGuid` is *this* row: the card on a first send,
600
+ the associated update row on an update.
601
+
602
+ **Validation happens before the RPC**, mirroring
603
+ `ExtensionCardRequest.swift`: team id shape, reverse-DNS extension bundle id
604
+ without `':'`, required `appName`/`caption`/`url`, positive integer
605
+ `appStoreId`, strict boolean `liveLayout`, a 4096 **UTF-16 unit** cap on
606
+ every string, and both halves of an update handle present and UUID-shaped.
607
+ One deliberate divergence: `url` must be **absolute** here, while the helper
608
+ accepts anything `URL(string:)` parses — a card's URL is the extension's own
609
+ state (`data:` or `https:` in practice), never a relative path. Every one of
610
+ these is a proven no-op (`effectStarted:false`, `reason:'refused'`).
611
+
612
+ **No verification pass exists.** `imsg history` selects `payload_data` as
613
+ NULL for bundle ids it does not decode, so a card reads back with an empty
614
+ payload and there is nothing to confirm against; this verb therefore never
615
+ mints `unverified`, `verify-read-failed` or `preflight-read-failed`, and only
616
+ `unsupported` / `refused` / `fire-failed` are reachable. A caller that needs
617
+ delivery proof reads `sendStatus(messageGuid)`. `EXTENSION_CARD_PROVEN_REJECTIONS`
618
+ (`gateway/extension-card.ts`) are `handleSendExtensionCard`'s and the card
619
+ builder's returns above the IMChat call; `send-extension-card failed:` is
620
+ deliberately **not** among them, because that `@try` wraps the whole
621
+ build-and-send block. FakeGateway reaches only the proven half.
622
+ - **`sendMultipart(chatId, parts, opts?)`** — `send.multipart`
623
+ (`RPCServer+MultipartHandler.swift` → `handleSendMultipart` in
624
+ `IMsgInjected.m`). Several parts as **one message**, targeting an **existing**
625
+ chat only. Gated on `multipartSend`, and the gate covers the whole verb
626
+ including a text-only list: the marker guards a silent LOSS rather than a
627
+ rejection (a helper predating file parts drops them and answers ok), so a
628
+ caller must never be left to discover mid-list that this host eats photos.
629
+
630
+ **What "one message" means on a phone** — proven on a real iPhone from the
631
+ lab Mac (macOS 26.6.2) on 2026-09-21, through the native CLI:
632
+ one notification and one `chat.db` row, **not** one bubble. Each part draws as
633
+ its own bubble, in the order given, and consecutive photos are gathered by the
634
+ receiving phone into a stack (five photos arrived as one stack labelled
635
+ "5 photos"). Two text parts in a row are fine — two bubbles — so, unlike
636
+ Linq, this verb allows them. A text part with a **trailing newline draws an
637
+ empty last line** in its bubble, so callers must not add one (the
638
+ single-attachment path's caption does, which is a native wart, not a model to
639
+ copy). **Part number = index in `parts`**, and that is exactly the
640
+ `partIndex` a later `tapback` names. Audio is not a part: the voice flag is
641
+ message-wide, so a voice note still goes alone through `sendAttachment`.
642
+
643
+ **`effect`, `subject` and `replyToGuid` are message-wide**, read by the
644
+ native handler exactly as `send.rich` reads them — IMCore has one of each per
645
+ message, never one per part. Per-part `textFormatting` is the `sendRich`
646
+ range vocabulary scoped to that part, offsets counted in that part's own
647
+ text; a mention still needs `mentionFormatting` and is `unsupported` without
648
+ it. Formatting on a **file** part is refused rather than dropped.
649
+
650
+ **Validation happens before the RPC**, mirroring the native pass that runs
651
+ before a single transfer is staged: a non-empty array, every entry an object
652
+ of a known `kind`, non-empty `text`, non-empty `filePath` carrying no newline
653
+ or NUL, per-part formatting through the same `textFormattingRefusal` sendRich
654
+ uses, and the Mac-side caps **100 parts / 40 file parts**
655
+ — plus one rule that exists only because of how this verb fails: a part
656
+ carrying **both** a `text` and a `filePath` is refused *before* its `kind` is
657
+ consulted, never resolved by the label. Whichever branch the label chose would
658
+ drop the other key without a word, and losing part content in silence is the
659
+ failure the whole verb exists to remove; it must not come back in through a
660
+ caller that names a part wrong. The native handler refuses it by index for the
661
+ same reason (an empty string counts as absent on both keys, on both sides)
662
+ (`MULTIPART_MAX_PARTS` / `MULTIPART_MAX_FILE_PARTS`, mirroring
663
+ `MultipartLimits` and `kMaxMultipartParts`). Each is a proven no-op
664
+ (`effectStarted:false`, `reason:'refused'`). Two native checks are
665
+ deliberately **not** mirrored: the 100 MiB total-byte cap and whether each
666
+ file exists. This SDK never touches the filesystem — the path belongs to the
667
+ Mac — so a local check would make the fake and the real path disagree in both
668
+ directions; a missing file is the helper's refusal, classified through
669
+ `MULTIPART_PROVEN_REJECTIONS`.
670
+
671
+ **No verification pass exists**, for the reason `sendAttachment` has none:
672
+ the synchronous response carries the message's guid, which is the proof to
673
+ read `sendStatus()` on, while the row Messages writes at dispatch says
674
+ nothing about the upload that follows (`is_sent` stays 0 until the bytes are
675
+ out). So only `unsupported` / `refused` / `fire-failed` are reachable.
676
+ `send-multipart failed:` is deliberately **not** a proven rejection: that
677
+ `@try` wraps the whole build-and-dispatch block. `partCount` is the **host's**
678
+ count of the parts it built and is never replaced with the caller's list
679
+ length — a count shorter than the list is precisely how a helper that dropped
680
+ file parts would show up. There is deliberately no numeric `id`: the native
681
+ result sets `message_id` to the message GUID, so no rowid exists on the real
682
+ path and FakeGateway must not hand back one either.
383
683
  - **`editMessage(chatId, targetGuid, text)`** — `message.edit`
384
684
  (`handleMessageEdit`; imsg `docs/edit.md`). Capability-gated on the
385
685
  `editMessage` marker (see above): on macOS 27 the helper calls the
@@ -432,6 +732,48 @@ prevent.
432
732
  and was absent in another). imsg exposes no `is_unsent`/`is_deleted` marker,
433
733
  so after a guid pre-check, delete `{ok:true}` means only that the RPC
434
734
  dispatched. The fake conservatively leaves its history row unchanged.
735
+
736
+ **A failure says which failure it is** (`MessageRemovalResult`, M2B-43).
737
+ Both verbs used to answer a bare `ok:false` whether nothing had fired or a
738
+ retraction had gone out unconfirmed, so a consumer had to settle every
739
+ failure as possibly-applied — the un-retirable rows of M2B-14. They now
740
+ carry the same `reason`/`effectStarted` pair through the same shared table
741
+ as the group verbs and tapback. The native layer contributes nothing here:
742
+ `message.unsend` and `message.delete` discard the bridge payload and answer
743
+ a hardcoded `{"ok": true}` on invocation, and neither verb verifies
744
+ anything, so every distinction below is either a helper refusal string or
745
+ one of the SDK's own read-backs.
746
+
747
+ - **`refused`** (`effectStarted:false`) — nothing fired. For `deleteMessage`,
748
+ the guid was not in the 30-message scan window after the pre-check poll.
749
+ For either verb, a `-32602`, or a `-32603` whose `error.data` carries one
750
+ of `UNSEND_PROVEN_REJECTIONS` / `DELETE_PROVEN_REJECTIONS`
751
+ (`gateway/message-removal.ts`, read off `handleUnsendMessage` /
752
+ `handleDeleteMessage`'s returns above the IMChat call). Note
753
+ `deleteChatItems:` is checked per call and advertises no `imsg status`
754
+ marker, so an unsupported host arrives as this refusal rather than as an
755
+ `unsupported` capability gate.
756
+ - **`preflight-read-failed`** (`effectStarted:false`) — `deleteMessage`'s
757
+ pre-check read threw, so the target was never looked for and nothing
758
+ fired. Kept apart from `refused` because it names the host's history read
759
+ as broken, not the caller's target. This used to escape as an exception.
760
+ - **`fire-failed`** (`effectStarted:true`) — the call errored without
761
+ proving the helper stopped short. Deliberately includes `unsend-message
762
+ failed` / `delete-message failed` and any bare NSException reason: the
763
+ helper's `@try` wraps only the selector invocation, so the text cannot say
764
+ which side of it raised. A bridge timeout lands here too.
765
+ - **`unverified`** (`effectStarted:true`) — `unsendMessage` only. It fired
766
+ cleanly and six 1 s history reads never showed the row with its text
767
+ cleared, including the out-of-window case (see the guid-targeting window
768
+ above, which this change also corrected).
769
+ - **`verify-read-failed`** (`effectStarted:true`) — `unsendMessage` only.
770
+ Fired cleanly, then a verification read threw. This used to escape as an
771
+ exception.
772
+
773
+ `deleteMessage` never mints `unverified`/`verify-read-failed`: it has no
774
+ postcondition to read, so once its RPC returns there is nothing left to
775
+ confirm. FakeGateway reaches only the proven half (`refused`) — it has no
776
+ bridge to time out on and no host read to lose.
435
777
  - **`setTyping(chatId, on)`/`markRead(chatId)`** — `typing`/`read`
436
778
  (`handleTyping`/`handleRead`). **Fire-and-forget: no observable effect
437
779
  through anything this Gateway exposes.** `is_read`/`date_read` are real
@@ -849,6 +1191,59 @@ the phone, created that day) and group chat 12 (three phone handles):
849
1191
  backgrounds) THEN failure`). That is per-endpoint pruning, not a failure
850
1192
  of the send, and the phone's endpoints are unaffected.
851
1193
 
1194
+ **HOST-VERIFIED 2026-09-20 (dev login, macOS 26, imsg 0.13.0) — tapback
1195
+ failure reasons (M2B-40), gateway-side half.** Run from the packed branch build
1196
+ against a live DM: add / add-again / remove / remove-again still answer
1197
+ `{ok:true}`, `already-reacted`, `{ok:true}`, `not-reacted` for both verbs
1198
+ (1.2–1.7 s per fired call), so the success and skip shapes are unchanged by the
1199
+ new verify wiring; a guid absent from the 30-row window answers
1200
+ `{ok:false, effectStarted:false, reason:'refused'}` for add, remove and the
1201
+ emoji arm, and chat.db gained **no row**; an empty emoji answers the same; and a
1202
+ real native refusal (`rpc.tapback` on an unknown `chat_id` → `-32602`, `data:
1203
+ "unknown chat_id 999999"`) classifies `refused`, which also confirms
1204
+ `error.data` survives the rpc plumbing the classifier reads.
1205
+
1206
+ **NOT YET HOST-VERIFIED — the helper half.** The `TAPBACK_PROVEN_REJECTIONS`
1207
+ texts are read from `IMsgInjected.m` `handleSendReaction`, not observed: none
1208
+ can be provoked without a broken host. `fire-failed`, `unverified` and
1209
+ `verify-read-failed` are unit-tested only. The one failure seen live (dev,
1210
+ 2026-09-18, a tapback aimed at a group-action row: `send-reaction failed:
1211
+ -[IMGroupActionItem expressiveSendStyleID]: unrecognized selector`) classifies
1212
+ `fire-failed`, which is the conservative side.
1213
+
1214
+ **HOST-VERIFIED 2026-09-20 (lab Mac, macOS 26.6.2, marker build, a real iPhone
1215
+ watching the thread) — tapbacks on one part (M2B-45), through these SDK verbs.**
1216
+ The native layer was already proven on real iPhones on macOS 26.6.2 and 27.0
1217
+ (Mapier-Labs/imsg `mapier/deploy` @ `b9c435f`); this run drove the gateway verbs
1218
+ themselves, against SDK `8743b22`:
1219
+
1220
+ - `capabilities.tapbackPart` read `true` off the marker build.
1221
+ - `tapback(…, 'love', false, 1)` → `{ok:true}`, and the SAME call again →
1222
+ `{ok:true, skipped:'already-reacted'}`. That second answer is also the
1223
+ readback proof: the guard can only skip if the history aggregate handed it
1224
+ `target_part: 1`, so `reactions[].target_part` is confirmed live, not just
1225
+ in the fake.
1226
+ - `tapback(…, 'love', false, 0)` then SENT rather than skipping, although part 1
1227
+ already carried `love` — the two do not collapse into one reaction.
1228
+ - `emojiTapback(…, '🔥', false, 1)` → `{ok:true}`: the emoji arm carries a part
1229
+ the same way.
1230
+ - `tapback(…, 'love', true, 0)` → `{ok:true}`, removing part 0's and leaving
1231
+ part 1's alone.
1232
+ - `partIndex: 5` on a message without it → `{ok:false, effectStarted:false,
1233
+ reason:'refused'}`. So `'has no part '` in `TAPBACK_PROVEN_REJECTIONS` is an
1234
+ OBSERVED classification, unlike its neighbours, which remain read-from-source.
1235
+ - `partIndex: 1.5` → refused client-side, nothing sent.
1236
+ - The chat.db rows carry a `p:N/` prefix and a range that AGREE — the shape an
1237
+ iPhone's own per-photo tapback writes, and the one whose disagreement is what
1238
+ the `tapbackPart` marker exists to prevent.
1239
+
1240
+ **Still NOT host-verified for M2B-45:** the capability-OFF arm (`partIndex >= 1`
1241
+ answering `unsupported`), which cannot be provoked without running a
1242
+ pre-marker helper; removing an EMOJI tapback from a part; and the ambiguous
1243
+ failure arms (`fire-failed`, `unverified`, `verify-read-failed`) reached with a
1244
+ part, which are unit-tested only for the same reason the M2B-40 note below
1245
+ gives.
1246
+
852
1247
  **NOT YET HOST-VERIFIED — `GroupMutationResult` (M2B-40).** The reason
853
1248
  vocabulary, the three-member floor and the DM preflight are derived from the
854
1249
  native sources (`IMsgInjected.m` `handleAddParticipant` /
@@ -865,6 +1260,86 @@ removal; (3) do the add/remove/rename round trips still return the bare
865
1260
  prints the measured membership and asserts the floor refusal, so a single run
866
1261
  settles all three.
867
1262
 
1263
+ **NOT YET HOST-VERIFIED — `MessageRemovalResult` (M2B-43).** The
1264
+ `UNSEND_PROVEN_REJECTIONS` / `DELETE_PROVEN_REJECTIONS` texts are read from
1265
+ `IMsgInjected.m` `handleUnsendMessage` / `handleDeleteMessage`, not observed;
1266
+ none can be provoked without a broken host. `fire-failed`, `unverified`,
1267
+ `verify-read-failed` and `preflight-read-failed` are unit-tested only
1268
+ (`tests/message-removal-failure.test.ts`), which under hard rule 1 is **not**
1269
+ proof. One run of `scripts/tier2-smoke.ts` on the host settles the two
1270
+ questions that matter: (1) do the unsend/delete round trips still answer a bare
1271
+ `{ok:true}` with the classification wiring in front of them — the smoke already
1272
+ prints the whole result object on failure, so a stray `reason` on a success
1273
+ shows up; and (2) does an out-of-window unsend now answer `unverified` where it
1274
+ used to answer `{ok:true}`. That second one is a **behavior change, not just a
1275
+ new field**: the old verify predicate (`!found?.text`) read an absent row as a
1276
+ cleared one, so an unsend whose guid was outside the 30-row window — or merely
1277
+ lagging history — reported a success nobody had observed. FakeGateway already
1278
+ refused that input, so the fake and the real gateway disagreed on it until now.
1279
+
1280
+ **NOT YET HOST-VERIFIED — `sendExtensionCard` (M2B-46).** The native half is
1281
+ proven: on 2026-09-20 a Mac-injected card for a non-Apple iMessage extension
1282
+ sent, delivered, drew, opened the installed app in-thread, and updated in place
1283
+ on real phones (macOS 26.6.2 and 27.0) — that run is where the first-card rule
1284
+ comes from. **The SDK verb has not run against a host Mac.** Everything in it
1285
+ is derived from the native sources (`RPCServer+ExtensionCardHandler.swift`,
1286
+ `ExtensionCardRequest.swift`, `IMsgExtensionCard.m`, `IMsgInjected.m`
1287
+ `handleSendExtensionCard`) and covered by unit tests and the fake
1288
+ (`tests/extension-card.test.ts`), which under hard rule 1 is **not** proof, and
1289
+ under rule 2's §4 contributor rule this stays forbidden surface until the host
1290
+ run lands. One run settles the three questions that matter: (1) does a first
1291
+ send actually come back carrying a `guid` — the whole `updatable` distinction
1292
+ exists because the handler omits it when Messages has not exposed one, and how
1293
+ often that happens is unmeasured; (2) does an update driven by the *returned*
1294
+ handle restamp the first card rather than adding a row, i.e. does the handle
1295
+ round trip survive the SDK's UUID validation of real Messages guids; and (3) do
1296
+ both markers read `true` on the current build — a host with `extensionCardSend`
1297
+ and not `extensionCardUpdate` is possible by construction but has never been
1298
+ seen. There is no smoke leg for this verb yet; the host proof is driven by hand.
1299
+
1300
+ **HOST-VERIFIED 2026-09-21 (lab Mac, macOS 26.6.2) — `sendMultipart` (M2B-55),
1301
+ through this SDK verb.** The native layer was already proven on a real iPhone
1302
+ the same day through the native CLI (Mapier-Labs/imsg#28): ten shapes — caption
1303
+ + photo, photo + caption, caption + 3 photos, alternating text and photos, two
1304
+ text parts, five photos, a 93 MiB ten-photo message — each arriving as a single
1305
+ notification with the parts in the order given and the photos stacked, which is
1306
+ where the "no trailing newline" and "consecutive text parts are fine" rules come
1307
+ from, and where the phone RENDERING of these shapes is settled. This run drove
1308
+ the gateway verb itself: the packed tarball of SDK `43b6454` installed into a
1309
+ scratch dir, `IMSG_BIN` pointed at the native build of imsg#28 head `f2a8bc6`
1310
+ (helper sha256 `3229d9dd…`). The phone rendering was not re-judged per SDK
1311
+ send — these legs prove the SDK's half, and `chat.db` is the oracle for it:
1312
+
1313
+ - `capabilities.multipartSend` and `capabilities.mentionFormatting` both read
1314
+ `true` off that build.
1315
+ - `[text, file, file, text]` → `{ok:true, guid, partCount:4}`, and that guid is
1316
+ **ONE** `chat.db` row with `part_count` 4, **two attachment joins**,
1317
+ `is_sent` 1, `is_delivered` 1, `error` 0. That pair is the load-bearing
1318
+ observation of the whole verb: a `partCount` of 4 arriving with both photos
1319
+ actually joined is the direct evidence that the file parts were not silently
1320
+ dropped — the failure the `multipartSend` marker exists to prevent, until now
1321
+ reasoned from the helper's source rather than seen.
1322
+ - `[text with a bold range, file]` with `effect` and `subject` →
1323
+ `{ok:true, guid, partCount:2}`; ONE row, `part_count` 2, one attachment,
1324
+ the subject set, and `expressive_send_style_id`
1325
+ `com.apple.messages.effect.CKConfettiEffect`, sent and delivered, `error` 0.
1326
+ So the per-part `text_formatting` and the message-wide `effect` / `subject`
1327
+ survive this SDK's wire shape into the fields the native handler's
1328
+ `send.rich` parsers write.
1329
+ - An empty list, a 41-file list, and `textFormatting` on a file part each
1330
+ answered `{ok:false, effectStarted:false, reason:'refused'}` — and the run
1331
+ wrote **exactly two rows in total**, so all three refusals fired nothing.
1332
+ That row count is the only way to prove `effectStarted:false`, which is a
1333
+ claim about what did NOT happen.
1334
+
1335
+ **Still NOT host-verified for M2B-55:** a confirmed mention inside a multipart
1336
+ text part (it needs a group thread, and the marker reading `true` is not the
1337
+ same as a mention landing); `replyToGuid` as an inline-reply target; and every
1338
+ string in `MULTIPART_PROVEN_REJECTIONS` — no send failed on the host, so the
1339
+ fire-failure classifier stays read-from-source and unit-tested only, unlike
1340
+ M2B-45's `'has no part '`, which its run happened to provoke. There is still no
1341
+ smoke leg for this verb; the host proof is driven by hand.
1342
+
868
1343
  - **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
869
1344
  chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
870
1345
  events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
@@ -946,7 +1421,9 @@ fail closed (covered by `tests/imsg-rpc.test.ts`).
946
1421
  recent 30 messages are scanned (the real path reads `history(chatId, 30)`) — a tapback on
947
1422
  an older message is invisible, and FakeGateway enforces the same window so features can't
948
1423
  be built against reach the real gateway doesn't have. Feeds "[reacted X to your message]"
949
- acknowledgment notes into agent context.
1424
+ acknowledgment notes into agent context. A note carries `targetPart` when they reacted to one
1425
+ part of our message (M2B-45) — 1 is the second photo of two — and carries no such key at all
1426
+ when they reacted to the whole message.
950
1427
  - **resolveDmChat(handle)** — the numeric `chat_id` of the existing DM thread with `handle`, or
951
1428
  `null` if none exists. **Read-only** — it must never create a thread (the real path reads
952
1429
  `chat.db` via `chats.list` and cannot mint one; FakeGateway may not exceed that). The runtime
@@ -1010,7 +1487,11 @@ a different product.
1010
1487
  (2026-07-02) and imsg 0.12.3 (2026-07-07): `messages.history` returns no `is_reaction` rows
1011
1488
  (they'd duplicate the reacted
1012
1489
  message), but every message carries a `reactions[]` aggregate — the current tapback state on
1013
- that bubble: `{ id, type, emoji?, is_from_me, sender?, created_at? }` per reaction.
1490
+ that bubble: `{ id, type, emoji?, is_from_me, sender?, created_at?, target_part? }` per reaction.
1491
+ `target_part` (M2B-45) is which PART of the message the reaction named, and is absent when it
1492
+ named the whole message — read it through `reactionPart()`, never directly, or "loved the
1493
+ message" and "loved photo 1" look like different targets. One sender can hold the same
1494
+ reaction type on two parts, and that is two entries, not one.
1014
1495
  Consequence: reaction-state logic (toggle guard, `recentReactions`) reads history — it is
1015
1496
  authoritative and restart-safe. The subscribe stream (`include_reactions: true`) still
1016
1497
  delivers reaction EVENTS for realtime wake-ups, but is never the source of state: a process
@@ -1213,6 +1694,35 @@ a different product.
1213
1694
  messages, reacting to an arbitrary (non-most-recent) message, and participant management
1214
1695
  on an existing group (add/remove/rename/photo/leave).
1215
1696
 
1697
+ - **An extension-card update aimed at the wrong card cannot be detected, and
1698
+ FakeGateway must not detect it either** (M2B-46). Every update in a session
1699
+ has to name that session's FIRST card; pointing one at a previous update's
1700
+ guid produces a separate card that is never replaced, with `ok:true`, a real
1701
+ guid and a delivery. The Mac has no way to catch it — it does not know which
1702
+ guid began a session — so a fake that rejected a hand-built handle would test
1703
+ consumers against a safety net that does not exist on the host. The
1704
+ protection lives in the API shape instead: `sendExtensionCard` takes only an
1705
+ `ExtensionCardHandle` it minted, and returns that same handle unchanged from
1706
+ every update, so carrying `result.handle` forward stays correct. Both
1707
+ implementations also refuse to invent a guid: when Messages exposes none, the
1708
+ success carries `updatable:false` and no handle rather than a synthesized one.
1709
+
1710
+ - **A multipart send is ONE row, and the fake may under-count its parts —
1711
+ never over-count** (M2B-55). `sendMultipart` emits a single outbound row on
1712
+ both implementations, whatever the part list holds: one notification, one
1713
+ `chat.db` row, several bubbles. What the fake does not do is synthesize that
1714
+ row's inner shape. The real row's `text` is Apple's own concatenation of the
1715
+ parts with object-replacement placeholders where the files sit, and its
1716
+ `attachments[]` only appears on a later history/watch row — the send response
1717
+ reports neither, and inventing them is exactly what `sendAttachment` already
1718
+ refuses to do. The consequence is deliberate: `FakeGateway.partCount()` still
1719
+ counts attachments, so a fake-sent multipart row reports 1 part however many
1720
+ it really had, and a part-level tapback on it is refused where a host would
1721
+ accept it. That is the allowed direction (the fake must not succeed at what
1722
+ the real path refuses), and counting `(text ? 1 : 0) + attachments.length`
1723
+ instead is a contract change that needs the host run to prove the real part
1724
+ layout first.
1725
+
1216
1726
  - **Inbound media.** Real messages can carry `attachments[]` with empty `text`. FakeGateway must
1217
1727
  be able to emit the exact attachment metadata with `text: undefined`/empty so
1218
1728
  this path gets exercised — the agent currently ignores attachments gracefully
@@ -1295,7 +1805,9 @@ harness (`tsx --test`, see `package.json`). Required cases:
1295
1805
  `ok:false` without mutation
1296
1806
  - `tapback`/`editMessage`/`unsendMessage`/`deleteMessage` all return `ok: false` — without
1297
1807
  mutating anything — for a guid outside the 30-message scan window (the guid-targeting
1298
- window above), even though the message still exists in the fake's full store
1808
+ window above), even though the message still exists in the fake's full store, and all
1809
+ four say `effectStarted:false` because the fake never fires (the real
1810
+ `unsendMessage` answers `unverified` for that input: it fires first)
1299
1811
  - `sendRich` sends to an existing chat with an inline reply target, and returns `ok: false`
1300
1812
  for a chat that doesn't exist (no find-or-create, unlike `send`)
1301
1813
  - `sendRich` accepts a subject while preserving the observable text echo (subject is
@@ -1321,8 +1833,16 @@ harness (`tsx --test`, see `package.json`). Required cases:
1321
1833
  mutation when the `editMessage` capability marker is absent
1322
1834
  - `unsendMessage` retracts the target row (text clears, row persists);
1323
1835
  `deleteMessage` is dispatch-only with no stable history postcondition (the
1324
- fake conservatively preserves the row); both return `ok: false` for an
1325
- unknown guid
1836
+ fake conservatively preserves the row); both name an unknown guid as
1837
+ `ok:false, effectStarted:false, reason:'refused'`
1838
+ - (real-path only, `tests/message-removal-failure.test.ts` over the pure
1839
+ module) for both removal verbs a bridge rejection raised before IMChat is
1840
+ `refused` and a timeout, an unmodelled `-32603` or a non-rpc error is
1841
+ `fire-failed`, with one verb's proven-rejection text not excusing another's;
1842
+ `deleteMessage`'s pre-check names an exhausted poll `refused` and a thrown
1843
+ read `preflight-read-failed`; `unsendMessage`'s read-back names an exhausted
1844
+ verify window `unverified` — including a guid it never sees, which must not
1845
+ be mistaken for a cleared row — and a thrown read `verify-read-failed`
1326
1846
  - `setTyping`/`markRead` dispatch `{ ok: true }` against an existing chat and `{ ok: false }`
1327
1847
  against an unknown one, with no queryable state either way
1328
1848
  - `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
@@ -1356,6 +1876,20 @@ harness (`tsx --test`, see `package.json`). Required cases:
1356
1876
  - `sendLocationRequest` posts the card as its own outbound row to an existing chat, returns
1357
1877
  the balloon guid, refuses an unknown chat (no find-or-create), and fails closed without
1358
1878
  the `locationRequest` marker (no row written)
1879
+ - `sendExtensionCard` posts the card as its own outbound row to an existing chat, composes
1880
+ the balloon id from the team and extension ids, hands back a `handle` whose
1881
+ `firstCardMessageGuid` is that row, and keeps returning that SAME handle through three
1882
+ chained updates (each its own row); bad input and an unknown chat are `refused` with no
1883
+ row written, a missing `extensionCardSend` marker is `unsupported`, and a host with
1884
+ `extensionCardSend` but not `extensionCardUpdate` sends but refuses the update. One case
1885
+ asserts a NEGATIVE: an update aimed at a previous update's guid is **not** caught — the
1886
+ fake must not be safer than the Mac, which cannot detect it either
1887
+ - `sendMultipart` lands text and file parts as ONE outbound row (not one per part), reports
1888
+ the host's `partCount` and a guid, and preserves an inline-reply guid alongside the
1889
+ message-wide effect and subject; an empty list, an empty text part, formatting on a file
1890
+ part, 41 file parts and an unknown chat are each `refused` with no row written, and a
1891
+ missing `multipartSend` marker is `unsupported` — including for a text-only list, which
1892
+ such a host could actually have sent
1359
1893
  - Fake-only restart coverage snapshots and restores a world, then proves
1360
1894
  `sendStatus`, `checkHandle`, Name & Photo idempotency, chat backgrounds (and their
1361
1895
  guid sequence), Find My shares, current group metadata, fixture state, and monotonic IDs