Перейти к основному содержимому

ADR 0173 — Удаление участника workspace рождается, защищённое тем же условием

Статус​

Accepted. Это ЧЕТВЁРТЫЙ в линии, открытой ADR 0127 и продолженной ADR 0156 и ADR 0157. Открывает дверь, которую 0157 отказался открывать мимоходом, — «Маршрут удаления участника workspace (…) Отдельное решение, если кто-нибудь попросит», — по решению сопровождающего (2026-09-27, AT-115/HS-006): маршрут рождается, защищённый УСЛОВИЕМ. Принятый ADR не редактируется: 0157 по-прежнему говорит, что маршрута не существует, а этот — тот, что говорит, что он появился.

Контекст​

Offboarding участника workspace был прямой записью в базу: у WorkspacesController не было @Delete участника — измерено во время BRB-002 и использовано 0157 как часть довода. 0157 оставил три вопроса тому, кто откроет эту дверь, и HS-006 показала, что это один и тот же разговор:

  1. последний owner. Сегодня инвариант «никогда ноль owners» исходит из условия — убрать последнего потребовало бы, чтобы он сделал это сам, а потолок 2 этому мешает. HS-006 опасалась, что маршрут удаления вновь откроет этот путь и гарантия должна будет исходить из подсчёта, который отверг 0127 («формулируется условием, в нём нет числа, которое могло бы устареть»);
  2. потолок 1 в workspace. Он остался вне (0157, пункт 3), потому что вместе с отсутствием маршрута удаления сделал бы owner поглощающим состоянием;
  3. потолок 2 при удалении: наследует делегирование удаления из проекта (remocaoEhAutoRebaixamento) или требует собственной формулировки, раз здесь «после» — всегда ничего?

Решение​

1. Рождается DELETE /workspaces/:workspaceId/members/:userId, owner, 204. Тот же минимум, что у upsert: со списком участников работает тот, кто его ведёт. Идемпотентно, как удаление из проекта, — цель без строки не ошибка.

2. Потолок 2 УНАСЛЕДОВАН, без новой мерки. RemoveWorkspaceMemberUseCase вызывает remocaoEhAutoRebaixamento, передавая роль актора в WORKSPACE как сегодняшнюю эффективную (в workspace нет композиции: эффективная — это строка) и null как роль ПОСЛЕ. Эта ветка уже существовала — это ветка проекта без строки workspace, «максимальное понижение», — и она отвечает на вопрос 3: «после» здесь — всегда ничего, а функция уже умела формулировать ничего. Удалить себя из workspace — всегда 403, с собственной фразой (MENSAGEM_TETO_AUTO_REMOCAO_DO_WORKSPACE): нажавший «удалить» не просил смены роли, по той же причине, по которой удаление из проекта получило свою в 0156. Имена параметров остались как у проекта: переименование изменило бы другие двери ради прочтения, а docblock функции говорит, как её читать.

3. Последний owner защищён ТЕМ ЖЕ условием, без подсчёта owners. Доказательство, по образцу того, что 0157 сделал для upsert:

  • маршрут требует owner (@RequireRole('owner')), поэтому удаляющий — всегда owner workspace;
  • никто не удаляет самого себя (пункт 2);
  • значит, каждое успешное удаление оставляет хотя бы одного owner: самого вызывающего. Убрать последнего потребовало бы, чтобы он удалил себя, — отклоняемое движение.

Посылка HS-006 («маршрут вновь откроет путь») не подтвердилась: маршрут ОТКРЫВАЕТ удаление других, но удаление последнего — это именно самоудаление, и оно по-прежнему закрыто условием, которое уже закрывало самопонижение. Подсчёт по-прежнему отвергнут по причинам 0157, пункт 2. Доказательство есть в тесте: "com dois owners, qualquer sequência de remoções deixa ao menos um: quem remove fica", а минимум owner у маршрута утверждается в тесте контроллера, потому что он — ПОЛОВИНА доказательства: если минимум упадёт, упадёт и доказательство.

4. Удаление ДРУГОГО owner остаётся возможным. Это способ отозвать владение целиком, и это естественное продолжение пункта 3 из 0157, который сохранил понижение другого владельца. У потолка 1 по-прежнему нет пары в этой области (вопрос 2): посылка, которую HS-006 видела падающей, — «нет выхода из owner по HTTP», — была доводом о ЦЕНЕ в 0157, а довод о ПРИНЦИПЕ стоит: потолок 1 — правило об инверсии иерархии, а @RequireRole('owner') делает инверсию невозможной. Новый маршрут лишь усиливает цену: с ним и отражённым потолком 1 удаление другого владельца тоже отклонялось бы, и owner снова стал бы поглощающим.

5. Каскад, в одной транзакции. Удаление одной лишь строки workspace_members было бы косметикой: перекрытие projectRole ?? workspaceRole (RN-471) удержало бы удалённого внутри каждого проекта, где у него есть собственная строка. Поэтому вместе:

  • строка workspace_members;
  • строки project_members удалённого в проектах этого workspace (строки других workspaces не затрагиваются);
  • его учётные данные, привязанные к этим проектам: ключи устройства ПРОЕКТА (по той же колонке, что перечисляет RN-519) и PAT — каждый PAT принадлежит проекту (project_id NOT NULL), — отозванные с причиной workspace_member_removed. Без этого повторное добавление однажды молча реактивировало бы сопряжение, которое удаление должно было завершить.

6. После коммита живое соединение падает путём RN-520. По одному disconnectRunnerOfUser(projectId, userId) на каждый проект workspace — ВСЕ, а не только режима runner, потому что runner подключается и в container/mounted (зеркало, RN-516); лишний проект стоит sem_runner, недостающий оставляет на ногах то, что удаление существует, чтобы оборвать. В try/catch, который только логирует: побочный эффект никогда не роняет основной, мерка RN-520.

7. ВЛАДЕЛЕЦ ЗАПИСИ не уходит; титул передаётся (решение сопровождающего, 2026-09-27, RN-616). workspaces.created_by не решает авторизацию (решает роль, ADR 0127), но именно по нему разрешаются учётные данные LLM, которые тратят агенты, и учётные данные git для действий агента (RN-058, ResolveCredentialOwnerUseCase), и владелец отчёта о расходах (RN-060). Удаление создателя по пункту 4 оставило бы агентов тратить учётные данные того, кого уже нет в workspace. Поэтому, под именем владельца записи (titular):

  • DELETE .../members/:userId с целью = titular — это названный 409, criador_do_workspace, говорящий, что сначала нужно передать и почему. Потолок пункта 2 идёт первым: titular, пытающийся удалить себя, получает обычный 403. Пункт 3 продолжает действовать — titular является owner, а вызывающий остаётся тем owner, который остаётся.
  • Рождается PUT /workspaces/:workspaceId/owner-of-record, owner, тело { userId }. Назначение должно УЖЕ быть owner workspace — быть titular значит платить, — иначе 409 titular_precisa_ser_owner. Записывает created_by, в транзакции с чтением роли; передача текущему titular идемпотентна. Вызвать может любой owner, в том числе передавая ДРУГОМУ owner, который не просил: это та же власть, что уже удаляет и понижает владельцев, а согласие назначения было бы потоком приглашения, которого в продукте нет.
  • Расход, начиная с коммита: ResolveCredentialOwnerUseCase начинает возвращать НОВОГО titular; ход агента ищет ЕГО учётные данные для provider модели, а действия git — его учётные данные git. Если у него их нет, продукт делает то, что уже делал с любым titular без учётных данных: ход завершается "Nenhuma credencial cadastrada para <provider>" (RunLlmTurnUseCase/StreamLlmTurnUseCase), а чтение удалённого git падает, называя, чьих учётных данных не хватает. Передача этого заранее НЕ проверяет — это было бы чтение по каждому provider без знания, какие используют проекты, а отказ по этой причине запер бы как раз выход titular, который уходит. ollama не использует учётные данные и остаётся прежним. Уже записанный расход остаётся, где был: token_usage не хранит titular, и отчёт целиком начинает читать новый titular, включая историю.
  • Нет доменного события для смены участника или титула (upsert и удаление тоже не выпускают); передача оставляет строку лога с от/кому/кто. Событие аудита для всех трёх — отдельное направление.

Что объявлено и НЕ сделано​

  • Ключи МАШИНЫ (project_id пуст) остаются: они принадлежат УЧЁТНОЙ ЗАПИСИ и обслуживают другие workspaces человека. Здесь они уже ничего не достигают — PatAuthGuard разрешает роль против запрошенного проекта, а она — никакая.
  • Открытые сессии остаются открытыми: они принадлежат проекту, а не человеку, а event log только дополняется. УЖЕ подключённый удалённым сокет сессии продолжается, пока не упадёт; новый тикет (RN-108) требует viewer и отклоняется. Обрыв канала сессии по пользователю потребовал бы новой команды в engine — отдельное направление.
  • Терминал :web удалённого не является целью отключения (RN-520 обрывает только :runner); тот же пробел, та же причина.
  • Экран. В apps/web нет раздела участников workspace (измерено: единственный след /workspaces/:id/members — сгенерированный тип в api-types.generated.ts, и нет даже GET участников workspace). Здесь он не создаётся; маршрут — API, как и upsert.

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

Offboarding перестаёт быть записью в базу. owner удаляет любого другого человека, включая другого owner (titular — после передачи), и удаление реально: человек уходит из workspace и из всех его проектов, а его runner падает.

У линии потолков пять дверей и два потолка. Ни одного нового потолка не родилось: потолок 2 получил ещё одну дверь, через ту же функцию, а потолок 1 по-прежнему только в проекте. docs/security-surface.md получает заметку о пятом маршруте.

Две старые фразы становятся ложными, и их заменяет этот ADR. Фраза 0157 («у WorkspacesController нет @Delete участника») и сообщение 403 самопонижения в workspace, которое говорило "não existe rota que remova membro" и перестало это говорить — фраза ошибки не может утверждать то, что неправда.

Один лишний запрос перед записью (роль актора) и N HTTP-вызовов к engine после, по одному на проект workspace, в самом редком административном маршруте продукта.