Event engine¶
State resolution fetches required mainline power-level ancestors through the shared auth-event source, including external lookup. Missing, cyclic or truncated ancestry fails closed. Room-v12 conflicted subgraphs memoize distinct vertices, validate the reachable DAG, and compute the union of paths by reverse reachability; shared paths do not repeatedly consume the event budget. See ADR-0095.
This capability note describes the Matrix event-engine foundation on top of canonical JSON.
Current scope¶
Implemented now:
- Matrix reference-hash event IDs using SHA-256 and unpadded Base64: the standard alphabet in room v3, URL-safe from v4
- Matrix content-hash calculation that removes
unsigned,signatures, andhashesbefore canonical JSON hashing - federated join and leave templates replace any existing
hashesobject with the freshly calculatedsha256content hash before signing, so a resident server's template cannot cause duplicate JSON members in the outgoing event (Matrix v1.19 adding hashes and signatures) - event envelope parsing and validation for core Matrix fields
- event signing payload construction that redacts by room version and excludes
unsignedandsignatures - Ed25519 signature attachment, Matrix unpadded Base64 encoding, presence checking, and provider-backed verification against the signed payload
- runtime-created room events now receive Matrix content hashes, reference-hash event IDs, and Ed25519 signatures before persistence
- room-version policy registry for the supported stable room versions (v3-v12) used by version-aware auth, redaction, and state-resolution lookups. Versions 1 and 2 are not supported and are refused on every path (ADR-0076): their event ID travels in the event rather than being a reference hash, and was never implemented
- room-version policy shape for event format, redaction rules, auth rules, state resolution, and event ID format
- redaction with room-version-dependent top-level and event-content key retention.
Beyond the
RedactionRulesbuckets (v1–v7, v8–v10, v11+), twoRoomVersionPolicyflags carry finer rules:redaction_keeps_aliases(m.room.aliases keepsaliasesin v1–v5 only; rooms/v6.md removed it) andredaction_keeps_join_authorisation(m.room.member keepsjoin_authorised_via_users_serverfrom v9). No version keeps any m.room.third_party_invite content (0.12.13; the redacted form feeds the reference hash, so a divergence here changes event IDs) origin_server_tsuses wall-clock Unix-epoch milliseconds per Matrix spec- event depth is persisted in the database and survives server restarts
- full Matrix v6+ event authorization rules (14-step algorithm per spec
section 10): create events, sender-domain validation, member joins/invites/
leaves/bans with join-rule and power-level checks, power-level elevation
guard (applied to the sender's own entry too, per spec rule 9.9 — a user
cannot self-elevate above their current level in a single event), removal
and demotion guard over the union of old and new
userskeys (per spec rule 9.8 — a user at or above the sender's power cannot be changed or removed by a non-superior sender), state-default and events-default power enforcement. - Legacy authorization boundaries (EVT-10, EVT-11, EVT-12(a,c)).
In v3-v9, both state and message
events[type]thresholds honor integer strings (including a single sign, leading zeroes and surrounding ASCII whitespace). Any presentcontent.third_party_inviteon an invite enters third-party authorization; a scalar, null or array is rejected rather than treated as an ordinary invite. In v3-v5 only,m.room.aliasesis allowed after the create/federation checks exactly when itsstate_keyequals the sender's complete domain, without a membership or power-level requirement. Named inbound auth events with persistent statusrejectedare refused. State resolution carries that trusted status separately from signed JSON and skips rejected own-auth fallback entries without truncating auth-chain walks;soft_failedandoutlierare not rejection. This does not blanket-remove rejected state-resolution candidates, which the spec can reauthorize. - Remaining legacy compatibility gap (EVT-12(d)). v3-v5 permit finite floating power levels with truncation. The strict event parser and scalar power-level validation still refuse these; this pass characterizes that fail-closed divergence without relaxing the signed-event parsing boundary.
- rule 1 (
m.room.create), complete since 0.12.4: noprev_events; theroom_id/senderdomain relationship for v1-v11 and the absence ofroom_idfor v12 (MSC4291); a recognisedcontent.room_version; and the creator fields (content.creatorbefore v11,content.additional_creatorsvalidated as user IDs in v12). None of these was enforced before 0.12.4, which let a federating server mint a room whose ID claimed another homeserver's domain. - rule 8, since 0.12.4: a state event whose
state_keystarts with@must have that key equal thesender.m.room.membernever reaches this rule — it returns from its own branch, exactly as the spec's rule 5 does. - rule 9 in full, since 0.12.4. Previously only 9.8 and 9.9 (the
content.usersmap) were implemented. Now also: 9.1-9.3 (type validation of the scalar keys, ofevents/notifications, and ofusers), 9.5 (every alteration ofusers_default,events_default,state_default,ban,redact,kickorinviteis bounded by the sender's power in both the old and the new direction) and 9.6/9.7 (the same two-sided bound per entry ofeventsandnotifications). The gap allowed a moderator to setusers_defaultabove their own level and take the room. - rule 8 (v3-v5, v12) / rule 7 (v6-v11) covers
m.room.power_levels. "If the event type's required power level is greater than thesender's power level, reject" runs for power_levels events before the rule-9 bounds, with theevents["m.room.power_levels"]entry as the required level andstate_defaultas the fallback. Before this fix the power_levels branch compared only againststate_default, so a level-50 moderator could rewrite a room whoseevents["m.room.power_levels"]was 100 (the createRoom default), and conformant peers rejected the event. Theeventsvalue is read with the room version's rules (string-encoded before v10; float before v6, truncated, ADR-0150). - A negative
usersentry is the user's level. Theuserslookup returnsstd::optional<int64_t>andusers_defaultapplies only when the user is absent fromusers. Before this fix a-1sentinel meant "absent", so a user explicitly muted at a negative level was givenusers_defaultand could send and kick again. Every consumer (send, kick, ban, invite, redaction targets, power-level target rules 9.8/9.9, the state-resolution power ordering and the push sender level) goes through this lookup. - one deliberate deviation, stricter than the spec. Rule 9.4 says a
m.room.power_levelsevent is allowed outright when the room has no previous one. This server instead still requires the defaultstate_default(50) in that case. ItsAuthEventMapis built from local resolved state rather than from the event's declaredauth_events, so "no previous power_levels" can also mean "this server does not have that state yet", under which a blanket allow would be a fail-open. The room-creation bootstrap is unaffected (the creator resolves to 100, or to infinite under v12). This can only reject where the spec would allow, never the reverse. content.eventsnever resolves a scalar power key. That map keys event types to levels, soban,kick,redact,invite,users_default,events_defaultandstate_defaultare not names it can carry. Until 0.12.4extract_power_level_keyfell back to it when a top-level key was absent, which let a sender smuggle a scalar value in under an event-type name and, for example, drop the effective ban level to zero. Kick/unban and ban additionally require the sender's power to be strictly greater than the target's own power level (spec rules 5.4/6.2) — theredact/banpower levels are not consulted when authorizingm.room.redactionitself (issue #410); it is authorized through the sameevents[type]/events_defaultpath as any other message event.redactonly governs whether an already-authorized redaction is applied to its target (see docs/matrix-v1.19-spec/server-server-api.md#redactions)- auth-event map construction from current room state for authorization
- auth checking wired into the event sending path: composed events are authorized against current room state before persistence; auth is conditional on the presence of a create event in room state to allow the simplified room-creation bootstrap flow
- auth checking wired into the inbound federation PDU path:
pdu_sink(ingest_pdu_event,local_http_router.cpp) originally ranauthorize_event_against_auth_eventsagainst the room's current resolved state only, before callingstore_event_with_state; events that fail auth returnrejected_authwithout a non-200 HTTP status (per Matrix /send spec — non-200 causes the remote to back off all federation). Superseded by ADR-0064 phase B2 (above): the current-state check is now the soft-fail step (step 6), run only after separate auth checks against the event's ownauth_events(step 4, reject) and the state before the event (step 5, reject) — see "Phase B2: the receipt-order auth checks themselves" above. - auth checking wired into the federation membership endpoints —
send_join,send_leave, andsend_knockrun the identicalauthorize_event_against_auth_eventsgate, against the room's current resolved state, before the membership acceptor persists the event or the membership row. Until 0.12.7 these endpoints verified only the inbound PDU's Ed25519 signature, content hash, and sender/origin consistency, then checked that the room existed — but signature verification establishes who signed an event; it never establishes whether they are permitted to make the transition. A remote server holding any valid signing key could join a user into an invite-only room, or move a membership it had no power level to move, simply by presenting a correctly signed PDU. Both write paths into the store — the ordinary/sendtransaction path above and the membership acceptor here — must enforce this gate: a rule enforced on only one of two paths into the same store is not enforced at all - membership endpoint/content agreement (0.12.16, FED-6):
send_join,send_leave, andsend_knockvalidate the membership value, event type, authenticated-origin/sender relationship, sender/state-key equality, and URL room/event identifiers before invoking the acceptor. The main-process mutation sink repeats these structural checks before changing state and derives the stored membership from the validated event, never the route - receipt EDU ACL enforcement (0.12.16, FED-8): the main-process mutation sink checks the authenticated sending origin against each receipt room's current server ACL. Denied rooms are skipped individually so a mixed EDU still updates allowed rooms; this also guards direct worker-relay calls
- creator identity is room-version-aware: room versions v1-v10 use
m.room.create.content.creator, while v11+ use the create event'ssenderand ignore a legacycontent.creatorfield. Creator identity alone does not grant room membership or permit ordinary event sends. - creator bootstrap joins are accepted only when
prev_eventscontains exactly the authoritative create event ID. Versions before v12 use the ID from persisted room state; v12 derives the create event ID from the room ID. Additional v12 creators receive the specified power privilege but not implicit bootstrap membership. Locally-created v11+ create events remove a client-supplied legacycreatorproperty. - v2 state resolution algorithm: conflicted/unconflicted partition, power events (spec definition) sorted by reverse topological power ordering and auth-checked first, remaining events ordered by the mainline of the partially resolved power levels (transitive power-levels walk with the spec's ∞ sentinel for events with no mainline ancestor), iterative auth-based conflict resolution
- helper functions for power-level extraction, membership parsing, sender domain extraction
- restricted-room join auth accepts a valid
content.join_authorised_via_users_serverwhen the named resident user is joined and has sufficient invite power. The auth rules see only that the authorising server's signature is present (they have no key resolver); every path that admits a remote PDU verifies it cryptographically withfederation::verify_join_authorising_server_signaturefirst:/send,send_join, worker relay, backfill ingestion, and thesend_joinstate this server stores when it joins a remote room (0.12.31, ADR-0171). The membership and power checks need the authorising user'sm.room.memberevent in the auth map, which "Auth events selection" names for such a join. State resolution has always selected it; since 0.12.32 the router's ingestion maps (build_auth_event_map_from_entries) select it too, so restricted joins received over federation are accepted when they should be - the sender's signature is checked against every key ID listed for its
server, not only the first (0.12.36, R05, ADR-0183):
federation::authorize_federation_pdu_senderon/send,send_*,/invite, worker relay and backfill, andfederation::verify_server_signatures_on_eventfor thesend_joinstate this server stores. Candidates are theed25519:key IDs in the event's order, without repeats, each through the caller's admitted lookup; more than four is refused before any lookup. A key that does not resolve, or expired before the event'sorigin_server_ts, is skipped; every other signature must verify, and at least one must ("If all signatures from known unexpired keys ... are found to be valid"). The authorising-server check (ADR-0171) still accepts any one valid signature, as its auth rule says - as the resident of a restricted room (0.12.36, R04, ADR-0184, ADR-0185),
homeserver::decide_restricted_joinchecks the allow conditions against this server's current state (at most 32 conditions; a room it holds no state for, or is no longer joined to, cannot be validated).make_joinnames the lowest local user ID with invite power injoin_authorised_via_users_serverand that user's member event inauth_events, or answers 400M_UNABLE_TO_AUTHORISE_JOIN, 400M_UNABLE_TO_GRANT_JOINor 403M_FORBIDDEN; federation workers relaymake_jointo main. Thesend_joinacceptor co-signs a join naming a local user only when the decision authorises that user, before any auth check or storage, after dropping any signature filed under this server's name, and refuses it unsigned otherwise. A local user's join into a local restricted room carries the field, andcompose_signed_eventrefuses any local join naming a local authoriser the decision does not accept (soPUT /state/m.room.membercannot skip the check) - a content-hash mismatch on a direct membership endpoint redacts, it does not
reject (0.12.36, R06):
handle_send_membershipandhandle_inviterefuse only a signature failure (400M_INVALID_PARAM); the acceptor and the invite handler redact and then store, authorise, sign and return the redacted copy. Thesend_joinresponseeventis the copy this server stored, never the raw request body - each join rule is recognised only in the room versions that define it
(
RoomVersionPolicy::knock_join_rulev7+,restricted_join_rulev8+,knock_restricted_join_rulev10+). From v10,knock_restrictedjoins follow the restricted rule (rules 4.3.5) and knocks acceptknockorknock_restricted; before v7 aknockmembership is rejected. A join rule the version does not define, including the non-specrestricted_v2, falls through to "Otherwise, reject" (0.12.13) - self-leave (
membership: "leave", sender matches state_key) is only authorized when the sender's current membership isinvite,join, orknock— a banned or never-joined user cannot self-leave (which would otherwise flipbantoleaveand let a banned user re-enter via a normal join/knock) - an
m.room.memberevent with an unrecognizedmembershipvalue is rejected outright rather than defaulting toleave - third-party (3PID) invite auth: an
m.room.memberevent withmembership: "invite"and acontent.third_party_inviteproperty is authorized against the full spec rule tree — target-not-banned,signed.mxid/tokenpresence,signed.mxid == state_key, a matchingm.room.third_party_invitestate event forsigned.token, sender match against that event's sender, and Ed25519 signature verification of the canonicalsignedpayload ({mxid, sender, token}) againstcontent.public_key/public_keyson them.room.third_party_inviteevent.m.room.third_party_inviteevent creation itself is gated on the room's invite power level (not the genericstate_defaultpower other state events use).crypto::ed25519_verifyis a new stateless verification entry point (no signing-key store needed) used for this and by the productionEd25519Provider - unit coverage for content hashes, reference-hash event IDs, event envelope parsing, signing payloads, signature attachment/verification, redaction, room-version fixtures, full auth rule steps, and v2 state resolution
- inbound federation PDU size and field-count limits enforced before
hashing/authorization (0.12.13, M3). Raw PDU JSON is capped at 65 536
bytes,
prev_eventsat 20, andauth_eventsat 10, all checked inparse_inbound_pdu_envelopebefore any content hashing or signature work.sender,room_id,state_key, andtypeare capped at 255 bytes bymatrix_id_is_validandparse_event_envelope. Seeinclude/merovingian/events/limits.hppand ADR-0067. content.m.federate: falseenforced for every room version (0.12.13, M4). Authorization rule step 3 now rejects cross-domain senders in v1–v5 rooms that disable federation, not only in v6+ and v12. Whenm.federateis absent ortrue, cross-domain senders remain permitted in all versions. Test:tests/conformance/test_event_auth_rules.cpp([m04]).
Not implemented yet:
- full Matrix room-version conformance fixture suite
- joining a local user through another resident when this server meets a
restricted room's condition but has no local user who can invite (refused
with 403 today), and sending an accepted
send_joinevent on to the room's other servers - the
PUT /_matrix/federation/v1/exchange_third_party_invite/{roomId}endpoint (signing an intermediate invite on behalf of a remote inviter).POST /invitewith a 3PID address (a real identity-server round trip,homeserver::invite_user_by_threepid, 0.12.6) andthird_party_signedvalidation on/join(verify_third_party_signed) are implemented and covered bytests/conformance/test_3pid_invite_conformance.cpp - room versions 1 and 2 event handling: both are registered in
room_version_policy.cpp, butEventFormat::room_v1_v2is never consumed andEventIdFormathas onlyreference_hash, so the$localpart:serverevent-ID format those versions use is not implemented (seedocs/todos/capability-gaps.md)
Runtime wiring¶
The local runtime path now serves room creation, local joins, local sends,
state summaries, joined room listing, and bounded sync summaries through the
client-server Matrix JSON adapter. Local sends compose Matrix-shaped room
version 12 events, persist the active server signing key, store signed event
JSON, record previous-event, auth-event, and signature rows, and authorize
events against the current room state before persistence. Sync deliberately
returns event counts and membership summaries rather than plaintext event
bodies, preserving the server-blind encrypted-room posture while the full
Matrix sync stream is still unfinished.
State-event materialization follows Matrix semantics: an event is a state event
when the state_key member is present, including when that state key is the
valid empty string.
Signing boundary¶
The event signing payload follows the Matrix event signing pipeline:
- Redact the event with the room-version policy.
- Remove
unsignedandsignatures. - Serialize as canonical JSON.
- Sign the canonical bytes with the active Ed25519 provider and store the
signature as Matrix unpadded Base64 under
signatures.<server>.<key_id>.
Step 3 uses canonicaljson::serialize_canonical_strict(), not the general-purpose
serialize_canonical() — it fails closed with CanonicalJsonError::float_not_allowed
on a Value tree containing any double, rather than serializing one. event_id.cpp's
reference-hash computation and signable.cpp use the same strict entry point. Floats
are already excluded from this path in practice by parse_lossless() rejecting them
at the parse boundary, but the strict serializer closes the same gap for any Value
tree built programmatically rather than parsed.
Verification rebuilds the same canonical payload, decodes the Matrix Base64 signature, and delegates Ed25519 verification to the configured provider.
signing_key_id_is_valid delegates the key id's shape to
crypto::ed25519_key_id_is_valid, which requires the ed25519: algorithm
prefix. A Key ID is the algorithm and version combined (spec, Appendices
§Cryptographic key representation); a bare version string is not one.
The signing diagnostic never carries the payload. sign_event_for_server
emits each payload's byte count and SHA-256 digest, not the canonical signing
payload or the signed event JSON. Logging those put full event bodies in debug
logs, which src/observability/AGENTS.md forbids, and under field names the
redactor did not recognise. The digests are still enough to compare
byte-for-byte with a federation peer when triaging a BadSignatureError: equal
digests mean equal payloads.
Runtime signing keys are generated from system entropy using
crypto_sign_keypair rather than being deterministically derived from public
server identity values.
The seed is persisted encrypted under the master key and reused across
restarts: a new key is minted only on first boot or through an explicit
rotation, and a key whose published valid_until_ts window has lapsed is
republished rather than replaced. Silent regeneration is prohibited, because a
key minted behind the running signing provider can sign nothing and no peer has
ever seen it. See ADR-0017
and docs/crypto-boundary.md.
Event IDs¶
make_content_hash calculates the Matrix content hash over the unredacted
event after removing unsigned, signatures, and hashes. make_reference_hash
redacts the event, removes unsigned and signatures, canonicalizes, and
calculates the SHA-256 reference hash. make_reference_hash_event_id prefixes
the unpadded Base64 reference hash with $: URL-safe from room v4, the standard
alphabet in v3 (RoomVersionPolicy::event_id_url_safe_base64, rooms/v3.md).
make_reference_hash itself always returns the URL-safe form.
The redaction algorithm is room-version specific, so every entry point takes the
event's own RoomVersionPolicy — including make_content_hash_id, which until
0.12.9 hardcoded version 12 regardless of the event. There is deliberately no
defaulted version: an ID computed under the wrong room version does not match
the one other servers derive.
verify_pdu_content_hash extracts the claimed hashes.sha256 field from an
inbound PDU and compares it against the result of make_content_hash. Inbound
federation PDUs are rejected before reaching the pdu_sink when this check
fails, as required by Matrix Server-Server API v1.19.
For room version 12 (MSC4291) the room ID is the m.room.create event's
reference hash with a ! sigil — the same hash as the create event ID, which
uses $ — and carries no :server domain. create_room composes the create
event first to derive this ID. A v12 create event has no room_id and no
prev_events, so two from one user with the same content in the same millisecond
would be byte-identical and derive the same room ID; create_room therefore stamps
each create event strictly after the previous one it composed
(HomeserverRuntime::last_create_event_ts_ms) and moves past any timestamp whose
room ID already exists, as after a restart with the clock stepped back
(ADR-0156).
The create event is also excluded from every other
event's auth_events, because the room ID already implies it. Room versions 10
and 11 keep server-scoped IDs (!opaque:server), a room_id in the create event,
and the create event in auth_events.
Runtime event graph¶
Runtime events store their immediate prev_events, current-state-derived
auth_events, and attached server signatures in the persistent store.
Auth-event maps are built from current room state for authorization checking.
The v2 state resolution algorithm resolves conflicting state using reverse
topological power ordering for power events and the mainline ordering (based
on the partially resolved power levels) for the remaining events. Room v12
uses state-res v2.1: the same algorithm with three modifications (below).
In room v12, reverse topological power ordering resolves the implicit
m.room.create event from each candidate's room_id through the event index
or event_lookup. It uses that create event to recognise both its sender
and content.additional_creators as having infinite power, even though the
create event is absent from explicit auth_events. Each candidate still
uses its own explicit power-level ancestor, not the shared resolved state.
An unavailable implicit create event, or one with the wrong type/state key,
makes the ordering fail closed. The [evt-8] conformance regression covers
creator ordering and unavailable create-event lookup.
Auth difference, full conflicted set, and the v12 conflicted state subgraph¶
Until 0.12.13, resolve_state_v2 only ever considered power events that
appeared literally as one of the two conflicted state groups' entries. The
spec's Algorithm step 1 is broader: "Select the set X of all power events
that appear in the full conflicted set", where the full conflicted set is
the conflicted state set plus the auth difference — events reachable only
through the auth_events chains of the conflicted events, not present as a
literal value in either fork's state. A power-level change hidden this way
(e.g. authorising a later ban) was silently invisible to the old resolver,
letting the ban it authorised be dropped even though the promoting event was
never actually in dispute — two conformant servers could resolve the same
input differently. StateResolutionRequest::event_lookup gives the resolver a
way to fetch those ancestor events (the persistent store, in production; see
homeserver::make_store_event_lookup, state_bookkeeping.cpp, used by
compute_state_before and recompute_current_state), and resolve_state_v2
now computes ∪Ci − ∩Ci (the auth difference across the
submitted state groups' full auth chains) before selecting X.
Room v12 (state-res v2.1, rooms::StateResolutionAlgorithm::v2_1) makes three
further changes (rooms/v12.md — "State resolution"):
- The iterative auth checks (Algorithm steps 2 and 4) start from an empty state map instead of the unconflicted state map.
- A new conflicted state subgraph — the union of every path along
auth_eventsedges between any pair of events in the conflicted state set, endpoints included — is computed. - The full conflicted set additionally includes that subgraph.
Modification 1 means almost nothing is present in the running state on the
first pass for a v12 room, so the iterative auth checks' own fallback matters
far more there: per the spec's "Iterative auth checks" definition, "If a
(event_type, state_key) key that is required for checking the authorisation
rules is not present in the state, then the appropriate state event from the
event's auth_events is used if the auth event is not rejected."
build_auth_event_map_from_state now implements this fallback for every
slot (create, power_levels, join_rules, sender/target member,
third_party_invite, authorising_user_member) — it did not before, which made
v12 support incomplete regardless of the algorithm-selection fix, since the
empty starting map meant almost every event's own auth context needed it.
0.12.13 (ADR-0064 phase B2) fix: the create-event deadlock this fallback
cannot break for v12. The fallback above reads a candidate event's OWN
auth_events when the running state lacks a key — but rooms/v12.md rule 3.2
requires every v12 event's auth_events to omit m.room.create (MSC4291:
the create event is implicit in the room ID). With modification 1's empty
starting map and no v12 event ever naming create in its own auth_events,
build_auth_event_map_from_state's create slot could never be filled by
either path — every v12 candidate's very first iterative auth check would
fail Step 2 ("room has no create event"), and no v12 room's state could ever
be resolved at all. Fixed by seeding resolved's m.room.create entry from
unconflicted at v2.1 initialization: a room's create event cannot
genuinely be in dispute (there is exactly one per room, and every submitted
state group already agrees on it — that agreement is precisely why
partition_conflicted_state places it in unconflicted, never
conflicted), so seeding just this one invariant entry does not reintroduce
anything modification 1's empty-start rule exists to guard against (mutable,
genuinely-contestable state like membership or power levels).
The auth-chain walk is bounded by
security.federation.state_resolution.max_auth_chain_walk_events
(default 131,072 visits) and fails closed: a missing or
unreachable event, an over-budget walk, or an exhausted lookup returns an
unresolved StateResolutionResult rather than resolving on a partial chain —
compute_state_before/recompute_current_state (phase B1/B2, above) treat
an unresolved result as PduIngestionStatus::missing_prev_state, not a
rejection. This does not apply to the iterative auth checks' own auth_events
fallback above, which is intentionally soft — an ancestor it cannot reach
only fails that one candidate event's own auth check (as it already did
before the fallback existed), not the whole resolution.
As of 0.12.18, security.federation.state_resolution also configures group
count, entries per group, aggregate submitted entries, distinct/conflicted
keys and mainline depth. The aggregate entry limit is checked before indexing
and prevents per-group capacity from multiplying unchecked. Defaults allow
65,536 entries per group, 131,072 submitted entries in total and 65,536 keys.
The policy applies to local event creation and federation bookkeeping;
changing it requires restart. Single-parent bookkeeping bypasses fork
resolution, so its full state can exceed a deliberately lowered resolver cap.
Three properties of the ordering are easy to get subtly wrong and are worth stating explicitly, because all three were defects (the first two until 0.12.9, the third until 0.12.13):
- Sender power is read through the room version's rules, not an integer-only
accessor. Room versions 1-9 permit power levels encoded as JSON strings and
the authorization path already parses them, so an integer-only read in the
resolver silently demoted such a sender to
users_defaultand let a lower-power event win.reverse_topological_power_sorttherefore takes aRoomVersionPolicyand reads levels througheffective_sender_power, which followsevents::power_level_encoding(policy)(integers; plus strings in v1-v9; plus floats, truncated, in v1-v5: EVT-12 (d), ADR-0150). The resolver andauthorization.cppmust agree on this: if they disagree, events are ordered by a power level the auth rules cannot see. - Code outside the auth rules reads a single power-level value through
events::decode_power_level(value, encoding), the decoder the rules use for every level and every entry ofevents,notificationsandusers. Push evaluation reads a room'snotificationslevels this way, so a v1-v9"room": "100"raises the@roomthreshold to 100 instead of falling back to 50 (PR #523 review). A value the rules would not read (a string in v10+) is left out, and the evaluator's default applies. - The auth-event map built from resolved state includes
authorising_user_member. The restricted-join branch of the auth rules needs them.room.memberevent of the user named bycontent.join_authorised_via_users_serverto validate the join. Omitting it made every valid restricted join in the conflicted set fail auth, which diverges room state across federation rather than merely rejecting one event. - Sender power for the ordering comes from the candidate's OWN
auth_events, never from the candidate's own new content and never from a shared state map. Spec (rooms/v10.md — Reverse topological power ordering, rule 1): power is read "looking at their respective auth_events". Until 0.12.13,power_level_from_eventread anm.room.power_levelscandidate's sender power from that same event's own new content — a self-elevating power_levels event (one that grants its own sender a level it does not actually hold) would rank itself by the level it claims, not the level its ownauth_eventsancestor actually grants it. Every other candidate's power was read from a shared "unconflicted" state map, which is simply the wrong source: a candidate'sauth_eventscan name a differentm.room.power_levelsevent than whatever happens to be unconflicted at resolution time. Both cases are fixed byfind_auth_ancestor_contextwalking the candidate's ownauth_events(via the same fail-closedAuthChainEventSourceused for the auth difference) and feeding the result toevents::effective_sender_power(moved out ofauthorization.cpp's anonymous namespace and exposed publicly, so the ordering and the auth rules read power identically, including MSC4289 creator-infinite power for room v12).reverse_topological_power_sortcorrespondingly takesEventJsonIndex+EventLookupFninstead of aStateMap unconflicted, and returnsoptional<vector<StateEventReference>>—nulloptwhen anauth_eventsentry needed to answer the question cannot be resolved (fail closed, ADR-0063), never a default value guessed from a partial chain.mainline_orderwas checked against the same defect class and found not to have it: it already reads each event's ownauth_eventspower-levels ancestor (it never computes a power level at all, only a mainline position), never a shared map.
Two further defects in this area (security audit 2026-09-29, EVT-3 and EVT-4) were fixed after 0.12.14:
- The unconflicted/conflicted partition is decided per key from a complete
tally. Spec (rooms/v10.md — Definitions): a key is in the unconflicted
state map only if it is "present in every Si with the same value V";
otherwise all its values are conflicted.
partition_conflicted_statepreviously decided incrementally while walking the groups, so with three or more groups (A, B, A,A, B, B,A, B, C) a later group could re-admit a key an earlier group had already moved to the conflicted side. The key then sat in both maps, and Algorithm step 5 ("replace any event with the event with the same key from the unconflicted state map") overwrote the resolved value with whichever group came last — resolution depended onprev_eventsorder and a ban in one fork could be dropped. The function now tallies, per key, the number of groups holding it and the set of distinct event ids; a key is unconflicted only when every group holds it with exactly one event id. Step 5 itself was correct and is unchanged; v2.1 (room v12) uses the same partition and the same step 5 (only the iterative auth checks' starting map differs). - The reverse topological power ordering is Kahn's algorithm, not a plain
sort. Spec: "the lexicographically smallest topological ordering based on
the DAG formed by auth events ... ordered from earliest event to latest ...
found by sorting the events using Kahn's algorithm ... at each step
selecting, among all the candidate vertices, the smallest vertex". A
power-descending
stable_sortignoredauth_eventsedges between candidates, so an admin's ban that cites a moderator's kick (the kick is the target's membership, hence an auth event of the ban) was applied first and the kick then overwrote it.reverse_topological_power_sortnow builds theauth_eventsDAG restricted to the set being ordered (edge from an auth event to each event that cites it), and repeatedly emits the smallest ready event (sender power descending,origin_server_tsascending, event id ascending). The comparison is a strict total order over unique event ids, so the result does not depend on input order. A cycle in the auth graph, or a duplicate event id in the input, returnsnullopt(fail closed) instead of looping or dropping events.mainline_orderis a plain sort on (mainline position,origin_server_ts, event id) by the spec's definition, and the iterative auth checks apply their input in the order given, so neither shared the defect.
Two additional state-resolution properties are being closed on the 0.12.16
security-audit branch (EVT-5 and EVT-9). For room versions 10–12, mainline
ordering must fetch each power-level predecessor named by the prior event's
auth_events, including events absent from submitted state groups. Missing,
malformed, cyclic, or depth-truncated required ancestors now reject the
resolution instead of silently shortening the mainline and falling back to
timestamp ordering. For room v12, the conflicted state subgraph is computed
from the auth graph reachable from conflicted roots: the result is the
intersection of vertices reachable from those roots and vertices that can
reach any root. The implementation processes distinct vertices and edges
iteratively, rejects cycles and malformed/missing graph events, and uses the
existing max_auth_chain_walk_events cap. Focused verification is pending.
Event depth is persisted alongside the event row so ordering metadata survives a server restart.
Unsolicited-room admission and pending outbound joins (ADR-0089)¶
As of 0.12.16 (FED-11), common PDU ingestion checks current local join/invite/knock interest before allocating stream IDs, fetching missing history, mutating caches or writing events. Remote-only, malformed, departed and banned membership rows do not establish interest. Room metadata or a room-version resolver cannot authorize unsolicited storage. The rejection-storage rules below apply after this admission boundary, not to arbitrary unknown-room traffic.
Outbound joins acquire a room-scoped RAII reservation before releasing the runtime mutex for the network exchange. Otherwise-uninterested rooms defer PDUs in transient queues with configurable default caps of 32 rooms, 256 distinct event IDs and 4MiB of JSON per room. Duplicates do not consume more capacity; overlaps and excess are refused. Failure or exception discards the queue without storing its PDUs.
After verified initial state and local membership commit, queued PDUs pass through the existing common sink outside every global-lock recursion level, preserving stripe-then-global lock order. This is bounded best-effort deferral, not a durable transaction queue. Explicit verified join-state bootstrap and requested-backfill writers remain separate paths; an incoming PDU cannot claim those privileges through its content. See ADR-0089 for the policy and tradeoffs.
Phase B1: state resolution wired into ingestion (ADR-0064)¶
Until 0.12.13's phase B1, resolve_state_v2 was correct in isolation (see
above) but never ran on the production inbound-PDU path: ingest_pdu_event
(src/homeserver/local_http_router.cpp) authorised each PDU against current
state only and wrote its state straight into current_state — two
concurrent, individually valid state events resolved as whichever arrived
last, so resolved state could diverge from every other conformant server on
the same DAG depending on delivery order.
Phase B1 (merovingian::homeserver::state_bookkeeping,
include/merovingian/homeserver/state_bookkeeping.hpp) makes state
bookkeeping — not yet the receipt-order auth checks themselves, still B2 —
spec-conformant for every event stored through ingest_pdu_event:
- State before an event is the state resolution of the after-states of
its
prev_events: a singleprev_eventneeds no resolution, several do. Aprev_eventwith no recorded state group fails the PDU closed (federation::PduIngestionStatus::missing_prev_state) rather than guessing — the event is not stored, but per spec ("Transactions") a transaction containing it must still return 200, since a delayed but legitimate PDU looks identical to one whose history has not been fetched yet. - State after an event is its state-before plus itself, if it is a state event.
- Every accepted event gets a delta state group for its after-state
(
database::create_or_reuse_state_group, ADR-0064 phase A) and updates the room's forward extremities. - Current state is a cache of the resolution over the forward
extremities, recomputed after each accepted event and diffed against the
previous cache so only changed
(event_type, state_key)entries are rewritten — through the samedatabase::store_stateevery other state write already used, sostate_transitionsand the existing sync wake-up path need no separate "state changed" plumbing.
See docs/database-persistence.md, "Phase B1 of spec-conformant PDU
ingestion", for the exact functions. Phase B1 completion (same 0.12.13
branch) extended this same bookkeeping to every local event-creation path
(homeserver::store_local_event, the choke point persist_composed_event
now calls) and to federated-join state seeding
(homeserver::record_event_state_with_parent) — see that doc section for
the full list of call sites.
Federated-join seeding only ever takes events for the room being joined
(FED-1, ADR-0083): filter_send_join_events_for_room drops every state
and auth_chain entry of the send_join response whose room_id — for a
v12 m.room.create, whose derived room ID — is not the joined room, before
signature checks, and ingest_send_join_state repeats the check before it
writes. Every remaining event passes a signature check, including one whose
sender is on our own server (verified against this server's own current and
retired keys). Auth-chain events are stored as outliers with no
current_state row.
Locally composed events: size limits and membership (CSAZ-9, CSAZ-6)¶
compose_signed_event (src/homeserver/room_service.cpp) returns a ComposeResult whose failure says why
no event was composed. too_large is the spec's "Size limits": an event type or state_key over 255 bytes
(events::max_event_type_length_bytes, events::max_state_key_length_bytes), checked before anything is
composed, or a signed event over 65536 bytes (events::max_event_size_bytes), measured on the signed canonical
JSON, which is the federation format the spec measures, so a request body under the limit can still be refused
once the envelope, hashes and signature are added. Every caller answers it with 400 M_TOO_LARGE, carried to the
client as OperationResult::errcode and LocalHttpResponse::errcode. createRoom also refuses client content
that cannot fit in any event (name, topic, creation_content, power_level_content_override,
initial_state) before it stores the room; content that fits alone but not once signed is refused after the
room exists, with the same error.
NUL is not representable in PostgreSQL text state keys (DB-6). The shared
events::state_key_contains_nul admission rule is used by inbound envelope
parsing and local composition. Local composition reports invalid_state_key
before any stream ordering is allocated; send_event maps it to
400 M_INVALID_PARAM. createRoom scans the complete client initial-state
list before persisting room/bootstrap state, so a bad later entry cannot
leave earlier entries or an empty-key collision behind. Empty state keys and
ordinary Unicode remain allowed. Escaped NUL inside JSON content is not a
text state key and is not blanket-rejected by this policy. This is a storage-
compatibility admission constraint, not a claim that all Matrix strings
forbid NUL. The live PostgreSQL admission regression uses the real worker-
relay handle_pdu_ingest_request and production-wired PDU sink, with a signed
successfully persisted control. Leading/interior/trailing NUL state keys
produce no captured statements or event/state/group/graph/membership/watermark
changes, including after reopening the store. Separate positive coverage
preserves empty/Unicode state keys, escaped-NUL JSON content and binary media.
This supplies inbound storage verification, not proxy or whole-finding
acceptance. Separate real no-proxy and healthy proxy/separate-worker socket
coverage now verifies controlled NUL-path refusals and unchanged business
rows/watermarks; its transport boundary and exclusions are described in
HTTP transport. The choice to reject
before mutation rather than rely on SQL binding, and to preserve one-pass
path decoding without blanket payload filtering, is recorded in proposed
ADR-0137.
persist_composed_event projects every m.room.member event it stores (ADR-0126): the memberships row,
LocalRoom.members, device-list share changes and invite metadata. That covers the membership APIs and
PUT /state/m.room.member alike; callers do not project on their own. The row and the invite change are a
database::MembershipProjection committed in the event's own transaction, with its current_state row
(local_membership_projection, DB-4, ADR-0149); finish_local_membership then updates the roster and
device lists, and realigns the row if state resolution left a different member event current. Inbound member
events (send_join, send_leave, send_knock, /invite, PDUs over /send) pass a projection the same
way. At main's startup (never in the federation worker) hydrate_local_database runs reconcile_memberships_with_current_state before building the
roster: a row is rewritten to the current member event when it is missing, when it says join and the event
does not, or when the event is newer; a row newer than its event (a federated leave, an /invite) is kept.
Phase B2: the receipt-order auth checks themselves (ADR-0064)¶
Phase B1 made the state model correct; ingest_pdu_event still authorised
every inbound PDU against current state only, treated any auth failure as
a hard rejection, and rejected (rather than redacted) a content-hash
mismatch. Phase B2 runs the spec's six-step "Checks performed on receipt of
a PDU" in order (steps 1–2, format and signature, are unchanged — format
validation stays in parse_inbound_pdu_envelope/authorize_federation_pdu,
run before ingest_pdu_event; for a PDU relayed by a federation worker, main
re-verifies the signature itself before ingestion, ADR-0071):
- Step 3 (hash).
events::verify_pdu_content_hashfailing no longer rejects the event.events::redact_event(the room version's redaction algorithm) is applied and processing continues with the redacted form, which is the JSON that gets stored —event_id,sender, and every key the redaction rules preserve are unaffected, since redaction is exactly what the spec's reference-hash/event-ID derivation already treats the event as (v3+ event IDs never depended on the pre-redactioncontentanyway). - Step 4 (auth against the PDU's own
auth_events). Before running the auth-rule algorithm,validate_auth_events_selectionenforces the spec's "Auth events selection" list — the permitted(type, state_key)pairs an event'sauth_eventsmay name (create unless v12-implicit, current power_levels, the sender's own member event, and form.room.memberadditionally the target member, join_rules, third_party_invite, and the restricted-join authorising member, each conditioned on the requested membership; join_rules only for join, invite and knock). Events this server creates follow the same list (auth_events_for_room, and themake_join/make_leave/make_knocktemplates), since a conformant server rejects any entry it does not name (0.12.13: leaves and bans used to carry join_rules). v12 (MSC4291, rooms/v12.md rule 3.2) is a hard MUST NOT: the create event is implicit in the room ID, and a v12 event naming it inauth_eventsis rejected, not merely warned about — this is enforced exactly as written, with no leniency for a "harmless" redundant reference. A named entry of a disallowed type, a duplicate(type, state_key), or one from a different room is a rejection. An entry this store has no event for at all ismissing_prev_state(the same "awaiting backfill" gap as a missingprev_event), not a rejection — phase C's fetch-then-request-state is what resolves it. Once selection passes, the auth map is built from the named events (fetched from the store), not current state —build_pdu_auth_event_mapgeneralised tobuild_auth_event_map_from_entriesso the same code builds this map, the state-before map, and the current-state map from whichever flat state snapshot each step needs. - Step 5 (auth against the state before the event), using phase B1's
compute_state_before. Failure rejects, same as step 4. - Step 6 (auth against current state). Failure here does not reject — spec "Soft failure": the event is stored, given an after-state group, and takes part in state resolution as normal, but it is never a forward extremity and never relayed to clients (see "Client-delivery filtering" below) — except that a soft-failed state event which resolution later admits into current state is shown to clients in the state section as usual.
Rejected and soft-failed events are both stored — this is the one place
phase B2 changes behaviour that was previously "at least safe": before this
phase, a step-4/5 auth failure returned early without persisting the event
at all, which broke the spec requirement that later events referencing a
rejected event can still be authorised against it. record_event_state/
record_event_state_with_parent (state_bookkeeping.hpp/.cpp) gained an
accepted flag (default true, so every phase-B1 caller is unaffected):
false still creates/reuses the event's after-state group and maps the
event to it (so compute_state_before can still resolve a later event's
prev_event_ids through it), but
database::update_forward_extremities's own accepted gate (already
built for exactly this in phase B1's schema work) skips updating
extremities — neither a rejected nor a soft-failed event is ever a forward
extremity. What differs between the two is the after-state passed in: a
rejected event's after-state is state_before unchanged (spec: "state...
calculated as normal, except not updating with the rejected event"); a
soft-failed event's is the normally-computed compute_state_after result
(spec: "participate in state resolution as normal"), so a later accepted
event chaining off it correctly propagates its state through
recompute_current_state if resolution ever admits it.
PersistentEvent::status ("accepted"|"rejected"|"soft_failed"|"outlier",
added in phase A but never set to the first two before this phase — the
0.12.12 security-audit finding that motivated this ADR) is set accordingly
at ingest time.
Client-delivery filtering. Every path that serves room timeline events
excludes status == "rejected" and status == "soft_failed": /sync
(client_server.cpp), MSC4186 sliding sync
(sync/sliding_sync_room_builder.cpp), GET .../messages, GET
.../context/{eventId} (both its events_before/events_after window and
the target-event lookup, which 404s the same as a nonexistent event id),
GET .../event/{eventId} (same 404), and search. Current-state delivery
(the /sync state section, required_state in sliding sync) is driven
entirely by current_state/recompute_current_state and is untouched by
this filter, so a soft-failed state event resolution admits is still
delivered there. Federation-facing reads
(src/federation/event_query.cpp: /event/<id>, /backfill,
/get_missing_events) are deliberately unfiltered, matching the spec:
/event/<id> may return a soft-failed event, and /backfill/
/get_missing_events only return one when the request itself references
it — which those endpoints' existing depth/reference-driven scans already
satisfy without a status check.
Transactions. A transaction containing a mix of accepted, rejected, and
soft-failed PDUs still returns 200 with per-PDU accounting (spec: "If an
event in an incoming transaction is rejected, this should not cause the
transaction request to be responded to with an error response").
PduIngestionStatus gained soft_failed, threaded through the worker IPC
status mapping (src/homeserver/worker_pool.cpp,
src/federation_worker/worker_event_loop.cpp) and the /send transaction
per-PDU switch (src/federation/inbound_request.cpp) alongside the
existing missing_prev_state.
Dead plumbing removed. PduStateConflictContext,
PduIngestionResult::state_conflict, StateConflictResolver,
ResolvedStateApplier, runtime.federation.state_conflict_resolver, and
federation::apply_state_resolution_v2 are gone — ADR-0064 identified them
as unreachable (nothing ever set state_conflict, so the resolver was
never invoked). PduIngestionStatus::rejected_state_conflict stays defined,
unused by any production sink, purely so the worker IPC wire format remains
a stable exhaustive set. resolve_state_v2 itself is untouched: phase B1's
compute_state_before/recompute_current_state remain its only callers.
Not covered by this phase. The membership-acceptor path
(send_join/send_leave/send_knock acceptance) still hard-rejects a
content-hash mismatch instead of redacting, and does not run the
auth_events/state-before/current-state three-way check — see
src/federation/AGENTS.md and docs/threat-model.md.
Phase C: backfill of missing PDU references (ADR-0064)¶
Phase C closes the gap left by phases A and B: an inbound PDU whose
prev_events or auth_events are unknown to this server is no longer
returned as missing_prev_state and forgotten (and, until R03, lost for good). Instead, ingest_pdu_event
checks for missing references and, when the PDU envelope carries an origin,
fetches them from that origin before retrying the PDU.
collect_missing_pdu_referencesdistinguishes two failure modes:auth_eventsthat are absent from the persistent store, andprev_eventsthat are absent or exist but have no recorded after-state group. Aprev_eventwithout a state group cannot be used as a state-before anchor, so it is treated as missing.- If any reference is missing, the room stripe lock and the runtime mutex are
released (via
RuntimeLockReleaseand a scoped stripe-lock reacquirer) andbackfill_missing_pdu_referencesperforms outbound federation calls while the server remains unlocked for other rooms. - The backfill strategy is
/_matrix/federation/v1/get_missing_events/{roomId}first, then per-event/_matrix/federation/v1/event/{eventId}. The/get_missing_eventscall asks for up to 20 events; the whole PDU is allowed at most 16 general outbound calls by default. Snapshot materialisation has a separate 256-call default, and the entire recovery shares a 512-call budget and 45-second deadline for further work/HTTP timeouts. These configurable caps prevent a malicious or delayed origin from driving unbounded outbound work. - Every fetched event is verified independently: content hash (mismatch
redacts), Ed25519 signature, the
auth_eventsselection check, and authorisation against its ownauth_events. A fetched event whose ownprev_eventsstill lack state groups is dropped; its own state-before cannot yet be computed, so it cannot safely serve as an anchor for another event. Because of that, the/get_missing_eventsresults are handled in ascendingdepthorder, not in the order the origin listed them, so a parent is stored before any child that names it. An/event/{eventId}response (per-reference or/state_idssnapshot) is kept only if its ID, computed under the room version, is the one requested. - A verified event is stored as
status == "outlier"with a recorded after-state group (accepted=false). Outliers participate in later state resolution and can becomeprev_eventsfor subsequent PDUs, but they never become forward extremities on their own. - If a
prev_eventstill has no state group,backfill_state_ids_snapshotasks the origin forGET /_matrix/federation/v1/state_ids/{roomId}at that event (at most 65,536 IDs in each list by default, a 131,072-unique-event cap, and a shared 256-call snapshot budget). Every named event not already stored is fetched and verified. A snapshot state event whose ownprev_eventshave no state is verified instead throughGET /_matrix/federation/v1/event_auth/{roomId}/{eventId}, against its ownauth_eventsonly (ADR-0069). An/event_authentry skips verification only if it is the very event already stored under its"event_id"field (equal reference hash); a matching field alone proves nothing, since the origin writes it. The snapshot is refused if it names an event from another room, arejectedevent, a non-state event, or a duplicate(type, state_key). The missing event is then authorised against the verified snapshot as its state-before. - An event verified only against its own
auth_eventshas an unknown state-before, so it is stored as an outlier with no state group (ADR-0070). A PDU that names it as aprev_eventtherefore triggers the/state_idsfallback at that event rather than being authorised against a state derived from the event'sauth_events. Once a verified state before a stored group-less event is known and the event passes auth against it, it gains a state group and keeps its status. - If references remain missing after the capped attempt, the original PDU is not applied (and
not stored in
events), but it is no longer forgotten: see "Retention of unresolved PDUs" below. Fail-closed is preserved; backfill only turns a resolvable gap into accepted history.
Retention of unresolved PDUs (R03, ADR-0165)¶
Spec: server-server-api.md "Transactions" (the 200 maps each PDU to its processing
result; a PDU with no error "can be considered successfully handled", so the sender does
not resend it) and "Backfilling and retrieving missing events".
Before R03 a PDU that stayed unresolved after the capped backfill was counted and
forgotten while its transaction was answered 200 and deduplicated, so the event was lost.
Now, at each of the three places ingest_pdu_event used to return missing_prev_state
after admitting the PDU (references still missing after backfill, an auth_event with no
recorded event, a prev_event with no state group), defer_unresolved_pdu writes the
envelope exactly as it would have been stored to deferred_pdus with its origin and the
status is PduIngestionStatus::deferred. The pending-federated-join queue
(pending_federated_joins) is a separate, in-memory deferral and is unchanged.
/sendresult.deferredhas no entry in the response'spdusmap: the PDU is held, so it is handled.missing_prev_state(not retained: no origin to fetch from, or a bound was hit) gets anerrorentry for its event ID, because nothing will retry it. Both are counted separately in thetransaction.accepteddiagnostic (pdu_deferred,pdu_missing_prev) and audited (federation.pdu_deferred,federation.pdu_missing_prev_state). The worker relay carriesdeferredas a string over IPC both ways.- Bounds. 100 retained PDUs per room, 1000 in total, 16 MiB of JSON in total; a PDU is
dropped after 24 hours or 10 retries that did not place it
(
federation.pdu_deferral_expired). Seedocs/database-persistence.md. - Retry.
homeserver::retry_deferred_pdus(src/homeserver/deferred_pdu_retry.cpp), run byDeferredPduRetryJob(aPeriodicJob, started inmain.cppafter the dispatcher), rebuilds each due PDU's envelope (federation::rebuild_inbound_pdu_envelope, with the room version the room resolves to now) and sends it through the production PDU sink. Accepted, soft-failed, rejected and invalid outcomes remove the row (the first three store the event); anything else counts an attempt and schedules the next from 30 s, doubling to 1 h. No lock is held across the call. Accepting a PDU makes due the retained PDUs of that room whose JSON names it and wakes the job (HomeserverRuntime::deferred_pdu_retry_wake). - Signatures. The sender's signature was verified before the row was written, and a
retry re-enters after that step. The table is written only by
ingest_pdu_event.
The membership-acceptor path does not yet run this backfill step — see the
src/federation/AGENTS.md residual note and the threat-model entry for phase C.
State at a requested event¶
The inbound federation GET /state/{roomId} and /state_ids/{roomId} endpoints
return the room state resolved as of the required event_id query parameter —
the state prior to the changes that event itself induces. Because the persistent
store keeps only the current resolved state per (type, state_key), historical
state is reconstructed by walking the event DAG backward from the requested
event's prev_events: state events are identified by the presence of a
state_key member in the stored PDU JSON, and for each (type, state_key) the
ancestor with the greatest (depth, event_id) wins. This is the deterministic
linearisation that v2 state resolution produces for a conflict-free DAG, so
superseded historical state values are recovered without a stored state group.
When event_id is absent the handler rejects the request with
400 M_MISSING_PARAM; an event_id that is unknown, or that belongs to another
room, answers 404 M_NOT_FOUND (there is no fallback to the current state, 0.12.15).
Both endpoints, like /event, /backfill and /get_missing_events, answer
403 M_FORBIDDEN to a server with no joined user in the room unless the room is
world_readable (federation::origin_may_read_room). /get_missing_events walks
prev_events breadth-first from latest_events, never returning or crossing
earliest_events or the latest events themselves, skipping events below min_depth,
and returns at most 20 events (default 10), oldest first.
The client-server GET /rooms/{roomId}/context/{eventId} endpoint reuses this
same backward DAG walk (federation::resolve_state_event_ids_at(), 0.11.11)
to populate its state field with the room state at the last event the
response actually returns, rather than the room's current state, per CS API:
"The state of the room at the last event returned." Because /context's
state needs the pinned event's own contribution included when that event
is itself a state event — unlike the federation endpoints above, which stop
one step short by design — the shared walk's result is folded together with
the pinned event before being returned. GET /rooms/{roomId}/messages was not
changed: its state field is spec'd around chunk-relevance/lazy-loading, not
a DAG position, so this reconstruction does not apply to it in the same way;
see docs/todos/capability-gaps.md for that tracked divergence.
Redaction¶
Applying redactions (CSAZ-11)¶
events::redaction_target reads the event a redaction names (redacts at the top level before room
v11, in content from v11) and events::judge_redaction decides whether the redaction applies, per
rooms/v3.md to v12.md "Handling redactions": the sender's power level is at least the redact level
(default 50; room creators in v12 have an infinite level), or the sender's domain equals the original
event sender's domain. A redaction of m.room.create never applies: before v11 the algorithm would
strip room_version, which the server reads from that event.
homeserver::reconcile_redactions_for_event (src/homeserver/redaction_service.cpp) runs for every
event the main process stores, through PersistentStore::redaction_observer, and once at startup
(reconcile_all_redactions). It judges the redactions involving that event, using the power levels among
the redaction event's own auth_events so every server decides alike. When one applies, the target's stored
JSON is overwritten with redact_event's output (database::replace_event_json): the original content is
deleted from the database, not kept aside, and every read path, /_matrix/federation/v1/event and backfill
included, serves the redacted form. hashes and signatures are kept by the algorithm, so receiving servers
still verify the redacted event. A redaction whose target is unknown is held; it stays "withheld"
(PersistentStore::redactions, derived from events, rebuilt after hydration) and clients are not sent it
until it applies. sync::HistoryVisibility::can_see is the one check every client read path shares.
Clients are served unsigned.redacted_because on a redacted event (database::attach_redacted_because,
called by client_event_with_id and the sliding sync builder) and redacts in both locations on a redaction
event (database::add_redaction_compat, rooms/v11.md).
The algorithm¶
The redaction engine retains top-level keys and event-content keys according to the supported room-version policy split (room v1–v10 vs v11+). Two room-version policy flags refine this further:
create_event_is_room_id(MSC4291, room v12): them.room.createevent has noroom_id— the room ID is the create event's reference hash — so redaction drops aroom_idfrom the create event. This keeps the create event's reference hash and signing payload byte-for-byte identical to a conformant peer's; leavingroom_idin caused Synapsesend_jointo reject the create event withBadSignatureError. Every other event, and all earlier room versions, retainroom_idas a protected top-level field.privilege_room_creators(MSC4289, room v12): the create event sender and the users listed incontent.additional_creatorshold an effectively infinite power level in the authorization rules, overriding any integer inm.room.power_levels. Because that privilege is implicit, creators MUST NOT also be listed inm.room.power_levelscontent.usersfor v12+ rooms — a conformant peer (e.g. Synapse) rejects a power_levels event that names a creator withCreator user ... must not appear in content.users.create_roomtherefore omits the creator andadditional_creatorsfrom the emittedusersmap (and strips any that arrive viapower_level_content_override) for room version 12+, while pre-v12 rooms keep listing the creator at level 100.
Later work must expand this with full Matrix room-version fixtures.
The 0.12.18 operational recovery/query settings are documented in operator budgets. Calls consume budgets even on failure; exhausting a call budget blocks another fetch but does not discard its successful final response. Deadline checks between steps do not preempt synchronous discovery, signature verification or database work already in progress. Increasing these bounds cannot restore an ordinary unresolved PDU that was never retained.