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 показала, что это один и тот же разговор:
- последний
owner. Сегодня инвариант «никогда ноль owners» исходит из условия — убрать последнего потребовало бы, чтобы он сделал это сам, а потолок 2 этому мешает. HS-006 опасалась, что маршрут удаления вновь откроет этот путь и гарантия должна будет исходить из подсчёта, который отверг 0127 («формулируется условием, в нём нет числа, которое могло бы устареть»); - потолок 1 в workspace. Он остался вне (0157, пункт 3), потому что
вместе с отсутствием маршрута удаления сделал бы
ownerпоглощающим состоянием; - потолок 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')), поэтому удаляющий — всегдаownerworkspace; - никто не удаляет самого себя (пункт 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 }. Назначение должно УЖЕ бытьownerworkspace — быть titular значит платить, — иначе 409titular_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, в самом редком административном маршруте продукта.