From 6b14b07f40fd0543cc17f71b08baf15ea5e25ce8 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 23:56:33 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=526 --- docs/architecture/README.md | 2 +- docs/architecture/internals.md | 2 + .../ADR-001-html-safe-card-data-render.md | 209 ++++++++++++++++++ docs/work-items/ORCH-095/10-tech-risks.md | 37 ++++ 4 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 docs/work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md create mode 100644 docs/work-items/ORCH-095/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 08744a5..6a16160 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -15,7 +15,7 @@ - **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`. - **Disk-watchdog** (`src/disk_watchdog.py`, ORCH-063 — [adr-0024](adr/adr-0024-disk-watchdog.md)) — фоновый daemon-поток (каркас `reconciler`/`job_reaper`), стартует/останавливается в `main.lifespan` (старт последним — после `reaper.start()`; стоп первым в reverse-порядке; гард `disk_monitor_enabled`). Каждые `disk_monitor_interval_s` (дефолт 300с) меряет заполнение **хост-ФС** по смонтированным bind-путям (`/repos`, `/app/data`) через stdlib `shutil.disk_usage` (не overlay `/` контейнера, не субпроцесс `df`; дедуп путей по `st_dev`). Решение об алерте — pure-функция `decide_action(used_pct, threshold, prev_state, now, realert_s)`: алерт на пересечении порога (дефолт **85%**), cooldown-повтор `disk_monitor_realert_s` (анти-спам, не на каждом тике), однократный recovery при возврате ниже порога. Алерт — `send_telegram` (notifying, best-effort). Состояние анти-спама — in-memory (без миграции БД). never-raise (per-path/per-tick/per-send); только читает и уведомляет — не трогает диск/контейнер, не рестартит прод (self-hosting безопасность). Kill-switch `ORCH_DISK_MONITOR_ENABLED`; снимок — блок `disk_monitor` в `GET /queue` (`enabled`/`threshold_pct`/`interval_s`/`realert_s`/`paths`[`used_pct`/`free_gb`/`alerting`/`last_alert_at`]). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. Детали — `docs/work-items/ORCH-063/06-adr/ADR-001-disk-watchdog.md`. - **Build-cache-pruner** (`src/build_cache_pruner.py`, ORCH-062 — [adr-0025](adr/adr-0025-build-cache-pruner.md)) — фоновый daemon-поток (каркас `disk_watchdog`), стартует/останавливается в `main.lifespan` (старт последним — после `disk_watchdog.start()`; стоп первым в reverse; гард `build_cache_prune_enabled`). «Вторая половина» disk-watchdog: **watchdog сигналит — pruner убирает**. Каждые `build_cache_prune_interval_s` (дефолт 21600с = 6ч) выполняет **строго `docker builder prune -f --filter until=`** (BuildKit GC; дефолт `until=24h` — удаляет build cache старше суток, тёплый кэш сохраняет; `-a` опционально, только в паре с фильтром). Затрагивает **только** build cache — НЕ образы/контейнеры; рестарт docker daemon/прода не выполняется (self-hosting безопасность). В контейнере нет `docker` CLI (`Dockerfile:11`), поэтому уборка идёт **на хосте через ssh** каналом `deploy_ssh_user@deploy_ssh_host` (как `image_freshness`/`self_deploy`); пустой `deploy_ssh_host` → тик no-op (скоуп на self-host). never-raise (per-команда/per-tick); учёт результата in-memory (без миграции БД). Kill-switch `ORCH_BUILD_CACHE_PRUNE_ENABLED`; снимок — блок `build_cache_prune` в `GET /queue` (`enabled`/`interval_s`/`until`/`last_run_ts`/`last_reclaimed`/`last_error`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. Детали — `docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md`. -- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. **ORCH-087:** bump ведёт авторитетный леджер всех созданных карточек (`tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении зачищает ВСЕ незакрытые mid (а не только скаляр `tracker_message_id`) → класс «замёрзшая сирота» устранён; строка стадии несёт фактический эффорт рядом с моделью (`· {model} · {effort}`, колонка `agent_runs.effort`, стамп в `launcher._spawn`); done-строка времени переписана на три подписанных метрики `⏱️ Агенты · твоё{~cap} · общее с ожиданием` (кап `ORCH_TRACKER_BRD_REVIEW_CAP_S`); deploy-цикл дополнен overlay-ключом `confirm_deploy`. **ORCH-091 (индикация-only):** три корректности рендера — (1) `_STAGE_STATUS_LABEL` покрывает ВСЕ ключи `STAGE_TRANSITIONS` (добавлены `deploy-staging`→«Deploying (staging)», `cancelled`→«Cancelled»; полнота гарантируется тестом по `stages.STAGE_TRANSITIONS`, не статичным списком — NFR-3), runtime-фолбэк для неизвестной стадии стал нейтральным (капитализированное имя) вместо «To Analyse»; (2) при откате конвейера `✅`-строки стадий ПОЗЖЕ текущей позиции (позиция — из порядка `STAGE_TRANSITIONS`, с нормализацией `deploy-staging→deploy` только в гейте подавления; `is_active_stage` не тронут) больше не рисуются; (3) строка стадии суммирует ВСЕ `agent_runs` агента (Σ cost/токены/время теми же формулами, что блок тоталов) → строгая сходимость с `SUM(agent_runs)`. Только `src/notifications.py` + тесты; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/транспорт — не тронуты. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7, [ADR-087](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md) и [ORCH-091 ADR-001](../work-items/ORCH-091/06-adr/ADR-001-tracker-status-rollback-metrics.md). +- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. **ORCH-087:** bump ведёт авторитетный леджер всех созданных карточек (`tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении зачищает ВСЕ незакрытые mid (а не только скаляр `tracker_message_id`) → класс «замёрзшая сирота» устранён; строка стадии несёт фактический эффорт рядом с моделью (`· {model} · {effort}`, колонка `agent_runs.effort`, стамп в `launcher._spawn`); done-строка времени переписана на три подписанных метрики `⏱️ Агенты · твоё{~cap} · общее с ожиданием` (кап `ORCH_TRACKER_BRD_REVIEW_CAP_S`); deploy-цикл дополнен overlay-ключом `confirm_deploy`. **ORCH-091 (индикация-only):** три корректности рендера — (1) `_STAGE_STATUS_LABEL` покрывает ВСЕ ключи `STAGE_TRANSITIONS` (добавлены `deploy-staging`→«Deploying (staging)», `cancelled`→«Cancelled»; полнота гарантируется тестом по `stages.STAGE_TRANSITIONS`, не статичным списком — NFR-3), runtime-фолбэк для неизвестной стадии стал нейтральным (капитализированное имя) вместо «To Analyse»; (2) при откате конвейера `✅`-строки стадий ПОЗЖЕ текущей позиции (позиция — из порядка `STAGE_TRANSITIONS`, с нормализацией `deploy-staging→deploy` только в гейте подавления; `is_active_stage` не тронут) больше не рисуются; (3) строка стадии суммирует ВСЕ `agent_runs` агента (Σ cost/токены/время теми же формулами, что блок тоталов) → строгая сходимость с `SUM(agent_runs)`. Только `src/notifications.py` + тесты; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/транспорт — не тронуты. Контракт всего компонента — never raises; карточка всегда silent. **ORCH-095 (HTML-безопасность данных):** текст карточки шлётся с `parse_mode=HTML`; каждый **data**-слот (длительности `_fmt_minutes`/`_capped_review_str`, статус-лейбл, модель/эффорт, токены/стоимость) экранируется `html.escape` ровно один раз на границе рендера, **markup**-слоты (`num_html`/`link_for`/`_done_link`/`esc_title`) — нет (двойное экранирование запрещено). Устранён класс «неэкранированные данные в HTML» (литерал `<1м` от `_fmt_minutes` → Telegram `400 can't parse entities` → застывшая карточка, инцидент ORCH-093); `_fmt_minutes` по-прежнему даёт `<1м` (escape рендерит визуально идентично). Застрявшая карточка в окне авто-восстанавливается следующим рендером; `edit_telegram`/`update_task_tracker`/леджер сирот не тронуты. Детали — [internals.md](internals.md) §7, [ADR-087](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md), [ORCH-091 ADR-001](../work-items/ORCH-091/06-adr/ADR-001-tracker-status-rollback-metrics.md) и [ORCH-095 ADR-001](../work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md). - **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту. - **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость). diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index ae850c5..d68e62e 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -142,6 +142,8 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash **Кликабельный номер задачи (ORCH-067).** Номер в заголовке карточки И во всех уведомлениях орка, где упоминается `work_item_id`, — HTML-ссылка на issue в Plane через общий `plane_issue_link` / `link_for` (URL строит `_plane_issue_url` с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → `html.escape(work_item_id)` без ``; динамические части экранируются, ``-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`). +**HTML-безопасность данных карточки (ORCH-095).** Текст карточки шлётся с `parse_mode=HTML` и собирается из слотов двух категорий: **markup** (намеренная разметка — `num_html`/`plane_issue_link`, `link_for(...)`, `_done_link(...)`, уже-экранированный `esc_title`) и **data** (подставляемые значения — длительности `_fmt_minutes`/`_capped_review_str`, статус-лейбл `_card_status_label`, имя модели `short_model_name`, эффорт `_run_effort`, токены/стоимость `fmt_tokens`/`fmt_cost`). Инвариант: **каждый data-слот экранируется `html.escape` ровно один раз на границе рендера** (`render_task_tracker`/`_stage_line`); функции-источники остаются HTML-агностичными, markup-слоты не экранируются (двойное экранирование запрещено). Это устранило класс «неэкранированные данные в HTML-тексте»: до фикса `_fmt_minutes(<60s)` возвращал литерал `<1м`, который Telegram парсил как открывающий тег → `editMessageText` `400 can't parse entities` → `EDIT_FAILED` → ранний `return` (анти-дубль ORCH-087) → карточка застывала (инцидент ORCH-093). `_fmt_minutes` по-прежнему возвращает `<1м` — escape на границе (`<1м`) рендерит его визуально идентично; формат не меняется. Застрявшая (в окне) карточка авто-восстанавливается следующим безопасным рендером; `edit_telegram`/`update_task_tracker`/леджер сирот/режимы `bump`/`edit` не тронуты. Детали — [ORCH-095 ADR-001](../work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md). + ## Database Schema ```sql diff --git a/docs/work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md b/docs/work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md new file mode 100644 index 0000000..96e727b --- /dev/null +++ b/docs/work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md @@ -0,0 +1,209 @@ +--- +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м`.** Текст карточки — смесь (а) намеренной разметки-обёртки +(`` номер задачи `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` **сохраняется +как есть**: будучи экранированным на границе (`<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-контекста (логи, потенциально иные потребители) — +вшивать `<` в их вывод сделало бы данные «грязными» в не-HTML-контексте. (2) Инвариант FR-2 +формулируется и тестируется как свойство ОДНОЙ функции (`render_task_tracker`): «ни один символ +`< > &` из данных не остаётся неэкранированным в выходе» — а не как разрозненные контракты пяти +источников. (3) Экранирование на границе по построению исключает двойное экранирование: каждый +D-слот экранируется в ровно одной точке; M-слоты не экранируются вовсе. + +**Инвариант D1:** видимый оператору формат всех D-полей не меняется (escape `<1м`→`<1м` +рендерится как `<1м`; `~Nм`, `Nм`, токены/стоимость/модель символов `< > &` не содержат → +escape для них no-op). + +### D2 — Сохранение `<1м` в источнике; формат-источник `_fmt_minutes` не меняется (⇒ FR-1, BR-3) + +BR-3/FR-1 допускают два пути: (а) экранировать `<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)`) уже экранированы — повторно +их не экранируем (иначе `&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` возвращает `<1м`)** — отвергнуто: пачкает данные + в не-HTML-контексте (логи), размазывает инвариант FR-2 по пяти функциям, усложняет защиту от + двойного экранирования (D1). +- **Переформулировать `<1м` → `~0м`/`< 1 мин`** — отвергнуто: меняет видимый оператору формат + (требует согласования), трогает логику/тесты `_fmt_minutes`; escape на границе достигает того же + при меньшей поверхности и нулевом визуальном изменении (D2). +- **Переключить карточку на `parse_mode=None`/MarkdownV2** — отвергнуто (вне объёма BRD §6): + сломает намеренную разметку (`` номер, ``), 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 (нет сырого `< > &` из данных И нет `&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 diff --git a/docs/work-items/ORCH-095/10-tech-risks.md b/docs/work-items/ORCH-095/10-tech-risks.md new file mode 100644 index 0000000..b254da9 --- /dev/null +++ b/docs/work-items/ORCH-095/10-tech-risks.md @@ -0,0 +1,37 @@ +--- +work_item: ORCH-095 +stage: architecture +author_agent: architect +status: accepted +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-095 — HTML-безопасность данных live-карточки + +Work Item: **ORCH-095** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Двойное экранирование** уже-экранированных полей (`esc_title`, href/label внутри `plane_issue_link`) → `&lt;` в выводе, визуальный мусор / регресс AC-2 | Сред. | Сред. | D1/D5: явный реестр M-слотов (markup) — через `_esc` НЕ проходят; `esc_title` остаётся единственной точкой escape заголовка; тест AC-2 ассертит отсутствие `&lt;` | +| TR-2 | **Случайное экранирование разметки-обёртки** (`num_html`/`link_for`/`_done_link`) → `` превращается в `<a>`, номер задачи перестаёт быть кликабельным (регресс BR-4/AC-3) | Низ. | Выс. | D5: M-слоты неприкосновенны; регресс-тесты `test_tracker_issue_link.py`/`test_notify_issue_links.py`/`test_telegram_tracker.py` зелёные; AC-3 проверяет наличие валидного `` в выводе | +| TR-3 | **Пропущен новый/существующий D-слот** (забыли обернуть `_esc`) → инъекция возвращается на другом поле | Низ. | Сред. | D3 defence-in-depth (обернуть ВСЕ D-поля разом); тест-инвариант AC-2 рендерит карточку с `< > &` в данных и ассертит отсутствие сырых спецсимволов из данных в выводе (свойство `render_task_tracker`, не пер-поле) | +| TR-4 | **Регресс never-raise**: `_esc(str(x))` на «битом» входе (объект с падающим `__str__`) бросает исключение в пути рендера (нарушение NFR-1) | Низ. | Сред. | FR-5: `_esc` сам never-raise (try/except → fallback-строка); путь `render_task_tracker`/`update_task_tracker` уже обёрнут `try/except` (строки 654/745); тест AC-5 с «битым» входом | +| TR-5 | **Застрявшая карточка не восстановилась** (задача завершилась до деплоя → нет будущего рендера) | Сред. | Низ. | Принятая known-limitation (D4): авто-recovery работает только при предстоящем переходе стадии; вне окна — Telegram-48ч (унаследовано ORCH-087); BR-5 ограничен карточками в окне | +| TR-6 | **Скрытая регрессия инвариантов соседних маркеров** (ORCH-087 анти-дубль, ORCH-091 суммирование `_stage_line`) при правке тела `_stage_line`/`render_task_tracker` | Низ. | Выс. | D6: изменение аддитивно (лишь оборачивает уже вычисленные значения в `_esc`), не меняет состав/порядок строк, логику подавления откатов и суммирования; полный регресс `pytest tests/ -q` зелёный (NFR-2) | +| TR-7 | **Self-hosting**: фикс деплоится на общий прод-инстанс (затронуты и enduro-trails) | Низ. | Сред. | NFR-3: изменение только слоя рендера; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; обязательная страховка `deploy-staging` (8501) перед прод-деплоем; прод `orchestrator` не рестартится в рамках разработки | + +## Сводный вывод + +Доминирующий класс рисков — **регресс рендера** (двойное экранирование / случайное экранирование +разметки / пропущенный D-слот), полностью покрываемый тест-инвариантом AC-2 + существующими +регресс-тестами трекера (AC-3/AC-5). Изменение **локализовано** в `src/notifications.py` (слой +рендера уведомлений), аддитивно к маркерам ORCH-042/067/087/091, не затрагивает машину стадий, +Quality Gates, схему БД, транспортные примитивы и режимы трекера. Остаточный риск для +прод-конвейера (self-hosting) — **низкий**: контракт never-raise сохранён, откат — обычный revert +PR без операций над данными. Эскалация `arch:major-change` **не требуется**; возврат в анализ +**не требуется** (ТЗ реализуемо без нарушения архитектурных принципов).