---
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` из намеренной
разметки-обёртки (`<a href>` номер задачи, форматирование) и подставляемых **данных**. Сейчас
экранирован только заголовок (`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`). Поведение должно стать одним из (выбор — архитектор):
- экранированный вывод `&lt;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(...)` — кликабельный `<a href>` номер задачи (внутри уже
  экранированы 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` не рестартуется в рамках разработки.
