--- work_item: ORCH-095 stage: analysis author_agent: analyst status: ready-for-review created_at: 2026-06-09 model_used: claude-opus-4-8 --- # 02 — ТЗ (TRZ): ORCH-095 — HTML-безопасность динамических полей render_task_tracker Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis > ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. > Архитектурное обоснование/выбор точки внесения экранирования — задача архитектора (06-adr). ## 1. Сводка изменения Текст live-карточки (`render_task_tracker`) собирается с `parse_mode=HTML` из намеренной разметки-обёртки (`` номер задачи, форматирование) и подставляемых **данных**. Сейчас экранирован только заголовок (`esc_title`) и href/label внутри `plane_issue_link`; остальные данные вставляются сырыми. Литерал `<1м` (длительность < 1 мин), возвращаемый `_fmt_minutes`, Telegram парсит как открывающий тег → `editMessageText` падает `400 can't parse entities` → `edit_telegram → EDIT_FAILED` → `update_task_tracker` делает ранний `return` → карточка застывает. Требуется: (а) сделать формат «< 1 мин» HTML-безопасным; (б) гарантировать HTML-безопасность **всех** данных, попадающих в текст карточки, **не** экранируя намеренную разметку-обёртку; (в) обеспечить возобновление обновлений ранее застрявших карточек. Изменение локализовано в слое уведомлений; машина стадий/гейты/схема БД не затрагиваются. ## 2. Задействованные модули / пути | Путь | Действие | |------|----------| | `src/notifications.py` | **изменить** — `_fmt_minutes` (~280) и/или точки рендера в `render_task_tracker` (~355): HTML-безопасность данных | | `src/notifications.py::render_task_tracker` | **изменить** — экранировать данные: длительности (`dur`), `status_label`, `model`/`effort`, метрики (defence-in-depth); НЕ трогать `num_html`, `_done_link`-разметку | | `src/notifications.py::_card_status_label` (~1173) | **проверить/экранировать на потребителе** — статус-лейбл вставляется в `status_line` сырым | | `src/notifications.py::edit_telegram` (~157) | **возможно изменить** (на усмотрение архитектора) — классификация `can't parse entities` для восстановления застрявших карточек (BR-5/AC-4) | | `src/notifications.py::update_task_tracker` (~650) | **возможно затронуть** — ветка `EDIT_FAILED` vs пересоздание при перманентном parse-фейле (BR-5/AC-4) | | `tests/test_telegram_tracker.py` (или новый `tests/test_tracker_html_escape.py`) | **создать/дополнить** — юнит HTML-безопасности всех динамических полей | | `CHANGELOG.md` | **изменить** — запись о фиксе | > Примечание: `fmt_tokens`/`fmt_cost`/`short_model_name` живут в `src/usage.py`; их выход > сейчас HTML-безопасен (цифры/`.`/`k`/`M`/`$`/имя модели). Менять `src/usage.py` **не > требуется** — defence-in-depth экранирование делается на потребителе в `notifications.py`. ## 3. Функциональные требования ### FR-1 — HTML-безопасный формат «меньше минуты» (⇒ BR-1, BR-3) Длительность стадии < 60 с не должна порождать подстроку, которую Telegram трактует как открывающий тег. Текущий `_fmt_minutes(seconds)` при `0 < seconds < 60` возвращает литерал `"<1м"` (`notifications.py:288-289`). Поведение должно стать одним из (выбор — архитектор): - экранированный вывод `<1м` (видится оператору как `<1м`), **либо** - переформулировка `~0м` / `< 1 мин` с последующим экранированием. Инвариант: для **любого** входа `_fmt_minutes` (включая `0м`, `Nм`, `~Nм` от `_capped_review_str`) результат, попав в `parse_mode=HTML`, не ломает парсер. `_fmt_minutes` сохраняет never-raise (нечисловой/None вход → `0м`). ### FR-2 — HTML-безопасность всех данных карточки (⇒ BR-2) Каждое **подставляемое значение-данные**, попадающее в текст `render_task_tracker`, экранируется `html.escape(...)` ровно один раз перед вставкой в HTML-текст. Перечень полей-данных: | Поле | Источник | Текущий статус | |------|----------|----------------| | Заголовок задачи | `title` → `esc_title` | уже экранирован ✓ (не дублировать) | | Длительности стадий / BRD / done | `_fmt_minutes`, `_capped_review_str` | **дыра** (FR-1) | | Статус-лейбл карточки | `_card_status_label` → `status_label` | **дыра** — экранировать | | Имя модели | `short_model_name(last["model"])` | экранировать (defence-in-depth) | | Эффорт | `_run_effort(last)` | экранировать (defence-in-depth) | | Токены / стоимость | `fmt_tokens`/`fmt_cost` | HTML-безопасны; экранировать defence-in-depth | | Метка «попытка N» / лейблы стадий | статические константы `_TRACKER_STAGES`/`_BRD_LABEL` | статичны; не требуют, но безопасно | Инвариант FR-2: после рендера **ни один** символ `< > &`, пришедший из данных, не остаётся неэкранированным в выходном тексте. ### FR-3 — Сохранность намеренной разметки-обёртки (⇒ BR-4) Намеренные HTML-фрагменты **не** экранируются: - `num_html` = `plane_issue_link(...)` — кликабельный `` номер задачи (внутри уже экранированы href через `html.escape(url, quote=True)` и label); - `link_for(...)` в строке «⏳ ждёт …» — намеренные ссылки; - `_done_link(...)` — строка `🔗 PR #n · 📦 Внедрено`. После фикса эти фрагменты рендерятся как валидный HTML и остаются кликабельными. Запрещено двойное экранирование уже экранированных полей (`esc_title`, внутренности `plane_issue_link`). ### FR-4 — Возобновление обновлений застрявших карточек (⇒ BR-5) После деплоя фикса карточка, ранее застрявшая на `400 can't parse entities`, должна возобновить обновления. Достаточное условие по умолчанию: текст следующего рендера больше не содержит небезопасной подстроки → `editMessageText` проходит (`200`) на ближайшем переходе стадии. Опционально (решение архитектора): классифицировать перманентный parse-фейл в `edit_telegram`/`update_task_tracker` как повод **переотправить** свежую карточку вместо тихого `return` по `EDIT_FAILED` — но **без** регресса защиты от дублей (ORCH-087: транзиентные фейлы по-прежнему НЕ плодят карточки). Если выбирается переклассификация — она должна отличать перманентный `can't parse entities` от транзиентного (network/timeout/5xx). ### FR-5 — never-raise (⇒ NFR-1) Все изменённые функции сохраняют контракт «никогда не роняют конвейер»: ошибка экранирования/рендера → деградация к существующему fallback (`f"task-{task_id}"` / пропуск строки), не исключение наружу. ## 4. Изменения API Нет. HTTP-эндпоинты не добавляются/не меняются. (Внешний вызов — только исходящий `editMessageText`/`sendMessage` к Telegram Bot API; контракт вызова не меняется, меняется лишь безопасность `text`.) ## 5. Изменения схемы БД Нет. Таблицы `tasks`/`agent_runs`/`tracker_messages` не затрагиваются; миграций нет. ## 6. Требования к новым/изменённым QG checks Нет. `QG_CHECKS` / `check_*` / `STAGE_TRANSITIONS` / машинные вердикты не затрагиваются. Баг — в слое рендера уведомлений, вне Quality Gate. ## 7. Совместимость / регресс - **Обратная совместимость:** изменение чисто в формировании строки текста карточки; данные БД, схема, режимы трекера (`bump`/`edit`), леджер сирот (ORCH-087), статусная модель (ORCH-066) — без изменений. - **Область раската:** все проекты на общем прод-инстансе (self-hosting) — фикс применяется к каждому новому рендеру сразу после деплоя; не требует миграции/бэкфилла. - **Kill-switch:** не требуется (исправление дефекта корректности, а не новая фича-ветка). Если архитектор выбирает переклассификацию parse-фейла в `update_task_tracker` (FR-4 опц.) — оценить целесообразность флага; по умолчанию изменение поведения минимально и безопасно. - **Обратимость:** изменение откатывается обычным revert PR (только `notifications.py` + тесты + CHANGELOG); прод-контейнер не требует ручных операций над данными. - **Артефакты pipeline:** обновляются `12-review.md` (reviewer), `13-test-report.md` (tester), `06-adr/ADR-001-*.md` (архитектор — выбор точки экранирования и стратегии FR-4), `CHANGELOG.md`. Машинные вердикты гейтов — без изменений. - **Self-hosting:** обязательна стадия `deploy-staging` (8501) перед прод-деплоем; прод `orchestrator` не рестартуется в рамках разработки.