Files
orchestrator/docs/work-items/ORCH-095/01-brd.md

155 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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` (заполняет архитектор).