---
work_item: ORCH-095
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---

# 01 — BRD (бизнес-требования): ORCH-095 — HTML-инъекция «<1м» в render_task_tracker застывает live-карточку

Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: analysis

## 1. Бизнес-контекст и проблема

Live-трекер задачи (`src/notifications.py::render_task_tracker`) — **основной канал
видимости конвейера для оператора**. Слава узнаёт состояние каждой задачи по её единственной
карточке в Telegram (инвариант «одна карточка на задачу», ORCH-042/067/087). Если карточка
перестаёт обновляться — оператор слепнет: задача реально идёт/завершилась, а карточка врёт.

**Установленный факт (воспроизведён детерминированно 09.06, сырой ответ Telegram).**
Прямой вызов `editMessageText` для застрявшей карточки ORCH-093 (`message_id 18854`) вернул:

```
400 Bad Request: can't parse entities: Unsupported start tag "1м" at byte offset 500
```

В тексте карточки на позиции ~379 присутствует подстрока `<1м · …` — длительность стадии
«меньше одной минуты», которую `_fmt_minutes` (`src/notifications.py:288-289`) рендерит как
литерал **`<1м`**. Карточка отправляется с `parse_mode=HTML` (`editMessageText`,
`notifications.py:175`). Telegram трактует `<1м` как **открывающий HTML-тег** → парсинг падает
с `400` → `edit_telegram` возвращает `EDIT_FAILED` → `update_task_tracker` по ветке
`EDIT_FAILED` (`notifications.py:733-739`) делает `return`, **не** отправляя новую карточку
(защита от дублей, ORCH-087) → карточка **застывает** на стейте, где `<1м` впервые попал в текст.

**Цепочка отказа** (по коду):
`_fmt_minutes(<60s) → "<1м"` → интерполируется в HTML без экранирования → `editMessageText`
`400 can't parse entities` → `edit_telegram → EDIT_FAILED` → `update_task_tracker` ранний
`return` → карточка не обновляется до конца жизни задачи.

**Почему проявляется не на каждой задаче.** Баг ловится **только** когда хотя бы одна
длительность стадии < 1 мин (`seconds < 60`) и эта строка попадает в текст, который затем
редактируется. Карточки ORCH-090/091 редактировались успешно (на момент `edit` в их тексте
`<1м` не было); ORCH-093 — упала. Это объясняет «плавающую» природу симптома.

**Корневой класс дефекта — шире одного `<1м`.** Текст карточки собирается с `parse_mode=HTML`
из смеси (а) намеренной разметки-обёртки (`<a href>` номер задачи, `<b>`) и (б) подставляемых
**данных**. Намеренная разметка экранироваться **не должна**; данные — должны. Сейчас
экранирован только заголовок (`esc_title`, `notifications.py:428`) и href/label внутри
`plane_issue_link`. Прочие данные — длительности (`_fmt_minutes`), метрики токенов/стоимости
(`fmt_tokens`/`fmt_cost`), имя модели (`short_model_name`), статус-лейбл
(`_card_status_label`) — вставляются **без** `html.escape`. `<1м` — первый сработавший
экземпляр этого класса; задача закрывает класс, а не единичный символ.

## 2. Объём (scope)

### В объёме
- Устранить HTML-инъекцию в `render_task_tracker`: любые **данные**, попадающие в текст
  карточки с `parse_mode=HTML`, не должны ломать парсер Telegram (`< > &` в данных
  безопасны).
- Привести формат «длительность < 1 мин» к HTML-безопасному виду (экранированный `&lt;1м`
  ИЛИ переформулировка `<1м` → `~0м` / `< 1 мин` с экранированием).
- Сохранить работоспособность **намеренной** разметки карточки (`<a href>` номер задачи,
  жирный/прочее форматирование) — экранируются только данные, не обёртка.
- Восстановить обновления уже застрявших карточек (после фикса карточка возобновляет
  обновления или переотправляется свежей).
- Юнит-покрытие HTML-безопасности всех динамических полей; зелёный регресс `pytest tests/ -q`;
  запись в `CHANGELOG.md`.

### Вне объёма
- Изменение `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, схемы БД — **не трогаются** (баг
  чисто в слое рендера уведомлений).
- Изменение режима трекера (`bump`/`edit`), логики леджера сирот (ORCH-087), статусной модели
  ORCH-066, транспортных примитивов (`send_telegram`/`edit_telegram`/`delete_telegram`) —
  кроме точечной HTML-безопасности самого текста.
- Редизайн раскладки/состава карточки, новые метрики, перевод строк.
- Изменение машинных вердиктов / frontmatter-контракта.

## 3. Заинтересованные стороны

- **Заказчик / репортёр:** Слава (оператор) — обнаружил баг 09.06 (карточка ORCH-093 застряла,
  «по 91 уже нету»).
- **Затронуты:** все наблюдатели Telegram-трекера по **всем** проектам (self-hosting: общий
  прод-инстанс обслуживает и enduro-trails — карточки их задач так же уязвимы при стадии < 1 мин).
- **Принимает результат:** reviewer/tester конвейера ORCH; финальная приёмка — оператор
  (карточки снова обновляются в реальном времени).

## 4. Бизнес-требования (BR)

- **BR-1** — Карточка трекера, в тексте которой есть стадия длительностью < 1 мин, должна
  успешно редактироваться (`editMessageText` → `200`, не `400 can't parse entities`). Источник
  отказа — литерал `<1м` от `_fmt_minutes` — устранён. (⇒ G1, G2)
- **BR-2** — **Все** динамические значения, вставляемые в текст карточки с `parse_mode=HTML`
  (длительности, метрики токенов/стоимости, имя модели/эффорта, имена/лейблы стадий,
  статус-лейбл, заголовок задачи), HTML-безопасны: символы `< > &` в **данных** не
  интерпретируются Telegram как разметка. (⇒ G1)
- **BR-3** — Длительность «меньше минуты» рендерится так, чтобы не выглядеть открывающим
  HTML-тегом: экранированный `&lt;1м` **ИЛИ** переформулировка (`~0м` / `< 1 мин`) с
  экранированием. Видимое оператору значение остаётся осмысленным («меньше минуты»). (⇒ G2)
- **BR-4** — **Регресс намеренной разметки:** кликабельный номер задачи (`<a href>`,
  `plane_issue_link`) и любое форматирование-обёртка (`<b>` и т.п.) продолжают рендериться и
  оставаться кликабельными/валидными — экранируются только подставляемые данные, не разметка. (⇒ G3)
- **BR-5** — Уже застрявшая карточка (класс ORCH-093) после деплоя фикса **возобновляет
  обновления**: либо успешный `editMessageText` на следующем переходе стадии, либо
  переотправка свежей карточки. Конкретный механизм восстановления (текст снова валиден →
  edit проходит, ИЛИ классификация `can't parse entities` как пересоздаваемой) — решение
  архитектора; бизнес-требование — карточка перестаёт быть «замёрзшей сиротой». (⇒ G... / AC-4)

## 5. Нефункциональные требования (NFR)

- **NFR-1 (never-raise):** `render_task_tracker` и весь путь уведомлений сохраняют контракт
  «никогда не роняют конвейер» — любая ошибка рендера/экранирования деградирует к
  fallback-строке, не исключение.
- **NFR-2 (нулевая регрессия разметки):** существующие зелёные тесты трекера
  (`test_telegram_tracker.py`, `test_tracker_*`, `test_notifications_orphans.py`,
  `test_notify_issue_links.py`) остаются зелёными; кликабельность номера и формат строк не
  деградируют визуально (кроме намеренной смены вида «<1м»).
- **NFR-3 (self-hosting):** фикс — изменение **только** слоя рендера уведомлений; прод-контейнер
  `orchestrator` не перезапускается в рамках стадий разработки; обязательна страховка
  `deploy-staging` перед прод-деплоем. Машина стадий/гейты/схема БД не затрагиваются.
- **NFR-4 (совместимость):** изменение обратносовместимо по данным/схеме; не требует миграций;
  применяется к новым рендерам сразу после деплоя.

## 6. Допущения и ограничения

- Карточка всегда отправляется с `parse_mode=HTML` (`send_telegram:58`, `edit_telegram:175`) —
  это инвариант (ссылки/жирный требуют HTML); переход на `parse_mode=None`/MarkdownV2 **не**
  рассматривается (сломает намеренную разметку, шире объёма).
- `fmt_tokens`/`fmt_cost` сейчас выдают только цифры/`.`/`k`/`M`/`$` (HTML-безопасно), но
  требование BR-2 покрывает их **defence-in-depth** на случай будущих изменений формата.
- Telegram-лимит 48ч: карточки старше 48ч физически неудаляемы/неперезаписываемы — для них
  восстановление недостижимо (known-limitation, унаследовано от ORCH-087); BR-5 относится к
  карточкам в пределах окна.
- Источник `<1м` — `_fmt_minutes` (единственная функция, эмитящая литерал `<`); прочие данные
  лишь потенциально опасны. Точка(и) внесения экранирования — решение архитектора (централизовать
  в `_fmt_minutes`/на точке рендера/обёрткой-хелпером).

## 7. Критерии успеха

Карточка задачи со стадией < 1 мин успешно редактируется (нет `400 can't parse entities`);
все динамические поля HTML-безопасны; намеренная разметка (ссылка-номер, форматирование)
рендерится и кликабельна; застрявшие карточки возобновляют обновления; `never-raise` сохранён;
`pytest tests/ -q` зелёный; `CHANGELOG.md` обновлён. Детальные PASS/FAIL — `03-acceptance-criteria.md`.

## 8. Риски

- **Двойное экранирование** уже экранированных полей (`esc_title`, href/label в
  `plane_issue_link`) → `&amp;lt;` в выводе. Митигировать на стадии архитектуры (экранировать
  ровно один раз на источник данных).
- **Случайное экранирование разметки-обёртки** (`<a>`, `<b>`) → ссылки/жирный перестают
  работать (регресс BR-4). Чёткая граница «данные vs обёртка».
- Изменение вида «<1м» меняет визуал карточки — согласовать формулировку с оператором (BR-3
  допускает оба варианта).
- Детали/перечень — `10-tech-risks.md` (заполняет архитектор).
