Capability gaps¶
Open work per capability area. Status column reflects the current level in the
not-started → planned → scaffolded → unit-covered → integrated → runtime-wired
→ spec-covered → production-gated ladder.
Reading this document. A status here is a claim, not a proof — only rows backed
by a conformance test in tests/conformance/ are independently verified. Two
failure modes have bitten us, so check for both when auditing:
- Silence is not coverage. An unlisted endpoint may be unimplemented rather than complete. The Application Service and Push Gateway APIs were absent entirely while going unmentioned here until the 0.11.11 audit.
- Historical example (before 0.11.11):
POST /pushers/setreturned 200 while discarding every pusher, and a push-rule row claimed coverage without behavior. Grep proves absence well; it does not prove behaviour.
When adding a capability, record what is missing as explicitly as what works.
Capability ledger¶
| Capability | Status | Gap |
|---|---|---|
| Build and warning policy | production-gated |
0.12.1 audit: mandatory fuzz execution was already closed and this row understated it — .github/workflows/fuzz.yml runs on every push and pull request (not just the weekly schedule), and scripts/run-fuzz-targets.sh runs under set -eu with -error_exitcode=77 per target, so any crash/leak/UBSan finding fails the job; no continue-on-error. Platform-specific production hardening enforcement added in 0.12.1: scripts/check-elf-hardening.sh statically verifies PIE/RELRO/BIND_NOW/non-exec-stack on every shipped binary (needs no live process, so it runs identically root or non-root, containerized or not) and is wired into a new ubuntu-hardened-listener-coverage CI job. Signed release artifacts, detached GPG signatures, and reproducible build verification landed in 0.10.38. |
| Secure configuration | runtime-wired |
Historical 0.12.1 note: the phase-specific CI naming and additional production-profile enforcement were deferred at that time. Current gaps: server.identity_server.allowed_bind_domains is parsed, validated and reload-planned, but no bind or requestToken path reads it, so it restricts nothing. This key is documented as inert in config/merovingian.conf.example; it remains a genuine configuration gap. database.migration_role is enforced for PostgreSQL migrations by open_postgresql_persistent_store() (since 0.12.5): it assumes the migration role for pending DDL, resets it, then assumes the runtime role. Do not treat the older 0.12.4 finding as current. |
| Runtime listener | runtime-wired |
0.12.1: real listener coverage gap found and closed — audited tests/integration/test_server_startup_hardening_flow.cpp (the only test that spawns the real merovingian-server binary) and found it proved only "stays alive, shuts down cleanly", never that it serves anything, and that every existing CI job WARN-skipped before reaching even that (debug builds never satisfy the compiler-hardening self-check; root containers never satisfy the privilege-drop self-check). Added a real HTTP GET over a real TCP socket against the live listener plus a post-shutdown connect-refused check to that scenario, and a new ubuntu-hardened-listener-coverage CI job (hardened-profile build + sudo setcap cap_setpcap+ep on the server binary) that actually reaches those assertions instead of skipping. A multi-listener thread pool remains open (per-endpoint rate-limit accounting shipped in 0.12.1 — see the HTTP transport row). Per-connection slowloris enforcement landed via connection_guard; HTTP/1.1 keep-alive (idle window, parked-connection cap, phase-aware slowloris composition) landed on feature/release-blockers (0.12.1). |
| HTTP transport | runtime-wired |
Bounded HTTP/1.1 parsing, keep-alive, per-source and per-user route buckets, tier/prefix operator overrides and admin routes are wired. Counters remain in memory by design; see docs/http-transport.md. Request-body streaming, HTTP/2 and a replacement/reviewed parser boundary remain open. |
| Runtime concurrency | runtime-wired |
Client-server and local-router handlers guard runtime state with RuntimeMutex; blocking network calls use RuntimeLockRelease to release every recursion level. Inbound federation runs its core outside the global lock and uses narrower callbacks and per-room PDU stripes; prepared database commits release the global lock. Classic and sliding-sync waits use their own configured pool. Global runtime-state contention still requires load/soak evidence; the previously observed /sync stall remains tracked in production-milestone.md. See docs/architecture.md and docs/http-transport.md for current scopes. |
| Client-server API | runtime-wired |
Sync surfaces (long polling, filters, presence, to-device, account-data, sliding sync) now wired. Static TURN credentials and OIDC discovery metadata (auth_metadata) are implemented. POST /_matrix/client/v3/rooms/{roomId}/invite with id_server/medium/address contacts the operator-trusted identity server's store-invite endpoint (via the identity module's IdentityServerClient) and persists an m.room.third_party_invite carrying the IS-issued token and public key, per Matrix v1.19 client-server-api § Third-party invites. The HS fails closed with 403 when id_server is not in server.identity_server.trusted_servers; the network call uses the SSRF-safe OutboundClient + CachedServerDiscovery outside runtime.mutex. 3PID bindings and IS unbind credentials are persisted (migrations 006 and 007); bind/unbind/requestToken drive the trusted identity server when the client supplies id_server and id_access_token. The four requestToken endpoints forward the client's send_attempt on the outbound call, for email and msisdn alike (audit R09; the identity server only sends when it is higher than the last it saw). An id_access_token becomes the Authorization header of the identity-server call, so every route that forwards it refuses one that is not printable ASCII without spaces, at most 4096 bytes, with 400 M_INVALID_PARAM before any call, and http::OutboundClient independently refuses any outbound header holding a control character (OutboundError::invalid_header; security audit run-1 sp02.is-token-crlf-header-injection, ADR-0180). Standalone unbind fails closed on transport error; account deactivation attempts IS unbind for each bound 3PID and reports partial failure as no-support. GET /context/{eventId}, notifications, OpenID token issuance, search, and /threads are routed; m.ignored_user_list is enforced on the documented delivery paths. Known gaps: SSO login and token redemption are implemented as described in the table below, but Merovingian has no first-party external IdP protocol adapter. Older statements that SSO is unrouted are obsolete; the remaining gap is the external IdP adapter. |
| Room history visibility | spec-covered |
Closed (audit CSAZ-2, CSAZ-3; ADR-0084): every client read path that returns room events or state applies sync::HistoryVisibility and sync::room_read_access_for (see src/sync/AGENTS.md). Conformance: tests/conformance/test_history_visibility_conformance.cpp; unit: tests/unit/test_sync_history_visibility.cpp; sliding sync: tests/integration/test_sliding_sync_flow.cpp ([csaz-3]). Known gaps: an event stored without an ADR-0064 state group (history from before state groups; migration 015 seeded only forward extremities) is judged by rule 2 alone from the user's own membership timeline: visible only to users who were joined when it was sent, so shared and world_readable history from that period stays hidden from later joiners and peekers until state is recorded for it (an undeterminable membership chain denies); the state "before" an m.room.history_visibility event or the user's own m.room.member event comes from the recorded state_transitions predecessor, and an event with no predecessor row gets no before-or-after leniency; "joined after the event" is judged by stream ordering, so a backfilled (late-arriving) event sorts after the user's join and is judged by the state at the event alone; the unread counts (notification_count, highlight_count, by_notification_count ordering, and the push badge) count only accepted events that pass sync::HistoryVisibility::can_see and, since 0.12.40, only events after the start of the user's current join, so they do not disclose withheld history or how many messages preceded the join (ADR-0205); /notifications rows are served as recorded (they were created for a user who was joined at delivery) and are not re-filtered; GET /events (peeking stream), the global GET /initialSync and GET /rooms/{roomId}/timestamp_to_event are not routed, so they have no read path to gate; search covers every room the caller has been in, including rooms left, kicked or banned from after a join, with each event judged by sync::HistoryVisibility (0.12.38, R16). |
| Application service API | runtime-wired |
Registration, auth, and outbound transaction delivery landed in 0.12.1 (merovingian::appservice, include/merovingian/appservice/, src/appservice/): registration files parsed as block-style YAML (registration_yaml.cpp, a strict bounded subset; JSON still loads as a fallback) — the earlier JSON-only parser could not read the spec's own example or any shipped bridge's registration.yaml build an in-memory AppserviceRegistry at startup from appservice.registration_files; as_token/hs_token are core::SecretBuffer-backed and compared constant-time; duplicate id/as_token across registrations is rejected at load time (spec MUST). as_token bearer auth plus ?user_id=/?device_id= identity-assertion masquerading is wired into the client-server dispatcher via an internal, never-on-the-wire token substitution (appservice/masquerade_token.hpp) so every existing identity-resolution call site works unmodified. m.login.application_service is implemented for both POST /register (passwordless, bypasses UIA per spec) and POST /login, and advertised on GET /login. Namespace exclusivity (M_EXCLUSIVE) is enforced for username registration and room-alias creation (PUT /directory/room/{roomAlias}, POST /createRoom). PUT /_matrix/app/v1/transactions/{txnId} is wired into the event pipeline (local sends, local membership transitions, federation-accepted PDUs) as a durable queue (appservice_outbox table, plus the appservice_txn_cursor table) drained by one background thread with exponential back-off (audit R01, ADR-0163) — see the Application Service API protocol table below. Conformance wave 3 closed the audit's appservice findings: interest includes the state_key of membership events and every alias of a room (R07); receive_ephemeral data (m.typing, m.receipt, m.presence) is sent in the transaction's ephemeral array, in memory, bounded and best-effort (R08, ADR-0190); POST /_matrix/client/v1/appservice/{appserviceId}/ping and the outbound POST /_matrix/app/v1/ping are implemented with every spec error code; PUT /_matrix/client/v3/directory/list/appservice/{networkId}/{roomId} and the third_party_instance_id / include_all_networks parameters of POST /publicRooms are implemented over the new appservice_directory table (ADR-0192); createRoom queues its own events; the queue of one appservice is capped by appservice.max_queued_events (ADR-0191). Query hooks landed too: the outbound GET /_matrix/app/v1/users/{userId} / /rooms/{roomAlias} query hooks are wired into the local-miss path — client_server.cpp's user-existence check (~9805) and room-alias resolution (~9006) call them when the identifier falls inside a registered appservice's namespace, re-checking locally afterwards; covered by tests/integration/test_appservice_query_hooks_flow.cpp. The third-party lookup surface landed separately (see the GET /_matrix/client/v3/thirdparty/* row below): all six /thirdparty/* client-server routes are wired against the outbound GET /_matrix/app/v1/thirdparty/* calls, so bridges can now be queried for their protocol/location/user directories. Remaining: overlapping exclusive namespaces across registrations are now boot-time findings, but detection compares pattern strings — two differing regexes that match overlapping identifiers are not reported, because deciding regex intersection is not something a boot-time check can do and a false conflict refusing to start a correct deployment would be worse. An appservice's sender_localpart user is checked against every other registration's exclusive users namespace as of 0.12.2 (the server name is now threaded through load_registrations()/validate_registrations()), which matters because that user is created at startup — the collision is guaranteed, not latent. |
| Push notifications | runtime-wired |
HTTP pushers persist, and locally composed and federated message/membership events evaluate each recipient's own push rules (the server defaults with their m.push_rules applied, 0.12.40) and dispatch asynchronously when server.push.enabled=true (default false). Notification history is recorded independently of gateway delivery. server.push.max_pushers_per_user caps registration (default 20), max_pushers_per_delivery caps delivery fan-out (25), and max_in_flight_deliveries caps background work (256). A gateway circuit opens after gateway_failure_threshold failures (3) for gateway_cooldown_seconds (300). A notification the gateway fails with an HTTP error, cannot be reached for, or skips with an open circuit is retried with exponential backoff (5 s doubling to 10 min, 8 retries) from a bounded in-memory queue drained by one job thread (0.12.40, R14, ADR-0206). Unread counts and the push badge follow each user's push rules (0.12.40, R15, ADR-0205). Email pushers (kind: "email") are delivered when server.email.smtp_url names an SMTP relay and refused otherwise: only to an email 3PID the account holds, as a delayed, throttled digest carrying the unread count alone (0.12.44, ADR-0219, ADR-0220). Gaps: delivery may be dropped at the concurrency cap, and queued retries and due emails do not survive a restart. See the Push Gateway table and docs/user-manual.md. |
| Authentication and sessions | runtime-wired |
Server-side access/refresh token expiry with configurable lifetimes, hashed registration-token validity, and constant-time token-hash lookups landed in 0.8.20. 0.12.4: POST /_matrix/client/v3/account/deactivate implemented — see the Client-server API protocol table below for what it does and does not do (it attempts identity-server unbinding for bound 3PIDs but does not erase content or leave rooms). Also 0.12.4: a per-account failed-login throttle (five failures/15 minutes locks the claimed user ID out for 15 minutes, 429) closes the gap where /login's per-user rate-limit tier — keyed on the authenticated user — never applied pre-login; see docs/auth-identity.md "Failed-login throttle". Since revised: failures are keyed per (account, client source) with a per-account ceiling, UIA has its own per-device counter, the counters are bounded, and the per-source threshold, per-account ceiling and window are the reloadable security.login_throttle.* keys. Add richer operator bootstrap lifecycle controls, account recovery controls, and Matrix conformance fixtures for remaining auth flows. |
| E2EE key APIs | spec-covered |
Key backup version management, session retrieval, count, etag, and session/room/batch deletion endpoints are wired. POST /_matrix/client/v3/keys/upload reports one-time key counts grouped by advertised algorithm. GET /_matrix/client/v3/keys/changes now validates from/to sync tokens and filters results to the requested stream range, with conformance coverage for missing/invalid parameters and stream-boundary behaviour. Cross-device verification: /_matrix/client/v3/keys/upload and /_matrix/client/v3/login (on new-device creation) record a device_lists.changed self-notification so a user's own devices discover each other and a freshly-logged-in device fetches existing devices' keys on its first /sync (0.11.8). Remaining: broader Matrix v1.19 semantics and additional conformance fixtures. |
| Rooms, events, and sync | runtime-wired |
Room creation/membership, event authorization, classic sync, MSC4186 sliding sync and their documented data surfaces are wired. Restricted joins (v8+) and knock-restricted joins (v10+) are evaluated by room-version policy; only room versions v3–v12 are supported (ADR-0076). Resident restricted-room joins (R04) are wired: make_join names a local user who can invite after checking the allow conditions, send_join co-signs, and local users join local restricted rooms through a condition (ADR-0184, ADR-0185). Not done: falling back to another resident when no local user can invite, and sending an accepted send_join event on to the room's other servers. Generic room account-data routes and other endpoint gaps are listed below; see conformance-audit-report-2026-10-17.md for current findings and docs/event-engine.md for authorization/state details. |
| Federation | runtime-wired |
Room-version-specific PDU verification is conformance-covered for every supported stable room version v3-v12 (v1 and v2 are not supported and are absent from the registry; see the Rooms, events, and sync row). Reference-hash event IDs and v12 implicit create/auth_events rules are covered. Simultaneously-active multiple Ed25519 signing keys are supported and conformance-covered. Remaining: broader Matrix federation conformance coverage. Key-rotation publication with old_verify_keys landed in 0.8.6. Pre-authentication remote-key resolution is budgeted (0.12.4). resolve_inbound_remote runs before check_inbound_request_signature and, for an origin it has no cached key for, calls remote_key_resolver, which performs .well-known + SRV + DNS discovery and an outbound GET /_matrix/key/v2/server. Verifying the signature requires the key, so the ordering is inherent and cannot be reversed. With security.federation.default_policy=allow, an unauthenticated sender can therefore trigger outbound requests by naming an origin in X-Matrix. The per-source-IP rate, in-flight and negative-cache budgets bound this path. Remaining: a sender distributed across many source IPs can consume the in-flight cap from each address. |
| Media repository | spec-covered |
Live remote media transport and server discovery wired in v0.7.2. Real image resampling landed in v0.8.10 via the sandboxed out-of-process merovingian-thumbnail-worker (libpng/libjpeg-turbo), generated on demand per requested geometry. Outbound remote fetch tries the mandatory authenticated GET /_matrix/federation/v1/media/download/{mediaId} endpoint first, falling back to the deprecated /_matrix/media/v3/download/{serverName}/{mediaId} endpoint only on 404 (v0.10.34). The inbound side of the same endpoint — serving locally-uploaded media to other homeservers over federation with X-Matrix authentication and a multipart/mixed response — landed in v0.10.38. Location redirects are followed via an SSRF-safe resolver with pinned addresses (v0.11.5). The multipart/mixed parser was hardened to strict RFC 2046 semantics in v0.11.6: boundary delimiters must sit on their own line, quoted/unquoted boundary tokens with optional whitespace are accepted, preambles and LF-only transport padding are tolerated, and the parser fails closed unless exactly two parts are present. 0.12.4: client-facing download/thumbnail responses now carry Content-Security-Policy, Cross-Origin-Resource-Policy: cross-origin, and Content-Disposition (inline only for the spec's "Serving inline content" allowlist, attachment otherwise) — see docs/media-repository.md "Response security headers". R17 (closed, 0.12.41): GET .../download/{serverName}/{mediaId}/{fileName} serves existing media under the caller's name (it returned 404 before), and the upload's filename is stored (media.upload_name, migration 026) and returned in Content-Disposition as the spec requires; a remote server's filename is kept with its copy (ADR-0211). Asynchronous uploads (closed, 0.12.41): POST /_matrix/media/v1/create and PUT /_matrix/media/v3/upload/{serverName}/{mediaId} with durable, expiring, per-user-capped reservations, and downloads of reserved IDs that wait up to timeout_ms on the media fetch pool before 504 M_NOT_YET_UPLOADED (federation answers 504 at once) (ADR-0212). URL previews (closed, 0.12.41, off by default): GET /_matrix/client/v1/media/preview_url and the deprecated v3 route behind security.media.url_preview_enabled, through one SSRF gate for every hop and the og:image (ADR-0213, ADR-0214). Remaining: Matrix v1.19 remote-thumbnail conformance fixtures; previews of non-HTML (e.g. an image URL) return {} rather than the image itself. |
| Database persistence | runtime-wired |
PostgreSQL migration/runtime role separation is enforced in open_postgresql_persistent_store(); the PostgreSQL worker also uses a separate login and profile-aware hydration. merovingian-db-migrate remains an offline plan printer; migrations apply automatically at server startup. Current SQLite worker gap: SQLite opens the shared file read-only, but its row loader ignores the table allowlist in TableLoadProfile and uses full hydration, so the PostgreSQL worker allowlist does not protect worker memory on SQLite. See docs/database-persistence.md, "Next starting points" and "Federation worker least-privilege role". |
| Observability and audit | production-gated |
Prometheus text exposition, bounded admin correlation headers, and structured request correlation landed in 0.8.11. Remaining: operator dashboards and retention/export policy for long-term audit archives. |
| Trust and safety | runtime-wired |
Remote HTTPS policy-server transport and admin policy-rule management workflows are now wired into registration, room creation, inbound federation, media download, and admin review flows. POST /_matrix/client/v3/rooms/{roomId}/report/{eventId} and GET /_matrix/client/v3/admin/safety/reports are conformance-covered. GET/PUT/DELETE /_matrix/client/v3/admin/safety/policy_rules/{scope}/{entity} is now conformance-covered for CRUD, invalid actions, missing rules, and non-admin guards. Remaining: moderator queues and broader workflow coverage. |
| Runtime hardening | runtime-wired |
ELF program-header probe (linker/RELRO) retired in v0.7.2; seccomp-bpf allowlist with SECCOMP_RET_KILL_PROCESS default, RLIMIT_CORE clamp, no_new_privs, and capability bounding set drop all landed in v0.8.18. OpenBSD pledge/unveil and FreeBSD Capsicum landed in v0.10.7 with CI test coverage. 0.12.1: closed a genuine fail-open gap — apply_worker_hardening() (src/platform/runtime_hardening.cpp) unconditionally returned accept() on every non-Linux platform regardless of federation.worker.apply_hardening, so the federation worker logged "runtime hardening applied" and handled untrusted federation traffic completely unsandboxed on FreeBSD/OpenBSD/NetBSD, with no test coverage anywhere. Now fails closed (worker_hardening_unavailable_decision()) and refuses to start instead. hardening_self_check.cpp's own is_ready() gate was already correctly strict (any unknown or disabled check blocks startup) — the gap was specifically in the worker's separate hardening path, not the main process's self-check. Remaining: optional in-process privilege drop and Landlock confinement for the main process itself, plus platform-specific (pledge/unveil- or Capsicum-equivalent) sandboxing for the federation worker itself on BSD, which today has no in-process sandbox at all and simply refuses to run there under apply_hardening=true. Linux Landlock confinement for the federation worker shipped in 0.12.13 (ADR-0062 part 3). 0.12.13 finding N1, parts 1 and 2 of 3 shipped: part 1 — the worker no longer opens the operator master-key file itself — main derives the IPC auth key once and hands it over a second inherited pipe fd (--ipc-key-fd). Part 2 — for database.backend=postgresql, the worker now connects with a separate, least-privilege PostgreSQL login (federation.worker.database_uri_file, provisioned by packaging/postgresql/provision-federation-worker-role.sql, granted SELECT only and no SELECT on server_signing_keys or the other tables database::table_load_profile_includes excludes) handed to the worker over a third inherited pipe fd (--db-uri-fd), the same never-a-file pattern as part 1. With PostgreSQL, database::TableLoadProfile::federation_worker excludes those rows from worker hydration even under the explicit, CRITICAL-logged federation.worker.allow_shared_database_credentials=true opt-out. SQLite has no role to separate (a single shared file), and its row loader currently ignores the table allowlist in TableLoadProfile, so the worker uses full hydration; this remains an open least-privilege gap. See ADR-0062 and docs/database-persistence.md, "Federation worker least-privilege role". Part 3 shipped in 0.12.13: Linux Landlock filesystem confinement, fail-closed unless federation.worker.allow_without_landlock=true (which logs CRITICAL). |
| Platform support | runtime-wired |
OpenBSD and NetBSD CI jobs added (full build + test suite per PR via vmactions/*-vm), joining Linux/Fedora/FreeBSD as Tier 1; platform runtime tests run on each via the suite; support tiers documented in platform-support.md. pledge/unveil and Capsicum now wired and tested in CI (v0.10.7). Linux Landlock now wired for the federation worker (ADR-0062 part 3, 0.12.13) — see hardening.md. Remaining: in-process privilege drop parity for the main process, and Landlock for the main process itself (currently service-manager sandboxing only, see hardening.md "What is intentionally deferred"). |
| Fuzzing and conformance | integrated |
Five new fuzz targets added in 0.8.14 (sync filter, config parser, stream token, query params, SRV record) with checked-in seed corpus and automated seeding in CI; confirmed in 0.12.1 that fuzz.yml already runs this mandatorily on every push/PR (see the Build and warning policy row) — that part of this row's old "remaining" text was stale. Remaining, not attempted in 0.12.1 — out of budget for this pass: property tests, load tests, chaos tests, and a broader Matrix conformance suite. Complement audited in 0.12.1: tests/fixtures/complement/ is this project's own lightweight JSON-fixture-driven flow test (test_sync_complement_fixture.cpp), not the upstream matrix-org/complement Go black-box test suite the production-milestone bullet means. A root Dockerfile exists but is not Complement-compatible (no SERVER_NAME-driven config, no federation TLS via Complement's provided CA, no fixed listen ports); there is no Go toolchain wiring, blueprint registration, or CI job anywhere in the repo. Standing up real upstream Complement in CI is a substantial, separate piece of work (Complement-shaped container image + a new CI job with Docker + Go) and was not started. |
| Supply chain and release | production-gated |
Debian, Fedora, RHEL-compatible, OpenSUSE, FreeBSD, OpenBSD, and NetBSD packages and release tarballs carry SLSA provenance attestations via actions/attest-build-provenance (verifiable with gh attestation verify) and detached GPG signatures by the maintainer key. The dependency pinning policy ([wrap-file] with SHA-256 hashes), license review artifact (merovingian.licenses.json), and reproducible static-Linux-tarball verification (scripts/reproducible-build.sh) landed in 0.10.38. 0.12.1: release-evidence recording landed — scripts/collect-release-evidence.sh, run per-platform in release.yml, records the actual compiler version, confirms link-time hardening via check-elf-hardening.sh, lists pinned dependency versions from subprojects/*.wrap, summarizes the test log Ok/Fail/Timeout counts, and lists the mandatory fuzz target names; publish-alpha-release folds every platform's evidence plus package checksums into the published release notes. |
Protocol coverage gaps¶
Client-server API¶
| Endpoint | Status | Needs |
|---|---|---|
GET /_matrix/client/v1/auth_metadata, MSC2965 OIDC |
spec-covered |
Discovery metadata implemented; full OAuth 2.0 flow remains future work. |
GET /_matrix/client/v3/voip/turnServer |
spec-covered |
Static TURN credentials from server.turn.* configuration; shared-secret time-limited usernames are not yet implemented. |
| Unrouted v1.19 operations | not-started |
These answer 404 M_UNRECOGNIZED (most are asserted as (implementation gap) scenarios in tests/conformance/test_client_server_conformance.cpp): GET /v3/admin/whois/{userId}; legacy GET /v3/events, GET /v3/events/{eventId}, GET /v3/initialSync; GET /v1/room_summary/{roomIdOrAlias} (only the unstable im.nheko.summary alias exists); POST /v3/account/password/email/requestToken and .../password/msisdn/requestToken; GET /.well-known/matrix/policy_server and GET /.well-known/matrix/support; POST /v1/login/get_token; GET /v1/rooms/{roomId}/timestamp_to_event (no route and no test). User reporting, redaction, the appservice ping and the appservice network directory have their own rows. |
GET /_matrix/media/v3/thumbnail/*, GET /_matrix/client/v1/media/thumbnail/* |
spec-covered |
Real image resampling (PNG/JPEG, scale/crop) via the sandboxed worker landed in 0.8.10. Remote thumbnails fetch via the authenticated federation media endpoint and resample locally instead of serving the full-size original (v0.11.5). Conformance coverage now verifies that a remote thumbnail request never returns 200 with the full-size original bytes when federation infrastructure is unavailable (v0.11.5). |
GET /_matrix/client/v1/media/config |
spec-covered |
|
PUT /_matrix/client/v3/directory/room/{roomAlias} |
spec-covered |
The alias must follow the appendices' grammar and be on this server's domain (400 M_INVALID_PARAM); needs the power to send m.room.canonical_alias in the room, or a server administrator (403 M_FORBIDDEN). The creator is recorded (migration 021). CSAZ-12, ADR-0127. |
DELETE /_matrix/client/v3/directory/room/{roomAlias} |
spec-covered |
Routed in 0.12.25. The alias's creator, a joined user with canonical-alias power in its room, or a server administrator may delete it (403 M_FORBIDDEN otherwise); 404 M_NOT_FOUND when unmapped. m.room.canonical_alias is not updated (the spec leaves that optional). CSAZ-12, ADR-0127. |
GET /_matrix/client/v3/directory/list/room/{roomId} |
spec-covered |
|
PUT /_matrix/client/v3/directory/list/room/{roomId} |
spec-covered |
Needs the power to send m.room.canonical_alias in the room, or a server administrator; otherwise 403 M_FORBIDDEN (CSAZ-12, ADR-0127). |
GET /_matrix/client/v3/rooms/{roomId}/aliases |
spec-covered |
|
GET /_matrix/client/v3/rooms/{roomId}/joined_members |
spec-covered |
Joined users only (a user who has left, was banned, knocked or was only invited gets 403), through sync::room_read_access_for (audit CSAZ-2). |
GET /_matrix/client/v3/rooms/{roomId}/members |
spec-covered |
A joined user gets the current roster; a user who was joined and has left or been banned gets the roster as of the event that ended their join; a knock, an invite, a declined invite and a forgotten room are 403 (audit CSAZ-2, sync::room_read_access_for). at is honoured: a plain stream ordering (the start/end/prev_batch form) or a /sync next_batch, answered from the state group of the newest event at or before it, and never later than a departed user's leave; an at that is neither is 400 M_INVALID_PARAM. membership/not_membership are applied as a conjunction, not the spec's "or" (unchanged). A world_readable room does not open the roster to a non-member: the spec's 403 text for this endpoint has no peek exception. |
GET /_matrix/client/v3/rooms/{roomId}/state and /state/{eventType}/{stateKey} |
spec-covered |
Same gate as /members: current state for a joined user, state as of the event that ended their join for a user who left or was banned, 403 otherwise. A ban is treated as a departure ("weren't previously a member" is the spec's only 403). |
GET /_matrix/client/v3/rooms/{roomId}/initialSync |
spec-covered |
Deprecated by the spec; kept for peeking. Members and former members (state as of departure for the latter) and any user peeking a room whose current visibility is world_readable; 403 for a knock, an invite or a declined invite in any other room. Its message chunk goes through the history-visibility filter and skips rejected and soft-failed events. The global GET /_matrix/client/v3/initialSync and GET /events are not routed. |
GET /_matrix/client/v3/rooms/{roomId}/event/{eventId} |
spec-covered |
404 M_NOT_FOUND for an event the user may not see under the room's history visibility at that event (audit CSAZ-3); 403 for a user with no read access to a room that is not world_readable. |
POST /_matrix/client/v3/rooms/{roomId}/upgrade |
spec-covered |
Full state copy (name, topic, join rules, power levels, etc.) to replacement room. Refused with 403 M_FORBIDDEN before the replacement room is created unless the user may send m.room.tombstone; a refused tombstone fails the upgrade (CSAZ-12). |
POST /_matrix/client/v3/rooms/{roomId}/receipt/{receiptType}/{eventId} |
spec-covered |
0.12.30: accepts m.read, m.read.private, and m.fully_read; rejects unknown types with 400 M_INVALID_PARAM, non-members with 403 M_FORBIDDEN, and malformed bodies with 400 M_BAD_JSON. m.read and m.read.private are durable receipt rows (ADR-0169), keyed by (room, type, user, thread_id), so restarts preserve read positions. thread_id is optional for m.read/m.read.private, must be a non-empty string naming main or a thread root in this room, and is carried by /sync m.receipt events and federated m.read EDUs. m.read is public and federated; m.read.private is visible only to its sender and is not federated. m.fully_read is accepted without thread_id, rejected if thread_id is supplied, and stored as the owner's m.fully_read room account data rather than a receipt; it never appears in m.receipt or a federation EDU. Invalid thread_id values (including a non-root room event ID) return 400 M_INVALID_PARAM. Gap: the optional 400 for "The event_id is not related to the thread_id" is not checked; the root's existence and main-timeline classification are checked, but the acknowledged event's relationship to it is not. |
POST /_matrix/client/v3/user_directory/search |
spec-covered |
|
POST /_matrix/client/v3/delete_devices |
spec-covered |
|
GET /_matrix/client/v3/sync |
spec-covered |
Unread-notification counts are relative to the user's read receipts, partitioned by thread (an unthreaded receipt covers every earlier event, a threaded one only its thread; 0.12.30), and bound to the response snapshot. 0.12.30: the unread_thread_notifications filter flag (on room.timeline) returns per-thread counts and limits unread_notifications to the main timeline, and a non-state event the user sends reads its own thread up to itself (ADR-0170). The summary object (m.joined_member_count, m.invited_member_count, m.heroes) is now emitted for every joined room and covered by conformance tests. filter_id parameter now wired and conformance-covered; sync-only stream tokens now bound to the response snapshot for to-device delivery so room keys queued during response construction remain pending for the next sync. |
GET /_matrix/client/v3/keys/changes |
spec-covered |
Requires and validates from and to sync tokens, decodes them to sync_stream_id boundaries, and filters changed/left device-list changes to that half-open range. Conformance coverage includes missing/invalid parameters and an upper-bound test proving to excludes later changes. |
POST /_matrix/client/v4/sync and POST /_matrix/client/unstable/org.matrix.msc4186/sync (MSC4186 Simplified Sliding Sync) |
spec-covered |
Advertised via unstable_features["org.matrix.msc4186"] = true and the compatibility alias unstable_features["org.matrix.simplified_msc3575"] = true; the stable v4 endpoint is now also routed and accepts the same request shape. Implements named lists with windowed ranges or singular range, SYNC/INVALIDATE/INSERT/DELETE/UPDATE ops, per-room state/timeline, room subscriptions, and all five extensions (to_device, e2ee, account_data, receipts, typing). Long-poll via sync pool; pos and timeout may be supplied in query parameters or in the JSON body. List + room_subscriptions merge: when a room is in both a list window and a room_subscriptions entry, required_state is the superset (dedup + wildcard-aware), timeline_limit is the max, and include_heroes is OR'd (previously the subscription overrode the list wholesale) — landed 0.11.9. Multi-list combination: a room in MORE THAN ONE list window now has ALL matching list windows combined (left-fold over combine_room_configs, associative+commutative) with each other and the room_subscriptions entry, not just the first matching list — landed 0.11.10. Access control (CSAZ-1): a room_subscriptions entry is served only for a room the caller has joined; an invited room yields invite_state only (no timeline or required_state); any other membership omits the room, and the receipts/typing extensions drop rooms the caller has not joined. timeline_limit is clamped to 100, and more than 256 subscriptions or 256 required_state pairs is a 400 M_INVALID_PARAM (sync::room_access_for, sliding_sync_request_limit_violation). Truncated timelines (0.12.45): a room carries limited when its timeline holds fewer events than it could, prev_batch (a /messages from) on every non-empty timeline, num_live, and on a grown timeline_limit resends its latest events with expanded_timeline, answering without long-polling once per request pos (ADR-0221). Remaining: broader MSC4186 conformance fixtures, subscription timeline pruning, and bump_stamp, which is never sent (Element X orders its room list by it). |
POST /_matrix/client/v3/account/password |
spec-covered |
UI-auth re-authentication. logout_devices revocation (other devices' access/refresh tokens and sessions) landed in 0.8.19. |
POST /_matrix/client/v3/account/deactivate |
spec-covered |
Implemented 0.12.4 (previously unhandled — see docs/auth-identity.md "Account deactivation"). UIA (m.login.password), permanent deactivation (the users.deactivated column), full access/refresh-token revocation including the caller's own session, password hash overwritten with an unmatchable value, and the users row retained so the localpart is never reissued. Each identity-server-bound 3PID is submitted for unbinding during deactivation, reusing the stored id_server + client_secret + sid to drive IS unbind auth mode 2 through the same trusted-base-URL SSRF gate as POST /account/3pid/unbind. id_server_unbind_result is success only when every identifier was unbound (and when there were none), no-support when any could not be — no identity server recorded, operator trust withdrawn for it, or the IS unreachable or refusing. Unlike the standalone unbind endpoint, which fails closed so the user can retry, a failure here still deactivates and still clears the local binding: deactivation is irreversible and there is no owner left to retry. Known gaps: authentication is required, where the spec makes it optional -- an unconditional bearer-token gate runs before route dispatch, so a user with no live session cannot deactivate through UIA alone even holding valid password credentials. Deliberately not closed in PR #488: it would create a password-only, session-less path to permanently destroy an account, which needs its own throttling and review rather than being appended to a large branch. Also, the erase request-body parameter is parsed but never inspected, so it has no effect regardless of its value (erasure is a spec SHOULD, not a MUST, so this is not itself non-conformant, but a client requesting erasure is silently no-op'd); and the account's rooms are not left. |
POST /_matrix/client/v3/createRoom |
spec-covered |
Broader conformance fixtures. |
POST /_matrix/client/v3/rooms/{roomId}/join |
spec-covered |
Federation-aware joins. |
POST /_matrix/client/v3/join/{roomIdOrAlias} |
spec-covered |
Room-alias resolution, ?server_name hint, federation-aware joins. |
POST /_matrix/client/v3/rooms/{roomId}/send |
spec-covered |
Restricted (v8+) and knock_restricted (v10+) join rule evaluation, gated by room version (0.12.13; the non-spec restricted_v2 is no longer accepted), and third-party invite auth (rule 4.3.1), landed in events/authorization.cpp. |
GET/PUT /_matrix/client/v3/user/{userId}/account_data/{type} |
runtime-wired |
Global account data is stored and retrieved for the authenticated user. |
GET/PUT /_matrix/client/v3/user/{userId}/rooms/{roomId}/account_data/{type} |
spec-covered |
Routed 0.12.38 (conformance audit R10, ADR-0197). Stored in room_account_data, delivered in the room's /sync and sliding-sync account_data, never falling back to the global type. 400 M_INVALID_PARAM for a malformed room ID, 400 M_NOT_JSON/M_BAD_JSON for a body that is not a JSON object, 403 for another user, 405 M_BAD_JSON for the server-managed m.fully_read and m.push_rules, 404 on GET for a type never set. A PUT needs a membership row in the room (404 M_NOT_FOUND otherwise) and a new type stops at server.client_api.max_room_account_data_per_room (400 M_TOO_LARGE). Conformance-covered in tests/conformance/test_account_data_conformance.cpp. |
GET/PUT/DELETE /_matrix/client/v3/user/{userId}/rooms/{roomId}/tags[/{tag}] |
spec-covered |
Room tagging via m.tag account data; order doubles supported in general JSON parser. |
/_matrix/client/v3/pushrules/... (GET, PUT, DELETE, .../enabled, .../actions) |
spec-covered |
0.12.40 (R19, ADR-0204): user-defined rules are created, ordered by before/after, deleted, enabled and given actions; server-default rules can be enabled and re-actioned but not created or deleted. Rules are kept as the user's m.push_rules account data, applied over the current defaults, and reach clients through /sync; delivery evaluates them. Capped by server.client_api.max_push_rules_per_user and per-rule bounds. Conformance-covered (test_push_rules_api_conformance.cpp). |
GET /_matrix/client/v3/pushers |
spec-covered |
Returns the caller's persisted pushers in the spec's Pusher shape (0.11.11). Conformance-covered (round-trip with POST /pushers/set). |
POST /_matrix/client/v3/pushers/set |
spec-covered |
Persists via store_pusher; kind: null deletes via delete_pusher; append implements the spec's cross-user pusher-removal rule (0.11.11). Body validation (including the https + /_matrix/push/v1/notify pusher-URL check) unchanged. Conformance-covered. |
GET /_matrix/client/v3/notifications |
spec-covered |
Routed (0.11.11): paginates via from/next_token; only=highlight filters to highlight-tweaked notifications; limit defaults to 50 (or the configured maximum if lower) and is clamped to server.client_api.max_notifications_page_size (default 1000); read is computed at request time via sync::read_receipt_markers() from the caller's m.read/m.read.private receipts (unthreaded, or the receipt for the event's thread). A user's own non-state event also advances their read marker through that event in its own thread; state events, including profile-propagation membership events, do not. History is recorded for local recipients whose push-rule evaluation resolves notify: true, regardless of whether push delivery is enabled or a pusher is registered. Persisted in notifications (migration 009) and pruned per user beyond server.client_api.max_notifications_retained_per_user (current default 1,000; configurable 1..100,000). profile_tag is empty because rows are recorded once per user/event, not per pusher. |
GET /_matrix/client/v1/rooms/{roomId}/threads |
spec-covered |
Routed (0.11.12): lists the room's thread roots ordered by each thread's latest_event, most recently active first, with the bundled m.thread aggregation (latest_event, count, current_user_participated) on every returned root. include=all|participated is honoured and any other value is rejected with 400 M_INVALID_PARAM rather than silently falling back to all; limit defaults to 50 and is capped at 500; from/next_batch paginate on the latest_event stream ordering, and next_batch is omitted when the page is the last one. m.ignored_user_list is applied via trust_safety::ignore_list, resolved once per request: child events from ignored senders are excluded from count/latest_event (spec MUST), and a root sent by an ignored user is returned redacted rather than omitted. Joined-membership gate, 403 for an unknown room or a room the caller cannot view. Known gaps: bundled m.thread aggregations are produced only by this endpoint — /sync, /messages, /context, /relations and /event/{eventId} still return events without an m.relations block, so a client relying on aggregations elsewhere must aggregate locally; threads in rooms the caller has left are not listed (the same join-only gate the other read endpoints use); and ordering is by the server's stream ordering rather than a full topological ordering. Like /relations, the handler scans the in-memory PersistentStore::events for the room on every request — limit bounds the response, not the scan — so cost grows with room history rather than with page size. |
POST /_matrix/client/v3/search |
spec-covered |
Routed (0.11.11): in-memory search over PersistentStore::events (no SQL full-text index — see docs/architecture.md), scoped to every room the caller has been in, including rooms left, kicked or banned from after a join (0.12.38, conformance audit R16; forgotten rooms, invites and knocks are not searched), with each event judged by sync::HistoryVisibility and include_state giving a left room's state as of the leave, and never searching end-to-end encrypted rooms (spec MUST). Searches content.body (m.room.message), content.name (m.room.name), and content.topic (m.room.topic) via a case-insensitive substring match — not stemmed/tokenised full text search. filter (RoomEventFilter, reusing sync::parse_room_event_filter_argument), keys, order_by (rank/recent), event_context (before_limit/after_limit/include_profile), include_state, and groupings (room_id/sender) are all honoured. m.ignored_user_list is applied via trust_safety::ignore_list, resolved once per request. Bounded: ClientApiLimits::max_search_events_scanned (2000) caps how many candidate events one request will JSON-parse and text-match before returning a next_batch continuation instead of scanning further; page size is separately capped at 25; rate-limited at 20/min per IP (the media tier) rather than the generic 90/min fallback. Known gaps: order_by: rank is only locally accurate within one request's bounded scan window, not a global top-K across the caller's entire matching history — a client paginating in rank order does not get a globally rank-sorted stream; per-group next_batch (pagination within one group) is not implemented, only the top-level next_batch (spec explicitly allows a server not to support this); profile_info returns each sender's current profile, not a point-in-time historic snapshot; and the MSC3765 extensible-topic content['m.topic'] text/plain representation referenced in the spec's room_events section is not indexed (only legacy content.topic). Conformance-covered in tests/conformance/test_search_conformance.cpp, including the access-control property that an event in a room the caller has never been in, sent after the caller left, from an ignored sender, or in an encrypted room, is never returned. |
GET /_matrix/client/v3/rooms/{roomId}/context/{eventId} |
spec-covered |
Returns events_before/event/events_after/state around a permalink target. Same read gate as /messages and /event/{eventId} (no read access and not world_readable: 403; an unknown event id, an event id from a different room, and an event the user may not see under the room's history visibility all 404 M_NOT_FOUND, identically, so existence elsewhere is not leaked). events_before and events_after hold only events the user may see, so the state below is pinned to one of them. limit (default 10, spec-applied to the combined events_before+events_after size) is clamped to the /messages maximum. filter accepts a RoomEventFilter via sync::parse_room_event_filter_argument(); malformed JSON is rejected with 400 M_BAD_JSON. start/end are the same plain stream-ordering tokens /messages produces. state is now reconstructed at the last event actually returned (0.11.11, PR #479 review fix) rather than read off the room's current state, per CS API: "The state of the room at the last event returned." federation::resolve_state_event_ids_at() (src/federation/event_query.cpp) reuses the same backward event-DAG walk build_state_response/build_state_ids_response already use for the federation GET /state//state_ids endpoints (reconstruct_state_at_event, landed 0.8.10), then folds the pinned event's own state contribution back in when it is itself a state event — the federation endpoints intentionally stop one step short of that (SS API GET /state: "prior to considering any state changes induced by the requested event"), while /context's state wants the room's state inclusive of the pinned event. Proven by a conformance scenario where a room's name changes strictly after the last event a bounded limit admits: state still carries the pre-rename name, not the room's present-day one. GET /messages was deliberately left unchanged — see its own row below for why extending this fix there does not apply cleanly. |
GET /_matrix/client/v3/rooms/{roomId}/messages |
spec-covered |
Paginates the room timeline via from/to/dir/limit/filter (RoomEventFilter). History visibility is enforced (audit CSAZ-3): every event goes through sync::HistoryVisibility, the page skips what the user may not see, and end still moves past skipped events; one page examines at most ClientApiLimits::max_messages_events_examined (2000) events, then returns what it has with a token to continue from (an empty chunk does not mean the timeline ended). A user who has left or was banned may read (state as of their departure), and a room whose current visibility is world_readable may be read without joining; everyone else is 403. Chunk-relevant state landed in 0.12.2, closing the divergence from /context recorded here: lazy_load_members was never parsed anywhere in the codebase, and this endpoint passed a default-constructed filter to its state builder, so it returned the room's full current state including every member. Membership state is now limited to senders appearing in the chunk. The endpoint-shape distinction is worth keeping: /context returns "the state of the room at the last event returned" and reconstructs it via the event-DAG walk, whereas /messages returns "state events relevant to showing the chunk" — so applying /context's temporal fix here would have left state wrong in a different way (over-inclusive, unfiltered by relevance) rather than right. |
POST /_matrix/client/v3/user/{userId}/openid/request_token |
spec-covered |
Mints a short-lived OpenID token (request_openid_token), stored in its own openid_tokens table — never the access_tokens table — so it cannot authenticate ordinary client-server requests. Path userId must equal the authenticated caller; a mismatch fails closed 403 M_FORBIDDEN identically whether or not the named user exists. Redeemed via the paired federation GET /_matrix/federation/v1/openid/userinfo (see the Federation API table); conformance-covered for both halves plus the access-token/OpenID-token separation in both directions. |
SSO login (m.login.sso, GET /_matrix/client/v3/login/sso/redirect[/{idpId}]) |
runtime-wired |
m.login.sso is advertised from GET /login with configured identity_providers; the redirect route validates redirectUrl against server.sso.redirect_url_allowlist, validates idpId, and redirects to the configured authorization URL. m.login.token uses short-lived single-use login tokens in the durable login_tokens table (migration 012), separate from access tokens. Merovingian does not provide a first-party CAS/SAML/OIDC adapter; homeserver::complete_sso_login is the integration point for an operator's external IdP adapter. Older statements that SSO is unrouted are obsolete. See docs/auth-identity.md and docs/threat-model.md. |
Ignoring users (m.ignored_user_list) |
spec-covered |
Enforced (0.11.11) via merovingian::trust_safety::ignore_list (resolve_ignored_users / is_delivery_suppressed), called from every client-facing delivery surface: GET /sync (timeline, invite list, typing/receipts), MSC4186 sliding sync (timeline, required_state — including the invite override for an explicitly-subscribed room, since sliding sync has no separate invite surface — and the receipts/typing extensions), GET /messages, GET /context/{eventId} (events_before/events_after only — the requested event itself is always returned), and push notification delivery (build_pending_push_deliveries, covering both message and membership/invite pushes). Per spec: non-state events from an ignored sender are withheld; state events are still delivered (room state stays accurate); a new-room invite is withheld even though it is itself a state event. Filtering is delivery-side only — it never touches event persistence, auth rules, state resolution, or federation acceptance, and it does not retroactively hide events already delivered to the client (a fresh sync is required for that, per spec). A malformed or absent m.ignored_user_list fails safe to "nothing ignored". Conformance-covered in tests/conformance/test_ignoring_users_conformance.cpp; sliding sync and push-delivery suppression are integration-covered in tests/integration/test_sliding_sync_flow.cpp and tests/integration/test_push_delivery_flow.cpp. Known gap: sliding sync's room list only ever enumerates joined rooms (a pre-existing limitation, not introduced here) — an invite can only be observed there via an explicit room_subscriptions entry naming the room, which now yields the invite's stripped invite_state only (an ignored inviter's invite is omitted), and that is where the suppression is proven. |
GET /_matrix/client/v3/thirdparty/{protocols,protocol/{p},location,location/{p},user,user/{p}} |
spec-covered |
All six third-party lookup routes are wired (src/appservice/appservice_client.cpp's query_thirdparty_* methods, routed in client_server.cpp). protocols/protocol/{protocol} call the owning appservice's GET /_matrix/app/v1/thirdparty/protocol/{protocol} and mint instance_id per spec; location/user (bare) fan out to every registered appservice and aggregate; location/{protocol}/user/{protocol} fan out to every appservice declaring that protocol, forwarding the client's query string verbatim as search fields. An unknown protocol is 404 M_NOT_FOUND; protocols still returns {} (not 404) when no appservice is registered. An unreachable/erroring/timing-out appservice degrades to "no results" for the routes it would have contributed to rather than failing the whole request — proven in tests/integration/test_appservice_thirdparty_flow.cpp. All six routes are unauthenticated-rejected (401) and bypass client rate limiting per the spec's Rate-limited: No flag. Every field taken from an appservice's reply is type-checked and bounded before being echoed to the client (appservice_client.cpp's parse_thirdparty_* — a bridge is not a trusted peer). Conformance-covered in tests/conformance/test_appservice_thirdparty_conformance.cpp. |
PUT /_matrix/client/v3/profile/{userId}/avatar_url |
spec-covered |
Conformance fixture now covers successful update, cross-user 403 guard, and retrieval via GET /_matrix/client/v3/profile/{userId}/avatar_url. 0.12.30: a display name or avatar change now sends an m.room.member join carrying it to every joined room ("Events on Change of Profile Information"), and this server's own joins carry the profile. The same section's m.presence update is sent too, and /sync m.presence events carry displayname/avatar_url from the profile. 0.12.35 (ADR-0182): the PUT answers 200 once the field is stored, even when a room or the presence write fails (logged at warning); the next profile change rechecks joined rooms and skips membership events already carrying the current profile. Rooms are chosen by current m.room.member state, so a stale runtime member list cannot rejoin a user to a room they left. |
GET /_matrix/client/v1/rooms/{roomId}/hierarchy |
spec-covered |
Paginated depth-first space tree with max_depth, suggested_only, and limit. |
GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId} |
spec-covered |
Direct relation scan with dir, from, to, limit, and recurse query parameters; supports encrypted poll responses via m.reference. |
GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/{relType} |
spec-covered |
Filtered by rel_type. |
GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/{relType}/{eventType} |
spec-covered |
Filtered by rel_type and child event_type. |
POST /_matrix/client/v3/rooms/{roomId}/report/{eventId} |
spec-covered |
Accepts a reason, returns 200 {}, rejects malformed bodies with M_BAD_JSON, and surfaces reports on GET /_matrix/client/v3/admin/safety/reports. 404 M_NOT_FOUND unless the reporter is joined and the event is in the room and visible to them; a repeat report of the same event by the same reporter writes no second row (CSAZ-12). POST /rooms/{roomId}/report (reporting a room, v1.13) is not routed. |
POST /_matrix/client/v3/users/{userId}/report |
not-started |
Not routed anywhere in client_server.cpp — only the event-report endpoint above exists. A request to this path falls through to the generic 404 M_UNRECOGNIZED handler. |
PUT /_matrix/client/v3/rooms/{roomId}/redact/{eventId}/{txnId} |
spec-covered |
Routed (CSAZ-11). Takes {"reason": ...}, answers {"event_id": ...}, and a repeated txnId for the same event returns the same event_id (the transaction is scoped to this endpoint and target, in client_txn_ids). The m.room.redaction is composed per room version (redacts at the top level before v11, in content from v11) and sent like any local event, including federation. A user may redact their own events; another user's only at the room's redact level; a server administrator any event sent by a user on this server; a suspended user only their own. A refusal is 403 M_FORBIDDEN (M_USER_SUSPENDED for a suspended user), an event this room does not contain 404 M_NOT_FOUND. PUT /send of an m.room.redaction goes through the same check and, before v11, has content.redacts moved to the top level. Applying redactions (homeserver/redaction_service.cpp, run for every event stored by the main process and once at startup): a redaction is applied once its target is stored, if the redaction sender's power level (taken from the power levels among the redaction's own auth_events) is at least the redact level or its domain is the target sender's domain (rooms/v3.md to v12.md "Handling redactions"). Applying overwrites the target's stored JSON with events::redact_event's output, in the database and in memory, so the original content is deleted and no path can serve it; hashes and signatures stay, so the redacted form still verifies over federation. Until it is applied a redaction is withheld from clients (database::redaction_is_withheld, enforced in sync::HistoryVisibility::can_see, so /event, /messages, /context, /sync, sliding sync, /relations, /threads and /search all skip it) and raises no push; one that arrives before its target is held and applied when the target arrives; one that does not apply stays withheld. Clients are served the redacted event with unsigned.redacted_because (all read paths share database::attach_redacted_because) and redaction events with redacts in both places. A redaction of an m.room.create event is never applied (the server reads the room version from it). On the first start after the upgrade, redactions an earlier version accepted but never applied are applied. Conformance-covered in tests/conformance/test_redaction_application_conformance.cpp and test_redaction_federation_conformance.cpp. Known gaps: a redaction is judged once, with the power levels in its own auth_events, not re-judged if the room's state changes; a redaction of an event the server has not received waits indefinitely (there is no expiry). |
POST /_matrix/client/v3/rooms/{roomId}/read_markers |
spec-covered |
Accepts m.read, m.read.private, and m.fully_read markers; rejects non-members with 403 M_FORBIDDEN and malformed bodies with 400 M_BAD_JSON. The m.read marker is reflected as an m.receipt ephemeral event in the next /sync; m.read.private is delivered only to the user who sent it (never to other members, never federated); m.fully_read is stored as the owner's m.fully_read room account data ({"event_id": ...}), delivered through /sync and the sliding sync account_data extension, and never appears under m.receipt or in a federated EDU. Both /sync and sliding sync apply sync::receipt_visible_to(). |
PUT /_matrix/client/v3/presence/{userId}/status |
spec-covered |
Accepts presence and optional status_msg; rejects cross-user updates with 403 M_FORBIDDEN and malformed bodies with 400 M_BAD_JSON. |
Federation API¶
| Endpoint | Status | Needs |
|---|---|---|
PUT /_matrix/federation/v1/send/{txnId} inbound |
spec-covered |
Richer EDU side-effects. Idempotency, unknown-EDU discard, oversize rejection, and PDU content-hash verification now conformance-covered. |
Server ACL enforcement (m.room.server_acl, MSC4436) |
spec-covered |
Per-room ACLs applied to protected federation endpoints and per-PDU/room-local EDU in inbound /send. Case-insensitive glob matching, IP-literal handling, and port stripping. |
PUT /_matrix/federation/v1/send/{txnId} outbound |
spec-covered |
Live signed-transaction interop test against a real Synapse peer landed in 0.8.6 (opt-in build_live_tests). |
| Federation join/leave/invite/knock/backfill | spec-covered |
Richer production leave/knock state semantics (stripped state, knock acceptance flows). |
| Server discovery | spec-covered |
TLS-bound origin validation helper and conformance fixtures added; live network conformance coverage remains optional. |
| Request and event signing/verification | spec-covered |
Live signed-request interop test against a real Synapse peer landed in 0.8.6 (opt-in). Inbound PDU content-hash verification wired and conformance-covered. |
GET /_matrix/federation/v1/query/profile |
spec-covered |
field accepts any key, m.tz included (R18); a field the user does not have is 404 M_NOT_FOUND, and unset fields are left out of a whole-profile answer. This server stores only displayname and avatar_url: m.tz and other extended profile fields have no client-side setProfileField storage, so they are never returned. |
GET /_matrix/federation/v1/query/directory |
spec-covered |
|
GET /_matrix/federation/v1/hierarchy/{roomId} |
spec-covered |
Space summary for remote servers. Conformance coverage added for 200, 404, invalid suggested_only, and missing-provider 501. |
| Event-graph queries | spec-covered |
auth_chain/auth_chain_ids transitive-closure reconstruction landed in 0.8.9; historical state-at-event reconstruction for /state and /state_ids landed in 0.8.10 with conformance fixtures; resolve_state_v2 with full conflicted/unconflicted partitioning is implemented and conformance-covered. |
| Outbound federation queues | spec-covered |
Live federation delivery coverage under realistic load. |
Key publication (GET /_matrix/key/v2/server) |
spec-covered |
Key-ID format, valid_until_ts expiry, old_verify_keys structural contract, and key-rotation publication (new key active, retired key in old_verify_keys) now covered by conformance fixtures. Multiple simultaneously-active signing keys are published in verify_keys and signed by the preferred key, with unit and conformance coverage. |
GET /_matrix/federation/v1/media/download/{mediaId} inbound (serving our media to remote servers) |
spec-covered |
Authenticated by X-Matrix request signatures and served from the main process media repository as a multipart/mixed response per Matrix v1.19. Conformance coverage added for 200, 404, 451, missing-provider 501, and percent-decoded mediaId. The federation worker is bypassed because it has no access to the local media store. |
GET /_matrix/federation/v1/media/download/{mediaId} outbound (fetching remote media) |
spec-covered |
Tries the authenticated endpoint first, falls back to the deprecated v3 endpoint on 404, and follows Location redirects via an SSRF-safe resolver with pinned addresses (v0.11.5). |
GET /_matrix/federation/v1/openid/userinfo |
spec-covered |
Redeems a token minted by the client-server POST /user/{userId}/openid/request_token (see the Client-server table), returning {"sub": "<user_id>"}. Per spec this endpoint requires no authentication and is not rate-limited, so it is dispatched entirely outside the X-Matrix signed-request path — alongside GET /_matrix/key/v2/server — rather than through federation::handle_inbound_federation_request. An unknown or expired token gets the identical 401 M_UNKNOWN_TOKEN response so a caller cannot distinguish the two. Conformance-covered, including that an ordinary client-server access token is rejected here. |
| Soft-fail on receipt of a PDU (SS API "Checks performed on receipt of a PDU") | spec-covered |
0.12.13 (ADR-0064 phase B2): ingest_pdu_event now runs auth against the PDU's own auth_events and the state before the event (reject on failure) followed by auth against current state (soft-fail, not reject, on failure). A soft-failed event is stored, given an after-state group, and takes part in state resolution, but is never a forward extremity and is excluded from every client-facing timeline (/sync, sliding sync, /messages, /context, /event/{eventId}, search); a soft-failed state event resolution later admits into current state is still delivered in the state section. See docs/event-engine.md "Phase B2" and tests/unit/test_pdu_ingestion_auth_checks.cpp. The membership-acceptor path (send_join/send_leave/send_knock acceptance) runs the same checks (0.12.13). |
Application Service API¶
Registration, auth, outbound transaction delivery, login, masquerading, and the
/thirdparty/* lookup surface are implemented — see the rows below and the
top-level ledger. Remaining gaps: one event per transaction with no batching,
in-memory retry backoff that resets on process restart, and ephemeral data
that is deliberately not durable (ADR-0190). Failed deliveries are retried by
the background queue without requiring new room events, and one appservice's
queue is capped (ADR-0191). The ts query-string timestamp-massaging
parameter is not implemented; room-ID namespace exclusivity has no
creation-time enforcement point because Matrix room IDs are server-generated;
this server does not serve POST /_matrix/federation/v1/publicRooms itself, so
network directories are applied to local listings and forwarded to remote
ones. See spec.
| Surface | Status | Needs |
|---|---|---|
Appservice registration files (as_token, hs_token, namespaces) |
spec-covered |
Landed 0.12.1: config parsing (appservice.registration_files, bounded YAML with JSON fallback), constant-time token storage (core::SecretBuffer), and namespace-based exclusivity for users and aliases (room-ID namespace exclusivity is parsed and matched via any_exclusive_namespace_matches but has no creation-time enforcement point, since Matrix room IDs are server-generated rather than client-chosen). Conformance-covered in tests/conformance/test_appservice_conformance.cpp. |
PUT /_matrix/app/v1/transactions/{txnId} (outbound) |
runtime-wired |
Landed 0.12.1: appservice::AppserviceClient::send_transaction (src/appservice/appservice_client.cpp) PUTs one event per transaction, Authorization: Bearer <hs_token> only (no legacy access_token query param — see the client's doc comment). Wired as a durable queue (audit R01, ADR-0163): appservice_delivery.cpp's enqueue_appservice_delivery, called from send_event(), dispatch_membership_push_notification() (local membership transitions), and the federation-accepted-PDU path (deliver_federation_push_notifications), writes one appservice_outbox row per interested, registered appservice (namespace match via registration_interested_in_room_event, decided at event time) and makes no network call. One dedicated PeriodicJob thread (drain_appservice_queues) sends each appservice's rows oldest first, one event per transaction. The state of the transaction in flight (next_txn_id, pending_txn_id/pending_stream_ordering) is durable in appservice_txn_cursor and is written BEFORE the send, so a retry, after a failure or after a restart, reuses the identical txn_id and event; a failed appservice backs off exponentially (1 s doubling to 5 min, in memory) and is retried without needing new room traffic. This satisfies the spec's queue, back-off and no-alter-on-retry requirements. Exercised against a live loopback mock appservice by tests/integration/test_appservice_delivery_flow.cpp. Conformance wave 3: interest (registration_interested_in_room_event with EventInterestFacts) now includes the state_key of an m.room.member event, so an invite to a virtual user who has not joined is delivered (R07), and matches any alias of the room; createRoom queues the events it makes through CreatedEventQueue (and the third-party invite stored on join is queued); queue_event_for_appservices is the one call every storing path makes; the queue of one appservice is capped at appservice.max_queued_events (default 100000), the newest event is refused and counted (merovingian_appservice_outbox_dropped_total), and the first refusal and every thousandth is logged at warning level and audited as appservice.outbox.full (ADR-0191). receive_ephemeral is honoured (R08, ADR-0190): m.typing (queued from update_room_typing_stream_id_if_changed), m.receipt (after store_receipt, local and federated; a private read receipt only for a user in the appservice's namespaces) and m.presence (after upsert_presence; for users in its namespaces or sharing a room with one of its users) are queued in memory, coalesced by key, at most 512 per appservice, 100 per transaction, and attached to the next transaction, or sent alone in an events-free transaction. Known gaps: one event per transaction rather than batching multiple pending events (deliberate, ADR-0163); the back-off state is in memory, so the first attempt after a restart is immediate; ephemeral data is best-effort (not redelivered after an outage or a restart, deliberately). |
POST /_matrix/client/v1/appservice/{appserviceId}/ping and POST /_matrix/app/v1/ping (outbound) |
spec-covered |
Wave 3. The client endpoint authenticates the as_token (the dispatcher's masquerade token names the appservice), requires the appservice in the path to be the caller (403 M_FORBIDDEN for a user token, another appservice's token or an unknown id), is not rate limited for a verified appservice, and answers {"duration_ms": N} or 400 M_URL_NOT_SET, 502 M_CONNECTION_FAILED, 502 M_BAD_STATUS (with status and a bounded body) or 504 M_CONNECTION_TIMEOUT. AppserviceClient::ping sends {"transaction_id": ...} (or {}) with the hs_token under the configured timeouts; runtime.mutex is released across the call and an ADR-0079 slot is held. Conformance-covered in tests/conformance/test_appservice_ping_conformance.cpp. |
PUT /_matrix/client/v3/directory/list/appservice/{networkId}/{roomId}, and third_party_instance_id / include_all_networks on POST /publicRooms |
spec-covered |
Wave 3 (ADR-0192). Persisted per (appservice, protocol, room) in appservice_directory; only an as_token may publish, the protocol must be declared by the registration (404 M_NOT_FOUND), the room must exist and the appservice must have an interest in it (403 M_FORBIDDEN); the spec lists no failure status, so these are the fail-closed readings. instance_id is now <registration id>:<protocol>:<index> (percent-encoded parts) so it resolves to the network without asking the appservice. A ?server= lookup forwards both fields to the remote server's POST /_matrix/federation/v1/publicRooms. Conformance-covered in tests/conformance/test_appservice_directory_conformance.cpp. |
GET /_matrix/app/v1/users/{userId}, /rooms/{roomAlias} (outbound) |
runtime-wired |
appservice::AppserviceClient::query_user/query_room_alias (src/appservice/appservice_client.cpp) are wired into the local-miss path: client_server.cpp's user-existence check (~9805) and room-alias resolution (~9006) call them when the identifier falls inside a registered appservice's namespace (gated by appservice::any_namespace_matches), re-checking locally afterwards; an unreachable or declining appservice is treated as a miss, not an error. Covered by tests/integration/test_appservice_query_hooks_flow.cpp. |
m.login.application_service login type |
spec-covered |
Landed 0.12.1: POST /register (passwordless, UIA bypassed per spec) and POST /login both accept the type, restricted to the appservice's own namespace (M_EXCLUSIVE otherwise); GET /login advertises it. Conformance-covered. |
Appservice-authenticated CS API (?user_id= masquerading) |
spec-covered |
Landed 0.12.1: as_token bearer auth defaults to the appservice's sender_localpart user; ?user_id= masquerades as any user in the appservice's users namespace (403 otherwise); ?device_id= is honoured only when it names a device already known to belong to that user (400 M_UNKNOWN_DEVICE otherwise). Conformance-covered. Known gap: the ts query-string timestamp-massaging parameter (spec v1.3) is not yet implemented. |
/_matrix/client/v3/thirdparty/* (location, user, protocol) |
spec-covered |
Implemented — see the GET /_matrix/client/v3/thirdparty/{protocols,protocol/{p},...} row in the Client-server API table above. protocols returns an empty {} (not 404) when no appservice is registered. Every one of the six routes that would call an appservice first takes a slot in the client-outbound budget (ADR-0079) and is 429 M_LIMIT_EXCEEDED when none is free, and stops starting further registrations once appservice.total_timeout_seconds has elapsed (security audit run-1 sp02.thirdparty-no-proxy-slot); the bare location and user routes call every registration, one after another, so they were the unbounded case. |
Push Gateway API¶
Wired for message events and membership transitions, gated behind
server.push.enabled (default false). See
spec.
| Surface | Status | Needs |
|---|---|---|
POST /_matrix/push/v1/notify (outbound to gateway) |
runtime-wired |
PushGatewayClient::notify() uses the SSRF-safe outbound client and discovery, forwards registered pusher data and processes rejected pushkeys. Local/federated events and membership transitions trigger asynchronous delivery. Work is bounded by server.push.max_in_flight_deliveries (default 256) and server.push.max_pushers_per_delivery (25). Registration has its own max_pushers_per_user cap (20). Gateway circuit breaking suppresses calls during a cooldown. A delivery the gateway fails with an HTTP error, cannot be reached for, or skips with an open circuit is retried with exponential backoff (5 s doubling to 10 min, 8 retries) from a bounded in-memory queue (0.12.40, R14, ADR-0206). Integration scenarios use a local TLS mock gateway. |
| Pusher persistence | spec-covered |
store_pusher/find_pusher/delete_pusher/list_pushers_for_user back GET /pushers/POST /pushers/set; pushers survive across requests and restarts (0.11.11). |
| Push-rule evaluation | runtime-wired |
evaluate_push_rules() (pure, unit- and conformance-covered since an earlier phase) is now actually invoked per sent event, and per membership transition, against each recipient's own rules: the server defaults with their stored m.push_rules applied (0.12.40, R19, ADR-0204; before that, default_push_ruleset() only, 0.11.11). .m.rule.invite_for_me is reachable: an invitee who is not yet a joined member is added as an explicit extra recipient rather than only iterating joined members (0.11.11). |
counts.unread |
runtime-wired |
homeserver::count_push_badge (0.12.40, ADR-0207): one walk of the events, newest first in stream order (ADR-0215), across every room the recipient is joined to, saturating at 1000. It applies /sync's rules: the recipient's push rules from the start of their join (R15, ADR-0205), only events the history-visibility filter shows, and the thread-partitioned read markers (0.12.30). It stops once every room is settled (nothing below can change the room's count) and visits at most 10000 rows. An uncapped badge equals the sum of /sync's exact unread_notifications counts. When the budget runs out first it is a lower bound that never counts an event /sync would not; a room whose join the walk did not reach counts from the recipient's membership row, which is never before the join. Its setup is a fixed number of table walks, whatever the number of rooms. |
Email pushers (kind: "email") |
runtime-wired |
0.12.44 (ADR-0219, ADR-0220): with server.email.smtp_url set, POST /pushers/set accepts kind: "email" with app_id m.email and a pushkey that is an email 3PID on the account (else 400 M_INVALID_PARAM); without a relay it is refused as since 0.12.3. A notification schedules one email after notification_delay_seconds, at most one per notification_throttle_seconds; it carries only the unread count, and is not sent if nothing is unread when due. SMTP over libcurl with TLS required except to a loopback literal. Gaps: no relay CA setting (system trust store only); adding the address as a 3PID needs an identity server, since this server sends no validation email; due emails are in memory only. |
Server administration¶
| Endpoint | Status | Needs |
|---|---|---|
GET /_merovingian/admin/health |
runtime-wired |
Served on the public client listener (0.11.9; previously 404 in production, only reachable via the test-only local router). Admin auth via require_admin() (401 missing/invalid token, 403 non-admin), rate-limited under the /_merovingian/admin/ per-IP prefix policy (30/60s default, operator-tunable). |
| Admin media moderation | runtime-wired |
Quarantine/release/remove use require_admin() with the 401/403 split (0.11.9); served and rate-limited on the client port. Remaining: operator docs. |
| Admin trust and safety review | spec-covered |
POST /_matrix/client/v3/admin/safety/review/{targetType}/{targetId} is now conformance-covered for room, media, and federation_server targets, empty review bodies, invalid target types, and non-admin guards. Remaining: moderator queues and broader workflow coverage. |
GET/PUT /_matrix/client/v1/admin/lock/{userId} |
runtime-wired |
Account lock admin endpoint with anti-enumeration (auth before lookup), locality, and self/other-admin guards; request-path M_USER_LOCKED (soft_logout:true) enforcement landed in 0.8.19. |
GET/PUT /_matrix/client/v1/admin/suspend/{userId} |
runtime-wired |
Account suspend admin endpoint; request-path M_USER_SUSPENDED enforcement with the spec allowlist of permitted actions landed in 0.8.19. |
| Exported metrics | runtime-wired |
Stable Prometheus text exposition contract documented in docs/observability-audit.md; X-Merovingian-Request-Id and Traceparent correlation headers landed in 0.8.11. Remaining: operator dashboards and retention/export policy. |
| Debug logging | runtime-wired |
Per-module level filtering, wall-clock rate limits, and structured diagnostics with request_id/trace_id/span_id fields landed in 0.5.0/0.8.11. Remaining: formal log-format stability commitment. |
/_merovingian/admin/accounts/{userId}, /_merovingian/admin/review/{targetType}/{targetId}, /_merovingian/admin/shutdown |
not-started |
Declared in observability::admin_routes() (src/observability/observability.cpp) but match_admin_route() is never called from dispatch — homeserver/local_http_router.cpp handles only health, metrics, media/metrics, audit, and media quarantine/release/remove. These three routes are unreachable dead declarations, not live endpoints. |
RESOLVED (0.12.13): remaining security-audit work¶
Every item in audit-0.12.13-handover.md —
the backfill fallback and its ADR-0070 tightening, main re-verifying relayed
PDU signatures (ADR-0071), the per-IP connection cap, and the eleven low items
with the listener-test flake — is finished; see the CHANGELOG.md 0.12.13
section. Only D3 (below) was deferred.
RESOLVED (0.12.36, R04): restricted-room joins through this server as the resident¶
Found on the 0.12.13 branch (2026-09-28), not fixed there. When a remote user
joins a room this server hosts through a restricted-room condition, the
resident server must put join_authorised_via_users_server (a local user
able to invite) in the make_join template, sign the resulting event in
send_join, and answer M_UNABLE_TO_AUTHORISE_JOIN or
M_UNABLE_TO_GRANT_JOIN when it cannot (server-server-api.md, make_join
and send_join; auth rule "restricted"). Status: resolved in 0.12.36
(conformance audit R04, ADR-0184, ADR-0185): homeserver::decide_restricted_join
checks the allow conditions against this server's current state; the
make_join template (built by main, relayed from workers) names the lowest
local user ID with invite power and that user's member event; the send_join
acceptor co-signs only a join it has authorised, before storing it; local joins
into local restricted rooms carry the field, and no locally composed join may
name a local authoriser the decision does not accept. Still open: a local
user whose condition holds where no local user can invite is refused rather
than joined through another resident, and the accepted join is not sent on to
the room's other servers.
Found on the 0.12.31 branch (2026-10-21): this server also refuses every
restricted join it receives from a non-invited user, even one validly
authorised by another resident. The /send ingest, the send_join acceptor
and backfill build their auth maps with build_auth_event_map_from_entries
(src/homeserver/local_http_router.cpp), which never fills
AuthEventMap::authorising_user_member (the member event the spec's auth-events
selection names for such a join; only state resolution adds it). The
restricted branch of the auth rules then refuses the join ("restricted join
missing authorising user membership"). Fixing it is safe only now that every
ingestion path verifies the authorising server's signature (0.12.31,
ADR-0171). Status: resolved in 0.12.32: every router auth map now selects
the authorising user's member event, so a join validly signed by the
authorising server and naming a joined member with invite power is accepted
on /send, send_join and backfill; one naming a non-member is still
refused (rule 4.3.5.2). The resident-side gap above was resolved separately, in 0.12.36
(R04).
The other findings recorded here on the 0.12.13 branch (v1/v2 event-ID
signatures, key validity against origin_server_ts, /event/{eventId} ID
checks, the raw-event_id backfill shortcut, dead select_auth_events /
AuthChain helpers) were fixed on that branch; see CHANGELOG.md 0.12.13
and ADR-0075 and ADR-0076.
DEFERRED: drop the unused state_group_edges table¶
state_group_edges (created by migration 001) has been unused since ADR-0064
chose single-parent delta state groups (state_groups.prev_state_group_id).
Dropping it needs a migration, which migrations/AGENTS.md requires explicit
approval for. The user chose on 2026-09-27 (decision D3 of the 0.12.13
handover) to defer the drop to a separate cleanup branch rather than add it to
the 0.12.13 security branch. When done: a numbered migration that drops the
table, its removal from src/database/schema.cpp, from the worker table
classification test (tests/unit/test_worker_db_uri.cpp), and from
docs/database-persistence.md.
RESOLVED (0.12.13, branch fix/audit-critical-high-0.12.13): membership-path receipt checks¶
State: the implementation is complete, the failing test fixtures have been
rebuilt, and the full suite is green (Ok: 54, Fail: 0, Timeout: 0).
Nothing here was a production defect; the branch is ready to merge.
ADR-0064 phase B2 brought the /send path onto the spec's receipt checks
("Checks performed on receipt of a PDU"). The final step extends the same
checks to the membership path (runtime.federation.membership_acceptor in
src/homeserver/local_http_router.cpp, serving send_join / send_leave /
send_knock):
- a content-hash mismatch REDACTS the event and processing continues, instead of rejecting it (spec step 3);
- the event is authorised against its own
auth_events(reject), the state before it (reject), then current state (soft-fail) — steps 4, 5, 6; - a rejected event is still stored, marked
rejected, so later events that reference it can be authorised, but never updates state, never becomes a forward extremity, and the peer receives 403; - a soft-failed event is stored, takes part in state resolution, and never flips the membership cache — this is what stops a join that references pre-ban history from re-admitting a banned user;
- rejections are logged with their reason (
Membership PDU rejected on receipt; ... reason=...), which is the fastest way to diagnose the remaining failures.
Correction (0.12.36, conformance audit R06): the redaction above never ran for a
direct send_join / send_leave / send_knock or /invite, because the
request layer (handle_send_membership, handle_invite) still refused a hash
mismatch with 403 / 400 before the acceptor or invite handler saw the event.
Both now pass it on; the acceptor and the invite handler redact, and store,
authorise, sign and return the redacted copy. A signature failure is still
refused, now with 400 M_INVALID_PARAM as each endpoint's spec text asks.
Why the suite was red¶
The new checks were strict in a way the old single current-state check was not.
Authorising against an event's own auth_events requires the SENDER to name
the events that permit it; authorising against the state before the event
requires real prev_events. Several fixtures predated those checks and built
join events with empty prev_events and/or empty auth_events. They passed
before because the room's current state happened to contain the join rules and
memberships. Rejecting them was correct: a conformant peer always names its auth
events (our own make_join template does).
The checks were never relaxed to make the fixtures pass. An earlier attempt
to relax the room-v12 auth_events rule was reverted for exactly this reason —
see docs/adr/0062..0064 and the v12 conformance test citing rooms/v12.md rule
3.2. The fixtures were what was wrong.
What was done¶
- Rebuilt the failing fixtures to send realistic events using
tests/support/membership_fixture_support.hpp(fixture_prev_event_ids= the room's forward extremities;fixture_auth_event_ids= create/power_levels/join_rules/sender-member per the spec's "Auth events selection", withomit_create = truefor room v12). - Seeded state groups for rooms built by pushing rows directly into the store
(
seed_accepted_eventintests/unit/test_membership_acceptor_receipt_checks.cpp), socompute_state_beforehas state to resolve instead of failing closed withmissing_prev_state. - Named the room's
m.room.join_rulesevent in every join fixture; without it the default invite-only rule denies the join. - Corrected one assertion that contradicted the spec: a scenario in
tests/unit/test_security_audit_m01_membership_auth.cpppreviously asserted that a rejected join is NOT stored; the spec's "Rejection" section requires it to be stored (the security property is that the membership is never applied, which the scenario still asserts). - Verified per
AGENTS.md"Verifying Work":python build.py wslin the background producedOk: 54,Fail: 0,Timeout: 0, and tag-filtered runs confirmed the previously failing[membership_ingest],[security_audit_m01],[federation_invite_join], and[federation-worker]scenarios now pass.
Result¶
Full suite after the rebuild: Ok: 54, Fail: 0, Timeout: 0. All
formerly failing membership/send_join scenarios now pass. The affected files
and scenarios were:
| File | Notes |
|---|---|
tests/unit/test_membership_acceptor_receipt_checks.cpp |
Seeded state groups; fixtures now name auth_events and prev_events. |
tests/unit/test_security_audit_m01_membership_auth.cpp |
Join PDU event IDs now match the v12 reference-hash IDs the server persists. |
tests/unit/test_federation_invite_join.cpp |
Converted remaining fixtures to membership_fixture_support.hpp helpers. |
tests/integration/test_federation_worker_flow.cpp |
Fixed malformed seed-state JSON that caused store_event_with_state() to fail. |
Resolved scenarios:
- A membership event passing its auth_events and state-before but failing current state is soft-failed
handle_membership_ingest_requestpersists a worker-relayed federated membershipsend_joinauth_chain includes the invite event when the join PDU references itsend_joinis accepted for a remote user who holds a pending invitesend_joinis accepted for an uninvited remote user in a public roomsend_joinis rejected for an uninvited remote user in an invite-only roomsend_joinresponse body includes the requiredmembers_omittedfieldsend_joinresponse includes origin, non-empty state, and non-empty auth_chainsend_joinstate array reflects pre-join room state with membershipsend_join: a joining event with a mismatched content hash is redacted and accepted
One unrelated, pre-existing flake is NOT caused by this work:
tests/integration/test_http_server_listener_flow.cpp asserts FD_CLOEXEC on
an accepted socket it locates by scanning /proc/self/fd for a matching port;
under parallel load it can match a different descriptor. It passes when the
suite is run alone.
Also resolved: ADR-0064 phase C (backfill)¶
Phase C has shipped. ingest_pdu_event now attempts a bounded backfill from
an inbound PDU's origin when prev_events or auth_events are missing:
POST /_matrix/federation/v1/get_missing_events/{roomId} (up to 20 events),
then GET /_matrix/federation/v1/event/{eventId} for individual references,
capped at a configurable 16 general outbound calls per PDU by default (0.12.18), with a shared recovery call/deadline budget. Each returned event is verified
independently (content hash, signature, auth-events selection, auth against
its own auth_events) and stored as an outlier with a recorded after-state
group. If references remain missing after the capped attempt, the PDU still
returns missing_prev_state and is not applied. See docs/event-engine.md
"Phase C" and docs/adr/0064-spec-conformant-pdu-ingestion-with-delta-state-groups.md.
Unresolved ordinary-PDU retention: closed by R03 (ADR-0165). An ordinary PDU whose missing history/state cannot be recovered is retained in deferred_pdus (bounded, durable) and retried by homeserver::retry_deferred_pdus; one that cannot be retained is reported to the sender as a per-PDU error in the /send response. Still open: client visibility of the late event (it arrives in the timeline when it is placed, as any backfilled event does) and a retention cap that a hostile peer cannot fill for a room (the per-room limit of 100 can be consumed until those rows expire).
Residual scope: the membership-acceptor path
(send_join/send_leave/send_knock) does not yet backfill missing
references.
CLOSED (0.12.19): medium security-audit remediation¶
All 31 medium findings of the 2026-09-29 audit are fixed on the 0.12.19 branch. The per-finding resolution, including the six first-round fixes that were wrong or incomplete and how each was corrected, is in the report's "Remediation status" section, which supersedes the table this section used to hold. That table had marked AUTH-3 complete while its startup creation loop never ran.
Still open from that audit: 41 low findings and the partial EVT-12 and OUT-3, listed in the report under "Outstanding — low".
The verification log below is the history of the earlier medium batches and is kept as evidence; it is not a list of open work.
Completed parent verification on 2026-10-02:
[evt-8]: the completed baseline failed 4 of 11 assertions; after the fix all 11 assertions passed in 1 case. Original/additional creator ordering and unavailable implicit create events are covered.[state-resolution],[state_res]: 249 assertions passed in 26 cases.[CSAZ-5],[public-rooms],[create-room]: 672 assertions passed in 14 cases after correcting the BDD section placement so both GET and POST execute.[fed-6],[fed-8],[fed-11]: RED baseline, 3 failed cases and 5 failed assertions out of 167. This is not proof of successful fixes.- After the FED-6/FED-8 implementation,
[fed-6],[fed-8],[membership_ingest],[federation_invite_join],[federation-worker]: 667 assertions passed in 45 cases (parent run, exit 0). - Strengthened
[fed-11]: 1 failed case, 4 failed assertions out of 511 (parent run, exit 42). Fifty distinct unsolicited events were retained; the storage/admission finding was still unresolved at that baseline. - CSAZ-7 worker scratch baseline: 4 failed assertions exposed full events;
final
[CSAZ-7],[CSAZ-5],[public-rooms],[create-room]run passed 1,264 assertions in 15 cases, exit 0. Parent inspected the source, test structure and completed logs; later combined-tree focused results are below.
Parent combined-tree focused verification after FED-11 (completed exit 0; Ninja confirmed unit/integration targets current):
- Unit
[fed-6],[fed-8],[fed-11]: 1,243 assertions in 5 cases passed. - Integration
[fed-11-lifecycle]: 970 assertions in 1 case passed. - Unit
[CSAZ-7],[CSAZ-5],[public-rooms],[create-room]: 1,286 assertions in 17 cases passed. - Integration
[join],[backfill]: 3,766 assertions in 20 cases passed. - Unit
[pdu_ingestion],[join]: 1,551 assertions in 61 cases passed.
The earlier combined tree passed the full Catch2 suite (54 Ok, 0 Fail, 0 Timeout) after the test-fixture repair pass; individual focused runs remain the evidence for each slice. Review also found that FED-8's ACL slice does not yet enforce the audit's separate joined-receipt-subject requirement; that requirement was subsequently implemented and verified as recorded in the FED-8 row above. The subsequent 2026-10-04 changes were verified separately in the fresh run recorded below.
Parent verification on 2026-10-04 (completed exit 0):
- Current audit unit batch: 149 assertions in 14 cases.
- Current audit integration batch: 367 assertions in 10 cases.
- State-resolution, creator and neighboring authorization conformance: 20,994 assertions in 129 cases.
- Exact worker-recovery case: 28 assertions; exact memory-backend parser rejection: 2 assertions.
- A separately linked counterfactual state-resolution binary reproduces both EVT-5 and EVT-9 with the corrected legal fixtures; its two failures are evidence of the old defects, not a passing result.
Final parent verification of this batch on 2026-10-04:
- Full
python build.py wsl: exit 0, 54 Ok, 0 Fail, 0 Timeout. The parent inspectedbuild-wsl/meson-logs/testlog.txtand all 54 JSON results; copies are preserved asbuild-wsl/audit-full-suite-retry-testlog.txtand.json. - Final unit focus including AUTH-3/AUTH-6: 157 assertions in 16 cases, exit 0.
- Exact HTTP-8 integration focus: 22 assertions in 2 cases, exit 0.
- Final state/creator/v12 conformance focus: 20,422 assertions in 18 cases, exit 0. Earlier neighboring authorization focus also completed successfully.
- Numeric IPv4 authorities with leading zeros still need decimal canonicalization for discovery/transport agreement; peer pin enforcement is verified, but this compatibility edge is not closed by the authority tests.
Additional parent verification on 2026-10-04, before the Pages integration:
- Full python build.py wsl: completed exit 0, 54 Ok, 0 Fail, 0 Timeout; all 54 JSON results inspected. Evidence is preserved in uild-wsl/audit-full-suite-second-retry-testlog.txt and .json.
- Exact directory, presence and media-write regression tags: 491 assertions in 13 cases, exit 0. Revised directory conformance: 184 assertions in 8 cases.
- Fixture and security integration focus: 760 assertions in 30 cases, exit 0.
- Real isolated PostgreSQL reload and media moderation: 256 assertions in 2 cases, exit 0, including upgrade from populated schema 17 to 18.
- Database, presence and public-room unit neighbors: 17,912 assertions in 158 cases, exit 0. Initial full-suite failures were stale migration-name and unauthenticated positive POST fixtures; behavioral assertions were preserved.
Pages integration and version 0.12.17 verification on 2026-10-04:
- Full python build.py wsl: completed exit 0, 55 Ok, 0 Fail, 0 Timeout; all 55 JSON results inspected. Copies are preserved as uild-wsl/audit-full-suite-pages-testlog.txt and .json.
- Updated directory flow uses real system discovery with a call counter, and a numeric loopback destination; exact tag passes 174 assertions in 4 cases.
- Package version checks: 8 tests passed. Pages tooling: 20 tests passed.
- Hash-locked workspace-local dependencies installed successfully; mkdocs build --strict completed exit 0 after correcting four ADR anchors.