anthropics / anthropics/claude-plugins-official

iMessage channel replays old messages as new when chat.db backfills: the ROWID watermark assumes insert order equals chronological order

Open
#5,224 3 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
36.3k
Forks
4.1k
Avg merge
2d 14h
Merged PRs (30d)
539

Description

## Summary

The iMessage channel periodically delivers large batches of old messages as if they were newly received, out of chronological order. Seven occurrences over five weeks in a continuously-running deployment, the largest being roughly 90 messages replayed in a single burst.

The root cause is that `ROWID > watermark` is treated as equivalent to "newer than what we have already seen." In `chat.db`, ROWID is assigned at local insert time, not by the message's own date. Any process that inserts old messages into chat.db after the fact gives them a fresh ROWID above the current watermark, and the poller then correctly-per-its-own-logic delivers day-old content as new.

## Environment

- Plugin: `imessage@claude-plugins-official`, version 0.1.0 (current; the plugin directory has no commits since 2026-03-30)
- macOS Ventura, always-on headless Claude Code session, channel up for weeks at a time
- Messages in iCloud: ON. Multiple Apple devices on the same Apple ID.

## Mechanism

From `external_plugins/imessage/server.ts` on `main`:

```ts
let watermark = qWatermark.get()?.max ?? 0
```

```ts
function poll(): void {
let rows: Row[]
try {
rows = qPoll.all(watermark)
} catch (err) {
process.stderr.write(`imessage channel: poll query failed: ${err}\n`)
return
}
for (const r of rows) {
watermark = r.rowid
handleInbound(r)
}
}
```

`qPoll` is `ROWID > ? ORDER BY ROWID ASC`. Three properties combine into the bug:

1. The watermark is in-memory only and is never persisted.
2. There is no dedup by message `guid`. The `guid` is read but never used to suppress a repeat delivery.
3. There is no staleness filter. A message whose `date` is 24 hours old is delivered identically to one that is 2 seconds old.

So the only thing standing between the session and a replay is the assumption that ROWID order tracks message date. chat.db does not guarantee that.

Note on the README: it documents the watermark as the anti-replay mechanism ("Watermark initializes to MAX(ROWID) at boot -- old messages aren't replayed on restart"). That statement is correct as far as *restart* goes. The gap is that restart is not the only way old rows appear above the watermark, and the README's framing led us to treat replay as impossible for several weeks longer than we should have.

## Two observed triggers

**A. iCloud multi-device sync backfill.** Historical messages sync into local chat.db well after they were sent, with fresh ROWIDs. This produces the large, days-spanning floods. Hard to reproduce on demand.

**B. Local Messages.app send-flush.** This is the better reproducer. Messages.app got stuck holding queued outbound sends, then flushed them into chat.db all at once roughly an hour later. Every flushed row landed above the watermark and was delivered inbound. The channel's own prior outbound was handed back to the session as if freshly received. No iCloud sync and no restart involved.

Trigger B suggests a reproduction path that does not require waiting on iCloud: induce a send backlog in Messages.app (network interruption during sends), let it flush, and observe the poller deliver the flushed rows.

## Occurrence log

Times are local. Content descriptions are generalized deliberately.

| Date | Scale | Notes |
|---|---|---|
| 2026-07-07 | batch from prior day | first observed |
| 2026-07-08 ~17:00 | dozens | spanned 2 days; included prior outbound |
| 2026-07-11 | dozens | spanned ~1.5 days |
| 2026-07-13 | several | out-of-order arrival, mixed with genuinely new messages |
| 2026-08-10 | large | landed mid-turn during an unrelated live task |
| 2026-08-11 ~07:49 | 6 | trigger B; ~1h old; exact 1:1 match to that morning's outbound |
| 2026-08-11 ~21:22 | ~90+ | largest observed; a full prior day of conversation |

Two occurrences on the same day, with the largest arriving roughly seven hours after the smallest.

## Impact

For a passive chat client, replay is noise. For an assistant that acts on messages, it is worse than noise, because every replayed message is a plausible-looking instruction that was already carried out once:

- **Re-execution risk.** Replayed content includes past requests to file, book, record, and edit. Acting on any of them duplicates a real side effect. Our deployment has avoided this seven times out of seven by recognizing the floods, but the mitigation is the model noticing stale timestamps, which is vigilance rather than a control.
- **Approval-token replay.** Our confirm-flow's tokens ride the same channel, so floods carry old approval tokens back inbound. Ours are independently safe (single-use, short TTL, and the verifier re-reads chat.db with an `is_from_me = 0` filter rather than trusting the channel), but any consumer that treats channel input as authoritative would be exposed here.
- **Unbounded cost.** Nothing in the design bounds a flood's size. It is proportional to whatever chat.db backfills. A full library resync would be delivered in its entirety, one inbound turn at a time.

## Interaction with #3993 -- please read these together

#3993 asks for the opposite symptom: messages arriving while the channel is down are lost, because the watermark resets to `MAX(ROWID)` on boot. Both reports are correct, and they are the same design defect seen from two sides.

The important part: **that boot-time reset is currently the only bounded recovery available to operators.** Because the watermark jumps to the current max on every start, restarting the channel puts it above any flood, and the same rows can never be redelivered. Today, a restart is a reliable stop button for a runaway replay.

Persisting the cursor across restarts, on its own, removes that stop button. A large backfill would then be replayed *and* survive restarts, because the persisted watermark sits below the backfilled rows and the poller would faithfully work through them again on every boot. A bounded annoyance becomes an unbounded one.

To be fair to #3993: its author already proposes dedup and a bounded replay window alongside persistence, which addresses this. The risk is only if the headline ask ("persist the watermark") gets implemented without those two parts. Please treat them as inseparable.

## Suggested fix

In rough priority order:

1. **Dedup by message `guid`**, persisted. This is the fix that addresses both issues and is the only one that is correct regardless of ROWID behavior.
2. **Filter on the message's own `date`**, not just its ROWID. Do not deliver anything older than a configurable window; log the suppression rather than dropping silently.
3. **Persist the cursor** (per #3993) -- but only together with 1 and 2.
4. Optionally, treat a poll returning an unusually large row count as suspicious and surface it as a channel-level warning rather than delivering it as N inbound turns.

## Meanwhile

If anyone else hits this: restarting the channel process is currently an effective stop button, for exactly the reason described above. That will stop being true if the cursor becomes persistent without dedup.

Happy to test a patch against a deployment that reproduces this regularly.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start in external_plugins/imessage/server.ts, reading poll(), qPoll, qWatermark, and the README's watermark description. Compare the replay behavior with issue #3993 and reproduce the delayed-send path if possible. Done means backfilled or delayed rows no longer arrive as new messages, while the recovery behavior and restart stop button are addressed together rather than by cursor persistence alone.

Written by the indexing model from the issue text.

Assessment

Tech stack
sql, typescript
Domain
backend, databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.