@mapier/imsg-sdk 0.5.0 → 0.6.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,8 @@ interface Gateway {
63
64
  mentionFormatting: boolean;
64
65
  sharedLocations: boolean;
65
66
  locationRequest: boolean;
67
+ extensionCardSend: boolean;
68
+ extensionCardUpdate: boolean;
66
69
  };
67
70
  subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
68
71
  history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
@@ -78,8 +81,20 @@ interface Gateway {
78
81
  checkHandle(address: string, opts?: { aliasType?: 'phone' | 'email' }): Promise<HandleCheck>;
79
82
 
80
83
  // 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>;
84
+ tapback(
85
+ chatId: number,
86
+ targetGuid: string,
87
+ reaction: Reaction,
88
+ remove?: boolean,
89
+ partIndex?: number,
90
+ ): Promise<ReactResult>;
91
+ emojiTapback(
92
+ chatId: number,
93
+ targetGuid: string,
94
+ emoji: string,
95
+ remove?: boolean,
96
+ partIndex?: number,
97
+ ): Promise<ReactResult>;
83
98
  sendRich(
84
99
  chatId: number,
85
100
  text: string,
@@ -97,13 +112,18 @@ interface Gateway {
97
112
  ): Promise<SendResult>;
98
113
  sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
99
114
  sendRichLink(chatId: number, url: string): Promise<SendResult>;
115
+ sendExtensionCard(
116
+ chatId: number,
117
+ card: ExtensionCard,
118
+ opts?: { update?: ExtensionCardHandle },
119
+ ): Promise<ExtensionCardResult>;
100
120
  editMessage(chatId: number, targetGuid: string, text: string): Promise<{
101
121
  ok: boolean;
102
122
  skipped?: 'unchanged';
103
123
  effectStarted?: boolean;
104
124
  }>;
105
- unsendMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
106
- deleteMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
125
+ unsendMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
126
+ deleteMessage(chatId: number, targetGuid: string): Promise<MessageRemovalResult>;
107
127
  setTyping(chatId: number, on: boolean): Promise<{ ok: boolean }>;
108
128
  markRead(chatId: number): Promise<{ ok: boolean }>;
109
129
  renameGroup(chatId: number, name: string): Promise<GroupMutationResult>;
@@ -201,7 +221,50 @@ interface GroupMutationResult {
201
221
  | 'refused' // proven rejection before IMChat — effectStarted:false
202
222
  | 'fire-failed' // the call errored, may have applied — effectStarted:true
203
223
  | 'unverified' // fired, never confirmed in the window — effectStarted:true
204
- | 'verify-read-failed'; // fired, the verification read broke — effectStarted:true
224
+ | 'verify-read-failed' // fired, the verification read broke — effectStarted:true
225
+ | 'preflight-read-failed'; // a PREflight read broke, nothing fired — effectStarted:false
226
+ }
227
+
228
+ // unsendMessage / deleteMessage (M2B-43). Same shape, same shared reason
229
+ // table: a success is bare, a failure always names where certainty was lost.
230
+ interface MessageRemovalResult {
231
+ ok: boolean;
232
+ effectStarted?: boolean;
233
+ reason?: GroupMutationFailure;
234
+ }
235
+
236
+ // sendExtensionCard (M2B-46). A card belonging to a third-party iMessage app
237
+ // extension; the balloon identifier is composed from teamId + extensionBundleId
238
+ // on the native side and never accepted whole.
239
+ interface ExtensionCard {
240
+ teamId: string; // exactly 10 characters of A-Z0-9
241
+ extensionBundleId: string; // the MESSAGES EXTENSION's id, reverse-DNS, no ':'
242
+ appName: string;
243
+ appStoreId?: number; // positive integer; without it an unmatched card leads nowhere
244
+ caption: string;
245
+ subcaption?: string;
246
+ summaryText?: string; // the line left behind once an update replaces this card
247
+ url: string; // the app's own state; any scheme, real cards use data:
248
+ liveLayout?: boolean; // default true — see the verb entry for what each value shows
249
+ }
250
+
251
+ // The ONLY update target this SDK accepts, and the reason it is a pair rather
252
+ // than a guid: every update must name the session's FIRST card.
253
+ interface ExtensionCardHandle {
254
+ sessionId: string;
255
+ firstCardMessageGuid: string;
256
+ }
257
+
258
+ interface ExtensionCardResult {
259
+ ok: boolean;
260
+ sessionId?: string;
261
+ messageGuid?: string; // THIS row (the card, or the update row) — not the update target
262
+ handle?: ExtensionCardHandle; // what a later update passes back as opts.update; present exactly when `updatable`
263
+ updatable?: boolean; // always present on ok:true
264
+ balloonBundleId?: string;
265
+ liveLayout?: boolean;
266
+ effectStarted?: boolean;
267
+ reason?: GroupMutationFailure; // only unsupported / refused / fire-failed are reachable
205
268
  }
206
269
  ```
207
270
 
@@ -238,6 +301,10 @@ confirmed-mention attribute key resolves at runtime (imsg#13); emoji
238
301
  additionally requires the selected RPC binary
239
302
  to report `rpc_features:["tapback.emoji"]`. That second half prevents a
240
303
  patched dylib behind a stock CLI from false-advertising custom emoji support.
304
+ Part-level tapbacks require `tapbackPart`, which the helper computes from the
305
+ part's own chat item plus an associated-message initializer taking a range —
306
+ and which no other tapback marker may stand in for, because a helper without it
307
+ does not reject `part_index`, it writes a corrupt row (M2B-45, above).
241
308
  Missing status, malformed JSON, or absent markers fail closed.
242
309
  `FakeGateway.capabilities` mirrors this gate and can disable the same surface
243
310
  for parity tests. A remote adapter (e.g. imsg-agent's connector) must gate on
@@ -250,11 +317,17 @@ guid by scanning `history(chatId, 30)` — the same 30-message ceiling
250
317
  `recentReactions` already documents above — so a guid older than that is as
251
318
  invisible to these methods as it is to `recentReactions`. `tapback` and
252
319
  `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
320
+ `ok: false` without touching anything (`tapback`/`emojiTapback`/`deleteMessage`
321
+ say so explicitly: `effectStarted:false, reason:'refused'`), and so does
322
+ `editMessage`
323
+ (it also refuses a message that is not `is_from_me`); `unsendMessage` fires the RPC
255
324
  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
325
+ window reports `ok: false, effectStarted: true, reason: 'unverified'` and may
326
+ have mutated the real row on the way there — "no confirmable effect," not a
327
+ guaranteed no-op. Until M2B-43 that case returned `{ ok: true }`: the verify
328
+ predicate read an ABSENT row as a cleared one, so an out-of-window (or
329
+ merely lagging) guid reported a retraction nobody had observed. Unsend leaves
330
+ a tombstone row, so absence is missing evidence, never success. FakeGateway
258
331
  must enforce the identical window regardless: finding the target guid by
259
332
  scanning ALL of `this.messages` unconditionally would let the fake succeed
260
333
  at targeting a message the real path can't reach (parity rule 2) — a
@@ -262,7 +335,7 @@ guid-targeted verb "working" in the fake against a message 40 rows back but
262
335
  refusing on the real path is exactly the trap this contract exists to
263
336
  prevent.
264
337
 
265
- - **`tapback(chatId, targetGuid, reaction, remove?)`** — a second, bridge-based
338
+ - **`tapback(chatId, targetGuid, reaction, remove?, partIndex?)`** — a second, bridge-based
266
339
  tapback path alongside `react()`. What it adds: targets **any** message guid
267
340
  in the chat, not just the newest incoming bubble; works in **group** chats
268
341
  (the bridge has no focused-window check to defeat, unlike the AppleScript
@@ -276,13 +349,88 @@ prevent.
276
349
  deliberate: "fix: reject unsupported custom emoji reaction sends instead of
277
350
  taking a no-op AppleScript path" (#55). So `reaction` stays the closed
278
351
  `Reaction` type here too.
279
- - **`emojiTapback(chatId, targetGuid, emoji, remove?)`** — Mapier-fork extension
352
+ - **`emojiTapback(chatId, targetGuid, emoji, remove?, partIndex?)`** — Mapier-fork extension
280
353
  backed by `IMEmojiTapback` + `IMTapbackSender`. It targets any guid in the
281
354
  same 30-message scan window as `tapback`, uses explicit add/remove guards,
282
355
  and verifies history for `type:"custom"` plus the exact emoji before
283
356
  returning success. The RPC sends `emoji` instead of `kind`; sending both
284
357
  would fall back into stock imsg's classic-6 normalizer. Live-verified on the
285
358
  host Mac with 👻 and 💀; chat.db records `associated_message_type=2006`.
359
+
360
+ **A tapback failure says which failure it is** (M2B-40). `tapback` and
361
+ `emojiTapback` are the only reaction path a consumer has left once
362
+ `reaction.apply` lost its minters (M2B-37), and they used to answer a bare
363
+ `ok:false` from four different positions. On `ok:false` they now carry the
364
+ same `reason` / `effectStarted` pair the group verbs do (`GroupMutationFailure`,
365
+ one shared table, so a reason cannot mean two things):
366
+
367
+ - **`unsupported`** (`effectStarted:false`) — `emojiTapback` without the
368
+ `emojiTapback` capability marker.
369
+ - **`refused`** (`effectStarted:false`) — the guid is not in the 30-message
370
+ scan window, the emoji is empty, or the helper rejected the call before its
371
+ send: a `-32602`, or a `-32603` whose `error.data` carries one of
372
+ `TAPBACK_PROVEN_REJECTIONS` (`gateway/tapback.ts`, read off
373
+ `handleSendReaction`'s returns above `[sender send]` / `[chat sendMessage:]`).
374
+ - **`fire-failed`** (`effectStarted:true`) — the call errored without proving
375
+ the helper stopped short. That deliberately includes `send-reaction failed:
376
+ …`: it wraps an NSException caught around the whole build-and-send block, so
377
+ the text cannot say which side of the send raised it.
378
+ - **`unverified`** (`effectStarted:true`) — fired cleanly, and six 1 s history
379
+ reads never showed the wanted state.
380
+ - **`verify-read-failed`** (`effectStarted:true`) — fired cleanly, then a
381
+ verification read threw. This used to escape as an exception.
382
+
383
+ `react()` is unchanged and still answers a bare `ok:false`; an absent
384
+ `effectStarted` must keep being treated as ambiguous.
385
+
386
+ **A tapback lands on ONE part of a message** (M2B-45). A message carrying two
387
+ photos has parts 0 and 1, so `partIndex: 1` reacts to the second photo alone.
388
+ Omitted or `0` is what both verbs always did, and the RPC for it is
389
+ unchanged — `part_index` is not sent at all, so a host that has never heard
390
+ of parts sees the same request it always did. Both verbs, add and remove,
391
+ classic and emoji, behave identically here.
392
+
393
+ - **Capability-gated on `tapbackPart`, and a part >= 1 without it is
394
+ `unsupported` (`effectStarted:false`), never a quiet part 0.** This marker
395
+ guards a SILENT corruption rather than a rejection, which is why nothing
396
+ else may stand in for it: an older helper *accepts* `part_index` and writes
397
+ a row whose `p:N/<guid>` prefix disagrees with its range, so the recipient's
398
+ phone draws the reaction on **every** photo while Messages discards the
399
+ sender's own row — unverifiable and unremovable. Production Macs run such a
400
+ helper. Downgrading to part 0 instead would react to the wrong photo and
401
+ report success, so the SDK refuses before building the RPC.
402
+ - **`partIndex` must be a non-negative integer**, checked client-side
403
+ (`tapbackPartRefusal`, `gateway/tapback.ts`): `1.5`, `-1` and `NaN` are
404
+ `refused` with nothing sent, matching the native's `invalid params`. A part
405
+ that cannot be read is never taken as 0.
406
+ - **A part the message does not have is refused before anything is sent** —
407
+ the helper counts the parts and returns `Message <guid> has no part <n>`,
408
+ which `TAPBACK_PROVEN_REJECTIONS` matches as `refused`
409
+ (`effectStarted:false`). FakeGateway enforces the same rule from its own
410
+ part count: the number of attachments, minimum 1. That model is
411
+ deliberately conservative — a text-plus-photos message really has more
412
+ parts than the fake counts, so the fake refuses parts the host would accept
413
+ and never the reverse (parity rule 2).
414
+ - **The guards and the verify compare the PART, not just the reaction.** One
415
+ sender can hold the same reaction type on part 0 and part 1 as two separate
416
+ reactions; they used to collapse into one. So reacting to photo 2 while
417
+ photo 1 already carries that reaction fires normally instead of answering
418
+ `already-reacted`, a remove on part 1 leaves part 0's alone, and the
419
+ verify-after-fire pass looks for the reaction on the part that was asked
420
+ for.
421
+ - **`react()` is deliberately left part-BLIND.** It drives AppleScript, which
422
+ has no part-level form, and its tapback is a toggle whose guard exists to
423
+ stop a second react from switching the first one off. Whether that toggle
424
+ would reach a reaction sitting on part 1 is Messages.app behaviour nobody
425
+ has verified on a host, so its guard keeps matching any part exactly as it
426
+ always did and declines instead. Only the bridge verbs compare parts.
427
+ - **Reading back:** a reaction that named a part carries `target_part` on the
428
+ `reactions[]` aggregate and `reaction_target_part` on a reaction event row;
429
+ `recentReactions` surfaces it as `ReactionNote.targetPart`. All three are
430
+ **absent** when the reaction named the whole message, so every comparison
431
+ goes through `reactionPart()` / `reactionOnPart()` (exported), which read
432
+ absent as 0. `reacted_to_guid` stays the bare message guid — the part never
433
+ appears in it.
286
434
  - **`sendRich(chatId, text, opts?)`** — real and confirmed present on the
287
435
  host's selected patched imsg 0.13.x (`send.rich`, `RPCServer+BridgeMessageHandlers.swift`
288
436
  `handleSendRich`; shipped well before 0.12.3 per CHANGELOG). Targets an
@@ -380,6 +528,70 @@ prevent.
380
528
  guid** (unlike text/poll/attachment, which return a guid) — the URL-preview
381
529
  balloon lands as its own row later. `SendResult.guid` is therefore always
382
530
  undefined here; FakeGateway must not return one either.
531
+ - **`sendExtensionCard(chatId, card, opts?)`** — `extension_card.send`
532
+ (`RPCServer+ExtensionCardHandler.swift`, `handleSendExtensionCard`), the kind
533
+ of balloon a third-party iMessage app extension sends. Targets an
534
+ **existing** chat only. The balloon identifier is composed on the native side
535
+ from `teamId` + `extensionBundleId` and is never accepted whole, so a caller
536
+ cannot claim an arbitrary app's balloon. Two capability markers gate it, read
537
+ from `imsg status --json` like every other patched verb: `extensionCardSend`
538
+ (the payload initializer polls also use) and `extensionCardUpdate` (that plus
539
+ the associated-message initializer). **Neither implies the other** — a host
540
+ can send cards and be unable to replace one — so a `sendExtensionCard` with
541
+ an `update` needs both and is `unsupported` without the second.
542
+
543
+ **What the recipient sees** (all three observed on a real phone, 2026-09-20):
544
+ with `liveLayout` true (the default) and the app installed, the installed
545
+ extension draws the card itself and the `caption` is never seen; with
546
+ `liveLayout:false` and the app installed, the app's icon beside *this card's*
547
+ caption and subcaption — the mode to use when the text is the point; without
548
+ the app, a small static card that links to the App Store page only when
549
+ `appStoreId` is given. **Cards never carry a picture.**
550
+
551
+ **The first-card rule, which is what shapes this API.** An update must always
552
+ name the session's **FIRST** card — the same `updates_message_guid` for every
553
+ update in the session, including the fifth, plus that session's id. It is not
554
+ a chain. Aiming an update at a *previous update's* guid does not replace
555
+ anything: that card arrives as a separate new card and stays in the thread as
556
+ an orphan, and it looks exactly like success (`ok:true`, a real guid,
557
+ delivered). **Nothing on the Mac can catch this**, because it cannot know
558
+ which guid began a session. So this verb does not take a guid at all: a first
559
+ send returns an `ExtensionCardHandle` as `result.handle` (`{sessionId,
560
+ firstCardMessageGuid}`); every update passes it back **unchanged** as
561
+ `opts.update` and gets the *same* handle out, never one built from its own
562
+ row. A caller doing `handle = result.handle` after each update therefore
563
+ stays correct by construction. A
564
+ handle assembled by hand out of an update's `messageGuid` is still wrong and
565
+ still cannot be detected — by the SDK or by the host — which is why
566
+ `ExtensionCardHandle` is documented as opaque.
567
+
568
+ **`updatable` answers the only question on a success.** `guid` is omitted by
569
+ the native handler when Messages has not exposed the row's guid yet, and
570
+ `ok:true` either way. Rather than invent one, `updatable:false` (with no
571
+ `update`) says plainly that this card can never be updated — send a fresh one
572
+ instead. On `ok:true`, `messageGuid` is *this* row: the card on a first send,
573
+ the associated update row on an update.
574
+
575
+ **Validation happens before the RPC**, mirroring
576
+ `ExtensionCardRequest.swift`: team id shape, reverse-DNS extension bundle id
577
+ without `':'`, required `appName`/`caption`/`url`, positive integer
578
+ `appStoreId`, strict boolean `liveLayout`, a 4096 **UTF-16 unit** cap on
579
+ every string, and both halves of an update handle present and UUID-shaped.
580
+ One deliberate divergence: `url` must be **absolute** here, while the helper
581
+ accepts anything `URL(string:)` parses — a card's URL is the extension's own
582
+ state (`data:` or `https:` in practice), never a relative path. Every one of
583
+ these is a proven no-op (`effectStarted:false`, `reason:'refused'`).
584
+
585
+ **No verification pass exists.** `imsg history` selects `payload_data` as
586
+ NULL for bundle ids it does not decode, so a card reads back with an empty
587
+ payload and there is nothing to confirm against; this verb therefore never
588
+ mints `unverified`, `verify-read-failed` or `preflight-read-failed`, and only
589
+ `unsupported` / `refused` / `fire-failed` are reachable. A caller that needs
590
+ delivery proof reads `sendStatus(messageGuid)`. `EXTENSION_CARD_PROVEN_REJECTIONS`
591
+ (`gateway/extension-card.ts`) are `handleSendExtensionCard`'s and the card
592
+ builder's returns above the IMChat call; `send-extension-card failed:` is
593
+ deliberately **not** among them, because that `@try` wraps the whole
594
+ build-and-send block. FakeGateway reaches only the proven half.
383
595
  - **`editMessage(chatId, targetGuid, text)`** — `message.edit`
384
596
  (`handleMessageEdit`; imsg `docs/edit.md`). Capability-gated on the
385
597
  `editMessage` marker (see above): on macOS 27 the helper calls the
@@ -432,6 +644,48 @@ prevent.
432
644
  and was absent in another). imsg exposes no `is_unsent`/`is_deleted` marker,
433
645
  so after a guid pre-check, delete `{ok:true}` means only that the RPC
434
646
  dispatched. The fake conservatively leaves its history row unchanged.
647
+
648
+ **A failure says which failure it is** (`MessageRemovalResult`, M2B-43).
649
+ Both verbs used to answer a bare `ok:false` whether nothing had fired or a
650
+ retraction had gone out unconfirmed, so a consumer had to settle every
651
+ failure as possibly-applied — the un-retirable rows of M2B-14. They now
652
+ carry the same `reason`/`effectStarted` pair through the same shared table
653
+ as the group verbs and tapback. The native layer contributes nothing here:
654
+ `message.unsend` and `message.delete` discard the bridge payload and answer
655
+ a hardcoded `{"ok": true}` on invocation, and neither verb verifies
656
+ anything, so every distinction below is either a helper refusal string or
657
+ one of the SDK's own read-backs.
658
+
659
+ - **`refused`** (`effectStarted:false`) — nothing fired. For `deleteMessage`,
660
+ the guid was not in the 30-message scan window after the pre-check poll.
661
+ For either verb, a `-32602`, or a `-32603` whose `error.data` carries one
662
+ of `UNSEND_PROVEN_REJECTIONS` / `DELETE_PROVEN_REJECTIONS`
663
+ (`gateway/message-removal.ts`, read off `handleUnsendMessage` /
664
+ `handleDeleteMessage`'s returns above the IMChat call). Note
665
+ `deleteChatItems:` is checked per call and advertises no `imsg status`
666
+ marker, so an unsupported host arrives as this refusal rather than as an
667
+ `unsupported` capability gate.
668
+ - **`preflight-read-failed`** (`effectStarted:false`) — `deleteMessage`'s
669
+ pre-check read threw, so the target was never looked for and nothing
670
+ fired. Kept apart from `refused` because it names the host's history read
671
+ as broken, not the caller's target. This used to escape as an exception.
672
+ - **`fire-failed`** (`effectStarted:true`) — the call errored without
673
+ proving the helper stopped short. Deliberately includes `unsend-message
674
+ failed` / `delete-message failed` and any bare NSException reason: the
675
+ helper's `@try` wraps only the selector invocation, so the text cannot say
676
+ which side of it raised. A bridge timeout lands here too.
677
+ - **`unverified`** (`effectStarted:true`) — `unsendMessage` only. It fired
678
+ cleanly and six 1 s history reads never showed the row with its text
679
+ cleared, including the out-of-window case (see the guid-targeting window
680
+ above, which this change also corrected).
681
+ - **`verify-read-failed`** (`effectStarted:true`) — `unsendMessage` only.
682
+ Fired cleanly, then a verification read threw. This used to escape as an
683
+ exception.
684
+
685
+ `deleteMessage` never mints `unverified`/`verify-read-failed`: it has no
686
+ postcondition to read, so once its RPC returns there is nothing left to
687
+ confirm. FakeGateway reaches only the proven half (`refused`) — it has no
688
+ bridge to time out on and no host read to lose.
435
689
  - **`setTyping(chatId, on)`/`markRead(chatId)`** — `typing`/`read`
436
690
  (`handleTyping`/`handleRead`). **Fire-and-forget: no observable effect
437
691
  through anything this Gateway exposes.** `is_read`/`date_read` are real
@@ -849,6 +1103,59 @@ the phone, created that day) and group chat 12 (three phone handles):
849
1103
  backgrounds) THEN failure`). That is per-endpoint pruning, not a failure
850
1104
  of the send, and the phone's endpoints are unaffected.
851
1105
 
1106
+ **HOST-VERIFIED 2026-09-20 (dev login, macOS 26, imsg 0.13.0) — tapback
1107
+ failure reasons (M2B-40), gateway-side half.** Run from the packed branch build
1108
+ against a live DM: add / add-again / remove / remove-again still answer
1109
+ `{ok:true}`, `already-reacted`, `{ok:true}`, `not-reacted` for both verbs
1110
+ (1.2–1.7 s per fired call), so the success and skip shapes are unchanged by the
1111
+ new verify wiring; a guid absent from the 30-row window answers
1112
+ `{ok:false, effectStarted:false, reason:'refused'}` for add, remove and the
1113
+ emoji arm, and chat.db gained **no row**; an empty emoji answers the same; and a
1114
+ real native refusal (`rpc.tapback` on an unknown `chat_id` → `-32602`, `data:
1115
+ "unknown chat_id 999999"`) classifies `refused`, which also confirms
1116
+ `error.data` survives the rpc plumbing the classifier reads.
1117
+
1118
+ **NOT YET HOST-VERIFIED — the helper half.** The `TAPBACK_PROVEN_REJECTIONS`
1119
+ texts are read from `IMsgInjected.m` `handleSendReaction`, not observed: none
1120
+ can be provoked without a broken host. `fire-failed`, `unverified` and
1121
+ `verify-read-failed` are unit-tested only. The one failure seen live (dev,
1122
+ 2026-09-18, a tapback aimed at a group-action row: `send-reaction failed:
1123
+ -[IMGroupActionItem expressiveSendStyleID]: unrecognized selector`) classifies
1124
+ `fire-failed`, which is the conservative side.
1125
+
1126
+ **HOST-VERIFIED 2026-09-20 (lab Mac, macOS 26.6.2, marker build, a real iPhone
1127
+ watching the thread) — tapbacks on one part (M2B-45), through these SDK verbs.**
1128
+ The native layer was already proven on real iPhones on macOS 26.6.2 and 27.0
1129
+ (Mapier-Labs/imsg `mapier/deploy` @ `b9c435f`); this run drove the gateway verbs
1130
+ themselves, against SDK `8743b22`:
1131
+
1132
+ - `capabilities.tapbackPart` read `true` off the marker build.
1133
+ - `tapback(…, 'love', false, 1)` → `{ok:true}`, and the SAME call again →
1134
+ `{ok:true, skipped:'already-reacted'}`. That second answer is also the
1135
+ readback proof: the guard can only skip if the history aggregate handed it
1136
+ `target_part: 1`, so `reactions[].target_part` is confirmed live, not just
1137
+ in the fake.
1138
+ - `tapback(…, 'love', false, 0)` then SENT rather than skipping, although part 1
1139
+ already carried `love` — the two do not collapse into one reaction.
1140
+ - `emojiTapback(…, '🔥', false, 1)` → `{ok:true}`: the emoji arm carries a part
1141
+ the same way.
1142
+ - `tapback(…, 'love', true, 0)` → `{ok:true}`, removing part 0's and leaving
1143
+ part 1's alone.
1144
+ - `partIndex: 5` on a message without it → `{ok:false, effectStarted:false,
1145
+ reason:'refused'}`. So `'has no part '` in `TAPBACK_PROVEN_REJECTIONS` is an
1146
+ OBSERVED classification, unlike its neighbours, which remain read-from-source.
1147
+ - `partIndex: 1.5` → refused client-side, nothing sent.
1148
+ - The chat.db rows carry a `p:N/` prefix and a range that AGREE — the shape an
1149
+ iPhone's own per-photo tapback writes, and the one whose disagreement is what
1150
+ the `tapbackPart` marker exists to prevent.
1151
+
1152
+ **Still NOT host-verified for M2B-45:** the capability-OFF arm (`partIndex >= 1`
1153
+ answering `unsupported`), which cannot be provoked without running a
1154
+ pre-marker helper; removing an EMOJI tapback from a part; and the ambiguous
1155
+ failure arms (`fire-failed`, `unverified`, `verify-read-failed`) reached with a
1156
+ part, which are unit-tested only for the same reason the M2B-40 note below
1157
+ gives.
1158
+
852
1159
  **NOT YET HOST-VERIFIED — `GroupMutationResult` (M2B-40).** The reason
853
1160
  vocabulary, the three-member floor and the DM preflight are derived from the
854
1161
  native sources (`IMsgInjected.m` `handleAddParticipant` /
@@ -865,6 +1172,43 @@ removal; (3) do the add/remove/rename round trips still return the bare
865
1172
  prints the measured membership and asserts the floor refusal, so a single run
866
1173
  settles all three.
867
1174
 
1175
+ **NOT YET HOST-VERIFIED — `MessageRemovalResult` (M2B-43).** The
1176
+ `UNSEND_PROVEN_REJECTIONS` / `DELETE_PROVEN_REJECTIONS` texts are read from
1177
+ `IMsgInjected.m` `handleUnsendMessage` / `handleDeleteMessage`, not observed;
1178
+ none can be provoked without a broken host. `fire-failed`, `unverified`,
1179
+ `verify-read-failed` and `preflight-read-failed` are unit-tested only
1180
+ (`tests/message-removal-failure.test.ts`), which under hard rule 1 is **not**
1181
+ proof. One run of `scripts/tier2-smoke.ts` on the host settles the two
1182
+ questions that matter: (1) do the unsend/delete round trips still answer a bare
1183
+ `{ok:true}` with the classification wiring in front of them — the smoke already
1184
+ prints the whole result object on failure, so a stray `reason` on a success
1185
+ shows up; and (2) does an out-of-window unsend now answer `unverified` where it
1186
+ used to answer `{ok:true}`. That second one is a **behavior change, not just a
1187
+ new field**: the old verify predicate (`!found?.text`) read an absent row as a
1188
+ cleared one, so an unsend whose guid was outside the 30-row window — or merely
1189
+ lagging history — reported a success nobody had observed. FakeGateway already
1190
+ refused that input, so the fake and the real gateway disagreed on it until now.
1191
+
1192
+ **NOT YET HOST-VERIFIED — `sendExtensionCard` (M2B-46).** The native half is
1193
+ proven: on 2026-09-20 a Mac-injected card for a non-Apple iMessage extension
1194
+ sent, delivered, drew, opened the installed app in-thread, and updated in place
1195
+ on real phones (macOS 26.6.2 and 27.0) — that run is where the first-card rule
1196
+ comes from. **The SDK verb has not run against a host Mac.** Everything in it
1197
+ is derived from the native sources (`RPCServer+ExtensionCardHandler.swift`,
1198
+ `ExtensionCardRequest.swift`, `IMsgExtensionCard.m`, `IMsgInjected.m`
1199
+ `handleSendExtensionCard`) and covered by unit tests and the fake
1200
+ (`tests/extension-card.test.ts`), which under hard rule 1 is **not** proof, and
1201
+ under rule 2's §4 contributor rule this stays forbidden surface until the host
1202
+ run lands. One run settles the three questions that matter: (1) does a first
1203
+ send actually come back carrying a `guid` — the whole `updatable` distinction
1204
+ exists because the handler omits it when Messages has not exposed one, and how
1205
+ often that happens is unmeasured; (2) does an update driven by the *returned*
1206
+ handle restamp the first card rather than adding a row, i.e. does the handle
1207
+ round trip survive the SDK's UUID validation of real Messages guids; and (3) do
1208
+ both markers read `true` on the current build — a host with `extensionCardSend`
1209
+ and not `extensionCardUpdate` is possible by construction but has never been
1210
+ seen. There is no smoke leg for this verb yet; the host proof is driven by hand.
1211
+
868
1212
  - **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
869
1213
  chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
870
1214
  events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
@@ -946,7 +1290,9 @@ fail closed (covered by `tests/imsg-rpc.test.ts`).
946
1290
  recent 30 messages are scanned (the real path reads `history(chatId, 30)`) — a tapback on
947
1291
  an older message is invisible, and FakeGateway enforces the same window so features can't
948
1292
  be built against reach the real gateway doesn't have. Feeds "[reacted X to your message]"
949
- acknowledgment notes into agent context.
1293
+ acknowledgment notes into agent context. A note carries `targetPart` when they reacted to one
1294
+ part of our message (M2B-45) — 1 is the second photo of two — and carries no such key at all
1295
+ when they reacted to the whole message.
950
1296
  - **resolveDmChat(handle)** — the numeric `chat_id` of the existing DM thread with `handle`, or
951
1297
  `null` if none exists. **Read-only** — it must never create a thread (the real path reads
952
1298
  `chat.db` via `chats.list` and cannot mint one; FakeGateway may not exceed that). The runtime
@@ -1010,7 +1356,11 @@ a different product.
1010
1356
  (2026-07-02) and imsg 0.12.3 (2026-07-07): `messages.history` returns no `is_reaction` rows
1011
1357
  (they'd duplicate the reacted
1012
1358
  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.
1359
+ that bubble: `{ id, type, emoji?, is_from_me, sender?, created_at?, target_part? }` per reaction.
1360
+ `target_part` (M2B-45) is which PART of the message the reaction named, and is absent when it
1361
+ named the whole message — read it through `reactionPart()`, never directly, or "loved the
1362
+ message" and "loved photo 1" look like different targets. One sender can hold the same
1363
+ reaction type on two parts, and that is two entries, not one.
1014
1364
  Consequence: reaction-state logic (toggle guard, `recentReactions`) reads history — it is
1015
1365
  authoritative and restart-safe. The subscribe stream (`include_reactions: true`) still
1016
1366
  delivers reaction EVENTS for realtime wake-ups, but is never the source of state: a process
@@ -1213,6 +1563,19 @@ a different product.
1213
1563
  messages, reacting to an arbitrary (non-most-recent) message, and participant management
1214
1564
  on an existing group (add/remove/rename/photo/leave).
1215
1565
 
1566
+ - **An extension-card update aimed at the wrong card cannot be detected, and
1567
+ FakeGateway must not detect it either** (M2B-46). Every update in a session
1568
+ has to name that session's FIRST card; pointing one at a previous update's
1569
+ guid produces a separate card that is never replaced, with `ok:true`, a real
1570
+ guid and a delivery. The Mac has no way to catch it — it does not know which
1571
+ guid began a session — so a fake that rejected a hand-built handle would test
1572
+ consumers against a safety net that does not exist on the host. The
1573
+ protection lives in the API shape instead: `sendExtensionCard` takes only an
1574
+ `ExtensionCardHandle` it minted, and returns that same handle unchanged from
1575
+ every update, so carrying `result.handle` forward stays correct. Both
1576
+ implementations also refuse to invent a guid: when Messages exposes none, the
1577
+ success carries `updatable:false` and no handle rather than a synthesized one.
1578
+
1216
1579
  - **Inbound media.** Real messages can carry `attachments[]` with empty `text`. FakeGateway must
1217
1580
  be able to emit the exact attachment metadata with `text: undefined`/empty so
1218
1581
  this path gets exercised — the agent currently ignores attachments gracefully
@@ -1295,7 +1658,9 @@ harness (`tsx --test`, see `package.json`). Required cases:
1295
1658
  `ok:false` without mutation
1296
1659
  - `tapback`/`editMessage`/`unsendMessage`/`deleteMessage` all return `ok: false` — without
1297
1660
  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
1661
+ window above), even though the message still exists in the fake's full store, and all
1662
+ four say `effectStarted:false` because the fake never fires (the real
1663
+ `unsendMessage` answers `unverified` for that input: it fires first)
1299
1664
  - `sendRich` sends to an existing chat with an inline reply target, and returns `ok: false`
1300
1665
  for a chat that doesn't exist (no find-or-create, unlike `send`)
1301
1666
  - `sendRich` accepts a subject while preserving the observable text echo (subject is
@@ -1321,8 +1686,16 @@ harness (`tsx --test`, see `package.json`). Required cases:
1321
1686
  mutation when the `editMessage` capability marker is absent
1322
1687
  - `unsendMessage` retracts the target row (text clears, row persists);
1323
1688
  `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
1689
+ fake conservatively preserves the row); both name an unknown guid as
1690
+ `ok:false, effectStarted:false, reason:'refused'`
1691
+ - (real-path only, `tests/message-removal-failure.test.ts` over the pure
1692
+ module) for both removal verbs a bridge rejection raised before IMChat is
1693
+ `refused` and a timeout, an unmodelled `-32603` or a non-rpc error is
1694
+ `fire-failed`, with one verb's proven-rejection text not excusing another's;
1695
+ `deleteMessage`'s pre-check names an exhausted poll `refused` and a thrown
1696
+ read `preflight-read-failed`; `unsendMessage`'s read-back names an exhausted
1697
+ verify window `unverified` — including a guid it never sees, which must not
1698
+ be mistaken for a cleared row — and a thrown read `verify-read-failed`
1326
1699
  - `setTyping`/`markRead` dispatch `{ ok: true }` against an existing chat and `{ ok: false }`
1327
1700
  against an unknown one, with no queryable state either way
1328
1701
  - `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
@@ -1356,6 +1729,14 @@ harness (`tsx --test`, see `package.json`). Required cases:
1356
1729
  - `sendLocationRequest` posts the card as its own outbound row to an existing chat, returns
1357
1730
  the balloon guid, refuses an unknown chat (no find-or-create), and fails closed without
1358
1731
  the `locationRequest` marker (no row written)
1732
+ - `sendExtensionCard` posts the card as its own outbound row to an existing chat, composes
1733
+ the balloon id from the team and extension ids, hands back a `handle` whose
1734
+ `firstCardMessageGuid` is that row, and keeps returning that SAME handle through three
1735
+ chained updates (each its own row); bad input and an unknown chat are `refused` with no
1736
+ row written, a missing `extensionCardSend` marker is `unsupported`, and a host with
1737
+ `extensionCardSend` but not `extensionCardUpdate` sends but refuses the update. One case
1738
+ asserts a NEGATIVE: an update aimed at a previous update's guid is **not** caught — the
1739
+ fake must not be safer than the Mac, which cannot detect it either
1359
1740
  - Fake-only restart coverage snapshots and restores a world, then proves
1360
1741
  `sendStatus`, `checkHandle`, Name & Photo idempotency, chat backgrounds (and their
1361
1742
  guid sequence), Find My shares, current group metadata, fixture state, and monotonic IDs
@@ -136,17 +136,20 @@ corrections changed which kinds `agentUsable()` returns.
136
136
  Flagged as the catalog's highest-value unbuilt seam: richer than any `native-balloon` kind,
137
137
  with `web-surface`'s zero install cost.
138
138
  - **`host-extension-card`** — renamed from an earlier draft's `extension-card`. `tier:
139
- 'extension'`, `requiresRecipientInstall: true`, `status: 'unbuilt'`. Modeled on Photon's
140
- Spectrum host-extension pattern (cited above) — external product, cited as provenance. The
141
- 2026-07-14 spike (`imsg-agent` `docs/parking-lot.md`, cross-repo context) already ran and
142
- returned a **verdict against building a bespoke per-feature extension**: Apple's own
143
- `MSMessageLiveLayout` docs confirm a live/interactive card only renders on a device with the
144
- *sending* extension installed, and there is no reverse-engineering precedent anywhere
145
- (BlueBubbles/mautrix/jesec/Project Zero) for injecting a third-party extension balloon via
146
- private API. The host model doesn't remove the install wall it only amortizes it across
147
- every future mini-app instead of paying it per card, which is a real difference in economics
148
- but not in Apple's gate. `status` stays `unbuilt`; unlike the `app-clip` seam, this one is not
149
- currently prioritized. `fallback: 'web-url'`.
139
+ 'extension'`, `requiresRecipientInstall: true`, `outbound: 'agent'`, `status: 'fake-only'`.
140
+ Modeled on Photon's Spectrum host-extension pattern (cited above) — external product, cited as
141
+ provenance. **No longer an unbuilt seam** (M2B-46): `Gateway.sendExtensionCard` sends a card
142
+ for a third-party iMessage app extension and updates it in place, which is exactly the
143
+ injection the 2026-07-14 spike found no precedent for. That spike's verdict is **overturned on
144
+ the mechanism, upheld on the wall**: a Mac-injected card for a non-Apple extension was observed
145
+ on real phones on 2026-09-20 sending, delivering, drawing, and opening the installed app
146
+ in-thread but Apple's gate is exactly where `MSMessageLiveLayout` said it was. With the app
147
+ installed the extension draws the card itself; with `liveLayout:false` the recipient sees the
148
+ app's icon beside *our* caption; without the app they get a small static card (App Store link
149
+ only when `appStoreId` is supplied). So the host model still amortizes the install across every
150
+ future mini-app rather than removing it. `status` is `fake-only`, not `host-smoked`: the SDK
151
+ verb has only been exercised against `FakeGateway` and its unit tests — the host run is
152
+ pending, which is why this row stays out of `agentUsable()`. `fallback: 'web-url'`.
150
153
 
151
154
  ## Adding a kind
152
155
 
@@ -163,14 +166,13 @@ Mirrors `docs/gateway-contract.md` §4's contributor discipline:
163
166
  `InteractionRegistry`/`InteractionHarness`.
164
167
  4. **This doc.** Update the row's writeup here with the citation.
165
168
 
166
- A row may enter the catalog as `status: 'unbuilt'` only as a documented seam — e.g. `app-clip`,
167
- `host-extension-card` — never as a placeholder for something already claimed to work elsewhere
168
- (the mistake the original `poll` and `effect` drafts made).
169
+ A row may enter the catalog as `status: 'unbuilt'` only as a documented seam — e.g. `app-clip` —
170
+ never as a placeholder for something already claimed to work elsewhere (the mistake the original
171
+ `poll` and `effect` drafts made).
169
172
 
170
173
  ## Unbuilt seams: priority note
171
174
 
172
- `app-clip` (zero-install, native, richer than any balloon) is the highest-value unbuilt lever in
173
- this catalog. `host-extension-card` (real MSMessages host extension) stays cataloged as a
174
- documented, deliberately `unbuilt` seam the 2026-07-14 spike found the install wall real and
175
- not currently worth paying for a bespoke extension but is not prioritized the way `app-clip`
176
- is.
175
+ `app-clip` (zero-install, native, richer than any balloon) is now the only unbuilt row left, and
176
+ the highest-value lever in this catalog. `host-extension-card` left this list in M2B-46: the
177
+ mechanism is built (`Gateway.sendExtensionCard`) and climbing the ladder — `fake-only` until the
178
+ host runrather than deliberately unbuilt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapier/imsg-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "TypeScript SDK for iMessage automation on macOS: the Gateway contract, ImsgGateway (real), and a device-free FakeGateway.",
5
5
  "license": "MIT",
6
6
  "repository": {