From 2920ec1fabfcf6f9b92f00a3ac5e98c1e7264695 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 11:13:00 +0100 Subject: [PATCH] docs(channels): settle replay-guard vs ingress-drain layering contract (#109799) * docs(channels): settle replay-guard vs ingress-drain layering contract in code comments and SDK docs * docs: refresh SDK channel docs map --- docs/docs_map.md | 1 + docs/plugins/sdk-channel-plugins.md | 22 +++++++++++++++++++ .../imessage/src/monitor/inbound-dedupe.ts | 21 +++++++++--------- .../telegram/src/message-dispatch-dedupe.ts | 7 +++++- src/plugin-sdk/persistent-dedupe.ts | 14 +++++++++++- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/docs/docs_map.md b/docs/docs_map.md index 54ffaebc808f..51fea736e82d 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -7360,6 +7360,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What your plugin owns - H2: Message adapter - H3: Inbound ingress (experimental) + - H3: Durable ingress and replay dedupe - H3: Typing indicators - H3: Media source params - H3: Native payload shaping diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 3bcec2c3eb23..aa73aec0fd82 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -94,6 +94,28 @@ the resolved state or decision. See [Channel ingress API](/plugins/sdk-channel-ingress) for the API design, ownership boundary, and test expectations. +### Durable ingress and replay dedupe + +Channels adopting the durable ingress drain follow the Telegram reference +pattern: enqueue the raw transport envelope at a single receive chokepoint +(no normalization at receive time), gate the transport ack on the durable +append for webhook transports, derive one serialized lane per conversation, +and mark the event complete at dispatch adoption. The queue's primary key is +`(queue_name, event_id)` and completion tombstones the row instead of +deleting it, so a late platform redelivery of the same `event_id` is rejected +durably for the tombstone retention window. + +That tombstone is the layering rule for replay guards +(`openclaw/plugin-sdk/persistent-dedupe`): a drained channel keeps a separate +replay guard only when the guard's identity or retention exceeds the queue's +— a logical message key that differs from the transport delivery id (Telegram +dedupes `chat_id:message_id` because debounce merges can re-surface a message +under a fresh `update_id`), or a longer window than the channel's tombstone +retention. If your guard key would equal the drain `event_id`, delete the +guard when adopting the drain and size `completedTtlMs`/`completedMaxEntries` +to cover the old guard window instead. Non-dedupe protections (age fences, +outbound echo caches) are unrelated to this rule and stay. + ### Typing indicators If your channel supports typing indicators outside inbound replies, expose diff --git a/extensions/imessage/src/monitor/inbound-dedupe.ts b/extensions/imessage/src/monitor/inbound-dedupe.ts index a91b88c02166..38baf0c60bf7 100644 --- a/extensions/imessage/src/monitor/inbound-dedupe.ts +++ b/extensions/imessage/src/monitor/inbound-dedupe.ts @@ -1,15 +1,16 @@ -// iMessage inbound replay protection: brings the channel in line with the -// other channels (whatsapp/discord/signal/...) by deduping inbound messages on -// a stable identity, plus an age fence that suppresses stale backlog Apple -// delivers in a burst after a bridge/Push recovery. +// iMessage inbound replay protection: GUID dedupe on a stable identity, plus +// an age fence that suppresses stale backlog Apple delivers in a burst after a +// bridge/Push recovery. // -// Why both: +// Why both, and what survives ingress-drain adoption: // - The GUID dedupe stops a message that was already dispatched from being -// dispatched again when imsg re-emits a recent row on reconnect. -// - Dedupe cannot catch a message that was *never seen* (the gateway was down -// when it was sent). Apple writes that backlog into chat.db with a fresh -// ROWID but the original (old) send date, so it arrives on the live watch as -// a "new" row. The age fence is what recognizes it as stale. +// dispatched again when imsg re-emits a recent row on reconnect. It is the +// transitional layer: if this channel adopts the durable ingress drain with +// event_id = GUID, the queue tombstone owns this job and the dedupe goes. +// - The age fence is NOT a dedupe and stays regardless. It catches messages +// the gateway *never saw* (sent while down): Apple writes that backlog into +// chat.db with a fresh ROWID/GUID but the original old send date, so no +// dedupe or tombstone can recognize it — only the send-date fence does. import { createHash } from "node:crypto"; import { createChannelReplayGuard } from "openclaw/plugin-sdk/persistent-dedupe"; import type { IMessagePayload } from "./types.js"; diff --git a/extensions/telegram/src/message-dispatch-dedupe.ts b/extensions/telegram/src/message-dispatch-dedupe.ts index cfcb0892ff34..3ff4eaa30d4c 100644 --- a/extensions/telegram/src/message-dispatch-dedupe.ts +++ b/extensions/telegram/src/message-dispatch-dedupe.ts @@ -1,4 +1,9 @@ -// Telegram plugin module implements message dispatch dedupe behavior. +// Telegram dispatch dedupe: a PERMANENT second layer above the ingress spool, +// not a leftover to delete on drain adoption. The spool tombstones transport +// update_ids; debounce/media-group flushes merge N update_ids into one +// dispatched turn, so a constituent message re-arriving under a *fresh* +// update_id is invisible to the update_id tombstone. This guard keys the +// logical (chat_id, message_id) — the only identity that catches that replay. import path from "node:path"; import type { Message } from "grammy/types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; diff --git a/src/plugin-sdk/persistent-dedupe.ts b/src/plugin-sdk/persistent-dedupe.ts index 2334cbf9f3a1..6b1df5f442e4 100644 --- a/src/plugin-sdk/persistent-dedupe.ts +++ b/src/plugin-sdk/persistent-dedupe.ts @@ -678,7 +678,19 @@ export function createClaimableDedupe( }; } -/** Create an event-keyed replay guard whose claims own their settlement handles. */ +/** + * Create an event-keyed replay guard whose claims own their settlement handles. + * + * Layering contract vs the durable ingress drain (`src/channels/message/ingress-queue.ts`): + * the drain already rejects duplicate event ids durably — `complete()` tombstones the row + * and enqueue is `ON CONFLICT DO NOTHING` for the tombstone retention window. A replay + * guard on a drained channel is justified only when its identity or retention exceeds the + * queue's: a *logical* message key that differs from the transport delivery id (Telegram: + * `chat_id:message_id` vs `update_id` — debounce/media-group merges can re-surface a + * constituent message under a fresh update_id only the guard sees), or a window longer + * than the channel's tombstone retention. If the guard key would equal the drain event_id + * and retention fits the tombstone window, delete the guard when adopting the drain. + */ export function createChannelReplayGuard( params: ChannelReplayGuardParams, ): ChannelReplayGuard {