ADR 0173 — Workspace member removal is born, protected by the same clause
Status
Accepted. It is the FOURTH in the line opened by ADR 0127 and continued by ADR 0156 and ADR 0157. It opens the door that 0157 refused to open in passing — "A route for removing a workspace member (…) Separate decision, if someone asks for it" — by decision of the maintainer (2026-09-27, AT-115/HS-006): the route is born, protected by a CLAUSE. An accepted ADR is not edited: 0157 still says the route does not exist, and this one is what says it now exists.
Context
Offboarding a workspace member was a direct write to the database:
WorkspacesController had no member @Delete — measured during BRB-002 and
used by 0157 as part of its argument. 0157 left three questions for whoever
opened this door, and HS-006 showed they are the same conversation:
- the last
owner. Today the "never zero owners" invariant comes from a clause — removing the last one would require them to do it themselves, and ceiling 2 prevents that. HS-006 feared a removal route would reopen that path and that the guarantee would have to come from a count, which is what 0127 refused ("stated as a clause, it has no number to go stale"); - ceiling 1 in the workspace. It was left out (0157, point 3) because,
combined with the absence of a removal route, it would make
owneran absorbing state; - ceiling 2 on removal: does it inherit the delegation of project removal
(
remocaoEhAutoRebaixamento) or need its own statement, since here the "after" is always none?
Decision
1. DELETE /workspaces/:workspaceId/members/:userId is born, owner, 204.
The same minimum as the upsert: whoever changes the member list is whoever
maintains it. Idempotent like project removal — a target without a row is not
an error.
2. Ceiling 2 is INHERITED, with no new rule. RemoveWorkspaceMemberUseCase
calls remocaoEhAutoRebaixamento passing the actor's WORKSPACE role as today's
effective role (in the workspace there is no composition: the effective role is
the row) and null as the role AFTER. That branch already existed — it is the
one for a project without a workspace row, "the maximum demotion" — and it
answers question 3: the "after" here is always none, and the function already
knew how to state none. Removing oneself from the workspace is always 403,
with its own sentence (MENSAGEM_TETO_AUTO_REMOCAO_DO_WORKSPACE): whoever
clicked "remove" did not ask for a role change, the same reason project removal
got its own in 0156. The parameter names stayed the project ones: renaming them
would change the other doors for the sake of a reading, and the function's
docblock states the reading.
3. The last owner is protected by the SAME clause, without counting
owners. The proof, in the mould of the one 0157 made for the upsert:
- the route requires
owner(@RequireRole('owner')), so whoever removes is always anownerof the workspace; - nobody removes themselves (point 2);
- therefore every successful removal leaves at least one
ownerstanding: the caller themselves. Removing the last one would require them to remove themselves — the refused move.
HS-006's premise ("the route would reopen the path") was not confirmed: the
route OPENS the removal of others, but removing the last one is precisely
self-removal, and that remains closed by the clause that already closed
self-demotion. Counting stays refused for the reasons in 0157, point 2. The
proof is in a test: "with two owners, any sequence of removals leaves at least
one: whoever removes stays", and the route's owner minimum is asserted in the
controller test, because it is HALF of the proof — if the minimum drops, the
proof drops with it.
4. Removing ANOTHER owner remains possible. It is the way to revoke
ownership entirely, and it is the natural extension of 0157's point 3, which
kept demoting another owner. Ceiling 1 still has no counterpart in this
scope (question 2): the premise HS-006 saw falling — "there being no way out
of owner over HTTP" — was 0157's COST argument, and the PRINCIPLE one still
stands: ceiling 1 is a rule about hierarchy inversion, and
@RequireRole('owner') makes inversion impossible. The new route only
reinforces the cost: with it and a mirrored ceiling 1, removing another owner
would also be refused, and owner would become absorbing again.
5. The cascade, in one transaction. Removing the workspace_members row
alone would be cosmetic: the projectRole ?? workspaceRole overlay
(RN-471) would keep the removed person inside
every project where they had their own row. So, together:
- the
workspace_membersrow; - the removed person's
project_membersrows in this workspace's projects (those of other workspaces are not touched); - their credentials tied to those projects: the PROJECT device keys (by the
same column that RN-519 lists) and the
PATs — every PAT belongs to a project (
project_id NOT NULL) —, revoked with the reasonworkspace_member_removed. Without this, being re-added one day would silently reactivate a pairing the removal should have ended.
6. After the commit, the live connection drops via the RN-520 path. One
disconnectRunnerOfUser(projectId, userId) per project of the workspace — ALL,
not only those in runner mode, because the runner also connects in
container/mounted (the mirror, RN-516); one
extra project costs a sem_runner, one missing leaves standing what the
removal exists to bring down. In a try/catch that only logs: a side effect
never brings down the main effect, the rule of
RN-520.
7. The HOLDER does not leave; holdership is transferred (maintainer
decision, 2026-09-27, RN-616).
workspaces.created_by does not decide authorization (the role does, ADR 0127),
but it is what resolves the LLM credential the agents spend and the git
credential of agent actions (RN-058,
ResolveCredentialOwnerUseCase) and the owner of the spending report
(RN-060). Removing the creator through
point 4 would leave the agents spending the credential of someone no longer in
the workspace. So, under the name holder:
DELETE .../members/:userIdtargeting the holder is a named 409,criador_do_workspace, saying that a transfer is needed first and why. The point-2 ceiling comes first: the holder trying to remove themselves gets the usual 403. Point 3 still holds — the holder is anowner, and the caller is still the owner who stays.PUT /workspaces/:workspaceId/owner-of-recordis born,owner, body{ userId }. The target must ALREADY be anownerof the workspace — being holder means paying — otherwise 409titular_precisa_ser_owner. It writescreated_by, in a transaction with the role read; transferring to the current holder is idempotent. Anyownercan call it, including transferring to ANOTHER owner who did not ask: it is the same authority that already removes and demotes owners, and the target's consent would be an invitation flow the product does not have.- Spending, from the commit on:
ResolveCredentialOwnerUseCasestarts returning the NEW holder; the agent turn looks for THEIR credential for the model's provider, and git actions, their git credential. If they do not have one, the product does what it already did with any holder without a credential: the turn ends with"Nenhuma credencial cadastrada para <provider>"(RunLlmTurnUseCase/StreamLlmTurnUseCase) and the git remote read fails naming whose credential is missing. The transfer does NOT check this beforehand — it would be one read per provider without knowing which ones the projects use, and refusing because of it would lock precisely the exit of the holder who is leaving.ollamauses no credential and stays the same. Spending already recorded stays where it was:token_usagedoes not store the holder, and the report is now read by the new holder in full, history included. - There is no domain event for member or holdership changes (the upsert and the removal do not emit one either); the transfer leaves a log line with from/to/who. An audit event for all three is a separate front.
What is declared and NOT done
- MACHINE keys (
project_idnull) stay: they belong to the ACCOUNT and serve the person's other workspaces. Here they no longer reach anything — thePatAuthGuardresolves the role against the requested project, and it is none. - Open sessions stay open: they belong to the project, not the person, and
the event log is append-only. A session socket ALREADY connected by the
removed person stays until it drops; the new ticket (RN-108) requires
viewerand is refused. Dropping the session channel per user would require a new command in the engine — a separate front. - The
:webterminal of the removed person is not a target of the disconnection (RN-520 only drops:runner); same gap, same reason. - Screen. There is no workspace members section in
apps/web(measured: the only trace of/workspaces/:id/membersis the generated type inapi-types.generated.ts, and there is not even aGETof workspace members). None is created here; the route is an API route, like the upsert.
Consequences
Offboarding stops being a database write. An owner removes any other
person, including another owner (the holder after transferring), and the
removal is real: they leave the workspace and all its projects, and their
runner drops.
The ceiling line has five doors and two ceilings. No new ceiling was born:
ceiling 2 gained one more door, through the same function, and ceiling 1 stays
only in the project. docs/security-surface.md gets the note about the fifth
route.
Two old sentences become false, and this ADR is what replaces them. The one
in 0157 ("WorkspacesController has no member @Delete") and the 403
message for self-demotion in the workspace, which said "there is no route that
removes a member" and no longer does — an error sentence cannot assert what is
not true.
One more query before writing (the actor's role), and N HTTP calls to the engine afterwards, one per project of the workspace, on the least frequent administration route in the product.