# Документация AlgoBoard

| Параметр | Значение |
| --- | --- |
| Контракт | `algoboard.v1` |
| Обновлено | 9 августа 2026 года |
| Транспорт | ConnectRPC unary, protobuf JSON |
| Локальный Base URL | `http://localhost:8080` |
| Источник | `backend/proto/algoboard/v1/*.proto` |

## Руководства по продукту

Практические руководства по объектам, workflow, полям, расчётам, доступу, автоматизациям и остальным разделам настроек опубликованы отдельными страницами в [документации AlgoBoard](/docs). Этот файл содержит только API и материалы для программного подключения.

## Быстрый запрос

1. Откройте «Настройки → API и агенты».
2. Создайте Agent, назначьте роли и capability policy.
3. Скопируйте секрет первого токена: повторно получить его нельзя.
4. Передавайте токен в заголовке `Authorization`.

```bash
export ALGOBOARD_TOKEN='ab_agent_...'

curl --fail-with-body \
  -X POST http://localhost:8080/algoboard.v1.BoardService/GetAppState \
  -H "Authorization: Bearer ${ALGOBOARD_TOKEN}" \
  -H "Content-Type: application/json" \
  -H "Connect-Protocol-Version: 1" \
  -d '{}'
```

Поля JSON записываются в `lowerCamelCase`: `projectId`, `storyPoints`, `customFieldValues`. Тело пустого unary-запроса — `{}`.

## Аутентификация и права

Браузерная сессия использует `HttpOnly` cookie `algoboard_session`. CLI и агенты передают `Authorization: Bearer <token>`.

Суперадминистратор — отдельная учётная запись платформы: у неё
`AuthSession.isSuperAdmin = true`, заполнены `accountEmail` и `accountLogin`, но отсутствуют
`member` и `workspace`. Она может вызывать только `GetSession`, `Logout`, `ChangePassword`
и методы с префиксом `Admin`. Обычные участники и agent tokens не имеют доступа к
superadmin RPC, а суперадминистратор не может вызывать tenant RPC.

Scopes агентского токена:

| Scope | Что разрешает |
| --- | --- |
| `read` | Состояние проекта, карточки, отчёты и другие read-only вызовы |
| `write` | Создание и изменение карточек, комментарии, спринты и доски |
| `admin` | Схема процесса, автоматизации, интеграции, импорт и токены; автоматически включает `read` и `write` |

Права агента проверяются в пять слоёв:

1. scope агентского токена ограничивает технический класс вызовов;
2. capability policy agent principal задаёт для `read`, `write`, `admin` решение `allow`, `ask` или `deny`;
3. настраиваемые роли проекта дают атомарные разрешения вроде `issue.view`, `issue.move`, `templates.manage` или `integrations.manage`;
4. разрешения полей ограничивают просмотр и изменение системных и созданных данных карточки;
5. ACL конкретного представления дополнительно ограничивает операции с этой доской.

Роли назначаются группам, участник получает объединение разрешений всех своих групп. Каталог доступных разрешений приходит в `projectState.permissionCatalog`, итог текущего участника — в `projectState.currentPermissions`. Пока в проекте нет ни одной настраиваемой роли, действует совместимый режим Owner/Editor/Viewer. После создания первой роли права не подставляются автоматически: их нужно явно назначить группам. Владелец рабочего пространства получает полный стартовый доступ, только пока ему не назначена ни одна настраиваемая роль; после первого назначения действует обычная редактируемая сумма ролей. Право назначать и изменять владельцев вынесено отдельно в `workspace.owners.manage`.

Агент использует отдельного служебного участника с `principalType=agent`; его действия не приписываются владельцу. Решение `ask` возвращает `failed_precondition` с одноразовым `approval_id` до вызова доменного handler, а `deny` — `permission_denied`. После человеческого approve агент обязан повторить точный запрос с `X-Algoboard-Approval`; сервер заново проверит RBAC и текущее состояние перед исполнением. `admin`-токен сам по себе не расширяет agent policy или роль проекта. И наоборот, проектное разрешение не обходит недостающий scope токена.

Исключение — board-вызовы внутри управляемого `AgentSkillInvocation`. После `ClaimAgentSkillInvocation` runner получает `delegationToken`, привязанный к invocation, runner и номеру попытки. Сервер сохраняет Agent principal для авторства и аудита, но вычисляет разрешения по активному `requestedByMemberId`: используются его текущие проектные роли, field permissions, view ACL и workspace-роль. Это не объединение прав Agent и человека и не snapshot: отзыв роли действует на следующий RPC. Токен перестаёт проходить аутентификацию после `FinishAgentSkillInvocation`, истечения lease, смены попытки, отзыва постоянного Agent token или деактивации участника. Account/security, superadmin и control-plane управления агентами через эту делегацию недоступны.

## Формат ответа и ошибок

Успешный вызов возвращает protobuf JSON без дополнительной оболочки.

```json
{
  "issue": {
    "id": "6e1d...",
    "key": "TEAM-42",
    "title": "Проверить импорт"
  }
}
```

Ошибка Connect содержит `code` и `message`:

```json
{
  "code": "failed_precondition",
  "message": "Перед переходом назначьте исполнителя"
}
```

Основные коды:

| Код | Причина | Что делать клиенту |
| --- | --- | --- |
| `unauthenticated` | Нет токена, токен истёк или отозван | Получить новый токен |
| `permission_denied` | Не хватает scope или роли | Не повторять запрос без изменения прав |
| `invalid_argument` | Некорректные поля или ID | Исправить тело запроса |
| `not_found` | Объект удалён или недоступен | Обновить состояние проекта |
| `failed_precondition` | Workflow запрещает действие либо Agent ждёт `approval_id` | Устранить условие или подтвердить receipt и повторить точный запрос |
| `internal` | Непредвиденная ошибка сервера | Повторить безопасный read; write сначала сверить |

Методы `Create*` не принимают idempotency key. После сетевой ошибки сначала прочитайте состояние и только затем решайте, повторять ли запрос.

## Основные вызовы

Все пути начинаются с:

```text
POST /algoboard.v1.BoardService/{Method}
```

### Сессия и состояние

| Метод | Scope | Результат |
| --- | --- | --- |
| `Register` | публичный | Создаёт workspace, владельца и cookie-сессию |
| `Login` | публичный | Вход по email или уникальному логину, создаёт cookie-сессию |
| `GetSession` | `read` | Текущий участник и workspace |
| `Logout` | `read` | Удаляет браузерную сессию |
| `ChangePassword` | self | Проверяет текущий пароль и устанавливает новый |
| `GetAppState` | `read` | Снимок выбранного проекта, отфильтрованный по `currentPermissions` |
| `RunQuery` | `read` | Безопасный серверный `select` из карточек или сотрудников |
| `GetReports` | `read` | Каталог встроенных и пользовательских DSL-отчётов |
| `CreateReport` | `reports.manage` | Создаёт пользовательский DSL-отчёт |
| `UpdateReport` | `reports.manage` | Редактирует пользовательский или встроенный отчёт |
| `DeleteReport` | `reports.manage` | Удаляет пользовательский отчёт |
| `PreviewReport` | `reports.view` | Выполняет сохранённый DSL и возвращает предпросмотр |
| `GetAgentManifest` | публичный | Машиночитаемый список agent tools и scopes |
| `AdminListTenants` | superadmin | Тенанты и агрегаты людей, аккаунтов и проектов |
| `AdminListTenantPeople` | superadmin | Люди и состояние их учётных записей |
| `AdminResetTenantMemberPassword` | superadmin | Создаёт доступ или принудительно меняет пароль |

Перед изменяющими запросами вызовите `GetAppState`. Ответ содержит актуальные ID проектов, цветов, колонок, досок, участников, полей, Wiki-статей, правил и интеграций.

```bash
curl --fail-with-body \
  -X POST http://localhost:8080/algoboard.v1.BoardService/GetAppState \
  -H "Authorization: Bearer ${ALGOBOARD_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"PROJECT_UUID"}'
```

### Отчёты по часам

`GetReports` возвращает в `reports` редактируемый встроенный отчёт
«Учёт времени · <название поля>» для каждого поля типа `HOURS`. Его DSL суммирует часы
по исполнителям и считает карточки с заполненным значением:

```sql
select
  [Исполнитель],
  sum([Часы разработки]) as [Часы],
  count([Часы разработки]) as [Карточки]
from Карточки
where [Часы разработки] is not empty
group by all
having [Часы] > 0
order by [Часы] desc
limit 100
```

Стабильный UUID поля хранится в `queryReferences`, поэтому переименование поля не ломает
сохранённый отчёт. Архивные карточки не учитываются.

Разрез, агрегаты и фильтры меняются непосредственно в `queryDsl`. Результат отчёта
возвращает `PreviewReport`; универсальные запросы без сохранения выполняет `RunQuery`.
Отдельного hardcoded API для часовых, потоковых или sprint-метрик нет.

```bash
curl --fail-with-body \
  -X POST http://localhost:8080/algoboard.v1.BoardService/GetReports \
  -H "Authorization: Bearer ${ALGOBOARD_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"projectId":"PROJECT_UUID"}'
```

Сокращённый ответ:

```json
{
  "reports": [
    {
      "id": "REPORT_UUID",
      "name": "Учёт времени · Часы разработки",
      "visualization": "bar",
      "queryDsl": "select …",
      "systemKey": "hours:FIELD_UUID"
    }
  ]
}
```

### Карточки

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateIssue` | `write` | Создать карточку с полями, метками и связями |
| `UpdateIssue` | `write` | Изменить значения карточки |
| `MoveIssue` | `write` | Переместить карточку и выполнить workflow/automation |
| `MoveTimelineIssue` | `write` | Изменить порядок карточки на временной шкале |
| `CaptureTimelineBaseline` | `write` | Зафиксировать текущие даты как базовый план |
| `ArchiveIssue` | `write` | Архивировать или восстановить |
| `AddComment` | `write` | Добавить комментарий от владельца токена |

Типы карточки: `1` — task, `2` — story, `3` — bug. Приоритеты: `1` — low, `2` — normal, `3` — high, `4` — critical. Допустимые story points: `0, 1, 2, 3, 5, 8, 13`.

```bash
curl --fail-with-body \
  -X POST http://localhost:8080/algoboard.v1.BoardService/CreateIssue \
  -H "Authorization: Bearer ${ALGOBOARD_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "projectId": "PROJECT_UUID",
    "columnId": "COLUMN_UUID",
    "title": "Проверить GitLab webhook",
    "description": "Создано агентом после аудита интеграции",
    "type": 1,
    "priority": 3,
    "storyPoints": 3,
    "dependencyIds": [],
    "customFieldValues": [
      {"fieldId": "FIELD_UUID", "value": "Высокая"}
    ]
  }'
```

Чтобы очистить optional-связь, передайте пустую строку:

```json
{"issueId":"ISSUE_UUID","assigneeId":"","sprintId":""}
```

`MoveIssue` проверяет workflow и выполняет automation rules в одной транзакции. Действие `MOVE_COLUMN` может изменить итоговую колонку.

`dependencyIds` содержит карточки, которых ждёт текущая карточка. Если `ALGO-10.dependencyIds = [ALGO-6]`, то `ALGO-6` блокирует `ALGO-10`. Для полной замены связей:

```json
{
  "issueId": "ISSUE_UUID",
  "dependencyIds": ["BLOCKING_ISSUE_UUID"],
  "replaceDependencies": true
}
```

Пустой список с `replaceDependencies: true` удаляет все зависимости текущей карточки. Сервер запрещает ссылку на саму карточку, связь с другим проектом и любой цикл.

Для календарного планирования используйте `timelineDependencies: TimelineDependencyInput[]` и
`replaceTimelineDependencies`. В каждой связи задаются `predecessorIssueId`, `type` и `lagDays`:
положительный лаг откладывает управляемую границу на рабочие дни, отрицательный задаёт
опережение. Старый `dependencyIds` остаётся совместимым сокращением для связи
«окончание → начало» без лага.

### Доски и под-доски

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateSavedBoard` | `write` | Создать фильтрованное представление |
| `UpdateSavedBoard` | `write` + ACL | Изменить содержимое или управление представлением |
| `DeleteSavedBoard` | `write` + ACL | Удалить несвязанное представление, сохранив карточки и остальные определения |
| `CreateAccessGroup` | `admin` | Создать пользовательскую группу участников |
| `UpdateAccessGroup` | `admin` | Изменить состав, оформление или порядок группы |
| `DeleteAccessGroup` | `admin` | Удалить группу, если она не используется |
| `CreateProjectRole` | `admin` | Создать пустую или явно настроенную роль проекта |
| `UpdateProjectRole` | `admin` | Изменить название, оформление, порядок или разрешения роли |
| `DeleteProjectRole` | `admin` | Удалить роль, которая не назначена участникам или группам |
| `SetMemberProjectRoles` | `admin` | Заменить прямые роли участника в текущем проекте |
| `UpsertBoardSubscription` | ACL `view` | Создать или сохранить личную подписку |
| `DeleteBoardSubscription` | ACL `view` | Удалить личную подписку |
| `MarkBoardSubscriptionRead` | ACL `view` | Отметить её события прочитанными |
| `MarkNotificationsRead` | `project.view` | Отметить персональные уведомления прочитанными |
| `CreateBoardFilterCard` | ACL `view` / `edit` | Добавить личный или общий быстрый фильтр на доску |
| `UpdateBoardFilterCard` | владелец / ACL `edit` | Изменить условия, название, цвет или позицию |
| `DeleteBoardFilterCard` | владелец / ACL `edit` | Удалить личный или общий фильтр |

Карточки принадлежат проекту, а не доске. Доски показывают один набор карточек через разные фильтры. `parentId` создаёт под-доску; максимальная глубина — восемь уровней.

Общие фильтры доски возвращаются в `projectState.boardFilterCards` и видны всем участникам с доступом к доске. При `personal = true` сервер связывает фильтр с текущим участником: такой фильтр выбирается из БД и возвращается только владельцу. Для личного фильтра достаточно ACL `view`, для общего нужен ACL `edit`. `position` в `UpdateBoardFilterCard` — индекс с нуля внутри своей области: личной конкретного владельца либо общей.

У каждого представления есть обязательный fallback `defaultAccess` и упорядоченный массив `accessRules`. Первое совпавшее правило для участника или пользовательской группы определяет `none`, `view`, `edit` либо `manage`; если совпадений нет, действует fallback. Владелец workspace получает стартовый `manage` только при отсутствии совпавшего правила: явное правило участника или группы может назначить ему любой уровень, включая `none`. `currentAccess` вычисляется сервером, а представления с уровнем `none` отсутствуют в снимке проекта. Пока возможность `access` выключена, сохранённые правила не применяются и не удаляются: уровень определяется разрешениями проекта (`views.manage`, `issue.edit`, `issue.view`).

Подписки не получают готового набора событий. Пользователь выбирает фактические `ActivityEvent.kind` или вводит собственные валидные ключи. Событие попадает в подписку, только если карточка проходит полный фильтр сохранённой доски: быстрые условия и `queryDsl` применяются совместно по `AND`. Выключение `subscriptionsEnabled` приостанавливает доставку, но не удаляет `BoardSubscription`. Возможность проекта `subscriptions` управляется отдельно: в выключенном состоянии она сохраняет определения, подавляет уведомления и блокирует изменение или отметку прочтения, но разрешает явное удаление подписки.

```json
{
  "projectId": "PROJECT_UUID",
  "parentId": "BOARD_UUID",
  "name": "Backend · критичные",
  "description": "Критичные задачи backend-команды",
  "color": "violet",
  "defaultAccess": "view",
  "accessRules": [],
  "subscriptionsEnabled": false,
  "filter": {
    "rules": [
      {"field": "labels", "operator": 1, "values": ["BACKEND_LABEL_UUID"]},
      {"field": "priority", "operator": 1, "values": ["4"]},
      {"field": "completed", "operator": 1, "values": ["false"]}
    ],
    "visibleColumnIds": ["TODO_COLUMN_UUID", "DOING_COLUMN_UUID"],
    "cardFieldKeys": ["key", "priority", "due_date", "assignee", "custom:FIELD_UUID"],
    "cardFieldsConfigured": true,
    "includeCompleted": true
  }
}
```

```json
{
  "boardId": "BOARD_UUID",
  "name": "Мои просроченные",
  "color": "rose",
  "filter": {
    "rules": [
      {"field": "assignee", "operator": 1, "values": ["MEMBER_UUID"]},
      {"field": "overdue", "operator": 1, "values": ["true"]}
    ],
    "visibleColumnIds": ["TODO_COLUMN_UUID", "DOING_COLUMN_UUID", "REVIEW_COLUMN_UUID"],
    "cardFieldKeys": ["key", "priority", "due_date", "labels", "assignee"],
    "cardFieldsConfigured": true,
    "includeCompleted": true
  }
}
```

Все `filter.rules` соединяются по AND. Несколько `values` одного правила означают совпадение хотя бы с одним значением.

| `BoardFilterOperator` | Значение |
| --- | ---: |
| `EQUALS` | `1` |
| `NOT_EQUALS` | `2` |
| `CONTAINS` | `3` |
| `NOT_CONTAINS` | `4` |
| `GREATER_THAN` | `5` |
| `LESS_THAN` | `6` |
| `IS_EMPTY` | `7` |
| `IS_NOT_EMPTY` | `8` |

Системные ключи поля: `search`, `key`, `title`, `description`, `object_type`, `type`, `priority`, `assignee`, `reporter`, `labels`, `sprint`, `column`, `story_points`, `start_date`, `due_date`, `overdue`, `blocked`, `completed`, `parent`, `dependencies`, `created_at`, `updated_at`. Кастомное поле задаётся ключом `custom:FIELD_UUID`, а настраиваемый тип связи — `relation:RELATION_TYPE_UUID`.

`visibleColumnIds` задаёт колонки вида. `cardFieldKeys` задаёт поля карточки; название показывается всегда. Чтобы сохранить явный, в том числе пустой, список полей, передайте `cardFieldsConfigured: true`. Поля `query`, `assigneeIds`, `labelIds`, `priorities`, `sprintId`, `onlyUnassigned`, `onlyOverdue` и `includeCompleted` поддерживаются для ранее созданных досок.

### Спринты и администрирование

| Группа | Методы |
| --- | --- |
| Спринты (`write`) | `CreateSprint`, `UpdateSprint`, `StartSprint`, `CompleteSprint` |
| Проекты (`admin`) | `CreateProject`, `UpdateProject` |
| Команда (`admin`) | `CreateMember`, `UpdateMember`, `SetMemberProjectRoles`, `SetMemberTemporaryPassword`, `UpdateWorkspace` |
| Стили (`admin`) | `CreateColorStyle`, `UpdateColorStyle`, `DeleteColorStyle` |
| Процесс (`admin`) | `CreateColumn`, `UpdateColumn`, `DeleteColumn`, `CreateWorkflowRule`, `UpdateWorkflowRule`, `DeleteWorkflowRule` |
| Типы объектов (`admin`) | `CreateObjectType`, `UpdateObjectType`, `DeleteObjectType`, `UpsertObjectTypeField`, `DeleteObjectTypeField` |
| Типы связей (`admin`) | `CreateRelationType`, `UpdateRelationType`, `DeleteRelationType` |
| Связи объектов (`write`) | `CreateObjectRelation`, `DeleteObjectRelation` |
| Метки (`admin`) | `CreateLabel`, `UpdateLabel`, `DeleteLabel` |
| Кастомные поля (`admin`) | `CreateCustomField`, `UpdateCustomField`, `DeleteCustomField` |

`DeleteColumn` требует целевую колонку того же проекта в `moveIssuesToColumnId`. Метод:

- переносит активные и архивные карточки;
- синхронизирует `completedAt`;
- переносит ссылки во всех сохранённых фильтрах, политиках, сигналах, бизнес-действиях, расчётах, пакетах, маршрутизации и дашбордах;
- переносит статические действия по смене состояния и ссылки автоматизаций;
- блокируется, пока состояние участвует в workflow-переходе: переход не удаляется и не переписывается автоматически;
- нормализует порядок колонок;
- возвращает `movedIssueCount`.

Автоматизации с условием по удалённой колонке перенаправляются на целевую и отключаются. Последнюю колонку проекта удалить нельзя.

```json
{
  "columnId": "REVIEW_COLUMN_UUID",
  "moveIssuesToColumnId": "DOING_COLUMN_UUID"
}
```

`DeleteLabel` снимает метку только с карточек. Если метку использует фильтр, автоматизация, бизнес-действие, пакет, маршрутизация или дашборд, сервер блокирует удаление и сохраняет все определения без изменений. Ответ содержит `affectedIssueCount`; карточки и доски не удаляются.

```json
{"labelId":"OBSOLETE_LABEL_UUID"}
```

Через пользовательскую сессию участник может изменить свои `name`, `avatarData`, `avatarContentType` и `clearAvatar`. Для изменения чужого профиля, базовой роли или активности нужны контекст проекта и разрешение `team.manage`. Любая операция над владельцем либо назначение базовой роли Owner дополнительно требуют `workspace.owners.manage`. Для агентского токена также нужен scope `admin`.

`CreateMember` атомарно создаёт участника и учётную запись. Нужны `projectId` текущего проекта и разрешение `team.manage`; создание владельца дополнительно требует `workspace.owners.manage`.

`SetMemberTemporaryPassword` создаёт учётную запись для участника без доступа. Если email уже принадлежит существующей учётной записи, метод только связывает с ней участника и не меняет логин, пароль или активные сессии. Нужны `projectId` и `team.manage`; для владельца также требуется `workspace.owners.manage`. Пользователь с новой учётной записью входит по логину или email, затем меняет временный пароль через `ChangePassword`.

Настраиваемые роли относятся к конкретному проекту и не совпадают с базовым `MemberRole`. `SetMemberProjectRoles` заменяет прямые назначения участника. Итоговые разрешения — объединение прямых ролей и ролей всех групп доступа, в которые входит участник.

- `login`: 3–32 латинских символа, цифры, точки, дефисы или подчёркивания; регистр не учитывается;
- `password`: 8–72 символа; сервер сохраняет bcrypt-хеш.

```json
{
  "workspaceId": "WORKSPACE_UUID",
  "name": "Анна Волкова",
  "email": "anna@company.ru",
  "login": "anna.volkova",
  "password": "temporary-pass-42",
  "role": "MEMBER_ROLE_EDITOR",
  "avatarColor": "sky"
}
```

Дата передаётся как `YYYY-MM-DD`. Значение custom field всегда передаётся строкой:

| `CustomFieldType` | Значение | Формат `CustomFieldInput.value` |
| --- | ---: | --- |
| `TEXT` | `1` | произвольный текст |
| `NUMBER` | `2` | число в строковом виде |
| `DATE` | `3` | `YYYY-MM-DD` |
| `SELECT` | `4` | один из `options` |
| `CHECKBOX` | `5` | `"true"` или `"false"` |
| `MEMBER` | `6` | UUID участника того же рабочего пространства |
| `WIKI_ARTICLE` | `7` | UUID статьи из `ProjectState.wikiArticles` того же проекта |
| `HOURS` | `8` | число от 0 до 100000 в строковом виде, например `"2.5"` |

Для `MEMBER` используйте ID из `GetAppState.members`, для `WIKI_ARTICLE` — из `GetAppState.projectState.wikiArticles`. Имя, email и ID из другого пространства или проекта не принимаются.

### Wiki

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateWikiArticle` | `write` | Создать корневую статью или подраздел |
| `UpdateWikiArticle` | `write` | Изменить название, текст, родителя или позицию |
| `DeleteWikiArticle` | `write` | Удалить статью и всё её поддерево |

Статья может содержать текст и дочерние статьи. Глубина не ограничена; циклы и родители из другого проекта запрещены. Пустой `parentId` переносит статью в корень.

```json
{
  "projectId": "PROJECT_UUID",
  "parentId": "PARENT_ARTICLE_UUID",
  "title": "Откат релиза",
  "body": "# Когда откатывать\n- Ошибка миграции\n- Рост 5xx"
}
```

`DeleteWikiArticleResponse` возвращает `deletedArticleCount` и `clearedFieldValueCount`. При удалении ветки сервер очищает ссылки полей `WIKI_ARTICLE`.

#### Аватары участников

`CreateMember` и `UpdateMember` принимают изображение в `avatarData` и MIME-тип в `avatarContentType`. В protobuf JSON бинарные данные кодируются в Base64. `clearAvatar: true` удаляет изображение.

```json
{
  "memberId": "MEMBER_UUID",
  "avatarData": "BASE64_PNG",
  "avatarContentType": "image/png"
}
```

API принимает PNG или JPEG до 512 КБ и 128×128 пикселей. Интерфейс уменьшает изображение до 128×128 и кодирует его в PNG. Сервер повторно проверяет размер и формат.

### Автоматизации

| Метод | Scope |
| --- | --- |
| `CreateAutomationRule` | `admin` |
| `UpdateAutomationRule` | `admin` |
| `DeleteAutomationRule` | `admin` |
| `TestAutomationRule` | `admin` |

Триггеры:

- `1` — карточка перемещена;
- `2` — карточка создана;
- `3` — карточка обновлена; `eventFilter.changedFields` может ограничить запуск фактически изменившимися полями;
- `4` — GitLab merge request;
- `5` — pipeline;
- `6` — job;
- `7` — push;
- `8` — tag push;
- `9` — deployment;
- `10` — release;
- `11` — note/comment.

Действия:

| Значение | Действие | `targetId` | `value` |
| --- | --- | --- | --- |
| `1` | Переместить в колонку | column ID | — |
| `2` | Назначить исполнителя | member ID или пусто | — |
| `3` | Установить приоритет | — | `1`–`4` |
| `4` | Установить спринт | sprint ID или пусто | — |
| `5` | Добавить метку | label ID | — |
| `6` | Записать custom field | field ID | строковое значение по типу поля, для `MEMBER` — member ID |
| `7` | Применить фильтр доски | board ID | — |
| `8` | Задать причину блокировки | — | текст |
| `9` | Добавить комментарий | — | текст |
| `10` | Изменить поле карточки или сотрудника | — | см. `fieldMutation` |

Для `ISSUE_UPDATED` пустой `changedFields` означает любое изменение. Непустой список использует семантику OR: правило запускается, если изменилось хотя бы одно выбранное поле. Системные ключи: `title`, `description`, `object_type`, `type`, `priority`, `story_points`, `assignee`, `sprint`, `start_date`, `due_date`, `parent`, `blocked_reason`, `labels`, `dependencies`, `checklists`. Пользовательское поле записывается как `custom:<FIELD_UUID>`.

Действие `10` использует типизированный `fieldMutation`: `SET` записывает результат
формулы, а `INC` и `DEC` прибавляют или вычитают числовой результат. Отдельной операции
`SETINT` нет. Формула исполняется тем же серверным lexer, AST, type checker и evaluator,
который обслуживает вычисляемые поля, условия и запросы. Действия правила видят результаты
предыдущих действий и применяются в одной транзакции; ошибка откатывает изменения карточки
и полей персонала.

Записывать можно системные поля карточки `title`, `description`, `story_points`,
`start_date`, `due_date`, `priority`, `blocked_reason`, `assignee`, `sprint`; custom fields
типов `TEXT`, `NUMBER`, `HOURS`, `DATE`, `CHECKBOX`, `SELECT`, `MEMBER`, `WIKI_ARTICLE`;
а также настраиваемые поля персонала сотрудника из `assignee` или конкретного custom field
типа `MEMBER`. `INC` и `DEC` разрешены только для `NUMBER`, `HOURS`, числовых полей
персонала и `story_points`; пустое текущее числовое значение считается нулём. Вычисляемые
поля можно читать в правой части, но нельзя изменять. Колонка, тип объекта, метки, связи,
комментарии и чек-листы не являются целями `fieldMutation`, потому что сохраняют отдельную
семантику действий. Старые `SET_PRIORITY`, `SET_SPRINT`, `SET_CUSTOM_FIELD` и
`SET_BLOCKED_REASON` продолжают читаться для совместимости.

GitLab-действия `20`–`35` создают ветки и merge requests, меняют/связывают/одобряют/закрывают/мержат MR, добавляют notes, запускают/повторяют/отменяют pipelines и jobs. Их параметры хранятся в редактируемом `AutomationAction.config`; строки поддерживают переменные `${issue.key}`, `${issue.title}`, `${slug.issue_key}`, `${event.merge_request_iid}`, `${event.pipeline_id}`, `${event.job_id}` и `${event.ref}`.

Правило содержит до 20 действий. Для триггера перемещения `fromColumnId` и `toColumnId` опциональны: отсутствие значения означает любую колонку. GitLab-правило явно выбирает `integrationId`; входящее событие дополнительно проверяется через `eventFilter`.

### Шаблоны создания

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateIssueTemplate` | `admin` | Создать форму и отдельную отзывную ссылку |
| `UpdateIssueTemplate` | `admin` | Изменить все свойства формы и порядок полей |
| `DeleteIssueTemplate` | `admin` | Отозвать форму, сохранив журнал отправок |
| `RotateIssueTemplateLink` | `admin` | Немедленно заменить секрет ссылки |
| `ResolveIssueTemplate` | публичный или member | Получить безопасную схему формы по token |
| `SubmitIssueTemplate` | публичный или member | Идемпотентно создать карточку через форму |

Новый проект не получает форм автоматически. Пользователь с `templates.manage` явно задаёт `access`, начальный тип/состояние, срок, лимит отправок, текст успеха и для каждого поля режим hidden/editable/required/locked. Ссылка имеет вид `/new?template=<token>`; зашифрованный token не возвращается отдельно.

Режимы доступа:

| Режим | Проверка |
| --- | --- |
| `PUBLIC` | Форма открывается без сессии |
| `PASSWORD` | Сначала возвращается только challenge без полей; пароль проверяется bcrypt-хешем |
| `MEMBERS` | Требуется активная сессия участника workspace |
| `GROUPS` | Требуется сессия и членство хотя бы в одной группе из `accessGroupIds` |

### Рабочее время и сигналы

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateBusinessCalendar` | `admin` | Создать календарь с пользовательским расписанием |
| `UpdateBusinessCalendar` | `admin` | Изменить расписание, исключения, часовой пояс или порядок |
| `DeleteBusinessCalendar` | `admin` | Удалить календарь, если его не использует политика или сотрудник |
| `CreateTimePolicy` | `admin` | Создать расчёт рабочего времени по выбранному полю |
| `UpdateTimePolicy` | `admin` | Изменить условия, длительность или календарь |
| `DeleteTimePolicy` | `admin` | Удалить политику, если её не использует правило сигнала |
| `CreateSignalRule` | `admin` | Создать настраиваемое правило отклонения |
| `UpdateSignalRule` | `admin` | Изменить условия, цвет, политику или порядок |
| `DeleteSignalRule` | `admin` | Удалить правило без удаления карточек |
| `UpdateSignalState` | `write` | Подтвердить активный сигнал или вернуть его в работу |

Новый проект не получает календарей, политик или правил. Фильтры применения и остановки политики времени, а также фильтр сигнала поддерживают быстрые условия и `queryDsl`; если заполнены оба режима, сервер соединяет их по `AND`. Пустой фильтр применения охватывает все карточки, а пустой фильтр остановки не останавливает расчёт. Выключение возможностей `time_policies` и `signals` останавливает вычисление, но сохраняет конфигурацию. Деловая семантика сигнала задаётся пользователем через название, описание, цвет и фильтр; фиксированные статусы политики описывают только результат вычисления времени.

### Персонал

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreatePersonnelField` | `admin` | Создать настраиваемое поле сотрудника |
| `UpdatePersonnelField` | `admin` | Изменить имя, тип, варианты или порядок поля |
| `DeletePersonnelField` | `admin` | Удалить поле и значения, если оно не используется формулой |
| `UpdateMemberProjectProfile` | `admin` | Назначить рабочий календарь и изменить значения сотрудника в проекте |

Новый проект не получает полей персонала и календарных назначений автоматически. Поля относятся к проекту; один участник workspace может иметь разные значения и календарь в разных проектах. MEMBER-поле карточки раскрывает значения в формуле через стабильный источник `member_field:<CARD_FIELD_ID>:<PERSONNEL_FIELD_ID>` и читаемую подпись вроде `[Разработчик.Ставка]`.

### Бизнес-действия

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateBusinessAction` | `admin` | Создать контекстную команду с пользовательской формой |
| `UpdateBusinessAction` | `admin` | Изменить применимость, поля формы, операции или порядок |
| `DeleteBusinessAction` | `admin` | Удалить определение, сохранив журнал выполнений |
| `ExecuteBusinessAction` | `write` | Атомарно применить настроенную команду к карточке |

Новый проект не получает действий и полей формы. Применимость действия задаётся полным `BoardFilter`: быстрые условия и `queryDsl` объединяются по `AND`. Один контракт применимости используется при показе действия у карточки, подсчёте кандидатов во вкладке «Действия» и при окончательной серверной проверке перед атомарным выполнением. Возможность `actions` включается независимо; её выключение блокирует выполнение, не удаляя определения и журнал.

### Вычисляемые поля

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateCalculationField` | `admin` | Создать формулу или агрегат по настраиваемой связи |
| `UpdateCalculationField` | `admin` | Изменить источники, область, форматирование, условия или порядок |
| `DeleteCalculationField` | `admin` | Удалить определение, если на него ничего не ссылается |
| `CreateCalculationConstant` | `admin` | Создать числовую или строковую константу проекта |
| `UpdateCalculationConstant` | `admin` | Изменить значение, название, тип или порядок константы |
| `DeleteCalculationConstant` | `admin` | Удалить константу, если она не используется формулой |

Значения не материализуются в карточках: `GetAppState` вычисляет их из текущих данных. Определения констант возвращаются в `ProjectState.calculationConstants`. Формулы записываются читаемыми выражениями со ссылками `[Название поля]`; стабильные `CalculationFormulaReference` связывают видимую подпись с источником, поэтому переименование не ломает вычисление. Для поля карточки типа MEMBER доступны пути `[Поле сотрудника.Свойство персонала]`, например `[Разработчик.Ставка]`. Поддерживаются арифметика, скобки, сравнения, логические операции и функции `IF`, `COALESCE`, `ROUND`, `MIN`, `MAX`, `ABS`, `CONCAT`, `DAYS_BETWEEN`, `DATE_ADD`, `AND`, `OR`, `NOT`, `LOWER`, `UPPER`, `LEN`. Тип результата формулы выводится сервером. В rollup-поле `relatedFilter` поддерживает быстрые условия и `queryDsl`; сервер применяет их по `AND` до агрегации связанных карточек.

Новый проект не получает расчётов или констант. Возможность `calculations` можно выключить без удаления определений; в выключенном состоянии сервер возвращает пустой список `calculationValues`.

### Управляемые пакеты

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateWorkBatch` | `admin` | Создать пакет с явно выбранным составом, режимом и операциями |
| `UpdateWorkBatch` | `admin` | Изменить определение без запуска |
| `DeleteWorkBatch` | `admin` | Удалить определение, сохранив журнал |
| `PreviewWorkBatch` | `write` | Проверить текущий состав и изменения по каждой карточке |
| `ExecuteWorkBatch` | `write` | Выполнить только неизменившийся preview |

Новый проект не получает пакетов. `selectionMode` выбирается между динамическим фильтром и зафиксированным списком, `executionMode` — между атомарным запуском и независимой обработкой карточек. Динамический состав поддерживает `queryDsl` и быстрые правила `rules`: сервер применяет их совместно по `AND` и заново вычисляет состав при каждом preview и выполнении. Пустые DSL и правила выбирают все доступные карточки в пределах `maxItems`. Сервер не задаёт режимы, цвет, лимит, фильтр или операции. Выключение возможности `batches` блокирует preview и выполнение, но сохраняет определения и журнал.

### Маршрутизация

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateRoutingPolicy` | `admin` | Создать полностью заданное правило распределения |
| `UpdateRoutingPolicy` | `admin` | Изменить триггеры, условия, кандидатов, ёмкость и порядок |
| `DeleteRoutingPolicy` | `admin` | Удалить определение, сохранив журнал решений |
| `PreviewRoutingPolicy` | `write` | Рассчитать решение и нагрузку кандидатов без изменений |
| `ExecuteRoutingPolicy` | `write` | Применить неизменившийся ручной preview |

Новый проект не получает правил. Команда явно выбирает ручной запуск и/или триггеры, отдельные фильтры применимости и нагрузки, стратегию, упорядоченных кандидатов, вес и ёмкость каждого участника, резервное решение, остановку контура и реакцию на отказ. Оба фильтра поддерживают быстрые условия и `queryDsl`, соединённые по `AND`: первый решает, применимо ли правило к карточке, второй определяет активную нагрузку каждого кандидата. Возможность `routing` включается независимо: её выключение подавляет автоматические запуски и блокирует ручные, но не меняет определения и журнал.

### Операционные дашборды

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateDashboard` | `admin` | Создать дашборд из полностью заданных виджетов |
| `UpdateDashboard` | `admin` | Изменить область, виджеты, обновление и порядок |
| `DeleteDashboard` | `admin` | Удалить только определение дашборда |
| `PreviewDashboard` | `read` | Вычислить текущие значения и строки без изменений данных |

Новый проект не получает дашбордов, KPI или виджетов. Команда явно задаёт общий фильтр, а для каждого виджета — название, цвет, визуализацию, собственный фильтр, меру, агрегацию, группировку, период, поле даты, порядок, лимит, табличные поля и ширину. Общая выборка и фильтр виджета поддерживают одновременно визуальные условия и типизированный Query DSL; если заполнены оба режима, они объединяются через `AND`. Мерами, группировками, датами и колонками таблицы могут быть поля профиля исполнителя или сотрудника из любого карточного поля типа `MEMBER`: `member_field:assignee:<personnelFieldId>` и `member_field:<memberCustomFieldId>:<personnelFieldId>`. Возможность `dashboards` включается независимо: выключение блокирует вычисление и скрывает рабочий экран, но сохраняет определения.

### Интеграции

| Метод | Scope | Назначение |
| --- | --- | --- |
| `CreateIntegration` | `admin` | Сохранить зашифрованные реквизиты Jira, YouGile или GitLab |
| `UpdateIntegration` | `admin` | Изменить конфигурацию; пустой token сохраняет старый |
| `DeleteIntegration` | `admin` | Удалить подключение и внешние ссылки |
| `TestIntegration` | `admin` | Проверить токен и доступ к проекту |
| `RunImport` | `admin` | Импортировать Jira/YouGile или выполнить `dryRun` |
| `SyncIntegration` | `admin` | Связать GitLab merge requests с карточками |

Provider: `1` — Jira, `2` — YouGile, `3` — GitLab.

Jira использует email + API token и JQL. YouGile использует Bearer token и ID доски. GitLab использует personal/project access token и ID или `namespace/project`.

```json
{
  "integrationId": "INTEGRATION_UUID",
  "targetBoardId": "BOARD_UUID",
  "targetColumnId": "COLUMN_UUID",
  "dryRun": true,
  "limit": 200
}
```

GitLab sync ищет ключ карточки (`TEAM-42`) в названии, описании и source branch merge request. Webhook URL возвращается в `integration.webhookUrl`; secret передаётся в `X-Gitlab-Token`.

### Агенты и токены

| Метод | Scope |
| --- | --- |
| `ListAgents` | пользователь с `api.manage` |
| `CreateAgent` | пользователь с `api.manage` |
| `UpdateAgent` | пользователь с `api.manage` |
| `ListAgentTokens` | `admin` |
| `CreateAgentToken` | `admin` |
| `RevokeAgentToken` | `admin` |
| `ListAgentApprovalReceipts` | пользователь с `api.manage` |
| `ReviewAgentApprovalReceipt` | пользователь с `api.manage` |
| `ListAgentRuns` | пользователь с `api.manage` |
| `ListManagedSkills` / `ListManagedSkillVersions` / `CreateManagedSkill` / `UploadManagedSkillVersion` | пользователь с `api.manage` |
| `UpsertAgentSkillAssignment` / `DeleteAgentSkillAssignment` | пользователь с `api.manage` |
| `InvokeAgentSkill` | пользователь с `chat.send` |
| `ListRunnableAgentSkillInvocations` / `DownloadManagedSkillVersion` | назначенный Agent, `read` |
| `ClaimAgentSkillInvocation` / `HeartbeatAgentSkillInvocation` / `FinishAgentSkillInvocation` | назначенный Agent, `write` |
| `ListAgentConnectors` / `CreateAgentConnectorPairing` | пользователь с `api.manage` |
| `ExchangeAgentConnectorPairing` | публичный одноразовый pairing code |
| `GetAgentConnector` / `HeartbeatAgentConnector` / `DisconnectAgentConnector` | токен именно этого connector либо пользователь с `api.manage` |

```json
{
  "workspaceId": "WORKSPACE_UUID",
  "projectId": "PROJECT_UUID",
  "name": "release-agent",
  "projectRoleIds": ["ROLE_UUID"],
  "capabilities": [
    {"capability": "read", "decision": "allow"},
    {"capability": "write", "decision": "ask"},
    {"capability": "admin", "decision": "deny"}
  ],
  "tokenScopes": ["read", "write"],
  "tokenExpiresInDays": 30
}
```

`projectId` задаёт контекст проверки `api.manage` и назначения ролей. Агент получает отдельный `memberId`; токены ссылаются на один `agentId`, поэтому ротация секрета не меняет авторство. `CreateAgentResponse.secret` и `CreateAgentTokenResponse.secret` возвращаются один раз. Сервер хранит SHA-256 hash.

Policy `ask` создаёт receipt только после успешной проверки scope, роли и разрешения проекта. Pending receipt живёт 15 минут. Approve сам не запускает команду: Agent повторяет точный protobuf-запрос с заголовком `X-Algoboard-Approval`. Receipt связан с procedure, Agent, token, `configVersion`, полным request hash и snapshot состояния; он атомарно используется не более одного раза. Request preview хранится с маскированием секретов.

Каждый аутентифицированный Agent RPC дополнительно получает `X-Algoboard-Agent-Run-Id`. Запись execution ledger создаётся до проверки capability/RBAC и поэтому сохраняет как разрешённые действия, так и `denied`, `failed` и `approval_required`. Клиент может передать диагностическую метку в `X-Algoboard-Agent-Client`; security identity всё равно определяется только Agent token.

## Машиночитаемая точка входа

```text
GET /.well-known/algoboard-agent.json
```

Ответ содержит Base URL, scopes, команду MCP и список инструментов. Руководство агента доступно по `GET /docs/agent-guide.md`.

## Полный RPC-справочник

Путь unary RPC:

```text
POST /algoboard.v1.BoardService/{Method}
```

В сигнатурах ниже `?` означает optional-поле: если его не передать, сервер сохраняет текущее значение. `[]` означает массив. Названия приведены в protobuf JSON — `lowerCamelCase`.

Обозначения доступа:

- **public** — токен не нужен;
- **optional** — без токена возвращается гостевое состояние, с токеном — текущая сессия;
- **read** — браузерная сессия или agent token scope `read`; для проектных RPC дополнительно проверяется указанное у метода разрешение;
- **write** — agent token scope `write` и соответствующее разрешение проекта; для board-scoped методов дополнительно и приоритетно действует ACL самого представления;
- **admin / owner** — историческая метка раздела: agent token требует scope `admin`, а пользователь — соответствующее атомарное разрешение проекта; базовая роль Owner сама по себе не обходит явно назначенные проектные роли;
- **self / admin** — участник может менять ограниченный набор собственных полей, для административного изменения нужны `team.manage`, а для владельцев ещё и `workspace.owners.manage`.

### Сессия, состояние и отчёты

### `Register`

- **Доступ:** public
- **Контракт:** `RegisterRequest → RegisterResponse`
- **Request:** `{name:string, email:string, password:string, workspaceName:string, projectName:string, projectKey:string}`
- **Response:** `{session:AuthSession}`

Создаёт workspace, пустой проект с указанными именем и ключом, учётную запись владельца, участника с ролью owner и браузерную cookie-сессию. Типы объектов, колонки и остальные элементы процесса пользователь настраивает самостоятельно.

### `Login`

- **Доступ:** public
- **Контракт:** `LoginRequest → LoginResponse`
- **Request:** `{email:string, password:string}`
- **Response:** `{session:AuthSession}`

Поле `email` принимает email или уникальный login без учёта регистра. Успешный ответ устанавливает cookie `algoboard_session`.

### `GetSession`

- **Доступ:** optional
- **Контракт:** `GetSessionRequest → GetSessionResponse`
- **Request:** `{}`
- **Response:** `{session:AuthSession}`

Без корректной cookie или Bearer-токена возвращает `session.authenticated = false`, а не ошибку `unauthenticated`.

### `Logout`

- **Доступ:** read
- **Контракт:** `LogoutRequest → LogoutResponse`
- **Request:** `{}`
- **Response:** `{}`

Удаляет текущую браузерную сессию. Для агентского токена операция не отзывает token; используйте `RevokeAgentToken`.

### `ChangePassword`

- **Доступ:** self
- **Контракт:** `ChangePasswordRequest → ChangePasswordResponse`
- **Request:** `{currentPassword:string, newPassword:string}`
- **Response:** `{}`

Проверяет текущий пароль вошедшего пользователя и заменяет его bcrypt-хеш. `newPassword` должен содержать 8–72 символа и отличаться от `currentPassword`. Текущая сессия сохраняется.

### `AdminListTenants`

- **Доступ:** superadmin
- **Контракт:** `AdminListTenantsRequest → AdminListTenantsResponse`
- **Request:** `{}`
- **Response:** `{tenants:AdminTenantSummary[]}`

Возвращает все рабочие пространства. `AdminTenantSummary` содержит `workspace`,
`peopleCount`, `activePeopleCount`, `accountCount` и `projectCount`.

### `AdminListTenantPeople`

- **Доступ:** superadmin
- **Контракт:** `AdminListTenantPeopleRequest → AdminListTenantPeopleResponse`
- **Request:** `{workspaceId:string}`
- **Response:** `{people:AdminTenantPerson[]}`

Для каждого человека `AdminTenantPerson` возвращает `member`, `hasAccount`, `accountId`,
`accountLogin`, `lastLoginAt` и `tenantCount`. Последнее поле показывает, в скольких
тенантах используется единая учётная запись.

### `AdminResetTenantMemberPassword`

- **Доступ:** superadmin
- **Контракт:** `AdminResetTenantMemberPasswordRequest → AdminResetTenantMemberPasswordResponse`
- **Request:** `{workspaceId:string, memberId:string, newPassword:string}`
- **Response:** `{person:AdminTenantPerson}`

Устанавливает новый bcrypt-пароль и отзывает все активные сессии целевой учётной записи.
Если у человека ещё нет аккаунта, создаёт его и связывает со всеми участниками с тем же
email. Сам пароль не попадает в ответ или audit log; журнал сохраняет автора, тенант,
участника, аккаунт и время операции. Для аккаунта, связанного с несколькими тенантами,
пароль меняется глобально.

### `GetAppState`

- **Доступ:** read
- **Контракт:** `GetAppStateRequest → GetAppStateResponse`
- **Request:** `{projectId?:string, view:string}`
- **Response:** `{workspace:Workspace, members:Member[], projects:Project[], projectState:ProjectState, currentMemberId:string, colorStyles:ColorStyle[]}`

Если `projectId` не передан, сервер выбирает первый активный доступный проект. Это основной вызов для получения UUID перед изменяющими командами. Необязательный `view` принимает имя экрана (`overview`, `board`, `signals`, `dashboards`, `reports`, `team`, `wiki`, `settings` и другие значения интерфейса). Сервер не загружает журналы и конфигурацию возможностей, которые этому экрану не нужны. Пустой `view` сохраняет полный ответ для интеграций.

Список `issues` содержит облегчённые карточки: без текста комментариев и чек-листов, но с `commentCount`. Полная карточка запрашивается через `GetIssue`.

### `GetIssue`

- **Доступ:** `issue.view`
- **Контракт:** `GetIssueRequest → GetIssueResponse`
- **Request:** `{issueId:string}`
- **Response:** `{issue:Issue}`

Возвращает одну доступную карточку целиком: описание, комментарии с авторами, чек-листы, пользовательские значения, зависимости и внешние ссылки. Используйте этот вызов при открытии карточки вместо загрузки подробностей всех карточек через `GetAppState`.

### `RunQuery`

- **Доступ:** read; дополнительно `issue.view` для `from Карточки` и `from Изменения` или `team.view` для `from Сотрудники`
- **Контракт:** `RunQueryRequest → RunQueryResponse`
- **Request:** `{projectId:string, query:string, references:CalculationFormulaReference[], cardFilter:BoardFilter, contextCardId?:string, pageSize:int32, pageToken:string, parameterValues:QueryParameterValue[]}`
- **Response:** `{columns:QueryColumn[], rows:QueryRow[], totalCount:int32, nextPageToken:string, references:CalculationFormulaReference[], normalizedQuery:string, parameters:QueryParameterDefinition[], resolvedContexts:QueryResolvedContext[], explain:QueryExplainStep[]}`

Выполняет ограниченный типизированный DSL только на сервере. Каноническая запись
использует строчные ключевые слова:
`[param $name: type = default] select [distinct] … from Карточки|Карточки.MEMBER_поле|Сотрудники|Изменения [where …] [group by …|all] [having …] [order by […] asc|desc [, …]] [limit n]`.
`JOIN`, подзапросы, запись данных и произвольные функции запрещены; `LIMIT` находится в
диапазоне `1..500`. `pageSize` ограничивает размер одной страницы, а непрозрачный
`pageToken` привязан к тексту запроса, фильтру и контексту карточки.

Проекции поддерживают `as [Понятный заголовок]`. Для отчётов доступны `group by`,
`group by all`, `having` и агрегаты `count()`, `count_distinct`, `sum`, `avg`, `min`,
`max`, условные варианты `*_if`, `median` и `percentile`. Выражение следующего столбца
может ссылаться на уже рассчитанный алиас, например
`percent([Готово], [План]) as [Готовность, %]`. Оконные функции `previous`, `change`,
`percent_change`, `running_sum`, `moving_avg` и `rank` работают после обязательного
`order by`. `explode([Метки])` разворачивает список перед группировкой. `group by all`
автоматически включает все неагрегированные проекции; `group by` и `having` могут
ссылаться на алиасы выбранных столбцов. `date_bucket(дата, "day"|"week"|"month")`
строит временные ряды. Для заполненности используйте читаемые операторы
`is empty` и `is not empty`.

`WHERE` и `HAVING` не имеют отдельного интерпретатора: формулы вычисляемых полей, условия
автоматизаций и запросы используют один lexer, AST, проверку типов и evaluator. Разрешены
арифметика, сравнения, `and`/`or`/`not`, `in`, `not in`, `between`, `contains`,
`not contains`, `matches` и фиксированный
набор функций `if`, `coalesce`, `round`, `min`, `max`, `abs`, `concat`,
`days_between`, `date_add`, `lower`, `upper`, `len`, `starts_with`, `ends_with`,
`contains_any`, `contains_all`, `date_bucket`, `last`, `next`, `range`, `shift`,
`start_of`, `end_of`, `working_days_between`, `working_hours_between`,
`add_working_days` и `is_working_day`. Контексты `@now`, `@today`, `@me`,
`@activeSprint`, `@previousSprint`, `@lastWorkingDay`, `@lastMonth` и другие
вычисляются заново при каждом запуске в часовом поясе рабочего календаря или проекта.
Имена в квадратных скобках сервер
связывает со стабильными `references`; права на скрытые поля проверяются до выполнения.

Параметр объявляется до `select`, например
`param $period: period = last(30d)`. Поддерживаются типы `period`, `date`, `datetime`,
`member`, `sprint`, `text`, `number` и `boolean`. Значения одного запуска передаются
через `parameterValues`; если значения нет, используется выражение по умолчанию.
`parameters` возвращает разрешённые значения параметров, `resolvedContexts` —
фактические значения `@…`, а `explain` — реальные количества строк до и после каждого
этапа выполнения.

```sql
select Карточка
from Карточки
where [Приоритет] >= 3
  and [Срок] < @today
order by [Срок] asc
limit 100
```

Сотрудника из конкретного поля карточки выбирают явным типизированным путём:

```sql
select Сотрудник
from Карточки.Поле_сотрудника
where [Сотрудник.Ставка] > 1000
limit 100
```

После точки принимается key или название поля типа `MEMBER`; для названия с пробелами
используйте `Карточки.[Поле сотрудника]`. Сервер сохраняет UUID поля в `references`,
отклоняет поля других типов и пропускает карточки без выбранного сотрудника.
`[Сотрудник.Ставка]` в таком запросе относится именно к человеку из указанного поля.
Без `distinct` один сотрудник может встретиться в нескольких строках — по одной на
каждую карточку. Неявного выбора исполнителя для `select Сотрудник from Карточки` нет.

Источник `Сотрудники` возвращает только активных участников проекта и поддерживает
системные поля `ID`, `Имя`, `Email`, `Логин`, `Роль`, `Активен`, а также доступные поля
персонала:

```sql
select Сотрудник
from Сотрудники
where [Сотрудник.Ставка] > 1000
  and [Карточка.Приоритет] >= 3
order by [Сотрудник.Ставка] asc
limit 1
```

`Карточка` в запросе `from Сотрудники` — уже извлечённый скалярный контекст, а не неявный join.
Во внешнем API его задаёт `contextCardId`; в автоматизации сервер передаёт объект
карточки события напрямую, без дополнительного поиска. Без контекста обращение к
`[Карточка.…]` отклоняется до выполнения. `QueryColumn` описывает `label`, стабильный `source`
и `type`; каждый `QueryValue` содержит `raw`, отображаемый `display`, `type` и признак
`present`. `QueryRow` содержит выбранную `card` или `employee` и массив `values`.

### `GetReports`

- **Доступ:** read
- **Контракт:** `GetReportsRequest → GetReportsResponse`
- **Request:** `{projectId:string}`
- **Response:** `{reports:Report[]}`

`Report` содержит `id`, `projectId`, `name`, `description`, `enabled`, `color`,
`visualization`, `queryDsl`, `queryReferences`, `systemKey`, `position`, `createdAt` и
`updatedAt`. Непустой `systemKey` отличает встроенный отчёт: его разрешено менять и
скрывать, но нельзя удалить. Встроенные определения создаются лениво при первом чтении
проекта и используют тот же DSL, что пользовательские.

### `CreateReport`

- **Доступ:** `reports.manage`
- **Контракт:** `CreateReportRequest → CreateReportResponse`
- **Request:** `{projectId:string, name:string, description:string, enabled:bool, color:string, visualization:string, queryDsl:string, queryReferences:CalculationFormulaReference[]}`
- **Response:** `{report:Report}`

Создаёт сохранённый отчёт после серверной компиляции DSL. `visualization` принимает
`number`, `table`, `bar`, `line` или `burndown`. В проекте может быть до 100 отчётов.

### `UpdateReport`

- **Доступ:** `reports.manage`
- **Контракт:** `UpdateReportRequest → UpdateReportResponse`
- **Request:** `{reportId:string, name?:string, description?:string, enabled?:bool, color?:string, visualization?:string, queryDsl?:string, queryReferences:CalculationFormulaReference[], replaceQuery:bool, position?:int32}`
- **Response:** `{report:Report}`

Редактирует и пользовательские, и встроенные отчёты. Для замены DSL передайте
`replaceQuery:true`, новый `queryDsl` и полный список `queryReferences`; сервер повторно
компилирует запрос до сохранения.

### `DeleteReport`

- **Доступ:** `reports.manage`
- **Контракт:** `DeleteReportRequest → DeleteReportResponse`
- **Request:** `{reportId:string}`
- **Response:** `{}`

Удаляет только пользовательский отчёт. Встроенный отчёт с `systemKey` можно отключить
через `UpdateReport(enabled:false)`, но нельзя удалить.

### `PreviewReport`

- **Доступ:** `reports.view`
- **Контракт:** `PreviewReportRequest → PreviewReportResponse`
- **Request:** `{reportId:string, parameterValues:QueryParameterValue[]}`
- **Response:** `{preview:ReportPreview}`

`ReportPreview` содержит `reportId`, `columns`, `rows`, `totalCount`, `references`,
`normalizedQuery`, `evaluatedAt`, `parameters`, `resolvedContexts` и `explain`.
Предпросмотр всегда выполняет сохранённый запрос на сервере с текущими правами
пользователя и актуальными данными проекта.

### `GetAgentManifest`

- **Доступ:** public
- **Контракт:** `GetAgentManifestRequest → GetAgentManifestResponse`
- **Request:** `{}`
- **Response:** `{manifest:AgentManifest}`

Возвращает тот же контракт, что и `GET /.well-known/algoboard-agent.json`.

### Проекты, пространство и команда

### `CreateProject`

- **Доступ:** admin / owner
- **Контракт:** `CreateProjectRequest → CreateProjectResponse`
- **Request:** `{workspaceId:string, name:string, key:string, color:string, projectId:string, memberIds:string[]}`
- **Семантика:** создатель автоматически становится администратором проекта; активные участники из `memberIds` получают стандартную рабочую роль. Участники рабочего пространства, которых нет в списке, не получают доступ к новому проекту.
- **Response:** `{project:Project}`

`key` становится неизменяемым префиксом карточек проекта. `projectId` — обязательный
контекст текущего проекта, по которому проверяется разрешение
`workspace.projects.create`.

### `UpdateProject`

- **Доступ:** admin / owner
- **Контракт:** `UpdateProjectRequest → UpdateProjectResponse`
- **Request:** `{projectId:string, name?:string, color?:string, archived?:boolean}`
- **Response:** `{project:Project}`

Архивный проект доступен для чтения, но его рабочие сущности нельзя изменять.

### `DeleteProject`

- **Доступ:** admin / owner (`project.manage`)
- **Контракт:** `DeleteProjectRequest → DeleteProjectResponse`
- **Request:** `{projectId:string}`
- **Response:** `{}`

Необратимо удаляет только архивный проект. Вместе с проектом каскадно удаляются карточки,
спринты, настройки, автоматизации, интеграции и остальные связанные данные. Попытка удалить
активный проект завершается конфликтом: перед полным удалением проект необходимо архивировать.

### `UpdateProjectCapability`

- **Доступ:** admin / owner
- **Контракт:** `UpdateProjectCapabilityRequest → UpdateProjectCapabilityResponse`
- **Request:** `{projectId:string, key:string, name?:string, description?:string, icon?:string, color?:string, enabled?:boolean, position?:int32}`
- **Response:** `{capability:ProjectCapability}`

Настраивает один независимый движок проекта. `key` выбирается из `projectState.capabilities`; системный ключ стабилен, а `name`, `description`, `icon`, `color` и `position` редактируются владельцем. Выключение через `enabled:false` скрывает интерфейс и останавливает поведение возможности, но не удаляет её данные и не меняет состояние других возможностей.

### `ConfigureProjectCapabilities`

- **Доступ:** admin / owner
- **Контракт:** `ConfigureProjectCapabilitiesRequest → ConfigureProjectCapabilitiesResponse`
- **Request:** `{projectId:string, enabledKeys:string[]}`
- **Response:** `{capabilities:ProjectCapability[]}`

Одним вызовом сохраняет стартовый состав возможностей проекта. Сервер отмечает настроенным весь каталог: ключи из `enabledKeys` включает, остальные выключает без удаления данных. Неизвестный ключ отклоняет весь запрос.

### `UpdateWorkspace`

- **Доступ:** admin / owner
- **Контракт:** `UpdateWorkspaceRequest → UpdateWorkspaceResponse`
- **Request:** `{workspaceId:string, name?:string, timezone?:string, defaultSprintDays?:int32, projectId?:string}`
- **Response:** `{workspace:Workspace}`

`timezone` использует IANA-name, например `Europe/Moscow`.

### `CreateColorStyle`

- **Доступ:** admin / owner
- **Контракт:** `CreateColorStyleRequest → CreateColorStyleResponse`
- **Request:** `{workspaceId:string, name:string, value:string, projectId?:string}`
- **Response:** `{colorStyle:ColorStyle}`

Добавляет цвет в палитру рабочего пространства. `value` — шестизначный HEX, например `#246BFD`. Стабильный внутренний `key` создаёт сервер; его используют поля `color` проектов, типов объектов, связей, меток, представлений, общих фильтров и аватаров.

### `UpdateColorStyle`

- **Доступ:** admin / owner
- **Контракт:** `UpdateColorStyleRequest → UpdateColorStyleResponse`
- **Request:** `{colorStyleId:string, name?:string, value?:string, position?:int32, projectId?:string}`
- **Response:** `{colorStyle:ColorStyle}`

Изменяет пользовательское название, HEX или порядок цвета. `key` не меняется, поэтому новый оттенок сразу применяется ко всем существующим назначениям.

### `DeleteColorStyle`

- **Доступ:** admin / owner
- **Контракт:** `DeleteColorStyleRequest → DeleteColorStyleResponse`
- **Request:** `{colorStyleId:string, projectId?:string}`
- **Response:** `{}`

Удаляет только неиспользуемый цвет. Перед удалением замените его во всех назначениях; в рабочем пространстве должен остаться хотя бы один цвет.

### `CreateMember`

- **Доступ:** admin / owner
- **Контракт:** `CreateMemberRequest → CreateMemberResponse`
- **Request:** `{workspaceId:string, name:string, email:string, role:MemberRole, avatarColor:string, avatarData?:bytes, avatarContentType?:string, login:string, password:string, projectId:string}`
- **Response:** `{member:Member}`

Создаёт участника и учётную запись атомарно. `avatarData` в JSON — Base64. Ограничения login, password и изображения описаны выше в разделе «Спринты и администрирование».

### `UpdateMember`

- **Доступ:** self / admin
- **Контракт:** `UpdateMemberRequest → UpdateMemberResponse`
- **Request:** `{memberId:string, name?:string, role?:MemberRole, active?:boolean, avatarData?:bytes, avatarContentType?:string, clearAvatar?:boolean, projectId?:string}`
- **Response:** `{member:Member}`

Участник может изменить у себя только `name` и avatar-поля без `projectId`. Для роли, активности или чужого профиля нужны `projectId` и `team.manage`. Операции над владельцем и назначение базовой роли Owner дополнительно требуют `workspace.owners.manage`. Для agent token всегда нужен scope `admin`.

### `SetMemberTemporaryPassword`

- **Доступ:** admin / owner
- **Контракт:** `SetMemberTemporaryPasswordRequest → SetMemberTemporaryPasswordResponse`
- **Request:** `{memberId:string, projectId:string, login:string, temporaryPassword:string}`
- **Response:** `{member:Member}`

Создаёт учётную запись для импортированного участника без доступа. `login` должен быть уникальным и содержать 3–32 допустимых символа, `temporaryPassword` — 8–72 символа. Если email участника уже связан с учётной записью, `login` и `temporaryPassword` игнорируются: участник привязывается к существующей записи, а её пароль и сессии не меняются. Метод требует `team.manage`; для участника-владельца дополнительно проверяется `workspace.owners.manage`. Свой пароль пользователь меняет через `ChangePassword`.

### `CreatePersonnelField`

- **Доступ:** `personnel.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `CreatePersonnelFieldRequest → CreatePersonnelFieldResponse`
- **Request:** `{projectId:string, name:string, description:string, type:string, options:string[]}`
- **Response:** `{field:PersonnelFieldDefinition}`

`type` принимает `text`, `number`, `date`, `boolean` или `select`. Для `select` нужен хотя бы один пользовательский вариант; для остальных типов `options` очищается.

### `UpdatePersonnelField`

- **Доступ:** `personnel.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `UpdatePersonnelFieldRequest → UpdatePersonnelFieldResponse`
- **Request:** `{fieldId:string, name?:string, description?:string, type?:string, options:string[], replaceOptions:boolean, position?:int32}`
- **Response:** `{field:PersonnelFieldDefinition}`

Изменение типа или вариантов отклоняется, если существующее значение сотрудника станет недопустимым.

### `DeletePersonnelField`

- **Доступ:** `personnel.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `DeletePersonnelFieldRequest → DeletePersonnelFieldResponse`
- **Request:** `{fieldId:string}`
- **Response:** `{}`

Значения удаляются каскадно. Если `CalculationFormulaReference` использует поле персонала, сервер возвращает conflict и не меняет данные.

### `UpdateMemberProjectProfile`

- **Доступ:** `personnel.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `UpdateMemberProjectProfileRequest → UpdateMemberProjectProfileResponse`
- **Request:** `{projectId:string, memberId:string, businessCalendarId?:string, clearBusinessCalendar:boolean, values:PersonnelFieldValue[], replaceValues:boolean}`
- **Response:** `{profile:MemberProjectProfile}`

`businessCalendarId` должен принадлежать тому же проекту. `clearBusinessCalendar` снимает назначение. `replaceValues:true` полностью заменяет значения; пустое значение удаляет конкретное свойство. Без `replaceValues` переданные значения сливаются с сохранёнными.

### Карточки и комментарии

### `CreateIssue`

- **Доступ:** write
- **Контракт:** `CreateIssueRequest → CreateIssueResponse`
- **Request:** `{projectId:string, columnId?:string, sprintId?:string, assigneeId?:string, reporterId?:string, title:string, description:string, type:IssueType, priority:IssuePriority, storyPoints:int32, labelIds:string[], startDate?:string, dueDate?:string, progressPercent:int32, actualStartDate?:string, actualFinishDate?:string, milestone:boolean, deadlineDate?:string, parentId?:string, dependencyIds:string[], timelineDependencies:TimelineDependencyInput[], customFieldValues:CustomFieldInput[], checklists:IssueChecklistInput[], blockedReason:string, objectTypeId?:string, chatSource?:ChatIssueSourceInput}`
- **Response:** `{issue:Issue}`

Если `columnId` не передан, сервер использует первую колонку проекта. `chatSource` связывает новую карточку с доступным пользовательским сообщением: сервер проверяет `chat.view` и `issue.create`, атомарно сохраняет неизменяемый снимок исходного content/plain text, пишет аудит и публикует `chat.message.updated`. Для одного `messageId` может существовать только одна карточка; повтор того же запроса возвращает уже связанную карточку. Остальные Create-команды пока не поддерживают idempotency key.

### `UpdateIssue`

- **Доступ:** write
- **Контракт:** `UpdateIssueRequest → UpdateIssueResponse`
- **Request:** `{issueId:string, title?:string, description?:string, type?:IssueType, priority?:IssuePriority, storyPoints?:int32, assigneeId?:string, sprintId?:string, labelIds:string[], replaceLabels:boolean, startDate?:string, dueDate?:string, progressPercent?:int32, actualStartDate?:string, actualFinishDate?:string, milestone?:boolean, deadlineDate?:string, parentId?:string, dependencyIds:string[], replaceDependencies:boolean, timelineDependencies:TimelineDependencyInput[], replaceTimelineDependencies:boolean, customFieldValues:CustomFieldInput[], replaceCustomFields:boolean, checklists:IssueChecklistInput[], replaceChecklists:boolean, blockedReason?:string, objectTypeId?:string}`
- **Response:** `{issue:Issue}`

Массив изменяет данные только вместе с соответствующим `replace... = true`. Пустая optional-строка очищает связь или значение. Например, `{"issueId":"…","assigneeId":""}` снимает исполнителя.

### `MoveIssue`

- **Доступ:** write
- **Контракт:** `MoveIssueRequest → MoveIssueResponse`
- **Request:** `{issueId:string, columnId:string, position:int32}`
- **Response:** `{issue:Issue}`

Проверяет workflow rule, WIP и обязательные поля, затем выполняет automation rules в одной транзакции. Перечитайте карточку после ответа: автоматизация могла изменить итоговую колонку или исполнителя.

### `MoveTimelineIssue`

- **Доступ:** write
- **Контракт:** `MoveTimelineIssueRequest → MoveTimelineIssueResponse`
- **Request:** `{issueId:string, targetIssueId:string, placeAfter:boolean}`
- **Response:** `{issue:Issue}`

Переставляет карточку на временной шкале относительно `targetIssueId`. При
`placeAfter:true` карточка ставится после цели, иначе перед ней. Позиция сохраняется в
`Issue.timelinePosition`.

### `CaptureTimelineBaseline`

- **Доступ:** write
- **Контракт:** `CaptureTimelineBaselineRequest → CaptureTimelineBaselineResponse`
- **Request:** `{projectId:string}`
- **Response:** `{issueCount:int32, capturedAt:Timestamp}`

Копирует текущие `startDate` и `dueDate` всех неархивных карточек проекта в одну пару
базовых дат. Повторный вызов заменяет прежний снимок; текущий план не меняется.

### `ArchiveIssue`

- **Доступ:** write
- **Контракт:** `ArchiveIssueRequest → ArchiveIssueResponse`
- **Request:** `{issueId:string, archived:boolean}`
- **Response:** `{issue:Issue}`

`archived = false` восстанавливает карточку.

### `AddComment`

- **Доступ:** write
- **Контракт:** `AddCommentRequest → AddCommentResponse`
- **Request:** `{issueId:string, authorId:string, body:string}`
- **Response:** `{comment:Comment}`

Для запросов от агента или браузерной сессии `authorId` можно оставить пустым: сервер использует текущего участника. Комментарий попадает в аудит.

### Спринты

### `CreateSprint`

- **Доступ:** write
- **Контракт:** `CreateSprintRequest → CreateSprintResponse`
- **Request:** `{projectId:string, name:string, goal:string, startDate:string, endDate:string}`
- **Response:** `{sprint:Sprint}`

Создаёт спринт в статусе `SPRINT_STATUS_PLANNED`. Даты передаются как `YYYY-MM-DD`.

### `UpdateSprint`

- **Доступ:** write
- **Контракт:** `UpdateSprintRequest → UpdateSprintResponse`
- **Request:** `{sprintId:string, name?:string, goal?:string, startDate?:string, endDate?:string}`
- **Response:** `{sprint:Sprint}`

### `StartSprint`

- **Доступ:** write
- **Контракт:** `StartSprintRequest → StartSprintResponse`
- **Request:** `{sprintId:string}`
- **Response:** `{sprint:Sprint}`

Переводит запланированный спринт в active. В проекте может быть только один активный спринт.

### `CompleteSprint`

- **Доступ:** write
- **Контракт:** `CompleteSprintRequest → CompleteSprintResponse`
- **Request:** `{sprintId:string}`
- **Response:** `{sprint:Sprint}`

Завершает активный спринт. Незавершённые карточки перемещаются в первую колонку категории `TODO`, а их `sprintId` очищается.

### Колонки, метки и workflow

### `CreateColumn`

- **Доступ:** admin / owner
- **Контракт:** `CreateColumnRequest → CreateColumnResponse`
- **Request:** `{projectId:string, name:string, wipLimit:int32, category:ColumnCategory, objectTypeId?:string}`
- **Response:** `{column:BoardColumn}`

Создаёт колонку в конце процесса. `objectTypeId` ограничивает колонку одним типом объекта; пустое значение оставляет её общей.

### `UpdateColumn`

- **Доступ:** admin / owner
- **Контракт:** `UpdateColumnRequest → UpdateColumnResponse`
- **Request:** `{columnId:string, name?:string, wipLimit?:int32, category?:ColumnCategory, position?:int32, objectTypeId?:string}`
- **Response:** `{column:BoardColumn}`

`wipLimit = 0` означает отсутствие лимита. `position` меняет порядок, а пустой `objectTypeId` делает колонку общей.

### `DeleteColumn`

- **Доступ:** admin / owner
- **Контракт:** `DeleteColumnRequest → DeleteColumnResponse`
- **Request:** `{columnId:string, moveIssuesToColumnId:string}`
- **Response:** `{movedIssueCount:int32}`

`moveIssuesToColumnId` обязателен, если в состоянии есть объекты либо его нужно явно заменить в зависимых фильтрах и действиях; целевое состояние должно принадлежать тому же проекту и отличаться от удаляемого. Без цели можно удалить только пустое и ни на что не ссылающееся состояние. Workflow-переходы всегда нужно изменить или удалить отдельно.

### `CreateLabel`

- **Доступ:** admin / owner
- **Контракт:** `CreateLabelRequest → CreateLabelResponse`
- **Request:** `{projectId:string, name:string, color:string}`
- **Response:** `{label:Label}`

### `UpdateLabel`

- **Доступ:** admin / owner
- **Контракт:** `UpdateLabelRequest → UpdateLabelResponse`
- **Request:** `{labelId:string, name?:string, color?:string}`
- **Response:** `{label:Label}`

### `DeleteLabel`

- **Доступ:** admin / owner
- **Контракт:** `DeleteLabelRequest → DeleteLabelResponse`
- **Request:** `{labelId:string}`
- **Response:** `{affectedIssueCount:int32}`

Снимает метку с карточек. Любая ссылка из фильтра, автоматизации, бизнес-действия, пакета, маршрутизации или дашборда блокирует удаление; сервер ничего не переписывает молча.

### `CreateWorkflowRule`

- **Доступ:** admin / owner
- **Контракт:** `CreateWorkflowRuleRequest → CreateWorkflowRuleResponse`
- **Request:** `{projectId:string, fromColumnId:string, toColumnId:string, enabled:boolean, allowedRoles:MemberRole[], allowedProjectRoleIds:string[], requireAssignee:boolean, requireStoryPoints:boolean, requireSprint:boolean, requiredCustomFieldIds:string[], requiredSystemFields:string[]}`
- **Response:** `{rule:WorkflowRule}`

Создаёт разрешённый переход между двумя разными колонками одного проекта и его обязательные условия. В проектах с гранулярными правами переход ограничивается через `allowedProjectRoleIds`; `allowedRoles` сохранён для совместимости с базовой моделью ролей.

### `UpdateWorkflowRule`

- **Доступ:** admin / owner
- **Контракт:** `UpdateWorkflowRuleRequest → UpdateWorkflowRuleResponse`
- **Request:** `{ruleId:string, enabled?:boolean, allowedRoles:MemberRole[], replaceAllowedRoles:boolean, allowedProjectRoleIds:string[], replaceAllowedProjectRoleIds:boolean, requireAssignee?:boolean, requireStoryPoints?:boolean, requireSprint?:boolean, requiredCustomFieldIds:string[], replaceRequiredCustomFields:boolean, requiredSystemFields:string[], replaceRequiredSystemFields:boolean}`
- **Response:** `{rule:WorkflowRule}`

Правило относится к конкретной паре `fromColumnId → toColumnId`; API обновляет его ограничения, но не меняет пару колонок.

### `DeleteWorkflowRule`

- **Доступ:** admin / owner
- **Контракт:** `DeleteWorkflowRuleRequest → DeleteWorkflowRuleResponse`
- **Request:** `{ruleId:string}`
- **Response:** `{}`

Удаляет переход из workflow. Карточки и колонки не изменяются.

### Типы объектов и их поля

### `CreateObjectType`

- **Доступ:** admin / owner
- **Контракт:** `CreateObjectTypeRequest → CreateObjectTypeResponse`
- **Request:** `{projectId:string, name:string, pluralName:string, key:string, description:string, icon:string, color:string, titleLabel:string}`
- **Response:** `{objectType:ObjectType}`

Создаёт тип карточки проекта. `key` используется как стабильный идентификатор типа, а `titleLabel` — как подпись основного поля названия.

### `UpdateObjectType`

- **Доступ:** admin / owner
- **Контракт:** `UpdateObjectTypeRequest → UpdateObjectTypeResponse`
- **Request:** `{objectTypeId:string, name?:string, pluralName?:string, key?:string, description?:string, icon?:string, color?:string, titleLabel?:string, position?:int32, archived?:boolean}`
- **Response:** `{objectType:ObjectType}`

Обновляет представление и порядок типа. Архивный тип остаётся доступен существующим карточкам, но не предлагается для новых.

### `DeleteObjectType`

- **Доступ:** admin / owner
- **Контракт:** `DeleteObjectTypeRequest → DeleteObjectTypeResponse`
- **Request:** `{objectTypeId:string}`
- **Response:** `{}`

Удаление доступно только когда тип больше не используется карточками, колонками, связями, расчётами, пакетами, маршрутизацией, дашбордами, представлениями или другими условиями.

### `UpsertObjectTypeField`

- **Доступ:** admin / owner
- **Контракт:** `UpsertObjectTypeFieldRequest → UpsertObjectTypeFieldResponse`
- **Request:** `{objectTypeId:string, fieldId:string, label:string, description:string, required:boolean, showOnCard:boolean, visibleOnCreate:boolean, readOnly:boolean, position:int32, defaultValue:string}`
- **Response:** `{field:ObjectTypeField}`

Добавляет поле в схему типа или атомарно обновляет его настройки. Само определение пользовательского поля должно уже существовать в проекте.

### `DeleteObjectTypeField`

- **Доступ:** admin / owner
- **Контракт:** `DeleteObjectTypeFieldRequest → DeleteObjectTypeFieldResponse`
- **Request:** `{objectTypeId:string, fieldId:string}`
- **Response:** `{}`

Убирает поле из схемы типа, не удаляя глобальное определение и сохранённые значения карточек. Если поле обязательно для workflow-перехода этого типа, удаление блокируется до явного изменения правила.

### Типы связей и связи объектов

### `CreateRelationType`

- **Доступ:** admin / owner
- **Контракт:** `CreateRelationTypeRequest → CreateRelationTypeResponse`
- **Request:** `{projectId:string, sourceObjectTypeId:string, targetObjectTypeId:string, name:string, reverseName:string, key:string, cardinality:RelationCardinality, color:string}`
- **Response:** `{relationType:RelationType}`

Создаёт редактируемое определение связи между двумя типами объектов. `name` описывает направление от источника к цели, `reverseName` — обратное направление, а `key` служит стабильным ключом для фильтров и интеграций.

### `UpdateRelationType`

- **Доступ:** admin / owner
- **Контракт:** `UpdateRelationTypeRequest → UpdateRelationTypeResponse`
- **Request:** `{relationTypeId:string, sourceObjectTypeId?:string, targetObjectTypeId?:string, name?:string, reverseName?:string, key?:string, cardinality?:RelationCardinality, color?:string, position?:int32, archived?:boolean}`
- **Response:** `{relationType:RelationType}`

Позволяет менять оба типа, формулировки направлений, ключ, кардинальность, цвет, порядок и архивный статус. Изменение типов или кардинальности отклоняется, если существующие связи перестанут соответствовать новой схеме.

### `DeleteRelationType`

- **Доступ:** admin / owner
- **Контракт:** `DeleteRelationTypeRequest → DeleteRelationTypeResponse`
- **Request:** `{relationTypeId:string}`
- **Response:** `{}`

Удаляет только неиспользуемое определение связи. Если существуют экземпляры, расчёты, пакеты, маршрутизация, дашборды, представления или другие условия со ссылкой на тип, сервер требует сначала изменить их; фильтры не очищаются автоматически.

### `CreateObjectRelation`

- **Доступ:** write
- **Контракт:** `CreateObjectRelationRequest → CreateObjectRelationResponse`
- **Request:** `{relationTypeId:string, sourceIssueId:string, targetIssueId:string}`
- **Response:** `{relation:ObjectRelation}`

Связывает два объекта в соответствии с направлением, допустимыми типами и кардинальностью определения. Самоссылки и дубликаты запрещены.

### `DeleteObjectRelation`

- **Доступ:** write
- **Контракт:** `DeleteObjectRelationRequest → DeleteObjectRelationResponse`
- **Request:** `{relationId:string}`
- **Response:** `{}`

Удаляет только выбранный экземпляр связи, не меняя её тип.

### Кастомные поля

### `CreateCustomField`

- **Доступ:** admin / owner
- **Контракт:** `CreateCustomFieldRequest → CreateCustomFieldResponse`
- **Request:** `{projectId:string, name:string, key:string, type:CustomFieldType, required:boolean, options:string[], description:string, showOnCard:boolean, objectTypeIds:string[]}`
- **Response:** `{field:CustomFieldDefinition}`

`key` уникален в проекте и после создания не меняется. `options` нужны для типа `SELECT`. `objectTypeIds` явно задаёт типы, в контекст которых сразу добавить поле; пустой массив оставляет поле без привязок.

### `UpdateCustomField`

- **Доступ:** admin / owner
- **Контракт:** `UpdateCustomFieldRequest → UpdateCustomFieldResponse`
- **Request:** `{fieldId:string, name?:string, required?:boolean, options:string[], replaceOptions:boolean, position?:int32, description?:string, showOnCard?:boolean}`
- **Response:** `{field:CustomFieldDefinition}`

Тип и key не изменяются. Для полной замены списка используйте `replaceOptions = true`.

### `DeleteCustomField`

- **Доступ:** admin / owner
- **Контракт:** `DeleteCustomFieldRequest → DeleteCustomFieldResponse`
- **Request:** `{fieldId:string}`
- **Response:** `{}`

Удаляет определение и его значения карточек через каскадный foreign key только после явного подтверждения пользователя. Любая ссылка из workflow, фильтра, временной политики, автоматизации, бизнес-действия, расчёта, пакета, маршрутизации или дашборда блокирует удаление и остаётся без изменений.

### `UpdateFieldPermission`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `UpdateFieldPermissionRequest → UpdateFieldPermissionResponse`
- **Request:** `{projectId:string, fieldKind:string, fieldId:string, viewRoleIds:string[], editRoleIds:string[], restricted:boolean, selfView:boolean}`
- **Response:** `{permission:FieldPermission}`

Задаёт доступ проектных ролей к системному полю, пользовательскому полю карточки, полю персонала или расчёту. `fieldKind` принимает `system`, `custom`, `personnel` или `calculation`. Для `system` используются стабильные идентификаторы содержательных полей карточки, например `description`, `priority`, `assignee`, `story_points`, `labels`, `due_date`, `comments` и `checklists`; ключ, название, тип объекта и состояние остаются навигационной основой карточки и не ограничиваются. Роль из `editRoleIds` должна также входить в `viewRoleIds`. При `restricted:false` правило удаляется и поле снова доступно всем ролям. Права нескольких ролей участника объединяются: достаточно одной роли с просмотром или изменением. Участник без права просмотра не получает определение или значение поля и не может использовать его в фильтрах, DSL, автоматизациях и действиях; без права изменения сервер отклоняет прямую запись. Для поля персонала `selfView:true` разрешает участнику видеть собственное значение, но не чужие значения и не даёт права редактирования.

### Доски и общие фильтры

### `CreateSavedBoard`

- **Доступ:** write
- **Контракт:** `CreateSavedBoardRequest → CreateSavedBoardResponse`
- **Request:** `{projectId:string, name:string, description:string, filter:BoardFilter, parentId?:string, color:string, defaultAccess:string, accessRules:BoardAccessRule[], subscriptionsEnabled:boolean}`
- **Response:** `{board:SavedBoard}`

Доска — сохранённое представление над общим набором карточек. `parentId` создаёт под-доску. `defaultAccess` обязателен: сервер не выбирает режим доступа за пользователя. Для помещения в существующую иерархию нужен `manage` к родительскому представлению.

### `UpdateSavedBoard`

- **Доступ:** ACL `edit` для содержимого, ACL `manage` для структуры и доступа
- **Контракт:** `UpdateSavedBoardRequest → UpdateSavedBoardResponse`
- **Request:** `{boardId:string, name?:string, description?:string, filter?:BoardFilter, isDefault?:boolean, parentId?:string, position?:int32, color?:string, defaultAccess?:string, accessRules:BoardAccessRule[], replaceAccessRules:boolean, subscriptionsEnabled?:boolean}`
- **Response:** `{board:SavedBoard}`

Пустой `parentId` переносит доску на верхний уровень. Сервер запрещает циклы. Массив правил заменяется только при `replaceAccessRules = true`; его порядок значим. Выключение подписок сохраняет их определения.

### `DeleteSavedBoard`

- **Доступ:** ACL `manage`
- **Контракт:** `DeleteSavedBoardRequest → DeleteSavedBoardResponse`
- **Request:** `{boardId:string}`
- **Response:** `{}`

Удаляет только само представление. Карточки проекта и остальные определения остаются. Перед удалением нужно назначить другое основное представление, явно переместить дочерние виды и удалить filter cards, подписки и ссылки автоматизаций. В проекте должна остаться хотя бы одна доска.

### `CreateAccessGroup`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `CreateAccessGroupRequest → CreateAccessGroupResponse`
- **Request:** `{projectId:string, name:string, description:string, color:string, memberIds:string[], roleIds:string[]}`
- **Response:** `{group:AccessGroup}`

Создаёт группу только из участников того же workspace и ролей того же проекта. Пустые состав и список ролей допустимы; сервер ничего не подставляет автоматически. Итоговые права участника — объединение разрешений всех ролей всех его групп.

### `UpdateAccessGroup`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `UpdateAccessGroupRequest → UpdateAccessGroupResponse`
- **Request:** `{groupId:string, name?:string, description?:string, color?:string, memberIds:string[], replaceMemberIds:boolean, roleIds:string[], replaceRoleIds:boolean, position?:int32}`
- **Response:** `{group:AccessGroup}`

Состав заменяется только при `replaceMemberIds = true`, назначения ролей — только при `replaceRoleIds = true`.

### `DeleteAccessGroup`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `DeleteAccessGroupRequest → DeleteAccessGroupResponse`
- **Request:** `{groupId:string}`
- **Response:** `{}`

Удаление блокируется, пока хотя бы одно представление или шаблон формы ссылается на группу. Правила доступа не переписываются скрыто.

### `CreateProjectRole`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `CreateProjectRoleRequest → CreateProjectRoleResponse`
- **Request:** `{projectId:string, name:string, description:string, color:string, permissions:string[]}`
- **Response:** `{role:ProjectRole}`

Роль создаётся без скрытых разрешений. Каждый ключ проверяется по серверному каталогу `PermissionDefinition`.

### `UpdateProjectRole`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `UpdateProjectRoleRequest → UpdateProjectRoleResponse`
- **Request:** `{roleId:string, name?:string, description?:string, color?:string, permissions:string[], replacePermissions:boolean, position?:int32}`
- **Response:** `{role:ProjectRole}`

Разрешения атомарно заменяются только при `replacePermissions = true`.

### `DeleteProjectRole`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `DeleteProjectRoleRequest → DeleteProjectRoleResponse`
- **Request:** `{roleId:string}`
- **Response:** `{}`

Удаление блокируется, пока роль прямо назначена хотя бы одному участнику либо назначена группе.

### `SetMemberProjectRoles`

- **Доступ:** `access.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `SetMemberProjectRolesRequest → SetMemberProjectRolesResponse`
- **Request:** `{projectId:string, memberId:string, roleIds:string[]}`
- **Response:** `{assignments:MemberProjectRole[]}`

Атомарно заменяет прямые назначения ролей участника в проекте. Все `roleIds` должны принадлежать этому проекту, а участник — его рабочему пространству. Пустой массив снимает прямые роли, но не меняет роли, полученные через группы доступа.

### `UpsertBoardSubscription`

- **Доступ:** ACL `view`; чужую подписку можно администрировать с `views.manage` или `team.manage`
- **Контракт:** `UpsertBoardSubscriptionRequest → UpsertBoardSubscriptionResponse`
- **Request:** `{boardId:string, memberId:string, enabled:boolean, eventKinds:string[], includeOwn:boolean}`
- **Response:** `{subscription:BoardSubscription}`

Пустой `memberId` означает текущего участника. Включённая подписка требует хотя бы один явно выбранный ключ `eventKinds`; ключ соответствует `^[a-z][a-z0-9_.:-]{0,63}$`. Новые определения можно сохранять только пока подписки разрешены представлением.

### `DeleteBoardSubscription`

- **Доступ:** ACL `view`; собственная подписка или owner
- **Контракт:** `DeleteBoardSubscriptionRequest → DeleteBoardSubscriptionResponse`
- **Request:** `{subscriptionId:string}`
- **Response:** `{}`

### `MarkBoardSubscriptionRead`

- **Доступ:** ACL `view`; собственная подписка или owner
- **Контракт:** `MarkBoardSubscriptionReadRequest → MarkBoardSubscriptionReadResponse`
- **Request:** `{subscriptionId:string}`
- **Response:** `{subscription:BoardSubscription}`

Сервер сохраняет `lastReadAt`. Виртуальные `BoardNotification` строятся из журнала событий и текущего соответствия карточки фильтру представления; отдельные копии уведомлений в БД не создаются.

### `MarkNotificationsRead`

- **Доступ:** `project.view`; только уведомления текущего участника
- **Контракт:** `MarkNotificationsReadRequest → MarkNotificationsReadResponse`
- **Request:** `{projectId:string, notificationIds:string[], markAll:boolean}`
- **Response:** `{}`

При `markAll = true` сервер отмечает прочитанными все персональные уведомления текущего участника в проекте. Иначе требуется хотя бы один `notificationId`. Уведомления о `@упоминаниях` сохраняются отдельно от подписок на представления и дедуплицируются по комментарию и получателю.

### `CreateBoardFilterCard`

- **Доступ:** ACL `view` для личного, ACL `edit` для общего
- **Контракт:** `CreateBoardFilterCardRequest → CreateBoardFilterCardResponse`
- **Request:** `{boardId:string, name:string, filter:BoardFilter, color:string, personal:boolean}`
- **Response:** `{card:BoardFilterCard}`

### `UpdateBoardFilterCard`

- **Доступ:** владелец личного фильтра с ACL `view` либо ACL `edit` для общего
- **Контракт:** `UpdateBoardFilterCardRequest → UpdateBoardFilterCardResponse`
- **Request:** `{cardId:string, name?:string, filter?:BoardFilter, position?:int32, color?:string}`
- **Response:** `{card:BoardFilterCard}`

### `DeleteBoardFilterCard`

- **Доступ:** владелец личного фильтра с ACL `view` либо ACL `edit` для общего
- **Контракт:** `DeleteBoardFilterCardRequest → DeleteBoardFilterCardResponse`
- **Request:** `{cardId:string}`
- **Response:** `{}`

### Wiki

### `CreateWikiArticle`

- **Доступ:** write
- **Контракт:** `CreateWikiArticleRequest → CreateWikiArticleResponse`
- **Request:** `{projectId:string, parentId?:string, title:string, body:string}`
- **Response:** `{article:WikiArticle}`

`parentId` можно не передавать для корневой статьи. `body` хранит HTML визуального редактора; старый Markdown остаётся читаемым и нормализуется интерфейсом при следующем сохранении.

### `UpdateWikiArticle`

- **Доступ:** write
- **Контракт:** `UpdateWikiArticleRequest → UpdateWikiArticleResponse`
- **Request:** `{articleId:string, parentId?:string, title?:string, body?:string, position?:int32}`
- **Response:** `{article:WikiArticle}`

Пустой `parentId` переносит статью в корень. Иерархия не ограничена по глубине; циклы запрещены.

### `DeleteWikiArticle`

- **Доступ:** write
- **Контракт:** `DeleteWikiArticleRequest → DeleteWikiArticleResponse`
- **Request:** `{articleId:string}`
- **Response:** `{deletedArticleCount:int32, clearedFieldValueCount:int32}`

Атомарно удаляет поддерево и очищает значения custom field типа `WIKI_ARTICLE`.

### Автоматизации

### `CreateAutomationRule`

- **Доступ:** admin / owner
- **Контракт:** `CreateAutomationRuleRequest → CreateAutomationRuleResponse`
- **Request:** `{projectId:string, name:string, enabled:boolean, trigger:AutomationTrigger, fromColumnId?:string, toColumnId?:string, actions:AutomationAction[], integrationId?:string, eventFilter:AutomationEventFilter}`
- **Response:** `{rule:AutomationRule}`

Для `ISSUE_MOVED` пустая граница колонки означает «любая». Для `ISSUE_UPDATED` фильтр `eventFilter.changedFields` проверяется по снимкам карточки до и после изменения; так назначение исполнителя не срабатывает на простое редактирование названия. Действия выполняются по порядку.

### `UpdateAutomationRule`

- **Доступ:** admin / owner
- **Контракт:** `UpdateAutomationRuleRequest → UpdateAutomationRuleResponse`
- **Request:** `{ruleId:string, name?:string, enabled?:boolean, trigger?:AutomationTrigger, fromColumnId?:string, toColumnId?:string, actions:AutomationAction[], replaceActions:boolean, integrationId?:string, eventFilter:AutomationEventFilter, replaceEventFilter:boolean}`
- **Response:** `{rule:AutomationRule}`

Массив действий заменяется только с `replaceActions = true`.

### `DeleteAutomationRule`

- **Доступ:** admin / owner
- **Контракт:** `DeleteAutomationRuleRequest → DeleteAutomationRuleResponse`
- **Request:** `{ruleId:string}`
- **Response:** `{}`

Удаление правила не удаляет его журнал запусков.

### `TestAutomationRule`

- **Доступ:** admin / owner
- **Контракт:** `TestAutomationRuleRequest → TestAutomationRuleResponse`
- **Request:** `{ruleId:string, issueId:string, fromColumnId?:string, toColumnId?:string}`
- **Response:** `{run:AutomationRun, changes:AutomationPreviewChange[]}`

Выполняет dry-run на снимке выбранной карточки. Ответ показывает `before` и `after`, но не изменяет карточку, комментарии или связи. Тест записывается в `projectState.automationRuns` со статусом `preview`; ошибки в устаревших ссылках записываются со статусом `failed`.

### Шаблоны создания

### `CreateIssueTemplate`

- **Доступ:** `templates.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `CreateIssueTemplateRequest → CreateIssueTemplateResponse`
- **Request:** `{projectId:string, name:string, description:string, enabled:boolean, access:IssueTemplateAccess, objectTypeId?:string, columnId?:string, fields:IssueTemplateField[], successMessage:string, expiresAt?:string, submissionLimit:int32, password:string, accessGroupIds:string[]}`
- **Response:** `{template:IssueTemplate}`

Создаёт форму без неявных полей. `expiresAt` передаётся в RFC 3339, `submissionLimit = 0` означает отсутствие лимита.

### `UpdateIssueTemplate`

- **Доступ:** `templates.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `UpdateIssueTemplateRequest → UpdateIssueTemplateResponse`
- **Request:** `{templateId:string, name?:string, description?:string, enabled?:boolean, access?:IssueTemplateAccess, objectTypeId?:string, columnId?:string, fields:IssueTemplateField[], replaceFields:boolean, successMessage?:string, expiresAt?:string, submissionLimit?:int32, position?:int32, password?:string, clearPassword:boolean, accessGroupIds:string[], replaceAccessGroupIds:boolean}`
- **Response:** `{template:IssueTemplate}`

`replaceFields = true` атомарно заменяет состав и порядок формы. Пустые optional ID снимают фиксацию типа или колонки. Непустой `password` меняет пароль; его исходное значение никогда не возвращается. При смене режима с `PASSWORD` хеш удаляется. Группы заменяются только при `replaceAccessGroupIds = true`.

### `DeleteIssueTemplate`

- **Доступ:** `templates.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `DeleteIssueTemplateRequest → DeleteIssueTemplateResponse`
- **Request:** `{templateId:string}`
- **Response:** `{}`

Ссылка перестаёт работать, но `IssueTemplateSubmission` остаётся в журнале проекта.

### `RotateIssueTemplateLink`

- **Доступ:** `templates.manage`; для agent token дополнительно scope `admin`
- **Контракт:** `RotateIssueTemplateLinkRequest → RotateIssueTemplateLinkResponse`
- **Request:** `{templateId:string}`
- **Response:** `{template:IssueTemplate}`

Создаёт новый token и немедленно отзывает старую ссылку.

### `ResolveIssueTemplate`

- **Доступ:** согласно `IssueTemplateAccess`
- **Контракт:** `ResolveIssueTemplateRequest → ResolveIssueTemplateResponse`
- **Request:** `{token:string, password:string}`
- **Response:** `{form:IssueTemplateForm}`

Возвращает только разрешённые поля, подписи и актуальные варианты выбора; внутренние ID конфигурации вне формы не раскрываются. Для `PASSWORD` без пароля возвращается метаинформация формы с `requiresPassword = true`, `accessGranted = false` и пустым `fields`.

### `SubmitIssueTemplate`

- **Доступ:** согласно `IssueTemplateAccess`
- **Контракт:** `SubmitIssueTemplateRequest → SubmitIssueTemplateResponse`
- **Request:** `{token:string, values:IssueTemplateValue[], idempotencyKey:string, password:string}`
- **Response:** `{issue:Issue, issueKey:string, successMessage:string, submission:IssueTemplateSubmission}`

Повтор с тем же `idempotencyKey` возвращает ранее созданную карточку. Клиент может передавать только поля в режимах editable/required; hidden/locked берутся из определения.

### Рабочее время и сигналы

### `CreateBusinessCalendar`

- **Доступ:** admin / owner
- **Контракт:** `CreateBusinessCalendarRequest → CreateBusinessCalendarResponse`
- **Request:** `{projectId:string, name:string, description:string, timezone:string, schedule:BusinessDaySchedule[], exceptions:BusinessCalendarException[]}`
- **Response:** `{calendar:BusinessCalendar}`

`timezone` — IANA-name. `schedule` не получает серверных значений по умолчанию: нужен хотя бы один явно включённый день с непересекающимся интервалом `HH:MM`.

### `UpdateBusinessCalendar`

- **Доступ:** admin / owner
- **Контракт:** `UpdateBusinessCalendarRequest → UpdateBusinessCalendarResponse`
- **Request:** `{calendarId:string, name?:string, description?:string, timezone?:string, schedule:BusinessDaySchedule[], replaceSchedule:boolean, exceptions:BusinessCalendarException[], replaceExceptions:boolean, position?:int32}`
- **Response:** `{calendar:BusinessCalendar}`

Массивы заменяются только с соответствующим `replaceSchedule` или `replaceExceptions`. Отключённый день не хранит интервалы.

### `DeleteBusinessCalendar`

- **Доступ:** admin / owner
- **Контракт:** `DeleteBusinessCalendarRequest → DeleteBusinessCalendarResponse`
- **Request:** `{calendarId:string}`
- **Response:** `{}`

Удаление отклоняется, пока календарь указан хотя бы в одной `TimePolicy`.

### `CreateTimePolicy`

- **Доступ:** admin / owner
- **Контракт:** `CreateTimePolicyRequest → CreateTimePolicyResponse`
- **Request:** `{projectId:string, calendarId:string, name:string, description:string, enabled:boolean, startField:string, durationMinutes:int32, warningMinutes:int32, filter:BoardFilter, stopFilter:BoardFilter}`
- **Response:** `{policy:TimePolicy}`

`startField` принимает `created_at`, `updated_at`, `start_date`, `due_date` или `custom:<FIELD_UUID>` для поля типа `DATE`. `filter` определяет область применения, `stopFilter` — условие остановки. Длительность считается только по интервалам выбранного календаря.

### `UpdateTimePolicy`

- **Доступ:** admin / owner
- **Контракт:** `UpdateTimePolicyRequest → UpdateTimePolicyResponse`
- **Request:** `{policyId:string, calendarId?:string, name?:string, description?:string, enabled?:boolean, startField?:string, durationMinutes?:int32, warningMinutes?:int32, filter:BoardFilter, replaceFilter:boolean, stopFilter:BoardFilter, replaceStopFilter:boolean, position?:int32}`
- **Response:** `{policy:TimePolicy}`

`filter` и `stopFilter` заменяются независимо. Выключение `enabled` сохраняет политику и её ссылки.

### `DeleteTimePolicy`

- **Доступ:** admin / owner
- **Контракт:** `DeleteTimePolicyRequest → DeleteTimePolicyResponse`
- **Request:** `{policyId:string}`
- **Response:** `{}`

Удаление отклоняется, пока `policyId` использует хотя бы одно `SignalRule`.

### `CreateSignalRule`

- **Доступ:** admin / owner
- **Контракт:** `CreateSignalRuleRequest → CreateSignalRuleResponse`
- **Request:** `{projectId:string, name:string, description:string, enabled:boolean, color:string, filter:BoardFilter, timePolicyId:string, policyStatuses:string[]}`
- **Response:** `{rule:SignalRule}`

Правило должно содержать условия `filter` либо ссылку на политику с хотя бы одним `policyStatuses`: `on_track`, `warning`, `breached`, `stopped`. `color` выбирается из редактируемой палитры workspace.

### `UpdateSignalRule`

- **Доступ:** admin / owner
- **Контракт:** `UpdateSignalRuleRequest → UpdateSignalRuleResponse`
- **Request:** `{ruleId:string, name?:string, description?:string, enabled?:boolean, color?:string, filter:BoardFilter, replaceFilter:boolean, timePolicyId?:string, policyStatuses:string[], replacePolicyStatuses:boolean, position?:int32}`
- **Response:** `{rule:SignalRule}`

Пустой `timePolicyId` убирает связь с политикой. Условия и статусы заменяются только при соответствующих `replace... = true`.

### `DeleteSignalRule`

- **Доступ:** admin / owner
- **Контракт:** `DeleteSignalRuleRequest → DeleteSignalRuleResponse`
- **Request:** `{ruleId:string}`
- **Response:** `{}`

Удаляет конфигурацию правила, не меняя карточки и политики времени.

### `UpdateSignalState`

- **Доступ:** write
- **Контракт:** `UpdateSignalStateRequest → UpdateSignalStateResponse`
- **Request:** `{ruleId:string, issueId:string, acknowledged:boolean}`
- **Response:** `{signal:Signal}`

`acknowledged = true` подтверждает текущее совпадение; `false` возвращает его в работу. Состояние хранится независимо от правила и карточки, а действие записывается в аудит.

### Бизнес-действия

### `CreateBusinessAction`

- **Доступ:** admin / owner
- **Контракт:** `CreateBusinessActionRequest → CreateBusinessActionResponse`
- **Request:** `{projectId:string, name:string, description:string, enabled:boolean, color:string, icon:string, confirmationText:string, filter:BoardFilter, inputFields:BusinessActionInputField[], operations:BusinessActionOperation[]}`
- **Response:** `{action:BusinessAction}`

`filter` определяет карточки, на которых видна команда. `inputFields` и `operations` не получают серверных значений по умолчанию; определение должно содержать хотя бы одно поле формы или изменение.

### `UpdateBusinessAction`

- **Доступ:** admin / owner
- **Контракт:** `UpdateBusinessActionRequest → UpdateBusinessActionResponse`
- **Request:** `{actionId:string, name?:string, description?:string, enabled?:boolean, color?:string, icon?:string, confirmationText?:string, filter:BoardFilter, replaceFilter:boolean, inputFields:BusinessActionInputField[], replaceInputFields:boolean, operations:BusinessActionOperation[], replaceOperations:boolean, position?:int32}`
- **Response:** `{action:BusinessAction}`

Фильтр, поля формы и операции заменяются независимо только при соответствующем `replace... = true`. Ссылки операции на `inputKey` проверяются после полной замены формы.

### `DeleteBusinessAction`

- **Доступ:** admin / owner
- **Контракт:** `DeleteBusinessActionRequest → DeleteBusinessActionResponse`
- **Request:** `{actionId:string}`
- **Response:** `{}`

Удаляется только определение. `BusinessActionRun` не имеет каскадной ссылки на него и остаётся доступным в журнале.

### `ExecuteBusinessAction`

- **Доступ:** write
- **Контракт:** `ExecuteBusinessActionRequest → ExecuteBusinessActionResponse`
- **Request:** `{actionId:string, issueId:string, values:BusinessActionValue[]}`
- **Response:** `{issue:Issue, run:BusinessActionRun}`

Сервер повторно проверяет включённую возможность, применимость фильтра, обязательные ответы, типы значений, workflow и ссылки. Все операции, автоматизации, запись журнала и аудит выполняются в одной транзакции; при ошибке карточка не меняется, а отдельная запись запуска получает статус `failed`.

### Вычисляемые поля

### `CreateCalculationField`

- **Доступ:** admin / owner
- **Контракт:** `CreateCalculationFieldRequest → CreateCalculationFieldResponse`
- **Request:** `{projectId:string, name:string, key:string, description:string, enabled:boolean, color:string, objectTypeId?:string, kind:string, resultType:string, operator:string, operands:CalculationOperand[], formula:string, formulaReferences:CalculationFormulaReference[], relationTypeId:string, relationDirection:string, aggregateField:string, aggregation:string, relatedFilter:BoardFilter, decimalPlaces:int32, emptyPolicy:string, prefix:string, suffix:string, separator:string}`
- **Response:** `{field:CalculationField}`

`kind` выбирается явно: новая `formula` использует выражение и стабильные ссылки, `rollup` — связь, направление, агрегацию и `relatedFilter`. Старые определения с `operator` и `operands` продолжают вычисляться и могут быть сохранены через новый редактор. Сервер не подставляет бизнес-поля, константы или готовые формулы.

### `UpdateCalculationField`

- **Доступ:** admin / owner
- **Контракт:** `UpdateCalculationFieldRequest → UpdateCalculationFieldResponse`
- **Request:** `{fieldId:string, name?:string, key?:string, description?:string, enabled?:boolean, color?:string, objectTypeId?:string, kind?:string, resultType?:string, operator?:string, operands:CalculationOperand[], replaceOperands:boolean, formula?:string, formulaReferences:CalculationFormulaReference[], replaceFormulaReferences:boolean, relationTypeId?:string, relationDirection?:string, aggregateField?:string, aggregation?:string, relatedFilter:BoardFilter, replaceRelatedFilter:boolean, decimalPlaces?:int32, emptyPolicy?:string, prefix?:string, suffix?:string, separator?:string, position?:int32}`
- **Response:** `{field:CalculationField}`

Операнды и фильтр связанных карточек заменяются только при соответствующих флагах `replace...`. Пустой `objectTypeId` снимает ограничение по типу объекта. Циклические ссылки между расчётами отклоняются.

### `DeleteCalculationField`

- **Доступ:** admin / owner
- **Контракт:** `DeleteCalculationFieldRequest → DeleteCalculationFieldResponse`
- **Request:** `{fieldId:string}`
- **Response:** `{}`

Удаление отклоняется, если расчёт используется другой формулой, rollup, представлением, фильтром, политикой, сигналом или бизнес-действием. Аналогично нельзя удалить пользовательское поле, тип связи или область объекта, пока они входят в определение расчёта.

### `CreateCalculationConstant`

- **Доступ:** admin / owner
- **Контракт:** `CreateCalculationConstantRequest → CreateCalculationConstantResponse`
- **Request:** `{projectId:string, name:string, key:string, description:string, type:string, value:string}`
- **Response:** `{constant:CalculationConstant}`

`type` равен `number` или `text`. Числовое значение должно быть конечным; текст может быть пустым. Константы не создаются автоматически.

### `UpdateCalculationConstant`

- **Доступ:** admin / owner
- **Контракт:** `UpdateCalculationConstantRequest → UpdateCalculationConstantResponse`
- **Request:** `{constantId:string, name?:string, key?:string, description?:string, type?:string, value?:string, position?:int32}`
- **Response:** `{constant:CalculationConstant}`

Название и значение можно менять без обновления формул. Смена типа отклоняется, пока константа используется, чтобы не сделать зависимые выражения невалидными.

### `DeleteCalculationConstant`

- **Доступ:** admin / owner
- **Контракт:** `DeleteCalculationConstantRequest → DeleteCalculationConstantResponse`
- **Request:** `{constantId:string}`
- **Response:** `{}`

Удаление отклоняется, если стабильная ссылка на константу остаётся хотя бы в одной формуле.

### Управляемые пакеты

### `CreateWorkBatch`

- **Доступ:** admin / owner
- **Контракт:** `CreateWorkBatchRequest → CreateWorkBatchResponse`
- **Request:** `{projectId:string, name:string, description:string, enabled:boolean, color:string, selectionMode:string, filter:BoardFilter, issueIds:string[], executionMode:string, operations:BatchOperation[], maxItems:int32}`
- **Response:** `{batch:WorkBatch}`

`selectionMode` обязан быть `dynamic` или `snapshot`, `executionMode` — `atomic` или `best_effort`. Нужны явно выбранный цвет, лимит от 1 до 1000 и хотя бы одна операция.

### `UpdateWorkBatch`

- **Доступ:** admin / owner
- **Контракт:** `UpdateWorkBatchRequest → UpdateWorkBatchResponse`
- **Request:** `{batchId:string, name?:string, description?:string, enabled?:boolean, color?:string, selectionMode?:string, filter:BoardFilter, replaceFilter:boolean, issueIds:string[], replaceIssueIds:boolean, executionMode?:string, operations:BatchOperation[], replaceOperations:boolean, maxItems?:int32, position?:int32}`
- **Response:** `{batch:WorkBatch}`

Фильтр, снимок ID и операции заменяются независимо только при соответствующем `replace... = true`. Изменение определения обновляет его версию и делает ранее полученный preview непригодным для запуска.

### `DeleteWorkBatch`

- **Доступ:** admin / owner
- **Контракт:** `DeleteWorkBatchRequest → DeleteWorkBatchResponse`
- **Request:** `{batchId:string}`
- **Response:** `{}`

Удаляется только определение. Записи `WorkBatchRun` не имеют каскадной ссылки на пакет и остаются в журнале проекта.

### `PreviewWorkBatch`

- **Доступ:** write
- **Контракт:** `PreviewWorkBatchRequest → PreviewWorkBatchResponse`
- **Request:** `{batchId:string}`
- **Response:** `{preview:WorkBatchPreview}`

Сервер заново получает динамический состав либо читает снимок, проверяет лимит, доступность карточек, workflow и каждую операцию без побочных эффектов. Ответ содержит статус и планируемые изменения каждой карточки, а также `selectionHash`.

### `ExecuteWorkBatch`

- **Доступ:** write
- **Контракт:** `ExecuteWorkBatchRequest → ExecuteWorkBatchResponse`
- **Request:** `{batchId:string, selectionHash:string}`
- **Response:** `{run:WorkBatchRun}`

Перед выполнением сервер повторяет preview. Если изменились определение, состав или `updatedAt` любой карточки, запрос отклоняется. В режиме `atomic` ошибка откатывает весь набор; в режиме `best_effort` каждая карточка выполняется в собственной транзакции, а итог может быть `partial`.

### Маршрутизация

### `CreateRoutingPolicy`

- **Доступ:** admin / owner
- **Контракт:** `CreateRoutingPolicyRequest → CreateRoutingPolicyResponse`
- **Request:** `{projectId:string, name:string, description:string, enabled:boolean, color:string, triggers:AutomationTrigger[], manualEnabled:boolean, filter:BoardFilter, strategy:string, candidates:RoutingCandidate[], loadFilter:BoardFilter, fallbackMode:string, fallbackMemberId:string, terminal:boolean, failureMode:string}`
- **Response:** `{policy:RoutingPolicy}`

Нужен хотя бы один явно выбранный способ запуска и один кандидат. `strategy` выбирается из `first_available`, `least_loaded`, `round_robin`, `weighted_cycle`; сервер не подставляет стратегию, кандидатов, веса, лимиты, цвет, условия или резервное поведение.

### `UpdateRoutingPolicy`

- **Доступ:** admin / owner
- **Контракт:** `UpdateRoutingPolicyRequest → UpdateRoutingPolicyResponse`
- **Request:** `{policyId:string, name?:string, description?:string, enabled?:boolean, color?:string, triggers:AutomationTrigger[], replaceTriggers:boolean, manualEnabled?:boolean, filter:BoardFilter, replaceFilter:boolean, strategy?:string, candidates:RoutingCandidate[], replaceCandidates:boolean, loadFilter:BoardFilter, replaceLoadFilter:boolean, fallbackMode?:string, fallbackMemberId?:string, terminal?:boolean, failureMode?:string, position?:int32}`
- **Response:** `{policy:RoutingPolicy}`

Триггеры, фильтр применимости, кандидаты и фильтр нагрузки заменяются независимо только при своих флагах `replace...`. Неактивный участник остаётся в определении и становится недоступным только при следующем расчёте.

### `DeleteRoutingPolicy`

- **Доступ:** admin / owner
- **Контракт:** `DeleteRoutingPolicyRequest → DeleteRoutingPolicyResponse`
- **Request:** `{policyId:string}`
- **Response:** `{}`

Удаляется только определение. `RoutingRun` не имеет каскадных ссылок на правило, карточку или участника и остаётся в журнале.

### `PreviewRoutingPolicy`

- **Доступ:** write
- **Контракт:** `PreviewRoutingPolicyRequest → PreviewRoutingPolicyResponse`
- **Request:** `{policyId:string, issueId:string}`
- **Response:** `{preview:RoutingPreview}`

Сервер проверяет применимость, активность и настроенную ёмкость каждого кандидата, считает нагрузку отдельным `loadFilter`, применяет выбранную стратегию и показывает резервное решение. Preview не меняет карточку и возвращает `selectionHash`.

### `ExecuteRoutingPolicy`

- **Доступ:** write
- **Контракт:** `ExecuteRoutingPolicyRequest → ExecuteRoutingPolicyResponse`
- **Request:** `{policyId:string, issueId:string, selectionHash:string}`
- **Response:** `{issue:Issue, run:RoutingRun}`

Ручной запуск разрешён только при `manualEnabled = true`. Хеш становится недействительным после изменения правила, карточки, состояния нагрузки, доступности участников или успешной истории циклического распределения. Автоматические правила выполняются по `position` внутри транзакции создания, изменения или перемещения карточки; `terminal` останавливает дальнейшие правила, а `failureMode = block` откатывает внешнюю операцию, сохраняя отказ в журнале.

### Операционные дашборды

### `CreateDashboard`

- **Доступ:** admin / owner
- **Контракт:** `CreateDashboardRequest → CreateDashboardResponse`
- **Request:** `{projectId:string, name:string, description:string, enabled:boolean, color:string, scopeFilter:BoardFilter, widgets:DashboardWidget[], refreshMode:string, refreshSeconds:int32}`
- **Response:** `{dashboard:Dashboard}`

Нужен хотя бы один полностью настроенный виджет. Сервер не подставляет название, цвет, визуализацию, меру, агрегацию, период, размер или режим обновления. `refreshMode` выбирается между `manual` и `interval`; интервал задаётся явно в диапазоне 10–3600 секунд.

### `UpdateDashboard`

- **Доступ:** admin / owner
- **Контракт:** `UpdateDashboardRequest → UpdateDashboardResponse`
- **Request:** `{dashboardId:string, name?:string, description?:string, enabled?:boolean, color?:string, scopeFilter:BoardFilter, replaceScopeFilter:boolean, widgets:DashboardWidget[], replaceWidgets:boolean, refreshMode?:string, refreshSeconds?:int32, position?:int32}`
- **Response:** `{dashboard:Dashboard}`

Общий фильтр и массив виджетов заменяются только при соответствующих флагах. Технические ID существующих виджетов сохраняются, поэтому клиент может сопоставить результаты следующего preview без зависимости от их названий или порядка.

### `DeleteDashboard`

- **Доступ:** admin / owner
- **Контракт:** `DeleteDashboardRequest → DeleteDashboardResponse`
- **Request:** `{dashboardId:string}`
- **Response:** `{}`

Удаляется только определение. Карточки, их поля, связи, расчёты и история не меняются.

### `PreviewDashboard`

- **Доступ:** read
- **Контракт:** `PreviewDashboardRequest → PreviewDashboardResponse`
- **Request:** `{dashboardId:string}`
- **Response:** `{preview:DashboardPreview}`

Сервер применяет общий фильтр, затем фильтр и временное окно каждого виджета. Результат содержит значение, точки или строки и исходные `issueIds` для прозрачного drill-down. Preview доступен наблюдателям, ничего не записывает и блокируется, если возможность либо сам дашборд выключены.

### Интеграции

### `BeginYouGileConnection`

- **Доступ:** admin / owner
- **Контракт:** `BeginYouGileConnectionRequest → BeginYouGileConnectionResponse`
- **Request:** `{projectId:string, login:string, password:string, baseUrl:string}`
- **Response:** `{sessionId:string, companies:YouGileCompany[], expiresAt:Timestamp}`

Проверяет логин и пароль через `POST /api-v2/auth/companies`. Пароль не пишется в базу и остаётся только в короткой серверной сессии подключения.

### `AuthorizeYouGileCompany`

- **Доступ:** admin / owner
- **Контракт:** `AuthorizeYouGileCompanyRequest → AuthorizeYouGileCompanyResponse`
- **Request:** `{projectId:string, sessionId:string, companyId:string}`
- **Response:** `{sessionId:string, projects:YouGileProject[], boards:YouGileBoard[], expiresAt:Timestamp, companyTaskCount?:int32, companyArchivedTaskCount?:int32}`

Выпускает ключ для выбранной компании, сразу удаляет пароль из временной сессии и загружает доступные проекты, доски, колонки и задачи. Счётчики строятся по фактически полученным элементам всех страниц по 20 записей: `paging.count` YouGile описывает текущую страницу и не используется как общий итог. Активные и архивные задачи считаются отдельно. Ключ не возвращается клиенту.

### `FinishYouGileConnection`

- **Доступ:** admin / owner
- **Контракт:** `FinishYouGileConnectionRequest → FinishYouGileConnectionResponse`
- **Request:** `{projectId:string, sessionId:string, boardId:string, name:string, targetBoardId?:string, targetColumnId?:string, limit:int32, sourceScope:string, boardIds:string[]}`
- **Response:** `{integration:IntegrationConnection, preview:IntegrationRunResult, sourceBoard:YouGileBoard, sourceBoards:YouGileBoard[]}`

Создаёт подключение с зашифрованным ключом и выполняет безопасный dry-run выбранной области YouGile. `boardIds` содержит одну или несколько досок; устаревающее поле `boardId` сохраняется для совместимости со старыми клиентами. `sourceScope`: `board`, `project`, `company` или `auto`. Для `board` сервер объединяет задачи всех выбранных досок без дублей. В `auto` сервер использует выбранные доски, если в них есть физические задачи, иначе их проекты, а для пустых проектов — всю компанию. Так сводки и зеркала импортируют оригинальные задачи с их реальными колонками, не имитируя недоступный через API виртуальный фильтр. После успешного preview временная сессия без пароля остаётся доступной 15 минут, чтобы пользователь мог вернуться к выбору и подключить другой набор досок без повторного входа. Реальный импорт запускается отдельным `RunImport`.

### `CreateIntegration`

- **Доступ:** admin / owner
- **Контракт:** `CreateIntegrationRequest → CreateIntegrationResponse`
- **Request:** `{projectId:string, provider:IntegrationProvider, name:string, baseUrl:string, externalProjectId:string, query:string, username:string, token:string, webhookSecret:string, enabled:boolean}`
- **Response:** `{integration:IntegrationConnection}`

`token` и `webhookSecret` сохраняются зашифрованными и не возвращаются. Ответ сообщает только `hasSecret` и `hasWebhookSecret`.

### `UpdateIntegration`

- **Доступ:** admin / owner
- **Контракт:** `UpdateIntegrationRequest → UpdateIntegrationResponse`
- **Request:** `{integrationId:string, name?:string, baseUrl?:string, externalProjectId?:string, query?:string, username?:string, token?:string, webhookSecret?:string, enabled?:boolean}`
- **Response:** `{integration:IntegrationConnection}`

Если secret-поле не передано или передано пустым, сервер сохраняет существующий секрет.

### `DeleteIntegration`

- **Доступ:** admin / owner
- **Контракт:** `DeleteIntegrationRequest → DeleteIntegrationResponse`
- **Request:** `{integrationId:string}`
- **Response:** `{}`

### `TestIntegration`

- **Доступ:** admin / owner
- **Контракт:** `TestIntegrationRequest → TestIntegrationResponse`
- **Request:** `{integrationId:string}`
- **Response:** `{ok:boolean, message:string, integration:IntegrationConnection}`

Проверяет сохранённые реквизиты и обновляет status/lastError подключения.

### `RunImport`

- **Доступ:** admin / owner
- **Контракт:** `RunImportRequest → RunImportResponse`
- **Request:** `{integrationId:string, targetBoardId?:string, targetColumnId?:string, dryRun:boolean, limit:int32}`
- **Response:** `{result:IntegrationRunResult, integration:IntegrationConnection}`

Поддерживает Jira и YouGile. Всегда начинайте с `dryRun = true`. Допустимый `limit` для MCP — 1–1000; значение по умолчанию — 1000.

YouGile читает задачи и пользователей страницами максимум по 20 элементов. Реальный запуск переносит карточки, пользователей, первого доступного исполнителя, автора, чек-листы, иерархию подзадач, зависимости и редактируемые связи. `IntegrationRunResult` возвращает отдельные счётчики этих сущностей. Чаты ставятся в durable-очередь и читаются по 20 сообщений: автор, исходное время, текст и служебные метаданные сохраняются, повторный запуск обновляет сообщения по внешнему ID.

Пока очередь работает, `integration.status = "syncing"`, а `integration.syncProgress` содержит человекочитаемый прогресс. После перезапуска сервера незавершённая очередь продолжается. Безопасные GET-запросы повторяются до пяти раз при сетевом обрыве, timeout, `429` и `5xx`; фоновая очередь дополнительно повторяет неудачную страницу до десяти раз с нарастающей задержкой.

### `SyncIntegration`

- **Доступ:** admin / owner
- **Контракт:** `SyncIntegrationRequest → SyncIntegrationResponse`
- **Request:** `{integrationId:string}`
- **Response:** `{result:IntegrationRunResult, integration:IntegrationConnection}`

Для GitLab ищет ключи карточек в merge requests и обновляет `Issue.externalLinks`.

### `RetryGitLabOutboxAction`

- **Доступ:** admin / owner
- **Контракт:** `RetryGitLabOutboxActionRequest → RetryGitLabOutboxActionResponse`
- **Request:** `{actionId:string}`
- **Response:** `{action:GitLabOutboxAction}`

Возвращает неуспешное действие в очередь немедленного выполнения. Успешное действие повторно не запускается.

### Агенты и токены

Управлять agent principals может только браузерный пользователь с `api.manage`. Сам агент не может создавать, менять или ставить на паузу других агентов даже при admin-token.

### `ListAgents`

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ListAgentsRequest → ListAgentsResponse`
- **Request:** `{workspaceId:string, projectId:string}`
- **Response:** `{agents:Agent[]}`

Возвращает agents рабочего пространства, их роли в указанном проекте, capability policies и токены без секретов.

### `CreateAgent`

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `CreateAgentRequest → CreateAgentResponse`
- **Request:** `{workspaceId:string, projectId:string, name:string, description:string, avatarColor:string, runtime:string, model:string, projectRoleIds:string[], capabilities:AgentCapabilityPolicy[], tokenScopes:string[], tokenExpiresInDays:int32}`
- **Response:** `{agent:Agent, token:AgentToken, secret:string}`

Атомарно создаёт agent principal, его служебного участника, проектные роли и первый токен. В проекте без granular roles базовый Viewer/Editor выводится из capability policy.

### `UpdateAgent`

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `UpdateAgentRequest → UpdateAgentResponse`
- **Request:** `{agentId:string, projectId:string, name?:string, description?:string, avatarColor?:string, status?:string, runtime?:string, model?:string, projectRoleIds:string[], replaceProjectRoles:boolean, capabilities:AgentCapabilityPolicy[], replaceCapabilities:boolean}`
- **Response:** `{agent:Agent}`

Меняет профиль, `active`/`paused` status, роли текущего проекта и capability policy. Каждое изменение увеличивает `configVersion`; пауза блокирует все токены агента со следующего запроса.

### `ListAgentTokens`

- **Доступ:** admin / owner
- **Контракт:** `ListAgentTokensRequest → ListAgentTokensResponse`
- **Request:** `{workspaceId:string, projectId?:string}`
- **Response:** `{tokens:AgentToken[]}`

Секреты не возвращаются. `projectId` нужно передавать как контекст проверки `api.manage`.

### `CreateAgentToken`

- **Доступ:** admin / owner
- **Контракт:** `CreateAgentTokenRequest → CreateAgentTokenResponse`
- **Request:** `{workspaceId:string, name:string, scopes:string[], expiresInDays:int32, projectId?:string, agentId?:string}`
- **Response:** `{token:AgentToken, secret:string}`

`projectId` нужно передавать как контекст проверки `api.manage`. При заданном `agentId` выпускается ещё один токен существующего principal. Без `agentId` старый клиент совместимости создаёт новый first-class Agent. `scopes` содержит `read`, `write` и/или `admin`; `admin` автоматически включает `read` и `write`. Полный secret формата `ab_agent_…` возвращается только один раз.

### `RevokeAgentToken`

- **Доступ:** admin / owner
- **Контракт:** `RevokeAgentTokenRequest → RevokeAgentTokenResponse`
- **Request:** `{tokenId:string, projectId?:string}`
- **Response:** `{}`

`projectId` нужно передавать как контекст проверки `api.manage`. Отзыв действует со следующего API-вызова.

### `ListAgentApprovalReceipts`

Путь: `POST /algoboard.v1.AgentApprovalService/ListAgentApprovalReceipts`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ListAgentApprovalReceiptsRequest → ListAgentApprovalReceiptsResponse`
- **Request:** `{projectId:string, statuses:string[], limit:int32}`
- **Response:** `{approvals:AgentApprovalReceipt[]}`

Возвращает последние receipts проекта, включая pending-запросы и историю решений/исполнений. `statuses` может содержать `pending`, `approved`, `rejected`, `executing`, `executed`, `failed`, `expired`; пустой список означает все статусы. `limit` — 1–100, значение `0` использует серверный default 50. Перед чтением сервер помечает просроченные pending/approved receipts как `expired`.

### `ReviewAgentApprovalReceipt`

Путь: `POST /algoboard.v1.AgentApprovalService/ReviewAgentApprovalReceipt`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ReviewAgentApprovalReceiptRequest → ReviewAgentApprovalReceiptResponse`
- **Request:** `{approvalId:string, projectId:string, decision:string, comment:string}`
- **Response:** `{approval:AgentApprovalReceipt}`

`decision` принимает `approved` или `rejected`. Рассмотреть можно только ещё не истёкший pending receipt. Approve не выполняет сохранённый запрос: Agent должен повторить тот же вызов с `X-Algoboard-Approval: <approvalId>`. Перед handler сервер атомарно переводит receipt в `executing`, а после ответа — в `executed` или `failed`.

### `ListAgentRuns`

Путь: `POST /algoboard.v1.AgentRunService/ListAgentRuns`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ListAgentRunsRequest → ListAgentRunsResponse`
- **Request:** `{projectId:string, agentId:string, statuses:string[], limit:int32}`
- **Response:** `{runs:AgentRun[]}`

Возвращает до 200 последних запусков выбранного проекта и workspace-level вызовы его Agent control plane. `agentId` и `statuses` необязательны; `limit=0` использует default 100. Статусы: `running`, `succeeded`, `failed`, `denied`, `approval_required`. Записи `running`, которые не завершились за две минуты, при чтении переводятся в `failed` как прерванные.

### `ListManagedSkills`

Путь: `POST /algoboard.v1.AgentSkillService/ListManagedSkills`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ListManagedSkillsRequest → ListManagedSkillsResponse`
- **Request:** `{workspaceId:string, projectId:string}`
- **Response:** `{skills:ManagedSkill[]}`

Возвращает workspace-каталог skills в Codex-совместимом формате с последней опубликованной версией. Тот же архив может выполнять app-scoped runner Codex или Claude Code; архив в списке не передаётся.

### `ListManagedSkillVersions`

Путь: `POST /algoboard.v1.AgentSkillService/ListManagedSkillVersions`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ListManagedSkillVersionsRequest → ListManagedSkillVersionsResponse`
- **Request:** `{projectId:string, skillId:string}`
- **Response:** `{versions:ManagedSkillVersion[]}`

Возвращает immutable-историю выбранного skill от самой новой версии к самой старой. Архивы не передаются; любую версию можно отдельно скачать или повторно закрепить за назначением без изменения `latest`.

### `CreateManagedSkill`

Путь: `POST /algoboard.v1.AgentSkillService/CreateManagedSkill`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `CreateManagedSkillRequest → CreateManagedSkillResponse`
- **Request:** `{workspaceId:string, projectId:string, skillMd:string}`
- **Response:** `{skill:ManagedSkill, version:ManagedSkillVersion}`

Создаёт ZIP версии 1 из полного `SKILL.md` и автоматически добавляет `agents/openai.yaml` с `allow_implicit_invocation: false`.

### `UploadManagedSkillVersion`

Путь: `POST /algoboard.v1.AgentSkillService/UploadManagedSkillVersion`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `UploadManagedSkillVersionRequest → UploadManagedSkillVersionResponse`
- **Request:** `{workspaceId:string, projectId:string, skillId?:string, filename:string, archive:bytes}`
- **Response:** `{skill:ManagedSkill, version:ManagedSkillVersion}`

Принимает ZIP до 2 МБ. Проверяются пути, ссылки, размеры распаковки, единственный корневой `SKILL.md`, YAML frontmatter и совпадение папки с `name`. Пустой `skillId` создаёт skill или новую версию существующего slug.

### `DownloadManagedSkillVersion`

Путь: `POST /algoboard.v1.AgentSkillService/DownloadManagedSkillVersion`.

- **Доступ:** пользователь с `api.manage` либо Agent, которому назначена эта версия
- **Контракт:** `DownloadManagedSkillVersionRequest → DownloadManagedSkillVersionResponse`
- **Request:** `{projectId:string, versionId:string}`
- **Response:** `{skill:ManagedSkill, version:ManagedSkillVersion, filename:string, archive:bytes}`

Agent может скачать только pinned-версию собственного активного назначения в проекте.

### `ListAgentSkillAssignments`

Путь: `POST /algoboard.v1.AgentSkillService/ListAgentSkillAssignments`.

- **Доступ:** пользователь с `api.manage` либо текущий Agent
- **Контракт:** `ListAgentSkillAssignmentsRequest → ListAgentSkillAssignmentsResponse`
- **Request:** `{projectId:string, agentId?:string}`
- **Response:** `{assignments:AgentSkillAssignment[]}`

Для Agent `agentId` принудительно ограничивается его собственной identity.

### `UpsertAgentSkillAssignment`

Путь: `POST /algoboard.v1.AgentSkillService/UpsertAgentSkillAssignment`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `UpsertAgentSkillAssignmentRequest → UpsertAgentSkillAssignmentResponse`
- **Request:** `{projectId:string, agentId:string, skillVersionId:string, command:string, approvalPolicy:string, enabled:bool, configJson:string}`
- **Response:** `{assignment:AgentSkillAssignment}`

Закрепляет точную версию и slash-command за агентом. `approvalPolicy` принимает `allow`, `ask`, `deny`; `configJson` должен быть JSON-объектом.

### `DeleteAgentSkillAssignment`

Путь: `POST /algoboard.v1.AgentSkillService/DeleteAgentSkillAssignment`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `DeleteAgentSkillAssignmentRequest → DeleteAgentSkillAssignmentResponse`
- **Request:** `{projectId:string, assignmentId:string}`
- **Response:** `{}`

Отключает назначение, отменяет ещё не запущенные invocations и скрывает slash-команду, но сохраняет skill, версии и полную историю.

### `InvokeAgentSkill`

Путь: `POST /algoboard.v1.AgentSkillService/InvokeAgentSkill`.

- **Доступ:** `chat.send`, только пользователь
- **Контракт:** `InvokeAgentSkillRequest → InvokeAgentSkillResponse`
- **Request:** `{projectId:string, conversationId:string, clientRequestId:string, command:string, arguments:string, threadRootId?:string}`
- **Response:** `{invocation:AgentSkillInvocation, message:ChatMessage, duplicate:bool, runnerOnline:bool}`

Создаёт обычное сообщение `/<command> <arguments>` и app-only invocation. Команда разрешается только среди активных Agent principal текущего разговора; повторный `clientRequestId` возвращает тот же запуск. `runnerOnline=false` позволяет интерфейсу сразу предупредить, что invocation останется в очереди до подключения локального runner.

### `ListAgentSkillInvocations`

Путь: `POST /algoboard.v1.AgentSkillService/ListAgentSkillInvocations`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ListAgentSkillInvocationsRequest → ListAgentSkillInvocationsResponse`
- **Request:** `{projectId:string, conversationId?:string, agentId?:string, statuses:string[], limit:int32}`
- **Response:** `{invocations:AgentSkillInvocation[]}`

Статусы: `pending_approval`, `queued`, `running`, `succeeded`, `failed`, `rejected`, `cancelled`.

### `ReviewAgentSkillInvocation`

Путь: `POST /algoboard.v1.AgentSkillService/ReviewAgentSkillInvocation`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ReviewAgentSkillInvocationRequest → ReviewAgentSkillInvocationResponse`
- **Request:** `{projectId:string, invocationId:string, decision:string, comment:string}`
- **Response:** `{invocation:AgentSkillInvocation}`

`decision` принимает `approved` или `rejected`. Одобрение атомарно переводит pending invocation в очередь runner.

### `ListRunnableAgentSkillInvocations`

Путь: `POST /algoboard.v1.AgentSkillService/ListRunnableAgentSkillInvocations`.

- **Доступ:** только Agent token со scope `read`
- **Контракт:** `ListRunnableAgentSkillInvocationsRequest → ListRunnableAgentSkillInvocationsResponse`
- **Request:** `{projectId:string, limit:int32}`
- **Response:** `{invocations:AgentSkillInvocation[]}`

Возвращает только queued invocations текущего Agent principal. Перед чтением сервер восстанавливает его `running` invocations с просроченным lease: активное назначение возвращается в очередь, отключённое отменяется, а запуск с тремя исчерпанными попытками завершается статусом `failed`.

### `ClaimAgentSkillInvocation`

Путь: `POST /algoboard.v1.AgentSkillService/ClaimAgentSkillInvocation`.

- **Доступ:** только Agent token со scope `write`
- **Контракт:** `ClaimAgentSkillInvocationRequest → ClaimAgentSkillInvocationResponse`
- **Request:** `{projectId:string, invocationId:string, runnerId:string}`
- **Response:** `{invocation:AgentSkillInvocation, delegationToken:string}`

Атомарно переводит запуск из `queued` в `running`, увеличивает `attemptCount` и выдаёт двухминутный lease в `heartbeatAt` / `leaseExpiresAt`; повторный claim другого runner отклоняется. `delegationToken` предназначен только для встроенного managed MCP и наследует текущие права `requestedByMemberId`; постоянный connector token в дочерний runtime не передаётся.

### `HeartbeatAgentSkillInvocation`

Путь: `POST /algoboard.v1.AgentSkillService/HeartbeatAgentSkillInvocation`.

- **Доступ:** только Agent token со scope `write`
- **Контракт:** `HeartbeatAgentSkillInvocationRequest → HeartbeatAgentSkillInvocationResponse`
- **Request:** `{projectId:string, invocationId:string, runnerId:string}`
- **Response:** `{invocation:AgentSkillInvocation}`

Продлевает lease ещё на две минуты только для текущего `runnerId`. После возврата запуска в очередь старый runner получает conflict и больше не может heartbeat или finish эту попытку.

### `FinishAgentSkillInvocation`

Путь: `POST /algoboard.v1.AgentSkillService/FinishAgentSkillInvocation`.

- **Доступ:** только Agent token со scope `write`
- **Контракт:** `FinishAgentSkillInvocationRequest → FinishAgentSkillInvocationResponse`
- **Request:** `{projectId:string, invocationId:string, runnerId:string, succeeded:bool, output:string, errorMessage:string}`
- **Response:** `{invocation:AgentSkillInvocation}`

Завершить running invocation может только runner, который его занял. Terminal transition освобождает lease. Сервер ограничивает сохраняемый output последними 100 000 символами.

### `ListAgentConnectors`

Путь: `POST /algoboard.v1.AgentSkillService/ListAgentConnectors`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `ListAgentConnectorsRequest → ListAgentConnectorsResponse`
- **Request:** `{projectId:string, agentId?:string}`
- **Response:** `{connectors:AgentConnector[]}`

Возвращает локальные подключения проекта. `status`, `mcpOnline` и `runnerOnline` вычисляются по отдельным heartbeat; presence считается online 90 секунд после последнего сигнала.

### `CreateAgentConnectorPairing`

Путь: `POST /algoboard.v1.AgentSkillService/CreateAgentConnectorPairing`.

- **Доступ:** `api.manage`, только пользователь
- **Контракт:** `CreateAgentConnectorPairingRequest → CreateAgentConnectorPairingResponse`
- **Request:** `{workspaceId:string, projectId:string, agentId:string, scopes:string[]}`
- **Response:** `{pairing:AgentConnectorPairing, code:string, connectCommand:string}`

Создаёт высокоэнтропийный одноразовый код на 10 минут. Сервер хранит только SHA-256 hash. Запрошенные scopes не могут обходить policy выбранного Agent.

### `ExchangeAgentConnectorPairing`

Путь: `POST /algoboard.v1.AgentSkillService/ExchangeAgentConnectorPairing`.

- **Доступ:** публичный RPC с одноразовым pairing code
- **Контракт:** `ExchangeAgentConnectorPairingRequest → ExchangeAgentConnectorPairingResponse`
- **Request:** `{code:string, name:string, runtime:string, version:string, hostname:string, platform:string, projectDir:string}`
- **Response:** `{connector:AgentConnector, token:AgentToken, secret:string, apiUrl:string}`

Атомарно помечает pairing использованным, создаёт отдельный отзываемый Agent token и связывает его с connector. Повторный, просроченный или неизвестный code возвращает одинаковую unauthenticated-ошибку. `secret` возвращается один раз.

### `GetAgentConnector`

Путь: `POST /algoboard.v1.AgentSkillService/GetAgentConnector`.

- **Доступ:** токен именно этого connector либо пользователь с `api.manage`
- **Контракт:** `GetAgentConnectorRequest → GetAgentConnectorResponse`
- **Request:** `{projectId:string, connectorId:string}`
- **Response:** `{connector:AgentConnector}`

Даже другой действующий token того же Agent не может присвоить connector и читать его локальную metadata.

### `HeartbeatAgentConnector`

Путь: `POST /algoboard.v1.AgentSkillService/HeartbeatAgentConnector`.

- **Доступ:** токен именно этого connector, scope `read`
- **Контракт:** `HeartbeatAgentConnectorRequest → HeartbeatAgentConnectorResponse`
- **Request:** `{projectId:string, connectorId:string, component:string, runtime:string, version:string, hostname:string, platform:string, projectDir:string}`
- **Response:** `{connector:AgentConnector}`

`component` принимает `mcp` или `runner`. Раздельные timestamps не позволяют одному локальному процессу затереть presence другого; heartbeat не заполняет execution ledger.

### `DisconnectAgentConnector`

Путь: `POST /algoboard.v1.AgentSkillService/DisconnectAgentConnector`.

- **Доступ:** токен именно этого connector либо пользователь с `api.manage`
- **Контракт:** `DisconnectAgentConnectorRequest → DisconnectAgentConnectorResponse`
- **Request:** `{projectId:string, connectorId:string}`
- **Response:** `{connector:AgentConnector}`

Идемпотентно переводит connector в `disconnected` и в той же транзакции отзывает связанный Agent token. История connector остаётся видимой владельцу проекта.

### `ManagedSkill`

```text
{id, workspaceId, slug, name, description, status, latestVersion,
 createdByMemberId, createdAt, updatedAt}
```

### `ManagedSkillVersion`

```text
{id, skillId, version, archiveSha256, archiveSize, skillMd,
 validationWarnings, createdByMemberId, createdAt}
```

### `AgentSkillAssignment`

```text
{id, workspaceId, projectId, agentId, skillId, skillVersionId,
 command, approvalPolicy, enabled, configJson, skill, skillVersion, agent,
 createdAt, updatedAt}
```

### `AgentSkillInvocation`

```text
{id, workspaceId, projectId, conversationId, messageId, assignmentId,
 agentId, skillId, skillVersionId, command, arguments, status,
 requestedByMemberId, reviewedByMemberId, reviewComment, runnerId,
 attemptCount, output, errorMessage, assignment, requestedAt, reviewedAt,
 startedAt, heartbeatAt, leaseExpiresAt, finishedAt, updatedAt}
```

### `AgentConnector`

```text
{id, workspaceId, projectId, agentId, tokenId, name, runtime, version,
 hostname, platform, projectDir, status, mcpOnline, runnerOnline,
 lastSeenAt, lastMcpHeartbeatAt, lastRunnerHeartbeatAt, disconnectedAt,
 createdAt, updatedAt}
```

### `AgentConnectorPairing`

```text
{id, workspaceId, projectId, agentId, scopes, expiresAt, usedAt, createdAt}
```

## Модели данных

### Правила protobuf JSON

- Proto `snake_case` превращается в JSON `lowerCamelCase`.
- `google.protobuf.Timestamp` — RFC 3339 UTC, например `"2026-07-25T14:30:00Z"`.
- `bytes` — Base64-строка.
- `int64` в protobuf JSON передаётся строкой, чтобы JavaScript не терял точность.
- Enum рекомендуется передавать символическим именем. Числовые значения поддерживаются для совместимости.
- Неустановленное optional-поле отсутствует в JSON. Обычное поле с default-значением также может отсутствовать в ответе.
- Календарные поля `startDate`, `endDate` и `dueDate` — строки `YYYY-MM-DD`, а не Timestamp.
- UUID всегда передаётся строкой. Используйте ID из свежего `GetAppState`, не угадывайте их.

### Основные сущности

#### `Workspace`

```text
{
  id:string,
  name:string,
  timezone:string,
  defaultSprintDays:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `ColorStyle`

```text
{
  id:string,
  workspaceId:string,
  name:string,
  key:string,
  value:string,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

`value` хранит редактируемый HEX, а стабильный `key` используется в цветовых полях остальных сущностей.

#### `Member`

```text
{
  id:string,
  workspaceId:string,
  name:string,
  email:string,
  role:MemberRole,
  avatarColor:string,
  active:boolean,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  avatarData:bytes,
  avatarContentType:string,
  accountLogin:string,
  principalType:string
}
```

`avatarData` приходит Base64-строкой. Пароль и его hash никогда не входят в API-ответ.

#### `PersonnelFieldDefinition`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  type:string,
  options:string[],
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `PersonnelFieldValue`

```text
{fieldId:string, value:string}
```

#### `MemberProjectProfile`

```text
{
  projectId:string,
  memberId:string,
  businessCalendarId?:string,
  values:PersonnelFieldValue[],
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `AuthSession`

```text
{
  authenticated:boolean,
  member?:Member,
  workspace?:Workspace,
  expiresAt?:Timestamp
}
```

#### `Project`

```text
{
  id:string,
  workspaceId:string,
  name:string,
  key:string,
  color:string,
  archived:boolean,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `ProjectCapability`

```text
{
  id:string,
  projectId:string,
  key:string,
  name:string,
  description:string,
  icon:string,
  color:string,
  enabled:boolean,
  position:int32,
  configured:boolean,
  createdAt?:Timestamp,
  updatedAt?:Timestamp
}
```

`configured:false` означает, что проект ещё не сохранил собственную конфигурацию этой возможности. Для нового проекта все опциональные возможности возвращаются выключенными. Начальные названия служат редактируемым описанием доступных движков, а не создают объекты, поля или правила.

#### `BoardColumn`

```text
{
  id:string,
  projectId:string,
  name:string,
  position:int32,
  wipLimit:int32,
  category:ColumnCategory,
  objectTypeId?:string
}
```

#### `ObjectType`

```text
{
  id:string,
  projectId:string,
  name:string,
  pluralName:string,
  key:string,
  description:string,
  icon:string,
  color:string,
  titleLabel:string,
  position:int32,
  archived:boolean,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `ObjectTypeField`

```text
{
  objectTypeId:string,
  fieldId:string,
  label:string,
  description:string,
  required:boolean,
  showOnCard:boolean,
  visibleOnCreate:boolean,
  readOnly:boolean,
  position:int32,
  defaultValue:string
}
```

#### `RelationType`

```text
{
  id:string,
  projectId:string,
  sourceObjectTypeId:string,
  targetObjectTypeId:string,
  name:string,
  reverseName:string,
  key:string,
  cardinality:RelationCardinality,
  color:string,
  position:int32,
  archived:boolean,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `ObjectRelation`

```text
{
  id:string,
  projectId:string,
  relationTypeId:string,
  sourceIssueId:string,
  targetIssueId:string,
  createdAt:Timestamp
}
```

#### `Sprint`

```text
{
  id:string,
  projectId:string,
  name:string,
  goal:string,
  status:SprintStatus,
  startDate:string,
  endDate:string,
  issueCount:int32,
  storyPoints:int32,
  completedPoints:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  completedAt?:Timestamp
}
```

#### `Label`

```text
{id:string, projectId:string, name:string, color:string}
```

### Карточка и журнал

#### `Issue`

```text
{
  id:string,
  projectId:string,
  columnId:string,
  sprintId?:string,
  assigneeId?:string,
  reporterId?:string,
  number:int32,
  key:string,
  title:string,
  description:string,
  type:IssueType,
  priority:IssuePriority,
  storyPoints:int32,
  position:int32,
  labels:Label[],
  comments:Comment[],
  archived:boolean,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  completedAt?:Timestamp,
  startDate?:string,
  dueDate?:string,
  baselineStartDate?:string,
  baselineDueDate?:string,
  progressPercent:int32,
  actualStartDate?:string,
  actualFinishDate?:string,
  milestone:boolean,
  deadlineDate?:string,
  parentId?:string,
  dependencyIds:string[],
  timelineDependencies:TimelineDependency[],
  customFieldValues:CustomFieldValue[],
  blockedReason:string,
  externalLinks:ExternalLink[],
  objectTypeId?:string,
  checklists:IssueChecklist[],
  timelinePosition:int32,
  commentCount:int32
}
```

`dependencyIds` — карточки, завершения которых текущая карточка ждёт. `parentId` задаёт родительскую карточку; сервер запрещает циклы и связи между проектами.

#### `TimelineDependency`

```text
{
  predecessorIssueId:string,
  type:TimelineDependencyType,
  lagDays:int32
}
```

#### `TimelineDependencyInput`

```text
{
  predecessorIssueId:string,
  type:TimelineDependencyType,
  lagDays:int32
}
```

`lagDays` ограничен диапазоном от −365 до 365 и считается по рабочему календарю
исполнителя последующей карточки.

#### `CustomFieldInput`

```text
{fieldId:string, value:string}
```

#### `CustomFieldValue`

```text
{fieldId:string, issueId:string, value:string}
```

#### `IssueChecklist`

```text
{
  id:string,
  issueId:string,
  title:string,
  position:int32,
  items:IssueChecklistItem[]
}
```

#### `IssueChecklistItem`

```text
{id:string, checklistId:string, title:string, completed:boolean, position:int32}
```

#### `IssueChecklistInput`

```text
{id?:string, title:string, items:IssueChecklistItemInput[]}
```

#### `IssueChecklistItemInput`

```text
{id?:string, title:string, completed:boolean}
```

При `replaceChecklists = true` сервер полностью синхронизирует чек-листы карточки; пустой массив удаляет их.

#### `Comment`

```text
{
  id:string,
  issueId:string,
  authorId:string,
  authorName:string,
  body:string,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  source:string,
  metadataJson:string
}
```

#### `ActivityEvent`

```text
{
  id:string,
  projectId:string,
  issueId?:string,
  actorId?:string,
  actorName:string,
  kind:string,
  summary:string,
  createdAt:Timestamp
}
```

#### `ExternalLink`

```text
{
  id:string,
  issueId:string,
  provider:IntegrationProvider,
  kind:string,
  externalId:string,
  title:string,
  url:string,
  status:string,
  updatedAt:Timestamp
}
```

### Доски и процесс

#### `BoardFilterRule`

```text
{field:string, operator:BoardFilterOperator, values:string[]}
```

#### `BoardSwimlaneCondition`

```text
{
  rules:BoardFilterRule[],
  queryDsl:string,
  queryReferences:CalculationFormulaReference[]
}
```

Быстрые `rules` и условие `queryDsl` одной дорожки объединяются по `and`.
`queryReferences` сохраняют стабильные привязки полей, использованных в DSL.

#### `BoardSwimlaneRule`

```text
{
  id:string,
  name:string,
  color:string,
  condition:BoardSwimlaneCondition
}
```

Правила проверяются сверху вниз: карточка попадает в первую подходящую дорожку.

#### `BoardSwimlaneConfig`

```text
{
  mode:BoardSwimlaneMode,
  field:string,
  rules:BoardSwimlaneRule[],
  showUnmatched:boolean,
  showEmpty:boolean
}
```

В режиме `FIELD` сервер группирует карточки по `field`. В режиме `RULES`
использует упорядоченные `rules`. `showUnmatched` добавляет дорожку для карточек,
не прошедших ни одно правило, а `showEmpty` сохраняет в ответе пустые дорожки.

#### `BoardFilter`

```text
{
  query:string,
  assigneeIds:string[],
  labelIds:string[],
  priorities:IssuePriority[],
  sprintId?:string,
  onlyUnassigned:boolean,
  onlyOverdue:boolean,
  includeCompleted:boolean,
  rules:BoardFilterRule[],
  visibleColumnIds:string[],
  cardFieldKeys:string[],
  cardFieldsConfigured:boolean,
  queryDsl:string,
  queryReferences:CalculationFormulaReference[],
  swimlanes:BoardSwimlaneConfig
}
```

`rules` соединяются по AND. Несколько `values` внутри одного правила соединяются по OR.
`queryDsl` хранит только логическое условие, например
`[Приоритет] >= 3 and [Срок] < @today`; служебные `select Карточка`,
`from Карточки` и `limit 500` добавляет сервер. `queryReferences` фиксируют
привязку читаемых имён к стабильным источникам. Если заполнены и `queryDsl`, и
быстрые параметры фильтра, они применяются вместе по правилу `and`.

#### `SavedBoard`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  filter:BoardFilter,
  isDefault:boolean,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  parentId?:string,
  position:int32,
  color:string,
  defaultAccess:string,
  accessRules:BoardAccessRule[],
  subscriptionsEnabled:boolean,
  currentAccess:string
}
```

#### `BoardAccessRule`

```text
{
  subjectType:string,
  subjectId:string,
  access:string
}
```

`subjectType` — `member` или `group`; `access` — `none`, `view`, `edit` или `manage`. Порядок элементов определяет приоритет.

#### `AccessGroup`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  color:string,
  memberIds:string[],
  roleIds:string[],
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `PermissionDefinition`

```text
{key:string, category:string, name:string, description:string}
```

#### `ProjectRole`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  color:string,
  permissions:string[],
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `MemberProjectRole`

```text
{
  projectId:string,
  memberId:string,
  roleId:string,
  createdAt:Timestamp
}
```

#### `BoardSubscription`

```text
{
  id:string,
  projectId:string,
  boardId:string,
  memberId:string,
  enabled:boolean,
  eventKinds:string[],
  includeOwn:boolean,
  lastReadAt?:Timestamp,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `BoardNotification`

```text
{
  subscriptionId:string,
  boardId:string,
  activityId:string,
  issueId:string,
  actorId:string,
  kind:string,
  summary:string,
  read:boolean,
  occurredAt:Timestamp
}
```

#### `MemberNotification`

```text
{
  id:string,
  projectId:string,
  memberId:string,
  actorId:string,
  issueId:string,
  commentId:string,
  kind:string,
  summary:string,
  read:boolean,
  createdAt:Timestamp
}
```

#### `BoardFilterCard`

```text
{
  id:string,
  boardId:string,
  name:string,
  filter:BoardFilter,
  position:int32,
  color:string,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  personal:boolean
}
```

`personal` не раскрывает идентификатор владельца. Если значение истинно, объект уже
отфильтрован сервером для текущего участника и недоступен другим пользователям даже по ID.

#### `WorkflowRule`

```text
{
  id:string,
  projectId:string,
  fromColumnId:string,
  toColumnId:string,
  enabled:boolean,
  allowedRoles:MemberRole[],
  allowedProjectRoleIds:string[],
  requireAssignee:boolean,
  requireStoryPoints:boolean,
  requireSprint:boolean,
  requiredCustomFieldIds:string[],
  requiredSystemFields:string[],
  updatedAt:Timestamp
}
```

#### `CustomFieldDefinition`

```text
{
  id:string,
  projectId:string,
  name:string,
  key:string,
  type:CustomFieldType,
  required:boolean,
  options:string[],
  position:int32,
  description:string,
  showOnCard:boolean
}
```

#### `FieldPermission`

```text
{
  projectId:string,
  fieldKind:string,
  fieldId:string,
  viewRoleIds:string[],
  editRoleIds:string[],
  configured:boolean,
  canView:boolean,
  canEdit:boolean,
  selfView:boolean
}
```

`fieldKind` равен `system`, `custom`, `personnel` или `calculation`. `configured:false` означает открытый доступ для всех ролей. Для обычного участника `canView` и `canEdit` содержат уже вычисленный эффективный доступ, а списки ролей не раскрываются. Запись системного поля остаётся в `fieldPermissions` даже при `canView:false`, чтобы клиент мог убрать известный системный контрол из интерфейса; значение поля при этом не передаётся. `selfView` относится только к полям персонала и даёт участнику просмотр собственного значения. Пользователь с `access.manage` получает конфигурацию ролей и все определения полей, чтобы управлять матрицей доступа.

### Wiki и автоматизации

#### `WikiArticle`

```text
{
  id:string,
  projectId:string,
  parentId?:string,
  title:string,
  body:string,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `AutomationAction`

```text
{
  type:AutomationActionType,
  targetId?:string,
  value:string,
  config:map<string,string>,
  queryDsl:string,
  queryReferences:CalculationFormulaReference[],
  fieldMutation:AutomationFieldMutation
}
```

Для `SET_ASSIGNEE` можно передать либо фиксированный `targetId`, либо `queryDsl` вида
`select Сотрудник from Сотрудники … limit 1`; одновременно задавать их нельзя.
`queryReferences` сохраняют проверенную сервером привязку полей сотрудника и
`Карточка`-контекста.

#### `AutomationFieldMutation`

```text
{
  operation:AutomationFieldOperation,
  target:CalculationFormulaReference,
  formula:string,
  formulaReferences:CalculationFormulaReference[]
}
```

Используется только при `AutomationAction.type = MUTATE_FIELD`. `target.source` содержит
стабильный ключ системного поля, `custom:<FIELD_UUID>` или путь к полю персонала:
`member_field:assignee:<PERSONNEL_FIELD_UUID>` /
`member_field:<MEMBER_FIELD_UUID>:<PERSONNEL_FIELD_UUID>`. Сервер проверяет права,
существование и тип цели при сохранении правила. `formulaReferences` связывают подписи в
формуле со стабильными источниками; переименование поля не ломает правило.

#### `AutomationEventFilter`

```text
{
  actions:string[],
  statuses:string[],
  sourceBranchPattern:string,
  targetBranchPattern:string,
  refPattern:string,
  environmentPattern:string,
  jobNamePattern:string,
  labels:string[],
  changedFields:string[],
  conditionFormula:string,
  conditionFormulaReferences:CalculationFormulaReference[]
}
```

GitLab-триггеры используют поля события GitLab. Локальный `ISSUE_UPDATED` использует
`changedFields`; пустой список принимает любое фактическое изменение, а непустой — любое
совпавшее поле. Непустая `conditionFormula` дополнительно должна вернуть `true`;
`conditionFormulaReferences` содержат её стабильные ссылки и исполняются тем же
серверным движком выражений.

### `CountBoardFilters`

- **Доступ:** `issue.view`
- **Контракт:** `CountBoardFiltersRequest → CountBoardFiltersResponse`
- **Request:** `{projectId:string, queries:BoardFilterCountQuery[]}`
- **Response:** `{results:BoardFilterCountResult[]}`

Считает несколько общих фильтров доски за один вызов и на одном снимке проекта.
Каждый `BoardFilterCountQuery` содержит `{key:string, query:string,
references:CalculationFormulaReference[], cardFilter:BoardFilter,
parameterValues:QueryParameterValue[]}`. Сервер возвращает соответствующий
`BoardFilterCountResult` в виде `{key:string, totalCount:int32, error:string}`;
ошибка одного фильтра не отменяет корректные результаты остальных. Запросы этого
метода должны использовать источник `Карточки`. За один вызов принимается не более
100 фильтров.

### Результат DSL-запроса

#### `QueryColumn`

```text
{label:string, source:string, type:string}
```

#### `QueryValue`

```text
{raw:string, display:string, type:string, present:boolean}
```

#### `QueryRow`

```text
{card:Issue, employee:Member, values:QueryValue[], swimlaneId:string}
```

`swimlaneId` связывает строку с одной из дорожек ответа. Классификация выполняется
до пагинации, поэтому счётчики дорожек относятся ко всей выборке.

#### `BoardSwimlaneResult`

```text
{
  id:string,
  name:string,
  color:string,
  totalCount:int32,
  unmatched:boolean,
  value:string
}
```

`value` используется интерфейсом для безопасного переноса карточки между дорожками,
построенными по редактируемому полю. Для дорожек по правилам оно остаётся пустым.

#### `QueryParameterValue`

```text
{name:string, value:string}
```

Значение параметра одного запуска. `name` принимается с начальным `$` или без него;
`value` может быть непосредственным значением нужного типа либо выражением вроде
`@lastMonth`, `last(14d)` или `range("2026-01-01", "2026-03-31")`.

#### `QueryParameterDefinition`

```text
{
  name:string,
  type:string,
  valueType:string,
  defaultValue:string,
  resolved:QueryValue
}
```

Описывает объявленный параметр и его фактически разрешённое значение. `type` — тип
управления (`period`, `member`, `number` и так далее), `valueType` — тип значения
движка, а `resolved` учитывает переданное значение либо выражение по умолчанию.

#### `QueryResolvedContext`

```text
{name:string, type:string, raw:string, display:string, present:boolean}
```

Фиксирует фактическое значение использованного системного контекста `@…` для конкретного
запуска. Это позволяет интерфейсу показать, какие даты, сотрудник, спринт или календарь
были применены.

#### `QueryExplainStep`

```text
{kind:string, label:string, inputCount:int32, outputCount:int32}
```

Динамический шаг плана выполнения. `inputCount` и `outputCount` показывают реальное
изменение количества строк на источнике, серверном фильтре, `explode`, `where`,
группировке, `distinct` и `limit`.

#### `RunQueryResponse`

```text
{
  columns:QueryColumn[],
  rows:QueryRow[],
  totalCount:int32,
  nextPageToken:string,
  references:CalculationFormulaReference[],
  normalizedQuery:string,
  parameters:QueryParameterDefinition[],
  resolvedContexts:QueryResolvedContext[],
  explain:QueryExplainStep[],
  swimlanes:BoardSwimlaneResult[]
}
```

`RunQueryRequest` содержит `projectId`, `query`, `references`, `cardFilter`,
необязательный `contextCardId`, `pageSize`, `pageToken` и
`parameterValues:QueryParameterValue[]`. Пустой entity в `QueryRow` не сериализуется
protobuf JSON.

#### Пакетные счётчики доски

```text
BoardFilterCountQuery {
  key:string,
  query:string,
  references:CalculationFormulaReference[],
  cardFilter:BoardFilter,
  parameterValues:QueryParameterValue[]
}

CountBoardFiltersRequest {
  projectId:string,
  queries:BoardFilterCountQuery[]
}

BoardFilterCountResult {
  key:string,
  totalCount:int32,
  error:string
}

CountBoardFiltersResponse {
  results:BoardFilterCountResult[]
}
```

#### `AutomationRule`

```text
{
  id:string,
  projectId:string,
  name:string,
  enabled:boolean,
  trigger:AutomationTrigger,
  fromColumnId?:string,
  toColumnId?:string,
  integrationId?:string,
  eventFilter:AutomationEventFilter,
  actions:AutomationAction[],
  runCount:int64,
  lastRunAt?:Timestamp,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

В JSON `runCount` приходит строкой.

#### `AutomationPreviewChange`

```text
{field:string, before:string, after:string}
```

`field` использует системные ключи `column`, `assignee`, `priority`, `sprint`, `labels`, `blocked_reason`, `comment` или `custom:<FIELD_UUID>`. Значения ID берутся из того же снимка проекта.

#### `AutomationRun`

```text
{
  id:string,
  projectId:string,
  ruleId:string,
  ruleName:string,
  issueId:string,
  issueKey:string,
  trigger:AutomationTrigger,
  status:string,
  dryRun:boolean,
  actionCount:int32,
  changes:AutomationPreviewChange[],
  errorMessage:string,
  startedAt:Timestamp,
  finishedAt:Timestamp
}
```

Журнал хранит копию `ruleName` и `issueKey`, поэтому остаётся читаемым после удаления правила или карточки.

#### `IssueTemplateFieldOption`

```text
{value:string, label:string}
```

#### `IssueTemplateField`

```text
{
  key:string,
  label:string,
  description:string,
  mode:IssueTemplateFieldMode,
  defaultValues:string[],
  placeholder:string,
  position:int32,
  inputType:string,
  options:IssueTemplateFieldOption[]
}
```

#### `IssueTemplate`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  enabled:boolean,
  access:IssueTemplateAccess,
  objectTypeId?:string,
  columnId?:string,
  fields:IssueTemplateField[],
  successMessage:string,
  shareUrl:string,
  expiresAt?:Timestamp,
  submissionLimit:int32,
  submissionCount:int64,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  accessGroupIds:string[],
  hasPassword:boolean
}
```

#### `IssueTemplateForm`

```text
{
  templateId:string,
  name:string,
  description:string,
  projectName:string,
  projectColor:string,
  objectTypeName:string,
  fields:IssueTemplateField[],
  successMessage:string,
  requiresAuth:boolean,
  expiresAt?:Timestamp,
  access:IssueTemplateAccess,
  requiresPassword:boolean,
  accessGranted:boolean
}
```

#### `IssueTemplateValue`

```text
{key:string, values:string[]}
```

#### `IssueTemplateSubmission`

```text
{
  id:string,
  projectId:string,
  templateId:string,
  templateName:string,
  issueId:string,
  issueKey:string,
  idempotencyKey:string,
  submitterMemberId:string,
  status:string,
  errorMessage:string,
  submittedAt:Timestamp
}
```

`submissionCount` — protobuf `int64`, поэтому в JSON приходит строкой.

### Рабочее время и сигналы

#### `BusinessTimeInterval`

```text
{start:string, end:string}
```

`start` и `end` используют `HH:MM`; начало должно быть раньше конца.

#### `BusinessDaySchedule`

```text
{weekday:int32, enabled:boolean, intervals:BusinessTimeInterval[]}
```

`weekday` использует ISO-нумерацию 1–7, где 1 — понедельник.

#### `BusinessCalendarException`

```text
{date:string, name:string, working:boolean, intervals:BusinessTimeInterval[]}
```

`date` — `YYYY-MM-DD`. Для нерабочего исключения `intervals` пуст; для рабочего нужен хотя бы один интервал.

#### `BusinessCalendar`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  timezone:string,
  schedule:BusinessDaySchedule[],
  exceptions:BusinessCalendarException[],
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `TimePolicy`

```text
{
  id:string,
  projectId:string,
  calendarId:string,
  name:string,
  description:string,
  enabled:boolean,
  startField:string,
  durationMinutes:int32,
  warningMinutes:int32,
  filter:BoardFilter,
  stopFilter:BoardFilter,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `TimePolicyEvaluation`

```text
{
  policyId:string,
  issueId:string,
  status:string,
  startedAt?:Timestamp,
  dueAt?:Timestamp,
  remainingMinutes:int32
}
```

`remainingMinutes` отрицателен после нарушения. `status` — `on_track`, `warning`, `breached` или `stopped`.

#### `SignalRule`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  enabled:boolean,
  color:string,
  filter:BoardFilter,
  timePolicyId:string,
  policyStatuses:string[],
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `Signal`

```text
{
  id:string,
  projectId:string,
  ruleId:string,
  ruleName:string,
  issueId:string,
  issueKey:string,
  issueTitle:string,
  color:string,
  summary:string,
  status:string,
  timePolicyStatus:string,
  dueAt?:Timestamp,
  remainingMinutes:int32,
  acknowledgedBy:string,
  acknowledgedAt?:Timestamp
}
```

`Signal` вычисляется при чтении состояния; это не новый тип карточки и не добавляет системных полей в схему проекта.

### Бизнес-действия

#### `BusinessActionInputField`

```text
{
  key:string,
  label:string,
  description:string,
  type:string,
  required:boolean,
  options:string[],
  defaultValue:string
}
```

`key` — уникальный `lower_snake_case`. `type` принимает `text`, `textarea`, `number`, `date`, `select`, `multiselect`, `checkbox`, `member` или `issue`; `options` задают варианты для `select` и `multiselect`, а для `member` и `issue` могут ограничивать доступный каталог.

#### `BusinessActionOperation`

```text
{
  field:string,
  value:string,
  inputKey:string,
  targetKind:string,
  memberSource:string
}
```

Если `inputKey` заполнен, значение берётся из ответа формы; иначе используется `value`.

`targetKind = card` изменяет карточку. В этом случае `field` принимает системную цель (`column`, `assignee`, `priority`, `sprint`, `label:add`, `label:remove`, `title`, `description`, `issue_type`, `story_points`, `start_date`, `due_date`, `parent`, `blocked_reason`, `comment`) либо `custom:<FIELD_UUID>`. Пустой `targetKind` читается как `card` для совместимости со старыми определениями.

`targetKind = personnel` изменяет поле профиля сотрудника с UUID из `field`. `memberSource` определяет сотрудника: `assignee`, `actor`, `input:<FORM_FIELD_KEY>` или `custom:<MEMBER_FIELD_UUID>`. Карточка и профиль сотрудника обновляются в одной транзакции: если хотя бы одно изменение недопустимо, не применяется ни одно.

#### `BusinessAction`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  enabled:boolean,
  color:string,
  icon:string,
  confirmationText:string,
  filter:BoardFilter,
  inputFields:BusinessActionInputField[],
  operations:BusinessActionOperation[],
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `BusinessActionValue`

```text
{key:string, value:string}
```

#### `BusinessActionRun`

```text
{
  id:string,
  projectId:string,
  actionId:string,
  actionName:string,
  issueId:string,
  issueKey:string,
  status:string,
  inputValues:BusinessActionValue[],
  changes:AutomationPreviewChange[],
  errorMessage:string,
  actorId:string,
  executedAt:Timestamp
}
```

Журнал хранит копии `actionName` и `issueKey`, поэтому остаётся читаемым после удаления определения или карточки.

### Вычисляемые поля

#### `CalculationOperand`

```text
{constant:boolean, field:string, value:string}
```

Для поля `constant = false`, а `field` содержит системный источник, `custom:<FIELD_UUID>` или `calculated:<CALCULATION_UUID>`. Для константы используется `value`; тип проверяется по оператору.

#### `CalculationField`

```text
{
  id:string,
  projectId:string,
  name:string,
  key:string,
  description:string,
  enabled:boolean,
  color:string,
  objectTypeId?:string,
  kind:string,
  resultType:string,
  operator:string,
  operands:CalculationOperand[],
  relationTypeId:string,
  relationDirection:string,
  aggregateField:string,
  aggregation:string,
  relatedFilter:BoardFilter,
  decimalPlaces:int32,
  emptyPolicy:string,
  prefix:string,
  suffix:string,
  separator:string,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

`resultType`: `number`, `text`, `date` или `boolean`. `emptyPolicy`: `empty`, `zero` или `error`. Поддерживаются формулы `copy`, `coalesce`, `sum`, `subtract`, `multiply`, `divide`, `percent`, `days_between`, `date_add_days`, `concat`, `all`, `any`; rollup-агрегации `count`, `count_distinct`, `sum`, `average`, `min`, `max`, `earliest`, `latest`, `first`, `concat`.

#### `CalculationValue`

```text
{fieldId:string, issueId:string, value:string, displayValue:string, error:string}
```

`value` предназначен для сравнений и последующих вычислений, `displayValue` уже содержит округление, префикс и суффикс. Ошибка относится только к конкретной паре поле–карточка и не блокирует чтение остального проекта.

### Управляемые пакеты

#### `BatchOperation`

```text
{field:string, value:string}
```

Поддерживаются те же системные и пользовательские цели, что у постоянных операций бизнес-действия. Источника из формы нет: значение всегда задано в определении пакета.

#### `WorkBatch`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  enabled:boolean,
  color:string,
  selectionMode:string,
  filter:BoardFilter,
  issueIds:string[],
  executionMode:string,
  operations:BatchOperation[],
  maxItems:int32,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

`filter` и `issueIds` сохраняются одновременно, поэтому смена режима не уничтожает предыдущую конфигурацию состава. В динамическом режиме `filter.queryDsl` и `filter.rules` применяются совместно по `AND`; пустые значения не добавляют ограничений.

#### `BatchItemResult`

```text
{
  issueId:string,
  issueKey:string,
  status:string,
  changes:AutomationPreviewChange[],
  errorMessage:string
}
```

#### `WorkBatchPreview`

```text
{
  batchId:string,
  selectionHash:string,
  targetCount:int32,
  validCount:int32,
  invalidCount:int32,
  items:BatchItemResult[]
}
```

#### `WorkBatchRun`

```text
{
  id:string,
  projectId:string,
  batchId:string,
  batchName:string,
  executionMode:string,
  selectionHash:string,
  status:string,
  targetCount:int32,
  successCount:int32,
  failureCount:int32,
  items:BatchItemResult[],
  actorId:string,
  startedAt:Timestamp,
  finishedAt:Timestamp
}
```

### Маршрутизация

#### `RoutingCandidate`

```text
{
  memberId:string,
  weight:int32,
  capacityMode:string,
  maxActive:int32
}
```

`capacityMode` выбирается явно: `unlimited` не ограничивает число подходящих карточек, `max_active` использует `maxActive`. `weight` влияет на `least_loaded` и `weighted_cycle`; порядок массива влияет на разрешение равенства, `first_available` и `round_robin`.

#### `RoutingCandidateEvaluation`

```text
{
  memberId:string,
  eligible:boolean,
  activeCount:int32,
  maxActive:int32,
  weight:int32,
  score:double,
  reason:string
}
```

#### `RoutingPolicy`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  enabled:boolean,
  color:string,
  triggers:AutomationTrigger[],
  manualEnabled:boolean,
  filter:BoardFilter,
  strategy:string,
  candidates:RoutingCandidate[],
  loadFilter:BoardFilter,
  fallbackMode:string,
  fallbackMemberId:string,
  terminal:boolean,
  failureMode:string,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

`fallbackMode`: `keep_current`, `clear`, `member` или `reject`. `failureMode`: `continue` или `block`. Фильтры и неактивные ссылки сохраняются при выключении возможности.

#### `RoutingPreview`

```text
{
  policyId:string,
  issueId:string,
  issueKey:string,
  applicable:boolean,
  selectedMemberId:string,
  fallbackUsed:boolean,
  decision:string,
  reason:string,
  selectionHash:string,
  candidates:RoutingCandidateEvaluation[]
}
```

#### `RoutingRun`

```text
{
  id:string,
  projectId:string,
  policyId:string,
  policyName:string,
  issueId:string,
  issueKey:string,
  trigger:AutomationTrigger,
  automatic:boolean,
  status:string,
  selectedMemberId:string,
  fallbackUsed:boolean,
  decision:string,
  reason:string,
  actorId:string,
  startedAt:Timestamp,
  finishedAt:Timestamp
}
```

### Операционные дашборды

#### `DashboardWidget`

```text
{
  id:string,
  name:string,
  description:string,
  color:string,
  visualization:string,
  filter:BoardFilter,
  measureField:string,
  aggregation:string,
  groupByField:string,
  timeMode:string,
  dateField:string,
  windowDays:int32,
  startDate:string,
  endDate:string,
  interval:string,
  order:string,
  limit:int32,
  tableFields:string[],
  width:int32
}
```

`visualization`: `number`, `breakdown`, `trend` или `table`. `aggregation`: `count`, `count_distinct`, `sum`, `average`, `min` или `max`. `timeMode`: `all_time`, `rolling_days` или `date_range`; для динамики `interval` выбирается между `day`, `week` и `month`. Поля могут быть системными, пользовательскими (`custom:<id>`), вычисляемыми (`calculated:<id>`) или связями (`relation:<id>`).

#### `Dashboard`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  enabled:boolean,
  color:string,
  scopeFilter:BoardFilter,
  widgets:DashboardWidget[],
  refreshMode:string,
  refreshSeconds:int32,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `DashboardPoint`

```text
{
  key:string,
  label:string,
  value:double,
  issueCount:int32,
  issueIds:string[]
}
```

#### `DashboardCell`

```text
{field:string, label:string, value:string}
```

#### `DashboardRow`

```text
{issueId:string, issueKey:string, cells:DashboardCell[]}
```

#### `DashboardWidgetResult`

```text
{
  widgetId:string,
  status:string,
  error:string,
  value:double,
  issueCount:int32,
  points:DashboardPoint[],
  rows:DashboardRow[],
  issueIds:string[]
}
```

#### `DashboardPreview`

```text
{
  dashboardId:string,
  widgets:DashboardWidgetResult[],
  evaluatedAt:Timestamp
}
```

### Интеграции и агенты

#### `YouGileCompany`

```text
{id:string, name:string, isAdmin:boolean}
```

#### `YouGileProject`

```text
{id:string, title:string, taskCount?:int32, archivedTaskCount?:int32}
```

#### `YouGileBoard`

```text
{id:string, title:string, projectId:string, taskCount?:int32, archivedTaskCount?:int32}
```

#### `IntegrationConnection`

```text
{
  id:string,
  projectId:string,
  provider:IntegrationProvider,
  name:string,
  baseUrl:string,
  externalProjectId:string,
  query:string,
  username:string,
  enabled:boolean,
  hasSecret:boolean,
  hasWebhookSecret:boolean,
  status:string,
  syncProgress:string,
  lastError:string,
  lastSyncAt?:Timestamp,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  webhookUrl:string
}
```

#### `IntegrationRunResult`

```text
{
  scanned:int32,
  created:int32,
  updated:int32,
  skipped:int32,
  users:int32,
  checklists:int32,
  checklistItems:int32,
  parentLinks:int32,
  dependencies:int32,
  relations:int32,
  chatsQueued:int32,
  errors:string[]
}
```

#### `GitLabWebhookEvent`

```text
{
  id:string,
  projectId:string,
  integrationId:string,
  integrationName:string,
  eventUuid:string,
  eventType:string,
  action:string,
  status:string,
  ref:string,
  sourceBranch:string,
  targetBranch:string,
  environment:string,
  jobName:string,
  externalId:string,
  issueId:string,
  issueKey:string,
  processingStatus:string,
  matchedRuleCount:int32,
  errorMessage:string,
  receivedAt:Timestamp,
  processedAt?:Timestamp
}
```

#### `GitLabOutboxAction`

```text
{
  id:string,
  projectId:string,
  ruleId:string,
  ruleName:string,
  integrationId:string,
  integrationName:string,
  issueId:string,
  issueKey:string,
  actionType:AutomationActionType,
  config:map<string,string>,
  status:string,
  attempts:int32,
  errorMessage:string,
  resultJson:string,
  nextAttemptAt?:Timestamp,
  createdAt:Timestamp,
  startedAt?:Timestamp,
  finishedAt?:Timestamp
}
```

Webhook-журнал дедуплицируется по `eventUuid` (при отсутствии — по hash payload). Outbox сохраняет снимки названий, параметров и результата; после ошибки использует ограниченный exponential backoff, а окончательно failed-действие можно вернуть через `RetryGitLabOutboxAction`.

#### `AgentToken`

```text
{
  id:string,
  workspaceId:string,
  memberId:string,
  agentId:string,
  name:string,
  prefix:string,
  scopes:string[],
  expiresAt:Timestamp,
  lastUsedAt?:Timestamp,
  createdAt:Timestamp
}
```

#### `AgentCapabilityPolicy`

```text
{capability:string, decision:string}
```

`capability` — один из `read`, `write`, `admin`; `decision` — `allow`, `ask` или `deny`.

#### `Agent`

```text
{
  id:string,
  workspaceId:string,
  memberId:string,
  ownerMemberId:string,
  name:string,
  description:string,
  status:string,
  runtime:string,
  model:string,
  capabilities:AgentCapabilityPolicy[],
  configVersion:int32,
  lastActiveAt?:Timestamp,
  createdAt:Timestamp,
  updatedAt:Timestamp,
  member:Member,
  owner:Member,
  projectRoleIds:string[],
  tokens:AgentToken[]
}
```

#### `AgentApprovalReceipt`

```text
{
  id:string,
  workspaceId:string,
  projectId:string,
  agentId:string,
  agentMemberId:string,
  ownerMemberId:string,
  agentName:string,
  tokenId:string,
  tokenPrefix:string,
  tokenName:string,
  agentConfigVersion:int32,
  procedure:string,
  capability:string,
  requestHash:string,
  requestJson:string,
  entityType:string,
  entityId:string,
  summary:string,
  beforeJson:string,
  afterJson:string,
  stateHash:string,
  risk:string,
  reversible:boolean,
  status:string,
  reviewedByMemberId:string,
  reviewer?:Member,
  reviewComment:string,
  failureMessage:string,
  requestedAt:Timestamp,
  expiresAt:Timestamp,
  reviewedAt?:Timestamp,
  executionStartedAt?:Timestamp,
  executedAt?:Timestamp,
  updatedAt:Timestamp
}
```

`status` проходит жизненный цикл `pending → approved → executing → executed`; альтернативные исходы — `rejected`, `failed`, `expired`. `requestJson`, `beforeJson` и `afterJson` предназначены для human review и не используются как команда на исполнение. Авторитетная привязка — `requestHash`, а stale-state защита — `stateHash`.

#### `AgentRun`

```text
{
  id:string,
  workspaceId:string,
  projectId:string,
  agentId:string,
  agentMemberId:string,
  ownerMemberId:string,
  agentName:string,
  runtime:string,
  model:string,
  tokenId:string,
  tokenPrefix:string,
  tokenName:string,
  agentConfigVersion:int32,
  procedure:string,
  capability:string,
  policyDecision:string,
  requestHash:string,
  requestJson:string,
  entityType:string,
  entityId:string,
  clientName:string,
  approvalId:string,
  status:string,
  connectCode:string,
  errorMessage:string,
  responseBytes:int64,
  durationMs:int64,
  delegatedByMemberId:string,
  agentSkillInvocationId:string,
  startedAt:Timestamp,
  finishedAt?:Timestamp
}
```

`requestJson` и `errorMessage` маскируют секретные поля исходного protobuf request; `requestHash` остаётся привязан к полному запросу. `responseBytes` — размер protobuf message, не wire-size HTTP/Connect framing. Для managed delegation `agentMemberId` сохраняет исполняющего Agent, `delegatedByMemberId` — человека, чьи права применены, а `agentSkillInvocationId` — источник делегации. Terminal run не переиспользуется и не меняется повторным вызовом.

#### `AgentTool`

```text
{name:string, description:string, requiredScope:string, readOnly:boolean, destructive:boolean}
```

#### `AgentManifest`

```text
{
  service:string,
  version:string,
  protocol:string,
  baseUrl:string,
  apiDocsPath:string,
  mcpCommand:string,
  scopes:string[],
  tools:AgentTool[]
}
```

### Снимок проекта

#### `ProjectState`

```text
{
  project:Project,
  columns:BoardColumn[],
  sprints:Sprint[],
  issues:Issue[],
  labels:Label[],
  activities:ActivityEvent[],
  savedBoards:SavedBoard[],
  workflowRules:WorkflowRule[],
  customFields:CustomFieldDefinition[],
  automationRules:AutomationRule[],
  integrations:IntegrationConnection[],
  boardFilterCards:BoardFilterCard[],
  wikiArticles:WikiArticle[],
  objectTypes:ObjectType[],
  objectTypeFields:ObjectTypeField[],
  relationTypes:RelationType[],
  objectRelations:ObjectRelation[],
  capabilities:ProjectCapability[],
  automationRuns:AutomationRun[],
  businessCalendars:BusinessCalendar[],
  personnelFields:PersonnelFieldDefinition[],
  memberProfiles:MemberProjectProfile[],
  timePolicies:TimePolicy[],
  timePolicyEvaluations:TimePolicyEvaluation[],
  signalRules:SignalRule[],
  signals:Signal[],
  businessActions:BusinessAction[],
  businessActionRuns:BusinessActionRun[],
  calculationFields:CalculationField[],
  calculationValues:CalculationValue[],
  workBatches:WorkBatch[],
  workBatchRuns:WorkBatchRun[],
  routingPolicies:RoutingPolicy[],
  routingRuns:RoutingRun[],
  dashboards:Dashboard[],
  issueTemplates:IssueTemplate[],
  issueTemplateSubmissions:IssueTemplateSubmission[],
  gitlabWebhookEvents:GitLabWebhookEvent[],
  gitlabOutboxActions:GitLabOutboxAction[],
  accessGroups:AccessGroup[],
  projectRoles:ProjectRole[],
  memberProjectRoles:MemberProjectRole[],
  permissionCatalog:PermissionDefinition[],
  currentPermissions:string[],
  fieldPermissions:FieldPermission[],
  boardSubscriptions:BoardSubscription[],
  boardNotifications:BoardNotification[],
  notifications:MemberNotification[]
}
```

### Отчёты

#### `GetReportsResponse`

```text
{
  reports:Report[]
}
```

#### `Report`

```text
{
  id:string,
  projectId:string,
  name:string,
  description:string,
  enabled:bool,
  color:string,
  visualization:string,
  queryDsl:string,
  queryReferences:CalculationFormulaReference[],
  systemKey:string,
  position:int32,
  createdAt:Timestamp,
  updatedAt:Timestamp
}
```

#### `PreviewReportRequest`

```text
{
  reportId:string,
  parameterValues:QueryParameterValue[]
}
```

#### `ReportPreview`

```text
{
  reportId:string,
  columns:QueryColumn[],
  rows:QueryRow[],
  totalCount:int32,
  references:CalculationFormulaReference[],
  normalizedQuery:string,
  evaluatedAt:Timestamp,
  parameters:QueryParameterDefinition[],
  resolvedContexts:QueryResolvedContext[],
  explain:QueryExplainStep[]
}
```

## Enum-справочник

### `MemberRole`

| Имя | Значение |
| --- | ---: |
| `MEMBER_ROLE_UNSPECIFIED` | 0 |
| `MEMBER_ROLE_OWNER` | 1 |
| `MEMBER_ROLE_EDITOR` | 2 |
| `MEMBER_ROLE_VIEWER` | 3 |

### `ColumnCategory`

| Имя | Значение |
| --- | ---: |
| `COLUMN_CATEGORY_UNSPECIFIED` | 0 |
| `COLUMN_CATEGORY_TODO` | 1 |
| `COLUMN_CATEGORY_IN_PROGRESS` | 2 |
| `COLUMN_CATEGORY_DONE` | 3 |

### `RelationCardinality`

| Имя | Значение |
| --- | ---: |
| `RELATION_CARDINALITY_UNSPECIFIED` | 0 |
| `RELATION_CARDINALITY_MANY_TO_MANY` | 1 |
| `RELATION_CARDINALITY_ONE_TO_MANY` | 2 |
| `RELATION_CARDINALITY_MANY_TO_ONE` | 3 |
| `RELATION_CARDINALITY_ONE_TO_ONE` | 4 |

### `IssueType`

| Имя | Значение |
| --- | ---: |
| `ISSUE_TYPE_UNSPECIFIED` | 0 |
| `ISSUE_TYPE_TASK` | 1 |
| `ISSUE_TYPE_STORY` | 2 |
| `ISSUE_TYPE_BUG` | 3 |

### `IssuePriority`

| Имя | Значение |
| --- | ---: |
| `ISSUE_PRIORITY_UNSPECIFIED` | 0 |
| `ISSUE_PRIORITY_LOW` | 1 |
| `ISSUE_PRIORITY_NORMAL` | 2 |
| `ISSUE_PRIORITY_HIGH` | 3 |
| `ISSUE_PRIORITY_CRITICAL` | 4 |

### `TimelineDependencyType`

| Имя | Значение | Смысл |
| --- | ---: | --- |
| `TIMELINE_DEPENDENCY_TYPE_UNSPECIFIED` | 0 | Не задано; сервер ожидает явный тип |
| `TIMELINE_DEPENDENCY_TYPE_FINISH_START` | 1 | Окончание предшественника управляет началом последователя |
| `TIMELINE_DEPENDENCY_TYPE_START_START` | 2 | Начало управляет началом |
| `TIMELINE_DEPENDENCY_TYPE_FINISH_FINISH` | 3 | Окончание управляет окончанием |
| `TIMELINE_DEPENDENCY_TYPE_START_FINISH` | 4 | Начало управляет окончанием |

### `SprintStatus`

| Имя | Значение |
| --- | ---: |
| `SPRINT_STATUS_UNSPECIFIED` | 0 |
| `SPRINT_STATUS_PLANNED` | 1 |
| `SPRINT_STATUS_ACTIVE` | 2 |
| `SPRINT_STATUS_COMPLETED` | 3 |

### `CustomFieldType`

| Имя | Значение |
| --- | ---: |
| `CUSTOM_FIELD_TYPE_UNSPECIFIED` | 0 |
| `CUSTOM_FIELD_TYPE_TEXT` | 1 |
| `CUSTOM_FIELD_TYPE_NUMBER` | 2 |
| `CUSTOM_FIELD_TYPE_DATE` | 3 |
| `CUSTOM_FIELD_TYPE_SELECT` | 4 |
| `CUSTOM_FIELD_TYPE_CHECKBOX` | 5 |
| `CUSTOM_FIELD_TYPE_MEMBER` | 6 |
| `CUSTOM_FIELD_TYPE_WIKI_ARTICLE` | 7 |
| `CUSTOM_FIELD_TYPE_HOURS` | 8 |

### `BoardFilterOperator`

| Имя | Значение |
| --- | ---: |
| `BOARD_FILTER_OPERATOR_UNSPECIFIED` | 0 |
| `BOARD_FILTER_OPERATOR_EQUALS` | 1 |
| `BOARD_FILTER_OPERATOR_NOT_EQUALS` | 2 |
| `BOARD_FILTER_OPERATOR_CONTAINS` | 3 |
| `BOARD_FILTER_OPERATOR_NOT_CONTAINS` | 4 |
| `BOARD_FILTER_OPERATOR_GREATER_THAN` | 5 |
| `BOARD_FILTER_OPERATOR_LESS_THAN` | 6 |
| `BOARD_FILTER_OPERATOR_IS_EMPTY` | 7 |
| `BOARD_FILTER_OPERATOR_IS_NOT_EMPTY` | 8 |

### `BoardSwimlaneMode`

| Имя | Значение |
| --- | ---: |
| `BOARD_SWIMLANE_MODE_UNSPECIFIED` | 0 |
| `BOARD_SWIMLANE_MODE_FIELD` | 1 |
| `BOARD_SWIMLANE_MODE_RULES` | 2 |

### `AutomationTrigger`

| Имя | Значение |
| --- | ---: |
| `AUTOMATION_TRIGGER_UNSPECIFIED` | 0 |
| `AUTOMATION_TRIGGER_ISSUE_MOVED` | 1 |
| `AUTOMATION_TRIGGER_ISSUE_CREATED` | 2 |
| `AUTOMATION_TRIGGER_ISSUE_UPDATED` | 3 |
| `AUTOMATION_TRIGGER_GITLAB_MERGE_REQUEST` | 4 |
| `AUTOMATION_TRIGGER_GITLAB_PIPELINE` | 5 |
| `AUTOMATION_TRIGGER_GITLAB_JOB` | 6 |
| `AUTOMATION_TRIGGER_GITLAB_PUSH` | 7 |
| `AUTOMATION_TRIGGER_GITLAB_TAG` | 8 |
| `AUTOMATION_TRIGGER_GITLAB_DEPLOYMENT` | 9 |
| `AUTOMATION_TRIGGER_GITLAB_RELEASE` | 10 |
| `AUTOMATION_TRIGGER_GITLAB_NOTE` | 11 |

### `AutomationActionType`

| Имя | Значение |
| --- | ---: |
| `AUTOMATION_ACTION_TYPE_UNSPECIFIED` | 0 |
| `AUTOMATION_ACTION_TYPE_MOVE_COLUMN` | 1 |
| `AUTOMATION_ACTION_TYPE_SET_ASSIGNEE` | 2 |
| `AUTOMATION_ACTION_TYPE_SET_PRIORITY` | 3 |
| `AUTOMATION_ACTION_TYPE_SET_SPRINT` | 4 |
| `AUTOMATION_ACTION_TYPE_ADD_LABEL` | 5 |
| `AUTOMATION_ACTION_TYPE_SET_CUSTOM_FIELD` | 6 |
| `AUTOMATION_ACTION_TYPE_APPLY_BOARD` | 7 |
| `AUTOMATION_ACTION_TYPE_SET_BLOCKED_REASON` | 8 |
| `AUTOMATION_ACTION_TYPE_ADD_COMMENT` | 9 |
| `AUTOMATION_ACTION_TYPE_MUTATE_FIELD` | 10 |
| `AUTOMATION_ACTION_TYPE_GITLAB_CREATE_BRANCH` | 20 |
| `AUTOMATION_ACTION_TYPE_GITLAB_CREATE_MERGE_REQUEST` | 21 |
| `AUTOMATION_ACTION_TYPE_GITLAB_UPDATE_MERGE_REQUEST` | 22 |
| `AUTOMATION_ACTION_TYPE_GITLAB_ADD_NOTE` | 23 |
| `AUTOMATION_ACTION_TYPE_GITLAB_APPROVE_MERGE_REQUEST` | 24 |
| `AUTOMATION_ACTION_TYPE_GITLAB_UNAPPROVE_MERGE_REQUEST` | 25 |
| `AUTOMATION_ACTION_TYPE_GITLAB_MERGE_MERGE_REQUEST` | 26 |
| `AUTOMATION_ACTION_TYPE_GITLAB_CLOSE_MERGE_REQUEST` | 27 |
| `AUTOMATION_ACTION_TYPE_GITLAB_REOPEN_MERGE_REQUEST` | 28 |
| `AUTOMATION_ACTION_TYPE_GITLAB_RUN_PIPELINE` | 29 |
| `AUTOMATION_ACTION_TYPE_GITLAB_RETRY_PIPELINE` | 30 |
| `AUTOMATION_ACTION_TYPE_GITLAB_CANCEL_PIPELINE` | 31 |
| `AUTOMATION_ACTION_TYPE_GITLAB_PLAY_JOB` | 32 |
| `AUTOMATION_ACTION_TYPE_GITLAB_RETRY_JOB` | 33 |
| `AUTOMATION_ACTION_TYPE_GITLAB_CANCEL_JOB` | 34 |
| `AUTOMATION_ACTION_TYPE_GITLAB_LINK_MERGE_REQUEST` | 35 |

### `AutomationFieldOperation`

| Имя | Значение |
| --- | ---: |
| `AUTOMATION_FIELD_OPERATION_UNSPECIFIED` | 0 |
| `AUTOMATION_FIELD_OPERATION_SET` | 1 |
| `AUTOMATION_FIELD_OPERATION_INC` | 2 |
| `AUTOMATION_FIELD_OPERATION_DEC` | 3 |

### `IssueTemplateAccess`

| Имя | Значение |
| --- | ---: |
| `ISSUE_TEMPLATE_ACCESS_UNSPECIFIED` | 0 |
| `ISSUE_TEMPLATE_ACCESS_MEMBERS` | 1 |
| `ISSUE_TEMPLATE_ACCESS_PUBLIC` | 2 |
| `ISSUE_TEMPLATE_ACCESS_PASSWORD` | 3 |
| `ISSUE_TEMPLATE_ACCESS_GROUPS` | 4 |

### `IssueTemplateFieldMode`

| Имя | Значение |
| --- | ---: |
| `ISSUE_TEMPLATE_FIELD_MODE_UNSPECIFIED` | 0 |
| `ISSUE_TEMPLATE_FIELD_MODE_HIDDEN` | 1 |
| `ISSUE_TEMPLATE_FIELD_MODE_EDITABLE` | 2 |
| `ISSUE_TEMPLATE_FIELD_MODE_REQUIRED` | 3 |
| `ISSUE_TEMPLATE_FIELD_MODE_LOCKED` | 4 |

### `IntegrationProvider`

| Имя | Значение |
| --- | ---: |
| `INTEGRATION_PROVIDER_UNSPECIFIED` | 0 |
| `INTEGRATION_PROVIDER_JIRA` | 1 |
| `INTEGRATION_PROVIDER_YOUGILE` | 2 |
| `INTEGRATION_PROVIDER_GITLAB` | 3 |

## Дополнительные HTTP endpoints

Эти пути не входят в `BoardService`.

| Метод и путь | Авторизация | Назначение |
| --- | --- | --- |
| `GET /livez` | нет | Процесс запущен |
| `GET /readyz` | нет | API запущен и база отвечает |
| `GET /docs/api.md` | нет | Этот документ |
| `GET /docs/agent-guide.md` | нет | Руководство для ИИ-агента |
| `GET /.well-known/algoboard-agent.json` | нет | Agent manifest |
| `POST /webhooks/gitlab/{integration_id}` | `X-Gitlab-Token` | GitLab merge request webhook |

Максимальный размер HTTP-запроса — 2 МиБ. GitLab webhook отвечает `202 {"ok":true}` после принятия события. Ошибки: `400` — некорректное тело, `401` — неверный secret, `404` — подключение не найдено.

## ChatService

Чаты используют отдельный endpoint `POST /algoboard.v1.ChatService/{Method}` и не входят в тяжёлый снимок `GetAppState`. Каждый запрос проходит session, workspace, capability `chats`, проектные разрешения и membership разговора. Мутации сообщений, каналов и состава DM повторяют эти проверки по свежим данным уже после получения conversation lock, поэтому параллельный отзыв права, capability или архивирование проекта не оставляет окно stale authorization. Приватный недоступный разговор отвечает `not_found` без раскрытия его существования.

`message.sequence` задаёт неизменяемый порядок сообщений. `eventSequence` отдельно упорядочивает создание, редактирование, удаление и изменения состава; после reconnect клиент вызывает `ListConversationEvents`, а WebSocket использует только как сигнал.

P0 rate limiter хранится в памяти процесса и использует отдельные token buckets для каждой тройки `workspaceId + memberId + action`: `SendMessage` допускает burst 20 и восстанавливает один token каждые 500 мс, `EditMessage` — burst 10 и один token каждую секунду. Превышение отвечает `resource_exhausted` с публичным сообщением и metadata `Retry-After`; рестарт процесса очищает buckets.

### `GetChatBootstrap`

`GetChatBootstrapRequest → GetChatBootstrapResponse`. Поля запроса: `workspaceId`, `projectId`. Возвращает `currentMember`, `chatsEnabled`, `permissions`, первую страницу `conversations`, её `nextCursor`, авторитетный `unreadConversationCount` и `serverTime`.

### `ListConversations`

`ListConversationsRequest → ListConversationsResponse`. Поля запроса: `workspaceId`, `projectId`, непрозрачный `cursor`, `limit`, `includeArchived`. Ответ: `conversations`, `nextCursor`.

### `CreateChannel`

`CreateChannelRequest → CreateChannelResponse`. Поля запроса: `workspaceId`, `projectId`, `name`, `description`, `visibility`, `memberIds`. Ответ содержит `conversation`.

### `UpdateChannel`

`UpdateChannelRequest → UpdateChannelResponse`. Поля запроса: `projectId`, `conversationId`, optional `name`, `description`, `visibility`. Ответ содержит `conversation`.

### `ArchiveChannel`

`ArchiveChannelRequest → ArchiveChannelResponse`. Поля запроса: `projectId`, `conversationId`. Ответ содержит `conversation`.

### `AddChannelMembers`

`AddChannelMembersRequest → AddChannelMembersResponse`. Поля запроса: `projectId`, `conversationId`, `memberIds`. Ответ содержит `conversation`.

### `RemoveChannelMember`

`RemoveChannelMemberRequest → RemoveChannelMemberResponse`. Поля запроса: `projectId`, `conversationId`, `memberId`. Ответ содержит `conversation`.

### `OpenDirectConversation`

`OpenDirectConversationRequest → OpenDirectConversationResponse`. Поля запроса: `workspaceId`, `projectId`, `participantMemberIds`, `clientRequestId`. Ответ содержит `conversation` и `created`; повторное открытие той же пары возвращает существующий DM.

### `AddDirectParticipants`

`AddDirectParticipantsRequest → AddDirectParticipantsResponse`. Поля запроса: `projectId`, `conversationId`, `memberIds`, обязательный `confirmHistoryAccess`. Ответ содержит тот же `conversation` и `systemMessage`. Новый участник видит историю с первого sequence, но сервер ставит его read cursor на системное событие добавления.

### `LeaveDirectConversation`

`LeaveDirectConversationRequest → LeaveDirectConversationResponse`. Поля запроса: `projectId`, `conversationId`. Пустой ответ подтверждает отзыв дальнейшего доступа.

### `ListMessages`

`ListMessagesRequest → ListMessagesResponse`. Поля запроса: `projectId`, `conversationId`, optional `beforeSequence` или `afterSequence`, `limit`. Ответ: `messages`, `hasMore`; offset-пагинации нет.

### `GetMessage`

`GetMessageRequest → GetMessageResponse`. Поля запроса: `projectId`, `conversationId`, `messageId`. Возвращает точный актуальный snapshot `message`, включая soft-deleted сообщение и `threadRootId`; применяет тот же project/chat/membership access, что и `ListMessages`.

### `SendMessage`

`SendMessageRequest → SendMessageResponse`. Поля запроса: `projectId`, `conversationId`, уникальный `clientRequestId`, структурированный `content`, optional `threadRootId`. Ответ содержит `message` и `duplicate`; повтор одного client request ID не создаёт вторую запись и не повторяет membership/notification side effects. Обычный текст человека в личном разговоре ровно с одним активным Agent автоматически создаёт идемпотентный direct-chat invocation; slash-команда остаётся в отдельном `InvokeAgentSkill`, групповой DM и сообщение самого Agent автозапуск не создают. Успешный `FinishAgentSkillInvocation` публикует результат direct-chat запуска обратно в тот же разговор и тред от имени Agent. Первая новая отправка в открытый проектный канал атомарно создаёт или реактивирует явный membership отправителя с ролью `MEMBER` и уровнем уведомлений `all`; durable `chat.membership.updated` предшествует `chat.message.created`, но invite-уведомление, системное сообщение и audit добавления не создаются. Delayed replay после более позднего удаления membership возвращает исходное сообщение и не вступает в канал заново.

### `EditMessage`

`EditMessageRequest → EditMessageResponse`. Поля запроса: `projectId`, `conversationId`, `messageId`, `content`. Ответ содержит обновлённый `message`.

### `DeleteMessage`

`DeleteMessageRequest → DeleteMessageResponse`. Поля запроса: `projectId`, `conversationId`, `messageId`, `moderatorReason`. Ответ содержит soft-deleted `message`; модераторское удаление требует причины и попадает в аудит.

### `ListThreadReplies`

`ListThreadRepliesRequest → ListThreadRepliesResponse`. Поля запроса: `projectId`, `conversationId`, `threadRootId`, optional `beforeSequence` или `afterSequence`, `limit`. Ответ: `rootMessage`, `replies`, `hasMore`.

### `MarkConversationRead`

`MarkConversationReadRequest → MarkConversationReadResponse`. Поля запроса: `projectId`, `conversationId`, `lastReadSequence`, optional `observedThreadRootId`, optional `observedFromSequence`. `readCursorScope = GLOBAL_CONVERSATION_SEQUENCE`: один cursor упорядочивает корневые сообщения и ответы всех тредов. `observedFromSequence` — sequence самого раннего реально загруженного сообщения наблюдаемой страницы; без этого поля сервер безопасно не продвигает cursor. Для основной ленты клиент не передаёт `observedThreadRootId`: наблюдаемыми считаются только top-level сообщения с `sequence >= observedFromSequence`, а replies и более ранняя незагруженная история блокируют продвижение. Для открытого треда клиент передаёт ID корня: корень считается загруженным отдельно, а среди replies наблюдаемы только записи этого root с `sequence >= observedFromSequence`; более ранний reply, top-level сообщение или reply другого треда остаётся blocker. `SendMessage` и системные сообщения сами по себе cursor не продвигают: после рендера клиент вызывает этот RPC с фактической границей загруженной страницы. Исключение — явная инициализация cursor нового участника DM на системном событии добавления.

Ответ возвращает фактически сохранённый `lastReadSequence`, авторитетный `unreadMentionCount`, `readCursorScope` и упорядоченные по `firstUnreadSequence` `unreadThreadSummaries`. Сервер возвращает не более 50 самых ранних непрочитанных roots на разговор; `unreadThreadRootCount` содержит полное число непрочитанных roots, а `unreadThreadSummariesTruncated` сообщает, что список усечён. Каждая запись содержит `rootMessageId`, `firstUnreadSequence`, `lastUnreadSequence`, `unreadCount` и присутствует даже тогда, когда root не вошёл в текущую страницу `ListMessages`. После продвижения cursor ответ показывает следующую группу ранних roots. Клиент использует `rootMessageId`, чтобы загрузить тред, и всегда продвигает cursor по порядку. Точный server-side blocker не ограничен этими 50 summary и продолжает учитывать все сообщения. `chat.read.updated` создаётся и публикуется только при фактическом увеличении cursor; повторный, заблокированный или вызванный без границы mark не раздувает durable event log.

### `SearchDirectParticipants`

`SearchDirectParticipantsRequest → SearchDirectParticipantsResponse`. Поля запроса: `projectId`, `query`, `limit`. Требует `project.view`, `chat.view`, `chat.send` и capability `chats`; возвращает активных участников workspace без текущего пользователя. Поиск учитывает имя и email, но ответ раскрывает только безопасные поля `memberId`, `displayName`, `avatarColor`.

### `SearchChannelParticipants`

`SearchChannelParticipantsRequest → SearchChannelParticipantsResponse`. Поля запроса: `projectId`, `query`, `limit`. Требует `project.view`, `chat.view`, `chat.channel.manage` и capability `chats`; возвращает активных участников workspace в минимальной проекции `memberId`, `displayName`, `avatarColor`, без email и account login.

### `SearchMentionableMembers`

`SearchMentionableMembersRequest → SearchMentionableMembersResponse`. Поля запроса: `projectId`, `conversationId`, `query`, `limit`. Ответ содержит доступных для упоминания `members`; клиент сохраняет выбранный `memberId`, а не имя.

### `SearchMentionableIssues`

`SearchMentionableIssuesRequest → SearchMentionableIssuesResponse`. Поля запроса: `projectId`, `conversationId`, `query`, `limit`. Ответ содержит доступные `issues`; клиент сохраняет `issueId`, а не key snapshot.

### `ListConversationEvents`

`ListConversationEventsRequest → ListConversationEventsResponse`. Поля запроса: `projectId`, `conversationId`, `afterEventSequence`, `limit`. Ответ: durable `events`, `hasMore`. Broadcast-события доступны всем текущим читателям разговора; targeted-типы `chat.notification.created` и `chat.read.updated` возвращаются только когда `memberId` совпадает с текущим участником, как и при live WebSocket-доставке.

### `ChatConversationType`

Значения: `CHAT_CONVERSATION_TYPE_CHANNEL`, `CHAT_CONVERSATION_TYPE_DIRECT`.

### `ChatConversationVisibility`

Значения: `CHAT_CONVERSATION_VISIBILITY_PROJECT_OPEN`, `CHAT_CONVERSATION_VISIBILITY_PRIVATE`.

### `ChatConversationMemberRole`

Значения: `OWNER`, `MODERATOR`, `MEMBER` в пространстве имён enum.

### `ChatReadCursorScope`

Значение `CHAT_READ_CURSOR_SCOPE_GLOBAL_CONVERSATION_SEQUENCE` означает единый монотонный cursor по общей последовательности корневых сообщений и replies. `CHAT_READ_CURSOR_SCOPE_UNSPECIFIED` не используется серверным ответом.

### `ChatMessageKind`

Значения: пользовательское сообщение, системное добавление и системное удаление участника.

### Модели чатов

| Модель | Поля protobuf JSON |
| --- | --- |
| `ChatMemberMention` | `memberId`, `labelSnapshot` |
| `ChatIssueReference` | `issueId`, неизменяемый `projectId`, отображаемый snapshot `keySnapshot`, response-only текущие `title` и `state`; сервер игнорирует входные `title/state`, не сохраняет их и пакетно вычисляет заново при чтении; если читатель потерял `issue.view`, представление становится обычным text `#keySnapshot` без ID, title и state, а сохранённый content не меняется |
| `ChatIssueSourceInput` | `conversationId`, `messageId`; optional-вложение `CreateIssueRequest.chatSource` для атомарного создания карточки из сообщения |
| `ChatContentPart` | oneof `text`, `memberMention`, `issueReference` |
| `ChatMessageContent` | `version`, `parts` |
| `ChatConversationMember` | `conversationId`, `memberId`, `role`, `member`, `joinedAt`, `leftAt` |
| `ChatConversationActionFlags` | `canView`, `canSend`, `canManage`, `canModerate`, `canAddParticipants`, `canLeave` |
| `ChatConversation` | `id`, `workspaceId`, `projectId`, `type`, `visibility`, `name`, `description`, `createdBy`, `members`, `lastSequence`, `eventSequence`, `lastReadSequence`, `unreadCount`, `hasUnreadMention`, `lastActivityAt`, `createdAt`, `updatedAt`, `archivedAt`, `actions`, `unreadThreadSummaries`, `readCursorScope`, `unreadThreadSummariesTruncated`, `unreadThreadRootCount` |
| `ChatThreadSummary` | `rootMessageId`, `replyCount`, `participantMemberIds`, `lastReplyAt`, `lastReplySequence` |
| `ChatUnreadThreadSummary` | `rootMessageId`, `firstUnreadSequence`, `lastUnreadSequence`, `unreadCount` |
| `ChatMessageActionFlags` | `canEdit`, `canDelete`, `canReply`, `canPromoteToIssue`; последнее вычисляется сервером из типа/состояния сообщения, отсутствия существующей связи и права `issue.create` |
| `ChatMessage` | `id`, `conversationId`, `sequence`, `authorId`, `author`, `kind`, `threadRootId`, `content`, `plainText`, `clientRequestId`, `createdAt`, `editedAt`, `deletedAt`, `threadSummary`, `actions`, optional permission-aware `promotedIssue` с текущей карточкой |
| `ChatConversationEvent` | `id`, `workspaceId`, `conversationId`, `eventSequence`, `type`, `messageId`, `conversationSequence`, `memberId`, `actorId`, `payloadJson`, `publishedAt` |
| `GetChatBootstrapRequest` | `workspaceId`, `projectId` |
| `GetChatBootstrapResponse` | `workspaceId`, `projectId`, `currentMember`, `chatsEnabled`, `permissions`, `conversations`, `nextCursor`, `unreadConversationCount`, `serverTime` |
| `ListConversationsRequest` | `workspaceId`, `projectId`, `cursor`, `limit`, `includeArchived` |
| `ListConversationsResponse` | `conversations`, `nextCursor` |
| `CreateChannelRequest` | `workspaceId`, `projectId`, `name`, `description`, `visibility`, `memberIds` |
| `CreateChannelResponse` | `conversation` |
| `UpdateChannelRequest` | `projectId`, `conversationId`, `name`, `description`, `visibility` |
| `UpdateChannelResponse` | `conversation` |
| `ArchiveChannelRequest` | `projectId`, `conversationId` |
| `ArchiveChannelResponse` | `conversation` |
| `AddChannelMembersRequest` | `projectId`, `conversationId`, `memberIds` |
| `AddChannelMembersResponse` | `conversation` |
| `RemoveChannelMemberRequest` | `projectId`, `conversationId`, `memberId` |
| `RemoveChannelMemberResponse` | `conversation` |
| `OpenDirectConversationRequest` | `workspaceId`, `projectId`, `participantMemberIds`, `clientRequestId` |
| `OpenDirectConversationResponse` | `conversation`, `created` |
| `AddDirectParticipantsRequest` | `projectId`, `conversationId`, `memberIds`, `confirmHistoryAccess` |
| `AddDirectParticipantsResponse` | `conversation`, `systemMessage` |
| `LeaveDirectConversationRequest` | `projectId`, `conversationId` |
| `LeaveDirectConversationResponse` | без полей |
| `ListMessagesRequest` | `projectId`, `conversationId`, `beforeSequence`, `afterSequence`, `limit` |
| `ListMessagesResponse` | `messages`, `hasMore` |
| `GetMessageRequest` | `projectId`, `conversationId`, `messageId` |
| `GetMessageResponse` | `message` |
| `SendMessageRequest` | `projectId`, `conversationId`, `clientRequestId`, `content`, `threadRootId` |
| `SendMessageResponse` | `message`, `duplicate` |
| `EditMessageRequest` | `projectId`, `conversationId`, `messageId`, `content` |
| `EditMessageResponse` | `message` |
| `DeleteMessageRequest` | `projectId`, `conversationId`, `messageId`, `moderatorReason` |
| `DeleteMessageResponse` | `message` |
| `ListThreadRepliesRequest` | `projectId`, `conversationId`, `threadRootId`, `beforeSequence`, `afterSequence`, `limit` |
| `ListThreadRepliesResponse` | `rootMessage`, `replies`, `hasMore` |
| `MarkConversationReadRequest` | `projectId`, `conversationId`, `lastReadSequence`, `observedThreadRootId`, `observedFromSequence` |
| `MarkConversationReadResponse` | `lastReadSequence`, `unreadThreadSummaries`, `readCursorScope`, `unreadThreadSummariesTruncated`, `unreadThreadRootCount`, `unreadMentionCount` |
| `ChatDirectParticipant` | `memberId`, `displayName`, `avatarColor` |
| `SearchDirectParticipantsRequest` | `projectId`, `query`, `limit` |
| `SearchDirectParticipantsResponse` | `participants` |
| `SearchChannelParticipantsRequest` | `projectId`, `query`, `limit` |
| `SearchChannelParticipantsResponse` | `participants` |
| `SearchMentionableMembersRequest` | `projectId`, `conversationId`, `query`, `limit` |
| `SearchMentionableMembersResponse` | `members` |
| `ChatIssueSuggestion` | `issueId`, `projectId`, `key`, `title`, `state`, `assigneeId`, `archived` |
| `SearchMentionableIssuesRequest` | `projectId`, `conversationId`, `query`, `limit` |
| `SearchMentionableIssuesResponse` | `issues` |
| `ListConversationEventsRequest` | `projectId`, `conversationId`, `afterEventSequence`, `limit` |
| `ListConversationEventsResponse` | `events`, `hasMore` |

`MemberNotification` дополнен полями `targetType`, `conversationId`, `messageId`, `threadRootId`; старые comment targets продолжают использовать `projectId`, `issueId` и `commentId`.

## Примеры клиентов

### TypeScript, Connect-Web

Сгенерированный клиент уже находится в `src/api/generated`.

```ts
import { createClient } from '@connectrpc/connect'
import { createConnectTransport } from '@connectrpc/connect-web'
import { BoardService } from './generated/algoboard/v1/algoboard_pb'

const transport = createConnectTransport({
  baseUrl: 'http://localhost:8080',
  fetch: (input, init) => fetch(input, {
    ...init,
    credentials: 'include',
  }),
})

const client = createClient(BoardService, transport)
const state = await client.getAppState({})
```

Для Bearer-токена добавьте interceptor или передавайте `headers` в call options:

```ts
const state = await client.getAppState({}, {
  headers: {
    Authorization: `Bearer ${token}`,
  },
})
```

### Go, ConnectRPC

```go
client := algoboardv1connect.NewBoardServiceClient(
    http.DefaultClient,
    "http://localhost:8080",
)

request := connect.NewRequest(&algoboardv1.GetAppStateRequest{})
request.Header().Set("Authorization", "Bearer "+token)

response, err := client.GetAppState(ctx, request)
if err != nil {
    return err
}
fmt.Println(response.Msg.GetProjectState().GetProject().GetName())
```

### Проверка через curl

```bash
curl --fail-with-body \
  -X POST http://localhost:8080/algoboard.v1.BoardService/GetAgentManifest \
  -H "Content-Type: application/json" \
  -H "Connect-Protocol-Version: 1" \
  -d '{}'
```

## Checklist интеграции

1. Проверьте `GET /readyz`.
2. Получите session cookie через `Login` или создайте Bearer-токен.
3. Вызовите `GetAppState` и сохраните только актуальные UUID.
4. Отправляйте JSON в `lowerCamelCase`.
5. Для частичных обновлений передавайте только изменяемые optional-поля.
6. Для repeated-полей используйте соответствующий `replace...` flag.
7. После `MoveIssue` перечитайте карточку: могли сработать автоматизации.
8. После сетевого сбоя сначала проверьте состояние, затем решайте, повторять ли Create.
9. Перед destructive-операцией покажите пользователю объект и последствия.
10. Для Jira/YouGile сначала выполните `RunImport` с `dryRun = true`.
