Three places disagreed with AGENTS' own level rules: get_chat/get_chat_administrators failures during /set_channel printed at error even though the usual cause is a mistyped channel name the user fixes on the spot (error is reserved for dead letters and the like — now warn, same as other user-served degradations); the edit-before-forward callback arrival line logged at info while the sibling arrival lines for commands and inline queries log at debug (now debug, still carries the escaped payload); and the two upload-fallback lines logged at info while photo.rs's parallel degradations log at warn (now warn both — Telegram failing to fetch our media is a degradation that leaves the user served). AGENTS' convention sentence is now explicit about all three instead of the ambiguous '(fallback, retry enqueue, dead-letter is error)'.
The per-request CLIENT timeout and the per-chunk DOWNLOAD_IDLE_TIMEOUT both reset on progress, so neither bounded the fetch as a whole: a URL that keeps dripping (a trickle the idle timeout reads as life) could hold one of the eight FETCH_SLOTS for effectively ever, and the queue behind the slots is what then stalls. The attempts loop now runs inside tokio::time::timeout(900 s) — generous for a genuinely large ugoira zip on a honest slow link (minutes), fatal for a drip — and answers Transient with the cache key, so the retry and its jittered backoff take over. The permit is taken outside the timeout: the slot is released on either path. Audit low finding 'url_workers long tail' (process-wide total-deadline option).
download_to_temp guarded against a file id, but prepare_upload_item answered a non-http media_url first — and item_url maps FileId to the id itself, so the id was presented as a local *path* and died later with a confusing file-open error instead of a classification. The guard now sits at the entry, download_to_temp takes the already-narrowed URL (the inner guard deleted as dead), and the test pins the refusal as Permanent before any I/O. The file also carries item10's download-classification arm (RateLimited is retryable there too) — same file, landed here to keep each tree compiling.
Two gaps in fetch_with_attempts: the sleep was the bare 1 << attempt, so every worker that failed together (a source coming back, a shared proxy blip) also recovered on the same tick and re-stamped the source; and a429 arrived as an anonymous Transient, its Retry-After header — the one place a source tells you exactly how long it wants silence — dropped on the floor.
retry_wait(attempt, roll, err) is the pure decision: doubling base plus a random slice of itself ([base, 2x base)), floored at a named Retry-After. status_error now takes the response, reads the seconds-form header and returns the new FetchError::RateLimited { site, retry_after_secs } (HTTP-date parses to None and stays transient); the pure table moved to classify_status so tests still build it from bare status codes. The delay is capped at MAX_RETRY_AFTER_SECS = 60 — the header is server-supplied and must not park one of the eight fetch slots.
Everywhere a Transient meant 'retryable' the new variant joins: the Site trait default, pixiv's override (plus its ugoira-zip mapping), bsky's HLS second attempt, the download classifier, and the user-facing message arm. Tests pin the429 rows (with and without the header, cap included) and retry_wait's math; AGENTS' retries bullet and variant list follow.
A non-photo body charged the process-wide budget for as long as download_to_temp held it; a photo charged nothing until its prepare step, so the download itself — up to MAX_PHOTO_DOWNLOAD_BYTES per item, six items per batch, eight prep slots process-wide — was memory the MEMORY_BUDGET comment promised was bounded but was not (~6x32 MiB on top of the accounted512 MiB). Photos now reserve their own download cap for that window; the prepare step still charges header probe + decode buffer, the only overlap that is actually held in memory together. Net accounting stays within the declared budget instead of exceeding it whenever a batch of large photos downloads at once (audit: upload.rs photo window uncharged).
send_media_group, send_animation and copy_messages paced themselves against both buckets; send_message and send_chat_action paced against neither. A dead-letter storm — or one error reply per failed item, plus the action refresh loop's repeated typing requests — could therefore burst past Telegram's per-bot30/s ceiling with only429s left to absorb it, exactly what the bot-wide bucket exists to prevent. Both now acquire one global token like their siblings (per-chat pacing for messages already happens at their call sites; chat actions are cheap and frequent, so only the global bucket applies to them).
plan_photo, target_dims and both pipeline branches added the two u32 dimensions before comparing against PHOTO_MAX_DIMENSION_SUM: u32::MAX + 2 wrapped to 1 and read 'within limits' — the plan said AsIs, and an absurd-sized image would go to Telegram untouched (in debug builds the addition panics instead). The four production sums now widen to u64 first; the test pins the wrap case as TooLarge. PNG caps each dimension at 2^31-1, so a spec-valid file cannot reach this today — the raw header is parsed before any crate validation, which is exactly where a hostile file lands (audit: photo.rs u32 wrap).
A button press on an edit-before-forward prompt already dropped records past EDIT_MESSAGE_TTL lazily (before the 300 s sweep clears them); a text reply to the same prompt did not — it rewrote the caption from a record the sweep was about to treat as dead, so the two paths disagreed about what 'expired' means. The reply path now applies the same rule: drop the stale record, return false, and let the message flow on as if no prompt existed. The test seeds a prompt90000 s old (past the TTL under any config a parallel test can hold) and pins all three effects: no edit consumed, no API call, record gone.
set()/update() logged a failed DB write and returned nothing: the cache already held the new value, so /set_format answered 'Format set' for a change that vanishes on the next restart — a promise the retry queue's enqueue path is already forbidden from making. Both now return whether the write landed, and /set_format's two save paths (set and reset) answer 'in memory only … lost on restart' when it did not. The flag travels as the second tuple element; callers that only relay the value destructure it, callers that ignore it are untouched. A false cannot be forced in a test without a pool-failure seam, so the honest path is pinned by the code, not a fixture.
LOCAL_USER_ID=0 passed every check and reached `setpriv --reuid=0`: the bot would run root while looking properly configured, and useradd -o accepted the duplicate uid without complaint. A non-numeric value failed later inside useradd behind `|| true`, which hid the real cause. The entrypoint now rejects both up front with a message naming the variable, and the id/uid expansions are quoted so a value with spaces cannot word-split into extra useradd arguments (audit SEC-007).
Verified by sourcing the entrypoint under a faked `id`: uid 0, 'abc' and '12 3' each exit 1 with the refusal line, while a non-root caller passes straight through to exec. AGENTS and .env.example state the constraint.
A Telegram display name, callback payload or channel handle is attacker-controlled text, and it went straight into info/error lines: one embedded newline forged a second log entry, and control characters could hide inside a line (log injection, audit SEC-006). handlers::log_escape replaces newlines, carriage returns and other control characters with visible escapes — clean input borrows, so logging allocates nothing extra — and every site that prints such a value uses it: the callback arrival line, the set-forward-channel info line (name + handle) and both get_chat error lines. The test pins that no raw newline can survive; AGENTS' logging convention names the helper.
The companion was the file's only unpinned image: it runs with a rw docker.sock, so 'latest' let tomorrow's upstream change deploy itself on the next pull, with full visibility into this stack's containers and environment. 2.8.2 is the release paired with the already-pinned nginx-proxy 1.11.6 — the two projects cut releases a day apart (1.11.6 on 2026-07-16, 2.8.2 on 2026-07-17).
Five statements had drifted from the repo:
- the test-suite comment claimed no CI test step exists; ci.yml has run
cargo test --workspace --locked all along (the audit's H5).
- the untested list still named config.rs as fully untested although three
parsing tests pin its webhook truth table, TTL fallback and blank secrets.
- the commands.rs executor was said to need a real Bot; its three tests run
through the scripted MockSender, and it joins the driven-through-mocks list.
- model.rs was called untested although every adapter fixture deserializes
into those DTOs.
- the volume line claimed ./cert is mounted; the shipped compose mounts only
./data, and WEBHOOK_CERT (a Telegram-facing self-signed upload) needs the
operator to add mount and env line themselves, which .env.example already
says. The live-test census also gained site/download.rs (2) and the bot
crate's repair.rs/urls.rs pair.
apply_media_headers had exactly one caller — media_request, the documented 'one choke point' every download already goes through — so the site-header walk sits inside that choke point now instead of one indirection past it, and the two doc comments merge into the one that describes both rules (guard + headers).
Every adapter built RenderData { url } from the exact value it then moved into Fetched.source_url — a copy of the same canonical URL held in two places, feeding one placeholder. caption_with reads {url} from self.source_url now and the field is gone from the struct (bsky's and twitter's halves of this change landed with their caption commits; their render builders no longer set it either).
A one-line saturating_sub wrapper exported for one production line (the prompt text) and two assertions — the expression is shorter than its name at every use.
All three callers passed item.thumbnail_url() as the fourth argument — a parameter that could never vary without defeating its own purpose, and only the video arm ever reads it. The function takes the item already; it asks the item. Three call sites lose an argument.
photo_media, video_media and animation_media each wrapped the same five lines (build the kind, attach the caption, attach the spoiler) for exactly one caller — media_from's own match arms. The arms carry those lines now; three pub(super) functions and their call indirection are gone, and the dispatch the doc already described as 'the one place' actually is the one place.
Tweet::caption duplicated site::caption byte for byte for non-empty text (and dropped the shared empty-text rule, leaving a dangling ': ' behind the author link when a tweet's text comes out empty after link expansion). It delegates now, like bsky's Post::caption a commit earlier and bilibili/misskey before them; encode_double_quoted_attribute loses its last twitter user.
Post::caption hand-formatted exactly the string site::caption produces — same template, same three escapes (the shared fn even handles the empty-text case these two lines did not: no dangling ': ' after the author link). One line now, matching what bilibili and misskey already do; the encode_double_quoted_attribute import loses its last bsky user.
InlineKind mirrored CachedMediaKind variant for variant (Photo/Video/Gif) and existed only to feed url_result, with two conversions kept in step: Media → InlineKind at the fetch call and CachedMediaKind → InlineKind at the cache call (plus the enum and the mapper). url_result takes CachedMediaKind now — the type the cache path already carries and the fetch path maps to with the same three-arm match — and the degraded-video skip collapses to a guard before the call. One enum fewer between a media kind and its Telegram result.
periodic_sweep listed sender, chat_store, link_cache, task_queue and config as separate parameters — exactly the five fields AppContext already carries, and exactly what handle_message and the workers pass around as one value. It takes &AppContext now (production via from_statics(&bot), the test via stores.ctx(&sender), which that test already had); CHAT_STORE and LINK_CACHE lose their last direct use in main and leave the import. The collaborator bundle is unchanged, so the paused-clock test drives the same loop.
ffmpeg_available() and log_once_ffmpeg_missing() only ever appeared together (both encoders, three lines each: check, log, return), and calling one without the other was a bug in waiting — a probe that never logged, or a log that never gated. ffmpeg_missing() is the single gate: false when the binary is there, otherwise log once per process and say yes. Both call sites shrink to one condition.
find_site walked the registry with "enabled && matches" and disabled_site walked it again with "!enabled && matches" — two passes re-running the same regexes to answer one question. matching_site finds the first match and the fetch decides what a disabled site means; the patterns are disjoint (one domain each), so "first enabled match" and "first match, then check" never disagree.
site_id_from_key split the prefix out and then walked the registry to confirm the prefix was a registered site id — a round trip over a value cache_key itself produced from that registry: the unknown and no-colon branches were unreachable for any key the bot makes. The single caller (the link-cache hit path) splits the prefix directly; the registry-echo test and the misskey assertion of it go with it.
photo_plan wrapped the header dispatch for exactly one consumer (decode_budget_bytes); plan_photo itself has three callers and stays. The dispatch is inlined into the budget function with its doc merged, saving the wrapper's signature, doc duplication and call indirection.
parse_png_header hand-decoded the IHDR — byte offsets, the depth byte's five legal values, the color byte's five — roughly thirty lines the png crate already implements (and validates properly: CRC included). It is now Decoder::new + read_info, which is all the header a plan needs; no pixels are decoded. The synthetic test fixture gained the IHDR CRC and an IDAT header (read_info stops at the first IDAT; the hand parser stopped four bytes earlier and checked no CRC), with a nine-line reflected CRC-32 alongside it; the assertions on width/height/depth/color are unchanged, as are the real-file cases.
Two template landmines for anyone running docker compose from the tracked files. (1) LOCAL_USER_ID defaulted to 1000 in compose and .env.example while the entrypoint and both READMEs say 9001 — the ./data owner on the host silently depended on which doc you read; all four now say 9001. (2) The healthcheck probed WEBHOOK_PORT unconditionally, so the template's own WEBHOOK=false (polling, no listener) shipped a permanently unhealthy container — the probe is now conditional on the interpolated WEBHOOK value (test '<v>' != true || exec 3<>/dev/tcp/…): polling deployments answer healthy without a port, webhook deployments still surface a dead listener to the orchestrator. Both expressions verified locally; compose YAML parses.
Config::load had exactly one test (BOT_ADMIN parsing) while carrying the branches an operator is most likely to mistype: the WEBHOOK truth table (case-insensitive true|yes|1 — 'on' must not enable it), an unparseable EDIT_MESSAGE_TTL_SECONDS (warn + default, not a silent 0 that expires prompts instantly), and empty WEBHOOK_CERT/WEBHOOK_SECRET_TOKEN (compose injects ${VAR:-} as an empty string, which must read as unset, not as a one-character secret). Three table-style tests reuse the existing env save/restore pattern; none of them asserts a field another test reads, so they are safe under parallel execution.
The pull_request paths filter listed only Dockerfile/entrypoint/.dockerignore/Cargo.{toml,lock}/workflow/crate manifests, so a PR changing only Rust sources never triggered the build-only check — and the Dockerfile's stub-plus-touch layering is exactly the thing that breaks against source structure it has never seen (a new crate directory, a build script). Such PRs now get the check before master/tag reveals the problem; crates/**/Cargo.toml is subsumed by crates/**.
Three holes in the live job. (1) It ran -p x-media only, on the comment that everything network-gated lives there — false: xmedia-bot carries two #[ignore]d live tests (repair refetch, text-only link) that CI never executed; both crates run now, still behind the 'live' name filter, and the bot crate's tests need no token (their sends go through MockSender). (2) BILIBILI_COOKIE was never passed, so the bilibili live tests always hit the risk-control early return. (3) That early return — like the pixiv download test's token gate — printed an indistinguishable 'skipping:' line that --show-output never showed, so with continue-on-error a fully-skipped weekly run read exactly like full coverage; skips now print a 'SKIP ' prefix, the steps tee with --show-output, and a summary step lists every skip in the run summary plus a workflow warning. The redundant 'Run token-gated tests' step (the whole non-ignored suite, a rerun of the test job) goes away with the comment that justified it.