Merge branch 'claude/team-key-share-rotate-4b2619'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 21s

Two conflicts, and the second is worth recording. main's M4 bullet gained the
Android signing decision while this branch rewrote the M5 line either side of it;
both are kept.

The other is an ADR number collision: two sessions each took 0010, one for vault
key rotation and one for Android distribution, and both are now on main. ADR
numbers are identifiers — "see ADR 0010" appears in code comments as well as in
prose — so leaving two would make every such reference ambiguous. The rotation
ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009
and the sync code; the Android one is referenced from README and android-port.md.
So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011,
with its title and both references updated. Nothing about either decision changes.
This commit is contained in:
2026-08-04 10:23:46 +02:00
14 changed files with 928 additions and 72 deletions
@@ -1086,7 +1086,7 @@ internal sealed partial class TeamsViewModel(
var reports = await open
.RekeyTeamVaultsAsync(
server.Grants, server.Directory, team.TeamId, remaining, cancellationToken)
server.Grants, server.Directory, server.Sync, team.TeamId, remaining, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
@@ -1102,29 +1102,7 @@ internal sealed partial class TeamsViewModel(
if (rotated.Count > 0)
{
// Says what a rotation is and is not worth, because the word promises more than it can
// deliver: from here on they cannot read this vault, and what is already in it was sealed
// under the key they used to hold.
sentences.Add(
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
+ "written from now on is readable to them.");
// The members who did not get the new key. They are still in the team and can still write,
// but until somebody wraps it to them they will find the vault stops updating.
// Distinct by id rather than by name, because two accounts can share a display name and
// collapsing them would tell somebody one person is owed a key when two are.
var missed = rotated
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
.Distinct()
.Select(Name)
.ToList();
if (missed.Count > 0)
{
sentences.Add(
$"The new key did not reach {Join(missed)} — press SHARE KEY for them, or they "
+ "will stop seeing changes.");
}
sentences.AddRange(Describe(rotated));
}
if (failed.Count > 0)
@@ -1136,6 +1114,42 @@ internal sealed partial class TeamsViewModel(
return string.Join(" ", sentences);
}
/// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
private IEnumerable<string> Describe(List<VaultRekeyReport> rotated)
{
yield return
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
+ "written from now on is readable to them.";
// Two different promises, so two different sentences. A vault whose items were all moved onto
// the new key is closed to them completely; one where some were left is closed to what happens
// next, and the difference is not the interface's to blur.
var sealedUp = rotated.Count(report => report.Sealed);
yield return sealedUp == rotated.Count
? "Everything already stored was re-sealed under the new key too, so their old key opens "
+ "nothing."
: $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new "
+ "key; the rest still hold items under the old one and will be picked up next time. "
+ "Rotate the credentials that mattered either way.";
// The members who did not get the new key. They are still in the team and can still write, but
// until somebody wraps it to them they will find the vault stops updating. Distinct by id
// rather than by name, because two accounts can share a display name and collapsing them would
// tell somebody one person is owed a key when two are.
var missed = rotated
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
.Distinct()
.Select(Name)
.ToList();
if (missed.Count > 0)
{
yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or "
+ "they will stop seeing changes.";
}
}
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
private string Name(Guid userId) =>
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();