Merge branch 'claude/vault-realtime-push-d64c61'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Canceled after 36s

This commit is contained in:
2026-08-04 16:38:42 +02:00
31 changed files with 3285 additions and 13 deletions
+56 -4
View File
@@ -356,6 +356,42 @@ Both default to your personal vault and neither moves on its own, because an ite
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
it can be filed under: a group is an item like any other and lives in exactly one vault.
### Changes that do not wait
A client holds a WebSocket open to the server — `GET /api/v1/events`, subprotocol
`dodossh.events.v1` — and the server sends a line down it whenever something you can read has moved. The
client's answer is the same delta pull it would have run on its timer, only now rather than in up to a
minute. Two things you can see: an edit somebody else makes appears while you are looking at the list, and
a vault shared with you turns up as soon as they share it.
**What is on that socket is a notice, not your data.** A frame says which vault changed and how far its
change log has got, and nothing else: no item, no ciphertext, not even which item it was. That is the
decision the rest of this rests on, and it is deliberate twice over — the server has nothing else it
*could* send, and keeping it that way means there is still exactly one path that applies a change to your
keychain, so the socket can be wrong or absent without anything being applied incorrectly.
**Polling is still there and is still what guarantees a pass.** The minute timer is unchanged. A network
that eats WebSockets, a server with `Events:Enabled` off, an older server, a proxy that will not upgrade, a
notice dropped because your machine was too slow to read it — every one of those leaves you with exactly
what this product did before the socket existed. Nothing is reachable only this way, and nothing is
supposed to become so.
Three limits are worth knowing rather than discovering:
- **One node.** Fan-out is in-process, so a deployment running more than one API replica only pushes for
writes that its own replica handled. The rest arrive on the timer. The seam for a PostgreSQL
`LISTEN`/`NOTIFY` backplane is in place and is not implemented, because an untested backplane would be
worse than a documented gap.
- **The socket does not outlive your access token.** It is closed at the token's expiry and the client
reconnects with a fresh one, which is a gap you will not see. That, plus re-reading your vault list every
few minutes, is what bounds how long a withdrawn grant can keep producing notices — and what it bounds is
*metadata*, because reading a vault needs a key the server has never held.
- **You are told about your own writes.** Your client pushed, so it has already pulled; the extra pass finds
nothing. Notices are coalesced over a quarter of a second so that a burst is one pass rather than a dozen.
The reasoning, including why this is a WebSocket rather than server-sent events and where a shared terminal
session will attach to it, is in [ADR 0012](docs/adr/0012-realtime-push.md).
### The Android head
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the
@@ -624,6 +660,20 @@ keychain plus a terminal — and the spike that gates all of it.
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
*Realtime done:* a WebSocket the client holds open, over which the server says which vault has moved so a
pull happens now rather than within the minute. What crosses it is a notice and never an item, which is
what keeps one code path applying changes and makes a dropped socket cost latency and nothing else — the
timer is unchanged and is still the guarantee. Two limits are stated rather than implied: fan-out is
in-process, so a multi-replica deployment falls back to the timer for writes another replica handled, and
a socket is closed at its access token's expiry rather than outliving the credential that authorised it.
See [Changes that do not wait](#changes-that-do-not-wait) and
[ADR 0012](docs/adr/0012-realtime-push.md).
It is also the transport a **shared terminal session** will use — one person's shell, watched or driven by
somebody else. Nothing of that exists yet, and ADR 0012 records the one decision made early so it need not
be renegotiated: session data will be binary frames on this same socket, because base64 in a JSON envelope
is the wrong shape for the one payload here that is continuous rather than occasional.
- **M3 — shared vaults**, sharing, ACLs. *Done.* Membership with roles, a public-key directory, the
append-only key log served for clients to verify against, shared vaults, and vault key grants
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
@@ -647,10 +697,12 @@ keychain plus a terminal — and the spike that gates all of it.
the rotation is re-sealed as it is pushed, so nothing reaches the server under a superseded key at all.
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
**A vault shared with you arrives on the next synchronisation pass**, within the minute, with no sign-in
and nothing to press. There is no push channel, so each pass asks the server which vaults this account can
reach before syncing the ones it already knows — which is also how a vault that has been deleted, or one
whose grant was withdrawn, stops being listed.
**A vault shared with you arrives at once**, with no sign-in and nothing to press. Each pass asks the
server which vaults this account can reach before syncing the ones it already knows — which is also how a
vault that has been deleted, or one whose grant was withdrawn, stops being listed — and the server now
says so the moment somebody wraps a key to you rather than leaving it for the next pass. Without a
reachable socket that becomes "within the minute", which is what it always was; see
[Changes that do not wait](#changes-that-do-not-wait).
**Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner
and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves