Event Processors
Client-side reducers that transform encrypted event streams into application state.
Event Sourcing
Decentrl applications are pure event processors. There is no backend database, no server-side state, no REST API returning "current data." Instead:
- Events are the source of truth — stored encrypted on your mediator
- Application state is a derived projection — built locally by applying events through reducers
- Reducers are pure functions — given the same events, they always produce the same state
This is the same pattern used by databases (write-ahead logs), distributed systems (event sourcing), and state management libraries (Redux). Decentrl applies it to encrypted, decentralized communication.
Defining a Chat Application
A chat application defines event types and how they reduce into state:
Application: ChatApp
Events (data only; sender, event ID and time come from meta):
chat.create → { id, participants, createdAt }
chat.message → { chatId, content }
chat.reaction → { chatId, messageKey, emoji, removed? }
chat.presence → { } // ephemeral
chat.read → { chatId, timestamp }
State slices:
chats → reduced from chat.create (only if meta.senderDid is a participant)
messages → reduced from chat.message (grouped by chatId, keyed by senderDid + eventId)
reactions → reduced from chat.reaction (add/remove by meta.senderDid + emoji)
lastSeen → reduced from chat.presence (latest meta.timestamp per meta.senderDid)
readMarkers → reduced from chat.read (max timestamp per chatId, per reader)Each Event Applied Once
The SDK applies each event to your reducers exactly once. It remembers each (sender, event ID) pair, so a retried or re-delivered event is skipped — and with persistence it saves those keys with a snapshot of the state, so a reload doesn't re-apply what the state already contains. Every event is also validated against its schema before it reaches a reducer. Reducers don't need to dedupe:
reduce chat.message:
append { ...data, senderDid: meta.senderDid, eventId: meta.eventId } to messages[chatId]
reduce chat.reaction:
if data.removed → filter out matching (meta.senderDid, emoji)
else → replace any (meta.senderDid, emoji) and append
reduce chat.read:
readMarkers[chatId][meta.senderDid] = max(existing, data.timestamp)Events can still arrive out of order: using max() for timestamps handles that naturally.
Trust meta.senderDid and meta.eventId, never identity in data: the recipient sets both to the authenticated sender and the signed event ID, while fields like data.sender or data.id are whatever the sender wrote. meta.timestamp is the sender's claim too. So payloads carry no sender, createdBy or authorDid that reducers trust, and a message is identified by (meta.senderDid, meta.eventId).
Beyond Chat
The same pattern works for any application:
Social network:
post.create → builds timeline
post.reaction → aggregates likes/reactions
follow.create → builds social graph
follow.remove → updates social graphCollaborative document:
doc.create → initializes document
doc.edit → applies operation (CRDT or OT)
doc.comment → builds comment threadMarketplace:
listing.create → adds product listing
listing.update → modifies price/availability
order.create → initiates transaction
order.status → tracks fulfillmentThe protocol doesn't care what your events represent. It handles identity, encryption, routing, and storage. Your application defines the schema and reducers — everything else is automatic.