Never persist getUpdates offset 0 (sentinel-zero re-delivery storm)
The 2026-07-02 live test stormed David's phone. A count=0 cold-start drain (empty backlog at reconnect) persisted offset=0; the next restart trusting that 0 restored it, and the normal poll loop issued getUpdates offset=0 -> Telegram re-returned the entire ~24h backlog -> every message re-delivered -> the leader acked each -> outbound storm. 0 is Telegram's "no offset / send me everything" sentinel and is never a real update_id, so it carries no high-water information: persisting it is the defect.
- save-offset!: persist ONLY a strictly-positive high-water update_id. A non-positive value (e.g. the offset after draining an empty backlog) writes NO file and clears any existing one, so the next start cold-drains instead of restoring a sentinel 0. - load-persisted-offset: already rejected <=0 (kept + documented) so a pre-existing poisoned "0" file is treated as cold start. Defense in depth. - test: new "never persists the sentinel 0" group (63 pass total). - repro: repro-sentinel-zero.sh reproduces the storm with a control binary (models the deployed load-accepts-0 behavior) and proves the fix; mock documents the empty-backlog case; evidence-2026-07-03-sentinel-zero.md.
Verified off-device in BOTH dev (bundle) and release (native) builds; the native codegen of (> n 0) is correct. NOTHING reached real Telegram.
repro/evidence-2026-07-03-sentinel-zero.md | 39 +++++++++++++++++++++++++++++++++++++++
repro/mock_telegram.py | 5 +++++
repro/repro-sentinel-zero.sh | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/courier/poller.sgl | 43 +++++++++++++++++++++++++++++++++++--------
test/test-poller-offset.sgl | 42 ++++++++++++++++++++++++++++++++++++++++++
5 files changed, 196 insertions(+), 8 deletions(-)repro/evidence-2026-07-03-sentinel-zero.mdadded
# Sentinel-zero storm — off-device repro + fix (2026-07-03)The 2026-07-02 live storm: a count=0 cold-start drain (empty backlog)persisted offset=0; a later restart that trusts 0 restores it and thenormal poll loop issues getUpdates offset=0 -> Telegram re-returns thewhole backlog -> re-delivered -> leader acks each -> outbound storm.Fix: never persist a non-positive offset (0 is Telegram's "send everything"sentinel, never a real update_id); an empty-backlog cold start leaves NOoffset file, so the next start cold-drains instead of restoring 0. loadalso independently rejects <=0. NOTHING below touched real Telegram(poller pointed at repro/mock_telegram.py via COURIER_TELEGRAM_API_URL).## Harness (repro/repro-sentinel-zero.sh): CONTROL vs FIXED```=== FIXED (../build/dev/bin/courier) === empty cold start left offset file? (expect no): note file removed by restart drain restart message events: 0 (expect 0) PASS: fixed binary does not re-deliver=== CONTROL (/home/daviwil/Projects/Code/sigil/courier-sentinel-control/build/dev/bin/courier) — models the deployed sentinel-0 binary === restart message events: 3 (expect >0 = storm reproduced) PASS: control reproduces the storm (3 re-delivered)=== ALL CHECKS PASSED ===```CONTROL = master(6805ad1) + load accepts 0 (models the deployed binary,which live-logged 'Restored offset=0'). FIXED = this branch.## Manual verification — FIXED RELEASE (native, the deploy target)```empty cold start (native): Cold start: drained ... count=0 offset=0 persisted=no (empty backlog) -> NO offset filerestart w/ 2-msg backlog: Cold start: drained ... count=2 offset=3 persisted=yes -> 0 message eventsplanted poisoned '0' file: Cold start: drained ... count=2 -> load REJECTED 0, drained, 0 message events(native codegen of (> n 0) verified correct; dev and release behave identically)```## Unit teststest/test-poller-offset.sgl adds a 'never persists the sentinel 0' group(save 0 -> no file; save 0 clears an existing file; negative -> no file;positive still persists). Full suite: 63 passed.repro/mock_telegram.pymodified
POST /_add body: {"text":"...","sender_id":"..","chat_id":".."} append one update GET /_stats {"delivered":[ids], "sends":N}The sentinel-zero (2026-07-02) storm case is exercised by`repro-sentinel-zero.sh`: `_seed []` gives an EMPTY backlog so a cold startdrains count=0, then `_add` accumulates a backlog before a restart -- thescenario where persisting offset=0 (pre-fix) causes re-delivery.Usage: mock_telegram.py <port> <logdir>"""import jsonrepro/repro-sentinel-zero.shadded
#!/usr/bin/env bash# Off-device reproduction of the SENTINEL-ZERO storm (2026-07-02 live failure)# and proof of the fix. NOTHING here touches real Telegram: the poller is# pointed at the local mock via COURIER_TELEGRAM_API_URL.## The bug: a count=0 cold-start drain (empty backlog) persisted offset=0.# A later restart that trusts that 0 restores it and the normal poll loop# issues getUpdates offset=0 -> Telegram re-returns the entire backlog -># every message is re-delivered -> the leader acks each -> outbound storm.## Scenario for BOTH binaries:# 1. empty backlog, cold start (no offset file) -> drain, count=0# 2. a backlog accumulates (e.g. ~24h of inbound)# 3. RESTART the poller# 4. assert step 3 delivers ZERO message events## CONTROL (models the deployed buggy binary: load accepts 0) -> step 3# RESTORES offset 0 and the normal loop RE-DELIVERS -> storm.# FIXED -> step 1 leaves NO offset file (0 is never persisted), so# step 3 is a cold start that DRAINS the backlog -> no delivery.## Usage: repro-sentinel-zero.sh <fixed-courier-bin> [control-courier-bin]# Without a control bin, only the FIXED assertions run.set -uHERE="$(cd "$(dirname "$0")" && pwd)"FIXED="${1:?usage: repro-sentinel-zero.sh <fixed-bin> [control-bin]}"CONTROL="${2:-}"PORT="${PORT:-19877}"WORK="$(mktemp -d)"RELAY="$WORK/relays"OFF="$WORK/telegram-offset" # = <dirname(relay-dir)>/telegram-offsetmkdir -p "$RELAY" "$WORK/logs"cleanup () { [ -n "${MOCK:-}" ] && kill -9 "$MOCK" 2>/dev/null; rm -rf "$WORK"; }trap cleanup EXITpython3 "$HERE/mock_telegram.py" "$PORT" "$WORK/logs" >"$WORK/mock.out" 2>&1 &MOCK=$!sleep 1.5curl -sf -m2 "http://127.0.0.1:$PORT/_stats" >/dev/null || { echo "FAIL: mock not up"; cat "$WORK/mock.out"; exit 1; }seed_empty () { curl -s -m2 -X POST "http://127.0.0.1:$PORT/_seed" -d '{"updates":[]}' >/dev/null; }add () { curl -s -m2 -X POST "http://127.0.0.1:$PORT/_add" -d "{\"text\":\"$1\"}" >/dev/null; }run () { # $1=bin $2=tag env COURIER_TELEGRAM_TOKEN=tok COURIER_TELEGRAM_API_URL="http://127.0.0.1:$PORT" \ COURIER_RELAY_DIR="$RELAY" \ timeout 6 "$1" --telegram-poller < <(sleep 12) >"$WORK/$2.out" 2>"$WORK/$2.err"}msgs () { local c; c=$(grep -c '"type":"message"' "$WORK/$1.out" 2>/dev/null); echo "${c:-0}"; }scenario () { # $1=bin $2=label ; echo message-event count on restart seed_empty; rm -f "$OFF" run "$1" "${2}_cold" add OLD-1; add OLD-2; add OLD-3 run "$1" "${2}_restart" echo "$(msgs ${2}_restart)"}rc=0echo "=== FIXED ($FIXED) ==="n=$(scenario "$FIXED" fixed)coldfile_exists=$([ -e "$OFF" ] && echo yes || echo no)echo " empty cold start left offset file? (expect no): note file removed by restart drain"echo " restart message events: $n (expect 0)"if [ "$n" -eq 0 ]; then echo " PASS: fixed binary does not re-deliver"; else echo " FAIL: fixed binary re-delivered $n"; rc=1; fiif [ -n "$CONTROL" ]; then echo "=== CONTROL ($CONTROL) — models the deployed sentinel-0 binary ===" n=$(scenario "$CONTROL" control) echo " restart message events: $n (expect >0 = storm reproduced)" if [ "$n" -gt 0 ]; then echo " PASS: control reproduces the storm ($n re-delivered)"; else echo " FAIL: control did not storm"; rc=1; fifiecho "=== $( [ $rc -eq 0 ] && echo ALL CHECKS PASSED || echo CHECKS FAILED ) ==="exit $rcsrc/courier/poller.sglmodified
(path-join (path-dirname (default-relay-dir)) "telegram-offset")) ;; Persisted offset, or #f when the file is absent, unreadable, or ;; not a positive integer. #f means "cold start" -> drain, don't ;; deliver, the pre-existing backlog. ;; not a STRICTLY POSITIVE integer. #f means "cold start" -> drain, ;; don't deliver, the pre-existing backlog. Rejecting <= 0 is ;; load-bearing: 0 is Telegram's "no offset / send me everything" ;; sentinel and carries no high-water information, so a restored 0 ;; would re-fetch and re-deliver the whole backlog (the 2026-07-02 ;; live storm). Telegram update_ids are large positive integers and ;; are never 0, so a 0/negative on disk is always a bug, not a resume ;; point -> treat it as cold start and drain. (define (load-persisted-offset) (let ((path (offset-file-path))) (and (file-exists? path) (let ((n (string->number (string-trim (read-file-string path))))) (and (integer? n) (> n 0) n)))))) ;; Persist the offset. Best-effort: a failed write only risks a ;; bounded re-delivery on the next restart, never a crash. (define (save-offset! n) ;; Remove the offset file, so the next start is a cold start (drain) ;; rather than restoring a stale/sentinel value. (define (clear-offset-file!) (guard (e (else #f)) (ensure-directory (path-dirname (offset-file-path))) (write-file-string (offset-file-path) (number->string n)))) (let ((path (offset-file-path))) (when (file-exists? path) (delete-file path))))) ;; Persist the offset -- but ONLY a real (strictly positive) ;; high-water update_id, which is the only durable resume state. ;; A non-positive n (e.g. the offset after draining an EMPTY backlog, ;; which stays 0) must NOT be written: persisting 0 seeds exactly the ;; sentinel that re-delivered the backlog on the next restart ;; (2026-07-02). Instead we clear any existing file so the next start ;; cold-drains. Best-effort: a failed write only risks a bounded ;; re-delivery next restart, never a crash. (define (save-offset! n) (if (and (integer? n) (> n 0)) (guard (e (else #f)) (ensure-directory (path-dirname (offset-file-path))) (write-file-string (offset-file-path) (number->string n))) (clear-offset-file!))) ;; Cold start: advance the offset past every currently-pending ;; update WITHOUT delivering any of them, so deploying (log-info "Restored Telegram getUpdates offset" offset: saved)) (let ((drained (drain-backlog! bot))) (let ((offset (tg-client-last-update-id (tg-bot-client bot)))) ;; save-offset! persists ONLY a positive high-water mark. ;; An empty backlog leaves offset 0 -> no file is written ;; (any stale one is cleared), so the next start cold-drains ;; again instead of restoring a sentinel 0 and re-delivering. (save-offset! offset) (log-info "Cold start: drained Telegram backlog (not delivered)" count: drained offset: offset)))))) count: drained offset: offset persisted: (if (> offset 0) "yes" "no (empty backlog)"))))))) ;;; Entry point for `courier --telegram-poller`. ;;;test/test-poller-offset.sglmodified
(write-file-string (offset-file-path) "57\n") (assert-equal 57 (load-persisted-offset))))));; ============================================================;; Sentinel-zero hole (the 2026-07-02 live storm);; ============================================================;;;; A count=0 cold-start drain leaves the offset at 0. Persisting 0 seeds;; Telegram's "no offset / send me everything" sentinel; a later start;; that trusts it re-fetches and re-delivers the whole backlog. So;; save-offset! must NEVER write a non-positive value, and must clear any;; existing file so the next start cold-drains instead.(test-group "save-offset! — never persists the sentinel 0" (test "saving 0 writes NO file" (with-temp-offset (lambda (tmp) (save-offset! 0) (assert-false (file-exists? (offset-file-path))) (assert-false (load-persisted-offset))))) (test "saving 0 CLEARS a previously-persisted offset" (with-temp-offset (lambda (tmp) (save-offset! 99) (assert-equal 99 (load-persisted-offset)) ;; e.g. a later empty-backlog cold start (save-offset! 0) (assert-false (file-exists? (offset-file-path))) (assert-false (load-persisted-offset))))) (test "saving a negative value writes NO file" (with-temp-offset (lambda (tmp) (save-offset! -3) (assert-false (file-exists? (offset-file-path))) (assert-false (load-persisted-offset))))) (test "a positive offset still persists and reloads" (with-temp-offset (lambda (tmp) (save-offset! 7) (assert-true (file-exists? (offset-file-path))) (assert-equal 7 (load-persisted-offset))))))(run-tests)