---
work_item: ORCH-095
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-09
model_used: claude-opus-4-8
---

# ADR-001: HTML-безопасный рендер данных live-карточки трекера (устранение инъекции «<1м»)

Work Item: **ORCH-095** — HTML-инъекция `<1м` в `render_task_tracker` застывает live-карточку
Стадия: **architecture**
Сквозная регистрация: **N/A — локальное решение задачи.** Изменение целиком в слое рендера
уведомлений (`src/notifications.py`); новой стадии/QG/компонента/смены БД нет, инварианты
`STAGE_TRANSITIONS`/`QG_CHECKS`/схемы не затрагиваются → глобальный `adr-NNNN` не заводится
(прецедент — ORCH-091, такой же indication-only фикс рендера, тоже без сквозного ADR).

## Статус
Accepted

## Контекст

Live-карточка задачи (`src/notifications.py::render_task_tracker`) — основной канал видимости
конвейера для оператора, инвариант «одна карточка на задачу» (ORCH-042/067/087). Карточка
отправляется и редактируется с `parse_mode=HTML` (`send_telegram:58`, `edit_telegram:175`).

**Сверено по коду.** `_fmt_minutes(seconds)` (`notifications.py:280-290`) при `0 < seconds < 60`
возвращает литерал `"<1м"`:

```python
if seconds < 60:
    return "<1м"
```

Эта подстрока интерполируется в HTML-текст карточки **без экранирования** (`_stage_line`:
`dur = _fmt_minutes(dur_sum)` → строка `f"✅ {label:<13} {dur} · …"`; те же `_fmt_minutes` /
`_capped_review_str` в строке BRD и в итоговой строке времени). Telegram трактует `<1м` как
открывающий HTML-тег → `editMessageText` отвечает `400 Bad Request: can't parse entities:
Unsupported start tag "1м"`. В `edit_telegram` неизвестный `400` классифицируется как
`EDIT_FAILED` (`notifications.py:203`), а `update_task_tracker` по ветке `EDIT_FAILED` делает
ранний `return` (анти-дубль ORCH-087) → **карточка застывает** (воспроизведено детерминированно
09.06 на ORCH-093, `message_id 18854`).

**Корневой класс шире одного `<1м`.** Текст карточки — смесь (а) намеренной разметки-обёртки
(`<a href>` номер задачи `num_html`, `link_for`, `_done_link`; заголовок уже экранирован как
`esc_title`, `notifications.py:428`) и (б) подставляемых **данных**. Экранирована только
категория-обёртка (href/label в `plane_issue_link` через `html.escape(..., quote=True)`) и
заголовок. Прочие данные — длительности (`_fmt_minutes`/`_capped_review_str`), статус-лейбл
(`_card_status_label` → `status_label`), имя модели (`short_model_name`), эффорт (`_run_effort`),
токены/стоимость (`fmt_tokens`/`fmt_cost`) — вставляются сырыми. `<1м` — первый сработавший
экземпляр класса «неэкранированные данные в HTML-тексте»; ТЗ требует закрыть класс, а не символ
(BR-2/FR-2).

«Как есть» не годится: симптом плавающий (ловится только когда хотя бы одна стадия длилась
< 60 с и её строка попадает в редактируемый текст), а отказ перманентный для конкретной карточки
до конца жизни задачи — оператор слепнет.

## Решение

### Сводка

Локализуем HTML-безопасность в **границе рендера**: каждое подставляемое **данные-значение**
экранируется `html.escape(...)` ровно один раз в точке интерполяции в `render_task_tracker`;
функции-источники данных (`_fmt_minutes`, `short_model_name`, `_run_effort`, `fmt_tokens`,
`fmt_cost`, `_card_status_label`) остаются **HTML-агностичными** (производят данные, не разметку).
Намеренная разметка-обёртка (`num_html`, `link_for(...)`, `_done_link`, уже-экранированный
`esc_title`) через экранирование **не** проходит. Литерал `<1м` в `_fmt_minutes` **сохраняется
как есть**: будучи экранированным на границе (`&lt;1м`), он рендерится оператору визуально
идентично (`<1м`) → видимый формат не меняется, согласование формулировки не требуется.

### D1 — Точка внесения экранирования: граница рендера, не источник данных (⇒ FR-1, FR-2)

Экранирование делается на **потребителе** (внутри `render_task_tracker`/`_stage_line`), а не
внутри функций-источников. Модель «слотов»: текст карточки собирается из слотов двух категорий —

- **Категория M (markup, НЕ экранировать):** `num_html` (`plane_issue_link`, внутри уже
  экранированы href+label), `link_for(...)` в строке «⏳ ждёт …», `_done_link(...)`
  («🔗 PR #n · 📦 Внедрено»), `esc_title` (уже экранирован в строке 428).
- **Категория D (data, экранировать ровно один раз):** `dur` (`_fmt_minutes`/`_capped_review_str`),
  `status_label` (`_card_status_label`), `model` (`short_model_name`), `effort` (`_run_effort`),
  `in_tok`/`out_tok` (`fmt_tokens`), `cost` (`fmt_cost`), а также числовые `attempt` и static-лейблы
  стадий (`_TRACKER_STAGES`/`_BRD_LABEL` — статичны и безопасны, но проходят через D ради
  единообразного инварианта).

Рекомендуемая реализация (необязательна к буквальному следованию — выбор формы за developer):
завести тонкий модуль-локальный хелпер `def _esc(x): return html.escape(str(x))` (never-raise:
на исключении `str()` → пустая строка/исходный fallback) и обернуть им каждый D-слот в момент
присваивания, например `dur = _esc(_fmt_minutes(dur_sum))`, `model = _esc(short_model_name(...))`,
`status_label = _esc(status_label)`. Источники данных НЕ трогаются (в т.ч. `src/usage.py` —
`fmt_tokens`/`fmt_cost`/`short_model_name` остаются как есть; defence-in-depth делается на
потребителе, как зафиксировано в ТЗ §2).

**Почему граница рендера, а не источник.** (1) Single-responsibility: `_fmt_minutes` и
`short_model_name` используются и вне HTML-контекста (логи, потенциально иные потребители) —
вшивать `&lt;` в их вывод сделало бы данные «грязными» в не-HTML-контексте. (2) Инвариант FR-2
формулируется и тестируется как свойство ОДНОЙ функции (`render_task_tracker`): «ни один символ
`< > &` из данных не остаётся неэкранированным в выходе» — а не как разрозненные контракты пяти
источников. (3) Экранирование на границе по построению исключает двойное экранирование: каждый
D-слот экранируется в ровно одной точке; M-слоты не экранируются вовсе.

**Инвариант D1:** видимый оператору формат всех D-полей не меняется (escape `<1м`→`&lt;1м`
рендерится как `<1м`; `~Nм`, `Nм`, токены/стоимость/модель символов `< > &` не содержат →
escape для них no-op).

### D2 — Сохранение `<1м` в источнике; формат-источник `_fmt_minutes` не меняется (⇒ FR-1, BR-3)

BR-3/FR-1 допускают два пути: (а) экранировать `&lt;1м`, либо (б) переформулировать (`~0м` /
`< 1 мин`). Выбираем **(а)**: `_fmt_minutes` продолжает возвращать `"<1м"`, безопасность даёт
escape на границе (D1). Это минимизирует поверхность изменения (никаких правок числовой/строковой
логики `_fmt_minutes`, `_capped_review_str`, тестов формата длительности) и сохраняет видимый
оператору вид `<1м` без согласования новой формулировки. `_fmt_minutes` сохраняет never-raise
(нечисловой/None → `0м`) без изменений.

### D3 — Defence-in-depth: экранируются ВСЕ D-поля, включая сейчас-безопасные (⇒ FR-2, BR-2)

Экранируются все поля категории D, в т.ч. сейчас гарантированно безопасные (`fmt_tokens`/
`fmt_cost` дают только цифры/`.`/`k`/`M`/`$`; `short_model_name` — `^claude-…$`). Стоимость
нулевая (escape безопасной строки — no-op), выгода — **структурный инвариант**: «каждый D-слот
карточки экранирован», который защищает от регрессии при будущей смене формата любого источника
(напр. если в имя модели/эффорта когда-нибудь попадёт пользовательский ввод). Тест AC-2 ассертит
инвариант, а не отдельные поля.

### D4 — FR-4 (восстановление застрявших карточек): авто-recovery следующим рендером; парс-фейл НЕ переклассифицируется (⇒ BR-5, FR-4)

Механизм восстановления — **достаточное условие по умолчанию** из FR-4: после деплоя фикса на
ближайшем переходе стадии `update_task_tracker` рендерит НОВЫЙ безопасный текст и вызывает
`edit_telegram(mid, new_text)` → Telegram отвечает `200` → застрявшая карточка (класс ORCH-093)
обновляется на месте. **Нового кода не требуется.**

Опциональную переклассификацию `can't parse entities` в `edit_telegram`/`update_task_tracker`
(переотправка свежей карточки вместо `EDIT_FAILED`) **отвергаем**:

- **Не помогает.** Если текст всё ещё небезопасен, `send_telegram` упадёт на том же `400`
  идентично `editMessageText` (тот же `parse_mode=HTML`) и вернёт `None` → новой карточки нет.
  После фикса D1–D3 источник `can't parse entities` из НАШИХ данных структурно устранён, поэтому
  отдельная ветка восстановления лечит несуществующий после фикса случай.
- **Риск.** Любое касание ветки `EDIT_FAILED`/леджера сирот рискует инвариантом ORCH-087
  (транзиентный фейл НЕ должен плодить карточки). Минимальная поверхность безопаснее.

`edit_telegram`, `update_task_tracker`, `send_telegram`, леджер `tracker_messages`, режимы
`bump`/`edit` — **не трогаются**. Known-limitation (унаследовано ORCH-087): для карточки, у
которой после фикса больше НЕ будет переходов стадии (задача завершилась до деплоя), повторного
рендера не возникнет → карточка остаётся замёрзшей; Telegram-лимит 48ч делает её неперезаписываемой
вне окна. BR-5 относится к карточкам в пределах окна с предстоящими переходами.

### D5 — Граница «данные vs обёртка»: M-слоты неприкосновенны, двойное экранирование запрещено (⇒ FR-3, BR-4)

`num_html` (`plane_issue_link`), `link_for(...)`, `_done_link(...)` и `esc_title` через `_esc`
НЕ проходят — остаются валидным HTML, номер задачи кликабелен. Внутренности `plane_issue_link`
(href `html.escape(url, quote=True)`, label `html.escape(work_item_id)`) уже экранированы — повторно
их не экранируем (иначе `&amp;lt;`, регресс AC-2/AC-3). Граница явная и тестируемая: D-слот → `_esc`;
M-слот → as-is.

### D6 — Трассировка и инварианты соседних маркеров (⇒ NFR-2, NFR-3)

`render_task_tracker`/`_stage_line` несут маркеры ORCH-042/067/087/091. Изменение ORCH-095
**аддитивно** к ним и обязано сохранить их инварианты: «одна карточка на задачу», леджер сирот и
анти-дубль (ORCH-087), отражение откатов + суммирование метрик `_stage_line` (ORCH-091), строка
Plane-статуса/кликабельный номер (ORCH-067). Поскольку ORCH-095 лишь оборачивает уже вычисленные
D-значения в `_esc`, не меняя ни состава строк, ни порядка, ни логики подавления/суммирования —
инварианты сохраняются по построению. Новые/изменённые строки помечаются маркером `ORCH-095`;
блок остаётся читаемым (не вводим 3+ новых маркера в один блок → сводный сквозной ADR не требуется,
TRACEABILITY анти-археология соблюдена).

## Альтернативы

- **Экранировать в источнике (`_fmt_minutes` возвращает `&lt;1м`)** — отвергнуто: пачкает данные
  в не-HTML-контексте (логи), размазывает инвариант FR-2 по пяти функциям, усложняет защиту от
  двойного экранирования (D1).
- **Переформулировать `<1м` → `~0м`/`< 1 мин`** — отвергнуто: меняет видимый оператору формат
  (требует согласования), трогает логику/тесты `_fmt_minutes`; escape на границе достигает того же
  при меньшей поверхности и нулевом визуальном изменении (D2).
- **Переключить карточку на `parse_mode=None`/MarkdownV2** — отвергнуто (вне объёма BRD §6):
  сломает намеренную разметку (`<a href>` номер, `<b>`), MarkdownV2 требует экранирования ещё
  большего набора символов.
- **Переклассификация `can't parse entities` → переотправка** — отвергнуто (D4): не помогает
  (send падает идентично), риск инварианту анти-дубля ORCH-087.

## Последствия

- **+** Класс «неэкранированные данные в HTML-тексте карточки» закрыт целиком (BR-2); `<1м` и
  любые будущие `< > &` из данных безопасны; карточка со стадией < 1 мин редактируется (`200`).
- **+** Структурный defence-in-depth инвариант («каждый D-слот экранирован»), тестируемый одним
  свойством `render_task_tracker` (AC-2), устойчив к будущим сменам формата источников.
- **+** Видимый формат карточки и намеренная разметка (кликабельный номер, `_done_link`) без
  изменений (BR-3/BR-4); никаких миграций/правок схемы/гейтов (NFR-3/NFR-4).
- **+** Застрявшие (в окне) карточки авто-восстанавливаются следующим рендером без нового кода
  (BR-5).
- **−** Точечная дисциплина «D-слот → `_esc`, M-слот → as-is» вносит точку для будущих ошибок
  (можно забыть обернуть новый D-слот или по ошибке обернуть M-слот → двойное экранирование).
  Митигейшн: тест-инвариант AC-2 (нет сырого `< > &` из данных И нет `&amp;lt;`) ловит обе
  ошибки; явный реестр M-слотов в D5.
- **−** Карточки задач, завершившихся до деплоя фикса, не восстанавливаются (нет будущего
  рендера) — known-limitation, унаследовано ORCH-087/Telegram-48ч; вне управляемого.
- **Откат:** обычный revert PR (только `src/notifications.py` + тесты + `CHANGELOG.md` +
  doc-правки); прод-контейнер `orchestrator` не требует ручных операций над данными/БД.

## Ссылки
- BRD: `docs/work-items/ORCH-095/01-brd.md`
- TRZ: `docs/work-items/ORCH-095/02-trz.md`
- Acceptance: `docs/work-items/ORCH-095/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-095/10-tech-risks.md`
- Сверено по коду: `src/notifications.py` (`_fmt_minutes:280-290`, `_capped_review_str:315-336`,
  `render_task_tracker:355-610`, `_stage_line:467-507`, `_card_status_label:1173-1186`,
  `plane_issue_link:932-949`, `_done_link:613-647`, `link_for:952-984`, `edit_telegram:157-207`,
  `update_task_tracker:650-746`, `send_telegram:42-71`, `esc_title:428`)
- Инварианты соседей: ORCH-042/067 (карточка/номер), ORCH-087 (леджер сирот/анти-дубль),
  ORCH-091 (откаты/суммирование `_stage_line`) — `docs/architecture/internals.md` §7
