ADR: ESIG-RDAC
Author: Flori Ava Star Weber
Co-Authors: Rasmus Kjær
Reviewers: Pierre
For room events, we’d like to sign them and verify signatures and authorization, and also be able to handle redactions. This ADR outlines the architecture of arminius-lib-event, a library crate designed to be an easy to use “one-stop-shop” for everything we’d conceivably like to do with room events throughout arminius. Since ruma already implements 85% of “core” behaviors, such as
- Verifying the auth chain for an event
- Canonicalizing events
- Redacting an event
in some form, arminius-lib-event is an abstraction, aimed at bringing all of these behaviours together, creating a type that neatly interfaces with the rest of our code.
Note
Historical Information
As of
2025-12-17, the branchflori/esig-rdaccontained a naive sketch of what such a crate might look like. After a first round of reviews pointed out this approaches’ flaws and shortcomings, this ADR was written.
Redesigning RoomEvent
I’d go with my original idea of encoding the state of the event into the type system using generics. States would, in this example, be RoomEvent<Draft>, RoomEvent<Unsigned>, RoomEvent<Signed> and RoomEvent<Verified>. These states would represent the complete lifecycle of an event in all cases:
RoomEvent<Draft>
A Draft RoomEvent contains only content and type and would replace RawEvent. It is essentially just raw user intent (e.g. “Xenia says Hello”).
RoomEvent<Unsigned>
Has auth_events and prev_events added. This would replace the InsertableEvent
RoomEvent<Signed>
Hash event_id added, and signatures generated.
RoomEvent<Verified>
Same as Signed, but with additional metadata on when the verification was attempted. Verification failures get represented through their own error type, such as a enum ValidationError { ... }, containing all the different ways in which a verification can fail.
Tip
A “verification attempt” describes an instance of the verifying function being called on a
Signedevent. The phrase makes no assumption about whether the verification succeeds. The metadata gets added regardless of whether the verification succeeded.
This invariant ensures that events are guaranteed to be verified at compile-time.
State Transition
stateDiagram-v2
userEvent: An incoming client request that should create some event
federatedEvent: Other homeservers
databaseEvent: Postgres Database
draftEvent: RoomEvent<Draft>
unsignedEvent: RoomEvent<Unsigned>
signedEvent: RoomEvent<Signed>
verifiedEvent: RoomEvent<Verified>
userEvent --> draftEvent: A user can create a draft event
draftEvent --> unsignedEvent: Our own homeserver attaches auth_events and prev_events
unsignedEvent --> verifiedEvent: Our own homeserver signs our own events. An event signed by us is inherently verified.
signedEvent --> verifiedEvent: Our own homeserver can verify foreign events
federatedEvent --> signedEvent: We can receive events from other homeservers
databaseEvent --> verifiedEvent: We can read events from the database
verifiedEvent --> databaseEvent: We can write verified events to the database
verifiedEvent --> federatedEvent: We can send verified events to other HSs
classDef eventOrigin fill:white
class userEvent, federatedEvent, databaseEvent eventOrigin
Draft to Unsigned
To make an Unsigned out of a Draft, the Draft must be contextualized into the DAG of the room.
An extremely naive function (not taking account current database schemas) could take the origin_server_ts, prev_events and auth_events. Auth checks also happen in this step. This invariant also means that Unsigned events are guaranteed to be authorized, which I think is very neat, and has the potential of eliminating a whole class of bugs, because the compiler prevents you from reaching the .sign() method without a successful contextualization function call.
Signing
RoomEvent<Unsigned> exposes a method to sign the event. This yields a RoomEvent<Verified> on success.
Redactions
A redacted event is also just one of Unsigned, Signed, Verified.
Note
Alternative considerations
I previously thought of representing redacted events through their own
Redactedstate, and using clever trait boundary shenanigans to allow state transitions from bothSignedandRedactedintoVerified. However, this potentially adds a lot of complexity for something that can be simplified/represented differently.
Verifying
The state transition from Signed to Verified is a method on Signed: async fn verify(key_provider: impl KeyFetcher) -> Result<RoomEvent<Verified>, ValidationError>. KeyFetcher would be a trait, abstracting the fetching logic, implemented by any federation client.
Alternatively…
…don’t abstract with KeyFetcher, just give a FederationNatsClient or something like that directly. Idk. Abstracting feels like we’d do our future selves a favor.
Federation Versus Client Events
RoomEvent<Verified> would expose these methods:
.to_pdu(), returning a PDU with DAG links and whatnot.to_client_event(ctx: ClientContext), whereClientContextcontainsageandtransaction_idto populate theunsignedfield. Also strips federation fields.
Intended Use Cases
User Fetching a Single Event from the Database
When fetching an event from the database, we receive it as a RoomEvent<Verified>. There are a wide variety of kinds of unsigned information that the user may want, depending on their use case. We provide some ctx: ClientContext in order to construct this context, and then use .to_client_event(ctx)
Receiving an Event from a Client
When a user sends the event, we take the event content and type they sent us, and construct a RoomEvent<Draft>. We then perform the transition Draft -> Unsigned -> Signed, and write it to the database.
Receiving an Event over Federation
When we receive an event from another homeserver, we instantiate it as RoomEvent<Unsigned>. We then use the transition Unsigned -> Verified
Sending an Event over Federation
If we have an event in the database, that another homeserver needs to see, we can fetch the RoomEvent<Verified> from the database.