@mapier/imsg-sdk 0.2.2 → 0.3.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.
@@ -57,6 +57,10 @@ interface Gateway {
57
57
  groupPhoto: boolean;
58
58
  groupParticipants: boolean;
59
59
  namePhotoSharing: boolean;
60
+ chatBackgroundSet: boolean;
61
+ chatBackgroundRemove: boolean;
62
+ editMessage: boolean;
63
+ mentionFormatting: boolean;
60
64
  };
61
65
  subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
62
66
  history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
@@ -91,7 +95,11 @@ interface Gateway {
91
95
  ): Promise<SendResult>;
92
96
  sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
93
97
  sendRichLink(chatId: number, url: string): Promise<SendResult>;
94
- editMessage(chatId: number, targetGuid: string, text: string): Promise<{ ok: boolean }>;
98
+ editMessage(chatId: number, targetGuid: string, text: string): Promise<{
99
+ ok: boolean;
100
+ skipped?: 'unchanged';
101
+ effectStarted?: boolean;
102
+ }>;
95
103
  unsendMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
96
104
  deleteMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
97
105
  setTyping(chatId: number, on: boolean): Promise<{ ok: boolean }>;
@@ -106,6 +114,21 @@ interface Gateway {
106
114
  skipped?: 'not-offered';
107
115
  effectStarted?: boolean;
108
116
  }>;
117
+ chatBackgroundStatus(chatId: number): Promise<{
118
+ chatId: number;
119
+ backgroundSet: boolean;
120
+ backgroundGuid: string | null;
121
+ } | null>;
122
+ setChatBackground(chatId: number, preset?: 'gradient'): Promise<{
123
+ ok: boolean;
124
+ backgroundGuid?: string;
125
+ effectStarted?: boolean;
126
+ }>;
127
+ removeChatBackground(chatId: number, expectedGuid?: string): Promise<{
128
+ ok: boolean;
129
+ skipped?: 'no-background';
130
+ effectStarted?: boolean;
131
+ }>;
109
132
  }
110
133
 
111
134
  interface ReactionNote {
@@ -160,7 +183,16 @@ both the long-lived RPC process and its one-time `status --json` probe.
160
183
  Patched verbs require the injected bridge's explicit selector markers
161
184
  `emojiTapbackSend`, `stickerSend`, `groupPhotoUpdate`, `groupAddParticipant`, and
162
185
  `groupRemoveParticipant`; Name & Photo requires both `namePhotoShouldOffer`
163
- and `namePhotoShare`; emoji additionally requires the selected RPC binary
186
+ and `namePhotoShare`; chat backgrounds are gated per verb on
187
+ `chatBackgroundSetGradient` (setter + PosterBoard/ChatKit runtime) and
188
+ `chatBackgroundRemove` (setter + live-GUID getter), which the injected helper
189
+ already composes; edit message requires any one of `editMessageItemTranslation`
190
+ (macOS 27's five-argument `-[IMChat editMessageItem:…newPartTranslation:…]`,
191
+ imsg#3), `editMessageItem` (macOS 26) or `editMessage` (macOS 13) — the helper
192
+ uses the first present one; confirmed @mentions on `sendRich` require
193
+ `mentionFormatting`, which the helper advertises only when IMSharedUtilities'
194
+ confirmed-mention attribute key resolves at runtime (imsg#13); emoji
195
+ additionally requires the selected RPC binary
164
196
  to report `rpc_features:["tapback.emoji"]`. That second half prevents a
165
197
  patched dylib behind a stock CLI from false-advertising custom emoji support.
166
198
  Missing status, malformed JSON, or absent markers fail closed.
@@ -175,10 +207,11 @@ guid by scanning `history(chatId, 30)` — the same 30-message ceiling
175
207
  `recentReactions` already documents above — so a guid older than that is as
176
208
  invisible to these methods as it is to `recentReactions`. `tapback` and
177
209
  `deleteMessage` pre-check against that scan before firing, so they return
178
- `{ ok: false }` without touching anything; `editMessage`/`unsendMessage` fire
179
- the RPC first and only find the ceiling on the VERIFY pass, so a guid outside
180
- the window still reports `{ ok: false }` but may have mutated the real row on
181
- the way there "no confirmable effect," not a guaranteed no-op. FakeGateway
210
+ `{ ok: false }` without touching anything, and so does `editMessage` (it also
211
+ refuses a message that is not `is_from_me`); `unsendMessage` fires the RPC
212
+ first and only finds the ceiling on the VERIFY pass, so a guid outside the
213
+ window still reports `{ ok: false }` but may have mutated the real row on the
214
+ way there — "no confirmable effect," not a guaranteed no-op. FakeGateway
182
215
  must enforce the identical window regardless: finding the target guid by
183
216
  scanning ALL of `this.messages` unconditionally would let the fake succeed
184
217
  at targeting a message the real path can't reach (parity rule 2) — a
@@ -219,6 +252,46 @@ prevent.
219
252
  this Gateway because `ImsgMessage` has no subject field. The `url` submode is
220
253
  exposed separately as `sendRichLink` because openclaw treats URL and
221
254
  text/subject/effect/reply as mutually exclusive modes.
255
+ `opts.textFormatting` is a list of ranges over the text in **UTF-16 code
256
+ units** (a JS string index; an emoji is two units): `styles`
257
+ (bold/italic/underline/strikethrough, macOS 15+, silently dropped on older
258
+ hosts), `effect` (a named macOS 27 text effect, resolved to Apple's id in
259
+ `resolveTextFormatting`), and `mention` — a **confirmed @mention**
260
+ (imsg#13, merged into `mapier/deploy` as `7c31772` — the pinned submodule
261
+ — `docs/send.md` "Text formatting and mentions"; the rules below mirror
262
+ `IMsgMentionFormatting.m` at that revision). `mention` is the EXACT `IMHandle.ID` of a participant of the
263
+ target chat as IMCore reports it (E.164 phone or the email), matched
264
+ byte-for-byte by the helper against the live participant list; the visible
265
+ text in the range must be the person's display name, never the handle (a
266
+ handle as visible text renders as a phone link with no highlight and no
267
+ notification, verified 2026-09-03 on an iPhone). Do not prefix the name
268
+ with "@": Messages' composer swallows the typed "@" and a genuine mention
269
+ row carries only the highlighted name (an "@" in the range simply renders
270
+ as text, as the 2026-09-03 SDK run showed). Formatting is **never
271
+ degraded**: the shared preflight `sendRichPreflight` /
272
+ `textFormattingRefusal` runs before the bridge is touched and refuses, with
273
+ a typed `SendResult.refused`, a mention range on a bridge without the
274
+ `mentionFormatting` marker (`mention-capability`), bad offsets on ANY range
275
+ — non-integer, zero length, outside the text, or splitting a surrogate pair
276
+ (`text-formatting-range`; the helper would otherwise silently drop a style
277
+ range and send plain text, which a caller cannot detect since formatting
278
+ is write-only), and a mention identifier that is not a bounded id or whose
279
+ visible text is the handle itself (`mention-identifier`). The helper
280
+ refuses a non-participant handle (`-32603` "Mention identifier is not a
281
+ participant handle of this chat", no row written), which `ImsgGateway`
282
+ classifies as `mention-not-participant`; a chat whose live participant
283
+ list is unreadable refuses every mention the same way. FakeGateway applies
284
+ the same preflight, checks membership against its chat record, and refuses
285
+ all mentions on an unreported-directory group. **A bare `ok:false` without
286
+ `refused` is NOT a no-send guarantee** (bridge timeout, transport loss);
287
+ only a `refused` result proves nothing was sent. Formatting and mentions
288
+ are write-only through this Gateway: `ImsgMessage` exposes neither, so the
289
+ smoke reads the confirmed-mention attribute (`__kIMMentionConfirmedMention`
290
+ followed by the handle) from `chat.db message.attributedBody` directly. The
291
+ `--only-mention` leg is opt-in via `--mention-handle` even in the full
292
+ Tier-2 run, because a confirmed mention notifies a real person.
293
+ **Host-verified 2026-09-03** through `ImsgGateway` on the shared Mac,
294
+ including the recipient-side highlight on an iPhone (see the ledger).
222
295
  - **`sendAttachment(chatId, filePath, opts?)`** — `send.attachment`
223
296
  (`handleSendAttachment`). Targets an **existing** chat only. `opts.audio:true`
224
297
  sends the file as a native voice-note bubble; `opts.replyToGuid` sets an
@@ -265,8 +338,44 @@ prevent.
265
338
  balloon lands as its own row later. `SendResult.guid` is therefore always
266
339
  undefined here; FakeGateway must not return one either.
267
340
  - **`editMessage(chatId, targetGuid, text)`** — `message.edit`
268
- (`handleMessageEdit`). **Edits mutate the existing row's text IN PLACE: no
269
- new row, no `subscribe()` event.** `MessageWatcher`'s cursor is a ROWID
341
+ (`handleMessageEdit`; imsg `docs/edit.md`). Capability-gated on the
342
+ `editMessage` marker (see above): on macOS 27 the helper calls the
343
+ five-argument `editMessageItem:atPartIndex:withNewPartText:newPartTranslation:backwardCompatabilityText:`
344
+ with part index 0 and the new text as backwards-compatibility text, and the
345
+ RPC answers `{ok:true}` on invocation only. **Preflight before firing**, same
346
+ discipline as `tapback`/`deleteMessage`: the guid must be inside the scan
347
+ window — located by a short poll (3 × 1 s, sleeping only between reads),
348
+ because a just-sent guid can lag history by a beat and a one-shot read
349
+ would fail closed on a message about to appear —
350
+ and the message must be an **editable own plain-text message**
351
+ (`isEditableOwnTextMessage` in `gateway/types.ts`, shared by both
352
+ implementations): `is_from_me` (Apple only permits editing your own
353
+ messages and the helper does not check), non-empty `text`, and no
354
+ attachments/poll — the RPC always edits part 0, which on those rows is not
355
+ the text, and a retracted row has nothing left to edit. Anything else is
356
+ refused as `ok:false, effectStarted:false` without touching the bridge, as
357
+ is empty or whitespace-only text. Text identical to the current row is the
358
+ idempotent `skipped:'unchanged'` (`ok:true`, nothing fired). The whole
359
+ decision is one shared function (`decideEditMessagePreflight`) so the two
360
+ implementations cannot drift. Known gap: a rich-link balloon carries its
361
+ URL as `text` and nothing on `ImsgMessage` marks it, so the real path
362
+ fires on it and the read-back decides; FakeGateway's rich-link row has no
363
+ text and is refused. After the RPC fires, a structured `-32603` whose
364
+ `data` is one of the helper's pre-invoke refusals (chat/message not found,
365
+ no selector, bad signature) is `effectStarted:false`; a bridge timeout or
366
+ any other `-32603` (an exception after IMChat was invoked) and an
367
+ unconfirmed read-back are `ok:false, effectStarted:true` — a consumer
368
+ reconciles those rather than treating them as a no-op. **Host-verified
369
+ 2026-09-03** through `ImsgGateway` on the shared Mac (see the ledger
370
+ below); recipient-side rendering remains unobserved. `unsendMessage`
371
+ deliberately keeps its fire-first shape: retracting a just-sent row is a
372
+ legitimate use and its verify pass is the existing contract; a matching
373
+ preflight is a follow-up, not part of this change. Native CLI/RPC E2E on macOS 27 `26A5421a` (imsg#11, chat 8): both
374
+ paths edited a fresh own message and read the new text back on the same
375
+ guid/ROWID, with `message.date_edited` stamped — that column is not exposed
376
+ through `messages.history`, so the SDK smoke reads it from chat.db directly
377
+ while the gateway verifies by text read-back. **Edits mutate the existing
378
+ row's text IN PLACE: no new row, no `subscribe()` event.** `MessageWatcher`'s cursor is a ROWID
270
379
  high-water mark (`WHERE ROWID > cursor`), so an UPDATE to an existing row is
271
380
  structurally invisible to the watch stream — the only way to observe an
272
381
  edit is re-reading `history()` and comparing text for a guid you already
@@ -340,6 +449,81 @@ both implementations — none of Apple's group primitives apply to a 1:1 chat.
340
449
  and the receiving iPhone displayed Mapi's new contact info, closing the
341
450
  receiver-side verification gap.
342
451
 
452
+ - **Chat backgrounds** — `chatBackgroundStatus` / `setChatBackground` /
453
+ `removeChatBackground` (Mapier-Labs/imsg#12, `chat.background.status|set|remove`).
454
+ A background is chat-scoped state recorded in chat.db's background tables,
455
+ not a message row: there is no `subscribe()` event and no history row on
456
+ either implementation. DM and group chats both accept all three verbs.
457
+ `chatBackgroundStatus` is a plain chat.db read (no selector marker, but it
458
+ needs the imsg#12 binary: an older build answers `-32601` and the call
459
+ throws). `null` means the chat does not exist (`-32602`); any other failure
460
+ is thrown, so a transient rpc/bridge fault can never masquerade as "no such
461
+ chat". It returns the persisted background channel GUID, which **lags
462
+ imagent** — a durable snapshot, never the live IMChat state.
463
+ `setChatBackground` supports the single runtime-verified `gradient` preset.
464
+ On the native side the RPC returns on helper acceptance — its response
465
+ embeds the pre-mutation chat.db snapshot, so no separate preflight read —
466
+ and the pipeline (PosterBoard builder → hidden `OverFullScreen` editor →
467
+ ChatKit poster/watch payloads → `setTranscriptBackgroundAndSendToChat:transferID:`)
468
+ runs asynchronously, bounded by per-stage watchdogs and a 90 s overall cap.
469
+ `ImsgGateway` therefore polls `chat.background.status` under a 90 s
470
+ deadline on a **monotonic** clock (`performance.now()`; a wall-clock
471
+ rollback cannot stretch the wait — the native CLI's own window; read first,
472
+ sleep 1 s between), in the pure loop `src/gateway/chat-background.ts`. Each poll is a
473
+ local chat.db read. The helper's operation record is a bridge round-trip on
474
+ Messages' main thread — it competes with the pipeline itself and, because
475
+ the native JSON-RPC loop is strictly serial, holds every other gateway verb
476
+ for up to 10 s on a stall — so it is read only (a) once, pulled forward,
477
+ when a NEW candidate appears (a background GUID different from the
478
+ pre-mutation snapshot) and (b) on a 15 s cadence for early failure
479
+ detection; a helper read that times out doubles that gap, up to 60 s, and
480
+ chat.db polling carries on meanwhile. **Success requires operation
481
+ correlation**: a candidate is accepted only when it equals the record's
482
+ `transfer_id` (the identifier IMChat persists as the background GUID, imsg
483
+ `docs/chat-background.md`). A changed guid, a newer `set` event, or both
484
+ are NOT evidence — a delayed write from an earlier operation satisfies them
485
+ — and a helper that no longer tracks the operation (bounded record table,
486
+ Messages relaunch) leaves nothing to correlate with, so that case ends the
487
+ wait as ambiguous rather than guessing. A bridge-timeout on a verification
488
+ read keeps polling; any other read failure (dead rpc child, transport loss)
489
+ ends the wait as ambiguous. `ok:true` carries the persisted GUID; a re-set
490
+ replaces the background and the GUID changes. FakeGateway mints a fresh GUID
491
+ per set from a sequence it persists in its snapshot. Result semantics
492
+ follow `shareNamePhoto`: `effectStarted:false` is a proven preflight
493
+ rejection — capability absent, `-32602` (unknown chat, bad preset), or a
494
+ helper refusal the SDK recognises in the JSON-RPC `error.data` (busy with
495
+ another background operation — the helper holds one in-process lock —,
496
+ guard mismatch, missing selector/runtime, "did not queue"); `ok:false,
497
+ effectStarted:true` means the effect may have started and persistence was
498
+ not proven — helper record `failed`, the deadline passed, an
499
+ rpc-child/transport failure, a bridge timeout, or any other `-32603` such as
500
+ an exception raised after IMChat was invoked (the native server reports all
501
+ of these under one code with the reason only in `data`) — and must be
502
+ reconciled, never reported as a proven no-op.
503
+ `removeChatBackground` is driven by the helper's **live** IMChat GUID, never
504
+ the caller's chat.db snapshot. The optional `expectedGuid` is a
505
+ compare-and-clear guard evaluated against that live GUID **first**, before
506
+ the no-background check: a mismatch — including a guard on a chat with no
507
+ live background — is `ok:false, effectStarted:false` and leaves the
508
+ background in place. An empty string is no guard (`normalizeChatBackgroundGuard`
509
+ in `gateway/types.ts`, shared by both implementations; the wire only
510
+ carries a non-empty one). Only then is a chat with no live background the
511
+ explicit `skipped:'no-background'` no-op (`ok:true`, IMChat never invoked).
512
+ The gateway reads chat.db before firing (the remove response's own snapshot
513
+ is post-invoke) and after a real clear checks that response, then polls
514
+ under a 30 s deadline for a `clear` event newer than the baseline; an unchanged empty
515
+ snapshot counts only when the pre-mutation read recorded the background
516
+ that is now gone, because an already-lagging empty snapshot proves nothing.
517
+ Native Set/Remove semantics were smoke-verified on macOS 27 `26A5421a`
518
+ (matching live GUID, chat.db event, cache, watch cache, upload metadata),
519
+ and the production CLI/RPC path went through its matched-build E2E before
520
+ imsg#12 merged. The SDK verbs were **host-verified 2026-09-03** through
521
+ `ImsgGateway` on the shared Mac (`--only-chat-background` leg of
522
+ `scripts/tier2-smoke.ts`; see the ledger below); recipient-side rendering,
523
+ group chats and the replace path remain unobserved. The leg
524
+ (`scripts/tier2-chat-background-leg.ts`) stops at the first failed check
525
+ and never clears a background whose guid it did not read back.
526
+
343
527
  **Host-verification ledger.** Exercised through `ImsgGateway` on the host Mac
344
528
  2026-07-14 (`scripts/tier2-smoke.ts`, chat 3996):
345
529
 
@@ -361,6 +545,66 @@ Each leg prints an EYEBALL step because a local write is not proof of
361
545
  receiver-visible delivery (the group-photo lesson). The `sendRich`
362
546
  text/effect/reply path was already live-verified.
363
547
 
548
+ Exercised 2026-09-03 on the shared Mac (macOS 27 `26A5421a`, user
549
+ `mapidev`, disposable DM chat 8, SDK `bf92147` against the deployed imsg#12
550
+ CLI/helper: `bridge_version 2`, `v2_ready true`, `chatBackgroundSetGradient
551
+ true`, `chatBackgroundRemove true`; `scripts/tier2-smoke.ts 8
552
+ --only-chat-background`, run dir `map175-sdk-bg-e2e.PCVLad`):
553
+
554
+ - **`setChatBackground` / `removeChatBackground` / `chatBackgroundStatus`**
555
+ — live-verified: both capability rows; baseline `backgroundSet:false`;
556
+ Set Gradient persisted a NEW background guid in ~7 s and a separate status
557
+ read returned the same guid; Remove with `NOT-THE-LIVE-GUID` →
558
+ `ok:false, effectStarted:false` with the background unchanged (the logged
559
+ `ImsgRpcError` is the deliberate mismatch); guarded Remove cleared it,
560
+ status read `backgroundSet:false`, and an unguarded Remove on the bare
561
+ chat → `skipped:'no-background'`. `ALL PASS`, exit 0; the audit recorded
562
+ two events, both in chat 8; native status identical before and after and
563
+ the fixture back at its no-background baseline. The `live-smoke.ts 8
564
+ --read-only` gate passed first. Not observed: the receiving device
565
+ rendering the Gradient; not covered: group chats, the replace (re-set)
566
+ path, relay/public-API integration.
567
+
568
+ Exercised 2026-09-03 on the same host (SDK `6cf6ac5` against the same
569
+ deployed helper: `editMessageItemTranslation true`, the two four-argument
570
+ markers false; `scripts/tier2-smoke.ts 8 --only-edit-message`, run dir
571
+ `map175-sdk-edit-e2e.fgUq4t`):
572
+
573
+ Exercised 2026-09-03 on the same host (SDK `6e28c82` against the deployed
574
+ imsg#13 helper, `mentionFormatting true`; disposable group chat 12 with
575
+ three phone-handle participants; `scripts/tier2-smoke.ts 12 --only-mention
576
+ --mention-handle <participant IMHandle.ID> --mention-name <display name>`, run dir
577
+ `map175-sdk-mention-e2e.PuauKG`):
578
+
579
+ - **`sendRich` confirmed @mention** — live-verified: capability row;
580
+ `@Neo tier2-smoke 21:13:08 mention` sent
581
+ (`86246294-B16D-4E5C-9E15-D5E80C90CB14`, row 3143) and read back on the
582
+ same guid; `chat.db attributedBody` carries `__kIMMentionConfirmedMention`
583
+ at byte 266 followed by the handle at byte 300; a non-participant handle
584
+ refused by the helper (`-32603` "Mention identifier is not a participant
585
+ handle of this chat") → `refused:'mention-not-participant'`, a range
586
+ outside the text → `'text-formatting-range'`, and the handle as visible
587
+ text → `'mention-identifier'`, none of the three texts in history
588
+ afterwards. 9/9 PASS, `ALL PASS`, exit 0; the audit recorded exactly one
589
+ event, in chat 12; native status identical before and after. The
590
+ `live-smoke.ts 12 --read-only` gate passed first (6/6). **Recipient-side
591
+ observed** this time: on the mentioned participant's iPhone the message
592
+ rendered with "@Neo" highlighted in the same style as a native mention.
593
+ Not covered: email-handle mentions (not covered natively either), the
594
+ `send-attachment` / multipart mention legs (no SDK surface).
595
+
596
+ - **`editMessage`** — live-verified: capability row from the three-marker
597
+ fold; marker message sent (`73263626-5A07-4B3C-8244-D3133B8A8EA6`), edited,
598
+ new text read back on the SAME guid, `chat.db message.date_edited`
599
+ stamped (`810141791007747072`); unchanged text → `skipped:'unchanged'`;
600
+ whitespace text → `ok:false, effectStarted:false` with the edited text
601
+ still in place; the chat's newest inbound row (the DM partner's echo of
602
+ the marker) → `ok:false, effectStarted:false` and untouched. 10/10 PASS,
603
+ `ALL PASS`, exit 0; native status identical before and after. The
604
+ `live-smoke.ts 8 --read-only` gate passed first (6/6). Not observed: the
605
+ receiving device rendering "Edited" (the inbound echo carried the original
606
+ text, which is the partner's copy, not evidence either way).
607
+
364
608
  - **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
365
609
  chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
366
610
  events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
@@ -783,8 +1027,16 @@ harness (`tsx --test`, see `package.json`). Required cases:
783
1027
  returns `ok:false` for fewer than two options or an unknown chat
784
1028
  - `sendRichLink` echoes an outbound row and returns `ok:false` for an unknown chat; preview
785
1029
  metadata is not queryable through `ImsgMessage`
1030
+ - `sendRich` with a confirmed mention of a participant sends once (DM and group, alongside
1031
+ styles); a non-participant handle, a wrongly formatted participant, a range outside the
1032
+ text (mention OR style), an empty or padded handle, the handle as visible text, an
1033
+ unreported group directory, and a missing `mentionFormatting` marker each return
1034
+ `ok:false` with the matching typed `refused` reason and nothing sent
786
1035
  - `editMessage` changes the target row's text in place — same row count before/after, no
787
- new row
1036
+ new row; refuses an inbound (not `is_from_me`) message, empty/whitespace text, and our own
1037
+ message outside the scan window without mutation;
1038
+ unchanged text is the idempotent `skipped:'unchanged'`; returns `ok:false` without
1039
+ mutation when the `editMessage` capability marker is absent
788
1040
  - `unsendMessage` retracts the target row (text clears, row persists);
789
1041
  `deleteMessage` is dispatch-only with no stable history postcondition (the
790
1042
  fake conservatively preserves the row); both return `ok: false` for an
@@ -794,13 +1046,19 @@ harness (`tsx --test`, see `package.json`). Required cases:
794
1046
  - `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
795
1047
  afterward (`chat_name`/`participants`) and return `ok: false` against a DM
796
1048
  - `setGroupPhoto`/`leaveGroup` return `ok: true` against a group and `ok: false` against a DM
797
- - patched sticker, group-photo, and participant verbs return `ok:false` without mutation
798
- when their `imsg status` capability markers are absent
1049
+ - patched sticker, group-photo, participant, and chat-background verbs return `ok:false`
1050
+ without mutation when their `imsg status` capability markers are absent
1051
+ - chat background: `setChatBackground` persists a NEW guid on a DM and a group (a re-set
1052
+ changes the guid), `chatBackgroundStatus` reflects it, `removeChatBackground` clears it;
1053
+ remove on a bare chat is the explicit `no-background` skip (also with an empty guard), a
1054
+ mismatched `expectedGuid` is a preflight rejection that leaves the background (and is
1055
+ checked before the no-background branch, so a stale guard on a bare chat rejects), and an
1056
+ unknown chat never starts an effect
799
1057
  - `recentReactions` surfaces an inbound custom-emoji reaction (`reaction: 'custom'`) with the
800
1058
  real `emoji` intact
801
1059
  - Fake-only restart coverage snapshots and restores a world, then proves
802
- `sendStatus`, `checkHandle`, Name & Photo idempotency, current group metadata,
803
- fixture state, and monotonic IDs survive exactly
1060
+ `sendStatus`, `checkHandle`, Name & Photo idempotency, chat backgrounds (and their
1061
+ guid sequence), current group metadata, fixture state, and monotonic IDs survive exactly
804
1062
 
805
1063
  FakeGateway runs this suite in CI on every PR. ImsgGateway runs it as a manual runbook step on
806
1064
  the host Mac (see `docs/host-mac-control.md`) until a host-Mac CI runner exists — at that point