` в feedgen 1.0.0 версии НЕ содержит — его нормализация не
+ обязательна, оставлена как дешёвая страховка от апгрейда библиотеки;
+- фикстуры не содержат постов, где `pubDate` берётся из `datetime.now()`
+ (это только None-date случай — он исключён, см. выше);
+- порядок merged-флагов ДО этапа 2 недетерминирован: `list(set(...))`
+ (rss_generator.py:260–265) зависит от PYTHONHASHSEED процесса, который из
+ conftest не запинить — содержимое ``
+ нормализуется сортировкой при сравнении golden; нормализация снимается
+ коммитом этапа 2 со ссылкой на §3.8;
+- ключ подписи media-URL пинится autouse-фикстурой
+ `monkeypatch.setattr(KeyManager, "signing_key", ...)` (образец —
+ tests/test_new_media_types.py): иначе golden, снятый на dev-машине,
+ краснеет на CI — свежий checkout генерирует новый `secrets.token_hex`
+ в `data/media_digest.key`, и все digest'ы в URL меняются;
+- фикстуры подаются monkeypatch'ем `tg_cache.cached_get_chat` /
+ `cached_get_chat_history` — lazy-import внутри фид-функций разрешает имена
+ поздно, поэтому патч модуля tg_cache работает (образец —
+ test_stage4_eventloop.py);
+- запись media-id пинится: monkeypatch `upsert_media_file_ids_bulk_sync`
+ (или `DB_PATH` → tmp_path) — иначе golden-тесты с медиа-фикстурами пишут
+ в реальную `./data/media_file_ids.db` (`DB_PATH` cwd-относителен,
+ file_io.py:13); байты фида это не меняет (flush глотает ошибки), но
+ side-effect вне tests/ недопустим; образец — test_stage4_eventloop.py:164;
+- фикстура time-based кластера требует `time_based_merge=True`: ключ читает
+ только `rss_generator.Config` — его патча достаточно; `post_parser.Config` —
+ независимый dict из того же `get_settings()`, патчить оба — дешёвая
+ страховка от будущего дрейфа, но не необходимость.
+
+Следствие пина TZ=UTC: дельту §3.7 (сдвиг TZ даты merged-футера) golden
+физически не видит — она верифицируется выделенным тестом этапа 2 с не-UTC TZ.
+
+Оракул эквивалентности всех этапов. Обновление golden — только коммитом со
+ссылкой на пункт §3. Ожидаемые изменения: этап 1 (пп.1, 3, 4; пп.2 и 5 —
+fail-closed ветки, golden их НЕ видит — верифицируются юнит-тестами
+sanitizer), этап 2 (пп.6, 8, 15 + снятие нормализации флагов), этап 4
+(добавление None-date фикстуры в HTML-golden, пп.11–12), этап 5b (п.14 — фикстура
+webpage-без-фото меняет и feed-level байты: до 5b незакрытый div
+дозакрывался bleach'ем в конце фрагмента).
+
+DoD: снапшоты в репо, тест сравнения зелёный и детерминированный (два прогона
+подряд — идентичные байты), любое изменение рендера валит его с внятным диффом.
+
+### Этап 1 — `sanitizer.py` + санитайз в пайплайне (ишью #22, обновить)
+
+```python
+# sanitizer.py — the ONLY bleach configuration in the project.
+ALLOWED_TAGS = ['p', 'a', 'b', 'i', 'strong', 'em', 's', 'del',
+ 'ul', 'ol', 'li', 'br', 'div', 'span',
+ 'img', 'video', 'audio', 'source'] # union of the 3 old copies
+ALLOWED_ATTRIBUTES = { ... } # identical in all 3 copies — move as-is
+ALLOWED_CSS_PROPERTIES = ["max-width", "max-height", "object-fit", "width", "height"]
+ALLOWED_PROTOCOLS = ['http', 'https', 'tg'] # non-default! tg:// links in footers
+
+# Shared instance is safe: CSSSanitizer only holds the allowed-properties list
+# (stateless config); bleach builds a fresh Cleaner per clean() call anyway.
+_CSS_SANITIZER = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPERTIES)
+
+def sanitize_html(html_raw: str, log_context: str = "") -> str:
+ """Sanitize one HTML fragment. FAIL-CLOSED: on any bleach error the
+ fragment is html.escape()d, never returned raw (stored-XSS guard).
+ log_context (e.g. "channel X, message_id Y") is included in error/slow
+ logs to keep operational grep-ability."""
+ # The FULL call — both non-default params are load-bearing:
+ # clean(html_raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES,
+ # protocols=ALLOWED_PROTOCOLS, css_sanitizer=_CSS_SANITIZER,
+ # strip=True)
+ # strip=True: disallowed tags are REMOVED (bleach default False would
+ # escape them into visible text). strip_comments stays default (True),
+ # matching all current call sites. Keep the >0.05s diag_sanitize_slow
+ # warning with input_len here.
+ # Error log name: html_sanitization_error (SINGLE name replacing the
+ # three per-path names — registry §3.16); log_context distinguishes
+ # the call sites, tests assert the name.
+```
+
+Правки:
+
+1. `post_parser._sanitize_html` (branch:702–737) → делегат в
+ `sanitizer.sanitize_html`. Fail-open ветка исчезает (§3.2).
+2. `_render_pipeline` получает параметр `channel` (только для логов) и после
+ `_render_messages_groups` (т.е. после фильтров) выполняет
+ `p['html'] = sanitize_html(p['html'], log_context=f"channel {channel}, message_id {p['message_id']}")`.
+ Комментарий: the pipeline already runs in a worker thread.
+ ВАЖНО: в этапах 1–2 близнецы ещё не слиты — аргумент `channel` добавляется
+ в ОБА вызова `to_thread(_render_pipeline, …)` (rss_generator.py:433 и 657).
+3. RSS-цикл (branch:458–497): удалить вложенный `_sanitize_sync` + `to_thread`;
+ `fe.content(content=post['html'], type='CDATA')` напрямую.
+4. HTML-путь (branch:671–705): удалить `_concat_html`/`_sanitize_sync` +
+ оба `to_thread`; `html = '\n
\n'.join(...)` —
+ join ПОСЛЕ санитайза (§3.3, §3.4).
+5. Импорты bleach/CSSSanitizer из rss_generator удалить. Тест
+ test_stage4_eventloop.py:474 monkeypatch'ит `rss_module.HTMLSanitizer` —
+ перенацелить на `sanitizer` (API relocation).
+6. Обновить golden (§3 пп.1–5) и комментарии о границах санитайза.
+
+Тесты: `tests/test_sanitizer.py` — s/del выживают; script/onerror ВЫРЕЗАЮТСЯ
+(не эскейпятся в текст — проверка strip=True); `tg://` href выживает;
+fail-closed при исключении; hr-разделитель в HTML-фиде.
+
+DoD: одно определение allowed_tags в репо; в rss_generator нет bleach;
+pytest + golden зелёные.
+
+### Этап 2 — выпил `processed_message_to_tg_message`
+
+```python
+# Select the main RAW message with the same criterion the processed dicts used:
+# first message that has text or caption, else the first of the group.
+main_idx = next((i for i, m in enumerate(group) if (m.text or m.caption)), 0)
+main_raw = group[main_idx]
+main_message = processed_messages[main_idx]
+
+# Deterministic merged flags: first-seen order, then 'merged'.
+merged_flags = list(dict.fromkeys(f for msg in processed_messages for f in msg['flags']))
+merged_flags.append("merged")
+
+footer_html = post_parser.generate_html_footer(main_raw, flags_list=merged_flags)
+```
+
+Дополнительно (§3.15): в `_reactions_views_links` пустая `reactions_html` НЕ
+добавляется в `first_line_parts` (иначе после перехода на реальный Message
+merged-посты с пустым reactions-объектом получили бы ведущий разделитель —
+артефакт, который сегодня есть и у одиночных постов; чиним для всех).
+
+Удалить конвертер целиком и импорт `SimpleNamespace`. Обновить golden
+(§3 пп.6, 8, 15) и СНЯТЬ сорт-нормализацию `message-flags` в
+golden-сравнении, введённую этапом 0: порядок флагов теперь детерминирован
+(§3.8), нормализация больше не нужна и лишь маскировала бы регрессии.
+
+Тесты: футер merged-группы — реальные ссылки главного сообщения; несколько
+custom-emoji → отдельные «❓ N» span'ы; paid → «⭐ N»; пустой reactions-объект →
+нет ведущего разделителя (и для одиночного поста); порядок флагов стабилен;
+**выделенный тест §3.7 с не-UTC TZ** (например `TZ=Europe/Moscow` + `tzset`):
+дата merged-футера совпадает с датой футера одиночного поста того же
+сообщения. TZ ОБЯЗАТЕЛЬНО восстанавливается в teardown (fixture/try-finally),
+иначе не-UTC протечёт в golden-тесты, которым conftest запинил UTC.
+
+DoD: функция удалена, pytest + golden зелёные.
+
+### Этап 3 — слияние близнецов вокруг `_prepare_feed_posts`
+
+```python
+@dataclass
+class PreparedFeed:
+ channel_username: str
+ channel_title: str # used by RSS metadata only
+ posts: list[dict] # rendered, filtered, sorted, SANITIZED
+
+class ChannelNotFound(Exception):
+ def __init__(self, channel_identifier): # prepared id, for create_error_feed
+ self.channel_identifier = channel_identifier
+
+# NO timed() abstraction: it could not carry the paired rss_/html_ log-line
+# names nor the extra context (message counts) anyway. Keep today's explicit
+# logger.debug lines, prefixed via log_prefix.
+
+async def _prepare_feed_posts(channel, client, *,
+ limit, exclude_flags, exclude_text, merge_seconds,
+ history_limit, enrich_replies: bool,
+ log_prefix: str) -> PreparedFeed:
+ # log_prefix: 'rss' | 'html' — preserves today's paired log-line names
+ # (f"{log_prefix}_channel_info_timing", f"{log_prefix}_messages_retrieval_timing", ...)
+ # 1) validate limit (1..200)
+ # 2) channel_name_prepare + cached_get_chat:
+ # UsernameInvalid/UsernameNotOccupied/no-username -> ChannelNotFound(prepared)
+ # FloodWait -> re-raise (api_server maps to 429)
+ # other errors -> ValueError chain (unified text, §3.10)
+ # 3) cached_get_chat_history(limit=history_limit):
+ # FloodWait -> re-raise BEFORE the ValueError wrap (§3.9)
+ # NOTE: keep `from tg_cache import ...` INSIDE this function — feed tests
+ # monkeypatch tg_cache and rely on late name resolution.
+ # 4) if enrich_replies: messages = await _reply_enrichment(client, messages)
+ # 5) try: to_thread(_render_pipeline, ...) finally: flush_pending_media_ids()
+```
+
+Форматтеры:
+
+- `generate_channel_rss`: `history_limit=limit*2, enrich_replies=False,
+ log_prefix="rss"` (RSS over-fetches so merging still yields ~limit posts) →
+ fg-метаданные + entries (`rss_date_range`-лог сохраняется) →
+ `to_thread(fg.rss_str)`.
+- `generate_channel_html`: `history_limit=limit, enrich_replies=True,
+ log_prefix="html"` (enrichment is HTML-only to keep RSS polling cheap —
+ deliberate) → join с `
`.
+- Оба: `except ChannelNotFound as e: return create_error_feed(str(e.channel_identifier), base_url)`
+ (байт-эквивалентно текущему выводу: prepared-значение уже используется
+ сегодня, int/str интерполируются одинаково — проверено в ревью).
+- Внешние catch-логи ОСТАЮТСЯ в форматтерах со своими сегодняшними именами
+ (`generate_channel_rss: …` — rss_generator.py:526, `html_generation_error:
+ …` — 716): каждый форматтер сохраняет собственный внешний try/except.
+
+Тесты: golden без изменений (главный критерий); ChannelNotFound → error feed
+в обоих путях; FloodWait из get_chat → 429; FloodWait из истории → 429
+(новый, §3.9); ValueError из истории → 400.
+
+DoD: один блок get_chat/get_history/render; diffstat отрицательный;
+pytest + golden зелёные.
+
+### Этап 4 — группировка без deepcopy и мутаций
+
+```python
+def _compute_time_based_group_ids(messages, merge_seconds) -> dict[int, str | int]:
+ """Return {message.id: effective_media_group_id} WITHOUT mutating messages.
+
+ Contract: all messages belong to ONE chat (message.id is unique only
+ per chat); callers must not mix chats in a single call.
+
+ Reproduces the old algorithm for every input the old code survived on
+ PRODUCTION data (naive kurigram dates). Inputs only aware-date test mocks
+ could produce (fully-None and aware+None mixes, where the old code
+ clustered the None-date tail by insertion order, incl. truthy-id
+ adoption) are deliberately replaced — registry §3.11:
+ - messages WITHOUT a date do not participate in time clustering and get
+ NO mapping entry (their own media_group_id still applies downstream) —
+ registry §3.11;
+ - sort by date ascending, timestamp-based key (naive-safe);
+ - a message joins the current cluster if the gap to the PREVIOUS message
+ is <= merge_seconds; the gap is computed by NAIVE datetime subtraction
+ (msg.date - prev.date).total_seconds(), exactly as the old code — NOT
+ via timestamps (they diverge during a DST fold, and the old behavior
+ is the contract);
+ - effective id of a cluster = the FIRST TRUTHY media_group_id seen in
+ cluster order (old code used truthiness, not `is not None`; it also
+ overwrote members' own differing ids — keep that);
+ - if no member has a truthy id and len(cluster) >= 2:
+ synthetic id f"time_{min(dates)}" (keep the exact format);
+ - singleton clusters and clusters with no effective id produce NO
+ entries. Every member of a cluster with an effective id gets an entry.
+ """
+```
+
+Правки:
+
+- `_create_messages_groups(messages, group_ids=None)`:
+ `effective = (group_ids or {}).get(message.id, message.media_group_id)`;
+ sort-ключ групп — timestamp-based (naive-безопасный), фолбэк для None-date
+ групп `float('inf')` → детерминированно переживают `[:limit]`-срез как
+ новейшие; финальная сортировка постов в `_render_messages_groups`
+ (фолбэк `0.0`) НЕ меняется — None-date посты выводятся в конце фида (§3.12).
+- `_render_pipeline` при `time_based_merge`: маппинг + пред-сортировка входа
+ date-ASC тем же timestamp-ключом (фолбэк `+inf` — None-date в конце);
+ стабильный sorted на fetch-order входе воспроизводит порядок старого
+ `messages_sorted`, включая ties (старый sorted работал на deepcopy того же
+ fetch-order списка, мутации происходили после сортировки — проверено).
+ `sorted()` не мутирует вход — deepcopy не нужен.
+- Удалить `_create_time_based_media_groups` и импорт `copy`; поправить
+ модульный импорт в test_stage4_eventloop.py:34–42 (API relocation).
+- Добавить None-date фикстуру ТОЛЬКО в HTML-golden (§3.11–12): в RSS
+ None-date пост получает `pubDate = datetime.now()` (rss_generator.py:
+ 503–507) — недетерминированно, и новую нормализацию для этого не вводим;
+ RSS-семантика None-date закрепляется unit-тестом (pubDate присутствует,
+ entry сгенерирован), вне golden.
+
+Тесты (`tests/test_group_ids.py`, naive-даты): time-кластер без id →
+синтетический; усыновление truthy id с backfill и перезаписью чужого id;
+falsy id (`0`, `""`) игнорируется как в старом коде; одиночка без entry;
+None-date не получает entry в маппинге, но его собственный `media_group_id`
+продолжает действовать (медиагруппа с None-датами собирается); смешанный
+None-date вход не падает (§3.11); полностью-None вход: новое поведение
+закреплено явно; None-date группы переживают `[:limit]`-срез (ключ групп
+`+inf`), в итоговом выводе — в конце фида (финальный фолбэк `0.0`, §3.12);
+вход не мутирован;
+ties при равных датах → порядок как у старого кода; кластеризация через
+DST-fold — как у старого кода (naive-вычитание).
+
+DoD: `copy.deepcopy` отсутствует; pytest + golden зелёные (golden-дифф — только
+добавленная None-date фикстура, §3.11–12).
+
+### Этап 5 — таблица медиа-типов в post_parser (два коммита)
+
+**5a — рефакторинг байт-в-байт.**
+
+Шаг 0: fragment-level снапшоты `_generate_html_media` (ДО санитайза) для всех
+типов: PHOTO, VIDEO, ANIMATION, VIDEO_NOTE, AUDIO, VOICE, STICKER img/video,
+DOCUMENT pdf/обычный, LIVE_PHOTO, STORY video/photo, POLL с description_media,
+PAID_MEDIA, WEB_PAGE с фото (пустой media-div!), **и edge-ветки**: WEB_PAGE
+без фото (незакрытый div — воспроизводится в 5a буквально), file_unique_id
+is None, channel_username is None, гейт webpage-превью «text ≤ 10».
+
+```python
+# Single source of truth: media type -> (object selector, render kind).
+# kind=None means "selector-only entry": the object participates in file-id
+# extraction/collection, but rendering happens outside the dispatcher.
+# The ONLY kind=None entry is WEB_PAGE (rendered by _format_webpage).
+# PAID_MEDIA has NO entry at all: its info block stays a separate branch in
+# _generate_html_media, and it is deliberately not collected/downloadable.
+MEDIA_SOURCES: dict[MessageMediaType, Callable[[Message], tuple[Any, str | None]]] = {
+ MessageMediaType.PHOTO: lambda m: (m.photo, 'img_400'),
+ MessageMediaType.DOCUMENT: _select_document, # returns kind 'pdf' OR 'img_400' by mime_type
+ MessageMediaType.STICKER: _select_sticker, # video_loop_200 vs img_200_sticker
+ MessageMediaType.STORY: _select_story, # maps helper kinds video->'video_400', img->'img_400'
+ MessageMediaType.POLL: _select_poll_media, # same helper-kind mapping
+ MessageMediaType.WEB_PAGE: lambda m: (getattr(m.web_page, 'photo', None), None),
+ # VIDEO/ANIMATION/VIDEO_NOTE -> video_400; AUDIO/VOICE -> audio;
+ # LIVE_PHOTO -> video_loop_400
+}
+
+@dataclass
+class RenderCtx:
+ url: str # signed /media URL — assembled by _generate_html_media,
+ # which KEEPS the digest call and the channel_username
+ # guard inline (they are not the renderer's business)
+ tg_link: str | None = None # t.me deep link — only the 'pdf' renderer uses it
+ emoji: str = '' # sticker alt text
+ mime: str | None = None # audio/voice source type; the DEFAULT is chosen
+ # by the ctx builder in _generate_html_media BY
+ # MEDIA TYPE (AUDIO -> audio/mpeg, VOICE ->
+ # audio/ogg, branch:907/912) — the renderer
+ # receives a ready value and never guesses
+
+# Renderers, NOT naked format strings: a renderer returns list[str] so the
+# byte structure of '\n'.join is preserved (audio emits TWO items: tag +
;
+# pdf emits its two-append div block). Renderer bodies are lifted VERBATIM
+# from the existing if/elif branches — including the `src="{url}"style=`
+# concatenation artifacts — byte-for-byte fidelity is the 5a contract.
+RENDERERS: dict[str, Callable[[RenderCtx], list[str]]] = { ... }
+```
+
+PDF идёт ЧЕРЕЗ таблицу: `_select_document` возвращает kind `'pdf'` для
+`application/pdf`, `RENDERERS['pdf']` использует `ctx.tg_link` (сборка ссылки
+`t.me/c/...` vs `t.me/...` — в `_generate_html_media`, как сейчас).
+
+Потребители: `_get_file_unique_id`, `_save_media_file_ids`,
+`_generate_html_media` — все через селектор. Ограничение: `flags.append(...)`
+НЕ выносить из `_extract_flags` (эндпоинт `/flags` парсит его исходник через
+`inspect.getsource`); тест: `get_all_possible_flags()` непуст и содержит
+известные флаги.
+
+Межмодульный инвариант-тест (границу с api_server закрепить машинно):
+для каждого типа из MEDIA_SOURCES объект, выбранный селектором, находится
+`api_server.find_file_id_in_message` по своему `file_unique_id` — новые entry
+таблицы не могут дать URL, который download-путь не разрешит (иначе /media
+404). Ограничение теста: обе стороны работают на моках — класс багов «обе
+функции ждут атрибут, которого нет у реального kurigram-объекта» он не ловит;
+опциональная best-effort митигация — сверка атрибутов моков с
+`pyrogram.types` (сама интроспекция хрупка при апгрейдах kurigram; ядро
+теста ценно и без неё, обязательной её не делать).
+Сама `find_file_id_in_message` НЕ сливается с таблицей: её контракт шире —
+поиск любого скачиваемого объекта независимо от `message.media`, включая
+`explanation_media`, который рендер сознательно игнорирует (§7).
+
+**5b — зарегистрированные фиксы** (отдельный коммит): закрыть
+`` во всех ветках (§3.14); guard «>100 МБ» на любой медиа-объект
+(§3.13); обновить fragment-снапшоты с diff-комментарием. Feed-level golden
+5b не трогает сверх этих пунктов.
+
+DoD: прямые атрибутные ЦЕПОЧКИ вида `m.photo.file_unique_id` — только в
+селекторах таблицы, хелперах `_poll_media_object`/`_story_media_object`
+(это и есть реализация селекторов) и `_format_webpage`. Потребители
+извлекают uid единообразно — `getattr(selected_obj, 'file_unique_id', None)`
+от объекта, ВОЗВРАЩЁННОГО селектором; санкционированные точки извлечения:
+`_get_file_unique_id`, `_save_media_file_ids`, инвариант-тест. Три лестницы
+удалены; снапшоты обоих уровней + pytest зелёные; инвариант-тест с
+api_server зелёный.
+
+### Этап 6 — косметика
+
+1. `_wrap_post_html(body, footer)` — единая обёртка (сейчас три копии:
+ `_format_html` и обе ветки `_render_messages_groups`).
+2. Инлайн-стили 400px/200px → константы (если не закрыто этапом 5).
+3. `_trim_messages_groups` → инлайн-срез `[:limit]`; поправить импорт в
+ test_stage4_eventloop.py (API relocation).
+4. Комментарии «stage-4»/«4.4» — переписать под новую границу санитайза.
+5. Фильтр exclude_flags → comprehension; фильтр exclude_text — оставить
+ циклом или обернуть предикатом, но `excluded_post`-debug-лог СОХРАНИТЬ
+ (единственный след, почему пост выпал из фида).
+
+## 6. Порядок и зависимости
+
+`0 → 1 → 2 → 3` строго последовательно. `4` и `5` независимы, после 3.
+`6` — последним. Каждый этап мержибелен сам по себе.
+
+## 7. Сознательно НЕ делаем (отложено)
+
+- Унификация глубины истории RSS (`limit*2`) vs HTML (`limit`).
+ Цена отложенности: кэш истории — ОДИН файл на канал, limit хранится внутри
+ и при несовпадении — miss (tg_cache.py:43–52, 106–109); канал с обоими
+ потребителями живёт в вечном miss-цикле со взаимным вытеснением и
+ удвоенным RPC. Дешёвая альтернатива при взятии в работу: limit в имени
+ кэш-файла.
+- `_reply_enrichment` в RSS-пути (RPC-нагрузка от частых опросов ридеров).
+- HTML-страница ошибки для `generate_channel_html` (сейчас RSS-XML).
+- Кэширование/демутация `_reply_enrichment` (мутирует кэшированные Message).
+- `api_server.find_file_id_in_message`: остаётся отдельной (контракт шире
+ рендера — см. этап 5a), граница закрыта инвариант-тестом.
+- Валидация `merge_seconds` в api_server (сейчас принимает `<=0` из query).
+- Валидация `exclude_text` (невалидный regex → `re.error` → 500 и сейчас,
+ и после этапа 3 — api_server пробрасывает параметр как есть).
+- CDATA-риск feedgen: html5lib не эскейпит `>` в атрибутах, `]]>` внутри href
+ теоретически рвёт CDATA. Пре-существующий, вне скоупа.
+- `/flags` через `inspect.getsource` — хрупкая механика, замена отложена.
+
+## 8. Журнал адверсариального ревью
+
+### Раунд 1 (по v1): 26 претензий (2 blocker, 8 major, 12 minor, 4 nit)
+
+- Приняты полностью и внесены: №3 (protocols), №4 (правки тестов при API
+ relocation), №5 (golden-оракул → этап 0), №6 (TZ merged-футера, §3.7),
+ №7 (FloodWait истории → 429, §3.9), №11 (§3.10), №12 (lazy-import пин),
+ №13 (наблюдаемость, log_context), №14 (реальная дельта реакций, §3.6),
+ №16+№15 (naive/aware краш → §3.11–12, truthiness), №18 (edge-матрица
+ снапшотов), №19 (payload ChannelNotFound), №20 (§3.5), №21 (обоснование
+ шареного CSSSanitizer), №22 (/flags-мина), №23 (excluded_post-лог),
+ №24 (контракт one-chat), №25 (§2), №26 (CDATA в §7).
+- №1 (blocker): снято разделением этапа 5 на 5a/5b и двумя слоями эталонов.
+- №2 (blocker): понижено до пункта реестра §3.4 по согласованной формулировке.
+- №8: контракты `find_file_id_in_message` и MEDIA_SOURCES признаны разными;
+ граница закрыта инвариант-тестом по предложению критика.
+- №9: закрыт пред-сортировкой входа naive-безопасным ключом.
+- №10: база запинена на f9550d8.
+- №17: `TAG_TEMPLATES: dict[str, str]` заменён на renderer-функции.
+
+### Раунд 2 (по v2): 13 претензий (1 blocker, 4 major, 5 minor, 3 nit)
+
+- №1 (blocker): краш naive/aware живёт в ДЕФОЛТНОМ пути сортировки групп, а
+ не только в тайм-кластеризации (подтверждено эмпирически) → §1 исправлен,
+ None-date фикстура перенесена из этапа 0 в этап 4 (вариант «а» критика),
+ §3.12 уточнён.
+- №2: направление дельты пустых реакций было инвертировано → выбран
+ fix-вариант: §3.15 (чинится для всех постов), формулировка §3.6 очищена.
+- №3: `strip=True` внесён в спеку полным вызовом `clean()` (тот же класс
+ дыры, что protocols в раунде 1).
+- №4: детерминизм golden обеспечен (TZ-пин, нормализация lastBuildDate/
+ generator); зафиксировано, что §3.7 golden не верифицирует → выделенный
+ не-UTC тест в этапе 2.
+- №5: противоречие DOCUMENT/PDF разрешено в пользу «PDF через таблицу»,
+ ctx расширен полем tg_link.
+- №6: RenderCtx специфицирован (поля, владелец сборки URL, verbatim-правило).
+- №7: базис gap зафиксирован — naive-вычитание как в старом коде (timestamp
+ расходится в DST-fold).
+- №8: позиция None-date групп зарегистрирована (§3.12: новейшие, `+inf`).
+- №9: формулировка теста исправлена («нет entry в маппинге», а не «синглтон»).
+- №10: ограничение инвариант-теста (ложная зелень на моках) проговорено +
+ митигация.
+- №11: `_render_pipeline` получает `channel` для log_context (этап 1 п.2).
+- №12: диаграмма §2 исправлена (фильтры/sort внутри `_render_messages_groups`,
+ санитайз после фильтров).
+- №13: DoD 5a включает хелперы `_poll_media_object`/`_story_media_object`.
+- Чистыми признаны: эквивалентность error feed (B5), §3.9 против tg_cache и
+ 429/Retry-After (B6), эквивалентность пред-сортировки (B3), внесение всех
+ исходов раунда 1.
+
+### Раунд 3 (верификация дельты v3)
+
+- Пять из шести выборов подтверждены (None-date → этап 4; fix пустых реакций;
+ PDF через таблицу; naive-gap; полный clean()).
+- Возражение по №5 принято: `+inf` определяет только выживание группы при
+ `[:limit]`-срезе, итоговую позицию задаёт финальная сортировка с фолбэком
+ `0.0` (None-date посты — в конце фида, как и раньше) — §3.12, этап 4 и его
+ тест переформулированы.
+- Криво внесённое исправлено: список ожидаемых golden-изменений дополнен
+ этапом 5b (п.14); исключения single-post пути в §2 расширены до
+ пп. 2, 13, 14, 15.
+- Практические заметки внесены: teardown TZ в не-UTC тесте §3.7; два
+ независимых Config-дикта при monkeypatch `time_based_merge`.
+
+### Раунд 4 (по v3): 17 пунктов (2 major, 4 minor, остальное nit/экономика)
+
+- Детерминизм golden добит двумя major-находками: порядок merged-флагов до
+ этапа 2 зависит от PYTHONHASHSEED (`list(set)`) → сорт-нормализация
+ `message-flags` до этапа 2; ключ подписи media-URL (`secrets.token_hex` в
+ `data/media_digest.key`) → пин autouse-фикстурой.
+- Согласован состав фикстур: добавлен strikethrough-пост (иначе §3.1
+ невидим), пп.2/5 помечены как невидимые для golden; exclude-сценарии
+ вынесены из golden в unit-тесты (экономическая критика).
+- `timed()` выкинут: не мог сохранить парные `rss_/html_` имена логов и не
+ окупался — заменён параметром `log_prefix` у `_prepare_feed_posts`.
+- Кодер-готовность: оба call-site'а `_render_pipeline` в этапах 1–2
+ проговорены; владелец mime-дефолта — ctx-билдер по media-типу; указатель
+ на паттерн monkeypatch tg_cache в этапе 0; обоснование двойного
+ Config-патча исправлено (достаточно rss_generator.Config).
+- Митигация инвариант-теста через интроспекцию `pyrogram.types` понижена до
+ опциональной (сама хрупка при апгрейдах).
+- §7 пополнен: вечный miss-цикл кэша истории у dual-consumer каналов (цена
+ отложенной унификации глубины), валидация exclude_text.
+- C-остатки признаны чистыми: частичный flush и дубли upsert закреплены
+ существующими тестами; лог одиночного санитайза контекста и сегодня не
+ имеет; api_server пробрасывает exclude_* без валидации — поведение не
+ меняется.
+- Экономический вердикт критика: аппарат тяжёл для ~700 строк рефакторинга,
+ но каждый элемент отвечает конкретному найденному багу; срезаны только
+ `timed()`, exclude-фикстуры и обязательность интроспекции. Этапы не
+ сливать: раздельная мержибельность дешевле.
+
+### Раунд 5 (по v4): независимый холодный проход, 8 пунктов (1 blocker, 1 major)
+
+Второй критик — без контекста предыдущих раундов, с запретом доверять
+журналу и реестру на слово. Итог валидации: ВСЕ line-refs §1 и ключевые
+эмпирические утверждения спеки подтверждены независимо (включая вырезание
+hr, заглатывание постов незакрытым div, strip/protocols-семантику, TypeError
+в дефолтном пути, miss-цикл кэша, соответствие MEDIA_SOURCES реальной
+лестнице вплоть до артефактов, находимость всех селекторных объектов в
+find_file_id_in_message); архитектурные решения признаны чистыми. Найдены
+дефекты исполнимости:
+
+- Blocker: None-date фикстура в RSS-golden недетерминированна
+ (`pubDate = now()` для None-date entry) → фикстура включается только в
+ HTML-golden, RSS-семантика — unit-тестом (этап 4 переписан).
+- Major: три имени санитайз-логов физически сливаются в одно при делегате —
+ противоречие с §4 → зарегистрировано как §3.16 (единое
+ `html_sanitization_error` + log_context); внешние catch-логи форматтеров
+ явно оставлены в этапе 3.
+- Механизм волатильности feedgen уточнён (lastBuildDate — один раз в
+ конструкторе, не при сериализации; generator без версии — нормализация
+ опциональна).
+- §3.11/контракт этапа 4: «Было» дополнено выжившими aware+None и
+ fully-None входами (кластеризация None-хвоста с adoption), заголовок
+ docstring сужен до «survived on production data».
+- DoD 5a переписан: единообразное извлечение uid через
+ `getattr(selected_obj, ...)` с санкционированными точками (сигнатура
+ селектора uid не возвращает — старая формулировка была невыполнима).
+- Этап 0: добавлен пин записи media-id (cwd-относительный DB_PATH писал бы
+ в реальную data/ из golden-тестов); поправлен line-ref api_server;
+ каветт §3.12 про будущие даты.
+
+Отклонённых претензий нет; по всем спорным пунктам достигнут консенсус.