Tula
BlogRatesAboutSecurityPrivacyTerms
Tula
BlogRatesAboutSecurityPrivacyTerms
← Back to blog
Tula Chat16 August 2026·8 min read

Exactly Once Decryption in an At-Least-Once Messaging World

How Tula designed a crash-safe receive path where duplicate delivery is normal, but cryptographic state advancement happens at most once.

Exactly Once Decryption in an At-Least-Once Messaging World

Mobile messaging systems live in an uncomfortable reality: delivery is often at least once, while cryptographic state cannot safely be advanced more than once for the same logical message.

A chat message may reach a device through a WebSocket, a reconnect replay, history synchronization, or a push-triggered background fetch. The same logical message can therefore appear several times. In a normal CRUD application, a unique constraint is often enough to make this harmless. In an end-to-end encrypted messenger using stateful ratchets, it is not.

The important distinction is simple:

Duplicate delivery is normal. Duplicate cryptographic state advancement is not.

This article explains how we designed Tula's incoming-message path around that distinction, why a database uniqueness constraint alone was insufficient, and how crash recovery changed the architecture.

The Naive Receive Path

A straightforward implementation looks like this:

receive ciphertext
    ↓
decrypt
    ↓
save plaintext message
    ↓
acknowledge delivery

That looks reasonable until the process dies between two steps.

Suppose decryption advances a Signal session ratchet, but the plaintext message has not yet been saved. If the process is terminated at that exact point, the device may later receive the same ciphertext again.

The application now faces a dangerous choice:

  1. decrypt the ciphertext again, even though the ratchet may already have advanced; or
  2. refuse to decrypt, even though the user-facing message was never persisted.

Either outcome can be wrong.

This is not just a transactional consistency problem between two ordinary tables. One side of the transaction is cryptographic state.

Why a Unique Constraint Is Not Enough

A common first instinct is to put a unique key on a message table:

UNIQUE(profile_id, message_id)

That prevents duplicate rows, but it does not prevent duplicate decryption.

Consider this sequence:

decrypt ciphertext
advance ratchet
process dies
message row never inserted
same message arrives again
unique constraint sees no row
decrypt runs again

The database did exactly what it was asked to do. The problem is that the idempotency decision happened after the irreversible cryptographic operation.

The lookup must happen before decryption.

That became one of our core invariants:

The idempotency key must be checked before libsignal is allowed to advance receive state.

For Tula, the logical receive identity is:

(profile scope, message_id)

The exact local profile scope matters because one installation can contain multiple local data partitions. The message identifier alone is not enough to identify where a projected message belongs.

The Processed Inbox

We introduced a durable processed inbox inside the same encrypted store that owns the Signal receive state.

Conceptually:

processed_inbox
------------------------------
scope
message_id
projected
staged_payload
processed_at

with a primary key equivalent to:

(scope, message_id)

Before decrypting anything, the native receive path checks this table.

message arrives
    ↓
lookup (scope, message_id)
    ↓
already present?
 ┌───────────────┴───────────────┐
 yes                             no
 ↓                               ↓
never decrypt again          decrypt once
                                 ↓
                         mutate Signal state
                                 ↓
                         insert inbox record

The lookup, Signal state mutation, and inbox insertion must share one transaction boundary.

The Load-Bearing Transaction

The most important receive-side rule we ended up with is:

No receive cryptographic state advance becomes durable unless a durable processed-message record becomes durable in the same crypto-store transaction.

That means the application cannot commit the ratchet first and "save the bookkeeping later."

Conceptually:

BEGIN CRYPTO TRANSACTION

check processed inbox

if absent:
    decrypt ciphertext
    update session state
    insert processed inbox row
else:
    return existing processed state

COMMIT

If the process dies before the commit, none of those changes are durable.

If the process dies after the commit, the processed inbox proves that the ciphertext has already been consumed cryptographically.

That is the point at which "decrypt exactly once" becomes enforceable rather than aspirational.

Crypto State and Message Projection Are Different Durability Domains

The next problem is that Tula deliberately keeps two classes of data separate:

  • nonrecoverable cryptographic receive state;
  • recoverable user message history.

The processed inbox belongs with the cryptographic state because it governs whether decryption may happen again.

The user-facing message row belongs with the recoverable message store.

That means projection is a second step:

crypto transaction commits
    ↓
processed inbox now owns staged result
    ↓
project into recoverable message store
    ↓
mark processed inbox as projected
    ↓
prune staged plaintext

This creates a safe replay boundary.

If the application crashes after crypto commit but before projection, the message is not decrypted again. Instead, the projection is replayed from durable staged state.

Why Tombstones Matter

Once projection succeeds, we no longer need to retain plaintext in the processed inbox.

But we must retain the fact that the ciphertext was already consumed.

So the processed row becomes a tombstone:

projected = true
staged_payload = null

That row may look almost empty, but it is security-critical. It means:

This message identifier has already crossed the cryptographic boundary. Never decrypt it again.

Without that tombstone, pruning the staged payload would erase the evidence needed for pre-decrypt idempotency.

The Missing-Payload Case

During failure analysis we found a state worth representing explicitly:

processed = true
projected = false
staged_payload = missing

This is not the same as "unprocessed."

The cryptographic state may already have advanced, so decrypting again would be unsafe. But the application also lacks the material needed to finish projection.

We model this as a terminal integrity condition rather than pretending the message can be retried normally.

Its semantics are:

  • never decrypt again;
  • do not claim projection succeeded;
  • do not silently drain it as if nothing happened;
  • surface it through privacy-safe diagnostics.

This is a good example of why state machines are often safer than booleans in cryptographic workflows.

Races Are Expected

Once the processed inbox existed, several difficult races became ordinary.

Push and WebSocket arrive together

Both may attempt to process the same message.

Only the first transaction sees the message as absent. The second sees the processed inbox row and skips decryption.

History replay after background processing

The background task may process a message first. A later WebSocket reconnect can replay the same logical message.

That is fine. The message remains idempotent at the crypto boundary.

Duplicate push

Two background invocations may fetch the same encrypted envelope.

Fetch duplication is not the security boundary. The processed inbox is.

App crash after crypto commit

Projection resumes without decryption.

This distinction is important:

The system is allowed to repeat transport and projection work. It is not allowed to repeat cryptographic consumption.

Why We Keep the Idempotency Decision Native

Tula's receive operation is serialized and owned natively rather than by JavaScript.

There are several reasons:

  • Signal session mutation already occurs in native code;
  • the transaction boundary must include crypto state and processed-inbox state;
  • JavaScript lifecycle events are not reliable enough to own this invariant;
  • background execution may load without normal React state;
  • a second JS caller must not accidentally bypass the lock.

The public API therefore exposes a high-level receive operation rather than raw decrypt primitives.

A useful security design rule emerged from this:

Make the safe operation easy to call and the unsafe lower-level operation impossible to call from ordinary application code.

Testing the Crash Windows

Unit tests are useful, but this architecture specifically needs tests around the points where the process can disappear.

We test cases equivalent to:

Crash after crypto commit, before projection

Expected:

  • ratchet state stays advanced;
  • processed inbox row exists;
  • no second decrypt occurs;
  • projection can be replayed.

Projection committed, acknowledgement not yet written

Expected:

  • replay finds the durable message row;
  • projection remains idempotent;
  • crypto tombstone is updated;
  • no duplicate message appears.

Same message delivered several times

Expected:

  • one cryptographic decrypt;
  • one logical message row;
  • repeated transport is harmless.

Corrupt ciphertext

Expected:

  • crypto transaction rolls back;
  • message is not marked processed;
  • a later valid message can still be consumed.

The Broader Lesson

End-to-end encryption changes what "exactly once" means.

We do not attempt to guarantee that the network delivers a message only once. That would be unrealistic across push, WebSocket reconnects, retries, and history synchronization.

Instead, we guarantee something narrower and more important:

For one logical message in one local profile scope, cryptographic receive state is advanced at most once.

Everything else is designed to be replayable.

That distinction made the system simpler. We stopped trying to prevent duplicate transport and started making duplicates safe.

What We Learned

A few lessons generalized beyond messaging:

  1. Put idempotency checks before irreversible operations.
    A uniqueness constraint after the operation may be too late.

  2. Treat cryptographic state as a transactional resource.
    It should not be updated independently of the durable evidence that the operation occurred.

  3. Separate consumption from projection.
    A message can be cryptographically consumed once while its user-facing representation is safely replayed many times.

  4. Keep tombstones when deletion would erase safety information.
    Absence can mean "never processed" or "processed and forgotten." Those are very different states.

  5. Model integrity failures explicitly.
    A missing staged payload after crypto commit is not a normal retry case.

  6. At-least-once transport is not the enemy.
    It becomes manageable once the stateful boundary is idempotent.

Closing Thoughts

The receive path in an encrypted messenger is easy to underestimate because the happy path looks short: fetch, decrypt, save, notify.

The difficult engineering lives in the spaces between those verbs.

A process can die. A push can arrive twice. A WebSocket can replay history. A user can restore message history without restoring ratchets. Two execution paths can race.

The design becomes robust when those events are treated as expected rather than exceptional.

For us, the breakthrough was to stop asking:

"How do we make sure this message is delivered only once?"

and start asking:

"Which part of processing must happen only once, and which parts should be safe to repeat?"

For a stateful encrypted receive path, that is the right question.

#end-to-end encryption#signal protocol#idempotency
Share

Related posts

How We Built Decrypted Push Previews Without Putting Message Content in Push
How We Built Decrypted Push Previews Without Putting Message Content in Push
Protecting Your Identity and Money on Tula
From seven seconds to 226 milliseconds: document previews under end-to-end encryption
From seven seconds to 226 milliseconds: document previews under end-to-end encryption
Tula

One app for how Africa connects, creates & pays.

Product

  • About
  • Tula Pay rates
  • Blog

Trust & Safety

  • Security
  • Account security
  • Privacy
  • Terms
© 2026 Tula Innovations Africa Limited · Standard Street, Nairobi CBD, Kenya · Incorporated in Kenya
Exactly Once Decryption in an At-Least-Once Messaging World · Tula