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
This commit is contained in:
Peter Steinberger
2026-07-17 11:13:00 +01:00
committed by GitHub
parent d9b86cb76c
commit 2920ec1fab
5 changed files with 53 additions and 12 deletions
+1
View File
@@ -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
+22
View File
@@ -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
@@ -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";
@@ -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";
+13 -1
View File
@@ -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<TEvent>(
params: ChannelReplayGuardParams<TEvent>,
): ChannelReplayGuard<TEvent> {