Querying Events
Encrypted tags enable querying stored events without revealing tag strings to the mediator.
Tag Templates
Each event type declares tag templates that enable efficient querying:
chat.create → tags: ["chat", "chat.${id}", "participant.${participants[0]}"]
chat.message → tags: ["chat.${chatId}"]
chat.reaction → tags: ["chat.${chatId}"]
chat.read → tags: ["readmarker", "readmarker.${chatId}"]
chat.presence → tags: [] // no tags = ephemeralTemplate variables like ${chatId} are interpolated from the event data at publish time. The resulting tag strings are then encrypted before being stored on the mediator.
How Encrypted Tags Work
An encrypted tag is an HMAC of the plaintext tag string, keyed with a tag key derived from the identity's storage key:
tag_key = HKDF_SHA256(storage_key, salt = empty, info = "decentrl/tag-key/v1", length = 32)
encrypted_tag = base64(HMAC_SHA256(tag_key, "chat.abc"))This is deterministic — the same identity and tag string always produce the same output. This allows exact-match lookups. But without the tag key, the tag is an opaque blob, and nobody else can compute one.
When Alice wants to query "all messages in chat abc":
- She computes
HMAC_SHA256(tag_key, "chat.abc") - She sends that as a tag filter to her mediator
- The mediator performs an exact-match lookup on opaque byte strings
- The mediator returns matching encrypted events
- Alice decrypts them with her storage key
The mediator has no idea what "chat.abc" means. It just matches blobs. It also can't test guesses — checking whether a tag is "chat.abc" would require Alice's tag key. (Tags used to be Ed25519 signatures, which anyone can verify with the public key in the DID document; that let a mediator confirm guessed tags, which is why they are HMACs now.)
One thing still leaks: equal tags are linkable. The mediator can see that several events share a tag, even though it doesn't know what the tag is.
The same HMAC also makes each stored event's dedupe_key, from event|<sender>|<event ID>. It isn't a tag and you never query by it: it only lets the mediator keep one row per event, so a replayed or retried event never shows up twice in your results.
State Reconstruction
On a fresh start, the client rebuilds its entire state from the event stream:
1. Query all stored events from own mediator (using QUERY_EVENTS with tag filters)
2. Decrypt each event with storage key
3. Validate each against its schema, then apply it through the reducers once (each stored event carries the authenticated `meta.senderDid` and `meta.eventId`)
4. State is now fully reconstructed — no backend database neededWith persistence, the client doesn't start from scratch on every reload. It saves a snapshot of the state together with the keys of the events it contains and a cursor: the mediator's storage time of the newest event read (the mediator stamps each stored event with its own clock). On reload it restores the snapshot and reads only events stored from 5 minutes below the cursor; events the snapshot already contains are recognised and skipped. A snapshot from another identity, another set of state slices or another appVersion is discarded, and the state is rebuilt from all stored events.
This is pure event sourcing: the event log is the source of truth, and the current state is a derived projection. There is no server-side database. There is no API to query "current state." The client reconstructs everything locally from its encrypted event stream.