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
+38 -16
View File
@@ -69,25 +69,47 @@ member is the mirror image: every team vault this machine can open is wrapped to
add. Both report per vault, including what they could not do — a vault whose key this machine does
not hold is skipped and stays flagged, because somebody else has to finish it.
## What this deliberately does not do
## The second half: re-sealing what is already stored
**It does not re-encrypt what is already stored.** After a rotation the vault's existing items remain
sealed under the generations they were written with. The person who left keeps whatever plaintext
they already pulled — that is the non-retroactive limit ADR 0001 records and no design here changes
it — and, if they kept the old vault key and later obtained ciphertext they had not already
downloaded, that ciphertext would still open to them.
> **Added 2026-08-04.** This was deferred when the decision above was taken, and is now built. The
> reasoning that made it safe to defer is what made it cheap to add, so it is recorded here rather
> than in an ADR of its own.
So the guarantee this buys is exact and worth stating in those words: **everything written from the
rotation onwards is unreadable to them.** Nothing about the past changes. The product says that
rather than the reassuring version, and the honest remediation for a departure is still to rotate the
credentials themselves.
A rotation on its own re-keys the vault and not its contents, which leaves one gap: somebody who left
with a copy of the old key could still open old ciphertext they later got hold of. `VaultResealer`
closes it by walking the vault and rewriting each item under the current key, as an ordinary upsert
against the version the server holds.
Re-sealing the vault's existing items under the new key is the remaining half and is deferred. It is
safe to add incrementally *because* of the decision above: mixed generations are readable, so a pass
that migrates items one batch at a time cannot strand anything, and a pass that fails half way leaves
a vault that still works. Building it the other way round — bumping the generation only once every
item had been re-sealed — would have needed the whole vault to move in one transaction, which is a
request-size limit dressed as an architecture.
Four properties, each of which is a decision:
- **It never decodes the plaintext.** An item is opened and the *same bytes* are sealed again under a
fresh data key. No codec, no merge, no schema version — so an item written by a newer client
survives untouched, where re-encoding it through this build's codec would silently drop the fields
this build has no concept of. It is also why one pass covers every item type, including types added
after it was written.
- **It is resumable, and needs no transaction.** Each item is one upsert, so a pass that dies half way
leaves a vault at mixed generations — which is a state that reads perfectly well, because that is
precisely what the decision above bought. Running it again picks up what is left.
- **A conflict is counted, not merged.** The pass changes no content, so there is nothing to merge:
an item somebody else wrote meanwhile is left at their version and re-sealed on the next pass.
- **A queued local edit is left alone, and re-sealed on the way out instead.** Rewriting it here would
overwrite the user's unpushed work with the version the server holds. Instead `SyncEngine` re-seals
a queued payload whose generation is stale as it dispatches it, and writes the revision back to the
outbox first so a retry sends the same bytes. That closes the one hole a pass over *stored* items
cannot see: a change made before the rotation and pushed after it would otherwise put a brand-new
item into the vault under the key the departed member holds.
The pass runs as the last step of a rotation, after a sync — a mirror that is behind produces a batch
of conflicts rather than a re-sealed vault. The interface reports which of the two guarantees was
reached, because they are different: a vault fully re-sealed is closed to the person who left, and one
where items were left behind is closed only to what happens next.
## What this still does not do
**It does not reach what they already pulled.** The person who left keeps whatever plaintext is on
their machine — that is the non-retroactive limit ADR 0001 records and no design here changes it. The
honest remediation for a departure is still to rotate the credentials themselves, and the product says
so rather than the reassuring version.
## Alternatives rejected
@@ -1,4 +1,4 @@
# ADR 0010 — Distributing the Android client, and who holds the release key
# ADR 0011 — Distributing the Android client, and who holds the release key
- Status: accepted
- Date: 2026-08-03