## Context Пакет C сверки follow-ups 25.10.2026 (sdlc-hub#129). Все правки — в Python-коде хаба, без смены схемы базы и контракта с агентами. Владелец пропустил вопросы brainstorming («не сталкивался и не хочу»): решения ниже приняты агентом и продублированы в «Decisions made without discussion». ## Decisions 1. **Нормализация параметров — один шаг в `validate`.** Сразу после проверки типов значений (`_type_error`) `validate` заменяет параметры результатом `normalize(ctype, params) -> dict` (`modules/jira/commands.py`), и все дальнейшие проверки (обязательные параметры, `_rule_error`, временные ключи, повтор темы) идут по нормализованным значениям. Иначе `epic_key: " new:1 "` прошёл бы как постоянный ключ и стал временным уже после проверки объявления, а ключ из пробелов прошёл бы проверку на пустоту. Обязательный параметр, ставший пустым, даёт ту же ошибку «нет <параметр>», пустой элемент списка — «<параметр> — без пустых элементов». Существующие `.strip()` в правилах остаются, они безвредны. Обрезаются строковые параметры всех типов, кроме текстовых `summary`, `description`, `body`, `goal`. Это ключи, `temp_key`, `sprint`, `rename_to`, `move_open_to`, `link_type`, `assignee`, `priority`, даты, `marker`, `attachment_id`, `file`, `filename`, `issue_type`, `target` (строка или элементы списка). Элементы списков `labels`, `labels_add`, `labels_remove`, `keys` тоже обрезаются. Числа, логические значения и `null` не меняются. Нормализация возвращает новый словарь и не меняет вход. 2. **`filename` — NFC.** `normalize` приводит `filename` у `ensure_attachment` к `unicodedata.normalize("NFC", …)`. MCP подставляет имя по умолчанию из `Path(path).name` и тоже приводит его к NFC; на macOS имена из пути приходят в NFD. Поиск вложения по имени в MCP-инструменте `attachment` сравнивает имена в NFC с обеих сторон. 3. **Проверки `hooks.check_batch` идут по нормализованным параметрам.** `validate` вызывается раньше, поэтому `filename` с пробелами по краям проверяется уже обрезанным. 4. **ASCII-цифры.** В `core/http.py` (рядом с `int_group`) появляется функция `is_digits(s) -> bool`: `s` — непустая строка только из `0-9` (`s.isascii() and s.isdigit()`). Модуль jira использует её вместо `str.isdigit()` везде, где за проверкой идёт `int()` или где строка — идентификатор: `hooks.py` (`attachment_id`), `mirror.py` (`check_attachments`), `attachments.py` (сортировка), `fetch.py` (версия агента), `mcp.py` (ссылка `#`). В разборе дат `history.py` и `mcp.py` значение уже прошло форматную проверку, но для единообразия там тоже `is_digits`. 5. **Форма элементов состояния.** `mirror.check_issues(issues)` вызывается до `check_comments` и `check_attachments`. Она требует: `issues` — список (иначе сейчас 500 на итерации), каждый элемент — объект с непустым строковым `key`. Иначе `ValueError` с номером элемента. Обработчик `POST /agent/state` уже превращает `ValueError` в 422 `bad_state`, как для комментариев. 6. **`POST /wiki/sync`.** Если `project` в теле задан и это не строка, ответ 422 `bad_request` «project — строка» до обращения к проектам. 7. **Файлы хранилища.** `files.size` и `files.read` ловят `OSError`. `FileNotFoundError` по-прежнему тихо даёт `None`. Остальные ошибки тоже дают `None`, но пишут в лог предупреждение с путём и ошибкой. Дальше всё идёт по существующему пути «файла нет»: `size = null` при claim, 404 при скачивании. 8. **`SDLC_HUB_HOME=""`.** `paths.home()` берёт `os.environ.get("SDLC_HUB_HOME") or <по умолчанию>`. Пустая строка равна незаданной переменной; `home_problem`/`home_warning` уже считают так. 9. **Восстановление прерванной публикации.** В начале `release.publish`, до проверки версии, вызывается `_recover(rd)`. Если `agent.py.prev` есть, а `agent.py` нет или его sha256 не совпадает с манифестом, тогда как sha256 `agent.py.prev` совпадает, `agent.py.prev` возвращается на место `agent.py` с предупреждением в лог. Если `agent.py` совпадает с манифестом, а `.prev` лежит, `.prev` удаляется. Во всех остальных случаях ничего не трогается, а в лог пишется предупреждение. Так закрываются T2.2 и M-6: после `_recover` в `agent.py` лежит опубликованная версия, и `os.replace(agent, prev)` не затирает годный `.prev`. 10. **Устаревшие ключи настроек.** `base_jql` и `board_id` добавляются в `modules/jira/settings.OBSOLETE`. `ensure_defaults` удаляет их при каждом открытии, не меняя `settings_version`, как `status_labels`. Запись через `PUT /settings` по-прежнему даёт 422 с пояснением. Агенту `board_id` давно не передаётся: его нет в `AGENT_KEYS`. ## Risks / Trade-offs - Нормализация меняет то, что раньше уходило агенту дословно. Пробелы по краям идентификатора в Jira не значимы, а текстовые поля не трогаются. - `_recover` срабатывает только при совпадении sha256 с манифестом, поэтому неизвестное содержимое не перезаписывается. - Удаление `board_id` из `setting` необратимо, но значение нигде не читается. Копия базы есть в `backups/`. ## Decisions made without discussion - R-1 (proposal, 26.10.2026): O-3 (`check_confirmed` считает обратную связь симметричного типа конфликтом) не входит в пакет и переносится в пакет B. Хаб не знает, какие типы симметричны: это знает агент по `/issueLinkType`. Ручная настройка списка типов в хабе отвергнута, она дублировала бы Jira. Цена ошибки: при потерянном отчёте команда `ensure_link` к «связанной» задаче, связанной в обратную сторону, подтверждается не по состоянию, а довыдачей агенту, и агент отвечает existed. Лишний вызов Jira, данные не страдают. - R-2 (design #1): обрезаются все строковые параметры, кроме `summary`, `description`, `body`, `goal`. Обоснование: пробелы по краям идентификатора всегда ошибка ввода, а в тексте могут быть значимы. Цена ошибки: тема с пробелами по краям уходит в Jira как есть, Jira обрезает её сама. - R-3 (design #2): имя вложения приводится к NFC, а не к NFD. NFC — форма, в которой имена приходят из Jira и набираются с клавиатуры. Цена ошибки: имя вложения в Jira будет в NFC, визуально то же самое. - R-4 (design #7): ошибка доступа к файлу хранилища трактуется как «файла нет», а не как отдельная ошибка API. Обоснование: путь «файла нет» уже понятен агенту и клиентам. Цена ошибки: причина видна только в логе хаба. - R-5 (design #9): `_recover` чинит только состояния, однозначно определяемые по sha256 манифеста. Остальные оставляет как есть с предупреждением в лог. Цена ошибки: в редком сочетании сбоев владелец публикует ещё раз. - R-6 (design #10): `base_jql` и `board_id` удаляются через механизм устаревших ключей, без новой версии схемы. Это тот же путь, что у `status_labels`, и при каждом старте ключи, вставленные старым процессом, убираются снова. Цена ошибки: нет. - R-7 (design #7, 26.10.2026): `files.size` для каталога на месте файла возвращает `None` (проверка `S_ISREG`), а не размер каталога. План предписывал только `except OSError`, но `Path.stat()` на каталоге не бросает исключение, и claim отдал бы агенту размер каталога. Решение согласовано с `files.exists` (`is_file`). Цена ошибки: нет, поведение совпадает с целью design #7 («файла нет»); `read` для каталога пишет предупреждение, `size` молчит (T4.1). - R-8 (design #1, 26.10.2026; финальное ревью I-1): необязательный параметр `ensure_fields` (поля `FIELD_PARAMS`: `assignee`, `epic_key`, `priority`, даты, оценки), заданный строкой из одних пробелов, отвергается с 422 «<параметр> — из одних пробелов; для очистки — "" или null». Иначе обрезка превращала бы его в `""`, а агент читает `""` как «очистить поле в Jira». Для `before`/`after`/`move_open_to` пробелы по-прежнему означают «не задано». Цена ошибки: ввод из одних пробелов отвергается хабом, а не Jira; очистка полей работает как раньше через `""` или `null`. - R-9 (design #7, уточняет R-7; финальное ревью I-3): `files.size` открывает файл и берёт размер через `fstat`, `_intact` ловит `OSError`. Причина: `Path.stat()` не требует права на чтение, и `chmod 0` давал настоящий размер при `read` → `None`; повторная загрузка того же файла давала 500. Теперь `size` для нечитаемого файла и каталога — `None` с предупреждением, `put` перезаписывает нечитаемый файл. Цена ошибки: лишнее открытие файла при claim. - R-10 (design #10; финальное ревью M-3, T6.2): на базе v7 с ключами v3 `migrate_v7` удаляет `base_jql` и `board_id` и поднимает `settings_version`; неразобранный `base_jql` теряется вместе с ключом (значение остаётся в предупреждении `migrate_v3` и в `backups/schema-v3`). Цена ошибки: потеря неразобранного значения, которое нигде не читается.