## Context `main` eb485d7. Устройство, на которое опираются правки: - **Настройки** — `src/jira_hub/settings.py`: `DEFAULTS` содержит `base_jql = "project = AURORA AND labels = AWPCA"`, `project_key = "AURORA"`, `board_id = None`; `AGENT_KEYS` отдаёт агенту `base_jql` и `board_id`. На живом хабе `board_id = 4216`, `settings_version = 10`, `schema_version = 3`. Ключи `labels`, `comment_marker` хаб и агент не читают (наследие `jira-sync`), здесь не трогаются. - **Полный прогон** — `api` (`POST /refresh {full: true}`) и `queue.maybe_auto_refresh` ставят `refresh {jql: base_jql}`; `queue.is_full_refresh` (по `params.jql == base_jql`) решает, считать ли загрузку полной (`authoritative` в `mirror.apply_state` → пересчёт `neighbor`). `mirror.board(conn, base_jql)` ищет `last_sync` по `sync_run.query = base_jql`. - **Снимок доски и список** — `mirror.board` отдаёт всё зеркало; `mirror.list_issues` фильтрует по `label` через `json_each(i.labels)`; признак `neighbor` — колонка зеркала (вне базовой JQL). - **Выдача команд** — `queue.deliver_params` дописывает параметры при выдаче (сейчас `size` у `ensure_attachment`), в базе `params` без них. - **Агент** (`jira-agent/src/agent.py`, v11): `sprints()` читает активные и будущие спринты доски `settings.board_id`; `sprint_names(keys)` в `refresh` собирает имена спринтов задач по этой доске; `do_ensure_sprint` без `board_id` — `failed`. Канбан-доска на `GET /board/{id}/sprint` отвечает Jira ошибкой (спринтов у неё нет). - **MCP** — `mcp_server.py`, user scope, `uv run --project ~/projects/jira-bridge/jira-hub jira-hub mcp`; ходит только через `client`. Описание `batch_create` требует метку `AWPCA` в `ensure_issue`. - **Веб-приложение** — `shell/Layout.tsx` (шапка «Хаб Jira», вкладки «Доска», «Задачи»), доска — `board/useBoardSource.ts` (`GET /board`), список — `issues/IssuesPage.tsx` (`GET /issues`, варианты фильтров из снимка доски). Раскладка доски — `board/storage.ts`, ключи `localStorage` `graph-<имя>…` от имени источника (у хаба — «Хаб»). `fromHub.ts` ставит `hasSprint: true` всегда. ## Goals / Non-Goals **Goals:** несколько проектов-меток в одном мосте; у каждого своя доска Jira (scrum или kanban); сессия Claude и страница веб-приложения работают в разрезе одного проекта; AWPCA после выкатки работает как раньше. **Non-Goals и Anti-Goals:** см. proposal.md. ## Decisions 1. **Проект — таблица `project`, схема v4.** Поля: `label` (TEXT PK — метка Jira, она же ключ проекта), `name` (TEXT, непустое), `board_id` (INTEGER или NULL), `board_type` (`scrum` | `kanban` | NULL — ещё не известен), `created_at`. Отдельная таблица, а не ключ настроек: проект — сущность со своим жизненным циклом, а настройки уходят агенту целиком и поднимают `settings_version` при каждой правке. Создание проекта `settings_version` не поднимает: агенту проекты не нужны, доски он получает в параметрах команд (решение 7). 2. **Метка проекта** — непустая строка без пробельных символов, запятых, кавычек и скобок (метка Jira не может содержать пробел; остальное исключено, чтобы метку можно было без экранирования вставить в JQL `labels in (…)`), до 100 символов. Регистр сохраняется как введён; совпадение с существующей без учёта регистра — 409 (Jira сравнивает метки без учёта регистра в JQL). Название — непустое после обрезки пробелов, до 100 символов. `board_id` — целое > 0 или отсутствует. 3. **Базовая JQL собирается, а не хранится.** `base_jql = "project = AND labels in (<метки по created_at, затем label>)"`; функция в `settings` (или новом модуле `projects`), её используют `POST /refresh {full}`, автообновление, `is_full_refresh`, `mirror.board` и `GET /settings` (поле `base_jql` в ответе — вычисленное, только для чтения; `PUT /settings` с `base_jql` или `board_id` — 422 с указанием, что JQL собирается из проектов, а доска — свойство проекта). Без проектов `base_jql = null`: `POST /refresh {full}` — 422 «нет проектов», автообновление не ставится. Решение владельца: одна JQL на все проекты. 4. **Миграция v3 → v4** (в одной транзакции с копией базы, как прежние): создать `project` и колонку `batch.project`; если `base_jql` в настройках имеет вид `… labels = ` или `… labels in (, …)` — завести проекты с этими метками; название проекта `AWPCA` — «АРМ КА», остальных — сама метка; `board_id` из настроек достаётся проекту, если проект один, иначе никому (с предупреждением в лог); `board_type = NULL` (определит первый `refresh`). Строкам `sync_run` с `query` = старая `base_jql` записать `query` = новая вычисленная — иначе доска до первого полного прогона показывала бы «полного прогона не было»; так же переписать `params.jql` команд `refresh` в `queued` и `claimed`, равный старой `base_jql`, — иначе такой прогон не признается полным. Ключи `base_jql` и `board_id` в `setting` миграция **не удаляет**: базу открывает любой процесс новой версии (`HubClient.from_home` в MCP, CLI), и это может случиться после `git pull`, но до перезапуска старого хаба, а старый хаб эти ключи читает. Новый код `base_jql` из `setting` не читает (вычисляет из проектов), а `board_id` остаётся в `AGENT_KEYS` как запасной для агента: по нему агент v12 выполнит `ensure_sprint`, поставленные до миграции без `board_id`. Ключи убираются отдельной задачей после выкатки (follow-up). В `DEFAULTS` новой версии ключей `base_jql` и `board_id` нет — на новой базе их не появляется. Если `base_jql` не разобрана — проектов нет, в лог — предупреждение с прежним значением. `jira-hub init --config` заводит проект `AWPCA` «АРМ КА» с `board_id` из конфига вместо записи `base_jql`/`board_id`. 5. **Разрез по проекту — одна функция зеркала для доски и списка.** Для проекта P: *свои* — задачи зеркала с меткой P; *соседи* — задачи без метки P, связанные со своими связью (в любую сторону), и эпики своих (`epic_key`), если они есть в зеркале. `GET /board?project=P` отдаёт своих и соседей, `neighbor = false` у своих и `true` у соседей; `links` — связи, у которых хотя бы один конец свой. `GET /issues?project=P` — та же выборка, фильтр `neighbor` относится к признаку относительно P; остальные фильтры — поверх. Без `project` — прежнее поведение (всё зеркало, `neighbor` из колонки). Неизвестный проект — 404. `last_sync` — общий (прогон один на все проекты). Доска признак `neighbor` не читает (`fromHub.ts`): соседи по связям из других проектов рисуются обычными карточками, как сейчас рисуются соседи зеркала; «+ соседи по связям» доска считает сама по связям. Признак `neighbor` относительно проекта виден в колонке «Сосед» списка задач. Владелец связей между проектами не ожидает, поэтому отдельной отрисовки соседей-из-другого-проекта нет. 6. **Пакет проекта — проверки в хабе, не в MCP.** `POST /batches {project: P, …}`: неизвестный P — 422. Для каждой команды: - `ensure_issue` — если в `labels` нет P, хаб дописывает P в конец (параметры хранятся уже с ней); метка другого проекта в `labels` — 422; - `ensure_fields` — `labels_remove` с P или `labels_add` с меткой другого проекта — 422; - «метка другого проекта» и «P в `labels_remove`» сравниваются **без учёта регистра** (Jira в JQL сравнивает метки так же): `awpca` в пакете STOCKS_DRRK — тоже чужая метка; - команды над существующей задачей (`ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment`) — `key` должен быть временным ключом этого пакета или задачей зеркала с меткой P, иначе 422 « не в проекте P»; - `ensure_link` — хотя бы один из `source`, `target` свой (временный ключ пакета или задача с меткой P); - `refresh` — без ограничений. Пакет хранит `batch.project = P`; `GET /batches?project=P` — только пакеты проекта. `POST /commands/{id}/retry` принимает необязательный `project`: передан и у исходного пакета другой проект (в том числе `NULL`) — 422 «команда #N не из проекта P»; передан и совпадает — новый пакет с P и проверками; не передан — новый пакет получает проект исходного. MCP в привязанной сессии всегда передаёт свой P, поэтому сессия не может повторить чужую команду или команду пакета без проекта. Без `project` — прежнее поведение, проверок нет, `batch.project = NULL` (автообновление, веб-приложение, старые клиенты). Проверки в хабе, а не в MCP: MCP по границам ходит только через `client`, а правило о метке проекта должно действовать для любого клиента, который назвал проект. 7. **Доски — в параметрах команд, настройки `board_id` нет.** Агенту не нужен список проектов, ему нужны доски: - `ensure_sprint` — при постановке хаб определяет проект задачи (метки задачи в зеркале ∩ проекты; для временного ключа — метки её `ensure_issue` в пакете) и дописывает в параметры `board_id` его доски. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта нет доски; доска `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («дождитесь обновления из Jira»). `sprint: "backlog"` на scrum-доске — как раньше. `board_id` от клиента в `ensure_sprint` — 422 (его выставляет хаб). `board_id` хранится в `params` (видно в истории команд, переживает довыдачу); повтор команды (`retry`) снимает `board_id` с параметров до проверки и вычисляет доску заново. - `refresh` — при выдаче (`deliver_params`, в базе не хранится) хаб дописывает `boards: [{id, type}]` — все доски проектов без повторов, `type` — известный тип или `null`. Агент для досок с `type = null` запрашивает `GET /rest/agile/1.0/board/{id}` и возвращает в результате команды `boards: {"": "scrum" | "kanban" | null}` (`null` — доска не найдена или нет доступа; причина — в лог агента); хаб записывает известные типы в `project`. Имена спринтов агент собирает со всех scrum-досок (известных и только что определённых); канбан-доски пропускает. Одна задача в спринтах двух досок (или двух спринтов одной) — побеждает последнее найденное имя, как в v11 (`sprint_names` перезаписывает). Агент v12 без `boards`/`board_id` в параметрах (старый хаб или команда, поставленная до миграции) берёт `settings.board_id`, как v11 (ключ остаётся до follow-up, решение 4). 8. **MCP: привязка к проекту флагом запуска.** `jira-hub mcp --project <метка>`. Привязка проверяется при каждом вызове (хаб может быть не запущен при старте MCP): метки нет в `GET /projects` — инструмент отвечает «проект <метка> не заведён в хабе: project_create или исправьте --project в .mcp.json». В привязанной сессии: - `issues` — `GET /issues?project=P`; `issue` — любая задача по ключу (явный ключ — осознанный запрос, а карточка соседа нужна при работе со связями); - `notes_set` — только своя задача проекта (метка P; соседи — нет), иначе отказ; - `batch_create`, `command_retry` — с `project = P` (проверки решения 6); - `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения; - `refresh` — `full` — общий полный прогон, `keys` — как раньше. Без привязки инструменты `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh` отвечают «сессия не привязана к проекту: …» с подсказкой про `--project` и `projects`; остальные (`projects`, `project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`) работают. Решение владельца: «только свои»; безопасное поведение без привязки — моё (см. ниже). 9. **`project_create` в MCP** — `POST /projects {label, name, board_id?, author: "claude"}`; ответ — проект, id пакета обновления и готовый фрагмент `.mcp.json`: `{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "jira-hub", "mcp", "--project", "<метка>"]}}}` с инструкцией: положить в корень репозитория проекта (слить с имеющимся `.mcp.json`) и сделать `/mcp` → reconnect (одобрить project-scope сервер). Запись `jira-hub` в project scope перекрывает одноимённую из user scope. Корень хаба MCP вычисляет от расположения своего пакета (`src/jira_hub/` → корень репозитория), не зашивая путь; путь к `.venv` в подсказку не попадает (`uv run` восстановить из `sys.argv` нельзя). Если MCP запущен из worktree, в подсказке окажется путь worktree — это видно в ответе, рабочий MCP запускается из основного checkout. `projects` — таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии отмечает текущий проект. 10. **`POST /projects`** — `{label, name, board_id?, author?}` → 201 `{project, batch_id}`; проект и пакет «Новый проект <метка>» из одного полного `refresh` ставятся в одной транзакции; автор пакета — `author` (по умолчанию `api`). Ошибки: 422 — неверные поля (решение 2), 409 — метка уже есть. `GET /projects` → список `{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; `issues_count` — задачи зеркала с меткой проекта. 11. **Веб-приложение — проект в адресе и в памяти браузера.** Выбор проекта — выпадающий список рядом с «Хаб Jira» (вариант A): «<метка> — <название>», текущий отмечен; в конце — «+ Новый проект…». Текущий проект — параметр `project` в адресе `/app/board` и `/app/issues`; без него — последний выбранный (`localStorage`), иначе первый из `GET /projects`; выбранный записывается в адрес (`replace`). Неизвестный проект в адресе — сообщение «проект <метка> не найден» со списком проектов. Нет ни одного проекта — доска и список показывают приглашение создать проект. Переходы «Доска» / «Задачи» сохраняют проект. Карточка задачи (`/app/issues/{key}`) от проекта не зависит. Форма «Новый проект» — окно поверх страницы: метка, название, номер доски (необязательно); ошибка хаба (`detail`) — рядом с кнопкой «Создать»; после создания — переключение на новый проект. Пакет создания ставится с автором `viewer`, поэтому доска подхватывает его как свой `refresh` (правило «Источник данных доски») и показывает ход. 12. **Доска в разрезе проекта.** `GET /board?project=P`; «Обновить из Jira» — как раньше `POST /refresh {full: true}` — обновляет все проекты (решение владельца). Состояние доски в `localStorage` — отдельно на проект. Сейчас в `web/src/board/storage.ts` ключи от имени источника не зависят: `fileKey(n)` = `graph-:board` (pos, posdate, lanes, fold, sprint, who, colw) и `pref(n)` = `graph-` (флаги neighbors, onlylinked, crit, bydate, hidekind, hideedge, snap, tbl, prob). На проект делятся **оба набора**: у проекта `AWPCA` — ровно нынешние ключи, у остальных — те же ключи с суффиксом `@<метка>` (`graph-pos:board@STOCKS_DRRK`, `graph-crit@STOCKS_DRRK`); общие для всего приложения настройки вне доски (если есть) не делятся. Загрузка CSV/JSON и `applyJsonSettings` пишут в ключи текущего проекта. Так раскладка AWPCA переживает выкатку, а проекты не смешиваются. Фильтр спринтов: `hasSprint` в модели из хаба — есть ли хотя бы одна **своя** задача (`neighbor = false`) со спринтом (даже если канбан-проект связан с задачами scrum-проекта); у канбан-проекта фильтра спринтов нет («колонки «Спринт» нет — показаны все задачи», как у CSV без спринтов). 13. **Список задач в разрезе проекта.** `GET /issues?project=P&…`; варианты фильтров — из снимка `GET /board?project=P`; параметр `project` в адресе — рядом с фильтрами; фильтр «Метка» остаётся. 14. **Порядок выкатки.** (1) Агент v12 опубликован и `agent_status` показывает v12 — он понимает и `boards`/`board_id` в параметрах, и прежний `settings.board_id`. (2) Слияние хаба, `make web`, перезапуск хаба (владелец) — миграция v4. (3) `min_agent_version = 12`. (4) В `awp-ca` положить `.mcp.json` с `--project AWPCA` и переподключить MCP — до этого сессии `awp-ca` с задачами работать не могут (решение 8). Хаб новее агента v11 до шага (1) не выкатывать: v11 не знает `boards` и `board_id` в параметрах и читает спринты только с доски из настроек — спринты задач других проектов пропадут из зеркала, а `ensure_sprint` для них уйдёт не на ту доску. Миграция v4 нестрашна для старого хаба, если базу откроет новый MCP/CLI раньше перезапуска (решение 4), но между `git pull` и перезапуском хаба лучше не открывать новые сессии MCP. 15. **Агент v12 — отдельные issue, ветка, design-only change и PR в `jira-agent`.** Контракт (параметры `boards`, `board_id`, результат `boards`) — в этом change (спека `agent` живёт в хабе); у агента — решения реализации. Агент сливается и публикуется первым (решение 14). ## Risks / Trade-offs - Задача с метками двух проектов видна в обоих; `ensure_sprint` для неё при разных досках — 422. Осознанно: владелец связей между проектами не ожидает. - Задача проекта без метки проекта (метку сняли в Jira руками) выпадает из полной JQL и остаётся в зеркале со старыми данными, как сейчас. - Без `.mcp.json` в `awp-ca` сессии там после выкатки не видят задач — это намеренно (анти-цель «не та метка»), но шаг выкатки легко забыть; он в чеклисте выкатки PR. - `refresh {keys}` хвостом пакета тоже получает `boards` и тратит по одному вызову `GET /board/{id}` на доску с неизвестным типом — только до первого определения. - Ошибка в номере доски при создании или доска, понадобившаяся канбан-проекту позже, интерфейсом не исправляются (правки проекта нет). Решение владельца 19.10.2026: на первое время он правит базу сам — `UPDATE project SET board_id = <номер>, board_type = NULL WHERE label = '<метка>'`; тип доски определит следующий `refresh`. Агенты (Claude) базу хаба напрямую не правят. Доска, на которую агент ответил «не найдена» (`null`), остаётся с `board_type = NULL`: каждый `refresh` снова запрашивает её тип, `ensure_sprint` отвечает «тип доски ещё не известен». - Создание проекта меняет базовую JQL, поэтому `last_sync` доски становится `null` («полного прогона не было»), пока не завершится пакет «Новый проект» — он ставится сразу, доска показывает его ход. Переписывать `sync_run.query` здесь было бы неправдой: прежний прогон новую метку не покрывал. - Сравнение меток без учёта регистра (решение 2) при создании; в разрезе проекта (решение 5) метка сравнивается точно, как в зеркале (`json_each`). Jira хранит метку так, как её записали, — расхождение возможно только при ручной правке метки в Jira другим регистром. ## Decisions made without discussion 1. 2026-10-19 — **проект — таблица `project`, а не ключ настроек.** Обоснование: сущность со своим циклом; правка не должна поднимать `settings_version` и перечитывание настроек агентом. Цена ошибки: таблица и миграция; перенос в настройки — одна миграция назад. 2. 2026-10-19 — **сессия MCP без `--project` не читает и не пишет задачи.** Обоснование: анти-цель — задача с меткой не того проекта; владелец согласился с этим в обсуждении («все ок»), сам режим «только служебные инструменты» — моё уточнение. Цена ошибки: `awp-ca` до своего `.mcp.json` не работает с задачами; смягчить — умолчание по проекту. 3. 2026-10-19 — **проверки метки и принадлежности задачи проекту — в хабе (`POST /batches {project}`), а не в MCP.** Обоснование: граница «MCP только через client», правило нужно и будущим клиентам. Цена ошибки: поле пакета и проверки в `queue`; перенос в MCP — без изменения базы. 4. 2026-10-19 — **хаб дописывает метку проекта в `ensure_issue` сам (в пакете с `project`).** Прежнее правило «хаб метки сам не добавляет» остаётся для пакетов без проекта. Обоснование: Claude не должен помнить метку — это и есть анти-цель. Цена ошибки: одна ветка в проверках постановки. 5. 2026-10-19 — **`issue` в MCP открывает любую задачу по ключу, `issues` — только проект.** Обоснование: явный ключ — осознанный запрос, карточка соседа нужна при работе со связями. Цена ошибки: одна проверка в MCP. 6. 2026-10-19 — **доски передаются агенту в параметрах команд (`ensure_sprint.board_id` при постановке, `refresh.boards` при выдаче), тип доски определяет агент в `refresh`.** Обоснование: хаб не ходит в Jira; отдельная команда «узнать доску» потребовала бы нового типа команды. Цена ошибки: два параметра контракта. 7. 2026-10-19 — **тип доски неизвестен → `ensure_sprint` 422, а не попытка на удачу.** Обоснование: создание проекта само ставит `refresh`, окно короткое. Цена ошибки: одна ошибка постановки сразу после создания проекта. 8. 2026-10-19 — **разрез по проекту: свои + соседи по связям + эпики своих; `neighbor` относительно проекта.** Обоснование: логика доски и «+ соседи» уже построена на `neighbor`; эпики нужны доске для имён. Цена ошибки: функция выборки и тесты к ней. 9. 2026-10-19 — **миграция называет проект AWPCA «АРМ КА», а `board_id` из настроек отдаёт ему.** Обоснование: так называется проект в workspace; доска 4216 — его. Цена ошибки: название правится будущим «переименованием» или вручную. 10. 2026-10-19 — **раскладка доски AWPCA остаётся на прежних ключах `localStorage`, остальные проекты — на ключах с меткой.** Обоснование: анти-цель «AWPCA потерял раскладку». Цена ошибки: особый случай в одном месте `storage.ts`. 11. 2026-10-19 — **`project_create` отвечает готовым `.mcp.json` с командой запуска текущего процесса MCP.** Обоснование: владелец хочет, чтобы Claude сам прописал проект в репозитории; путь хаба не зашивается. Цена ошибки: формат подсказки. 12. 2026-10-19 — **метка проекта без пробелов, запятых, кавычек, скобок; уникальность без учёта регистра.** Обоснование: безопасная подстановка в JQL. Цена ошибки: отказ на экзотической метке. 13. 2026-10-19 — **`GET /batches?project=` и `batches` в привязанной сессии — только пакеты проекта; `batch_status/wait/cancel` по id — без ограничения.** Обоснование: список не засоряется чужими пакетами, а явный id — осознанный запрос. Цена ошибки: фильтр списка. 14. 2026-10-19 — **миграция v4 не удаляет `base_jql` и `board_id` из `setting`; `board_id` остаётся запасной доской агента; удаление — follow-up.** Обоснование: базу мигрирует любой процесс новой версии (MCP, CLI), возможно — под работающим старым хабом (находка builder check). Цена ошибки: два мёртвых ключа до follow-up. 15. 2026-10-19 — **миграция переписывает `params.jql` у `refresh` в `queued`/`claimed` со старой базовой JQL.** Обоснование: иначе прогон из очереди не признается полным. Цена ошибки: одна строка миграции. 16. 2026-10-19 — **`retry` с `project` отказывает, если проект исходного пакета другой; хаб снимает свой `board_id` при повторе.** Обоснование: сессия проекта не должна повторять чужие команды; иначе повтор `ensure_sprint` падал бы на своей же проверке «board_id от клиента». Цена ошибки: поле тела `retry` и одна ветка. 17. 2026-10-19 — **чужая метка проекта и `labels_remove` P — без учёта регистра; принадлежность задачи — точно.** Обоснование: Jira в JQL сравнивает метки без учёта регистра — `awpca` попала бы в общую JQL, но ни в чей разрез. Цена ошибки: сравнение в проверках. 18. 2026-10-19 — **`notes_set` в привязанной сессии — только свои задачи, без соседей.** Обоснование: «только свои» владельца. Цена ошибки: одна проверка. 19. 2026-10-19 — **фрагмент `.mcp.json` — `uv run --project <корень хаба от расположения пакета>`.** Обоснование: вызов `uv run` из `sys.argv` не восстановить, путь к `.venv` в чужом репозитории хрупок. Цена ошибки: формат подсказки. 20. 2026-10-19 — **на проект делятся и раскладка (`fileKey`), и флаги доски (`pref`); AWPCA — на нынешних ключах, остальные — с суффиксом `@<метка>`.** Обоснование: спека требует хранить фильтры отдельно; анти-цель — потеря раскладки AWPCA. Цена ошибки: схема ключей в `storage.ts`. 21. 2026-10-19 — **`last_sync` после создания проекта — `null` до завершения его обновления, `sync_run.query` не переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске. 22. 2026-10-19 — **пустой `author` в `POST /projects` — как `api`, нестрока — 422 `bad_project`.** Обоснование: как у остальных маршрутов (`body.get("author") or "api"`); спека требует 422 для не-строки. Цена ошибки: пустая строка пишется автором `api`, а не отвергается. 23. 2026-10-19 — **`CommandError` из постановки refresh в `POST /projects` — 422 `bad_batch`, а не `bad_project`.** Обоснование: ошибка относится к пакету, а не к полям проекта; спека называет только `project_exists` и `bad_project`. Цена ошибки: код ошибки в ответе.