The management API can create members (by logging them in), list them, and deactivate or reactivate them. It cannot
change or remove a roster row. When a memberaccess_member row and reality disagree, there is no way to put it right
short of SQL, and the person affected only ever sees the generic login failure.
Reproduced
Two admitted addresses can end up sharing one username, and the second one is then refused forever.
With @example.com in a group, _alice@example.com logged in first and got the account Alice@example.com, because
title normalisation trims the leading underscore. Its roster row records _alice@example.com. Logging in as
alice@example.com afterwards derives the same username, finds a roster row naming a different address, and is
refused at accountIsSomeoneElses(). Every later attempt is refused the same way. Deactivating the first account does
not release the name: the row still binds it to that address.
Underscores are not the only way in. Runs of them collapse to one, and NormalizedEmail checks for whitespace with
an ASCII-only pattern, so Unicode spaces reach title normalisation and are folded there too.
Related, by reading the code rather than reproduction
An account can also outlive its provisioning. MemberProvisioner throws if the reader group cannot be added, but the
account row was already committed by then, so the account exists with no group and no roster row, and later logins hit
the same refusal. A pending provisioning now survives a failed attempt, so a transient cause recovers on the next try;
a persistent one leaves the account behind.
Allowlist entries have their own version of this. DeleteGroupUseCase and AddEntryUseCase both check state on a
replica with no transaction, so an entry can outlive its group. It admits nobody, since the lookup joins the group
table, but it still holds the unique slot on its value: re-adding that address to any group answers duplicate_entry
with conflictingGroupId null, and the group listing that would show it answers group_not_found. The row is
invisible over the API and the address is unusable.
Thoughts
An endpoint that removes a roster row would cover the reproduced case and the orphaned account, and something
equivalent for entries would cover the third. Removing a member from the roster is not the same act as deactivating
them, and it changes what the member count means, so it deserves its own thinking rather than a flag on the existing
call. Preventing the username collision in the first place is a separate question and may be the better half of the
answer.
AI-authored — Claude Code, Opus 5 (max); found during a review of #1, filed at @JeroenDeDauw's request, no revisions; not yet human-reviewed; the username collision was reproduced on a dev wiki, the other two are from reading the code.
The management API can create members (by logging them in), list them, and deactivate or reactivate them. It cannot
change or remove a roster row. When a
memberaccess_memberrow and reality disagree, there is no way to put it rightshort of SQL, and the person affected only ever sees the generic login failure.
Reproduced
Two admitted addresses can end up sharing one username, and the second one is then refused forever.
With
@example.comin a group,_alice@example.comlogged in first and got the accountAlice@example.com, becausetitle normalisation trims the leading underscore. Its roster row records
_alice@example.com. Logging in asalice@example.comafterwards derives the same username, finds a roster row naming a different address, and isrefused at
accountIsSomeoneElses(). Every later attempt is refused the same way. Deactivating the first account doesnot release the name: the row still binds it to that address.
Underscores are not the only way in. Runs of them collapse to one, and
NormalizedEmailchecks for whitespace withan ASCII-only pattern, so Unicode spaces reach title normalisation and are folded there too.
Related, by reading the code rather than reproduction
An account can also outlive its provisioning.
MemberProvisionerthrows if the reader group cannot be added, but theaccount row was already committed by then, so the account exists with no group and no roster row, and later logins hit
the same refusal. A pending provisioning now survives a failed attempt, so a transient cause recovers on the next try;
a persistent one leaves the account behind.
Allowlist entries have their own version of this.
DeleteGroupUseCaseandAddEntryUseCaseboth check state on areplica with no transaction, so an entry can outlive its group. It admits nobody, since the lookup joins the group
table, but it still holds the unique slot on its value: re-adding that address to any group answers
duplicate_entrywith
conflictingGroupIdnull, and the group listing that would show it answersgroup_not_found. The row isinvisible over the API and the address is unusable.
Thoughts
An endpoint that removes a roster row would cover the reproduced case and the orphaned account, and something
equivalent for entries would cover the third. Removing a member from the roster is not the same act as deactivating
them, and it changes what the member count means, so it deserves its own thinking rather than a flag on the existing
call. Preventing the username collision in the first place is a separate question and may be the better half of the
answer.