buenomini

TrueSeal delivery guarantees

My sync library promised every message arrives. It does, sometimes twice, and I changed my mind about who should deal with that.

The TrueSeal docs used to say this about delivery: "Every blob reaches every group member. Eventually, unconditionally." That was my promise, and no sync library can keep that promise, mine included.

TrueSeal is the E2EE sync thing I've been building. A device sends an encrypted message, the relay holds it for each recipient, the recipient picks it up and tells the relay "got it, you can delete it". That "got it" is the ack.

# At-least-once in plain words

The sender keeps retrying until the relay says it stored the message. The relay keeps the message until the recipient acks it. If the ack gets lost (connection drops, app gets killed), the relay doesn't know the message arrived, so it sends it again on the next connection. Nothing gets dropped, but the same message can show up twice. That's at-least-once.

Exactly-once would mean the network and the app's database commit together, atomically, and they can't. There's always a moment between "the app stored it" and "the relay heard about it" where a crash puts you on one side or the other. You pick which side: maybe lose it, or maybe get it twice.

And my setup had the worst version of the lose-it side. The device acked as soon as the bytes arrived, before decryptinge. That was on purpose, acking only on success would tell the relay whether decryption worked. But it meant a crash right after the ack lost the message for good.

# First fix: apps dedup

Someone has to drop the duplicates. An old ADR of mine already said "callers dedup", but the SDKs dropped the sequence number, so no app could. The protocol has to stay agnostic about payloads, so I went with "the app owns it". The library now exposes a stable messageId (derived from the sender plus its sequence number), and every app stores processed IDs in the same transaction as its own data and skips the repeats.

That shipped in every SDK, and the docs got rewritten to say "at-least-once" (and to stop telling people to dedup by content hash, which is wrong, two deliberate sends can carry the same bytes).

# Less than two weeks later

Then I went through the whole delivery contract again, and my one note on it was "these developer/engineer-first primitives need to be as simple as possible to implement".

Apply that to dedup and the answer flips. "Every app must store processed IDs atomically with its own data" is easy to get wrong, and every app would have to get it right on its own. And the agnostic argument doesn't hold up, the library doesn't need to understand the payload to remember an ID it already handled. So now:

  1. The app's message handler is async, and the library waits for it to finish.
  2. The library writes the message ID to its own dedup table (kept 60 days).
  3. Only then does it ack the relay.
sequenceDiagram
    participant R as Relay
    participant L as Library
    participant A as App handler
    R->>L: deliver message
    L->>L: decrypt, verify, check dedup table
    L->>A: run handler
    Note over A: crash here, relay redelivers and handler runs again
    A-->>L: done
    L->>L: write message ID
    Note over L: crash here, redelivered but skipped as duplicate
    L->>R: ack

The library still acks messages it can't use (bad signature, not a member), so the relay still can't tell if decryption worked. All it sees is how long processing took.

It's still not exactly-once. The promise is now "a handler runs twice for the same message only if the app crashed while handling it". So handlers should still be idempotent, but the common duplicates (lost acks, sender replays) never reach app code.